136 lines
4.7 KiB
Python
136 lines
4.7 KiB
Python
"""
|
|
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
|