From 2e6f444302207c6d3718805ad4b6ccd5ca0efc54 Mon Sep 17 00:00:00 2001 From: DuOtto Date: Fri, 31 Jul 2026 09:02:21 +0200 Subject: [PATCH] Change to CAP_DSHOW for VideoCapture --- Test/Gestures/gesture_config.json | 6 ++ source/Tbd/__pycache__/helper.cpython-311.pyc | Bin 4155 -> 8995 bytes source/Tbd/helper.py | 82 ++++++++++++++++++ source/UI/HomePageWidget.py | 34 +++++--- source/UI/SetupPageWidget.py | 42 ++++----- .../SetupPageWidget.cpython-311.pyc | Bin 13423 -> 12644 bytes 6 files changed, 125 insertions(+), 39 deletions(-) diff --git a/Test/Gestures/gesture_config.json b/Test/Gestures/gesture_config.json index 6f0967b..1191e17 100644 --- a/Test/Gestures/gesture_config.json +++ b/Test/Gestures/gesture_config.json @@ -5,6 +5,12 @@ "left": "PointIndex", "right": "", "extensions": [] + }, + { + "action": "Pointer", + "left": "", + "right": "PointIndex", + "extensions": [] } ] } \ No newline at end of file diff --git a/source/Tbd/__pycache__/helper.cpython-311.pyc b/source/Tbd/__pycache__/helper.cpython-311.pyc index 8f81ba7638f226cde2ef44765a5bf627b18d819e..99be3d96bc1706823b4c83e13c0df7c0467ced8f 100644 GIT binary patch literal 8995 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 delta 955 zcmZ9Ky>HV%7>9kS9j8g0q#t@wh1`4k zIW{vMW*VNe-wsS2TYg};4U>$py~pW~+*0JX&@GJKL&XF$szj3Pizp1CWDsH;gi_P? zjDWrvyD*Ufoo-rg%OtuV5W{Lc3dkh0DSABi1ZC-Ver8QYJMRQigq$p(_ZBvrM%xbb zed0AO+ax76m1B=DGjxP+38k}5vh+@|(EXCHA~}>S7n@JJf5yL}{0v(Y${V%2dTnQq zOv7l%Hv{_fY&D~zQ1nf^=_97gBtuIRD`<}1pU|UFUd_^v+1Y0V%8*4sl_5;)y5YKB zU<8)u`nnztS#CgPVSWxU4}iM}Wf${{APb<*0aUktUINn7Ph zCEgD(RCA%^ww?Wh-W`jNea|I}^iz4Y&Rdf=74MPWb@D^=Dm-zp%HPBGuAeOXzLoW#0?=_nGR_CL6k$;ki! diff --git a/source/Tbd/helper.py b/source/Tbd/helper.py index 8140839..9cce31a 100644 --- a/source/Tbd/helper.py +++ b/source/Tbd/helper.py @@ -1,10 +1,92 @@ import numpy as np import json import cv2 +import threading from dataclasses import dataclass, field, asdict from typing import List, Optional, Tuple, Dict +from pygrabber.dshow_graph import FilterGraph + + +class DShowMJPGCapture: + """ + cv2.VideoCapture-like wrapper (read/release/isOpened) that selects an exact + DirectShow media type (e.g. MJPG at a given resolution) before capturing. + + This exists because cv2.VideoCapture(..., cv2.CAP_DSHOW).set(CAP_PROP_FOURCC, ...) + is unreliable on some webcams: OpenCV opens its own separate DirectShow filter + graph, which renegotiates its own format independently of anything set through + pygrabber beforehand. Doing format selection AND frame grabbing in the same + graph (via pygrabber's sample grabber) avoids that. + """ + + def __init__(self, index: int, width: int, height: int, + fourcc_substr: str = "MJPG", timeout: float = 2.0): + self.index = index + self.width = width + self.height = height + self.selected_format = None + self._opened = False + self._latest_frame = None + self._frame_event = threading.Event() + self._timeout = timeout + + self._graph = FilterGraph() + self._graph.add_video_input_device(index) + + device = self._graph.get_input_device() + formats = device.get_formats() + match = next( + (f for f in formats + if f["width"] == width and f["height"] == height + and fourcc_substr.upper() in f["media_type_str"].upper()), + None, + ) + if match is None: + raise RuntimeError( + f"No {fourcc_substr} format at {width}x{height} available on camera {index}. " + f"Available formats: {formats}" + ) + device.set_format(match["index"]) + self.selected_format = match + + self._graph.add_sample_grabber(self._on_frame) + self._graph.add_null_render() + self._graph.prepare_preview_graph() + self._graph.run() + self._opened = True + + def _on_frame(self, frame): + self._latest_frame = frame + self._frame_event.set() + + def isOpened(self) -> bool: + return self._opened + + def read(self): + if not self._opened: + return False, None + self._frame_event.clear() + self._graph.grab_frame() + if not self._frame_event.wait(self._timeout): + return False, None + return True, self._latest_frame + + def release(self): + if self._opened: + self._graph.stop() + self._graph.remove_filters() + self._opened = False + + # No-ops for compatibility with code paths that still call .set()/.get() + # on the capture object (real config happens via device.set_format above). + def set(self, prop_id, value): + return False + + def get(self, prop_id): + return 0.0 + # ----- Helper Functions ----- def compute_distance(p1, p2): diff --git a/source/UI/HomePageWidget.py b/source/UI/HomePageWidget.py index 2709dfb..72565e9 100644 --- a/source/UI/HomePageWidget.py +++ b/source/UI/HomePageWidget.py @@ -436,25 +436,25 @@ class HomeWindow(QWidget): for camera in self.cameraList: # Loop all Cameras ok, frame = camera.capture.read() #Capture a frame - if not ok: # Check if captured frame is valid, if even one frame is invalide the whole capture failed + if not ok: # A single failed grab shouldn't discard what other cameras saw self.logger.log(f"Could not grab frame for calibration sample, from camera {camera.index}.") - return - - h, w = frame.shape[:2] + continue + + h, w = frame.shape[:2] self.logger.log(f"cam{camera.index} capture size = {w}x{h}") gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # Turn the frame black and white gray = 255 - gray # Invert the color of the frame, as the ChArCuo board I use is inverted (to save printer Ink) - h, w = gray.shape[:2] + h, w = gray.shape[:2] self.logger.log(f"cam{camera.index} capture size = {w}x{h}") #This is apparently a fast check, bevor i detect the actuall board corner, ids, _ = self.arcuo_detector.detectMarkers(gray) #Detect ChArCuo Markers - if ids is None or len(ids) == 0: #Check if Markers where found, if none where found the sample is invalid + if ids is None or len(ids) == 0: #Board not visible from this camera this time, skip it, not the whole sample self.logger.log(f"Not enought markers found for calibration sample, from camera {camera.index}.") - return - + continue + # Detecting the Charcuo board and evaluating if its good as a sample result = self.charuco_detector.detectBoard(gray) @@ -463,12 +463,12 @@ class HomeWindow(QWidget): if charuco_ids is None: self.logger.log(f"Not enought markers found for calibration sample, from camera {camera.index}.") - return - + continue + if len(charuco_ids) < MIN_CHARUCO_CORNERS: self.logger.log(f"Not enought markers found for calibration sample, from camera {camera.index}.") - return - + continue + # Adding the sample for the current camera to detections detections[camera.index] = CharucoDetection( camera_id = camera.index, @@ -477,12 +477,18 @@ class HomeWindow(QWidget): gray_frame = gray ) - # All cameras found valide markers so the sample capture was succesfull + # Need at least two cameras' worth of detections for this sample to be + # useful for stereo pairing (a single-camera detection can still help + # that camera's intrinsics, but can't contribute to any camera pair). + if len(detections) < 2: + self.logger.log("Not enough cameras saw the board in this sample, discarding.") + return + self.calibration_samples.append( CalibrationSample(detections=detections) ) - self.logger.log("Added calibration sample.") + self.logger.log(f"Added calibration sample ({len(detections)}/{len(self.cameraList)} cameras saw the board).") def updateCameras(self): self._clearLayout() diff --git a/source/UI/SetupPageWidget.py b/source/UI/SetupPageWidget.py index 0de989e..065cb5a 100644 --- a/source/UI/SetupPageWidget.py +++ b/source/UI/SetupPageWidget.py @@ -1,8 +1,7 @@ import cv2, numpy as np import os - -from Tbd.helper import CameraObject +from Tbd.helper import CameraObject, DShowMJPGCapture from UI.UILogger import UILogger from PySide6.QtCore import Qt, QTimer @@ -98,31 +97,24 @@ class SetupPage(QWidget): # index=None #) for index in indexToAdd: - capture = cv2.VideoCapture(index, cv2.CAP_MSMF) - #capture = cv2.VideoCapture(index, cv2.CAP_DSHOW) + try: + capture = DShowMJPGCapture(index, 2592, 1944, fourcc_substr="MJPG") + except RuntimeError as e: + self.logger.log(str(e)) + print(str(e)) + continue - capture.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG')) - capture.set(cv2.CAP_PROP_FPS, 30) - capture.set(cv2.CAP_PROP_FRAME_WIDTH, 2592) - capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 1944) + self.logger.log(f"Selected format: {capture.selected_format}") + print(f"Selected format: {capture.selected_format}") - #capture.set(cv2.CAP_PROP_FRAME_WIDTH, 2048.0) - #capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 1536.0) - width = capture.get(cv2.CAP_PROP_FRAME_WIDTH) - height = capture.get(cv2.CAP_PROP_FRAME_HEIGHT) - fps = capture.get(cv2.CAP_PROP_FPS) + ret, frame = capture.read() + if ret: + self.logger.log(f"Actual frame shape: {frame.shape}") + print(f"Actual frame shape: {frame.shape}") + else: + self.logger.log("Failed to read a frame after configuring camera") + print("Failed to read a frame after configuring camera") - fourcc = int(capture.get(cv2.CAP_PROP_FOURCC)) - fourcc_str = "".join([chr((fourcc >> 8*i) & 0xFF) for i in range(4)]) - - print(f"Resolution: {width}x{height}") - print(f"FPS: {fps}") - print(f"Format (FOURCC): {fourcc_str}") - self.logger.log(f"Resolution: {width}x{height}") - self.logger.log(f"FPS: {fps}") - self.logger.log(f"Format (FOURCC): {fourcc_str}") - #currentcapture = cv2.VideoCapture(index, cv2.CAP_MSMF) - #self.logger.log("Initial frame:", w, "x", h) self.cameraList.append(CameraObject( capture=capture, @@ -185,7 +177,7 @@ class SetupPage(QWidget): index = int(self.cameraIndex.text()) self.release_camera() - self.cameraObject.capture = cv2.VideoCapture(index) + self.cameraObject.capture = cv2.VideoCapture(index, cv2.CAP_DSHOW) self.cameraObject.index = index if not self.cameraObject.capture.isOpened(): diff --git a/source/UI/__pycache__/SetupPageWidget.cpython-311.pyc b/source/UI/__pycache__/SetupPageWidget.cpython-311.pyc index e55ab62df2fe85b0f77c398740aa3d487c46be41..cb626e37032264a267a3d99d1007fb806826120c 100644 GIT binary patch delta 3146 zcmZ`*eQZb>~A1gCzscl{ia zESamSYiY-{WS6O|>jdiYnEMxk;NYPB>L<@nh60Q3X8t#e;c!W{BrH59bFwjG zRc#3y>zgum)sb+h&V-YV%^8;}Bm~u+aI>)`<59(g$of{`c@tjNw*kE_QK!}?>eYrs zgX&B8)W$?382#3ca2Hh2v zRE0?Ku}LMR>Eiy;v$=CeW5a_{DX$fY^6O8N#)^8_a77Ac=svNCC$FI|cikaS~6QHM-o`;9sjzew^*&jkMj? zNdMSYZ#B-@!*$I72WFs)J$}O#FTLdnITrW}<1z=~EYP1dg-humq+j}y6})M}5RUM= zFrF)tR4Q30jukXA>m5}x5ax;;9M2I|(sl)B8~RgPQOX3z2?TYpa8}AIK7pAmxe0@l1BB#=ZUkeHH(r}i*ysNr<<7}gk{wbs>A3dF@T7bU{vakG^Wq-T)!+6 zjCazER9?}I%|y|3GuuxA&H{p-H3^Qfjt`Kj8q*A6PM%g_e8% zjc;$i8Tzg1y@O@{=(2yb;vZeGE?7UiSHFIRGYTya;jY+l(fPXP2cDlBuk`=aa@Ddd zhAU#YEQariz8|J8wJ(le9A6YJ3Jbzrv3bE({e1>)#R`h>?chp+_rl@t$6t$=14B2h zl`VtthG*FotGHriSL~k4Tdv!CWumg-X?Vl4?Aljx?JK+X-ERqA4!jZgQQMnsug1(4PM*fzbx*qh`Y<| zxxc!pa`2gDaik)Sl*JJ?V{lnKR1puA#X~C=x)6xB74Xn+?WiAc7;ZZp(RTCghJIr- zV7?PD!+^dYIP66Slf!u4SVDr}fPJl9tuG>>4&Vju1DkMu{8izCa9+6UsXwogF8X5Y zbFKZrjPQh2)3wuqOk(tl)(-v^8f@EHtu{-f?Rmb$rnpHfieMm+QvkX#HPxrvQd3$q zmw~7gqVXf~k>tSONZ-id06C3vHk32Tk|ycGL6#Fq;O*SGlWa$hy`X5r>1kEU6FVD^ zlbkxFLhXy#SsaqDqZZQ{K&k|Hb%t=b2Vk~oO_?m_VFjgHDZfi^2X8{9F0{|{rEZY? zs14w{gACx*jR=@Px)qi_u1ss>B#xXpD9g!Ovh6~OEnR@rO;0J1ESf|#w(505)hbj| zfO-MJU#;AI(0`Il8FT0o)jHn-gPKBj_+w}g!HFb{KoAND(*TcX+y;c%<~5tl z6J;u$D;C(j_7;l2OJ_RU>CgtyPCs|I@VDvK&r)S6fdcutolU;yL8vdw5;T6pAWh{+CQA!KYA$s?3&F8i3uR%j&%3%%qWD}j-F?rwh@J33K5KYU3kbDET&m~tClYB@C^M| zpGd#xYu(BGqFc0?JY3;~ofaB@og>UbJk|f&_NY0zm{Gn;-h?3!|3Vu*x-HUtpW9x3 c(nDX{cD4Vok%yb|$+%$sgmFIo51$E72f47a!8644^by|i?T$^k{!#sU|F_g-LfRdDbs|dxoe36FASHmEKp^D z999e?J_dn>7)F$$c5TOQ)c8-(ppDzKK>d$I_lL273lyl^x_|1R{So9(`(`OhlWk0{t)Rbl!j^UJHO+@R{%6(N> z7b)@;Mf5(sJXVJd5#&P=qt7UhHDOc4>@&-KEy#*}#d2QPdC zpzuJ?`jwgz5MdjMshq5loL5a5WJY<8`$yGLR~d9yth2n3j86yVd9U9eiiQNg-}#_l zeL!O{Q>2j+M1E*+utnrUL$!t;3pSCT8?LcxGK1P#4S5^Y>8gP3QQ{i%Q{>UqQH+~B zLA7l2=SGWytta>ETo3k`BkaCXkV(o=RKOnEQwjXC;898nZ@Z)k%6q^yAV|=yy4`Q# zI`XLaAUjQ1%P6QBx6}^MZAj`k1|51eI>~1i7duHFS(ZU#b-7FPnwnwG>xkd_v{LcLfmMY-x4zXDb@^UOLEIHi^80cN zd06i_BAD_5hFLV^`@5#1JeN~{z}!;64Z>fpu}n%y)wJwVLr+_7oaX<5Z(G1=Uk-2G zrG0x>obEs28^t86wV8HJFp(2Fhh|lkQoc-T&PmlTml~yZk zN=DJ!mK)3LLYh(VY01iW=;U9;Ae8^s7=9v`pM%gMW6rkQ$a7>C_0T*8>f@5S?K(_k;t|%^+!@7!R z)YYH;3y6nJOUmE^hKL@7p~QwH&6!vd2ZK9t3H5$IwY5J?%1&BF$lIwbUJh~r~}fw+*wyrdf(nD$T1P7Fy(Fkdp$;q=VZv_Gd$ zvdZXFI3)1cpF0r9yxXAZS;@9Dni-fl=07!Zbnduhr`&W-lgvJTY-ITOoTQxR1w244 zQZ2?pQAv$y(!#^kP&r#4r#+eq@dyx4k(|sVeJIND%X6^-j>9ycVkZzueKy1i3zBAm z56v$KlJZPEA?b5Q`V#`iwv&KLfDb@k&9%djUC%lj^siCDJmjWt$xSDpt}bf&h5@p6 z?1{rAIvO{U_k;JsSx1lP=vflD~gCaVZK?e)i`!@GyQJ0uTT^ZE%T*ufeu8mzCyQvi2 zU0K^9(ROI<*kj~ezp9YW$0#W($gaRxK4 zsHI?j@1{Oe(Fl(AjE;LH-m8JqWT(qXFfxSRl%gUkX z9WQ&c@-Xl{ESBE`g+O5A5%P6eEqjUlxvYz=e^6ci4qGn6O>&{lxtj{}0S>aR$_=U^%)FxpEOo> z*U9y{2e2#7oowPy#<_sN=N>qOyj{5oHtnxkWz$W7#FIeo=;bJqpA?vexuk`)=iq4p zfh=Ij5QA63*6RQtqH(ZiHsJUFY{cFjnk4=NF5cw{Q3h3jQ@2ZtT%y z>wCod9&)kqT{uK*(;s>cgUq8TSXuT4RYHixCGD1>xt#i9Ok5&+nk(5yWTcq~U7s}z zEwU~Jz62`aAsP(wabe3|NzE_uQDNI+GT~`pGbG{h=I-*rm!4sTk$Q{Ai0QKop4jHr zQa?@3`1_R5r=6swh4jeWBzSXRiH8oRWfRlq1a7A!MhOOzU)Kz7pNtQM!u)vPTr9a` z0R>i*_gd>%k=$+l3z%`Wt#iDXvgPtZ%POY-5MVbYos?{Amy3@aA#x;O>b#iVfV_8n zjrOXE#cMCCg|3`&ptMk~HBYSMus3e@s?E=tZNibSyw}sTs>?W!OU@>eI+~2c&q<1C z9DfgFl78+q=Uw2#kX&S2CBw-P@5zzz*gTXVakKwVyU>X#X zaYr(o3}NckxnyM75}0AmsSpQ+IDzT$lfUk*>XbCXxp*i#kL3fRo4CWsxmDiPce>t4 zj7x_3etaK9G{GjyNPoM>2DOo`&oFg`A4#|Wcwk1!RzGK6PA_Y}ktP59FLHU|e*^sb BkbeLG