Add project files
This commit is contained in:
0
pipeline/__init__.py
Normal file
0
pipeline/__init__.py
Normal file
BIN
pipeline/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
pipeline/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/__init__.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/__init__.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/bg_removal.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/bg_removal.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/bg_removal.cpython-311.pyc
Normal file
BIN
pipeline/__pycache__/bg_removal.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/bg_removal.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/bg_removal.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/classical.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/classical.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/classical.cpython-311.pyc
Normal file
BIN
pipeline/__pycache__/classical.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/classical.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/classical.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/color.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/color.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/color.cpython-311.pyc
Normal file
BIN
pipeline/__pycache__/color.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/color.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/color.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/color_grid.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/color_grid.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/color_grid.cpython-311.pyc
Normal file
BIN
pipeline/__pycache__/color_grid.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/color_grid.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/color_grid.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/deep.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/deep.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/deep.cpython-311.pyc
Normal file
BIN
pipeline/__pycache__/deep.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/deep.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/deep.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/engine.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/engine.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/engine.cpython-311.pyc
Normal file
BIN
pipeline/__pycache__/engine.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/engine.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/engine.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/flower_count.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/flower_count.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/sam3_client.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/sam3_client.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/shape_match.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/shape_match.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/shape_match.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/shape_match.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/texture_match.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/texture_match.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/texture_match.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/texture_match.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/utils.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/utils.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/utils.cpython-311.pyc
Normal file
BIN
pipeline/__pycache__/utils.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/utils.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/utils.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/vase_compare.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/vase_compare.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/verify.cpython-310.pyc
Normal file
BIN
pipeline/__pycache__/verify.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/verify.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/verify.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pipeline/__pycache__/yolo_world.cpython-38.pyc
Normal file
BIN
pipeline/__pycache__/yolo_world.cpython-38.pyc
Normal file
Binary file not shown.
147
pipeline/bg_removal.py
Normal file
147
pipeline/bg_removal.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Background removal via rembg (BiRefNet lite), disk-cached by content hash
|
||||
so a re-upload of the same image never re-runs the model.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from rembg import remove, new_session
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_session = None
|
||||
|
||||
|
||||
def get_session():
|
||||
"""Lazily create the rembg session once and reuse it for every request."""
|
||||
global _session
|
||||
if _session is None:
|
||||
try:
|
||||
_session = new_session(config.REMBG_MODEL_NAME,
|
||||
providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to create rembg session with CUDAExecutionProvider, "
|
||||
"falling back to CPU-only"
|
||||
)
|
||||
_session = new_session(config.REMBG_MODEL_NAME, providers=["CPUExecutionProvider"])
|
||||
|
||||
try:
|
||||
providers = _session.inner_session.get_providers()
|
||||
logger.info("rembg session ready, using providers: %s", providers)
|
||||
if "CUDAExecutionProvider" not in providers:
|
||||
logger.warning(
|
||||
"rembg is running on CPU (no CUDAExecutionProvider) -- "
|
||||
"background removal will be much slower. Check that "
|
||||
"onnxruntime-gpu is installed and the CUDA driver is visible."
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
return _session
|
||||
|
||||
|
||||
def unload_session():
|
||||
"""Drops the rembg/onnxruntime session so its (possibly CUDA-backed)
|
||||
memory is freed. It's a lazy singleton (see get_session above), so the
|
||||
next call to remove_background_bytes/_file simply recreates it. Used to
|
||||
make room for SAM-based flower counting on an 8GB card."""
|
||||
global _session
|
||||
freed = _session is not None
|
||||
_session = None
|
||||
if freed:
|
||||
gc.collect()
|
||||
return freed
|
||||
|
||||
|
||||
def resize_max_dim(pil_img: Image.Image, max_dim: int) -> Image.Image:
|
||||
"""Downscale in place-equivalent fashion so neither side exceeds max_dim.
|
||||
No-op (returns the same image) if already within bounds -- this is the
|
||||
single choke point that keeps memory/time bounded for every model in the
|
||||
pipeline (bg removal, SIFT/ORB, SuperPoint, LoFTR), regardless of how
|
||||
large the original upload was."""
|
||||
w, h = pil_img.size
|
||||
scale = max_dim / max(w, h)
|
||||
if scale >= 1.0:
|
||||
return pil_img
|
||||
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
|
||||
return pil_img.resize(new_size, Image.LANCZOS)
|
||||
|
||||
|
||||
def hash_bytes(data: bytes) -> str:
|
||||
return hashlib.sha1(data).hexdigest()[:20]
|
||||
|
||||
|
||||
def remove_background_bytes(image_bytes: bytes, cache_dir: str):
|
||||
"""
|
||||
Runs background removal on raw image bytes (an upload), caching the
|
||||
result by content hash. Returns (rgba_bgra_ndarray, cache_key).
|
||||
"""
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
key = hash_bytes(image_bytes)
|
||||
cache_path = os.path.join(cache_dir, key + ".png")
|
||||
|
||||
if os.path.exists(cache_path):
|
||||
img = cv2.imread(cache_path, cv2.IMREAD_UNCHANGED)
|
||||
if img is not None and img.ndim == 3 and img.shape[2] == 4:
|
||||
return img, key
|
||||
|
||||
pil_img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||
orig_size = pil_img.size
|
||||
pil_img = resize_max_dim(pil_img, config.MAX_IMAGE_DIM)
|
||||
if pil_img.size != orig_size:
|
||||
logger.info("Resized upload %s -> %s before processing", orig_size, pil_img.size)
|
||||
|
||||
result = remove(pil_img, session=get_session())
|
||||
result.save(cache_path)
|
||||
|
||||
rgba = cv2.cvtColor(np.array(result), cv2.COLOR_RGBA2BGRA)
|
||||
return rgba, key
|
||||
|
||||
|
||||
def remove_background_file(src_path: str, cache_dir: str):
|
||||
"""Same as remove_background_bytes, but caches by original filename --
|
||||
used for the fixed template set, which doesn't change between requests."""
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
base_name = os.path.splitext(os.path.basename(src_path))[0]
|
||||
cache_path = os.path.join(cache_dir, base_name + ".png")
|
||||
|
||||
if os.path.exists(cache_path):
|
||||
img = cv2.imread(cache_path, cv2.IMREAD_UNCHANGED)
|
||||
if img is not None and img.ndim == 3 and img.shape[2] == 4:
|
||||
return img
|
||||
|
||||
pil_img = Image.open(src_path).convert("RGB")
|
||||
pil_img = resize_max_dim(pil_img, config.MAX_IMAGE_DIM)
|
||||
result = remove(pil_img, session=get_session())
|
||||
result.save(cache_path)
|
||||
|
||||
return cv2.cvtColor(np.array(result), cv2.COLOR_RGBA2BGRA)
|
||||
|
||||
|
||||
def create_mask(alpha):
|
||||
_, mask = cv2.threshold(alpha, config.ALPHA_THRESHOLD, 255, cv2.THRESH_BINARY)
|
||||
kernel = np.ones((3, 3), np.uint8)
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
||||
mask = cv2.erode(mask, kernel, iterations=config.ERODE_ITER)
|
||||
return mask
|
||||
|
||||
|
||||
def split_rgba(rgba):
|
||||
"""rgba: BGRA ndarray -> (bgr, alpha, mask)"""
|
||||
if rgba.shape[2] == 4:
|
||||
bgr = rgba[:, :, :3]
|
||||
alpha = rgba[:, :, 3]
|
||||
else:
|
||||
bgr = rgba
|
||||
alpha = np.ones(bgr.shape[:2], dtype=np.uint8) * 255
|
||||
mask = create_mask(alpha)
|
||||
return bgr, alpha, mask
|
||||
110
pipeline/classical.py
Normal file
110
pipeline/classical.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
SIFT / ORB matching: one query image vs the fixed template set.
|
||||
Template keypoints/descriptors are precomputed once at startup and reused
|
||||
for every request -- only the query image is processed per request.
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import config
|
||||
|
||||
_sift = None
|
||||
_orb = None
|
||||
_template_features = {"SIFT": None, "ORB": None} # name -> {template_name: {"kp","des"}}
|
||||
|
||||
|
||||
def _get_sift():
|
||||
global _sift
|
||||
if _sift is None:
|
||||
_sift = cv2.SIFT_create()
|
||||
return _sift
|
||||
|
||||
|
||||
def _get_orb():
|
||||
global _orb
|
||||
if _orb is None:
|
||||
_orb = cv2.ORB_create(nfeatures=config.ORB_N_FEATURES)
|
||||
return _orb
|
||||
|
||||
|
||||
def _detector_for(method):
|
||||
return _get_sift() if method == "SIFT" else _get_orb()
|
||||
|
||||
|
||||
def _matcher_for(method):
|
||||
if method == "SIFT":
|
||||
index_params = dict(algorithm=1, trees=5) # FLANN_INDEX_KDTREE
|
||||
else:
|
||||
index_params = dict(algorithm=6, table_number=6, key_size=12,
|
||||
multi_probe_level=1) # FLANN_INDEX_LSH
|
||||
search_params = dict(checks=50)
|
||||
return cv2.FlannBasedMatcher(index_params, search_params)
|
||||
|
||||
|
||||
def extract_features(method, bgr, mask):
|
||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
detector = _detector_for(method)
|
||||
kp, des = detector.detectAndCompute(gray, mask)
|
||||
return kp, des
|
||||
|
||||
|
||||
def set_template_features(method, template_name, kp, des):
|
||||
if _template_features[method] is None:
|
||||
_template_features[method] = {}
|
||||
_template_features[method][template_name] = {"kp": kp, "des": des}
|
||||
|
||||
|
||||
def get_template_features(method):
|
||||
return _template_features[method] or {}
|
||||
|
||||
|
||||
def match_pair(method, kp_q, des_q, kp_t, des_t):
|
||||
"""Returns (inlier_count, confidence_pct)."""
|
||||
if des_q is None or des_t is None or len(kp_q) == 0 or len(kp_t) == 0:
|
||||
return 0, 0.0
|
||||
|
||||
matcher = _matcher_for(method)
|
||||
try:
|
||||
raw_matches = matcher.knnMatch(des_q, des_t, k=2)
|
||||
except cv2.error:
|
||||
return 0, 0.0
|
||||
|
||||
good = []
|
||||
for pair in raw_matches:
|
||||
if len(pair) != 2:
|
||||
continue
|
||||
m, n = pair
|
||||
if m.distance < config.LOWE_RATIO * n.distance:
|
||||
good.append(m)
|
||||
|
||||
if len(good) < config.MIN_RAW_MATCHES:
|
||||
return 0, 0.0
|
||||
|
||||
src = np.float32([kp_q[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
dst = np.float32([kp_t[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
|
||||
_, ransac_mask = cv2.findHomography(src, dst, cv2.RANSAC, 5.0)
|
||||
if ransac_mask is None:
|
||||
return 0, 0.0
|
||||
|
||||
inlier_count = int(ransac_mask.sum())
|
||||
confidence_pct = (inlier_count / len(good)) * 100 if good else 0.0
|
||||
return inlier_count, confidence_pct
|
||||
|
||||
|
||||
def match_against_templates(method, kp_q, des_q):
|
||||
"""Returns a list of {"template": name, "score": int, "confidence": float},
|
||||
sorted by score descending, for every precomputed template."""
|
||||
results = []
|
||||
for template_name, data in get_template_features(method).items():
|
||||
inlier_count, confidence_pct = match_pair(
|
||||
method, kp_q, des_q, data["kp"], data["des"]
|
||||
)
|
||||
results.append({
|
||||
"template": template_name,
|
||||
"score": inlier_count,
|
||||
"confidence": round(confidence_pct, 1),
|
||||
})
|
||||
results.sort(key=lambda r: r["score"], reverse=True)
|
||||
return results
|
||||
148
pipeline/color.py
Normal file
148
pipeline/color.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Independent color-space comparison: how much of the uploaded photo's color
|
||||
distribution is also present in each template, plus which specific colors
|
||||
matched. Entirely separate from the SIFT/ORB/SuperGlue/LoFTR scores and the
|
||||
weighted final match -- nothing here feeds into those, it's its own section.
|
||||
|
||||
Template-side color data (histogram + dominant colors) is precomputed once
|
||||
at startup from the same masked bgr/mask already produced during bootstrap,
|
||||
same principle as the other methods: a request only ever analyzes the one
|
||||
uploaded image.
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
N_DOMINANT_COLORS = 5
|
||||
HIST_HUE_BINS = 30
|
||||
HIST_SAT_BINS = 32
|
||||
|
||||
# k-means on every foreground pixel is wasteful and, given this app's
|
||||
# history of memory blowups, worth bounding explicitly -- a random sample
|
||||
# is statistically equivalent for "what are the dominant colors" purposes.
|
||||
MAX_KMEANS_SAMPLE = 20000
|
||||
|
||||
# Empirical ceiling for CIE76 Lab distance beyond which two colors are
|
||||
# considered maximally dissimilar (similarity floors at 0%). ~100 comfortably
|
||||
# covers the largest perceptual differences (e.g. black vs. white is ~100).
|
||||
MAX_LAB_DISTANCE = 100.0
|
||||
|
||||
_template_color_data = {} # name -> {"hist": ndarray, "colors": [...]}
|
||||
|
||||
|
||||
def _masked_pixels_bgr(bgr, mask):
|
||||
ys, xs = np.where(mask > 0)
|
||||
if len(ys) == 0:
|
||||
return np.zeros((0, 3), dtype=np.uint8)
|
||||
return bgr[ys, xs]
|
||||
|
||||
|
||||
def compute_hs_histogram(bgr, mask):
|
||||
"""Hue+Saturation 2D histogram (Value/brightness deliberately excluded
|
||||
so lighting differences between photos don't masquerade as color
|
||||
differences), normalized so it sums to 1."""
|
||||
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||||
hist = cv2.calcHist([hsv], [0, 1], mask, [HIST_HUE_BINS, HIST_SAT_BINS],
|
||||
[0, 180, 0, 256])
|
||||
total = hist.sum()
|
||||
if total > 0:
|
||||
hist = hist / total
|
||||
return hist
|
||||
|
||||
|
||||
def histogram_match_pct(hist_a, hist_b):
|
||||
"""% of one distribution's mass that the other also covers -- both
|
||||
histograms are pre-normalized, so HISTCMP_INTERSECT directly sums
|
||||
min(a[i], b[i]) into a 0-1 overlap fraction."""
|
||||
intersection = cv2.compareHist(hist_a.astype(np.float32), hist_b.astype(np.float32),
|
||||
cv2.HISTCMP_INTERSECT)
|
||||
return round(float(intersection) * 100, 1)
|
||||
|
||||
|
||||
def dominant_colors(bgr, mask, k=N_DOMINANT_COLORS):
|
||||
pixels = _masked_pixels_bgr(bgr, mask)
|
||||
if len(pixels) == 0:
|
||||
return []
|
||||
|
||||
if len(pixels) > MAX_KMEANS_SAMPLE:
|
||||
idx = np.random.choice(len(pixels), MAX_KMEANS_SAMPLE, replace=False)
|
||||
pixels = pixels[idx]
|
||||
|
||||
unique_count = len(np.unique(pixels, axis=0))
|
||||
k_eff = min(k, unique_count)
|
||||
if k_eff < 1:
|
||||
return []
|
||||
|
||||
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 20, 0.5)
|
||||
_, labels, centers = cv2.kmeans(pixels.astype(np.float32), k_eff, None,
|
||||
criteria, 3, cv2.KMEANS_PP_CENTERS)
|
||||
|
||||
labels = labels.flatten()
|
||||
counts = np.bincount(labels, minlength=k_eff)
|
||||
total = counts.sum()
|
||||
|
||||
colors = []
|
||||
for i in range(k_eff):
|
||||
b, g, r = centers[i]
|
||||
colors.append({
|
||||
"rgb": [int(round(r)), int(round(g)), int(round(b))],
|
||||
"pct": round(float(counts[i]) / total * 100, 1),
|
||||
})
|
||||
colors.sort(key=lambda c: c["pct"], reverse=True)
|
||||
return colors
|
||||
|
||||
|
||||
def _lab_distance(rgb_a, rgb_b):
|
||||
a = np.uint8([[[rgb_a[2], rgb_a[1], rgb_a[0]]]]) # rgb -> bgr for cv2
|
||||
b = np.uint8([[[rgb_b[2], rgb_b[1], rgb_b[0]]]])
|
||||
lab_a = cv2.cvtColor(a, cv2.COLOR_BGR2LAB)[0][0].astype(np.float32)
|
||||
lab_b = cv2.cvtColor(b, cv2.COLOR_BGR2LAB)[0][0].astype(np.float32)
|
||||
return float(np.linalg.norm(lab_a - lab_b))
|
||||
|
||||
|
||||
def match_color_pairs(input_colors, template_colors):
|
||||
"""For each of the input's dominant colors, find the closest template
|
||||
dominant color by perceptual (Lab) distance and report a similarity %."""
|
||||
pairs = []
|
||||
for ic in input_colors:
|
||||
if not template_colors:
|
||||
break
|
||||
best = min(template_colors, key=lambda tc: _lab_distance(ic["rgb"], tc["rgb"]))
|
||||
dist = _lab_distance(ic["rgb"], best["rgb"])
|
||||
similarity = max(0.0, 100.0 * (1 - dist / MAX_LAB_DISTANCE))
|
||||
pairs.append({
|
||||
"input_rgb": ic["rgb"],
|
||||
"input_pct": ic["pct"],
|
||||
"template_rgb": best["rgb"],
|
||||
"template_pct": best["pct"],
|
||||
"similarity": round(similarity, 1),
|
||||
})
|
||||
return pairs
|
||||
|
||||
|
||||
def set_template_color_data(name, bgr, mask):
|
||||
_template_color_data[name] = {
|
||||
"hist": compute_hs_histogram(bgr, mask),
|
||||
"colors": dominant_colors(bgr, mask),
|
||||
}
|
||||
|
||||
|
||||
def compare_input_to_templates(input_bgr, input_mask):
|
||||
input_hist = compute_hs_histogram(input_bgr, input_mask)
|
||||
input_colors = dominant_colors(input_bgr, input_mask)
|
||||
|
||||
results = []
|
||||
for name, data in _template_color_data.items():
|
||||
match_pct = histogram_match_pct(input_hist, data["hist"])
|
||||
pairs = match_color_pairs(input_colors, data["colors"])
|
||||
results.append({
|
||||
"template": name,
|
||||
"match_pct": match_pct,
|
||||
"color_pairs": pairs,
|
||||
})
|
||||
results.sort(key=lambda r: r["match_pct"], reverse=True)
|
||||
|
||||
return {
|
||||
"input_dominant_colors": input_colors,
|
||||
"templates": results,
|
||||
}
|
||||
194
pipeline/color_grid.py
Normal file
194
pipeline/color_grid.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Color family grid: dynamically discovers color regions within an image via
|
||||
K-means clustering in LAB space (adapted from a standalone dynamic-color-
|
||||
family script the user provided as a reference), then renders each region
|
||||
as its own tile in a grid -- keeping only that region's original pixels,
|
||||
everything else left dark -- and, given a second image, matches each region
|
||||
to its closest counterpart by average color and reports how much area they
|
||||
each cover.
|
||||
|
||||
Purely visual/informational, like the AI verification section: nothing here
|
||||
feeds into any score.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bounded the same way pipeline/color.py bounds its own k-means call --
|
||||
# clustering every foreground pixel is wasteful, a random sample is
|
||||
# statistically equivalent for finding cluster centers.
|
||||
MAX_KMEANS_SAMPLE = 20000
|
||||
|
||||
|
||||
def cluster_families(bgr, mask, k=None):
|
||||
"""K-means-clusters the masked (foreground) pixels into k color
|
||||
families in LAB space. Returns a list sorted by area descending:
|
||||
{"mask": HxW bool ndarray, "rgb": [r,g,b], "pct": float, "pixel_count": int}
|
||||
"""
|
||||
k = k or config.FAMILY_GRID_K
|
||||
ys, xs = np.where(mask > 0)
|
||||
if len(ys) == 0:
|
||||
return []
|
||||
|
||||
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB).astype(np.float32)
|
||||
fg_lab = lab[ys, xs]
|
||||
|
||||
# Down-weight lightness so light/dark shadows of the same hue land in
|
||||
# the same family instead of splitting across families by brightness.
|
||||
features = fg_lab.copy()
|
||||
features[:, 0] *= config.FAMILY_GRID_LIGHTNESS_WEIGHT
|
||||
|
||||
if len(features) > MAX_KMEANS_SAMPLE:
|
||||
sample_idx = np.random.choice(len(features), MAX_KMEANS_SAMPLE, replace=False)
|
||||
sample = features[sample_idx]
|
||||
else:
|
||||
sample = features
|
||||
|
||||
k_eff = min(k, len(np.unique(sample, axis=0)))
|
||||
if k_eff < 1:
|
||||
return []
|
||||
|
||||
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 20, 0.5)
|
||||
_, _, centers = cv2.kmeans(sample, k_eff, None, criteria, 3, cv2.KMEANS_PP_CENTERS)
|
||||
|
||||
# Assign EVERY foreground pixel (not just the sample) to its nearest
|
||||
# center -- needed to build full-resolution family masks. Cheap: a
|
||||
# handful of vectorized (N,) distance passes, one per cluster.
|
||||
dists = np.stack([np.linalg.norm(features - c, axis=1) for c in centers], axis=1)
|
||||
labels = np.argmin(dists, axis=1)
|
||||
|
||||
h, w = mask.shape
|
||||
total = len(labels)
|
||||
families = []
|
||||
for i in range(k_eff):
|
||||
member = labels == i
|
||||
count = int(member.sum())
|
||||
if count == 0:
|
||||
continue
|
||||
full_mask = np.zeros((h, w), dtype=bool)
|
||||
full_mask[ys[member], xs[member]] = True
|
||||
mean_bgr = bgr[ys[member], xs[member]].mean(axis=0)
|
||||
families.append({
|
||||
"mask": full_mask,
|
||||
"rgb": [int(round(mean_bgr[2])), int(round(mean_bgr[1])), int(round(mean_bgr[0]))],
|
||||
"pct": round(count / total * 100, 1),
|
||||
"pixel_count": count,
|
||||
})
|
||||
|
||||
families.sort(key=lambda f: f["pct"], reverse=True)
|
||||
return families
|
||||
|
||||
|
||||
def _lab_distance_rgb(rgb_a, rgb_b):
|
||||
a = np.uint8([[[rgb_a[2], rgb_a[1], rgb_a[0]]]])
|
||||
b = np.uint8([[[rgb_b[2], rgb_b[1], rgb_b[0]]]])
|
||||
lab_a = cv2.cvtColor(a, cv2.COLOR_BGR2LAB)[0][0].astype(np.float32)
|
||||
lab_b = cv2.cvtColor(b, cv2.COLOR_BGR2LAB)[0][0].astype(np.float32)
|
||||
return float(np.linalg.norm(lab_a - lab_b))
|
||||
|
||||
|
||||
def match_families(input_families, template_families):
|
||||
"""For each input family (largest first), finds its closest template
|
||||
family by average color and reports an area-match %: the smaller area
|
||||
share divided by the larger, so it reads 100% when both cover the same
|
||||
proportion of their own (differently-sized) images, regardless of which
|
||||
one is physically bigger."""
|
||||
pairs = []
|
||||
for rank, ifam in enumerate(input_families):
|
||||
if not template_families:
|
||||
break
|
||||
tfam = min(template_families,
|
||||
key=lambda f: _lab_distance_rgb(ifam["rgb"], f["rgb"]))
|
||||
color_dist = _lab_distance_rgb(ifam["rgb"], tfam["rgb"])
|
||||
color_similarity = max(0.0, 100.0 * (1 - color_dist / 100.0))
|
||||
bigger = max(ifam["pct"], tfam["pct"])
|
||||
area_match = (min(ifam["pct"], tfam["pct"]) / bigger * 100) if bigger > 0 else 0.0
|
||||
pairs.append({
|
||||
"rank": rank + 1,
|
||||
"input_rgb": ifam["rgb"],
|
||||
"input_pct": ifam["pct"],
|
||||
"template_rgb": tfam["rgb"],
|
||||
"template_pct": tfam["pct"],
|
||||
"area_match_pct": round(area_match, 1),
|
||||
"color_similarity_pct": round(color_similarity, 1),
|
||||
})
|
||||
return pairs
|
||||
|
||||
|
||||
def overall_area_match(pairs):
|
||||
"""Single headline %: each pair's area-match weighted by how much of
|
||||
the input image that family actually covers (so a good match on the
|
||||
dominant color counts for more than a good match on a sliver)."""
|
||||
if not pairs:
|
||||
return None
|
||||
total_weight = sum(p["input_pct"] for p in pairs)
|
||||
if total_weight <= 0:
|
||||
return None
|
||||
weighted = sum(p["area_match_pct"] * p["input_pct"] for p in pairs)
|
||||
return round(weighted / total_weight, 1)
|
||||
|
||||
|
||||
def render_family_grid(bgr, families):
|
||||
"""Renders each family as its own tile (original colors preserved,
|
||||
everything else left dark) arranged in a grid with separator lines and
|
||||
a rank/percentage label -- returns a BGR ndarray ready for cv2.imwrite.
|
||||
Each tile is downscaled to FAMILY_GRID_TILE_MAX_DIM before being placed,
|
||||
so the assembled canvas stays small by construction."""
|
||||
if not families:
|
||||
return bgr.copy()
|
||||
|
||||
h, w = bgr.shape[:2]
|
||||
tile_max = config.FAMILY_GRID_TILE_MAX_DIM
|
||||
scale = min(1.0, tile_max / max(h, w))
|
||||
tw, th = max(1, int(w * scale)), max(1, int(h * scale))
|
||||
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
if scale < 1.0:
|
||||
rgb_small = cv2.resize(rgb, (tw, th), interpolation=cv2.INTER_AREA)
|
||||
else:
|
||||
rgb_small = rgb
|
||||
|
||||
line_width = config.FAMILY_GRID_LINE_WIDTH
|
||||
k = len(families)
|
||||
cols = math.ceil(math.sqrt(k))
|
||||
rows = math.ceil(k / cols)
|
||||
|
||||
grid_w = cols * tw + (cols + 1) * line_width
|
||||
grid_h = rows * th + (rows + 1) * line_width
|
||||
|
||||
canvas = Image.new("RGB", (grid_w, grid_h), (24, 22, 19))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
for rank, fam in enumerate(families):
|
||||
r_idx, c_idx = divmod(rank, cols)
|
||||
x0 = line_width + c_idx * (tw + line_width)
|
||||
y0 = line_width + r_idx * (th + line_width)
|
||||
|
||||
mask_small = fam["mask"]
|
||||
if scale < 1.0:
|
||||
mask_small = cv2.resize(mask_small.astype(np.uint8), (tw, th),
|
||||
interpolation=cv2.INTER_NEAREST).astype(bool)
|
||||
|
||||
tile = np.zeros_like(rgb_small)
|
||||
tile[mask_small] = rgb_small[mask_small]
|
||||
canvas.paste(Image.fromarray(tile, mode="RGB"), (x0, y0))
|
||||
|
||||
draw.rectangle([x0, y0, x0 + tw - 1, y0 + th - 1],
|
||||
outline=(210, 200, 180), width=max(1, line_width // 2))
|
||||
|
||||
label = f"#{rank + 1} {fam['pct']}%"
|
||||
label_w = 9 * len(label) + 16
|
||||
draw.rectangle([x0 + 6, y0 + 6, x0 + 6 + label_w, y0 + 26], fill=(20, 18, 16))
|
||||
draw.rectangle([x0 + 10, y0 + 10, x0 + 22, y0 + 22],
|
||||
fill=tuple(fam["rgb"]), outline=(255, 255, 255))
|
||||
draw.text((x0 + 28, y0 + 10), label, fill=(240, 235, 225))
|
||||
|
||||
return cv2.cvtColor(np.array(canvas), cv2.COLOR_RGB2BGR)
|
||||
235
pipeline/deep.py
Normal file
235
pipeline/deep.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Deep-learning matchers: SuperPoint+LightGlue ("SuperGlue" in the UI/output
|
||||
naming, per the existing convention -- LightGlue is the permissively
|
||||
licensed, actively maintained successor actually running under the hood)
|
||||
and LoFTR (dense, pairwise).
|
||||
|
||||
Template-side features/tensors are precomputed once at startup and reused
|
||||
for every request; only the query image is processed per request. Since
|
||||
there are only a handful of templates, even LoFTR (which has no reusable
|
||||
per-image descriptor and must re-run a full forward pass per pair) is cheap
|
||||
here -- it does NOT scale the way it would against hundreds of images.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
_superpoint = None
|
||||
_lightglue = None
|
||||
_loftr = None
|
||||
|
||||
_template_superpoint_feats = {} # template_name -> {"feats", "mask"}
|
||||
_template_loftr_tensors = {} # template_name -> {"tensor", "mask"}
|
||||
|
||||
|
||||
def get_superpoint():
|
||||
global _superpoint
|
||||
if _superpoint is None:
|
||||
logger.info("Loading SuperPoint on %s...", DEVICE)
|
||||
from lightglue import SuperPoint
|
||||
_superpoint = SuperPoint(
|
||||
max_num_keypoints=config.SUPERPOINT_MAX_KEYPOINTS
|
||||
).eval().to(DEVICE)
|
||||
return _superpoint
|
||||
|
||||
|
||||
def get_lightglue():
|
||||
global _lightglue
|
||||
if _lightglue is None:
|
||||
logger.info("Loading LightGlue on %s...", DEVICE)
|
||||
from lightglue import LightGlue
|
||||
_lightglue = LightGlue(features="superpoint").eval().to(DEVICE)
|
||||
return _lightglue
|
||||
|
||||
|
||||
def get_loftr():
|
||||
global _loftr
|
||||
if _loftr is None:
|
||||
logger.info("Loading LoFTR on %s...", DEVICE)
|
||||
import kornia.feature as KF
|
||||
_loftr = KF.LoFTR(pretrained="outdoor").eval().to(DEVICE)
|
||||
return _loftr
|
||||
|
||||
|
||||
def unload_models():
|
||||
"""Drops the SuperPoint/LightGlue/LoFTR model objects (not the small
|
||||
per-template feature/tensor caches, which stay put) and frees their CUDA
|
||||
memory. They're plain lazy singletons (see get_superpoint/get_lightglue/
|
||||
get_loftr above), so the next call to any of those simply reloads --
|
||||
exactly like a fresh process start. Used to make room for SAM-based
|
||||
flower counting on an 8GB card that can't hold everything at once."""
|
||||
global _superpoint, _lightglue, _loftr
|
||||
freed = _superpoint is not None or _lightglue is not None or _loftr is not None
|
||||
_superpoint = None
|
||||
_lightglue = None
|
||||
_loftr = None
|
||||
if freed:
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
return freed
|
||||
|
||||
|
||||
def masked_gray_tensor(bgr, mask, max_dim=None, fill_value=0.5):
|
||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0
|
||||
|
||||
if max_dim is not None:
|
||||
h, w = gray.shape
|
||||
scale = max_dim / max(h, w)
|
||||
if scale < 1.0:
|
||||
new_w, new_h = int(w * scale), int(h * scale)
|
||||
gray = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
mask = cv2.resize(mask, (new_w, new_h), interpolation=cv2.INTER_NEAREST)
|
||||
|
||||
gray[mask == 0] = fill_value
|
||||
tensor = torch.from_numpy(gray)[None, None].to(DEVICE)
|
||||
return tensor, mask
|
||||
|
||||
|
||||
def keep_points_inside_mask(pts, mask):
|
||||
if len(pts) == 0:
|
||||
return np.zeros(0, dtype=bool)
|
||||
h, w = mask.shape
|
||||
xs = np.clip(pts[:, 0].round().astype(int), 0, w - 1)
|
||||
ys = np.clip(pts[:, 1].round().astype(int), 0, h - 1)
|
||||
return mask[ys, xs] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# SuperPoint + LightGlue
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def superpoint_extract(bgr, mask):
|
||||
# ASSUMPTION: cap at MAX_IMAGE_DIM even though uploads are already
|
||||
# resized upstream -- this is what used to run at full (sometimes
|
||||
# multi-thousand-pixel) upload resolution and was the main driver of
|
||||
# the multi-GB memory spikes that got the process OOM-killed.
|
||||
tensor, resized_mask = masked_gray_tensor(bgr, mask, max_dim=config.MAX_IMAGE_DIM)
|
||||
with torch.no_grad():
|
||||
feats = get_superpoint().extract(tensor)
|
||||
return feats, resized_mask
|
||||
|
||||
|
||||
def set_template_superpoint(template_name, bgr, mask):
|
||||
feats, resized_mask = superpoint_extract(bgr, mask)
|
||||
_template_superpoint_feats[template_name] = {"feats": feats, "mask": resized_mask}
|
||||
|
||||
|
||||
def _lightglue_pair(feats_q, feats_t, mask_q, mask_t):
|
||||
from lightglue.utils import rbd
|
||||
|
||||
with torch.no_grad():
|
||||
matches01 = get_lightglue()({"image0": feats_q, "image1": feats_t})
|
||||
|
||||
feats_q_, feats_t_, matches01_ = [rbd(x) for x in [feats_q, feats_t, matches01]]
|
||||
matches = matches01_["matches"]
|
||||
if matches.shape[0] == 0:
|
||||
return 0, 0.0
|
||||
|
||||
kpts_q = feats_q_["keypoints"][matches[..., 0]].cpu().numpy()
|
||||
kpts_t = feats_t_["keypoints"][matches[..., 1]].cpu().numpy()
|
||||
|
||||
keep = keep_points_inside_mask(kpts_q, mask_q) & keep_points_inside_mask(kpts_t, mask_t)
|
||||
kpts_q, kpts_t = kpts_q[keep], kpts_t[keep]
|
||||
|
||||
if len(kpts_q) < 4:
|
||||
return 0, 0.0
|
||||
|
||||
src = kpts_q.reshape(-1, 1, 2).astype(np.float32)
|
||||
dst = kpts_t.reshape(-1, 1, 2).astype(np.float32)
|
||||
|
||||
_, ransac_mask = cv2.findHomography(src, dst, cv2.RANSAC, 5.0)
|
||||
if ransac_mask is None:
|
||||
return 0, 0.0
|
||||
|
||||
inlier_count = int(ransac_mask.sum())
|
||||
confidence_pct = (inlier_count / len(src)) * 100 if len(src) > 0 else 0.0
|
||||
return inlier_count, confidence_pct
|
||||
|
||||
|
||||
def superglue_match_against_templates(bgr, mask):
|
||||
feats_q, mask_q = superpoint_extract(bgr, mask)
|
||||
results = []
|
||||
for template_name, data in _template_superpoint_feats.items():
|
||||
inlier_count, confidence_pct = _lightglue_pair(
|
||||
feats_q, data["feats"], mask_q, data["mask"]
|
||||
)
|
||||
results.append({
|
||||
"template": template_name,
|
||||
"score": inlier_count,
|
||||
"confidence": round(confidence_pct, 1),
|
||||
})
|
||||
results.sort(key=lambda r: r["score"], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# LoFTR
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def set_template_loftr(template_name, bgr, mask):
|
||||
tensor, resized_mask = masked_gray_tensor(bgr, mask, max_dim=config.LOFTR_MAX_DIM)
|
||||
_template_loftr_tensors[template_name] = {"tensor": tensor, "mask": resized_mask}
|
||||
|
||||
|
||||
def _loftr_pair(tensor_q, tensor_t, mask_q, mask_t):
|
||||
with torch.no_grad():
|
||||
out = get_loftr()({"image0": tensor_q, "image1": tensor_t})
|
||||
|
||||
conf = out["confidence"].cpu().numpy()
|
||||
keep_conf = conf >= config.LOFTR_CONFIDENCE_THRESHOLD
|
||||
|
||||
kpts_q = out["keypoints0"].cpu().numpy()[keep_conf]
|
||||
kpts_t = out["keypoints1"].cpu().numpy()[keep_conf]
|
||||
|
||||
keep = keep_points_inside_mask(kpts_q, mask_q) & keep_points_inside_mask(kpts_t, mask_t)
|
||||
kpts_q, kpts_t = kpts_q[keep], kpts_t[keep]
|
||||
|
||||
if len(kpts_q) < 4:
|
||||
return 0, 0.0
|
||||
|
||||
src = kpts_q.reshape(-1, 1, 2).astype(np.float32)
|
||||
dst = kpts_t.reshape(-1, 1, 2).astype(np.float32)
|
||||
|
||||
_, ransac_mask = cv2.findHomography(src, dst, cv2.RANSAC, 5.0)
|
||||
if ransac_mask is None:
|
||||
return 0, 0.0
|
||||
|
||||
inlier_count = int(ransac_mask.sum())
|
||||
confidence_pct = (inlier_count / len(src)) * 100 if len(src) > 0 else 0.0
|
||||
return inlier_count, confidence_pct
|
||||
|
||||
|
||||
def loftr_match_against_templates(bgr, mask):
|
||||
tensor_q, mask_q = masked_gray_tensor(bgr, mask, max_dim=config.LOFTR_MAX_DIM)
|
||||
results = []
|
||||
for template_name, data in _template_loftr_tensors.items():
|
||||
inlier_count, confidence_pct = _loftr_pair(
|
||||
tensor_q, data["tensor"], mask_q, data["mask"]
|
||||
)
|
||||
results.append({
|
||||
"template": template_name,
|
||||
"score": inlier_count,
|
||||
"confidence": round(confidence_pct, 1),
|
||||
})
|
||||
# LoFTR has no reusable per-image descriptor -- this is a full dense
|
||||
# CNN+transformer forward pass PER TEMPLATE (6 of them per query,
|
||||
# back to back). Left uncleared, each pass's intermediate
|
||||
# activations fragment the allocator further than SuperPoint/
|
||||
# LightGlue's much lighter per-template cost ever does, and on an
|
||||
# 8GB card that's enough on its own to tip into CUDA OOM a few
|
||||
# templates in.
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
results.sort(key=lambda r: r["score"], reverse=True)
|
||||
return results
|
||||
858
pipeline/engine.py
Normal file
858
pipeline/engine.py
Normal file
@@ -0,0 +1,858 @@
|
||||
"""
|
||||
Orchestrates the whole match pipeline:
|
||||
|
||||
startup (once): bg-remove every template + precompute features for all
|
||||
four methods, so a request only ever has to process the
|
||||
single uploaded image.
|
||||
|
||||
per request: bg-remove the upload, then run SIFT / ORB / SuperGlue /
|
||||
LoFTR one after another. They used to run concurrently
|
||||
(real wall-clock ~max() instead of sum()), but on an
|
||||
8GB card that let SuperGlue and LoFTR hold overlapping
|
||||
GPU allocations at once, which was enough on its own to
|
||||
tip into CUDA OOM. Running them sequentially caps peak
|
||||
GPU memory to whichever single method needs the most,
|
||||
at the cost of some latency.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import logging
|
||||
import os
|
||||
import resource
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import cv2
|
||||
import torch
|
||||
|
||||
import config
|
||||
from pipeline import (bg_removal, classical, color, color_grid, deep,
|
||||
flower_count, sam3_client, shape_match, texture_match,
|
||||
utils, vase_compare, yolo_world)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
METHODS = ("SIFT", "ORB", "SuperGlue", "LoFTR")
|
||||
|
||||
_templates_meta = {} # name -> {"original_filename", "nobg_path"}
|
||||
_bootstrapped = False
|
||||
|
||||
# Serializes each request's pipeline end-to-end. Combined with running the
|
||||
# four methods sequentially (see below), only one method for one request is
|
||||
# ever doing heavy CPU/GPU work at any instant across the whole process.
|
||||
_pipeline_lock = threading.Lock()
|
||||
|
||||
|
||||
def bootstrap():
|
||||
"""Precompute everything template-side. Safe to call more than once;
|
||||
only does real work the first time."""
|
||||
global _bootstrapped
|
||||
if _bootstrapped:
|
||||
return
|
||||
|
||||
logger.info("Preparing templates (bg removal + feature precompute for "
|
||||
"SIFT / ORB / SuperGlue / LoFTR)...")
|
||||
|
||||
for fname in utils.list_template_files():
|
||||
name = utils.template_name(fname)
|
||||
src_path = os.path.join(config.TEMPLATE_IMAGES_DIR, fname)
|
||||
|
||||
rgba = bg_removal.remove_background_file(src_path, config.TEMPLATES_NOBG_CACHE)
|
||||
bgr, _, mask = bg_removal.split_rgba(rgba)
|
||||
|
||||
kp, des = classical.extract_features("SIFT", bgr, mask)
|
||||
classical.set_template_features("SIFT", name, kp, des)
|
||||
|
||||
kp, des = classical.extract_features("ORB", bgr, mask)
|
||||
classical.set_template_features("ORB", name, kp, des)
|
||||
|
||||
deep.set_template_superpoint(name, bgr, mask)
|
||||
deep.set_template_loftr(name, bgr, mask)
|
||||
color.set_template_color_data(name, bgr, mask)
|
||||
shape_match.set_template_shape_data(name, mask)
|
||||
texture_match.set_template_texture_data(name, bgr, mask)
|
||||
|
||||
_templates_meta[name] = {
|
||||
"original_filename": fname,
|
||||
"nobg_path": os.path.join(config.TEMPLATES_NOBG_CACHE, name + ".png"),
|
||||
}
|
||||
logger.info(" template ready: %s", name)
|
||||
|
||||
_bootstrapped = True
|
||||
logger.info("%d templates ready (device: %s).", len(_templates_meta), deep.DEVICE)
|
||||
|
||||
|
||||
def templates_meta():
|
||||
return _templates_meta
|
||||
|
||||
|
||||
def _run_sift(bgr, mask):
|
||||
kp, des = classical.extract_features("SIFT", bgr, mask)
|
||||
return classical.match_against_templates("SIFT", kp, des)
|
||||
|
||||
|
||||
def _run_orb(bgr, mask):
|
||||
kp, des = classical.extract_features("ORB", bgr, mask)
|
||||
return classical.match_against_templates("ORB", kp, des)
|
||||
|
||||
|
||||
def _run_superglue(bgr, mask):
|
||||
return deep.superglue_match_against_templates(bgr, mask)
|
||||
|
||||
|
||||
def _run_loftr(bgr, mask):
|
||||
return deep.loftr_match_against_templates(bgr, mask)
|
||||
|
||||
|
||||
_METHOD_RUNNERS = {
|
||||
"SIFT": _run_sift,
|
||||
"ORB": _run_orb,
|
||||
"SuperGlue": _run_superglue,
|
||||
"LoFTR": _run_loftr,
|
||||
}
|
||||
|
||||
|
||||
def _timed(method, fn, *args):
|
||||
"""Runs one method's matcher, catching any exception so a single
|
||||
failing method (e.g. a CUDA OOM on an unusual image) is logged and
|
||||
reported back as a failed card instead of taking the whole request
|
||||
down with it."""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = fn(*args)
|
||||
error = None
|
||||
except Exception as e:
|
||||
logger.exception("Method %s failed", method)
|
||||
result = []
|
||||
error = str(e)
|
||||
elapsed = time.perf_counter() - start
|
||||
return result, elapsed, error
|
||||
|
||||
|
||||
def _overall_best(method_results):
|
||||
"""Borda-count-style aggregate across methods: each method's ranking of
|
||||
templates contributes points, so one noisy method can't dominate the
|
||||
call the way a raw-score sum could."""
|
||||
points = {}
|
||||
for method, payload in method_results.items():
|
||||
ranked = payload["results"]
|
||||
n = len(ranked)
|
||||
for idx, row in enumerate(ranked):
|
||||
points[row["template"]] = points.get(row["template"], 0) + (n - idx)
|
||||
|
||||
if not points:
|
||||
return None
|
||||
return max(points, key=points.get)
|
||||
|
||||
|
||||
def _weighted_scores(method_results):
|
||||
"""Literal weighted sum per template: weighted_score(t) = sum_m
|
||||
WEIGHT[m] * raw_score(m, t). Returns (winning_template_or_None, ranked
|
||||
list of {"template", "weighted_score", "breakdown"} sorted descending).
|
||||
A method that errored contributes 0 everywhere (its results list is
|
||||
empty), it doesn't skew the total."""
|
||||
totals = {}
|
||||
breakdown = {} # template -> {method: contribution}
|
||||
|
||||
for method, payload in method_results.items():
|
||||
weight = config.METHOD_WEIGHTS.get(method, 0)
|
||||
for row in payload["results"]:
|
||||
template = row["template"]
|
||||
contribution = weight * row["score"]
|
||||
totals[template] = totals.get(template, 0.0) + contribution
|
||||
breakdown.setdefault(template, {})[method] = round(contribution, 2)
|
||||
|
||||
if not totals:
|
||||
return None, []
|
||||
|
||||
ranked = sorted(
|
||||
(
|
||||
{
|
||||
"template": template,
|
||||
"weighted_score": round(total, 2),
|
||||
"breakdown": breakdown.get(template, {}),
|
||||
}
|
||||
for template, total in totals.items()
|
||||
),
|
||||
key=lambda r: r["weighted_score"],
|
||||
reverse=True,
|
||||
)
|
||||
return ranked[0]["template"], ranked
|
||||
|
||||
|
||||
def _color_as_method_result(color_analysis, elapsed, error):
|
||||
"""Reshapes color.compare_input_to_templates()'s output into the same
|
||||
{"template", "score", "confidence"} shape the other methods use, so it
|
||||
can be handed to _weighted_scores() generically -- that's the only
|
||||
place this participates; it's deliberately kept out of the sequential
|
||||
METHODS loop (color analysis isn't a GPU/CPU-heavy per-template model
|
||||
call, no need for that machinery) and out of _overall_best (Borda stays
|
||||
exactly the original four methods, unchanged)."""
|
||||
results = [
|
||||
{"template": t["template"], "score": t["match_pct"], "confidence": t["match_pct"]}
|
||||
for t in color_analysis.get("templates", [])
|
||||
]
|
||||
best = results[0] if results else None
|
||||
return {
|
||||
"results": results,
|
||||
"time_sec": elapsed,
|
||||
"error": error,
|
||||
"best": best,
|
||||
"is_confident": bool(best and best["score"] >= config.SCORE_THRESHOLD["Color"]),
|
||||
"best_image_file": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_family_grid(request_dir, input_bgr, input_mask, weighted_best):
|
||||
"""Purely visual, tied to the weighted-best template only: clusters
|
||||
both the upload and the winning template into color-region tiles,
|
||||
renders each as its own side-by-side grid image, and matches up
|
||||
corresponding regions by color to report an area-match % per region."""
|
||||
input_families = color_grid.cluster_families(input_bgr, input_mask)
|
||||
|
||||
template_rgba = cv2.imread(
|
||||
_templates_meta[weighted_best]["nobg_path"], cv2.IMREAD_UNCHANGED
|
||||
)
|
||||
template_bgr, _, template_mask = bg_removal.split_rgba(template_rgba)
|
||||
template_families = color_grid.cluster_families(template_bgr, template_mask)
|
||||
|
||||
matches = color_grid.match_families(input_families, template_families)
|
||||
overall_pct = color_grid.overall_area_match(matches)
|
||||
|
||||
input_grid_file = "input_family_grid.png"
|
||||
template_grid_file = "template_family_grid.png"
|
||||
cv2.imwrite(os.path.join(request_dir, input_grid_file),
|
||||
color_grid.render_family_grid(input_bgr, input_families))
|
||||
cv2.imwrite(os.path.join(request_dir, template_grid_file),
|
||||
color_grid.render_family_grid(template_bgr, template_families))
|
||||
|
||||
return {
|
||||
"template": weighted_best,
|
||||
"input_grid_file": input_grid_file,
|
||||
"template_grid_file": template_grid_file,
|
||||
"overall_area_match_pct": overall_pct,
|
||||
"matches": matches,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_shape_visuals(request_dir, input_shape_data, weighted_best):
|
||||
"""Purely visual, tied to the weighted-best template: renders both
|
||||
silhouettes (normalized into the same canvas) side by side plus an
|
||||
overlay highlighting exactly where they agree/diverge."""
|
||||
template_shape_data = shape_match._template_shape_data.get(weighted_best)
|
||||
if template_shape_data is None:
|
||||
return None
|
||||
|
||||
input_file = "input_silhouette.png"
|
||||
template_file = "template_silhouette.png"
|
||||
overlay_file = "shape_overlay.png"
|
||||
cv2.imwrite(os.path.join(request_dir, input_file),
|
||||
shape_match.render_silhouette(input_shape_data["canonical_mask"],
|
||||
shape_match._INPUT_COLOR))
|
||||
cv2.imwrite(os.path.join(request_dir, template_file),
|
||||
shape_match.render_silhouette(template_shape_data["canonical_mask"],
|
||||
shape_match._TEMPLATE_COLOR))
|
||||
cv2.imwrite(os.path.join(request_dir, overlay_file),
|
||||
shape_match.render_overlay(input_shape_data["canonical_mask"],
|
||||
template_shape_data["canonical_mask"]))
|
||||
|
||||
return {
|
||||
"input_file": input_file,
|
||||
"template_file": template_file,
|
||||
"overlay_file": overlay_file,
|
||||
}
|
||||
|
||||
|
||||
def _build_texture_visuals(request_dir, input_texture_data, weighted_best):
|
||||
"""Purely visual, tied to the weighted-best template: renders both
|
||||
images' LBP (local micro-pattern) maps side by side."""
|
||||
template_texture_data = texture_match._template_texture_data.get(weighted_best)
|
||||
if template_texture_data is None:
|
||||
return None
|
||||
|
||||
input_file = "input_lbp.png"
|
||||
template_file = "template_lbp.png"
|
||||
cv2.imwrite(os.path.join(request_dir, input_file),
|
||||
texture_match.render_lbp_visual(input_texture_data["lbp_image"],
|
||||
input_texture_data["mask_bool"]))
|
||||
cv2.imwrite(os.path.join(request_dir, template_file),
|
||||
texture_match.render_lbp_visual(template_texture_data["lbp_image"],
|
||||
template_texture_data["mask_bool"]))
|
||||
|
||||
return {
|
||||
"input_file": input_file,
|
||||
"template_file": template_file,
|
||||
}
|
||||
|
||||
|
||||
def _cleanup_old_uploads():
|
||||
if not os.path.isdir(config.UPLOADS_DIR):
|
||||
return
|
||||
cutoff = time.time() - config.MAX_UPLOAD_AGE_SECONDS
|
||||
for entry in os.listdir(config.UPLOADS_DIR):
|
||||
path = os.path.join(config.UPLOADS_DIR, entry)
|
||||
try:
|
||||
if os.path.isdir(path) and os.path.getmtime(path) < cutoff:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _peak_rss_mb():
|
||||
# ru_maxrss is KB on Linux, bytes on macOS -- this app only targets Linux.
|
||||
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
|
||||
|
||||
|
||||
def _release_resources():
|
||||
"""Best-effort cleanup between requests. gc.collect() drops any
|
||||
lingering numpy/cv2/torch objects promptly instead of waiting for the
|
||||
next allocation to trigger a cycle; empty_cache() hands unused *cached*
|
||||
CUDA blocks back to the driver so nvidia-smi/other processes see them
|
||||
freed (it does not, and cannot, free host RAM)."""
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def _build_flower_count_comparison(input_count, template_count, template):
|
||||
diff = input_count - template_count
|
||||
if diff == 0:
|
||||
message = (
|
||||
f"Your upload and {template} both show {input_count} flowers -- "
|
||||
f"flower count doesn't look like a factor in the match score here."
|
||||
)
|
||||
else:
|
||||
more_fewer = "more" if diff > 0 else "fewer"
|
||||
message = (
|
||||
f"Your upload has {input_count} flowers detected vs {template_count} in "
|
||||
f"{template} -- {abs(diff)} {more_fewer}. No method above scores flower "
|
||||
f"count directly, but a mismatch like this usually also lowers the "
|
||||
f"keypoint (SIFT/ORB/SuperGlue/LoFTR) and shape/texture scores, since "
|
||||
f"there's simply more or less bouquet for them to match against -- so "
|
||||
f"it's often part of why the overall match isn't higher."
|
||||
)
|
||||
return {
|
||||
"input_count": input_count,
|
||||
"template_count": template_count,
|
||||
"diff": diff,
|
||||
"message": message,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def count_flowers(request_id: str, template: str = None) -> dict:
|
||||
"""On-demand flower counting for an already-processed upload, via two
|
||||
independent methods shown side by side, plus (if a matched template
|
||||
name is given) a vase-identity comparison:
|
||||
|
||||
- SAM3 (instance segmentation): finds individual flower-shaped
|
||||
regions by prompting SAM3 with the plain-English concept "flower".
|
||||
Unlike SAM1's old "segment everything, then guess" approach, this
|
||||
never proposes the vase or any ribbon/bow in the first place -- no
|
||||
exclude-box filtering needed. Runs as a subprocess into a separate
|
||||
Python 3.10 env (see pipeline/sam3_client.py) since transformers'
|
||||
Sam3Model needs Python>=3.10, incompatible with this app's own env.
|
||||
The same subprocess call also fetches SAM3's "vase" mask for the
|
||||
upload (and the template's, if a match is known) for the vase
|
||||
comparison below -- one model load covers every job.
|
||||
- YOLO-World (open-vocabulary detection): independent second opinion,
|
||||
unchanged from before -- its own "flower"/"vase"/"ribbon"/"bow"
|
||||
counts and box visualization, shown as its own coarser corroborating
|
||||
signal (one box per contiguous flower region, not per bloom).
|
||||
- Vase comparison (DINOv2 + CLIP): crops the vase out of the upload
|
||||
and the matched template using SAM3's precise masks (background
|
||||
pixels inside the crop are blacked out, so the embeddings only see
|
||||
the vase itself), then embeds both crops with DINOv2 (fine detail)
|
||||
and CLIP (holistic/semantic) and reports a same/uncertain/different
|
||||
verdict. Skipped (not an error) if no vase was detected on either
|
||||
side.
|
||||
|
||||
Heavyweight and strictly opt-in -- triggered only by its own UI button,
|
||||
never run as part of process_upload. Unloads the matching pipeline's own
|
||||
GPU-resident models first (SuperPoint/LightGlue/LoFTR, rembg's
|
||||
onnxruntime session) so YOLO-World/DINOv2/CLIP never have to share the
|
||||
8GB card with them (SAM3 runs in its own process/env regardless), then
|
||||
unloads YOLO-World/DINOv2/CLIP again afterward -- everything lazily
|
||||
reloads itself on whichever request needs it next, exactly like a fresh
|
||||
process start would."""
|
||||
request_dir = os.path.join(config.UPLOADS_DIR, request_id)
|
||||
nobg_path = os.path.join(request_dir, "nobg.png")
|
||||
if not os.path.isfile(nobg_path):
|
||||
raise FileNotFoundError(f"no processed upload found for request {request_id}")
|
||||
|
||||
with _pipeline_lock:
|
||||
start = time.perf_counter()
|
||||
rgba = cv2.imread(nobg_path, cv2.IMREAD_UNCHANGED)
|
||||
bgr, _, mask = bg_removal.split_rgba(rgba)
|
||||
|
||||
freed_deep = deep.unload_models()
|
||||
freed_rembg = bg_removal.unload_session()
|
||||
if freed_deep or freed_rembg:
|
||||
logger.info("[%s] unloaded matching-pipeline models before "
|
||||
"SAM3/YOLO-World/DINO/CLIP run", request_id)
|
||||
|
||||
try:
|
||||
by_class = yolo_world.detect(bgr)
|
||||
yolo_error = None
|
||||
except Exception as e:
|
||||
logger.exception("[%s] YOLO-World detection failed", request_id)
|
||||
by_class = {}
|
||||
yolo_error = str(e)
|
||||
|
||||
template_bgr = None
|
||||
template_mask = None
|
||||
if template is not None and template in _templates_meta:
|
||||
template_rgba = cv2.imread(
|
||||
_templates_meta[template]["nobg_path"], cv2.IMREAD_UNCHANGED
|
||||
)
|
||||
template_bgr, _, template_mask = bg_removal.split_rgba(template_rgba)
|
||||
|
||||
sam3_images = {"input": bgr}
|
||||
sam3_jobs = [
|
||||
{"image": "input", "prompt": config.SAM3_FLOWER_PROMPT,
|
||||
"threshold": config.SAM3_FLOWER_THRESHOLD},
|
||||
{"image": "input", "prompt": config.SAM3_VASE_PROMPT,
|
||||
"threshold": config.SAM3_VASE_THRESHOLD},
|
||||
]
|
||||
if template_bgr is not None:
|
||||
sam3_images["template"] = template_bgr
|
||||
sam3_jobs.append({"image": "template", "prompt": config.SAM3_VASE_PROMPT,
|
||||
"threshold": config.SAM3_VASE_THRESHOLD})
|
||||
# Also count flowers in the template photo itself (same "flower"
|
||||
# concept prompt) -- lets the UI tell the user when a lower match
|
||||
# score might partly be explained by a different flower count,
|
||||
# rather than leaving that as an unexplained low number.
|
||||
sam3_jobs.append({"image": "template", "prompt": config.SAM3_FLOWER_PROMPT,
|
||||
"threshold": config.SAM3_FLOWER_THRESHOLD})
|
||||
|
||||
try:
|
||||
sam3_result = sam3_client.run_jobs(sam3_images, sam3_jobs, workdir=request_dir)
|
||||
sam3_error = None
|
||||
except Exception as e:
|
||||
logger.exception("[%s] SAM3 failed", request_id)
|
||||
sam3_result = {}
|
||||
sam3_error = str(e)
|
||||
|
||||
try:
|
||||
flower_instances = sam3_result.get(("input", config.SAM3_FLOWER_PROMPT), [])
|
||||
count_data = flower_count.count_flowers(bgr, mask, request_dir,
|
||||
instances_raw=flower_instances)
|
||||
sam_error = sam3_error
|
||||
except Exception as e:
|
||||
logger.exception("[%s] flower counting (SAM3) failed", request_id)
|
||||
count_data = {"total_count": 0, "clusters": []}
|
||||
sam_error = sam3_error or str(e)
|
||||
|
||||
sam_visual_file = None
|
||||
if sam_error is None:
|
||||
try:
|
||||
visual = flower_count.render_instances(bgr, count_data)
|
||||
sam_visual_file = "flower_count_sam.png"
|
||||
cv2.imwrite(os.path.join(request_dir, sam_visual_file), visual)
|
||||
except Exception:
|
||||
logger.exception("[%s] SAM3 flower count visualization failed", request_id)
|
||||
|
||||
# Same flower-instance segmentation, run on the matched template's
|
||||
# own photo -- lets callers (the web UI's mismatch note, tester.py's
|
||||
# report image) show/compare "your photo's flowers" side by side
|
||||
# with "the template's flowers", not just two bare numbers.
|
||||
template_count_data = {"total_count": 0, "clusters": []}
|
||||
template_sam_visual_file = None
|
||||
template_sam_error = None
|
||||
if template_bgr is not None and template_mask is not None:
|
||||
if sam3_error is not None:
|
||||
template_sam_error = sam3_error
|
||||
else:
|
||||
try:
|
||||
template_flower_instances = sam3_result.get(
|
||||
("template", config.SAM3_FLOWER_PROMPT), []
|
||||
)
|
||||
template_count_data = flower_count.count_flowers(
|
||||
template_bgr, template_mask, request_dir,
|
||||
instances_raw=template_flower_instances,
|
||||
)
|
||||
visual = flower_count.render_instances(template_bgr, template_count_data)
|
||||
template_sam_visual_file = "flower_count_sam_template.png"
|
||||
cv2.imwrite(os.path.join(request_dir, template_sam_visual_file), visual)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] template flower count failed", request_id)
|
||||
template_sam_error = str(e)
|
||||
|
||||
yolo_visual_file = None
|
||||
if yolo_error is None:
|
||||
try:
|
||||
yolo_overlay = yolo_world.render_boxes(bgr, by_class)
|
||||
yolo_visual_file = "flower_count_yolo.png"
|
||||
cv2.imwrite(os.path.join(request_dir, yolo_visual_file), yolo_overlay)
|
||||
except Exception:
|
||||
logger.exception("[%s] YOLO-World visualization failed", request_id)
|
||||
|
||||
# Vase-identity comparison: needs a matched template name and a vase
|
||||
# mask on both sides (from the batched SAM3 call above). Soft-
|
||||
# skipped (not an error) if either is missing -- e.g. a weak/no
|
||||
# match, or a photo where SAM3 simply didn't find the vase.
|
||||
input_vase_instances = sam3_result.get(("input", config.SAM3_VASE_PROMPT), [])
|
||||
vase_comparison = None
|
||||
if template is not None and sam3_error is not None:
|
||||
vase_comparison = {"error": f"SAM3 unavailable: {sam3_error}"}
|
||||
elif template is not None and not input_vase_instances:
|
||||
vase_comparison = {"error": "No vase detected in your upload."}
|
||||
elif template is not None and template_bgr is not None:
|
||||
try:
|
||||
template_vase_instances = sam3_result.get(
|
||||
("template", config.SAM3_VASE_PROMPT), []
|
||||
)
|
||||
if not template_vase_instances:
|
||||
vase_comparison = {"error": "No vase detected in the matched template photo."}
|
||||
else:
|
||||
input_vase_mask = max(input_vase_instances, key=lambda i: i["score"])["mask"]
|
||||
template_vase_mask = max(template_vase_instances, key=lambda i: i["score"])["mask"]
|
||||
input_vase_crop = vase_compare.crop_mask(bgr, input_vase_mask)
|
||||
template_vase_crop = vase_compare.crop_mask(template_bgr, template_vase_mask)
|
||||
|
||||
result = vase_compare.compare_vases(input_vase_crop, template_vase_crop)
|
||||
input_crop_file = "vase_crop_input.png"
|
||||
template_crop_file = "vase_crop_template.png"
|
||||
cv2.imwrite(os.path.join(request_dir, input_crop_file), input_vase_crop)
|
||||
cv2.imwrite(os.path.join(request_dir, template_crop_file), template_vase_crop)
|
||||
vase_comparison = {
|
||||
**result,
|
||||
"template": template,
|
||||
"input_crop_file": input_crop_file,
|
||||
"template_crop_file": template_crop_file,
|
||||
"error": None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("[%s] vase comparison failed", request_id)
|
||||
vase_comparison = {"error": str(e)}
|
||||
|
||||
# Flower-count comparison: surfaces a plain-English explanation when
|
||||
# the upload and the matched template simply have different numbers
|
||||
# of flowers -- none of SIFT/ORB/SuperGlue/LoFTR/shape/texture score
|
||||
# "flower count" directly, so a lower match score caused mostly by a
|
||||
# count mismatch would otherwise look unexplained to the user.
|
||||
flower_count_comparison = None
|
||||
if template is not None and template_sam_error is None and template_bgr is not None:
|
||||
flower_count_comparison = _build_flower_count_comparison(
|
||||
count_data.get("total_count", 0), template_count_data.get("total_count", 0), template
|
||||
)
|
||||
elif template is not None and template_sam_error is not None:
|
||||
flower_count_comparison = {"error": f"SAM3 unavailable: {template_sam_error}"}
|
||||
|
||||
yolo_world.unload_model()
|
||||
vase_compare.unload_models()
|
||||
_release_resources()
|
||||
elapsed = time.perf_counter() - start
|
||||
logger.info(
|
||||
"[%s] flower count: %.2fs, SAM3 total=%s, YOLO flower/vase/ribbon=%s/%s/%s, "
|
||||
"vase comparison=%s",
|
||||
request_id, elapsed, count_data.get("total_count"),
|
||||
len(by_class.get("flower", [])), len(by_class.get("vase", [])),
|
||||
len(by_class.get("ribbon", [])) + len(by_class.get("bow", [])),
|
||||
vase_comparison.get("verdict") if vase_comparison and not vase_comparison.get("error") else None,
|
||||
)
|
||||
|
||||
return {
|
||||
"sam": {
|
||||
"total_count": count_data.get("total_count", 0),
|
||||
"clusters": count_data.get("clusters", []),
|
||||
"visual_file": sam_visual_file,
|
||||
"error": sam_error,
|
||||
},
|
||||
"sam_template": {
|
||||
"total_count": template_count_data.get("total_count", 0),
|
||||
"clusters": template_count_data.get("clusters", []),
|
||||
"visual_file": template_sam_visual_file,
|
||||
"error": template_sam_error,
|
||||
},
|
||||
"yolo": {
|
||||
"flower_count": len(by_class.get("flower", [])),
|
||||
"vase_count": len(by_class.get("vase", [])),
|
||||
"ribbon_count": len(by_class.get("ribbon", [])) + len(by_class.get("bow", [])),
|
||||
"visual_file": yolo_visual_file,
|
||||
"error": yolo_error,
|
||||
},
|
||||
"vase_comparison": vase_comparison,
|
||||
"flower_count_comparison": flower_count_comparison,
|
||||
"time_sec": round(elapsed, 3),
|
||||
}
|
||||
|
||||
|
||||
def flower_summary(request_id: str, template: str) -> dict:
|
||||
"""Lightweight companion to the weighted verdict, shown automatically
|
||||
right beside it (not behind the opt-in "Count flowers" button) as soon
|
||||
as a confident match is found: just the flower-count comparison (same
|
||||
message as count_flowers' version) plus a CLIP-only similarity of the
|
||||
flower material itself (SAM3's "flower" masks on both sides, unioned
|
||||
and background-blacked-out, then embedded with CLIP alone).
|
||||
|
||||
Deliberately skips YOLO-World, DINOv2, and the vase comparison -- those
|
||||
stay behind the button since this one runs unconditionally on every
|
||||
confident match and should stay as fast as a SAM3 round trip allows.
|
||||
Still needs the matching pipeline's GPU-resident models unloaded first,
|
||||
same as count_flowers."""
|
||||
request_dir = os.path.join(config.UPLOADS_DIR, request_id)
|
||||
nobg_path = os.path.join(request_dir, "nobg.png")
|
||||
if not os.path.isfile(nobg_path):
|
||||
raise FileNotFoundError(f"no processed upload found for request {request_id}")
|
||||
if template not in _templates_meta:
|
||||
raise ValueError(f"unknown template {template!r}")
|
||||
|
||||
with _pipeline_lock:
|
||||
rgba = cv2.imread(nobg_path, cv2.IMREAD_UNCHANGED)
|
||||
bgr, _, mask = bg_removal.split_rgba(rgba)
|
||||
template_rgba = cv2.imread(
|
||||
_templates_meta[template]["nobg_path"], cv2.IMREAD_UNCHANGED
|
||||
)
|
||||
template_bgr, _, template_mask = bg_removal.split_rgba(template_rgba)
|
||||
|
||||
freed_deep = deep.unload_models()
|
||||
freed_rembg = bg_removal.unload_session()
|
||||
if freed_deep or freed_rembg:
|
||||
logger.info("[%s] unloaded matching-pipeline models before flower summary",
|
||||
request_id)
|
||||
|
||||
count_comparison = None
|
||||
clip_pct = None
|
||||
error = None
|
||||
try:
|
||||
sam3_result = sam3_client.run_jobs(
|
||||
images={"input": bgr, "template": template_bgr},
|
||||
jobs=[
|
||||
{"image": "input", "prompt": config.SAM3_FLOWER_PROMPT,
|
||||
"threshold": config.SAM3_FLOWER_THRESHOLD},
|
||||
{"image": "template", "prompt": config.SAM3_FLOWER_PROMPT,
|
||||
"threshold": config.SAM3_FLOWER_THRESHOLD},
|
||||
],
|
||||
workdir=request_dir,
|
||||
)
|
||||
input_instances = sam3_result.get(("input", config.SAM3_FLOWER_PROMPT), [])
|
||||
template_instances = sam3_result.get(("template", config.SAM3_FLOWER_PROMPT), [])
|
||||
|
||||
input_data = flower_count.count_flowers(bgr, mask, request_dir,
|
||||
instances_raw=input_instances)
|
||||
template_data = flower_count.count_flowers(template_bgr, template_mask, request_dir,
|
||||
instances_raw=template_instances)
|
||||
count_comparison = _build_flower_count_comparison(
|
||||
input_data["total_count"], template_data["total_count"], template
|
||||
)
|
||||
|
||||
input_union = flower_count.union_mask(input_data["_instance_masks"])
|
||||
template_union = flower_count.union_mask(template_data["_instance_masks"])
|
||||
if input_union is not None and template_union is not None:
|
||||
input_crop = vase_compare.crop_mask(bgr, input_union)
|
||||
template_crop = vase_compare.crop_mask(template_bgr, template_union)
|
||||
if input_crop is not None and template_crop is not None:
|
||||
clip_pct = vase_compare.clip_similarity_pct(input_crop, template_crop)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] flower summary failed", request_id)
|
||||
error = str(e)
|
||||
|
||||
vase_compare.unload_models()
|
||||
_release_resources()
|
||||
|
||||
return {
|
||||
"flower_count_comparison": count_comparison,
|
||||
"flower_clip_similarity_pct": clip_pct,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def process_upload(image_bytes: bytes, orig_filename: str) -> dict:
|
||||
bootstrap()
|
||||
_cleanup_old_uploads()
|
||||
|
||||
request_id = uuid.uuid4().hex[:12]
|
||||
logger.info("[%s] new upload: %r (%.1f KB)", request_id, orig_filename,
|
||||
len(image_bytes) / 1024)
|
||||
|
||||
with _pipeline_lock:
|
||||
try:
|
||||
result = _process_upload_locked(request_id, image_bytes, orig_filename)
|
||||
except Exception:
|
||||
logger.exception("[%s] pipeline failed", request_id)
|
||||
raise
|
||||
finally:
|
||||
_release_resources()
|
||||
logger.info("[%s] done, peak RSS so far: %.0f MB", request_id, _peak_rss_mb())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _process_upload_locked(request_id: str, image_bytes: bytes, orig_filename: str) -> dict:
|
||||
request_dir = os.path.join(config.UPLOADS_DIR, request_id)
|
||||
os.makedirs(request_dir, exist_ok=True)
|
||||
|
||||
total_start = time.perf_counter()
|
||||
|
||||
ext = os.path.splitext(orig_filename)[1].lower() or ".png"
|
||||
original_path = os.path.join(request_dir, "original" + ext)
|
||||
with open(original_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
bg_start = time.perf_counter()
|
||||
rgba, _ = bg_removal.remove_background_bytes(image_bytes, config.UPLOADS_NOBG_CACHE)
|
||||
bg_elapsed = time.perf_counter() - bg_start
|
||||
logger.info("[%s] background removal: %.2fs", request_id, bg_elapsed)
|
||||
bgr, _, mask = bg_removal.split_rgba(rgba)
|
||||
|
||||
nobg_path = os.path.join(request_dir, "nobg.png")
|
||||
cv2.imwrite(nobg_path, rgba)
|
||||
|
||||
method_results = {}
|
||||
for method in METHODS:
|
||||
results, elapsed, error = _timed(method, _METHOD_RUNNERS[method], bgr, mask)
|
||||
method_results[method] = {"results": results, "time_sec": elapsed, "error": error}
|
||||
logger.info("[%s] %s: %.2fs%s", request_id, method, elapsed,
|
||||
f" (FAILED: {error})" if error else "")
|
||||
# Free this method's GPU allocations before the next one runs rather
|
||||
# than waiting until the whole request finishes -- SuperGlue and
|
||||
# LoFTR are the two that actually use the GPU, and freeing between
|
||||
# them keeps their peak allocations from ever coexisting.
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
for method, payload in method_results.items():
|
||||
results = payload["results"]
|
||||
best = results[0] if results else None
|
||||
payload["best"] = best
|
||||
payload["is_confident"] = bool(
|
||||
best and best["score"] >= config.SCORE_THRESHOLD[method]
|
||||
)
|
||||
if best is not None:
|
||||
template_bgr = cv2.imread(
|
||||
_templates_meta[best["template"]]["nobg_path"], cv2.IMREAD_UNCHANGED
|
||||
)
|
||||
annotated = utils.annotate_score(
|
||||
template_bgr, best["score"], best["confidence"],
|
||||
label=config.METHOD_LABELS[method],
|
||||
)
|
||||
out_name = f"{method}_best.png"
|
||||
cv2.imwrite(os.path.join(request_dir, out_name), annotated)
|
||||
payload["best_image_file"] = out_name
|
||||
|
||||
# Color-space comparison: its own section in the UI (independent of the
|
||||
# method-grid cards above), but also folded into the weighted verdict
|
||||
# below via config.METHOD_WEIGHTS["Color"]. A failure here shouldn't
|
||||
# take down a request that otherwise succeeded.
|
||||
color_start = time.perf_counter()
|
||||
try:
|
||||
color_analysis = color.compare_input_to_templates(bgr, mask)
|
||||
color_error = None
|
||||
except Exception as e:
|
||||
logger.exception("[%s] color analysis failed", request_id)
|
||||
color_analysis = {"input_dominant_colors": [], "templates": []}
|
||||
color_error = str(e)
|
||||
color_elapsed = time.perf_counter() - color_start
|
||||
logger.info("[%s] color analysis: %.2fs%s", request_id, color_elapsed,
|
||||
f" (FAILED: {color_error})" if color_error else "")
|
||||
|
||||
# Shape matching (silhouette/contour, via Hu moments + canonical-aligned
|
||||
# IoU) and texture matching (LBP + GLCM/Haralick) -- both independent
|
||||
# sections, like color space: never fed into weighting or any score.
|
||||
shape_start = time.perf_counter()
|
||||
try:
|
||||
input_shape_data, shape_results = shape_match.compare_to_templates(mask)
|
||||
shape_error = None
|
||||
except Exception as e:
|
||||
logger.exception("[%s] shape analysis failed", request_id)
|
||||
input_shape_data, shape_results = None, []
|
||||
shape_error = str(e)
|
||||
shape_elapsed = time.perf_counter() - shape_start
|
||||
logger.info("[%s] shape analysis: %.2fs%s", request_id, shape_elapsed,
|
||||
f" (FAILED: {shape_error})" if shape_error else "")
|
||||
|
||||
texture_start = time.perf_counter()
|
||||
try:
|
||||
input_texture_data, texture_results = texture_match.compare_to_templates(bgr, mask)
|
||||
texture_error = None
|
||||
except Exception as e:
|
||||
logger.exception("[%s] texture analysis failed", request_id)
|
||||
input_texture_data, texture_results = None, []
|
||||
texture_error = str(e)
|
||||
texture_elapsed = time.perf_counter() - texture_start
|
||||
logger.info("[%s] texture analysis: %.2fs%s", request_id, texture_elapsed,
|
||||
f" (FAILED: {texture_error})" if texture_error else "")
|
||||
|
||||
# Only used for weighting/the returned "methods" dict (so the frontend
|
||||
# can look up Color's raw score for the weighted-breakdown display) --
|
||||
# NOT rendered as a 5th method-grid card, and NOT part of _overall_best.
|
||||
methods_with_color = dict(method_results)
|
||||
methods_with_color["Color"] = _color_as_method_result(color_analysis, color_elapsed, color_error)
|
||||
|
||||
weighted_best, weighted_scores = _weighted_scores(methods_with_color)
|
||||
|
||||
# Color family grid: purely visual, tied to whichever template the
|
||||
# weighted verdict landed on. Skipped if there's no confident match at
|
||||
# all (nothing to compare against). A failure here is likewise soft.
|
||||
family_grid = None
|
||||
if weighted_best is not None:
|
||||
try:
|
||||
family_grid = _build_family_grid(request_dir, bgr, mask, weighted_best)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] family grid failed", request_id)
|
||||
family_grid = {"error": str(e)}
|
||||
|
||||
# Shape/texture visuals: same "tied to whichever template the weighted
|
||||
# verdict landed on" convention as the color family grid.
|
||||
shape_visuals = None
|
||||
if weighted_best is not None and input_shape_data is not None:
|
||||
try:
|
||||
shape_visuals = _build_shape_visuals(request_dir, input_shape_data, weighted_best)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] shape visuals failed", request_id)
|
||||
shape_visuals = None
|
||||
|
||||
texture_visuals = None
|
||||
if weighted_best is not None and input_texture_data is not None:
|
||||
try:
|
||||
texture_visuals = _build_texture_visuals(request_dir, input_texture_data, weighted_best)
|
||||
except Exception as e:
|
||||
logger.exception("[%s] texture visuals failed", request_id)
|
||||
texture_visuals = None
|
||||
|
||||
total_elapsed = time.perf_counter() - total_start
|
||||
logger.info("[%s] total: %.2fs, weighted best: %s", request_id, total_elapsed, weighted_best)
|
||||
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"upload_nobg_file": "nobg.png",
|
||||
"upload_original_file": os.path.basename(original_path),
|
||||
"bg_removal_time_sec": round(bg_elapsed, 3),
|
||||
"total_time_sec": round(total_elapsed, 3),
|
||||
"overall_best": _overall_best(method_results),
|
||||
"method_weights": config.METHOD_WEIGHTS,
|
||||
"weighted_best": weighted_best,
|
||||
"weighted_scores": weighted_scores,
|
||||
"color_analysis": color_analysis,
|
||||
"color_analysis_time_sec": round(color_elapsed, 3),
|
||||
"color_analysis_error": color_error,
|
||||
"family_grid": family_grid,
|
||||
"shape_analysis": {
|
||||
"results": shape_results,
|
||||
"time_sec": round(shape_elapsed, 3),
|
||||
"error": shape_error,
|
||||
"visuals": shape_visuals,
|
||||
},
|
||||
"texture_analysis": {
|
||||
"results": texture_results,
|
||||
"time_sec": round(texture_elapsed, 3),
|
||||
"error": texture_error,
|
||||
"visuals": texture_visuals,
|
||||
},
|
||||
"methods": {
|
||||
method: {
|
||||
"label": config.METHOD_LABELS[method],
|
||||
"time_sec": round(payload["time_sec"], 3),
|
||||
"best": payload["best"],
|
||||
"is_confident": payload["is_confident"],
|
||||
"best_image_file": payload.get("best_image_file"),
|
||||
"results": payload["results"],
|
||||
"error": payload["error"],
|
||||
}
|
||||
for method, payload in methods_with_color.items()
|
||||
},
|
||||
}
|
||||
138
pipeline/flower_count.py
Normal file
138
pipeline/flower_count.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Flower-instance counting via SAM3 (facebook/sam3), prompted with the plain-
|
||||
English concept "flower". See pipeline/sam3_client.py (and config.py's SAM3
|
||||
section) for why this runs as a one-shot subprocess into a separate Python
|
||||
3.10 environment rather than an in-process model call.
|
||||
|
||||
Unlike the earlier approach (SAM1 in automatic "segment everything" mode,
|
||||
then guessing which proposals were flowers from size/position heuristics
|
||||
and excluding boxes YOLO-World identified as vase/ribbon), SAM3's concept
|
||||
prompting does the semantic part itself: prompted with "flower", it simply
|
||||
never proposes the vase or any ribbon/bow in the first place. What's left
|
||||
here is only: a light sanity filter (the instance must actually overlap the
|
||||
already-known foreground), then color-clustering the survivors as a rough
|
||||
proxy for distinct flower "kinds" -- there's still no trained species
|
||||
classifier, just an assumption that different flower types usually differ
|
||||
in color.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import config
|
||||
from pipeline import sam3_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def union_mask(instance_masks):
|
||||
"""OR-combines every per-flower instance mask into one -- "all the
|
||||
flower material, regardless of which bloom it belongs to". Used to crop
|
||||
just the flowers (excluding vase/ribbon/background) out of a photo for
|
||||
the CLIP flower-similarity check. Returns None if there are no
|
||||
instances to combine."""
|
||||
if not instance_masks:
|
||||
return None
|
||||
union = instance_masks[0].copy()
|
||||
for m in instance_masks[1:]:
|
||||
union |= m
|
||||
return union
|
||||
|
||||
|
||||
def count_flowers(bgr, mask, workdir, image_key="input", instances_raw=None):
|
||||
"""instances_raw, if given, reuses SAM3 results already fetched by the
|
||||
caller (engine.py batches the "flower" job for the upload together with
|
||||
any "vase" jobs into a single sam3_client.run_jobs() call so the model
|
||||
only loads once per request); otherwise fetches them here standalone."""
|
||||
fg_area = int((mask > 0).sum())
|
||||
if fg_area == 0:
|
||||
return {"total_count": 0, "clusters": [], "_instance_masks": [], "_cluster_of_instance": []}
|
||||
|
||||
if instances_raw is None:
|
||||
result = sam3_client.run_jobs(
|
||||
images={image_key: bgr},
|
||||
jobs=[{"image": image_key, "prompt": config.SAM3_FLOWER_PROMPT,
|
||||
"threshold": config.SAM3_FLOWER_THRESHOLD}],
|
||||
workdir=workdir,
|
||||
)
|
||||
instances_raw = result.get((image_key, config.SAM3_FLOWER_PROMPT), [])
|
||||
|
||||
# Light sanity filter only -- SAM3 already did the semantic work of
|
||||
# "is this a flower", this just guards against a stray instance
|
||||
# entirely outside the known foreground (shouldn't happen against an
|
||||
# already background-removed image, but costs nothing to check).
|
||||
instance_masks = [
|
||||
inst["mask"] for inst in instances_raw
|
||||
if np.logical_and(inst["mask"], mask > 0).any()
|
||||
]
|
||||
|
||||
# Color-cluster the surviving instances as a proxy for distinct flower
|
||||
# "kinds" -- same idea as the color-family grid elsewhere in this app
|
||||
# (pipeline/color_grid.py), just applied per-instance instead of
|
||||
# per-pixel-region.
|
||||
avg_colors_lab = []
|
||||
for m in instance_masks:
|
||||
pixels = bgr[m].reshape(-1, 1, 3).astype(np.uint8)
|
||||
lab = cv2.cvtColor(pixels, cv2.COLOR_BGR2LAB).reshape(-1, 3)
|
||||
avg_colors_lab.append(lab.mean(axis=0))
|
||||
|
||||
clusters = []
|
||||
cluster_of_instance = []
|
||||
if avg_colors_lab:
|
||||
pts = np.array(avg_colors_lab, dtype=np.float32)
|
||||
k = min(config.SAM_MAX_KIND_CLUSTERS, len(pts))
|
||||
if k <= 1:
|
||||
labels = np.zeros(len(pts), dtype=int)
|
||||
centers = pts
|
||||
else:
|
||||
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.5)
|
||||
_, labels, centers = cv2.kmeans(pts, k, None, criteria, 5, cv2.KMEANS_PP_CENTERS)
|
||||
labels = labels.flatten()
|
||||
cluster_of_instance = labels.tolist()
|
||||
for c in range(len(centers)):
|
||||
count = int((labels == c).sum())
|
||||
if count == 0:
|
||||
continue
|
||||
lab_center = centers[c].reshape(1, 1, 3).astype(np.uint8)
|
||||
bgr_center = cv2.cvtColor(lab_center, cv2.COLOR_LAB2BGR)[0, 0]
|
||||
clusters.append({
|
||||
"count": count,
|
||||
"color_rgb": [int(bgr_center[2]), int(bgr_center[1]), int(bgr_center[0])],
|
||||
})
|
||||
clusters.sort(key=lambda c: c["count"], reverse=True)
|
||||
|
||||
return {
|
||||
"total_count": len(instance_masks),
|
||||
"clusters": clusters,
|
||||
"_instance_masks": instance_masks,
|
||||
"_cluster_of_instance": cluster_of_instance,
|
||||
}
|
||||
|
||||
|
||||
_PALETTE = [
|
||||
(66, 133, 244), (219, 68, 55), (244, 180, 0), (15, 157, 88),
|
||||
(171, 71, 188), (255, 112, 67), (0, 172, 193), (158, 157, 36),
|
||||
]
|
||||
|
||||
|
||||
def render_instances(bgr, count_data):
|
||||
"""Overlays a translucent, distinctly-colored fill per detected flower
|
||||
instance (color = its cluster/"kind") plus its index, so the count is
|
||||
visually verifiable rather than just a bare number."""
|
||||
overlay = bgr.copy()
|
||||
instance_masks = count_data.get("_instance_masks", [])
|
||||
cluster_of_instance = count_data.get("_cluster_of_instance", [])
|
||||
for idx, m in enumerate(instance_masks):
|
||||
cluster_id = cluster_of_instance[idx] if idx < len(cluster_of_instance) else idx
|
||||
color = np.array(_PALETTE[cluster_id % len(_PALETTE)], dtype=np.float32)
|
||||
overlay[m] = (color * 0.55 + overlay[m].astype(np.float32) * 0.45).astype(np.uint8)
|
||||
ys, xs = np.nonzero(m)
|
||||
if len(xs):
|
||||
cx, cy = int(xs.mean()), int(ys.mean())
|
||||
cv2.putText(overlay, str(idx + 1), (cx - 8, cy + 6),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2, cv2.LINE_AA)
|
||||
cv2.putText(overlay, str(idx + 1), (cx - 8, cy + 6),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (30, 30, 30), 1, cv2.LINE_AA)
|
||||
return overlay
|
||||
103
pipeline/sam3_client.py
Normal file
103
pipeline/sam3_client.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Bridge from the main app (torch17_new, Python 3.8) to SAM3 (sam3_worker.py,
|
||||
run under sam2_env's Python 3.10 -- see config.py's SAM3 section for why
|
||||
this has to be a subprocess rather than an in-process import).
|
||||
|
||||
One subprocess invocation can batch multiple (image, prompt) jobs -- e.g.
|
||||
"flower" on the upload and "vase" on both the upload and the matched
|
||||
template -- so the ~6s model load only happens once per request, not once
|
||||
per job.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Sam3Error(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_jobs(images: dict, jobs: list, workdir: str) -> dict:
|
||||
"""images: {image_key: bgr_ndarray}. jobs: [{"image": key, "prompt": str,
|
||||
"threshold": float}, ...]. Returns {(image_key, prompt): [{"mask": bool
|
||||
ndarray, "score": float, "box": [x1,y1,x2,y2] or None}, ...]}.
|
||||
|
||||
Raises Sam3Error on any failure (missing token, subprocess crash,
|
||||
timeout, malformed response) -- callers treat this the same as any
|
||||
other soft/optional-feature failure (caught, logged, reported back as
|
||||
an error string, never taking the whole request down)."""
|
||||
if not config.HF_TOKEN:
|
||||
raise Sam3Error(
|
||||
"No HF_TOKEN found (checked environment and .env) -- SAM3's "
|
||||
"weights are gated on Hugging Face and can't be downloaded "
|
||||
"without an access-granted token."
|
||||
)
|
||||
|
||||
request_dir = os.path.join(workdir, f"sam3_{uuid.uuid4().hex[:8]}")
|
||||
os.makedirs(request_dir, exist_ok=True)
|
||||
try:
|
||||
image_paths = {}
|
||||
for key, bgr in images.items():
|
||||
path = os.path.join(request_dir, f"{key}.png")
|
||||
cv2.imwrite(path, bgr)
|
||||
image_paths[key] = path
|
||||
|
||||
output_dir = os.path.join(request_dir, "out")
|
||||
request = {"images": image_paths, "jobs": jobs, "output_dir": output_dir}
|
||||
request_path = os.path.join(request_dir, "request.json")
|
||||
with open(request_path, "w") as f:
|
||||
json.dump(request, f)
|
||||
|
||||
env = dict(os.environ)
|
||||
env["HF_TOKEN"] = config.HF_TOKEN
|
||||
|
||||
proc = subprocess.run(
|
||||
[config.SAM3_PYTHON_BIN, config.SAM3_WORKER_SCRIPT, "--request", request_path],
|
||||
capture_output=True, text=True, timeout=config.SAM3_TIMEOUT_SECONDS, env=env,
|
||||
)
|
||||
|
||||
response_path = os.path.join(output_dir, "response.json")
|
||||
if not os.path.isfile(response_path):
|
||||
raise Sam3Error(
|
||||
f"SAM3 worker produced no response (exit {proc.returncode}): "
|
||||
f"{proc.stderr[-2000:] if proc.stderr else '(no stderr)'}"
|
||||
)
|
||||
|
||||
with open(response_path) as f:
|
||||
response = json.load(f)
|
||||
|
||||
if response.get("error"):
|
||||
raise Sam3Error(f"SAM3 worker failed: {response['error'][:2000]}")
|
||||
|
||||
out = {}
|
||||
for entry in response["results"]:
|
||||
key = (entry["image"], entry["prompt"])
|
||||
instances = []
|
||||
for inst in entry["instances"]:
|
||||
mask_path = os.path.join(output_dir, inst["mask_file"])
|
||||
mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)
|
||||
if mask is None:
|
||||
continue
|
||||
instances.append({
|
||||
"mask": mask > 127,
|
||||
"score": inst["score"],
|
||||
"box": inst.get("box"),
|
||||
})
|
||||
out[key] = instances
|
||||
return out
|
||||
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise Sam3Error(f"SAM3 worker timed out after {config.SAM3_TIMEOUT_SECONDS}s") from e
|
||||
finally:
|
||||
shutil.rmtree(request_dir, ignore_errors=True)
|
||||
135
pipeline/shape_match.py
Normal file
135
pipeline/shape_match.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
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
|
||||
134
pipeline/texture_match.py
Normal file
134
pipeline/texture_match.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Texture matching: compares surface/material texture -- independent of
|
||||
color and of overall silhouette shape. Two standard, complementary
|
||||
classical texture descriptors (no deep learning needed):
|
||||
|
||||
- Local Binary Patterns (LBP): encodes each pixel's local micro-pattern
|
||||
relative to its neighbors, compared as a histogram (same convention as
|
||||
pipeline/color.py's hue/saturation histogram) -- good at catching fine,
|
||||
repetitive patterns like fabric weave or petal grain.
|
||||
- GLCM (gray-level co-occurrence matrix) / Haralick features (contrast,
|
||||
homogeneity, energy, correlation) -- coarser statistical texture
|
||||
properties, good at catching smooth-vs-rough, uniform-vs-busy material
|
||||
differences that a local pattern histogram alone can miss.
|
||||
|
||||
Purely informational, its own section: never feeds into any score.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from skimage.feature import graycomatrix, graycoprops, local_binary_pattern
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LBP_BINS = config.TEXTURE_LBP_POINTS + 2 # "uniform" LBP yields P+2 distinct codes
|
||||
|
||||
_template_texture_data = {} # name -> {"lbp_hist", "glcm_features", "lbp_image", "mask_bool"}
|
||||
|
||||
|
||||
def _masked_gray(bgr, mask):
|
||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
return gray, mask > 0
|
||||
|
||||
|
||||
def _compute_lbp(gray, mask_bool):
|
||||
lbp_image = local_binary_pattern(gray, config.TEXTURE_LBP_POINTS,
|
||||
config.TEXTURE_LBP_RADIUS, method="uniform")
|
||||
values = lbp_image[mask_bool]
|
||||
if values.size == 0:
|
||||
return lbp_image, np.zeros(_LBP_BINS, dtype=np.float64)
|
||||
hist, _ = np.histogram(values, bins=_LBP_BINS, range=(0, _LBP_BINS), density=True)
|
||||
return lbp_image, hist
|
||||
|
||||
|
||||
def _compute_glcm_features(gray, mask_bool):
|
||||
ys, xs = np.where(mask_bool)
|
||||
if len(ys) == 0:
|
||||
return {p: 0.0 for p in config.TEXTURE_GLCM_PROPS}
|
||||
|
||||
y0, y1, x0, x1 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1
|
||||
crop_gray = gray[y0:y1, x0:x1].copy()
|
||||
crop_mask = mask_bool[y0:y1, x0:x1]
|
||||
|
||||
levels = config.TEXTURE_GLCM_LEVELS
|
||||
quantized = (crop_gray.astype(np.float32) / 256 * levels).astype(np.uint8)
|
||||
quantized[~crop_mask] = 0 # background -> level 0, excluded from GLCM below
|
||||
|
||||
angles = (0, np.pi / 4, np.pi / 2, 3 * np.pi / 4)
|
||||
glcm = graycomatrix(quantized, distances=list(config.TEXTURE_GLCM_DISTANCES),
|
||||
angles=list(angles), levels=levels, symmetric=True, normed=True)
|
||||
|
||||
# Exclude any co-occurrence touching the masked-out background level.
|
||||
glcm[0, :, :, :] = 0
|
||||
glcm[:, 0, :, :] = 0
|
||||
total = glcm.sum()
|
||||
if total > 0:
|
||||
glcm = glcm / total
|
||||
|
||||
return {p: float(np.mean(graycoprops(glcm, p))) for p in config.TEXTURE_GLCM_PROPS}
|
||||
|
||||
|
||||
def compute_texture_data(bgr, mask):
|
||||
gray, mask_bool = _masked_gray(bgr, mask)
|
||||
lbp_image, lbp_hist = _compute_lbp(gray, mask_bool)
|
||||
glcm_features = _compute_glcm_features(gray, mask_bool)
|
||||
return {
|
||||
"lbp_hist": lbp_hist,
|
||||
"glcm_features": glcm_features,
|
||||
"lbp_image": lbp_image,
|
||||
"mask_bool": mask_bool,
|
||||
}
|
||||
|
||||
|
||||
def set_template_texture_data(name, bgr, mask):
|
||||
_template_texture_data[name] = compute_texture_data(bgr, mask)
|
||||
|
||||
|
||||
def _hist_similarity_pct(hist_a, hist_b):
|
||||
# Histogram intersection, same convention as pipeline/color.py.
|
||||
return round(float(np.minimum(hist_a, hist_b).sum()) * 100, 1)
|
||||
|
||||
|
||||
def _glcm_similarity_pct(features_a, features_b):
|
||||
sims = []
|
||||
for prop in config.TEXTURE_GLCM_PROPS:
|
||||
a, b = features_a[prop], features_b[prop]
|
||||
scale = max(abs(a), abs(b), 1e-9)
|
||||
sims.append(max(0.0, 1 - abs(a - b) / scale))
|
||||
return round(float(np.mean(sims)) * 100, 1)
|
||||
|
||||
|
||||
def compare_to_templates(bgr, mask):
|
||||
"""Returns (input_texture_data, ranked_results) -- results sorted by
|
||||
match_pct descending, one entry per template with the LBP-based and
|
||||
GLCM-based sub-scores (plus the raw GLCM features) broken out too."""
|
||||
input_data = compute_texture_data(bgr, mask)
|
||||
|
||||
results = []
|
||||
for name, tdata in _template_texture_data.items():
|
||||
lbp_sim = _hist_similarity_pct(input_data["lbp_hist"], tdata["lbp_hist"])
|
||||
glcm_sim = _glcm_similarity_pct(input_data["glcm_features"], tdata["glcm_features"])
|
||||
match_pct = round((lbp_sim + glcm_sim) / 2, 1)
|
||||
results.append({
|
||||
"template": name,
|
||||
"match_pct": match_pct,
|
||||
"lbp_similarity_pct": lbp_sim,
|
||||
"glcm_similarity_pct": glcm_sim,
|
||||
"input_glcm_features": {p: round(v, 3) for p, v in input_data["glcm_features"].items()},
|
||||
"template_glcm_features": {p: round(v, 3) for p, v in tdata["glcm_features"].items()},
|
||||
})
|
||||
results.sort(key=lambda r: r["match_pct"], reverse=True)
|
||||
return input_data, results
|
||||
|
||||
|
||||
def render_lbp_visual(lbp_image, mask_bool):
|
||||
"""Normalizes the LBP code map to a viewable grayscale image, masked to
|
||||
the foreground only, for the side-by-side visual comparison."""
|
||||
norm = cv2.normalize(lbp_image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||||
out = np.zeros_like(norm)
|
||||
out[mask_bool] = norm[mask_bool]
|
||||
return cv2.cvtColor(out, cv2.COLOR_GRAY2BGR)
|
||||
95
pipeline/utils.py
Normal file
95
pipeline/utils.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
import cv2
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def list_template_files():
|
||||
if not os.path.isdir(config.TEMPLATE_IMAGES_DIR):
|
||||
raise FileNotFoundError(f"Template folder not found: {config.TEMPLATE_IMAGES_DIR}")
|
||||
return sorted(
|
||||
f for f in os.listdir(config.TEMPLATE_IMAGES_DIR)
|
||||
if f.lower().endswith(config.VALID_EXTS)
|
||||
)
|
||||
|
||||
|
||||
def template_name(fname):
|
||||
return os.path.splitext(fname)[0]
|
||||
|
||||
|
||||
def annotate_score(bgr_img, score, confidence_pct, label=None):
|
||||
img = bgr_img.copy()
|
||||
text = f"Score: {score} Conf: {confidence_pct:.1f}%"
|
||||
if label:
|
||||
text = f"{label} | {text}"
|
||||
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
scale = max(0.55, img.shape[1] / 900)
|
||||
thickness = max(1, int(scale * 2))
|
||||
|
||||
(tw, th), baseline = cv2.getTextSize(text, font, scale, thickness)
|
||||
cv2.rectangle(img, (5, 5), (15 + tw, 20 + th + baseline), (20, 20, 20), -1)
|
||||
cv2.putText(img, text, (10, 15 + th), font, scale, (110, 231, 183), thickness,
|
||||
cv2.LINE_AA)
|
||||
return img
|
||||
|
||||
|
||||
def encode_png_bytes(bgr_or_bgra_img):
|
||||
ok, buf = cv2.imencode(".png", bgr_or_bgra_img)
|
||||
if not ok:
|
||||
raise RuntimeError("Failed to encode image to PNG")
|
||||
return buf.tobytes()
|
||||
|
||||
|
||||
def compress_image_bytes(image_bytes: bytes, max_bytes: int, max_dim: int) -> tuple:
|
||||
"""
|
||||
Downscale + re-encode as JPEG only if image_bytes exceeds max_bytes;
|
||||
otherwise returns it untouched -- images already under the limit are
|
||||
never re-compressed, so nothing is lost for the common case.
|
||||
|
||||
Downscaling to max_dim costs no *usable* detail here: the matching
|
||||
pipeline (bg removal, SIFT/ORB/SuperPoint/LoFTR) already caps every
|
||||
image to this same size before processing it, and the external AI
|
||||
verification endpoint's vision model downsamples internally to its own
|
||||
fixed input resolution regardless. This just stops storing/transmitting
|
||||
pixels nothing in the system ever actually looks at.
|
||||
|
||||
Returns (bytes, was_compressed).
|
||||
"""
|
||||
if len(image_bytes) <= max_bytes:
|
||||
return image_bytes, False
|
||||
|
||||
original_size = len(image_bytes)
|
||||
|
||||
pil_img = Image.open(io.BytesIO(image_bytes))
|
||||
pil_img = ImageOps.exif_transpose(pil_img) # bake in camera rotation before resizing
|
||||
pil_img = pil_img.convert("RGB")
|
||||
|
||||
w, h = pil_img.size
|
||||
scale = max_dim / max(w, h)
|
||||
if scale < 1.0:
|
||||
pil_img = pil_img.resize((max(1, int(w * scale)), max(1, int(h * scale))),
|
||||
Image.LANCZOS)
|
||||
|
||||
quality = config.COMPRESS_JPEG_QUALITY_START
|
||||
data = None
|
||||
while True:
|
||||
buf = io.BytesIO()
|
||||
pil_img.save(buf, format="JPEG", quality=quality, optimize=True)
|
||||
data = buf.getvalue()
|
||||
if len(data) <= max_bytes or quality <= config.COMPRESS_JPEG_QUALITY_MIN:
|
||||
break
|
||||
quality -= config.COMPRESS_JPEG_QUALITY_STEP
|
||||
|
||||
logger.info(
|
||||
"Compressed upload: %.1f MB -> %.1f MB (%dx%d, JPEG q%d)",
|
||||
original_size / (1024 * 1024), len(data) / (1024 * 1024),
|
||||
pil_img.width, pil_img.height, quality,
|
||||
)
|
||||
return data, True
|
||||
158
pipeline/vase_compare.py
Normal file
158
pipeline/vase_compare.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Vase-identity comparison: crops the vase region (via SAM3's "vase" concept
|
||||
mask -- see pipeline/sam3_client.py) from both the upload and the matched
|
||||
template, then compares the two crops with two complementary embedding
|
||||
models:
|
||||
|
||||
- DINOv2 (facebook/dinov2-base): self-supervised, patch-level visual
|
||||
features -- good at fine-grained shape/texture/material detail.
|
||||
- CLIP (openai/clip-vit-base-patch32): contrastive image embedding -- a
|
||||
coarser, more holistic notion of visual similarity, used as a second
|
||||
opinion that isn't fooled by the same quirks DINO might be.
|
||||
|
||||
Purely informational, opt-in (run alongside the SAM/YOLO-World flower count,
|
||||
triggered by the same button) -- never feeds into matching/scoring.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
_dino_model = None
|
||||
_dino_processor = None
|
||||
_clip_model = None
|
||||
_clip_processor = None
|
||||
|
||||
|
||||
def _get_dino():
|
||||
global _dino_model, _dino_processor
|
||||
if _dino_model is None:
|
||||
logger.info("Loading DINOv2 (%s) on %s...", config.DINO_MODEL_NAME, DEVICE)
|
||||
from transformers import AutoImageProcessor, AutoModel
|
||||
_dino_processor = AutoImageProcessor.from_pretrained(config.DINO_MODEL_NAME)
|
||||
_dino_model = AutoModel.from_pretrained(config.DINO_MODEL_NAME).eval().to(DEVICE)
|
||||
return _dino_model, _dino_processor
|
||||
|
||||
|
||||
def _get_clip():
|
||||
global _clip_model, _clip_processor
|
||||
if _clip_model is None:
|
||||
logger.info("Loading CLIP (%s) on %s...", config.CLIP_MODEL_NAME, DEVICE)
|
||||
from transformers import CLIPModel, CLIPProcessor
|
||||
_clip_model = CLIPModel.from_pretrained(config.CLIP_MODEL_NAME).eval().to(DEVICE)
|
||||
_clip_processor = CLIPProcessor.from_pretrained(config.CLIP_MODEL_NAME)
|
||||
return _clip_model, _clip_processor
|
||||
|
||||
|
||||
def unload_models():
|
||||
global _dino_model, _dino_processor, _clip_model, _clip_processor
|
||||
freed = _dino_model is not None or _clip_model is not None
|
||||
_dino_model = None
|
||||
_dino_processor = None
|
||||
_clip_model = None
|
||||
_clip_processor = None
|
||||
if freed:
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
return freed
|
||||
|
||||
|
||||
def _to_pil(bgr):
|
||||
return Image.fromarray(cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB))
|
||||
|
||||
|
||||
def _dino_embed(bgr):
|
||||
model, processor = _get_dino()
|
||||
with torch.no_grad():
|
||||
inputs = processor(images=_to_pil(bgr), return_tensors="pt").to(DEVICE)
|
||||
out = model(**inputs)
|
||||
feat = out.last_hidden_state[:, 0] # CLS token
|
||||
feat = torch.nn.functional.normalize(feat, dim=-1)
|
||||
return feat.cpu().numpy()[0]
|
||||
|
||||
|
||||
def _clip_embed(bgr):
|
||||
model, processor = _get_clip()
|
||||
with torch.no_grad():
|
||||
inputs = processor(images=_to_pil(bgr), return_tensors="pt").to(DEVICE)
|
||||
feat = model.get_image_features(**inputs)
|
||||
feat = torch.nn.functional.normalize(feat, dim=-1)
|
||||
return feat.cpu().numpy()[0]
|
||||
|
||||
|
||||
def _cosine_pct(a, b):
|
||||
"""Cosine similarity of two L2-normalized vectors, clamped to [0, 1] and
|
||||
reported as a percentage -- negative similarity (near-opposite vectors)
|
||||
is clamped to 0% rather than reported as a negative number, since
|
||||
"how similar" isn't meaningful past that point for this use case."""
|
||||
sim = float(np.dot(a, b))
|
||||
return round(max(0.0, min(1.0, sim)) * 100, 1)
|
||||
|
||||
|
||||
def crop_mask(bgr, mask_bool):
|
||||
"""Crops the bounding box of mask_bool out of bgr, with a small pad
|
||||
(config.VASE_CROP_PAD_FRAC) so the mask's own (occasionally imprecise)
|
||||
edge doesn't cut off the vase's rim or base -- then blacks out any
|
||||
pixel inside that padded box that the mask doesn't cover, so DINOv2/CLIP
|
||||
only ever see vase pixels, not whatever flower stems or ribbon happen to
|
||||
share the box's corners. Returns None if the mask is empty."""
|
||||
ys, xs = np.nonzero(mask_bool)
|
||||
if len(xs) == 0:
|
||||
return None
|
||||
h, w = bgr.shape[:2]
|
||||
x1, y1, x2, y2 = xs.min(), ys.min(), xs.max(), ys.max()
|
||||
bw, bh = x2 - x1, y2 - y1
|
||||
pad = config.VASE_CROP_PAD_FRAC
|
||||
px1, px2 = x1 - bw * pad, x2 + bw * pad
|
||||
py1, py2 = y1 - bh * pad, y2 + bh * pad
|
||||
px1, py1 = max(0, int(round(px1))), max(0, int(round(py1)))
|
||||
px2, py2 = min(w, int(round(px2))), min(h, int(round(py2)))
|
||||
if px2 <= px1 or py2 <= py1:
|
||||
return None
|
||||
|
||||
crop = bgr[py1:py2, px1:px2].copy()
|
||||
mask_crop = mask_bool[py1:py2, px1:px2]
|
||||
crop[~mask_crop] = 0
|
||||
return crop
|
||||
|
||||
|
||||
def clip_similarity_pct(crop_a, crop_b):
|
||||
"""CLIP-only similarity between two crops -- no DINOv2. Used for the
|
||||
auto-triggered "flower check" beside the weighted verdict (see
|
||||
engine.flower_summary): unlike the opt-in vase comparison, that one runs
|
||||
on every confident match, so it deliberately loads only CLIP (small,
|
||||
fast) rather than both embedding models."""
|
||||
return _cosine_pct(_clip_embed(crop_a), _clip_embed(crop_b))
|
||||
|
||||
|
||||
def compare_vases(input_crop_bgr, template_crop_bgr):
|
||||
dino_pct = _cosine_pct(_dino_embed(input_crop_bgr), _dino_embed(template_crop_bgr))
|
||||
clip_pct = _cosine_pct(_clip_embed(input_crop_bgr), _clip_embed(template_crop_bgr))
|
||||
combined_pct = round(
|
||||
config.VASE_DINO_WEIGHT * dino_pct + config.VASE_CLIP_WEIGHT * clip_pct, 1
|
||||
)
|
||||
|
||||
if combined_pct >= config.VASE_SAME_THRESHOLD:
|
||||
verdict = "same"
|
||||
elif combined_pct >= config.VASE_UNCERTAIN_THRESHOLD:
|
||||
verdict = "uncertain"
|
||||
else:
|
||||
verdict = "different"
|
||||
|
||||
return {
|
||||
"dino_similarity_pct": dino_pct,
|
||||
"clip_similarity_pct": clip_pct,
|
||||
"combined_pct": combined_pct,
|
||||
"verdict": verdict,
|
||||
}
|
||||
76
pipeline/verify.py
Normal file
76
pipeline/verify.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Optional third-party AI verification, layered on top of the core
|
||||
feature-matching pipeline: sends the winning template's original photo and
|
||||
the user's original upload to an external vision-LLM endpoint that runs a
|
||||
detailed QC-style comparison (flowers, vase, ribbon, composition) and
|
||||
returns a MATCH/DISCREPANCIES/CONFIDENCE verdict plus free-text description.
|
||||
|
||||
This never affects the core match result -- if the endpoint is slow, down,
|
||||
or unreachable (it's a Cloudflare tunnel, which can go stale), the caller
|
||||
is expected to treat any exception here as a soft failure.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VERDICT_RE = re.compile(
|
||||
# DISCREP\w* rather than a literal "DISCREPANCIES" -- the LLM behind
|
||||
# the endpoint doesn't reliably spell it the same way every time
|
||||
# ("DISCREPANCIES" vs "DISCREPENCIES" have both been observed), and a
|
||||
# missed match here silently dumps the whole raw block into the UI.
|
||||
r"MATCH:\s*\[?\s*(?P<match>YES|NO|PARTIAL)\s*\]?\s*"
|
||||
r"DISCREP\w*:\s*\[?\s*(?P<discrepancies>.*?)\s*\]?\s*"
|
||||
r"CONFIDENCE:\s*\[?\s*(?P<confidence>High|Medium|Low)\s*\]?",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _parse_result_text(text: str) -> dict:
|
||||
"""The endpoint's `result` field is free text with an embedded
|
||||
MATCH/DISCREPANCIES/CONFIDENCE block, sometimes followed by a prose
|
||||
description, sometimes not. Pull out the structured bits; whatever's
|
||||
left over (if anything) is the description."""
|
||||
text = text or ""
|
||||
m = _VERDICT_RE.search(text)
|
||||
|
||||
if not m:
|
||||
return {"match": None, "confidence": None, "discrepancies": None,
|
||||
"description": text.strip()}
|
||||
|
||||
return {
|
||||
"match": m.group("match").upper(),
|
||||
"confidence": m.group("confidence").capitalize(),
|
||||
"discrepancies": m.group("discrepancies").strip() or "None",
|
||||
"description": text[m.end():].strip(),
|
||||
}
|
||||
|
||||
|
||||
def verify_images(reference_path: str, actual_path: str) -> dict:
|
||||
"""reference_path = the matched template's original photo (IMAGE 1),
|
||||
actual_path = the user's original upload (IMAGE 2)."""
|
||||
with open(reference_path, "rb") as f1, open(actual_path, "rb") as f2:
|
||||
files = {
|
||||
"image1": (os.path.basename(reference_path), f1, "image/png"),
|
||||
"image2": (os.path.basename(actual_path), f2, "image/jpeg"),
|
||||
}
|
||||
response = requests.post(config.VERIFY_ENDPOINT_URL, files=files,
|
||||
timeout=config.VERIFY_TIMEOUT_SECONDS)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
parsed = _parse_result_text(data.get("result", ""))
|
||||
return {
|
||||
"match": parsed["match"],
|
||||
"confidence": parsed["confidence"],
|
||||
"discrepancies": parsed["discrepancies"],
|
||||
"description": parsed["description"],
|
||||
"raw_result": data.get("result"),
|
||||
"pixel_precheck": data.get("pixel_precheck"),
|
||||
}
|
||||
94
pipeline/yolo_world.py
Normal file
94
pipeline/yolo_world.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Open-vocabulary object detection via YOLO-World (YOLOv8 family, ultralytics),
|
||||
run alongside SAM (see pipeline/flower_count.py) for the flower-count feature.
|
||||
|
||||
SAM has no notion of "flower" -- it blindly proposes every objectlike region
|
||||
it can find, so the vase and any ribbon/bow tied around it get segmented and
|
||||
counted as if they were flowers. YOLO-World, prompted with plain-language
|
||||
classes, actually knows what those things look like: its "vase"/"ribbon"/
|
||||
"bow" detections are used (in engine.count_flowers) to exclude those regions
|
||||
from SAM's flower-instance count.
|
||||
|
||||
Its own "flower" boxes are shown to the user as a second, independent count
|
||||
-- in practice it tends to draw one box per contiguous flower region rather
|
||||
than per individual bloom, so it's a coarser, corroborating signal, not a
|
||||
replacement for SAM's per-instance count.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import logging
|
||||
|
||||
import cv2
|
||||
import torch
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
_model = None
|
||||
|
||||
|
||||
def get_model():
|
||||
global _model
|
||||
if _model is None:
|
||||
logger.info("Loading YOLO-World (%s) on %s...", config.YOLO_WORLD_CHECKPOINT, DEVICE)
|
||||
from ultralytics import YOLO
|
||||
_model = YOLO(config.YOLO_WORLD_CHECKPOINT)
|
||||
_model.set_classes(config.YOLO_WORLD_CLASSES)
|
||||
return _model
|
||||
|
||||
|
||||
def unload_model():
|
||||
global _model
|
||||
freed = _model is not None
|
||||
_model = None
|
||||
if freed:
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
return freed
|
||||
|
||||
|
||||
def detect(bgr):
|
||||
"""Returns {class_name: [{"xyxy": [x1,y1,x2,y2], "confidence": float}, ...]}
|
||||
for every class in config.YOLO_WORLD_CLASSES (empty list if none found)."""
|
||||
model = get_model()
|
||||
with torch.no_grad():
|
||||
results = model.predict(bgr, conf=config.YOLO_WORLD_CONF,
|
||||
iou=config.YOLO_WORLD_IOU, verbose=False)
|
||||
r = results[0]
|
||||
by_class = {name: [] for name in config.YOLO_WORLD_CLASSES}
|
||||
for b in r.boxes:
|
||||
name = r.names[int(b.cls[0])]
|
||||
by_class.setdefault(name, []).append({
|
||||
"xyxy": [float(v) for v in b.xyxy[0].tolist()],
|
||||
"confidence": round(float(b.conf[0]), 3),
|
||||
})
|
||||
return by_class
|
||||
|
||||
|
||||
_BOX_COLORS = {
|
||||
"flower": (66, 133, 244), "vase": (219, 68, 55),
|
||||
"ribbon": (244, 180, 0), "bow": (15, 157, 88),
|
||||
}
|
||||
|
||||
|
||||
def render_boxes(bgr, by_class):
|
||||
"""Draws a labeled box per detection, colored by class, so the user can
|
||||
see exactly which regions YOLO-World identified as flower vs. vase vs.
|
||||
ribbon/bow."""
|
||||
overlay = bgr.copy()
|
||||
for name, boxes in by_class.items():
|
||||
color = _BOX_COLORS.get(name, (170, 170, 170))
|
||||
for box in boxes:
|
||||
x1, y1, x2, y2 = [int(v) for v in box["xyxy"]]
|
||||
cv2.rectangle(overlay, (x1, y1), (x2, y2), color, 3)
|
||||
label = f"{name} {box['confidence']:.2f}"
|
||||
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)
|
||||
label_y = max(th + 6, y1)
|
||||
cv2.rectangle(overlay, (x1, label_y - th - 8), (x1 + tw + 6, label_y), color, -1)
|
||||
cv2.putText(overlay, label, (x1 + 3, label_y - 5),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)
|
||||
return overlay
|
||||
Reference in New Issue
Block a user