diff --git a/data_only_viz/iphone_usb_source.py b/data_only_viz/iphone_usb_source.py index 0321e63..3f68242 100644 --- a/data_only_viz/iphone_usb_source.py +++ b/data_only_viz/iphone_usb_source.py @@ -51,6 +51,19 @@ def rotate_norm_xy(x: float, y: float, mode: str) -> "tuple[float, float]": return f(x, y) if f else (x, y) +def effective_point_rotate(mode: str, mirror: bool) -> str: + """Point-rotation mode equivalent to the video pipeline's transform. + + The VIDEO is horizontally flipped at decode (mirror) THEN rotated by + multi.py; the POINTS are rotated here THEN flipped at display time by + the renderer. Flip and 90-degree rotations do not commute: under the + mirror the point rotation must be the mirror-conjugate of the video + rotation (cw <-> ccw swapped; 180 commutes).""" + if mirror and mode in ("cw", "ccw"): + return "cw" if mode == "ccw" else "ccw" + return mode + + def _make_point(x: float, y: float, z: float, conf: float) -> PoseKp: """Wrap raw floats from the wire into a PoseKp landmark. @@ -150,8 +163,9 @@ class IphoneUSBSource: self._mirror = mirror and _cfg.concert_mirror # VIDEO_ROTATE also applies to the iPhone 2D landmarks (skeleton2D + # hands) so they stay aligned with the rotated video (multi.py only - # rotates the frame). - self._video_rotate = _cfg.video_rotate or "none" + # rotates the frame). Mirror-conjugated: see effective_point_rotate. + self._video_rotate = effective_point_rotate( + _cfg.video_rotate or "none", self._mirror) self._codec = av.codec.CodecContext.create("hevc", "r") if av is not None else None self._lock = threading.Lock() self._frame = None # latest BGR np.ndarray diff --git a/data_only_viz/tests/test_iphone_usb_source.py b/data_only_viz/tests/test_iphone_usb_source.py index da9b88c..46f48a0 100644 --- a/data_only_viz/tests/test_iphone_usb_source.py +++ b/data_only_viz/tests/test_iphone_usb_source.py @@ -301,3 +301,23 @@ def test_rotate_norm_xy_matches_rot90(): assert rotate_norm_xy(0.2, 0.7, "180") == pytest.approx((0.8, 0.3)) assert rotate_norm_xy(0.2, 0.7, "none") == (0.2, 0.7) assert rotate_norm_xy(0.2, 0.7, "garbage") == (0.2, 0.7) + + +def test_effective_point_rotate_mirror_conjugation(): + """Video = flip THEN rotate; points = rotate THEN flip (renderer). + Prove the conjugated mode lands points where the video pixel lands: + flip_x(T(x,y)) must equal rot(flip_x(x,y)) for T = conjugate(rot).""" + from data_only_viz.iphone_usb_source import ( + effective_point_rotate, rotate_norm_xy, + ) + flip = lambda x, y: (1.0 - x, y) + for video_mode in ("ccw", "cw", "180", "none"): + pt_mode = effective_point_rotate(video_mode, mirror=True) + for (x, y) in [(0.2, 0.7), (0.9, 0.1), (0.5, 0.5)]: + video_dest = rotate_norm_xy(*flip(x, y), video_mode) + point_dest = flip(*rotate_norm_xy(x, y, pt_mode)) + assert video_dest == pytest.approx(point_dest), ( + video_mode, x, y) + # No mirror: modes pass through untouched + assert effective_point_rotate("ccw", mirror=False) == "ccw" + assert effective_point_rotate("none", mirror=True) == "none"