Manual

do

Maker

.

com

Mira eletrônica com visão computacional

Mira eletrônica com visão computacional

Sobre a mira eletrônica

Esse artigo trata de uma prova de conceito com baixa resolução e sem colocá-la sobre um robô, afim de evitar reproduções malignas.

O propósito maior é mostrar a simplicidade do uso de visão computacional para tarefas simples, utilizando trigonometria invés de estereoscopia. Dá pra fazer diversos "brinquedos maker" com essa técnica, acho até que vou reaplicar em breve.

Esse artigo é meramente instrutivo e pode ser usado para diversos propósitos que não incluem armas.

Se fosse devidamente elaborado...

Com as mudanças na política de segurança, certamente teremos makers criativos produzindo material com armas. Quem não gostaria de se sentir um Tony Stark? Voltando ao mundo real, sabemos que existem diversos critérios para se possuir uma arma, como era antes do Instituto do desarmamento, enfiado goela abaixo nos brasileiros. Mas mesmo com o devido treinamento e a partir de então, estando de igual para igual com um opressor (um bandido que invade uma casa para roubo, por exemplo), ainda assim há o risco de ferimentos por um disparo feito pelo bandido. Claro que aquelas cenas lindas de filme onde os tiros só pegam em vidros enquanto o herói faz rolamento pelo chão só serve para cinema, na vida real deve-se reduzir o máximo possível sua projeção como um alvo (isso é ensinado em academia, aprendi quando fui agente de segurança). Pensando a respeito, não seria melhor se algo como o Robocop pusesse a cara de frente com o bandido enquanto você espera o deslinde dessa celeuma em um canto qualquer da casa? Claro que sim! Bem, essa mira eletrônica não é um projeto recomendado com uso de armas de verdade por diversos motivos, dentre os quais, disparos acidentais, mas é divertido fazer a parte cinematográfica da coisa.

Se fosse colocado sobre um robô e a câmera estivesse sobre o pam-tilt, apenas o eixo Y precisaria passar pelo controle descrito nesse artigo. No formato que a mira eletrônica está exemplificada, serviria bem como "acessório" de câmeras já instaladas em ambientes fechados, por exemplo. Voltando ao cinema, em uma sala de museu onde jóias estão sob vigilância constante, seria uma das aplicações.

Robô de imobilização permanente

Uma mira eletrônica  em um robô certamente poderia ser chamado assim, mas o faço por brincadeira, uma vez que nesse artigo utilizo OpenCV para fazer a detecção facial, então fazer a mira na testa com um laser. Para evitar bloqueios em redes sociais, não vou colocar sobre um robô completo, vou utilizar um pam-tilt para mirar um laser na testa conforme a localização que o rosto aparece e fazer o face detection pela webcam do Notebook. Igualmente seria em um Raspberry, uma vez que estou utilizando Linux. Mas não fiz o robô completo porque já me basta ter sido obrigado a enviar várias fotos do meu rosto e documento para o facebook quando fiz esse artigo de bomba com Arduino.

Espero ter deixado claro o suficiente que é uma brincadeira e o objetivo é dar um exemplo de uso da visão computacional sem estereoscopia, utilizando trigonometria para manipular um robô em um plano 2D. Tendo esclarecido, sigamos com o projeto de mira eletrônica.

Instalando o OpenCV

No Raspberry ou em um notebook com Linux o processo é o mesmo. Instale o Python-OpenCV:

sudo su
apt-get update && apt-get install python-opencv

Depois, localize o arquivo do haarcascade relacionado ao face detection frontal (ainda como root, ou digite "sudo su" primeiro):

find / -name haarcascade_frontalcatface.xml

No meu notebook o arquivo está localizado em **/usr/local/share/OpenCV/haarcascades/haarcascade_frontalcatface.xml.**Depois de encontrado, altere o caminho para o arquivo no código de teste.

Código para fazer face detection com OpenCV

O código é bem simples. O que fiz primeiro foi identificar a posição da testa (ainda não é a mira eletrônica, primeiro precisamos identificar a região de referência). Marquei com uma área vermelha, assim como marquei o local da detecção da face (esta, em verde). Lembre-se (ou tome ciência) de que o face detection gera falso-positivo e para evitar realmente um disparo acidental ( de mentira, feito com laser), deve-se utilizar inteligência artificial. Com isso, quando um rosto for identificado, a inteligência artificial se certificará de que realmente é um rosto. Se for um rosto desconhecido, então dispara (o laser). Se esse artigo e o vídeo tiverem retorno positivo, coloco inteligência artificial no projeto para mostrar, mas por enquanto o face detection já dará uma boa brincadeira.

O primeiro código (para identificar a localização do disparo) é esse:

#!/usr/bin/env python

from  __future__ import print_function
import numpy as np
import cv2

MY_CLASSIFIER = '/usr/local/share/OpenCV/haarcascades/haarcascade_frontalcatface.xml'

cap = cv2.VideoCapture(0)

face_cascade = cv2.CascadeClassifier(MY_CLASSIFIER)

while (cap.isOpened()):
    ret,frame = cap.read()
    gray = cv2.cvtColor(frame,cv2.COLOR_BGR2GRAY)
    faces = face_cascade.detectMultiScale(gray, scaleFactor=1.3, minNeighbors=5, flags=cv2.CASCADE_SCALE_IMAGE,minSize=(50,50), maxSize=None)

    if len(faces) > 0:
        #print("Pessoa detectada!")
        for (x, y, w, h) in faces:
            cv2.rectangle(frame, (x - 10, y - 20), (x + w + 10, y + h + 10), (0, 255, 0), 2)
            cv2.rectangle(frame, (x+w/2,y+h/5),(x+w/2+5,y+h/5+5), (0,0,255,0),2)
            roi_gray = frame[y-15:y + h+10, x-10:x + w+10]

        cv2.imshow("Manual do Maker", frame)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break
    cv2.imshow("Manual do Maker", frame)

