ayzeksalimli's picture
Push project (code, README, Docker/compose, models) — no .github/workflows
9f85448 verified
Raw
History Blame Contribute Delete
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",
)
@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