From 74a4c0222919a5bdc26bdf1220ed08bab006f097 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 20 Feb 2026 11:42:13 +0000 Subject: [PATCH] feat(autoslicer_detection): implement class AutoslicerDetectionMixin --- scanslicer/autoslicer_detection.py | 562 +++++++++++++++++++++++++++++ 1 file changed, 562 insertions(+) diff --git a/scanslicer/autoslicer_detection.py b/scanslicer/autoslicer_detection.py index 57748fd..3c142fb 100644 --- a/scanslicer/autoslicer_detection.py +++ b/scanslicer/autoslicer_detection.py @@ -3,3 +3,565 @@ import numpy as np # AutoslicerDetectionMixin: class definition for ScanSlicer behavior. +class AutoslicerDetectionMixin: + # Find all bbox related to n + # get_box_relatives: function definition for ScanSlicer behavior. + 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 + + # _poly_iou_prepared: function definition for ScanSlicer behavior. + 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) + + # poly_iou: function definition for ScanSlicer behavior. + 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) + + # suppress_border_bar_artifacts: function definition for ScanSlicer behavior. + 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 + + # trim_horizontal_band: function definition for ScanSlicer behavior. + def trim_horizontal_band(row_start, row_end): + nonlocal cleaned, 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 + + # trim_vertical_band: function definition for ScanSlicer behavior. + def trim_vertical_band(col_start, col_end): + nonlocal cleaned, 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 + + # extract_boxes_from_mask: function definition for ScanSlicer behavior. + 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 + + # apply_morphology: function definition for ScanSlicer behavior. + 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 + + # apply_threshold_pipeline: function definition for ScanSlicer behavior. + 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 + + # apply_canny_pipeline: function definition for ScanSlicer behavior. + 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) + + # extract_boxes_from_mask_relaxed: function definition for ScanSlicer behavior. + 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 + + # white_background_fallback: function definition for ScanSlicer behavior. + 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 + + # autodetect_slices: function definition for ScanSlicer behavior. + 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)