perf: stream thumbnail download in bounded batches to cap peak RAM

Previously all candidate thumbnails (up to 3000) were loaded into a
single dict before any processing started. At ~5 MB per decoded preview
image, 472 candidates = ~2.4 GB of thumbnail data alone, easily
exhausting a 4 GB container memory limit on CPU.

Now thumbnails are downloaded and processed in batches of 32. Each
image is pop()'d from the batch dict immediately after embedding so the
decoder memory is released before the next batch starts. Peak in-flight
thumbnail memory is now bounded to ~32 × 8 MB = ~256 MB regardless of
candidate pool size. GPU users benefit too — faster first results and
lower host RAM pressure during large runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:07:40 +00:00
co-authored by Claude Sonnet 4.6
parent af9949c47f
commit 5b25b0df06
+52 -47
View File
@@ -203,64 +203,69 @@ def _select_by_embedding(
else:
candidates = assets
# --- Phase 1: Concurrent thumbnail download ---
# --- Phases 1-4: Batched download → quality filter → crop → embed ---
# Process in bounded batches so at most _BATCH decoded images live in RAM
# at once. With 472 candidates each thumbnail is ~3-8 MB decoded; loading
# all at once easily exhausts a 4 GB container limit on CPU.
from concurrent.futures import ThreadPoolExecutor, as_completed
thumbnail_map: dict[str, Image.Image] = {}
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(_fetch_thumbnail, a["id"]): a for a in candidates}
for i, future in enumerate(as_completed(futures)):
if progress_callback:
progress_callback(i, len(candidates))
asset = futures[future]
try:
img = future.result()
if img is not None:
thumbnail_map[asset["id"]] = img
except Exception:
continue
# --- Phase 2-4: Quality filter → Crop → Embed ---
_BATCH = 32
embeddings, valid_candidates, confidence_scores = [], [], []
quality_filtered = 0
processed = 0
for asset in candidates:
img = thumbnail_map.get(asset["id"])
if img is None:
continue
for batch_start in range(0, len(candidates), _BATCH):
batch = candidates[batch_start : batch_start + _BATCH]
confidence = _get_face_confidence(asset, person_id=person_id)
# Download this batch concurrently
batch_images: dict[str, Image.Image] = {}
with ThreadPoolExecutor(max_workers=min(8, len(batch))) as pool:
futures = {pool.submit(_fetch_thumbnail, a["id"]): a for a in batch}
for future in as_completed(futures):
asset = futures[future]
try:
img = future.result()
if img is not None:
batch_images[asset["id"]] = img
except Exception:
continue
# Quality gate: filter before expensive embedding computation
if entity_type == "face":
face_bbox = _get_face_bbox(asset, person_id=person_id)
quality = assess_quality(
img,
face_bbox=face_bbox,
confidence=confidence,
blur_threshold=Config.BLUR_THRESHOLD,
min_face_px=Config.MIN_FACE_WIDTH,
min_confidence=Config.MIN_CONFIDENCE,
)
if not quality.passed:
quality_filtered += 1
logger.debug(f"Quality filtered {asset['id']}: {quality.reason}")
# Process and immediately release each image to cap peak memory
for asset in batch:
img = batch_images.pop(asset["id"], None)
processed += 1
if progress_callback:
progress_callback(processed, len(candidates))
if img is None:
continue
# Crop the target person's face before embedding
face_crop = _crop_face_from_thumbnail(img, asset, person_id=person_id)
embed_img = face_crop if face_crop is not None else img
else:
embed_img = img
confidence = _get_face_confidence(asset, person_id=person_id)
emb = get_embedding(embed_img, entity_type, asset_id=asset["id"])
if emb is not None:
embeddings.append(emb)
valid_candidates.append(asset)
confidence_scores.append(confidence)
if entity_type == "face":
face_bbox = _get_face_bbox(asset, person_id=person_id)
quality = assess_quality(
img,
face_bbox=face_bbox,
confidence=confidence,
blur_threshold=Config.BLUR_THRESHOLD,
min_face_px=Config.MIN_FACE_WIDTH,
min_confidence=Config.MIN_CONFIDENCE,
)
if not quality.passed:
quality_filtered += 1
logger.debug(f"Quality filtered {asset['id']}: {quality.reason}")
continue
if progress_callback:
progress_callback(len(candidates), len(candidates))
face_crop = _crop_face_from_thumbnail(img, asset, person_id=person_id)
embed_img = face_crop if face_crop is not None else img
else:
embed_img = img
emb = get_embedding(embed_img, entity_type, asset_id=asset["id"])
if emb is not None:
embeddings.append(emb)
valid_candidates.append(asset)
confidence_scores.append(confidence)
if quality_filtered > 0:
logger.info(f"Quality filtering removed {quality_filtered} images.")