From c4910d82efd8486690fdfced91531ff949bd23ce Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:16:04 +0000 Subject: [PATCH 1/7] fix: treat Frigate 500 'Could not process' as permanent rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frigate returns HTTP 500 with 'Could not process' when its face detector cannot find or embed a face in the uploaded crop — this will never succeed on retry. Previously these were silently logged at DEBUG and retried on every future run. - Surface 500 error details inline (same display path as 400) - Mark 500 + 'could not process' as a permanent rejection so the asset is skipped on future runs instead of retried indefinitely --- winnow/executor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/winnow/executor.py b/winnow/executor.py index b5a3822..1aa9877 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -566,13 +566,14 @@ def upload_to_frigate(jobs: list[dict]) -> None: error_detail = resp.json().get("message", full_body[:100]) except Exception: error_detail = full_body[:100] - if resp.status_code == 400: + if resp.status_code in (400, 500): progress.console.print(f" [dim]{error_detail}[/dim]") else: logger.debug("%s HTTP %s: %s", fname, resp.status_code, error_detail) _is_permanent = ( (resp.status_code == 400 and "face" in full_body.lower()) or resp.status_code == 422 + or (resp.status_code == 500 and "could not process" in full_body.lower()) ) if _is_permanent: asset_id = asset_map.get(fname) From b69f77637856f4b98017c5418c1a4cba2104ceb5 Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:18:43 +0000 Subject: [PATCH 2/7] fix: surface skip reasons and suppress norm_crop FutureWarning process_face_mode now returns a descriptive string instead of None for filtered-out faces ("face too small 45x38px, min 90px", "no face metadata"), so the executor can print a useful reason rather than the generic "no usable face data". Also suppresses the InsightFace norm_crop FutureWarning about deprecated estimate usage, which was noisy at INFO level on every aligned crop. --- winnow/executor.py | 3 ++- winnow/image_processing.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index 1aa9877..6eb0a9c 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -202,8 +202,9 @@ def execute_jobs(jobs: list[dict]) -> None: count += 1 else: + reason = saved if isinstance(saved, str) else "no usable face data" progress.console.print( - f"[yellow]Skipped {asset['id']} (no usable face data)[/yellow]" + f"[yellow]Skipped {asset['id']} ({reason})[/yellow]" ) except Exception as e: logger.error("Failed to process asset %s: %s", asset.get("id", ""), e) diff --git a/winnow/image_processing.py b/winnow/image_processing.py index 23c3f0e..c9c9b85 100644 --- a/winnow/image_processing.py +++ b/winnow/image_processing.py @@ -48,7 +48,9 @@ def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> I if lm.shape != (5, 2): logger.debug("Invalid landmark shape: %s, expected (5, 2)", lm.shape) return None - aligned = norm_crop(img_np, lm) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message=".*estimate.*is deprecated", category=FutureWarning) + aligned = norm_crop(img_np, lm) return Image.fromarray(aligned) except ImportError: logger.debug("InsightFace not available for face alignment") @@ -66,10 +68,11 @@ def process_face_mode( count: int, min_width: int | None = None, insightface_app=None, -) -> tuple[int, int] | None: +) -> tuple[int, int] | str | None: """Crop face based on Immich metadata and save to output directory. - Returns (width, height) of the saved crop, or None if no crop was saved. + Returns (width, height) of the saved crop, a skip-reason string if the + face was filtered out, or None if no crop was saved for other reasons. When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True, re-detects the face in the Immich bbox region using InsightFace to get precise landmarks for a proper 112x112 aligned crop. Falls back to @@ -89,7 +92,7 @@ def process_face_mode( if not face_info: logger.debug("No face info for %s in asset %s", person.get("name"), asset.get("id")) - return None + return "no face metadata" img_w, img_h = img.size meta_w = face_info.get("imageWidth") or 0 @@ -108,7 +111,7 @@ def process_face_mode( face_w, face_h = x2 - x1, y2 - y1 if face_w < min_width or face_h < min_width: logger.debug("Face too small (%.1fx%.1f)", face_w, face_h) - return None + return f"face too small ({face_w:.0f}x{face_h:.0f}px, min {min_width}px)" # Re-detect face with InsightFace for landmark-based alignment. # Immich's /api/faces endpoint does not include landmarks, so the From f6e494071e1a9ee71f69ea449f0f487d5e26c19e Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:19:09 +0000 Subject: [PATCH 3/7] fix: lower MAX_AUTO_IMAGES default from 20 to 5 Smaller default cap is more conservative for new installs and better reflects the minimum viable training set for Frigate face recognition. Users who need more can set MAX_AUTO_IMAGES explicitly. --- README.md | 2 +- compose.yml | 2 +- winnow/config.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f4c0968..197cb09 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | Variable | Default | Description | | :--- | :--- | :--- | -| `MAX_AUTO_IMAGES` | `20` | Maximum training images per person in Frigate | +| `MAX_AUTO_IMAGES` | `5` | Maximum training images per person in Frigate | | `QUALITY_REPLACEMENT` | `true` | When at cap, swap a weaker tracked image for a better candidate. With Frigate scoring active, targets the most redundant image (highest pre-upload recognize score); otherwise uses blur score. Never touches manually added Frigate files. Set `false` to skip people at cap | #### Advanced Tuning *(calibrated — do not adjust)* diff --git a/compose.yml b/compose.yml index d1ce394..c1ce25e 100644 --- a/compose.yml +++ b/compose.yml @@ -31,7 +31,7 @@ services: # - USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true) # - MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # - BLUR_THRESHOLD=100.0 # Laplacian blur threshold; lower = accept more blur (default: 100.0) - # - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 20) + # - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 5) # ── Caching & Models ────────────────────────────────────────────────── # - FORCE_CPU=true # Disable GPU, fall back to CPU diff --git a/winnow/config.py b/winnow/config.py index 2a33680..48a62c5 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -132,7 +132,7 @@ class _Config: self.MERGE_DUPLICATE_PEOPLE = _getenv_bool("MERGE_DUPLICATE_PEOPLE", False) self.BLUR_THRESHOLD = _getenv_float("BLUR_THRESHOLD", 120.0) self.MIN_CONFIDENCE = _getenv_float("MIN_CONFIDENCE", 0.7) - self.MAX_AUTO_IMAGES = _getenv_int("MAX_AUTO_IMAGES", 20) + self.MAX_AUTO_IMAGES = _getenv_int("MAX_AUTO_IMAGES", 5) self.QUALITY_REPLACEMENT = _getenv_bool("QUALITY_REPLACEMENT", True) self.FRIGATE_SCORE_CEILING = _getenv_optional_float("FRIGATE_SCORE_CEILING") self.ENABLE_FRIGATE_SCORES = _getenv_bool("ENABLE_FRIGATE_SCORES", True) From 86caffc8d720939e72481f213aff94b6909d3fe7 Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:31:20 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix:=203=20audit=20findings=20=E2=80=94=20t?= =?UTF-8?q?ruthy=20skip-reason=20bug,=20500=20match=20consistency,=20CHANG?= =?UTF-8?q?ELOG=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. if saved: → if isinstance(saved, tuple): so string skip-reasons from process_face_mode no longer register as successes and create phantom asset_map entries with no JPEG on disk. Dead reason/fallback code in the else branch now correctly handles str and None returns. 2. "could not process" permanent-rejection check now uses error_detail (json message field, falling back to body[:100]) instead of full_body, keeping the match consistent with what is displayed to the user. 3. CHANGELOG [Unreleased] breaking-change note for MAX_AUTO_IMAGES 20→5 so upgrading users know to set the env var if they want the old cap. --- CHANGELOG.md | 4 ++++ winnow/executor.py | 7 +++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6966e91..92ff442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **`MAX_AUTO_IMAGES` default lowered from 20 to 5** — existing users who have not set this variable and already have more than 5 winnow-managed images in Frigate will find themselves at cap on the next run. With `QUALITY_REPLACEMENT=true` (the default), winnow will attempt to swap weaker images rather than uploading new ones. Set `MAX_AUTO_IMAGES=20` to restore the previous behaviour. + ## [0.6.5] - 2026-06-17 ### Added diff --git a/winnow/executor.py b/winnow/executor.py index 6eb0a9c..5b0aeb6 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -186,12 +186,11 @@ def execute_jobs(jobs: list[dict]) -> None: saved = process_face_mode( img, asset, person, person_dir, count, insightface_app=insightface_app ) - if saved: + if isinstance(saved, tuple): filename = f"{count}.jpg" asset_map[filename] = asset["id"] score_map[filename] = asset.get("quality_score") - if isinstance(saved, tuple): - dims_map[filename] = saved + dims_map[filename] = saved # Time-spread path: compute blur score from the downloaded # image. Capped at 1440px via blur_score_from_image() so the # scale matches the preview thumbnails the embedding path uses @@ -574,7 +573,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: _is_permanent = ( (resp.status_code == 400 and "face" in full_body.lower()) or resp.status_code == 422 - or (resp.status_code == 500 and "could not process" in full_body.lower()) + or (resp.status_code == 500 and "could not process" in error_detail.lower()) ) if _is_permanent: asset_id = asset_map.get(fname) From 5e0a8713147249217794367d431966d48d5ca5fd Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:47:31 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix:=202=20low=20findings=20from=20audit=20?= =?UTF-8?q?=E2=80=94=20consistent=20500=20match=20source,=20accurate=20ret?= =?UTF-8?q?urn=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use full_body for the 500 'could not process' permanent-rejection check, consistent with the 400 'face' check on the line above. error_detail is truncated to 100 chars via the fallback path, which could silently miss the phrase in a long response body. Remove | None from process_face_mode return type — every code path returns tuple[int,int] or str; None is unreachable. Update docstring to match. --- winnow/executor.py | 2 +- winnow/image_processing.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index 5b0aeb6..992c93d 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -573,7 +573,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: _is_permanent = ( (resp.status_code == 400 and "face" in full_body.lower()) or resp.status_code == 422 - or (resp.status_code == 500 and "could not process" in error_detail.lower()) + or (resp.status_code == 500 and "could not process" in full_body.lower()) ) if _is_permanent: asset_id = asset_map.get(fname) diff --git a/winnow/image_processing.py b/winnow/image_processing.py index c9c9b85..0d18d2b 100644 --- a/winnow/image_processing.py +++ b/winnow/image_processing.py @@ -68,11 +68,11 @@ def process_face_mode( count: int, min_width: int | None = None, insightface_app=None, -) -> tuple[int, int] | str | None: +) -> tuple[int, int] | str: """Crop face based on Immich metadata and save to output directory. - Returns (width, height) of the saved crop, a skip-reason string if the - face was filtered out, or None if no crop was saved for other reasons. + Returns (width, height) of the saved crop, or a skip-reason string if the + face was filtered out. When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True, re-detects the face in the Immich bbox region using InsightFace to get precise landmarks for a proper 112x112 aligned crop. Falls back to From 0906342edf8bbd62245753fa833fed50b15afae0 Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 19:12:58 +0000 Subject: [PATCH 6/7] fix: 10 correctness bugs from full codebase audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - immich_api: .get("items") or [] handles {"items": null} without crashing len() - immich_api: catch TypeError alongside ValueError in filter_recent_assets for timezone-naive fileCreatedAt comparisons - scheduler: catch SystemExit in addition to KeyboardInterrupt so cli.main() cannot kill the long-running scheduler process - scheduler: reseed croniter from wall-clock time after each run so overrunning jobs don't schedule an immediate back-to-back rerun - config: reject negative YEARS_FILTER values with a warning, reset to default 10 - frigate_api: return True (not False) for empty filenames list — callers cannot distinguish no-op from network failure on False - jobs: warn on unrecognised STRATEGY value instead of silently falling back - jobs: casefold ONLY_PEOPLE / SKIP_PEOPLE matching so "john doe" matches "John Doe" - executor: <= → < so a same-score candidate can fill a freed replacement slot - embeddings: set _insightface_loaded=True on GPU+CPU double-failure to prevent N re-init attempts (one per asset) when InsightFace is broken for a whole run --- scheduler.py | 3 ++- winnow/config.py | 3 +++ winnow/embeddings.py | 5 ++++- winnow/executor.py | 4 ++-- winnow/frigate_api.py | 4 +++- winnow/immich_api.py | 4 ++-- winnow/jobs.py | 14 +++++++++----- 7 files changed, 25 insertions(+), 12 deletions(-) diff --git a/scheduler.py b/scheduler.py index 99368d5..709868b 100644 --- a/scheduler.py +++ b/scheduler.py @@ -49,11 +49,12 @@ def _run_scheduler() -> None: try: main() print("winnow run complete", flush=True) - except KeyboardInterrupt: + except (KeyboardInterrupt, SystemExit): raise except Exception as e: logger.error("winnow run failed: %s", e, exc_info=True) print(f"winnow run failed: {e}", flush=True) + cron = croniter(schedule, time.time()) next_run = cron.get_next(float) print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True) time.sleep(min(60, max(1, next_run - time.time()))) diff --git a/winnow/config.py b/winnow/config.py index 48a62c5..57db3ad 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -127,6 +127,9 @@ class _Config: self.API_KEY = os.getenv("API_KEY") self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train") self.YEARS_FILTER = _getenv_int("YEARS_FILTER", 10) + if self.YEARS_FILTER < 0: + logging.warning("YEARS_FILTER=%s is negative — using default 10", self.YEARS_FILTER) + self.YEARS_FILTER = 10 self.MIN_FACE_WIDTH = _getenv_int("MIN_FACE_WIDTH", 90) self.MIN_FACE_COUNT = _getenv_int("MIN_FACE_COUNT", 3) self.MERGE_DUPLICATE_PEOPLE = _getenv_bool("MERGE_DUPLICATE_PEOPLE", False) diff --git a/winnow/embeddings.py b/winnow/embeddings.py index 7787da7..efd0264 100644 --- a/winnow/embeddings.py +++ b/winnow/embeddings.py @@ -107,7 +107,6 @@ def get_insightface_app(): global _insightface_app, _insightface_loaded if _insightface_loaded: return _insightface_app - _insightface_loaded = True ctx_id = -1 insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface")) @@ -172,10 +171,12 @@ def get_insightface_app(): _insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640)) logger.info("InsightFace Buffalo_L: ready on %s (%.1fs)", device_str, time.time() - t0) + _insightface_loaded = True return _insightface_app except ImportError: logger.error("InsightFace not installed!") + _insightface_loaded = True return None except Exception as e: logger.error("Failed to load InsightFace: %s", e) @@ -193,9 +194,11 @@ def get_insightface_app(): ) _insightface_app.prepare(ctx_id=-1, det_size=(640, 640)) logger.info("InsightFace Buffalo_L: ready on CPU (fallback, %.1fs)", time.time() - t0) + _insightface_loaded = True return _insightface_app except Exception as ex: logger.error("InsightFace CPU fallback failed: %s", ex) + _insightface_loaded = True return None diff --git a/winnow/executor.py b/winnow/executor.py index 992c93d..d8c04e4 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -384,9 +384,9 @@ def upload_to_frigate(jobs: list[dict]) -> None: # freed slot isn't filled with something worse than what we removed. if min_quality_score_for_slot is not None: file_score = score_map.get(fname) - if file_score is not None and file_score <= min_quality_score_for_slot: + if file_score is not None and file_score < min_quality_score_for_slot: progress.console.print( - f" [dim]⏭ {fname}: score {file_score:.3f} ≤ freed slot floor" + f" [dim]⏭ {fname}: score {file_score:.3f} < freed slot floor" f" {min_quality_score_for_slot:.3f}, skipping[/dim]" ) progress.advance(upload_task) diff --git a/winnow/frigate_api.py b/winnow/frigate_api.py index 3d15dd7..eba6939 100644 --- a/winnow/frigate_api.py +++ b/winnow/frigate_api.py @@ -146,8 +146,10 @@ def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool: Returns True on success, False if unreachable or the request fails. """ frigate_url = _get_frigate_url() - if not frigate_url or not filenames: + if not frigate_url: return False + if not filenames: + return True from urllib.parse import quote encoded_name = quote(person_name, safe="") try: diff --git a/winnow/immich_api.py b/winnow/immich_api.py index 71ade02..436cbc0 100644 --- a/winnow/immich_api.py +++ b/winnow/immich_api.py @@ -134,7 +134,7 @@ def fetch_all_assets(person: dict) -> tuple[list[dict], int]: # Immich ≥2.x returns {"assets": {"items": [...]}}; # earlier versions returned {"assets": [...]} directly. if isinstance(page_assets, dict): - page_assets = page_assets.get("items", []) + page_assets = page_assets.get("items") or [] page_count = len(page_assets) # raw count for termination check before filtering @@ -303,7 +303,7 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d recent.append(asset) else: skipped += 1 - except ValueError: + except (ValueError, TypeError): bad_timestamp += 1 continue diff --git a/winnow/jobs.py b/winnow/jobs.py index 656be22..71545f3 100644 --- a/winnow/jobs.py +++ b/winnow/jobs.py @@ -86,7 +86,11 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st "standard": (30, "smart"), "broad": (100, "smart"), } - return strategy_map.get(strategy, ("auto", "smart")) + result = strategy_map.get(strategy) + if result is None: + logger.warning("Unrecognised STRATEGY=%r — falling back to auto", strategy) + return ("auto", "smart") + return result def _perform_selection( @@ -244,13 +248,13 @@ def auto_configure(people: list[dict]) -> list[dict]: return [] strategy = os.environ.get("STRATEGY", "auto") - skip = [s.strip() for s in os.environ.get("SKIP_PEOPLE", "").split(",") if s.strip()] - only = [s.strip() for s in os.environ.get("ONLY_PEOPLE", "").split(",") if s.strip()] + skip = {s.strip().casefold() for s in os.environ.get("SKIP_PEOPLE", "").split(",") if s.strip()} + only = {s.strip().casefold() for s in os.environ.get("ONLY_PEOPLE", "").split(",") if s.strip()} if only: - valid_people = [p for p in valid_people if p["name"] in only] + valid_people = [p for p in valid_people if p["name"].casefold() in only] if skip: - valid_people = [p for p in valid_people if p["name"] not in skip] + valid_people = [p for p in valid_people if p["name"].casefold() not in skip] min_face_count = Config.MIN_FACE_COUNT From f3b5bd9334a0c501a46bd8ab0ddf9337caf9d6df Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 19:16:43 +0000 Subject: [PATCH 7/7] fix: update MAX_AUTO_IMAGES default assertion to 5 --- tests/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index 353a7c6..e093269 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,7 +21,7 @@ def test_config_loads_defaults(monkeypatch): assert cfg.MIN_FACE_COUNT == 3 assert cfg.BLUR_THRESHOLD == 120.0 assert cfg.MIN_CONFIDENCE == 0.7 - assert cfg.MAX_AUTO_IMAGES == 20 + assert cfg.MAX_AUTO_IMAGES == 5 assert cfg.QUALITY_REPLACEMENT is True assert cfg.FACE_MARGIN == 0.15 assert cfg.USE_FULL_RESOLUTION is True