ESP32-CAM Веб-сервер с OpenCV.js: Обнаружение и отслеживание цвета

Это руководство знакомит с OpenCV.js и инструментами OpenCV для среды веб-сервера камеры ESP32. В качестве примера мы создадим простой веб-сервер ESP32-CAM, который включает обнаружение цвета и отслеживание движущегося объекта.

ESP32-CAM Веб-сервер с OpenCV.js: Обнаружение и отслеживание цвета

Этот урок ни в коем случае не является исчерпывающим описанием всех возможностей OpenCV для веб-серверов камер ESP32. Ожидается, что это введение вдохновит на дальнейшую работу с OpenCV и камерами ESP32.

Этот проект/урок был создан на основе проекта Andrew R. Sass и отредактирован Сарой Сантос.

Введение

ESP32 может выступать в роли сервера для браузерного клиента, а некоторые модели включают камеру (например, ESP32-CAM), которая позволяет клиенту просматривать фотографии или видео в браузере. HTML, JavaScript и другие языки браузера могут использовать обширные возможности ESP32 и его камеры.

Для тех, у кого мало или совсем нет опыта работы с камерами ESP32, можно начать со следующего урока:

OpenCV.js

Как описано в docs.opencv.org, «OpenCV.js — это JavaScript-привязка для выбранного подмножества функций OpenCV для веб-платформы». OpenCV.js использует Emscripten, компилятор LLVM-to-JavaScript, для компиляции функций OpenCV в API-библиотеку, которая продолжает расти.

OpenCV.js

OpenCV.js работает в браузере, что позволяет быстро опробовать функции OpenCV, имея лишь скромный опыт в HTML и JavaScript. Те, кто имеет опыт работы с приложениями камеры ESP32, уже обладают таким опытом.

Обзор проекта

Проект, который мы создадим в этом уроке, представляет собой веб-сервер, который позволяет отслеживать цвет движущегося объекта. На интерфейсе веб-сервера вы можете настраивать различные параметры для правильного выбора цвета, который вы хотите отслеживать. Затем браузер отправляет координаты x и y центра масс движущегося объекта в реальном времени на плату ESP32.

ESP32-CAM Обнаружение цвета OpenCVJS Обзор проекта

Вот предварительный просмотр веб-сервера.

ESP32-CAM Отслеживание цвета Предварительный просмотр веб-сервера

Необходимые условия

Перед тем, как приступить к этому проекту, убедитесь, что вы выполнили следующие предварительные условия.

Arduino IDE

Мы будем программировать плату ESP32 с помощью Arduino IDE. Поэтому вам нужна установленная Arduino IDE, а также дополнение ESP32:

VS Code (опционально)

Если вы предпочитаете использовать VS Code + PlatformIO для программирования платы, вы можете следовать следующему уроку, чтобы узнать, как настроить VS Code для работы с платами ESP32.

Выбор камеры ESP32

Этот проект совместим с любой платой камеры ESP32, оснащённой камерой OV2640. Существует несколько моделей камер ESP32. Для сравнения наиболее популярных камер обратитесь к следующей статье:

Убедитесь, что вы знаете распиновку используемой платы камеры. Для распиновки наиболее популярных плат ознакомьтесь с этой статьёй:

Код — ESP32-CAM с OpenCV.js

Программа состоит из двух частей:

  • серверная программа, которая работает на камере ESP32

  • клиентская программа, которая работает в браузере Chrome

Программа разделена на два файла: файл OCV_ColorTrack_P.ino, содержащий серверную программу, и заголовочный файл index_OCV_ColorTrack.h, содержащий клиентскую программу (HTML, CSS и JavaScript с OpenCV.js).

Создайте новый скетч Arduino с именем OCV_ColorTrack_P и скопируйте следующий код.

/*********
  The include file, index_OCV_ColorTrack.h, the Client, is an intoduction of OpenCV.js to the ESP32 Camera environment. The Client was
  developed and written by Andrew R. Sass. Permission to reproduce the index_OCV_ColorTrack.h file is granted free of charge if this
  entire copyright notice is included in all copies of the index_OCV_ColorTrack.h file.

  Complete instructions at https://RandomNerdTutorials.com/esp32-cam-opencv-js-color-detection-tracking/

  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*********/

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "esp_camera.h"
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
#include "index_OCV_ColorTrack.h"

// Replace with your network credentials
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

String Feedback="";
String Command="",cmd="",P1="",P2="",P3="",P4="",P5="",P6="",P7="",P8="",P9="";
byte ReceiveState=0,cmdState=1,strState=1,questionstate=0,equalstate=0,semicolonstate=0;
//ANN:0
//       AI-Thinker
#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM      0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27
#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM        5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

WiFiServer server(80);
//ANN:2
void ExecuteCommand() {
  if (cmd!="colorDetect") {  //Omit printout
    //Serial.println("cmd= "+cmd+" ,P1= "+P1+" ,P2= "+P2+" ,P3= "+P3+" ,P4= "+P4+" ,P5= "+P5+" ,P6= "+P6+" ,P7= "+P7+" ,P8= "+P8+" ,P9= "+P9);
    //Serial.println("");
  }

  if (cmd=="resetwifi") {
    WiFi.begin(P1.c_str(), P2.c_str());
    Serial.print("Connecting to ");
    Serial.println(P1);
    long int StartTime=millis();
    while (WiFi.status() != WL_CONNECTED)
    {
        delay(500);
        if ((StartTime+5000) < millis()) break;
    }
    Serial.println("");
    Serial.println("STAIP: "+WiFi.localIP().toString());
    Feedback="STAIP: "+WiFi.localIP().toString();
  }
  else if (cmd=="restart") {
    ESP.restart();
  }
  else if (cmd=="cm"){
    int XcmVal = P1.toInt();
    int YcmVal = P2.toInt();
    Serial.println("cmd= "+cmd+" ,VALXCM= "+XcmVal);
    Serial.println("cmd= "+cmd+" ,VALYCM= "+YcmVal);
  }
  else if (cmd=="quality") {
    sensor_t * s = esp_camera_sensor_get();
    int val = P1.toInt();
    s->set_quality(s, val);
  }
  else if (cmd=="contrast") {
    sensor_t * s = esp_camera_sensor_get();
    int val = P1.toInt();
    s->set_contrast(s, val);
  }
  else if (cmd=="brightness") {
    sensor_t * s = esp_camera_sensor_get();
    int val = P1.toInt();
    s->set_brightness(s, val);
  }
  else {
    Feedback="Command is not defined.";
  }
  if (Feedback=="") {
    Feedback=Command;
  }
}

void setup() {
  WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);

  Serial.begin(115200);
  Serial.setDebugOutput(true);
  Serial.println();

  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;
  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;
  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;
  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;
  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  //init with high specs to pre-allocate larger buffers
  if(psramFound()){
    config.frame_size = FRAMESIZE_UXGA;
    config.jpeg_quality = 10;  //0-63 lower number means higher quality
    config.fb_count = 2;
  } else {
    config.frame_size = FRAMESIZE_SVGA;
    config.jpeg_quality = 12;  //0-63 lower number means higher quality
    config.fb_count = 1;
  }

  // camera init
  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed with error 0x%x", err);
    delay(1000);
    ESP.restart();
  }

  //drop down frame size for higher initial frame rate
  sensor_t * s = esp_camera_sensor_get();
  s->set_framesize(s, FRAMESIZE_CIF);  //UXGA|SXGA|XGA|SVGA|VGA|CIF|QVGA|HQVGA|QQVGA

  WiFi.mode(WIFI_AP_STA);
  WiFi.begin(ssid, password);

  delay(1000);

  long int StartTime=millis();
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    if ((StartTime+10000) < millis())
      break;
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("ESP IP Address: http://");
    Serial.println(WiFi.localIP());
  }
  server.begin();
}

