Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- script/storage_recompress_oneshot.py +298 -0
- web_demo/app.py +64 -2
- web_demo/supabase_client.py +81 -0
- web_demo/templates/admin.html +40 -1
script/storage_recompress_oneshot.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""One-shot Supabase storage recompress (v7 "hot cache, low-res").
|
| 3 |
+
|
| 4 |
+
Context: the `ring-measurements` bucket crossed the 1 GB free-tier quota.
|
| 5 |
+
The measurement pipeline never sees past ~1024px (SAM downscales internally),
|
| 6 |
+
so storing full-res user uploads buys nothing for re-measurement. This script
|
| 7 |
+
shrinks the existing objects in place:
|
| 8 |
+
|
| 9 |
+
- photos/ -> 2048px long-edge JPEG q80 (validated measurement-safe)
|
| 10 |
+
- results/ -> 1024px long-edge JPEG q80 (overlay, never re-measured)
|
| 11 |
+
|
| 12 |
+
Safety model (free tier has NO rollback):
|
| 13 |
+
1. `backup` downloads every photos/ object to a local dir FIRST and leaves
|
| 14 |
+
it pristine. That is the ultimate fallback for photos.
|
| 15 |
+
2. Recompress encodes in memory, RE-DECODES to verify the bytes are a valid
|
| 16 |
+
image, and only then overwrites. Never writes garbage over a good object.
|
| 17 |
+
3. Photos are recompressed only if a verified local backup exists.
|
| 18 |
+
4. skip-if-not-smaller: never write a candidate that isn't meaningfully
|
| 19 |
+
smaller than what's already there (avoids re-encode bloat).
|
| 20 |
+
|
| 21 |
+
Subcommands:
|
| 22 |
+
inventory read-only: count + bytes for photos/ + results/
|
| 23 |
+
backup download all photos/ -> BACKUP_DIR (idempotent)
|
| 24 |
+
test-mechanism upload+overwrite+verify+delete a throwaway object
|
| 25 |
+
recompress --prefix photos --max-side 2048 [--apply]
|
| 26 |
+
recompress --prefix results --max-side 1024 [--apply]
|
| 27 |
+
|
| 28 |
+
Without --apply, recompress is a DRY RUN (projects savings, writes nothing).
|
| 29 |
+
|
| 30 |
+
Requires SUPABASE_URL / SUPABASE_SERVICE_KEY in the environment.
|
| 31 |
+
"""
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import argparse
|
| 35 |
+
import sys
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
from typing import Dict, List, Optional, Tuple
|
| 38 |
+
|
| 39 |
+
import cv2
|
| 40 |
+
import numpy as np
|
| 41 |
+
|
| 42 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 43 |
+
sys.path.insert(0, str(ROOT))
|
| 44 |
+
|
| 45 |
+
from web_demo.supabase_client import _get_client, BUCKET # noqa: E402
|
| 46 |
+
|
| 47 |
+
BACKUP_DIR = ROOT / "input" / "supabase_archive"
|
| 48 |
+
PAGE_LIMIT = 100
|
| 49 |
+
JPEG_QUALITY = 80
|
| 50 |
+
MIN_GAIN = 0.95 # only write if candidate <= 95% of original size
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --------------------------------------------------------------------------- #
|
| 54 |
+
# storage helpers
|
| 55 |
+
# --------------------------------------------------------------------------- #
|
| 56 |
+
def _storage():
|
| 57 |
+
client = _get_client()
|
| 58 |
+
if client is None:
|
| 59 |
+
print("ERROR: Supabase client not initialized "
|
| 60 |
+
"(check SUPABASE_URL / SUPABASE_SERVICE_KEY).")
|
| 61 |
+
sys.exit(1)
|
| 62 |
+
return client.storage.from_(BUCKET)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _obj_size(obj: dict) -> Optional[int]:
|
| 66 |
+
md = obj.get("metadata") or {}
|
| 67 |
+
return md.get("size") if md.get("size") is not None else obj.get("size")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def list_all(storage, folder: str) -> List[dict]:
|
| 71 |
+
out: List[dict] = []
|
| 72 |
+
offset = 0
|
| 73 |
+
while True:
|
| 74 |
+
resp = storage.list(folder, {"limit": PAGE_LIMIT, "offset": offset})
|
| 75 |
+
if not resp:
|
| 76 |
+
break
|
| 77 |
+
# storage may return a placeholder row for the folder itself; drop dirs
|
| 78 |
+
out.extend([o for o in resp if o.get("name") and _obj_size(o) is not None])
|
| 79 |
+
if len(resp) < PAGE_LIMIT:
|
| 80 |
+
break
|
| 81 |
+
offset += PAGE_LIMIT
|
| 82 |
+
return out
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _human(n: float) -> str:
|
| 86 |
+
for unit in ("B", "KB", "MB", "GB"):
|
| 87 |
+
if abs(n) < 1024:
|
| 88 |
+
return f"{n:.1f} {unit}"
|
| 89 |
+
n /= 1024
|
| 90 |
+
return f"{n:.1f} TB"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _overwrite(storage, path: str, data: bytes, content_type: str) -> None:
|
| 94 |
+
"""Overwrite an existing object in place. Tries update(), falls back to
|
| 95 |
+
upload() with x-upsert."""
|
| 96 |
+
opts = {"content-type": content_type, "cache-control": "3600"}
|
| 97 |
+
try:
|
| 98 |
+
storage.update(path, data, file_options=opts)
|
| 99 |
+
except Exception:
|
| 100 |
+
storage.upload(path, data,
|
| 101 |
+
file_options={**opts, "upsert": "true"})
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# --------------------------------------------------------------------------- #
|
| 105 |
+
# encode
|
| 106 |
+
# --------------------------------------------------------------------------- #
|
| 107 |
+
def recompress_bytes(raw: bytes, max_side: int) -> Optional[bytes]:
|
| 108 |
+
"""Decode -> (optionally) downscale to max_side -> JPEG q80 -> re-decode
|
| 109 |
+
verify. Returns new bytes, or None if decode/verify failed."""
|
| 110 |
+
arr = np.frombuffer(raw, dtype=np.uint8)
|
| 111 |
+
img = cv2.imdecode(arr, cv2.IMREAD_COLOR) # drops alpha; BGR
|
| 112 |
+
if img is None:
|
| 113 |
+
return None
|
| 114 |
+
h, w = img.shape[:2]
|
| 115 |
+
long_side = max(h, w)
|
| 116 |
+
if long_side > max_side:
|
| 117 |
+
scale = max_side / long_side
|
| 118 |
+
img = cv2.resize(img, (int(round(w * scale)), int(round(h * scale))),
|
| 119 |
+
interpolation=cv2.INTER_AREA)
|
| 120 |
+
ok, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
|
| 121 |
+
if not ok:
|
| 122 |
+
return None
|
| 123 |
+
out = enc.tobytes()
|
| 124 |
+
# verify: the new bytes must decode back to a valid image
|
| 125 |
+
if cv2.imdecode(np.frombuffer(out, np.uint8), cv2.IMREAD_COLOR) is None:
|
| 126 |
+
return None
|
| 127 |
+
return out
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# --------------------------------------------------------------------------- #
|
| 131 |
+
# subcommands
|
| 132 |
+
# --------------------------------------------------------------------------- #
|
| 133 |
+
def cmd_inventory(_args) -> int:
|
| 134 |
+
storage = _storage()
|
| 135 |
+
grand = 0
|
| 136 |
+
for folder in ("photos", "results", "feedback"):
|
| 137 |
+
objs = list_all(storage, folder)
|
| 138 |
+
total = sum(_obj_size(o) or 0 for o in objs)
|
| 139 |
+
grand += total
|
| 140 |
+
print(f"{folder}/: {len(objs)} objects, {_human(total)}")
|
| 141 |
+
print(f"TOTAL (3 prefixes): {_human(grand)}")
|
| 142 |
+
return 0
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def cmd_backup(_args) -> int:
|
| 146 |
+
storage = _storage()
|
| 147 |
+
dest = BACKUP_DIR / "photos"
|
| 148 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 149 |
+
objs = list_all(storage, "photos")
|
| 150 |
+
print(f"photos/: {len(objs)} objects to back up -> {dest}")
|
| 151 |
+
done = skipped = failed = 0
|
| 152 |
+
for i, o in enumerate(objs, 1):
|
| 153 |
+
name = o["name"]
|
| 154 |
+
size = _obj_size(o) or 0
|
| 155 |
+
local = dest / name
|
| 156 |
+
if local.exists() and local.stat().st_size == size:
|
| 157 |
+
skipped += 1
|
| 158 |
+
continue
|
| 159 |
+
try:
|
| 160 |
+
data = storage.download(f"photos/{name}")
|
| 161 |
+
local.write_bytes(data)
|
| 162 |
+
if local.stat().st_size != size:
|
| 163 |
+
print(f" [{i}] SIZE MISMATCH {name}: "
|
| 164 |
+
f"got {len(data)} expected {size}")
|
| 165 |
+
failed += 1
|
| 166 |
+
continue
|
| 167 |
+
done += 1
|
| 168 |
+
if done % 25 == 0:
|
| 169 |
+
print(f" ...{done} downloaded")
|
| 170 |
+
except Exception as e:
|
| 171 |
+
print(f" [{i}] FAILED {name}: {e}")
|
| 172 |
+
failed += 1
|
| 173 |
+
print(f"backup done: {done} new, {skipped} already present, {failed} failed")
|
| 174 |
+
return 1 if failed else 0
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def cmd_test_mechanism(_args) -> int:
|
| 178 |
+
storage = _storage()
|
| 179 |
+
path = "photos/__recompress_selftest__.jpg"
|
| 180 |
+
a = cv2.imencode(".jpg", np.full((64, 64, 3), 30, np.uint8))[1].tobytes()
|
| 181 |
+
b = cv2.imencode(".jpg", np.full((64, 64, 3), 200, np.uint8))[1].tobytes()
|
| 182 |
+
try:
|
| 183 |
+
try:
|
| 184 |
+
storage.remove([path])
|
| 185 |
+
except Exception:
|
| 186 |
+
pass
|
| 187 |
+
storage.upload(path, a, file_options={"content-type": "image/jpeg"})
|
| 188 |
+
_overwrite(storage, path, b, "image/jpeg")
|
| 189 |
+
back = storage.download(path)
|
| 190 |
+
ok = (back == b)
|
| 191 |
+
print(f"overwrite-in-place {'OK' if ok else 'FAILED'} "
|
| 192 |
+
f"(round-tripped {len(back)} bytes, expected {len(b)})")
|
| 193 |
+
return 0 if ok else 1
|
| 194 |
+
finally:
|
| 195 |
+
try:
|
| 196 |
+
storage.remove([path])
|
| 197 |
+
except Exception:
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def cmd_recompress(args) -> int:
|
| 202 |
+
storage = _storage()
|
| 203 |
+
prefix = args.prefix
|
| 204 |
+
max_side = args.max_side
|
| 205 |
+
apply = args.apply
|
| 206 |
+
objs = list_all(storage, prefix)
|
| 207 |
+
print(f"{prefix}/: {len(objs)} objects, target {max_side}px/q{JPEG_QUALITY}, "
|
| 208 |
+
f"{'APPLY' if apply else 'DRY RUN'}")
|
| 209 |
+
|
| 210 |
+
backup_photos = BACKUP_DIR / "photos"
|
| 211 |
+
before = after = 0
|
| 212 |
+
written = skipped_small = skipped_nobackup = failed = 0
|
| 213 |
+
|
| 214 |
+
for i, o in enumerate(objs, 1):
|
| 215 |
+
name = o["name"]
|
| 216 |
+
if name.startswith("__recompress_selftest__"):
|
| 217 |
+
continue
|
| 218 |
+
size = _obj_size(o) or 0
|
| 219 |
+
before += size
|
| 220 |
+
path = f"{prefix}/{name}"
|
| 221 |
+
|
| 222 |
+
# photos: require a verified local backup before destructive write
|
| 223 |
+
if prefix == "photos":
|
| 224 |
+
b = backup_photos / name
|
| 225 |
+
if not (b.exists() and b.stat().st_size == size):
|
| 226 |
+
skipped_nobackup += 1
|
| 227 |
+
after += size
|
| 228 |
+
continue
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
raw = storage.download(path)
|
| 232 |
+
except Exception as e:
|
| 233 |
+
print(f" [{i}] download FAILED {name}: {e}")
|
| 234 |
+
failed += 1
|
| 235 |
+
after += size
|
| 236 |
+
continue
|
| 237 |
+
|
| 238 |
+
new = recompress_bytes(raw, max_side)
|
| 239 |
+
if new is None:
|
| 240 |
+
print(f" [{i}] decode/verify FAILED {name} β left untouched")
|
| 241 |
+
failed += 1
|
| 242 |
+
after += size
|
| 243 |
+
continue
|
| 244 |
+
|
| 245 |
+
if len(new) >= size * MIN_GAIN:
|
| 246 |
+
skipped_small += 1
|
| 247 |
+
after += size
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
after += len(new)
|
| 251 |
+
if apply:
|
| 252 |
+
try:
|
| 253 |
+
_overwrite(storage, path, new, "image/jpeg")
|
| 254 |
+
written += 1
|
| 255 |
+
if written % 25 == 0:
|
| 256 |
+
print(f" ...{written} rewritten")
|
| 257 |
+
except Exception as e:
|
| 258 |
+
print(f" [{i}] upload FAILED {name}: {e}")
|
| 259 |
+
failed += 1
|
| 260 |
+
after += size - len(new) # revert projection
|
| 261 |
+
else:
|
| 262 |
+
written += 1 # would-write count in dry run
|
| 263 |
+
|
| 264 |
+
verb = "rewrote" if apply else "would rewrite"
|
| 265 |
+
print(f"\n{prefix}/ summary:")
|
| 266 |
+
print(f" {verb}: {written}")
|
| 267 |
+
print(f" skipped (not smaller): {skipped_small}")
|
| 268 |
+
if prefix == "photos":
|
| 269 |
+
print(f" skipped (no backup): {skipped_nobackup}")
|
| 270 |
+
print(f" failed: {failed}")
|
| 271 |
+
print(f" size: {_human(before)} -> {_human(after)} "
|
| 272 |
+
f"({(1 - after / before) * 100:.1f}% smaller)" if before else " size: 0")
|
| 273 |
+
return 1 if failed else 0
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def main() -> int:
|
| 277 |
+
p = argparse.ArgumentParser(description=__doc__,
|
| 278 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 279 |
+
sub = p.add_subparsers(dest="cmd", required=True)
|
| 280 |
+
sub.add_parser("inventory")
|
| 281 |
+
sub.add_parser("backup")
|
| 282 |
+
sub.add_parser("test-mechanism")
|
| 283 |
+
r = sub.add_parser("recompress")
|
| 284 |
+
r.add_argument("--prefix", required=True, choices=("photos", "results"))
|
| 285 |
+
r.add_argument("--max-side", type=int, required=True)
|
| 286 |
+
r.add_argument("--apply", action="store_true",
|
| 287 |
+
help="actually overwrite (default: dry run)")
|
| 288 |
+
args = p.parse_args()
|
| 289 |
+
return {
|
| 290 |
+
"inventory": cmd_inventory,
|
| 291 |
+
"backup": cmd_backup,
|
| 292 |
+
"test-mechanism": cmd_test_mechanism,
|
| 293 |
+
"recompress": cmd_recompress,
|
| 294 |
+
}[args.cmd](args)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
if __name__ == "__main__":
|
| 298 |
+
sys.exit(main())
|
web_demo/app.py
CHANGED
|
@@ -35,6 +35,7 @@ from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, D
|
|
| 35 |
from src.ai_recommendation import ai_explain_recommendation
|
| 36 |
from web_demo.supabase_client import (
|
| 37 |
upload_file,
|
|
|
|
| 38 |
save_measurement,
|
| 39 |
save_feedback,
|
| 40 |
persistence_enabled,
|
|
@@ -50,6 +51,7 @@ from web_demo.supabase_client import (
|
|
| 50 |
update_ground_truth,
|
| 51 |
delete_measurement,
|
| 52 |
delete_feedback,
|
|
|
|
| 53 |
)
|
| 54 |
from src.confidence_constants import (
|
| 55 |
CONFIDENCE_LEVEL_HIGH_THRESHOLD,
|
|
@@ -79,6 +81,50 @@ logger = logging.getLogger(__name__)
|
|
| 79 |
# request queues one task, and we don't need ordering.
|
| 80 |
_persist_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="supa-persist")
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
def _persist_measurement_async(
|
| 84 |
*,
|
|
@@ -98,9 +144,14 @@ def _persist_measurement_async(
|
|
| 98 |
photo_url = None
|
| 99 |
result_url = None
|
| 100 |
if upload_path and upload_path.exists():
|
| 101 |
-
photo_url =
|
|
|
|
| 102 |
if result_png_path.exists():
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
record_with_urls = dict(record)
|
| 105 |
record_with_urls["photo_url"] = photo_url
|
| 106 |
record_with_urls["result_url"] = result_url
|
|
@@ -972,6 +1023,17 @@ def api_admin_stats():
|
|
| 972 |
return jsonify(_compute_stats(rows, days=days))
|
| 973 |
|
| 974 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 975 |
_CSV_INJECTION_LEAD = ("=", "+", "-", "@", "\t", "\r")
|
| 976 |
|
| 977 |
|
|
|
|
| 35 |
from src.ai_recommendation import ai_explain_recommendation
|
| 36 |
from web_demo.supabase_client import (
|
| 37 |
upload_file,
|
| 38 |
+
upload_bytes,
|
| 39 |
save_measurement,
|
| 40 |
save_feedback,
|
| 41 |
persistence_enabled,
|
|
|
|
| 51 |
update_ground_truth,
|
| 52 |
delete_measurement,
|
| 53 |
delete_feedback,
|
| 54 |
+
storage_usage,
|
| 55 |
)
|
| 56 |
from src.confidence_constants import (
|
| 57 |
CONFIDENCE_LEVEL_HIGH_THRESHOLD,
|
|
|
|
| 81 |
# request queues one task, and we don't need ordering.
|
| 82 |
_persist_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="supa-persist")
|
| 83 |
|
| 84 |
+
# v7 storage diet: shrink what we store, not what we measure. Measurement
|
| 85 |
+
# runs on the full-res in-memory image FIRST; only the stored copies are
|
| 86 |
+
# downscaled. The pipeline never sees past ~1024px anyway (SAM downscales
|
| 87 |
+
# internally), so a 2048px stored photo reproduces the full-res measurement
|
| 88 |
+
# (validated: median 0.07 mm delta, zero new failures at 2048/q80). The
|
| 89 |
+
# result overlay is display-only and never re-measured, so it goes smaller.
|
| 90 |
+
PHOTO_MAX_SIDE = 2048
|
| 91 |
+
RESULT_MAX_SIDE = 1024
|
| 92 |
+
STORE_JPEG_QUALITY = 80
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _shrink_jpeg(image_path: Path, max_side: int) -> Optional[bytes]:
|
| 96 |
+
"""Read an image, downscale to max_side long-edge (never upscale), and
|
| 97 |
+
return JPEG q80 bytes. Returns None on any decode/encode failure so the
|
| 98 |
+
caller can fall back to uploading the original file."""
|
| 99 |
+
try:
|
| 100 |
+
img = cv2.imread(str(image_path))
|
| 101 |
+
if img is None:
|
| 102 |
+
return None
|
| 103 |
+
h, w = img.shape[:2]
|
| 104 |
+
long_side = max(h, w)
|
| 105 |
+
if long_side > max_side:
|
| 106 |
+
scale = max_side / long_side
|
| 107 |
+
img = cv2.resize(img, (int(round(w * scale)), int(round(h * scale))),
|
| 108 |
+
interpolation=cv2.INTER_AREA)
|
| 109 |
+
ok, enc = cv2.imencode(".jpg", img,
|
| 110 |
+
[cv2.IMWRITE_JPEG_QUALITY, STORE_JPEG_QUALITY])
|
| 111 |
+
if not ok:
|
| 112 |
+
return None
|
| 113 |
+
return enc.tobytes()
|
| 114 |
+
except Exception as exc: # noqa: BLE001
|
| 115 |
+
logger.warning("shrink failed for %s: %s", image_path, exc)
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _upload_shrunk(local_path: Path, storage_path: str, max_side: int) -> Optional[str]:
|
| 120 |
+
"""Upload a downscaled JPEG copy of local_path. Falls back to uploading
|
| 121 |
+
the original file if shrink fails, so a recompress hiccup never drops the
|
| 122 |
+
image entirely."""
|
| 123 |
+
data = _shrink_jpeg(local_path, max_side)
|
| 124 |
+
if data is not None:
|
| 125 |
+
return upload_bytes(data, storage_path, "image/jpeg")
|
| 126 |
+
return upload_file(str(local_path), storage_path)
|
| 127 |
+
|
| 128 |
|
| 129 |
def _persist_measurement_async(
|
| 130 |
*,
|
|
|
|
| 144 |
photo_url = None
|
| 145 |
result_url = None
|
| 146 |
if upload_path and upload_path.exists():
|
| 147 |
+
photo_url = _upload_shrunk(
|
| 148 |
+
upload_path, f"photos/{upload_name}", PHOTO_MAX_SIDE)
|
| 149 |
if result_png_path.exists():
|
| 150 |
+
# Keep the historical `_result.png` object name (DB result_url
|
| 151 |
+
# points at it) but store JPEG bytes β browsers honor the
|
| 152 |
+
# image/jpeg content-type over the .png extension.
|
| 153 |
+
result_url = _upload_shrunk(
|
| 154 |
+
result_png_path, f"results/{result_png_name}", RESULT_MAX_SIDE)
|
| 155 |
record_with_urls = dict(record)
|
| 156 |
record_with_urls["photo_url"] = photo_url
|
| 157 |
record_with_urls["result_url"] = result_url
|
|
|
|
| 1023 |
return jsonify(_compute_stats(rows, days=days))
|
| 1024 |
|
| 1025 |
|
| 1026 |
+
@app.route("/api/admin/storage")
|
| 1027 |
+
def api_admin_storage():
|
| 1028 |
+
if not _check_admin_token():
|
| 1029 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 1030 |
+
usage = storage_usage()
|
| 1031 |
+
if usage is None:
|
| 1032 |
+
return jsonify({"available": False})
|
| 1033 |
+
usage["available"] = True
|
| 1034 |
+
return jsonify(usage)
|
| 1035 |
+
|
| 1036 |
+
|
| 1037 |
_CSV_INJECTION_LEAD = ("=", "+", "-", "@", "\t", "\r")
|
| 1038 |
|
| 1039 |
|
web_demo/supabase_client.py
CHANGED
|
@@ -64,6 +64,29 @@ def persistence_enabled() -> bool:
|
|
| 64 |
BUCKET = "ring-measurements"
|
| 65 |
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
def upload_file(local_path: str, storage_path: str) -> Optional[str]:
|
| 68 |
"""Upload a file to Supabase Storage. Returns public URL or None."""
|
| 69 |
client = _get_client()
|
|
@@ -204,6 +227,64 @@ def count_feedback() -> Optional[int]:
|
|
| 204 |
return None
|
| 205 |
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
|
| 208 |
"""Fetch measurements for admin page, newest first."""
|
| 209 |
client = _get_client()
|
|
|
|
| 64 |
BUCKET = "ring-measurements"
|
| 65 |
|
| 66 |
|
| 67 |
+
def upload_bytes(data: bytes, storage_path: str, content_type: str) -> Optional[str]:
|
| 68 |
+
"""Upload raw bytes to Supabase Storage. Returns public URL or None.
|
| 69 |
+
|
| 70 |
+
Used by the web demo to upload an in-memory downscaled JPEG (v7 storage
|
| 71 |
+
diet) without first writing it to a temp file. content_type is explicit
|
| 72 |
+
because the stored object name may keep a legacy extension (e.g. a
|
| 73 |
+
`_result.png` object that now carries JPEG bytes)."""
|
| 74 |
+
client = _get_client()
|
| 75 |
+
if client is None:
|
| 76 |
+
return None
|
| 77 |
+
try:
|
| 78 |
+
client.storage.from_(BUCKET).upload(
|
| 79 |
+
storage_path,
|
| 80 |
+
data,
|
| 81 |
+
file_options={"content-type": content_type},
|
| 82 |
+
)
|
| 83 |
+
logger.info("Storage upload %s: %s bytes", storage_path, len(data))
|
| 84 |
+
return client.storage.from_(BUCKET).get_public_url(storage_path)
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error("Failed to upload %s (%s bytes): %s", storage_path, len(data), e)
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
def upload_file(local_path: str, storage_path: str) -> Optional[str]:
|
| 91 |
"""Upload a file to Supabase Storage. Returns public URL or None."""
|
| 92 |
client = _get_client()
|
|
|
|
| 227 |
return None
|
| 228 |
|
| 229 |
|
| 230 |
+
def _list_prefix_objects(storage, prefix: str) -> List[Dict[str, Any]]:
|
| 231 |
+
"""Paginate storage.list(prefix) and return every real object (skips the
|
| 232 |
+
folder placeholder row that has no size metadata)."""
|
| 233 |
+
out: List[Dict[str, Any]] = []
|
| 234 |
+
offset = 0
|
| 235 |
+
page = 100
|
| 236 |
+
while True:
|
| 237 |
+
resp = storage.list(prefix, {"limit": page, "offset": offset})
|
| 238 |
+
if not resp:
|
| 239 |
+
break
|
| 240 |
+
for o in resp:
|
| 241 |
+
md = o.get("metadata") or {}
|
| 242 |
+
if md.get("size") is not None:
|
| 243 |
+
out.append(o)
|
| 244 |
+
if len(resp) < page:
|
| 245 |
+
break
|
| 246 |
+
offset += page
|
| 247 |
+
return out
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# Free-tier storage quota; used only to compute a headroom % for the admin
|
| 251 |
+
# panel. Supabase enforces 1 GB; we surface headroom against a slightly
|
| 252 |
+
# conservative 1.0 GB so the number nudges before the hard wall.
|
| 253 |
+
STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def storage_usage() -> Optional[Dict[str, Any]]:
|
| 257 |
+
"""Summarize bucket usage for the admin dashboard: per-prefix object count
|
| 258 |
+
+ bytes, grand total, and headroom vs the free-tier quota. Returns None
|
| 259 |
+
when persistence is disabled so the UI can show 'unknown' rather than 0.
|
| 260 |
+
|
| 261 |
+
Walks the storage list API (the bucket has no size column); a few hundred
|
| 262 |
+
objects across three prefixes is one quick paginated sweep per prefix.
|
| 263 |
+
"""
|
| 264 |
+
client = _get_client()
|
| 265 |
+
if client is None:
|
| 266 |
+
return None
|
| 267 |
+
try:
|
| 268 |
+
storage = client.storage.from_(BUCKET)
|
| 269 |
+
prefixes = {}
|
| 270 |
+
total = 0
|
| 271 |
+
for prefix in ("photos", "results", "feedback"):
|
| 272 |
+
objs = _list_prefix_objects(storage, prefix)
|
| 273 |
+
size = sum((o.get("metadata") or {}).get("size", 0) for o in objs)
|
| 274 |
+
prefixes[prefix] = {"objects": len(objs), "bytes": size}
|
| 275 |
+
total += size
|
| 276 |
+
return {
|
| 277 |
+
"prefixes": prefixes,
|
| 278 |
+
"total_bytes": total,
|
| 279 |
+
"quota_bytes": STORAGE_QUOTA_BYTES,
|
| 280 |
+
"used_fraction": (total / STORAGE_QUOTA_BYTES
|
| 281 |
+
if STORAGE_QUOTA_BYTES else None),
|
| 282 |
+
}
|
| 283 |
+
except Exception as e:
|
| 284 |
+
logger.error("Failed to compute storage usage: %s", e)
|
| 285 |
+
return None
|
| 286 |
+
|
| 287 |
+
|
| 288 |
def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
|
| 289 |
"""Fetch measurements for admin page, newest first."""
|
| 290 |
client = _get_client()
|
web_demo/templates/admin.html
CHANGED
|
@@ -334,6 +334,7 @@
|
|
| 334 |
adminContent.style.display = "block";
|
| 335 |
document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
|
| 336 |
loadStats();
|
|
|
|
| 337 |
loadData();
|
| 338 |
loadFeedback();
|
| 339 |
return true;
|
|
@@ -610,6 +611,31 @@
|
|
| 610 |
|
| 611 |
const fmtPct = (v) => v == null ? "-" : (v * 100).toFixed(1) + "%";
|
| 612 |
const fmtInt = (v) => v == null ? "-" : v.toLocaleString();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
const fmtDay = (iso) => {
|
| 614 |
if (!iso) return "β";
|
| 615 |
// YYYY-MM-DD β MM-DD for compact axis labels
|
|
@@ -626,6 +652,7 @@
|
|
| 626 |
};
|
| 627 |
|
| 628 |
const renderStatCards = (s) => {
|
|
|
|
| 629 |
const t = s.totals;
|
| 630 |
const last7Delta = deltaArrow(t.last_7_days, t.prev_7_days);
|
| 631 |
const cards = [
|
|
@@ -639,6 +666,7 @@
|
|
| 639 |
value: t.rating_count ? `${t.avg_rating.toFixed(2)} β
` : "β",
|
| 640 |
sub: `${fmtInt(t.rating_count)} rated Β· ${fmtInt(t.comment_count)} comments`,
|
| 641 |
},
|
|
|
|
| 642 |
];
|
| 643 |
statGrid.innerHTML = cards.map((c) => `
|
| 644 |
<div class="stat-card">
|
|
@@ -786,7 +814,18 @@
|
|
| 786 |
}
|
| 787 |
};
|
| 788 |
|
| 789 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
windowSelect.addEventListener("change", loadStats);
|
| 791 |
</script>
|
| 792 |
</body>
|
|
|
|
| 334 |
adminContent.style.display = "block";
|
| 335 |
document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
|
| 336 |
loadStats();
|
| 337 |
+
loadStorage();
|
| 338 |
loadData();
|
| 339 |
loadFeedback();
|
| 340 |
return true;
|
|
|
|
| 611 |
|
| 612 |
const fmtPct = (v) => v == null ? "-" : (v * 100).toFixed(1) + "%";
|
| 613 |
const fmtInt = (v) => v == null ? "-" : v.toLocaleString();
|
| 614 |
+
const fmtBytes = (n) => {
|
| 615 |
+
if (n == null) return "β";
|
| 616 |
+
const u = ["B", "KB", "MB", "GB", "TB"];
|
| 617 |
+
let i = 0;
|
| 618 |
+
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
| 619 |
+
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + " " + u[i];
|
| 620 |
+
};
|
| 621 |
+
|
| 622 |
+
// Storage usage is fetched separately from /api/admin/stats; cached here
|
| 623 |
+
// and folded into the stat-card grid so it sits with the headline numbers.
|
| 624 |
+
let storageStats = null;
|
| 625 |
+
let lastStats = null;
|
| 626 |
+
|
| 627 |
+
const storageCard = () => {
|
| 628 |
+
if (storageStats === null) return { label: "Storage used", value: "β¦", sub: "" };
|
| 629 |
+
if (!storageStats.available) return { label: "Storage used", value: "n/a", sub: "persistence off" };
|
| 630 |
+
const s = storageStats;
|
| 631 |
+
const pct = s.used_fraction != null ? (s.used_fraction * 100).toFixed(0) + "%" : "";
|
| 632 |
+
const p = s.prefixes || {};
|
| 633 |
+
const part = (k) => (p[k] ? `${k} ${fmtBytes(p[k].bytes)}` : "");
|
| 634 |
+
const sub = `${pct} of ${fmtBytes(s.quota_bytes)} Β· ` +
|
| 635 |
+
[part("photos"), part("results"), part("feedback")].filter(Boolean).join(" Β· ");
|
| 636 |
+
const subCls = (s.used_fraction != null && s.used_fraction > 0.8) ? "delta-down" : "";
|
| 637 |
+
return { label: "Storage used", value: fmtBytes(s.total_bytes), sub, subCls };
|
| 638 |
+
};
|
| 639 |
const fmtDay = (iso) => {
|
| 640 |
if (!iso) return "β";
|
| 641 |
// YYYY-MM-DD β MM-DD for compact axis labels
|
|
|
|
| 652 |
};
|
| 653 |
|
| 654 |
const renderStatCards = (s) => {
|
| 655 |
+
lastStats = s;
|
| 656 |
const t = s.totals;
|
| 657 |
const last7Delta = deltaArrow(t.last_7_days, t.prev_7_days);
|
| 658 |
const cards = [
|
|
|
|
| 666 |
value: t.rating_count ? `${t.avg_rating.toFixed(2)} β
` : "β",
|
| 667 |
sub: `${fmtInt(t.rating_count)} rated Β· ${fmtInt(t.comment_count)} comments`,
|
| 668 |
},
|
| 669 |
+
storageCard(),
|
| 670 |
];
|
| 671 |
statGrid.innerHTML = cards.map((c) => `
|
| 672 |
<div class="stat-card">
|
|
|
|
| 814 |
}
|
| 815 |
};
|
| 816 |
|
| 817 |
+
const loadStorage = async () => {
|
| 818 |
+
try {
|
| 819 |
+
const resp = await fetch(`/api/admin/storage?token=${encodeURIComponent(adminToken)}`);
|
| 820 |
+
if (resp.status === 401) return;
|
| 821 |
+
storageStats = await resp.json();
|
| 822 |
+
if (lastStats) renderStatCards(lastStats); // re-render so the card fills in
|
| 823 |
+
} catch (e) {
|
| 824 |
+
console.error("Failed to load storage", e);
|
| 825 |
+
}
|
| 826 |
+
};
|
| 827 |
+
|
| 828 |
+
document.getElementById("dashRefreshBtn").addEventListener("click", () => { loadStats(); loadStorage(); });
|
| 829 |
windowSelect.addEventListener("change", loadStats);
|
| 830 |
</script>
|
| 831 |
</body>
|