cap.release()
cv2.destroyAllWindows()

Para ser instrutivo, vou explicar um pouco.

Execução do script Python

A primeira linha do código serve para dizer onde está o interpretador Python, uma vez que no Linux podemos tornar qualquer arquivo executável e assim, não há necessidade de chamar o interpretador utilizando o arquivo como parâmetro. O processo seria algo como:

#dar um bit de execução ao arquivo
chmod 700 bang.py
#executar o programa
./bang.py

De outro modo (ou em Windows), teriamos que fazer:

python bang.py

Seguidamente, importamos algumas bibliotecas; não se incomode com suas funcionalidades agora, apenas saiba que cv2 é o OpenCV.

A linha face_cascade é onde indicamos o caminho para o classificador, que é o arquivo contendo o treinamento para detecção facial. Por isso que mais acima expliquei como encontrá-lo no sistema.

A linha cap se refere ao dispositivo de captura, que poderia ser uma imagem, um vídeo ou, apontando para 0, a primeira câmera do sistema. No caso, a webcam do notebook. Também é possível utilizar uma URL para uma câmera IP, futuramente mostro, usei bastante profissionalmente.

Na linha ret,frame pegamos as informações de uma leitura. Na linha gray, fazemos a conversão para gray scale. Isso sempre é utilizado porque desse modo a imagem terá apenas uma camada de pixels para ser analisada. De outro modo, teria que mapear as camadas BGR (que é o RGB ao contrário).

Parâmetros da função de detecção

Na linha face fazemos então a detecção facial, passando os parâmetros (para mais, veja documentação):

  • imagem - que é a matriz de pixels onde será feita a leitura em busca de uma face.
  • objetos- que é o vetor de retângulos onde cada retângulo contém o objeto detectado. Nãp estou utilizando.
  • scaleFactor - Para especificar o retângulo em volta da detecção. Esse parâmetro podemos variar com critério; quanto maior a área a mapear, mais processamento.
  • MinNeighbors - Especifica o tamanho da região a fazer a análise. Retângulos menores processam mais rapidamente, mas exigem mais etapas. Retângulos maiores processam mais lentamente, mas concluem de forma mais rápida. Dependendo da quantidade de faces a detectar, se o código para gerenciamento de muitas câmeras, um tamanho menor é melhor geralmente. Mas há de se fazer tuning.
  • flags - Não é mais utilizado no novo cascade, mas ainda estou mantendo. No CV3, o parâmetro não mais existe.
  • minSize - O tamanho mínimo desejado para o objeto. Isso ajuda a evitar falsos positivos, com um pouco de experimentação começamos a perceber a necessidade do ajuste.
  • maxSize- Do mesmo modo, podemos limitar o tamanho máximo. Isso pode ajudar a reduzir processamento também, porque limita o escaneamento a um tamanho X. Se ele começar em 50x50, depois 55x55, 60x60 e for indo até chegar a, digamos, 300x300, o número de fases poderia ser demasiado. Supondo que após testes, sabemos que a face não deverá passar de 100x100, poderíamos então passar uma tupla com o valor 100x100, no mesmo formato que está especificado em minSize.

Se uma face for detectada, a variável faces terá um valor maior que 0. Nesse caso, fazemos um loop para pegar as posições das faces. Repare que o código não contempla o apontamento para múltiplas faces, estou partindo do pressuposto que apenas 1 pessoa morrerá por vez nessa mira eletrônica (brincadeira de novo).

Nesse mesmo loop, estou traçando um retângulo na região da face, com uma tolerância para todos os lados. Para apontar o laser, fiz a simulação primeiro, traçando um pequeno retângulo na área que desejo que o laser aponte. Tendo achado a região de interesse, agora é necessário proporcionalizar o movimento do servo motor para casar com o posicionamento identificado pelo face detection. Veremos em detalhes mais adiante.

O restante é exibição, tecla gatilho para sair do programe e finalização do OpenCV.

Depois que acertei direitinho a posição, foi a vez de configurar o pam-tilt.

PAM e Tilt com Arduino

Manipular o PAM é fácil, o que dará mais trabalho é ajustar o laser para acertar a posição da mira eletrônica baseando-se nas coordenadas enviadas pelo CV. Já escrevi a respeito de pam-tilt nesse artigo. Configurei de forma automática agora, mas no artigo referenciado tem o controle das duas formas; digital e analógico, caso prefira fazer a mira e o disparo manualmente.

Como estou utilizando o notebook, coloquei um Arduino Nano conectado para receber o posicionamento a partir do OpenCV rodando no notebook, mas no Raspberry poderia ser feito de outra maneira.

Laser e Arduino Nano

Primeiramente, colei o laser com cola quente sobre o PAM. Depois, colei o Arduino Nano de cabeça para baixo sobre o laser; eles não se tocam.

Wiring

Não é recomendado alimentar o módulo diretamente pela controladora, mas como é só para o artigo, conectei tudo diretamente ao 5V do Arduino Nano, utilizando uma mini protoboard.

No código abaixo você vê o número dos pinos digitais utilizados para a conexão do servo e do laser. Invés de colocar o laser em GND, coloquei no pino digital 6 para fazer PWM. O servo do eixo Y coloquei no pino 10 e o do eixo X coloquei no pino 9.

O padrão do servo é:

Laranjadigital
Vermelho5V
MarromGND

Pinout

Peguei um pinout do Arduino Nano no CircuitsToday.

arduino_nano_pinout.webp

Eu ia fazer com o ESP8266, mas comecei me deparar com problemas que me forçariam a adicionar mais componentes ao projeto, então preferi substituir pelo Arduino Nano. Bonito não está, mas para prova de conceito é mais que o suficiente:

 

Programando o Arduino Nano

Para programá-lo, utilizei a IDE Atom com PlatformIO. Já estava utilizando essa IDE para programar o ESP32, mas obtive uma mensagem para configurar o udev. Se estiver utilizando Linux também, pode fazer o seguinte:

sudo su
cd /etc/udev/rules.d
curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core/develop/scripts/99-platformio-udev.rules | sudo tee /etc/udev/rules.d/99-platformio-udev.rules
service udev restart

Isso eliminará a mensagem de aviso e, se eventualmente precedia algum erro ao fazer upload, provavelmente não o terá mais.

Servo motor com Arduino Nano

O programa da mira eletrônica para o Arduino Nano é bastante simples, como você pode notar. Esse código é do ajuste; como coloquei os servos de qualquer jeito, eles não estão devidamente posicionados no pam-tilt, por isso precisei "calibrar" as posições de X e Y.

#include <Arduino.h>
#include <Servo.h>

//Definição dos pinos para o servos motor dos eixos X e Y
#define AXIS_Y_PIN 10
#define AXIS_X_PIN 9
#define LASER_PIN  6

//Instância dos servos
Servo axis_x_servo;
Servo axis_y_servo;

byte axis_x_initial_pos = 110;
byte axis_y_initial_pos = 49;

byte i = 0;

void setup() {
  pinMode(LASER_PIN,OUTPUT);
  analogWrite(LASER_PIN, 255);

  //conecta os servos
  axis_y_servo.attach(AXIS_Y_PIN);
  axis_x_servo.attach(AXIS_X_PIN);

  Serial.begin(115200);

  axis_x_servo.write(axis_x_initial_pos);
  delay(2000);
  axis_x_servo.detach();

  axis_y_servo.write(axis_y_initial_pos);
  delay(2000);
  axis_y_servo.detach();
}

void loop() {

}

Feito isso, pude implementar a leitura da serial e o reposicionamento do pam-tilt, além do acionamento do laser. Então, a partir do terminal serial (aberto pela própria IDE Atom), fiz alguns testes. Para reposicionar, basta passar, 00,00. Isso porque ele espera 5 Bytes para tomar alguma ação. Criei a função setAndBang() para acionar os servos e o laser. Como estou utilizando a alimentação do próprio Arduino, preferi não acionar ambos os servos ao mesmo tempo pela questão da corrente. Para a prova de conceito já está bom demais. Além disso, desconecto o servo ao final de cada operação. O código está pronto para ser utilizado com o programa de visão computacional da mira eletrônica. Mais uma vez reforço, fiz o código básico de ambos, para poder demonstrar apenas, além de evitar reproduções malígnas. O código final para o Arduino ficou assim:

#include <Arduino.h>
#include <Servo.h>
#include <string.h>


//Definição dos pinos para o servos motor dos eixos X e Y
#define AXIS_Y_PIN 10
#define AXIS_X_PIN 9
#define LASER_PIN  6

//Instância dos servos
Servo axis_x_servo;
Servo axis_y_servo;

byte axis_x_initial_pos = 110;
byte axis_y_initial_pos = 49;

const byte bufSize = 8;
byte i             = 0;
int x_pos          = 0;
int y_pos          = 0;
char buf[bufSize]  = {0};

void setupServos(){
  //conecta os servos
  axis_y_servo.attach(AXIS_Y_PIN);
  axis_x_servo.attach(AXIS_X_PIN);

  axis_x_servo.write(axis_x_initial_pos);
  delay(2000);
  axis_x_servo.detach();

  axis_y_servo.write(axis_y_initial_pos);
  delay(2000);
  axis_y_servo.detach();
}

void setAndBang(){
  axis_y_servo.attach(AXIS_Y_PIN);
  delay(100);
  axis_y_servo.write(axis_y_initial_pos-y_pos);
  delay(1500);
  axis_y_servo.detach();

  axis_x_servo.attach(AXIS_X_PIN);
  delay(100);
  axis_x_servo.write(axis_x_initial_pos-x_pos);
  delay(1500);
  axis_x_servo.detach();
  analogWrite(LASER_PIN, 10);
  delay(2000);
  analogWrite(LASER_PIN, 255);
}

void setup() {
  pinMode(LASER_PIN,OUTPUT);
  analogWrite(LASER_PIN, 255);
  Serial.begin(115200);

  setupServos();
}

void loop() {
    while (Serial.available()){
      Serial.readBytesUntil('\n', buf, 7);
    }
    if (buf[0] != 0){
        Serial.println(buf);
        x_pos = String(buf).substring(1,3).toInt();
        y_pos = String(buf).substring(5,7).toInt();

        if (buf[0] == '-'){
          x_pos = -x_pos;
        }
        if (buf[4] == '-'){
          y_pos = -y_pos;
        }
        Serial.println(x_pos);
        Serial.println(y_pos);
        setAndBang();
        memset(buf,0,bufSize-1);
    }
}

O código para usar com a webcam está disposto logo adiante, apenas siga com a leitura e relaxe.

Trigonometria no triângulo retângulo

trigonometria.png

Pois então, meu caro. Lembra que "a soma dos quadrados dos catetos é igual o quadrado da hipotenusa"? Já precisou usar alguma vez na vida?

Estou escrevendo um livro (não que eu seja um grande escritor, mas pode ser uma leitura interessante), falando sobre a metodologia de ensino e formas de aprendizado. Resumidamente, eu acredito que se o ensino não for aplicado realmente, será difícil um aluno começar a desejar alguma profissão, pois não sabe a importância do que eventualmente está aprendendo. Além disso, tem a questão da forma que é ensinado, que acaba virando uma coisa decorada e por consequência, esquecida com o tempo. Decorar é um fardo, ainda mais para jovens.

Bem, aqui teremos que usar algum recurso além de mover o servo motor da mira eletrônica, por uma simples razão. O alvo estará em um plano de profundidade, e as coordenadas passadas ao Arduino Nano se referem a X e Y. Vou usar um valor constante, mas poderia facilmente ser substituído pelo valor advindo de um sensor ultrassônico ou um LiDaR (Light Detection And Range - espero ter a oportunidade de escrever a respeito um dia). E porque precisamos da profundidade?

