Files
hermes-config/skills/software-development/immich-server/references/yolo-gpu-classification.md
T
2026-07-12 10:17:17 -04:00

15 KiB

YOLO GPU Classification on GTX 1050 Ti (Pascal, CC 6.1)

CUDA/PyTorch Compatibility Matrix

PyTorch CUDA Python GTX 1050 Ti Result
2.12.0+cu130 13.0 3.14 no kernel image is available for execution on the device
2.12.0+cu130 13.0 3.14 CUDA error: out of memory (when zombie process holds VRAM)
2.5.1+cu124 12.4 3.12 Working

Root cause: PyTorch 2.12.0 (CUDA 13.0) dropped support for Pascal GPUs (compute capability 6.1) at the binary level — the shipped PTX/SASS targets newer architectures. The only PyTorch wheels available for Python 3.14 are 2.12.0+cu130, so Python 3.12 must be used with an older PyTorch + CUDA 12.4 build.

Setup (Python 3.12 + CUDA 12.4)

# Install Python 3.12 via deadsnakes (Ubuntu 26.04 doesn't ship it)
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update
sudo apt install -y python3.12 python3.12-venv

# Create venv with CUDA 12.4 PyTorch
python3.12 -m venv /home/ray/yolo_venv_cu124
source /home/ray/yolo_venv_cu124/bin/activate
pip install torch==2.5.1+cu124 torchvision==0.20.1+cu124 --index-url https://download.pytorch.org/whl/cu124
pip install ultralytics

# Verify (should show CUDA: True, CC: (6, 1))
python3 -c 'import torch; print(f"CUDA: {torch.cuda.is_available()}, CC: {torch.cuda.get_device_capability(0)}")'

Why Python 3.12? Ubuntu 26.04 ships Python 3.14.4 as default. PyTorch 2.5.x has no wheels for Python 3.14 — only 2.12.0+cu130, which doesn't support Pascal GPUs. Python 3.12 is the newest Python with CUDA 12.4 PyTorch wheels available via deadsnakes PPA.

🚨 Pitfalls

Zombie GPU Processes After Background Kill

When Hermes kills a background process (e.g. process(action="kill")), the SSH session dies but the Python process on the remote server keeps running and holds GPU VRAM. This causes subsequent runs to OOM even with a compatible PyTorch version.

Always check before launching a new GPU run:

# Check for zombies
nvidia-smi
# Look for python3 processes in the "Processes" section

# Kill all yolo processes
pkill -f 'seagate_photos_yolo'   # or your script name
# Wait 1-2 seconds for GPU memory to release
sleep 2
nvidia-smi   # Verify 0 MiB used

Symptom: CUDA error: out of memory on a fresh run even though the script should fit in VRAM.

Corrupt Image Fallback: Don't Retry on GPU

When a batch of images fails YOLO processing (corrupt JPEGs, truncated files), do NOT retry each file individually on GPU. The individual fallback path (model([filepath], imgsz=384, ...)) is:

  • Slow (model re-inference per file)
  • Unreliable (corrupt file still fails at lower resolution)
  • Wastes GPU cycles on unreadable data

Instead: On batch failure, move the entire batch directly to the target folder without GPU retry:

except Exception as e:
    # Batch-level failure — likely corrupt files
    for filepath in batch:
        try:
            rel_path = os.path.relpath(filepath, SRC)
            dst_path = os.path.join(DST, rel_path)
            os.makedirs(os.path.dirname(dst_path), exist_ok=True)
            shutil.move(filepath, dst_path)
            moved += 1
        except Exception:
            errors += 1
    continue

Background Output Buffering

Hermes' process(action="poll") may not show stdout from background SSH sessions due to pipe buffering. Redirect output to a log file and read it with read_file instead:

# In background command:
python3 -u /tmp/script.py > /tmp/script.log 2>&1

# Monitor progress:
tail -3 /tmp/script.log
# Or use read_file tool

