Add freehand exclusion mode
Support rectangle and freehand exclusion shapes, toggle via toolbar, and store new strokes in the mask-backed exclusion system.
This commit is contained in:
+147
-10
@@ -1,14 +1,39 @@
|
||||
"""Mouse handlers for exclusion rectangles."""
|
||||
"""Mouse handlers for exclusion shapes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ExclusionMixin:
|
||||
"""Manage exclusion rectangles drawn on the preview canvas."""
|
||||
"""Manage exclusion shapes (rectangles and freehand strokes) on the preview canvas."""
|
||||
|
||||
def _exclude_start(self, event):
|
||||
if self.preview_img is None:
|
||||
return
|
||||
mode = getattr(self, "exclude_mode", "rect")
|
||||
x = max(0, min(self.preview_img.width - 1, int(event.x)))
|
||||
y = max(0, min(self.preview_img.height - 1, int(event.y)))
|
||||
if mode == "free":
|
||||
self._current_stroke = [(x, y)]
|
||||
width = int(getattr(self, "free_draw_width", 14))
|
||||
preview_id = getattr(self, "_stroke_preview_id", None)
|
||||
if preview_id:
|
||||
try:
|
||||
self.canvas_orig.delete(preview_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._stroke_preview_id = self.canvas_orig.create_line(
|
||||
x,
|
||||
y,
|
||||
x,
|
||||
y,
|
||||
fill="yellow",
|
||||
width=width,
|
||||
smooth=True,
|
||||
capstyle="round",
|
||||
joinstyle="round",
|
||||
)
|
||||
self._rubber_start = None
|
||||
return
|
||||
x = max(0, min(self.preview_img.width - 1, int(event.x)))
|
||||
y = max(0, min(self.preview_img.height - 1, int(event.y)))
|
||||
self._rubber_start = (x, y)
|
||||
@@ -20,6 +45,20 @@ class ExclusionMixin:
|
||||
self._rubber_id = self.canvas_orig.create_rectangle(x, y, x, y, outline="yellow", width=2)
|
||||
|
||||
def _exclude_drag(self, event):
|
||||
mode = getattr(self, "exclude_mode", "rect")
|
||||
if mode == "free":
|
||||
stroke = getattr(self, "_current_stroke", None)
|
||||
if not stroke:
|
||||
return
|
||||
x = max(0, min(self.preview_img.width - 1, int(event.x)))
|
||||
y = max(0, min(self.preview_img.height - 1, int(event.y)))
|
||||
if stroke[-1] != (x, y):
|
||||
stroke.append((x, y))
|
||||
preview_id = getattr(self, "_stroke_preview_id", None)
|
||||
if preview_id:
|
||||
coords = [coord for point in stroke for coord in point]
|
||||
self.canvas_orig.coords(preview_id, *coords)
|
||||
return
|
||||
if not self._rubber_start:
|
||||
return
|
||||
x0, y0 = self._rubber_start
|
||||
@@ -28,6 +67,33 @@ class ExclusionMixin:
|
||||
self.canvas_orig.coords(self._rubber_id, x0, y0, x1, y1)
|
||||
|
||||
def _exclude_end(self, event):
|
||||
mode = getattr(self, "exclude_mode", "rect")
|
||||
if mode == "free":
|
||||
stroke = getattr(self, "_current_stroke", None)
|
||||
if stroke and len(stroke) > 1:
|
||||
cleaned = self._compress_stroke(stroke)
|
||||
if len(cleaned) > 1:
|
||||
shape = {
|
||||
"kind": "stroke",
|
||||
"points": cleaned,
|
||||
"width": int(getattr(self, "free_draw_width", 14)),
|
||||
}
|
||||
self.exclude_shapes.append(shape)
|
||||
stamper = getattr(self, "_stamp_shape_on_mask", None)
|
||||
if callable(stamper):
|
||||
stamper(shape)
|
||||
else:
|
||||
self._exclude_mask_dirty = True
|
||||
self._current_stroke = None
|
||||
preview_id = getattr(self, "_stroke_preview_id", None)
|
||||
if preview_id:
|
||||
try:
|
||||
self.canvas_orig.delete(preview_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._stroke_preview_id = None
|
||||
self.update_preview()
|
||||
return
|
||||
if not self._rubber_start:
|
||||
return
|
||||
x0, y0 = self._rubber_start
|
||||
@@ -36,22 +102,93 @@ class ExclusionMixin:
|
||||
rx0, rx1 = sorted((x0, x1))
|
||||
ry0, ry1 = sorted((y0, y1))
|
||||
if (rx1 - rx0) > 0 and (ry1 - ry0) > 0:
|
||||
self.exclude_rects.append((rx0, ry0, rx1, ry1))
|
||||
shape = {"kind": "rect", "coords": (rx0, ry0, rx1, ry1)}
|
||||
self.exclude_shapes.append(shape)
|
||||
stamper = getattr(self, "_stamp_shape_on_mask", None)
|
||||
if callable(stamper):
|
||||
stamper(shape)
|
||||
else:
|
||||
self._exclude_mask_dirty = True
|
||||
if self._rubber_id:
|
||||
try:
|
||||
self.canvas_orig.delete(self._rubber_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._rubber_start = None
|
||||
self._rubber_id = None
|
||||
self.update_preview()
|
||||
|
||||
def clear_excludes(self):
|
||||
self.exclude_rects = []
|
||||
self.canvas_orig.delete("all")
|
||||
if self.preview_tk:
|
||||
self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk)
|
||||
self.exclude_shapes = []
|
||||
self._rubber_start = None
|
||||
self._current_stroke = None
|
||||
if self._rubber_id:
|
||||
try:
|
||||
self.canvas_orig.delete(self._rubber_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._rubber_id = None
|
||||
if self._stroke_preview_id:
|
||||
try:
|
||||
self.canvas_orig.delete(self._stroke_preview_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._stroke_preview_id = None
|
||||
for item in getattr(self, "_exclude_canvas_ids", []):
|
||||
try:
|
||||
self.canvas_orig.delete(item)
|
||||
except Exception:
|
||||
pass
|
||||
self._exclude_canvas_ids = []
|
||||
self._exclude_mask = None
|
||||
self._exclude_mask_px = None
|
||||
self._exclude_mask_dirty = True
|
||||
self.update_preview()
|
||||
|
||||
def undo_exclude(self):
|
||||
if self.exclude_rects:
|
||||
self.exclude_rects.pop()
|
||||
self.update_preview()
|
||||
if not getattr(self, "exclude_shapes", None):
|
||||
return
|
||||
self.exclude_shapes.pop()
|
||||
self._exclude_mask_dirty = True
|
||||
self.update_preview()
|
||||
|
||||
def toggle_exclusion_mode(self):
|
||||
current = getattr(self, "exclude_mode", "rect")
|
||||
next_mode = "free" if current == "rect" else "rect"
|
||||
self.exclude_mode = next_mode
|
||||
self._current_stroke = None
|
||||
if next_mode == "free":
|
||||
if self._rubber_id:
|
||||
try:
|
||||
self.canvas_orig.delete(self._rubber_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._rubber_id = None
|
||||
self._rubber_start = None
|
||||
else:
|
||||
if self._stroke_preview_id:
|
||||
try:
|
||||
self.canvas_orig.delete(self._stroke_preview_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._stroke_preview_id = None
|
||||
message_key = "status.free_draw_enabled" if next_mode == "free" else "status.free_draw_disabled"
|
||||
if hasattr(self, "status"):
|
||||
try:
|
||||
self.status.config(text=self._t(message_key))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _compress_stroke(points: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
"""Reduce duplicate points without altering the drawn path too much."""
|
||||
if not points:
|
||||
return []
|
||||
compressed: list[tuple[int, int]] = [points[0]]
|
||||
for point in points[1:]:
|
||||
if point != compressed[-1]:
|
||||
compressed.append(point)
|
||||
return compressed
|
||||
|
||||
|
||||
__all__ = ["ExclusionMixin"]
|
||||
|
||||
@@ -23,6 +23,7 @@ class UIBuilderMixin:
|
||||
("🖱", self._t("toolbar.pick_from_image"), self.enable_pick_mode),
|
||||
("💾", self._t("toolbar.save_overlay"), self.save_overlay),
|
||||
("🧹", self._t("toolbar.clear_excludes"), self.clear_excludes),
|
||||
("✏️", self._t("toolbar.toggle_free_draw"), self.toggle_exclusion_mode),
|
||||
("↩", self._t("toolbar.undo_exclude"), self.undo_exclude),
|
||||
("🔄", self._t("toolbar.reset_sliders"), self.reset_sliders),
|
||||
("🌓", self._t("toolbar.toggle_theme"), self.toggle_theme),
|
||||
|
||||
Reference in New Issue
Block a user