Temos um ponto comum, que é o ponto do observador (o observador é a webcam, mas as coordenadas serão transformadas para a mira eletrônica). A partir dele, um alvo frontal é percebido. A imagem é vista absolutamente de frente e o alvo estará em algum ponto X e Y dessa imagem. Peguemos inicialmente o eixo X, que corre horizontalmente. A partir do centro da imagem (que é o centro do observador) para um dos lados, teremos o alvo. Isso significa que temos duas retas, sendo a profundidade (Z) e a distância horizontal (X), que é a perpendicular. Isso significa também que temos um ângulo de 90 graus, ou um ângulo reto. Para que fique claro, repare a imagem acima.

Cada deslocamento do servo motor corresponde a 1 grau de inclinação. Não dá para simplesmente proporcionalizar o deslocamento do alvo no plano X porque o movimento do PAM-tilt é circular, enquanto o movimento no eixo X e Y é linear e em dado momento, o ângulo variará consideravelmente. Isso ficará mais claro, não se preocupe.

Conceitos do triângulo retângulo

Primeiro, se chama triângulo retângulo porque um de seus ângulos é reto. Um ângulo reto tem 90 graus.

Os catetos são os lados que formam o ângulo reto do triângulo.

A hipotenusa é o lado oposto ao ângulo reto; a linha restante para fechar o triângulo.

catetos_hipotenusa.png

Este é o teorema de Pitágoras: $latex h^2=ca^2+co^2$

Agora, avancemos, no momento certo aplicaremos.

Ângulos notáveis

Os ângulos notáveis são os que aparecem com mais frequência, sendo 30, 45 e 60 graus. Quando precisar realmente decorar alguma coisa, use métodos alternativos como música, palácio mental ou assimilação direta. Esse é o melhor vídeo sobre ângulos notáveis, nunca mais você vai se esquecer (substitua "raiz em cada um" por "raiz não vai no 1"):

https://youtu.be/kmOXRAwjNbM

A técnica de palácio mental eu aprendi após assistir o Sherlock Holmes na Netflix, que me despertou interesse e então fui ver se era real e adotei a técnica.

https://www.youtube.com/watch?v=qou1Sc5wF-w

Agora, precisamos por em prática essa teoria para definirmos o que será feito em nosso código.

Calculando o ângulo

Precisaremos ter em mente o objetivo. Não vamos utilizar de forma alguma 180 graus em nossa mira eletrônica. Podemos previamente capturar o ângulo máximo utilizável mantendo o laser ligado no pam-tilt, então movendo N graus horizontalmente a partir do centro. Logo, teremos esse ângulo para esquerda e direita. Suponhamos que o limite de abertura seja 45 graus, desconsideramos a posição 0 do servo, então teremos 22 graus para cada lado. Agora, sabendo a profundidade e proporcionalizando o eixo X (ainda veremos essa parte), podemos calcular o ângulo.

Como vamos precisar saber seno/cosseno desses 22 graus, podemos criar um array com constantes para usar conforme o ângulo, mas optei por uma forma mais "limpa" de fazê-lo.

Utilizei o console do interpretador Python para elaborar o código. É uma excelente ideia aprender Python para prototipar antes de escrever código em C. Uma das razões é a simplificação da lógica e outra, por não haver necessidade de compilar, subir o programa na MCU etc.

Se não tem ainda Python instalado, baixe-o em seu Windows ou, se tiver Linux, já estará lá. Depois, basta executá-lo sem parâmetros para ter acesso ao console do interpretador.

Calcular seno, cosseno e tangente