Use python3 -u (unbuffered) and flush=True on all print() calls.

Full Working Script (Production)

The production script yolo_gpu_immediate.py (deployed at /tmp/yolo_gpu_immediate.py on rayserver) combines:

  • Immich DB query via docker exec immich_postgres psql ...
  • Path mapping from container /data/library/... to real /mnt/wd-passport/immich/photos/library/...
  • YOLOv8n single-image GPU inference at ~6-7 img/s on GTX 1050 Ti
  • Immediate shutil.move() per image — not batched at end (user preference)
  • stream=True — prevents Ultralytics RAM accumulation warning
  • Progress logging every 500 images

Querying Immich's PostgreSQL

SELECT "originalPath" FROM asset
WHERE type='IMAGE' AND visibility='timeline'
  AND "originalPath" LIKE '/data/library/%'
ORDER BY "originalPath";

Executed via:

docker exec immich_postgres psql -U postgres -d immich -t -A -c "<SQL>"

The "originalPath" from Immich looks like /data/library/4defcb72-1058-4fcd-826a-e2e87e59aa4c/2026/2026-05-11/.... To map to real filesystem, strip the /data/ prefix and prepend the real photo base (/mnt/wd-passport/immich/photos).

Classification Ruleset

  • Person (class 0) → keep
  • Nature (classes 16-27=birds/cats/dogs/horses/sheep/cow/elephant/bear/zebra/giraffe, 58=potted plant, 77=vase, 80=umbrella) → keep
  • Urban/indoor move-ables (classes 1-15 = human artifacts like car/bicycle/motorcycle/airplane/bus/train/truck/boat/traffic light/fire hydrant/stop sign/parking meter/bench, 56=chair, 57=couch, 59=bed, 60=dining table, 61=toilet, 62=tv, 63=laptop, 64=mouse, 65=remote, 67=cell phone, 70=oven, 71=toaster, 72=sink, 73=refrigerator, 74=book, 75=clock, 76=keyboard, 78=scissors, 79=teddy bear) → move
  • No detections + file <400KB → move (screenshots, memes)
  • No detections + file >=400KB → keep (likely scenic with no recognizable objects)

GPU Memory Profile

During single-image inference on GTX 1050 Ti (4GB):

  • Memory used: ~445 MB
  • GPU utilization: ~5% (bottlenecked by image loading/preprocessing, not compute)
  • Batch size 4 can increase utilization but risks OOM for larger images

Background Execution (Keep Agent Responsive)

The user explicitly wants all long-running tasks in background so Hermes stays accessible.

Pattern: redirect to log file, monitor with read_file.

# Start (via Hermes terminal background=true)
ssh rayserver "source /home/ray/yolo_venv_cu124/bin/activate && python3 -u /tmp/script.py > /tmp/yolo_seagate.log 2>&1"

# Monitor progress — use read_file, NOT process poll (buffering issue)
read_file /tmp/yolo_seagate.log

# Files moved so far
find /mnt/seagate8tb/NO\ PEOPLE/ -type f | wc -l

# Kill zombies (check nvidia-smi first!)
pkill -f script_name
nvidia-smi   # verify GPU memory freed

Never use tail via terminal for log monitoring — use read_file instead. Background SSH stdout is buffered at the pipe level and process(action="poll") often shows empty output even when the script is actively writing.

EXIF Rotation: Critical Fix for Phone Photos

YOLO via OpenCV ignores EXIF orientation. Phone portrait photos (which are stored landscape with an EXIF rotation flag) will appear sideways to the model. People in these photos are routinely missed.

Fix: PIL load with EXIF transpose

from PIL import Image, ImageOps
import numpy as np

with Image.open(ap) as img:
    img = ImageOps.exif_transpose(img)
    if img is None:
        img = Image.open(ap)  # reload if exif_transpose returned None
    if img.mode != 'RGB':
        img = img.convert('RGB')  # handle RGBA (PNG alpha channel), CMYK, etc.
    img_np = np.array(img)[:, :, ::-1]  # RGB -> BGR for Ultralytics pipeline
    results = model(img_np, device="cuda:0", verbose=False)

