#!/usr/bin/env python3 """ Immich YOLO Photo Classifier Pipeline Classifies photos as: has-people, scenic, or neither. Moves non-people/non-scenic photos to NO PEOPLE PHOTOS folder. Usage: /mnt/storage/yolo_venv/bin/python3 this_script.py Requirements: - /mnt/storage/yolo_venv/ with ultralytics installed - ~/yolov8n_openvino_model/ (exported from yolov8n.pt) - Immich PostgreSQL container running - Photos on /mnt/wd-passport/immich/photos/ """ import multiprocessing, os, time, warnings, subprocess, shutil warnings.filterwarnings("ignore") PHOTO_BASE = "/mnt/wd-passport/immich/photos" NO_PEOPLE_DIR = "/mnt/wd-passport/immich/NO PEOPLE PHOTOS" 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''' 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} 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: try: r = model(ap, verbose=False) has_person = has_nature = has_move = False for r2 in r: if r2.boxes: for box in r2.boxes: cls = int(box.cls[0]) if cls == PERSON_CLASS: has_person = True elif cls in NATURE_CLASSES: has_nature = True elif cls in MOVE_CLASSES: has_move = True if has_person: results.append((ap, "keep_people")) elif has_nature: results.append((ap, "keep_scenic")) elif has_move: results.append((ap, "move")) else: results.append((ap, "check_size")) except Exception as e: results.append((ap, f"error:{e}")) return results if __name__ == "__main__": multiprocessing.set_start_method("forkserver", force=True) num_workers = min(multiprocessing.cpu_count(), 8) os.makedirs(NO_PEOPLE_DIR, exist_ok=True) model_path = os.path.expanduser("~/yolov8n_openvino_model/") print(f"[1] Querying DB...", flush=True) result = subprocess.run(DB_QUERY, shell=True, capture_output=True, text=True, timeout=120) db_paths = [p.strip() for p in result.stdout.strip().split('\n') if p.strip()] print(f"[2] Mapping {len(db_paths)} paths...", flush=True) actual = [] for p in db_paths: ap = PHOTO_BASE + "/" + p[6:] if os.path.exists(ap): actual.append(ap) print(f" {len(actual)} accessible files", flush=True) print(f"[3] Classifying with {num_workers} workers...", flush=True) chunks = [actual[i::num_workers] for i in range(num_workers)] chunk_args = [(model_path, c) for c in chunks] t_start = time.time() with multiprocessing.Pool(num_workers) as pool: all_results = pool.map(worker, chunk_args) elapsed = time.time() - t_start keep_people = keep_scenic = errors = 0 to_move, check_size = [], [] for chunk in all_results: for ap, d in chunk: if d == "keep_people": keep_people += 1 elif d == "keep_scenic": keep_scenic += 1 elif d == "move": to_move.append(ap) elif d == "check_size": check_size.append(ap) else: errors += 1 print(f"\n YOLO: {elapsed/60:.1f}min ({len(actual)/elapsed:.1f} img/s)", flush=True) print(f" People:{keep_people} Scenic:{keep_scenic} Move:{len(to_move)} Check:{len(check_size)} Err:{errors}", flush=True) print(f"[4] Size check on {len(check_size)} undetected...", flush=True) for ap in check_size: try: if os.path.getsize(ap) < 400000: to_move.append(ap) except: pass print(f" Now {len(to_move)} to move", flush=True) print(f"[5] Moving {len(to_move)} photos...", flush=True) moved = file_errors = 0 for ap in to_move: try: rel = os.path.relpath(ap, PHOTO_BASE) dest = os.path.join(NO_PEOPLE_DIR, rel) os.makedirs(os.path.dirname(dest), exist_ok=True) shutil.move(ap, dest) moved += 1 except Exception as e: file_errors += 1 print(f"\nDONE! Moved {moved} photos", flush=True) print(f" Total:{len(actual)} Kept(people):{keep_people} Kept(scenic):{keep_scenic} Moved:{moved} Errors:{file_errors}", flush=True) print(f" Time: {(time.time()-t_start)/60:.1f}min", flush=True) print(f" Output: {NO_PEOPLE_DIR}", flush=True)