diff --git a/winnow/config.py b/winnow/config.py index 37a99c7..2a33680 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -173,7 +173,7 @@ class _Config: data = json.loads(config_file.read_text()) if not self.IMMICH_URL: self.IMMICH_URL = data.get("IMMICH_URL") - if os.getenv("OUTPUT_DIR") is None: + if not os.getenv("OUTPUT_DIR"): self.OUTPUT_DIR = data.get("OUTPUT_DIR", self.OUTPUT_DIR) except (json.JSONDecodeError, OSError) as e: logging.warning("Failed to load config file: %s", e) diff --git a/winnow/diversity.py b/winnow/diversity.py index c412743..4e9ada2 100644 --- a/winnow/diversity.py +++ b/winnow/diversity.py @@ -508,7 +508,6 @@ def _cluster_aware_selection( medoid_indices, cluster_labels = _kmedoids(dist_matrix, k) selected = list(medoid_indices) - selected_set = set(selected) logger.debug("Selected %s cluster medoids as initial picks.", len(selected)) @@ -541,7 +540,6 @@ def _cluster_aware_selection( break selected.append(best_idx) - selected_set.add(best_idx) # Update min distances dists_to_new = dist_matrix[best_idx] diff --git a/winnow/embeddings.py b/winnow/embeddings.py index cab4294..efa154c 100644 --- a/winnow/embeddings.py +++ b/winnow/embeddings.py @@ -181,8 +181,9 @@ def get_face_embedding(img_pil: Image.Image) -> np.ndarray | None: return None try: - # InsightFace expects BGR cv2 image - img_bgr = cv2.cvtColor(np.asarray(img_pil), cv2.COLOR_RGB2BGR) + # InsightFace expects BGR cv2 image; normalise mode first so RGBA/grayscale don't + # raise a channel-count error inside cvtColor. + img_bgr = cv2.cvtColor(np.asarray(img_pil.convert("RGB")), cv2.COLOR_RGB2BGR) # Suppress scikit-image FutureWarning from InsightFace's face_align.py with warnings.catch_warnings(): diff --git a/winnow/executor.py b/winnow/executor.py index c5fee48..0290da0 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -1,6 +1,7 @@ """Execution phase: image processing and Frigate upload.""" import logging +import operator import os import shutil from io import BytesIO @@ -27,8 +28,10 @@ from .log_config import console from .quality import blur_score_from_image from .reconcile import enrich_asset_with_face_data, reconcile_frigate_mappings from .upload_tracker import ( - UPLOAD_TRACKER_FILE, REJECT_TRACKER_FILE, + UPLOAD_TRACKER_FILE, + begin_batch, + flush_batch, get_lowest_quality_mapped_file, get_most_redundant_mapped_file, get_tracked_frigate_file_count, @@ -36,8 +39,6 @@ from .upload_tracker import ( has_frigate_scores, mark_rejected, mark_uploaded, - begin_batch, - flush_batch, remove_frigate_file, remove_frigate_files_batch, ) @@ -452,13 +453,13 @@ def upload_to_frigate(jobs: list[dict]) -> None: get_target = get_most_redundant_mapped_file score_label, better_note = "frigate", " (more novel)" no_score_msg = "Frigate recognize unavailable, skipping replacement" - is_better_than = lambda c, t: c < t + is_better_than = operator.lt else: candidate_score = score_map.get(fname) get_target = get_lowest_quality_mapped_file score_label, better_note = "blur", "" no_score_msg = "no quality score, skipping replacement" - is_better_than = lambda c, t: c > t + is_better_than = operator.gt if candidate_score is None: progress.console.print(f" [dim]⏭ {fname}: {no_score_msg}[/dim]") @@ -489,7 +490,10 @@ def upload_to_frigate(jobs: list[dict]) -> None: effective_count -= 1 min_quality_score_for_slot = None if using_fscore else target_score else: - logger.warning("Failed to delete %s for %s, skipping replacement", target_frigate_file, name) + logger.warning( + "Failed to delete %s for %s, skipping replacement", + target_frigate_file, name, + ) failed_deletes.add(target_frigate_file) progress.advance(upload_task) continue @@ -603,11 +607,19 @@ def upload_to_frigate(jobs: list[dict]) -> None: try: flush_batch(UPLOAD_TRACKER_FILE) except Exception as _flush_exc: - logger.warning("flush_batch failed during cleanup — batch will be recovered on next begin_batch: %s", _flush_exc) + logger.warning( + "flush_batch failed during cleanup" + " — batch will be recovered on next begin_batch: %s", + _flush_exc, + ) try: flush_batch(REJECT_TRACKER_FILE) except Exception as _flush_exc: - logger.warning("flush_batch failed during cleanup — batch will be recovered on next begin_batch: %s", _flush_exc) + logger.warning( + "flush_batch failed during cleanup" + " — batch will be recovered on next begin_batch: %s", + _flush_exc, + ) # Batch-map Frigate filenames to asset IDs now that all uploads are done. if actually_uploaded and not _skip_reconcile: diff --git a/winnow/image_processing.py b/winnow/image_processing.py index 7ded632..23c3f0e 100644 --- a/winnow/image_processing.py +++ b/winnow/image_processing.py @@ -92,11 +92,14 @@ def process_face_mode( return None img_w, img_h = img.size - meta_w = face_info.get("imageWidth") or img_w - meta_h = face_info.get("imageHeight") or img_h + meta_w = face_info.get("imageWidth") or 0 + meta_h = face_info.get("imageHeight") or 0 - # Scale bounding box to actual image dimensions - scale_x, scale_y = img_w / meta_w, img_h / meta_h + # Scale bounding box from detection-image space to actual image dimensions. + # Fall back to 1.0 if Immich omits the field — bbox is assumed to already + # be in image space (correct for thumbnails, wrong for full-res). + scale_x = img_w / meta_w if meta_w else 1.0 + scale_y = img_h / meta_h if meta_h else 1.0 x1 = face_info["boundingBoxX1"] * scale_x y1 = face_info["boundingBoxY1"] * scale_y x2 = face_info["boundingBoxX2"] * scale_x diff --git a/winnow/jobs.py b/winnow/jobs.py index b8243c7..7344572 100644 --- a/winnow/jobs.py +++ b/winnow/jobs.py @@ -66,7 +66,11 @@ def _get_strategy_choice(has_embedding: bool) -> tuple[int | str, str]: def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, str]: """Resolve env var strategy to (limit, selection_mode) without prompts.""" if not has_embedding: - return _getenv_int("LIMIT", 30), "time" + limit = _getenv_int("LIMIT", 30) + if limit <= 0: + logger.warning("LIMIT=%s is invalid — ignoring and using default 30", limit) + limit = 30 + return limit, "time" custom_limit = _getenv_optional_int("LIMIT") if custom_limit is not None: @@ -77,6 +81,7 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st strategy_map = { "adaptive": ("auto", "smart"), "auto": ("auto", "smart"), # legacy alias for adaptive + "skip": (0, "skip"), "standard": (30, "smart"), "broad": (100, "smart"), } @@ -297,9 +302,8 @@ def auto_configure(people: list[dict]) -> list[dict]: if limit == "auto": # Switch from open-ended auto to a fixed budget at remaining capacity # so the diversity selector itself stops at the right count instead of - # selecting MAX_AUTO_IMAGES and then discarding the excess by position. - if already_uploaded > 0: - limit = capacity + # selecting more than MAX_AUTO_IMAGES and overflowing the cap. + limit = capacity else: limit = min(limit, capacity) diff --git a/winnow/reconcile.py b/winnow/reconcile.py index 2a4f251..c012f87 100644 --- a/winnow/reconcile.py +++ b/winnow/reconcile.py @@ -61,7 +61,7 @@ def reconcile_frigate_mappings( try: return float(fname.rsplit("_", 1)[-1].rsplit(".", 1)[0]) except (ValueError, IndexError): - return 0.0 + return float("inf") logger.debug( "%s: mapping %s file(s) by filename timestamp — assumes Frigate processes" diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index 3b56678..b5b90fc 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -109,7 +109,11 @@ def begin_batch(filename: str) -> None: try: _write_to_disk(path, _cache[key]) except Exception: - logger.warning("begin_batch: could not flush leftover deferred state for %s — partial progress may be lost", path) + logger.warning( + "begin_batch: could not flush leftover deferred state for %s" + " — partial progress may be lost", + path, + ) _deferred.discard(key) _dirty.discard(key) _deferred.add(key) @@ -162,7 +166,7 @@ def _mark( logger.warning("_mark called with empty person_name for asset %s — asset not recorded", asset_id) return data = _load(filename) - by_person = data.setdefault("by_person", {}) + by_person = dict(data.get("by_person", {})) entry = _migrate_entry(by_person.get(person_name, {})) ids = set(entry["asset_ids"]) ids.add(asset_id) @@ -174,7 +178,9 @@ def _mark( if frigate_score is not None: entry["frigate_scores"][asset_id] = round(frigate_score, 4) by_person[person_name] = entry - _save(filename, data) + new_data = dict(data) + new_data["by_person"] = by_person + _save(filename, new_data) logger.debug("Marked %s in %s (%s)", asset_id, filename, person_name) @@ -371,11 +377,13 @@ def find_by_crop_dimension(size: int) -> list[dict]: def update_frigate_count(person_name: str, count: int) -> None: """Record Frigate's authoritative training image count for a person.""" data = _load(UPLOAD_TRACKER_FILE) - by_person = data.setdefault("by_person", {}) + by_person = dict(data.get("by_person", {})) entry = _migrate_entry(by_person.get(person_name, {})) entry["frigate_count"] = count by_person[person_name] = entry - _save(UPLOAD_TRACKER_FILE, data) + new_data = dict(data) + new_data["by_person"] = by_person + _save(UPLOAD_TRACKER_FILE, new_data) def reset_all_people() -> None: