Skip to content

Latest commit

 

History

History
2123 lines (1757 loc) · 93.6 KB

File metadata and controls

2123 lines (1757 loc) · 93.6 KB

Configuration Reference

🌐 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).

Table of Contents


Users

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"
    ]
  }
}

User fields

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

Shared directories

The shared_directories key (sibling of user objects) lists directories visible to all users.

Roles

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

Adding users

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.json

After adding a user, edit scoring_config.json to configure their directories.

Backward compatibility

  • No users key = legacy single-user mode (unchanged behavior)
  • viewer.password and viewer.edition_password are ignored in multi-user mode
  • Existing ratings in the photos table remain for single-user mode; use --migrate-user-preferences to copy them

Scanning

Controls directory scanning behavior.

{
  "scanning": {
    "skip_hidden_directories": true
  }
}
Setting Default Description
skip_hidden_directories true Skip directories starting with . during photo scanning

Categories

Array of category definitions. See Scoring for detailed category documentation.

Each category has:

  • name - Category identifier
  • priority - Lower = higher priority (evaluated first)
  • filters - Conditions for matching
  • weights - Scoring metric weights (must sum to 100)
  • modifiers - Behavior adjustments
  • tags - CLIP vocabulary for tag-based matching

Form & color-harmony weights. Every category's weights block carries five explainable metric keys — symmetry_percent, balance_percent, edge_entropy_percent, fractal_percent, and color_harmony_percent — populated by --recompute-form. They ship at 0 in 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

{
  "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

Thresholds

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

Composition

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

EXIF Adjustments

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)

Exposure

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

Penalties

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)

Normalization

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

Models

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

VRAM Auto-Detection

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)

FACET_VRAM_PROFILE environment override

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/photos

Quality Assessment Models

Selects 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

Available Quality Models

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

Switching Quality Models

  1. Edit scoring_config.json:

    "quality": {
      "model": "topiq"
    }
  2. Re-score existing photos (optional):

    python facet.py /path --pass quality
    python facet.py --recompute-average

Processing

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
    }
  }
}

Key Concepts

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.

Settings Reference

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

Processing Modes

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)

How Multi-Pass Works

Instead of loading all models at once, multi-pass:

  1. Loads images in RAM chunks (default ram_chunk_size: 32)
  2. For each chunk, runs models sequentially: load model → process chunk → unload model
  3. 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.

Auto-Tuning Behavior

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

Dynamic Pass Grouping

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) -

CLI Options

# 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-models

Burst Detection

Groups 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

Burst Scoring

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)

Duplicate Detection

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.


Extended IQA tier (optional)

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.


Face Detection

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

Face Clustering

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 + cupy packages. The Docker GPU image bundles cuML, so the GPU profiles cluster on GPU out of the box.
  • The legacy profile is forced to CPU clustering regardless of use_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 cupy device-count guard falls back to CPU HDBSCAN when cuML/cupy import but no usable GPU is found (e.g. running the GPU image in legacy mode).
  • always warns 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

Face Processing

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

Monochrome Detection

Black & white photo detection.

{
  "monochrome_detection": {
    "saturation_threshold_percent": 5
  }
}
Setting Default Description
saturation_threshold_percent 5 Mean saturation < 5% = monochrome

Tagging

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.

Available Tagging Models

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

Default Tagging Models per Profile

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)

Re-tagging Photos

python facet.py --recompute-tags       # Re-tag using configured model per profile
python facet.py --recompute-tags-vlm   # Re-tag using VLM tagger

Standalone Tags

Tags 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.


Analysis

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

Viewer

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

Features

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.

Client Proofing

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

Path Mapping

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.jpg and //NAS/Photos/img.jpg both 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"}

Password Protection

Optional password protection for the viewer:

{
  "viewer": {
    "password": "your-password-here"
  }
}

When set, users must authenticate before accessing the viewer.

Viewer Performance

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.


Performance

Database performance settings.

{
  "performance": {
    "mmap_size_mb": 2048,
    "cache_size_mb": 128,
    "slow_request_ms": 1000
  }
}

Note: wal_checkpoint_minutes is an optional override and is not present in the shipped performance block (which contains only mmap_size_mb, cache_size_mb, and slow_request_ms). Add it explicitly to change the default of 30.

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.

Storage

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.

Plugins

Event-driven plugin system for reacting to scoring events. Plugins can be Python modules, webhooks, or built-in actions.

Configuration

{
  "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

Supported 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

Writing a Plugin

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.

Webhooks

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).

Built-in Actions

Action Description Options
copy_to_folder Copy photo to a folder folder, min_score
send_notification Log a notification min_score

API Endpoints

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

Capsules

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 }
  }
}

Global Settings

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)

Capsule Types

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

Dimension-Based Capsules

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).

Slideshow Transitions

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

Reverse Geocoding

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.

Similarity Groups

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.

Auto-Cull

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-Aware Culling Profiles

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.

Scenes

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

Narrative Moments

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-moments once (GPU recommended) for that backfill; add --limit N to 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).

Social Export

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.

Portfolio Export

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.

Photo Frame / Kiosk

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.

Phone Auto-Upload

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.

Junk Sweep

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

AI Critique

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

See Web Viewer — AI Critique.

VLM Backend

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.

Distortion Attributes

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

Skin Tone

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

Immich Sync

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.

Timeline

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.

Map

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).

Translation

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).

Aesthetic CLIP (R2)

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.

Adding alternative VLM tagger / critique models (R3)

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.):

  1. 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
    }
  2. Point a profile at it:
    "profiles": {
      "24gb": { "tagging_model": "pixtral_12b", ... }
    }
  3. Run python facet.py --recompute-tags-vlm to re-tag.

No code changes needed. Validate quality via a side-by-side spot check on ~30 photos before promoting to default.

Share Secret

Auto-generated 64-character hex string for session/sharing tokens:

{
  "share_secret": "31a1c944ea5c82b871e61e50e5920daa2d1940b126c395f519088506595fd925"
}

Generated automatically on first run if not present.