""" Shape matching: compares the overall silhouette/contour of the uploaded arrangement against each template -- independent of color and of the local keypoint/texture matching SIFT/ORB/SuperGlue/LoFTR do. Two complementary signals, both standard, well-established methods (no deep learning needed for this): - cv2.matchShapes (built on Hu moments): translation/rotation/scale invariant shape-distance between the two contours' raw geometry. - Silhouette IoU after canonical alignment: crop each mask to its own bounding box, resize+center into a fixed canvas, then measure direct pixel overlap -- catches proportion/aspect differences Hu moments can miss, and doubles as the visual side-by-side/overlay image. Purely informational, its own section: never feeds into any score. """ import logging import cv2 import numpy as np import config logger = logging.getLogger(__name__) _template_shape_data = {} # name -> {"contour": ndarray|None, "canonical_mask": HxW bool} def _largest_contour(mask): contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None return max(contours, key=cv2.contourArea) def _canonical_silhouette(mask): """Crops to the mask's bounding box, then resizes+centers it into a fixed square canvas preserving aspect ratio -- so silhouettes are directly visually/IoU comparable regardless of the original photo's scale, crop, or resolution.""" size = config.SHAPE_CANONICAL_SIZE ys, xs = np.where(mask > 0) if len(ys) == 0: return np.zeros((size, size), dtype=bool) y0, y1, x0, x1 = ys.min(), ys.max(), xs.min(), xs.max() cropped = (mask[y0:y1 + 1, x0:x1 + 1] > 0).astype(np.uint8) * 255 h, w = cropped.shape scale = (size * 0.9) / max(h, w) new_w, new_h = max(1, int(w * scale)), max(1, int(h * scale)) resized = cv2.resize(cropped, (new_w, new_h), interpolation=cv2.INTER_NEAREST) canvas = np.zeros((size, size), dtype=np.uint8) y_off = (size - new_h) // 2 x_off = (size - new_w) // 2 canvas[y_off:y_off + new_h, x_off:x_off + new_w] = resized return canvas > 0 def compute_shape_data(mask): return { "contour": _largest_contour(mask), "canonical_mask": _canonical_silhouette(mask), } def set_template_shape_data(name, mask): _template_shape_data[name] = compute_shape_data(mask) def _hu_similarity_pct(contour_a, contour_b): if contour_a is None or contour_b is None: return 0.0 dist = cv2.matchShapes(contour_a, contour_b, cv2.CONTOURS_MATCH_I1, 0.0) return max(0.0, 100.0 * (1 - dist / config.SHAPE_HU_DISTANCE_SCALE)) def _iou_pct(mask_a, mask_b): inter = np.logical_and(mask_a, mask_b).sum() union = np.logical_or(mask_a, mask_b).sum() return (float(inter) / float(union) * 100.0) if union > 0 else 0.0 def compare_to_templates(mask): """Returns (input_shape_data, ranked_results) -- results sorted by match_pct descending, one entry per template with the Hu-based and IoU-based sub-scores broken out too.""" input_data = compute_shape_data(mask) results = [] for name, tdata in _template_shape_data.items(): hu_sim = _hu_similarity_pct(input_data["contour"], tdata["contour"]) iou = _iou_pct(input_data["canonical_mask"], tdata["canonical_mask"]) match_pct = round((hu_sim + iou) / 2, 1) results.append({ "template": name, "match_pct": match_pct, "hu_similarity_pct": round(hu_sim, 1), "iou_pct": round(iou, 1), }) results.sort(key=lambda r: r["match_pct"], reverse=True) return input_data, results # --------------------------------------------------------------- # Visuals: two normalized silhouettes side by side + an overlay showing # exactly where they agree/diverge. # --------------------------------------------------------------- _BG = (24, 22, 19) _INPUT_COLOR = (118, 143, 124) # BGR -- matches the site's --sage _TEMPLATE_COLOR = (90, 122, 185) # BGR -- matches the site's --terracotta _OVERLAP_COLOR = (150, 205, 200) def render_silhouette(canonical_mask, color=_INPUT_COLOR): size = config.SHAPE_CANONICAL_SIZE img = np.full((size, size, 3), _BG, dtype=np.uint8) img[canonical_mask] = color return img def render_overlay(input_mask, template_mask): size = config.SHAPE_CANONICAL_SIZE img = np.full((size, size, 3), _BG, dtype=np.uint8) only_input = input_mask & ~template_mask only_template = template_mask & ~input_mask both = input_mask & template_mask img[only_input] = _INPUT_COLOR img[only_template] = _TEMPLATE_COLOR img[both] = _OVERLAP_COLOR return img