As fórmulas estão envoltas em tags Latex. Use esse editor online (ou outro que desejar, remova as tags $latex e `$' e veja a fórmula.

No programa não teremos que nos preocupar com as contas, pois utilizaremos uma biblioteca matemática, mas para que não falte informação, vou dispor o cálculo aqui. Se seu interesse é exclusivamente no programa, pode pular para o tópico Seno e Radianos.

Para encontrar o seno, fazemos a divisão do cateto oposto pela hipotenusa:

$latex sen \alpha = \frac{co}{h} $

Para calcular o cosseno, fazemos a divisão do cateto adjacente pela hipotenusa:

$latex cos \alpha = \frac{ca}{h} $

E para calcular a tangente, fazemos a divisão do cateto oposto pelo cateto adjacente:

$latex tg \alpha = \frac{co}{ca} $

O legal é que na programação nós podemos dispensar tabelas para fazer substituições, como você verá mais adiante. Com isso, chegamos rapidamente ao resultado, mas é importante saber o fundamento por trás das funções matemáticas.

Olhe para esse triângulo retângulo:

seno_manual.webp

Para descobrir a medida de X (cateto oposto ao ângulo de 45 graus), aplicamos o seno. Porém, uma das variáveis é um ângulo, que precisa ser convertido para o seno de 45, conforme tabela trigonométrica (caso feito manualmente), enquanto no programa poderíamos fazer simplesmente:

math.sin(math.radians(45.0))

Considerando a tabela (ou o retorno da função anterior), o valor do seno de 45 graus é 0,7071:

$latex sen 45 \textdegree = \frac{x}{10} $

$latex 0,7071 = \frac{x}{10} $

$latex x = 0,7071*10 = 7,071cm $ ou, 70,71mm.

Operações trigonométricas inversas

As inversas são utilizadas em cálculos para permitir resolução de algumas integrais. Fujamos desses detalhes. Mais abaixo mostro a utilização desses recursos com exemplos resolvidos em uma simples linha de código. Duas, quando estou fazendo um loop para pegar os valores de 22 graus, apenas siga a leitura.

Seno e radianos

Basicamente, importei a biblioteca matemática. Para importá-la no Python, basta digitar dentro do interpretador:

import math

Para a função seno, é necessário passar o valor em radianos. É errado fazer assim:

math.sin(30)

Para converter em radianos, utilizamos a função math.radians(30). Podemos chamar essa função radians dentro da função sin.

print(math.sin(math.radians(30))

O resultado será 0.5. Porém, se chamado dentro do interpretador, não haverá arredondamento:

radiano.webp

Fica a seu critério fazê-lo diretamente em C/C++ no Arduino Nano ou então gerar os valores no console Python, depois criar um array. É mais feio, porém não haverá processamento na MCU. Eu optarei por deixar todo o processamento no programa em Python e enviarei apenas os ângulos para a movimentação dos servos.

22_graus.webp

Os valores cujo centésimo passe de 5 pode ser arredondado para cima. Por exemplo, 4 graus seria 0,0697... e pode ser arredondado para 0,07. Esse arredondamento para a mira eletrônica só é tolerável porque o servo mexe apenas 1 grau por vez. Mas existem artifícios para contornar essa situação, citarei mais adiante.

No Arduino é a mesma coisa, a função sin() recebe o parâmetro em radianos. O resultado será sempre algo entre -1 e 1, apenas para relembrá-lo, caso não lembre dos gráficos cartesianos. Citei algo a respeito quando escrevi o artigo para fazer o som de sirene com Arduino e buzzer, nesse artigo.

Outra informação importante é que a soma dos ângulos internos de um triângulo sempre será 180. Um dos ângulos do triângulo retângulo é mandatoriamente 90 graus, portanto, achando o segundo, ficará fácil determinar o terceiro.

Inversa da tangente (ou "arco tangente")

Supondo que queiramos saber a tangente do ângulo de 41 graus. Usando os princípios supracitados, faríamos algo como:

print(math.tan(math.radians(41)))

Logo, teríamos um resultado como 0,8692... ou, com 3 casas decimais, 0.869. Nesse caso, sabemos o ângulo e calculamos a tangente, mas para o que queremos, basta saber o ângulo para movimentar proporcionalmente o servo motor. Logo, devemos fazer a operação inversa; a inversa da tangente, ou, arco tangente.

Utilizando o valor acima para encontrar os 41 graus, poderíamos fazer:

round(math.degrees(math.atan(0.875)),2)

É bom considerar a utilização de arredondamento, caso contrário podemos ter um grau a mais ou a menos no movimento do servo motor e, isso faz sim muita diferença, mais perceptível em relação à profundidade. Repare:

round.webp

Eu testei para garantir que isso não resultaria em falha. Se quiser experimentar também, são apenas 2 linhas de Python:

for i in range(1,91):
    round(math.degrees(math.atan(math.tan(math.radians(i)))))

Descobrir o ângulo

Já garantimos a conversão de radianos para graus. Agora precisamos descobrir o valor que passaremos para a função que retornará os graus a enviar por serial para o Arduino Nano da mira eletrônica. Outra opção seria passar apenas as coordenadas X e Y e fazer todo o processamento no Arduino, mas o face detection está sendo feito com OpenCV em Python, não custa nada incluir mais algumas linhas e entregar o resultado pronto via serial.

No programa em Python, teremos os valores X e Y advindos do quadro capturado pela webcam. Devemos definir primeiramente a profundidade em relação à mira eletrônica. O segundo passo é definir a abertura do ângulo, que será a coordenada do eixo X. O foco nesse momento é o servo motor que se move horizontalmente.

Para descobrir um ângulo é bastante simples:

calculo_do_angulo.webp

Temos o ângulo de 90 graus, já conhecido por se tratar de um triângulo retângulo. Pensemos que o ponto do observador é a linha de profundidade (lembre-se; o observador é a webcam mas as coordenadas devem ser passadas em relação à mira eletrônica) - no nosso caso, a linha vertical, cuja profundidade está em 1 metro. Temos uma linha horizontal advinda das coordenadas da imagem capturada pela webcam e que proporcionalizada, resulta em 130cm. Precisamos agora calcular a tangente. Daí voltamos ao velho teorema de Pitágoras: "A soma dos quadrados dos catetos é igual o quadrado da hipotenusa". Portanto, sabemos que a profundidade é 100cm e o campo de visão é de 130cm:

$latex 100^2 + 130^2 = 26900$

Agora basta tirar a raiz:

$latex \sqrt26900 = 164 $

Já temos todos os valores, agora é só descobrir o ângulo a partir do observador. Conforme explicado, devemos agora dividir o valor do cateto oposto ao ângulo pretendido e dividir pela hipotenusa. Depois, faz-se o seno invertido. Em Python fica assim:

asin_do_observador.webp

Pronto, aproximadamente 52 graus! É muito provável que essa seja sua primeira aplicação de trigonometria na "vida real". Mas o trabalho não acabou, agora devemos criar a relação pixel/centímetros para os eixos X e Y. Depois, devemos programar tanto a abertura do eixo X como do eixo Y. Mas será moleza agora que temos tudo devidamente descrito, hum?

Relação pixels/centímetro

Para que haja a detecção facial, o OpenCV abre a câmera e lê N quadros por segundo. A cada quadro lido, é aplicado todo o algorítimo de detecção facial; uma varredura nos blocos de pixel determinados como matriz para essa varredura é lido, então a área com a face é marcada. Se a câmera for aberta com 30fps (30 frames per second) então o processamento ocorrerá 30 vezes por segundo. Não precisamos de tanto, 15 frames é mais que o suficiente. Além disso, quanto maior a imagem, maior o processamento. Também não precisamos de uma imagem full hd para fazer a detecção, podemos reduzir o tamanho para 1/4, por exemplo. Não devemos nos esquecer de trabalhar sobre a imagem convertida para escala de cinza, como descrito mais acima, onde disponho o primeiro código de teste com OpenCV.

Tendo definido os parâmetros de captura, agora precisamos proporcionalizar esses pixels por centímetro para que seja possível calcular o ângulo de deslocamento da mira eletrônica a partir do observador (a webcam). Quando definirmos a profundidade, "mentiremos" para o programa, pois a profundidade estará relacionada com a mira eletrônica, não com a câmera. Nesse caso, devemos determinar a distância entre o pam-tilt e o plano de fundo, capturado pela webcam, utilizando uma trena.

Para definir o comprimento do eixo X, podemos utilizar também a trena a partir do limite definido como profundidade. A distância entre cada grau em relação ao eixo X não é proporcional e, como não vamos utilizar valores fracionados, é altamente recomendado que a profundidade não seja muito grande. Esse desenho demonstra exatamente a razão:

abertura_do_angulo.png

Repare que com a profundidade maior, o espaço entre  cada grau também é maior, enquanto que com a profundidade menor, o espaçamento entre cada grau se reduz. Uma forma de resolver isso é aproveitar o movimento restante do servo motor. Por exemplo, usamos apenas 22 graus para cada lado, resultando em 45 graus, incluindo o ponto 0. Isso significa que o servo motor ainda tem mais de 100 graus restantes. Podemos então criar uma redução com engrenagens, de forma a movimentar 4 graus no servo motor para 1 grau de resultado final. Desse modo, teremos uma precisão de 0.25 graus por grau movido no servo motor. Não será o propósito aqui, mas se interessa, leia o artigo relacionado clicando aqui. Um conjunto de engrenagem com 24 dentes e pinhão com 6 dentes já resolveria a questão de precisão da mira eletrônica.

Será necessário experimentar também o laser da mira eletrônica nesse momento para identificarmos corretamente quantos graus serão utilizados para cobrir todo o plano visível em relação à profundidade. Utilizei 45 graus apenas como exemplo, teremos que descobrir o valor real com experimentação ou calculando o arco de seno com base no triângulo retângulo, que parte do centro vertical e vai até uma das bordas, na horizontal. Repare que ainda estamos focando no movimento do eixo X, posteriormente devemos repetir o processo para o eixo Y.

Resumo da segunda parte

Na primeira parte, tratamos de fazer o face detection. Em seguida, juntamos os conceitos necessários de trigonometria para fazer a descoberta do ângulo a inclinar o servo da mira eletrônica, a partir do observador.

Repare que, em caso de utilizar um sensor ultrassônico para profundidade, os três seguimentos do triângulo retângulo são variáveis. Só não muda o ângulo de 90 graus, senão não é triângulo retângulo, certo?

Vimos como utilizar o teorema de Pitágoras para descobrir a hipotenusa, que é a vértice mais importante em relação ao servo motor da mira eletrônica, pois o ângulo próximo ao observador é que definirá o deslocamento.

Vimos que a mudança de ângulo se dará por inteiros, portanto cada movimento se refere a 1 grau de movimento e nesse caso, a precisão é baixa, pois ao longo do seguimento o espaço entre cada grau aumenta. Por isso que essa brincadeira só vai funcionar bem em curtas distâncias.

Se for utilizar essa mira eletrônica para outro propósito que não uma brincadeira (e também que não seja em uma arma de verdade), então considere que, como supracitado, um conjunto de redução ajudará a aumentar a precisão - e nesse caso, não deve ser desconsiderada a parte fracionada do ângulo encontrado.

Fizemos a operação trigonométrica normal e inversa para seno e tangente. Apenas para deixar claro, vamos fazer o seno e o arco  seno novamente:

seno_e_arco_de_seno.webp

Agora, dando continuidade à parte mecânica, vamos definir a relação pixel/cm com base no que vimos acima.

Mecânica - descobrindo visualmente o ângulo de abertura da mira eletrônica

Aqui teremos que fazer duas coisas; descobrir o ângulo máximo de abertura dentro do campo de visão da webcam para o laser em relação ao plano de fundo (a distância do cateto em relação ao observador) e proporcionalizar px/cm.

Para descobrir o ângulo máximo de abertura, temos duas possibilidades. Em ambas, o laser deve estar perpendicular em relação ao eixo X da imagem. Isso significa, como se fosse uma pessoa olhando para frente.

O posicionamento da mira laser pode ser feito de duas maneiras; colocando-a em um dos cantos e o movimento até o alvo sempre se dará em uma só direção, ou; o pam-tilt pode ser posicionado bem de frente com a webcam, então movimentar-se tanto para a esquerda quanto para a direita. Seja qual for sua opção, o primeiro passo é manter o laser ligado de frente com o plano de fundo para encontrar a posição. Após, movimenta-se o servo do eixo X N graus, de forma digital, para descobrir qual o ângulo de abertura.

Para descobrir a relação px/cm, é um pouco mais complicado, caso você coloque um sensor ultrassônico no eixo do observador. Como estou usando uma constante para profundidade, facilita um bocado. Basta abrir uma trena em frente à webcam, cobrindo todo o ângulo de visão da webcam. É grotesco, mas funciona.

Teste de conversão para graus a partir da visão computacional

Antes de enviar os dados para o Arduino, obviamente foi necessário testar o código com as funções trigonométricas. O primeiro teste foi bastante bem. Criei duas funções para cumprir a tarefa; uma para descobrir a tangente, outra para converter em graus para a mira eletrônica.

A webcam está capturando imagem em 640x480. Com profundidade de 74cm, X e Y ficaram na mesma proporção em milímetros; 480 milímetros para 480 pixels, 640 milímetros para 640 pixels. Por causa disso, pulei a função de proporcionalizar as dimensões.

No exemplo, usei as dimensões do ponto de vista da webcam. Como a mira eletrônica ficará deslocada, será necessário criar duas constantes que definirão a distância X e Y da mira até a webcam. Depois, subtrai-se ou adiciona-se o valor ao cateto de cada triângulo, antes de iniciar os cálculos trigonométricos. O primeiro teste (antes de criar a "posição virtual"):

mira_eletronica-cv.webp

 

O valor dos graus está arredondado no código, uma vez que será movido exatamente 1 grau por vez no servo motor, como já explicado anteriormente. O valor de X e Y se referem ao ponto de mira, onde está em vermelho, na testa.

Você já deve ter reparado (se está realmente fazendo a leitura) que estou dividindo a tarefa em partes. A melhor coisa em um projeto é conseguir separar funcionalidades e fazê-las funcionar de forma independente. Isso facilita a manutenção do código e permite novas implementações.

To sleep or not to sleep?

Supondo 30fps na captura da webcam, significaria que em 1 segundo teríamos 30 passagens pelo loop do face detection. Em dado momento haverá uma face detectada, então o comando para a mira eletrônica deve ser enviada. No frame seguinte, lá está o mesmo rosto porque, exceto se trate de uma aparição fantasmagórica, por um tempo mensurável a webcam estará capturando esse mesmo rosto. O que fazer nesse caso?

Thread em Python

Em Python utilizamos a função sleep(segundos). Essa função pertence à biblioteca time. Só que não podemos fazer esse delay dentro do loop do face detection, porque geraremos um gargalo na câmera. Por essa razão, o controle do envio para a mira eletrônica deve ser externo e para isso, podemos utilizar uma thread. Se você não está familiarizado com threads, seria interessante aprender a respeito. Se você tem acompanhado meus artigos sobre ESP32, deve ter lido em diversos deles sobre a utilização de tasks. Bem, threads e tasks são semelhantes. Com elas o programa tem a capacidade de executar tarefas assíncronas. Como queremos um controle de envio independente para a mira eletrônica, fazemos uma função que rodará de forma independente e contínua. Como sempre, primeiro vem o teste:

import threading
...
def FreeForFire():
    while keepRunning:
        if (bang):
            fromInt  = str(x_pos2nano)
            fromInt += ","
            fromInt += str(y_pos2nano)
            conn.write(fromInt)
            sleep(5)
            bang = False
        sleep(1)
        print("bang...")

fff = threading.Thread(target=FreeForFire, args=[])
fff.start()

Assinalamos no código do face detection uma variável chamada bang que é um boolean. Verificamos nesse mesmo loop se ela já está assinalada como True. Se não estiver, assinala.

Na thread, verifica-se se esse boolean está assinalado. Se estiver, pega as coordenadas e envia para o Arduino da mira eletrônica. Faz um sleep de 5 segundos para dar tempo de tudo acontecer, então marca o boolean bang como False, permitindo que ele seja novamente assinalado como True no loop do face detection. Pronto.

No exemplo acima, ainda mantive um print extra com sleep de 1 segundos para saber que a thread não morreu. Isso deve ser feito como tratamento de exceção de qualquer modo, mas para esse artigo o código está simplificado.

Flag no evento de detecção para ação da thread

A Thread estará em execução contínua rodando de forma assíncrona, mas graças ao boolean bang, só executará a rotina de envio à mira eletrônica no momento em que for solicitada. Essa flag deve ser marcada no loop do face detection. Para marcar, primeiro é necessário ver se ela já não está assinalada. Caso esteja, não executa nenhuma ação e segue seu fluxo normalmente. Se a flag já foi liberada pela thread, então as variáveis com as coordenadas são alimentadas e a flag é levantada novamente:

import threading
def FreeForFire():
    global bang
    print("ok")
    while keepRunning:
        if bang:
            fromInt = "+"
            fromInt += str(x_pos2nano)
            fromInt += ","
            fromInt += str(y_pos2nano)
            print("Serial: "+fromInt)
            conn.write(fromInt)
            sleep(12)
            bang = False
        print("bang...")
        sleep(1)

fff = threading.Thread(target=FreeForFire, args=[])
fff.start()

E no loop do face detection:

if not bang:
                #faz o que tem que fazer e assinala bang
                #apenas passa as coordenadas em pixels subtraindo a diferença
                #da distância da câmera até o pam
                strFromCoordinates(x+w-cam_pos_x,y+h-cam_pos_y)

                bang = True

 

Código completo do controlador

O controlador da mira eletrônica, nesse caso do artigo, é o notebook. A detecção é feita, a proporção px/cm é aplicada, os valores de X e Y são convertidos para graus, a subtração do deslocamento da webcam em relação à mira eletrônica é aplicada e esses valores em graus são enviados via serial para o Arduino que controla os servos motor. A função do controlador é só essa, o resto é responsabilidade da mira eletrônica.

As constantes cam_pos_x = 60cam_pos_y = 75 devem ser ajustadas conforme a posição que for colocado o pan-tilt, em relação ao laser preso a ele.

A detecção de rosto nesse código é frontal apenas, por isso, dependendo do ângulo, o laser pegará até na fonte. Isso porque a medição é feita em um plano 2D, mas a mira continua estando certa. Se variar a profundidade, ainda assim é provável que pegue em um ponto fatal da cabeça. Experimentei e a mira foi um pouco acima da boca. Claro que conforme aumenta a distância, a margem de erro começa aumentar também.

Quando montei minha pam-tilt, cometi a cagad... digo, o erro de colocá-lo antes de ajustar o ângulo em 0, por isso só tenho 10 graus para cima e todo o resto para baixo, por isso posicionei a mira eletrônica em cima da tampa do notebook. Quando comecei o projeto, foi totalmente baseado nela ao lado esquerdo do notebook, por isso fiquei mais feliz ainda, pois quando fui obrigado a mudar a posição não tive problemas!

O código completo do controlador:

#!/usr/bin/env python
#*** encoding: utf-8 ***
'''
O artigo relacionado esta em http://www.manualdomaker.com/article/mira-eletronica-com-visao-computacional
Cada parte do codigo cita a sessao correspondente no artigo.
'''

#5 Codigo para fazer face detection com OpenCV
from  __future__ import print_function
from time import sleep
import numpy as np
import cv2
import math
import serial
import threading

cap = cv2.VideoCapture(0)

conn = serial.Serial("/dev/ttyUSB0",115220,timeout=2)


print(cap.get(3))
print(cap.get(4))

face_cascade = cv2.CascadeClassifier('/usr/local/share/OpenCV/haarcascades/haarcascade_frontalcatface.xml')

#7 Trigonometria no triangulo retangulo
#profundidade e largura, medidas com trena. Agora eh regra de 3 para a largura
cam_depth  = 740.0

cam_width  = 640.0
cam_height = 480.0
#coincide na profundidade de 740mm!
px_width   = 640.0
px_height  = 480.0

#posicionamento fisico da mira eletronica em mm
cam_pos_x = 60
cam_pos_y = 75

#Fonte para exibir texto sobre o frame
font = cv2.FONT_HERSHEY_SIMPLEX

#se False, a thread não faz nada. Se houver face detection, vira True
bang        = False
#Mantem a thread rodando enquanto nao for pressionada a tecla 'q'
keepRunning = True

#guarda as coordenadas a enviar
x_pos2nano = 0.0
y_pos2nano = 0.0

#11 To sleep or not to sleep?
def FreeForFire():
    global bang
    print("ok")
    while keepRunning:
        if bang:
            fromInt = "+"
            fromInt += str(x_pos2nano)
            fromInt += ","
            fromInt += str(y_pos2nano)
            print("Serial: "+fromInt)
            conn.write(fromInt)
            sleep(12)
            bang = False
        print("bang...")
        sleep(1)

fff = threading.Thread(target=FreeForFire, args=[])
fff.start()

#8.5 Descobrir o angulo
# Como as faces nos eixos X ou Y sao variaveis, deve-se calcular a tangente toda a vez que um rosto for detectado
#Para ajustar a posicao da mira eletronica em relacao a webcam eh simples; basta subtrair a diferenca de posicao
#determinada nas constantes cam_pos_x e cam_pos_y. Isso incrementara (ou decrementara) o cateto dos eixos x e y antes
# de aplicar os calculos. Eh exatamente como se a camera estivesse na posicao da mira eletronica inves de sua posicao
#original
def getTan(axis_pos):
   quadrados_dos_catetos  = cam_depth**2 + axis_pos**2
   tan_size = math.sqrt(quadrados_dos_catetos)
   return tan_size

#Agora ja eh possivel descobrir o angulo, dividindo o cateto do observador pela hipotenusa e convertendo para graus
def degreesOnCamera(axis_pos):
    tan_size    = getTan(axis_pos)
    radians     = axis_pos/tan_size
    #Para pegar decimal do grau, passar parametro de casas decimais p/ round(). Ex:
    #angleToMove = round(math.degrees(math.asin(radians)),2)
    #Do jeito que esta, arredonda para grau inteiro.
    angleToMove = round(math.degrees(math.asin(radians)))
    return angleToMove

def strFromCoordinates(miraX,miraY):
    degX = degreesOnCamera(miraX/2)
    degY = -degreesOnCamera(miraY/5)
    print(degX)
    print(degY)
    global x_pos2nano
    x_pos2nano = str(degX)
    global y_pos2nano
    y_pos2nano = degY

while (cap.isOpened()):
    ret,frame = cap.read()
    gray = cv2.cvtColor(frame,cv2.COLOR_BGR2GRAY)
    faces = face_cascade.detectMultiScale(gray, scaleFactor=1.3, minNeighbors=3, flags=cv2.CASCADE_SCALE_IMAGE,minSize=(50,50), maxSize=None)


    if len(faces) > 0:
        for (x, y, w, h) in faces:
            cv2.rectangle(frame, (x - 10, y - 20), (x + w + 10, y + h + 10), (0, 255, 0), 2)
            #cv2.rectangle(frame, (x+w/2,y+h/5),(x+w/2+5,y+h/5+5), (0,0,255,0),2)

            cv2.putText(frame,str(x+w/2)+" (X),",(10,50), font, 1,(0,255,155),2,cv2.LINE_AA)
            cv2.putText(frame,str(y+h/5)+" (Y)",(150,50), font, 1,(0,255,155),2,cv2.LINE_AA)

            degX = degreesOnCamera(x+w-cam_pos_x/2)
            degY = degreesOnCamera(y+h-cam_pos_y/5)

            cv2.putText(frame,str(degX)+" graus X,",(10,100), font, 1,(0,255,155),2,cv2.LINE_AA)
            cv2.putText(frame,str(degY)+" graus Y",(260,100), font, 1,(0,255,155),2,cv2.LINE_AA)

            if not bang:
                #faz o que tem que fazer e assinala bang
                #apenas passa as coordenadas em pixels subtraindo a diferença
                #da distância da câmera até o pam
                strFromCoordinates(x+w-cam_pos_x,y+h-cam_pos_y)

                bang = True

            roi_gray = frame[y-15:y + h+10, x-10:x + w+10]

        cv2.imshow("Manual do Maker", frame)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        keepRunning = False
        break
    cv2.imshow("Manual do Maker", frame)

cap.release()
cv2.destroyAllWindows()

Tratamento de exceções

Algumas anomalias podem ocorrer. Por exemplo, a porta serial poderia não estar disponível, poderia se tornar irresponsível por alguma razão, o array de char que vai para o Arduino poderia conter algum caractere estranho etc. Esses tratamentos devem ser adicionados em projetos comerciais. Como se trata de um artigo, não fiz um monte de coisas porque é apenas uma prova de conceito - e ficou melhor do que eu esperava para uma apresentação.

mira_na_testa.webp

Vídeo

Fiz um vídeo mostrando o resultado logo ao início e então fiz algumas explicações sobre os recursos utilizados, espero que goste, deixe seu like, se inscreva no canal e ative o sininho de notificações, ok?

Nosso canal no Youtube.

O material para reproduzir o experimento você encontra em nossos parceiros dispostos no carrossel, ao início de cada página.

Inscreva-se no nosso canal Manual do Maker no YouTube.

Também estamos no Instagram.

Nome do Autor

Djames Suhanko

Autor do blog "Do bit Ao Byte / Manual do Maker".

Viciado em embarcados desde 2006.
LinuxUser 158.760, desde 1997.