File size: 10,057 Bytes
9f85448
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
"""3D drawing mode built on monocular depth."""

from __future__ import annotations

import argparse
import time

import cv2
import numpy as np

import ui
from canvas3d import Canvas3D, View
from depth import DepthEstimator, DepthWorker
from gestures import Gesture
from main import App

RING_HOLD_SECONDS = 0.35
PALM_RESET_SECONDS = 0.6
DEPTH_MIX = 0.78
SCENE_ALPHA = 0.02


class App3D(App):
    """Draw real 3D strokes using estimated depth."""

    def __init__(self, args: argparse.Namespace) -> None:
        super().__init__(args)
        self.canvas = Canvas3D(self.w, self.h, args.output)
        self.depth = DepthWorker(args.depth_model, imgsz=args.depth_size)
        print(f"[i] depth model: {args.depth_model}", flush=True)
        print(f"[i] depth device: {self.depth.device}  imgsz: {args.depth_size}", flush=True)

        self.depth_map: np.ndarray | None = None
        self.depth_norm: np.ndarray | None = None
        self.depth_view = False
        self._view = View()
        self._depth_seq = -1
        self._scene_ref: float | None = None
        self._scene_gain = 1.0
        self._depth_announced = False
        self._frame_index = 0
        self._ring_since: float | None = None
        self._ring_latched = False
        self._palm_since: float | None = None
        self._palm_latched = False

        self.yaw, self.pitch, self.roll, self.scale = 0.0, 0.0, 0.0, 1.0
        self.yaw_s.set(0.0)
        self.pitch_s.set(0.0)
        self.scale_s.set(1.0)

    def view(self) -> View:
        """Current smoothed camera."""
        return self._view

    def step_view(self) -> View:
        """Advance the smoothed camera."""
        self._view = View(self.yaw_s(self.yaw), self.pitch_s(self.pitch),
                          self.roll, self.scale_s(self.scale))
        self.canvas.set_view(self._view)
        return self._view

    def on_frame(self, frame: np.ndarray) -> None:
        """Feed the depth worker."""
        self.step_view()
        self._frame_index += 1
        if self._frame_index % max(1, self.args.depth_every) == 0:
            self.depth.submit(frame)
        latest = self.depth.latest
        if latest is None or self.depth.frames == self._depth_seq:
            return
        self._depth_seq = self.depth.frames
        self.depth_map = latest
        self.depth_norm = self.depth.latest_norm
        self.track_scene(latest)
        if not self._depth_announced:
            self._depth_announced = True
            self.toast("Depth ready. Move your hand closer or further to draw in depth")

    def track_scene(self, depth_map: np.ndarray) -> None:
        """Cancel global depth drift."""
        sample = depth_map[::8, ::8]
        sample = sample[np.isfinite(sample)]
        if sample.size == 0:
            return
        median = float(np.median(sample))
        if median <= 0.0:
            return
        if self._scene_ref is None:
            self._scene_ref = median
        else:
            self._scene_ref += SCENE_ALPHA * (median - self._scene_ref)
        self._scene_gain = float(np.clip(self._scene_ref / median, 0.5, 2.0))

    def depth_at(self, point) -> float:
        """Metric depth under a screen point."""
        if self.depth_map is None:
            return float("nan")
        d = DepthEstimator.sample(self.depth_map, float(point[0]), float(point[1]),
                                  default=float("nan"))
        return d * self._scene_gain

    def depth_readout(self) -> str:
        """Depth status line."""
        if self.depth_map is None:
            return "depth: warming up"
        if self.canvas.depth_ref is None:
            return f"depth: {self.canvas.last_depth:.2f} m   draw to set the zero plane"
        offset = (self.canvas.last_depth - self.canvas.depth_ref) * 100.0
        turn = "yaw %+.0f pitch %+.0f" % (np.degrees(self._view.yaw), np.degrees(self._view.pitch))
        return f"depth: {self.canvas.last_depth:.2f} m   z {offset:+.0f} cm   {turn}"

    def handle_ring(self, states: list) -> None:
        """Toggle depth colors on two ring fingers."""
        rings = [s for s in states if s.gesture == Gesture.RING and s.stable]
        if len(rings) < 2:
            self._ring_since = None
            self._ring_latched = False
            return
        now = time.time()
        self._ring_since = self._ring_since or now
        if not self._ring_latched and now - self._ring_since >= RING_HOLD_SECONDS:
            self._ring_latched = True
            self.depth_view = not self.depth_view
            self.toast("Depth colors " + ("on" if self.depth_view else "off"))

    def handle_palm(self, states: list) -> None:
        """Reset the view on open palm."""
        palms = [s for s in states if s.gesture == Gesture.OPEN_PALM and s.stable]
        if not palms:
            self._palm_since = None
            self._palm_latched = False
            return
        now = time.time()
        self._palm_since = self._palm_since or now
        if not self._palm_latched and now - self._palm_since >= PALM_RESET_SECONDS:
            self._palm_latched = True
            self.reset_view()
            self.toast("View reset")

    def reset_view(self) -> None:
        """Back to the front view."""
        self.yaw, self.pitch, self.roll, self.scale = 0.0, 0.0, 0.0, 1.0
        self.yaw_s.set(0.0)
        self.pitch_s.set(0.0)
        self.scale_s.set(1.0)
        self._grab = None
        self._grab_span = None
        self.step_view()

    def rotate_view(self, grabbing: list) -> None:
        """Rotate and scale the drawing."""
        p = grabbing[0].pinch_point
        if self._grab is None:
            self._grab = (p.copy(), self.yaw, self.pitch)
        anchor, yaw0, pitch0 = self._grab
        dx = (p[0] - anchor[0]) / self.w
        dy = (p[1] - anchor[1]) / self.h
        self.yaw = yaw0 + dx * 2.0 * np.pi * 1.1
        self.pitch = float(np.clip(pitch0 + dy * np.pi * 1.1, -1.3, 1.3))

        if len(grabbing) >= 2:
            span = float(np.linalg.norm(grabbing[0].pinch_point - grabbing[1].pinch_point))
            if self._grab_span is None:
                self._grab_span = (span, self.scale)
            span0, scale0 = self._grab_span
            if span0 > 1e-3:
                self.scale = float(np.clip(scale0 * (span / span0), 0.25, 4.0))
        else:
            self._grab_span = None

    def handle_draw_mode(self, states: list, dt: float) -> None:
        """Draw in 3D, grab to rotate."""
        self.handle_ring(states)
        self.handle_palm(states)

        drawing = [s for s in states if s.gesture == Gesture.DRAW]
        grabbing = [s for s in states if s.gesture == Gesture.GRAB]

        if grabbing:
            self.stop_stroke(force=True)
            self.rotate_view(grabbing)
            self.grab_since = None
            return

        self._grab = None
        self._grab_span = None

        if drawing:
            point = self.tip_filter(drawing[0].cursor, dt)
            self.canvas.begin(self._view)
            self.canvas.add_point(point, self.depth_at(point), dt, self._view)
            self._draw_lost_since = None
            self.grab_since = None
            return

        self.stop_stroke()
        self.grab_since = None

    def clear_canvas(self) -> None:
        """Wipe the drawing."""
        self.stop_stroke(force=True)
        self.canvas.clear()
        self._grab = None
        self._grab_span = None
        self.grab_since = None
        self.toast("Canvas cleared")

    def background(self, frame: np.ndarray) -> np.ndarray:
        """Camera or depth colored frame."""
        if not self.depth_view or self.depth_norm is None:
            return frame
        colored = DepthEstimator.colorize(self.depth_norm)
        return cv2.addWeighted(colored, DEPTH_MIX, frame, 1.0 - DEPTH_MIX, 0.0)

    def render(self, frame: np.ndarray, hands, states) -> np.ndarray:
        """Compose the output frame."""
        view = self._view
        out = self.canvas.render_over(self.background(frame), view)

        if self.show_skeleton:
            for hand, st in zip(hands, states):
                ui.draw_hand(out, hand, st)
        pen = self.canvas.pen_color()
        active = self.active_state(states)
        if active is not None:
            ui.draw_cursor(out, active.cursor, active.gesture, pen, self.canvas.thickness)

        shown = Gesture.NONE if active is None else active.gesture
        if len([s for s in states if s.gesture == Gesture.GRAB]) >= 2:
            shown = Gesture.ZOOM
        ui.draw_hud(
            out,
            gesture=shown,
            hands_info=[(s.handedness, s.gesture) for s in states],
            mode_3d=False,
            fps=self.fps,
            device=self.device,
            color=pen,
            thickness=self.canvas.thickness,
            strokes=len(self.canvas.strokes),
            clear_progress=0.0,
            toast=self.toast_text if time.time() < self.toast_until else "",
            mode_label="3D DRAW",
            hint="Finger draw    Fist rotate    Two rings depth",
        )
        ui.put_text(out, self.depth_readout(), (16, 84), 16, (170, 220, 255))
        if self.show_debug and states:
            ui.draw_debug(out, hands, states)
        if self.show_help:
            ui.draw_help(out, ui.HELP_LINES_3D)
        return out

    def handle_key(self, key: int) -> bool:
        """Handle one keypress."""
        if key == ord("g"):
            self.canvas.use_gradient()
            self.toast("Depth gradient color")
            return True
        if key == ord(" "):
            self.depth_view = not self.depth_view
            self.toast("Depth colors " + ("on" if self.depth_view else "off"))
            return True
        if key == ord("r"):
            self.reset_view()
            self.toast("View reset")
            return True
        return super().handle_key(key)

    def run(self) -> None:
        """Run the main loop."""
        try:
            super().run()
        finally:
            self.depth.close()