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.
- selects/__init__.py +3 -0
- selects/__main__.py +4 -0
- selects/classical/__init__.py +0 -0
- selects/classical/aesthetic_grade.py +101 -0
- selects/classical/auto_reject.py +29 -0
- selects/classical/blur.py +27 -0
- selects/classical/exposure.py +29 -0
- selects/classical/faces.py +65 -0
- selects/classical/straighten.py +111 -0
- selects/cli.py +176 -0
- selects/config.py +74 -0
- selects/db/__init__.py +127 -0
- selects/db/migrations/env.py +67 -0
- selects/db/migrations/script.py.mako +26 -0
- selects/db/migrations/versions/8ff743c44fc7_baseline_schema.py +297 -0
- selects/db/migrations/versions/a1b2c3d4e5f6_add_source_to_photo_tags_pk.py +105 -0
- selects/db/migrations/versions/c3d4e5f6a7b8_add_face_attribute_columns.py +63 -0
- selects/db/migrations/versions/d4e5f6a7b8c9_add_video_analysis_columns.py +75 -0
- selects/db/models.py +393 -0
- selects/decode/__init__.py +20 -0
- selects/decode/heic.py +16 -0
- selects/decode/jpeg.py +41 -0
- selects/decode/raw.py +27 -0
- selects/decode/video.py +61 -0
- selects/dedup.py +273 -0
- selects/export.py +316 -0
- selects/gpu.py +78 -0
- selects/indexer/__init__.py +0 -0
- selects/indexer/exif.py +147 -0
- selects/indexer/orchestrator.py +113 -0
- selects/indexer/preview.py +39 -0
- selects/indexer/walker.py +84 -0
- selects/ml/__init__.py +0 -0
- selects/ml/caption.py +168 -0
- selects/ml/curation.py +321 -0
- selects/ml/deblur_nafnet.py +275 -0
- selects/ml/embed.py +171 -0
- selects/ml/face_attributes.py +396 -0
- selects/ml/faces.py +126 -0
- selects/ml/locations.py +460 -0
- selects/ml/lowlight.py +173 -0
- selects/ml/model_assets.py +326 -0
- selects/ml/moments.py +214 -0
- selects/ml/nl_story.py +261 -0
- selects/ml/persons.py +149 -0
- selects/ml/ram_tags.py +260 -0
- selects/ml/retouch_csrnet.py +173 -0
- selects/ml/search.py +68 -0
- selects/ml/smart_clusters.py +539 -0
- selects/ml/stories.py +594 -0
- selects/ml/tags.py +150 -0
- selects/ml/taste.py +390 -0
- selects/ml/thematic_clusters.py +270 -0
- selects/ml/trip_data.py +195 -0
- selects/ml/trip_type.py +84 -0
- selects/pipeline.py +128 -0
- selects/recap.py +588 -0
- selects/server/__init__.py +0 -0
- selects/server/app.py +155 -0
- selects/server/dedup_routes.py +91 -0
- selects/server/export_routes.py +252 -0
- selects/server/faces2_routes.py +128 -0
- selects/server/fs_routes.py +49 -0
- selects/server/libraries.py +87 -0
- selects/server/library_manager.py +295 -0
- selects/server/models_routes.py +62 -0
- selects/server/pipeline_runner.py +41 -0
- selects/server/recap_routes.py +52 -0
- selects/server/routes.py +2392 -0
- selects/server/search2_routes.py +212 -0
- selects/server/static/assets/index-8kseOkrj.js +89 -0
- selects/server/static/assets/index-DBCjYqrq.css +1 -0
- selects/server/static/index.html +15 -0
- selects/server/taste_routes.py +44 -0
- selects/server/video_routes.py +199 -0
- selects/server/watch_routes.py +60 -0
- selects/server/ws.py +48 -0
- selects/video.py +392 -0
- selects/watcher.py +314 -0
- selects-0.1.0.dist-info/METADATA +255 -0
- selects-0.1.0.dist-info/RECORD +84 -0
- selects-0.1.0.dist-info/WHEEL +4 -0
- selects-0.1.0.dist-info/entry_points.txt +2 -0
- selects-0.1.0.dist-info/licenses/LICENSE +21 -0
selects/__init__.py
ADDED
selects/__main__.py
ADDED
|
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]
|