void loop() {
  Feedback="";Command="";cmd="";P1="";P2="";P3="";P4="";P5="";P6="";P7="";P8="";P9="";
  ReceiveState=0,cmdState=1,strState=1,questionstate=0,equalstate=0,semicolonstate=0;

  WiFiClient client = server.available();

  if (client) {
    String currentLine = "";

    while (client.connected()) {
      if (client.available()) {
        char c = client.read();

        getCommand(c);

        if (c == '\n') {
          if (currentLine.length() == 0) {

            if (cmd=="colorDetect") {
              camera_fb_t * fb = NULL;
              fb = esp_camera_fb_get();
              if(!fb) {
                Serial.println("Camera capture failed");
                delay(1000);
                ESP.restart();
              }
              //ANN:1
              client.println("HTTP/1.1 200 OK");
              client.println("Access-Control-Allow-Origin: *");
              client.println("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept");
              client.println("Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS");
              client.println("Content-Type: image/jpeg");
              client.println("Content-Disposition: form-data; name=\"imageFile\"; filename=\"picture.jpg\"");
              client.println("Content-Length: " + String(fb->len));
              client.println("Connection: close");
              client.println();

              uint8_t *fbBuf = fb->buf;
              size_t fbLen = fb->len;
              for (size_t n=0;n<fbLen;n=n+1024) {
                if (n+1024<fbLen) {
                  client.write(fbBuf, 1024);
                  fbBuf += 1024;
                }
                else if (fbLen%1024>0) {
                  size_t remainder = fbLen%1024;
                  client.write(fbBuf, remainder);
                }
              }
              esp_camera_fb_return(fb);
            }
            else {
              //ANN:1
              client.println("HTTP/1.1 200 OK");
              client.println("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept");
              client.println("Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS");
              client.println("Content-Type: text/html; charset=utf-8");
              client.println("Access-Control-Allow-Origin: *");
              client.println("Connection: close");
              client.println();
              String Data="";
              if (cmd!="")
                Data = Feedback;
              else {
                Data = String((const char *)INDEX_HTML);
              }
              int Index;
              for (Index = 0; Index < Data.length(); Index = Index+1000) {
                client.print(Data.substring(Index, Index+1000));
              }
              client.println();
            }

            Feedback="";
            break;
          } else {
            currentLine = "";
          }
        }
        else if (c != '\r') {
          currentLine += c;
        }
        if ((currentLine.indexOf("/?")!=-1)&&(currentLine.indexOf(" HTTP")!=-1)) {
          if (Command.indexOf("stop")!=-1) {
            client.println();
            client.println();
            client.stop();
          }
          currentLine="";
          Feedback="";
          ExecuteCommand();
        }
      }
    }
    delay(1);
    client.stop();
  }
}

void getCommand(char c){
  if (c=='?') ReceiveState=1;
  if ((c==' ')||(c=='\r')||(c=='\n')) ReceiveState=0;

  if (ReceiveState==1) {
    Command=Command+String(c);
    if (c=='=') cmdState=0;
    if (c==';') strState++;
    if ((cmdState==1)&&((c!='?')||(questionstate==1))) cmd=cmd+String(c);
    if ((cmdState==0)&&(strState==1)&&((c!='=')||(equalstate==1))) P1=P1+String(c);
    if ((cmdState==0)&&(strState==2)&&(c!=';')) P2=P2+String(c);
    if ((cmdState==0)&&(strState==3)&&(c!=';')) P3=P3+String(c);
    if ((cmdState==0)&&(strState==4)&&(c!=';')) P4=P4+String(c);
    if ((cmdState==0)&&(strState==5)&&(c!=';')) P5=P5+String(c);
    if ((cmdState==0)&&(strState==6)&&(c!=';')) P6=P6+String(c);
    if ((cmdState==0)&&(strState==7)&&(c!=';')) P7=P7+String(c);
    if ((cmdState==0)&&(strState==8)&&(c!=';')) P8=P8+String(c);
    if ((cmdState==0)&&(strState>=9)&&((c!=';')||(semicolonstate==1))) P9=P9+String(c);
    if (c=='?') questionstate=1;
    if (c=='=') equalstate=1;
    if ((strState>=9)&&(c==';')) semicolonstate=1;
  }
}

Посмотреть исходный код

Сохраните этот файл.

index_OCV_ColorTrack.h

Затем откройте новую вкладку в Arduino IDE, как показано на следующем изображении.

Arduino IDE Создание новой вкладки

Назовите её index_OCV_ColorTrack.h.

Arduino IDE Имя новой вкладки файла

Скопируйте следующий код в этот файл.

/****************************
  This include file, index_OCV_ColorTrack.h, the Client, is an intoduction of OpenCV.js to the ESP32 Camera environment. The Client was
  developed and written by Andrew R. Sass. Permission to reproduce the index_OCV_ColorTrack.h file is granted free of charge if this
  entire copyright notice is included in all copies of the index_OCV_ColorTrack.h file.

  Complete instructions at https://RandomNerdTutorials.com/esp32-cam-opencv-js-color-detection-tracking/
*******************************/
static const char PROGMEM INDEX_HTML[] = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
   <title>ESP32-CAMERA COLOR DETECTION</title>
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width,initial-scale=1">
   <!----ANN:3--->
   <script async src=" https://docs.opencv.org/master/opencv.js" type="text/javascript"></script>
</head>
<style>
html {
    font-family: Arial, Helvetica, sans-serif;
    }
body {
    background-color: #F7F7F2;
    margin: 0px;
}
h1 {
    font-size: 1.6rem;
    color:white;
    text-align: center;
}
.topnav {
    overflow: hidden;
    background-color: #0A1128;
}
.main-controls{
  padding-top: 5px;
}
h2 {
    color: #0A1128;
    font-size: 1rem;
}
.section {
    margin: 2px;
    padding: 10px;
}
.column{
    float: left;
    width: 50%
}
table {
    margin: 0;
    width: 90%;
    border-collapse: collapse;
}
th{
    text-align: center;
}
.row{
    margin-right:50px;
    margin-left:50px;
}

#colorDetect{
    border: none;
    color: #FEFCFB;
    background-color: #0A1128;
    padding: 15px;
    text-align: center;
    display: inline-block;
    font-size: 16px;
    border-radius: 4px;
}
#restart{
    border: none;
    color: #FEFCFB;
    background-color: #7B0828;
    padding: 15px;
    text-align: center;
    display: inline-block;
    font-size: 16px;
    border-radius: 4px;
}
button{
    border: none;
    color: #FEFCFB;
    background-color: #0A1128;
    padding: 10px;
    text-align: center;
    display: inline-block;
    border-radius: 4px;
}

</style>
<body>
    <div class="topnav">
        <h1>ESP32-CAM Color Detection and Tracking</h1>
    </div>
    <div class="main-controls">
        <table>
            <tr>
                <td><center><input type="button" id="colorDetect" value="COLOR DETECTION"></center></td>
                <td><center><input type="button" id="restart" value="RESET BOARD"></center></td>
            </tr>
        </table>
    </div>
