Olá pessoal!
Neste artigo, escreveremos um pequeno programa para resolver o problema de detecção e reconhecimento de objetos (detecção de objetos) em tempo real. O programa será escrito na linguagem de programação Swift para a plataforma iOS. Para detectar objetos, usaremos uma rede neural convolucional com uma arquitetura chamada YOLOv3. No artigo, aprenderemos como trabalhar no iOS com redes neurais usando a estrutura CoreML, um pouco de compreensão do que é a rede YOLOv3 e como usar e processar as saídas dessa rede. Também verificaremos o funcionamento do programa e comparamos diversas variações de YOLOv3: YOLOv3-tiny e YOLOv3-416.
As fontes estarão disponíveis no final do artigo, para que todos possam testar a operação da rede neural em seu dispositivo.
Detecção de objetos
Para começar, entenderemos brevemente qual é a tarefa de detectar objetos (detecção de objetos) na imagem e quais ferramentas são usadas para isso hoje. Entendo que muitos estão bastante familiarizados com este tópico, mas ainda me permito contar um pouco sobre ele.
Agora, muitas tarefas no campo da visão computacional são resolvidas com a ajuda de redes neurais convolucionais (Redes Neurais Convolucionais), a seguir denominadas CNN. Devido à sua estrutura, eles extraem recursos da imagem. As CNNs são usadas na classificação, reconhecimento, segmentação e muitas outras.
Arquiteturas CNN populares para reconhecimento de objetos:
- R-CNN. Podemos dizer o primeiro modelo para resolver este problema. Funciona como um classificador de imagem comum. Diferentes regiões da imagem são alimentadas à entrada da rede e são feitas previsões para elas. Muito lento, pois executa uma única imagem milhares de vezes.
- R-CNN rápido. Uma versão aprimorada e mais rápida do R-CNN, funciona com um princípio semelhante, mas primeiro a imagem inteira é alimentada na entrada da CNN, depois as regiões são geradas a partir da representação interna recebida. Mas ainda é muito lento para tarefas em tempo real.
- R-CNN mais rápido. A principal diferença dos anteriores é que, em vez do algoritmo de busca seletiva, ele usa uma rede neural para selecionar regiões para "memorizá-las".
- YOLO. Um princípio de operação completamente diferente dos anteriores não usa regiões. O mais rápido. Mais detalhes serão discutidos no artigo.
- SSD É semelhante em princípio ao YOLO, mas usa o VGG16 como uma rede para extrair recursos. Também muito rápido e adequado para trabalho em tempo real.
- Redes de pirâmides de recursos (FPN). Outro tipo de rede, como o Single Shot Detector, devido aos recursos de extração de recursos, é melhor do que o SSD reconhece objetos pequenos.
- RetinaNet. Utiliza uma combinação de FPN + ResNet e, graças a uma função de erro especial (perda focal), proporciona uma precisão mais alta.
Neste artigo, usaremos a arquitetura YOLO, ou seja, sua última modificação, YOLOv3.
Por que YOLO?
YOLO ou You Only Look Once é a arquitetura muito popular da CNN, usada para reconhecer vários objetos em uma imagem. Informações mais completas sobre isso podem ser obtidas no
site oficial , no mesmo local, você pode encontrar publicações nas quais a teoria e o componente matemático dessa rede são descritos em detalhes, assim como o processo de treinamento.
A principal característica dessa arquitetura em comparação com outras é que a maioria dos sistemas aplica a CNN várias vezes em diferentes regiões da imagem; no YOLO, a CNN é aplicada uma vez à imagem inteira de uma só vez. A rede divide a imagem em um tipo de grade e prevê caixas delimitadoras e a probabilidade de haver um objeto desejado para cada seção.
As vantagens dessa abordagem é que a rede olha para a imagem inteira de uma só vez e leva em consideração o contexto ao detectar e reconhecer um objeto. O YOLO também é 1000 vezes mais rápido que o R-CNN e cerca de 100x mais rápido que o Fast R-CNN. Neste artigo, lançaremos uma rede em um dispositivo móvel para processamento online, portanto, essa é a qualidade mais importante para nós.
Informações mais detalhadas sobre a comparação de arquiteturas podem ser encontradas
aqui .
YOLOv3
YOLOv3 é uma versão avançada da arquitetura YOLO. Consiste em 106 camadas convolucionais e detecta melhor objetos pequenos em comparação com seu predecessor YOLOv2. A principal característica do YOLOv3 é que existem três camadas na saída, cada uma delas projetada para detectar objetos de tamanhos diferentes.
A figura abaixo mostra sua estrutura esquemática:
YOLOv3-tiny - Uma versão cortada da arquitetura YOLOv3 consiste em menos camadas (existem apenas duas camadas de saída). Ele prevê objetos menores piores e é destinado a pequenos conjuntos de dados. Porém, devido à estrutura truncada, os pesos da rede ocupam uma pequena quantidade de memória (~ 35 MB) e produz um FPS mais alto. Portanto, essa arquitetura é preferível para uso em um dispositivo móvel.
Estamos escrevendo um programa para reconhecimento de objetos
A parte divertida começa!
Vamos criar um aplicativo que reconheça vários objetos na imagem em tempo real usando a câmera do telefone. Todo o código será escrito na linguagem de programação Swift 4.2 e executado em um dispositivo iOS.
Neste tutorial, teremos uma rede pronta com escalas pré-treinadas em um conjunto de dados
COCO . Apresenta 80 classes diferentes. Portanto, nosso neurônio será capaz de reconhecer 80 objetos diferentes.
Do Darknet ao CoreML
A arquitetura YOLOv3 original é implementada usando a estrutura
Darknet .
No iOS, a partir da versão 11.0, existe uma maravilhosa biblioteca
CoreML que permite executar modelos de aprendizado de máquina diretamente no dispositivo. Mas há uma limitação: o
programa só pode ser executado em um dispositivo executando o iOS 11 e superior.O problema é que o CoreML entende apenas o formato específico do modelo
.coreml . Para as bibliotecas mais populares, como Tensorflow, Keras ou XGBoost, é possível converter diretamente para o formato CoreML. Mas para a Darknet não existe essa possibilidade. Para converter o modelo salvo e treinado de Darknet em CoreML, você pode usar várias opções, por exemplo, salvar Darknet em
ONNX e depois convertê-lo de ONNX em CoreML.
Usaremos uma maneira mais simples e usaremos a implementação Keras do YOLOv3. O algoritmo de ação é o seguinte: carregue os pesos Darknet no modelo Keras, salve-o no formato Keras e converta-o diretamente para CoreML a partir disso.
- Faça o download do Darknet. Faça o download dos arquivos do modelo Darknet-YOLOv3 treinado aqui . Neste artigo, usarei duas arquiteturas: YOLOv3-416 e YOLOv3-tiny. Vamos precisar de arquivos cfg e pesos.
- De Darknet a Keras. Primeiro, clone o repositório , vá para a pasta repo e execute o comando:
python convert.py yolov3.cfg yolov3.weights yolo.h5
onde yolov3.cfg e yolov3.weights baixaram arquivos Darknet. Como resultado, devemos ter um arquivo com a extensão .h5 - este é o modelo YOLOv3 salvo no formato Keras. - Do Keras ao CoreML. O último passo permaneceu. Para converter o modelo em CoreML, é necessário executar o script python (você deve primeiro instalar a biblioteca coremltools para python):
import coremltools coreml_model = coremltools.converters.keras.convert( 'yolo.h5', input_names='image', image_input_names='image', input_name_shape_dict={'image': [None, 416, 416, 3]},
As etapas descritas acima devem ser executadas para os dois modelos YOLOv3-416 e YOLOv3-tiny.
Quando fizemos tudo isso, temos dois arquivos: yolo.mlmodel e yolo-tiny.mlmodel. Agora você pode começar a escrever o código do próprio aplicativo.
Criando um aplicativo iOS
Não descreverei todo o código do aplicativo, você pode vê-lo no repositório, cujo link será fornecido no final do artigo. Deixe-me apenas dizer que temos três UIViewController-a: OnlineViewController, PhotoViewController e SettingsViewController. A primeira é a saída da câmera e a detecção on-line de objetos para cada quadro. No segundo, você pode tirar uma foto ou selecionar uma foto da galeria e testar a rede nessas imagens. O terceiro contém as configurações. Você pode escolher o modelo YOLOv3-416 ou YOLOv3-tiny, além de escolher os limites IoU (interseção sobre a união) e a confiança do objeto (a probabilidade de haver um objeto na seção de imagem atual).
Carregando modelos no CoreMLDepois de convertermos o modelo treinado do formato Darknet para CoreML, temos um arquivo com a extensão
.mlmodel . No meu caso, criei dois arquivos:
yolo.mlmodel e
yolo-tiny.mlmodel , para os modelos YOLOv3-416 e YOLOv3-tiny, respectivamente. Agora você pode carregar esses arquivos em um projeto no Xcode.
Criamos a classe ModelProvider, que armazenará o modelo e os métodos atuais para invocação assíncrona da rede neural para execução. O modelo é carregado desta maneira:
private func loadModel(type: YOLOType) { do { self.model = try YOLO(type: type) } catch { assertionFailure("error creating model") } }
A classe YOLO é diretamente responsável por carregar arquivos
.mlmodel e manipular saídas de modelo. Faça o download dos arquivos de modelo:
var url: URL? = nil self.type = type switch type { case .v3_Tiny: url = Bundle.main.url(forResource: "yolo-tiny", withExtension:"mlmodelc") self.anchors = tiny_anchors case .v3_416: url = Bundle.main.url(forResource: "yolo", withExtension:"mlmodelc") self.anchors = anchors_416 } guard let modelURL = url else { throw YOLOError.modelFileNotFound } do { model = try MLModel(contentsOf: modelURL) } catch let error { print(error) throw YOLOError.modelCreationError }
Código completo do fornecedor do modelo. import UIKit import CoreML protocol ModelProviderDelegate: class { func show(predictions: [YOLO.Prediction]?, stat: ModelProvider.Statistics, error: YOLOError?) } @available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *) class ModelProvider { struct Statistics { var timeForFrame: Float var fps: Float } static let shared = ModelProvider(modelType: Settings.shared.modelType) var model: YOLO! weak var delegate: ModelProviderDelegate? var predicted = 0 var timeOfFirstFrameInSecond = CACurrentMediaTime() init(modelType type: YOLOType) { loadModel(type: type) } func reloadModel(type: YOLOType) { loadModel(type: type) } private func loadModel(type: YOLOType) { do { self.model = try YOLO(type: type) } catch { assertionFailure("error creating model") } } func predict(frame: UIImage) { DispatchQueue.global().async { do { let startTime = CACurrentMediaTime() let predictions = try self.model.predict(frame: frame) let elapsed = CACurrentMediaTime() - startTime self.showResultOnMain(predictions: predictions, elapsed: Float(elapsed), error: nil) } catch let error as YOLOError { self.showResultOnMain(predictions: nil, elapsed: -1, error: error) } catch { self.showResultOnMain(predictions: nil, elapsed: -1, error: YOLOError.unknownError) } } } private func showResultOnMain(predictions: [YOLO.Prediction]?, elapsed: Float, error: YOLOError?) { if let delegate = self.delegate { DispatchQueue.main.async { let fps = self.measureFPS() delegate.show(predictions: predictions, stat: ModelProvider.Statistics(timeForFrame: elapsed, fps: fps), error: error) } } } private func measureFPS() -> Float { predicted += 1 let elapsed = CACurrentMediaTime() - timeOfFirstFrameInSecond let currentFPSDelivered = Double(predicted) / elapsed if elapsed > 1 { predicted = 0 timeOfFirstFrameInSecond = CACurrentMediaTime() } return Float(currentFPSDelivered) } }
Processamento de saída de rede neuralAgora vamos descobrir como processar as saídas da rede neural e obter as caixas delimitadoras correspondentes. No Xcode, se você selecionar um arquivo de modelo, poderá ver o que são e as camadas de saída.
Entrada e saída YOLOv3-tiny.Entrada e saída YOLOv3-416.Como você pode ver na imagem acima, temos três para YOLOv3-416 e duas para pequenas camadas de saída YOLOv3 em cada uma das caixas delimitadoras para vários objetos.
Nesse caso, esse é um conjunto regular de números, vamos descobrir como analisá-lo.
O modelo YOLOv3 usa três camadas como saída para dividir a imagem em uma grade diferente. Os tamanhos das células dessas grades têm os seguintes valores: 8, 16 e 32. Suponha que tenhamos uma imagem de 416x416 pixels de tamanho na entrada e as matrizes de saída terão um tamanho de 52x52 , 26x26 e 13x13 (416/8 = 52, 416/16 = 26 e 416/32 = 13). No caso do YOLOv3-tiny, tudo é o mesmo, mas em vez de três grades, temos duas: 16 e 32, ou seja, matrizes de dimensões 26x26 e 13x13.
Após iniciar o modelo CoreML carregado, obtemos dois (ou três) objetos da classe MLMultiArray na saída. E se você observar a propriedade shape desses objetos, veremos a seguinte figura (para YOLOv3-tiny):
Como esperado, a dimensão das matrizes será 26 x 26 e 13 x 13. Mas o que o número 255 significa? Como mencionado anteriormente, as camadas de saída são matrizes 52x52, 26x26 e 13x13. O fato é que cada elemento dessa matriz não é um número, é um vetor. Ou seja, a camada de saída é uma matriz tridimensional. Esse vetor tem a dimensão B x (5 + C), onde B é o número da caixa delimitadora na célula, C é o número de classes. De onde vem o número 5? A razão é a seguinte: para cada caixa-a, a probabilidade de predição de um objeto (confiança do objeto) é um número, e os quatro restantes são x, y, largura e altura para a caixa-predita. A figura abaixo mostra uma representação esquemática deste vetor:
Representação esquemática da camada de saída (mapa de recursos).Para nossa rede treinada em 80 classes, são previstas 3 caixas delimitadoras a para cada célula da grade de partições, para cada uma delas - 80 probabilidades de classe + confiança no objeto + 4 números responsáveis pela posição e tamanho dessa caixa-a. Total: 3 x (5 + 80) = 255.
Para obter esses valores da classe MLMultiArray, é melhor usar um ponteiro bruto para uma matriz de dados e aritmética de endereço:
let pointer = UnsafeMutablePointer<Double>(OpaquePointer(out.dataPointer))
Agora você precisa processar um vetor de 255 elementos. Para cada caixa, é necessário obter uma distribuição de probabilidade para 80 classes, você pode fazer isso usando a função softmax.
O que é softmaxFunção converte um vetor
dimensão K em um vetor da mesma dimensão, em que cada coordenada
o vetor resultante é representado por um número real no intervalo [0,1] e a soma das coordenadas é 1.
onde K é a dimensão do vetor.
Função Softmax no Swift:
private func softmax(_ x: inout [Float]) { let len = vDSP_Length(x.count) var count = Int32(x.count) vvexpf(&x, x, &count) var sum: Float = 0 vDSP_sve(x, 1, &sum, len) vDSP_vsdiv(x, 1, &sum, &x, 1, len) }
Para obter as coordenadas e os tamanhos da caixa delimitadora-a, você precisa usar as fórmulas:
onde
- coordenadas x, y previstas, largura e altura, respectivamente,
É a função sigmóide e
- valores de âncoras (âncoras) para três caixas. Esses valores são determinados durante o treinamento e são definidos no arquivo Helpers.swift:
let anchors1: [Float] = [116,90, 156,198, 373,326]
Representação esquemática do cálculo da posição de uma caixa delimitadora.Código completo para processamento de camadas de saída. private func process(output out: MLMultiArray, name: String) throws -> [Prediction] { var predictions = [Prediction]() let grid = out.shape[out.shape.count-1].intValue let gridSize = YOLO.inputSize / Float(grid) let classesCount = labels.count print(out.shape) let pointer = UnsafeMutablePointer<Double>(OpaquePointer(out.dataPointer)) if out.strides.count < 3 { throw YOLOError.strideOutOfBounds } let channelStride = out.strides[out.strides.count-3].intValue let yStride = out.strides[out.strides.count-2].intValue let xStride = out.strides[out.strides.count-1].intValue func offset(ch: Int, x: Int, y: Int) -> Int { return ch * channelStride + y * yStride + x * xStride } for x in 0 ..< grid { for y in 0 ..< grid { for box_i in 0 ..< YOLO.boxesPerCell { let boxOffset = box_i * (classesCount + 5) let bbx = Float(pointer[offset(ch: boxOffset, x: x, y: y)]) let bby = Float(pointer[offset(ch: boxOffset + 1, x: x, y: y)]) let bbw = Float(pointer[offset(ch: boxOffset + 2, x: x, y: y)]) let bbh = Float(pointer[offset(ch: boxOffset + 3, x: x, y: y)]) let confidence = sigmoid(Float(pointer[offset(ch: boxOffset + 4, x: x, y: y)])) if confidence < confidenceThreshold { continue } let x_pos = (sigmoid(bbx) + Float(x)) * gridSize let y_pos = (sigmoid(bby) + Float(y)) * gridSize let width = exp(bbw) * self.anchors[name]![2 * box_i] let height = exp(bbh) * self.anchors[name]![2 * box_i + 1] for c in 0 ..< 80 { classes[c] = Float(pointer[offset(ch: boxOffset + 5 + c, x: x, y: y)]) } softmax(&classes) let (detectedClass, bestClassScore) = argmax(classes) let confidenceInClass = bestClassScore * confidence if confidenceInClass < confidenceThreshold { continue } predictions.append(Prediction(classIndex: detectedClass, score: confidenceInClass, rect: CGRect(x: CGFloat(x_pos - width / 2), y: CGFloat(y_pos - height / 2), width: CGFloat(width), height: CGFloat(height)))) } } } return predictions }
Supressão não máximaDepois de receber as coordenadas e os tamanhos das caixas delimitadoras e as probabilidades correspondentes para todos os objetos encontrados na imagem, você poderá começar a desenhá-los na parte superior da imagem. Mas há um problema! Tal situação pode surgir quando várias caixas com probabilidades bastante altas são previstas para um objeto. O que fazer neste caso? Aqui, um algoritmo bastante simples chamado Não supressão máxima vem em nosso auxílio.
O algoritmo é o seguinte:
- Estamos procurando uma caixa delimitadora com a maior probabilidade de pertencer ao objeto.
- Corremos por todas as caixas delimitadoras que também pertencem a esse objeto.
- Nós os excluiremos se Intersecção sobre união (IoU) com a primeira caixa delimitadora for maior que o limite especificado.
A IoU é calculada usando uma fórmula simples:
Cálculo de IoU. static func IOU(a: CGRect, b: CGRect) -> Float { let areaA = a.width * a.height if areaA <= 0 { return 0 } let areaB = b.width * b.height if areaB <= 0 { return 0 } let intersection = a.intersection(b) let intersectionArea = intersection.width * intersection.height return Float(intersectionArea / (areaA + areaB - intersectionArea)) }
Supressão não máxima. private func nonMaxSuppression(boxes: inout [Prediction], threshold: Float) { var i = 0 while i < boxes.count { var j = i + 1 while j < boxes.count { let iou = YOLO.IOU(a: boxes[i].rect, b: boxes[j].rect) if iou > threshold { if boxes[i].score > boxes[j].score { if boxes[i].classIndex == boxes[j].classIndex { boxes.remove(at: j) } else { j += 1 } } else { if boxes[i].classIndex == boxes[j].classIndex { boxes.remove(at: i) j = i + 1 } else { j += 1 } } } else { j += 1 } } i += 1 } }
Depois disso, o trabalho diretamente com os resultados da previsão de redes neurais pode ser considerado completo. Em seguida, você precisa escrever funções e classes para obter as imagens da câmera, exibir a imagem na tela e renderizar as caixas delimitadoras previstas. Não descreverei todo esse código neste artigo, mas ele pode ser visualizado no repositório.
Também vale mencionar que adicionei um pouco de suavização das caixas delimitadoras ao processar imagens on-line; nesse caso, é a média usual da posição e tamanho do quadrado previsto nos últimos 30 quadros.
Testando o programa
Agora testamos o aplicativo.
Deixe-me lembrá-lo mais uma vez: Existem três ViewControllers no aplicativo, um para processar fotos ou instantâneos, um para processar um fluxo de vídeo online e um terceiro para configurar a rede.
Vamos começar com o terceiro. Nele, você pode escolher um dos dois modelos YOLOv3-tiny ou YOLOv3-416, escolher o limite de confiança e o IoU, também pode ativar ou desativar o anti-aliasing online.
Agora vamos ver como o neurônio treinado trabalha com imagens reais; para isso, tiramos uma foto da galeria e passamos pela rede. A imagem abaixo mostra os resultados do YOLOv3-tiny com configurações diferentes.
Diferentes modos de operação do YOLOv3-tiny. A imagem da esquerda mostra o modo usual de operação. No meio - o limite IoU = 1, ou seja, como se a supressão não máxima estivesse ausente. À direita, há um baixo limiar de confiança do objeto, ou seja, todas as caixas delimitadoras possíveis são exibidas.A seguir, o resultado de YOLOv3-416. Você pode notar que, em comparação com YOLOv3-tiny, os quadros resultantes são mais corretos, assim como objetos menores na imagem são reconhecidos, o que corresponde ao trabalho da terceira camada de saída.
Imagem processada usando YOLOv3-416.Quando o modo de operação on-line foi ativado, cada quadro foi processado e foi feita uma previsão,
testes foram realizados no iPhone XS, portanto
, o resultado foi bastante aceitável para as duas opções de rede.
YOLOv3-tiny produz uma média de 30 - 32 qps, YOLOv3-416 - de 23 a 25 qps. O dispositivo em que foi testado é bastante produtivo; portanto, em modelos anteriores, os resultados podem diferir; nesse caso, é claro, é preferível usar o YOLOv3-tiny. Outro ponto importante: o yolo-tiny.mlmodel (YOLOv3-tiny) ocupa cerca de 35 MB, enquanto o yolo.mlmodel (YOLOv3 -16) pesa cerca de 250 MB, o que é uma diferença muito significativa.
Conclusão
Como resultado, um aplicativo iOS foi escrito que, com a ajuda de uma rede neural, é possível reconhecer objetos na imagem. Vimos como trabalhar com a biblioteca CoreML e como usá-la para executar vários modelos pré-treinados (a propósito, você também pode treinar com ela). O problema de reconhecimento de objetos foi resolvido usando a rede YOLOv3. No iPhone XS, essa rede (YOLOv3-tiny) é capaz de processar imagens com uma frequência de ~ 30 quadros por segundo, o que é suficiente para a operação em tempo real.
O código completo do aplicativo pode ser visualizado no
GitHub .