Important: After exif_transpose, the image MUST be converted to 'RGB' mode. PNGs with alpha channels are RGBA (4 channels) and YOLO expects exactly 3.

4-Orientation Brute-Force (When EXIF Alone Fails)

Some photos are stored rotated in pixel data WITHOUT any EXIF orientation flag. ImageOps.exif_transpose() returns them unchanged. Fix: try all 4 rotations:

import numpy as np

# After PIL load (RGB), convert to BGR for YOLO pipeline
img_bgr = np.array(img)[:, :, ::-1]

orientations = {
    '0°': img_bgr,
    '90°': np.rot90(img_bgr, k=3),   # 90° CW
    '180°': np.rot90(img_bgr, k=2),  # 180°
    '270°': np.rot90(img_bgr, k=1),  # 270° CW
}

found_person = False
for orient_name, orient_img in orientations.items():
    results = model(orient_img, device="cuda:0", verbose=False)
    for r in results:
        if r.boxes:
            for box in r.boxes:
                if int(box.cls[0]) == 0:  # person class
                    found_person = True
                    break
        if found_person: break
    if found_person: break

Performance impact: 4x slower (~2.5-3 effective img/s on GTX 1050 Ti). Run as background task. Typically restores 2-7% of flagged photos as having people.

PIL Decompression Bomb Protection

Pillow refuses to load images larger than 178MP by default (decompression bomb safety). Photos from phone panoramas or photo stitches (200MP+) will fail with:

Image size (199756800 pixels) exceeds limit of 178956970 pixels

These images cannot be checked via PIL. Two options:

  • Skip them (they stay in their current classification, usually with a count in the error tally)
  • Raise the limit with Image.MAX_IMAGE_PIXELS = None (not recommended — can cause OOM on 4GB GPU)

Post-Classification Sorting: Trash vs Keep

After moving non-people photos to a separate folder, use YOLO's object detections to categorize them into Trash (likely insignificant) and Keep (potentially important).

Classification Rules

TRASH (screenshots, household clutter, no meaningful content):

  • cell phone (67), tv (62), laptop (63), keyboard (66), mouse (64), remote (65)
  • toilet (61), chair (56), couch (57), refrigerator (72), microwave (68), oven (69), sink (71)
  • hair drier (78), toothbrush (79), scissors (76)
  • No detections at all (blank/solid/text-only images)

KEEP (potentially important):

  • book (73) — documents, receipts, textbooks
  • car (2), truck (7), bus (5), motorcycle (3), bicycle (1), airplane (4) — vehicles/travel
  • dining table (60), cup (41), bottle (39), bowl (45), wine glass (40) — food/meals
  • dog (16), cat (15), bird (14) — pets
  • sports ball (32), cake (55), pizza (53) — events/food
  • clock (74) — time-stamped events
  • potted plant (58), vase (75), backpack (24), umbrella (25) — personal/meaningful

A photo with ANY "Keep" class detected goes to Keep. ONLY photos where ALL detected classes are Trash go to Trash. No-detection photos go to Trash.

Typical Distribution

On a real-world photo library (16K photos), after YOLOv8n classification:

  • ~24% flagged for removal (no people, no scenic content)
  • ~22% of that flagged set is screenshots/trash
  • ~55% is documents/books
  • ~15% is vehicles/meals/personal worth keeping
  • ~3% has missed people in rotated orientation (restored via multi-orientation check)

Restore Pass: Re-Checking Flagged Photos

After the initial classification pass, ALWAYS do a restore pass on the moved photos. The user will spot false positives. The restore workflow:

  1. Scan all files in the NO PEOPLE folder
  2. For each, load with PIL + EXIF transpose + 4 orientations
  3. If ANY orientation detects a person (class 0), shutil.move() back to original path
  4. Log which orientation caught it (helps confirm the fix works)
