ScanSlicer/scanslicer/autoslicer_detection.py
dan 426a18f342 chore: prune boilerplate and refresh stale docs
- Remove 257 redundant per-definition comment stubs across the package
- Drop dead nonlocal declarations in autoslicer_detection.py
- Document the swapped flag and swap_left_right in architecture docs
- Remove unsupported 'legacy override' claims from autotune/runtime docs
2026-09-11 23:07:30 +02:00

553 lines
22 KiB
Python

import cv2
import numpy as np
class AutoslicerDetectionMixin:
# Find all bbox related to n
def get_box_relatives(self, hierarchy, n):
relatives = []
while 1:
father = hierarchy[n][3]
if father >= 0:
relatives.append(father)
n = father
else:
return relatives
def _poly_iou_prepared(self, a, b, area_a=None, area_b=None):
if area_a is None:
area_a = cv2.contourArea(a)
if area_b is None:
area_b = cv2.contourArea(b)
if area_a <= 0 or area_b <= 0:
return 0.0
try:
inter_area, _ = cv2.intersectConvexConvex(a, b)
except cv2.error:
return 0.0
union_area = area_a + area_b - inter_area
if union_area <= 0:
return 0.0
return float(inter_area / union_area)
def poly_iou(self, poly_a, poly_b):
a = np.asarray(poly_a, dtype=np.float32).reshape(-1, 2)
b = np.asarray(poly_b, dtype=np.float32).reshape(-1, 2)
return self._poly_iou_prepared(a, b)
def suppress_border_bar_artifacts(self, mask, update_status_callback=None, status_prefix=""):
if mask is None or mask.ndim != 2:
return mask
h, w = mask.shape[:2]
if h < 20 or w < 20:
return mask
binary = (mask > 0).astype(np.uint8)
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8)
if num_labels <= 1:
return mask
max_thin_h = max(2, int(round(h * 0.02)))
max_thin_w = max(2, int(round(w * 0.02)))
min_long_w = max(16, int(round(w * 0.25)))
min_long_h = max(16, int(round(h * 0.25)))
cleaned = binary.copy()
removed = 0
for idx in range(1, num_labels):
x, y, width, height, _area = stats[idx]
near_top = y <= max_thin_h
near_bottom = (y + height) >= (h - max_thin_h)
near_left = x <= max_thin_w
near_right = (x + width) >= (w - max_thin_w)
horizontal_bar = height <= max_thin_h and width >= min_long_w and (near_top or near_bottom)
vertical_bar = width <= max_thin_w and height >= min_long_h and (near_left or near_right)
if horizontal_bar or vertical_bar:
cleaned[labels == idx] = 0
removed += 1
# Also trim thin foreground strips inside narrow border bands.
# This handles bars connected to a larger photo component.
band_h = max(2, min(20, int(round(h * 0.008))))
band_w = max(2, min(20, int(round(w * 0.008))))
min_band_long_w = max(20, int(round(w * 0.2)))
min_band_long_h = max(20, int(round(h * 0.2)))
trimmed = 0
def trim_horizontal_band(row_start, row_end):
nonlocal trimmed
band = cleaned[row_start:row_end, :]
if band.size == 0:
return
n, band_labels, band_stats, _ = cv2.connectedComponentsWithStats(band, connectivity=8)
if n <= 1:
return
for label_idx in range(1, n):
x, y, width, height, area = band_stats[label_idx]
if width < min_band_long_w:
continue
if height > band_h:
continue
fill = float(area) / float(max(1, width * height))
if fill < 0.25:
continue
mask_sel = band_labels == label_idx
if np.any(mask_sel):
band[mask_sel] = 0
trimmed += 1
cleaned[row_start:row_end, :] = band
def trim_vertical_band(col_start, col_end):
nonlocal trimmed
band = cleaned[:, col_start:col_end]
if band.size == 0:
return
n, band_labels, band_stats, _ = cv2.connectedComponentsWithStats(band, connectivity=8)
if n <= 1:
return
for label_idx in range(1, n):
x, y, width, height, area = band_stats[label_idx]
if height < min_band_long_h:
continue
if width > band_w:
continue
fill = float(area) / float(max(1, width * height))
if fill < 0.25:
continue
mask_sel = band_labels == label_idx
if np.any(mask_sel):
band[mask_sel] = 0
trimmed += 1
cleaned[:, col_start:col_end] = band
trim_horizontal_band(0, min(h, band_h))
trim_horizontal_band(max(0, h - band_h), h)
trim_vertical_band(0, min(w, band_w))
trim_vertical_band(max(0, w - band_w), w)
if removed > 0:
self.update_status(
update_status_callback,
status_prefix + f"Suppressed {removed} border artifact component(s).",
)
if trimmed > 0:
self.update_status(
update_status_callback,
status_prefix + f"Trimmed {trimmed} border strip artifact(s).",
)
if removed > 0 or trimmed > 0:
return (cleaned * 255).astype(np.uint8)
return mask
def extract_boxes_from_mask(self, mask, update_status_callback=None, status_prefix=""):
mask = self.suppress_border_bar_artifacts(mask, update_status_callback, status_prefix)
self.update_status(update_status_callback, status_prefix + "Finding contours...")
contours, hierarchy = cv2.findContours(mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
if hierarchy is None:
return []
hierarchy = hierarchy[0]
# Calculate total image area and minimum box thresh
h, w, _ = self.image.shape
img_area = h * w
min_area = self.params.bbox_min_size.get() / 100 * img_area
max_area = self.params.bbox_max_size.get() / 100 * img_area
min_aspect = self.params.aspect_ratio_min.get() / 100.0
max_aspect = max(min_aspect, self.params.aspect_ratio_max.get() / 100.0)
border_margin_x = w * self.params.border_ignore.get() / 100.0
border_margin_y = h * self.params.border_ignore.get() / 100.0
merge_iou = self.params.iou_merge_thresh.get() / 100.0
approx_pct = self.params.contour_approx_pct.get() / 1000.0
boxes = []
box_polys_f32 = []
box_areas = []
good_ids = set()
last_contour = len(contours) - 1
for n, contour in enumerate(contours):
if self.abort_flag:
return []
# No triangles
if len(contour) < 4:
continue
if n % 25 == 0 or n == last_contour:
self.update_status(
update_status_callback,
status_prefix + "Processing contour " + str(n) + "/" + str(len(contours)),
)
# If child of a selected bbox, then it's discarded (it's inside)
ancestor = hierarchy[n][3]
skip = False
while ancestor >= 0:
if ancestor in good_ids:
skip = True
break
ancestor = hierarchy[ancestor][3]
if skip:
continue
contour_for_rect = contour
if approx_pct > 0:
perimeter = cv2.arcLength(contour, True)
epsilon = perimeter * approx_pct
approximated = cv2.approxPolyDP(contour, epsilon, True)
if len(approximated) >= 4:
contour_for_rect = approximated
# Find bounding box
bbox_rot_rect = cv2.minAreaRect(contour_for_rect)
bounding_box_f32 = cv2.boxPoints(bbox_rot_rect).astype(np.float32)
bounding_box = bounding_box_f32.astype(np.intp)
shape_area = cv2.contourArea(contour)
bbox_area = cv2.contourArea(bounding_box_f32)
# Too small or too big
if shape_area < 1 or bbox_area < min_area or bbox_area > max_area:
continue
# Fill ratio
fill_ratio = shape_area / bbox_area * 100
if fill_ratio < self.params.bbox_fill_thresh.get():
continue
# Aspect ratio (long/short, orientation-agnostic)
bbox_w, bbox_h = bbox_rot_rect[1]
long_edge = max(bbox_w, bbox_h)
short_edge = min(bbox_w, bbox_h)
if short_edge <= 0:
continue
aspect_ratio = long_edge / short_edge
if aspect_ratio < min_aspect or aspect_ratio > max_aspect:
continue
# Ignore contours touching image border by configurable margin.
if border_margin_x > 0 or border_margin_y > 0:
min_x = float(np.min(bounding_box[:, 0]))
max_x = float(np.max(bounding_box[:, 0]))
min_y = float(np.min(bounding_box[:, 1]))
max_y = float(np.max(bounding_box[:, 1]))
if (
min_x <= border_margin_x
or min_y <= border_margin_y
or max_x >= (w - 1 - border_margin_x)
or max_y >= (h - 1 - border_margin_y)
):
continue
# Merge near-duplicates by overlap threshold.
merged = False
if merge_iou > 0:
for idx, existing_poly in enumerate(box_polys_f32):
if self._poly_iou_prepared(existing_poly, bounding_box_f32, box_areas[idx], bbox_area) >= merge_iou:
existing_area = box_areas[idx]
if bbox_area > existing_area:
boxes[idx] = bounding_box
box_polys_f32[idx] = bounding_box_f32
box_areas[idx] = bbox_area
merged = True
break
if merged:
good_ids.add(n)
continue
# It's good
good_ids.add(n)
boxes.append(bounding_box)
box_polys_f32.append(bounding_box_f32)
box_areas.append(bbox_area)
if len(boxes) > self.MAX_RETURNED_BOXES:
ranked = sorted(
zip(box_areas, boxes),
key=lambda item: item[0],
reverse=True,
)[: self.MAX_RETURNED_BOXES]
boxes = [box for _, box in ranked]
self.update_status(
update_status_callback,
status_prefix + f"Too many candidates; keeping top {self.MAX_RETURNED_BOXES}.",
)
return boxes
def apply_morphology(self, mask, update_status_callback=None):
operation = self.params.morph_op.get()
kernel_size = self.params.dilate_kernel.get()
if operation == 0 or kernel_size <= 0:
return mask
kernel = np.ones((kernel_size, kernel_size), np.uint8)
if operation == 1:
self.update_status(update_status_callback, "Morphology: dilate...")
return cv2.dilate(mask, kernel)
if operation == 2:
self.update_status(update_status_callback, "Morphology: erode...")
return cv2.erode(mask, kernel)
if operation == 3:
self.update_status(update_status_callback, "Morphology: open...")
return cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
if operation == 4:
self.update_status(update_status_callback, "Morphology: close...")
return cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
return mask
def apply_threshold_pipeline(self, image_gray, update_status_callback=None):
# Binary filter
if self.params.bw_method.get() == 0:
self.update_status(update_status_callback, "Simple binary thresholding...")
_, mask = cv2.threshold(image_gray, self.params.bw_thresh.get(), 255, cv2.THRESH_BINARY)
return mask
# Adaptive thresh
if self.params.bw_method.get() == 1:
self.update_status(update_status_callback, "Adaptive Gaussian thresholding...")
# OpenCV requires blockSize to be odd and > 1.
block = self.params.bw_gauss.get()
if block <= 1:
block = 3
elif block % 2 == 0:
block += 1
return cv2.adaptiveThreshold(
image_gray,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
block,
self.params.bw_adaptive_c.get(),
)
# Otsu thresh
self.update_status(update_status_callback, "Otsu thresholding...")
_, mask = cv2.threshold(
image_gray,
0,
255,
cv2.THRESH_BINARY + cv2.THRESH_OTSU,
)
return mask
def apply_canny_pipeline(self, image_gray, update_status_callback=None):
self.update_status(update_status_callback, "Canny edge detection...")
low = min(self.params.canny_low.get(), self.params.canny_high.get())
high = max(self.params.canny_low.get(), self.params.canny_high.get())
return cv2.Canny(image_gray, low, high)
def extract_boxes_from_mask_relaxed(self, mask, update_status_callback=None, status_prefix="", return_areas=False):
mask = self.suppress_border_bar_artifacts(mask, update_status_callback, status_prefix)
self.update_status(update_status_callback, status_prefix + "Finding contours...")
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
if return_areas:
return [], []
return []
h, w, _ = self.image.shape
img_area = h * w
min_area = max(1.0, (self.params.bbox_min_size.get() / 100.0) * img_area * 0.5)
max_area = min(float(img_area), (self.params.bbox_max_size.get() / 100.0) * img_area * 1.05)
min_aspect = max(1.0, self.params.aspect_ratio_min.get() / 100.0)
max_aspect = max(10.0, self.params.aspect_ratio_max.get() / 100.0)
merge_iou = max(0.2, self.params.iou_merge_thresh.get() / 100.0)
boxes = []
box_polys_f32 = []
box_areas = []
last_contour = len(contours) - 1
for n, contour in enumerate(contours):
if self.abort_flag:
if return_areas:
return [], []
return []
if len(contour) < 4:
continue
if n % 25 == 0 or n == last_contour:
self.update_status(
update_status_callback,
status_prefix + "Processing contour " + str(n) + "/" + str(len(contours)),
)
rect = cv2.minAreaRect(contour)
box_f32 = cv2.boxPoints(rect).astype(np.float32)
box = box_f32.astype(np.intp)
area = cv2.contourArea(box_f32)
if area < min_area or area > max_area:
continue
bw, bh = rect[1]
long_edge = max(bw, bh)
short_edge = min(bw, bh)
if short_edge <= 0:
continue
aspect_ratio = long_edge / short_edge
if aspect_ratio < min_aspect or aspect_ratio > max_aspect:
continue
merged = False
for idx, existing in enumerate(box_polys_f32):
if self._poly_iou_prepared(existing, box_f32, box_areas[idx], area) >= merge_iou:
existing_area = box_areas[idx]
if area > existing_area:
boxes[idx] = box
box_polys_f32[idx] = box_f32
box_areas[idx] = area
merged = True
break
if not merged:
boxes.append(box)
box_polys_f32.append(box_f32)
box_areas.append(area)
if len(boxes) > self.MAX_RETURNED_BOXES:
ranked = sorted(
zip(box_areas, boxes),
key=lambda item: item[0],
reverse=True,
)[: self.MAX_RETURNED_BOXES]
box_areas = [area for area, _ in ranked]
boxes = [box for _, box in ranked]
if return_areas:
return boxes, box_areas
return boxes
def white_background_fallback(self, image_gray, update_status_callback=None):
self.update_status(update_status_callback, "Fallback: trying white-background recovery...")
h, w = image_gray.shape[:2]
# Keep cleanup kernels conservative so separate photos are not merged
# into one connected component.
close_k = int(np.clip(round(min(h, w) * 0.0025), 3, 15))
if close_k % 2 == 0:
close_k += 1
open_k = max(3, close_k // 2)
if open_k % 2 == 0:
open_k += 1
close_kernel = np.ones((close_k, close_k), np.uint8)
open_kernel = np.ones((open_k, open_k), np.uint8)
candidates = []
_, otsu_inv = cv2.threshold(image_gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
candidates.append(("Otsu inverted", otsu_inv))
for threshold in (220, 235, 245):
_, mask = cv2.threshold(image_gray, threshold, 255, cv2.THRESH_BINARY_INV)
candidates.append((f"Inverted threshold {threshold}", mask))
median = float(np.median(image_gray))
low = int(max(0.0, 0.66 * median))
high = int(min(255.0, 1.33 * median))
edge_mask = cv2.Canny(image_gray, low, high)
candidates.append(("Edges", edge_mask))
best_boxes = []
best_mask = None
best_score = (-1, -1, -1.0, -999999)
for name, raw_mask in candidates:
if self.abort_flag:
return [], None
self.update_status(update_status_callback, f"Fallback: {name}...")
mask_variants = [("raw", raw_mask)]
cleaned = cv2.morphologyEx(raw_mask, cv2.MORPH_CLOSE, close_kernel)
cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, open_kernel)
if name == "Edges":
cleaned = cv2.dilate(cleaned, open_kernel)
mask_variants.append(("cleaned", cleaned))
for variant_name, candidate_mask in mask_variants:
boxes, box_areas = self.extract_boxes_from_mask_relaxed(
candidate_mask,
update_status_callback,
status_prefix=f"Fallback ({name}/{variant_name}): ",
return_areas=True,
)
if len(boxes) == 0:
continue
total_area = float(sum(box_areas))
# Prefer plausible multi-photo detections and use area as tie-breaker.
plausible = 1 if len(boxes) <= 12 else 0
multi = 1 if len(boxes) >= 2 else 0
score = (plausible, multi, len(boxes), float(total_area))
if score > best_score:
best_score = score
best_boxes = boxes
best_mask = candidate_mask
return best_boxes, best_mask
def autodetect_slices(self, update_status_callback=None, preview_filter_output=False, fast_mode=False):
filter_out = self.image_gray
self.abort_flag = False
if self.params.clahe_clip.get() > 0:
self.update_status(update_status_callback, "Auto contrast (CLAHE)...")
clip_limit = max(0.1, self.params.clahe_clip.get() / 10.0)
clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(8, 8))
filter_out = clahe.apply(filter_out)
# Gaussian blur
if self.params.gaussian.get() > 0:
self.update_status(update_status_callback, "Gaussian blur...")
if self.params.gaussian.get() % 2 == 0:
block = self.params.gaussian.get() + 1
else:
block = self.params.gaussian.get()
filter_out = cv2.GaussianBlur(filter_out, (block, block), 0)
processing_gray = filter_out.copy()
if self.params.detect_pipeline.get() == 0:
filter_out = self.apply_threshold_pipeline(filter_out, update_status_callback)
else:
filter_out = self.apply_canny_pipeline(filter_out, update_status_callback)
invert_mode = self.params.invert_mode.get()
preview_filter = filter_out
if invert_mode == 2:
# Force inverted polarity first, then apply morphology on the same polarity
# that contours are extracted from.
inverted_filter = cv2.bitwise_not(filter_out)
preview_filter = self.apply_morphology(inverted_filter, update_status_callback)
boxes = self.extract_boxes_from_mask(preview_filter, update_status_callback)
else:
preview_filter = self.apply_morphology(filter_out, update_status_callback)
boxes = self.extract_boxes_from_mask(preview_filter, update_status_callback)
# Fallback for the opposite polarity (dark objects on bright background or vice versa).
if invert_mode == 0 and len(boxes) == 0 and not self.abort_flag:
inverted_filter = cv2.bitwise_not(filter_out)
inverted_filter = self.apply_morphology(inverted_filter, update_status_callback)
inverted_boxes = self.extract_boxes_from_mask(
inverted_filter,
update_status_callback,
status_prefix="Fallback (inverted): ",
)
if len(inverted_boxes) > 0:
boxes = inverted_boxes
preview_filter = inverted_filter
if len(boxes) == 0 and not self.abort_flag and not fast_mode:
fallback_boxes, fallback_mask = self.white_background_fallback(processing_gray, update_status_callback)
if len(fallback_boxes) > 0:
boxes = fallback_boxes
preview_filter = fallback_mask
if preview_filter_output:
return boxes, cv2.cvtColor(preview_filter, cv2.COLOR_GRAY2RGB)
return boxes, cv2.cvtColor(self.image, cv2.COLOR_BGR2RGB)