ring-sizer / script /storage_recompress_oneshot.py
feng-x's picture
Upload folder using huggingface_hub
e88c8ff verified
Raw
History Blame Contribute Delete
10.7 kB
#!/usr/bin/env python3
"""One-shot Supabase storage recompress (v7 "hot cache, low-res").
Context: the `ring-measurements` bucket crossed the 1 GB free-tier quota.
The measurement pipeline never sees past ~1024px (SAM downscales internally),
so storing full-res user uploads buys nothing for re-measurement. This script
shrinks the existing objects in place:
- photos/ -> 2048px long-edge JPEG q80 (validated measurement-safe)
- results/ -> 1024px long-edge JPEG q80 (overlay, never re-measured)
Safety model (free tier has NO rollback):
1. `backup` downloads every photos/ object to a local dir FIRST and leaves
it pristine. That is the ultimate fallback for photos.
2. Recompress encodes in memory, RE-DECODES to verify the bytes are a valid
image, and only then overwrites. Never writes garbage over a good object.
3. Photos are recompressed only if a verified local backup exists.
4. skip-if-not-smaller: never write a candidate that isn't meaningfully
smaller than what's already there (avoids re-encode bloat).
Subcommands:
inventory read-only: count + bytes for photos/ + results/
backup download all photos/ -> BACKUP_DIR (idempotent)
test-mechanism upload+overwrite+verify+delete a throwaway object
recompress --prefix photos --max-side 2048 [--apply]
recompress --prefix results --max-side 1024 [--apply]
Without --apply, recompress is a DRY RUN (projects savings, writes nothing).
Requires SUPABASE_URL / SUPABASE_SERVICE_KEY in the environment.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import cv2
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from web_demo.supabase_client import _get_client, BUCKET # noqa: E402
BACKUP_DIR = ROOT / "input" / "supabase_archive"
PAGE_LIMIT = 100
JPEG_QUALITY = 80
MIN_GAIN = 0.95 # only write if candidate <= 95% of original size
# --------------------------------------------------------------------------- #
# storage helpers
# --------------------------------------------------------------------------- #
def _storage():
client = _get_client()
if client is None:
print("ERROR: Supabase client not initialized "
"(check SUPABASE_URL / SUPABASE_SERVICE_KEY).")
sys.exit(1)
return client.storage.from_(BUCKET)
def _obj_size(obj: dict) -> Optional[int]:
md = obj.get("metadata") or {}
return md.get("size") if md.get("size") is not None else obj.get("size")
def list_all(storage, folder: str) -> List[dict]:
out: List[dict] = []
offset = 0
while True:
resp = storage.list(folder, {"limit": PAGE_LIMIT, "offset": offset})
if not resp:
break
# storage may return a placeholder row for the folder itself; drop dirs
out.extend([o for o in resp if o.get("name") and _obj_size(o) is not None])
if len(resp) < PAGE_LIMIT:
break
offset += PAGE_LIMIT
return out
def _human(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if abs(n) < 1024:
return f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} TB"
def _overwrite(storage, path: str, data: bytes, content_type: str) -> None:
"""Overwrite an existing object in place. Tries update(), falls back to
upload() with x-upsert."""
opts = {"content-type": content_type, "cache-control": "3600"}
try:
storage.update(path, data, file_options=opts)
except Exception:
storage.upload(path, data,
file_options={**opts, "upsert": "true"})
# --------------------------------------------------------------------------- #
# encode
# --------------------------------------------------------------------------- #
def recompress_bytes(raw: bytes, max_side: int) -> Optional[bytes]:
"""Decode -> (optionally) downscale to max_side -> JPEG q80 -> re-decode
verify. Returns new bytes, or None if decode/verify failed."""
arr = np.frombuffer(raw, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR) # drops alpha; BGR
if img is None:
return None
h, w = img.shape[:2]
long_side = max(h, w)
if long_side > max_side:
scale = max_side / long_side
img = cv2.resize(img, (int(round(w * scale)), int(round(h * scale))),
interpolation=cv2.INTER_AREA)
ok, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
if not ok:
return None
out = enc.tobytes()
# verify: the new bytes must decode back to a valid image
if cv2.imdecode(np.frombuffer(out, np.uint8), cv2.IMREAD_COLOR) is None:
return None
return out
# --------------------------------------------------------------------------- #
# subcommands
# --------------------------------------------------------------------------- #
def cmd_inventory(_args) -> int:
storage = _storage()
grand = 0
for folder in ("photos", "results", "feedback"):
objs = list_all(storage, folder)
total = sum(_obj_size(o) or 0 for o in objs)
grand += total
print(f"{folder}/: {len(objs)} objects, {_human(total)}")
print(f"TOTAL (3 prefixes): {_human(grand)}")
return 0
def cmd_backup(_args) -> int:
storage = _storage()
dest = BACKUP_DIR / "photos"
dest.mkdir(parents=True, exist_ok=True)
objs = list_all(storage, "photos")
print(f"photos/: {len(objs)} objects to back up -> {dest}")
done = skipped = failed = 0
for i, o in enumerate(objs, 1):
name = o["name"]
size = _obj_size(o) or 0
local = dest / name
if local.exists() and local.stat().st_size == size:
skipped += 1
continue
try:
data = storage.download(f"photos/{name}")
local.write_bytes(data)
if local.stat().st_size != size:
print(f" [{i}] SIZE MISMATCH {name}: "
f"got {len(data)} expected {size}")
failed += 1
continue
done += 1
if done % 25 == 0:
print(f" ...{done} downloaded")
except Exception as e:
print(f" [{i}] FAILED {name}: {e}")
failed += 1
print(f"backup done: {done} new, {skipped} already present, {failed} failed")
return 1 if failed else 0
def cmd_test_mechanism(_args) -> int:
storage = _storage()
path = "photos/__recompress_selftest__.jpg"
a = cv2.imencode(".jpg", np.full((64, 64, 3), 30, np.uint8))[1].tobytes()
b = cv2.imencode(".jpg", np.full((64, 64, 3), 200, np.uint8))[1].tobytes()
try:
try:
storage.remove([path])
except Exception:
pass
storage.upload(path, a, file_options={"content-type": "image/jpeg"})
_overwrite(storage, path, b, "image/jpeg")
back = storage.download(path)
ok = (back == b)
print(f"overwrite-in-place {'OK' if ok else 'FAILED'} "
f"(round-tripped {len(back)} bytes, expected {len(b)})")
return 0 if ok else 1
finally:
try:
storage.remove([path])
except Exception:
pass
def cmd_recompress(args) -> int:
storage = _storage()
prefix = args.prefix
max_side = args.max_side
apply = args.apply
objs = list_all(storage, prefix)
print(f"{prefix}/: {len(objs)} objects, target {max_side}px/q{JPEG_QUALITY}, "
f"{'APPLY' if apply else 'DRY RUN'}")
backup_photos = BACKUP_DIR / "photos"
before = after = 0
written = skipped_small = skipped_nobackup = failed = 0
for i, o in enumerate(objs, 1):
name = o["name"]
if name.startswith("__recompress_selftest__"):
continue
size = _obj_size(o) or 0
before += size
path = f"{prefix}/{name}"
# photos: require a verified local backup before destructive write
if prefix == "photos":
b = backup_photos / name
if not (b.exists() and b.stat().st_size == size):
skipped_nobackup += 1
after += size
continue
try:
raw = storage.download(path)
except Exception as e:
print(f" [{i}] download FAILED {name}: {e}")
failed += 1
after += size
continue
new = recompress_bytes(raw, max_side)
if new is None:
print(f" [{i}] decode/verify FAILED {name} — left untouched")
failed += 1
after += size
continue
if len(new) >= size * MIN_GAIN:
skipped_small += 1
after += size
continue
after += len(new)
if apply:
try:
_overwrite(storage, path, new, "image/jpeg")
written += 1
if written % 25 == 0:
print(f" ...{written} rewritten")
except Exception as e:
print(f" [{i}] upload FAILED {name}: {e}")
failed += 1
after += size - len(new) # revert projection
else:
written += 1 # would-write count in dry run
verb = "rewrote" if apply else "would rewrite"
print(f"\n{prefix}/ summary:")
print(f" {verb}: {written}")
print(f" skipped (not smaller): {skipped_small}")
if prefix == "photos":
print(f" skipped (no backup): {skipped_nobackup}")
print(f" failed: {failed}")
print(f" size: {_human(before)} -> {_human(after)} "
f"({(1 - after / before) * 100:.1f}% smaller)" if before else " size: 0")
return 1 if failed else 0
def main() -> int:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("inventory")
sub.add_parser("backup")
sub.add_parser("test-mechanism")
r = sub.add_parser("recompress")
r.add_argument("--prefix", required=True, choices=("photos", "results"))
r.add_argument("--max-side", type=int, required=True)
r.add_argument("--apply", action="store_true",
help="actually overwrite (default: dry run)")
args = p.parse_args()
return {
"inventory": cmd_inventory,
"backup": cmd_backup,
"test-mechanism": cmd_test_mechanism,
"recompress": cmd_recompress,
}[args.cmd](args)
if __name__ == "__main__":
sys.exit(main())