Spaces:
Sleeping
Sleeping
Delete src/utils.py
Browse files- src/utils.py +0 -178
src/utils.py
DELETED
|
@@ -1,178 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
src/utils.py — Pure helper functions shared across the entire codebase.
|
| 3 |
-
|
| 4 |
-
All stateless — no I/O, no model calls, no side effects.
|
| 5 |
-
Import freely from any module.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import hashlib
|
| 9 |
-
import re
|
| 10 |
-
from typing import Union
|
| 11 |
-
from urllib.parse import urlparse
|
| 12 |
-
|
| 13 |
-
import inflect
|
| 14 |
-
import numpy as np
|
| 15 |
-
from fastapi import Request
|
| 16 |
-
|
| 17 |
-
from .config import FACE_THRESHOLD_LOW
|
| 18 |
-
|
| 19 |
-
_inflect = inflect.engine()
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
# ════════════════════════════════════════════════════════════════════
|
| 23 |
-
# VECTOR HELPERS
|
| 24 |
-
# ════════════════════════════════════════════════════════════════════
|
| 25 |
-
|
| 26 |
-
def to_list(v) -> list:
|
| 27 |
-
"""
|
| 28 |
-
Convert a numpy array (or any object with .tolist()) to a plain Python list.
|
| 29 |
-
Pinecone's SDK requires plain lists for vector values, not numpy arrays.
|
| 30 |
-
"""
|
| 31 |
-
return v.tolist() if hasattr(v, "tolist") else v
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
# ════════════════════════════════════════════════════════════════════
|
| 35 |
-
# SCORE NORMALIZATION
|
| 36 |
-
# ════════════════════════════════════════════════════════════════════
|
| 37 |
-
|
| 38 |
-
def face_ui_score(raw: float, n_faces: int = 1) -> float:
|
| 39 |
-
"""
|
| 40 |
-
Map a raw Pinecone cosine score to a human-readable UI confidence (0.75–0.99).
|
| 41 |
-
|
| 42 |
-
Why remap? A raw cosine score of 0.36 (above threshold, genuine match) reads
|
| 43 |
-
as "36% confident" to a user, which is misleading. Remapping the valid match
|
| 44 |
-
range [FACE_THRESHOLD_LOW, 1.0] onto [0.75, 0.99] communicates that anything
|
| 45 |
-
shown IS a match; the percentage communicates relative quality within matches.
|
| 46 |
-
|
| 47 |
-
Multi-face boost: +5 % per additional matched face, capped at 0.99.
|
| 48 |
-
Rationale — an image containing 3 of 3 searched faces should rank above an
|
| 49 |
-
image containing only 1 of 3.
|
| 50 |
-
"""
|
| 51 |
-
lo = FACE_THRESHOLD_LOW
|
| 52 |
-
base = 0.75 + ((raw - lo) / (1.0 - lo)) * 0.24
|
| 53 |
-
if n_faces > 1:
|
| 54 |
-
base *= 1.0 + 0.05 * (n_faces - 1)
|
| 55 |
-
return round(min(0.99, base), 4)
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
# ════════════════════════════════════════════════════════════════════
|
| 59 |
-
# FILE / PATH HELPERS
|
| 60 |
-
# ════════════════════════════════════════════════════════════════════
|
| 61 |
-
|
| 62 |
-
def img_hash(image_path: str) -> str:
|
| 63 |
-
"""
|
| 64 |
-
MD5 of the first 64 KB of a file — fast collision-resistant cache key.
|
| 65 |
-
Reading the full file for every cache lookup on large images would be slow;
|
| 66 |
-
the first 64 KB is almost always unique enough to distinguish images.
|
| 67 |
-
"""
|
| 68 |
-
h = hashlib.md5()
|
| 69 |
-
with open(image_path, "rb") as f:
|
| 70 |
-
h.update(f.read(65536))
|
| 71 |
-
return h.hexdigest()
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def sanitize_filename(filename: str) -> str:
|
| 75 |
-
"""
|
| 76 |
-
Make a filename safe for use in temp file paths.
|
| 77 |
-
Replaces spaces with underscores; strips characters outside [a-zA-Z0-9._-].
|
| 78 |
-
"""
|
| 79 |
-
return re.sub(r"[^\w.\-]", "", re.sub(r"\s+", "_", filename))
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
def standardize_category_name(name: str) -> str:
|
| 83 |
-
"""
|
| 84 |
-
Normalize a user-supplied folder/category name for consistent storage:
|
| 85 |
-
- Lowercase and underscore-separated
|
| 86 |
-
- No special characters
|
| 87 |
-
- Singularized (dogs → dog, shoes → shoe)
|
| 88 |
-
|
| 89 |
-
Singularization ensures "Dog", "Dogs", "dogs" all map to the same folder.
|
| 90 |
-
inflect.singular_noun() returns False for already-singular words,
|
| 91 |
-
so `or clean` keeps the original in that case.
|
| 92 |
-
"""
|
| 93 |
-
clean = re.sub(r"\s+", "_", name.strip().lower())
|
| 94 |
-
clean = re.sub(r"[^\w]", "", clean)
|
| 95 |
-
return _inflect.singular_noun(clean) or clean
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
# ════════════════════════════════════════════════════════════════════
|
| 99 |
-
# CLOUDINARY HELPERS
|
| 100 |
-
# ════════════════════════════════════════════════════════════════════
|
| 101 |
-
|
| 102 |
-
def get_cloudinary_creds(env_url: str) -> dict:
|
| 103 |
-
"""
|
| 104 |
-
Parse a Cloudinary Environment URL (cloudinary://key:secret@cloud_name)
|
| 105 |
-
into separate credential components for SDK calls.
|
| 106 |
-
Returns an empty dict for blank/invalid input.
|
| 107 |
-
"""
|
| 108 |
-
if not env_url:
|
| 109 |
-
return {}
|
| 110 |
-
parsed = urlparse(env_url)
|
| 111 |
-
return {
|
| 112 |
-
"api_key": parsed.username or "",
|
| 113 |
-
"api_secret": parsed.password or "",
|
| 114 |
-
"cloud_name": parsed.hostname or "",
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def cld_thumb_url(secure_url: str) -> str:
|
| 119 |
-
"""
|
| 120 |
-
Inject a Cloudinary on-the-fly image transformation into a full-resolution
|
| 121 |
-
URL, producing a 400×400 fill thumbnail for grid display.
|
| 122 |
-
|
| 123 |
-
Cloudinary transformations are inserted between /upload/ and the version/path:
|
| 124 |
-
.../image/upload/v123/folder/img.jpg
|
| 125 |
-
→ .../image/upload/w_400,h_400,c_fill,q_auto,f_auto/v123/folder/img.jpg
|
| 126 |
-
|
| 127 |
-
Returns the original URL unchanged if transformation cannot be applied
|
| 128 |
-
(e.g. non-standard URL format or already-transformed URL).
|
| 129 |
-
"""
|
| 130 |
-
marker = "/image/upload/"
|
| 131 |
-
idx = secure_url.find(marker)
|
| 132 |
-
if idx == -1:
|
| 133 |
-
return secure_url
|
| 134 |
-
base = secure_url[: idx + len(marker)]
|
| 135 |
-
rest = secure_url[idx + len(marker):]
|
| 136 |
-
if any(rest.startswith(p) for p in ("w_", "h_", "c_", "q_")):
|
| 137 |
-
return secure_url # already has a transformation block
|
| 138 |
-
return f"{base}w_400,h_400,c_fill,q_auto,f_auto/{rest}"
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
def url_to_public_id(image_url: str) -> str:
|
| 142 |
-
"""
|
| 143 |
-
Extract the Cloudinary public_id from a secure_url.
|
| 144 |
-
Strips the version segment (vXXX) if present.
|
| 145 |
-
Returns empty string on failure.
|
| 146 |
-
|
| 147 |
-
Example:
|
| 148 |
-
https://res.cloudinary.com/demo/image/upload/v1234/folder/img.jpg
|
| 149 |
-
→ folder/img
|
| 150 |
-
"""
|
| 151 |
-
try:
|
| 152 |
-
path = urlparse(image_url).path
|
| 153 |
-
parts = path.split("/")
|
| 154 |
-
upload_idx = parts.index("upload")
|
| 155 |
-
after = parts[upload_idx + 1:]
|
| 156 |
-
if after and after[0].startswith("v") and after[0][1:].isdigit():
|
| 157 |
-
after = after[1:]
|
| 158 |
-
return "/".join(after).rsplit(".", 1)[0]
|
| 159 |
-
except Exception:
|
| 160 |
-
return ""
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
# ════════════════════════════════════════════════════════════════════
|
| 164 |
-
# REQUEST HELPERS
|
| 165 |
-
# ════════════════════════════════════════════════════════════════════
|
| 166 |
-
|
| 167 |
-
def get_ip(request: Request) -> str:
|
| 168 |
-
"""
|
| 169 |
-
Extract the real client IP from a FastAPI request.
|
| 170 |
-
Respects the X-Forwarded-For header set by reverse proxies / CDNs.
|
| 171 |
-
"""
|
| 172 |
-
xff = request.headers.get("X-Forwarded-For", "")
|
| 173 |
-
return xff.split(",")[0].strip() if xff else getattr(request.client, "host", "unknown")
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def is_default_key(key: str, default: str) -> bool:
|
| 177 |
-
"""Return True if `key` matches the shared demo key (guest mode)."""
|
| 178 |
-
return bool(default) and key.strip() == default.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|