281 lines
12 KiB
Python
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"
|
|
)
|