Add project files

This commit is contained in:
Suman
2026-08-04 17:09:29 +05:30
parent 464a68cceb
commit ac76cc86bc
372 changed files with 12221 additions and 0 deletions

858
pipeline/engine.py Normal file
View 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()
},
}