ESP32-CAM i OpenCV do odczytu analogowego manometru

ESP32-CAM i OpenCV do odczytu analogowego manometru
2 lipca 2020 No Comments ESP32 Bartłomiej Szymański

Wstęp

Na rynku istnieje wiele czujników i rozwiązań do pomiaru parametrów i wartości fizycznych. Jednak większość takich czujników wymaga ingerencji w układ (zazwyczaj musi mieć on fizyczny kontakt z mierzonym ciałem). Na przykład do pomiaru ciśnienia cieczy lub gazu w urządzeniu, gdzie zamontowany jest jedynie manometr, a w medium transmisyjne nie wolno nam ingerować, typowy czujnik ciśnienia analogowo/cyfrowy niestety nam się nie przyda. Wtedy trzeba się zastanowić w jaki sposób można dokonać pomiaru i w podanym przykładzie na szczęście okazuje się, że producent zamontował manometr. Tutaj z pomocą przychodzi nam przetwarzanie obrazów. Stosując kamerę oraz jakiś nawet prosty komputer będący w stanie obsłużyć bibliotekę OpenCV łatwo jest ten problem rozwiązać. Dzięki temu można przystosować urządzenie, które ma na celu dokonanie pomiaru do spojrzenia na manometr w podobny sposób, w jaki robi to człowiek. Tracąc odrobinę na dokładności pomiaru zyskujemy możliwość zdalnej kontroli zadanej wartości. Możemy wtedy szybko zareagować gdy pojawi się problem, ponieważ system nas o tym poinformuje.


Układ pomiarowy i komponenty

Układ składa się z SoC (System on Chip) w postaci zintegrowanej kamery z pełnoprawnym mikrokontrolerem i modułem WiFi jakim jest ESP32-CAM oraz w obecnym przypadku komputera, który za pomocą programu napisanego w pythonie i biblioteki OpenCV pobiera zrzuty z adresu IP kamery i je analizuje.

Schemat blokowy układu

Rys. 1. Schemat blokowy układu pomiarowego.

Sama kamera ESP32-CAM działa w trybie kamery IP, podłącza się do zadanej sieci WiFi istniejącej w otoczeniu, a następnie uruchamia własny webserwer z udostępnianiem obrazu. W dalszej kolejności program w bibliotece OpenCV przechwytuje ten obraz, wykrywa tarczę, następnie wskazówkę i na podstawie kąta odchylenia linii wskazówki względem środka tarczy określa jej położenie. W obecnej chwili użyty jest do tego profilometr z uwagi na łatwość przestawienia wskazówki, dzięki czemu w prosty sposób mogłem określić czy wszystko działa jak należy. Dodatkowo za pomocą układu CH340, który służy też jako programator, sprawdzam jakie oraz czy w ogóle są wysyłane ramki z obrazem.

Mierzony profilometr

Rys. 2. Widok na mierzony profilometr.

ESP32-CAM

Rys. 3. Omawiany SoC ESP32-CAM z kamerą OV2640.

Opis podłączenia / wyprowadzeń

Rys. 4. Schemat podłączenia układu.

Układ należy zasilić ze stabilnego źródła zasilania o wartości napięcia 3,3V. Programowanie obywa się przy założonej zworce i wpiętym układzie CH340, należy uruchomić programowanie, nacisnąć reset, a następnie poczekać aż program zostanie wgrany do układu.


Opis programu kamery ESP32-CAM

W funkcji "setup()" ustawiamy wyjście kontrolne UART i inicjalizujemy kamerę: 
  Serial.begin(115200);
  Serial.println();

  {
    using namespace esp32cam;
    Config cfg;
    cfg.setPins(pins::AiThinker);
    cfg.setResolution(hiRes);
    cfg.setBufferCount(2);
    cfg.setJpeg(80);

    bool ok = Camera.begin(cfg);
    Serial.println(ok ? "CAMERA OK" : "CAMERA FAIL");
  }

W funkcji "setup()" inicjalizujemy moduł WiFi w trybie klienta i czekamy na połączenie: 
  WiFi.persistent(false);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }

