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]
|
## [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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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())))
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user