<div class="container">
  <div class = "row">
    <div class = "column">
        <div class="section">
            <div class ="video-container">
                <h2>Video Streaming</h2>
                <center><img id="ShowImage" src="" style="display:none"></center>
                <center><canvas id="canvas" style="display:none"></canvas></center>
            </div>
        </div>
        <div class="section">
            <table>
              <tr>
                  <td>Quality</td>
                  <td><input type="range" id="quality" min="10" max="63" value="10"></td>
              </tr>
              <tr>
                  <td>Brightness</td>
                  <td><input type="range" id="brightness" min="-2" max="2" value="0"></td>
              </tr>
              <tr>
                  <td>Contrast</td>
                  <td><input type="range" id="contrast" min="-2" max="2" value="0"></td>
              </tr>
            </table>
        </div>

      <!-----ANN:5---->
      <div class="section">
        <h2>RGB Color Trackbars</h2>
        <table>
            <tr>
                <td>R min:&#160;&#160;&#160;<span id="RMINdemo"></span></td>
                <td><input type="range" id="rmin" min="0" max="255" value="0" class = "slider"></td>
                <td>R max:&#160;&#160;&#160;<span id="RMAXdemo"></span></td>
                <td><input type="range" id="rmax" min="0" max="255" value="50" class = "slider"></td>
            </tr>
            <tr>
                <td>G min:&#160;&#160;&#160;<span id="GMINdemo"></span></td>
                <td><input type="range" id="gmin" min="0" max="255" value="0" class = "slider"></td>
                <td>G max:&#160;&#160;&#160;<span id="GMAXdemo"></span></td>
                <td><input type="range" id="gmax" min="0" max="255" value="50" class = "slider"></td>
            </tr>
            <tr>
                <td>B min:&#160;&#160;&#160;<span id ="BMINdemo"></span></td>
                <td><input type="range" id="bmin" min="0" max="255" value="0" class = "slider">  </td>
                <td>B max:&#160;&#160;&#160;<span id="BMAXdemo"></span></td>
                <td> <input type="range" id="bmax" min="0" max="255" value="50" class = "slider">   </td>
            </tr>
        </table>
      </div>

      <div class="section">
        <h2>Threshold Minimum-Binary Image</h2>
        <table>
            <tr>
                <td>Minimum Threshold:&#160;&#160;&#160;<span id="THRESH_MINdemo"></span></td>
                <td><input type="range" id="thresh_min" min="0" max="255" value="120" class = "slider">  </td>
            </tr>
        </table>
    </div>
     <!----ANN:9--->
     <div class="section">
        <h2>Color Probe</h2>
        <table>
            <tr>
                <td>X probe:&#160;&#160;&#160;<span id="X_PROBEdemo"></span></td>
                <td><input type="range" id="x_probe" min="0" max="400" value="200" class = "slider"></td>
                <td>Y probe:&#160;&#160;&#160;<span id="Y_PROBEdemo"></span></td>
                <td> <input type="range" id="y_probe" min="0" max="296" value="148" class = "slider"></td>
            </tr>
        </table>
      </div>

    </div>

    <div class = "column">
        <div class="section">
            <h2>Image Mask</h2>
            <canvas id="imageMask"></canvas>
        </div>
        <div class="section">
            <h2>Image Canvas</h2>
            <canvas id="imageCanvas"></canvas>
        </div>
        <div class="section">
            <table>
                <tr>
                    <td><button type="button" id="invertButton" class="btn btn-primary">INVERT</button></td>
                    <td><button type="button" id="contourButton" class="btn btn-primary">SHOW CONTOUR</button></td>
                    <td><button type="button" id="trackButton" class="btn btn-primary">TRACKING</button></td>
                </tr>
                <tr>
                    <td>Invert: <span id="INVERTdemo"></span></td>
                    <td>Contour: <span id="CONTOURdemo"></span></td>
                    <td>Track: <span id="TRACKdemo"></span>
                    </td>
                </tr>
            </table>
        </div>
        <div class="section">
            <table>
                <tr>
                    <td><strong>XCM:</strong> <span id="XCMdemo"></span></td>
                    <td><strong>YCM:</strong> <span id="YCMdemo"></span></td>
                </tr>
            </table>
        </div>
        <div class="section">
            <canvas id="textCanvas" width="480" height="180" style= "border: 1px solid #black;"></canvas>
            <iframe id="ifr" style="display:none"></iframe>
            <div id="message"></div>
        </div>
        </div>
  </div>
</div>
<div class="modal"></div>
<script>
var colorDetect = document.getElementById('colorDetect');
var ShowImage = document.getElementById('ShowImage');
var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
var imageMask = document.getElementById("imageMask");
var imageMaskContext = imageMask.getContext("2d");
var imageCanvas = document.getElementById("imageCanvas");
var imageContext = imageCanvas.getContext("2d");
var txtcanvas = document.getElementById("textCanvas");
var ctx = txtcanvas.getContext("2d");
var message = document.getElementById('message');
var ifr = document.getElementById('ifr');
var myTimer;
var restartCount=0;
const modelPath = 'https://ruisantosdotme.github.io/face-api.js/weights/';
let currentStream;
let displaySize = { width:400, height: 296 }
let faceDetection;

let b_tracker = false;
let x_cm = 0;
let y_cm = 0;

let b_invert = false;

let b_contour = false;

var RMAX=50;
var RMIN=0;
var GMAX=50;
var GMIN=0;
var BMAX=50;
var BMIN=0;
var THRESH_MIN=120;
var X_PROBE=200;
var Y_PROBE=196;
var R=0;
var G=0;
var B=0;
var A=0;

colorDetect.onclick = function (event) {
  clearInterval(myTimer);
  myTimer = setInterval(function(){error_handle();},5000);
  ShowImage.src=location.origin+'/?colorDetect='+Math.random();
}

var Module = {
  onRuntimeInitialized(){onOpenCvReady();}
}

function onOpenCvReady(){
  console.log("OpenCV IS READY!!!");
  drawReadyText();
  document.body.classList.remove("loading");
}


