feat(phase 4): vision fetches NC previews; stop writing /data/thumbs

Last consumer of the on-disk thumbnail pipeline was the vision
worker reading /data/thumbs/{id}/medium.webp. Now it asks Nextcloud
for a 640px preview (the same edge size the old thumb used) and
decodes the bytes in-memory — no disk dependency.

- nextcloud_dav.get_preview_bytes: sync sibling of get_preview_async,
  for the celery vision worker (which is sync).
- vision._load_thumb: tries NC preview first; transitional disk
  fallback stays for rows still indexed during the rollout.
- thumbs.WORKER_THUMB_SIZES = set() — generate_thumbnails still runs
  the decode + pHash side-effect (perceptual dedup is mule-only and
  needs original-resolution pixels) but no longer writes thumbnail
  files.

The HTTP thumbnail endpoint's disk fallback path stays in place
unchanged: for NC-404 cases (e.g. iPhone JPEGs mis-extensioned as
.DNG), inline Pillow regeneration still writes a tiny per-photo
file so subsequent requests are fast. That path is rare and the
files are small.

Disk impact: /data/thumbs currently has ~22k medium.webp totaling
~1 GB. They'll stop being read after the worker-vision container
restarts, but no automatic delete — purge with the same find
pattern used for small/large reclaim when ready:

    find /data/thumbs -name "medium.webp" -delete
This commit is contained in:
Claudio
2026-05-11 13:52:52 +02:00
parent f4618ddf97
commit 5a67ed7e7b
3 changed files with 106 additions and 8 deletions

View File

@@ -45,7 +45,76 @@ def _get_sync_session() -> Session:
return sessionmaker(bind=_get_sync_engine())()
# CLIP was trained against 640px medium thumbs that the on-disk
# pipeline used to produce. Now we ask Nextcloud's preview endpoint
# for the same edge size so the classifier sees the same input
# distribution.
_VISION_PREVIEW_PX = 640
def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
"""Load the photo's RGB pixels into a numpy array for inference.
Primary path: ask Nextcloud's `/index.php/core/preview` for a
640px preview via the sync helper. Replaces the disk read of
`/data/thumbs/{photo_id}/medium.webp` so the on-disk pipeline
can retire entirely.
Disk fallback (transitional): if NC has no preview or no
credentials, look for the medium.webp the old pipeline wrote.
Goes dead once `generate_thumbnails` stops writing files.
"""
from io import BytesIO
from app.models import Photo
from app.models.user import User
from app.services.nextcloud_dav import get_preview_bytes
user_id: str | None = None
fileid: int | None = None
session = _get_sync_session()
try:
row = session.execute(
select(Photo.user_id, Photo.nextcloud_fileid).where(
Photo.id == photo_id
)
).one_or_none()
if row:
user_id, fileid = row[0], row[1]
finally:
session.close()
if user_id and fileid:
session = _get_sync_session()
try:
owner = session.execute(
select(User).where(User.id == user_id)
).scalar_one_or_none()
finally:
session.close()
if owner is not None and owner.nextcloud_app_password_enc:
try:
body = get_preview_bytes(
owner, fileid, _VISION_PREVIEW_PX, _VISION_PREVIEW_PX,
)
except Exception as e:
logger.warning(
"NC preview fetch failed for %s: %s", photo_id, e
)
body = None
if body:
try:
img = Image.open(BytesIO(body)).convert("RGB")
img.load()
arr = np.array(img)
img.close()
return arr
except Exception as e:
logger.warning(
"NC preview decode failed for %s: %s", photo_id, e
)
# Legacy disk fallback — transitional, dead once thumbs.py stops
# writing /data/thumbs.
thumb_base = Path("/data/thumbs")
thumb_path = thumb_base / photo_id / f"{size}.webp"
if not thumb_path.exists():
@@ -53,7 +122,7 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
if matches:
thumb_path = matches[0]
else:
logger.warning("Thumbnail not found: %s", thumb_path)
logger.warning("Thumbnail not found anywhere for %s", photo_id)
return None
try:
img = Image.open(thumb_path).convert("RGB")