diff --git a/.github/workflows/update-lockfile.yml b/.github/workflows/update-lockfile.yml new file mode 100644 index 0000000..1c1e139 --- /dev/null +++ b/.github/workflows/update-lockfile.yml @@ -0,0 +1,53 @@ +# .github/workflows/update-lockfile.yml +name: Update uv.lock + +on: + push: + paths: + - 'pyproject.toml' + workflow_dispatch: + +jobs: + update-lockfile: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + echo "Disk space freed." + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install 3.12 + + - name: Regenerate lockfile + run: uv lock + + - name: Check for changes + id: diff + run: | + if git diff --quiet uv.lock; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push updated lockfile + if: steps.diff.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add uv.lock + git commit -m "chore: update uv.lock" + git push + diff --git a/Dockerfile b/Dockerfile index 0f0af30..f91c8bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,10 @@ RUN chmod +x /app/entrypoint.sh # ── Runtime stage ───────────────────────────────────────────────────────── FROM build AS runtime +# Expose cuDNN libraries installed by nvidia-cudnn-cu12 pip package +# so onnxruntime-gpu can find libcudnn.so.9 at runtime +ENV LD_LIBRARY_PATH="/app/.venv/lib/python3.12/site-packages/nvidia/cudnn/lib:${LD_LIBRARY_PATH}" + RUN groupadd -g 568 apps && useradd -u 568 -g apps -m -s /bin/bash appuser \ && mkdir -p /models/.insightface /models/huggingface \ && chown -R appuser:apps /app /models diff --git a/if_curator/cli.py b/if_curator/cli.py index d6888d1..3f842e0 100644 --- a/if_curator/cli.py +++ b/if_curator/cli.py @@ -16,7 +16,7 @@ from .config import Config, ConfigManager from .diversity import select_diverse_assets from .embeddings import is_embedding_available, load_embedding_model 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_face_data, fetch_full_image, filter_recent_assets, get_people from .logging import console, setup_logging logger = logging.getLogger(__name__) @@ -422,6 +422,51 @@ def _show_preview(jobs: list[dict]) -> None: console.print() +def _enrich_asset_with_face_data(asset: dict, person: dict) -> dict: + """Enrich an asset dict with face data from the Immich faces API. + + The search/metadata endpoint does not include face bounding box data, + so we fetch it from GET /api/faces?id={asset_id} and inject it into + the asset's "people" field so process_face_mode can find it. + + Returns the enriched asset dict (modifies in place and returns it). + """ + person_id = person["id"] + face_data = fetch_face_data(asset["id"], person_id=person_id) + + if face_data is None: + logger.debug( + f"Face data API returned nothing for {person.get('name')} " + f"in asset {asset.get('id')} — Immich may not have detected a face" + ) + return asset + + # Skip zero-area bounding boxes (face detection failed or no face found) + if face_data.bbox == (0, 0, 0, 0): + logger.debug( + f"Zero-area bounding box for {person.get('name')} in asset {asset.get('id')}" + ) + return asset + + logger.debug( + f"Got face data for {person.get('name')} in asset {asset.get('id')}: " + f"bbox={face_data.bbox}, img_size={face_data.image_width}x{face_data.image_height}" + ) + + face_info = { + "boundingBoxX1": face_data.bbox[0], + "boundingBoxY1": face_data.bbox[1], + "boundingBoxX2": face_data.bbox[2], + "boundingBoxY2": face_data.bbox[3], + "imageWidth": face_data.image_width, + "imageHeight": face_data.image_height, + } + + # Inject into asset so process_face_mode can find it via asset["people"] + asset["people"] = [{"id": person_id, "faces": [face_info]}] + return asset + + def execute_jobs(jobs: list[dict]) -> None: """Download and process images for all jobs.""" if not jobs: @@ -450,8 +495,17 @@ def execute_jobs(jobs: list[dict]) -> None: os.makedirs(person_dir, exist_ok=True) count = 0 + skipped_download = 0 + skipped_no_face = 0 + skipped_other = 0 + for asset in assets: try: + # For face mode, enrich the asset with face bounding box data + # from the Immich faces API (not included in search/metadata results) + if mode == "face": + asset = _enrich_asset_with_face_data(asset, person) + # Use full-resolution for final output when configured if use_full_res: img = fetch_full_image(asset["id"]) @@ -464,7 +518,9 @@ def execute_jobs(jobs: list[dict]) -> None: img = Image.open(BytesIO(resp.content)) if resp.ok else None if img is None: - progress.console.print(f"[red]Failed download {asset['id']}[/red]") + skipped_download += 1 + progress.console.print(f"[red]✗ Failed download {asset['id']}[/red]") + logger.debug(f"Image download failed for asset {asset['id']}") else: saved = ( process_face_mode(img, asset, person, person_dir, count) @@ -475,6 +531,22 @@ def execute_jobs(jobs: list[dict]) -> None: ) if saved: count += 1 + logger.debug(f"Saved image #{count} from asset {asset['id']}") + else: + if mode == "face": + skipped_no_face += 1 + progress.console.print( + f"[yellow]⏭ No usable face data for {asset['id']}[/yellow]" + ) + logger.debug( + f"process_face_mode returned False for asset {asset['id']} — " + f"people={asset.get('people', 'MISSING')}" + ) + else: + skipped_other += 1 + progress.console.print( + f"[yellow]⏭ Skipped {asset['id']}[/yellow]" + ) except Exception as e: logger.error(f"Failed to process asset {asset['id']}: {e}") @@ -483,6 +555,16 @@ def execute_jobs(jobs: list[dict]) -> None: progress.remove_task(job_task) + # Per-person execution summary + rprint( + f"\n [bold]{name}:[/bold] saved {count}/{len(assets)} images " + f"(download_failed={skipped_download}, no_face_data={skipped_no_face}, other={skipped_other})" + ) + logger.info( + f"{name}: saved {count}/{len(assets)} " + f"(download_failed={skipped_download}, no_face_data={skipped_no_face}, other={skipped_other})" + ) + def main() -> None: """Entry point for if-curator CLI.""" diff --git a/pyproject.toml b/pyproject.toml index 296dfbf..29cc010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ dependencies = [ "croniter>=5.0.2", "insightface>=0.7.3", + "nvidia-cudnn-cu12>=9.0.0", "numpy>=2.2.6", "onnxruntime-gpu>=1.23.2", "opencv-python-headless>=4.12.0.88", @@ -76,6 +77,7 @@ opencv-python-headless = "cv2" python-dotenv = "dotenv" insightface = "insightface" numpy = "numpy" +nvidia-cudnn-cu12 = "nvidia.cudnn" onnxruntime-gpu = "onnxruntime" requests = "requests" rich = "rich" @@ -84,7 +86,7 @@ transformers = "transformers" ultralytics = "ultralytics" [tool.deptry.per_rule_ignores] -DEP002 = ["onnxruntime-gpu"] +DEP002 = ["onnxruntime-gpu", "nvidia-cudnn-cu12"] [build-system] requires = ["hatchling"]