Java + OpenCV + repaint()

0

Witajcie, jest to mój pierwszy post na forum i dopiero raczkuję w świecie programowania. Próbuję stworzyć program z niezbyt skomplikowanym GUI (kilka zakładek, przycisków, comboBoxów i duży JPanel) który umożliwiałby detekcję twarzy z obrazu (to się udało!) oraz ze strumienia video (a to niezupełnie!). Problem polega na tym, że po pobraniu obrazu z kamery w formacie Mat (OpenCV) i przeniesieniu tego do formatu BufferedImage próbuję ten obraz w pętli wyświetlić na JPanelu, ale uzyskuję oczywiście tylko ostatnią klatkę z całej pętli.

W przypadku, gdy pisałem próbny kod, wszystko działało dobrze, dopiero po przeniesieniu do większego programu, składającego się z kilku klas wewnętrznych, wielu metod i większego GUI, pojawił się problem. Oto fragment kodu, który wydaje się nie działać poprawnie:

class MojStartZ2Listener implements ActionListener {

		public void actionPerformed(ActionEvent a) {
			switch (wyborOpcjiZ2) {
			case 0:
				odtworzVideo();
				break;
			case 1:
				break;
			case 2:
				break;
			default:
				break;
			}
		}

		public void odtworzVideo() {
			System.loadLibrary(Core.NATIVE_LIBRARY_NAME);

			Mat obrazKamery = new Mat();
			VideoCapture kamera = new VideoCapture(0);
			int i = 0;
			if (kamera.isOpened()) {
				try {
					Thread.sleep(800);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				while (i < 10) {
                               //Tutaj argumentem było "true", ale dodałem zmienną, aby sprawdzić co się wydarzy po skończeniu pętli,
                               //i wtedy odkryłem, że ostatni obraz jest wyświetlany na JPanelu
					i++;
					kamera.read(obrazKamery);
					if (!obrazKamery.empty()) {

						MatToBufferedImage zmiana = new MatToBufferedImage();
						obrazVideoZ2 = zmiana.getBuffed(obrazKamery);

						obrazPanelRysunekZ2.repaint();
						try {
							Thread.sleep(10);
						} catch (Exception ex) {

						}
						String filename = "D:/Eclipse/Projekty2/Obrazy/faceDetectionKamera.png";
						Highgui.imwrite(filename, obrazKamery);
					}
				}
			}
		}
	}// Koniec klasy

Obrazy są zapisywane w ścieżce filename w czasie rzeczywistym, więc miałem "podgląd" swojej kamery, obserwując zawartość folderu, więc wiem, że na pewno problem leży w kontakcie klasy ActionListenera (który jest klasą wewnętrzną) z metodą TworzGUI() klasy zewnętrznej, która rysuje całe GUI, w tym panel. Czytając różne wątki zdążyłem wydedukować, że problem najprawdopodobniej leży w wątkach i w tym, że metoda repaint() przy wywoływaniu w pętli ma tendencję do działania "jak jedno" wywołanie, które uwidacznia się dopiero po zakończeniu działania całej pętli. Niestety, nie wiem jak sobie z tym problemem poradzić.

Nie do końca rozumiem obsługę wątków (ich wykorzystanie w praktyce, poza metodami sleep :)) i nie wiem jak połączyć jedno z drugim, aby ostatecznie, po kliknięciu przycisku na JPanelu uwidocznił mi się obraz z kamery, dlatego prosiłbym by nie pisać zbyt fachowych terminów. Dziękuję z góry za każdą odpowiedź, i pozdrawiam.

0

Witam ponownie, spędziłem wiele godzin czytając o wątkach, i spróbowałem zastosować takie rozwiązanie, że całą metodę odtworzVideo() przeniosłem do nowej klasy wewnętrznej, która implementowała interfejs Runnable. W metodzie run umieściłem kod, który wcześniej był w metodzie odpowiedzialnej za wyświetlanie obrazu z kamery. Następnie w switchu utworzyłem nowy wątek, którego argumentem był konstruktor tej nowej klasy wewnętrznej + metoda start(). No i działa!

Kod "uruchamiający", czyli odpowiedź na kliknięcie przycisku:

	class MojStartZ2Listener implements ActionListener {

		public void actionPerformed(ActionEvent a) {
			switch (wyborOpcjiZ2) {
			case 0:
				new Thread(new OdtworzVideoWNowymWatku()).start();
				break;
			case 1:
				break;
			case 2:
				break;
			default:
				break;
			}
		}
	}// Koniec klasy

