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]
### 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
+1 -1
View File
@@ -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)*
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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())))
+1 -1
View File
@@ -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
+4 -1
View File
@@ -127,12 +127,15 @@ 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)
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)
+4 -1
View File
@@ -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
+7 -6
View File
@@ -186,11 +186,10 @@ 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
# Time-spread path: compute blur score from the downloaded
# image. Capped at 1440px via blur_score_from_image() so the
@@ -202,8 +201,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", "<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.
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)
@@ -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)
+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.
"""
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:
+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):
logger.debug("Invalid landmark shape: %s, expected (5, 2)", lm.shape)
return None
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:
@@ -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:
"""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,
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
+2 -2
View File
@@ -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
+9 -5
View File
@@ -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