# Restore path calculation
orig = ap.replace(NO_PEOPLE_DIR, PHOTO_BASE)
os.makedirs(os.path.dirname(orig), exist_ok=True)
shutil.move(ap, orig)

Flattening Moved Photos

After moving flagged photos out of Immich's structured directory tree, flatten them into a single folder for easier browsing:

mkdir -p "/mnt/wd-passport/immich/NO PEOPLE PHOTOS/ALL Photos"
find "/mnt/wd-passport/immich/NO PEOPLE PHOTOS/" -type f -not -path '*/ALL Photos/*' -exec mv -t "/mnt/wd-passport/immich/NO PEOPLE PHOTOS/ALL Photos/" {} +

Handle filename collisions with number suffixes.

Full Pipeline Workflow Order

The complete YOLO classification workflow runs in this order:

1. CLASSIFY  → 2. RESTORE  → 3. FLATTEN  → 4. SORT
   (initial)    (re-check)    (remove      (trash vs keep
                4-orient)     subdirs)     by detection)

Step 1: Classify

  • Query Immich DB for all image paths
  • Map container paths to real filesystem paths
  • Run YOLOv8n on GPU, immediate shutil.move() on non-people/non-scenic photos
  • Log progress every 500 images

Step 2: Restore (Multi-Orientation Re-check)

  • Scan all moved files in the NO PEOPLE target folder
  • For each, load with PIL + EXIF transpose + try all 4 orientations
  • If ANY orientation detects a person (class 0), move back to original path
  • Log which orientation caught the detection
  • Typically restores 2-7% of flagged photos

Step 3: Flatten

  • After restore pass, gather all remaining photos into a single flat directory
  • Remove the deep Immich subdirectory structure (UUID/YYYY/YYYY-MM-DD/)
  • Handle filename collisions with _1, _2 suffixes
  • Result: one folder with N files, no subdirectories

Step 4: Sort (Trash vs Keep)

  • Run YOLO on all flattened photos to detect what objects are present
  • Categorize each photo:
    • Trash: ALL detected classes are screenshots/clutter AND no meaningful detections
    • Keep: ANY detected class is a "keep" class (vehicles, food, pets, documents, etc.)
  • Move into Trash/ and Keep/ subfolders
  • Photos with no detections → Trash

Flatten Script (with Collision Handling)

import os, shutil

SRC = "/path/to/NO PEOPLE PHOTOS"
DST = os.path.join(SRC, "ALL Photos")
os.makedirs(DST, exist_ok=True)

files = []
for root, dirs, fnames in os.walk(SRC):
    if root.startswith(DST):
        continue
    for f in fnames:
        files.append(os.path.join(root, f))

collisions = 0
for src in files:
    fname = os.path.basename(src)
    dst = os.path.join(DST, fname)
    if os.path.exists(dst):
        base, ext = os.path.splitext(fname)
        n = 1
        while os.path.exists(os.path.join(DST, f"{base}_{n}{ext}")):
            n += 1
        dst = os.path.join(DST, f"{base}_{n}{ext}")
        collisions += 1
    shutil.move(src, dst)

print(f"Moved {len(files)} files, {collisions} renamed")

Sort Script (Trash vs Keep)

import os, shutil, csv

# See reference above for TRASH_CLASSES set definition
# See the Post-Classification Sorting section above for the detailed class lists
# CSV file produced by the categorization run: filename,detected_classes
# A photo goes to KEEP if ANY detected class is NOT in the TRASH set
# Otherwise it goes to TRASH (which also catches no-detection photos)

Full Pipeline Script Template

For a complete end-to-end script combining classification, EXIF rotation, 4-orientation restore, and sorting, combine the patterns above. Run as background process with nohup. The user expects to check progress periodically and be able to interact with the agent while the job runs.