Download src/canvas.py from ayzeksalimli/hand-tracking-drawing: direct link, hf CLI and curl.
- Browser
- Download file 8.44 kB
-
https://huggingface.co/ayzeksalimli/hand-tracking-drawing/resolve/main/src/canvas.py
- Command line
-
hf download hf://ayzeksalimli/hand-tracking-drawing/src/canvas.py
-
curl -L -o canvas.py https://huggingface.co/ayzeksalimli/hand-tracking-drawing/resolve/main/src/canvas.py
8.44 kB
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| from datetime import datetime | |
| from pathlib import Path | |
| import cv2 | |
| import numpy as np | |
| PALETTE: tuple[tuple[int, int, int], ...] = ( | |
| (60, 60, 255), | |
| (60, 220, 255), | |
| (90, 230, 90), | |
| (255, 190, 60), | |
| (255, 110, 200), | |
| (255, 255, 255), | |
| ) | |
| COLOR_NAMES: tuple[str, ...] = ( | |
| "red", "yellow", "green", "blue", "pink", "white", | |
| ) | |
| class Stroke: | |
| """One drawn stroke.""" | |
| color: tuple[int, int, int] | |
| thickness: int | |
| points: list[tuple[int, int]] = field(default_factory=list) | |
| class Canvas: | |
| """Vector drawing canvas.""" | |
| MAX_JUMP = 0.12 | |
| def __init__(self, width: int, height: int, output_dir: str | Path = "output") -> None: | |
| self.width = width | |
| self.height = height | |
| self.output_dir = Path(output_dir) | |
| self.strokes: list[Stroke] = [] | |
| self._active: Stroke | None = None | |
| self.color: tuple[int, int, int] = PALETTE[0] | |
| self.thickness: int = 6 | |
| self._layer = np.zeros((height, width, 3), dtype=np.uint8) | |
| self._mask = np.zeros((height, width), dtype=np.uint8) | |
| self._bounds: list[int] | None = None | |
| self.revision = 0 | |
| def begin(self) -> None: | |
| """Start a new stroke.""" | |
| if self._active is None: | |
| self._active = Stroke(color=self.color, thickness=self.thickness) | |
| self.strokes.append(self._active) | |
| def add_point(self, point) -> None: | |
| """Append point to stroke.""" | |
| if self._active is None: | |
| self.begin() | |
| assert self._active is not None | |
| x, y = int(round(float(point[0]))), int(round(float(point[1]))) | |
| x = max(0, min(self.width - 1, x)) | |
| y = max(0, min(self.height - 1, y)) | |
| pts = self._active.points | |
| if pts and pts[-1] == (x, y): | |
| return | |
| if pts: | |
| jump = np.hypot(x - pts[-1][0], y - pts[-1][1]) | |
| if jump > self.MAX_JUMP * np.hypot(self.width, self.height): | |
| self.end() | |
| self.begin() | |
| pts = self._active.points | |
| pts.append((x, y)) | |
| if len(pts) >= 2: | |
| self._draw_segment(pts[-2], pts[-1], self._active) | |
| else: | |
| self._draw_dot((x, y), self._active) | |
| def end(self) -> None: | |
| """Finish current stroke.""" | |
| if self._active is not None and len(self._active.points) < 2: | |
| if len(self._active.points) == 0: | |
| self.strokes.remove(self._active) | |
| self._rebuild() | |
| self._active = None | |
| def _grow_bounds(self, points, radius: int) -> None: | |
| """Extend content bounding box.""" | |
| xs = [p[0] for p in points] | |
| ys = [p[1] for p in points] | |
| x0, y0 = min(xs) - radius, min(ys) - radius | |
| x1, y1 = max(xs) + radius, max(ys) + radius | |
| if self._bounds is None: | |
| self._bounds = [x0, y0, x1, y1] | |
| else: | |
| b = self._bounds | |
| b[0], b[1] = min(b[0], x0), min(b[1], y0) | |
| b[2], b[3] = max(b[2], x1), max(b[3], y1) | |
| def _draw_segment(self, a, b, stroke: Stroke) -> None: | |
| """Draw one line segment.""" | |
| cv2.line(self._layer, a, b, stroke.color, stroke.thickness, cv2.LINE_AA) | |
| cv2.line(self._mask, a, b, 255, stroke.thickness, cv2.LINE_AA) | |
| self._grow_bounds((a, b), stroke.thickness // 2 + 2) | |
| self.revision += 1 | |
| def _draw_dot(self, p, stroke: Stroke) -> None: | |
| """Draw one dot.""" | |
| r = max(1, stroke.thickness // 2) | |
| cv2.circle(self._layer, p, r, stroke.color, -1, cv2.LINE_AA) | |
| cv2.circle(self._mask, p, r, 255, -1, cv2.LINE_AA) | |
| self._grow_bounds((p,), r + 2) | |
| self.revision += 1 | |
| def _rebuild(self) -> None: | |
| """Redraw all strokes.""" | |
| self._layer[:] = 0 | |
| self._mask[:] = 0 | |
| self._bounds = None | |
| self.revision += 1 | |
| for s in self.strokes: | |
| if len(s.points) == 1: | |
| self._draw_dot(s.points[0], s) | |
| for a, b in zip(s.points, s.points[1:]): | |
| self._draw_segment(a, b, s) | |
| def undo(self) -> None: | |
| """Remove last stroke.""" | |
| if self.strokes: | |
| self.strokes.pop() | |
| self._active = None | |
| self._rebuild() | |
| def clear(self) -> None: | |
| """Erase whole canvas.""" | |
| self.strokes.clear() | |
| self._active = None | |
| self._rebuild() | |
| def scale_content(self, factor: float) -> None: | |
| """Scale drawing about center.""" | |
| if not self.strokes or abs(factor - 1.0) < 1e-3: | |
| return | |
| cx, cy = self.width * 0.5, self.height * 0.5 | |
| for s in self.strokes: | |
| s.points = [ | |
| ( | |
| int(round(cx + (x - cx) * factor)), | |
| int(round(cy + (y - cy) * factor)), | |
| ) | |
| for x, y in s.points | |
| ] | |
| s.thickness = int(max(1, min(48, round(s.thickness * factor)))) | |
| self._active = None | |
| self._rebuild() | |
| def content_span(self) -> float: | |
| """Drawing diagonal length.""" | |
| box = self.content_bbox(padding=0) | |
| if box is None: | |
| return 0.0 | |
| x0, y0, x1, y1 = box | |
| return float(np.hypot(x1 - x0, y1 - y0)) | |
| def set_color(self, index: int) -> None: | |
| """Select palette color.""" | |
| self.color = PALETTE[index % len(PALETTE)] | |
| def set_thickness(self, value: int) -> None: | |
| """Set stroke thickness.""" | |
| self.thickness = int(max(1, min(48, value))) | |
| def is_empty(self) -> bool: | |
| """No strokes drawn.""" | |
| return not any(s.points for s in self.strokes) | |
| def layer(self) -> np.ndarray: | |
| """Color drawing layer.""" | |
| return self._layer | |
| def mask(self) -> np.ndarray: | |
| """Drawing alpha mask.""" | |
| return self._mask | |
| def composite_over(self, frame: np.ndarray, opacity: float = 1.0) -> np.ndarray: | |
| """Blend drawing onto frame.""" | |
| out = frame.copy() | |
| box = self.content_bbox(padding=2) | |
| if box is None: | |
| return out | |
| x0, y0, x1, y1 = box | |
| roi = out[y0:y1, x0:x1] | |
| layer = self._layer[y0:y1, x0:x1] | |
| mask = self._mask[y0:y1, x0:x1] | |
| if opacity < 1.0: | |
| mask = (mask.astype(np.float32) * opacity).astype(np.uint8) | |
| a = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) | |
| inv = cv2.bitwise_not(a) | |
| blended = cv2.add( | |
| cv2.multiply(roi, inv, scale=1 / 255.0), | |
| cv2.multiply(layer, a, scale=1 / 255.0), | |
| ) | |
| out[y0:y1, x0:x1] = blended | |
| return out | |
| def to_bgra(self) -> np.ndarray: | |
| """Drawing with transparent background.""" | |
| bgra = np.dstack([self._layer, self._mask]) | |
| return bgra | |
| def content_bbox(self, padding: int = 24) -> tuple[int, int, int, int] | None: | |
| """Bounding box of drawing.""" | |
| if self._bounds is None: | |
| return None | |
| bx0, by0, bx1, by1 = self._bounds | |
| x0 = max(0, bx0 - padding) | |
| y0 = max(0, by0 - padding) | |
| x1 = min(self.width, bx1 + padding + 1) | |
| y1 = min(self.height, by1 + padding + 1) | |
| if x1 <= x0 or y1 <= y0: | |
| return None | |
| return x0, y0, x1, y1 | |
| def cropped_bgra(self, padding: int = 24) -> np.ndarray | None: | |
| """Cropped transparent drawing.""" | |
| box = self.content_bbox(padding) | |
| if box is None: | |
| return None | |
| x0, y0, x1, y1 = box | |
| return self.to_bgra()[y0:y1, x0:x1] | |
| def save(self, tag: str = "") -> list[Path]: | |
| """Save canvas as PNGs.""" | |
| self.output_dir.mkdir(parents=True, exist_ok=True) | |
| stamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| suffix = f"_{tag}" if tag else "" | |
| saved: list[Path] = [] | |
| transparent = self.output_dir / f"drawing_{stamp}{suffix}.png" | |
| cv2.imwrite(str(transparent), self.to_bgra()) | |
| saved.append(transparent) | |
| white = np.full((self.height, self.width, 3), 255, dtype=np.uint8) | |
| alpha = (self._mask.astype(np.float32) / 255.0)[:, :, None] | |
| flat = (white * (1.0 - alpha) + self._layer * alpha).astype(np.uint8) | |
| on_white = self.output_dir / f"drawing_{stamp}{suffix}_white.png" | |
| cv2.imwrite(str(on_white), flat) | |
| saved.append(on_white) | |
| return saved | |