Nowo dodana klasa wewnętrzna:

 public class OdtworzVideoWNowymWatku implements Runnable {
		public void run() {
			
				System.loadLibrary(Core.NATIVE_LIBRARY_NAME);

				Mat obrazKamery = new Mat();
				VideoCapture kamera = new VideoCapture(0);
				int i = 0;
				if (kamera.isOpened()) {
					try {
						Thread.sleep(800);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
					while (i < 1000) {
						i++;
						kamera.read(obrazKamery);
						if (!obrazKamery.empty()) {

							MatToBufferedImage zmiana = new MatToBufferedImage();
							obrazVideoZ2 = zmiana.getBuffed(obrazKamery);

							obrazPanelRysunekZ2.repaint();
							try {
								Thread.sleep(10);
							} catch (Exception ex) {

							}
							String filename = "D:/Eclipse/Projekty2/Obrazy/faceDetectionKamera.png";
							Highgui.imwrite(filename, obrazKamery);
						}
					}
				}	
		}
	}

Pozdrawiam!

3

Twój pierwszy i najpoważniejszy błąd, to nieprawdopodobnie czasochłonna obsługa zdarzenia. Powinna ona zająć maksymalnie ułamek milisekundy, a zajmuje czas, który może być liczony w sekundach. Obsługa zdarzeń jest jednowątkowa. Oznacza to, że dopóki nie zakończysz swojej obsługi zdarzenia, żadne inne zdarzenie, ani nawet odrysowanie gui nie zostanie obsłużone.
Są tylko dwie zasady dotyczące obsługi zdarzeń:

  1. Żadnych czasochłonnych operacji!
  2. Jeżeli korzystasz ze sleep, operacji plikowej lub tworzenia obiektów (new/fabryka), to jeszcze raz wszystko przeczytaj, skasuj ten kod i wróć do punktu 1.

U Ciebie obsługa zdarzenia kończy się dopiero po tym jak zmarnujesz 0,8s na pierwsze sleep oraz nieokreślony czas na utworzenie obiektu VideoCapture, a następnie w pętli kolejny raz marnujesz 10 razy po 10 ms oraz nieokreślony czas na zapis obrazu do pliku (prawdopodobnie). W efekcie tracisz co najmniej sekundę na obsługę jednego zdarzenia. W tym czasie całe GUI obsługiwane przez Javę jest kompletnie zamrożone i czeka na zakończenie Twojej procedury obsługi zdarzenia.

W jednym przebiegu pętli obsługi zdarzeń wszystkie polecenia repaint dotyczące tego samego obszaru obrazu są scalane w jedno wywołanie. Obsługa pojedynczego zdarzenia jest jednym z elementów jednego przebiegu takiej pętli. Samej pętli nie widzisz ponieważ jest ona schowana w klasach Swinga i w jej trakcie przebiegane są wszystkie procedury:

  • obsługi zdarzeń, wszystkich obiektów, które mogą je wygenerować
  • aktualizacji obiektów (w tym żądania repaint)
  • odmalowania zaktualizowanych obiektów
    Krótko mówiąc z punktu widzenia obsługi GUI Twoja procedura jest niczym innym jak nieświadomym wandalizmem. :)

Teraz jak to naprawić.
Twoja obsługa przycisku ma za zadanie jedynie włączyć przełącznik pozwalający Twojemu programowi na samodzielne odrysowywanie kolejnych klatek pobieranych z jakiegoś źródła. I na tym ma się zakończyć. Ponieważ jednak potrzebne jest przygotowanie źródła, otwarcie plików i ewentualna obsługa błędów (obsługa pliku zawsze może wygenerować błąd i/o) - to musisz to zrobić najlepiej przed momentem w którym będziesz mógł otrzymać pierwsze żądanie obsługi zdarzenia. I tu właśnie wchodzi obsługa wątków.
Pierwszym wątkiem z jakim masz do czynienia jest wywołanie metody main w przypadku aplikacji lub init w przypadku apletu. Najlepiej więc z tego miejsca przygotować wszystko do przeprowadzania płynnej animacji, czytania z plików, strumieni itp.
Drugim wątkiem z jakim masz do czynienia jest wywołanie obsługi zdarzenia. Zazwyczaj wszystkie procedury obsługi wywoływane są w jednym wątku w jakiejś kolejności - dlatego mówi się, że obsługa GUI jest jednowątkowa.
Wbrew pozorom wątek odpalający metodę main nie musi się kończyć, choć w przypadku apletu init powinna zostać zakończona bo nie ma żadnej gwarancji, ze init, start, stop i destroy muszą być koniecznie osobnymi wątkami.
Wątek kręcący pętlą obsługi GUI uruchamia kiedy zostanie wykonana pierwsza operacja, która będzie wymagać jej działania - np. setVisible(true) lub addXXXListener.