function error_handle() {
  restartCount++;
  clearInterval(myTimer);
  if (restartCount<=2) {
    message.textContent = "Get still error. Restart ESP32-CAM "+restartCount+" times.";
    myTimer = setInterval(function(){colorDetect.click();},10000);
    ifr.src = document.location.origin+'?restart';
  }
  else
    message.textContent = "Get still error. Please close the page and check ESP32-CAM.";
}
colorDetect.style.display = "block";
ShowImage.onload = function (event) {
  console.log("SHOW iMAGE");
  clearInterval(myTimer);
  restartCount=0;
  canvas.setAttribute("width", ShowImage.width);
  canvas.setAttribute("height", ShowImage.height);
  canvas.style.display = "block";
  imageCanvas.setAttribute("width", ShowImage.width);
  imageCanvas.setAttribute("height", ShowImage.height);
  imageCanvas.style.display = "block";

  imageMask.setAttribute("width", ShowImage.width);
  imageMask.setAttribute("height", ShowImage.height);
  imageMask.style.display = "block";

  context.drawImage(ShowImage,0,0,ShowImage.width,ShowImage.height);

  DetectImage();
}
restart.onclick = function (event) {
  fetch(location.origin+'/?restart=stop');
}
quality.onclick = function (event) {
  fetch(document.location.origin+'/?quality='+this.value+';stop');
}
brightness.onclick = function (event) {
  fetch(document.location.origin+'/?brightness='+this.value+';stop');
}
contrast.onclick = function (event) {
  fetch(document.location.origin+'/?contrast='+this.value+';stop');
}
async function DetectImage() {
  console.log("DETECT IMAGE");

  let src = cv.imread(ShowImage);
  arows = src.rows;
  acols = src.cols;
  aarea = arows*acols;
  adepth = src.depth();
  atype = src.type();
  achannels = src.channels();
  console.log("rows = " + arows);
  console.log("cols = " + acols);
  console.log("pic area = " + aarea);
  console.log("depth = " + adepth);
  console.log("type = " + atype);
  console.log("channels = " + achannels);

  var RMAXslider = document.getElementById("rmax");
  var RMAXoutput = document.getElementById("RMAXdemo");
  RMAXoutput.textContent = RMAXslider.value;
  RMAXslider.oninput = function(){
  RMAXoutput.textContent = this.value;
  RMAX = parseInt(RMAXoutput.textContent,10);
  console.log("RMAX=" + RMAX);
  }

  console.log("RMAX=" + RMAX);

  var RMINslider = document.getElementById("rmin");
  var RMINoutput = document.getElementById("RMINdemo");
  RMINoutput.textContent = RMINslider.value;
  RMINslider.oninput = function(){
    RMINoutput.textContent = this.value;
    RMIN = parseInt(RMINoutput.textContent,10);
    console.log("RMIN=" + RMIN);
  }
  console.log("RMIN=" + RMIN);

  var GMAXslider = document.getElementById("gmax");
  var GMAXoutput = document.getElementById("GMAXdemo");
  GMAXoutput.textContent = GMAXslider.value;
  GMAXslider.oninput = function(){
    GMAXoutput.textContent = this.value;
    GMAX = parseInt(GMAXoutput.textContent,10);
  }
  console.log("GMAX=" + GMAX);

  var GMINslider = document.getElementById("gmin");
  var GMINoutput = document.getElementById("GMINdemo");
  GMINoutput.textContent = GMINslider.value;
  GMINslider.oninput = function(){
    GMINoutput.textContent = this.value;
    GMIN = parseInt(GMINoutput.textContent,10);
  }
  console.log("GMIN=" + GMIN);

  var BMAXslider = document.getElementById("bmax");
  var BMAXoutput = document.getElementById("BMAXdemo");
  BMAXoutput.textContent = BMAXslider.value;
  BMAXslider.oninput = function(){
    BMAXoutput.textContent = this.value;
    BMAX = parseInt(BMAXoutput.textContent,10);
  }
  console.log("BMAX=" + BMAX);

  var BMINslider = document.getElementById("bmin");
  var BMINoutput = document.getElementById("BMINdemo");
  BMINoutput.textContent = BMINslider.value;
  BMINslider.oninput = function(){
  BMINoutput.textContent = this.value;
  BMIN = parseInt(BMINoutput.textContent,10);
  }
  console.log("BMIN=" + BMIN);

  var THRESH_MINslider = document.getElementById("thresh_min");
  var THRESH_MINoutput = document.getElementById("THRESH_MINdemo");
  THRESH_MINoutput.textContent = THRESH_MINslider.value;
  THRESH_MINslider.oninput = function(){
  THRESH_MINoutput.textContent = this.value;
  THRESH_MIN = parseInt(THRESH_MINoutput.textContent,10);
  }
  console.log("THRESHOLD MIN=" + THRESH_MIN);

  var X_PROBEslider = document.getElementById("x_probe");
  var X_PROBEoutput = document.getElementById("X_PROBEdemo");
  X_PROBEoutput.textContent = X_PROBEslider.value;
  X_PROBEslider.oninput = function(){
  X_PROBEoutput.textContent = this.value;
  X_PROBE = parseInt(X_PROBEoutput.textContent,10);
  }
  console.log("X_PROBE=" + X_PROBE);

  var Y_PROBEslider = document.getElementById("y_probe");
  var Y_PROBEoutput = document.getElementById("Y_PROBEdemo");
  Y_PROBEoutput.textContent = Y_PROBEslider.value;
  Y_PROBEslider.oninput = function(){
  Y_PROBEoutput.textContent = this.value;
  Y_PROBE = parseInt(Y_PROBEoutput.textContent,10);
  }
  console.log("Y_PROBE=" + Y_PROBE);

  document.getElementById('trackButton').onclick = function(){
    b_tracker = (true && !b_tracker)
    console.log("TRACKER = " + b_tracker );
    var TRACKoutput = document.getElementById("TRACKdemo");
    TRACKoutput.textContent = b_tracker;
  }

  document.getElementById('invertButton').onclick = function(){
    b_invert = (true && !b_invert)
    console.log("TRACKER = " + b_invert );
    var INVERToutput = document.getElementById("INVERTdemo");
    INVERToutput.textContent = b_invert;
  }

  document.getElementById('contourButton').onclick = function(){
    b_contour = (true && !b_contour)
    console.log("TRACKER = " + b_contour );
    var CONTOURoutput = document.getElementById("CONTOURdemo");
    CONTOURoutput.textContent = b_contour;
  }

  let tracker = 0;

  var TRACKoutput = document.getElementById("TRACKdemo");
  TRACKoutput.textContent = b_tracker;
  var XCMoutput = document.getElementById("XCMdemo");
  var YCMoutput = document.getElementById("YCMdemo");

  XCMoutput.textContent = 0;
  YCMoutput.textContent = 0;

  var INVERToutput = document.getElementById("INVERTdemo");
  INVERToutput.textContent = b_invert;

  var CONTOURoutput = document.getElementById("CONTOURdemo");
  CONTOURoutput.textContent = b_contour;

  let M00Array = [0,];
  let orig = new cv.Mat();
  let mask = new cv.Mat();
  let mask1 = new cv.Mat();
  let mask2 = new cv.Mat();
  let contours = new cv.MatVector();
  let hierarchy = new cv.Mat();
  let rgbaPlanes = new cv.MatVector();

  let color = new cv.Scalar(0,0,0);

  clear_canvas();


  orig = cv.imread(ShowImage);
  cv.split(orig,rgbaPlanes);
  let BP = rgbaPlanes.get(2);
  let GP = rgbaPlanes.get(1);
  let RP = rgbaPlanes.get(0);
  cv.merge(rgbaPlanes,orig);


  let row = Y_PROBE
  let col = X_PROBE
  drawColRowText(acols,arows);

  console.log("ISCONTINUOUS = " + orig.isContinuous());

  R = src.data[row * src.cols * src.channels() + col * src.channels()];
  G = src.data[row * src.cols * src.channels() + col * src.channels() + 1];
  B = src.data[row * src.cols * src.channels() + col * src.channels() + 2];
  A = src.data[row * src.cols * src.channels() + col * src.channels() + 3];
  console.log("RDATA = " + R);
  console.log("GDATA = " + G);
  console.log("BDATA = " + B);
  console.log("ADATA = " + A);

  drawRGB_PROBE_Text();

  let point4 = new cv.Point(col,row);
  cv.circle(src,point4,5,[255,255,255,255],2,cv.LINE_AA,0);

  let high = new cv.Mat(src.rows,src.cols,src.type(),[RMAX,GMAX,BMAX,255]);
  let low = new cv.Mat(src.rows,src.cols,src.type(),[RMIN,GMIN,BMIN,0]);

  cv.inRange(src,low,high,mask1);

  cv.threshold(mask1,mask,THRESH_MIN,255,cv.THRESH_BINARY);

  if(b_invert==true){
     cv.bitwise_not(mask,mask2);
  }

  if(b_tracker == true){
  try{
   if(b_invert==false){
    cv.findContours(mask,contours,hierarchy,cv.RETR_CCOMP,cv.CHAIN_APPROX_SIMPLE);
   }
   else{
    cv.findContours(mask2,contours,hierarchy,cv.RETR_CCOMP,cv.CHAIN_APPROX_SIMPLE);
   }
    console.log("CONTOUR_SIZE = " + contours.size());

    if(b_contour==true){
     for(let i = 0; i < contours.size(); i++){
        cv.drawContours(src,contours,i,[0,0,0,255],2,cv.LINE_8,hierarchy,100)
     }
    }

    let cnt;
    let Moments;
    let M00;
    let M10;

    for(let k = 0; k < contours.size(); k++){
        cnt = contours.get(k);
        Moments = cv.moments(cnt,false);
        M00Array[k] = Moments.m00;
    }

    let max_area_arg = MaxAreaArg(M00Array);
    console.log("MAXAREAARG = "+max_area_arg);

    let ArgMaxArea = MaxAreaArg(M00Array);
    if(ArgMaxArea >= 0){
    cnt = contours.get(MaxAreaArg(M00Array));
    Moments = cv.moments(cnt,false);
    M00 = Moments.m00;
    M10 = Moments.m10;
    M01 = Moments.m01;
    x_cm = M10/M00;
    y_cm = M01/M00;

    XCMoutput.textContent = Math.round(x_cm);
    YCMoutput.textContent = Math.round(y_cm);

    console.log("M00 = "+M00);
    console.log("XCM = "+Math.round(x_cm));
    console.log("YCM = "+Math.round(y_cm));

    fetch(document.location.origin+'/?cm='+Math.round(x_cm)+';'+Math.round(y_cm)+';stop');

    console.log("M00ARRAY = " + M00Array);

    let rect = cv.boundingRect(cnt);
    let point1 = new cv.Point(rect.x,rect.y);
    let point2 = new cv.Point(rect.x+rect.width,rect.y+rect.height);

    cv.rectangle(src,point1,point2,[0,0,255,255],2,cv.LINE_AA,0);

    let point3 = new cv.Point(x_cm,y_cm);
    cv.circle(src,point3,2,[0,0,255,255],2,cv.LINE_AA,0);

    }
    else{
      if(ArgMaxArea==-1){
        console.log("ZERO ARRAY LENGTH");
      }
      else{
        console.log("DUPLICATE MAX ARRAY-ELEMENT");
      }
    }

    cnt.delete();
   drawXCM_YCM_Text();

  }
  catch{
    console.log("ERROR TRACKER NO CONTOUR");
    clear_canvas();
    drawErrorTracking_Text();
  }

  }
  else{
      XCMoutput.textContent = 0;
      YCMoutput.textContent = 0;
  }

  if(b_invert==false){
     cv.imshow('imageMask', mask);
  }
  else{
     cv.imshow('imageMask', mask2);
  }
  cv.imshow('imageCanvas', src);

  src.delete();
  high.delete();
  low.delete();
  orig.delete();
  mask1.delete();
  mask2.delete();
  mask.delete();
  contours.delete();
  hierarchy.delete();
  RP.delete();

 setTimeout(function(){colorDetect.click();},500);

}

