selects 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.
Files changed (84) hide show
  1. selects/__init__.py +3 -0
  2. selects/__main__.py +4 -0
  3. selects/classical/__init__.py +0 -0
  4. selects/classical/aesthetic_grade.py +101 -0
  5. selects/classical/auto_reject.py +29 -0
  6. selects/classical/blur.py +27 -0
  7. selects/classical/exposure.py +29 -0
  8. selects/classical/faces.py +65 -0
  9. selects/classical/straighten.py +111 -0
  10. selects/cli.py +176 -0
  11. selects/config.py +74 -0
  12. selects/db/__init__.py +127 -0
  13. selects/db/migrations/env.py +67 -0
  14. selects/db/migrations/script.py.mako +26 -0
  15. selects/db/migrations/versions/8ff743c44fc7_baseline_schema.py +297 -0
  16. selects/db/migrations/versions/a1b2c3d4e5f6_add_source_to_photo_tags_pk.py +105 -0
  17. selects/db/migrations/versions/c3d4e5f6a7b8_add_face_attribute_columns.py +63 -0
  18. selects/db/migrations/versions/d4e5f6a7b8c9_add_video_analysis_columns.py +75 -0
  19. selects/db/models.py +393 -0
  20. selects/decode/__init__.py +20 -0
  21. selects/decode/heic.py +16 -0
  22. selects/decode/jpeg.py +41 -0
  23. selects/decode/raw.py +27 -0
  24. selects/decode/video.py +61 -0
  25. selects/dedup.py +273 -0
  26. selects/export.py +316 -0
  27. selects/gpu.py +78 -0
  28. selects/indexer/__init__.py +0 -0
  29. selects/indexer/exif.py +147 -0
  30. selects/indexer/orchestrator.py +113 -0
  31. selects/indexer/preview.py +39 -0
  32. selects/indexer/walker.py +84 -0
  33. selects/ml/__init__.py +0 -0
  34. selects/ml/caption.py +168 -0
  35. selects/ml/curation.py +321 -0
  36. selects/ml/deblur_nafnet.py +275 -0
  37. selects/ml/embed.py +171 -0
  38. selects/ml/face_attributes.py +396 -0
  39. selects/ml/faces.py +126 -0
  40. selects/ml/locations.py +460 -0
  41. selects/ml/lowlight.py +173 -0
  42. selects/ml/model_assets.py +326 -0
  43. selects/ml/moments.py +214 -0
  44. selects/ml/nl_story.py +261 -0
  45. selects/ml/persons.py +149 -0
  46. selects/ml/ram_tags.py +260 -0
  47. selects/ml/retouch_csrnet.py +173 -0
  48. selects/ml/search.py +68 -0
  49. selects/ml/smart_clusters.py +539 -0
  50. selects/ml/stories.py +594 -0
  51. selects/ml/tags.py +150 -0
  52. selects/ml/taste.py +390 -0
  53. selects/ml/thematic_clusters.py +270 -0
  54. selects/ml/trip_data.py +195 -0
  55. selects/ml/trip_type.py +84 -0
  56. selects/pipeline.py +128 -0
  57. selects/recap.py +588 -0
  58. selects/server/__init__.py +0 -0
  59. selects/server/app.py +155 -0
  60. selects/server/dedup_routes.py +91 -0
  61. selects/server/export_routes.py +252 -0
  62. selects/server/faces2_routes.py +128 -0
  63. selects/server/fs_routes.py +49 -0
  64. selects/server/libraries.py +87 -0
  65. selects/server/library_manager.py +295 -0
  66. selects/server/models_routes.py +62 -0
  67. selects/server/pipeline_runner.py +41 -0
  68. selects/server/recap_routes.py +52 -0
  69. selects/server/routes.py +2392 -0
  70. selects/server/search2_routes.py +212 -0
  71. selects/server/static/assets/index-8kseOkrj.js +89 -0
  72. selects/server/static/assets/index-DBCjYqrq.css +1 -0
  73. selects/server/static/index.html +15 -0
  74. selects/server/taste_routes.py +44 -0
  75. selects/server/video_routes.py +199 -0
  76. selects/server/watch_routes.py +60 -0
  77. selects/server/ws.py +48 -0
  78. selects/video.py +392 -0
  79. selects/watcher.py +314 -0
  80. selects-0.1.0.dist-info/METADATA +255 -0
  81. selects-0.1.0.dist-info/RECORD +84 -0
  82. selects-0.1.0.dist-info/WHEEL +4 -0
  83. selects-0.1.0.dist-info/entry_points.txt +2 -0
  84. selects-0.1.0.dist-info/licenses/LICENSE +21 -0
