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:
parent
5cb2945577
commit
f678c403b7
10
app/app.py
10
app/app.py
|
|
@ -44,9 +44,17 @@ class ICRAApp(
|
||||||
self._update_job = None
|
self._update_job = None
|
||||||
|
|
||||||
# Exclusion rectangles (preview coordinates)
|
# Exclusion rectangles (preview coordinates)
|
||||||
self.exclude_rects: list[tuple[int, int, int, int]] = []
|
self.exclude_shapes: list[dict[str, object]] = []
|
||||||
self._rubber_start = None
|
self._rubber_start = None
|
||||||
self._rubber_id = None
|
self._rubber_id = None
|
||||||
|
self._stroke_preview_id = None
|
||||||
|
self.exclude_mode = "rect"
|
||||||
|
self._exclude_mask = None
|
||||||
|
self._exclude_mask_dirty = True
|
||||||
|
self._exclude_mask_px = None
|
||||||
|
self._exclude_canvas_ids: list[int] = []
|
||||||
|
self._current_stroke: list[tuple[int, int]] | None = None
|
||||||
|
self.free_draw_width = 14
|
||||||
self.pick_mode = False
|
self.pick_mode = False
|
||||||
|
|
||||||
# Image references
|
# Image references
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,39 @@
|
||||||
"""Mouse handlers for exclusion rectangles."""
|
"""Mouse handlers for exclusion shapes."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
class ExclusionMixin:
|
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):
|
def _exclude_start(self, event):
|
||||||
if self.preview_img is None:
|
if self.preview_img is None:
|
||||||
return
|
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)))
|
x = max(0, min(self.preview_img.width - 1, int(event.x)))
|
||||||
y = max(0, min(self.preview_img.height - 1, int(event.y)))
|
y = max(0, min(self.preview_img.height - 1, int(event.y)))
|
||||||
self._rubber_start = (x, 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)
|
self._rubber_id = self.canvas_orig.create_rectangle(x, y, x, y, outline="yellow", width=2)
|
||||||
|
|
||||||
def _exclude_drag(self, event):
|
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:
|
if not self._rubber_start:
|
||||||
return
|
return
|
||||||
x0, y0 = self._rubber_start
|
x0, y0 = self._rubber_start
|
||||||
|
|
@ -28,6 +67,33 @@ class ExclusionMixin:
|
||||||
self.canvas_orig.coords(self._rubber_id, x0, y0, x1, y1)
|
self.canvas_orig.coords(self._rubber_id, x0, y0, x1, y1)
|
||||||
|
|
||||||
def _exclude_end(self, event):
|
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:
|
if not self._rubber_start:
|
||||||
return
|
return
|
||||||
x0, y0 = self._rubber_start
|
x0, y0 = self._rubber_start
|
||||||
|
|
@ -36,22 +102,93 @@ class ExclusionMixin:
|
||||||
rx0, rx1 = sorted((x0, x1))
|
rx0, rx1 = sorted((x0, x1))
|
||||||
ry0, ry1 = sorted((y0, y1))
|
ry0, ry1 = sorted((y0, y1))
|
||||||
if (rx1 - rx0) > 0 and (ry1 - ry0) > 0:
|
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_start = None
|
||||||
self._rubber_id = None
|
self._rubber_id = None
|
||||||
self.update_preview()
|
self.update_preview()
|
||||||
|
|
||||||
def clear_excludes(self):
|
def clear_excludes(self):
|
||||||
self.exclude_rects = []
|
self.exclude_shapes = []
|
||||||
self.canvas_orig.delete("all")
|
self._rubber_start = None
|
||||||
if self.preview_tk:
|
self._current_stroke = None
|
||||||
self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk)
|
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()
|
self.update_preview()
|
||||||
|
|
||||||
def undo_exclude(self):
|
def undo_exclude(self):
|
||||||
if self.exclude_rects:
|
if not getattr(self, "exclude_shapes", None):
|
||||||
self.exclude_rects.pop()
|
return
|
||||||
self.update_preview()
|
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"]
|
__all__ = ["ExclusionMixin"]
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class UIBuilderMixin:
|
||||||
("🖱", self._t("toolbar.pick_from_image"), self.enable_pick_mode),
|
("🖱", self._t("toolbar.pick_from_image"), self.enable_pick_mode),
|
||||||
("💾", self._t("toolbar.save_overlay"), self.save_overlay),
|
("💾", self._t("toolbar.save_overlay"), self.save_overlay),
|
||||||
("🧹", self._t("toolbar.clear_excludes"), self.clear_excludes),
|
("🧹", 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.undo_exclude"), self.undo_exclude),
|
||||||
("🔄", self._t("toolbar.reset_sliders"), self.reset_sliders),
|
("🔄", self._t("toolbar.reset_sliders"), self.reset_sliders),
|
||||||
("🌓", self._t("toolbar.toggle_theme"), self.toggle_theme),
|
("🌓", self._t("toolbar.toggle_theme"), self.toggle_theme),
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@
|
||||||
"toolbar.pick_from_image" = "Farbe aus Bild klicken"
|
"toolbar.pick_from_image" = "Farbe aus Bild klicken"
|
||||||
"toolbar.save_overlay" = "Overlay speichern"
|
"toolbar.save_overlay" = "Overlay speichern"
|
||||||
"toolbar.clear_excludes" = "Ausschlüsse löschen"
|
"toolbar.clear_excludes" = "Ausschlüsse löschen"
|
||||||
|
"toolbar.toggle_free_draw" = "Freihandmodus umschalten"
|
||||||
"toolbar.undo_exclude" = "Letzten Ausschluss entfernen"
|
"toolbar.undo_exclude" = "Letzten Ausschluss entfernen"
|
||||||
"toolbar.reset_sliders" = "Slider zurücksetzen"
|
"toolbar.reset_sliders" = "Slider zurücksetzen"
|
||||||
"toolbar.toggle_theme" = "Theme umschalten"
|
"toolbar.toggle_theme" = "Theme umschalten"
|
||||||
"status.no_file" = "Keine Datei geladen."
|
"status.no_file" = "Keine Datei geladen."
|
||||||
"status.defaults_restored" = "Standardwerte aktiv."
|
"status.defaults_restored" = "Standardwerte aktiv."
|
||||||
|
"status.free_draw_enabled" = "Freihand-Ausschluss aktiviert."
|
||||||
|
"status.free_draw_disabled" = "Rechteck-Ausschluss aktiviert."
|
||||||
"status.loaded" = "Geladen: {name} — {dimensions}{position}"
|
"status.loaded" = "Geladen: {name} — {dimensions}{position}"
|
||||||
"status.filename_label" = "{name} — {dimensions}{position}"
|
"status.filename_label" = "{name} — {dimensions}{position}"
|
||||||
"status.color_selected" = "Farbe gewählt: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
|
"status.color_selected" = "Farbe gewählt: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@
|
||||||
"toolbar.pick_from_image" = "Pick from image"
|
"toolbar.pick_from_image" = "Pick from image"
|
||||||
"toolbar.save_overlay" = "Save overlay"
|
"toolbar.save_overlay" = "Save overlay"
|
||||||
"toolbar.clear_excludes" = "Clear exclusions"
|
"toolbar.clear_excludes" = "Clear exclusions"
|
||||||
|
"toolbar.toggle_free_draw" = "Toggle free-draw"
|
||||||
"toolbar.undo_exclude" = "Undo last exclusion"
|
"toolbar.undo_exclude" = "Undo last exclusion"
|
||||||
"toolbar.reset_sliders" = "Reset sliders"
|
"toolbar.reset_sliders" = "Reset sliders"
|
||||||
"toolbar.toggle_theme" = "Toggle theme"
|
"toolbar.toggle_theme" = "Toggle theme"
|
||||||
"status.no_file" = "No file loaded."
|
"status.no_file" = "No file loaded."
|
||||||
"status.defaults_restored" = "Defaults restored."
|
"status.defaults_restored" = "Defaults restored."
|
||||||
|
"status.free_draw_enabled" = "Free-draw exclusion mode enabled."
|
||||||
|
"status.free_draw_disabled" = "Rectangle exclusion mode enabled."
|
||||||
"status.loaded" = "Loaded: {name} — {dimensions}{position}"
|
"status.loaded" = "Loaded: {name} — {dimensions}{position}"
|
||||||
"status.filename_label" = "{name} — {dimensions}{position}"
|
"status.filename_label" = "{name} — {dimensions}{position}"
|
||||||
"status.color_selected" = "Colour chosen: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
|
"status.color_selected" = "Colour chosen: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%"
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,13 @@ class ImageProcessingMixin:
|
||||||
|
|
||||||
self.image_path = path
|
self.image_path = path
|
||||||
self.orig_img = image
|
self.orig_img = image
|
||||||
self.exclude_rects = []
|
self.exclude_shapes = []
|
||||||
self._rubber_start = None
|
self._rubber_start = None
|
||||||
self._rubber_id = None
|
self._rubber_id = None
|
||||||
|
self._stroke_preview_id = None
|
||||||
|
self._exclude_mask = None
|
||||||
|
self._exclude_mask_px = None
|
||||||
|
self._exclude_mask_dirty = True
|
||||||
self.pick_mode = False
|
self.pick_mode = False
|
||||||
|
|
||||||
self.prepare_preview()
|
self.prepare_preview()
|
||||||
|
|
@ -155,7 +159,7 @@ class ImageProcessingMixin:
|
||||||
|
|
||||||
overlay = self._build_overlay_image(
|
overlay = self._build_overlay_image(
|
||||||
self.orig_img,
|
self.orig_img,
|
||||||
self.exclude_rects,
|
tuple(self.exclude_shapes),
|
||||||
alpha=int(self.alpha.get()),
|
alpha=int(self.alpha.get()),
|
||||||
scale_from_preview=self.preview_img.size,
|
scale_from_preview=self.preview_img.size,
|
||||||
is_match_fn=self.matches_target_color,
|
is_match_fn=self.matches_target_color,
|
||||||
|
|
@ -188,10 +192,16 @@ class ImageProcessingMixin:
|
||||||
self.canvas_orig.config(width=size[0], height=size[1])
|
self.canvas_orig.config(width=size[0], height=size[1])
|
||||||
self.canvas_overlay.config(width=size[0], height=size[1])
|
self.canvas_overlay.config(width=size[0], height=size[1])
|
||||||
self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk)
|
self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk)
|
||||||
|
self._exclude_mask = None
|
||||||
|
self._exclude_mask_px = None
|
||||||
|
self._exclude_mask_dirty = True
|
||||||
|
if getattr(self, "exclude_shapes", None):
|
||||||
|
self._ensure_exclude_mask()
|
||||||
|
|
||||||
def update_preview(self) -> None:
|
def update_preview(self) -> None:
|
||||||
if self.preview_img is None:
|
if self.preview_img is None:
|
||||||
return
|
return
|
||||||
|
self._ensure_exclude_mask()
|
||||||
merged = self.create_overlay_preview()
|
merged = self.create_overlay_preview()
|
||||||
if merged is None:
|
if merged is None:
|
||||||
return
|
return
|
||||||
|
|
@ -201,8 +211,7 @@ class ImageProcessingMixin:
|
||||||
|
|
||||||
self.canvas_orig.delete("all")
|
self.canvas_orig.delete("all")
|
||||||
self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk)
|
self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk)
|
||||||
for (x0, y0, x1, y1) in self.exclude_rects:
|
self._render_exclusion_overlays()
|
||||||
self.canvas_orig.create_rectangle(x0, y0, x1, y1, outline="yellow", width=3)
|
|
||||||
|
|
||||||
stats = self.compute_stats_preview()
|
stats = self.compute_stats_preview()
|
||||||
if stats:
|
if stats:
|
||||||
|
|
@ -234,15 +243,17 @@ class ImageProcessingMixin:
|
||||||
def create_overlay_preview(self) -> Image.Image | None:
|
def create_overlay_preview(self) -> Image.Image | None:
|
||||||
if self.preview_img is None:
|
if self.preview_img is None:
|
||||||
return None
|
return None
|
||||||
|
self._ensure_exclude_mask()
|
||||||
base = self.preview_img.convert("RGBA")
|
base = self.preview_img.convert("RGBA")
|
||||||
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
||||||
draw = ImageDraw.Draw(overlay)
|
draw = ImageDraw.Draw(overlay)
|
||||||
pixels = base.load()
|
pixels = base.load()
|
||||||
|
mask_px = self._exclude_mask_px
|
||||||
width, height = base.size
|
width, height = base.size
|
||||||
alpha = int(self.alpha.get())
|
alpha = int(self.alpha.get())
|
||||||
for y in range(height):
|
for y in range(height):
|
||||||
for x in range(width):
|
for x in range(width):
|
||||||
if self._is_excluded(x, y):
|
if mask_px is not None and mask_px[x, y]:
|
||||||
continue
|
continue
|
||||||
r, g, b, a = pixels[x, y]
|
r, g, b, a = pixels[x, y]
|
||||||
if a == 0:
|
if a == 0:
|
||||||
|
|
@ -250,14 +261,25 @@ class ImageProcessingMixin:
|
||||||
if self.matches_target_color(r, g, b):
|
if self.matches_target_color(r, g, b):
|
||||||
draw.point((x, y), fill=(255, 0, 0, alpha))
|
draw.point((x, y), fill=(255, 0, 0, alpha))
|
||||||
merged = Image.alpha_composite(base, overlay)
|
merged = Image.alpha_composite(base, overlay)
|
||||||
for (x0, y0, x1, y1) in self.exclude_rects:
|
outline = ImageDraw.Draw(merged)
|
||||||
ImageDraw.Draw(merged).rectangle([x0, y0, x1, y1], outline=(255, 215, 0, 200), width=3)
|
for shape in getattr(self, "exclude_shapes", []):
|
||||||
|
if shape.get("kind") == "rect":
|
||||||
|
x0, y0, x1, y1 = shape["coords"] # type: ignore[index]
|
||||||
|
outline.rectangle([x0, y0, x1, y1], outline=(255, 215, 0, 200), width=3)
|
||||||
|
elif shape.get("kind") == "stroke":
|
||||||
|
points = shape.get("points", [])
|
||||||
|
if len(points) < 2:
|
||||||
|
continue
|
||||||
|
width_px = int(shape.get("width", 8))
|
||||||
|
outline.line(points, fill=(255, 215, 0, 200), width=width_px, joint="round")
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
def compute_stats_preview(self):
|
def compute_stats_preview(self):
|
||||||
if self.preview_img is None:
|
if self.preview_img is None:
|
||||||
return None
|
return None
|
||||||
|
self._ensure_exclude_mask()
|
||||||
px = self.preview_img.convert("RGBA").load()
|
px = self.preview_img.convert("RGBA").load()
|
||||||
|
mask_px = self._exclude_mask_px
|
||||||
width, height = self.preview_img.size
|
width, height = self.preview_img.size
|
||||||
matches_all = total_all = 0
|
matches_all = total_all = 0
|
||||||
matches_keep = total_keep = 0
|
matches_keep = total_keep = 0
|
||||||
|
|
@ -267,11 +289,11 @@ class ImageProcessingMixin:
|
||||||
r, g, b, a = px[x, y]
|
r, g, b, a = px[x, y]
|
||||||
if a == 0:
|
if a == 0:
|
||||||
continue
|
continue
|
||||||
is_excluded = self._is_excluded(x, y)
|
excluded = bool(mask_px and mask_px[x, y])
|
||||||
total_all += 1
|
total_all += 1
|
||||||
if self.matches_target_color(r, g, b):
|
if self.matches_target_color(r, g, b):
|
||||||
matches_all += 1
|
matches_all += 1
|
||||||
if not is_excluded:
|
if not excluded:
|
||||||
total_keep += 1
|
total_keep += 1
|
||||||
if self.matches_target_color(r, g, b):
|
if self.matches_target_color(r, g, b):
|
||||||
matches_keep += 1
|
matches_keep += 1
|
||||||
|
|
@ -300,28 +322,19 @@ class ImageProcessingMixin:
|
||||||
return hue_ok and (s >= smin) and (v >= vmin) and (v <= vmax)
|
return hue_ok and (s >= smin) and (v >= vmin) and (v <= vmax)
|
||||||
|
|
||||||
def _is_excluded(self, x: int, y: int) -> bool:
|
def _is_excluded(self, x: int, y: int) -> bool:
|
||||||
return any(x0 <= x <= x1 and y0 <= y <= y1 for (x0, y0, x1, y1) in self.exclude_rects)
|
self._ensure_exclude_mask()
|
||||||
|
if self._exclude_mask_px is None:
|
||||||
@staticmethod
|
return False
|
||||||
def _map_preview_excludes(
|
try:
|
||||||
excludes: Iterable[Tuple[int, int, int, int]],
|
return bool(self._exclude_mask_px[x, y])
|
||||||
orig_size: Tuple[int, int],
|
except Exception:
|
||||||
preview_size: Tuple[int, int],
|
return False
|
||||||
) -> list[Tuple[int, int, int, int]]:
|
|
||||||
scale_x = orig_size[0] / preview_size[0]
|
|
||||||
scale_y = orig_size[1] / preview_size[1]
|
|
||||||
mapped = []
|
|
||||||
for x0, y0, x1, y1 in excludes:
|
|
||||||
mapped.append(
|
|
||||||
(int(x0 * scale_x), int(y0 * scale_y), int(x1 * scale_x), int(y1 * scale_y))
|
|
||||||
)
|
|
||||||
return mapped
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _build_overlay_image(
|
def _build_overlay_image(
|
||||||
cls,
|
cls,
|
||||||
image: Image.Image,
|
image: Image.Image,
|
||||||
excludes_preview: Iterable[Tuple[int, int, int, int]],
|
shapes: Iterable[dict[str, object]],
|
||||||
*,
|
*,
|
||||||
alpha: int,
|
alpha: int,
|
||||||
scale_from_preview: Tuple[int, int],
|
scale_from_preview: Tuple[int, int],
|
||||||
|
|
@ -331,10 +344,11 @@ class ImageProcessingMixin:
|
||||||
draw = ImageDraw.Draw(overlay)
|
draw = ImageDraw.Draw(overlay)
|
||||||
pixels = image.load()
|
pixels = image.load()
|
||||||
width, height = image.size
|
width, height = image.size
|
||||||
excludes = cls._map_preview_excludes(excludes_preview, image.size, scale_from_preview)
|
mask = cls._build_exclude_mask_for_size(tuple(shapes), scale_from_preview, image.size)
|
||||||
|
mask_px = mask.load() if mask else None
|
||||||
for y in range(height):
|
for y in range(height):
|
||||||
for x in range(width):
|
for x in range(width):
|
||||||
if any(x0 <= x <= x1 and y0 <= y <= y1 for (x0, y0, x1, y1) in excludes):
|
if mask_px is not None and mask_px[x, y]:
|
||||||
continue
|
continue
|
||||||
r, g, b, a = pixels[x, y]
|
r, g, b, a = pixels[x, y]
|
||||||
if a == 0:
|
if a == 0:
|
||||||
|
|
@ -343,5 +357,113 @@ class ImageProcessingMixin:
|
||||||
draw.point((x, y), fill=(255, 0, 0, alpha))
|
draw.point((x, y), fill=(255, 0, 0, alpha))
|
||||||
return overlay
|
return overlay
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_exclude_mask_for_size(
|
||||||
|
cls,
|
||||||
|
shapes: Iterable[dict[str, object]],
|
||||||
|
preview_size: Tuple[int, int],
|
||||||
|
target_size: Tuple[int, int],
|
||||||
|
) -> Image.Image | None:
|
||||||
|
if not preview_size or not target_size or preview_size[0] == 0 or preview_size[1] == 0:
|
||||||
|
return None
|
||||||
|
mask = Image.new("L", target_size, 0)
|
||||||
|
draw = ImageDraw.Draw(mask)
|
||||||
|
scale_x = target_size[0] / preview_size[0]
|
||||||
|
scale_y = target_size[1] / preview_size[1]
|
||||||
|
for shape in shapes:
|
||||||
|
kind = shape.get("kind")
|
||||||
|
cls._draw_shape_on_mask(draw, shape, scale_x=scale_x, scale_y=scale_y)
|
||||||
|
return mask
|
||||||
|
|
||||||
|
def _ensure_exclude_mask(self) -> None:
|
||||||
|
if self.preview_img is None:
|
||||||
|
return
|
||||||
|
size = self.preview_img.size
|
||||||
|
if (
|
||||||
|
self._exclude_mask is None
|
||||||
|
or self._exclude_mask.size != size
|
||||||
|
or getattr(self, "_exclude_mask_dirty", False)
|
||||||
|
):
|
||||||
|
self._exclude_mask = Image.new("L", size, 0)
|
||||||
|
draw = ImageDraw.Draw(self._exclude_mask)
|
||||||
|
for shape in getattr(self, "exclude_shapes", []):
|
||||||
|
self._draw_shape_on_mask(draw, shape, scale_x=1.0, scale_y=1.0)
|
||||||
|
self._exclude_mask_px = self._exclude_mask.load()
|
||||||
|
self._exclude_mask_dirty = False
|
||||||
|
elif self._exclude_mask_px is None:
|
||||||
|
self._exclude_mask_px = self._exclude_mask.load()
|
||||||
|
|
||||||
|
def _stamp_shape_on_mask(self, shape: dict[str, object]) -> None:
|
||||||
|
if self.preview_img is None:
|
||||||
|
return
|
||||||
|
if self._exclude_mask is None or self._exclude_mask.size != self.preview_img.size:
|
||||||
|
self._exclude_mask_dirty = True
|
||||||
|
return
|
||||||
|
draw = ImageDraw.Draw(self._exclude_mask)
|
||||||
|
self._draw_shape_on_mask(draw, shape, scale_x=1.0, scale_y=1.0)
|
||||||
|
self._exclude_mask_px = self._exclude_mask.load()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _draw_shape_on_mask(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
shape: dict[str, object],
|
||||||
|
*,
|
||||||
|
scale_x: float,
|
||||||
|
scale_y: float,
|
||||||
|
) -> None:
|
||||||
|
kind = shape.get("kind")
|
||||||
|
if kind == "rect":
|
||||||
|
x0, y0, x1, y1 = shape["coords"] # type: ignore[index]
|
||||||
|
draw.rectangle(
|
||||||
|
[
|
||||||
|
x0 * scale_x,
|
||||||
|
y0 * scale_y,
|
||||||
|
x1 * scale_x,
|
||||||
|
y1 * scale_y,
|
||||||
|
],
|
||||||
|
fill=255,
|
||||||
|
)
|
||||||
|
elif kind == "stroke":
|
||||||
|
points = shape.get("points")
|
||||||
|
if not points or len(points) < 2:
|
||||||
|
return
|
||||||
|
base_width = float(shape.get("width", 8)) # type: ignore[arg-type]
|
||||||
|
width_px = max(1, int(round(base_width * (scale_x + scale_y) / 2.0)))
|
||||||
|
scaled = [(px * scale_x, py * scale_y) for px, py in points] # type: ignore[misc]
|
||||||
|
draw.line(scaled, fill=255, width=width_px, joint="round")
|
||||||
|
|
||||||
|
def _render_exclusion_overlays(self) -> None:
|
||||||
|
if not hasattr(self, "canvas_orig"):
|
||||||
|
return
|
||||||
|
for item in getattr(self, "_exclude_canvas_ids", []):
|
||||||
|
try:
|
||||||
|
self.canvas_orig.delete(item)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._exclude_canvas_ids = []
|
||||||
|
for shape in getattr(self, "exclude_shapes", []):
|
||||||
|
kind = shape.get("kind")
|
||||||
|
if kind == "rect":
|
||||||
|
x0, y0, x1, y1 = shape["coords"] # type: ignore[index]
|
||||||
|
item = self.canvas_orig.create_rectangle(
|
||||||
|
x0, y0, x1, y1, outline="yellow", width=3
|
||||||
|
)
|
||||||
|
self._exclude_canvas_ids.append(item)
|
||||||
|
elif kind == "stroke":
|
||||||
|
points = shape.get("points")
|
||||||
|
if not points or len(points) < 2:
|
||||||
|
continue
|
||||||
|
width_px = int(shape.get("width", 8))
|
||||||
|
coords = [coord for point in points for coord in point] # type: ignore[misc]
|
||||||
|
item = self.canvas_orig.create_line(
|
||||||
|
*coords,
|
||||||
|
fill="yellow",
|
||||||
|
width=width_px,
|
||||||
|
smooth=True,
|
||||||
|
capstyle="round",
|
||||||
|
joinstyle="round",
|
||||||
|
)
|
||||||
|
self._exclude_canvas_ids.append(item)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ImageProcessingMixin"]
|
__all__ = ["ImageProcessingMixin"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue