Files
2026-07-12 10:17:17 -04:00

37 KiB
Raw Permalink Blame History

name, description
name description
computer-vision-inference Run YOLO and other CV models on self-hosted servers for photo/video classification. Covers GPU compatibility checks, OpenVINO fallback, batch processing patterns, and integration with media servers (Immich).

Computer Vision Inference on Self-Hosted Infrastructure

User Preference: Plan Before Executing

When the user asks you to do a task, stop and think before running any command. Don't default to the first approach that comes to mind. Evaluate the most efficient way: consider the bottleneck (disk I/O, GPU, CPU), the right tools (dcraw, OpenVINO, CUDA), and whether the plan could fail. Getting the approach right upfront saves hours of rework. This applies to every task, not just CV — it's a general approach preference.

Run YOLO object detection on large photo collections (15k50k+ images) on a homelab server.

GPU Compatibility Check (First Step)

Before installing anything, verify the GPU can actually run modern ML frameworks:

# Check GPU and compute capability (CC)
nvidia-smi --query-gpu=name,compute_cap --format=csv,noheader
# OR
nvidia-smi --query-gpu=name --format=csv,noheader
# Check CC at: https://developer.nvidia.com/cuda-gpus

CC compatibility table:

Architecture CC Range PyTorch CUDA 12.x CUDA 13.0+ Notes
Turing (RTX 20xx) 7.5 Supported Supported Minimum for modern PyTorch
Ampere (RTX 30xx) 8.x Supported Supported
Ada Lovelace (RTX 40xx) 8.9 Supported Supported
Pascal (GTX 10xx) 6.x Works (CUDA 12.4/12.6) Dropped sm_61 removed from CUDA 13.0 kernels

⚠️ Python 3.14 trap (Ubuntu 26.04): The system Python 3.14 ships only PyTorch 2.12.0 wheels, which are compiled for CUDA 13.0. Pascal GPUs get no kernel image is available errors. Fix: install Python 3.12 from deadsnakes PPA and use CUDA 12.4 PyTorch wheels.

YOLO Model Sizing by VRAM

Model Params VRAM (batch=2, 640px) Min GPU
YOLOv8n 3.2M ~400 MB 2 GB
YOLOv8s 11.2M ~600 MB 2 GB
YOLOv8m 25.9M ~1.2 GB 3 GB
YOLOv8l 43.7M ~2 GB 4 GB
YOLOv8x 68.2M ~3.5 GB 6 GB

4GB card (GTX 1050 Ti / 1060): Use YOLOv8s (safe) or YOLOv8m (tight but works). YOLOv8x will OOM at batch 2+. YOLOv8n is fastest but less accurate on small objects.

Installation

Option A: OpenVINO (for Intel CPUs or Pascal+ GPUs)

# Create venv on a partition with space (not /tmp)
sudo mkdir -p /mnt/storage/yolo_venv
sudo chown -R $USER:$USER /mnt/storage/yolo_venv
python3 -m venv /mnt/storage/yolo_venv
/mnt/storage/yolo_venv/bin/pip install ultralytics

# Export YOLO model to OpenVINO format
/mnt/storage/yolo_venv/bin/python3 -c "
from ultralytics import YOLO
model = YOLO('yolov8n.pt')
model.export(format='openvino', device='cpu')
"
# Saved as ~/yolov8n_openvino_model/

Option B: PyTorch CUDA (for CC ≥ 7.5 GPUs)

python3 -m venv /mnt/storage/yolo_venv
/mnt/storage/yolo_venv/bin/pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124
/mnt/storage/yolo_venv/bin/pip install ultralytics

Option C: PyTorch CUDA for Pascal GPUs (CC 6.x -- GTX 1050 Ti, GTX 1060, etc.)

Pascal GPUs (CC 6.x) have no official PyTorch CUDA support in CUDA 13.0+. On Ubuntu 26.04 the system Python 3.14 ships ONLY PyTorch 2.12.0 with CUDA 13.0 — Pascal will fail with no kernel image is available for execution on the device.

Approach 1: Python 3.12 + CUDA 12.4 (recommended for Ubuntu 26.04)

Install Python 3.12 from deadsnakes PPA, create a venv, and use CUDA 12.4 PyTorch wheels:

# Install Python 3.12 (Ubuntu 26.04)
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update && sudo apt install -y python3.12 python3.12-venv

# Create venv and install PyTorch CUDA 12.4
python3.12 -m venv /mnt/storage/yolo_venv_cu124
source /mnt/storage/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 pillow numpy

# Verify
python3 -c "import torch; print(f'CUDA: {torch.cuda.is_available()}, GPU: {torch.cuda.get_device_name(0)}')"

Approach 2: Python 3.10 + PyTorch 2.0.1 + CUDA 11.8 (legacy stable)

# 1. Install uv (Python 3.14+ system with PEP 668? uv bypasses it)
pip3 install uv 2>&1 || /mnt/storage/yolo_venv/bin/pip install uv

# 2. Install Python 3.10
export PATH="$HOME/.local/bin:$PATH"
uv python install 3.10

# 3. Create venv with Python 3.10
uv venv --python 3.10 /path/to/gpu_venv

# 4. Install PyTorch 2.0.1+cu118 (supports CUDA 11.8 + CC 6.1)
uv pip install --python /path/to/gpu_venv/bin/python \
  torch==2.0.1+cu118 torchvision==0.15.2+cu118 \
  --index-url https://download.pytorch.org/whl/cu118

# 5. Pin numpy < 2 (PyTorch 2.0.1 was compiled against numpy 1.x)
uv pip install --python /path/to/gpu_venv/bin/python "numpy<2"

# 6. Install ultralytics
uv pip install --python /path/to/gpu_venv/bin/python ultralytics
# If pi-heif fails via uv, install pip first then use pip:
uv pip install --python /path/to/gpu_venv/bin/python pip
/path/to/gpu_venv/bin/pip install pi-heif

# 7. Verify GPU
/path/to/gpu_venv/bin/python3 -c "
import torch
print(f'CUDA: {torch.cuda.is_available()}, GPU: {torch.cuda.get_device_name(0)}')
t = torch.zeros(1,3,640,640).cuda()
conv = torch.nn.Conv2d(3, 16, 3).cuda()
print(f'CUDA compute: OK ({conv(t).shape})')
"

Performance reference (GTX 1050 Ti, USB HDD): ~46 img/s at batch=4. Bottleneck is disk I/O, not GPU.

YOLO Inference Patterns

Templates available:

  • templates/yolo_no_people_filter.py — GPU batch scan: keep people/scenic/screenshots, move everything else. 4GB-GPU-safe (YOLOv8s, batch=2).

Single image (test):

from ultralytics import YOLO
model = YOLO("yolov8n_openvino_model/")
results = model("/path/to/photo.jpg", verbose=False)
for r in results:
    if r.boxes:
        for box in r.boxes:
            cls = int(box.cls[0])
            name = r.names[cls]
            conf = float(box.conf[0])
            print(f"  {name}: {conf:.2f}")

EXIF-aware inference (fixes sideways photos with orientation flags)

YOLO loads images via OpenCV (cv2.imread) which ignores EXIF orientation flags. A portrait photo taken with a phone appears rotated 90° to the model, causing missed person/object detections. Fix by pre-rotating with PIL. However, this only catches photos that HAVE EXIF orientation flags — some photos are stored with rotated pixel data and no orientation flag at all. See the section below for the brute-force fallback.

from PIL import Image, ImageOps
import numpy as np

def load_image_exif_aware(path: str) -> np.ndarray:
    """Load image with EXIF rotation applied, return BGR numpy array.
    
    Note: ImageOps.exif_transpose returns None if the image has 
    no EXIF orientation tag. Always handle None by reloading fresh.
    """
    with Image.open(path) as img:
        img = ImageOps.exif_transpose(img)
        if img is None:
            # No EXIF orientation data — reload fresh 
            return None  # Caller should fall back to brute-force rotation
        if img.mode == "RGBA":
            img = img.convert("RGB")
        elif img.mode != "RGB":
            img = img.convert("RGB")
        return np.array(img)[:, :, ::-1]  # RGB -> BGR for YOLO

img_np = load_image_exif_aware("/path/to/photo.jpg")
if img_np is not None:
    results = model(img_np, verbose=False)

Performance: PIL load + EXIF transpose is fast (~11 img/s on GTX 1050 Ti) — the rotate is a metadata operation, not a pixel resample.

Multi-Orientation Detection (Critical for Rotated Photos)

YOLO's default pipeline (OpenCV cv2.imread) ignores EXIF orientation flags and stores pixels as-is. A portrait phone photo appears 90° rotated to the model, causing missed detections. Fixing orientation uncovered 280+ missed people photos in a 4,000-photo corpus.

Two-Layer Strategy

Layer 1: EXIF-aware PIL loading (fast, catches photos with orientation flags) Layer 2: 4-orientation brute force (definitive, catches photos stored rotated without EXIF flags)

Layer Speed Found on 4k photos Time
EXIF-only (PIL + ImageOps.exif_transpose) ~11 img/s 0 missed photos ~6 min
4-orientation brute force ~4.5 img/s 280+ missed photos ~25 min

Key takeaway: EXIF-only found nothing. 4-orientation found massive numbers of missed people photos. Many phones and apps (especially older Android, TikTok screenshots, and re-saved images) store rotated pixel data without setting the EXIF orientation flag. EXIF handling alone is insufficient.

Helper: try_all_orientations()

The definitive approach: rotate the image array 4 ways and check each for people (class 0).

import numpy as np
from PIL import Image

def try_all_orientations(ap: str, model) -> tuple[bool, str]:
    """Check all 4 rotations of a photo for people. Returns (found_person, orientation_name)."""
    try:
        with Image.open(ap) as img:
            if img.mode != 'RGB':
                img = img.convert('RGB')
            img_np = np.array(img)[:, :, ::-1]  # RGB -> BGR for YOLO
    except Exception:
        return (False, "")

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

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

# Usage in restore loop:
found, orientation = try_all_orientations(ap, model)
if found:
    print(f"Found person at {orientation}")
    shutil.move(ap, original_path)

Performance: ~4.5 img/s (4× slower than single-orientation). ~25 min for 4,000 photos. RAM stays stable (tested on GTX 1050 Ti).

Full dual-pass workflow

# Pass 1: EXIF-aware (fast pre-filter)
for ap in moved_photos:
    img_bgr = load_with_exif(ap)       # tries EXIF transpose
    if img_bgr is None:
        continue                        # nothing to see RAW — pass to brute force
    results = model(img_bgr, verbose=False)
    if has_person(results):
        restore(ap)

# Pass 2: 4-orientation (catches everything else)
for ap in still_in_moved_photos:
    found, orient = try_all_orientations(ap, model)
    if found:
        restore(ap)

Practical note: Do pass 2 even if pass 1 found nothing — on a real corpus, pass 1 found 0 but pass 2 found 280+. The EXIF orientation flag is simply not present in many stored photos.

Edge cases when loading images via PIL

Problem Cause Fix
RGBA PNG PNG screenshots with alpha channel produce 4-channel arrays img = img.convert("RGB")
CMYK/grayscale Some scanned images or legacy formats img = img.convert("RGB")
Decompression bomb (200MP+ panoramas) Pillow error: "Image size exceeds limit" PIL.Image.MAX_IMAGE_PIXELS = None or catch PIL.DecompressionBombError and skip; the latter is safer
HEIC/HEIF iPhone photos pip install pi-heif
Nikon RAW (NEF) DSLR RAW files from Nikon cameras — 20-24MB each, 6000x4000px Install dcraw (sudo apt install dcraw) for command-line conversion. Use dcraw -c -w <file.NEF> to pipe decoded PPM to stdout. Do NOT use rawpy/LibRaw — it fails on many camera-specific NEF variants (especially Nikon D5200+ and other consumer bodies). dcraw is the gold standard. Example: `dcraw -c -w photo.NEF
Truncated JPEG Corrupt or partial downloads Wrap in try/except, skip on error
Missing EXIF orientation flag Photo pixels are rotated but no EXIF flag set Use 4-orientation brute force detection (see multi-orientation section)

Immediate-move pattern (files moved as classified)

When the user wants photos moved/purged as they're processed (not batched at the end), move inside the classification loop:

moved_total = 0
for i, ap in enumerate(paths):
    results = model(ap, device="cuda:0", verbose=False)

    should_move = False
    # ... classify results ...

    if should_move:
        try:
            dest = ap.replace(PHOTO_DIR, NO_PEOPLE_DIR)
            os.makedirs(os.path.dirname(dest), exist_ok=True)
            shutil.move(ap, dest)
            moved_total += 1
        except Exception as e:
            log(f"MOVE FAILED: {ap} -> {e}")

Important: Unlike the batched pattern where "moved: X" means "queued for move", in immediate mode moved_total is the actual count of files already in the destination folder.

Multiprocessing batch (use if __name__ == "__main__" guard):

import multiprocessing

def worker(args):
    model_path, paths = args
    from ultralytics import YOLO
    import warnings
    warnings.filterwarnings("ignore")
    model = YOLO(model_path, task="detect")
    results = []
    for ap in paths:
        r = model(ap, verbose=False)
        results.append((ap, decision))
    return results

if __name__ == "__main__":
    multiprocessing.set_start_method("forkserver", force=True)
    num_workers = min(multiprocessing.cpu_count(), 8)
    chunks = [all_paths[i::num_workers] for i in range(num_workers)]
    chunk_args = [(model_path, c) for c in chunks]
    with multiprocessing.Pool(num_workers) as pool:
        all_results = pool.map(worker, chunk_args)

COCO Class Classification Heuristic

PERSON_CLASS = 0
NATURE_CLASSES = {16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 58, 77, 80}
MOVE_CLASSES = {56, 57, 59, 60, 61, 62, 63, 64, 65, 67, 70, 71, 72, 73, 74, 75, 76, 78, 79,
                1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}

Rules: Person→keep, Nature→keep, Indoor/urban→move, No detections→check file size (<400KB→move)

Integration with Immich

Query asset paths from PostgreSQL:

DB_QUERY = '''docker exec immich_postgres psql -U postgres -d immich -t -A -c "SELECT \\\"originalPath\\\" FROM asset WHERE type='IMAGE' AND visibility='timeline' AND \\\"originalPath\\\" LIKE '/data/library/%' ORDER BY \\\"originalPath\\\";" 2>/dev/null'''

Map virtual paths to filesystem:

PHOTO_BASE = "/mnt/wd-passport/immich/photos"
def map_path(db_path):
    if db_path.startswith("/data/"):
        return PHOTO_BASE + "/" + db_path[6:]
    elif db_path.startswith("/external/"):
        return "/mnt/wd-passport" + db_path[8:]
    return db_path

Flatten sorted photos into a single folder

After classification, photos end up scattered across Immich's subdirectory tree (e.g., NO PEOPLE PHOTOS/library/4defcb72-.../2026/2026-04-07/photo.jpg). To flatten them into a single folder with no subdirectories:

import os, shutil

NO_PEOPLE = "/mnt/wd-passport/immich/NO PEOPLE PHOTOS"
TARGET = os.path.join(NO_PEOPLE, "ALL Photos")
os.makedirs(TARGET, exist_ok=True)

# Find all files excluding the target itself
files = []
for root, dirs, fnames in os.walk(NO_PEOPLE):
    if root.startswith(TARGET):
        continue
    for f in fnames:
        files.append(os.path.join(root, f))

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

print(f"Moved {moved} files, {collisions} renamed for collision")

# Clean up empty directories
for root, dirs, fnames in os.walk(NO_PEOPLE, topdown=False):
    if root != TARGET and not os.listdir(root) and root != NO_PEOPLE:
        os.rmdir(root)

Expectation: 3,600+ files flatten in seconds. Filename collisions are rare (~1 in 3,600). Collision handling appends _1, _2, etc. before the extension.

Categorization by Detected Content

After sorting photos out (no people, no scenery), you may want to further categorize the remaining photos by what objects they actually contain. Run YOLO on every photo and build a detection histogram, then let the user decide which categories are worthwhile and which are trash.

Detection Histogram Script

import os, numpy as np
from PIL import Image
from collections import Counter
from ultralytics import YOLO

ALL_PHOTOS = "/mnt/wd-passport/immich/NO PEOPLE PHOTOS/ALL Photos"
model = YOLO("yolov8n.pt")

# Gather all image files
files = sorted([os.path.join(ALL_PHOTOS, f) for f in os.listdir(ALL_PHOTOS)
                if f.lower().endswith(('.jpg', '.jpeg', '.png', '.heic', '.heif'))])

detection_counts = Counter()  # class_id -> count of photos containing it
no_detect = 0

for i, ap in enumerate(files):
    if i > 0 and i % 300 == 0:
        print(f"  [{i}/{len(files)}] ... no_detect: {no_detect}", flush=True)

    # Load with PIL (handles EXIF + RGBA)
    with Image.open(ap) as img:
        if img.mode != 'RGB':
            img = img.convert('RGB')
        img_np = np.array(img)[:, :, ::-1]

    results = model(img_np, device="cuda:0", verbose=False)

    classes_found = set()
    for r in results:
        if r.boxes:
            for box in r.boxes:
                classes_found.add(int(box.cls[0]))

    if classes_found:
        for c in classes_found:
            detection_counts[c] += 1
    else:
        no_detect += 1

# Print top results
CLASS_NAMES = {0:"person",1:"bicycle",2:"car",3:"motorcycle",15:"cat",16:"dog",
               39:"bottle",41:"cup",56:"chair",62:"tv",63:"laptop",67:"cell phone",
               73:"book",74:"clock"}  # add more as needed
for cls_id, count in detection_counts.most_common(20):
    name = CLASS_NAMES.get(cls_id, f"c{cls_id}")
    print(f"  {name:20s}: {count:4d} photos ({count/len(files)*100:.1f}%)")
print(f"  {'no detection':20s}: {no_detect:4d} photos ({no_detect/len(files)*100:.1f}%)")

Real-World Detection Distribution (3,668 photos sorted out as "no people/scenery")

Object Photos % Likely
book 668 18% Documents, receipts, textbooks
cell phone 342 9% Phone screenshots
tv/monitor 320 9% Screen captures
laptop 274 7% Computer screenshots
car 265 7% Vehicle photos
refrigerator 218 6% Fridge contents
clock 128 3% Timestamps
chair 102 3% Random furniture
dining table 74 2% Meals
toilet 74 2% Bathroom shots
airplane 54 1.5% Travel
No detection 899 25% Blank/blurry/text-only

Sorting Into Subfolders by Category

After the histogram gives you a picture of what's there, present it to the user and ask which categories to keep vs discard. Then sort:

import os, shutil

TRASH_CLASSES = {62, 63, 67, 73}  # tv, laptop, cell phone, book
TRASH_DIR = os.path.join(ALL_PHOTOS, "..", "Trash Screenshots")
os.makedirs(TRASH_DIR, exist_ok=True)

KEEP_CLASSES = {2, 5, 4, 39, 41, 74}  # car, bus, airplane, bottle, cup, clock
KEEP_DIR = os.path.join(ALL_PHOTOS, "..", "Maybe Keep")
os.makedirs(KEEP_DIR, exist_ok=True)

for fname in os.listdir(ALL_PHOTOS):
    ap = os.path.join(ALL_PHOTOS, fname)
    if not os.path.isfile(ap):
        continue
    # ... check fname against a pre-computed CSV of classes ...
    shutil.move(ap, dest)

Practical tip: Instead of re-running YOLO on every photo, save the classification results to a CSV during the detection histogram pass. Then use the CSV to sort — much faster:

# During detection pass, write:
#   all_photo_categories.csv
#   filename,detected_classes
with open("all_photo_categories.csv", "w") as f:
    f.write("filename,detected_classes\n")
    for fname, classes in photo_classes.items():
        names = ";".join(sorted(CLASS_NAMES.get(c, f"c{c}") for c in classes))
        f.write(f"{fname},{names}\n")
    # Also record no-detection photos
    for fname in no_detect_files:
        f.write(f"{fname},none\n")

Then sort using the CSV — instant classification lookup, no GPU needed.

Pitfalls

  • HEIC photos: pip install pi-heif for HEIC/HEIF support
  • Slow USB drives: Use PostgreSQL DB query instead of filesystem find/walk on external drives with 20k+ files
  • Docker bridge IP loss: If bridge lacks gateway IP, do full docker compose down && docker compose up -d
  • OpenVINO batching: LATENCY mode won't batch — use multiprocessing for parallelism
  • Venv placement: PyTorch+ultralytics is ~2-3GB. Install on a data partition, not /tmp
  • Multiprocessing: Must use if __name__ == "__main__" guard with set_start_method("forkserver") in Python 3.14+
  • stream=True to prevent OOM: Without stream=True, YOLO holds all results in RAM across the entire run. For 15k+ images, each result with boxes metadata accumulates until the process is killed by OOM killer. Always pass stream=True to model(ap, verbose=False, stream=True) when iterating through many images. Note: stream=True returns a generator; materialize it with list() or iterate immediately.
  • Zombie GPU processes: Killing a YOLO script (Ctrl+C, kill, or OOM crash) often leaves a zombie Python process holding all VRAM. The GPU shows 4024MiB / 4096MiB used but no visible process. Run nvidia-smi to find the PID, then kill -9 <pid>. Always check nvidia-smi after a failed run — the GPU won't be usable until the zombie is cleared.
  • Cascading CUDA failures after zombie: If a new YOLO run immediately OOMs on a model that previously fit (e.g. YOLOv8s on 4GB), check nvidia-smi for a ghost process from a prior run. The "out of memory" message is correct — the GPU IS full, just by a process that appears dead.
  • Corrupt file batch fallback trap: When a YOLO batch fails due to corrupt images, the naive fallback re-tries each image individually on GPU at reduced resolution. On a 4GB card with dozens of corrupt files (common in recovery data), this can stall the pipeline for hours — each retry re-loads the model, allocates CUDA context, and fails. Fix: treat batch-level failures as a corrupt batch. Move all images in the failed batch directly to the destination without any GPU retry. If the user asks to classify corrupt images separately, use PIL to check for readability first (fast CPU operation), then only run YOLO on readable files.
  • Background output buffering via SSH: When launching a long-running Python script via terminal(background=true) over SSH, standard output is often completely invisible — the SSH pipe buffers aggressively and the process viewer shows empty output for minutes. Fix: redirect stdout+stderr to a log file on the remote server (> /tmp/script.log 2>&1), then monitor the log file with read_file(). Also use python3 -u (unbuffered mode) and flush=True on all print() calls.
  • NumPy version mismatch: PyTorch 2.0.1 was compiled against numpy 1.x. Installing ultralytics pulls in numpy 2.x, causing RuntimeError: Numpy is not available at inference time. Pin numpy<2 in the venv after installing everything else.
  • setsid for background isolation: nohup is insufficient for SSH-launched background processes — setsid detaches from the terminal session entirely, preventing SIGHUP from the SSH closure. Use: setsid python /tmp/script.py > /home/ray/log 2>&1 &

GPU Memory FAQ

Users frequently ask "why is my GPU only using X MB when it has Y GB available?" during YOLO inference:

Why VRAM usage stays low (e.g. 432MB / 4GB)

Factor Why
Model size YOLOv8n (nano) is ~6.3MB. Weights + CUDA overhead = ~200500MB VRAM. YOLOv8x would use ~12GB
Batch size = 1 Each frame is processed and discarded before the next. No gradient history, no activation cache (those are training-only)
Inference ≠ Training Training batches many images, backpropagates through the full graph, and caches activations for gradient computation — that's what fills VRAM. Inference is forward-pass-only
CUDA context floor PyTorch reserves ~300500MB of VRAM just for the CUDA runtime context regardless of model size

The 1050 Ti's 4GB is sitting idle because the workload doesn't need more — not because something's misconfigured. YOLOv8n at batch-1 on a single image simply doesn't fill a 4GB buffer.

Why system RAM is high even with GPU inference

If a YOLO process shows 3.7GB RSS in ps aux but only 432MB in nvidia-smi:

Layer RAM
Python + PyTorch + Ultralytics + CUDA libs loaded in memory ~1.5 GB
Image decode buffers (reads JPEGs from disk into pixel arrays) varies
PyTorch pre-allocated CPU tensor arena ~500 MB
CUDA driver / runtime bookkeeping on CPU side ~200 MB
Python overhead (file path list, results dicts for 15k+ images) ~200 MB

The GPU is the calculator, but the CPU is the assistant that reads files from disk, decodes JPEGs, normalizes pixels, and copies tensors over. The assistant needs a desk (RAM) to work at.

Sequential Batch Processing (for disk-bound pipelines)

Comparing NEF (RAW) vs JPG Pairs with YOLO

Useful when you need to verify that paired RAW+JPEG files (same base filename in a Nikon camera's dual-save setup) are actually the same photo. The JPEG is the camera's processed output, the NEF is the sensor data — they should be the same image with different processing.

Pitfall: rawpy/LibRaw fails on many consumer NEFs

rawpy (Python binding for LibRaw) produces "data corrupted" errors on many Nikon D5200 and similar consumer-body NEF files. These errors are false positives — the files open fine in Lightroom, Nikon's software, and dcraw. Always fall back to dcraw for NEF conversion.

dcraw + YOLO comparison workflow

import subprocess, io
from PIL import Image

# Convert NEF to PPM via dcraw (piped to stdout)
r = subprocess.run(['dcraw', '-c', '-w', str(nef_path)],
                   capture_output=True, timeout=60)
if r.returncode != 0:
    raise Exception(f"dcraw failed: {r.stderr.decode()[:80]}")

# Read PPM from stdout
nef_img = Image.open(io.BytesIO(r.stdout)).convert('RGB')
jpg_img = Image.open(jpg_path).convert('RGB')

# Resize both to same dimensions for comparison
target = (640, 480)
nef_r = nef_img.resize(target, Image.LANCZOS)
jpg_r = jpg_img.resize(target, Image.LANCZOS)

# Run YOLO on CPU
from ultralytics import YOLO
model = YOLO('yolov8n.pt')
jr = model(jpg_r, device='cpu', verbose=False)
nr = model(nef_r, device='cpu', verbose=False)

# Compare detected classes
jc = sorted([model.names[int(b.cls)] for b in jr[0].boxes])
nc = sorted([model.names[int(b.cls)] for b in nr[0].boxes])

if jc == nc:
    print("Same photo — identical YOLO detections")
else:
    print(f"Different detections: JPG={jc} vs NEF={nc}")

Expected results

The NEF version typically detects more objects than the JPEG version because it has the full sensor dynamic range (24MP, deeper shadows/highlights). A JPG that sees 4 people might detect 8 in the NEF, all in shadow areas the JPG crushed. This confirms they're the same photo with richer data in the RAW version — not different photos.

NEF size reference

Camera NEF size JPEG size Resolution
Nikon D5200 ~23 MB ~2-8 MB 6036 × 4020

When to use CPU vs GPU for verification

If the installed PyTorch version doesn't support the GPU's compute capability (common with GTX 1050 Ti / CC 6.1 on modern PyTorch CUDA 12+), force CPU:

model(image, device='cpu', verbose=False)

CPU inference on 640×480 images with YOLOv8n is ~1-2 img/s per core. For 100 images, expect ~2-3 minutes — acceptable for verification tasks.

When the bottleneck is disk I/O (reading from a USB HDD), not GPU compute, multiprocessing doesn't help much. A simple sequential loop is simpler and more predictable:

from ultralytics import YOLO
model = YOLO("yolov8n.pt")

counts = defaultdict(int)
to_move = []
t_start = time.time()

for i, ap in enumerate(all_paths):
    if i > 0 and i % 500 == 0:
        elapsed = time.time() - t_start
        rate = i / elapsed
        eta = (len(all_paths) - i) / rate / 60
        print(f"  [{i}/{len(all_paths)}] {rate:.1f} img/s, ETA {eta:.0f}min", flush=True)

    try:
        results = model(ap, device="cuda:0", verbose=False)
    except:
        continue

    # classify results...

Performance reference (GTX 1050 Ti, USB HDD): ~67 img/s on 16k images, ~40 min total for 16k photos.

Running Long Inference Without Blocking the Hermes Gateway

Critical: If the agent launches a long classification script inside its conversation turn, the Telegram/Discord bot appears "typing..." and cannot process new messages until the turn finishes. Each agent iteration also makes an LLM API call — polling progress wastes tokens while the user is charged per-call.

Anti-pattern: Agent launches script and waits

User: "classify my photos"
Agent: runs terminal("python classify.py")   ← blocks for 40 min
User: "what's the status?"                    ← queued, not seen
       ...bot shows typing for 40 min...
       ← agent finally responds (dozens of API calls eaten)

Create a cron job with no_agent=True — the script runs independently, stdout goes directly to Telegram. Survives gateway restart, zero token cost during execution:

cronjob(
    action="create",
    schedule="now",
    script="/home/ray/yolo_classify.py",
    no_agent=True,
    deliver="origin"
)

Pattern B: terminal(background=true) + log file + end turn

Launch with background=true, redirect stdout to a log file (SSH pipe buffers aggressively — without this, output is invisible), and immediately end the turn. Bot becomes responsive but does not survive gateway restart — child processes are killed when the gateway restarts:

# Redirect to a log file to avoid SSH pipe buffering
terminal(command="bash -c 'source venv/bin/activate && python3 -u classify.py > /tmp/classify.log 2>&1'", background=true, notify_on_complete=true)
# Monitor progress via read_file("/tmp/classify.log")

Note: Always use python3 -u (unbuffered) and flush=True on print() calls, even with log redirection. Without -u, Python's own output buffering delays log writes and read_file() shows stale data.

This user's preference: Always run long tasks in the background. They want to stay able to message me while work is processing — no waiting for 10-40 min tasks to finish before I respond.


### Pitfall: "moved: X" is queued, not actually moved

In a 2-phase script (classify all → then move all), the log shows `moved: 2393` during classification. This is `len(to_move)` — photos **queued for moving**, not actually moved. The destination directory stays empty until the move phase runs. Label the counter clearly to avoid confusion:

```python
# During classification:
print(f"  queued for move: {len(to_move)}", flush=True)

# During move phase:
moved = 0
for ap in to_move:
    shutil.move(ap, dest)
    moved += 1
print(f"  Actually moved: {moved}", flush=True)

Pitfall: Gateway restart kills agent-child processes

Agent-launched processes (even nohup'd) are children of the gateway process tree. Restarting the gateway orphans/kills them. For work that must survive restarts, always use a cron job with no_agent=True (Pattern A).

Video Content Detection and Renaming

When dealing with recovered or generically-named video files (e.g., FILE000.MOV, FILE002.MOV from data recovery tools), you can auto-detect their content using YOLO on extracted frames and rename them descriptively.

Workflow

  1. Extract a single frame from each video at ~3 seconds
  2. Run YOLO on CPU (since video processing doesn't benefit from GPU for single-frame extraction)
  3. Build a descriptive filename from detected objects
  4. Handle collisions, blank frames, and edge cases

Full Renaming Script

import os, subprocess, json
from PIL import Image
from ultralytics import YOLO

DIR = "/mnt/drive/Videos/Recovered/MOV Multimedia file"
model = YOLO("yolov8n.pt")

files = sorted([f for f in os.listdir(DIR) 
                if f.upper().endswith(('.MOV', '.MP4', '.3GP', '.AVI'))
                and f.upper().startswith("FILE")])

for i, fname in enumerate(files):
    src = os.path.join(DIR, fname)
    base, ext = os.path.splitext(fname)

    # Extract frame at 3 seconds (fall back to 1s for short clips)
    frame_path = "/tmp/_vf.jpg"
    for ss in ["00:00:03", "00:00:01"]:
        r = subprocess.run(["ffmpeg", "-y", "-ss", ss, "-i", src, 
                           "-vframes", "1", "-q:v", "2", frame_path],
                          capture_output=True, timeout=20)
        if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1000:
            break

    # Run YOLO on extracted frame
    classes = []
    if os.path.exists(frame_path):
        try:
            img = Image.open(frame_path)
            results = model(img, device="cpu", verbose=False)
            classes = [model.names[int(b.cls)] for b in results[0].boxes]
            os.remove(frame_path)
        except:
            pass

    # Build descriptive name
    if classes:
        people = classes.count("person")
        other = [c for c in sorted(set(classes)) if c != "person"]
        parts = []
        if people == 1:
            parts.append("person")
        elif people > 1:
            parts.append(f"{people}_people")
        parts.extend(other[:3])  # max 3 other objects
        new_name = "_".join(parts).replace(" ", "_") + ext.lower()
    else:
        new_name = f"scene{ext.lower()}"  # blank/dark/no detections

    # Avoid filename collisions
    dst = os.path.join(DIR, new_name)
    counter = 1
    while os.path.exists(dst):
        name_no_ext = os.path.splitext(new_name)[0]
        dst = os.path.join(DIR, f"{name_no_ext}_{counter}{ext.lower()}")
        counter += 1

    os.rename(src, dst)
    if (i+1) % 10 == 0:
        print(f"  [{i+1}/{len(files)}]", flush=True)

Real-World Renaming Examples (80 recovered files)

Old Name New Name Detected
FILE000.MOV chair_clock_tv.mov Indoor scene with TV
FILE054.MOV bench_car_elephant.mov Outdoor with animals
FILE125.MOV 5_people_cake_cell_phone_dining_table.mov Party/birthday
FILE214.MOV person_dog.mov Person walking dog
FILE021.MOV 2_people_chair_surfboard.mov Beach/outdoor
FILE176.MOV kite.mov Kite flying
FILE095.MOV scene.mov Blank/dark frame

Expected Results Distribution

On 80 recovered video files from a data recovery tool:

  • ~60 files get descriptive names (people, animals, objects detected)
  • ~20 files become scene.mov (blank/dark frames — common in recovered data where keyframes didn't survive)
  • ~0-2 files hit errors (truncated files, unreadable headers)

Performance

  • ~3-5 seconds per file on CPU (i7-10700K): ffmpeg frame extraction (~1s) + YOLOv8n inference (~2-4s)
  • ~4-7 minutes total for 80 files
  • GPU doesn't help here — ffmpeg decoding and single-frame inference are CPU-bound at this scale

Pitfalls

  • Blank/dark frames at 3s: Some recovered videos have corruption at specific timestamps. The fallback to 1s catches most of these. If both fail, the file becomes scene.mov — still identifiable as having no content.
  • .3GP extension: Data recovery tools often produce .3GP files alongside .MOV. These are valid video containers. Include them in the file filter.
  • Short clips: Videos under 3 seconds cause ffmpeg to warn about invalid timestamp but still produce a frame from the beginning. The ss flag gracefully clips to available duration.
  • Collision avoidance: Multiple files with the same detected objects (e.g., 3 different person.mov files) get _1, _2 suffixes. The inner while loop checks existence and increments.
  • Don't rename twice: Files already renamed (no "FILE" prefix) will be skipped by the startswith("FILE") guard. Always keep this guard for idempotency.
  • Broken file headers: Truncated MOV files may cause ffmpeg to hang. Always set timeout=20 on subprocess calls.

Performance Estimates (i7-10700K, 16 threads)

Engine img/s (1 worker) img/s (8 workers) 25k images
PyTorch CPU 0.3 ~23h
OpenVINO 1.5 ~8-10 ~4.5h
PyTorch CUDA (if supported) 30+ ~15min

Reference Files

  • references/immich-yolo-classifier.md — Immich-specific YOLO classifier query + path mapping.
  • references/immich-classification-pipeline.md — Full Immich photo classification pipeline: DB query, EXIF-aware multi-orientation inference, immediate-move pattern, content categorization, flat-folder sorting. (Absorbed from immich-photo-classification.)
  • references/video-content-renaming.md — YOLO-based video frame detection and auto-renaming for recovered files.
  • templates/yolo_no_people_filter.py — GPU batch scan template: keep people/scenic/screenshots, move everything else. 4GB-GPU-safe (YOLOv8s, batch=2).