function MaxAreaArg(arr){
    if (arr.length == 0) {
        return -1;
    }

    var max = arr[0];
    var maxIndex = 0;
    var dupIndexCount = 0;

    if(arr[0] >= .90*aarea){
        max = 0;
    }

    for (var i = 1; i < arr.length; i++) {
        if (arr[i] > max && arr[i] < .99*aarea) {
            maxIndex = i;
            max = arr[i];
            dupIndexCount = 0;
        }
        else if(arr[i]==max && arr[i]!=0){
            dupIndexCount++;
        }
    }

    if(dupIndexCount==0){
        return maxIndex;
    }

    else{
        return -2;
    }
}

function clear_canvas(){
    ctx.clearRect(0,0,txtcanvas.width,txtcanvas.height);
    ctx.rect(0,0,txtcanvas.width,txtcanvas.height);
    ctx.fillStyle="red";
    ctx.fill();
}

function drawReadyText(){
    ctx.fillStyle = 'black';
    ctx.font = '20px serif';
    ctx.fillText('OpenCV.JS READY',txtcanvas.width/4,txtcanvas.height/10);
}

function drawColRowText(x,y){
    ctx.fillStyle = 'black';
    ctx.font = '20px serif';
    ctx.fillText('ImageCols='+x,0,txtcanvas.height/10);
    ctx.fillText('ImageRows='+y,txtcanvas.width/2,txtcanvas.height/10);
}

function drawRGB_PROBE_Text(){
    ctx.fillStyle = 'black';
    ctx.font = '20px serif';
    ctx.fillText('Rp='+R,0,2*txtcanvas.height/10);
    ctx.fillText('Gp='+G,txtcanvas.width/4,2*txtcanvas.height/10);
    ctx.fillText('Bp='+B,txtcanvas.width/2,2*txtcanvas.height/10);
    ctx.fillText('Ap='+A,3*txtcanvas.width/4,2*txtcanvas.height/10);
}

function drawXCM_YCM_Text(){
    ctx.fillStyle = 'black';
    ctx.font = '20px serif';
    ctx.fillText('XCM='+Math.round(x_cm),0,3*txtcanvas.height/10);
    ctx.fillText('YCM='+Math.round(y_cm),txtcanvas.width/4,3*txtcanvas.height/10);
}

function drawErrorTracking_Text(){
    ctx.fillStyle = 'black';
    ctx.font = '20px serif';
    ctx.fillText('ERROR TRACKING-NO CONTOUR',0,3*txtcanvas.height/10);
}

  </script>
</body>
</html>
)rawliteral";

Посмотреть исходный код

Сохраните файл.

Сетевые учётные данные

Для правильной работы программы необходимо вставить свои сетевые учётные данные в следующие переменные в файле OCV_ColorTrack_P.ino:

const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

Распиновка камеры

По умолчанию код использует распиновку для модуля ESP32-CAM AI-Thinker.

#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM      0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27
#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM        5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

Если вы используете другую плату камеры, не забудьте вставить правильную распиновку. Вы можете перейти к следующей статье, чтобы найти распиновку для вашей платы:

Как работает код

Продолжайте читать, чтобы узнать, как работает код, или переходите к следующему разделу.

Для удобства чтения и понимания программы в код были добавлены комментарии — АННОТАЦИИ (ANNOTATIONS).

Например, распиновка для ESP32-CAM указана под аннотацией ANN:0, расположенной в файле .ino. ANN:0 можно найти с помощью команды Edit/Find в Arduino IDE.

#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM      0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27
#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM        5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

Серверный скетч

Серверная программа OCV_ColorTrack.ino взята из ESP32-CAM Projects, Модуль 5 авторства Руи Сантоса и Сары Сантос. Она содержит стандартную функцию setup() для камеры ESP32, которая настраивает камеру, IP-адрес сервера и пароль.

Аннотация 1 (ANN:1)

Однако, что не является стандартным в этой серверной программе — это инструкции, имеющие жизненно важное значение, которые разрешают контроль доступа (Access-Control). Смотрите код в ANN:1.