Nie jesteś jednak ograniczony tymi zdefiniowanymi wątkami. Wszędzie gdzie potrzeba możesz utworzyć oraz następnie uruchomić nowy wątek. Tworzenie i uruchamianie nie musi być w jednym miejscu (nawet lepiej, żeby nie było). Jeżeli Twoja procedura obsługi potrzebuje więcej czasu na operacje, to zawsze może uruchomić inny wątek i tam mogą być te operacje kontynuowane. Aktualnie nowe wątki nie powinno odpalać się przez tworzenie obiektu Thread (to jest bardzo niski poziom wymagający drobiazgowej obsługi), lecz przez wysokopoziomowe klasy jak SwingWorker czy obiekty zadań (Runnable, Callable) wykonywane przez różne typy egzekutorów oraz timery.
W Twoim wypadku procedura obsługi naciśnięcia przycisku powinna uruchomić wątek timera, który w wywołaniu zegarowym co najmniej 25 razy na sekundę będzie odczytywał kolejny obraz z kamery, przygotowywał go w formacie nadającym się do odmalowania w wybranym oknie/komponencie, a następnie wyrzucał żądanie repaint dla tego okna/komponentu.
To żądanie wyzwoli wywołanie metody paint/paintComponent (zależnie na jak wysokim poziomie chcesz odmalowywać kontrolkę), w której będziesz mógł po prostu odpalić metodę draw na już przygotowanym obiekcie obrazka. Ważne, że czas wykonania wywołania zegarowego też jest ograniczony do maksymalnie 1/25 sekundy ponieważ jeżeli wykona się dłużej, to rozpocznie się już kolejne wywołanie zegarowe, które zacznie wczytywać kolejną klatkę itd. Wtedy jeżeli deficyt czasu będzie narastał doprowadzi to do awarii danych i katastrofy.

Zamiast timera można też użyć osobnego wątku, który będzie wykonywał pętlę aktywnego renderingu (tak jak robią to profesjonalne gry). Dzięki temu można uniknąć katastrofy w przypadku chwilowych opóźnień (tzw. laga), który może wyniknąć z przyczyn nad którymi nie masz żadnej kontroli (np. defragmentacji pamięci, kalibracji kamery, opóźnień sygnału itp.). Aktywny rendering pozwala bowiem na bieżący pomiar czasu i pominięcie niektórych najbardziej czasochłonnych elementów w sytuacji deficytu czasu, którego nie ma jak nadgonić. Jednak na Twoim poziomie jest to zbyt trudne, więc masz tylko informację, że istnieje lepsza alternatywa dla Timera.

Jeżeli więc ograniczysz się tylko do Timera, to musisz kontrolować czas wykonania każdego wywołania zegarowego i ograniczyć go do okresu czasu wynikającego z podziału sekundy na ilość klatek, którą chcesz/musisz uzyskać. Najlepiej jednak podziel go jeszcze na 2 lub 4 bo sam timer i reszta systemu też ma swój narzut czasowy, a aplikację możesz przecież wykonywać na systemie jednoprocesorowym. Musisz też utrzymywać flagę informującą czy jesteś w trakcie wykonywania wywołania zegarowego tak aby każde następne wywołanie zegarowe mogło ją sprawdzić i w przypadku gdy poprzednie wywołanie się nie zakończyło natychmiast zakończyć obsługę bieżącego wywołania (lub w czasie testów generować wyjątek oraz przerywać działanie aplikacji). To pozwoli uniknąć katastrofy - gdyby taka miała nastąpić.

Krótko podsumowując:

  1. main/init - przygotowujesz pliki, kamerę, obiekty potrzebne do płynnego odczytu takie jak timer itp., na komponencie, który ma wyświetlać obraz odpalasz setIgnoreRepaint(true), żeby nie było niekontrolowanych odrysowań gdy Twoje obrazy nie są jeszcze gotowe.
  2. Metoda actionPerformed - ustawiasz flagę aktywności animacji i uruchamiasz timer do generowania wywołań zegarowych
  3. Metoda wywołania zegarowego sprawdza czy aktualizacja jest w trakcie (wtedy wychodzi), ustawia flagę aktualizacji, odczytuje obraz z kamery, przygotowuje go do wyświetlenia i wyłącza flagę aktualizacji przed wyjściem.
  4. Metoda paint (dla JPanel) lub paintComponent (dla innych) odrysowuje przygotowany obraz i natychmiast kończy się.
  5. O sleep powinieneś zapomnieć, że w ogóle istnieje. De facto sleep powoduje utworzenie nowego wątku, który nic nie robi, tylko czeka, a po jego zakończeniu robi join z wątkiem, który sleep wywołał. Dlatego można ją przerwać przed czasem i dlatego to przerwanie jest obligatoryjnie do złapania.

[aktualizacja]
W międzyczasie sobie poradziłeś, ale to nadal jest tylko działająca prowizorka, w której ręcznie sterujesz. Działa to głównie dzięki zbiegowi okoliczności i dobrej jakości kodu bibliotek, które wywołujesz. :)

0

Bałem się, że nikt się nie zainteresuje moim problemem, albo zostanie przerzucony do Newbie i zginie w katuszach, a tu proszę, taka konstruktywna i treściwa odpowiedź! Bardzo dziękuję za poświęcony mi czas, teraz spróbuję to ponownie dokładnie przeanalizować i zastosować w praktyce :).

1 użytkowników online, w tym zalogowanych: 0, gości: 1