Skip to content

Commit c4a9e77

Browse files
committed
feat: add AI photo organizer with face recognition and multi-language support
Add the `pdx organize` command that uses a vision-language model via Ollama to describe photos and group them into named folders by date. - YAML-based configuration with example template - Family context file for personalized AI descriptions - Folder history as style examples for AI naming - Face recognition via insightface with reference photo directories - HEIC/HEIF conversion for VLM compatibility - Multi-language prompts (Czech and English) - Video discovery and organization alongside photos - GPS/EXIF-based location extraction with home location filtering - Helpful error when config.yaml is missing - Documentation for setup, configuration, and all features Bug fixes: - fix: move text tokens to GPU and results back to CPU in prompt_to_vector - fix: filter files by supported extensions in handle_path to prevent indexing non-photo files passed as arguments
1 parent da37de7 commit c4a9e77

13 files changed

Lines changed: 833 additions & 19 deletions

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
1+
# Configuration and Personal Data
2+
config.yaml
3+
family_context.txt
4+
history_cache.json
5+
6+
# Python artifacts
7+
.venv/
18
/pdx.egg-info/
29
__pycache__/

README-Windows.md

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,78 @@ cd pdx
4343
cd /mnt/c/Users/<YourWindowsUser>/
4444
```
4545

46+
## Install exiftool
47+
48+
The `organize` command requires `exiftool` for EXIF and GPS metadata extraction.
49+
Without it, all photos will show as "Unknown location".
50+
51+
```
52+
sudo apt install -y libimage-exiftool-perl
53+
```
54+
55+
## Connect to Ollama on Windows
56+
57+
If Ollama is installed on Windows (not inside WSL), you need to make it accessible from WSL.
58+
59+
### 1. Make Ollama listen on all interfaces
60+
61+
Set a Windows environment variable:
62+
63+
- **Settings → System → About → Advanced system settings → Environment Variables**
64+
- Add a new variable: `OLLAMA_HOST` = `0.0.0.0`
65+
- Fully quit Ollama from the system tray (right-click → Quit) and relaunch it
66+
67+
Verify in PowerShell:
68+
69+
```
70+
netstat -an | findstr 11434
71+
```
72+
73+
You should see `0.0.0.0:11434` in the output.
74+
75+
### 2. Add a firewall rule
76+
77+
Find the WSL network address. In WSL, run:
78+
79+
```
80+
ip -4 addr show eth0 | grep -oP 'inet \K[\d.]+'
81+
```
82+
83+
Replace the host part of the IP with `0` to get the network address (e.g. if the
84+
command prints `a.b.c.d`, use `a.b.0.0`). Then open PowerShell as Administrator:
85+
86+
```
87+
netsh advfirewall firewall add rule name="Ollama WSL" dir=in action=allow protocol=TCP localport=11434 remoteip=<a.b.0.0>/20
88+
```
89+
90+
This restricts access to the WSL subnet only.
91+
92+
### 3. Update config.yaml
93+
94+
Find your WSL gateway IP (this is the Windows host as seen from WSL):
95+
96+
```
97+
ip route show default | awk '{print $3}'
98+
```
99+
100+
Update `config.yaml` with that IP:
101+
102+
```yaml
103+
ai:
104+
ollama_url: "http://<gateway-ip>:11434/api/chat"
105+
```
106+
107+
### 4. Verify connectivity
108+
109+
```
110+
curl http://<gateway-ip>:11434/api/version
111+
```
112+
113+
You should get a JSON response with the Ollama version.
114+
46115
## Additional Debian packages (optional)
47116

48-
- To view the photos selected after a `pdx` query, use `qimgv` instead of `gwenview` due to stability in Windows
117+
- To view the photos selected after a `pdx` query, use `qimgv` instead of `gwenview` due to stability in Windows
49118
```
50119
sudo apt install -y qimgv
51120
```

README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Photo inDeXer (pdx)
22

3+
## Prerequisites
4+
5+
- Python 3.13+
6+
- [Ollama](https://ollama.com) with a vision-language model pulled (e.g. `ollama pull gemma4:26b`)
7+
- [Podman](https://podman.io) for running Qdrant
8+
- `exiftool` for EXIF/GPS metadata extraction (`sudo apt install -y libimage-exiftool-perl`)
9+
- `libGL` for face recognition (`sudo apt install -y libgl1` on Debian/Ubuntu, `sudo dnf install -y mesa-libGL` on Fedora) — optional, only needed if using the `faces` config
10+
11+
See [README-Windows.md](README-Windows.md) for Windows/WSL-specific setup.
12+
313
## Set up a virtual Python environment
414

515
```sh
@@ -8,6 +18,60 @@ source .venv/bin/activate
818
pip install -e .
919
```
1020

21+
## Configuration
22+
23+
Copy the example config and adjust it for your setup:
24+
25+
```sh
26+
cp config.example.yaml config.yaml
27+
```
28+
29+
| Section | Key | Description |
30+
|---------|-----|-------------|
31+
| `ai` | `language` | Output language: `cs` (Czech) or `en` (English) |
32+
| `ai` | `ollama_url` | URL of the Ollama API endpoint |
33+
| `ai` | `model_name` | Vision-language model to use (e.g. `gemma4:26b`) |
34+
| `location` | `home_names` | List of city names considered "home" — photos taken here won't have the location in the folder name |
35+
| `faces` | `reference_dir` | Directory with reference face photos for recognition (one subdirectory per person) |
36+
| `faces` | `similarity_threshold` | Face matching threshold (lower = stricter, default `0.4`) |
37+
| `faces` | `name_map` | Map directory names to display names (e.g. `john: "Johnny"`) |
38+
| `storage` | `context_file` | Path to a text file with family/personal context for the AI |
39+
| `storage` | `history_file` | JSON list of past folder names (e.g. `"210619 - Beach volleyball"`) used as style examples for AI naming |
40+
41+
### Face recognition (optional)
42+
43+
To enable face recognition, create a reference directory with one subdirectory per person, each containing a few clear photos of their face (one face per photo):
44+
45+
```
46+
~/results/pdx/faces/
47+
├── john/
48+
│ ├── photo1.jpg
49+
│ ├── photo2.jpg
50+
│ └── photo3.jpg
51+
└── jane/
52+
├── photo1.jpg
53+
└── photo2.jpg
54+
```
55+
56+
Directory names are used as identifiers. Use `name_map` in the config to map them to display names (e.g. `john: "Johnny"`). 3-5 reference photos per person is usually enough.
57+
58+
### Family context (optional)
59+
60+
The `context_file` (default: `family_context.txt`) gives the AI background knowledge about your family — names, hobbies, sports, travel habits. This helps it generate more accurate photo descriptions and folder names. Write it in the same language as your `language` setting. Example:
61+
62+
```
63+
FAMILY MEMBERS:
64+
- Dad: Born 1985. Hobbies, sports.
65+
- Mom: Born 1987. Hobbies, interests.
66+
- Child1: Born 2013. Sport (team name, jersey color).
67+
68+
SPORTS:
69+
- Sport1 (Child1 only): Jersey description, equipment.
70+
- Sport2 (Dad): Gear, typical events.
71+
```
72+
73+
If the file is missing, the AI falls back to generic descriptions.
74+
1175
## Start/stop Qdrant (podman)
1276

1377
Storage is in the `pdx` directory under XDG data home (default: `~/.local/share/pdx`).
@@ -29,6 +93,11 @@ pdx index -c private /path/to/private_photos # create or extend the `private`
2993
pdx erase -c private # delete the `private` collection
3094
```
3195