//ANN:1
client.println("HTTP/1.1 200 OK");
client.println("Access-Control-Allow-Origin: *");
client.println("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept");
client.println("Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS");
client.println("Content-Type: image/jpeg");
client.println("Content-Disposition: form-data; name=\"imageFile\"; filename=\"picture.jpg\"");
client.println("Content-Length: " + String(fb->len));
client.println("Connection: close");
client.println();

Это инструктирует браузер разрешить совместную работу изображения камеры и OpenCV.js, которые имеют разные источники, в программе. Без этих инструкций браузер Chrome выдаёт ошибки.

Аннотация 2 (ANN:2)

Серверный цикл loop() отслеживает сообщения клиента и декодирует их через ExecuteCommand(), который находится в ANN:2.

//ANN:2
void ExecuteCommand() {
  if (cmd!="colorDetect") {  //Omit printout
    //Serial.println("cmd= "+cmd+" ,P1= "+P1+" ,P2= "+P2+" ,P3= "+P3+" ,P4= "+P4+" ,P5= "+P5+" ,P6= "+P6+" ,P7= "+P7+" ,P8= "+P8+" ,P9= "+P9);
    //Serial.println("");
  }

  if (cmd=="resetwifi") {
    WiFi.begin(P1.c_str(), P2.c_str());
    Serial.print("Connecting to ");
    Serial.println(P1);
    long int StartTime=millis();
    while (WiFi.status() != WL_CONNECTED)
    {
        delay(500);
        if ((StartTime+5000) < millis()) break;
    }
    Serial.println("");
    Serial.println("STAIP: "+WiFi.localIP().toString());
    Feedback="STAIP: "+WiFi.localIP().toString();
  }
  else if (cmd=="restart") {
    ESP.restart();
  }
  else if (cmd=="cm"){
    int XcmVal = P1.toInt();
    int YcmVal = P2.toInt();
    Serial.println("cmd= "+cmd+" ,VALXCM= "+XcmVal);
    Serial.println("cmd= "+cmd+" ,VALYCM= "+YcmVal);
  }
  else if (cmd=="quality") {
    sensor_t * s = esp_camera_sensor_get();
    int val = P1.toInt();
    s->set_quality(s, val);
  }
  else if (cmd=="contrast") {
    sensor_t * s = esp_camera_sensor_get();
    int val = P1.toInt();
    s->set_contrast(s, val);
  }
  else if (cmd=="brightness") {
    sensor_t * s = esp_camera_sensor_get();
    int val = P1.toInt();
    s->set_brightness(s, val);
  }
  else {
    Feedback="Command is not defined.";
  }
  if (Feedback=="") {
    Feedback=Command;
  }
}

Исходная программа использует эту функцию для приёма и выполнения команд от ползунков в клиенте, которые управляют характеристиками изображения и передаются клиентом через инструкцию «fetch».

В нашей текущей программе эта функция, описание которой будет дано далее, используется для передачи «центра масс» цветовой цели, обнаруженной клиентом, на сервер ESP32 — функция, жизненно важная для робототехнических приложений.

Кроме изменения, связанного с извлечением координат x и y центра масс и их выводом, в серверной программе нет других изменений.

Клиентский скетч (OpenCV.js)

Помимо подпрограмм ползунков характеристик изображения и их передачи данных на сервер через «fetch», а также подпрограммы обработки ошибок в исходной клиентской программе электронной книги, упомянутой выше, клиентская программа здесь является новой и содержит код, посвящённый применению OpenCV.js к изображению камеры ESP32, передаваемому в браузер (как упоминалось ранее, «fetch» используется для передачи данных о цветовой цели на сервер).

Клиентский код обильно снабжён инструкциями console.log, которые позволяют пользователю видеть результаты работы кода. Консоль Chrome console.log открывается нажатием CTRL + SHIFT + J одновременно.

Аннотация 3 (ANN:3)

ANN:3 подключает последнюю версию OpenCV.js в нашу веб-страницу. Нажмите здесь, чтобы узнать больше.

<script async src=" https://docs.opencv.org/master/opencv.js" type="text/javascript"></script>

ANN:READY

ANN:READY отмечает модуль, который сигнализирует о том, что OpenCV.js инициализирован. После завершения инициализации можно нажать кнопку Color Detection. Хотя более быстрые компьютеры не требуют этой возможности, она включена для полноты.

OpenCVJS готов ESP32-CAM Веб-сервер

Аннотация 4 (ANN:4)

Скриншот клиентской программы, работающей в Chrome, показывает две колонки, созданные HTML-частью кода. В левой колонке отображается исходное изображение камеры, которое передаётся примерно с частотой 1 кадр в секунду. Это изображение с идентификатором ShowImage является исходным изображением для подпрограммы OpenCV в программе.

ANN:4 отмечает создание src и его характеристик: rows, cols и т.д.

//ANN:4
let src = cv.imread(ShowImage);
arows = src.rows;
acols = src.cols;
aarea = arows*acols;
adepth = src.depth();
atype = src.type();
achannels = src.channels();
console.log("rows = " + arows);
console.log("cols = " + acols);
console.log("pic area = " + aarea);
console.log("depth = " + adepth);
console.log("type = " + atype);
console.log("channels = " + achannels);

Ползунки RGB Color Trackbars

Под исходным изображением находятся три оригинальных ползунка характеристик изображения (Quality, Brightness и Contrast), а также RGB Color Trackbars.

ESP32-CAM Веб-сервер Отслеживание цвета RGB ползунки OpenCVJS

Они используются для установки пределов диапазона цветов, допустимых в «обработанном» изображении в CV-приложении. Код для ползунков находится в ANN:5, ANN:6.

<!-----ANN:5---->
<div class="section">
<h2>RGB Color Trackbars</h2>
<table>
  <tr>
    <td>R min:&#160;&#160;&#160;<span id="RMINdemo"></span></td>
    <td><input type="range" id="rmin" min="0" max="255" value="0" class = "slider"></td>
    <td>R max:&#160;&#160;&#160;<span id="RMAXdemo"></span></td>
    <td><input type="range" id="rmax" min="0" max="255" value="50" class = "slider"></td>
  </tr>
  <tr>
    <td>G min:&#160;&#160;&#160;<span id="GMINdemo"></span></td>
    <td><input type="range" id="gmin" min="0" max="255" value="0" class = "slider"></td>
    <td>G max:&#160;&#160;&#160;<span id="GMAXdemo"></span></td>
    <td><input type="range" id="gmax" min="0" max="255" value="50" class = "slider"></td>
  </tr>
  <tr>
    <td>B min:&#160;&#160;&#160;<span id ="BMINdemo"></span></td>
    <td><input type="range" id="bmin" min="0" max="255" value="0" class = "slider"></td>
    <td>B max:<span id="BMAXdemo"></span></td>
    <td> <input type="range" id="bmax" min="0" max="255" value="50" class = "slider"></td>
  </tr>
</table>
</div>
//ANN:6
var RMAXslider = document.getElementById("rmax");
var RMAXoutput = document.getElementById("RMAXdemo");
RMAXoutput.textContent = RMAXslider.value;
RMAXslider.oninput = function() {
  RMAXoutput.textContent = this.value;
  RMAX = parseInt(RMAXoutput.textContent,10);
  console.log("RMAX=" + RMAX);
}
console.log("RMAX=" + RMAX);

var RMINslider = document.getElementById("rmin");
var RMINoutput = document.getElementById("RMINdemo");
RMINoutput.textContent = RMINslider.value;
RMINslider.oninput = function(){
RMINoutput.textContent = this.value;
  RMIN = parseInt(RMINoutput.textContent,10);
  console.log("RMIN=" + RMIN);
}
console.log("RMIN=" + RMIN);

