Skip to content

Commit f0c1f9e

Browse files
3D Canvas added with yolo-depth
1 parent c5a6698 commit f0c1f9e

9 files changed

Lines changed: 970 additions & 11 deletions

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,42 @@ those keypoints, so recognition is independent of hand size, distance, or handed
3232
Keys: `S` save, `C` clear, `U` undo, `1..6` color, `[` / `]` thickness,
3333
`SPACE` 2D/3D, `R` reset view, `K` skeleton, `D` debug, `H` help, `Q` quit.
3434

35+
## 3D Drawing
36+
37+
The **3D Drawing** mode uses the YOLO depth model (`models/yolo26s-depth.pt`), which
38+
returns a per pixel depth map in meters. Every drawn point takes its Z from the metric
39+
depth under the fingertip, so moving the hand closer or further really draws into the
40+
scene: the stroke is a 3D curve in space, not a flat picture placed on a board. The
41+
first drawn point sets the zero plane, everything after that is relative to it.
42+
Depth runs in a background thread, so tracking and drawing stay at camera speed.
43+
44+
Raw monocular depth flickers, so the pen does not follow it directly: the global scale
45+
drift of the map is cancelled against the static background, the fingertip depth goes
46+
through a one euro filter with a noise deadband and a speed limit, and the newest points
47+
of a stroke are low passed along the curve. A circle drawn without moving the hand comes
48+
out flat, while a deliberate push of 25 cm is kept in full.
49+
50+
By default strokes are colored by a depth gradient - warm near the camera, cool further
51+
away. `1..6` switch to a solid color, `G` goes back to the gradient.
52+
53+
Gestures and keys are the same as in 2D, with two additions:
54+
55+
- **Two ring fingers (hold)** - toggle depth colors for the whole frame, drawing keeps working
56+
- **Open palm (hold)** - reset the view
57+
58+
In 3D the fist rotates the drawing itself and two fists scale it, `SPACE` toggles
59+
depth colors, `R` resets the view.
60+
3561
## Run
3662

3763
```bash
3864
pip install -r requirements.txt
3965
python src/main.py
4066
```
67+
68+
Pick the mode in the menu, or skip it:
69+
70+
```bash
71+
python src/main.py --no-menu --mode 3d
72+
python src/main.py --no-menu --mode 3d --depth-size 448 --depth-every 1
73+
```

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
mediapipe>=0.10.30
2+
ultralytics>=8.4.19
3+
torch>=2.4
24
opencv-python>=4.10
35
numpy>=1.26
46
pillow>=10.0

src/app3d.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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

Comments
 (0)