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", ) @dataclass 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))) @property def is_empty(self) -> bool: """No strokes drawn.""" return not any(s.points for s in self.strokes) @property def layer(self) -> np.ndarray: """Color drawing layer.""" return self._layer @property 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