🌐 English · Français · Deutsch · Italiano · Español · Português · 简体中文
Photos are classified into a category, then scored with that category's weights.
- Category Detection - Photo analyzed for content (faces, tags, EXIF data)
- Filter Evaluation - Categories evaluated in priority order until one matches (a scoring context can promote/exclude categories per album or photo without changing the base order — see Scoring Contexts)
- Weight Application - Category-specific weights applied to metrics
- Modifier Application - Bonuses, penalties, and behavior flags applied
- Final Score - Weighted sum clamped to 0-10 range
scoring_config.json defines 34 categories (33 named plus default), evaluated in ascending priority order until one matches. Lower priority wins. The full list lives in the categories array; the main ones:
| Priority | Category | Detection Method |
|---|---|---|
| 8 | art |
Tags: painting, statue, drawing, cartoon, anime |
| 10 | astro |
Tags: aurora, astrophotography, stars, milky way |
| 15 | concert |
Tags: concert |
| 35 | group_portrait |
Face ratio ≥ 5% AND is_group_portrait |
| 42 | silhouette |
Has face AND is_silhouette |
| 45 | portrait |
Face ratio ≥ 5%, not silhouette/group/mono |
| 46 | portrait_bw |
Monochrome portrait (face ≥ 5%) |
| 55 | macro |
Tags: macro, insect, butterfly, dewdrop, ... |
| 65 | wildlife |
Tags: animal, bird, marine, reptile, primate |
| 80 | long_exposure |
Shutter 1-10 seconds |
| 85 | night |
Luminance < 0.15 |
| 88 | monochrome |
is_monochrome (saturation < 5%) |
| 95 | street |
Tags: street, urban_culture |
| 96 | human_others |
Has face AND face ratio < 5% |
| 100 | landscape |
Tags: landscape, mountain, beach, forest, ... |
| 999 | default |
Fallback (no filter) |
Other tag-based categories include aerial, food, sports, vehicle, travel, fashion, candid, product, architecture, urban, golden_hour, blue_hour, cinematic, vintage, abstract, minimalist, dramatic, and weather.
The priority order above is global — every photo evaluates against the same list. A scoring context is a named delta over that base order: it promotes a short list of categories to the front and excludes others outright, without renumbering anything. default (empty promote/excluded) is the no-op context, so nothing changes for a photo unless a context is explicitly assigned to it.
Effective order = promote (in the order given) → the global priority order with the promoted and excluded names removed → default last. A name listed in both promote and excluded is dropped entirely — excluded wins. ScoringConfig.resolve_context_order() (config/scoring_config.py) computes and memoizes this once per context name.
Shipped presets — editable from the viewer's Scoring Context tab (PUT /api/config/scoring_contexts/{name}, edition-gated) or directly in the JSON; see Scoring Contexts for the full field reference:
| Context | Promotes | Excludes |
|---|---|---|
default |
— | — |
action_stage |
sports, concert, candid |
silhouette |
party_event |
group_portrait, candid, food |
— |
portrait_session |
portrait, portrait_bw, fashion |
— |
wildlife |
wildlife |
— |
landscape |
landscape, golden_hour, blue_hour |
— |
motorsport |
sports, vehicle |
silhouette |
Only the delta is editable — drag the promoted head into order (or use the move-up/move-down buttons), toggle a category's exclusion — never a full standalone ordering per context: the non-promoted categories always keep the global priority order, so a category added later can never be silently missing from six separate lists. See Editing a context for the validation rules.
A context is assigned per album (PUT /api/albums/{id}/scoring_context, which materializes it onto every photo that is currently a member — a one-time snapshot, not a live subscription for a smart album, see Assigning a context) or, for a single stubborn photo, applied as a sticky category override (POST /api/comparison/override_category). Both levers persist in a photo_scoring_overrides side table rather than as columns on photos — save_photo/save_photos_batch write photo rows with INSERT OR REPLACE, which would silently wipe a new column on that row at the next rescan. Setting one lever leaves the other untouched, and either can be cleared independently. Neither takes effect on already-scored photos until a recompute — python facet.py --recompute-average, or POST /api/scan/recompute from the viewer (guarded cross-process against a second scan/recompute running at once — see Changing priorities requires a recompute). If normalization.per_category is enabled, run the recompute twice — see Normalization for why the first pass normalizes against each photo's old category.
Reordering — whether by editing global priority or by promoting via a context — only changes which category is tried first. It cannot make a category's filters match a photo they otherwise wouldn't. config/category_filter.py:122-128 fails a numeric range filter outright whenever the photo's underlying value is missing or unparseable, rather than skipping just that bound — a missing value and an out-of-range value are treated identically, and the category is passed over either way.
Concretely: sports (priority 71) carries shutter_speed_max: 0.02. A dance frame shot slower than 1/50s, or with no readable EXIF shutter speed at all, fails that filter no matter where sports sits in the evaluation order — even promoted to the very front by a context like action_stage. The photo falls through to whatever matches next, typically fashion (priority 43, tagged fashion, has a face) or silhouette (priority 42, backlit with a face). This is the single most useful thing to check when a photo lands in an unexpected category: before reordering or promoting anything, confirm the target category's numeric filters can actually match the photo's stored EXIF, not just its tags.
The EXIF trap above assumes the tag layer already matched — required_tags has its own version of the same failure: a filter can't match a tag that doesn't exist in the CLIP vocabulary at all. Before commit 917dd94, nothing in scoring_config.json covered dance: a frame the tagger described as dance, performance, or stage matched none of sports's required_tags (sports, motion, athlete, competition, action_sport) and fell straight through to the default catch-all — promoting sports via action_stage changed nothing, because promotion only reorders when a category is tried, never whether its filters match.
sports.tags.dance now carries 11 CLIP prompts (dance performance, dancer on stage, ballet dancer, contemporary dance, ballroom dancing, latin dance, hip hop dance, dance competition, dance troupe performing, dancer mid leap, dancer in motion), and dance was added to sports.filters.required_tags, so a tagged dance photo can now clear that filter. This makes sports reachable for dance content — it doesn't waive the shutter-speed trap above: a slow-shutter or EXIF-less dance frame can now pass the tag check and still fail shutter_speed_max: 0.02, landing in fashion or silhouette exactly as described there.
Existing photos keep whatever tags they already have — the new vocabulary only changes what the tagger assigns from here on. Re-tag the library with python facet.py --recompute-tags to apply it retroactively.
GET/POST /api/config/category_priorities (edition-gated) reads and rewrites the base order that every context deltas against. POST takes {"order": [name, ...]} — a set-equal permutation of every non-default category name — and permutes the existing priority values onto the new order rather than renumbering (10/20/30/…): the priority multiset is unchanged, so the numbers in the table above stay meaningful and uniqueness holds by construction. default (priority 999) is pinned last and excluded from reordering. Every write takes a timestamped .backup.<timestamp> copy of scoring_config.json first; this writer and the weight editor (update_category_weights) now share one lock, since the previous unguarded read-modify-write let a concurrent save from each silently drop the other's changes.
Reordering does not touch any photo's stored category by itself — run a recompute afterward (--recompute-average or POST /api/scan/recompute) to apply it.
Known limitation: api/types.py builds the gallery's type/filter dropdown list from ScoringConfig.get_categories() once at import time. A priority reorder is picked up immediately for actual category matching (every scoring and recompute call re-reads the config from disk), but the gallery's type dropdown keeps its old ordering until the viewer process restarts. Filtering itself is unaffected — reordering adds or removes no category names.
Each category in scoring_config.json has these components:
{
"name": "portrait",
"priority": 45,
"filters": {
"face_ratio_min": 0.05,
"has_face": true,
"is_silhouette": false,
"is_group_portrait": false,
"is_monochrome": false
},
"weights": {
"aesthetic_percent": 32,
"eye_sharpness_percent": 16,
"face_quality_percent": 14,
"composition_percent": 12,
"liqe_percent": 8,
"exposure_percent": 4,
"tech_sharpness_percent": 4,
"color_percent": 4,
"contrast_percent": 4,
"aesthetic_iaa_percent": 2
},
"modifiers": {
"bonus": 0.419,
"_apply_blink_penalty": true,
"noise_tolerance_multiplier": 0.006,
"_clipping_multiplier": 0.5
},
"tags": {}
}| Filter | Field | Description |
|---|---|---|
face_ratio_min / face_ratio_max |
face_ratio |
Face area as fraction (0.0-1.0) |
face_count_min / face_count_max |
face_count |
Number of faces |
iso_min / iso_max |
ISO |
Camera ISO |
shutter_speed_min / shutter_speed_max |
shutter_speed |
Exposure time (seconds) |
luminance_min / luminance_max |
mean_luminance |
Brightness (0.0-1.0) |
focal_length_min / focal_length_max |
focal_length |
Focal length (mm) |
f_stop_min / f_stop_max |
f_stop |
Aperture f-number |
| Filter | Description |
|---|---|
has_face |
At least one face detected |
is_monochrome |
Saturation < 5% |
is_silhouette |
Backlit with heavy shadows/highlights |
is_group_portrait |
face_count >= min_faces_for_group (configurable, default: 4) |
| Filter | Description |
|---|---|
required_tags |
List of tags photo must have |
excluded_tags |
List of tags photo must NOT have |
tag_match_mode |
"any" (default) or "all" |
All weights use the _percent suffix. They are normalized by get_weights(), so totals need not equal exactly 100 — but keeping them at 100 keeps scores on the 0-10 scale.
| Key | Metric | Source | Best For |
|---|---|---|---|
aesthetic_percent |
Visual appeal | TOPIQ or CLIP+MLP | All |
quality_percent |
Legacy quality | Redistributed into aesthetic (no separate signal) |
— |
face_quality_percent |
Face clarity | InsightFace | Portraits |
eye_sharpness_percent |
Eye sharpness | InsightFace landmarks | Portraits |
tech_sharpness_percent |
Overall sharpness | Laplacian variance | Landscapes |
composition_percent |
Composition | SAMP-Net or rule-based | All |
exposure_percent |
Exposure balance | Histogram analysis | All |
color_percent |
Color harmony | HSV analysis | Color photos |
contrast_percent |
Tonal contrast | Histogram spread | B&W |
dynamic_range_percent |
Tonal range | Histogram analysis | HDR, landscapes |
isolation_percent |
Subject separation | Face vs background | Portraits, wildlife |
leading_lines_percent |
Leading lines | Edge detection | Architecture |
power_point_percent |
Rule-of-thirds | Subject placement | All |
saturation_percent |
Color saturation | HSV analysis | Vibrant photos |
noise_percent |
Noise level | Noise estimation | Low-light |
face_sharpness_percent |
Face region sharpness | Face analysis | Portraits |
aesthetic_iaa_percent |
Artistic aesthetic merit | TOPIQ IAA (AVA-trained) | Art, creative |
face_quality_iqa_percent |
Face quality (IQA) | TOPIQ NR-Face | Portraits |
liqe_percent |
LIQE quality score | LIQE | Diagnostics |
subject_sharpness_percent |
Subject region sharpness | BiRefNet + Laplacian | Portraits, wildlife |
subject_prominence_percent |
Subject area ratio | BiRefNet | Macro, wildlife |
subject_placement_percent |
Subject rule-of-thirds | BiRefNet | All |
bg_separation_percent |
Background separation | BiRefNet | Portraits, macro |
Adjust scoring behavior per category:
| Modifier | Type | Description |
|---|---|---|
bonus |
float | Added to final score (e.g., 0.5) |
noise_tolerance_multiplier |
float | Scale noise penalty (0.5 = half) |
iso_tolerance_multiplier |
float | Scale ISO penalty |
min_saturation_bonus |
float | Bonus for high saturation |
contrast_bonus |
float | Bonus for high contrast |
_skip_clipping_penalty |
bool | Skip exposure clipping penalty |
_skip_oversaturation_penalty |
bool | Skip oversaturation penalty |
_clipping_multiplier |
float | Scale clipping penalty |
_apply_blink_penalty |
bool | Apply blink detection penalty |
Four dimensions derived from BiRefNet subject segmentation:
| Weight Key | Metric | Description |
|---|---|---|
subject_sharpness_percent |
Subject sharpness | Focus quality of the subject region vs the background. High = sharp subject, soft background. |
subject_prominence_percent |
Subject prominence | Subject area as a fraction of the frame. High for macro and tightly-framed subjects, low for wide scenes. |
subject_placement_percent |
Subject placement | Rule-of-thirds score for the subject's center of mass. |
bg_separation_percent |
Background separation | Edge gradient difference at the subject boundary (bokeh quality). |
Use subject_sharpness_percent and bg_separation_percent for portrait/wildlife; subject_prominence_percent for macro.
Three additional quality models:
| Weight Key | Model | Description |
|---|---|---|
aesthetic_iaa_percent |
TOPIQ IAA | AVA-trained aesthetic merit, distinct from the technical-quality aesthetic score. Best for art/creative categories. |
face_quality_iqa_percent |
TOPIQ NR-Face | Face-region quality assessment. Best for portrait categories. |
liqe_percent |
LIQE | Quality score plus a distortion diagnosis (motion blur, overexposure, noise). |
These models run as part of the default scoring pipeline on all GPU profiles (8gb/16gb/24gb) and share VRAM with TOPIQ; the CPU legacy profile skips them. Add their weight keys to any category where the assessment is useful.
| Column | Source | Description |
|---|---|---|
aesthetic_clip |
analyzers/aesthetic_clip.py + cached CLIP/SigLIP embedding |
A free supplementary aesthetic score (0-10) derived from cached image embeddings by projecting onto an "aesthetic axis" built from positive/negative text prompts. Zero extra image inference at scan time. Not part of the default aggregate. Populate with python scripts/compute_aesthetic_clip.py --db <path>. Benchmark with python scripts/benchmark_aesthetic.py --db <path> --ava AVA.txt --photo-dir <dir>. AVA SRCC ≈ 0.52 on the 500-photo ava_test/ set (vs 0.94 for aesthetic_iaa) — useful as a cheap pre-filter or when TOPIQ-IAA is unavailable. |
Tags trigger tag-based categories and are matched using CLIP similarity:
{
"tags": {
"landscape": ["landscape", "scenic view", "nature scene"],
"mountain": ["mountain", "alpine", "peaks"],
"beach": ["beach", "ocean", "seaside", "coastal"]
}
}Each key is the canonical tag name, and the array contains synonyms for CLIP matching.
The viewer's "Top Picks" filter uses a custom weighted score:
"top_picks_weights": {
"aggregate_percent": 30,
"aesthetic_percent": 28,
"composition_percent": 18,
"face_quality_percent": 24
}Score computation:
- With face (face_ratio ≥ 20%): All four metrics contribute
- Without face:
face_quality_percentredistributed evenly (half each) toaestheticandcomposition(with default weights: aesthetic 0.40, composition 0.30)
Default weights are optimized for TOPIQ (0.93 SRCC), the aesthetic model for all profiles.
| Profile | Aesthetic Model | Embeddings | Tagger | Recommendations |
|---|---|---|---|---|
24gb |
TOPIQ (0.93 SRCC) | SigLIP 2 NaFlex SO400M | Qwen3.5-4B | Best accuracy, default weights |
16gb |
TOPIQ (0.93 SRCC) | SigLIP 2 NaFlex SO400M | Qwen3.5-2B | Default weights |
8gb |
CLIP+MLP (0.76 SRCC) | CLIP ViT-L-14 | CLIP similarity | Default weights work well |
legacy |
CLIP+MLP on CPU | CLIP ViT-L-14 | CLIP similarity | Default weights, slower |
All GPU profiles (8gb/16gb/24gb) additionally run supplementary PyIQA models (TOPIQ IAA, TOPIQ NR-Face, LIQE) and optionally BiRefNet_dynamic for subject saliency; the CPU legacy profile skips them.
Run --compute-recommendations after switching profiles to analyze score distributions.
- Open
/stats→ Categories tab → Weights sub-tab - Unlock edition mode
- Select a category from the editor dropdown
- Adjust sliders — the live Score Distribution Preview shows estimated impact
- Click Save then Recompute Scores to apply
The viewer runs --recompute-category under the hood, updating only photos in that category.
python facet.py --compute-recommendationsShows:
- Score distributions per category
- Weight correlation analysis
- Suggested adjustments
Edit scoring_config.json category weights. Ensure they sum to 100.
python facet.py --recompute-average # All categories
python facet.py --recompute-category portrait # Single category (faster)Uses stored embeddings - no GPU needed.
python facet.py --compute-recommendationsCompare distributions before/after.
Train weights by comparing photo pairs:
- Set a non-empty
edition_passwordin config:"viewer": { "edition_password": "your-password" } - Start viewer:
python viewer.py - Click "Compare" button
- Side-by-side photos
- Keyboard: ← (left wins), → (right wins), T (tie), S (skip). The on-screen buttons are still labelled A / B (the values submitted), but the keys are ArrowLeft/ArrowRight.
- Progress bar shows comparisons toward 50 minimum
Comparisons carry a source marker so the optimizer can weight them by reliability:
vote— explicit A/B votes from the comparison interfaceculling— derived automatically from burst/similar culling decisions: each rejected photo is paired against up to two kept photos from the same group (capped at 12 pairs per group). Kept photos win. Explicit votes on the same pair are never overwritten.rating— synthetic pairs generated from star ratings and favorites
Reviewing burst groups in the viewer therefore grows the training set for weight optimization without any extra effort.
# Check comparison stats
python facet.py --comparison-stats
# Optimize weights from comparisons (applied only if it generalizes)
python facet.py --optimize-weights --optimize-category portrait
# Restrict training data to specific sources
python facet.py --optimize-weights --optimize-category portrait --optimize-sources vote,culling
# Apply even if the held-out gate is not met
python facet.py --optimize-weights --optimize-category portrait --optimize-force
# Apply to all photos
python facet.py --recompute-averageBeyond explicit A/B votes, two more label streams feed the optimizer:
- Culling decisions are captured automatically on every burst/similar
confirm (
source='culling'). - Star ratings, favorites and rejections are materialized into synthetic
pairs with
python facet.py --sync-label-comparisons(source='rating'). Re-running re-syncs from the current labels, so retracted ratings disappear.
The optimizer weighs each source by reliability (vote 1.0, rating 0.7,
culling 0.5) when maximizing the Bradley-Terry likelihood. It trains on the
exact 0-10 metric vector the scorer uses (including liqe, aesthetic_iaa,
face_quality_iqa and the subject-saliency metrics), so optimized weights map
directly onto production scoring.
Weights are applied only if they generalize: the final weights are fit on
all comparisons, but the decision to write them is gated on held-out k-fold
accuracy, not training accuracy. If the held-out gain over the current weights
is below the threshold (default 2 pp) the run reports the numbers and writes
nothing — pass --optimize-force to override. Optimization is per-category and
needs labelled comparisons for that category; categories with no votes
cannot be tuned from data.
Recommended cadence:
python facet.py --mine-insights # what signal exists, drift, health
python facet.py --sync-label-comparisons # refresh rating-derived pairs
python facet.py --optimize-weights # learn weights from all sources
python facet.py --recompute-average # apply + persist percentile snapshotDuring comparison, the Weight Preview panel lets you adjust sliders for real-time score changes and click "Suggest Weights" for optimized values. This is the same in-viewer slider workflow described in Option A: Via Viewer above — see there for the full save/recompute flow.
Suggest Weights also answers a narrower question than the CV gate above:
how well do this category's current, live weights already agree with your
own comparisons? Clicking it returns accuracy_before — the percentage of
that category's labelled pairs (A/B votes, culling, and rating-derived
pairs) whose winner the live weights predict correctly — next to
accuracy_after, the same figure for the suggested weights. Both are shown
side by side in the Weight Suggestions tab and in the A/B Compare tab's
sidebar every time you run it (GET /api/comparison/learned_weights,
optimization/weight_optimizer.py:optimize_weights_direct). Like the CLI
gate, this needs min_comparisons_for_optimization (default 30) labelled
pairs for that category — the button reports the shortfall instead of a
number below that.
{
"name": "underwater",
"priority": 62,
"filters": {
"required_tags": ["underwater"],
"tag_match_mode": "any"
},
"weights": {
"aesthetic_percent": 40,
"color_percent": 25,
"composition_percent": 20,
"exposure_percent": 15
},
"modifiers": {
"noise_tolerance_multiplier": 0.3,
"bonus": 0.5
},
"tags": {
"underwater": ["underwater", "scuba", "diving", "ocean"],
"fish": ["fish", "coral", "reef"]
}
}Add to the categories array in scoring_config.json, then run --recompute-average (or --recompute-category underwater for just the new category).
# Edit scoring_config.json:
# Find "concert" category, adjust:
# "noise_tolerance_multiplier": 0.05
# "exposure_percent": 5
python facet.py --recompute-category concertOr use the viewer's weight editor at /stats → Categories → Weights for live preview and one-click recompute.
# Edit: "vram_profile": "8gb"
python facet.py --compute-recommendations # Analyze
# Reduce aesthetic_percent in categories if needed
python facet.py --recompute-average- Add category definition (see above)
- Run
python facet.py --validate-categories - Run
python facet.py --recompute-average