var GMAXslider = document.getElementById("gmax");
var GMAXoutput = document.getElementById("GMAXdemo");
GMAXoutput.textContent = GMAXslider.value;
GMAXslider.oninput = function(){
  GMAXoutput.textContent = this.value;
  GMAX = parseInt(GMAXoutput.textContent,10);
}
console.log("GMAX=" + GMAX);

var GMINslider = document.getElementById("gmin");
var GMINoutput = document.getElementById("GMINdemo");
GMINoutput.textContent = GMINslider.value;
GMINslider.oninput = function(){
  GMINoutput.textContent = this.value;
  GMIN = parseInt(GMINoutput.textContent,10);
}
console.log("GMIN=" + GMIN);

var BMAXslider = document.getElementById("bmax");
var BMAXoutput = document.getElementById("BMAXdemo");
BMAXoutput.textContent = BMAXslider.value;
BMAXslider.oninput = function(){
  BMAXoutput.textContent = this.value;
  BMAX = parseInt(BMAXoutput.textContent,10);
}
console.log("BMAX=" + BMAX);

var BMINslider = document.getElementById("bmin");
var BMINoutput = document.getElementById("BMINdemo");
BMINoutput.textContent = BMINslider.value;
BMINslider.oninput = function(){
  BMINoutput.textContent = this.value;
  BMIN = parseInt(BMINoutput.textContent,10);
}
console.log("BMIN=" + BMIN);

Максимальные и минимальные значения красного, зелёного и синего (RGB) применяются к функции OpenCV inRange() в ANN:7.

let high = new cv.Mat(src.rows,src.cols,src.type(),[RMAX,GMAX,BMAX,255]);
let low = new cv.Mat(src.rows,src.cols,src.type(),[RMIN,GMIN,BMIN,0]);

cv.inRange(src,low,high,mask1);
//inRange(source image, lower limit, higher limit, destination image)

cv.threshold(mask1,mask,THRESH_MIN,255,cv.THRESH_BINARY);
//threshold(source image,destination image,threshold,255,threshold method);

Изображение является 4-канальным: RGBA, где A — уровень прозрачности. В этом уроке A будет установлен на 100% непрозрачности, то есть 255. Код основан на том, что, помимо плоскости A, изображение имеет 3 цветовые плоскости RGB, каждый пиксель в каждой плоскости имеет значение от 0 до 255. Верхние/нижние пределы применяются к соответствующим цветовым плоскостям для каждого пикселя.

Обратите внимание, что inRange() имеет выходное изображение, которое было создано ранее в программе (ANN:8).

let M00Array = [0,];
let orig = new cv.Mat();
let mask = new cv.Mat();
let mask1 = new cv.Mat();
let mask2 = new cv.Mat();
let contours = new cv.MatVector();
let hierarchy = new cv.Mat();
let rgbaPlanes = new cv.MatVector();

let color = new cv.Scalar(0,0,0);

clear_canvas();

orig = cv.imread(ShowImage);
cv.split(orig,rgbaPlanes);  //SPLIT
let BP = rgbaPlanes.get(2);  // SELECTED COLOR PLANE
let GP = rgbaPlanes.get(1);
let RP = rgbaPlanes.get(0);
cv.merge(rgbaPlanes,orig);

Важно: каждое изображение, созданное в программе OpenCV, должно быть удалено, чтобы избежать утечки памяти компьютера (ANN:8A).

src.delete();
high.delete();
low.delete();
orig.delete();
mask1.delete();
mask2.delete();
mask.delete();
contours.delete();
hierarchy.delete();
//cnt.delete();
RP.delete();

Выходное изображение Mask1 не отображается в программе, хотя могло бы. Однако оно используется функцией threshold(), следующей сразу за inRange().

Функция threshold() проверяет составное значение пикселя исходного изображения и устанавливает соответствующее значение назначения на 0 или 255 в зависимости от того, меньше или больше исходное значение порога. Верхнее изображение в правой колонке показывает это бинарное изображение.

Для полноты была добавлена функция инвертирования бинарного изображения. При нажатии кнопки INVERT на веб-странице бинарное изображение инвертируется (чёрное становится белым, белое — чёрным), и последующая обработка выполняется над новым изображением. Кнопка бистабильная, поэтому второе нажатие возвращает бинарное изображение в исходное состояние.

Зонд целевого цвета

ESP32-CAM Отслеживание цвета Цветовой зонд OpenCVJS

На скриншоте красная крышка является целью в обычной комнатной обстановке с обычной 60-ваттной люминесцентной лампой. Лампа излучает красный, зелёный и синий свет. Красная крышка отражает красный, зелёный и синий свет, но преимущественно красный. Метод определения количества каждого отражённого цвета будет описан сейчас. Этот метод позволяет установить ползунки RGB с минимальными усилиями. Его использование настоятельно рекомендуется.

ESP32-CAM OpenCVJS Исходное изображение Маска и отслеживание цвета

Изображение предоставлено Andrew R. Sass

Метод включает использование ползунков Color Probe. Эти два ползунка, X и Y Probe, используются для размещения маленького белого кружка зонда в нужной позиции на нижнем изображении правой колонки. Значения RGB в этой позиции зонда измеряются и используются для установки максимумов и минимумов inRange() RGB, описанных ранее.

Смотрите ANN:9, 9A, 9B, 9C для кода, связанного с этим зондом.

Когда оптимальные значения для нужной цели найдены с помощью зонда X, Y и установлены ползунками, цель в бинарном изображении становится белой, а остальная часть изображения — чёрной, в идеале, как показано на скриншоте.

Этот идеал обычно может быть реализован только при тщательном контроле условий освещения. Стандартное комнатное освещение является приемлемым. Для оптимальных результатов можно использовать фильтры, но здесь они не использовались.

Вот ещё один пример:

ESP32-CAM OpenCVJS Пример исходного изображения маски и отслеживания цвета

Отслеживание

Когда бинарное изображение сочтено приемлемым, можно нажать бистабильную кнопку TRACKING. ANN:10 отмечает начало подпрограммы отслеживания.

