【深度学习】【图像分类网络】(一)残差神经网络ResNet以及组卷积ResNeXt
ResNet网络
论文:Deep Residual Learning for Image Recognition
网络中的亮点:
1 超深的网络结构(突破了1000层)
上图为简单堆叠卷积层和池化层的深层网络在训练和测试集上的表现,可以看到56层的神经网络的效果并没有20层的效果好,造成这种结果的原因可能是:
1.梯度消失或梯度爆炸
假设每一层的误差梯度是一个小于1的数,误差反向传播时,每向前传播一层就需要乘上一个小于1的误差梯度,网络深度越深,梯度就越趋近于0导致梯度消失,同理也会出现梯度爆炸的情况
2.退化问题(degradation problem)
2 提出residual模块
左边残差结构主要用于层数较少的ResNet-34,右边用于层数较多的RestNet-50/101/152.以左面为例,深度为256的输入数据经过256个3x3的卷积核卷积后,使用relu激活函数激活,再经过256个3x3的卷积核卷积后与原输入数据相加(主分支与shortcut捷径的输出特征矩阵shape,即高度宽度以及深度必须相同),最后再进行relu激活。
这里的相加不同于GoogleNet的cat连接操作
右图1x1的卷积核作用是降维和升维
图一
以34层网络为例,将残差结构分为了4部分,每部分包括一定数量的残差结构。如第一部分有三个残差结构,每个残差结构对应两个卷积层,可对应下图的紫色部分。
图二
实线与虚线残差结构
由图二可以观察到,34层的残差神经网络的conv_3、conv_4、conv_5层的第一层残差结构都是虚线。
低层的残差结构
实线残差结构与虚线残差结构的不同:
实线残差结构的输入和输出shape相同,主干线和捷径可以直接相加,虚线残差结构不同,如图三的conv_3输入为56x56x64,输出要求为28x28x128,因此需要步距为2、1x1的128个卷积核进行卷积操作,保证主分支和捷径的shape相同,进行相加操作
(56 - 1) / 2 + 1 = 28
高层残差结构
原论文中的虚线残差结构第一个1x1的卷积层步距为2,第二个3x3的卷积层的步距是1.但在pytorch的官方文档中如上图第一个1x1的卷积层步距为1,第二个3x3的卷积层的步距是2,可以再imageNet的top1上提升0.5%
3 使用Batch Normalization BN层加速训练(丢弃dropout)
目的:使一批(batch)而不是某一张图像的feature map,使其满足均值为0,方差为1的分布
对于一个拥有d维的输入x,对它的每一个维度进行标准化处理,假设输入的图像使rgb三通道的颜色图像,那么d对应的就是channel=3,x=(x1, x2, x3), x1,x2,x3分别代表三个通道的特征矩阵。标准化处理就是分别对R、G、B三个通道进行处理。
迁移学习
简介
使用别人预训练模型的参数训练自己较小的数据集(数据集较小不足以训练模型)
优势:
1.能够快速训练出一个理想的结果
2.当数据集较小时也能训练出理想的结果
浅层的网络卷积层能够识别一些特定的信息,随着网络的不断加深,网络能够学习到的信息越来越复杂、抽象,以至于能够识别眼睛、鼻子、嘴巴等,最后通过全连接层把一系列特征进行组合输出所对应的类别的概率。
这些浅层的卷积结构是通用的,即在其他网络中也适用,可以将其训练参数迁移到其他网络中。
常见的迁移学习方式
1.载入权重之后训练所有的参数
2.载入权重之后只训练最后几层全连接层参数
3.载入权重之后在原网络基础上再添加一层全连接层,仅训练最后一个全连接层
前两种方法需要修改最后的全连接层的输出,与类别对应
ResNext网络结构
组卷积Group Convolution
分组进行卷积后再进行concat拼接
参数对比(卷积核大小为k):
普通卷积:k*k*Cin*n
组卷积:(k*k*Cin/g*n/g)*g = k*k*Cin*n/g
这里如果g=Cin,n=Cin,即是对输入矩阵的每个channel分配一个channel为1的卷积核进行卷积,就相当于DW卷积(后面MobileNet中讲到 )
ResNeXt-50(32x4d):32代表组卷积的group数,4d表示组卷积卷积核的个数
组卷积的等价性
首先c是最简单的形式,直接通过1x1的卷积核进行降维、组卷积、升维处理,最后与原图矩阵相加。
a可以理解为下面的形式,组卷积后再concate连接
b:,先concate连接再进行卷积操作
参数表中32为组个数,4d表示组卷积中卷积核的个数,只需将左图的ResNet的结构替换即可变为ResNeXt
ResNet网络的pytorch实现
train.py
import json
import os
import matplotlib.pyplot as plt
import torch
from torch import nn, optim
from torchvision import datasets
from torchvision.transforms import transforms
import torch.utils.data
from torchvision.models.mobilenet import model_urls
from model import ResNet34
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
# print(device)
data_transform = {
'train': transforms.Compose([transforms.RandomResizedCrop(224),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize([.485, .456, .406], [.229, .224, .225])]),
'val': transforms.Compose([transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize([.485, .456, .406], [.229, .224, .225])])
}
data_root = os.path.abspath(os.getcwd())
image_path = os.path.join(data_root, "data", "flower_data")
batch_size = 16
train_dataset = datasets.ImageFolder(root=image_path + r"/train",
transform=data_transform['train'])
train_num = len(train_dataset)
train_loader = torch.utils.data.DataLoader(train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=0)
train_steps = len(train_loader)
# transforms处理后的图像展示
# image,label = train_dataset.__getitem__(1001)
# toPIL = transforms.ToPILImage()
# image = toPIL(image)
# plt.imshow(image)
# plt.show()
flower_dict = train_dataset.class_to_idx
cla_dict = dict((val, key) for (key, val) in flower_dict.items())
# indent:参数根据数据格式缩进显示,读起来更加清晰。
json_str = json.dumps(cla_dict, indent=4)
with open('class_indices.json', 'w') as json_file:
json_file.write(json_str)
val_dataset = datasets.ImageFolder(root=image_path + r'/val',
transform=data_transform['val'])
val_num = len(val_dataset)
val_loader = torch.utils.data.DataLoader(val_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=0)
net = ResNet34(num_classes=5)
net.to(device)
# 定义交叉熵损失函数
loss_function = nn.CrossEntropyLoss()
params = [p for p in net.parameters() if p.requires_grad]
optimizer = optim.Adam(params, lr=.0001)
best_acc = .0
save_path = './resNet34.pth'
for epoch in range(3):
net.train()
running_loss = .0
for step, data in enumerate(train_loader, start=0):
images, labels = data
optimizer.zero_grad()
logits = net(images.to(device))
loss = loss_function(logits, labels.to(device))
# 误差反向传播
loss.backward()
optimizer.step()
running_loss += loss.item()
# validate
net.eval()
acc = .0
with torch.no_grad():
for step, data in enumerate(val_loader, start=0):
images, labels = data
outputs = net(images.to(device))
predict_y = torch.max(outputs, dim=1)[1]
acc += torch.eq(predict_y, labels.to(device)).sum().item()
val_acc = acc / val_num
print('[epoch %d] train loss: %.3f val_acc: %.3f' %
(epoch + 1, running_loss / train_steps, val_acc ))
if val_acc > best_acc:
best_acc = val_acc
torch.save(net.state_dict(), save_path)
model.py
import torch
import torch.nn as nn
# 浅层18 34层网络
class BasicBlock(nn.Module):
# 定义前几层卷积层与最后一层卷积层卷积核个数的倍数关系
expansion = 1
def __init__(self, in_channel, out_channel, stride=1, downsample=None):
super(BasicBlock, self).__init__()
# output = (input - 3 + 2 * 1) / 1 + 1 = input
self.conv1 = nn.Conv2d(in_channels=in_channel,
out_channels=out_channel,
kernel_size=3,
stride=stride,
padding=1,
bias=False)
# 定义batchnorm归一化featureMap加速训练
self.bn1 = nn.BatchNorm2d(out_channel)
self.relu = nn.ReLU()
self.conv2 = nn.Conv2d(in_channels=out_channel,
out_channels=out_channel,
kernel_size=3,
padding=1,
bias=False)
self.bn2 = nn.BatchNorm2d(out_channel)
# 定义下采样用于虚线残差结构
self.downsample = downsample
def forward(self, x):
# 分支
identity = x
if self.downsample is not None:
identity = self.downsample(x)
# 主干
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
out += identity
out = self.relu(out)
return out
class Bottleneck(nn.Module):
expansion = 4
def __init__(self, in_channel, out_channel, stride=1, downsample=None):
super(Bottleneck, self).__init__()
# output =
self.conv1 = nn.Conv2d(in_channels=in_channel, out_channels=out_channel,
kernel_size=1,
stride=1,
bias=False)
self.bn1 = nn.BatchNorm2d(out_channel)
self.relu = nn.ReLU(inplace=True)
self.conv2 = nn.Conv2d(in_channels=out_channel, out_channels=out_channel,
kernel_size=3,
stride=stride,
bias=False,
padding=1)
self.bn2 = nn.BatchNorm2d(out_channel)
self.conv3 = nn.Conv2d(in_channels=out_channel, out_channels=out_channel * self.expansion,
kernel_size=1,
stride=1,
bias=False)
self.bn3 = nn.BatchNorm2d(out_channel * self.expansion)
self.downsample = downsample
def forward(self, x):
# 分支
identity = x
if self.downsample is not None:
identity = self.downsample(x)
# 主干
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
out = self.relu(out)
out = self.conv3(out)
out = self.bn3(out)
out += identity
out = self.relu(out)
return out
class ResNet(nn.Module):
def __init__(self, block, block_num, num_classes=1000, include_top=True):
super(ResNet, self).__init__()
self.include_top = include_top
self.in_channel = 64
# 输入为224x224,输出为112 故padding为3 才能使 (input - 7 + 2 * padding) / 2 + 1 = input / 2
self.conv1 = nn.Conv2d(3, self.in_channel, kernel_size=7, stride=2, padding=3, bias=False)
self.bn1 = nn.BatchNorm2d(self.in_channel)
# inplace为True,将会改变输入的数据 ,否则不会改变原输入,只会产生新的输出
self.relu = nn.ReLU(inplace=True)
# (112-3+2*1)/2+1=56
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.layer1 = self._make_layer(block, 64, block_num[0])
self.layer2 = self._make_layer(block, 128, block_num[1], stride=2)
self.layer3 = self._make_layer(block, 256, block_num[2], stride=2)
self.layer4 = self._make_layer(block, 512, block_num[3], stride=2)
if self.include_top:
# 定义自适应平均池化下采样层,无论输入是什么形状输出都为1 x 1
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)
# 初始化网络权重参数
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
# block表明是BasicBlock还是Bottleneck
# channel为输入特征矩阵深度
# block_num为层结构的残差结构数
def _make_layer(self, block, channel, block_num, stride=1):
downsample = None
# 根据ResNet的网络结构,除了18 和 34的第一层layer输入深度和输出深度相同,其他情况道德第一层卷积层都需要使用虚线残差网络结构
if stride != 1 or self.in_channel != block.expansion * channel:
downsample = nn.Sequential(
# (input-1)/1+1=input
nn.Conv2d(self.in_channel, block.expansion * channel, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(block.expansion * channel)
)
layers = []
layers.append(block(self.in_channel, channel, stride=stride, downsample=downsample))
self.in_channel = channel * block.expansion
for _ in range(1, block_num):
layers.append(block(self.in_channel, channel))
return nn.Sequential(*layers)
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
if self.include_top:
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.fc(x)
return x
def ResNet34(num_classes=1000, include_top=True):
return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top)
def ResNet101(num_classes=1000, include_top=True):
return ResNet(Bottleneck, [3, 4, 23, 3], num_classes=num_classes, include_top=include_top)
predict.py
import json
import torch
from torchvision import transforms
from PIL import Image
from model import ResNet34
data_transform = transforms.Compose([transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize([.485, .456, .406], [.229, .224, .225])])
img = Image.open('./test.png')
img = data_transform(img)
img = torch.unsqueeze(img, dim=0)
json_file = open('./class_indices.json')
class_indict = json.load(json_file)
model = ResNet34(num_classes=5)
model_weight_path = './resNet34.pth'
model.load_state_dict(torch.load(model_weight_path))
model.eval()
with torch.no_grad():
output = torch.squeeze(model(img))
predict = torch.softmax(output, dim=0)
predict_cla = torch.argmax(predict).numpy()
print(class_indict[str(predict_cla)], predict[predict_cla].numpy())
在实验室服务器训练了50个epoch后测试集准确率最高可以达到0.86:
[epoch 40] train loss: 0.444 val_acc: 0.802
[epoch 41] train loss: 0.453 val_acc: 0.838
[epoch 42] train loss: 0.416 val_acc: 0.838
[epoch 43] train loss: 0.428 val_acc: 0.849
[epoch 44] train loss: 0.427 val_acc: 0.835
[epoch 45] train loss: 0.420 val_acc: 0.863
[epoch 46] train loss: 0.421 val_acc: 0.843
[epoch 47] train loss: 0.396 val_acc: 0.841
[epoch 48] train loss: 0.394 val_acc: 0.843
[epoch 49] train loss: 0.387 val_acc: 0.841
[epoch 50] train loss: 0.390 val_acc: 0.830
使用pytorch官网的ResNet34预训练参数进行迁移学习训练
下载地址在torchvision.models.resnet ResNet源码中:

