lensgrep 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lensgrep might be problematic. Click here for more details.

lensgrep/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """lensgrep — grep for your photo library."""
2
+
3
+ __version__ = "0.1.0"
lensgrep/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m lensgrep`."""
2
+
3
+ from .cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
lensgrep/cli.py ADDED
@@ -0,0 +1,344 @@
1
+ """lensgrep CLI - Typer commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from . import __version__
15
+ from .core.cluster import cluster_all
16
+ from .core.db import Database
17
+ from .core.embedder import Embedder
18
+ from .core.paths import data_dir, db_path
19
+ from .core.scanner import Scanner
20
+
21
+ # Force UTF-8 stdout/stderr — Windows legacy code pages (cp1250 etc.) choke on
22
+ # common output chars otherwise. No-op on POSIX.
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ reconfigure = getattr(_stream, "reconfigure", None)
25
+ if reconfigure is not None:
26
+ with contextlib.suppress(OSError, ValueError):
27
+ reconfigure(encoding="utf-8", errors="replace")
28
+
29
+ app = typer.Typer(
30
+ name="lensgrep",
31
+ help="Grep for your photo library. Find any person across thousands of photos, locally.",
32
+ no_args_is_help=True,
33
+ context_settings={"help_option_names": ["-h", "--help"]},
34
+ )
35
+ console = Console()
36
+
37
+
38
+ def _version_cb(value: bool) -> None:
39
+ if value:
40
+ console.print(f"lensgrep {__version__}")
41
+ raise typer.Exit()
42
+
43
+
44
+ @app.callback()
45
+ def _root(
46
+ version: Annotated[
47
+ bool | None,
48
+ typer.Option(
49
+ "--version", callback=_version_cb, is_eager=True, help="Print version and exit."
50
+ ),
51
+ ] = None,
52
+ ) -> None:
53
+ pass
54
+
55
+
56
+ @app.command()
57
+ def scan(
58
+ paths: Annotated[list[Path], typer.Argument(help="Folders or files to scan (recursive).")],
59
+ incremental: Annotated[
60
+ bool,
61
+ typer.Option("--incremental/--full", help="Skip already-scanned files based on mtime."),
62
+ ] = True,
63
+ gpu: Annotated[bool, typer.Option("--gpu/--cpu", help="Use CUDA if available.")] = True,
64
+ max_side: Annotated[
65
+ int, typer.Option(help="Downscale images longer than this for detection.")
66
+ ] = 2400,
67
+ recluster: Annotated[
68
+ bool, typer.Option("--recluster/--no-recluster", help="Re-run DBSCAN after scan.")
69
+ ] = True,
70
+ ) -> None:
71
+ """Scan folders for faces and store embeddings in the local database."""
72
+ db = Database()
73
+ embedder = Embedder(gpu=gpu)
74
+ scanner = Scanner(db, embedder, max_side=max_side)
75
+
76
+ console.print(f"[cyan]>>[/] scanning {len(paths)} root(s) -> {db_path()}")
77
+ stats = scanner.scan(paths, incremental=incremental)
78
+ console.print(
79
+ f"[green]done[/] scanned={stats.scanned} skipped={stats.skipped} "
80
+ f"new={stats.new_or_changed} faces+={stats.faces_added} fail={stats.failures}"
81
+ )
82
+
83
+ if recluster and stats.faces_added > 0:
84
+ console.print("[cyan]>>[/] re-clustering...")
85
+ counts = cluster_all(db)
86
+ n_clusters = sum(1 for k in counts if k >= 0)
87
+ n_noise = counts.get(-1, 0)
88
+ console.print(f"[green]clustered[/] {n_clusters} groups, {n_noise} unassigned")
89
+
90
+ db.close()
91
+
92
+
93
+ @app.command()
94
+ def people(
95
+ min_size: Annotated[int, typer.Option(help="Minimum cluster size to show.")] = 5,
96
+ limit: Annotated[int, typer.Option(help="Max rows to display.")] = 50,
97
+ ) -> None:
98
+ """List detected clusters of faces - name them with `lensgrep name`."""
99
+ db = Database()
100
+ clusters = db.cluster_summary(min_size=min_size)[:limit]
101
+ persons = {p.id: p for p in db.list_persons()}
102
+
103
+ table = Table(title="People (clusters)")
104
+ table.add_column("Cluster", justify="right", style="cyan")
105
+ table.add_column("Faces", justify="right")
106
+ table.add_column("Named as", style="green")
107
+ for cid, n, pid, _pname in clusters:
108
+ name = persons[pid].name if pid in persons else "-"
109
+ table.add_row(str(cid), str(n), name)
110
+ console.print(table)
111
+
112
+ named_table = Table(title="\nNamed persons")
113
+ named_table.add_column("Name", style="green")
114
+ named_table.add_column("Faces", justify="right")
115
+ named_table.add_column("Note", style="dim")
116
+ for p in db.list_persons():
117
+ named_table.add_row(p.name, str(p.face_count), p.note or "")
118
+ console.print(named_table)
119
+
120
+ db.close()
121
+
122
+
123
+ @app.command()
124
+ def name(
125
+ cluster_id: Annotated[int, typer.Argument(help="Cluster id from `lensgrep people`.")],
126
+ person_name: Annotated[str, typer.Argument(help="Name to assign.")],
127
+ note: Annotated[str | None, typer.Option(help="Optional note.")] = None,
128
+ ) -> None:
129
+ """Tag a cluster of faces with a person's name."""
130
+ db = Database()
131
+ with db.tx():
132
+ pid = db.upsert_person(person_name, note)
133
+ n = db.assign_person_to_cluster(cluster_id, pid)
134
+ console.print(f"[green]ok[/] '{person_name}' <- cluster {cluster_id}, assigned {n} face(s)")
135
+ db.close()
136
+
137
+
138
+ @app.command()
139
+ def find(
140
+ person_name: Annotated[str, typer.Argument(help="Person name to find photos of.")],
141
+ limit: Annotated[int, typer.Option("-n", "--limit", help="Max photos to list.")] = 50,
142
+ paths_only: Annotated[
143
+ bool, typer.Option("--paths", help="Print file paths only (one per line).")
144
+ ] = False,
145
+ ) -> None:
146
+ """Find photos of a tagged person."""
147
+ db = Database()
148
+ person = db.get_person(person_name)
149
+ if person is None:
150
+ console.print(f"[red]error[/] no such person: {person_name}")
151
+ raise typer.Exit(1)
152
+ rows = db.find_images_for_person(person.id, limit=limit)
153
+ seen: set[str] = set()
154
+ rows = [r for r in rows if not (r[1] in seen or seen.add(r[1]))]
155
+
156
+ if paths_only:
157
+ for _fid, p, _d, _s in rows:
158
+ console.print(p, highlight=False, markup=False)
159
+ else:
160
+ table = Table(title=f"Photos of {person.name} ({len(rows)} shown)")
161
+ table.add_column("#", justify="right")
162
+ table.add_column("path")
163
+ table.add_column("det", justify="right")
164
+ table.add_column("sharp", justify="right")
165
+ for i, (_fid, p, d, s) in enumerate(rows, 1):
166
+ table.add_row(str(i), p, f"{d:.2f}", f"{s:.0f}")
167
+ console.print(table)
168
+
169
+ db.close()
170
+
171
+
172
+ @app.command()
173
+ def export(
174
+ person_name: Annotated[str, typer.Argument(help="Person to export.")],
175
+ out_dir: Annotated[Path, typer.Argument(help="Destination folder.")],
176
+ limit: Annotated[int, typer.Option("-n", "--limit", help="Max photos.")] = 1000,
177
+ mode: Annotated[str, typer.Option(help="copy | symlink | hardlink")] = "copy",
178
+ ) -> None:
179
+ """Copy/symlink all photos of a person into a folder."""
180
+ from .exporters.files import export_person_files
181
+
182
+ db = Database()
183
+ n = export_person_files(db, person_name, out_dir, limit=limit, mode=mode)
184
+ console.print(f"[green]ok[/] exported {n} photo(s) to {out_dir}")
185
+ db.close()
186
+
187
+
188
+ @app.command()
189
+ def lora(
190
+ person_name: Annotated[str, typer.Argument(help="Person to build LoRA dataset for.")],
191
+ out_dir: Annotated[Path, typer.Argument(help="Destination folder for the dataset.")],
192
+ size: Annotated[
193
+ int, typer.Option("-n", "--size", help="Target dataset size (35-60 recommended).")
194
+ ] = 50,
195
+ resolution: Annotated[int, typer.Option(help="Crop resolution (1024 for SDXL/Flux).")] = 1024,
196
+ token: Annotated[
197
+ str | None, typer.Option(help="Caption token. Defaults to lowercased person name.")
198
+ ] = None,
199
+ captions: Annotated[str, typer.Option(help="template | none")] = "template",
200
+ ) -> None:
201
+ """Build a Flux/SDXL LoRA training dataset for a tagged person."""
202
+ from .exporters.lora import build_lora_dataset
203
+
204
+ db = Database()
205
+ n = build_lora_dataset(
206
+ db,
207
+ person_name,
208
+ out_dir,
209
+ size=size,
210
+ resolution=resolution,
211
+ token=token,
212
+ captions=captions,
213
+ )
214
+ console.print(
215
+ f"[green]ok[/] LoRA dataset: {n} image(s) at {resolution}x{resolution} -> {out_dir}"
216
+ )
217
+ console.print(f" Token: [cyan]{token or person_name.lower().replace(' ', '_')}[/]")
218
+ console.print(" Next: open AI Toolkit / kohya_ss -> point dataset path here -> train.")
219
+ db.close()
220
+
221
+
222
+ @app.command()
223
+ def stats() -> None:
224
+ """Show database statistics."""
225
+ db = Database()
226
+ s = db.stats()
227
+ table = Table(title=f"lensgrep - {data_dir()}")
228
+ table.add_column("metric", style="cyan")
229
+ table.add_column("value", justify="right")
230
+ for k, v in s.items():
231
+ table.add_row(k.replace("_", " "), f"{v:,}")
232
+ console.print(table)
233
+ db.close()
234
+
235
+
236
+ @app.command()
237
+ def dupes(
238
+ near: Annotated[
239
+ bool,
240
+ typer.Option(
241
+ "--near/--exact", help="Find perceptually-similar duplicates (default: --exact only)."
242
+ ),
243
+ ] = False,
244
+ threshold: Annotated[
245
+ int,
246
+ typer.Option(
247
+ help="pHash Hamming distance for --near (0=identical, 5=common dupes, 10=loose)."
248
+ ),
249
+ ] = 5,
250
+ keep: Annotated[
251
+ str,
252
+ typer.Option(
253
+ help="Which file to keep per group: largest | oldest | newest | shortest-path"
254
+ ),
255
+ ] = "largest",
256
+ mode: Annotated[
257
+ str, typer.Option(help="What to do with victims: list | delete | symlink")
258
+ ] = "list",
259
+ limit: Annotated[int, typer.Option("-n", "--limit", help="Max groups to display.")] = 50,
260
+ ) -> None:
261
+ """Find duplicate photos in the indexed library.
262
+
263
+ By default, --exact (bit-identical files) and --mode list (read-only).
264
+ Run `lensgrep dupes --mode delete` to actually free disk space.
265
+ """
266
+ from .core.dedupe import (
267
+ execute_actions,
268
+ find_exact_duplicates,
269
+ find_near_duplicates,
270
+ plan_actions,
271
+ )
272
+
273
+ db = Database()
274
+ if near:
275
+ console.print(f"[cyan]>>[/] finding near-duplicates (pHash distance <= {threshold})...")
276
+ groups = find_near_duplicates(db, threshold=threshold)
277
+ actions_threshold: int | None = threshold
278
+ else:
279
+ console.print("[cyan]>>[/] finding exact duplicates (SHA-256 match)...")
280
+ groups = find_exact_duplicates(db)
281
+ actions_threshold = None
282
+
283
+ if not groups:
284
+ console.print("[green]No duplicates found.[/]")
285
+ db.close()
286
+ return
287
+
288
+ actions = plan_actions(groups, strategy=keep, max_keeper_distance=actions_threshold)
289
+ total_freeable = sum(a.bytes_freed for a in actions)
290
+
291
+ console.print(
292
+ f"[yellow]Found {len(groups)} group(s), {sum(len(a.victims) for a in actions)} extra file(s), "
293
+ f"could free {total_freeable / 1024 / 1024:.1f} MB.[/]"
294
+ )
295
+
296
+ for i, act in enumerate(actions[:limit], 1):
297
+ t = Table(title=f"Group {i} (keep={keep})", show_lines=False)
298
+ t.add_column("role", style="cyan")
299
+ t.add_column("path")
300
+ t.add_column("size", justify="right")
301
+ t.add_column("WxH", justify="right")
302
+ t.add_row(
303
+ "KEEP",
304
+ act.keeper.path,
305
+ f"{act.keeper.size / 1024:.0f} KB",
306
+ f"{act.keeper.width}x{act.keeper.height}" if act.keeper.width else "?",
307
+ )
308
+ for v in act.victims:
309
+ t.add_row(
310
+ f"[red]{mode}[/]",
311
+ v.path,
312
+ f"{v.size / 1024:.0f} KB",
313
+ f"{v.width}x{v.height}" if v.width else "?",
314
+ )
315
+ console.print(t)
316
+
317
+ if mode == "list":
318
+ console.print(
319
+ "\n[dim]Read-only listing. Re-run with [bold]--mode delete[/bold] or [bold]--mode symlink[/bold] to act.[/]"
320
+ )
321
+ else:
322
+ removed, freed = execute_actions(db, actions, mode=mode)
323
+ console.print(f"[green]ok[/] {mode}d {removed} file(s), freed {freed / 1024 / 1024:.1f} MB")
324
+
325
+ db.close()
326
+
327
+
328
+ @app.command()
329
+ def recluster(
330
+ eps: Annotated[float, typer.Option(help="DBSCAN epsilon (cosine distance).")] = 0.35,
331
+ min_samples: Annotated[int, typer.Option(help="DBSCAN min samples.")] = 5,
332
+ ) -> None:
333
+ """Re-cluster all face embeddings."""
334
+ db = Database()
335
+ console.print(f"[cyan]>>[/] re-clustering (eps={eps}, min_samples={min_samples})...")
336
+ counts = cluster_all(db, eps=eps, min_samples=min_samples)
337
+ n_clusters = sum(1 for k in counts if k >= 0)
338
+ n_noise = counts.get(-1, 0)
339
+ console.print(f"[green]done[/] {n_clusters} clusters, {n_noise} noise")
340
+ db.close()
341
+
342
+
343
+ if __name__ == "__main__":
344
+ app()
@@ -0,0 +1 @@
1
+ """lensgrep core: database, scanning, embedding, search, clustering."""
@@ -0,0 +1,44 @@
1
+ """DBSCAN clustering of face embeddings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from sklearn.cluster import DBSCAN
7
+
8
+ from .db import Database
9
+
10
+
11
+ def normalize(embs: np.ndarray) -> np.ndarray:
12
+ return embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9)
13
+
14
+
15
+ def cluster_all(
16
+ db: Database,
17
+ eps: float = 0.35,
18
+ min_samples: int = 5,
19
+ ) -> dict[int, int]:
20
+ """Cluster all faces in DB by ArcFace cosine distance.
21
+
22
+ Returns: {cluster_id: count}. Cluster -1 = noise.
23
+ Writes cluster_id back to faces table.
24
+ """
25
+ ids, embs = db.all_face_embeddings()
26
+ if not ids:
27
+ return {}
28
+ embs = normalize(embs.astype(np.float32))
29
+ db_clust = DBSCAN(eps=eps, min_samples=min_samples, metric="cosine", n_jobs=-1)
30
+ labels = db_clust.fit_predict(embs)
31
+
32
+ counts: dict[int, int] = {}
33
+ for lab in labels:
34
+ counts[int(lab)] = counts.get(int(lab), 0) + 1
35
+
36
+ with db.tx():
37
+ db.set_cluster_ids(zip(ids, (int(x) for x in labels), strict=True))
38
+ # Carry forward any existing person_id assignments to faces that just
39
+ # joined a named cluster on this scan. Without this, an incremental
40
+ # scan can index new photos of a tagged person but find/export/lora
41
+ # won't see them until the user manually re-runs `lensgrep name`.
42
+ db.propagate_persons_through_clusters()
43
+
44
+ return counts