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:
+150
-28
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user