net = ResNet34()
# 使用官网的预训练权重
model_weight_path = "./resnet34-333f7ec4.pth"
missing_keys, unexpected_keys = net.load_state_dict(torch.load(model_weight_path), strict=False)
inchannel = net.fc.in_features
net.fc = nn.Linear(inchannel, 5)
net.to(device)
注意这里net.to(device)需要放在最后,否则因为对模型的修改会导致一些参数没有导入GPU而出现下面的错误
Tensor for 'out' is on CPU, Tensor for argument #1 'self' is on CPU,
使用预训练权重后10个epoch就能达到0.9的准确率:
[epoch 1] train loss: 0.495 val_acc: 0.876
[epoch 2] train loss: 0.339 val_acc: 0.896
[epoch 3] train loss: 0.274 val_acc: 0.896
[epoch 4] train loss: 0.267 val_acc: 0.926
[epoch 5] train loss: 0.234 val_acc: 0.940
[epoch 6] train loss: 0.209 val_acc: 0.909
[epoch 7] train loss: 0.206 val_acc: 0.934
[epoch 8] train loss: 0.199 val_acc: 0.929
[epoch 9] train loss: 0.192 val_acc: 0.920
[epoch 10] train loss: 0.189 val_acc: 0.926
pytorch实现ResNext