//ANN:10
if(b_tracker == true){
try{
 if(b_invert==false){

Поскольку, как упоминалось выше, эта статья не касается функции INVERT, интерес представляет только случай b_invert равный false.

ANN:11 Первый шаг в отслеживании — findContours, алгоритм OpenCV, который находит контуры всех белых объектов в бинарном изображении.

//ANN:11
    cv.findContours(mask,contours,hierarchy,cv.RETR_CCOMP,cv.CHAIN_APPROX_SIMPLE);
//findContours(source image, array of contours found, hierarchy of contours
// if contours are inside other contours, method of contour data retrieval,
//algorithm method)
}
else{
  cv.findContours(mask2,contours,hierarchy,cv.RETR_CCOMP,cv.CHAIN_APPROX_SIMPLE);
}
console.log("CONTOUR_SIZE = " + contours.size());

//draw contours
if(b_contour==true){
  for(let i = 0; i < contours.size(); i++){
    cv.drawContours(src,contours,i,[0,0,0,255],2,cv.LINE_8,hierarchy,100)
  }
}

Если кнопка отслеживания нажата, когда бинарное изображение полностью чёрное, инструкции, зависящие от вывода findContours, выбросят исключения; try-catch позволяет программе безопасно продолжить работу, выводя сообщение в console.log и текстовое поле.

Contours.size() — это выход findContours и представляет собой массив контуров белых объектов, найденных в бинарном изображении. Contours.size() определяет количество элементов в массиве. Иерархия (контуры внутри других контуров) здесь не представляет интереса, так как белых объектов (обведённых чёрным) внутри других белых объектов не будет.

ANN:12 Отмечает начало нахождения моментов найденных контуров.

//ANN:12
let cnt;
let Moments;
let M00;
let M10;

M00 — это нулевой момент — «площадь», ограниченная контуром. В OpenCV это фактически количество пикселей, заключённых в контуре. M10 и M01 — это взвешенное по координатам x и y количество пикселей.

Как обычно, начало координатной системы x,y находится в верхнем левом углу изображения. X положителен горизонтально вправо, а Y положителен вертикально вниз. Поэтому M10/M00 и M01/M00 — это координаты x,y центроида контура в массиве.

ANN:13, 13A отмечает нахождение контура с наибольшей площадью в массиве контуров с помощью функции MaxAreaArg и передачу центроида x_cm, y_cm на ESP32 через инструкцию fetch.

//ANN:13
for(let k = 0; k < contours.size(); k++){
  cnt = contours.get(k);
  Moments = cv.moments(cnt,false);
  M00Array[k] = Moments.m00;
  // cnt.delete();
}

//ANN13A
let max_area_arg = MaxAreaArg(M00Array);
console.log("MAXAREAARG = "+max_area_arg);

//let TestArray = [0,0,0,15,4,15,2];
//let TestArray0 = [];
//let max_test_area_arg = MaxAreaArg(TestArray0);
//console.log("MAXTESTAREAARG = "+max_test_area_arg);

let ArgMaxArea = MaxAreaArg(M00Array);
if(ArgMaxArea >= 0){
cnt = contours.get(MaxAreaArg(M00Array));  //use the contour with biggest MOO
//cnt = contours.get(54);
Moments = cv.moments(cnt,false);
M00 = Moments.m00;
M10 = Moments.m10;
M01 = Moments.m01;
x_cm = M10/M00;    // 75 for circle_9.jpg
y_cm = M01/M00;    // 41 for circle_9.jpg

XCMoutput.textContent = Math.round(x_cm);
YCMoutput.textContent = Math.round(y_cm);

console.log("M00 = "+M00);
console.log("XCM = "+Math.round(x_cm));
console.log("YCM = "+Math.round(y_cm));

//fetch(document.location.origin+'/?xcm='+Math.round(x_cm)+';stop');
fetch(document.location.origin+'/?cm='+Math.round(x_cm)+';'+Math.round(y_cm)+';stop');

console.log("M00ARRAY = " + M00Array);

Во время работы программы координаты центроида отображаются в Serial Monitor, а также в console.log и в текстовом поле на экране браузера. ESP32 может использовать данные центроида для целей отслеживания в робототехнических приложениях.

ANN:14 Отмечает код для синего ограничивающего прямоугольника, который ограничивает контур с наибольшей площадью, и центроид этого контура. Их можно увидеть на нижнем изображении в правой колонке экрана браузера.

//ANN:14

//**************min area bounding rect********************
//let rotatedRect=cv.minAreaRect(cnt);
//let vertices = cv.RotatedRect.points(rotatedRect);

//for(let j=0;j<4;j++){
//    cv.line(src,vertices[j],
//        vertices[(j+1)%4],[0,0,255,255],2,cv.LINE_AA,0);
//}
//***************end min area bounding rect*************************************

//***************bounding rect***************************
let rect = cv.boundingRect(cnt);
let point1 = new cv.Point(rect.x,rect.y);
let point2 = new cv.Point(rect.x+rect.width,rect.y+rect.height);

cv.rectangle(src,point1,point2,[0,0,255,255],2,cv.LINE_AA,0);
//*************end bounding rect***************************

//*************draw center point*********************
let point3 = new cv.Point(x_cm,y_cm);
cv.circle(src,point3,2,[0,0,255,255],2,cv.LINE_AA,0);
//***********end draw center point*********************

}//end if(ArgMaxArea >= 0)
else{
  if(ArgMaxArea==-1){
    console.log("ZERO ARRAY LENGTH");
  }
  else{              //ArgMaxArea=-2
    console.log("DUPLICATE MAX ARRAY-ELEMENT");
  }
}

cnt.delete();

Под нижним изображением в правой колонке текстовое поле содержит выбранные выходные данные программы, включая данные зонда X, Y, координаты центроида и вывод catch, если генерируется исключение, как упоминалось выше.

ESP32-CAM Отслеживание цвета Выходные сообщения X Y координаты

Загрузка кода

После ввода сетевых учётных данных и распиновки для вашей камеры вы можете загрузить код.

В меню Tools выберите следующие настройки перед загрузкой кода на плату.

ESP32-CAM Wrover Настройки загрузки
  • BOARD: ESP32 Wrover Module

  • Flash Mode: «QIO»

  • PARTITION SCHEME: «Huge App (3Mb No OTA/1MB SPIFFS)»

  • Flash Frequency: «80 Mhz»

  • Upload Speed: «115200»

  • Core Debug Level: «None»

Тестирование программы

После загрузки кода откройте Serial Monitor на скорости 115200 бод. Нажмите встроенную кнопку RST, и IP-адрес ESP должен быть напечатан. В данном случае IP-адрес — 192.168.1.95.

ESP32-CAM Веб-сервер Отслеживание цвета Демонстрация OpenCV.js IP-адрес

Откройте браузер в вашей локальной сети и введите IP-адрес ESP32-CAM.

Откройте console.log при открытии браузера. Проверьте, правильно ли загружается OpenCV.js. В правом нижнем углу веб-страницы должно отображаться «OpenCV.JS READY».

Затем нажмите кнопку Color Detection в верхней левой колонке окна браузера.

Вы должны увидеть похожее окно без сообщений об ошибках.

ESP32-CAM Веб-сервер Отслеживание цвета Демонстрация OpenCV.js

После установки правильных настроек для нацеливания на цвет с помощью зонда целевого цвета (как объяснялось ранее), нажмите кнопку Tracking.

В то же время координаты центроида цели должны отображаться на веб-странице, а также в Serial Monitor ESP32-CAM.

ESP32-CAM Веб-сервер Отслеживание цвета Демонстрация OpenCV.js Arduino IDE Serial Monitor

Заключение

Ни один из элементов проекта, описанного в этом уроке, не является новым. Веб-сервер камеры ESP32 и OpenCV были подробно и детально описаны в литературе.

Новизна здесь заключается в объединении этих двух технологий через OpenCV.js. Камера ESP32 с её малыми размерами, Wi-Fi, высокими технологиями и низкой стоимостью обещает стать интересным новым фронтенд-средством захвата изображений для веб-серверных приложений OpenCV.

Узнайте больше об ESP32-CAM

Мы надеемся, что вам понравился этот проект. Узнайте больше об ESP32-CAM в наших уроках:

Об авторе Andrew R. Sass

Этот проект/урок был разработан Andrew R. Sass. Мы отредактировали урок, чтобы он соответствовал стилю наших уроков. Помимо некоторых CSS, код является оригинальным, предоставленным Andrew.

Биография автора: Andrew («DOC») R. Sass имеет степени BSEE (MIT), MSEE и PhD EE (PURDUE). Он является инженером-исследователем на пенсии (компоненты интегральных схем), преподавателем на пенсии второй карьеры (AP Physics, Physics, Robotics) и был наставником местной робототехнической команды FIRST.