import cv2 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)