卷积层通道数32x4=128扩大了一倍,但是所用的参数基本没有变化
只需修改ResNet部分代码:
class ResNet(nn.Module):
def __init__(self, block, block_num, num_classes=1000, include_top=True, groups=1, width_per_group=64):
super(ResNet, self).__init__()
self.include_top = include_top
self.in_channel = 64
self.groups = groups
self.width_per_group = width_per_group
# 输入为224x224,输出为112 故padding为3 才能使 (input - 7 + 2 * padding) / 2 + 1 = input / 2
self.conv1 = nn.Conv2d(3, self.in_channel, kernel_size=7, stride=2, padding=3, bias=False)
self.bn1 = nn.BatchNorm2d(self.in_channel)
# inplace为True,将会改变输入的数据 ,否则不会改变原输入,只会产生新的输出
self.relu = nn.ReLU(inplace=True)
# (112-3+2*1)/2+1=56
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.layer1 = self._make_layer(block, 64, block_num[0])
self.layer2 = self._make_layer(block, 128, block_num[1], stride=2)
self.layer3 = self._make_layer(block, 256, block_num[2], stride=2)
self.layer4 = self._make_layer(block, 512, block_num[3], stride=2)
if self.include_top:
# 定义自适应平均池化下采样层,无论输入是什么形状输出都为1 x 1
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)
# 初始化网络权重参数
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
# block表明是BasicBlock还是Bottleneck
# channel为输入特征矩阵深度
# block_num为层结构的残差结构数
def _make_layer(self, block, channel, block_num, stride=1):
downsample = None
# 根据ResNet的网络结构,除了18 和 34的第一层layer输入深度和输出深度相同,其他情况道德第一层卷积层都需要使用虚线残差网络结构
if stride != 1 or self.in_channel != block.expansion * channel:
downsample = nn.Sequential(
# (input-1)/1+1=input
nn.Conv2d(self.in_channel,
block.expansion * channel,
kernel_size=1,
stride=stride,
bias=False),
nn.BatchNorm2d(block.expansion * channel)
)
layers = []
layers.append(block(self.in_channel, channel,
stride=stride,
downsample=downsample,
groups=self.groups,
width_per_group=self.width_per_group))
self.in_channel = channel * block.expansion
for _ in range(1, block_num):
layers.append(block(self.in_channel,
channel,
groups=self.groups,
width_per_group=self.width_per_group))
return nn.Sequential(*layers)
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
if self.include_top:
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.fc(x)
return x
ResNet50训练结果:
[epoch 1] train loss: 1.633 val_acc: 0.467
[epoch 2] train loss: 1.246 val_acc: 0.591
[epoch 3] train loss: 1.183 val_acc: 0.511
ResNet50_32x4d:
[epoch 1] train loss: 1.620 val_acc: 0.407
[epoch 2] train loss: 1.258 val_acc: 0.495
[epoch 3] train loss: 1.203 val_acc: 0.52597
【深度学习】【图像分类网络】(一)残差神经网络ResNet以及组卷积ResNeXt的更多相关文章
- 深度学习与CV教程(4) | 神经网络与反向传播
作者:韩信子@ShowMeAI 教程地址:http://www.showmeai.tech/tutorials/37 本文地址:http://www.showmeai.tech/article-det ...
- 深度学习与CV教程(6) | 神经网络训练技巧 (上)
作者:韩信子@ShowMeAI 教程地址:http://www.showmeai.tech/tutorials/37 本文地址:http://www.showmeai.tech/article-det ...
- 对比《动手学深度学习》 PDF代码+《神经网络与深度学习 》PDF
随着AlphaGo与李世石大战的落幕,人工智能成为话题焦点.AlphaGo背后的工作原理"深度学习"也跳入大众的视野.什么是深度学习,什么是神经网络,为何一段程序在精密的围棋大赛中 ...
- Andrew Ng - 深度学习工程师 - Part 1. 神经网络和深度学习(Week 4. 深层神经网络)
=================第2周 神经网络基础=============== ===4.1 深层神经网络=== Although for any given problem it migh ...
- 【Deeplearning】(转)深度学习知识网络
转自深度学习知识框架,小象牛逼! 图片来自小象学院公开课,下面直接解释几条线 神经网络 线性回归 (+ 非线性激励) → 神经网络 有线性映射关系的数据,找到映射关系,非常简单,只能描述简单的映射关系 ...
- 深度学习之深L层神经网络
声明 本文参考(8条消息) [中文][吴恩达课后编程作业]Course 1 - 神经网络和深度学习 - 第四周作业(1&2)_何宽的博客-CSDN博客 力求自己理解,刚刚走进深度学习希望可以一 ...
- 深度学习笔记 (二) 在TensorFlow上训练一个多层卷积神经网络
上一篇笔记主要介绍了卷积神经网络相关的基础知识.在本篇笔记中,将参考TensorFlow官方文档使用mnist数据集,在TensorFlow上训练一个多层卷积神经网络. 下载并导入mnist数据集 首 ...
- 深度学习基础网络 ResNet
Highway Networks 论文地址:arXiv:1505.00387 [cs.LG] (ICML 2015),全文:Training Very Deep Networks( arXiv:150 ...
- 深度学习原理与框架-递归神经网络-RNN_exmaple(代码) 1.rnn.BasicLSTMCell(构造基本网络) 2.tf.nn.dynamic_rnn(执行rnn网络) 3.tf.expand_dim(增加输入数据的维度) 4.tf.tile(在某个维度上按照倍数进行平铺迭代) 5.tf.squeeze(去除维度上为1的维度)
1. rnn.BasicLSTMCell(num_hidden) # 构造单层的lstm网络结构 参数说明:num_hidden表示隐藏层的个数 2.tf.nn.dynamic_rnn(cell, ...
- 深度学习原理与框架-递归神经网络-RNN网络基本框架(代码?) 1.rnn.LSTMCell(生成单层LSTM) 2.rnn.DropoutWrapper(对rnn进行dropout操作) 3.tf.contrib.rnn.MultiRNNCell(堆叠多层LSTM) 4.mlstm_cell.zero_state(state初始化) 5.mlstm_cell(进行LSTM求解)
问题:LSTM的输出值output和state是否是一样的 1. rnn.LSTMCell(num_hidden, reuse=tf.get_variable_scope().reuse) # 构建 ...
随机推荐
- vector的使用方法
vector是STL容器的可变长度数组.可变长度数组的头文件是<vector>,有以下常见的使用方法: 1.vector<int> v(N,i):建立一个可变长度数组v,内部元 ...
- 2020/03/25 CSS相关知识点
2020-03-25 16:35:03 又是一个风和日丽的下午!今天的内容比较多 真是令人头大 ,手速又慢所以缺的可能比较多,而且这东西还是多靠实践为好. 文件下载地址: https://share. ...
- 网络存储服务ip-san搭建
网络存储服务ip-san搭建 ip-san简称SAN(Storage Area Network),中文意思存储局域网络,ip- ...
- VisualVM简单配置以及插件安装
一.概述 VisualVM是随JDK发布的功能很强大的运行监视和故障处理程序.除了运行监视,故障处理外,还提供了很多其他方面的功能,如性能分析等.它有一个很大的优点:不需要被监视的程序基于特殊Agen ...
- ELK集群基础环境初始化
集群基础环境初始化 1.准备虚拟机 192.168.1.7 192.168.1.6192.168.1.183 2.切换为国内centos源 3.修改sshd服务优化 [root@elk01 ~]# s ...
- python-实现动态web服务器
# encoding=utf-8 import socket from multiprocessing import Process import re import sys # 设置静态文件根目录 ...
- 使用Chloe 连接MySql服务器
1.需要安装的依赖 Chloe Chloe.MySql MySql.Data(6.9.12) 这个版本对framework没有具体的版本要求 对于 MySql 数据库,需要安装 Install-Pac ...
- Python学习笔记--SQL数据
SQL 本人受到Java的影响,数据库的话,就不按照教程走了,我就直接使用的是Navicat软件的数据库啦! SQL支持注释: 两种单行注释(-- 和# ),和一种多行注释(/* */) 基础的使用语 ...
- 对Android关联SDK后,还是无法显示那俩图标的解决
显示出来!!!! 可以这么解决: 步骤一: 步骤二: 找到这个,在上方的栏里面: 步骤三: 将这四个选中: 步骤四: 然后选中这个栏: 步骤五: 选中Android: 步骤六: 最后,点击右下角的Ap ...
- 声网Agora 教育 aPaaS 灵动课堂升级:UI与业务逻辑分离,界面、功能自定义更灵活
声网Agora 教育 aPaaS 产品灵动课堂现已升级至 v1.1.0 版本.声网Agora 灵动课堂可以帮助教育机构和开发者最快 15 分钟上线自有品牌.全功能的在线互动教学平台,节省 90% 开发 ...