Merge pull request #42 from sudolulo/fix/frigate-500-permanent-rejection

fix: Frigate 500 permanent rejection + 13 correctness bugs
This commit is contained in:
2026-06-17 15:17:20 -04:00
committed by GitHub
12 changed files with 47 additions and 26 deletions
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.6.5] - 2026-06-17
### Added ### Added
+1 -1
View File
@@ -187,7 +187,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
| Variable | Default | Description | | 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 | | `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)* #### Advanced Tuning *(calibrated — do not adjust)*
+1 -1
View File
@@ -31,7 +31,7 @@ services:
# - USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true) # - USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true)
# - MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # - 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) # - 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 ────────────────────────────────────────────────── # ── Caching & Models ──────────────────────────────────────────────────
# - FORCE_CPU=true # Disable GPU, fall back to CPU # - FORCE_CPU=true # Disable GPU, fall back to CPU
+2 -1
View File
@@ -49,11 +49,12 @@ def _run_scheduler() -> None:
try: try:
main() main()
print("winnow run complete", flush=True) print("winnow run complete", flush=True)
except KeyboardInterrupt: except (KeyboardInterrupt, SystemExit):
raise raise
except Exception as e: except Exception as e:
logger.error("winnow run failed: %s", e, exc_info=True) logger.error("winnow run failed: %s", e, exc_info=True)
print(f"winnow run failed: {e}", flush=True) print(f"winnow run failed: {e}", flush=True)
cron = croniter(schedule, time.time())
next_run = cron.get_next(float) next_run = cron.get_next(float)
print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True) 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()))) time.sleep(min(60, max(1, next_run - time.time())))
+1 -1
View File
@@ -21,7 +21,7 @@ def test_config_loads_defaults(monkeypatch):
assert cfg.MIN_FACE_COUNT == 3 assert cfg.MIN_FACE_COUNT == 3
assert cfg.BLUR_THRESHOLD == 120.0 assert cfg.BLUR_THRESHOLD == 120.0
assert cfg.MIN_CONFIDENCE == 0.7 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.QUALITY_REPLACEMENT is True
assert cfg.FACE_MARGIN == 0.15 assert cfg.FACE_MARGIN == 0.15
assert cfg.USE_FULL_RESOLUTION is True assert cfg.USE_FULL_RESOLUTION is True
+4 -1
View File
@@ -127,12 +127,15 @@ class _Config:
self.API_KEY = os.getenv("API_KEY") self.API_KEY = os.getenv("API_KEY")
self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train") self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train")
self.YEARS_FILTER = _getenv_int("YEARS_FILTER", 10) 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_WIDTH = _getenv_int("MIN_FACE_WIDTH", 90)
self.MIN_FACE_COUNT = _getenv_int("MIN_FACE_COUNT", 3) self.MIN_FACE_COUNT = _getenv_int("MIN_FACE_COUNT", 3)
self.MERGE_DUPLICATE_PEOPLE = _getenv_bool("MERGE_DUPLICATE_PEOPLE", False) self.MERGE_DUPLICATE_PEOPLE = _getenv_bool("MERGE_DUPLICATE_PEOPLE", False)
self.BLUR_THRESHOLD = _getenv_float("BLUR_THRESHOLD", 120.0) self.BLUR_THRESHOLD = _getenv_float("BLUR_THRESHOLD", 120.0)
self.MIN_CONFIDENCE = _getenv_float("MIN_CONFIDENCE", 0.7) 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.QUALITY_REPLACEMENT = _getenv_bool("QUALITY_REPLACEMENT", True)
self.FRIGATE_SCORE_CEILING = _getenv_optional_float("FRIGATE_SCORE_CEILING") self.FRIGATE_SCORE_CEILING = _getenv_optional_float("FRIGATE_SCORE_CEILING")
self.ENABLE_FRIGATE_SCORES = _getenv_bool("ENABLE_FRIGATE_SCORES", True) self.ENABLE_FRIGATE_SCORES = _getenv_bool("ENABLE_FRIGATE_SCORES", True)
+4 -1
View File
@@ -107,7 +107,6 @@ def get_insightface_app():
global _insightface_app, _insightface_loaded global _insightface_app, _insightface_loaded
if _insightface_loaded: if _insightface_loaded:
return _insightface_app return _insightface_app
_insightface_loaded = True
ctx_id = -1 ctx_id = -1
insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface")) 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)) _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) logger.info("InsightFace Buffalo_L: ready on %s (%.1fs)", device_str, time.time() - t0)
_insightface_loaded = True
return _insightface_app return _insightface_app
except ImportError: except ImportError:
logger.error("InsightFace not installed!") logger.error("InsightFace not installed!")
_insightface_loaded = True
return None return None
except Exception as e: except Exception as e:
logger.error("Failed to load InsightFace: %s", 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)) _insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
logger.info("InsightFace Buffalo_L: ready on CPU (fallback, %.1fs)", time.time() - t0) logger.info("InsightFace Buffalo_L: ready on CPU (fallback, %.1fs)", time.time() - t0)
_insightface_loaded = True
return _insightface_app return _insightface_app
except Exception as ex: except Exception as ex:
logger.error("InsightFace CPU fallback failed: %s", ex) logger.error("InsightFace CPU fallback failed: %s", ex)
_insightface_loaded = True
return None return None
+7 -6
View File
@@ -186,11 +186,10 @@ def execute_jobs(jobs: list[dict]) -> None:
saved = process_face_mode( saved = process_face_mode(
img, asset, person, person_dir, count, insightface_app=insightface_app img, asset, person, person_dir, count, insightface_app=insightface_app
) )
if saved: if isinstance(saved, tuple):
filename = f"{count}.jpg" filename = f"{count}.jpg"
asset_map[filename] = asset["id"] asset_map[filename] = asset["id"]
score_map[filename] = asset.get("quality_score") 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 # Time-spread path: compute blur score from the downloaded
# image. Capped at 1440px via blur_score_from_image() so the # image. Capped at 1440px via blur_score_from_image() so the
@@ -202,8 +201,9 @@ def execute_jobs(jobs: list[dict]) -> None:
count += 1 count += 1
else: else:
reason = saved if isinstance(saved, str) else "no usable face data"
progress.console.print( progress.console.print(
f"[yellow]Skipped {asset['id']} (no usable face data)[/yellow]" f"[yellow]Skipped {asset['id']} ({reason})[/yellow]"
) )
except Exception as e: except Exception as e:
logger.error("Failed to process asset %s: %s", asset.get("id", "<unknown>"), e) logger.error("Failed to process asset %s: %s", asset.get("id", "<unknown>"), e)
@@ -384,9 +384,9 @@ def upload_to_frigate(jobs: list[dict]) -> None:
# freed slot isn't filled with something worse than what we removed. # freed slot isn't filled with something worse than what we removed.
if min_quality_score_for_slot is not None: if min_quality_score_for_slot is not None:
file_score = score_map.get(fname) 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( 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]" f" {min_quality_score_for_slot:.3f}, skipping[/dim]"
) )
progress.advance(upload_task) progress.advance(upload_task)
@@ -566,13 +566,14 @@ def upload_to_frigate(jobs: list[dict]) -> None:
error_detail = resp.json().get("message", full_body[:100]) error_detail = resp.json().get("message", full_body[:100])
except Exception: except Exception:
error_detail = full_body[:100] 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]") progress.console.print(f" [dim]{error_detail}[/dim]")
else: else:
logger.debug("%s HTTP %s: %s", fname, resp.status_code, error_detail) logger.debug("%s HTTP %s: %s", fname, resp.status_code, error_detail)
_is_permanent = ( _is_permanent = (
(resp.status_code == 400 and "face" in full_body.lower()) (resp.status_code == 400 and "face" in full_body.lower())
or resp.status_code == 422 or resp.status_code == 422
or (resp.status_code == 500 and "could not process" in full_body.lower())
) )
if _is_permanent: if _is_permanent:
asset_id = asset_map.get(fname) asset_id = asset_map.get(fname)
+3 -1
View File
@@ -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. Returns True on success, False if unreachable or the request fails.
""" """
frigate_url = _get_frigate_url() frigate_url = _get_frigate_url()
if not frigate_url or not filenames: if not frigate_url:
return False return False
if not filenames:
return True
from urllib.parse import quote from urllib.parse import quote
encoded_name = quote(person_name, safe="") encoded_name = quote(person_name, safe="")
try: try:
+7 -4
View File
@@ -48,6 +48,8 @@ def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> I
if lm.shape != (5, 2): if lm.shape != (5, 2):
logger.debug("Invalid landmark shape: %s, expected (5, 2)", lm.shape) logger.debug("Invalid landmark shape: %s, expected (5, 2)", lm.shape)
return None return None
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message=".*estimate.*is deprecated", category=FutureWarning)
aligned = norm_crop(img_np, lm) aligned = norm_crop(img_np, lm)
return Image.fromarray(aligned) return Image.fromarray(aligned)
except ImportError: except ImportError:
@@ -66,10 +68,11 @@ def process_face_mode(
count: int, count: int,
min_width: int | None = None, min_width: int | None = None,
insightface_app=None, insightface_app=None,
) -> tuple[int, int] | None: ) -> tuple[int, int] | str:
"""Crop face based on Immich metadata and save to output directory. """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, or a skip-reason string if the
face was filtered out.
When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True, When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True,
re-detects the face in the Immich bbox region using InsightFace to get re-detects the face in the Immich bbox region using InsightFace to get
precise landmarks for a proper 112x112 aligned crop. Falls back to precise landmarks for a proper 112x112 aligned crop. Falls back to
@@ -89,7 +92,7 @@ def process_face_mode(
if not face_info: if not face_info:
logger.debug("No face info for %s in asset %s", person.get("name"), asset.get("id")) 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 img_w, img_h = img.size
meta_w = face_info.get("imageWidth") or 0 meta_w = face_info.get("imageWidth") or 0
@@ -108,7 +111,7 @@ def process_face_mode(
face_w, face_h = x2 - x1, y2 - y1 face_w, face_h = x2 - x1, y2 - y1
if face_w < min_width or face_h < min_width: if face_w < min_width or face_h < min_width:
logger.debug("Face too small (%.1fx%.1f)", face_w, face_h) 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. # Re-detect face with InsightFace for landmark-based alignment.
# Immich's /api/faces endpoint does not include landmarks, so the # Immich's /api/faces endpoint does not include landmarks, so the
+2 -2
View File
@@ -134,7 +134,7 @@ def fetch_all_assets(person: dict) -> tuple[list[dict], int]:
# Immich ≥2.x returns {"assets": {"items": [...]}}; # Immich ≥2.x returns {"assets": {"items": [...]}};
# earlier versions returned {"assets": [...]} directly. # earlier versions returned {"assets": [...]} directly.
if isinstance(page_assets, dict): 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 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) recent.append(asset)
else: else:
skipped += 1 skipped += 1
except ValueError: except (ValueError, TypeError):
bad_timestamp += 1 bad_timestamp += 1
continue continue
+9 -5
View File
@@ -86,7 +86,11 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st
"standard": (30, "smart"), "standard": (30, "smart"),
"broad": (100, "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( def _perform_selection(
@@ -244,13 +248,13 @@ def auto_configure(people: list[dict]) -> list[dict]:
return [] return []
strategy = os.environ.get("STRATEGY", "auto") strategy = os.environ.get("STRATEGY", "auto")
skip = [s.strip() for s in os.environ.get("SKIP_PEOPLE", "").split(",") if s.strip()] skip = {s.strip().casefold() 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()] only = {s.strip().casefold() for s in os.environ.get("ONLY_PEOPLE", "").split(",") if s.strip()}
if only: 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: 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 min_face_count = Config.MIN_FACE_COUNT