|
| 1 | +"""3D drawing mode built on monocular depth.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import argparse |
| 6 | +import time |
| 7 | + |
| 8 | +import cv2 |
| 9 | +import numpy as np |
| 10 | + |
| 11 | +import ui |
| 12 | +from canvas3d import Canvas3D, View |
| 13 | +from depth import DepthEstimator, DepthWorker |
| 14 | +from gestures import Gesture |
| 15 | +from main import App |
| 16 | + |
| 17 | +RING_HOLD_SECONDS = 0.35 |
| 18 | +PALM_RESET_SECONDS = 0.6 |
| 19 | +DEPTH_MIX = 0.78 |
| 20 | +SCENE_ALPHA = 0.02 |
| 21 | + |
| 22 | + |
| 23 | +class App3D(App): |
| 24 | + """Draw real 3D strokes using estimated depth.""" |
| 25 | + |
| 26 | + def __init__(self, args: argparse.Namespace) -> None: |
| 27 | + super().__init__(args) |
| 28 | + self.canvas = Canvas3D(self.w, self.h, args.output) |
| 29 | + self.depth = DepthWorker(args.depth_model, imgsz=args.depth_size) |
| 30 | + print(f"[i] depth model: {args.depth_model}", flush=True) |
| 31 | + print(f"[i] depth device: {self.depth.device} imgsz: {args.depth_size}", flush=True) |
| 32 | + |
| 33 | + self.depth_map: np.ndarray | None = None |
| 34 | + self.depth_norm: np.ndarray | None = None |
| 35 | + self.depth_view = False |
| 36 | + self._view = View() |
| 37 | + self._depth_seq = -1 |
| 38 | + self._scene_ref: float | None = None |
| 39 | + self._scene_gain = 1.0 |
| 40 | + self._depth_announced = False |
| 41 | + self._frame_index = 0 |
| 42 | + self._ring_since: float | None = None |
| 43 | + self._ring_latched = False |
| 44 | + self._palm_since: float | None = None |
| 45 | + self._palm_latched = False |
| 46 | + |
| 47 | + self.yaw, self.pitch, self.roll, self.scale = 0.0, 0.0, 0.0, 1.0 |
| 48 | + self.yaw_s.set(0.0) |
| 49 | + self.pitch_s.set(0.0) |
| 50 | + self.scale_s.set(1.0) |
| 51 | + |
| 52 | + def view(self) -> View: |
| 53 | + """Current smoothed camera.""" |
| 54 | + return self._view |
| 55 | + |
| 56 | + def step_view(self) -> View: |
| 57 | + """Advance the smoothed camera.""" |
| 58 | + self._view = View(self.yaw_s(self.yaw), self.pitch_s(self.pitch), |
| 59 | + self.roll, self.scale_s(self.scale)) |
| 60 | + self.canvas.set_view(self._view) |
| 61 | + return self._view |
| 62 | + |
| 63 | + def on_frame(self, frame: np.ndarray) -> None: |
| 64 | + """Feed the depth worker.""" |
| 65 | + self.step_view() |
| 66 | + self._frame_index += 1 |
| 67 | + if self._frame_index % max(1, self.args.depth_every) == 0: |
| 68 | + self.depth.submit(frame) |
| 69 | + latest = self.depth.latest |
| 70 | + if latest is None or self.depth.frames == self._depth_seq: |
| 71 | + return |
| 72 | + self._depth_seq = self.depth.frames |
| 73 | + self.depth_map = latest |
| 74 | + self.depth_norm = self.depth.latest_norm |
| 75 | + self.track_scene(latest) |
| 76 | + if not self._depth_announced: |
| 77 | + self._depth_announced = True |
| 78 | + self.toast("Depth ready. Move your hand closer or further to draw in depth") |
| 79 | + |
| 80 | + def track_scene(self, depth_map: np.ndarray) -> None: |
| 81 | + """Cancel global depth drift.""" |
| 82 | + sample = depth_map[::8, ::8] |
| 83 | + sample = sample[np.isfinite(sample)] |
| 84 | + if sample.size == 0: |
| 85 | + return |
| 86 | + median = float(np.median(sample)) |
| 87 | + if median <= 0.0: |
| 88 | + return |
| 89 | + if self._scene_ref is None: |
| 90 | + self._scene_ref = median |
| 91 | + else: |
| 92 | + self._scene_ref += SCENE_ALPHA * (median - self._scene_ref) |
| 93 | + self._scene_gain = float(np.clip(self._scene_ref / median, 0.5, 2.0)) |
| 94 | + |
| 95 | + def depth_at(self, point) -> float: |
| 96 | + """Metric depth under a screen point.""" |
| 97 | + if self.depth_map is None: |
| 98 | + return float("nan") |
| 99 | + d = DepthEstimator.sample(self.depth_map, float(point[0]), float(point[1]), |
| 100 | + default=float("nan")) |
| 101 | + return d * self._scene_gain |
| 102 | + |
| 103 | + def depth_readout(self) -> str: |
| 104 | + """Depth status line.""" |
| 105 | + if self.depth_map is None: |
| 106 | + return "depth: warming up" |
| 107 | + if self.canvas.depth_ref is None: |
| 108 | + return f"depth: {self.canvas.last_depth:.2f} m draw to set the zero plane" |
| 109 | + offset = (self.canvas.last_depth - self.canvas.depth_ref) * 100.0 |
| 110 | + turn = "yaw %+.0f pitch %+.0f" % (np.degrees(self._view.yaw), np.degrees(self._view.pitch)) |
| 111 | + return f"depth: {self.canvas.last_depth:.2f} m z {offset:+.0f} cm {turn}" |
| 112 | + |
| 113 | + def handle_ring(self, states: list) -> None: |
| 114 | + """Toggle depth colors on two ring fingers.""" |
| 115 | + rings = [s for s in states if s.gesture == Gesture.RING and s.stable] |
| 116 | + if len(rings) < 2: |
| 117 | + self._ring_since = None |
| 118 | + self._ring_latched = False |
| 119 | + return |
| 120 | + now = time.time() |
| 121 | + self._ring_since = self._ring_since or now |
| 122 | + if not self._ring_latched and now - self._ring_since >= RING_HOLD_SECONDS: |
| 123 | + self._ring_latched = True |
| 124 | + self.depth_view = not self.depth_view |
| 125 | + self.toast("Depth colors " + ("on" if self.depth_view else "off")) |
| 126 | + |
| 127 | + def handle_palm(self, states: list) -> None: |
| 128 | + """Reset the view on open palm.""" |
| 129 | + palms = [s for s in states if s.gesture == Gesture.OPEN_PALM and s.stable] |
| 130 | + if not palms: |
| 131 | + self._palm_since = None |
| 132 | + self._palm_latched = False |
| 133 | + return |
| 134 | + now = time.time() |
| 135 | + self._palm_since = self._palm_since or now |
| 136 | + if not self._palm_latched and now - self._palm_since >= PALM_RESET_SECONDS: |
| 137 | + self._palm_latched = True |
| 138 | + self.reset_view() |
| 139 | + self.toast("View reset") |
| 140 | + |
| 141 | + def reset_view(self) -> None: |
| 142 | + """Back to the front view.""" |
| 143 | + self.yaw, self.pitch, self.roll, self.scale = 0.0, 0.0, 0.0, 1.0 |
| 144 | + self.yaw_s.set(0.0) |
| 145 | + self.pitch_s.set(0.0) |
| 146 | + self.scale_s.set(1.0) |
| 147 | + self._grab = None |
| 148 | + self._grab_span = None |
| 149 | + self.step_view() |
| 150 | + |
| 151 | + def rotate_view(self, grabbing: list) -> None: |
| 152 | + """Rotate and scale the drawing.""" |
| 153 | + p = grabbing[0].pinch_point |
| 154 | + if self._grab is None: |
| 155 | + self._grab = (p.copy(), self.yaw, self.pitch) |
| 156 | + anchor, yaw0, pitch0 = self._grab |
| 157 | + dx = (p[0] - anchor[0]) / self.w |
| 158 | + dy = (p[1] - anchor[1]) / self.h |
| 159 | + self.yaw = yaw0 + dx * 2.0 * np.pi * 1.1 |
| 160 | + self.pitch = float(np.clip(pitch0 + dy * np.pi * 1.1, -1.3, 1.3)) |
| 161 | + |
| 162 | + if len(grabbing) >= 2: |
| 163 | + span = float(np.linalg.norm(grabbing[0].pinch_point - grabbing[1].pinch_point)) |
| 164 | + if self._grab_span is None: |
| 165 | + self._grab_span = (span, self.scale) |
| 166 | + span0, scale0 = self._grab_span |
| 167 | + if span0 > 1e-3: |
| 168 | + self.scale = float(np.clip(scale0 * (span / span0), 0.25, 4.0)) |
| 169 | + else: |
| 170 | + self._grab_span = None |
| 171 | + |
| 172 | + def handle_draw_mode(self, states: list, dt: float) -> None: |
| 173 | + """Draw in 3D, grab to rotate.""" |
| 174 | + self.handle_ring(states) |
| 175 | + self.handle_palm(states) |
| 176 | + |
| 177 | + drawing = [s for s in states if s.gesture == Gesture.DRAW] |
| 178 | + grabbing = [s for s in states if s.gesture == Gesture.GRAB] |
| 179 | + |
| 180 | + if grabbing: |
| 181 | + self.stop_stroke(force=True) |
| 182 | + self.rotate_view(grabbing) |
| 183 | + self.grab_since = None |
| 184 | + return |
| 185 | + |
| 186 | + self._grab = None |
| 187 | + self._grab_span = None |
| 188 | + |
| 189 | + if drawing: |
| 190 | + point = self.tip_filter(drawing[0].cursor, dt) |
| 191 | + self.canvas.begin(self._view) |
| 192 | + self.canvas.add_point(point, self.depth_at(point), dt, self._view) |
| 193 | + self._draw_lost_since = None |
| 194 | + self.grab_since = None |
| 195 | + return |
| 196 | + |
| 197 | + self.stop_stroke() |
| 198 | + self.grab_since = None |
| 199 | + |
| 200 | + def clear_canvas(self) -> None: |
| 201 | + """Wipe the drawing.""" |
| 202 | + self.stop_stroke(force=True) |
| 203 | + self.canvas.clear() |
| 204 | + self._grab = None |
| 205 | + self._grab_span = None |
| 206 | + self.grab_since = None |
| 207 | + self.toast("Canvas cleared") |
| 208 | + |
| 209 | + def background(self, frame: np.ndarray) -> np.ndarray: |
| 210 | + """Camera or depth colored frame.""" |
| 211 | + if not self.depth_view or self.depth_norm is None: |
| 212 | + return frame |
| 213 | + colored = DepthEstimator.colorize(self.depth_norm) |
| 214 | + return cv2.addWeighted(colored, DEPTH_MIX, frame, 1.0 - DEPTH_MIX, 0.0) |
| 215 | + |
| 216 | + def render(self, frame: np.ndarray, hands, states) -> np.ndarray: |
| 217 | + """Compose the output frame.""" |
| 218 | + view = self._view |
| 219 | + out = self.canvas.render_over(self.background(frame), view) |
| 220 | + |
| 221 | + if self.show_skeleton: |
| 222 | + for hand, st in zip(hands, states): |
| 223 | + ui.draw_hand(out, hand, st) |
| 224 | + pen = self.canvas.pen_color() |
| 225 | + active = self.active_state(states) |
| 226 | + if active is not None: |
| 227 | + ui.draw_cursor(out, active.cursor, active.gesture, pen, self.canvas.thickness) |
| 228 | + |
| 229 | + shown = Gesture.NONE if active is None else active.gesture |
| 230 | + if len([s for s in states if s.gesture == Gesture.GRAB]) >= 2: |
| 231 | + shown = Gesture.ZOOM |
| 232 | + ui.draw_hud( |
| 233 | + out, |
| 234 | + gesture=shown, |
| 235 | + hands_info=[(s.handedness, s.gesture) for s in states], |
| 236 | + mode_3d=False, |
| 237 | + fps=self.fps, |
| 238 | + device=self.device, |
| 239 | + color=pen, |
| 240 | + thickness=self.canvas.thickness, |
| 241 | + strokes=len(self.canvas.strokes), |
| 242 | + clear_progress=0.0, |
| 243 | + toast=self.toast_text if time.time() < self.toast_until else "", |
| 244 | + mode_label="3D DRAW", |
| 245 | + hint="Finger draw Fist rotate Two rings depth", |
| 246 | + ) |
| 247 | + ui.put_text(out, self.depth_readout(), (16, 84), 16, (170, 220, 255)) |
| 248 | + if self.show_debug and states: |
| 249 | + ui.draw_debug(out, hands, states) |
| 250 | + if self.show_help: |
| 251 | + ui.draw_help(out, ui.HELP_LINES_3D) |
| 252 | + return out |
| 253 | + |
| 254 | + def handle_key(self, key: int) -> bool: |
| 255 | + """Handle one keypress.""" |
| 256 | + if key == ord("g"): |
| 257 | + self.canvas.use_gradient() |
| 258 | + self.toast("Depth gradient color") |
| 259 | + return True |
| 260 | + if key == ord(" "): |
| 261 | + self.depth_view = not self.depth_view |
| 262 | + self.toast("Depth colors " + ("on" if self.depth_view else "off")) |
| 263 | + return True |
| 264 | + if key == ord("r"): |
| 265 | + self.reset_view() |
| 266 | + self.toast("View reset") |
| 267 | + return True |
| 268 | + return super().handle_key(key) |
| 269 | + |
| 270 | + def run(self) -> None: |
| 271 | + """Run the main loop.""" |
| 272 | + try: |
| 273 | + super().run() |
| 274 | + finally: |
| 275 | + self.depth.close() |
0 commit comments