W funkcji "setup()" określamy ścieżki katalogów webservera oraz poszczególne funkcje służące do konkretnych transmisji obrazu, a następnie wywołujemy funkcję inicjalizacyjną (server.begin()).
  Serial.print("http://");
  Serial.println(WiFi.localIP());
  Serial.println("  /cam.bmp");
  Serial.println("  /cam-lo.jpg");
  Serial.println("  /cam-hi.jpg");
  Serial.println("  /cam.mjpeg");

  server.on("/cam.bmp", handleBmp);
  server.on("/cam-lo.jpg", handleJpgLo);
  server.on("/cam-hi.jpg", handleJpgHi);
  server.on("/cam.jpg", handleJpg);
  server.on("/cam.mjpeg", handleMjpeg);

  server.begin();

W pętli głównej wywołujemy funkcję utrzymania sesji klienta.
void loop()
{
  server.handleClient();
}


Opis programu z użyciem biblioteki OpenCV

 Pętla główna składa się z:
• parametrów służących poprawieniu jakości odczytu
• funkcji „take_measure()” odczytującej pomiar
• funkcji wyświetlających znalezione dane tarczy i wskazówki na obrazie z kamery
• warunku na zakończenie pracy programu
while True:
    #Parameters for real gauge
    #min_angle = 45
    #max_angle = 320
    #min_value = 0
    #max_value = 200
    #units = "PSI"
    
    threshold_img = 120 #175
    threshold_ln = 150
    minLineLength = 40
    maxLineGap = 8

    #Distance from center coefficients
    diff1LowerBound = 0.15 
    diff1UpperBound = 0.25

    #Distance from circle coefficients
    diff2LowerBound = 0.5 
    diff2UpperBound = 1.0
        
    img, img2 = take_measure(threshold_img, threshold_ln, minLineLength, 
    maxLineGap, diff1LowerBound, diff1UpperBound, diff2LowerBound, diff2UpperBound)
    cv2.imshow('test',img)
    cv2.imshow('test2',img2)

    if ord('q')==cv2.waitKey(10):
        exit(0)

Funkcja „take_measure()”:
 • Pobieranie zrzutu z kamery:
    imgResp=urllib.request.urlopen(url)
    imgNp=np.array(bytearray(imgResp.read()),dtype=np.uint8)
    img=cv2.imdecode(imgNp,-1)
    img2=cv2.imdecode(imgNp,-1)

• Określenie parametrów obrazu i poszukiwanie okręgu na obrazie (HoughCircles)
    height, width = img.shape[:2]
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)  
    circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, 1, 20)

• Rysowanie środka oraz okręgu znalezionego na obrazie
        #Draw center and circle
        cv2.circle(img, (x, y), r, (0, 255, 0), 3, cv2.LINE_AA)  # draw circle
        cv2.circle(img, (x, y), 2, (0, 255, 0), 3, cv2.LINE_AA)  # draw center of circle

