Files
Vase-Matcher/config.py
2026-08-04 17:09:29 +05:30

281 lines
12 KiB
Python

"""
Central config for Vase Matcher.
"""
import os
# ASSUMPTION: cap thread pools BEFORE cv2/onnxruntime/torch are imported
# anywhere in the process. Left uncapped, each of these libraries grabs one
# thread per core for every op, which on a shared dev machine starves
# everything else (editor, window manager, etc.) even for a single request.
NUM_WORKER_THREADS = min(4, os.cpu_count() or 4)
for _env_var in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS",
"NUMEXPR_NUM_THREADS", "ORT_NUM_THREADS"):
os.environ.setdefault(_env_var, str(NUM_WORKER_THREADS))
# The 7.91 GiB card is small enough that PyTorch's caching allocator can hit
# CUDA OOM from fragmentation alone even when the aggregate free memory
# would be enough -- this is what the OOM error itself recommends.
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# Reuse the existing template photos rather than duplicating them.
TEMPLATE_IMAGES_DIR = "/media/suman/Backup_of_extra_/Sasi/Flowers_images/Templates"
CACHE_DIR = os.path.join(BASE_DIR, "cache")
TEMPLATES_NOBG_CACHE = os.path.join(CACHE_DIR, "templates_nobg")
UPLOADS_NOBG_CACHE = os.path.join(CACHE_DIR, "uploads_nobg")
UPLOADS_DIR = os.path.join(BASE_DIR, "uploads")
MAX_UPLOAD_AGE_SECONDS = 60 * 60 * 6 # janitor sweeps uploads older than this
VALID_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
REMBG_MODEL_NAME = "birefnet-general-lite"
# ASSUMPTION: uploads (phone photos especially) can be arbitrarily large --
# multi-thousand-pixel images fed uncapped into bg removal + SuperPoint were
# the source of multi-GB memory spikes that got the process OOM-killed.
# Every image is downscaled to this before touching any model; templates are
# capped too for consistency even though they're already small.
MAX_IMAGE_DIM = 1600
ALPHA_THRESHOLD = 200
ERODE_ITER = 1
LOWE_RATIO = 0.75
MIN_RAW_MATCHES = 4
ORB_N_FEATURES = 5000
SUPERPOINT_MAX_KEYPOINTS = 1024
LOFTR_MAX_DIM = 480
LOFTR_CONFIDENCE_THRESHOLD = 0.5
# Score thresholds only affect the "confident match" badge -- the full
# ranked list of templates + scores is always returned per method.
SCORE_THRESHOLD = {
"SIFT": 10,
"ORB": 10,
"SuperGlue": 15,
"LoFTR": 15,
# match_pct is already a 0-100 histogram-overlap percentage (see
# pipeline/color.py), not a raw inlier count -- 50 is a reasonable
# "more than half your photo's color distribution is covered" bar.
"Color": 50,
}
METHOD_LABELS = {
"SIFT": "SIFT (classical)",
"ORB": "ORB (classical)",
"SuperGlue": "SuperPoint + LightGlue",
"LoFTR": "LoFTR (dense)",
"Color": "Color space",
}
# Weighted final verdict: weighted_score(template) = sum_m WEIGHT[m] *
# raw_score(m, template). Separate from the Borda-count "overall_best"
# above (which aggregates ranks, not raw scores) -- this is a literal
# weighted sum, so a method with naturally larger raw-score magnitudes
# (e.g. SuperGlue's inlier counts run much higher than SIFT/ORB's) pulls
# harder on the total even at a lower weight. That's expected given the
# formula as specified, not a bug.
#
# "Color" folds the color-space match_pct (0-100) into this same weighted
# sum. It does NOT appear as a 5th card in the method grid (that section is
# unchanged) and does NOT feed into the Borda-count "overall_best" -- it
# only contributes to the weighted verdict, per request.
METHOD_WEIGHTS = {
"LoFTR": 0.75,
"SuperGlue": 0.15,
"SIFT": 0.10,
"ORB": 0.1,
"Color": 0.7,
}
# Optional third-party AI verification step, run after the weighted best
# template is known: sends {matched template photo, user's original upload}
# to an external vision-LLM endpoint for a detailed QC-style comparison. This
# is a Cloudflare tunnel URL -- it can go stale if that tunnel is restarted,
# so failures here are handled as a soft/optional error, never a hard one.
VERIFY_ENDPOINT_URL = "https://marshall-toys-ridge-showing.trycloudflare.com/verify"
VERIFY_TIMEOUT_SECONDS = 90
# Color family grid (dynamic K-means color-region clustering, LAB space --
# see pipeline/color_grid.py): visual side-by-side of "which parts of your
# photo and the matched template are the same color region" plus a per-family
# area-match %. Purely informational, like AI verification -- never feeds
# into any score.
FAMILY_GRID_K = 5
FAMILY_GRID_LIGHTNESS_WEIGHT = 0.6 # down-weight L* so shadows of the same
# hue cluster together, same as lightness
# damping in dynamic color-family tools
FAMILY_GRID_LINE_WIDTH = 4
# Each tile is downscaled to this before being placed in the grid (not the
# other way around) -- keeps the assembled canvas small by construction
# instead of building one at full (up to 1600px) resolution per tile and
# shrinking after.
FAMILY_GRID_TILE_MAX_DIM = 260
# Hard ceiling Werkzeug enforces at the request layer -- anything bigger is
# rejected with 413 before our view function ever runs. Kept well above
# COMPRESS_ABOVE_BYTES so large-but-legitimate phone photos (modern phone
# JPEGs can run 15-25 MB at high megapixel counts) get a chance to be
# compressed instead of outright rejected; this is just the outer guard
# against something absurd (a mislabeled video, etc.).
MAX_CONTENT_LENGTH_BYTES = 50 * 1024 * 1024 # 50 MB hard reject ceiling
# Soft threshold: uploads bigger than this are downscaled + re-encoded as
# JPEG (not rejected) before entering the pipeline. Reuses MAX_IMAGE_DIM --
# the matching pipeline downsamples to that anyway, so capping the *stored*
# original to the same size loses no detail the app would ever have used.
COMPRESS_ABOVE_BYTES = 15 * 1024 * 1024 # 15 MB
COMPRESS_JPEG_QUALITY_START = 92
COMPRESS_JPEG_QUALITY_MIN = 60
COMPRESS_JPEG_QUALITY_STEP = 8
LOG_DIR = os.path.join(BASE_DIR, "logs")
LOG_FILE = os.path.join(LOG_DIR, "app.log")
HOST = "0.0.0.0"
PORT = 5053
# ==========================================================
# Shape matching (pipeline/shape_match.py) -- purely informational, its own
# section, does NOT feed into any score.
# ==========================================================
# Both silhouettes are cropped to their own bounding box and resized/centered
# into this square canvas before comparison -- makes them directly
# comparable (and visually comparable) regardless of the original photo's
# scale, crop, or resolution.
SHAPE_CANONICAL_SIZE = 256
# cv2.matchShapes (CONTOURS_MATCH_I1, built on Hu moments) returns an
# unbounded distance -- 0 for identical contours, empirically usually well
# under 1.0 for genuinely similar silhouettes and several times that for
# clearly different ones. This is the divisor used to turn that distance
# into a 0-100% similarity; tune if it reads as over/under-sensitive on
# your actual photos.
SHAPE_HU_DISTANCE_SCALE = 1.5
# ==========================================================
# Texture matching (pipeline/texture_match.py) -- Local Binary Patterns
# (fine local micro-pattern) + GLCM/Haralick features (coarser statistical
# texture: contrast, homogeneity, energy, correlation). Purely
# informational, its own section, does NOT feed into any score.
# ==========================================================
TEXTURE_LBP_RADIUS = 2
TEXTURE_LBP_POINTS = 8 * TEXTURE_LBP_RADIUS
TEXTURE_GLCM_LEVELS = 32
TEXTURE_GLCM_DISTANCES = (1, 2)
TEXTURE_GLCM_PROPS = ("contrast", "homogeneity", "energy", "correlation")
# ==========================================================
# Flower-instance counting (pipeline/flower_count.py) -- opt-in, run only
# when the user explicitly clicks "Count flowers", never as part of the
# normal match pipeline. Uses SAM3 (see the SAM3 section further below),
# prompted with the concept "flower", to get per-instance masks directly --
# then color-clusters the survivors as a rough proxy for distinct flower
# "kinds" (no trained flower species classifier is available). Purely a
# separate, on-demand diagnostic -- never feeds into any score.
# ==========================================================
# Upper bound on how many distinct "kinds" (color clusters) to look for among
# the counted instances -- capped since color is only a rough species proxy,
# not a real classifier, and too many clusters just fragments noise.
SAM_MAX_KIND_CLUSTERS = 6
# ==========================================================
# YOLO-World (pipeline/yolo_world.py) -- open-vocabulary detection, run
# alongside SAM3 for the same opt-in flower-count feature as an independent
# second opinion. Its "flower" boxes are shown to the user as a second,
# separate count -- it tends to draw one box per contiguous flower region
# rather than per bloom, so it's a coarser, corroborating signal, not a
# replacement for SAM3's per-instance count.
# ==========================================================
YOLO_WORLD_CHECKPOINT = "/media/suman/Backup_of_extra_/Sasi/SAM/yolov8s-worldv2.pt"
YOLO_WORLD_CLASSES = ["flower", "vase", "ribbon", "bow"]
YOLO_WORLD_CONF = 0.08
YOLO_WORLD_IOU = 0.4
# ==========================================================
# Vase-identity comparison (pipeline/vase_compare.py) -- run alongside the
# same opt-in flower-count feature: crops the vase out of both the upload
# and the matched template (via SAM3's "vase" concept mask, already fetched
# in the same batched SAM3 call as the flower count above) and compares them
# with two complementary embedding models:
# - DINOv2: self-supervised patch-level features, good at fine-grained
# shape/texture/material detail.
# - CLIP: contrastive image embedding, a coarser/more holistic second
# opinion.
# Purely informational -- never feeds into matching/scoring.
# ==========================================================
DINO_MODEL_NAME = "facebook/dinov2-base"
CLIP_MODEL_NAME = "openai/clip-vit-base-patch32"
# Padding added around the raw YOLO box before cropping, as a fraction of
# the box's own width/height -- avoids cutting off the vase's rim/base right
# at the detector's (imprecise) box edge.
VASE_CROP_PAD_FRAC = 0.06
# DINO is weighted higher since fine detail (the "every minute detail" ask)
# is specifically its strength; CLIP is the corroborating, coarser signal.
VASE_DINO_WEIGHT = 0.6
VASE_CLIP_WEIGHT = 0.4
# Combined-similarity (0-100%) cutoffs for the same/uncertain/different
# verdict. Untuned/no ground-truth calibration set exists yet for vase
# identity specifically -- adjust if these read as over/under-confident on
# real photos.
VASE_SAME_THRESHOLD = 75
VASE_UNCERTAIN_THRESHOLD = 60
# ==========================================================
# SAM3 (facebook/sam3, via transformers) -- concept-prompted segmentation:
# instead of SAM1's "segment everything, then guess what's a flower from
# size/position heuristics", SAM3 is directly prompted with a plain-English
# concept ("flower", "vase") and returns instance masks for exactly that
# concept. Empirically this alone (no exclude-box filtering needed) never
# segments the vase/ribbon when prompted for "flower", and gives a tight
# per-instance mask for "vase" that's more precise than YOLO-World's box.
#
# transformers>=5.5.0 (needed for Sam3Model/Sam3Processor) requires
# Python>=3.10, incompatible with this app's own env (torch17_new, Python
# 3.8) -- so SAM3 runs in a SEPARATE conda env (sam2_env, Python 3.10,
# already had transformers 5.5.0 + bitsandbytes installed) via a one-shot
# subprocess per request (see sam3_worker.py / pipeline/sam3_client.py),
# not an in-process import. Loaded 4-bit (NF4) there: ~700MB resident,
# ~1.9GB peak during inference -- comfortably fits this 8GB card even
# without unloading anything else first.
# ==========================================================
SAM3_PYTHON_BIN = "/media/suman/Backup_of_extra_/miniconda3/envs/sam2_env/bin/python3"
SAM3_WORKER_SCRIPT = os.path.join(BASE_DIR, "sam3_worker.py")
SAM3_TIMEOUT_SECONDS = 180
SAM3_FLOWER_PROMPT = "flower"
SAM3_FLOWER_THRESHOLD = 0.5
SAM3_VASE_PROMPT = "vase"
SAM3_VASE_THRESHOLD = 0.3
# Read once here (not sourced by the shell that starts app.py) so the
# subprocess can be handed HF_TOKEN explicitly without relying on it being
# globally exported -- avoids adding a python-dotenv dependency for what's
# a single KEY=VALUE line.
def _read_dotenv_value(path, key):
try:
with open(path) as f:
for line in f:
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except FileNotFoundError:
pass
return None
HF_TOKEN = os.environ.get("HF_TOKEN") or _read_dotenv_value(
os.path.join(BASE_DIR, ".env"), "HF_TOKEN"
)