🌐 English · Français · Deutsch · Italiano · Español · Português
All settings are in scoring_config.json. After modifying, run python facet.py --recompute-average to update scores (no GPU needed).
- Users
- Scanning
- Categories
- Scoring
- Thresholds
- Composition
- EXIF Adjustments
- Exposure
- Penalties
- Normalization
- Models
- Quality Assessment Models
- Processing
- Burst Detection
- Burst Scoring
- Duplicate Detection
- Face Detection
- Face Clustering
- Face Processing
- Monochrome Detection
- Tagging
- Standalone Tags
- Analysis
- Viewer
- Performance
- Storage
- Plugins
- Capsules
- Similarity Groups
- Scenes
- Timeline
- Map
- Translation
Optional multi-user mode. When the users key is present (with at least one user), single-password auth is replaced with per-user login.
{
"users": {
"alice": {
"password_hash": "salt_hex:dk_hex",
"display_name": "Alice",
"role": "superadmin",
"directories": ["/volume1/Photos/Alice"]
},
"bob": {
"password_hash": "salt_hex:dk_hex",
"display_name": "Bob",
"role": "user",
"directories": ["/volume1/Photos/Bob"]
},
"shared_directories": [
"/volume1/Photos/Family",
"/volume1/Photos/Vacations"
]
}
}| Field | Type | Description |
|---|---|---|
password_hash |
string | PBKDF2-HMAC-SHA256 hash (salt_hex:dk_hex). Generated by --add-user CLI. |
display_name |
string | Shown in the UI header |
role |
string | user, admin, or superadmin |
directories |
array | Private photo directories for this user |
The shared_directories key (sibling of user objects) lists directories visible to all users.
| Role | View own + shared | Rate/favorite | Manage persons/faces | Trigger scans |
|---|---|---|---|---|
user |
yes | yes | no | no |
admin |
yes | yes | yes | no |
superadmin |
yes | yes | yes | yes |
Users are created via CLI only — there is no registration UI or API:
python database.py --add-user alice --role superadmin --display-name "Alice"
# Prompts for password, writes hash to scoring_config.jsonAfter adding a user, edit scoring_config.json to configure their directories.
- No
userskey = legacy single-user mode (unchanged behavior) viewer.passwordandviewer.edition_passwordare ignored in multi-user mode- Existing ratings in the
photostable remain for single-user mode; use--migrate-user-preferencesto copy them
Controls directory scanning behavior.
{
"scanning": {
"skip_hidden_directories": true
}
}| Setting | Default | Description |
|---|---|---|
skip_hidden_directories |
true |
Skip directories starting with . during photo scanning |
Array of category definitions. See Scoring for detailed category documentation.
Each category has:
name- Category identifierpriority- Lower = higher priority (evaluated first)filters- Conditions for matchingweights- Scoring metric weights (must sum to 100)modifiers- Behavior adjustmentstags- CLIP vocabulary for tag-based matching
Form & color-harmony weights. Every category's
weightsblock carries five explainable metric keys —symmetry_percent,balance_percent,edge_entropy_percent,fractal_percent, andcolor_harmony_percent— populated by--recompute-form. They ship at0in every category, so aggregates stay byte-identical until you give one a weight (then re-run--recompute-average). Weights within a category must still sum to 100.
{
"scoring": {
"score_min": 0.0,
"score_max": 10.0,
"score_precision": 2
}
}| Setting | Default | Description |
|---|---|---|
score_min |
0.0 |
Minimum possible score |
score_max |
10.0 |
Maximum possible score |
score_precision |
2 |
Decimal places for scores |
Detection thresholds for automatic categorization.
{
"thresholds": {
"portrait_face_ratio_percent": 5,
"blink_penalty_percent": 50,
"night_luminance_threshold": 0.15,
"night_iso_threshold": 3200,
"long_exposure_shutter_threshold": 1.0,
"astro_shutter_threshold": 10.0
}
}| Setting | Default | Description |
|---|---|---|
portrait_face_ratio_percent |
5 |
Face > 5% of frame = portrait |
blink_penalty_percent |
50 |
Score multiplier when blink detected (0.5x) |
night_luminance_threshold |
0.15 |
Mean luminance below this = night |
night_iso_threshold |
3200 |
ISO above this = low-light |
long_exposure_shutter_threshold |
1.0 |
Shutter > 1s = long exposure |
astro_shutter_threshold |
10.0 |
Shutter > 10s = astrophotography |
Rule-based composition scoring (used when SAMP-Net is not active).
{
"composition": {
"power_point_weight": 2.0,
"line_weight": 1.0
}
}| Setting | Default | Description |
|---|---|---|
power_point_weight |
2.0 |
Weight for rule-of-thirds placement |
line_weight |
1.0 |
Weight for leading lines |
Automatic scoring adjustments based on camera settings.
{
"exif_adjustments": {
"iso_sharpness_compensation": true,
"aperture_isolation_boost": true
}
}| Setting | Default | Description |
|---|---|---|
iso_sharpness_compensation |
true |
Reduce sharpness penalty for high-ISO |
aperture_isolation_boost |
true |
Boost isolation for wide apertures (f/1.4-f/2.8) |
Controls exposure analysis and clipping detection.
{
"exposure": {
"shadow_clip_threshold_percent": 15,
"highlight_clip_threshold_percent": 10,
"silhouette_detection": true
}
}| Setting | Default | Description |
|---|---|---|
shadow_clip_threshold_percent |
15 |
Flag if > 15% pixels pure black |
highlight_clip_threshold_percent |
10 |
Flag if > 10% pixels pure white |
silhouette_detection |
true |
Detect intentional silhouettes |
Score penalties for technical issues.
{
"penalties": {
"noise_sigma_threshold": 4.0,
"noise_max_penalty_points": 1.5,
"noise_penalty_per_sigma": 0.3,
"bimodality_threshold": 2.5,
"bimodality_penalty_points": 0.5,
"leading_lines_blend_percent": 30,
"oversaturation_threshold": 0.9,
"oversaturation_pixel_percent": 5,
"oversaturation_penalty_points": 0.5
}
}| Setting | Default | Description |
|---|---|---|
noise_sigma_threshold |
4.0 |
Noise above this triggers penalty |
noise_max_penalty_points |
1.5 |
Maximum noise penalty |
noise_penalty_per_sigma |
0.3 |
Points per sigma above threshold |
bimodality_threshold |
2.5 |
Histogram bimodality coefficient |
bimodality_penalty_points |
0.5 |
Penalty for bimodal histograms |
leading_lines_blend_percent |
30 |
Blend into comp_score |
oversaturation_threshold |
0.9 |
Mean saturation threshold |
oversaturation_pixel_percent |
5 |
Reserved for pixel-level detection |
oversaturation_penalty_points |
0.5 |
Oversaturation penalty |
Noise penalty formula:
penalty = min(noise_max_penalty_points, (noise_sigma - threshold) * noise_penalty_per_sigma)
Controls how raw metrics are scaled to 0-10 scores.
{
"normalization": {
"method": "percentile",
"percentile_target": 90,
"per_category": true,
"category_min_samples": 50
}
}| Setting | Default | Description |
|---|---|---|
method |
"percentile" |
Normalization method |
percentile_target |
90 |
90th percentile = score of 10.0 |
per_category |
true |
Category-specific normalization |
category_min_samples |
50 |
Minimum photos for per-category |
Selects which models are used per VRAM profile.
{
"models": {
"vram_profile": "auto",
"keep_in_ram": "auto",
"profiles": {
"legacy": {
"aesthetic_model": "clip-mlp",
"clip_config": "clip_legacy",
"composition_model": "samp-net",
"tagging_model": "clip",
"supplementary_pyiqa": ["topiq_iaa", "topiq_nr_face", "liqe"],
"saliency_enabled": true,
"description": "CPU: CLIP-MLP aesthetic + SAMP-Net composition + CLIP tagging + TOPIQ IAA/NR-Face/LIQE + BiRefNet saliency (8GB+ RAM; saliency/IQA are slower on CPU)"
},
"8gb": {
"aesthetic_model": "clip-mlp",
"clip_config": "clip_legacy",
"composition_model": "samp-net",
"tagging_model": "clip",
"supplementary_pyiqa": ["topiq_iaa", "topiq_nr_face", "liqe"],
"saliency_enabled": true,
"description": "CLIP-MLP aesthetic + SAMP-Net composition + CLIP tagging + TOPIQ IAA/NR-Face/LIQE + BiRefNet saliency (6-14GB VRAM)"
},
"16gb": {
"aesthetic_model": "topiq",
"clip_config": "clip",
"composition_model": "samp-net",
"tagging_model": "qwen3.5-2b",
"supplementary_pyiqa": ["topiq_iaa", "topiq_nr_face", "liqe"],
"saliency_enabled": true,
"description": "TOPIQ aesthetic + SigLIP 2 embeddings + Qwen3.5-2B tagging (~14GB VRAM)"
},
"24gb": {
"aesthetic_model": "topiq",
"clip_config": "clip",
"composition_model": "qwen2-vl-2b",
"tagging_model": "qwen3.5-4b",
"supplementary_pyiqa": ["topiq_iaa", "topiq_nr_face", "liqe"],
"saliency_enabled": true,
"description": "TOPIQ aesthetic + SigLIP 2 embeddings + Qwen3.5-4B tagging (~18GB VRAM)"
}
},
"clip": {
"model_name": "google/siglip2-so400m-patch16-naflex",
"backend": "transformers",
"embedding_dim": 1152,
"similarity_threshold_percent": 8
},
"clip_legacy": {
"model_name": "ViT-L-14",
"pretrained": "laion2b_s32b_b82k",
"embedding_dim": 768,
"similarity_threshold_percent": 22
},
"qwen2_vl": {
"model_path": "Qwen/Qwen2-VL-2B-Instruct",
"torch_dtype": "bfloat16",
"max_new_tokens": 256
},
"qwen3_5_2b": {
"model_path": "Qwen/Qwen3.5-2B",
"torch_dtype": "bfloat16",
"max_new_tokens": 100,
"vlm_batch_size": 4
},
"qwen3_5_4b": {
"model_path": "Qwen/Qwen3.5-4B",
"torch_dtype": "bfloat16",
"max_new_tokens": 100,
"vlm_batch_size": 2
},
"saliency": {
"model": "ZhengPeng7/BiRefNet_dynamic",
"resolution": 1024,
"mask_threshold": 0.3,
"min_subject_pixels": 50
},
"samp_net": {
"model_path": "pretrained_models/samp_net.pth",
"download_url": "https://github.com/bcmi/Image-Composition-Assessment-with-SAMP/releases/download/v1.0/samp_net.pth",
"input_size": 384,
"patterns": [
"none", "center", "rule_of_thirds", "golden_ratio", "triangle",
"horizontal", "vertical", "diagonal", "symmetric", "curved",
"radial", "vanishing_point", "pattern", "fill_frame"
]
}
}
}| Setting | Default | Description |
|---|---|---|
vram_profile |
"auto" |
Active profile (auto, legacy, 8gb, 16gb, 24gb). Overridable at runtime by the FACET_VRAM_PROFILE env var (see below). |
keep_in_ram |
"auto" |
Keep models in RAM between multi-pass chunks ("auto", "always", "never"). auto checks available RAM before caching. |
profiles.*.supplementary_pyiqa |
["topiq_iaa", "topiq_nr_face", "liqe"] |
PyIQA models to run for this profile (all four profiles run the full set) |
profiles.*.saliency_enabled |
true (all profiles) |
Run BiRefNet subject saliency for this profile |
clip.model_name |
"google/siglip2-so400m-patch16-naflex" |
SigLIP 2 NaFlex embedding model (16gb/24gb) |
clip.backend |
"transformers" |
"transformers" (SigLIP 2 NaFlex) or "open_clip" (legacy) |
clip.embedding_dim |
1152 |
Embedding dimensions (1152 for SigLIP 2) |
clip.similarity_threshold_percent |
8 |
Minimum CLIP cosine similarity for a tag match |
clip_legacy.model_name |
"ViT-L-14" |
Legacy CLIP model (legacy/8gb profiles) |
clip_legacy.pretrained |
"laion2b_s32b_b82k" |
Legacy pre-trained weights |
clip_legacy.embedding_dim |
768 |
Legacy embedding dimensions |
clip_legacy.similarity_threshold_percent |
22 |
Tag-match threshold for legacy CLIP |
qwen2_vl.model_path |
"Qwen/Qwen2-VL-2B-Instruct" |
HuggingFace path (24gb composition VLM) |
qwen3_5_2b.model_path |
"Qwen/Qwen3.5-2B" |
Tagging model for 16gb profile |
qwen3_5_2b.vlm_batch_size |
4 |
Images per VLM inference batch |
qwen3_5_4b.model_path |
"Qwen/Qwen3.5-4B" |
Tagging model for 24gb profile |
qwen3_5_4b.vlm_batch_size |
2 |
Images per VLM inference batch |
saliency.model |
"ZhengPeng7/BiRefNet_dynamic" |
BiRefNet saliency model |
saliency.resolution |
1024 |
Inference resolution |
saliency.mask_threshold |
0.3 |
Sigmoid threshold for the binary subject mask |
saliency.min_subject_pixels |
50 |
Minimum subject pixels to count a subject as detected |
samp_net.input_size |
384 |
Composition model input size |
When vram_profile is "auto" (default), the system detects available GPU VRAM at startup and selects the largest profile that fits:
| Detected VRAM | Selected Profile |
|---|---|
| ≥ 20GB | 24gb |
| ≥ 14GB | 16gb |
| ≥ 6GB | 8gb |
| No GPU | legacy (uses system RAM) |
The FACET_VRAM_PROFILE environment variable overrides models.vram_profile at load time (honored by config/scoring_config.py), so a single mounted config can serve every Docker profile without editing the JSON. Accepted values are auto, legacy, 8gb, 16gb, and 24gb; any other value is ignored with a warning (so a typo can't silently mis-scan). The per-profile Docker Compose overlays (docker-compose.{legacy,8gb,16gb,24gb}.yml) set this variable for you.
FACET_VRAM_PROFILE=8gb python facet.py /path/to/photosSelects the model that scores image quality/aesthetics, via the pyiqa library.
{
"quality": {
"model": "auto",
"prefer_llm": false
}
}| Setting | Default | Description |
|---|---|---|
model |
"auto" |
Quality model: auto, topiq, hyperiqa, dbcnn, musiq, clip-mlp. auto uses topiq. |
prefer_llm |
false |
Prefer an LLM-based scorer when one is available |
SRCC = Spearman Rank Correlation Coefficient on the KonIQ-10k benchmark (1.0 = perfect).
| Model | SRCC | VRAM | Notes |
|---|---|---|---|
topiq |
0.93 | ~2GB | Default (auto); ResNet50 backbone with top-down attention |
hyperiqa |
0.90 | ~2GB | Hyper-network, content-adaptive |
dbcnn |
0.90 | ~2GB | Dual-branch CNN (synthetic + authentic distortions) |
musiq |
0.87 | ~2GB | Multi-scale transformer; handles any resolution |
clipiqa+ |
0.86 | ~4GB | CLIP with learned quality prompts |
clip-mlp |
0.76 | ~4GB | Legacy CLIP ViT-L-14 + MLP head |
-
Edit
scoring_config.json:"quality": { "model": "topiq" }
-
Re-score existing photos (optional):
python facet.py /path --pass quality python facet.py --recompute-average
Unified processing settings for GPU batch processing and multi-pass mode.
{
"processing": {
"mode": "auto",
"gpu_batch_size": 16,
"ram_chunk_size": 32,
"num_workers": 4,
"auto_tuning": {
"enabled": true,
"monitor_interval_seconds": 5,
"tuning_interval_images": 32,
"min_processing_workers": 1,
"max_processing_workers": 32,
"min_gpu_batch_size": 2,
"max_gpu_batch_size": 32,
"min_ram_chunk_size": 10,
"max_ram_chunk_size": 128,
"memory_limit_percent": 85,
"cpu_target_percent": 85,
"metrics_print_interval_seconds": 30
},
"thumbnails": {
"photo_size": 640,
"photo_quality": 80,
"face_padding_ratio": 0.3
}
}
}gpu_batch_size - How many images are processed together on the GPU in a single forward pass. Limited by VRAM. Auto-tuned: reduced when GPU memory exceeds limit.
ram_chunk_size - How many images are cached in RAM between model passes (multi-pass mode only). Reduces disk I/O by loading images once per chunk. Limited by system RAM. Auto-tuned: reduced when system memory exceeds limit.
| Setting | Default | Description |
|---|---|---|
mode |
"auto" |
Processing mode: auto, multi-pass, single-pass |
gpu_batch_size |
16 |
Images per GPU batch (VRAM-limited) |
ram_chunk_size |
32 |
Images per RAM chunk (multi-pass) |
num_workers |
4 |
Image loader threads |
load_workers |
num_workers |
Multi-pass chunk loader threads (capped at 8, 1 = sequential) |
raw_decode_concurrency |
0 (auto) |
Max simultaneous RAW decodes; auto-sized from CPU/RAM (1-4), 1 = fully serialized |
raw_decode_timeout_seconds |
120 |
Abandon a hung RAW decode after this delay (0 = disabled); scan fails fast after repeated hangs |
exif_prefetch |
true |
Single-pass mode: prefetch EXIF in background instead of blocking the GPU thread |
| auto_tuning | ||
enabled |
true |
Enable auto-tuning |
monitor_interval_seconds |
5 |
Resource check interval |
tuning_interval_images |
32 |
Re-tune every N images |
min_processing_workers |
1 |
Minimum loader threads |
max_processing_workers |
32 |
Maximum loader threads |
min_gpu_batch_size |
2 |
Minimum GPU batch size |
max_gpu_batch_size |
32 |
Maximum GPU batch size |
min_ram_chunk_size |
10 |
Minimum RAM chunk size |
max_ram_chunk_size |
128 |
Maximum RAM chunk size |
memory_limit_percent |
85 |
System memory usage limit |
cpu_target_percent |
85 |
CPU usage target |
metrics_print_interval_seconds |
30 |
Stats print interval |
| thumbnails | ||
photo_size |
640 |
Stored thumbnail size (pixels) |
photo_quality |
80 |
Thumbnail JPEG quality |
face_padding_ratio |
0.3 |
Padding around face crops |
| Mode | Description |
|---|---|
auto |
Automatically selects multi-pass or single-pass based on VRAM |
multi-pass |
Sequential model loading (works with limited VRAM) |
single-pass |
All models loaded at once (requires high VRAM) |
Instead of loading all models at once, multi-pass:
- Loads images in RAM chunks (default
ram_chunk_size: 32) - For each chunk, runs models sequentially: load model → process chunk → unload model
- Combines results in a final aggregation pass
Each image is loaded once per chunk, and passes are grouped to fit available VRAM, so the larger tagger/composition VLMs run even on limited VRAM.
The system monitors resource usage and adjusts:
| Metric | Action |
|---|---|
| GPU memory > limit | Reduce gpu_batch_size by 25% |
| System RAM > limit | Reduce ram_chunk_size by 25% |
| System RAM < (limit - 20%) | Increase ram_chunk_size by 25% |
| CPU > target | Suggest fewer workers |
| Queue timeouts > 5% | Suggest more workers |
When VRAM allows, multiple small models run together:
| VRAM | Pass 1 | Pass 2 |
|---|---|---|
| 8GB | CLIP + SAMP-Net + InsightFace | TOPIQ |
| 12GB | CLIP + SAMP-Net + InsightFace + TOPIQ | - |
| 16GB | CLIP + SAMP-Net + InsightFace + TOPIQ | tagger VLM |
| 24GB+ | All models together (single-pass) | - |
# Default: auto multi-pass with optimal grouping
python facet.py /path/to/photos
# Force single-pass (all models loaded at once)
python facet.py /path --single-pass
# Run specific pass only
python facet.py /path --pass quality # TOPIQ only
python facet.py /path --pass quality-iaa # TOPIQ IAA (aesthetic merit)
python facet.py /path --pass quality-face # TOPIQ NR-Face
python facet.py /path --pass quality-liqe # LIQE (quality + distortion)
python facet.py /path --pass tags # Configured tagger only
python facet.py /path --pass composition # SAMP-Net only
python facet.py /path --pass faces # InsightFace only
python facet.py /path --pass embeddings # CLIP/SigLIP embeddings only
python facet.py /path --pass saliency # BiRefNet subject saliency
# List available models
python facet.py --list-modelsGroups similar photos taken in quick succession.
{
"burst_detection": {
"similarity_threshold_percent": 70,
"time_window_minutes": 0.8,
"rapid_burst_seconds": 0.4
}
}| Setting | Default | Description |
|---|---|---|
similarity_threshold_percent |
70 |
Image hash similarity threshold |
time_window_minutes |
0.8 |
Maximum time between photos |
rapid_burst_seconds |
0.4 |
Photos within this auto-grouped |
Weights used by burst culling to compute a composite score for selecting the best shot within each burst group. Weights should sum to 1.0.
{
"burst_scoring": {
"weight_aggregate": 0.4,
"weight_aesthetic": 0.25,
"weight_sharpness": 0.2,
"weight_blink": 0.15
}
}| Setting | Default | Description |
|---|---|---|
weight_aggregate |
0.4 |
Weight of the overall aggregate score |
weight_aesthetic |
0.25 |
Weight of the aesthetic quality score |
weight_sharpness |
0.2 |
Weight of the technical sharpness score |
weight_blink |
0.15 |
Penalty weight for detected blinks (higher = stronger penalty) |
Detect duplicate photos globally using perceptual hash (pHash) comparison.
{
"duplicate_detection": {
"similarity_threshold_percent": 90,
"prefilter_hamming": 12,
"embedding_cosine_threshold": 0.90
}
}| Setting | Default | Description |
|---|---|---|
similarity_threshold_percent |
90 |
Strict pHash gate (90% = Hamming distance <= 6 of 64 bits); used as the sole criterion when an embedding is missing for either photo |
prefilter_hamming |
12 |
Optional override (absent from the shipped file). Stage-1 loose Hamming gate for the candidate set when both photos have embeddings (coerced to be >= the strict gate) |
embedding_cosine_threshold |
0.90 |
Optional override (absent from the shipped file). Stage-2 SigLIP/CLIP cosine gate: a loose-pHash candidate only merges when cosine >= this |
Detection is two-stage: loose pHash candidates (recall) confirmed by a tight embedding cosine gate (precision). Photos without an embedding fall back to the strict pHash-only criterion, so behavior is unchanged when embeddings are absent.
Run python facet.py --detect-duplicates to detect and group duplicates. Run python facet.py --sweep-dedup-thresholds [labels.json] to evaluate the cosine gate — with a labels JSON it prints a precision/recall table, otherwise the candidate-cosine distribution and how many strict-pHash collisions the gate rejects.
Heavy/experimental quality scorers, OFF by default and never a replacement for TOPIQ — they add supplementary columns only when explicitly enabled. When enabled, the extended scorers run during a normal scan and write their own columns; a load/VRAM failure is logged and the column is left NULL (the scan never aborts).
{
"iqa_extended": {
"qalign": "4bit",
"aesthetic_v25": true,
"deqa": false
}
}| Setting | Default | Accepted values | Column | Description |
|---|---|---|---|---|
qalign |
false |
false · "4bit" · "8bit" · true/"full" |
qalign_score |
Q-Align LLM-based IQA (pyiqa-backed). "4bit" (~6-8GB VRAM) is the practical choice on a 16GB card; "8bit" ~12-14GB; full precision (true) wants 16GB+. 4-/8-bit need bitsandbytes. |
aesthetic_v25 |
false |
true / false |
aesthetic_v25 |
Aesthetic Predictor V2.5 (SigLIP head, ~2GB). Requires the aesthetic-predictor-v2-5 package. |
deqa |
false |
true / false |
deqa_score |
DeQA-Score VLM IQA (16GB+ GPU; skipped & left NULL otherwise). |
Install the optional dependencies for whatever you enable: pip install -e .[iqa-extended] (adds aesthetic-predictor-v2-5 + bitsandbytes), or uncomment the matching lines in requirements.txt. Q-Align itself ships with pyiqa; DeQA-Score downloads via transformers.
When enabled, each metric is exposed to the weighted aggregate but defaults to weight 0, so --recompute-average is byte-identical until you give it a weight. Run python facet.py --eval-iqa-srcc to measure how well each metric ranks your library against your own star ratings.
Viewer surfacing. When any of these columns is populated, the viewer shows the value in the photo-detail Quality panel (Q-Align, Aesthetic V2.5, DeQA) and exposes a matching range slider in the gallery filter sidebar under Extended Quality (min_qalign/max_qalign, min_aesthetic_v25/max_aesthetic_v25, min_deqa/max_deqa). Photos scanned before the tier was enabled simply have NULL in these columns and are unaffected by the filters.
Robustness. DeQA-Score loads remote trust_remote_code code whose forward signature varies across checkpoint revisions; its scorer is defensive — any prediction failure (wrong signature, unexpected output shape, OOM) is caught and the image's deqa_score is left NULL rather than crashing the scan.
InsightFace face detection settings.
{
"face_detection": {
"min_confidence_percent": 65,
"min_face_size": 20,
"blink_ear_threshold": 0.28,
"min_faces_for_group": 4,
"enable_3d_landmarks": false,
"eyes_closed_max": 4.0,
"poor_expression_min": 4.0,
"blendshapes": {
"enabled": true,
"min_crop_size": 192
}
}
}| Setting | Default | Description |
|---|---|---|
min_confidence_percent |
65 |
Minimum detection confidence |
min_face_size |
20 |
Minimum face size in pixels |
blink_ear_threshold |
0.28 |
Eye Aspect Ratio for blink detection |
min_faces_for_group |
4 |
Minimum faces to classify as group portrait (recomputed on --recompute-average) |
enable_3d_landmarks |
false |
Optional override (absent from the shipped file; code default false). Load InsightFace landmark_3d_68 module for head-pose extraction (yaw/pitch/roll). Costs ~5MB extra ONNX weights. Currently informational; future profile/silhouette refinements will read this. |
eyes_closed_max |
4.0 |
Per-face eyes-open score (0–10) at or below which the culling darkroom flags a face as blinking. Drives the red/orange/green face rings and the eyes threshold slider (moved from a hardcoded constant) |
poor_expression_min |
4.0 |
Per-face smile/expression score (0–10) below which the darkroom flags a weak expression. Drives the expression face ring and slider (moved from a hardcoded constant) |
blendshapes.enabled |
true |
Use appearance-based MediaPipe blendshape scores for per-face eyes_open_score / smile_score when MediaPipe and the face_landmarker.task bundle are available; when true they replace the landmark-geometry scores, otherwise the geometry fallback runs automatically. Optional dependency — install with pip install mediapipe==0.10.35 --no-deps (never a plain pip install mediapipe). See FACE_RECOGNITION.md. |
blendshapes.min_crop_size |
192 |
Faces whose padded crop is smaller than this (px, shorter side) fall back to the geometric score rather than upscale a tiny face |
HDBSCAN clustering for face recognition.
{
"face_clustering": {
"enabled": true,
"min_faces_per_person": 2,
"min_samples": 2,
"auto_merge_distance_percent": 15,
"clustering_algorithm": "best",
"leaf_size": 40,
"use_gpu": "auto",
"merge_threshold": 0.6
}
}| Setting | Default | Description |
|---|---|---|
enabled |
true |
Enable face clustering |
min_faces_per_person |
2 |
Minimum photos per person |
min_samples |
2 |
HDBSCAN min_samples parameter |
auto_merge_distance_percent |
15 |
Auto-merge within this distance |
clustering_algorithm |
"best" |
HDBSCAN algorithm |
leaf_size |
40 |
Tree leaf size (CPU only) |
use_gpu |
"auto" |
GPU mode: auto, always, never (see notes below) |
merge_threshold |
0.6 |
Centroid similarity for matching |
GPU clustering notes (use_gpu):
- Requires the optional
cuml+cupypackages. The Docker GPU image bundles cuML, so the GPU profiles cluster on GPU out of the box. - The
legacyprofile is forced to CPU clustering regardless ofuse_gpu(it deliberately avoids GPU models), so it always clusters on CPU even on a GPU host. - Even on GPU profiles, the clusterer only uses the GPU when a CUDA device is actually present — a
cupydevice-count guard falls back to CPU HDBSCAN when cuML/cupy import but no usable GPU is found (e.g. running the GPU image inlegacymode). alwayswarns and falls back to CPU if cuML is unavailable; any GPU/cuML error during clustering also degrades gracefully to CPU HDBSCAN rather than aborting the run.
Clustering algorithms:
| Algorithm | Complexity | Best For |
|---|---|---|
boruvka_balltree |
O(n log n) | High-dimensional data (recommended) |
boruvka_kdtree |
O(n log n) | Low-dimensional data |
prims_balltree |
O(n²) | Memory-constrained, high-dim |
prims_kdtree |
O(n²) | Memory-constrained, low-dim |
best |
Auto | Let HDBSCAN decide |
Controls face extraction and thumbnail generation.
{
"face_processing": {
"crop_padding": 0.3,
"use_db_thumbnails": true,
"face_thumbnail_size": 640,
"face_thumbnail_quality": 90,
"extract_workers": 2,
"extract_batch_size": 16,
"refill_workers": 4,
"refill_batch_size": 100,
"auto_tuning": {
"enabled": true,
"memory_limit_percent": 80,
"min_batch_size": 8,
"monitor_interval_seconds": 5
}
}
}| Setting | Default | Description |
|---|---|---|
crop_padding |
0.3 |
Padding ratio for face crops |
use_db_thumbnails |
true |
Use stored thumbnails |
face_thumbnail_size |
640 |
Thumbnail size in pixels |
face_thumbnail_quality |
90 |
JPEG quality |
extract_workers |
2 |
Parallel extraction workers |
extract_batch_size |
16 |
Extraction batch size |
refill_workers |
4 |
Thumbnail refill workers |
refill_batch_size |
100 |
Refill batch size |
| auto_tuning | ||
enabled |
true |
Enable memory-based tuning |
memory_limit_percent |
80 |
Memory usage limit |
min_batch_size |
8 |
Minimum batch size |
monitor_interval_seconds |
5 |
Check interval |
Black & white photo detection.
{
"monochrome_detection": {
"saturation_threshold_percent": 5
}
}| Setting | Default | Description |
|---|---|---|
saturation_threshold_percent |
5 |
Mean saturation < 5% = monochrome |
General tagging settings. The tagging model is configured per-profile in models.profiles.*.tagging_model.
{
"tagging": {
"enabled": true,
"max_tags": 5
}
}| Setting | Default | Description |
|---|---|---|
enabled |
true |
Enable tagging |
max_tags |
5 |
Maximum tags per photo |
Note: CLIP-specific settings like similarity_threshold_percent are in the models.clip section.
Configured via models.profiles.*.tagging_model:
| Model | VRAM | Tag Style | Notes |
|---|---|---|---|
clip |
0 (reuses embeddings) | Mood/atmosphere (dramatic, golden_hour, vintage) | No extra model load; less literal object detection |
qwen3.5-2b |
~4GB | Structured scenes (landscape, architecture, reflection) | Requires transformers + extra VRAM |
qwen3.5-4b |
~8GB | Detailed scenes with nuance | Higher VRAM; slower inference |
| Profile | Tagging Model | Embedding Model |
|---|---|---|
legacy |
clip |
CLIP ViT-L-14 (768-dim) |
8gb |
clip |
CLIP ViT-L-14 (768-dim) |
16gb |
qwen3.5-2b |
SigLIP 2 NaFlex SO400M (1152-dim) |
24gb |
qwen3.5-4b |
SigLIP 2 NaFlex SO400M (1152-dim) |
python facet.py --recompute-tags # Re-tag using configured model per profile
python facet.py --recompute-tags-vlm # Re-tag using VLM taggerTags with synonym lists that are not tied to any specific category. These are available for all photos regardless of category assignment. Each key is the tag name; the value is a list of synonyms for CLIP/VLM matching.
{
"standalone_tags": {
"bokeh": ["bokeh", "shallow depth of field", "background blur", "out of focus"],
"surreal": ["surreal", "dreamlike", "fantasy", "composite", "double exposure"],
"flat_lay": ["flat lay", "overhead shot", "top down", "bird's eye product"],
"golden_hour": ["golden hour", "magic hour", "warm light", "sunset light"],
"portrait_tag": ["portrait", "headshot", "face portrait", "close-up portrait"]
}
}Add new standalone tags by providing a key and a list of synonyms. Tags defined here are merged with category-specific tags to form the full tag vocabulary.
Thresholds for --compute-recommendations.
{
"analysis": {
"aesthetic_max_threshold": 9.0,
"aesthetic_target": 9.5,
"quality_avg_threshold": 7.5,
"quality_weight_threshold_percent": 10,
"correlation_dominant_threshold": 0.5,
"category_min_samples": 50,
"category_imbalance_threshold": 0.5,
"score_clustering_std_threshold": 1.0,
"top_score_threshold": 8.5,
"exposure_avg_threshold": 8.0
}
}| Setting | Default | Description |
|---|---|---|
aesthetic_max_threshold |
9.0 |
Warn if max aesthetic below this |
aesthetic_target |
9.5 |
Target for aesthetic_scale |
quality_avg_threshold |
7.5 |
"High value" quality threshold |
quality_weight_threshold_percent |
10 |
Warn if quality weight ≤ this |
correlation_dominant_threshold |
0.5 |
"Dominant signal" warning |
category_min_samples |
50 |
Minimum photos per category |
category_imbalance_threshold |
0.5 |
Score gap warning |
score_clustering_std_threshold |
1.0 |
Warn if std dev < this |
top_score_threshold |
8.5 |
Warn if max aggregate < this |
exposure_avg_threshold |
8.0 |
Warn if avg exposure > this |
Web gallery display and behavior.
{
"viewer": {
"default_category": "",
"edition_password": "",
"comparison_mode": {
"min_comparisons_for_optimization": 50,
"pair_selection_strategy": "learning",
"candidate_pool_size": 200,
"show_current_scores": true
},
"sort_options": { ... },
"pagination": {
"default_per_page": 64
},
"dropdowns": {
"max_cameras": 50,
"max_lenses": 50,
"max_persons": 50,
"max_tags": 20,
"min_photos_for_person": 10
},
"persons": {
"needs_naming_min_faces": 5
},
"raw_processor": {
"backend": "rawpy",
"darktable": {
"executable": "darktable-cli",
"hq": true,
"width": null,
"height": null,
"extra_args": [],
"cull_styles": [],
"preview_max_edge": 1440,
"preview_timeout_seconds": 60
}
},
"display": {
"tags_per_photo": 4,
"card_width_px": 168,
"image_width_px": 160,
"image_jpeg_quality": 96,
"thumbnail_slider": {
"min_px": 120,
"max_px": 400,
"default_px": 168,
"step_px": 8
}
},
"face_thumbnails": {
"output_size_px": 64,
"jpeg_quality": 80,
"crop_padding_ratio": 0.2,
"min_crop_size_px": 20
},
"quality_thresholds": {
"good": 6,
"great": 7,
"excellent": 8,
"best": 9
},
"photo_types": {
"top_picks_min_score": 7,
"top_picks_min_face_ratio": 0.2,
"top_picks_weights": {
"aggregate_percent": 30,
"aesthetic_percent": 28,
"composition_percent": 18,
"face_quality_percent": 24
},
"low_light_max_luminance": 0.2
},
"defaults": {
"hide_blinks": true,
"hide_bursts": true,
"hide_duplicates": true,
"hide_details": true,
"tooltip_mode": "hover",
"hide_rejected": true,
"sort": "aggregate",
"sort_direction": "DESC",
"type": "",
"gallery_mode": "mosaic"
},
"cache_ttl_seconds": 60,
"notification_duration_ms": 2000,
"moment_confidence_min": 0,
"path_mapping": {}
}
}Note:
sort_options(elided as{ ... }above) maps DB columns to dropdown labels and is rarely edited. The Content group includes a{ "column": "narrative_moment_confidence", "label": "Moment Confidence" }sort (NULLs sink last).
| Setting | Default | Description |
|---|---|---|
default_category |
"" |
Default category filter |
edition_password |
"" |
Password to unlock edition mode (empty = disabled) |
| comparison_mode | ||
min_comparisons_for_optimization |
50 |
Minimum for optimization |
pair_selection_strategy |
"learning" |
Pair strategy: learning (embedding-diversity cold-start + rank-disagreement once trained), uncertainty, boundary, active, random |
candidate_pool_size |
200 |
Random candidate pool the learning strategy samples pairs within |
show_current_scores |
true |
Show scores during comparison |
| pagination | ||
default_per_page |
64 |
Photos per page |
| dropdowns | ||
max_cameras |
50 |
Max cameras in dropdown |
max_lenses |
50 |
Max lenses |
max_persons |
50 |
Max persons |
max_tags |
20 |
Max tags |
min_photos_for_person |
10 |
Hide persons with fewer photos from dropdown |
| persons | ||
needs_naming_min_faces |
5 |
Minimum face_count for an auto-clustered cluster to appear in the "Needs naming" section of /persons |
| raw_processor | ||
darktable.executable |
"darktable-cli" |
darktable-cli binary name or absolute path |
darktable.profiles |
[] |
Array of named darktable export profiles (see below) |
darktable.profiles[].name |
(required) | Profile display name (used in download menu and API profile param) |
darktable.profiles[].hq |
true |
Pass --hq true for high-quality export |
darktable.profiles[].width |
(omit) | Max output width (omit for full resolution) |
darktable.profiles[].height |
(omit) | Max output height (omit for full resolution) |
darktable.profiles[].style |
(omit) | darktable style name applied during export (--style) |
darktable.profiles[].apply_custom_presets |
true |
When false, passes --apply-custom-presets false so only the explicit style renders (not auto-applied presets) |
darktable.profiles[].extra_args |
[] |
Additional CLI arguments (e.g., ["--style-overwrite"]) |
darktable.cull_styles |
[] |
Named darktable styles offered as edited-look previews in the culling darkroom (GET /api/photo/cull_preview). Empty = the style selector is hidden. Each style must already exist in the darktable configuration of the user running the viewer; the name is passed verbatim to --style. |
darktable.cull_styles[].name |
(required) | darktable style name (passed to --style, and validated by the endpoint) |
darktable.cull_styles[].label_key |
(name) | Optional i18n key for the menu label; defaults to the style name |
darktable.preview_max_edge |
1440 |
Max edge (px) the cull preview is rendered to |
darktable.preview_timeout_seconds |
60 |
Per-render darktable-cli timeout for a cull preview |
| display | ||
tags_per_photo |
4 |
Tags shown on cards |
card_width_px |
168 |
Card width |
image_width_px |
160 |
Image width |
image_jpeg_quality |
96 |
JPEG quality for RAW/HEIF conversion in /api/download and /api/image (1–100) |
thumbnail_slider.min_px |
120 |
Minimum thumbnail size (px) |
thumbnail_slider.max_px |
400 |
Maximum thumbnail size (px) |
thumbnail_slider.default_px |
168 |
Default thumbnail size (px) |
thumbnail_slider.step_px |
8 |
Slider step increment (px) |
| face_thumbnails | ||
output_size_px |
64 |
Thumbnail size |
jpeg_quality |
80 |
JPEG quality |
crop_padding_ratio |
0.2 |
Face padding |
min_crop_size_px |
20 |
Minimum crop size |
| quality_thresholds | ||
good |
6 |
Good threshold |
great |
7 |
Great threshold |
excellent |
8 |
Excellent threshold |
best |
9 |
Best threshold |
| photo_types | ||
top_picks_min_score |
7 |
Top Picks minimum |
top_picks_min_face_ratio |
0.2 |
Face ratio for weights |
low_light_max_luminance |
0.2 |
Low light threshold |
| defaults | ||
type |
"" |
Default photo type filter (e.g., "portraits", "landscapes", or "" for All) |
sort |
"aggregate" |
Default sort column |
sort_direction |
"DESC" |
Default sort direction ("ASC" or "DESC") |
hide_blinks |
true |
Hide blink photos by default |
hide_bursts |
true |
Show only best of burst by default |
hide_duplicates |
true |
Hide non-lead duplicate photos by default |
hide_details |
true |
Hide photo details on cards by default |
tooltip_mode |
"hover" |
Tooltip trigger: "hover", "click", or "off". Replaces the prior hide_tooltip boolean. |
hide_rejected |
true |
Hide rejected photos by default |
gallery_mode |
"mosaic" |
Default gallery layout ("grid" or "mosaic") |
| allowed_origins | ||
allowed_origins |
["http://localhost:4200", "http://localhost:5000"] |
CORS allowed origins for the FastAPI server. Add your domain or reverse proxy URL when hosting remotely. |
| security_headers | ||
security_headers.content_security_policy |
(SPA-safe default) | Content-Security-Policy header value. Defaults to a policy permitting the SPA's own resources (inline theme script/style, Google Fonts, OpenStreetMap tiles, same-origin API). Set to "" to disable, or supply a stricter policy. |
security_headers.hsts |
false |
Send Strict-Transport-Security. Enable only when the viewer is served over HTTPS. |
| Other | ||
cache_ttl_seconds |
60 |
Query cache TTL |
notification_duration_ms |
2000 |
Toast duration |
moment_confidence_min |
0 |
Below this stored narrative_moment_confidence posterior (0–1), moment labels render dimmed with an "(uncertain)" suffix in the Scenes header, the Culling scene-group header, and the gallery photo tooltip. 0 = never dim |
Toggle optional features to reduce memory usage or simplify the UI:
{
"viewer": {
"features": {
"show_similar_button": true,
"show_merge_suggestions": true,
"show_rating_controls": true,
"show_rating_badge": true,
"show_memories": true,
"show_captions": true,
"show_timeline": true,
"show_map": true,
"show_scenes": true,
"show_my_taste": true
}
}
}| Setting | Default | Description |
|---|---|---|
show_similar_button |
true |
Show "Find Similar" button on photo cards (uses numpy for CLIP similarity) |
show_merge_suggestions |
true |
Enable merge suggestions feature on manage persons page |
show_rating_controls |
true |
Show star rating and favorite controls |
show_rating_badge |
true |
Show rating badge on photo cards |
show_scan_button |
false |
Show scan trigger button for superadmin users (requires GPU on viewer host) |
metrics_enabled |
false |
Enable the public GET /metrics Prometheus endpoint. Off by default — it exposes photo/person/face counts, DB size, and process memory; enable only when the endpoint is reachable from the scraper network, not from the public internet. |
show_semantic_search |
true |
Show semantic search bar (text-to-image search using CLIP/SigLIP embeddings) |
show_albums |
true |
Show albums feature (create, manage, and browse photo albums) |
show_critique |
true |
Show AI critique button on photo cards (rule-based score breakdown) |
show_vlm_critique |
true |
Enable VLM-powered critique mode (requires 16gb/24gb VRAM profile). Code falls back to false when the key is absent. |
show_embed_metadata |
true |
Show the per-thumbnail "Write metadata to file" action in edition mode (embeds ratings/keywords into the original image via exiftool) |
show_memories |
true |
Show "On This Day" memories dialog (photos taken on the same date in previous years) |
show_captions |
true |
Show AI-generated captions on photo cards |
show_timeline |
true |
Show timeline view for chronological browsing with date navigation |
show_map |
true |
Show map view with GPS-based photo locations (requires Leaflet). Code falls back to false when the key is absent. |
show_capsules |
true |
Show the Capsules view (curated photo diaporamas grouped by theme) |
show_folders |
true |
Show folder-based browsing of the photo directory structure |
show_scenes |
true |
Show the Scenes view (/scenes) that groups burst-lead photos into chronological scenes for story-order culling |
show_my_taste |
true |
Show the "My Taste" sort backed by the personal ranker's learned score, with a learned-coverage / accuracy confidence badge |
show_social_export |
true |
Show the edition-only Social crop download menu (saliency-aware crops for social aspect presets). See Social Export |
show_portfolio_export |
true |
Show the edition-only Export portfolio album action (self-contained static HTML gallery). See Portfolio Export |
show_proofing |
false |
Enable client proofing on shared albums: a share link (plus optional PIN) lets a no-account client heart photos and leave comments, which the album owner reviews from an edition-gated dialog. Off by default. See Client Proofing |
Memory optimization: Setting show_similar_button: false prevents numpy from being loaded, reducing viewer memory footprint. The similar photos feature computes CLIP embedding cosine similarity which requires numpy.
viewer.features.show_proofing (default false) turns any shared album into a client-proofing surface. A share link — optionally gated by viewer.proofing.pin — lets a client with no account exchange the share token for a short-lived session, then heart photos and leave comments. Picks live in a dedicated album_client_picks table, bounded to that album's photos and fully isolated from the owner's ratings (they never touch photos.is_favorite / user_preferences and never train the personal ranker). The owner reads the picks from an edition-gated dialog on the album card.
{
"viewer": {
"features": { "show_proofing": false },
"proofing": {
"pin": "",
"session_minutes": 1440
}
}
}| Setting | Default | Description |
|---|---|---|
features.show_proofing |
false |
Master switch for client proofing on shared albums |
proofing.pin |
"" |
Optional PIN a client must enter (with the share token) to open a proofing session. Empty = no PIN. Checks are rate-limited and byte-safe |
proofing.session_minutes |
1440 |
Lifetime in minutes of a client proofing session token (default 24h). Sessions also stop the moment the album is unshared or proofing is disabled |
Map database paths to local filesystem paths. Useful when photos were scored on one machine (e.g., Windows with UNC paths) but the viewer runs on another (e.g., Linux NAS with mount points).
{
"viewer": {
"path_mapping": {
"\\\\NAS\\Photos": "/mnt/photos",
"D:\\Pictures": "/volume1/pictures"
}
}
}| Setting | Default | Description |
|---|---|---|
path_mapping |
{} |
Dict of source prefix to destination prefix. When serving full-size images or VLM critique, database paths starting with a source prefix are rewritten to use the destination prefix. |
How it works:
- Only applies when reading files from disk (full-size image serving, file downloads, VLM critique). Database paths are never modified.
- Backslash/forward slash normalization is handled automatically:
\\NAS\Photos\img.jpgand//NAS/Photos/img.jpgboth match. - Mappings are evaluated in order; the first matching prefix wins.
- Path mapping targets are automatically included in the scan directory allowlist for multi-user security checks.
Example: A database populated on Windows stores paths like \\NAS\Photos\2024\IMG_001.jpg. On Linux, the same share is mounted at /mnt/nas/Photos. Configure:
"path_mapping": {"\\\\NAS\\Photos": "/mnt/nas/Photos"}Optional password protection for the viewer:
{
"viewer": {
"password": "your-password-here"
}
}When set, users must authenticate before accessing the viewer.
Override global performance settings when running the viewer. Useful for low-memory NAS deployment where scoring needs high resources but the viewer doesn't.
{
"viewer": {
"performance": {
"mmap_size_mb": 0,
"cache_size_mb": 4,
"pool_size": 2,
"thumbnail_cache_size": 200,
"face_cache_size": 50
}
}
}| Setting | Default | Description |
|---|---|---|
mmap_size_mb |
(global) | SQLite mmap size override for viewer connections. 0 disables mmap. |
cache_size_mb |
(global) | SQLite cache size override for viewer connections |
pool_size |
5 |
Connection pool size (reduce for low-memory systems) |
thumbnail_cache_size |
2000 |
Max entries in the in-memory thumbnail resize cache |
face_cache_size |
500 |
Max entries in the in-memory face thumbnail cache |
When not set, the viewer uses the global performance values. See Deployment for recommended NAS settings.
Database performance settings.
{
"performance": {
"mmap_size_mb": 2048,
"cache_size_mb": 128,
"slow_request_ms": 1000
}
}Note:
wal_checkpoint_minutesis an optional override and is not present in the shippedperformanceblock (which contains onlymmap_size_mb,cache_size_mb, andslow_request_ms). Add it explicitly to change the default of30.
| Setting | Default | Description |
|---|---|---|
mmap_size_mb |
2048 |
SQLite memory-mapped I/O size |
cache_size_mb |
128 |
SQLite cache size |
wal_checkpoint_minutes |
30 |
Optional override (absent from the shipped file). Interval in minutes for the viewer's background PRAGMA wal_checkpoint(TRUNCATE). Prevents WAL bloat on long-running deployments. Set to 0 to disable. |
slow_request_ms |
1000 |
Viewer API requests slower than this many milliseconds are logged at WARNING with a SLOW marker. Set to 0 to disable. |
Controls where thumbnails and embeddings are stored. Default is BLOB columns in the SQLite database; filesystem mode stores them as files on disk instead, which reduces database size.
{
"storage": {
"mode": "database",
"filesystem_path": "./storage"
}
}| Setting | Default | Description |
|---|---|---|
mode |
"database" |
Storage backend: "database" (SQLite BLOBs) or "filesystem" (files on disk) |
filesystem_path |
"./storage" |
Base directory for filesystem mode. Thumbnails are stored in <path>/thumbnails/ and embeddings in <path>/embeddings/, organized into subdirectories by content hash. |
Filesystem mode details:
- Files are organized by SHA-256 hash of the photo path, with two-character subdirectories to avoid too many files in one directory (e.g.,
thumbnails/a3/a3f8..._640.jpg). - Deleting a photo removes all associated thumbnail sizes and embedding files.
- The directory is created automatically on first use.
Event-driven plugin system for reacting to scoring events. Plugins can be Python modules, webhooks, or built-in actions.
{
"plugins": {
"enabled": true,
"high_score_threshold": 8.0,
"webhooks": [
{
"url": "https://example.com/hook",
"events": ["on_score_complete", "on_high_score"],
"min_score": 8.0
}
],
"actions": {
"copy_high_scores": {
"event": "on_high_score",
"action": "copy_to_folder",
"folder": "/path/to/best-photos",
"min_score": 9.0
}
}
}
}| Key | Default | Description |
|---|---|---|
enabled |
false |
Master switch — when false, no events are emitted |
high_score_threshold |
8.0 |
Minimum aggregate score to trigger on_high_score events |
webhooks |
[] |
List of webhook endpoints to receive JSON POST payloads |
actions |
{} |
Named built-in actions triggered by events |
| Event | Trigger | Payload |
|---|---|---|
on_score_complete |
After each photo is scored | path, filename, aggregate, aesthetic, comp_score, category, tags |
on_new_photo |
When a photo enters the database | Same as on_score_complete |
on_high_score |
When aggregate ≥ high_score_threshold |
Same as on_score_complete |
on_burst_detected |
When a burst group is identified | burst_group_id, photo_count, best_path, paths |
Place a .py file in the plugins/ directory. Define functions named after the events you want to handle:
def on_score_complete(data: dict) -> None:
print(f"Scored: {data['path']} — {data['aggregate']:.1f}")
def on_high_score(data: dict) -> None:
print(f"High score! {data['path']} — {data['aggregate']:.1f}")See plugins/example_plugin.py.example for the full interface.
Each webhook receives a JSON POST with SSRF protection (private/loopback addresses are blocked):
{
"event": "on_high_score",
"data": {
"path": "/photos/IMG_001.jpg",
"aggregate": 9.2,
"aesthetic": 9.5,
"comp_score": 8.8,
"category": "portrait",
"tags": "person, outdoor"
}
}Webhook options: url (required), events (list of event names), min_score (minimum aggregate to trigger).
| Action | Description | Options |
|---|---|---|
copy_to_folder |
Copy photo to a folder | folder, min_score |
send_notification |
Log a notification | min_score |
| Method | Path | Description |
|---|---|---|
GET |
/api/plugins |
List loaded plugins, webhooks, and actions |
POST |
/api/plugins/test-webhook |
Send a test payload to a webhook URL |
Curated photo diaporamas (slideshows) grouped by theme. Capsules are auto-generated from your photo library and cached with a configurable TTL.
{
"capsules": {
"min_aggregate": 6.0,
"max_photos_per_capsule": 40,
"max_photo_overlap": 0.2,
"mmr_lambda": 0.5,
"mmr_moment_weight": 0.0,
"freshness_hours": 24,
"reverse_geocoding": true,
"journey": {
"min_distance_km": 50,
"min_photos": 8,
"time_gap_hours": 24
},
"faces_of": { "min_photos": 10 },
"seasonal": { "min_photos": 10 },
"golden": { "percentile": 99, "max_photos": 50 },
"color_story": { "embedding_threshold": 0.75, "min_group_size": 8, "max_groups": 5 },
"this_week_years_ago": { "min_photos_per_year": 3 },
"monthly": { "min_photos": 8 },
"yearly": { "min_photos": 20, "max_photos": 60 },
"camera": { "min_photos": 15 },
"tag_collection": { "min_photos": 15 },
"seeded": {
"num_seeds": 10,
"min_photos": 8,
"seed_lifetime_minutes": 1440,
"time_window_days": 7,
"embedding_threshold": 0.7,
"location_radius_km": 30
},
"progress": { "min_improvement_pct": 5, "min_photos": 10, "period_months": 3 },
"color_palette": { "min_photos": 8 },
"rare_pair": { "max_shared_photos": 5, "min_score": 7.0, "min_photos": 3 }
}
}| Setting | Default | Description |
|---|---|---|
min_aggregate |
6.0 |
Minimum aggregate score for photos to be included in capsules |
max_photos_per_capsule |
40 |
Maximum photos per capsule (MMR diversity applied above 5) |
max_photo_overlap |
0.2 |
Maximum fraction of shared photos between two capsules before dedup removes one |
mmr_lambda |
0.5 |
MMR diversity weight: 0=maximize diversity, 1=maximize quality |
mmr_moment_weight |
0.0 |
Optional weight blending each photo's narrative_moment_confidence into capsule MMR selection. 0.0 = unchanged behaviour |
freshness_hours |
24 |
Cache TTL and rotation period for cover photos and seeded capsules |
reverse_geocoding |
true |
Enable offline reverse geocoding for location/journey capsule titles (requires reverse_geocoder package) |
| Type | Description |
|---|---|
journey |
Trips detected via GPS clustering + temporal gaps. Titles include destination name when geocoding is enabled. |
faces_of |
Best photos of each recognized person |
seasonal |
Photos grouped by season + year |
golden |
Top 1% by aggregate score |
color_story |
Visually similar groups via CLIP embedding clustering |
this_week |
"This Week, Years Ago" — extended On This Day across ±3 days |
location |
Geotagged photo clusters with reverse-geocoded place names |
person_pair |
Pairs of named persons appearing together |
seeded |
Seed-based discovery via time, similarity, person, tag, location, mood |
progress |
"Your Photography is Improving" from quarterly score trends |
color_palette |
"Color of the Month" from saturation/monochrome profiles |
rare_pair |
Infrequent person pairs in high-scoring photos |
favorites |
Favorited photos grouped by year and season |
Automatically generated from database columns:
| Dimension | Groups By |
|---|---|
year |
Year extracted from date_taken |
month |
Year-month extracted from date_taken |
week |
Year-week extracted from date_taken |
camera |
Camera model |
lens |
Lens model |
tag |
Photo tags (requires photo_tags table) |
day_of_week |
Day of week (Sunday–Saturday) |
composition |
SAMP-Net composition pattern (rule_of_thirds, horizontal, etc.) |
focal_range |
Focal length bins: ultra wide (<24mm), wide (24–35mm), standard (36–70mm), portrait (71–135mm), telephoto (136–300mm), super telephoto (300mm+) |
category |
Photo content category (portrait, landscape, street, etc.) |
time_of_day |
Time bins: golden morning, morning, midday, afternoon, golden evening, night |
star_rating |
User star ratings (1–5 stars) |
Cross-dimensional combos are also generated (e.g., camera × year, focal_range × category, category × year).
Each capsule type maps to a themed slide transition:
| Transition | Used By | Effect |
|---|---|---|
crossfade |
Default | 300ms opacity swap |
slide |
journey, location, this_week | Slide in from right (500ms) |
zoom |
faces_of, color_story | Scale 1.05→1.0 with fade (400ms) |
kenburns |
golden, seasonal, star_rating, favorites | Slow zoom 1.0→1.08 over slide duration |
Location and journey capsules use offline reverse geocoding via the reverse_geocoder package (local GeoNames dataset, ~30MB, no API calls). Results are cached in the location_names database table at 0.1° grid resolution (~11km).
Install: pip install reverse_geocoder
Set "reverse_geocoding": false to disable and fall back to coordinate display.
Settings for the AI similar-photo culling feature, which groups visually similar photos using CLIP/SigLIP embeddings:
{
"similarity_groups": {
"default_threshold": 0.85,
"min_group_size": 2,
"max_photos": 10000,
"max_group_size": 50
}
}| Setting | Default | Description |
|---|---|---|
default_threshold |
0.85 |
Minimum cosine similarity (0.0–1.0) to consider two photos as visually similar. Lower values produce larger groups but with less visual similarity. |
min_group_size |
2 |
Minimum number of photos required to form a similarity group |
max_photos |
10000 |
Maximum photos to load for similarity computation (O(n²) cost). Increase for larger libraries at the expense of computation time. |
max_group_size |
50 |
Maximum photos per similarity group. Larger groups are split to keep the UI usable. |
One-button auto-cull for the culling darkroom (POST /api/culling/auto, edition-gated). It culls a whole scope — all groups, or only bursts / similars / scenes, optionally narrowed to an album or date window — in a single pass. Each group keeps its best photo plus everything within a strictness-derived margin (the same keeper budget as the manual darkroom slider), floored at a per-group minimum, and rejects the rest.
{
"auto_cull": {
"default_strictness": 50,
"highlights_min": 8.0
}
}| Setting | Default | Description |
|---|---|---|
default_strictness |
50 |
Keeper budget (0–100) used when the request omits strictness. Higher = keep fewer photos per group (tighter margin around the group's best) |
highlights_min |
8.0 |
Minimum aggregate score for a group's best photo to be collected into the optional Highlights album when an auto-cull is applied (idempotent) |
dry_run defaults on and returns a per-group keep/reject preview; an apply additionally records source='culling' comparison rows and nudges one auto-retrain. See Web Viewer — Auto-cull.
Genre presets that bundle every culling knob into one click: sports keeps only the single sharpest of a long burst, weddings keep more variants with eyes-open critical, concerts relax the eye/expression gates, wildlife drops the human-face gate entirely. The culling darkroom shows a preset selector.
{
"cull_profiles": {
"default": "balanced",
"profiles": {
"balanced": { "label_key": "culling.profiles.balanced", "strictness": 50, "eyes_closed_max": 4.0, "poor_expression_min": 4.0, "keep_min_per_group": 1, "similarity_threshold": 85 },
"wedding": { "label_key": "culling.profiles.wedding", "strictness": 35, "eyes_closed_max": 5.0, "poor_expression_min": 5.0, "keep_min_per_group": 2, "similarity_threshold": 90 },
"sports": { "label_key": "culling.profiles.sports", "strictness": 85, "eyes_closed_max": 2.0, "poor_expression_min": 0.0, "keep_min_per_group": 1, "similarity_threshold": 80 },
"concert": { "label_key": "culling.profiles.concert", "strictness": 55, "eyes_closed_max": 2.0, "poor_expression_min": 0.0, "keep_min_per_group": 1, "similarity_threshold": 85 },
"wildlife": { "label_key": "culling.profiles.wildlife", "strictness": 70, "eyes_closed_max": 0.0, "poor_expression_min": 0.0, "keep_min_per_group": 1, "similarity_threshold": 82 }
}
}
}| Setting | Description |
|---|---|
default |
Profile id applied when none is stored client-side |
profiles.<id>.label_key |
i18n dot-path for the preset's display name (culling.profiles.*) |
profiles.<id>.strictness |
Keeper budget (0–100) fed into the auto-cull margin when this preset is active |
profiles.<id>.eyes_closed_max |
Eyes-open score (0–10) at/below which a face counts as closed — overrides the global face_detection.eyes_closed_max in the darkroom face badges |
profiles.<id>.poor_expression_min |
Expression/smile score (0–10) below which a face counts as poor — overrides face_detection.poor_expression_min |
profiles.<id>.keep_min_per_group |
Per-group floor on the auto-cull keep set for this preset |
profiles.<id>.similarity_threshold |
Similarity-grouping threshold (percent) the darkroom applies when the preset is selected |
Endpoint (read-only): GET /api/culling/profiles returns the ordered preset list plus the default. The auto-cull request (POST /api/culling/auto) and the per-face batch (POST /api/culling-group/faces) accept an optional profile id; an explicit strictness/min_keep_per_group in the request always wins over the preset.
Settings for the Scenes view, which groups burst-lead photos into chronological scenes (split by capture-time gaps) for story-order culling:
{
"scenes": {
"gap_minutes": 20.0,
"min_size": 2,
"max_photos": 5000,
"max_scene_size": 60,
"adaptive": true,
"adaptive_k": 6.0
}
}| Setting | Default | Description |
|---|---|---|
gap_minutes |
20.0 |
A new scene starts when more than this many minutes pass between consecutive burst-lead photos (the floor when adaptive is on) |
min_size |
2 |
Minimum photos for a scene to be shown |
max_photos |
5000 |
Maximum burst-lead photos loaded for scene grouping |
max_scene_size |
60 |
A scene larger than this is recursively sub-split at its largest internal gaps, so a continuously-shot event never collapses into one giant scene |
adaptive |
true |
When on, the effective gap widens to adaptive_k × median of the shoot's consecutive gaps (tightens for rapid shooting, loosens for sparse holidays) |
adaptive_k |
6.0 |
Multiplier applied to the median gap when adaptive is on |
split_on_moment_change |
false |
When on (and narrative moments are computed), sub-split a time-run where the dominant moment changes and holds for moment_split_min_run frames |
moment_split_min_run |
4 |
Hysteresis for split_on_moment_change — how many consecutive frames a new moment must persist to force a boundary |
Zero-shot labelling of each photo's scene/activity "moment". The default general vocabulary covers celebration, dining, beach, water_activity, mountains, nature_wildlife, cityscape, travel_landmark, concert, sports, group_gathering, portrait, children, pets, nightlife, ceremony, scenic_landscape, snow_winter, home_indoor, road_vehicle, or other — so it works on any library, not just weddings (wedding ships as an opt-in genre). Populated by --detect-moments (auto-runs at the end of every scan) and surfaced as scene names and a gallery filter. Something neither Narrative Select nor AfterShoot do.
The signal is caption-semantic: each photo's AI caption is encoded once with the text tower and stored (the caption_embedding column); the moment is the best max-pooled cosine of that caption embedding against the per-moment text prompts. The stored image embedding is the fallback when a photo has no caption. Caption text matches moment prompts ~2.4× more cleanly than the raw image embedding does, so the caption signal carries higher thresholds than the image fallback; each is tuned per backend (open_clip cosines run much lower than SigLIP's). The transformers (SigLIP) values ship as conservative defaults — re-tune them if you run a SigLIP profile.
{
"narrative_moments": {
"enabled": true,
"prompt_template": "a photo of {desc}",
"default_event_type": "general",
"pooling": "max",
"caption_min_confidence": 0,
"thresholds": {
"caption": {
"open_clip": { "min_confidence": 0.30, "min_margin": 0.02 },
"transformers": { "min_confidence": 0.12, "min_margin": 0.01 }
},
"image": {
"open_clip": { "min_confidence": 0.20, "min_margin": 0.01 },
"transformers": { "min_confidence": 0.10, "min_margin": 0.01 }
}
},
"priors": {
"enabled": true, "weight": 0.04, "caption_tag_scale": 0.25,
"rules": [
{ "kind": "structural", "when": { "is_group_portrait": true, "face_count_min": 4 }, "boost": { "group_gathering": 1.0 } },
{ "kind": "tag", "when": { "tags_any": ["beach", "ocean", "sand"] }, "boost": { "beach": 0.8 } }
],
"event_types": { "wedding": { "rules": [ { "kind": "tag", "when": { "tags_any": ["cake"] }, "boost": { "cake_cutting": 1.0 } } ] } }
},
"vlm_tiebreak": { "enabled": false, "min_confidence": 0.0, "min_margin": 0.04 },
"transitions": { "stay_prob": 0.7, "forward_bias": 0.0, "weight": 0.3 },
"event_types": { "general": { "beach": ["people at a sandy beach by the sea", "..."], "...": [] }, "wedding": { "vows": ["the couple exchanging vows at the altar", "..."] } }
}
}| Setting | Default | Description |
|---|---|---|
enabled |
true |
Master switch; when off, --detect-moments and the scan hook no-op |
prompt_template |
"a photo of {desc}" |
Wrapper applied to every prompt before encoding |
default_event_type |
"general" |
Which event_types vocabulary is active. general = 20 agnostic scene/activity moments; wedding ships as an opt-in genre |
pooling |
"max" |
Per-moment score = the single best prompt cosine (max-pool), more discriminative than averaging |
caption_min_confidence |
0 |
Caption quality gate: when > 0, --generate-captions and the on-demand caption endpoint skip photos that are unlabelled, other, or below this stored moment confidence. 0 = no gate |
thresholds.<signal>.<backend>.min_confidence |
caption 0.30/0.12, image 0.20/0.10 |
Below this top-1 cosine a photo is other. Keyed by signal (caption vs image) then backend — caption cosines run ~2.4× higher |
thresholds.<signal>.<backend>.min_margin |
caption 0.02/0.01, image 0.01/0.01 |
Minimum top-1/top-2 cosine gap; below it the frame is other |
priors.enabled / priors.weight |
true / 0.04 |
L1 face/tag nudges that only break near-ties; weight caps each boost at cosine scale |
priors.caption_tag_scale |
0.25 |
Scales tag rules down on the caption signal (L0 already encodes the caption); structural rules keep full weight on both signals |
priors.rules |
(general set) | Declarative {kind, when, boost} list, vocabulary-agnostic. kind: structural (face geometry) or tag. when predicates (all ANDed): is_group_portrait, face_count_min/face_count_max, face_ratio_min/face_ratio_max, tags_any, tags_all. boost: {moment: amount} — a moment absent from the active vocabulary is silently skipped, so one rule set degrades gracefully across vocabs |
priors.event_types.<et>.rules |
wedding override |
Per-event-type rules that replace the global rules when that vocabulary is active, keeping the shared list vocabulary-clean |
transitions.stay_prob / forward_bias / weight |
0.7 / 0.0 / 0.3 |
L2 timeline smoothing (Viterbi): stay-heavy with no forward progression (the agnostic vocab has no canonical order), applied lightly (weight=0 = no smoothing) |
vlm_tiebreak.enabled / min_confidence / min_margin |
false / 0.0 / 0.04 |
L3 tie-break (now active): when enabled on 16gb/24gb profiles, only low-posterior (below min_confidence) or low-margin (below min_margin) frames are re-classified by the profile VLM during --detect-moments / --recompute-moments |
event_types |
general + wedding |
Per-event-type {moment: [prompt synonyms]}; set default_event_type to switch genre or add your own |
Caption backfill cost. Caption embeddings are computed once and stored, so the per-photo cosine is free afterwards. A scan encodes only its handful of new captions (cheap, incremental), but the first full pass over an existing library encodes every caption — one text-tower forward pass per caption, fast on GPU and ~hours on CPU. Run
python facet.py --detect-momentsonce (GPU recommended) for that backfill; add--limit Nto verify on a sample first.
Discovering a library-specific vocabulary. The general set is a sensible default, but you can propose a vocabulary fitted to your library with python facet.py --discover-moments: it clusters the stored caption_embedding vectors (HDBSCAN), names each cluster from its captions (a keyword plus the captions nearest the centroid as ready-made prompts), and writes the result as an event_types.discovered block to scoring_config.discovered.json. Review it, copy discovered into event_types above, set default_event_type to discovered, and run --recompute-moments to adopt — discovery proposes, it never rewrites the active config. --discover-min-cluster-size N controls granularity (smaller = more, finer moments).
Saliency-aware crop presets for social aspect ratios (GET /api/photo/social_crop, edition-gated). Each preset crops the full-resolution original to a target aspect and frames it on the detected subject — the largest rectangle of that aspect fitting inside the image, centered on the subject and clamped at the edges. The subject box follows a fallback chain: the persisted BiRefNet subject box (photos.subject_bbox) → the union of detected face boxes → a plain center crop. See Web Viewer — Download.
{
"social_export": {
"presets": {
"square": { "label_key": "social_export.presets.square", "aspect": "1:1" },
"portrait_4x5": { "label_key": "social_export.presets.portrait_4x5", "aspect": "4:5" },
"story_9x16": { "label_key": "social_export.presets.story_9x16", "aspect": "9:16" }
},
"jpeg_quality": 92
}
}| Setting | Default | Description |
|---|---|---|
presets.<id>.label_key |
— | i18n dot-path for the preset's display name (social_export.presets.*) |
presets.<id>.aspect |
— | Target aspect as "w:h" (e.g. 1:1, 4:5, 9:16) |
jpeg_quality |
92 |
JPEG quality of the exported crop |
Gated by viewer.features.show_social_export (default true). The photos.subject_bbox column is written by the saliency pass at scan time and by --recompute-saliency; rows scanned before it existed fall back to the face-union or center crop automatically.
Export an album as a self-contained static HTML gallery a photographer can drop on any web host — no external tool (thumbsup/sigal) required (POST /api/albums/{album_id}/export-portfolio, edition-gated). The generated directory holds index.html (a responsive CSS-only thumbnail grid plus an inline vanilla-JS lightbox with zero external/CDN references — fully offline), an assets/ folder of sequentially-named JPEGs (no library paths leaked), and a manifest.json. Each photo uses the on-disk original (downscaled to max_edge) when readable and falls back to the stored 640px thumbnail BLOB when the original is unreachable (offline network shares); the source used is recorded per photo in the manifest. Generation is deterministic and idempotent — a re-export rewrites only its own files.
{
"portfolio": {
"max_photos": 500,
"max_edge": 2048,
"jpeg_quality": 88
}
}| Setting | Default | Description |
|---|---|---|
max_photos |
500 |
Albums larger than this are refused with a 400 (the export is synchronous) |
max_edge |
2048 |
Long-edge cap (px) for exported originals; the request may override it (clamped 256–8000) |
jpeg_quality |
88 |
JPEG quality of the exported images |
The target_dir goes through the exact same allow-list as the copy/move export endpoints (viewer.export.allowed_target_dirs plus the scan directories). Gated by viewer.features.show_portfolio_export (default true). See Web Viewer — Portfolio export.
Serve curated "best shots" to login-less kiosk devices — smart photo frames, Home Assistant dashboards, ImmichFrame / Immich-Kiosk style displays — over three anonymous, static-token endpoints (GET /api/frame/photos, GET /api/frame/image/{id}, GET /api/frame/next). Access is a long-lived opaque frame token; an empty tokens list disables the whole feature (every endpoint returns 404). Responses never contain filesystem paths — photos are addressed by an opaque signed id derived from the row's rowid.
{
"frame": {
"tokens": [],
"count": 20,
"max_count": 100,
"min_aggregate": 7.0,
"max_edge": 1920,
"favorites_only": false,
"categories": []
}
}| Setting | Default | Description |
|---|---|---|
tokens |
[] |
Opaque frame tokens (list). Empty = feature disabled (404). Use long random strings, one per device; remove one to revoke it. Generate with python -c "import secrets; print(secrets.token_urlsafe(32))" |
count |
20 |
Default number of photos returned by /api/frame/photos |
max_count |
100 |
Hard cap on the count query parameter |
min_aggregate |
7.0 |
Minimum aggregate score for a photo to be curated |
max_edge |
1920 |
Long-edge cap (px) for served JPEGs; the max_edge query parameter may lower it but never raise it above this |
favorites_only |
false |
When true, only favorited photos are curated |
categories |
[] |
Allow-list of category names (empty = all categories) |
Tokens are compared constant-time as UTF-8 bytes, so a missing token is a 401 and a wrong or non-ASCII token is a 403 (never a 500). Curation excludes rejected, junk (junk_kind) and blink photos, then applies the score floor / favorites / categories filters; the returned set is a score-weighted random sample. See Web Viewer — Photo Frame / Kiosk Endpoint for the Home Assistant recipe.
A frame token is not a user login: it carries no user_id and is checked against the whole library, so in multi-user mode it ignores every user's private directories and grants read access across all users' photos, not just shared_directories. Only issue frame tokens on installs where every configured user is comfortable with that.
A minimal WebDAV endpoint under /dav so phone auto-upload apps (PhotoSync et al.) can push photos into an inbox directory that facet.py --watch then scores automatically — the PhotoPrism mobile-sync pattern. Upload-only plumbing: it never touches user sessions or JWTs. Access is HTTP Basic with shared-device credentials (username / password), not a user account. The whole /dav tree returns 404 while disabled — the feature is enabled only when username, password, and inbox_dir are all set. Every operation is confined to inbox_dir (traversal / absolute-path / symlink escape refused), and uploads stream to disk atomically with the max_file_mb cap.
{
"upload": {
"username": "",
"password": "",
"inbox_dir": "",
"max_file_mb": 500
}
}| Setting | Default | Description |
|---|---|---|
username |
"" |
HTTP Basic username (shared device credential). Empty = feature disabled (404). |
password |
"" |
HTTP Basic password (shared device credential). Empty = feature disabled (404). Use a long random string. |
inbox_dir |
"" |
Absolute path of the upload inbox. Empty = feature disabled (404). Point it at one of the scanned directories (or a subdirectory) so facet.py --watch scores uploads as they land. Created on demand. |
max_file_mb |
500 |
Per-file size cap (MB); an upload exceeding it aborts with 413 and leaves no partial file. |
Credentials are compared constant-time as UTF-8 bytes; a missing or wrong Authorization header is a 401 with WWW-Authenticate: Basic realm="Facet upload". Implemented methods: OPTIONS, PROPFIND (depth 0/1), MKCOL, PUT, MOVE, DELETE, GET, HEAD (LOCK/UNLOCK are not implemented). See Web Viewer — Phone Auto-Upload for the PhotoSync recipe and a curl smoke test.
Zero-shot detector for non-photo "junk" — screenshots, scanned documents, receipts, memes, presentation slides — over the stored image embedding (no image decode, no per-image model pass; the same shape as narrative moments without the temporal smoothing). Each kind carries a list of text prompts; the photo's embedding is cosine-scored against every prompt and max-pooled per kind. A not_junk contrast prompt set gates the decision: a photo is only flagged when the best junk kind clears min_confidence AND beats the best not_junk prompt by min_margin — otherwise it is stored as the not_junk sentinel (evaluated clean). NULL means "not evaluated": --detect-junk labels only NULL rows (and auto-runs at scan end), while --recompute-junk re-evaluates the whole library. Populates photos.junk_kind; the viewer's Junk Sweep review queue (VIEWER.md) reads it.
{
"junk_sweep": {
"enabled": true,
"prompt_template": "{desc}",
"pooling": "max",
"thresholds": {
"open_clip": { "min_confidence": 0.2, "min_margin": 0.06 },
"transformers": { "min_confidence": 0.1, "min_margin": 0.02 }
},
"kinds": {
"screenshot": ["a screenshot of a phone user interface", "..."],
"document": ["a scanned document", "..."],
"receipt": ["a close-up photo of a paper receipt", "..."],
"meme": ["a meme with overlaid text", "..."],
"slide": ["a presentation slide", "..."]
},
"not_junk_prompts": ["a natural photograph", "a candid photo of people", "..."]
}
}| Setting | Default | Description |
|---|---|---|
enabled |
true |
Run junk detection during --detect-junk / --recompute-junk and at scan end |
prompt_template |
"{desc}" |
Format string applied to every prompt ({desc} = the prompt); identity by default since the prompts are full sentences |
pooling |
"max" |
Pool the per-prompt cosines back to a kind by max (best single prompt, more discriminative) or mean |
thresholds.<backend>.min_confidence |
open_clip 0.2, transformers 0.1 |
Minimum max-pooled cosine for the best junk kind to be considered (CLIP/open_clip cosines run lower than SigLIP/transformers, so each backend has its own gate) |
thresholds.<backend>.min_margin |
open_clip 0.06, transformers 0.02 |
How far the best junk kind must beat the best not_junk contrast prompt before the photo is flagged |
kinds |
screenshot/document/receipt/meme/slide | {kind: [prompt synonyms]}; add, remove, or rename kinds freely — the column and viewer queue follow the config |
not_junk_prompts |
8 photograph prompts | Contrast set describing real photographs; the gate that keeps genuine photos out of the queue |
Prompt configuration for the VLM-powered critique (16gb/24gb profiles). The critique injects the full rule breakdown, penalties and EXIF into a configurable ladder prompt, renders the reply as Observation / Assessment / Suggestions, and caches it per photo in photos.vlm_critique (translated on demand into vlm_critique_translated). It runs against the stored thumbnail, so RAW files critique correctly instead of failing silently; refresh regenerates. The default ladder follows the AesBench four-ability structure (perceive → feel → judge → advise): its Assessment gives a short verdict on composition, color & light, focus/DOF & technical execution, and subject & moment, each reconciled against the injected metrics rather than restating the numbers.
{
"critique": {
"vlm": {
"max_new_tokens": 320
}
}
}| Setting | Default | Description |
|---|---|---|
critique.vlm.max_new_tokens |
320 |
Token budget for the structured VLM critique generation |
Selects where the caption/tag vision-language model runs. local (default) uses the in-process transformers Qwen path bundled with the 16gb/24gb VRAM profiles — no change for existing installs. The two remote backends point Facet at an external server so captioning and VLM tagging work on the legacy/8gb profiles that ship no local VLM: when a remote backend is selected the VLM features are no longer gated on the VRAM profile.
{
"vlm_backend": {
"type": "local",
"ollama": {
"base_url": "http://localhost:11434",
"model": "qwen2.5vl:7b",
"timeout_seconds": 120
},
"openai_compatible": {
"base_url": "http://localhost:1234/v1",
"api_key": "",
"model": "qwen2.5-vl-7b",
"timeout_seconds": 120
}
}
}| Setting | Default | Description |
|---|---|---|
type |
"local" |
Backend: local (in-process transformers Qwen), ollama (Ollama native REST API), or openai_compatible (any OpenAI chat-completions endpoint — LM Studio, vLLM, OpenRouter) |
ollama.base_url |
"http://localhost:11434" |
Ollama server base URL; the image is sent as base64 to POST /api/generate |
ollama.model |
"qwen2.5vl:7b" |
Ollama model tag (must be a vision model already pulled on the server) |
ollama.timeout_seconds |
120 |
Per-request timeout for Ollama calls |
openai_compatible.base_url |
"http://localhost:1234/v1" |
OpenAI-compatible base URL including the /v1 suffix; requests go to {base_url}/chat/completions with the image as an image_url data URI |
openai_compatible.api_key |
"" |
Bearer token sent as Authorization: Bearer <key>; leave empty for keyless local servers |
openai_compatible.model |
"qwen2.5-vl-7b" |
Model name passed to the endpoint |
openai_compatible.timeout_seconds |
120 |
Per-request timeout for OpenAI-compatible calls |
The shared backend drives captioning (--generate-captions and the on-demand /api/caption), the VLM critique (/api/critique?mode=vlm), VLM re-tagging (--recompute-tags-vlm), and the narrative-moment VLM tie-breaker. A remote request failure is surfaced as a per-photo failure (logged, empty tags / no caption) and never crashes the run. In-scan tagging still uses the profile's own tagger; run --recompute-tags-vlm to apply a remote backend to an existing library.
Zero-shot, advisory-only distortion labelling. --recompute-distortions scores each photo against ExIQA-style contrastive prompts over its stored CLIP/SigLIP embedding and stores the likely defects (motion blur, color cast, oversharpening, …) as an advisory JSON column. It never feeds the aggregate; the labels render as warning chips in the critique dialog.
{
"distortion_attributes": {
"enabled": true,
"top_n": 5,
"thresholds": {
"open_clip": { "temperature": 0.02, "min_confidence": 0.6 },
"transformers": { "temperature": 0.05, "min_confidence": 0.6 }
},
"vocabulary": {}
}
}| Setting | Default | Description |
|---|---|---|
enabled |
true |
Compute distortion attributes during --recompute-distortions |
top_n |
5 |
Maximum number of distortion labels kept per photo |
thresholds.<backend>.temperature |
open_clip 0.02, transformers 0.05 |
Softmax temperature over the contrastive prompt scores, per embedding backend (like narrative_moments, open_clip and transformers cosines run at different scales) |
thresholds.<backend>.min_confidence |
0.6 |
Minimum probability for a distortion label to be kept |
vocabulary |
{} |
Optional override of the built-in distortion prompt set ({attribute: [prompt synonyms]}); empty = module defaults |
Portrait skin-tone naturalness (advisory-only). --recompute-skin-tone samples cheek CIELAB chroma from stored face thumbnails + landmarks and measures its CIEDE2000 distance from a correlated-color-temperature skin locus, flagging portraits whose skin drifts green / magenta / blue / yellow. It never feeds the aggregate; the result renders as a skin-tone note in the critique dialog.
{
"skin_tone": {
"cast_delta_threshold": 12.0
}
}| Setting | Default | Description |
|---|---|---|
cast_delta_threshold |
12.0 |
Minimum CIEDE2000 delta between the measured skin chroma and the skin locus before a color cast is flagged |
One-way sync of Facet star ratings and favorites to an Immich server over its REST API. Assets are resolved by originalPath through the configured path-prefix mappings, in a single bulk search pass. Run it with --immich-sync (check first with --immich-test); see Commands — Immich Sync.
{
"immich": {
"url": "",
"api_key": "",
"path_map": [
{ "facet_prefix": "", "immich_prefix": "" }
],
"push": {
"ratings": true,
"favorites": true,
"top_picks_album": "",
"top_picks_min_rating": 4
},
"timeout_seconds": 30
}
}| Setting | Default | Description |
|---|---|---|
url |
"" |
Base URL of the Immich server (e.g. http://nas:2283) |
api_key |
"" |
Immich API key, sent as the x-api-key header |
path_map |
[{facet_prefix, immich_prefix}] |
Prefix rewrites from Facet paths to Immich originalPath values; the first matching facet_prefix is swapped for its immich_prefix when resolving an asset |
push.ratings |
true |
Push star ratings. Immich's version-safe policy is honored — only 1–5 is written, never 0/−1 |
push.favorites |
true |
Push the favorite flag |
push.top_picks_album |
"" |
Optional Immich album name that collects pushed photos above the rating threshold. Empty = no album |
push.top_picks_min_rating |
4 |
Minimum star rating for a photo to be added to top_picks_album |
timeout_seconds |
30 |
Per-request REST timeout |
--immich-sync honors --dry-run (resolves every asset but writes nothing) and --user (pushes that user's user_preferences ratings in multi-user mode). REST-only — Facet never touches the Immich database.
Settings for the chronological timeline view:
{
"timeline": {
"photos_per_group": 30
}
}| Setting | Default | Description |
|---|---|---|
photos_per_group |
30 |
Number of photos loaded per date group in the timeline view. Higher values show more photos per date but increase page weight. |
Settings for the interactive map view:
{
"map": {
"cluster_zoom_threshold": 10
}
}| Setting | Default | Description |
|---|---|---|
cluster_zoom_threshold |
10 |
Zoom level at which individual markers replace clusters. Lower values show individual markers earlier (more detail at wider zoom). Range: 1 (world) to 18 (street). |
Settings for AI caption translation via MarianMT:
{
"translation": {
"target_language": "fr"
}
}| Setting | Default | Description |
|---|---|---|
target_language |
"fr" |
Target language code for --translate-captions. Supported: fr (French), de (German), es (Spanish), it (Italian), pt (Brazilian Portuguese). Uses Helsinki-NLP MarianMT models (CPU, no GPU required). |
Supplementary aesthetic score derived from cached CLIP/SigLIP embeddings via text projection. Prompts are user-tunable for AVA benchmarking — see scripts/benchmark_aesthetic.py for measuring the SRCC impact of any change.
{
"aesthetic_clip": {
"positive_prompts": [
"a professional, high-quality photograph",
"an aesthetically beautiful image",
"a masterful, award-winning photograph",
"a sharp, well-composed photograph",
"a stunning, visually striking image"
],
"negative_prompts": [
"a low-quality, amateur photograph",
"a blurry, poorly composed photograph",
"an unattractive, mundane snapshot",
"a noisy, badly lit photograph",
"a boring, forgettable image"
]
}
}Empty arrays fall back to the module defaults baked into analyzers/aesthetic_clip.py. Don't tune these without re-running the AVA benchmark — the defaults score SRCC ~0.52 on ava_test/ and changes can easily regress to ~0.30.
Each VRAM profile's tagging_model key (e.g. qwen3.5-2b) maps to a model entry in the same models section. To experiment with a different VLM (Pixtral-12B, InternVL-2.5, etc.):
- Add a model entry under
models:"pixtral_12b": { "model_path": "mistralai/Pixtral-12B-2409", "torch_dtype": "bfloat16", "max_new_tokens": 100, "vlm_batch_size": 1 }
- Point a profile at it:
"profiles": { "24gb": { "tagging_model": "pixtral_12b", ... } }
- Run
python facet.py --recompute-tags-vlmto re-tag.
No code changes needed. Validate quality via a side-by-side spot check on ~30 photos before promoting to default.
Auto-generated 64-character hex string for session/sharing tokens:
{
"share_secret": "31a1c944ea5c82b871e61e50e5920daa2d1940b126c395f519088506595fd925"
}Generated automatically on first run if not present.