selects/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """selects – local AI-assisted travel photo and video culling."""
2
+
3
+ __version__ = "0.1.0"
selects/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from selects.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
File without changes
@@ -0,0 +1,101 @@
1
+ """Natural auto-edit for selects's /api/enhance endpoint.
2
+
3
+ Rewritten 2026-05-25 — user feedback: previous CLAHE pipeline still over-
4
+ sharpened and bumped contrast on already-good photos. The new approach is
5
+ "do as little as possible" — only correct what's actually wrong:
6
+
7
+ - If the dynamic range is already wide (p1→p99 spans most of [0,255]),
8
+ skip the stretch entirely.
9
+ - If shadows aren't actually blocked, skip CLAHE.
10
+ - Always skip the unsharp mask (was the main over-sharpening offender).
11
+ - White balance correction is *very* subtle (15% pull toward neutral)
12
+ so warm sunsets and cool snow scenes are preserved.
13
+ - Highlight roll-off only kicks in if there's measurable clipping.
14
+
15
+ For a photo that's already well-exposed and well-balanced, this function
16
+ is now nearly a passthrough — which is the desired behaviour.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import cv2
21
+ import numpy as np
22
+ from PIL import Image
23
+
24
+
25
+ def auto_edit(img: Image.Image) -> Image.Image:
26
+ """Apply a natural auto-edit. Returns a new RGB Image."""
27
+ arr = np.array(img.convert("RGB"))
28
+
29
+ lab = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB)
30
+ L, A, B = cv2.split(lab)
31
+
32
+ L_f = L.astype(np.float32)
33
+
34
+ # ── Diagnostics on the input image ─────────────────────────────────────
35
+ p_low, p_high = np.percentile(L_f, [1.0, 99.0])
36
+ dyn_range = p_high - p_low
37
+ # How much of the image is sitting against the top of the curve.
38
+ pct_clipped_hi = float((L_f > 245).mean())
39
+ pct_clipped_lo = float((L_f < 10).mean())
40
+ # Where the median sits — used to decide if shadows are genuinely blocked.
41
+ median = float(np.median(L_f))
42
+
43
+ needs_stretch = dyn_range < 200 # default 8-bit range is ~255, so this
44
+ # only triggers on low-contrast images
45
+ needs_shadow_lift = median < 80 and pct_clipped_lo > 0.05
46
+ needs_highlight_recover = pct_clipped_hi > 0.04
47
+
48
+ L_out = L # default: untouched
49
+
50
+ # ── 1. Percentile stretch — only if range is genuinely narrow ──────────
51
+ if needs_stretch and dyn_range > 8:
52
+ L_out = np.clip(
53
+ (L_f - p_low) * (255.0 / dyn_range),
54
+ 0, 255,
55
+ ).astype(np.uint8)
56
+
57
+ # ── 2. CLAHE — only if shadows are actually blocked ────────────────────
58
+ if needs_shadow_lift:
59
+ clahe = cv2.createCLAHE(clipLimit=1.5, tileGridSize=(8, 8))
60
+ L_clahe = clahe.apply(L_out)
61
+ # 75% original + 25% CLAHE — very gentle local lift.
62
+ L_out = cv2.addWeighted(L_out, 0.75, L_clahe, 0.25, 0)
63
+
64
+ # ── 3. Highlight roll-off — only if clipping detected ──────────────────
65
+ if needs_highlight_recover:
66
+ roll_lut = np.arange(256, dtype=np.float32)
67
+ above = roll_lut > 232
68
+ roll_lut[above] = 232 + (roll_lut[above] - 232) * 0.70
69
+ roll_lut = np.clip(roll_lut, 0, 255).astype(np.uint8)
70
+ L_out = cv2.LUT(L_out, roll_lut)
71
+
72
+ # ── 4. White balance — always subtle (15% pull toward neutral) ─────────
73
+ a_mean = float(A.mean())
74
+ b_mean = float(B.mean())
75
+ a_drift = a_mean - 128.0
76
+ b_drift = b_mean - 128.0
77
+ # Only correct if there's an obvious cast (>5 units off neutral).
78
+ if abs(a_drift) > 5 or abs(b_drift) > 5:
79
+ a_corr = np.clip(A.astype(np.float32) - a_drift * 0.15, 0, 255).astype(np.uint8)
80
+ b_corr = np.clip(B.astype(np.float32) - b_drift * 0.15, 0, 255).astype(np.uint8)
81
+ else:
82
+ a_corr, b_corr = A, B
83
+
84
+ lab_out = cv2.merge([L_out, a_corr, b_corr])
85
+ rgb_out = cv2.cvtColor(lab_out, cv2.COLOR_LAB2RGB)
86
+
87
+ # NOTE: removed the unsharp-mask step. The user reported it over-sharpens
88
+ # everything. Sharpening should be a deliberate per-photo edit, not part
89
+ # of an auto pipeline.
90
+
91
+ return Image.fromarray(rgb_out)
92
+
93
+
94
+ def aesthetic_grade(
95
+ img: Image.Image,
96
+ preset: str = "natural",
97
+ has_face: bool = False,
98
+ ) -> Image.Image:
99
+ """Backwards-compat shim — old callers passed preset / has_face."""
100
+ _ = preset, has_face
101
+ return auto_edit(img)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ BLUR_THRESHOLD = 30.0
6
+ CLIP_THRESHOLD = 0.95
7
+
8
+
9
+ @dataclass
10
+ class RejectInput:
11
+ blur: float
12
+ exposure_score: float
13
+ clipped_ratio: float
14
+ faces_count: int
15
+
16
+
17
+ @dataclass
18
+ class RejectResult:
19
+ auto_reject: bool
20
+ reason: str | None
21
+
22
+
23
+ def evaluate_reject(inp: RejectInput) -> RejectResult:
24
+ if inp.blur < BLUR_THRESHOLD:
25
+ return RejectResult(True, "severe_blur")
26
+ if inp.clipped_ratio > CLIP_THRESHOLD:
27
+ if inp.exposure_score < 0.1 and inp.faces_count == 0:
28
+ return RejectResult(True, "blown_out" if inp.exposure_score > 0.5 else "all_black")
29
+ return RejectResult(False, None)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def laplacian_variance(img: np.ndarray) -> float:
7
+ """Variance of Laplacian as a sharpness proxy. Higher = sharper."""
8
+ import cv2
9
+
10
+ gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
11
+ try:
12
+ if cv2.cuda.getCudaEnabledDeviceCount() > 0:
13
+ return _gpu_lap_var(gray)
14
+ except AttributeError:
15
+ pass
16
+ lap = cv2.Laplacian(gray, cv2.CV_64F)
17
+ return float(lap.var())
18
+
19
+
20
+ def _gpu_lap_var(gray: np.ndarray) -> float:
21
+ import cv2
22
+
23
+ gpu = cv2.cuda_GpuMat()
24
+ gpu.upload(gray)
25
+ lap = cv2.cuda.createLaplacianFilter(cv2.CV_8U, cv2.CV_64F, ksize=1).apply(gpu)
26
+ lap_cpu = lap.download()
27
+ return float(lap_cpu.var())
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass
9
+ class ExposureResult:
10
+ score: float
11
+ mean: float
12
+ clipped_ratio: float
13
+
14
+
15
+ def exposure_score(img: np.ndarray) -> ExposureResult:
16
+ """Score in [0,1]. Higher = better exposed. 0 = all-black or all-white."""
17
+ import cv2
18
+
19
+ gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
20
+ mean = float(gray.mean()) / 255.0
21
+
22
+ n = gray.size
23
+ clipped = float(((gray < 8).sum() + (gray > 247).sum()) / n)
24
+
25
+ midness = 1.0 - 2.0 * abs(mean - 0.5)
26
+ # Normalise std to [0,1]: max theoretical std for a bimodal 0/255 image is 127.5
27
+ std_norm = min(float(gray.std()) / 127.5, 1.0)
28
+ score = max(0.0, midness * (1.0 - clipped) * (0.5 + 0.5 * std_norm))
29
+ return ExposureResult(score=score, mean=mean, clipped_ratio=clipped)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+ import numpy as np
7
+
8
+ _detector = None
9
+
10
+
11
+ @dataclass
12
+ class Face:
13
+ x: int
14
+ y: int
15
+ w: int
16
+ h: int
17
+ confidence: float
18
+ embedding: Optional[np.ndarray] = field(default=None, compare=False)
19
+ # Sub-model outputs from buffalo_l (the default FaceAnalysis loads all
20
+ # modules — detection, recognition, landmark_2d_106, landmark_3d_68/pose).
21
+ kps: Optional[np.ndarray] = field(default=None, compare=False) # 5x2 keypoints
22
+ landmark_2d_106: Optional[np.ndarray] = field(default=None, compare=False) # 106x2 landmarks
23
+ pose: Optional[np.ndarray] = field(default=None, compare=False) # (pitch, yaw, roll) deg
24
+
25
+
26
+ def _get_detector():
27
+ global _detector
28
+ if _detector is not None:
29
+ return _detector
30
+ from insightface.app import FaceAnalysis
31
+
32
+ providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
33
+ app = FaceAnalysis(name="buffalo_l", providers=providers)
34
+ app.prepare(ctx_id=0, det_size=(640, 640))
35
+ _detector = app
36
+ return app
37
+
38
+
39
+ def detect_faces(img: np.ndarray) -> list[Face]:
40
+ import cv2
41
+
42
+ bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
43
+ det = _get_detector()
44
+ faces = det.get(bgr)
45
+ result = []
46
+ for f in faces:
47
+ x1, y1, x2, y2 = (int(v) for v in f.bbox)
48
+ emb: Optional[np.ndarray] = None
49
+ if hasattr(f, "embedding") and f.embedding is not None:
50
+ emb = np.array(f.embedding, dtype=np.float32)
51
+
52
+ def _arr(name: str) -> Optional[np.ndarray]:
53
+ v = getattr(f, name, None)
54
+ return np.array(v, dtype=np.float64) if v is not None else None
55
+
56
+ result.append(
57
+ Face(
58
+ x=x1, y=y1, w=x2 - x1, h=y2 - y1,
59
+ confidence=float(f.det_score), embedding=emb,
60
+ kps=_arr("kps"),
61
+ landmark_2d_106=_arr("landmark_2d_106"),
62
+ pose=_arr("pose"),
63
+ )
64
+ )
65
+ return result
@@ -0,0 +1,111 @@
1
+ """Quick auto-straighten using probabilistic Hough lines.
2
+
3
+ Finds near-horizontal lines (skies, horizons, building tops) and rotates the
4
+ image so their median angle becomes exactly horizontal. Robust enough for
5
+ casual phone photos; not a full perspective correction.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import cv2
10
+ import numpy as np
11
+ from PIL import Image
12
+
13
+ # Maximum rotation we'll apply (degrees). Anything more is usually a
14
+ # deliberate composition, not an error.
15
+ MAX_ROTATION_DEG = 12.0
16
+ # We only consider line segments whose angle is within this band of strictly
17
+ # horizontal (in degrees). Outside this band the line probably isn't a horizon.
18
+ HORIZONTAL_BAND_DEG = 20.0
19
+
20
+
21
+ def estimate_rotation_angle(img: Image.Image) -> float:
22
+ """Return the angle (degrees) by which the image should be rotated to
23
+ appear straight. Positive = rotate CCW. Returns 0 if no good signal.
24
+ """
25
+ arr = np.array(img.convert("RGB"))
26
+ gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
27
+ # Mild blur to suppress micro-edges
28
+ gray = cv2.GaussianBlur(gray, (5, 5), 0)
29
+ edges = cv2.Canny(gray, 60, 180)
30
+ h, w = edges.shape
31
+
32
+ min_line_len = int(min(h, w) * 0.18)
33
+ max_line_gap = int(min(h, w) * 0.03)
34
+
35
+ lines = cv2.HoughLinesP(
36
+ edges,
37
+ rho=1,
38
+ theta=np.pi / 360.0,
39
+ threshold=80,
40
+ minLineLength=min_line_len,
41
+ maxLineGap=max_line_gap,
42
+ )
43
+ if lines is None or len(lines) == 0:
44
+ return 0.0
45
+
46
+ angles = []
47
+ weights = []
48
+ for line in lines:
49
+ x1, y1, x2, y2 = line[0]
50
+ dx = x2 - x1
51
+ dy = y2 - y1
52
+ # Skip vertical-ish lines — building edges shouldn't drive the horizon
53
+ length = (dx * dx + dy * dy) ** 0.5
54
+ if length < 4:
55
+ continue
56
+ # Angle relative to horizontal, in (-90, 90]
57
+ angle = np.degrees(np.arctan2(dy, dx))
58
+ # Normalise so we only look at near-horizontal candidates
59
+ if angle > 90:
60
+ angle -= 180
61
+ if angle < -90:
62
+ angle += 180
63
+ if abs(angle) > HORIZONTAL_BAND_DEG:
64
+ continue
65
+ angles.append(angle)
66
+ weights.append(length)
67
+
68
+ if not angles:
69
+ return 0.0
70
+
71
+ # Weighted median: more confidence in longer lines
72
+ pairs = sorted(zip(angles, weights))
73
+ total = sum(w for _, w in pairs)
74
+ acc = 0.0
75
+ median_angle = 0.0
76
+ for a, w in pairs:
77
+ acc += w
78
+ if acc >= total / 2:
79
+ median_angle = a
80
+ break
81
+
82
+ # Clamp to a sane maximum so we don't twist images sideways
83
+ if median_angle > MAX_ROTATION_DEG:
84
+ median_angle = MAX_ROTATION_DEG
85
+ elif median_angle < -MAX_ROTATION_DEG:
86
+ median_angle = -MAX_ROTATION_DEG
87
+
88
+ return float(median_angle)
89
+
90
+
91
+ def straighten(img: Image.Image) -> tuple[Image.Image, float]:
92
+ """Return (straightened image, applied angle in degrees)."""
93
+ angle = estimate_rotation_angle(img)
94
+ if abs(angle) < 0.4:
95
+ return img.convert("RGB"), 0.0
96
+
97
+ arr = np.array(img.convert("RGB"))
98
+ h, w = arr.shape[:2]
99
+ M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
100
+ rotated = cv2.warpAffine(
101
+ arr, M, (w, h),
102
+ flags=cv2.INTER_CUBIC,
103
+ borderMode=cv2.BORDER_REPLICATE,
104
+ )
105
+ # Crop a small border to hide the rotation triangles
106
+ if abs(angle) > 0.4:
107
+ crop_pct = min(0.04, abs(angle) / MAX_ROTATION_DEG * 0.04)
108
+ cx = int(w * crop_pct)
109
+ cy = int(h * crop_pct)
110
+ rotated = rotated[cy : h - cy, cx : w - cx]
111
+ return Image.fromarray(rotated), float(angle)
selects/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import webbrowser
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from selects.config import get_folder_config
9
+ from selects.db import init_db
10
+ from selects.gpu import detect_capabilities
11
+ from selects.indexer.orchestrator import index_folder
12
+ from selects.pipeline import run_classical_stage
13
+
14
+
15
+ @click.group()
16
+ def main():
17
+ """selects — local AI-assisted travel photo & video culling."""
18
+
19
+
20
+ @main.command()
21
+ @click.argument("folder", type=click.Path(exists=True, file_okay=False, path_type=Path))
22
+ @click.option(
23
+ "--pass", "pass_",
24
+ type=click.Choice(["all", "index", "classical", "embed", "tag", "smart_tag", "ram_tag",
25
+ "thematic", "date", "story", "face_embed", "moment"]),
26
+ default="all",
27
+ )
28
+ def index(folder: Path, pass_: str):
29
+ """Index a folder and run available pipeline stages."""
30
+ cfg = get_folder_config(folder)
31
+ init_db(cfg.db_path)
32
+
33
+ if pass_ in ("all", "index"):
34
+ added = index_folder(
35
+ cfg,
36
+ on_progress=lambda i, t, name: click.echo(f"[{i}/{t}] {name}", err=True),
37
+ )
38
+ click.echo(f"indexed: {added} new files")
39
+
40
+ if pass_ in ("all", "classical"):
41
+ processed = run_classical_stage(
42
+ cfg,
43
+ on_progress=lambda i, t, name: click.echo(f"[{i}/{t}] classical: {name}", err=True),
44
+ )
45
+ click.echo(f"classical: {processed} processed")
46
+
47
+ if pass_ == "embed":
48
+ from selects.ml.embed import run_embedding_stage
49
+ n = run_embedding_stage(
50
+ cfg,
51
+ on_progress=lambda i, t, name: click.echo(f"[{i}/{t}] embed: {name}", err=True),
52
+ )
53
+ click.echo(f"embed: {n} processed")
54
+
55
+ if pass_ == "tag":
56
+ from selects.ml.tags import run_tag_stage
57
+ n = run_tag_stage(
58
+ cfg,
59
+ on_progress=lambda i, t, name: click.echo(f"[{i}/{t}] tag: {name}", err=True),
60
+ )
61
+ click.echo(f"tag: {n} photos tagged")
62
+
63
+ if pass_ == "smart_tag":
64
+ from selects.ml.smart_clusters import run_smart_cluster_stage
65
+ n = run_smart_cluster_stage(
66
+ cfg,
67
+ on_progress=lambda i, t, name: click.echo(f"[{i}/{t}] smart_tag: {name}", err=True),
68
+ )
69
+ click.echo(f"smart_tag: {n} photos clustered")
70
+
71
+ if pass_ == "thematic":
72
+ from selects.ml.thematic_clusters import run_thematic_stage
73
+ n = run_thematic_stage(cfg, on_progress=None)
74
+ click.echo(f"thematic: {n} location clusters")
75
+
76
+ if pass_ == "date":
77
+ from selects.ml.thematic_clusters import run_date_stage
78
+ n = run_date_stage(cfg, on_progress=None)
79
+ click.echo(f"date: {n} day clusters")
80
+
81
+ if pass_ == "ram_tag":
82
+ from selects.ml.ram_tags import run_ram_tagging_stage
83
+ n = run_ram_tagging_stage(
84
+ cfg,
85
+ on_progress=lambda i, t, name: click.echo(f"[{i}/{t}] ram_tag: {name}", err=True),
86
+ )
87
+ click.echo(f"ram_tag: {n} photos tagged")
88
+
89
+ if pass_ in ("all", "story"):
90
+ from selects.ml.stories import run_story_stage
91
+ n = run_story_stage(
92
+ cfg,
93
+ on_progress=lambda i, t, name: click.echo(f"[{i}/{t}] story: {name}", err=True),
94
+ )
95
+ click.echo(f"story: {n} stories built")
96
+
97
+ if pass_ == "face_embed":
98
+ from selects.ml.faces import run_face_embedding_stage
99
+ n = run_face_embedding_stage(
100
+ cfg,
101
+ on_progress=lambda i, t, name: click.echo(f"[{i}/{t}] face_embed: {name}", err=True),
102
+ )
103
+ click.echo(f"face_embed: {n} photos processed")
104
+
105
+ if pass_ == "moment":
106
+ from selects.ml.moments import run_moment_stage
107
+ n = run_moment_stage(
108
+ cfg,
109
+ on_progress=lambda i, t, name: click.echo(f"[{i}/{t}] moment: {name}", err=True),
110
+ )
111
+ click.echo(f"moment: {n} moments built")
112
+
113
+
114
+ @main.command()
115
+ @click.argument(
116
+ "folder",
117
+ required=False,
118
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
119
+ )
120
+ @click.option("--port", default=8000, type=int)
121
+ @click.option("--host", default="127.0.0.1", type=str)
122
+ @click.option("--no-browser", is_flag=True)
123
+ @click.option("--no-background", is_flag=True, help="Don't auto-run indexer on startup")
124
+ def serve(folder: Path | None, port: int, host: str, no_browser: bool, no_background: bool):
125
+ """Serve the web UI + API.
126
+
127
+ With FOLDER: serve that folder as the (bootstrap) library. Without FOLDER:
128
+ serve the registry's active library if one exists, otherwise start with no
129
+ active library so the web onboarding page can create the first one.
130
+ """
131
+ import threading
132
+
133
+ import uvicorn
134
+
135
+ from selects.server.app import build_app
136
+ from selects.server.library_manager import LibraryManager
137
+
138
+ if folder is not None:
139
+ cfg = get_folder_config(folder)
140
+ init_db(cfg.db_path)
141
+ app = build_app(cfg, run_background=not no_background)
142
+ else:
143
+ manager = LibraryManager()
144
+ _libs, active_id = manager.list_libraries()
145
+ if active_id is not None:
146
+ try:
147
+ manager.activate(active_id)
148
+ except Exception:
149
+ pass
150
+ app = build_app(manager=manager, run_background=not no_background)
151
+
152
+ url = f"http://{'127.0.0.1' if host == '0.0.0.0' else host}:{port}"
153
+
154
+ if not no_browser:
155
+ def _open_browser():
156
+ import time
157
+
158
+ time.sleep(1.5)
159
+ webbrowser.open(url)
160
+
161
+ threading.Thread(target=_open_browser, daemon=True).start()
162
+
163
+ click.echo(f"selects serving at {url}")
164
+ uvicorn.run(app, host=host, port=port, log_level="warning")
165
+
166
+
167
+ @main.command()
168
+ def doctor():
169
+ """Report CUDA / NVDEC / nvImageCodec / cv2.cuda capabilities."""
170
+ caps = detect_capabilities()
171
+ click.echo(f"CUDA : {'yes' if caps.cuda_available else 'no'} ({caps.device_name})")
172
+ click.echo(f"CUDA capability : {caps.cuda_capability}")
173
+ click.echo(f"VRAM : {caps.vram_total_mb} MB")
174
+ click.echo(f"NVDEC (torchcodec): {'yes' if caps.nvdec_available else 'no'}")
175
+ click.echo(f"nvImageCodec : {'yes' if caps.nvimgcodec_available else 'no'}")
176
+ click.echo(f"cv2.cuda : {'yes' if caps.cv2_cuda_available else 'no'}")
selects/config.py ADDED
@@ -0,0 +1,74 @@
1
+ """selects configuration via pydantic-settings."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from pydantic import field_validator
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class FolderConfig(BaseSettings):
12
+ """Per-folder runtime configuration.
13
+
14
+ All fields can be overridden via env vars prefixed SELECTS_.
15
+ Example: SELECTS_WEB_PORT=9000
16
+ """
17
+
18
+ model_config = SettingsConfigDict(
19
+ env_prefix="SELECTS_",
20
+ env_file=".env",
21
+ extra="ignore",
22
+ )
23
+
24
+ folder: Path
25
+ web_port: int = 8765
26
+ web_host: str = "127.0.0.1"
27
+
28
+ # Burst detection
29
+ burst_window_seconds: int = 3
30
+ burst_similarity_threshold: float = 0.92
31
+
32
+ # Aesthetic curation thresholds (combined = AP_WEIGHT*AP + NIMA_WEIGHT*NIMA).
33
+ # Per-scope gate: photo must be in the top 25% of its scope (day/place/person).
34
+ # Library-wide floor: photo must also be in the top 35% globally — a
35
+ # mediocre photo isn't rescued just because its scope is thin.
36
+ ap_weight: float = 0.6
37
+ nima_weight: float = 0.4
38
+ aesthetic_per_scope_pct: float = 75.0 # top 25% within scope
39
+ aesthetic_library_pct: float = 50.0 # top 50% globally — generous default;
40
+ # bump higher (e.g. 65) to tighten
41
+
42
+ # Processing speed mode: "fast" skips some ML steps for quick preview
43
+ speed_mode: Literal["fast", "full"] = "full"
44
+
45
+ @field_validator("folder", mode="before")
46
+ @classmethod
47
+ def resolve_folder(cls, v: object) -> Path:
48
+ return Path(str(v)).expanduser().resolve()
49
+
50
+ # ------------------------------------------------------------------ #
51
+ # Derived paths (stored under <folder>/.selects/) #
52
+ # ------------------------------------------------------------------ #
53
+
54
+ @property
55
+ def state_dir(self) -> Path:
56
+ """Hidden state directory inside the watched folder."""
57
+ return self.folder / ".selects"
58
+
59
+ @property
60
+ def db_path(self) -> Path:
61
+ return self.state_dir / "index.db"
62
+
63
+ @property
64
+ def thumbs_dir(self) -> Path:
65
+ return self.state_dir / "thumbs"
66
+
67
+ @property
68
+ def previews_dir(self) -> Path:
69
+ return self.state_dir / "previews"
70
+
71
+
72
+ def get_folder_config(folder: Path | str, **overrides: object) -> FolderConfig:
73
+ """Create a FolderConfig for the given folder, with optional field overrides."""
74
+ return FolderConfig(folder=folder, **overrides) # type: ignore[arg-type]