automated update: 2026-06-10 22:25:47
This commit is contained in:
+4
-2
@@ -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
@@ -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,4 +1,4 @@
|
|||||||
from .cli import main
|
from if_curator.cli import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+8
-1
@@ -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
@@ -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
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
+42
-9
@@ -3,23 +3,56 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
from croniter import croniter
|
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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user