From f678c403b737b2267ebcf5decc75a6d2dff796ba Mon Sep 17 00:00:00 2001 From: lm Date: Fri, 17 Oct 2025 17:00:23 +0200 Subject: [PATCH] Add freehand exclusion mode Support rectangle and freehand exclusion shapes, toggle via toolbar, and store new strokes in the mask-backed exclusion system. --- app/app.py | 10 +- app/gui/exclusions.py | 157 ++++++++++++++++++++++++++++-- app/gui/ui.py | 1 + app/lang/de.toml | 3 + app/lang/en.toml | 3 + app/logic/image_processing.py | 178 ++++++++++++++++++++++++++++------ 6 files changed, 313 insertions(+), 39 deletions(-) diff --git a/app/app.py b/app/app.py index 34c6abc..aaf6cc2 100644 --- a/app/app.py +++ b/app/app.py @@ -44,9 +44,17 @@ class ICRAApp( self._update_job = None # 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_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 # Image references diff --git a/app/gui/exclusions.py b/app/gui/exclusions.py index fb51cb4..cad4c96 100644 --- a/app/gui/exclusions.py +++ b/app/gui/exclusions.py @@ -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"] diff --git a/app/gui/ui.py b/app/gui/ui.py index 168d37d..0ec0ea0 100644 --- a/app/gui/ui.py +++ b/app/gui/ui.py @@ -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), diff --git a/app/lang/de.toml b/app/lang/de.toml index e646464..6d692fa 100644 --- a/app/lang/de.toml +++ b/app/lang/de.toml @@ -6,11 +6,14 @@ "toolbar.pick_from_image" = "Farbe aus Bild klicken" "toolbar.save_overlay" = "Overlay speichern" "toolbar.clear_excludes" = "Ausschlüsse löschen" +"toolbar.toggle_free_draw" = "Freihandmodus umschalten" "toolbar.undo_exclude" = "Letzten Ausschluss entfernen" "toolbar.reset_sliders" = "Slider zurücksetzen" "toolbar.toggle_theme" = "Theme umschalten" "status.no_file" = "Keine Datei geladen." "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.filename_label" = "{name} — {dimensions}{position}" "status.color_selected" = "Farbe gewählt: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%" diff --git a/app/lang/en.toml b/app/lang/en.toml index f8efc1d..3eec912 100644 --- a/app/lang/en.toml +++ b/app/lang/en.toml @@ -6,11 +6,14 @@ "toolbar.pick_from_image" = "Pick from image" "toolbar.save_overlay" = "Save overlay" "toolbar.clear_excludes" = "Clear exclusions" +"toolbar.toggle_free_draw" = "Toggle free-draw" "toolbar.undo_exclude" = "Undo last exclusion" "toolbar.reset_sliders" = "Reset sliders" "toolbar.toggle_theme" = "Toggle theme" "status.no_file" = "No file loaded." "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.filename_label" = "{name} — {dimensions}{position}" "status.color_selected" = "Colour chosen: {label} — Hue {hue:.1f}°, S {saturation:.0f}%, V {value:.0f}%" diff --git a/app/logic/image_processing.py b/app/logic/image_processing.py index 4ee0f07..51e5a8f 100644 --- a/app/logic/image_processing.py +++ b/app/logic/image_processing.py @@ -115,9 +115,13 @@ class ImageProcessingMixin: self.image_path = path self.orig_img = image - self.exclude_rects = [] + self.exclude_shapes = [] self._rubber_start = 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.prepare_preview() @@ -155,7 +159,7 @@ class ImageProcessingMixin: overlay = self._build_overlay_image( self.orig_img, - self.exclude_rects, + tuple(self.exclude_shapes), alpha=int(self.alpha.get()), scale_from_preview=self.preview_img.size, 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_overlay.config(width=size[0], height=size[1]) 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: if self.preview_img is None: return + self._ensure_exclude_mask() merged = self.create_overlay_preview() if merged is None: return @@ -201,8 +211,7 @@ class ImageProcessingMixin: self.canvas_orig.delete("all") self.canvas_orig.create_image(0, 0, anchor="nw", image=self.preview_tk) - for (x0, y0, x1, y1) in self.exclude_rects: - self.canvas_orig.create_rectangle(x0, y0, x1, y1, outline="yellow", width=3) + self._render_exclusion_overlays() stats = self.compute_stats_preview() if stats: @@ -234,15 +243,17 @@ class ImageProcessingMixin: def create_overlay_preview(self) -> Image.Image | None: if self.preview_img is None: return None + self._ensure_exclude_mask() base = self.preview_img.convert("RGBA") overlay = Image.new("RGBA", base.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) pixels = base.load() + mask_px = self._exclude_mask_px width, height = base.size alpha = int(self.alpha.get()) for y in range(height): for x in range(width): - if self._is_excluded(x, y): + if mask_px is not None and mask_px[x, y]: continue r, g, b, a = pixels[x, y] if a == 0: @@ -250,14 +261,25 @@ class ImageProcessingMixin: if self.matches_target_color(r, g, b): draw.point((x, y), fill=(255, 0, 0, alpha)) merged = Image.alpha_composite(base, overlay) - for (x0, y0, x1, y1) in self.exclude_rects: - ImageDraw.Draw(merged).rectangle([x0, y0, x1, y1], outline=(255, 215, 0, 200), width=3) + outline = ImageDraw.Draw(merged) + 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 def compute_stats_preview(self): if self.preview_img is None: return None + self._ensure_exclude_mask() px = self.preview_img.convert("RGBA").load() + mask_px = self._exclude_mask_px width, height = self.preview_img.size matches_all = total_all = 0 matches_keep = total_keep = 0 @@ -267,11 +289,11 @@ class ImageProcessingMixin: r, g, b, a = px[x, y] if a == 0: continue - is_excluded = self._is_excluded(x, y) + excluded = bool(mask_px and mask_px[x, y]) total_all += 1 if self.matches_target_color(r, g, b): matches_all += 1 - if not is_excluded: + if not excluded: total_keep += 1 if self.matches_target_color(r, g, b): matches_keep += 1 @@ -300,28 +322,19 @@ class ImageProcessingMixin: return hue_ok and (s >= smin) and (v >= vmin) and (v <= vmax) 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) - - @staticmethod - def _map_preview_excludes( - excludes: Iterable[Tuple[int, int, int, int]], - orig_size: Tuple[int, int], - preview_size: Tuple[int, int], - ) -> 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 + self._ensure_exclude_mask() + if self._exclude_mask_px is None: + return False + try: + return bool(self._exclude_mask_px[x, y]) + except Exception: + return False @classmethod def _build_overlay_image( cls, image: Image.Image, - excludes_preview: Iterable[Tuple[int, int, int, int]], + shapes: Iterable[dict[str, object]], *, alpha: int, scale_from_preview: Tuple[int, int], @@ -331,10 +344,11 @@ class ImageProcessingMixin: draw = ImageDraw.Draw(overlay) pixels = image.load() 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 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 r, g, b, a = pixels[x, y] if a == 0: @@ -343,5 +357,113 @@ class ImageProcessingMixin: draw.point((x, y), fill=(255, 0, 0, alpha)) 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"]