96+
## Organize photos
97+
```sh
98+
pdx organize -c private /path/to/organized_folder # Use AI and EXIF to group photos into a structured directory tree.
99+
```
100+
32101
## Query photos
33102

34103
```sh

config.example.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# PDX Configuration Template
2+
# Copy this file to config.yaml and adjust for your setup.
3+
4+
ai:
5+
language: en # cs or en
6+
ollama_url: "http://localhost:11434/api/chat"
7+
model_name: "gemma4:26b"
8+
9+
location:
10+
# Add names of cities or districts you consider "Home"
11+
home_names:
12+
- "City"
13+
14+
faces:
15+
reference_dir: "faces"
16+
similarity_threshold: 0.4
17+
name_map: # optional: folder name -> display name
18+
john: "John"
19+
20+
storage:
21+
context_file: "family_context.txt"
22+
history_file: "folder_history.json"

pdx/cli.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
@click.version_option()
2020
def pdx():
2121
"""PDX: photo indexing and search CLI."""
22-
pass
22+
logging.basicConfig(level=logging.INFO)
2323

2424

2525
@pdx.command()
@@ -37,8 +37,6 @@ def index(collection: str, force_cpu: bool, real_path: bool, paths: tuple[str, .
3737
"""Index photos into a Qdrant collection."""
3838
from pdx.find import find_photos
3939

40-
logging.basicConfig(level=logging.INFO)
41-
4240
# collect paths to photos
4341
photos = find_photos(paths, include_symlinks=real_path)
4442
cnt = len(photos)
@@ -56,6 +54,25 @@ def index(collection: str, force_cpu: bool, real_path: bool, paths: tuple[str, .
5654
idx.index_photos(collection, photos)
5755

5856

57+
@pdx.command()
58+
@click.argument("target", type=click.Path())
59+
@click.option("--collection", "-c", default="default", help="Qdrant collection name.")
60+
@click.option("--config", "-f", default="config.yaml", help="Path to config file.")
61+
def organize(target: str, collection: str, config: str):
62+
"""Organize indexed photos into a structured directory tree."""
63+
from pdx.organizer import Organizer
64+
65+
config_path = os.path.realpath(config)
66+
if not os.path.isfile(config_path):
67+
raise click.BadParameter(
68+
f"Config file not found: {config}\n"
69+
"Copy config.example.yaml to config.yaml and adjust it for your setup.",
70+
param_hint="'--config'",
71+
)
72+
org = Organizer(collection, target, config_path=config_path)
73+
org.organize()
74+
75+
5976
@pdx.command()
6077
@click.argument("query_args", nargs=-1)
6178
@click.option("--collection", "-c", default="default", help="Qdrant collection name.")
@@ -80,7 +97,6 @@ def query(
8097
"""Search photos by natural language query. No query: interactive mode."""
8198
from pdx.query import QueryHandler
8299

83-
logging.basicConfig(level=logging.INFO)
84100
handler = QueryHandler(collection, limit, min_score, viewer)
85101

86102
query_str = " ".join(query_args).strip() if query_args else ""

pdx/faces.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import logging
2+
import warnings
3+
from pathlib import Path
4+
5+
import cv2
6+
import numpy as np
7+
from insightface.app import FaceAnalysis
8+
9+
warnings.filterwarnings(
10+
"ignore", message=".*estimate.*deprecated.*", category=FutureWarning
11+
)
12+
13+
14+
PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".png", ".heic", ".heif"}
15+
16+
17+
class FaceRecognizer:
18+
def __init__(
19+
self,
20+
reference_dir,
21+
similarity_threshold=0.4,
22+
det_size=(640, 640),
23+
name_map=None,
24+
):
25+
self.similarity_threshold = similarity_threshold
26+
self.name_map = name_map or {}
27+
28+
self.app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"])
29+
self.app.prepare(ctx_id=-1, det_size=det_size)
30+
31+
self.centroids = self._load_references(Path(reference_dir))
32+
if self.centroids:
33+
logging.info(
34+
f"Face recognition: loaded {len(self.centroids)} persons: {', '.join(self.centroids.keys())}"
35+
)
36+
else:
37+
logging.warning("Face recognition: no reference embeddings loaded")
38+
39+
def _load_references(self, reference_dir):
40+
centroids = {}
41+
for person_dir in sorted(reference_dir.iterdir()):
42+
if not person_dir.is_dir():
43+
continue
44+
embeddings = []
45+
for img_file in sorted(person_dir.iterdir()):
46+
if img_file.suffix.lower() not in PHOTO_EXTENSIONS:
47+
continue
48+
img = cv2.imread(str(img_file))
49+
if img is None:
50+
logging.warning(f"Face ref: cannot read {img_file}")
51+
continue
52+
faces = self.app.get(img)
53+
if len(faces) == 0:
54+
logging.warning(f"Face ref: no face in {img_file}")
55+
elif len(faces) > 1:
56+
logging.warning(f"Face ref: multiple faces in {img_file}, skipping")
57+
else:
58+
embeddings.append(faces[0].embedding)
59+
if embeddings:
60+
centroids[person_dir.name] = np.mean(embeddings, axis=0)
61+
else:
62+
logging.warning(f"Face ref: no usable faces for '{person_dir.name}'")
63+
return centroids
64+
65+
def _resolve_name(self, folder_name):
66+
if folder_name in self.name_map:
67+
return self.name_map[folder_name]
68+
return folder_name[0].upper() + folder_name[1:]
69+
70+
def identify_faces(self, image_path):
71+
try:
72+
img = cv2.imread(str(image_path))
73+
if img is None:
74+
return []
75+
faces = self.app.get(img)
76+
if not faces:
77+
return []
78+
except Exception as e:
79+
logging.warning(f"Face detection failed for {image_path}: {e}")
80+
return []
81+
82+
identified = []
83+
for face in faces:
84+
best_name = None
85+
best_score = -1
86+
for name, centroid in self.centroids.items():
87+
score = np.dot(face.embedding, centroid) / (
88+
np.linalg.norm(face.embedding) * np.linalg.norm(centroid)
89+
)
90+
if score > best_score:
91+
best_score = score
92+
best_name = name
93+
if best_name and best_score >= self.similarity_threshold:
94+
display_name = self._resolve_name(best_name)
95+
if display_name not in identified:
96+
identified.append(display_name)
97+
98+
return identified

pdx/find.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99

1010
PHOTOS_EXTS = ("heic", "heif", "jpeg", "jpg", "png")
11+
VIDEO_EXTS = ("mp4", "mov", "avi", "mkv", "mts", "m4v")
1112

1213

1314
class Finder:
@@ -42,8 +43,11 @@ def find_photos_in_dir(self, path: Path) -> None:
4243

4344
def handle_path(self, path: Path) -> None:
4445
if path.is_file():
45-
# take regular files as photos to index
46-
self._photos.append(str(path))
46+
ext = path.suffix[1:].lower() if path.suffix else ""
47+
if ext in PHOTOS_EXTS:
48+
self._photos.append(str(path))
49+
else:
50+
logging.warning(f"skipping unsupported file: {path}")
4751
elif path.is_dir():
4852
# traverse directories recursively and look for files matching PHOTOS_EXTS
4953
self.find_photos_in_dir(path)
@@ -59,3 +63,14 @@ def find_photos(paths: tuple[str, ...], include_symlinks: bool = False) -> list[
5963
finder.handle_path(path)
6064

6165
return finder.photos
66+
67+
68+
def find_videos(directories: set[Path]) -> list[Path]:
69+
videos = []
70+
for d in directories:
71+
if not d.is_dir():
72+
continue
73+
for f in d.iterdir():
74+
if f.is_file() and f.suffix and f.suffix[1:].lower() in VIDEO_EXTS:
75+
videos.append(f)
76+
return videos

0 commit comments

Comments
 (0)