Merge pull request #42 from sudolulo/fix/frigate-500-permanent-rejection
fix: Frigate 500 permanent rejection + 13 correctness bugs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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())))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+8
-7
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user