• Wygenerowanie na zadanym okręgu skali i podziału kątowego, a następnie naniesienie tych parametrów na obraz
        separation = 10.0 #in degrees
        interval = int(360 / separation)
        p1 = np.zeros((interval,2))  #set empty arrays
        p2 = np.zeros((interval,2))
        p_text = np.zeros((interval,2))
        for i in range(0,interval):
            for j in range(0,2):
                if (j%2==0):
                    p1[i][j] = x + 0.9 * r * np.cos(separation * i * 3.14 / 180) #point for lines
                else:
                    p1[i][j] = y + 0.9 * r * np.sin(separation * i * 3.14 / 180)
        text_offset_x = 10
        text_offset_y = 5
        for i in range(0, interval):
            for j in range(0, 2):
                if (j % 2 == 0):
                    p2[i][j] = x + r * np.cos(separation * i * 3.14 / 180)
                    p_text[i][j] = x - text_offset_x + 1.2 * r * np.cos((separation) * (i+9) * 3.14 / 180) #point for text labels, i+9 rotates the labels by 90 degrees
                else:
                    p2[i][j] = y + r * np.sin(separation * i * 3.14 / 180)
                    p_text[i][j] = y + text_offset_y + 1.2* r * np.sin((separation) * (i+9) * 3.14 / 180)  # point for text labels, i+9 rotates the labels by 90 degrees

        #Lines and labels
        for i in range(0,interval):
            cv2.line(img, (int(p1[i][0]), int(p1[i][1])), (int(p2[i][0]), int(p2[i][1])),(0, 255, 0), 2)
            cv2.putText(img, '%s' %(int(i*separation)), (int(p_text[i][0]), int(p_text[i][1])), 
            cv2.FONT_HERSHEY_SIMPLEX, 0.3,(255,0,0),1,cv2.LINE_AA)

        cv2.putText(img, "Gauge OK!", (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.9,(0,255,0),2,cv2.LINE_AA)

 • Skala szarości i progowanie drugiego obrazu, którego celem jest  ułatwienie znalezienia strzałki, a następnie wywołanie funkcji  poszukującej linie na tym obrazie 
        gray3 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
        maxValue = 255
        
        # Threshold image to take better measurements
        th, dst2 = cv2.threshold(gray3, threshold_img, maxValue, cv2.THRESH_BINARY_INV);

        in_loop = 0
        lines = cv2.HoughLinesP(image=dst2, rho=3, theta=np.pi / 180, threshold=threshold_ln, minLineLength=minLineLength, maxLineGap=maxLineGap)
        final_line_list = []

• Poszukiwanie współrzędnych linii, które znajdują się najbliżej środka narysowanego okręgu, następnie sprawdzenie czy mieszczą się w akceptowalnym zakresie (wartości współczynników podane są w funkcji 
głównej)
        for i in range(0, len(lines)):
            for x1, y1, x2, y2 in lines[i]:
                diff1 = dist_2_pts(x, y, x1, y1)  # x, y is center of circle
                diff2 = dist_2_pts(x, y, x2, y2)  # x, y is center of circle

                if (diff1 > diff2):
                    temp = diff1
                    diff1 = diff2
                    diff2 = temp
                    
                # Check if line is in range of circle
                if (((diff1<diff1UpperBound*r) and (diff1>diff1LowerBound*r) and (diff2<diff2UpperBound*r)) and (diff2>diff2LowerBound*r)):
                    line_length = dist_2_pts(x1, y1, x2, y2)
                    final_line_list.append([x1, y1, x2, y2])
                    in_loop = 1

        if (in_loop == 1):
            x1 = final_line_list[0][0]
            y1 = final_line_list[0][1]
            x2 = final_line_list[0][2]
            y2 = final_line_list[0][3]
            cv2.line(img2, (x1, y1), (x2, y2), (0, 255, 255), 2)
            dist_pt_0 = dist_2_pts(x, y, x1, y1)
            dist_pt_1 = dist_2_pts(x, y, x2, y2)
            if (dist_pt_0 > dist_pt_1):
                x_angle = x1 - x
                y_angle = y - y1
            else:
                x_angle = x2 - x
                y_angle = y - y2

• Określenie i obliczenie kąta pochylenia wskazówki względem wyrysowanej skali i środka tarczy
            # Finding angle using the arc tan of y/x
            res = np.arctan(np.divide(float(y_angle), float(x_angle)))

            #Converting to degrees
            res = np.rad2deg(res)
            if x_angle > 0 and y_angle > 0:  #in quadrant I
                final_angle = 270 - res
            if x_angle < 0 and y_angle > 0:  #in quadrant II
                final_angle = 90 - res
            if x_angle < 0 and y_angle < 0:  #in quadrant III
                final_angle = 90 - res
            if x_angle > 0 and y_angle < 0:  #in quadrant IV
                final_angle = 270 - res

            cv2.putText(img2, "Indicator OK!", (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.9,(0,255,0),2,cv2.LINE_AA)
            print ("Final Angle: ", final_angle)
• Funkcja uśredniająca znalezione okręgi – czyli określająca średnie położenie znalezionego okręgu końcowego z okręgów początkowych
def avg_circles(circles, b):
    avg_x=0
    avg_y=0
    avg_r=0
    for i in range(b):
        avg_x = avg_x + circles[0][i][0]
        avg_y = avg_y + circles[0][i][1]
        avg_r = avg_r + circles[0][i][2]
    avg_x = int(avg_x/(b))
    avg_y = int(avg_y/(b))
    avg_r = int(avg_r/(b))
    return avg_x, avg_y, avg_r

• Funkcja służąca do określenia dystansu pomiędzy środkiem okręgu, a punktem początkowym znalezionej linii, oraz pomiędzy środkiem okręgu, a punktem końcowym znalezionej linii.
def dist_2_pts(x1, y1, x2, y2):
    return np.sqrt((x2 - x1)**2 + (y2 - y1)**2)


Efekty, możliwe usprawnienia, perspektywy rozwoju i przemyślenia

Test programu

Rys. 5. Efekt odczytu wartości na profilometrze

Jak widać po prawej stronie mamy udaną próbę wykrycia tarczy manometru, po lewej natomiast udaną próbę odczytu położenia wskazówki, w konsoli po prawej stronie widoczny jest odczytany kąt. Wyniki są powtarzalne, błąd nie przekracza 10 stopni.

Generalnie algorytmy działają całkiem nieźle, z pewnością wystarczająco dobrze do tego zastosowania, w przypadku odsuwania, przysuwania, zmiany kąta patrzenia do pewnego momentu program radzi sobie dość dobrze z odczytywaniem wskazania. Jedyną modyfikacją jaka może być tu potrzebna to zmiana długości wykrywanej linii, ponieważ przy zbyt dużym zbliżeniu program potrafi wykryć też małą strzałkę widoczną na profilometrze (myślę, że da się znaleźć jakiś prosty sposób na pozbycie się tego efektu), natomiast przy odsuwaniu kamery od profilometru w pewnym momencie program nie wykrywa strzałki ponieważ jest za krótka. Co jest zrozumiałe, z uwagi na skończoną ilość pikseli w obrazie.

Problem pojawia się natomiast przy słabym oświetleniu, trzeba zmieniać wartości progowania, i parametr maksymalnej długości przerwy wykrywanej linii (maxLineGap). Tutaj wydaje mi się łatwo będzie to rozwiązać kilkoma diodami LED IR, które równomiernie doświetlą tarczę manometru. Podobnie jest pewien kłopot, kiedy obrócimy manometr lub kamerę, trzeba wtedy wziąć pod uwagę, że punkt początkowy nie będzie w tym samym miejscu i trzeba będzie przypisywać inne kąty do danej skali wskazania. Tutaj można to dość łatwo zautomatyzować na przykład rysując charakterystyczną linię na manometrze, lub po prostu sztywno ustawić kamerę, aby nie ulegała obrotowi i przesunięciu.

Każdy inny zegar lub manometr powinien zostać bez problemu wykryty i odczytany o ile obraz jest w miarę czytelny (wcześniej robiłem próby na zdjęciach innych manometrów), jednak w celu uzyskania dokładnego odczytu warto pobawić się parametrami dla każdego z nich. Tutaj może się okazać, że manometr ma szerszą wskazówkę lub kolejną małą wskazówkę, która będzie nam zaburzać pomiar. Warto byłoby zatem rozwinąć program o automatyczne eliminowanie takich efektów. Dobrą sprawą byłoby też rozwinięcie programu o odczyt kilku manometrów na jednym obrazie z kamery, tak aby na przykład nie trzeba było stosować trzech kamer do trzech manometrów ustawionych blisko siebie. Bardzo duży problem pojawia się gdy mamy niejednolite tło, łatwo wtedy oszukać program innym okręgiem pojawiającym się w tle, wypadałoby to poprawić. Sama kamera z której korzystam jest całkiem niezła do prostych zastosowań, jednak im lepszej jakości jest obraz tym łatwiej jest o prawidłowy odczyt z małym błędem.

Warto też na koniec dodać, że wskazówka w profilometrze jest bardzo cienka, stąd program był testowany w dość trudnych warunkach. Myślę, że można jeszcze spróbować się pobawić parametrami progowania w celu uzyskania wyższej jakości i częstotliwości odczytu.


Podsumowanie

Przedstawionym rozwiązaniem bardzo łatwo jest odczytać parametry wskazań manometrów, gdzie nie możemy wpiąć się w badany układ typowym czujnikiem. Rozwiązanie jest stosunkowo tanie, ponieważ ESP32-CAM kosztuje około 40 zł brutto, oczywiście im lepsza kamera tym droższa. Jednak do analizy danych wystarczy zwykły komputer z dostępem do sieci, do której połączona jest kamera. Docelowo chciałbym, aby urządzenie pracowało w terenie dopięte do centralki, do której jest możliwość podłączenia się tunelem VPN, następnie serwer stojący już w moim obrębie pobierałby taki obraz i odpowiednio go obrabiał, a dalej upychałby odczyty do bazy danych, z której korzystałoby oprogramowanie wizualizujące odczyty (czyli na przykład rysujące wykresy).

Pliki źródłowe:

https://github.com/bartek-szymanski-szyba/ESP32-projects/tree/master/ESP32-CAM-OpenCV-Gauge-read-via-image-processing

Tags
About The Author

Leave a reply

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *