ESP32-CAM i OpenCV do odczytu analogowego manometru
ESP32-CAM i OpenCV do odczytu analogowego manometru
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.

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.

Rys. 2. Widok na mierzony profilometr.

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

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

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).