automated update: 2026-06-10 22:25:47

This commit is contained in:
root
2026-06-10 22:25:47 -04:00
parent 78ce09adaa
commit b9b734507b
13 changed files with 243 additions and 79 deletions
+4 -2
View File
@@ -22,8 +22,10 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& cp /root/.local/bin/uv /usr/local/bin/uv && cp /root/.local/bin/uv /usr/local/bin/uv
WORKDIR /app WORKDIR /app
RUN git clone --depth 1 https://github.com/sudolulo/if_curator_headless.git . \ # Copy project files instead of git clone for reproducible builds
&& uv sync --extra gpu && uv add croniter && uv cache clean COPY pyproject.toml uv.lock* ./
COPY if_curator/ if_curator/
RUN uv sync --extra gpu --extra object && uv add croniter && uv cache clean
COPY entrypoint.sh scheduler.py /app/ COPY entrypoint.sh scheduler.py /app/
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
+2 -1
View File
@@ -13,7 +13,7 @@ services:
- ENABLE_CACHE=true - ENABLE_CACHE=true
- CACHE_DIR=/app/.if_cache - CACHE_DIR=/app/.if_cache
- HF_HOME=/models/huggingface - HF_HOME=/models/huggingface
- INSIGHTFACE_HOME=/models/insightface - INSIGHTFACE_HOME=/models
# - ONLY_PEOPLE=John,Jane # - ONLY_PEOPLE=John,Jane
# - SKIP_PEOPLE=Unknown # - SKIP_PEOPLE=Unknown
# - MIN_FACE_COUNT=5 # - MIN_FACE_COUNT=5
@@ -37,3 +37,4 @@ services:
- driver: nvidia - driver: nvidia
count: all count: all
capabilities: [gpu] capabilities: [gpu]
+1 -1
View File
@@ -1,4 +1,4 @@
from .cli import main from if_curator.cli import main
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+8 -1
View File
@@ -80,8 +80,15 @@ _cache: EmbeddingCache | None = None
def get_cache(cache_dir: str = ".if_cache") -> EmbeddingCache: def get_cache(cache_dir: str = ".if_cache") -> EmbeddingCache:
"""Get or create the singleton cache instance.""" """Get or create the singleton cache instance.
Note: The ``cache_dir`` parameter is only used when creating the
singleton for the first time. Subsequent calls return the existing
instance regardless of ``cache_dir``. If you need a cache with a
different directory, instantiate ``EmbeddingCache`` directly.
"""
global _cache global _cache
if _cache is None: if _cache is None:
_cache = EmbeddingCache(cache_dir) _cache = EmbeddingCache(cache_dir)
return _cache return _cache
+31 -13
View File
@@ -14,7 +14,7 @@ from rich.table import Table
from .config import Config, ConfigManager from .config import Config, ConfigManager
from .diversity import select_diverse_assets from .diversity import select_diverse_assets
from .embeddings import is_embedding_available from .embeddings import is_embedding_available, load_embedding_model
from .image_processing import process_face_mode, process_full_mode, process_object_mode from .image_processing import process_face_mode, process_full_mode, process_object_mode
from .immich_api import fetch_all_assets, fetch_full_image, filter_recent_assets, get_people from .immich_api import fetch_all_assets, fetch_full_image, filter_recent_assets, get_people
from .logging import console, setup_logging from .logging import console, setup_logging
@@ -146,6 +146,7 @@ def interactive_configure(people: list[dict]) -> list[dict]:
return jobs return jobs
def auto_configure(people: list[dict]) -> list[dict]: def auto_configure(people: list[dict]) -> list[dict]:
"""Non-interactive: configure jobs for all named people automatically.""" """Non-interactive: configure jobs for all named people automatically."""
valid_people = sorted([p for p in people if p.get("name")], key=lambda x: x["name"]) valid_people = sorted([p for p in people if p.get("name")], key=lambda x: x["name"])
@@ -164,6 +165,13 @@ def auto_configure(people: list[dict]) -> list[dict]:
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"] not in skip]
# Filter by minimum face count (Issue #6: previously unimplemented)
min_face_count = Config.MIN_FACE_COUNT
if min_face_count > 0:
valid_people = [p for p in valid_people if p.get("assetCount", 0) >= min_face_count]
if valid_people:
rprint(f" Filtered to {len(valid_people)} people with ≥{min_face_count} assets (MIN_FACE_COUNT={min_face_count})")
jobs = [] jobs = []
for person in valid_people: for person in valid_people:
name = person["name"] name = person["name"]
@@ -239,6 +247,7 @@ def upload_to_frigate(jobs: list[dict]) -> None:
rprint(f" People: [bold]{len(jobs)}[/bold], Total images: [bold]{total_files}[/bold]") rprint(f" People: [bold]{len(jobs)}[/bold], Total images: [bold]{total_files}[/bold]")
uploaded, failed = 0, 0 uploaded, failed = 0, 0
max_retries = 2
with Progress( with Progress(
SpinnerColumn(), SpinnerColumn(),
@@ -276,6 +285,9 @@ def upload_to_frigate(jobs: list[dict]) -> None:
for fname in person_files: for fname in person_files:
fpath = os.path.join(person_dir, fname) fpath = os.path.join(person_dir, fname)
success = False
for attempt in range(1, max_retries + 1):
try: try:
with open(fpath, "rb") as f: with open(fpath, "rb") as f:
resp = requests.post( resp = requests.post(
@@ -286,34 +298,40 @@ def upload_to_frigate(jobs: list[dict]) -> None:
if resp.status_code == 200: if resp.status_code == 200:
uploaded += 1 uploaded += 1
person_uploaded += 1 person_uploaded += 1
success = True
break
else: else:
if attempt < max_retries:
logger.warning(f"Upload attempt {attempt}/{max_retries} for {fname}: HTTP {resp.status_code}, retrying...")
continue
failed += 1 failed += 1
person_failed += 1 person_failed += 1
progress.console.print( progress.console.print(
f" [red]✗ {fname}: HTTP {resp.status_code}[/red]" f" [red]✗ {fname}: HTTP {resp.status_code} (after {max_retries} attempts)[/red]"
) )
try: try:
error_detail = resp.json().get("message", resp.text[:100]) error_detail = resp.json().get("message", resp.text[:100])
progress.console.print(f" [dim]{error_detail}[/dim]") progress.console.print(f" [dim]{error_detail}[/dim]")
except Exception: except Exception:
progress.console.print(f" [dim]{resp.text[:100]}[/dim]") progress.console.print(f" [dim]{resp.text[:100]}[/dim]")
except requests.exceptions.ConnectionError: except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
if attempt < max_retries:
logger.warning(f"Upload attempt {attempt}/{max_retries} for {fname}: {type(exc).__name__}, retrying...")
continue
failed += 1 failed += 1
person_failed += 1 person_failed += 1
label = "Connection refused" if isinstance(exc, requests.exceptions.ConnectionError) else "Request timed out (30s)"
progress.console.print( progress.console.print(
f" [red]✗ {fname}: Connection refused[/red]" f" [red]✗ {fname}: {label} (after {max_retries} attempts)[/red]"
)
except requests.exceptions.Timeout:
failed += 1
person_failed += 1
progress.console.print(
f" [red]✗ {fname}: Request timed out (30s)[/red]"
) )
except Exception as e: except Exception as e:
if attempt < max_retries:
logger.warning(f"Upload attempt {attempt}/{max_retries} for {fname}: {type(e).__name__}, retrying...")
continue
failed += 1 failed += 1
person_failed += 1 person_failed += 1
progress.console.print( progress.console.print(
f" [red]✗ {fname}: {type(e).__name__} - {e}[/red]" f" [red]✗ {fname}: {type(e).__name__} - {e} (after {max_retries} attempts)[/red]"
) )
progress.advance(upload_task) progress.advance(upload_task)
@@ -349,8 +367,8 @@ def _perform_selection(assets: list, limit: int | str, name: str, selection_mode
model_display = "InsightFace (face embeddings)" if entity_type == "face" else "SigLIP (visual embeddings)" model_display = "InsightFace (face embeddings)" if entity_type == "face" else "SigLIP (visual embeddings)"
rprint(f"\n[cyan]Using {model_display} for diversity analysis...[/cyan]") rprint(f"\n[cyan]Using {model_display} for diversity analysis...[/cyan]")
# Pre-load model to avoid interference with progress bar # Pre-load model explicitly (separate from availability check)
is_embedding_available(entity_type) load_embedding_model(entity_type)
with Progress( with Progress(
SpinnerColumn(), SpinnerColumn(),
+54 -7
View File
@@ -14,10 +14,10 @@ load_dotenv()
CONFIG_FILE = Path(".immich_config.json") CONFIG_FILE = Path(".immich_config.json")
class Config: class _Config:
"""Singleton configuration with uppercase attribute access for backward compatibility.""" """Singleton configuration with uppercase attribute access for backward compatibility."""
_instance: ClassVar["Config | None"] = None _instance: ClassVar["_Config | None"] = None
# Configuration values # Configuration values
IMMICH_URL: str | None = None IMMICH_URL: str | None = None
@@ -26,11 +26,14 @@ class Config:
YEARS_FILTER: int = 10 YEARS_FILTER: int = 10
# Quality filtering # Quality filtering
MIN_FACE_WIDTH: int = 100 MIN_FACE_WIDTH: int = 50
BLUR_THRESHOLD: float = 100.0 BLUR_THRESHOLD: float = 100.0
MIN_CONFIDENCE: float = 0.7 MIN_CONFIDENCE: float = 0.7
MAX_AUTO_IMAGES: int = 80 MAX_AUTO_IMAGES: int = 80
# People filtering
MIN_FACE_COUNT: int = 0
# Output quality # Output quality
FACE_MARGIN: float = 0.15 FACE_MARGIN: float = 0.15
USE_FULL_RESOLUTION: bool = True USE_FULL_RESOLUTION: bool = True
@@ -40,7 +43,7 @@ class Config:
ENABLE_CACHE: bool = False ENABLE_CACHE: bool = False
CACHE_DIR: str = ".if_cache" CACHE_DIR: str = ".if_cache"
def __new__(cls) -> "Config": def __new__(cls) -> "_Config":
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
cls._instance._load() cls._instance._load()
@@ -54,6 +57,9 @@ class Config:
self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train") self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train")
self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10")) self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10"))
self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "50")) self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "50"))
self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "0"))
self.ENABLE_CACHE = os.getenv("ENABLE_CACHE", "false").lower() in ("true", "1", "yes")
self.CACHE_DIR = os.getenv("CACHE_DIR", ".if_cache")
# Fall back to config file for missing values # Fall back to config file for missing values
if CONFIG_FILE.exists(): if CONFIG_FILE.exists():
@@ -66,6 +72,11 @@ class Config:
except (json.JSONDecodeError, OSError) as e: except (json.JSONDecodeError, OSError) as e:
logging.warning(f"Failed to load config file: {e}") logging.warning(f"Failed to load config file: {e}")
@classmethod
def reset(cls) -> None:
"""Reset the singleton — mainly useful for testing or delayed env setup."""
cls._instance = None
def save(self) -> None: def save(self) -> None:
"""Persist configuration to file.""" """Persist configuration to file."""
try: try:
@@ -105,11 +116,47 @@ class Config:
raise ValueError("Missing Immich URL or API Key.") raise ValueError("Missing Immich URL or API Key.")
# Singleton instance and backward-compatible aliases # Singleton instance — use a lazy property pattern to avoid import-time side effects
Config = Config() # type: ignore[misc] # when env vars aren't yet set. Call Config.instance() or just access attributes on
ConfigManager = type("ConfigManager", (), {"get": staticmethod(lambda: Config)}) # the module-level `Config` (which delegates to the singleton).
class _ConfigAccessor:
"""Lazy accessor that defers singleton creation until first attribute access.
This avoids reading .env and config files at import time, so environment
variables set after importing the module are properly picked up.
"""
def __getattr__(self, name: str):
return getattr(_Config(), name)
def __setattr__(self, name: str, value):
if name.startswith("_"):
super().__setattr__(name, value)
else:
setattr(_Config(), name, value)
def reset(self) -> None:
"""Reset the underlying singleton."""
_Config.reset()
def interactive_setup(self) -> None:
"""Delegate to the singleton."""
_Config().interactive_setup()
def validate(self) -> None:
"""Delegate to the singleton."""
_Config().validate()
def save(self) -> None:
"""Delegate to the singleton."""
_Config().save()
Config = _ConfigAccessor()
ConfigManager = type("ConfigManager", (), {"get": staticmethod(lambda: _Config())})
def get_headers() -> dict[str, str]: def get_headers() -> dict[str, str]:
"""Return HTTP headers for Immich API requests.""" """Return HTTP headers for Immich API requests."""
return {"x-api-key": Config.API_KEY or "", "Accept": "application/json"} return {"x-api-key": Config.API_KEY or "", "Accept": "application/json"}
+47 -6
View File
@@ -7,6 +7,7 @@ Unified embedding interface for faces and objects.
""" """
import contextlib import contextlib
import importlib
import logging import logging
import os import os
import warnings import warnings
@@ -21,8 +22,10 @@ logger = logging.getLogger(__name__)
# Lazy-loaded singletons # Lazy-loaded singletons
_insightface_app = None _insightface_app = None
_insightface_loaded = False
_siglip_model = None _siglip_model = None
_siglip_processor = None _siglip_processor = None
_siglip_loaded = False
def _is_force_cpu() -> bool: def _is_force_cpu() -> bool:
@@ -37,9 +40,10 @@ def _is_force_cpu() -> bool:
def get_insightface_app(): def get_insightface_app():
"""Singleton for InsightFace app with automatic GPU/CPU fallback.""" """Singleton for InsightFace app with automatic GPU/CPU fallback."""
global _insightface_app global _insightface_app, _insightface_loaded
if _insightface_app is not None: if _insightface_loaded:
return _insightface_app return _insightface_app
_insightface_loaded = True
try: try:
import onnxruntime as ort import onnxruntime as ort
@@ -120,9 +124,10 @@ def get_face_embedding(img_pil: Image.Image) -> np.ndarray | None:
def get_siglip_model(): def get_siglip_model():
"""Singleton for SigLIP model and processor with GPU auto-detection.""" """Singleton for SigLIP model and processor with GPU auto-detection."""
global _siglip_model, _siglip_processor global _siglip_model, _siglip_processor, _siglip_loaded
if _siglip_model is not None: if _siglip_loaded:
return _siglip_model, _siglip_processor return _siglip_model, _siglip_processor
_siglip_loaded = True
try: try:
import warnings import warnings
@@ -264,9 +269,45 @@ def get_embedding(
return emb return emb
def is_embedding_available(entity_type: str = "face") -> bool: def _is_module_available(module_name: str) -> bool:
"""Check if embedding model is available for the given entity type.""" """Check if a Python module is importable without importing it fully."""
try:
importlib.util.find_spec(module_name)
return True
except (ModuleNotFoundError, ValueError):
return False
def is_embedding_available(entity_type: str = "face", *, load: bool = False) -> bool:
"""Check if embedding model is available for the given entity type.
By default this performs a lightweight import-check only (no model loading).
Pass ``load=True`` to actually load the model (expensive, hundreds of MB).
Args:
entity_type: 'face' or 'object'
load: If True, fully load the model to verify. If False (default),
only check that the required packages are importable.
"""
if load:
if entity_type == "face": if entity_type == "face":
return get_insightface_app() is not None return get_insightface_app() is not None
model, _ = get_siglip_model() model, _ = get_siglip_model()
return model is not None return model is not None
# Lightweight check: just verify the packages are importable
if entity_type == "face":
return _is_module_available("insightface") and _is_module_available("onnxruntime")
return _is_module_available("transformers") and _is_module_available("torch")
def load_embedding_model(entity_type: str = "face") -> bool:
"""Explicitly load the embedding model for the given entity type.
Returns True if the model loaded successfully.
"""
if entity_type == "face":
return get_insightface_app() is not None
model, _ = get_siglip_model()
return model is not None
+7 -4
View File
@@ -140,15 +140,17 @@ def process_object_mode(
results = model(img, verbose=False, device=device) results = model(img, verbose=False, device=device)
found = False found = False
for idx, (box, cls_id, conf) in enumerate( class_idx = 0 # Sequential counter per target class (Issue #10)
(box, int(box.cls[0]), float(box.conf[0])) for r in results for box in r.boxes for box in (box for r in results for box in r.boxes):
): cls_id = int(box.cls[0])
conf = float(box.conf[0])
if 0 <= cls_id < len(model.names) and model.names[cls_id] == target_class and conf > 0.5: if 0 <= cls_id < len(model.names) and model.names[cls_id] == target_class and conf > 0.5:
x1, y1, x2, y2 = box.xyxy[0].tolist() x1, y1, x2, y2 = box.xyxy[0].tolist()
img.crop((x1, y1, x2, y2)).save( img.crop((x1, y1, x2, y2)).save(
os.path.join(output_dir, f"{count}_{idx}.jpg"), os.path.join(output_dir, f"{count}_{class_idx}.jpg"),
format="JPEG", format="JPEG",
) )
class_idx += 1
found = True found = True
return found return found
@@ -161,3 +163,4 @@ def process_full_mode(img: Image.Image, output_dir: str, count: int) -> bool:
"""Save full image.""" """Save full image."""
img.save(os.path.join(output_dir, f"{count}.jpg"), format="JPEG") img.save(os.path.join(output_dir, f"{count}.jpg"), format="JPEG")
return True return True
+4 -1
View File
@@ -13,6 +13,8 @@ from .config import Config, get_headers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAX_PAGES = 1000 # Safety limit for pagination
@dataclass @dataclass
class FaceData: class FaceData:
@@ -50,7 +52,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
logger.info(f"Fetching assets for {name}...") logger.info(f"Fetching assets for {name}...")
assets = [] assets = []
for page in range(1, 1000): # Safety limit for page in range(1, MAX_PAGES + 1):
try: try:
resp = requests.post( resp = requests.post(
url, url,
@@ -209,3 +211,4 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d
logger.info(f"Retained {len(recent)} assets (filtered {skipped} old assets).") logger.info(f"Retained {len(recent)} assets (filtered {skipped} old assets).")
return recent return recent
+7 -2
View File
@@ -1,6 +1,7 @@
"""Logging configuration for if-curator.""" """Logging configuration for if-curator."""
import logging import logging
import os
import warnings import warnings
from rich.console import Console from rich.console import Console
@@ -33,8 +34,11 @@ def setup_logging(verbose: bool = False) -> logging.Logger:
# Rich console handler - uses shared console to avoid breaking progress bars # Rich console handler - uses shared console to avoid breaking progress bars
root.addHandler(RichHandler(rich_tracebacks=True, markup=True, console=console)) root.addHandler(RichHandler(rich_tracebacks=True, markup=True, console=console))
# File handler (always debug level) # File handler (always debug level) — log file respects OUTPUT_DIR if set
file_handler = logging.FileHandler("immich_export.log") log_dir = os.environ.get("OUTPUT_DIR", ".")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, "immich_export.log")
file_handler = logging.FileHandler(log_path)
file_handler.setLevel(logging.DEBUG) file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
root.addHandler(file_handler) root.addHandler(file_handler)
@@ -48,3 +52,4 @@ def setup_logging(verbose: bool = False) -> logging.Logger:
warnings.filterwarnings("ignore", category=FutureWarning, module="transformers") warnings.filterwarnings("ignore", category=FutureWarning, module="transformers")
return root return root
+3 -2
View File
@@ -70,7 +70,7 @@ def check_exposure(img_np: np.ndarray, lo: float = 30.0, hi: float = 225.0) -> t
return True, "" return True, ""
def check_face_size(face_width: float, face_height: float, min_px: int = 100) -> tuple[bool, str]: def check_face_size(face_width: float, face_height: float, min_px: int = 50) -> tuple[bool, str]:
"""Validate face crop is large enough for meaningful features.""" """Validate face crop is large enough for meaningful features."""
if face_width < min_px or face_height < min_px: if face_width < min_px or face_height < min_px:
return False, f"Face too small ({face_width:.0f}x{face_height:.0f}, min={min_px}px)" return False, f"Face too small ({face_width:.0f}x{face_height:.0f}, min={min_px}px)"
@@ -94,7 +94,7 @@ def assess_quality(
face_bbox: tuple[float, float, float, float] | None = None, face_bbox: tuple[float, float, float, float] | None = None,
confidence: float | None = None, confidence: float | None = None,
blur_threshold: float = 100.0, blur_threshold: float = 100.0,
min_face_px: int = 100, min_face_px: int = 50,
min_confidence: float = 0.7, min_confidence: float = 0.7,
) -> QualityResult: ) -> QualityResult:
"""Run all quality checks on an image. """Run all quality checks on an image.
@@ -130,3 +130,4 @@ def assess_quality(
reasons.append(reason) reasons.append(reason)
return QualityResult(passed=len(reasons) == 0, reasons=reasons) return QualityResult(passed=len(reasons) == 0, reasons=reasons)
+8 -5
View File
@@ -5,7 +5,7 @@ description = "Immich to Frigate training sets"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "ds-sebastian" }] authors = [{ name = "sudolulo" }]
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"] keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
@@ -23,21 +23,23 @@ dependencies = [
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"requests>=2.32.5", "requests>=2.32.5",
"rich>=14.2.0", "rich>=14.2.0",
"torch>=2.9.1",
"transformers>=4.57.6",
"ultralytics>=8.3.252",
] ]
[project.scripts] [project.scripts]
if-curator = "if_curator.cli:main" if-curator = "if_curator.cli:main"
[project.urls] [project.urls]
Repository = "https://github.com/ds-sebastian/if_curator" Repository = "https://github.com/sudolulo/if_curator_headless"
[project.optional-dependencies] [project.optional-dependencies]
gpu = [ gpu = [
"onnxruntime-gpu>=1.23.2", "onnxruntime-gpu>=1.23.2",
] ]
object = [
"torch>=2.9.1",
"transformers>=4.57.6",
"ultralytics>=8.3.252",
]
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -75,3 +77,4 @@ DEP002 = ["onnxruntime-gpu"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
+43 -10
View File
@@ -3,23 +3,56 @@ import os
import sys import sys
import subprocess import subprocess
import time import time
from croniter import croniter import logging
from pathlib import Path
try:
from croniter import croniter
except ImportError:
print("❌ croniter not installed. Run: uv add croniter")
sys.exit(1)
SCHEDULE = os.environ["CRON_SCHEDULE"]
MODELS_DIR = os.environ.get("HF_HOME", "/models/huggingface")
INSIGHTFACE_BASE = os.environ.get("INSIGHTFACE_HOME", "/models")
SCHEDULE = os.environ.get("CRON_SCHEDULE", "0 3 * * SUN")
RUN_ENV = {**os.environ, "PYTHONUNBUFFERED": "1"} RUN_ENV = {**os.environ, "PYTHONUNBUFFERED": "1"}
def run_curator(): logger = logging.getLogger(__name__)
print(f"\n▶ [{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting if-curator...", flush=True)
subprocess.run(["uv", "run", "python", "-m", "if_curator.cli"], env=RUN_ENV)
now = time.time()
cron = croniter(SCHEDULE, now) def check_models():
"""Log model status before each run."""
print("📦 Checking models...", flush=True)
buffalo = Path(INSIGHTFACE_BASE) / ".insightface" / "models" / "buffalo_l"
if buffalo.exists():
print(" ✅ InsightFace Buffalo_L: present", flush=True)
else:
print(" ⬇️ InsightFace Buffalo_L: not found — will download", flush=True)
hf_hub = Path(MODELS_DIR) / "hub"
if hf_hub.exists() and any(hf_hub.iterdir()):
print(" ✅ HuggingFace models: present", flush=True)
else:
print(" ⬇️ HuggingFace models: not found — will download", flush=True)
print("🚀 Starting if-curator...", flush=True)
NOW = time.time()
cron = croniter(SCHEDULE, NOW)
next_run = cron.get_next(float) next_run = cron.get_next(float)
print(f"▶ Scheduler active. Next run: {time.ctime(next_run)}")
while True: while True:
if time.time() >= next_run: now = time.time()
run_curator() if now >= next_run:
print(f"\n▶ [{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting if-curator...", flush=True)
check_models()
result = subprocess.run(["uv", "run", "if-curator"], env=RUN_ENV)
if result.returncode != 0:
logger.error(f"if-curator exited with code {result.returncode}")
print(f"❌ if-curator failed with exit code {result.returncode}", flush=True)
else:
print("✅ if-curator completed successfully", flush=True)
next_run = cron.get_next(float) next_run = cron.get_next(float)
time.sleep(60) time.sleep(60)