VSEARCH / scripts /sync-interiors.py
bep40's picture
feat(sync): two-way interior sync against vai-avatar's library
07cd7be verified
Raw History Blame Contribute Delete
14.9 kB
#!/usr/bin/env python3
"""
Two-way sync between this Space's interiors and vai-avatar's interior library.
WHY THIS CANNOT JUST COPY FILES
-------------------------------
The field searches interiors with its own CLIP matrix
(`image-embeddings_25k-512-32bit.bin`, 512 dims). vai-avatar's library uses a
different embedding model. A new image therefore cannot be copied across: it
has to be encoded with the field's own model first, or it lands in a 512-dim
space that is not the one the field searches.
This was measured rather than assumed. Cosine between the two datasets on the
377 images they share is 0.6347; cosine between two DIFFERENT images is 0.6336.
Same dimensionality, no signal -- the spaces are not interchangeable, so a file
copy would produce confidently wrong search results.
WHAT IT DOES
------------
added live on the API, absent from the field -> download, embed, upload,
append to both index files
removed present in the field, gone from the API -> drop from the index and
the vector blob, delete the bitmap
The source of truth is the API's own `/latest`, not the dataset's
`metadata.json`. That file is a history, not a manifest: it holds 1,752 rows
for 1,122 live images, so deleted rows linger in it and a file-driven sync
would resurrect whatever the user removed on the other side.
RUN
---
pip install -r scripts/requirements-sync.txt
python scripts/sync-interiors.py --dry-run # report, change nothing
python scripts/sync-interiors.py # apply
python scripts/sync-interiors.py --limit 5 # apply a few, for testing
"""
from __future__ import annotations
import argparse
import base64
import io
import json
import os
import struct
import sys
import time
import urllib.error
import urllib.request
from PIL import Image
# --------------------------------------------------------------------------
# Where things live
# --------------------------------------------------------------------------
API = os.environ.get("INTERIOR_API_URL", "https://bep40-vai-avatar2-interior-api.hf.space")
DATASET = os.environ.get("FIELD_DATASET", "bep40/interior-image-field-assets")
HUB = os.environ.get("HF_HUB") or os.environ.get("HF_TOKEN")
INDEX_NAME = "image-embeddings.json"
BLOB_NAME = "image-embeddings_25k-512-32bit.bin"
RESOLVE = f"https://huggingface.co/datasets/{DATASET}/resolve/main/"
# The field's image model, matching what produced the existing vectors.
IMAGE_MODEL = "sentence-transformers/clip-ViT-B-32-multilingual-v1"
DIMS = 512
BRANDS = ("malloca", "grob", "hafele", "eurogold", "canzy", "konox", "demax")
def log(message: str) -> None:
print(message, flush=True)
def get(url: str, timeout: int = 120) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": "interior-sync/1.0"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
def post_json(url: str, payload: dict, timeout: int = 120) -> dict:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "User-Agent": "interior-sync/1.0"},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode())
# --------------------------------------------------------------------------
# The field's vector format
#
# `image-embeddings_25k-512-32bit.bin` is headerless little-endian float32,
# 512 floats per row, row order matching `image-embeddings.json`. The "25k" in
# the filename is decorative: the file holds exactly as many rows as the index
# has entries.
# --------------------------------------------------------------------------
def decode_blob(raw: bytes) -> list[list[float]]:
count, remainder = divmod(len(raw), 4 * DIMS)
if remainder:
raise ValueError(
f"blob is {len(raw)} bytes, not a whole number of {DIMS}-float rows "
f"(remainder {remainder}) -- the index and the vectors disagree"
)
flat = struct.unpack(f"<{count * DIMS}f", raw[: count * 4 * DIMS])
return [list(flat[i * DIMS : (i + 1) * DIMS]) for i in range(count)]
def encode_blob(rows: list[list[float]]) -> bytes:
for index, row in enumerate(rows):
if len(row) != DIMS:
raise ValueError(f"row {index} has {len(row)} values, expected {DIMS}")
return struct.pack(f"<{len(rows) * DIMS}f", *(v for row in rows for v in row))
# --------------------------------------------------------------------------
# Reading the current state
# --------------------------------------------------------------------------
def fetch_live_images() -> dict[str, dict]:
"""Every image the API currently serves, keyed by image_id.
The paging parameter is `top_k`, not `limit`; sending `limit` silently
returns the default page of 24 no matter what is asked for.
"""
first = post_json(f"{API}/latest", {"top_k": 1, "offset": 0})
total = int(first.get("total", 0))
live: dict[str, dict] = {}
offset = 0
while offset < total:
page = post_json(f"{API}/latest", {"top_k": 250, "offset": offset})
rows = page.get("results", [])
if not rows:
break
for row in rows:
live[row["image_id"]] = row
offset += len(rows)
time.sleep(0.4)
log(f" API reports {total} live images; collected {len(live)}")
if len(live) != total:
log(f" WARNING: expected {total} ids but collected {len(live)}")
return live
def load_field_state() -> tuple[list[dict], list[list[float]]]:
index = json.loads(get(RESOLVE + INDEX_NAME).decode())
blob = decode_blob(get(RESOLVE + BLOB_NAME))
if len(index) != len(blob):
raise ValueError(
f"index has {len(index)} entries but the blob holds {len(blob)} vectors; "
"refusing to write a file the field cannot load"
)
return index, blob
# --------------------------------------------------------------------------
# Record conventions, mirrored from the field's own scraper output
# --------------------------------------------------------------------------
def aspect_ratio(image: Image.Image) -> float:
width, height = image.size
return round(width / height, 4) if height else 1.0
def blur_placeholder(image: Image.Image) -> str:
"""
A tiny base64 JPEG, like the placeholders the scraper writes.
The field paints `blur` behind each tile, so an image without one shows an
empty box for the frames before its bitmap arrives. This costs ~60 bytes and
removes that flash.
"""
buffer = io.BytesIO()
image.convert("RGB").resize((4, 3)).save(buffer, format="JPEG", quality=40)
return base64.b64encode(buffer.getvalue()).decode()
def title_from(entry: dict) -> str:
for key in ("desc", "title"):
value = entry.get(key)
if isinstance(value, str) and value.strip():
return " ".join(value.split())[:120]
for key in ("labels", "keywords"):
value = entry.get(key)
if isinstance(value, list) and value:
return str(value[0]).strip()[:120]
return ""
def categories_from(entry: dict) -> list[str]:
out: list[str] = []
for key in ("categories", "room_hints"):
for value in entry.get(key) or []:
text = str(value).strip()
if text and text not in out:
out.append(text)
return out[:4] or ["Uncategorized"]
def build_record(entry: dict, image: Image.Image) -> dict:
"""One interior row, in exactly the shape image-embeddings.json uses."""
image_id = entry["image_id"]
return {
"id": image_id,
# The field resolves an interior's bitmap relative to its own dataset and
# the scraper stored a bare filename, so keep that convention.
"url": f"{image_id}.jpg",
"ar": aspect_ratio(image),
"blur": blur_placeholder(image),
"title": title_from(entry),
"categories": categories_from(entry),
"source": str(entry.get("source") or ""),
}
# --------------------------------------------------------------------------
# Embedding
# --------------------------------------------------------------------------
class Embedder:
"""The field's own image model, loaded lazily so a no-op sync is cheap."""
def __init__(self) -> None:
self._model = None
def _load(self):
if self._model is None:
from sentence_transformers import SentenceTransformer
log(f" loading {IMAGE_MODEL} (first run downloads ~600 MB)")
self._model = SentenceTransformer(IMAGE_MODEL)
return self._model
def image(self, image: Image.Image) -> list[float]:
vector = self._load().encode([image], convert_to_numpy=True)[0]
norm = float((vector**2).sum() ** 0.5)
if norm > 0:
vector = vector / norm
if len(vector) != DIMS:
raise ValueError(f"model returned {len(vector)} dims, expected {DIMS}")
return [float(v) for v in vector]
# --------------------------------------------------------------------------
# The sync itself
# --------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="report changes, write nothing")
parser.add_argument("--no-upload", action="store_true", help="build everything but do not push")
parser.add_argument("--limit", type=int, default=0, help="cap how many images to add")
args = parser.parse_args()
if not HUB and not args.dry_run and not args.no_upload:
log("HF_TOKEN is not set. Export it, or pass --no-upload to build without pushing.")
return 2
log("Reading the field...")
index, vectors = load_field_state()
log(f" field holds {len(index)} interiors")
log("Reading the API...")
live = fetch_live_images()
have = {str(row.get("id")) for row in index}
to_add = sorted(set(live) - have)
to_drop = sorted(have - set(live))
log("")
log(f" to add : {len(to_add)}")
log(f" to remove: {len(to_drop)}")
if to_drop:
log(f" {', '.join(to_drop[:6])}{' ...' if len(to_drop) > 6 else ''}")
if to_add:
log(f" {', '.join(to_add[:6])}{' ...' if len(to_add) > 6 else ''}")
if args.limit:
to_add = to_add[: args.limit]
log(f" limited to {len(to_add)} additions for this run")
if not to_add and not to_drop:
log("\nNothing to do -- the two sides already agree.")
return 0
if args.dry_run:
log("\nDry run: nothing was written.")
return 0
if not to_add:
log("\nOnly removals pending, which the same run applies below.")
if not to_add and not to_drop:
return 0
added_rows: list[dict] = []
added_vectors: list[list[float]] = []
bitmaps: list[tuple[str, bytes]] = []
if to_add:
log(f"\nEmbedding {len(to_add)} new interiors...")
embedder = Embedder()
for n, image_id in enumerate(to_add, 1):
entry = live[image_id]
try:
raw = get(f"{API}/image/{image_id}.jpg")
image = Image.open(io.BytesIO(raw))
image.load()
except (urllib.error.URLError, OSError) as error:
log(f" [{n}/{len(to_add)}] {image_id}: fetch failed ({error}) -- skipped")
continue
try:
vector = embedder.image(image)
except Exception as error: # noqa: BLE001 - report and keep going
log(f" [{n}/{len(to_add)}] {image_id}: embed failed ({error}) -- skipped")
continue
added_rows.append(build_record(entry, image))
added_vectors.append(vector)
bitmaps.append((f"images/{image_id}.jpg", raw))
log(f" [{n}/{len(to_add)}] {image_id} {image.size[0]}x{image.size[1]} {title_from(entry)[:44]}")
new_index = index + added_rows
new_vectors = vectors + added_vectors
if to_drop:
# Rebuild both files from the surviving rows, so the index and the blob
# cannot drift out of alignment: the field refuses to load them otherwise.
drop = set(to_drop)
keep = [i for i, row in enumerate(new_index) if str(row.get("id")) not in drop]
new_index = [new_index[i] for i in keep]
new_vectors = [new_vectors[i] for i in keep]
log("")
log(f"Final: {len(new_index)} interiors "
f"(+{len(added_rows)} added, -{len(to_drop)} removed)")
if not added_rows and not to_drop:
log("Nothing changed.")
return 0
if args.no_upload:
log("Skipping push (--no-upload).")
return 0
from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi
client = HfApi(token=HUB) if HUB else HfApi()
operations = [CommitOperationAdd(path_in_repo=name, path_or_fileobj=data)
for name, data in bitmaps]
operations.append(CommitOperationAdd(
path_in_repo=INDEX_NAME,
path_or_fileobj=json.dumps(new_index, ensure_ascii=False).encode(),
))
operations.append(CommitOperationAdd(
path_in_repo=BLOB_NAME, path_or_fileobj=encode_blob(new_vectors)))
if to_drop:
# Only delete what is actually on the Hub, or the whole commit 404s.
present = set(client.list_repo_files(DATASET, repo_type="dataset"))
removed = 0
for image_id in to_drop:
path = f"images/{image_id}.jpg"
if path in present:
operations.append(CommitOperationDelete(path_in_repo=path))
removed += 1
log(f" deleting {removed} of {len(to_drop)} bitmaps (the rest were already gone)")
commit = client.create_commit(
repo_id=DATASET,
repo_type="dataset",
operations=operations,
commit_message=(
f"sync interiors with vai-avatar: +{len(added_rows)} -{len(to_drop)}\n\n"
"Driven by the API's live image list rather than metadata.json: that file\n"
"retains deleted rows (1,752 rows for 1,122 live images), so a\n"
"file-driven sync resurrects whatever the user removed on the other side.\n\n"
"New images are embedded with the field's own CLIP model. The two sides\n"
"use different embedding models, so copying a file would place it in an\n"
"unrelated vector space and search would return noise.\n\n"
"Generated by scripts/sync-interiors.py."
),
)
log(f"Pushed {commit.oid} to {DATASET}")
log("The field polls the dataset revision, so these appear within a minute.")
return 0
if __name__ == "__main__":
sys.exit(main())