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 +3 -0
- lensgrep/__main__.py +6 -0
- lensgrep/cli.py +344 -0
- lensgrep/core/__init__.py +1 -0
- lensgrep/core/cluster.py +44 -0
- lensgrep/core/db.py +468 -0
- lensgrep/core/dedupe.py +370 -0
- lensgrep/core/embedder.py +101 -0
- lensgrep/core/paths.py +29 -0
- lensgrep/core/scanner.py +254 -0
- lensgrep/core/search.py +65 -0
- lensgrep/exporters/__init__.py +1 -0
- lensgrep/exporters/crop.py +52 -0
- lensgrep/exporters/files.py +54 -0
- lensgrep/exporters/lora.py +253 -0
- lensgrep-0.1.0.dist-info/METADATA +307 -0
- lensgrep-0.1.0.dist-info/RECORD +20 -0
- lensgrep-0.1.0.dist-info/WHEEL +4 -0
- lensgrep-0.1.0.dist-info/entry_points.txt +2 -0
- lensgrep-0.1.0.dist-info/licenses/LICENSE +21 -0
lensgrep/__init__.py
ADDED
lensgrep/__main__.py
ADDED
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."""
|
lensgrep/core/cluster.py
ADDED
|
@@ -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
|