cvikl commited on
Commit
fb91fdc
Β·
0 Parent(s):

First commit.

Browse files
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ venv/
3
+ data/images/
4
+ data/masks/
5
+ data/features/
6
+ data/*.zip
7
+ data/.tsne_cache.npz
8
+ *.pt
9
+ *.onnx
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ libgl1 libglib2.0-0 git && rm -rf /var/lib/apt/lists/*
5
+
6
+ WORKDIR /code
7
+ COPY requirements.txt .
8
+ RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu \
9
+ && pip install --no-cache-dir -r requirements.txt
10
+
11
+ COPY . .
12
+
13
+ # Hugging Face Spaces expects the app on port 7860
14
+ EXPOSE 7860
15
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Timotej Cvikl, Ε½iga Klun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Religious Artwork Analysis
2
+
3
+ Code for **"Analysis of Artworks Across Different Religions"** β€” a study of
4
+ whether (and which) visual features separate religious artworks of Buddhism,
5
+ Christianity, Hinduism and Islam, using a hand-verified, balanced dataset of
6
+ 3,997 paintings (~1,000 per religion).
7
+
8
+ - **Dataset**: [Religious Artwork Dataset on Kaggle](https://www.kaggle.com/datasets/zigaklun/religious-artwork-dataset)
9
+ (3,997 images from 8 museum/archive sources, hand-verified labels, CC BY-NC 4.0)
10
+ - **Live demo**: interactive t-SNE explorer (see [`app/`](app/))
11
+
12
+ ## Key result
13
+
14
+ Semantic features transfer across museums; style features largely do not.
15
+ Religion-classification accuracy (chance = 0.25):
16
+
17
+ | feature family | dims | pooled 5-fold | leave-one-source-out |
18
+ |-----------------------|-----:|--------------:|---------------------:|
19
+ | CLIP attribute scores | 27 | 0.919 | **0.856** |
20
+ | CLIP embedding | 512 | 0.941 | 0.828 |
21
+ | DINOv2 embedding | 768 | 0.910 | 0.783 |
22
+ | hand-crafted (all) | 378 | 0.741 | 0.391 |
23
+ | pose (main figure) | 36 | 0.611 | 0.369 |
24
+ | face composition | 39 | 0.501 | 0.280 |
25
+
26
+ The pooled–LOSO gap measures **source leakage**: features that encode museum
27
+ reproduction style (scan texture, framing) look discriminative pooled but
28
+ collapse on unseen sources. Reproduce with `evaluation/family_accuracy.py`.
29
+
30
+ ## Setup
31
+
32
+ ```bash
33
+ python -m venv venv && source venv/bin/activate
34
+ pip install -r requirements.txt
35
+
36
+ # 1. data (needs a Kaggle API token)
37
+ python data/download.py
38
+
39
+ # 2. preprocessing: guarded background masks (~1 h, CPU)
40
+ python preprocessing/generate_masks.py
41
+
42
+ # 3. features (each checkpointed & resumable)
43
+ python features/extract_handcrafted.py --workers 8
44
+ python features/extract_clip.py
45
+ python features/extract_dino.py
46
+ python features/extract_faces.py
47
+ python features/extract_pose.py
48
+
49
+ # 4. evaluation table
50
+ python evaluation/family_accuracy.py
51
+
52
+ # 5. web app
53
+ uvicorn app.main:app --port 8000
54
+ ```
55
+
56
+ ## Repository layout
57
+
58
+ ```
59
+ data/ dataset download + expected layout
60
+ preprocessing/ crop_padding + guarded U^2-Net background masking
61
+ features/ one extractor per feature family (see features/README.md)
62
+ evaluation/ pooled vs leave-one-source-out accuracy per family
63
+ app/ FastAPI + Plotly interactive t-SNE explorer
64
+ ```
65
+
66
+ ## Authors
67
+
68
+ Timotej Cvikl & Ε½iga Klun β€” Faculty of Computer and Information Science, University of
69
+ Ljubljana. Code under MIT license; dataset under CC BY-NC 4.0 (see the Kaggle page).
app/README.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Interactive t-SNE explorer
2
+
3
+ FastAPI + Plotly app: per-family weighted t-SNE over all extracted features,
4
+ with live CLIP zero-shot attribute editing (edit prompts, the server encodes
5
+ them with CLIP and rescores the whole dataset).
6
+
7
+ ## Run locally
8
+
9
+ Requires `data/` populated (images + feature parquets β€” see repo README).
10
+
11
+ ```bash
12
+ uvicorn app.main:app --port 8000
13
+ ```
14
+
15
+ First start fits the t-SNE (~a minute) and caches it in `data/.tsne_cache.npz`.
16
+
17
+ ## Deploy as a Hugging Face Space (free)
18
+
19
+ 1. Create a Space (type: **Docker**) at huggingface.co/new-space.
20
+ 2. Push this repository to it, with `data/` populated via git-lfs
21
+ (`git lfs track "data/images/**" "data/features/*.parquet"`).
22
+ 3. The included `Dockerfile` does the rest; the Space serves on port 7860.
23
+
24
+ Note: the CLIP label editor writes `app/clip_labels.json` β€” on a public
25
+ deployment consider making the Space private or removing write endpoints.
app/clip_labels.json ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "christianity": [
3
+ {
4
+ "label": "halo",
5
+ "prompt": "a golden halo around a figure"
6
+ },
7
+ {
8
+ "label": "cross",
9
+ "prompt": "a crucifix or cross"
10
+ },
11
+ {
12
+ "label": "virgin mary",
13
+ "prompt": "the virgin mary"
14
+ },
15
+ {
16
+ "label": "angels",
17
+ "prompt": "angels with wings"
18
+ },
19
+ {
20
+ "label": "nativity",
21
+ "prompt": "a nativity scene"
22
+ },
23
+ {
24
+ "label": "biblical figs",
25
+ "prompt": "biblical figures in robes"
26
+ }
27
+ ],
28
+ "islam": [
29
+ {
30
+ "label": "calligraphy",
31
+ "prompt": "arabic calligraphy"
32
+ },
33
+ {
34
+ "label": "arabesque",
35
+ "prompt": "geometric arabesque patterns"
36
+ },
37
+ {
38
+ "label": "mosque",
39
+ "prompt": "a mosque with a minaret"
40
+ },
41
+ {
42
+ "label": "quran ms",
43
+ "prompt": "an illuminated quran manuscript"
44
+ },
45
+ {
46
+ "label": "islamic tiles",
47
+ "prompt": "islamic tile patterns"
48
+ }
49
+ ],
50
+ "buddhism": [
51
+ {
52
+ "label": "buddha",
53
+ "prompt": "a buddha statue"
54
+ },
55
+ {
56
+ "label": "lotus",
57
+ "prompt": "a lotus flower"
58
+ },
59
+ {
60
+ "label": "thangka",
61
+ "prompt": "a thangka painting"
62
+ },
63
+ {
64
+ "label": "mandala",
65
+ "prompt": "a mandala"
66
+ },
67
+ {
68
+ "label": "dharma wheel",
69
+ "prompt": "a dharma wheel"
70
+ },
71
+ {
72
+ "label": "bodhisattva",
73
+ "prompt": "a bodhisattva figure"
74
+ }
75
+ ],
76
+ "hinduism": [
77
+ {
78
+ "label": "many-armed",
79
+ "prompt": "a deity with multiple arms"
80
+ },
81
+ {
82
+ "label": "ganesha",
83
+ "prompt": "ganesha the elephant god"
84
+ },
85
+ {
86
+ "label": "hindu temple",
87
+ "prompt": "a colorful hindu temple"
88
+ },
89
+ {
90
+ "label": "krishna",
91
+ "prompt": "krishna playing the flute"
92
+ },
93
+ {
94
+ "label": "shiva nataraja",
95
+ "prompt": "dancing shiva nataraja"
96
+ }
97
+ ],
98
+ "general": [
99
+ {
100
+ "label": "gold leaf",
101
+ "prompt": "gold leaf background"
102
+ },
103
+ {
104
+ "label": "prayer",
105
+ "prompt": "religious figures in prayer"
106
+ },
107
+ {
108
+ "label": "sacred geom.",
109
+ "prompt": "sacred geometry"
110
+ },
111
+ {
112
+ "label": "ritual objects",
113
+ "prompt": "incense and ritual objects"
114
+ },
115
+ {
116
+ "label": "manuscript",
117
+ "prompt": "a religious manuscript"
118
+ }
119
+ ]
120
+ }
app/main.py ADDED
@@ -0,0 +1,1118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Religious Art Space Navigator β€” FastAPI backend.
3
+
4
+ Startup: loads features.parquet, normalises feature groups, concatenates them,
5
+ and fits a single openTSNE projection.
6
+ Serves: initial embedding, CLIP label editor (GET/POST /api/clip_labels),
7
+ and on-demand t-SNE recompute (POST /api/recompute).
8
+
9
+ Run:
10
+ uvicorn app.main:app --reload --port 8000
11
+ """
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import time
17
+ from contextlib import asynccontextmanager
18
+
19
+ import clip as openai_clip
20
+ import cv2
21
+ import numpy as np
22
+ import pandas as pd
23
+ import torch
24
+ from fastapi import FastAPI, HTTPException
25
+ from fastapi.responses import FileResponse, Response
26
+ from fastapi.staticfiles import StaticFiles
27
+ from openTSNE import TSNE
28
+ from pydantic import BaseModel
29
+ from scipy.spatial import ConvexHull, Delaunay
30
+ from sklearn.cluster import HDBSCAN
31
+ from sklearn.decomposition import PCA
32
+ from sklearn.preprocessing import StandardScaler
33
+
34
+ FEATURES_PARQUET = "features/handcrafted_gold.parquet" # Timotej's HC file (joins via metadata id)
35
+ DINO_PARQUET = "data/features_dino.parquet"
36
+ CLIP_PARQUET = "data/features_clip.parquet"
37
+ POSE_PARQUET = "data/features_pose.parquet"
38
+ FACES_PARQUET = "data/features_faces.parquet"
39
+ METADATA_CSV = "data/artwork_metadata.csv"
40
+ IMAGES_DIR = "data/images"
41
+ CACHE_PATH = "data/.tsne_cache.npz"
42
+ CLIP_LABELS_PATH = "app/clip_labels.json"
43
+ CLIP_SCORE_THRESHOLD = 0.22 # zero out scores below this before normalisation
44
+ _CACHE_VERSION = "v5"
45
+
46
+ FACE_FEATURE_COLS = [
47
+ "face_count", "face_detected", "face_coverage",
48
+ "mean_face_size", "max_face_size", "face_centroid_x",
49
+ "face_centroid_y", "face_size_std", "mean_face_angle",
50
+ ]
51
+
52
+ HC_SUBGROUPS = {
53
+ "hc_color": ["hc_hist_", "hc_norm_hist_", "hc_h_hist_", "hc_s_hist_", "hc_v_hist_",
54
+ "hc_avg_hue", "hc_avg_sat"],
55
+ "hc_flat": ["hc_flatness_"],
56
+ "hc_geom": ["hc_lbp_", "hc_fft_band_"],
57
+ "hc_lines": ["hc_angle_hist_", "hc_hough_", "hc_straight_ratio"],
58
+ "hc_light": ["hc_brightness", "hc_contrast", "hc_darkness", "hc_edge_density"],
59
+ "hc_symmetry": ["hc_sym_"],
60
+ }
61
+
62
+ POSE_CONNECTIONS = [
63
+ (0,1),(1,2),(2,3),(3,7),(0,4),(4,5),(5,6),(6,8),(9,10),
64
+ (11,12),(11,13),(13,15),(15,17),(15,19),(17,19),
65
+ (12,14),(14,16),(16,18),(16,20),(18,20),
66
+ (11,23),(12,24),(23,24),
67
+ (23,25),(25,27),(27,29),(27,31),(29,31),
68
+ (24,26),(26,28),(28,30),(28,32),(30,32),
69
+ ]
70
+
71
+ def _load_clip_labels() -> tuple[list[str], list[str], dict]:
72
+ """Read clip_labels.json β†’ (CLIP_LABELS flat, ATTRIBUTES flat, raw dict)."""
73
+ with open(CLIP_LABELS_PATH) as f:
74
+ raw = json.load(f)
75
+ labels, prompts = [], []
76
+ for entries in raw.values():
77
+ for e in entries:
78
+ labels.append(e["label"])
79
+ prompts.append(e["prompt"])
80
+ return labels, prompts, raw
81
+
82
+
83
+ CLIP_LABELS, _CLIP_ATTRIBUTES, _ = _load_clip_labels()
84
+
85
+ state = {}
86
+
87
+
88
+ def _norm(arr: np.ndarray) -> np.ndarray:
89
+ scaled = StandardScaler().fit_transform(arr.astype(np.float32))
90
+ return scaled / np.sqrt(scaled.shape[1])
91
+
92
+
93
+ def _file_hash(path: str) -> str:
94
+ h = hashlib.sha256()
95
+ with open(path, "rb") as f:
96
+ for chunk in iter(lambda: f.read(1 << 20), b""):
97
+ h.update(chunk)
98
+ return h.hexdigest()[:16]
99
+
100
+
101
+ def _cache_key() -> str:
102
+ parts = [_CACHE_VERSION]
103
+ for p in (FEATURES_PARQUET, DINO_PARQUET, CLIP_PARQUET, POSE_PARQUET, FACES_PARQUET, CLIP_LABELS_PATH):
104
+ if os.path.exists(p):
105
+ parts.append(_file_hash(p))
106
+ return ":".join(parts)
107
+
108
+
109
+ def _encode_clip_prompts(prompts: list[str]) -> np.ndarray:
110
+ """Encode text prompts with CLIP text encoder β†’ (n_prompts, 512) float32."""
111
+ model, _ = state.get("_clip_model_pair") or (None, None)
112
+ if model is None:
113
+ device = "cuda" if torch.cuda.is_available() else "cpu"
114
+ model, _ = openai_clip.load("ViT-B/16", device=device)
115
+ state["_clip_model_pair"] = (model, device)
116
+ device = state["_clip_model_pair"][1]
117
+ with torch.no_grad():
118
+ tokens = openai_clip.tokenize(prompts).to(device)
119
+ text_feats = model.encode_text(tokens).float()
120
+ text_feats /= text_feats.norm(dim=-1, keepdim=True)
121
+ return text_feats.cpu().numpy()
122
+
123
+
124
+ def _recompute_clip_scores(image_vecs: np.ndarray, prompts: list[str],
125
+ threshold: float = 0.0) -> np.ndarray:
126
+ """Dot-product stored image embeddings against re-encoded prompts.
127
+ Scores below threshold are zeroed out (sparse, discriminative representation)."""
128
+ text_feats = _encode_clip_prompts(prompts)
129
+ scores = (image_vecs @ text_feats.T).astype(np.float32)
130
+ if threshold > 0:
131
+ scores[scores < threshold] = 0.0
132
+ return scores
133
+
134
+
135
+ def _run_tsne() -> None:
136
+ """Rebuild concatenated feature matrix and fit a single openTSNE. Updates state['coords']."""
137
+ t0 = time.time()
138
+ blocks = []
139
+ group_names = []
140
+
141
+ for name in HC_SUBGROUPS:
142
+ if state.get(name) is not None:
143
+ blocks.append(state[name])
144
+ group_names.append(name)
145
+
146
+ for name in ("dino", "clip_v"):
147
+ if state.get(name) is not None:
148
+ blocks.append(state[name])
149
+ group_names.append(name)
150
+
151
+ # CLIP scores: always recompute from current prompts so label edits take effect
152
+ _, prompts, _ = _load_clip_labels()
153
+ image_vecs = state.get("_clip_image_vecs")
154
+ if image_vecs is not None and len(prompts) > 0:
155
+ thr = state.get("clip_threshold", CLIP_SCORE_THRESHOLD)
156
+ clip_s = _norm(_recompute_clip_scores(image_vecs, prompts, threshold=thr))
157
+ blocks.append(clip_s)
158
+ state["clip_s"] = clip_s
159
+ group_names.append("clip_s")
160
+ else:
161
+ state["clip_s"] = None
162
+ if len(prompts) == 0:
163
+ print(" clip_s: skipped (no labels defined)")
164
+
165
+ for name in ("pose", "faces"):
166
+ if state.get(name) is not None:
167
+ blocks.append(state[name])
168
+ group_names.append(name)
169
+
170
+ X = np.concatenate(blocks, axis=1)
171
+ print(f" t-SNE input: {X.shape} ({len(group_names)} groups: {group_names})")
172
+
173
+ X50 = PCA(n_components=min(50, X.shape[1]), random_state=42).fit_transform(X)
174
+ coords = np.array(
175
+ TSNE(n_components=2, perplexity=40, n_jobs=-1,
176
+ initialization="pca", random_state=42).fit(X50),
177
+ dtype=np.float32,
178
+ )
179
+ state["coords"] = coords
180
+ print(f" t-SNE done in {time.time() - t0:.1f}s")
181
+
182
+
183
+ def load_and_fit():
184
+ # ── CLIP (required β€” has filename + religion) ──────────────────────────────
185
+ print("Loading CLIP features ...")
186
+ clip_df = pd.read_parquet(CLIP_PARQUET)
187
+ base_df = clip_df[["filename", "religion"]].copy()
188
+
189
+ state["clip_v"] = _norm(np.stack(clip_df["clip_vector"].tolist()))
190
+ state["_clip_image_vecs"] = np.stack(clip_df["clip_vector"].tolist()).astype(np.float32)
191
+ state["clip_s"] = _norm(np.stack(clip_df["clip_scores"].tolist()))
192
+ print(f" CLIP: {len(clip_df)} images")
193
+
194
+ # ── DINO (required) ────────────────────────────────────────────────────────
195
+ print("Loading DINO features ...")
196
+ dino_df = pd.read_parquet(DINO_PARQUET)[["filename", "dino_vector"]]
197
+ base_df = base_df.merge(dino_df, on="filename", how="left")
198
+ state["dino"] = _norm(np.stack(base_df["dino_vector"].tolist()))
199
+ print(f" DINO: {state['dino'].shape[1]} dims")
200
+ base_df = base_df.drop(columns=["dino_vector"])
201
+
202
+ # ── Handcrafted (optional β€” from features.parquet when Timotej adds it) ───
203
+ for name in HC_SUBGROUPS:
204
+ state[name] = None
205
+ state[name + "_cols"] = []
206
+
207
+ if os.path.exists(FEATURES_PARQUET):
208
+ print("Loading handcrafted features ...")
209
+ hc_df = pd.read_parquet(FEATURES_PARQUET)
210
+ # HC parquet uses 'id' (metadata.csv row index) β€” resolve to filename via metadata
211
+ meta_ids = pd.read_csv(METADATA_CSV, dtype=str)[["id", "filename"]]
212
+ hc_df = hc_df.merge(meta_ids, on="id", how="left").drop(columns=["id"], errors="ignore")
213
+ # Drop non-feature columns before merge
214
+ hc_df = hc_df.drop(columns=["hc_fg_applied"], errors="ignore")
215
+ base_df = base_df.merge(hc_df, on="filename", how="left")
216
+ hc_cols = [c for c in base_df.columns if c.startswith("hc_")]
217
+ for name, prefixes in HC_SUBGROUPS.items():
218
+ cols = [c for c in hc_cols if any(c.startswith(p) for p in prefixes)]
219
+ if not cols:
220
+ print(f" {name}: no columns matched")
221
+ continue
222
+ state[name] = _norm(base_df[cols].fillna(0).values)
223
+ state[name + "_cols"] = cols
224
+ print(f" {name}: {len(cols)} dims")
225
+ else:
226
+ print(" handcrafted_gold.parquet not found β€” HC features skipped")
227
+
228
+ # ── Pose ───────────────────────────────────────────────────────────────────
229
+ state["pose"] = None
230
+ if os.path.exists(POSE_PARQUET):
231
+ pose_cols = ["filename", "pose_vector"]
232
+ _pdf = pd.read_parquet(POSE_PARQUET)
233
+ if "pose_detected" in _pdf.columns:
234
+ pose_cols.append("pose_detected")
235
+ pose_df = _pdf[pose_cols]
236
+ base_df = base_df.merge(pose_df, on="filename", how="left")
237
+ # pose_vector stays in base_df for the viz fast-reject; pose_detected too
238
+ pose_raw = np.stack(base_df["pose_vector"].tolist())
239
+ if pose_raw.std() > 0:
240
+ state["pose"] = _norm(pose_raw)
241
+ print(f" pose: {state['pose'].shape[1]} dims")
242
+
243
+ # ── Faces ──────────────────────────────────────────────────────────────────
244
+ state["faces"] = None
245
+ if os.path.exists(FACES_PARQUET):
246
+ # Load face_vector for features + face_detected/face_count for viz fast-reject
247
+ face_df = pd.read_parquet(FACES_PARQUET)[["filename", "face_vector", "face_detected", "face_count"]]
248
+ base_df = base_df.merge(face_df, on="filename", how="left")
249
+ if "face_vector" in base_df.columns:
250
+ face_raw = np.stack(base_df["face_vector"].tolist())
251
+ # Zero-face rows (no detection) would all collapse to the same point
252
+ # after StandardScaler β†’ PCA line artifact. Add tiny jitter so they
253
+ # spread naturally in t-SNE while still being distinct from real faces.
254
+ zero_mask = (face_raw == 0).all(axis=1)
255
+ if zero_mask.any():
256
+ rng = np.random.default_rng(42)
257
+ face_raw[zero_mask] += rng.normal(0, 1e-3, (zero_mask.sum(), face_raw.shape[1]))
258
+ if face_raw.std() > 0:
259
+ state["faces"] = _norm(face_raw)
260
+ n_det = int((~zero_mask).sum())
261
+ print(f" faces: {state['faces'].shape[1]} dims ({n_det} detected, {zero_mask.sum()} no-face jittered)")
262
+ # face_detected/face_count stay in base_df for viz fast-reject via _raw_df
263
+
264
+ # ── Metadata ───────────────────────────────────────────────────────────────
265
+ meta_cols = ["filename", "title", "artist", "year"]
266
+ try:
267
+ meta = pd.read_csv(METADATA_CSV, dtype=str)[meta_cols]
268
+ base_df = base_df.merge(meta, on="filename", how="left")
269
+ except Exception as e:
270
+ print(f" metadata.csv join skipped: {e}")
271
+
272
+ keep = ["filename", "religion", "sub_religion", "source", "title", "artist", "year"]
273
+ state["meta"] = base_df[[c for c in keep if c in base_df.columns]].reset_index(drop=True)
274
+ state["_raw_df"] = base_df.reset_index(drop=True)
275
+
276
+ # Try cache (keyed on parquet hashes + clip_labels.json)
277
+ cache_key = _cache_key()
278
+ if os.path.exists(CACHE_PATH):
279
+ try:
280
+ z = np.load(CACHE_PATH)
281
+ if str(z["__key__"]) == cache_key:
282
+ state["coords"] = z["coords"]
283
+ print(f"Loaded t-SNE from cache ({CACHE_PATH})")
284
+ print(f"Ready β€” {len(base_df)} images.")
285
+ return
286
+ else:
287
+ print(" cache key mismatch β€” will recompute t-SNE")
288
+ except Exception as e:
289
+ print(f" cache read failed ({e}) β€” will recompute")
290
+
291
+ _run_tsne()
292
+
293
+ np.savez_compressed(CACHE_PATH, __key__=cache_key, coords=state["coords"])
294
+ print(f" saved t-SNE cache β†’ {CACHE_PATH}")
295
+ print(f"Ready β€” {len(base_df)} images.")
296
+
297
+
298
+ @asynccontextmanager
299
+ async def lifespan(app: FastAPI):
300
+ load_and_fit()
301
+ yield
302
+
303
+
304
+ app = FastAPI(lifespan=lifespan)
305
+ app.mount("/static", StaticFiles(directory="app/static"), name="static")
306
+ app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images")
307
+
308
+
309
+ @app.get("/")
310
+ def root():
311
+ return FileResponse("app/static/index.html")
312
+
313
+
314
+ @app.get("/api/embeddings")
315
+ def embeddings():
316
+ meta = state["meta"]
317
+ coords = state["coords"]
318
+ rows = meta.to_dict("records")
319
+ for i, row in enumerate(rows):
320
+ row["x"] = float(coords[i, 0])
321
+ row["y"] = float(coords[i, 1])
322
+ for k, v in row.items():
323
+ if isinstance(v, float) and np.isnan(v):
324
+ row[k] = ""
325
+ return rows
326
+
327
+
328
+ class Weights(BaseModel):
329
+ hc_color: float = 1.0
330
+ hc_flat: float = 1.0
331
+ hc_geom: float = 1.0
332
+ hc_lines: float = 1.0
333
+ hc_light: float = 1.0
334
+ hc_symmetry: float = 1.0
335
+ dino: float = 1.0
336
+ clip_v: float = 1.0
337
+ clip_s: float = 1.0
338
+ pose: float = 1.0
339
+ faces: float = 1.0
340
+
341
+
342
+ @app.post("/api/reproject")
343
+ def reproject(w: Weights):
344
+ weights = w.model_dump()
345
+ t0 = time.time()
346
+
347
+ blocks = []
348
+ for name in [*HC_SUBGROUPS.keys(), "dino", "clip_v", "clip_s", "pose", "faces"]:
349
+ arr = state.get(name)
350
+ if arr is None:
351
+ continue
352
+ weight = weights.get(name, 1.0)
353
+ if weight <= 0:
354
+ continue
355
+ blocks.append(arr * weight)
356
+
357
+ if not blocks:
358
+ coords = state["coords"]
359
+ else:
360
+ X = np.concatenate(blocks, axis=1)
361
+ X50 = PCA(n_components=min(50, X.shape[1]), random_state=42).fit_transform(X)
362
+ coords = np.array(
363
+ TSNE(n_components=2, perplexity=40, n_jobs=-1,
364
+ initialization="pca", random_state=42).fit(X50),
365
+ dtype=np.float32,
366
+ )
367
+ state["coords"] = coords # keep in sync so clusters match
368
+
369
+ print(f" reproject done in {time.time() - t0:.1f}s")
370
+ return [{"x": float(x), "y": float(y)} for x, y in coords]
371
+
372
+
373
+ class ClipLabelEntry(BaseModel):
374
+ label: str
375
+ prompt: str
376
+
377
+ class ClipLabelsBody(BaseModel):
378
+ christianity: list[ClipLabelEntry] = []
379
+ islam: list[ClipLabelEntry] = []
380
+ buddhism: list[ClipLabelEntry] = []
381
+ hinduism: list[ClipLabelEntry] = []
382
+ general: list[ClipLabelEntry] = []
383
+
384
+
385
+ @app.get("/api/clip_labels")
386
+ def get_clip_labels():
387
+ with open(CLIP_LABELS_PATH) as f:
388
+ return json.load(f)
389
+
390
+
391
+ @app.post("/api/clip_labels")
392
+ def set_clip_labels(body: ClipLabelsBody):
393
+ raw = body.model_dump()
394
+ # Validate: every entry must have non-empty label and prompt
395
+ for religion, entries in raw.items():
396
+ for e in entries:
397
+ if not e["label"].strip() or not e["prompt"].strip():
398
+ raise HTTPException(400, f"Empty label or prompt in {religion}")
399
+
400
+ with open(CLIP_LABELS_PATH, "w") as f:
401
+ json.dump(raw, f, indent=2)
402
+
403
+ # Refresh global CLIP_LABELS list
404
+ global CLIP_LABELS, _CLIP_ATTRIBUTES
405
+ CLIP_LABELS, _CLIP_ATTRIBUTES, _ = _load_clip_labels()
406
+
407
+ t0 = time.time()
408
+ _run_tsne()
409
+ np.savez_compressed(CACHE_PATH, __key__=_cache_key(), coords=state["coords"])
410
+ coords = state["coords"]
411
+ return {
412
+ "ok": True,
413
+ "n_labels": len(CLIP_LABELS),
414
+ "duration_s": round(time.time() - t0, 1),
415
+ "points": [{"x": float(x), "y": float(y)} for x, y in coords],
416
+ }
417
+
418
+
419
+ @app.get("/api/clip_threshold")
420
+ def get_clip_threshold():
421
+ return {"threshold": state.get("clip_threshold", CLIP_SCORE_THRESHOLD)}
422
+
423
+
424
+ class ThresholdBody(BaseModel):
425
+ threshold: float
426
+
427
+
428
+ @app.post("/api/clip_threshold")
429
+ def set_clip_threshold(body: ThresholdBody):
430
+ thr = max(0.0, min(float(body.threshold), 1.0))
431
+ state["clip_threshold"] = thr
432
+ _run_tsne()
433
+ np.savez_compressed(CACHE_PATH, __key__=_cache_key(), coords=state["coords"])
434
+ coords = state["coords"]
435
+ return {"ok": True, "points": [{"x": float(x), "y": float(y)} for x, y in coords]}
436
+
437
+
438
+ @app.post("/api/recompute")
439
+ def recompute():
440
+ t0 = time.time()
441
+ _run_tsne()
442
+ np.savez_compressed(CACHE_PATH, __key__=_cache_key(), coords=state["coords"])
443
+ coords = state["coords"]
444
+ return {
445
+ "ok": True,
446
+ "duration_s": round(time.time() - t0, 1),
447
+ "points": [{"x": float(x), "y": float(y)} for x, y in coords],
448
+ }
449
+
450
+
451
+ def _smart_cluster(coords: np.ndarray) -> np.ndarray:
452
+ """Density-based clustering on the 2D blended layout β€” matches what the
453
+ user sees. HDBSCAN with EOM selection gives variable cluster sizes; small
454
+ `min_samples` + moderate `min_cluster_size` surfaces the visible islands
455
+ (including elongated/curved shapes) without fragmenting the dense cores.
456
+ Returns int labels where -1 = noise."""
457
+ n = len(coords)
458
+ min_cluster_size = max(30, n // 100)
459
+
460
+ hdb = HDBSCAN(
461
+ min_cluster_size=min_cluster_size,
462
+ min_samples=8,
463
+ cluster_selection_method="eom",
464
+ )
465
+ labels = hdb.fit_predict(coords)
466
+
467
+ n_clusters = int(labels.max()) + 1 if labels.max() >= 0 else 0
468
+ n_noise = int((labels == -1).sum())
469
+ print(f" HDBSCAN: {n_clusters} clusters, {n_noise}/{n} noise "
470
+ f"(min_cluster_size={min_cluster_size})")
471
+ return labels
472
+
473
+
474
+ def _grow_clusters(coords: np.ndarray, labels: np.ndarray,
475
+ factor: float = 3.0) -> np.ndarray:
476
+ """Halo pass: any orphan (noise) point whose nearest cluster member is
477
+ within `factor` Γ— the global median nearest-neighbour distance gets
478
+ absorbed by that cluster. Two iterations, so once-removed neighbours
479
+ can join via the newly-grown halo β€” but the threshold is frozen on
480
+ iteration 1 to prevent runaway growth across density gaps."""
481
+ if labels.max() < 0:
482
+ return labels
483
+ from sklearn.neighbors import NearestNeighbors
484
+
485
+ nn_global = NearestNeighbors(n_neighbors=2).fit(coords)
486
+ d_global, _ = nn_global.kneighbors(coords)
487
+ threshold = float(np.median(d_global[:, 1])) * factor
488
+
489
+ new_labels = labels.copy()
490
+ for it in range(2):
491
+ core_mask = new_labels >= 0
492
+ if not core_mask.any():
493
+ break
494
+ noise_idx = np.flatnonzero(new_labels == -1)
495
+ if len(noise_idx) == 0:
496
+ break
497
+
498
+ nn = NearestNeighbors(n_neighbors=1).fit(coords[core_mask])
499
+ core_labels = new_labels[core_mask]
500
+ dists, idxs = nn.kneighbors(coords[noise_idx])
501
+
502
+ grown = 0
503
+ for ni, dist, nearest in zip(noise_idx, dists[:, 0], idxs[:, 0]):
504
+ if dist <= threshold:
505
+ new_labels[ni] = int(core_labels[nearest])
506
+ grown += 1
507
+ print(f" halo pass {it+1}: pulled in {grown}/{len(noise_idx)} orphans "
508
+ f"(threshold={threshold:.4f})")
509
+ if grown == 0:
510
+ break
511
+ return new_labels
512
+
513
+
514
+ def _circumradius(a, b, c) -> float:
515
+ """Circumradius of triangle (a, b, c). Returns inf for degenerate ones."""
516
+ ax, ay = a; bx, by = b; cx, cy = c
517
+ d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
518
+ if abs(d) < 1e-12:
519
+ return float("inf")
520
+ a2 = ax * ax + ay * ay
521
+ b2 = bx * bx + by * by
522
+ c2 = cx * cx + cy * cy
523
+ ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d
524
+ uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d
525
+ return float(np.hypot(ux - ax, uy - ay))
526
+
527
+
528
+ def _alpha_ring(pts: np.ndarray, alpha_mult: float = 2.0):
529
+ """Concave-hull outline via alpha-shape (Delaunay triangles with small
530
+ circumradius). Returns a closed ring of [x, y] vertices, or None on
531
+ failure β€” caller should fall back to convex hull."""
532
+ if len(pts) < 4:
533
+ return None
534
+ try:
535
+ tri = Delaunay(pts)
536
+ except Exception:
537
+ return None
538
+
539
+ radii = np.array([_circumradius(*pts[s]) for s in tri.simplices])
540
+ finite = radii[np.isfinite(radii)]
541
+ if len(finite) == 0:
542
+ return None
543
+ threshold = float(np.median(finite)) * alpha_mult
544
+
545
+ edge_count: dict[tuple[int, int], int] = {}
546
+ for ti, s in enumerate(tri.simplices):
547
+ if radii[ti] > threshold:
548
+ continue
549
+ for i, j in ((0, 1), (1, 2), (2, 0)):
550
+ a, b = int(s[i]), int(s[j])
551
+ key = (a, b) if a < b else (b, a)
552
+ edge_count[key] = edge_count.get(key, 0) + 1
553
+ boundary = [e for e, c in edge_count.items() if c == 1]
554
+ if len(boundary) < 3:
555
+ return None
556
+
557
+ # Walk the boundary edges into rings; return the longest ring.
558
+ adj: dict[int, list[int]] = {}
559
+ for a, b in boundary:
560
+ adj.setdefault(a, []).append(b)
561
+ adj.setdefault(b, []).append(a)
562
+
563
+ used: set[tuple[int, int]] = set()
564
+ rings: list[list[int]] = []
565
+ for start in list(adj):
566
+ # find any unused edge starting at this vertex
567
+ for first in adj[start]:
568
+ key = (start, first) if start < first else (first, start)
569
+ if key in used:
570
+ continue
571
+ used.add(key)
572
+ path = [start, first]
573
+ cur, prev = first, start
574
+ while cur != start:
575
+ nxt = None
576
+ for n in adj.get(cur, ()):
577
+ if n == prev:
578
+ continue
579
+ k = (cur, n) if cur < n else (n, cur)
580
+ if k in used:
581
+ continue
582
+ nxt = n
583
+ used.add(k)
584
+ break
585
+ if nxt is None:
586
+ break
587
+ path.append(nxt)
588
+ prev, cur = cur, nxt
589
+ if len(path) >= 4 and path[-1] == start:
590
+ rings.append(path)
591
+
592
+ if not rings:
593
+ return None
594
+ rings.sort(key=len, reverse=True)
595
+ return [pts[i].tolist() for i in rings[0]]
596
+
597
+
598
+ def _core_hull(pts: np.ndarray, pct: float = 75.0):
599
+ """Convex hull using only points within the pct-th percentile distance
600
+ from the cluster centroid, avoiding outlier-stretched polygons."""
601
+ centroid = pts.mean(axis=0)
602
+ dists = np.linalg.norm(pts - centroid, axis=1)
603
+ threshold = np.percentile(dists, pct)
604
+ core = pts[dists <= threshold]
605
+ if len(core) < 3:
606
+ core = pts # fall back to all points
607
+ try:
608
+ hull = ConvexHull(core)
609
+ verts = core[hull.vertices].tolist()
610
+ verts.append(verts[0])
611
+ return verts
612
+ except Exception:
613
+ mn, mx = core.min(axis=0), core.max(axis=0)
614
+ return [[mn[0],mn[1]],[mx[0],mn[1]],[mx[0],mx[1]],[mn[0],mn[1]]]
615
+
616
+
617
+ def _cluster_outline(pts: np.ndarray):
618
+ """Prefer alpha-shape (concave); fall back to convex hull if it fails."""
619
+ ring = _alpha_ring(pts)
620
+ if ring is not None and len(ring) >= 4:
621
+ return ring
622
+ return _core_hull(pts)
623
+
624
+
625
+ # ── Per-group cluster label generators ───────────────────────────────────────
626
+
627
+ def _label_color(mask: np.ndarray) -> str | None:
628
+ df = state["_raw_df"]
629
+ cols = state.get("hc_color_cols", [])
630
+ hue_cols = [c for c in cols if "avg_hue" in c]
631
+ sat_cols = [c for c in cols if "avg_sat" in c]
632
+ parts = []
633
+ if hue_cols:
634
+ hue = df[hue_cols[0]].values[mask].mean()
635
+ if hue < 30 or hue > 330:
636
+ parts.append("warm reds/oranges")
637
+ elif hue < 90:
638
+ parts.append("yellows/greens")
639
+ elif hue < 180:
640
+ parts.append("cool greens/cyans")
641
+ else:
642
+ parts.append("blues/purples")
643
+ if sat_cols:
644
+ sat = df[sat_cols[0]].values[mask].mean()
645
+ parts.append("vibrant" if sat > 0.45 else "muted/desaturated")
646
+ return " Β· ".join(parts) if parts else None
647
+
648
+
649
+ def _label_flat(mask: np.ndarray) -> str | None:
650
+ df = state["_raw_df"]
651
+ cols = [c for c in state.get("hc_flat_cols", []) if "flatness" in c]
652
+ if not cols:
653
+ return None
654
+ val = df[cols].values[mask].mean()
655
+ # higher flatness std β†’ more painterly; lower β†’ flatter fills
656
+ return "flat color fills" if val < 0.08 else "painterly gradients"
657
+
658
+
659
+ def _label_geom(mask: np.ndarray) -> str | None:
660
+ df = state["_raw_df"]
661
+ cols = state.get("hc_geom_cols", [])
662
+ fft_cols = [c for c in cols if "fft_band" in c]
663
+ lbp_cols = [c for c in cols if "lbp_" in c]
664
+ parts = []
665
+ if fft_cols:
666
+ bands = df[fft_cols].values[mask].mean(axis=0)
667
+ mid = bands[2:5].sum()
668
+ low = bands[:2].sum() + 1e-10
669
+ if mid / low > 1.5:
670
+ parts.append("repeating geometric patterns")
671
+ if lbp_cols:
672
+ lbp = df[lbp_cols].values[mask].mean(axis=0)
673
+ # entropy as proxy for texture complexity
674
+ p = lbp / (lbp.sum() + 1e-10)
675
+ entropy = float(-np.sum(p * np.log(p + 1e-10)))
676
+ parts.append("complex texture" if entropy > 3.5 else "uniform texture")
677
+ return " Β· ".join(parts) if parts else None
678
+
679
+
680
+ def _label_lines(mask: np.ndarray) -> str | None:
681
+ df = state["_raw_df"]
682
+ cols = state.get("hc_lines_cols", [])
683
+ count_cols = [c for c in cols if "hough_count" in c]
684
+ ratio_cols = [c for c in cols if "straight_ratio" in c]
685
+ angle_cols = [c for c in cols if "angle_hist_" in c]
686
+ parts = []
687
+ if count_cols and ratio_cols:
688
+ count = df[count_cols[0]].values[mask].mean()
689
+ ratio = df[ratio_cols[0]].values[mask].mean()
690
+ if count > 5 and ratio > 0.4:
691
+ parts.append("strong straight lines")
692
+ elif ratio < 0.2:
693
+ parts.append("curved/organic lines")
694
+ if angle_cols:
695
+ angles = df[angle_cols].values[mask].mean(axis=0)
696
+ dom = int(np.argmax(angles))
697
+ direction = ["horizontal","diagonalβ†—","vertical","diagonalβ†˜",
698
+ "horizontal","diagonalβ†—","vertical","diagonalβ†˜"]
699
+ parts.append(f"dominant {direction[dom]} lines")
700
+ return " Β· ".join(parts) if parts else None
701
+
702
+
703
+ def _label_light(mask: np.ndarray) -> str | None:
704
+ df = state["_raw_df"]
705
+ cols = state.get("hc_light_cols", [])
706
+ bright_cols = [c for c in cols if "brightness" in c]
707
+ contrast_cols= [c for c in cols if "contrast" in c]
708
+ dark_cols = [c for c in cols if "darkness" in c]
709
+ parts = []
710
+ if bright_cols:
711
+ b = df[bright_cols[0]].values[mask].mean()
712
+ if b > 0.6: parts.append("bright")
713
+ elif b < 0.3: parts.append("dark")
714
+ if dark_cols:
715
+ d = df[dark_cols[0]].values[mask].mean()
716
+ if d > 0.5: parts.append("heavy shadows")
717
+ if contrast_cols:
718
+ c = df[contrast_cols[0]].values[mask].mean()
719
+ parts.append("high contrast" if c > 0.5 else "low contrast")
720
+ return " Β· ".join(parts) if parts else None
721
+
722
+
723
+ def _label_symmetry(mask: np.ndarray) -> str | None:
724
+ df = state["_raw_df"]
725
+ cols = state.get("hc_symmetry_cols", [])
726
+ lr_cols = [c for c in cols if "sym_lr" in c or "sym_h" in c]
727
+ tb_cols = [c for c in cols if "sym_tb" in c or "sym_v" in c]
728
+ parts = []
729
+ if lr_cols:
730
+ s = df[lr_cols[0]].values[mask].mean()
731
+ parts.append("left-right symmetric" if s > 0.85 else "asymmetric")
732
+ if tb_cols:
733
+ s = df[tb_cols[0]].values[mask].mean()
734
+ if s > 0.85: parts.append("top-bottom symmetric")
735
+ return " Β· ".join(parts) if parts else None
736
+
737
+
738
+ def _label_religion(mask: np.ndarray) -> str | None:
739
+ meta = state["meta"]
740
+ if "religion" not in meta.columns:
741
+ return None
742
+ counts = meta["religion"].values[mask]
743
+ unique, cnts = np.unique(counts, return_counts=True)
744
+ top_frac = cnts.max() / cnts.sum()
745
+ if top_frac > 0.65:
746
+ return f"predominantly {unique[cnts.argmax()]}"
747
+ return None
748
+
749
+
750
+ def _label_clip(mask: np.ndarray) -> str | None:
751
+ clip_s = state.get("clip_s")
752
+ if clip_s is None:
753
+ return None
754
+ dev = clip_s[mask].mean(axis=0)
755
+ order = np.argsort(dev)[::-1]
756
+ labels = CLIP_LABELS # always read the global (updated on label save)
757
+ picks = [i for i in order if i < len(labels) and dev[i] > 0.05][:2]
758
+ if not picks:
759
+ return None
760
+ return " Β· ".join(labels[i] for i in picks)
761
+
762
+
763
+ def _label_pose(mask: np.ndarray) -> str | None:
764
+ df = state["_raw_df"]
765
+ if "pose_detected" not in df.columns:
766
+ return None
767
+ ratio = df["pose_detected"].values[mask].mean()
768
+ if ratio > 0.6:
769
+ return "figurative (bodies present)"
770
+ if ratio < 0.2:
771
+ return "non-figurative"
772
+ return None
773
+
774
+
775
+ def _label_faces(mask: np.ndarray) -> str | None:
776
+ df = state["_raw_df"]
777
+ face_cols = [c for c in FACE_FEATURE_COLS if c in df.columns]
778
+ if not face_cols:
779
+ return None
780
+ count_col = [c for c in face_cols if "face_count" in c]
781
+ if not count_col:
782
+ return None
783
+ avg = df[count_col[0]].values[mask].mean()
784
+ if avg >= 3:
785
+ return "crowd/group scenes"
786
+ if avg >= 1:
787
+ return "portrait/close-up"
788
+ return "no faces"
789
+
790
+
791
+ # Map each group name to its labeller (receives boolean mask, returns str|None)
792
+ _GROUP_LABELLERS = {
793
+ "hc_color": _label_color,
794
+ "hc_flat": _label_flat,
795
+ "hc_geom": _label_geom,
796
+ "hc_lines": _label_lines,
797
+ "hc_light": _label_light,
798
+ "hc_symmetry": _label_symmetry,
799
+ "dino": _label_religion,
800
+ "clip_v": _label_religion,
801
+ "clip_s": _label_clip,
802
+ "pose": _label_pose,
803
+ "faces": _label_faces,
804
+ }
805
+
806
+
807
+ def _cluster_label(mask: np.ndarray) -> str:
808
+ """Collect labels from all active labellers, take top 3 unique."""
809
+ scores = []
810
+ for name, labeller in _GROUP_LABELLERS.items():
811
+ if state.get(name) is None:
812
+ continue
813
+ label = labeller(mask)
814
+ if label:
815
+ scores.append(label)
816
+ seen, picks = set(), []
817
+ for lbl in scores:
818
+ if lbl not in seen:
819
+ seen.add(lbl)
820
+ picks.append(lbl)
821
+ if len(picks) == 3:
822
+ break
823
+ return " Β· ".join(picks) if picks else "cluster"
824
+
825
+
826
+ _CLUSTER_GROUP_SPECS = [
827
+ ("hc_color", "Color", _label_color),
828
+ ("hc_flat", "Flatness", _label_flat),
829
+ ("hc_geom", "Geometry", _label_geom),
830
+ ("hc_lines", "Lines", _label_lines),
831
+ ("hc_light", "Light", _label_light),
832
+ ("hc_symmetry", "Symmetry", _label_symmetry),
833
+ ("clip_s", "CLIP attr", _label_clip),
834
+ ("pose", "Pose", _label_pose),
835
+ ("faces", "Faces", _label_faces),
836
+ ]
837
+
838
+
839
+ @app.post("/api/cluster")
840
+ def cluster():
841
+ coords = state["coords"]
842
+ labels = _smart_cluster(coords)
843
+ labels = _grow_clusters(coords, labels)
844
+
845
+ out = []
846
+ if labels.max() < 0:
847
+ return {"clusters": out, "point_labels": [-1] * len(coords)}
848
+
849
+ meta = state["meta"]
850
+ has_religion = "religion" in meta.columns
851
+
852
+ for cid in range(int(labels.max()) + 1):
853
+ mask = labels == cid
854
+ pts = coords[mask]
855
+ if len(pts) < 3:
856
+ continue
857
+
858
+ verts = _cluster_outline(pts)
859
+ centroid = pts.mean(axis=0)
860
+ label = _cluster_label(mask)
861
+
862
+ religion_counts = {}
863
+ if has_religion:
864
+ vals, counts = np.unique(meta["religion"].values[mask], return_counts=True)
865
+ religion_counts = {str(v): int(c) for v, c in zip(vals, counts)}
866
+
867
+ descriptions = []
868
+ for _, human_name, labeller in _CLUSTER_GROUP_SPECS:
869
+ lbl = labeller(mask)
870
+ if lbl:
871
+ descriptions.append({"group": human_name, "label": lbl})
872
+
873
+ member_idx = np.flatnonzero(mask)
874
+ rng = np.random.default_rng(cid)
875
+ k = int(min(6, len(member_idx)))
876
+ sample_pick = rng.choice(member_idx, size=k, replace=False)
877
+ samples = [str(meta.iloc[int(i)]["filename"]) for i in sample_pick]
878
+
879
+ out.append({
880
+ "id": cid,
881
+ "hull": verts,
882
+ "cx": float(centroid[0]),
883
+ "cy": float(centroid[1]),
884
+ "size": int(mask.sum()),
885
+ "label": label,
886
+ "religion_counts": religion_counts,
887
+ "descriptions": descriptions,
888
+ "samples": samples,
889
+ })
890
+ # point_labels lets the frontend route any point-click to its cluster
891
+ # while in cluster mode (-1 = noise β†’ not clickable).
892
+ return {"clusters": out, "point_labels": [int(x) for x in labels]}
893
+
894
+
895
+ # ── Subset stats endpoint (overview / lasso / zoom) ───────────────────────────
896
+
897
+ class SubsetReq(BaseModel):
898
+ indices: list[int] | None = None # None or empty β†’ all points
899
+
900
+
901
+ @app.post("/api/subset_info")
902
+ def subset_info(req: SubsetReq):
903
+ meta = state["meta"]
904
+ total = len(meta)
905
+
906
+ if not req.indices:
907
+ mask = np.ones(total, dtype=bool)
908
+ else:
909
+ mask = np.zeros(total, dtype=bool)
910
+ valid = [i for i in req.indices if 0 <= i < total]
911
+ if valid:
912
+ mask[valid] = True
913
+
914
+ n = int(mask.sum())
915
+ if n == 0:
916
+ return {"count": 0, "religion_counts": {}, "descriptions": [], "samples": []}
917
+
918
+ religion_counts = {}
919
+ if "religion" in meta.columns:
920
+ vals, counts = np.unique(meta["religion"].values[mask], return_counts=True)
921
+ religion_counts = {str(v): int(c) for v, c in zip(vals, counts)}
922
+
923
+ descriptions = []
924
+ for _, human_name, labeller in _CLUSTER_GROUP_SPECS:
925
+ lbl = labeller(mask)
926
+ if lbl:
927
+ descriptions.append({"group": human_name, "label": lbl})
928
+
929
+ member_idx = np.flatnonzero(mask)
930
+ rng = np.random.default_rng(int(n)) # deterministic by subset size
931
+ k = int(min(6, n))
932
+ pick = rng.choice(member_idx, size=k, replace=False)
933
+ samples = [str(meta.iloc[int(i)]["filename"]) for i in pick]
934
+
935
+ return {
936
+ "count": n,
937
+ "total": total,
938
+ "religion_counts": religion_counts,
939
+ "descriptions": descriptions,
940
+ "samples": samples,
941
+ }
942
+
943
+
944
+ # ── Per-image analysis endpoint ───────────────────────────────────────────────
945
+
946
+ @app.get("/api/image_info/{filename:path}")
947
+ def image_info(filename: str):
948
+ df = state["_raw_df"]
949
+ mask_series = df["filename"] == filename
950
+ if not mask_series.any():
951
+ return {}
952
+ mask = mask_series.values # numpy bool array, same length as coords
953
+
954
+ group_specs = [
955
+ ("hc_color", "Color", _label_color),
956
+ ("hc_flat", "Flatness", _label_flat),
957
+ ("hc_geom", "Geometry", _label_geom),
958
+ ("hc_lines", "Lines", _label_lines),
959
+ ("hc_light", "Light", _label_light),
960
+ ("hc_symmetry", "Symmetry", _label_symmetry),
961
+ ("clip_s", "CLIP attr", _label_clip),
962
+ ("pose", "Pose", _label_pose),
963
+ ("faces", "Faces", _label_faces),
964
+ ]
965
+ descriptions = []
966
+ for _, human_name, labeller in group_specs:
967
+ lbl = labeller(mask)
968
+ if lbl:
969
+ descriptions.append({"group": human_name, "label": lbl})
970
+
971
+ # Key scalar values (raw, un-normalised)
972
+ idx = mask_series[mask_series].index[0]
973
+ scalars = {}
974
+ wanted = {
975
+ "hc_brightness": "Brightness",
976
+ "hc_contrast": "Contrast",
977
+ "hc_darkness": "Darkness",
978
+ "hc_edge_density": "Edge density",
979
+ "hc_avg_hue": "Avg hue",
980
+ "hc_avg_sat": "Avg saturation",
981
+ "hc_straight_ratio":"Straight ratio",
982
+ "hc_hough_count": "Hough lines",
983
+ "face_count": "Faces",
984
+ "face_coverage": "Face coverage",
985
+ "pose_detected": "Pose detected",
986
+ }
987
+ for col, name in wanted.items():
988
+ if col in df.columns:
989
+ val = df.loc[idx, col]
990
+ try:
991
+ fval = float(val)
992
+ if not np.isnan(fval):
993
+ scalars[name] = round(fval, 3)
994
+ except (TypeError, ValueError):
995
+ pass
996
+
997
+ return {"descriptions": descriptions, "scalars": scalars}
998
+
999
+
1000
+ # ── Visualisation image endpoints ─────────────────────────────────────────────
1001
+
1002
+ YUNET_MODEL_PATH = os.path.join("features", "models", "face_detection_yunet_2023mar.onnx")
1003
+ _yolo_pose_model = None
1004
+
1005
+
1006
+ def _get_yolo():
1007
+ global _yolo_pose_model
1008
+ if _yolo_pose_model is not None:
1009
+ return _yolo_pose_model
1010
+ try:
1011
+ from ultralytics import YOLO
1012
+ _yolo_pose_model = YOLO("yolov8m-pose.pt")
1013
+ print(" YOLOv8-Pose ready (viz)")
1014
+ except Exception as e:
1015
+ print(f" YOLOv8 init failed: {e}")
1016
+ return _yolo_pose_model
1017
+
1018
+
1019
+ def _load_image(filename: str):
1020
+ path = os.path.join(IMAGES_DIR, filename)
1021
+ img = cv2.imread(path)
1022
+ return img # BGR or None
1023
+
1024
+
1025
+ def _encode_jpg(img: np.ndarray, max_px: int = 800) -> bytes:
1026
+ h, w = img.shape[:2]
1027
+ scale = min(max_px / w, max_px / h, 1.0)
1028
+ if scale < 1.0:
1029
+ img = cv2.resize(img, (int(w * scale), int(h * scale)),
1030
+ interpolation=cv2.INTER_AREA)
1031
+ _, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 82])
1032
+ return buf.tobytes()
1033
+
1034
+
1035
+ @app.get("/api/viz/canny/{filename:path}")
1036
+ def viz_canny(filename: str):
1037
+ img = _load_image(filename)
1038
+ if img is None:
1039
+ return Response(status_code=404)
1040
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
1041
+ gray = cv2.GaussianBlur(gray, (5, 5), 0)
1042
+ edges = cv2.Canny(gray, 80, 200)
1043
+ out = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
1044
+ return Response(content=_encode_jpg(out), media_type="image/jpeg")
1045
+
1046
+
1047
+ @app.get("/api/viz/pose/{filename:path}")
1048
+ def viz_pose(filename: str):
1049
+ # Fast-reject using stored pose_detected flag
1050
+ df = state["_raw_df"]
1051
+ rows = df[df["filename"] == filename]
1052
+ if rows.empty:
1053
+ return Response(status_code=404)
1054
+ if "pose_detected" in df.columns and not bool(rows.iloc[0].get("pose_detected", 1)):
1055
+ return Response(status_code=404)
1056
+
1057
+ img = _load_image(filename)
1058
+ if img is None:
1059
+ return Response(status_code=404)
1060
+
1061
+ model = _get_yolo()
1062
+ if model is None:
1063
+ return Response(status_code=404)
1064
+
1065
+ # Re-run YOLOv8 on the original image β€” same as preview_pose.py
1066
+ results = model(img, verbose=False, conf=0.15)
1067
+ annotated = results[0].plot(kpt_radius=4, line_width=2) # BGR
1068
+
1069
+ if not any(r.keypoints is not None and len(r.keypoints) > 0 for r in results):
1070
+ return Response(status_code=404)
1071
+
1072
+ return Response(content=_encode_jpg(annotated), media_type="image/jpeg")
1073
+
1074
+
1075
+ # YuNet landmark colour order: R.eye, L.eye, nose, R.mouth, L.mouth
1076
+ _LM_COLORS = [(0, 255, 0), (0, 0, 255), (255, 0, 0), (0, 255, 255), (255, 255, 0)]
1077
+
1078
+
1079
+ @app.get("/api/viz/faces/{filename:path}")
1080
+ def viz_faces(filename: str):
1081
+ # Fast-reject using stored face_detected flag
1082
+ df = state["_raw_df"]
1083
+ rows = df[df["filename"] == filename]
1084
+ if rows.empty:
1085
+ return Response(status_code=404)
1086
+ if "face_detected" in df.columns and not bool(rows.iloc[0].get("face_detected", 1)):
1087
+ return Response(status_code=404)
1088
+
1089
+ if not os.path.exists(YUNET_MODEL_PATH):
1090
+ return Response(status_code=404)
1091
+
1092
+ img = _load_image(filename)
1093
+ if img is None:
1094
+ return Response(status_code=404)
1095
+
1096
+ h, w = img.shape[:2]
1097
+ # YuNet must be initialised at the actual image size β€” same as preview_faces.py
1098
+ det = cv2.FaceDetectorYN.create(
1099
+ YUNET_MODEL_PATH, "", (w, h),
1100
+ score_threshold=0.7, nms_threshold=0.3,
1101
+ )
1102
+ _, faces = det.detect(img)
1103
+ if faces is None or len(faces) == 0:
1104
+ return Response(status_code=404)
1105
+
1106
+ # Desaturate background so boxes pop
1107
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
1108
+ bg = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
1109
+ bg = cv2.addWeighted(bg, 0.82, img, 0.18, 0)
1110
+
1111
+ for f in faces:
1112
+ x, y, fw, fh = int(f[0]), int(f[1]), int(f[2]), int(f[3])
1113
+ cv2.rectangle(bg, (x, y), (x + fw, y + fh), (60, 200, 255), 2, cv2.LINE_AA)
1114
+ for k in range(5):
1115
+ lx, ly = int(f[4 + k * 2]), int(f[5 + k * 2])
1116
+ cv2.circle(bg, (lx, ly), 3, _LM_COLORS[k], -1, cv2.LINE_AA)
1117
+
1118
+ return Response(content=_encode_jpg(bg), media_type="image/jpeg")
app/static/index.html ADDED
@@ -0,0 +1,1532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Religious Art Space</title>
6
+ <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ body {
11
+ background: #111;
12
+ color: #ddd;
13
+ font-family: system-ui, sans-serif;
14
+ display: flex;
15
+ height: 100vh;
16
+ overflow: hidden;
17
+ }
18
+
19
+ #plot-wrap { flex: 1; min-width: 0; }
20
+ /* Win against Plotly's own drag-layer cursor (it sets crosshair). */
21
+ #plot-wrap.clickable .nsewdrag,
22
+ #plot-wrap.clickable .cursor-crosshair,
23
+ #plot-wrap.clickable .cursor-pointer { cursor: pointer !important; }
24
+
25
+ #img-panel {
26
+ width: 400px;
27
+ background: #1c1c1c;
28
+ border-right: 1px solid #2a2a2a;
29
+ padding: 18px 16px;
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: 10px;
33
+ overflow-y: auto;
34
+ }
35
+ #img-empty {
36
+ color: #555;
37
+ font-size: 13px;
38
+ padding: 40px 0;
39
+ text-align: center;
40
+ }
41
+ #img-content, #cluster-content, #summary-content { display: none; }
42
+ body.has-image #summary-content { display: none; }
43
+ body.has-image #img-content { display: flex; flex-direction: column; gap: 0; }
44
+ body.has-cluster #summary-content { display: none; }
45
+ body.has-cluster #cluster-content { display: flex; flex-direction: column; gap: 0; }
46
+ /* Default state: nothing has-* β†’ show the overview/subset summary panel. */
47
+ body:not(.has-image):not(.has-cluster) #summary-content {
48
+ display: flex; flex-direction: column; gap: 0;
49
+ }
50
+ #img-empty { display: none; } /* replaced by #summary-content */
51
+
52
+ /* Persistent header β€” Clear button is visible in every panel state. */
53
+ #panel-header {
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: space-between;
57
+ gap: 8px;
58
+ margin-bottom: 10px;
59
+ }
60
+ #panel-header h2 { margin-bottom: 0; }
61
+ #btn-clear {
62
+ background: #222;
63
+ border: 1px solid #333;
64
+ color: #aaa;
65
+ font-size: 11px;
66
+ padding: 4px 10px;
67
+ border-radius: 4px;
68
+ cursor: pointer;
69
+ white-space: nowrap;
70
+ }
71
+ #btn-clear:hover { background: #2a2a2a; color: #ddd; }
72
+
73
+ /* ── Summary panel ── */
74
+ #summary-title {
75
+ font-size: 15px;
76
+ font-weight: 600;
77
+ color: #eee;
78
+ line-height: 1.4;
79
+ margin-bottom: 6px;
80
+ }
81
+ #summary-subtitle {
82
+ font-size: 12px;
83
+ color: #888;
84
+ margin-bottom: 12px;
85
+ }
86
+
87
+ /* ── Cluster panel ── */
88
+ #cluster-title {
89
+ font-size: 15px;
90
+ font-weight: 600;
91
+ color: #eee;
92
+ line-height: 1.4;
93
+ margin-bottom: 6px;
94
+ }
95
+ #cluster-subtitle {
96
+ font-size: 12px;
97
+ color: #888;
98
+ margin-bottom: 12px;
99
+ }
100
+ .religion-bars {
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: 4px;
104
+ margin-bottom: 4px;
105
+ }
106
+ .religion-bar {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 8px;
110
+ font-size: 11px;
111
+ color: #aaa;
112
+ }
113
+ .religion-bar .rname { flex: 0 0 78px; text-transform: capitalize; }
114
+ .religion-bar .rtrack {
115
+ flex: 1;
116
+ height: 6px;
117
+ background: #222;
118
+ border-radius: 3px;
119
+ overflow: hidden;
120
+ }
121
+ .religion-bar .rfill { height: 100%; border-radius: 3px; }
122
+ .religion-bar .rpct { flex: 0 0 36px; text-align: right; color: #888; font-variant-numeric: tabular-nums; }
123
+
124
+ .sample-grid {
125
+ display: grid;
126
+ grid-template-columns: repeat(3, 1fr);
127
+ gap: 6px;
128
+ margin-top: 4px;
129
+ }
130
+ .sample-grid img {
131
+ width: 100%;
132
+ aspect-ratio: 1;
133
+ object-fit: cover;
134
+ border-radius: 3px;
135
+ background: #111;
136
+ cursor: pointer;
137
+ border: 1px solid #222;
138
+ }
139
+ .sample-grid img:hover { border-color: #555; }
140
+
141
+ #sidebar {
142
+ width: 260px;
143
+ background: #1c1c1c;
144
+ border-left: 1px solid #2a2a2a;
145
+ padding: 18px 14px;
146
+ display: flex;
147
+ flex-direction: column;
148
+ gap: 20px;
149
+ overflow-y: auto;
150
+ }
151
+
152
+ #sidebar-header {
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: space-between;
156
+ gap: 8px;
157
+ }
158
+ #lang-toggle { display: flex; gap: 1px; border-radius: 4px; overflow: hidden; }
159
+ .lang-btn {
160
+ background: #222;
161
+ border: 1px solid #333;
162
+ color: #888;
163
+ font-size: 10px;
164
+ font-weight: 600;
165
+ padding: 3px 7px;
166
+ cursor: pointer;
167
+ letter-spacing: .5px;
168
+ }
169
+ .lang-btn.active { background: #7c3aed22; border-color: #7c3aed; color: #a78bfa; }
170
+ .lang-btn:hover:not(.active) { background: #2a2a2a; color: #ddd; }
171
+
172
+ h1 { font-size: 13px; font-weight: 600; color: #fff; letter-spacing: .5px; }
173
+ h2 { font-size: 11px; color: #777; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
174
+
175
+ .slider-row { margin-bottom: 10px; }
176
+ .slider-row label {
177
+ display: flex;
178
+ justify-content: space-between;
179
+ font-size: 12px;
180
+ color: #bbb;
181
+ margin-bottom: 4px;
182
+ }
183
+ .slider-row label span.val { color: #fff; font-variant-numeric: tabular-nums; }
184
+ input[type=range] {
185
+ -webkit-appearance: none;
186
+ width: 100%;
187
+ height: 4px;
188
+ border-radius: 2px;
189
+ background: #333;
190
+ outline: none;
191
+ cursor: pointer;
192
+ }
193
+ input[type=range]::-webkit-slider-thumb {
194
+ -webkit-appearance: none;
195
+ width: 14px; height: 14px;
196
+ border-radius: 50%;
197
+ background: #888;
198
+ cursor: pointer;
199
+ }
200
+
201
+ #img-title {
202
+ font-size: 15px;
203
+ font-weight: 600;
204
+ color: #eee;
205
+ line-height: 1.4;
206
+ margin-bottom: 8px;
207
+ }
208
+ .religion-tag {
209
+ display: inline-block;
210
+ padding: 3px 12px;
211
+ border-radius: 10px;
212
+ font-size: 12px;
213
+ font-weight: 600;
214
+ margin-bottom: 12px;
215
+ color: #fff;
216
+ }
217
+ #img-panel img#img-preview { width: 100%; border-radius: 4px; display: block; }
218
+ .meta-line { font-size: 13px; color: #666; line-height: 1.8; margin-top: 10px; }
219
+
220
+ /* ── Feature analysis ── */
221
+ .section-divider {
222
+ font-size: 11px;
223
+ color: #444;
224
+ text-transform: uppercase;
225
+ letter-spacing: 1px;
226
+ border-top: 1px solid #2a2a2a;
227
+ padding-top: 12px;
228
+ margin-top: 16px;
229
+ margin-bottom: 10px;
230
+ }
231
+ .feat-row {
232
+ display: flex;
233
+ gap: 10px;
234
+ margin-bottom: 7px;
235
+ align-items: baseline;
236
+ }
237
+ .feat-key {
238
+ font-size: 11px;
239
+ color: #555;
240
+ flex: 0 0 76px;
241
+ text-transform: uppercase;
242
+ letter-spacing: .5px;
243
+ }
244
+ .feat-val {
245
+ font-size: 13px;
246
+ color: #bbb;
247
+ flex: 1;
248
+ line-height: 1.4;
249
+ }
250
+ .scalars-row {
251
+ display: flex;
252
+ flex-wrap: wrap;
253
+ gap: 6px;
254
+ margin-top: 10px;
255
+ }
256
+ .scalar-chip {
257
+ background: #222;
258
+ border: 1px solid #2e2e2e;
259
+ border-radius: 4px;
260
+ padding: 3px 8px;
261
+ font-size: 11px;
262
+ color: #777;
263
+ }
264
+ .scalar-chip b { color: #aaa; font-weight: 500; }
265
+
266
+ /* ── Viz thumbnails ── */
267
+ .viz-row {
268
+ display: flex;
269
+ flex-direction: column;
270
+ gap: 14px;
271
+ margin-top: 4px;
272
+ }
273
+ .viz-thumb-wrap { width: 100%; }
274
+ .viz-thumb {
275
+ width: 100%;
276
+ border-radius: 4px;
277
+ display: block;
278
+ background: #111;
279
+ }
280
+ .viz-label {
281
+ font-size: 11px;
282
+ color: #555;
283
+ margin-top: 5px;
284
+ letter-spacing: .3px;
285
+ }
286
+
287
+ .dev-btn {
288
+ width: 100%;
289
+ padding: 7px;
290
+ border-radius: 4px;
291
+ border: none;
292
+ font-size: 12px;
293
+ cursor: pointer;
294
+ margin-bottom: 6px;
295
+ }
296
+ #loading {
297
+ display: none;
298
+ position: fixed;
299
+ bottom: 18px; left: 50%;
300
+ transform: translateX(-50%);
301
+ background: rgba(0,0,0,.85);
302
+ border: 1px solid #333;
303
+ padding: 8px 20px;
304
+ border-radius: 20px;
305
+ font-size: 12px;
306
+ z-index: 99;
307
+ }
308
+ </style>
309
+ </head>
310
+ <body>
311
+
312
+ <div id="img-panel">
313
+ <div id="panel-header">
314
+ <h2 data-i18n="panel_overview">Overview</h2>
315
+ <button id="btn-clear" data-i18n="clear_reset" data-i18n-title="clear_reset_title"
316
+ title="Reset selection, zoom and panel">Clear / Reset</button>
317
+ </div>
318
+
319
+ <div id="summary-content">
320
+ <div id="summary-title" data-i18n="summary_all">All points</div>
321
+ <div id="summary-subtitle"></div>
322
+ <div class="section-divider" data-i18n="religion_mix">Religion mix</div>
323
+ <div class="religion-bars" id="summary-religions"></div>
324
+ <div class="section-divider" data-i18n="description">Description</div>
325
+ <div id="summary-descriptions"></div>
326
+ <div class="section-divider" data-i18n="samples">Samples</div>
327
+ <div class="sample-grid" id="summary-samples"></div>
328
+ </div>
329
+
330
+ <div id="img-empty" data-i18n="click_hint">Click a point in the plot to see its image here.</div>
331
+ <div id="img-content">
332
+ <!-- 1. Title -->
333
+ <div id="img-title"></div>
334
+ <!-- 2. Religion tag -->
335
+ <div><span class="religion-tag" id="img-tag"></span></div>
336
+ <!-- 3. Big image -->
337
+ <img id="img-preview" src="" alt="">
338
+ <!-- 4. Artist Β· year Β· source -->
339
+ <div class="meta-line" id="img-meta"></div>
340
+
341
+ <!-- 5. Analysis -->
342
+ <div class="section-divider" data-i18n="analysis">Analysis</div>
343
+ <div id="feat-rows"></div>
344
+ <div id="scalars-row" class="scalars-row"></div>
345
+
346
+ <!-- 6. Visualizations (smaller) -->
347
+ <div class="section-divider" data-i18n="visualizations">Visualizations</div>
348
+ <div class="viz-row" id="viz-row">
349
+ <div class="viz-thumb-wrap" id="viz-canny-wrap">
350
+ <img class="viz-thumb" id="viz-canny" src="" alt="Canny">
351
+ <div class="viz-label" data-i18n="edges">Edges</div>
352
+ </div>
353
+ <div class="viz-thumb-wrap" id="viz-faces-wrap" style="display:none">
354
+ <img class="viz-thumb" id="viz-faces" src="" alt="Faces">
355
+ <div class="viz-label" data-i18n="faces">Faces</div>
356
+ </div>
357
+ <div class="viz-thumb-wrap" id="viz-pose-wrap" style="display:none">
358
+ <img class="viz-thumb" id="viz-pose" src="" alt="Pose">
359
+ <div class="viz-label" data-i18n="pose">Pose</div>
360
+ </div>
361
+ </div>
362
+ </div>
363
+
364
+ <div id="cluster-content">
365
+ <div id="cluster-title"></div>
366
+ <div id="cluster-subtitle"></div>
367
+ <div class="section-divider" data-i18n="religion_mix">Religion mix</div>
368
+ <div class="religion-bars" id="cluster-religions"></div>
369
+ <div class="section-divider" data-i18n="description">Description</div>
370
+ <div id="cluster-descriptions"></div>
371
+ <div class="section-divider" data-i18n="samples">Samples</div>
372
+ <div class="sample-grid" id="cluster-samples"></div>
373
+ </div>
374
+ </div>
375
+
376
+ <div id="plot-wrap"></div>
377
+
378
+ <div id="sidebar">
379
+ <div id="sidebar-header">
380
+ <h1 data-i18n="app_title">Religious Art Space</h1>
381
+ <div id="lang-toggle">
382
+ <button class="lang-btn active" data-lang="en">EN</button>
383
+ <button class="lang-btn" data-lang="sl">SL</button>
384
+ </div>
385
+ </div>
386
+
387
+ <div>
388
+ <h2 data-i18n="handcrafted">Hand-crafted</h2>
389
+ <div class="slider-row">
390
+ <label><span data-i18n="color_histogram">Color histogram</span><span class="val" id="v-hc_color">1.0</span></label>
391
+ <input type="range" id="sl-hc_color" min="0" max="2" step="0.1" value="1">
392
+ </div>
393
+ <div class="slider-row">
394
+ <label><span data-i18n="color_flatness">Color flatness</span><span class="val" id="v-hc_flat">1.0</span></label>
395
+ <input type="range" id="sl-hc_flat" min="0" max="2" step="0.1" value="1">
396
+ </div>
397
+ <div class="slider-row">
398
+ <label><span data-i18n="geometric_patterns">Geometric patterns</span><span class="val" id="v-hc_geom">1.0</span></label>
399
+ <input type="range" id="sl-hc_geom" min="0" max="2" step="0.1" value="1">
400
+ </div>
401
+ <div class="slider-row">
402
+ <label><span data-i18n="lines">Lines</span><span class="val" id="v-hc_lines">1.0</span></label>
403
+ <input type="range" id="sl-hc_lines" min="0" max="2" step="0.1" value="1">
404
+ </div>
405
+ <div class="slider-row">
406
+ <label><span data-i18n="light_contrast">Light / contrast</span><span class="val" id="v-hc_light">1.0</span></label>
407
+ <input type="range" id="sl-hc_light" min="0" max="2" step="0.1" value="1">
408
+ </div>
409
+ <div class="slider-row">
410
+ <label><span data-i18n="symmetry">Symmetry</span><span class="val" id="v-hc_symmetry">1.0</span></label>
411
+ <input type="range" id="sl-hc_symmetry" min="0" max="2" step="0.1" value="1">
412
+ </div>
413
+ </div>
414
+
415
+ <div>
416
+ <h2 data-i18n="learned_other">Learned / other</h2>
417
+ <div class="slider-row">
418
+ <label><span>DINOv2</span><span class="val" id="v-dino">1.0</span></label>
419
+ <input type="range" id="sl-dino" min="0" max="2" step="0.1" value="1">
420
+ </div>
421
+ <div class="slider-row">
422
+ <label><span data-i18n="clip_image">CLIP image</span><span class="val" id="v-clip_v">1.0</span></label>
423
+ <input type="range" id="sl-clip_v" min="0" max="2" step="0.1" value="1">
424
+ </div>
425
+ <div class="slider-row">
426
+ <label><span data-i18n="clip_attributes">CLIP attributes</span><span class="val" id="v-clip_s">1.0</span></label>
427
+ <input type="range" id="sl-clip_s" min="0" max="2" step="0.1" value="1">
428
+ </div>
429
+ <div class="slider-row">
430
+ <label><span data-i18n="pose">Pose</span><span class="val" id="v-pose">1.0</span></label>
431
+ <input type="range" id="sl-pose" min="0" max="2" step="0.1" value="1">
432
+ </div>
433
+ <div class="slider-row">
434
+ <label><span data-i18n="faces">Faces</span><span class="val" id="v-faces">1.0</span></label>
435
+ <input type="range" id="sl-faces" min="0" max="2" step="0.1" value="1">
436
+ </div>
437
+ </div>
438
+
439
+ <div>
440
+ <h2>CLIP Labels</h2>
441
+ <div class="slider-row">
442
+ <label><span>Score threshold</span><span class="val" id="v-clip-thr">0.22</span></label>
443
+ <input type="range" id="sl-clip-thr" min="0.10" max="0.35" step="0.01" value="0.22">
444
+ </div>
445
+ <button class="dev-btn" id="btn-edit-labels" onclick="openLabelEditor()"
446
+ style="width:100%;background:#1a2a1a;border:1px solid #2e7d32;color:#81c784;margin-bottom:6px;">
447
+ + Edit Labels
448
+ </button>
449
+ </div>
450
+
451
+ <div style="display:flex; gap:8px;">
452
+ <button class="dev-btn" id="btn-zero" onclick="toggleAllZero()" style="flex:1;background:#222;border:1px solid #333;color:#aaa;">Set all to 0</button>
453
+ <button class="dev-btn" id="btn-cluster" onclick="toggleClusters()" style="flex:1;background:#222;border:1px solid #333;color:#aaa;">Clusters</button>
454
+ </div>
455
+
456
+ </div>
457
+
458
+ <!-- CLIP Label Editor Modal -->
459
+ <div id="label-modal" style="display:none;position:fixed;inset:0;z-index:200;
460
+ background:rgba(0,0,0,.7);align-items:center;justify-content:center;">
461
+ <div style="background:#181818;border:1px solid #333;border-radius:12px;
462
+ width:min(680px,94vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;">
463
+
464
+ <div style="padding:18px 20px 12px;border-bottom:1px solid #2a2a2a;
465
+ display:flex;align-items:center;justify-content:space-between;">
466
+ <h2 style="font-size:1rem;font-weight:600;">Edit CLIP Labels</h2>
467
+ <button onclick="closeLabelEditor()"
468
+ style="background:none;border:none;color:#666;font-size:1.3rem;cursor:pointer;line-height:1;">Γ—</button>
469
+ </div>
470
+
471
+ <div id="label-editor-body" style="overflow-y:auto;padding:16px 20px;flex:1;"></div>
472
+
473
+ <div style="padding:12px 20px;border-top:1px solid #2a2a2a;
474
+ display:flex;gap:8px;align-items:center;">
475
+ <button onclick="restoreDefaultLabels()"
476
+ style="padding:6px 14px;border-radius:6px;border:1px solid #333;
477
+ background:none;color:#666;cursor:pointer;font-size:.8rem;">
478
+ β†Ί Restore defaults
479
+ </button>
480
+ <button onclick="clearAllLabels()"
481
+ style="padding:6px 14px;border-radius:6px;border:1px solid #333;
482
+ background:none;color:#666;cursor:pointer;font-size:.8rem;margin-right:auto;">
483
+ βœ• Clear all
484
+ </button>
485
+ <button onclick="closeLabelEditor()"
486
+ style="padding:7px 18px;border-radius:6px;border:1px solid #333;
487
+ background:#222;color:#aaa;cursor:pointer;font-size:.85rem;">Cancel</button>
488
+ <button id="btn-save-labels" onclick="saveLabels()"
489
+ style="padding:7px 18px;border-radius:6px;border:1px solid #2e7d32;
490
+ background:#1a2a1a;color:#81c784;cursor:pointer;font-size:.85rem;font-weight:600;">
491
+ Save &amp; Recompute
492
+ </button>
493
+ </div>
494
+ </div>
495
+ </div>
496
+
497
+ <div id="loading">⏳ Recomputing t-SNE…</div>
498
+
499
+ <script>
500
+ const COLORS = {
501
+ christianity: "#4682b4",
502
+ islam: "#55a868",
503
+ buddhism: "#c44e52",
504
+ hinduism: "#dd8452",
505
+ };
506
+ // Muted versions shown when cluster overlay is active
507
+ const COLORS_MUTED = {
508
+ christianity: "#4a5e72",
509
+ islam: "#4a6354",
510
+ buddhism: "#6e4a4b",
511
+ hinduism: "#6e5a48",
512
+ };
513
+
514
+ const SLIDER_IDS = [
515
+ "hc_color", "hc_flat", "hc_geom", "hc_lines", "hc_light", "hc_symmetry",
516
+ "dino", "clip_v", "clip_s", "pose", "faces",
517
+ ];
518
+ const RELIGION_ORDER = ["christianity", "islam", "buddhism", "hinduism", "general"];
519
+
520
+ // ── i18n ─────────────────────────────────────────────────────────────────────
521
+ //
522
+ // Static UI strings keyed by `data-i18n` attributes plus dynamic strings
523
+ // looked up via t(). Backend-generated description phrases (e.g. "warm
524
+ // reds/oranges Β· vibrant") are translated by translateContent() β€” it splits
525
+ // on " Β· " and pattern-matches each part against BACKEND_SL.
526
+
527
+ const I18N = {
528
+ en: {
529
+ app_title: "Religious Art Space",
530
+ panel_overview: "Overview",
531
+ clear_reset: "Clear / Reset",
532
+ clear_reset_title: "Reset selection, zoom and panel",
533
+ summary_all: "All points",
534
+ religion_mix: "Religion mix",
535
+ description: "Description",
536
+ samples: "Samples",
537
+ analysis: "Analysis",
538
+ visualizations: "Visualizations",
539
+ edges: "Edges",
540
+ faces: "Faces",
541
+ pose: "Pose",
542
+ click_hint: "Click a point in the plot to see its image here.",
543
+ handcrafted: "Hand-crafted",
544
+ learned_other: "Learned / other",
545
+ color_histogram: "Color histogram",
546
+ color_flatness: "Color flatness",
547
+ geometric_patterns: "Geometric patterns",
548
+ lines: "Lines",
549
+ light_contrast: "Light / contrast",
550
+ symmetry: "Symmetry",
551
+ clip_image: "CLIP image",
552
+ clip_attributes: "CLIP attributes",
553
+ set_all_zero: "Set all to 0",
554
+ set_all_one: "Set all to 1",
555
+ clusters_btn: "Clusters",
556
+ clusters_btn_on: "Clusters βœ“",
557
+ reprojecting: "Reprojecting…",
558
+ // dynamic helpers
559
+ images: "images",
560
+ of: "of",
561
+ selection: "Selection",
562
+ zoomed_view: "Zoomed view",
563
+ cluster: "Cluster",
564
+ cluster_of: "of",
565
+ // religion display
566
+ rel_christianity: "christianity",
567
+ rel_islam: "islam",
568
+ rel_buddhism: "buddhism",
569
+ rel_hinduism: "hinduism",
570
+ },
571
+ sl: {
572
+ app_title: "Prostor verske umetnosti",
573
+ panel_overview: "Pregled",
574
+ clear_reset: "Počisti / Ponastavi",
575
+ clear_reset_title: "Ponastavi izbor, povečavo in panel",
576
+ summary_all: "Vse točke",
577
+ religion_mix: "Verska sestava",
578
+ description: "Opis",
579
+ samples: "Vzorci",
580
+ analysis: "Analiza",
581
+ visualizations: "Vizualizacije",
582
+ edges: "Robovi",
583
+ faces: "Obrazi",
584
+ pose: "DrΕΎa",
585
+ click_hint: "Klikni točko v grafu, da se prikaže slika.",
586
+ handcrafted: "Ročno izdelane",
587
+ learned_other: "Naučene / ostalo",
588
+ color_histogram: "Barvni histogram",
589
+ color_flatness: "Ploskost barv",
590
+ geometric_patterns: "Geometrijski vzorci",
591
+ lines: "Linije",
592
+ light_contrast: "Svetloba / kontrast",
593
+ symmetry: "Simetrija",
594
+ clip_image: "CLIP slika",
595
+ clip_attributes: "CLIP atributi",
596
+ set_all_zero: "Vse na 0",
597
+ set_all_one: "Vse na 1",
598
+ clusters_btn: "Skupine",
599
+ clusters_btn_on: "Skupine βœ“",
600
+ reprojecting: "Preračunavanje…",
601
+ images: "slik",
602
+ of: "od",
603
+ selection: "Izbor",
604
+ zoomed_view: "Povečan pogled",
605
+ cluster: "Skupina",
606
+ cluster_of: "od",
607
+ rel_christianity: "krőčanstvo",
608
+ rel_islam: "islam",
609
+ rel_buddhism: "budizem",
610
+ rel_hinduism: "hinduizem",
611
+ },
612
+ };
613
+
614
+ // Backend description phrases (full strings the Python labellers emit).
615
+ const BACKEND_SL = {
616
+ // group headings (shown in feat-key)
617
+ "Color": "Barva",
618
+ "Flatness": "Ploskost",
619
+ "Geometry": "Geometrija",
620
+ "Lines": "Linije",
621
+ "Light": "Svetloba",
622
+ "Symmetry": "Simetrija",
623
+ "CLIP attr": "CLIP atr.",
624
+ "Pose": "DrΕΎa",
625
+ "Faces": "Obrazi",
626
+ // color
627
+ "warm reds/oranges": "tople rdečo-oranžne",
628
+ "yellows/greens": "rumeno-zelene",
629
+ "cool greens/cyans": "hladne zeleno-modre",
630
+ "blues/purples": "modre/vijolične",
631
+ "vibrant": "ΕΎivahno",
632
+ "muted/desaturated": "umirjeno/nenasičeno",
633
+ // flatness
634
+ "flat color fills": "ploskovne barvne ploskve",
635
+ "painterly gradients": "slikarski prelivi",
636
+ // geometry
637
+ "repeating geometric patterns": "ponavljajoči geometrijski vzorci",
638
+ "complex texture": "kompleksna tekstura",
639
+ "uniform texture": "enotna tekstura",
640
+ // lines
641
+ "strong straight lines": "izrazite ravne linije",
642
+ "curved/organic lines": "ukrivljene/organske linije",
643
+ "dominant horizontal lines": "prevladujoče vodoravne linije",
644
+ "dominant vertical lines": "prevladujoče navpične linije",
645
+ "dominant diagonalβ†— lines": "prevladujoče diagonalneβ†— linije",
646
+ "dominant diagonalβ†˜ lines": "prevladujoče diagonalneβ†˜ linije",
647
+ // light
648
+ "bright": "svetlo",
649
+ "dark": "temno",
650
+ "heavy shadows": "močne sence",
651
+ "high contrast": "visok kontrast",
652
+ "low contrast": "nizek kontrast",
653
+ // symmetry
654
+ "left-right symmetric": "levo-desno simetrično",
655
+ "top-bottom symmetric": "zgoraj-spodaj simetrično",
656
+ "asymmetric": "asimetrično",
657
+ // pose
658
+ "figurative (bodies present)": "figuralno (s telesi)",
659
+ "non-figurative": "nefiguralno",
660
+ // faces
661
+ "crowd/group scenes": "množične/skupinske scene",
662
+ "portrait/close-up": "portret / od blizu",
663
+ "no faces": "brez obrazov",
664
+ // scalar chip labels (long names)
665
+ "Brightness": "Svetlost",
666
+ "Contrast": "Kontrast",
667
+ "Darkness": "Temnost",
668
+ "Edge density": "Gostota robov",
669
+ "Avg hue": "Povp. odtenek",
670
+ "Avg saturation": "Povp. nasičenost",
671
+ "Straight ratio": "Razmerje ravnih",
672
+ "Hough lines": "Hough linije",
673
+ "Face coverage": "Pokritost obraza",
674
+ "Pose detected": "DrΕΎa zaznana",
675
+ // CLIP labels
676
+ "halo": "avreola", "cross": "kriΕΎ", "virgin mary": "Devica Marija",
677
+ "angels": "angeli", "nativity": "rojstvo", "biblical figures": "svetopisemske osebe",
678
+ "calligraphy": "kaligrafija", "arabesque": "arabeska", "mosque": "moΕ‘eja",
679
+ "quran ms": "korana", "islamic tiles": "islamske ploőčice",
680
+ "buddha": "Buda", "lotus": "lotos", "thangka": "thangka",
681
+ "mandala": "mandala", "dharma wheel": "kolo dharme", "bodhisattva": "bodisatva",
682
+ "many-armed deity": "večroko božanstvo", "ganesha": "Ganeő",
683
+ "hindu temple": "hindujski tempelj", "krishna": "KriΕ‘na",
684
+ "shiva nataraja": "Ε iva NataradΕΎa", "gold leaf": "zlat list",
685
+ "prayer": "molitev", "sacred geometry": "sveta geometrija",
686
+ "ritual objects": "obredni predmeti", "manuscript": "rokopis",
687
+ };
688
+
689
+ let LANG = localStorage.getItem("lang") || "en";
690
+
691
+ function t(key) {
692
+ return (I18N[LANG] && I18N[LANG][key]) || I18N.en[key] || key;
693
+ }
694
+
695
+ function translatePhrase(s) {
696
+ if (LANG === "en" || !s) return s;
697
+ if (BACKEND_SL[s]) return BACKEND_SL[s];
698
+ // "predominantly <religion>" β†’ "preteΕΎno <religion-sl>"
699
+ const m = s.match(/^predominantly (.+)$/);
700
+ if (m) {
701
+ const rel = m[1].trim();
702
+ return "preteΕΎno " + (t("rel_" + rel) || rel);
703
+ }
704
+ return s;
705
+ }
706
+
707
+ function translateContent(s) {
708
+ // Backend joins multi-clause descriptions with " Β· ".
709
+ if (LANG === "en" || !s) return s;
710
+ return s.split(" Β· ").map(translatePhrase).join(" Β· ");
711
+ }
712
+
713
+ function translateReligion(rel) {
714
+ return t("rel_" + rel) || rel;
715
+ }
716
+
717
+ function applyI18n() {
718
+ document.querySelectorAll("[data-i18n]").forEach(el => {
719
+ el.textContent = t(el.dataset.i18n);
720
+ });
721
+ document.querySelectorAll("[data-i18n-title]").forEach(el => {
722
+ el.title = t(el.dataset.i18nTitle);
723
+ });
724
+ // Buttons whose label depends on toggle state
725
+ document.getElementById("btn-zero").textContent =
726
+ allZeroed ? t("set_all_one") : t("set_all_zero");
727
+ document.getElementById("btn-cluster").textContent =
728
+ showClusters ? t("clusters_btn_on") : t("clusters_btn");
729
+ // Lang-toggle button active state
730
+ document.querySelectorAll(".lang-btn").forEach(b => {
731
+ b.classList.toggle("active", b.dataset.lang === LANG);
732
+ });
733
+ }
734
+
735
+ function setLanguage(lang) {
736
+ if (lang === LANG) return;
737
+ LANG = lang;
738
+ localStorage.setItem("lang", lang);
739
+ applyI18n();
740
+ // Repaint anything generated from data, not from data-i18n attributes.
741
+ renderPlot();
742
+ if (selectedClusterId != null && clusters[selectedClusterId]) {
743
+ showClusterInfo(selectedClusterId);
744
+ } else if (document.body.classList.contains("has-image")) {
745
+ // Best-effort: re-render the current image panel by matching the title.
746
+ const cur = document.getElementById("img-title").textContent;
747
+ const p = points.find(pt => (pt.title || pt.filename) === cur);
748
+ if (p) showInfo(p);
749
+ } else {
750
+ showSummary(selectedIndices, selectedIndices ? null : t("summary_all"));
751
+ }
752
+ }
753
+ document.querySelectorAll(".lang-btn").forEach(b => {
754
+ b.addEventListener("click", () => setLanguage(b.dataset.lang));
755
+ });
756
+
757
+ let points = [];
758
+ let showClusters = false;
759
+ let clusters = [];
760
+ let pointLabels = []; // per-point cluster id, -1 = noise
761
+ let selectedClusterId = null;
762
+ let selectedIndices = null; // lasso/box selection (null = none)
763
+
764
+ // ── Plot ──────────────────────────────────────────────────────────────────────
765
+
766
+
767
+ function buildTraces(pts) {
768
+ const palette = showClusters ? COLORS_MUTED : COLORS;
769
+ const opacity = showClusters ? 0.38 : 0.75;
770
+ const religions = [...new Set(pts.map(p => p.religion))].sort();
771
+ return religions.map(rel => {
772
+ const sub = pts.map((p, i) => ({ p, i })).filter(({ p }) => p.religion === rel);
773
+ // In cluster mode each point's click routes to its cluster; noise (-1)
774
+ // points get customdata = null, so the click handler ignores them.
775
+ const customdata = showClusters
776
+ ? sub.map(({ i }) => {
777
+ const cid = pointLabels[i];
778
+ return cid >= 0 ? ["cluster", cid] : null;
779
+ })
780
+ : sub.map(({ i }) => i);
781
+ const text = showClusters
782
+ ? sub.map(({ i }) => {
783
+ const cid = pointLabels[i];
784
+ if (cid < 0 || !clusters[cid]) return "";
785
+ return `${clusters[cid].label}<br>${clusters[cid].size} images`;
786
+ })
787
+ : undefined;
788
+ return {
789
+ x: sub.map(({ p }) => p.x),
790
+ y: sub.map(({ p }) => p.y),
791
+ customdata,
792
+ text,
793
+ hoverinfo: showClusters ? "text" : undefined,
794
+ mode: "markers",
795
+ type: "scattergl",
796
+ name: translateReligion(rel),
797
+ marker: { color: palette[rel] || "#666", size: 5, opacity },
798
+ };
799
+ });
800
+ }
801
+
802
+ const HULL_PALETTE = [
803
+ [255,200,100],[100,180,255],[200,100,255],[80,220,160],
804
+ [255,110,140],[140,230,80],[255,155,60], [60,160,255],
805
+ [210,60,200], [60,210,160],[255,60,110], [200,210,60],
806
+ ];
807
+
808
+ function buildHullTraces(clusts) {
809
+ const traces = [];
810
+ clusts.forEach((c, i) => {
811
+ const [r,g,b] = HULL_PALETTE[i % HULL_PALETTE.length];
812
+ const selected = (i === selectedClusterId);
813
+ // 1) filled outline β€” visual only (Plotly doesn't fire clicks on the fill)
814
+ traces.push({
815
+ x: c.hull.map(p => p[0]),
816
+ y: c.hull.map(p => p[1]),
817
+ fill: "toself",
818
+ fillcolor: `rgba(${r},${g},${b},${selected ? 0.38 : 0.22})`,
819
+ line: { color: `rgba(${r},${g},${b},${selected ? 1.0 : 0.82})`,
820
+ width: selected ? 2.5 : 1.5 },
821
+ mode: "lines",
822
+ type: "scatter",
823
+ showlegend: false,
824
+ hoverinfo: "skip",
825
+ });
826
+ // 2) invisible large centroid marker β€” the actual click target.
827
+ // Carries customdata so the click handler can route to the right cluster.
828
+ traces.push({
829
+ x: [c.cx],
830
+ y: [c.cy],
831
+ customdata: [["cluster", i]],
832
+ mode: "markers",
833
+ type: "scatter",
834
+ marker: { size: 40, color: `rgba(${r},${g},${b},0.001)` },
835
+ showlegend: false,
836
+ hoverinfo: "text",
837
+ text: `${c.label}<br>${c.size} images`,
838
+ hoverlabel: { bgcolor: "#000", bordercolor: "#555", font: { color: "#eee" } },
839
+ });
840
+ });
841
+ return traces;
842
+ }
843
+
844
+ function buildAnnotations() {
845
+ return clusters.map(c => ({
846
+ x: c.cx, y: c.cy, text: c.label,
847
+ showarrow: false,
848
+ font: { color: "#fff", size: 11 },
849
+ bgcolor: "rgba(0,0,0,0.75)",
850
+ bordercolor: "#555",
851
+ borderwidth: 1,
852
+ borderpad: 4,
853
+ }));
854
+ }
855
+
856
+ function renderPlot() {
857
+ const traces = [
858
+ ...(showClusters ? buildHullTraces(clusters) : []),
859
+ ...buildTraces(points),
860
+ ];
861
+ const layout = {
862
+ paper_bgcolor: "#111",
863
+ plot_bgcolor: "#111",
864
+ font: { color: "#ccc" },
865
+ xaxis: { visible: false, zeroline: false },
866
+ yaxis: { visible: false, zeroline: false },
867
+ margin: { t: 10, b: 10, l: 10, r: 10 },
868
+ legend: {
869
+ x: 0, y: 1, xanchor: "left", yanchor: "top",
870
+ bgcolor: "rgba(0,0,0,0)", font: { size: 12 },
871
+ },
872
+ hovermode: "closest",
873
+ dragmode: "zoom",
874
+ annotations: showClusters ? buildAnnotations() : [],
875
+ };
876
+ Plotly.react("plot-wrap", traces, layout, { responsive: true });
877
+
878
+ const el = document.getElementById("plot-wrap");
879
+
880
+ el.on("plotly_click", e => {
881
+ const cd = e.points[0].customdata;
882
+ if (Array.isArray(cd) && cd[0] === "cluster") {
883
+ showClusterInfo(cd[1]);
884
+ } else if (!showClusters && typeof cd === "number") {
885
+ // Image-point clicks only work outside cluster mode.
886
+ showInfo(points[cd]);
887
+ }
888
+ });
889
+
890
+ // Lasso / box-select β†’ summarize selection, then drop the tool. We defer
891
+ // the dragmode reset to the next tick so it lands AFTER Plotly finishes
892
+ // its own internal post-selection housekeeping (otherwise it gets ignored).
893
+ el.on("plotly_selected", e => {
894
+ const dropTool = () => setTimeout(() => {
895
+ Plotly.relayout("plot-wrap", { dragmode: "zoom" });
896
+ }, 0);
897
+
898
+ if (!e || !e.points || e.points.length === 0) {
899
+ showSummaryForAll();
900
+ dropTool();
901
+ return;
902
+ }
903
+ const idx = [];
904
+ e.points.forEach(p => {
905
+ const cd = p.customdata;
906
+ if (typeof cd === "number") idx.push(cd);
907
+ // cluster centroid markers (customdata = ["cluster", i]) are ignored.
908
+ });
909
+ if (idx.length === 0) { showSummaryForAll(); dropTool(); return; }
910
+ selectedIndices = idx;
911
+ showSummary(idx, `${t("selection")} (${idx.length})`);
912
+ dropTool();
913
+ });
914
+
915
+ // Zoom β†’ summarize what's in view (debounced).
916
+ el.on("plotly_relayout", e => {
917
+ if (!e) return;
918
+ // Ignore pure dragmode changes (no axis info), and selection-frame resets.
919
+ const touchesAxes =
920
+ "xaxis.range[0]" in e || "yaxis.range[0]" in e ||
921
+ "xaxis.autorange" in e || "yaxis.autorange" in e;
922
+ if (!touchesAxes) return;
923
+ scheduleZoomSummary();
924
+ });
925
+
926
+ // Cursor feedback: pointer when hovering anything clickable. Plotly stamps
927
+ // its own crosshair on .nsewdrag, so we toggle a class and override via CSS.
928
+ el.on("plotly_hover", e => {
929
+ const cd = e.points[0].customdata;
930
+ const clickable =
931
+ (Array.isArray(cd) && cd[0] === "cluster") ||
932
+ (!showClusters && typeof cd === "number");
933
+ el.classList.toggle("clickable", clickable);
934
+ });
935
+ el.on("plotly_unhover", () => { el.classList.remove("clickable"); });
936
+ }
937
+
938
+ // ── Info panel ────────────────────────────────────────────────────────────────
939
+
940
+ async function showInfo(p) {
941
+ document.body.classList.remove("has-cluster");
942
+ document.body.classList.add("has-image");
943
+ selectedClusterId = null;
944
+
945
+ // 1. Title
946
+ document.getElementById("img-title").textContent = p.title || p.filename;
947
+
948
+ // 2. Religion tag
949
+ const tag = document.getElementById("img-tag");
950
+ tag.textContent = translateReligion(p.religion);
951
+ tag.style.background = COLORS[p.religion] || "#555";
952
+
953
+ // 3. Image
954
+ document.getElementById("img-preview").src = `/images/${p.filename}`;
955
+
956
+ // 4. Artist Β· year Β· source (no title here)
957
+ document.getElementById("img-meta").innerHTML = [
958
+ p.artist || "",
959
+ p.year || "",
960
+ p.source ? `<span style="color:#444">${p.source}</span>` : "",
961
+ ].filter(Boolean).join(" Β· ");
962
+
963
+ // 6a. Canny (always)
964
+ document.getElementById("viz-canny").src = `/api/viz/canny/${p.filename}`;
965
+
966
+ // 6b. Faces (show only if model detects faces)
967
+ const facesWrap = document.getElementById("viz-faces-wrap");
968
+ const facesImg = document.getElementById("viz-faces");
969
+ facesWrap.style.display = "none";
970
+ facesImg.onload = () => { facesWrap.style.display = "block"; };
971
+ facesImg.onerror = () => { facesWrap.style.display = "none"; };
972
+ facesImg.src = `/api/viz/faces/${p.filename}`;
973
+
974
+ // 6c. Pose (show only if skeleton detected)
975
+ const poseWrap = document.getElementById("viz-pose-wrap");
976
+ const poseImg = document.getElementById("viz-pose");
977
+ poseWrap.style.display = "none";
978
+ poseImg.onload = () => { poseWrap.style.display = "block"; };
979
+ poseImg.onerror = () => { poseWrap.style.display = "none"; };
980
+ poseImg.src = `/api/viz/pose/${p.filename}`;
981
+
982
+ // 5. Feature descriptions
983
+ const infoRes = await fetch(`/api/image_info/${p.filename}`);
984
+ const info = await infoRes.json();
985
+
986
+ const featRows = document.getElementById("feat-rows");
987
+ featRows.innerHTML = "";
988
+ (info.descriptions || []).forEach(d => {
989
+ const row = document.createElement("div");
990
+ row.className = "feat-row";
991
+ const key = translatePhrase(d.group);
992
+ const val = translateContent(d.label);
993
+ row.innerHTML = `<span class="feat-key">${key}</span><span class="feat-val">${val}</span>`;
994
+ featRows.appendChild(row);
995
+ });
996
+
997
+ const scalarsRow = document.getElementById("scalars-row");
998
+ scalarsRow.innerHTML = "";
999
+ const scalarLabels = {
1000
+ "Brightness": "bright",
1001
+ "Contrast": "contrast",
1002
+ "Darkness": "dark",
1003
+ "Edge density": "edges",
1004
+ "Avg hue": "hue",
1005
+ "Avg saturation":"sat",
1006
+ "Straight ratio":"straight",
1007
+ "Hough lines": "lines",
1008
+ "Faces": "faces",
1009
+ "Face coverage": "cov",
1010
+ "Pose detected": "pose",
1011
+ };
1012
+ Object.entries(info.scalars || {}).forEach(([name, val]) => {
1013
+ const chip = document.createElement("span");
1014
+ chip.className = "scalar-chip";
1015
+ const short = scalarLabels[name] || name;
1016
+ const display = Number.isInteger(val) ? val : val.toFixed(2);
1017
+ chip.innerHTML = `<b>${short}</b> ${display}`;
1018
+ scalarsRow.appendChild(chip);
1019
+ });
1020
+ }
1021
+
1022
+ function showClusterInfo(id) {
1023
+ const c = clusters[id];
1024
+ if (!c) return;
1025
+ selectedClusterId = id;
1026
+ document.body.classList.remove("has-image");
1027
+ document.body.classList.add("has-cluster");
1028
+
1029
+ document.getElementById("cluster-title").textContent =
1030
+ c.label && c.label !== "cluster"
1031
+ ? translateContent(c.label)
1032
+ : `${t("cluster")} ${id + 1}`;
1033
+ document.getElementById("cluster-subtitle").textContent =
1034
+ `${c.size} ${t("images")} Β· ${t("cluster").toLowerCase()} ${id + 1} ${t("cluster_of")} ${clusters.length}`;
1035
+
1036
+ // Religion mix bars
1037
+ const relWrap = document.getElementById("cluster-religions");
1038
+ relWrap.innerHTML = "";
1039
+ const total = Object.values(c.religion_counts || {}).reduce((a, b) => a + b, 0);
1040
+ Object.entries(c.religion_counts || {})
1041
+ .sort((a, b) => b[1] - a[1])
1042
+ .forEach(([rel, cnt]) => {
1043
+ const pct = total ? (cnt / total) * 100 : 0;
1044
+ const color = COLORS[rel] || "#666";
1045
+ const row = document.createElement("div");
1046
+ row.className = "religion-bar";
1047
+ row.innerHTML = `
1048
+ <span class="rname">${translateReligion(rel)}</span>
1049
+ <span class="rtrack"><span class="rfill" style="width:${pct.toFixed(1)}%;background:${color}"></span></span>
1050
+ <span class="rpct">${pct.toFixed(0)}%</span>`;
1051
+ relWrap.appendChild(row);
1052
+ });
1053
+
1054
+ // Per-group descriptions
1055
+ const descWrap = document.getElementById("cluster-descriptions");
1056
+ descWrap.innerHTML = "";
1057
+ (c.descriptions || []).forEach(d => {
1058
+ const row = document.createElement("div");
1059
+ row.className = "feat-row";
1060
+ const key = translatePhrase(d.group);
1061
+ const val = translateContent(d.label);
1062
+ row.innerHTML = `<span class="feat-key">${key}</span><span class="feat-val">${val}</span>`;
1063
+ descWrap.appendChild(row);
1064
+ });
1065
+
1066
+ // Sample thumbnails β€” click loads that image's full panel
1067
+ const sampWrap = document.getElementById("cluster-samples");
1068
+ sampWrap.innerHTML = "";
1069
+ (c.samples || []).forEach(fn => {
1070
+ const img = document.createElement("img");
1071
+ img.src = `/images/${fn}`;
1072
+ img.title = fn;
1073
+ img.onclick = () => {
1074
+ const p = points.find(pt => pt.filename === fn);
1075
+ if (p) showInfo(p);
1076
+ };
1077
+ sampWrap.appendChild(img);
1078
+ });
1079
+
1080
+ // Re-render so the selected hull gets its highlight styling.
1081
+ renderPlot();
1082
+ }
1083
+
1084
+ // ── Summary panel (overview / lasso / zoom) ──────────────────────────────────
1085
+
1086
+ async function showSummary(indices, title) {
1087
+ // Switch to summary state β€” image/cluster panels hide.
1088
+ document.body.classList.remove("has-image");
1089
+ document.body.classList.remove("has-cluster");
1090
+ selectedClusterId = null;
1091
+
1092
+ const res = await fetch("/api/subset_info", {
1093
+ method: "POST",
1094
+ headers: { "Content-Type": "application/json" },
1095
+ body: JSON.stringify({ indices: indices || null }),
1096
+ });
1097
+ const data = await res.json();
1098
+
1099
+ document.getElementById("summary-title").textContent = title || t("summary_all");
1100
+ const sub = data.total && data.count !== data.total
1101
+ ? `${data.count} ${t("of")} ${data.total} ${t("images")}`
1102
+ : `${data.count} ${t("images")}`;
1103
+ document.getElementById("summary-subtitle").textContent = sub;
1104
+
1105
+ // Religion mix bars
1106
+ const relWrap = document.getElementById("summary-religions");
1107
+ relWrap.innerHTML = "";
1108
+ const total = Object.values(data.religion_counts || {}).reduce((a, b) => a + b, 0);
1109
+ Object.entries(data.religion_counts || {})
1110
+ .sort((a, b) => b[1] - a[1])
1111
+ .forEach(([rel, cnt]) => {
1112
+ const pct = total ? (cnt / total) * 100 : 0;
1113
+ const color = COLORS[rel] || "#666";
1114
+ const row = document.createElement("div");
1115
+ row.className = "religion-bar";
1116
+ row.innerHTML = `
1117
+ <span class="rname">${translateReligion(rel)}</span>
1118
+ <span class="rtrack"><span class="rfill" style="width:${pct.toFixed(1)}%;background:${color}"></span></span>
1119
+ <span class="rpct">${pct.toFixed(0)}%</span>`;
1120
+ relWrap.appendChild(row);
1121
+ });
1122
+
1123
+ // Descriptions
1124
+ const descWrap = document.getElementById("summary-descriptions");
1125
+ descWrap.innerHTML = "";
1126
+ (data.descriptions || []).forEach(d => {
1127
+ const row = document.createElement("div");
1128
+ row.className = "feat-row";
1129
+ const key = translatePhrase(d.group);
1130
+ const val = translateContent(d.label);
1131
+ row.innerHTML = `<span class="feat-key">${key}</span><span class="feat-val">${val}</span>`;
1132
+ descWrap.appendChild(row);
1133
+ });
1134
+
1135
+ // Samples
1136
+ const sampWrap = document.getElementById("summary-samples");
1137
+ sampWrap.innerHTML = "";
1138
+ (data.samples || []).forEach(fn => {
1139
+ const img = document.createElement("img");
1140
+ img.src = `/images/${fn}`;
1141
+ img.title = fn;
1142
+ img.onclick = () => {
1143
+ const p = points.find(pt => pt.filename === fn);
1144
+ if (p) showInfo(p);
1145
+ };
1146
+ sampWrap.appendChild(img);
1147
+ });
1148
+ }
1149
+
1150
+ function showSummaryForAll() {
1151
+ selectedIndices = null;
1152
+ showSummary(null, "All points");
1153
+ }
1154
+
1155
+ // Debounced zoom-summary: filter currently-visible points and summarize them.
1156
+ let zoomTimer = null;
1157
+ function scheduleZoomSummary() {
1158
+ clearTimeout(zoomTimer);
1159
+ zoomTimer = setTimeout(computeZoomSummary, 250);
1160
+ }
1161
+ function computeZoomSummary() {
1162
+ const el = document.getElementById("plot-wrap");
1163
+ const fl = el._fullLayout;
1164
+ if (!fl || !fl.xaxis || !fl.yaxis) return;
1165
+ const xr = fl.xaxis.range, yr = fl.yaxis.range;
1166
+ if (!xr || !yr) return;
1167
+ // Detect "fully zoomed out" β†’ treat as overview.
1168
+ const xspan = xr[1] - xr[0], yspan = yr[1] - yr[0];
1169
+ let allMinX = Infinity, allMaxX = -Infinity, allMinY = Infinity, allMaxY = -Infinity;
1170
+ for (const p of points) {
1171
+ if (p.x < allMinX) allMinX = p.x;
1172
+ if (p.x > allMaxX) allMaxX = p.x;
1173
+ if (p.y < allMinY) allMinY = p.y;
1174
+ if (p.y > allMaxY) allMaxY = p.y;
1175
+ }
1176
+ const isFullView =
1177
+ xr[0] <= allMinX - 0.001 && xr[1] >= allMaxX + 0.001 &&
1178
+ yr[0] <= allMinY - 0.001 && yr[1] >= allMaxY + 0.001;
1179
+ if (isFullView) { showSummaryForAll(); return; }
1180
+
1181
+ const idx = [];
1182
+ for (let i = 0; i < points.length; i++) {
1183
+ const p = points[i];
1184
+ if (p.x >= xr[0] && p.x <= xr[1] && p.y >= yr[0] && p.y <= yr[1]) idx.push(i);
1185
+ }
1186
+ if (idx.length === 0) { showSummaryForAll(); return; }
1187
+ selectedIndices = idx;
1188
+ showSummary(idx, `${t("zoomed_view")} (${idx.length})`);
1189
+ }
1190
+
1191
+ document.getElementById("btn-clear").onclick = () => {
1192
+ // Full reset β€” same state as a fresh page load.
1193
+ selectedIndices = null;
1194
+ selectedClusterId = null;
1195
+ // Drop any selection highlight on every trace.
1196
+ Plotly.restyle("plot-wrap", "selectedpoints", null);
1197
+ // Reset zoom, dragmode, and clear Plotly v2's persistent selection outline.
1198
+ Plotly.relayout("plot-wrap", {
1199
+ "xaxis.autorange": true,
1200
+ "yaxis.autorange": true,
1201
+ dragmode: "zoom",
1202
+ selections: [],
1203
+ });
1204
+ showSummaryForAll();
1205
+ };
1206
+
1207
+ // ── Init ──────────────────────────────────────────────────────────────────────
1208
+
1209
+ async function init() {
1210
+ applyI18n();
1211
+ const [embRes, thrRes] = await Promise.all([
1212
+ fetch("/api/embeddings"),
1213
+ fetch("/api/clip_threshold"),
1214
+ ]);
1215
+ points = await embRes.json();
1216
+ const { threshold } = await thrRes.json();
1217
+ const sl = document.getElementById("sl-clip-thr");
1218
+ sl.value = threshold;
1219
+ document.getElementById("v-clip-thr").textContent = threshold.toFixed(2);
1220
+ renderPlot();
1221
+ showSummaryForAll();
1222
+ }
1223
+
1224
+ // ── Sliders / Reproject ───────────────────────────────────────────────────────
1225
+
1226
+ function weights() {
1227
+ const w = {};
1228
+ SLIDER_IDS.forEach(k => { w[k] = +document.getElementById("sl-" + k).value; });
1229
+ return w;
1230
+ }
1231
+
1232
+ let timer = null;
1233
+ function onSlider() {
1234
+ const w = weights();
1235
+ SLIDER_IDS.forEach(k => {
1236
+ document.getElementById("v-" + k).textContent = w[k].toFixed(1);
1237
+ });
1238
+ clearTimeout(timer);
1239
+ timer = setTimeout(() => reproject(w), 800);
1240
+ }
1241
+
1242
+ async function reproject(w) {
1243
+ document.getElementById("loading").style.display = "flex";
1244
+ try {
1245
+ const res = await fetch("/api/reproject", {
1246
+ method: "POST",
1247
+ headers: { "Content-Type": "application/json" },
1248
+ body: JSON.stringify(w),
1249
+ });
1250
+ const coords = await res.json();
1251
+ coords.forEach((c, i) => { points[i].x = c.x; points[i].y = c.y; });
1252
+ clusters = []; pointLabels = []; selectedClusterId = null;
1253
+ document.body.classList.remove("has-cluster");
1254
+ renderPlot();
1255
+ } finally {
1256
+ document.getElementById("loading").style.display = "none";
1257
+ }
1258
+ }
1259
+
1260
+ let allZeroed = false;
1261
+ function toggleAllZero() {
1262
+ allZeroed = !allZeroed;
1263
+ const val = allZeroed ? 0 : 1;
1264
+ SLIDER_IDS.forEach(k => {
1265
+ document.getElementById("sl-" + k).value = val;
1266
+ document.getElementById("v-" + k).textContent = val.toFixed(1);
1267
+ });
1268
+ const btn = document.getElementById("btn-zero");
1269
+ btn.textContent = allZeroed ? t("set_all_one") : t("set_all_zero");
1270
+ reproject(weights());
1271
+ }
1272
+
1273
+ SLIDER_IDS.forEach(k =>
1274
+ document.getElementById("sl-" + k).addEventListener("input", onSlider)
1275
+ );
1276
+
1277
+ // ── CLIP Score Threshold slider ───────────────────────────────────────────────
1278
+
1279
+ let clipThrTimer = null;
1280
+ document.getElementById("sl-clip-thr").addEventListener("input", () => {
1281
+ const v = +document.getElementById("sl-clip-thr").value;
1282
+ document.getElementById("v-clip-thr").textContent = v.toFixed(2);
1283
+ clearTimeout(clipThrTimer);
1284
+ clipThrTimer = setTimeout(async () => {
1285
+ document.getElementById("loading").style.display = "flex";
1286
+ try {
1287
+ const res = await fetch("/api/clip_threshold", {
1288
+ method: "POST",
1289
+ headers: {"Content-Type": "application/json"},
1290
+ body: JSON.stringify({threshold: v}),
1291
+ });
1292
+ const data = await res.json();
1293
+ if (data.points) {
1294
+ data.points.forEach((c, i) => { if (points[i]) { points[i].x = c.x; points[i].y = c.y; } });
1295
+ clusters = []; pointLabels = []; selectedClusterId = null;
1296
+ renderPlot();
1297
+ Plotly.relayout("plot-wrap", {"xaxis.autorange": true, "yaxis.autorange": true});
1298
+ }
1299
+ } finally {
1300
+ document.getElementById("loading").style.display = "none";
1301
+ }
1302
+ }, 800);
1303
+ });
1304
+
1305
+ // ── CLIP Label Editor ─────────────────────────────────────────────────────────
1306
+
1307
+ const DEFAULT_CLIP_LABELS = {
1308
+ christianity: [
1309
+ {label:"halo", prompt:"a golden halo around a figure"},
1310
+ {label:"cross", prompt:"a crucifix or cross"},
1311
+ {label:"virgin mary", prompt:"the virgin mary"},
1312
+ {label:"angels", prompt:"angels with wings"},
1313
+ {label:"nativity", prompt:"a nativity scene"},
1314
+ {label:"biblical figs", prompt:"biblical figures in robes"},
1315
+ ],
1316
+ islam: [
1317
+ {label:"calligraphy", prompt:"arabic calligraphy"},
1318
+ {label:"arabesque", prompt:"geometric arabesque patterns"},
1319
+ {label:"mosque", prompt:"a mosque with a minaret"},
1320
+ {label:"quran ms", prompt:"an illuminated quran manuscript"},
1321
+ {label:"islamic tiles", prompt:"islamic tile patterns"},
1322
+ ],
1323
+ buddhism: [
1324
+ {label:"buddha", prompt:"a buddha statue"},
1325
+ {label:"lotus", prompt:"a lotus flower"},
1326
+ {label:"thangka", prompt:"a thangka painting"},
1327
+ {label:"mandala", prompt:"a mandala"},
1328
+ {label:"dharma wheel", prompt:"a dharma wheel"},
1329
+ {label:"bodhisattva", prompt:"a bodhisattva figure"},
1330
+ ],
1331
+ hinduism: [
1332
+ {label:"many-armed", prompt:"a deity with multiple arms"},
1333
+ {label:"ganesha", prompt:"ganesha the elephant god"},
1334
+ {label:"hindu temple", prompt:"a colorful hindu temple"},
1335
+ {label:"krishna", prompt:"krishna playing the flute"},
1336
+ {label:"shiva nataraja",prompt:"dancing shiva nataraja"},
1337
+ ],
1338
+ general: [
1339
+ {label:"gold leaf", prompt:"gold leaf background"},
1340
+ {label:"prayer", prompt:"religious figures in prayer"},
1341
+ {label:"sacred geom.", prompt:"sacred geometry"},
1342
+ {label:"ritual objects",prompt:"incense and ritual objects"},
1343
+ {label:"manuscript", prompt:"a religious manuscript"},
1344
+ ],
1345
+ };
1346
+
1347
+ let labelEditorState = {}; // religion β†’ [{label, prompt}, ...]
1348
+
1349
+ function restoreDefaultLabels() {
1350
+ labelEditorState = JSON.parse(JSON.stringify(DEFAULT_CLIP_LABELS));
1351
+ renderLabelEditor();
1352
+ }
1353
+
1354
+ function clearAllLabels() {
1355
+ RELIGION_ORDER.forEach(rel => { labelEditorState[rel] = []; });
1356
+ renderLabelEditor();
1357
+ }
1358
+
1359
+ async function openLabelEditor() {
1360
+ const res = await fetch("/api/clip_labels");
1361
+ labelEditorState = await res.json();
1362
+ renderLabelEditor();
1363
+ document.getElementById("label-modal").style.display = "flex";
1364
+ }
1365
+
1366
+ function closeLabelEditor() {
1367
+ document.getElementById("label-modal").style.display = "none";
1368
+ }
1369
+
1370
+ const RELIGION_DISPLAY = {
1371
+ christianity: "Christianity", islam: "Islam",
1372
+ buddhism: "Buddhism", hinduism: "Hinduism", general: "General",
1373
+ };
1374
+
1375
+ function renderLabelEditor() {
1376
+ const body = document.getElementById("label-editor-body");
1377
+ body.innerHTML = "";
1378
+
1379
+ RELIGION_ORDER.forEach(rel => {
1380
+ const entries = labelEditorState[rel] || [];
1381
+ const sec = document.createElement("div");
1382
+ sec.style.cssText = "margin-bottom:18px;";
1383
+
1384
+ const heading = document.createElement("div");
1385
+ heading.style.cssText = "font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#888;margin-bottom:8px;";
1386
+ heading.textContent = RELIGION_DISPLAY[rel] || rel;
1387
+ sec.appendChild(heading);
1388
+
1389
+ // Tag row
1390
+ const tagRow = document.createElement("div");
1391
+ tagRow.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;";
1392
+ entries.forEach((e, idx) => {
1393
+ const tag = document.createElement("span");
1394
+ tag.title = e.prompt;
1395
+ tag.style.cssText = "display:inline-flex;align-items:center;gap:4px;padding:3px 8px 3px 10px;" +
1396
+ "background:#252525;border:1px solid #333;border-radius:20px;font-size:.78rem;color:#ccc;";
1397
+ tag.innerHTML = `${e.label} <button onclick="removeLabel('${rel}',${idx})" style="background:none;border:none;color:#666;cursor:pointer;font-size:.9rem;line-height:1;padding:0 2px;" title="Remove">Γ—</button>`;
1398
+ tagRow.appendChild(tag);
1399
+ });
1400
+ sec.appendChild(tagRow);
1401
+
1402
+ // Add row
1403
+ const addRow = document.createElement("div");
1404
+ addRow.style.cssText = "display:flex;gap:6px;";
1405
+ addRow.innerHTML = `
1406
+ <input placeholder="Short label" id="add-label-${rel}"
1407
+ style="flex:1;min-width:80px;background:#111;border:1px solid #333;border-radius:6px;
1408
+ padding:5px 8px;color:#ddd;font-size:.8rem;" />
1409
+ <input placeholder="Full prompt text" id="add-prompt-${rel}"
1410
+ style="flex:2;background:#111;border:1px solid #333;border-radius:6px;
1411
+ padding:5px 8px;color:#ddd;font-size:.8rem;" />
1412
+ <button onclick="addLabel('${rel}')"
1413
+ style="padding:5px 12px;border-radius:6px;border:1px solid #2e7d32;
1414
+ background:#1a2a1a;color:#81c784;cursor:pointer;font-size:.85rem;font-weight:600;">+</button>`;
1415
+ sec.appendChild(addRow);
1416
+
1417
+ // Enter key in either input triggers add
1418
+ ['add-label-', 'add-prompt-'].forEach(prefix => {
1419
+ setTimeout(() => {
1420
+ const el = document.getElementById(prefix + rel);
1421
+ if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') addLabel(rel); });
1422
+ }, 0);
1423
+ });
1424
+
1425
+ body.appendChild(sec);
1426
+ });
1427
+ }
1428
+
1429
+ function removeLabel(rel, idx) {
1430
+ labelEditorState[rel].splice(idx, 1);
1431
+ renderLabelEditor();
1432
+ }
1433
+
1434
+ function addLabel(rel) {
1435
+ const lEl = document.getElementById("add-label-" + rel);
1436
+ const pEl = document.getElementById("add-prompt-" + rel);
1437
+ const label = lEl.value.trim();
1438
+ const prompt = pEl.value.trim();
1439
+ if (!label || !prompt) { lEl.focus(); return; }
1440
+ if (!labelEditorState[rel]) labelEditorState[rel] = [];
1441
+ labelEditorState[rel].push({ label, prompt });
1442
+ lEl.value = ""; pEl.value = "";
1443
+ renderLabelEditor();
1444
+ }
1445
+
1446
+ async function saveLabels() {
1447
+ const btn = document.getElementById("btn-save-labels");
1448
+ btn.disabled = true;
1449
+ btn.textContent = "Saving…";
1450
+ try {
1451
+ const res = await fetch("/api/clip_labels", {
1452
+ method: "POST",
1453
+ headers: { "Content-Type": "application/json" },
1454
+ body: JSON.stringify(labelEditorState),
1455
+ });
1456
+ const data = await res.json();
1457
+ if (data.points) {
1458
+ data.points.forEach((c, i) => { if (points[i]) { points[i].x = c.x; points[i].y = c.y; } });
1459
+ clusters = []; pointLabels = []; selectedClusterId = null;
1460
+ document.body.classList.remove("has-cluster");
1461
+ }
1462
+ closeLabelEditor();
1463
+ renderPlot();
1464
+ Plotly.relayout("plot-wrap", { "xaxis.autorange": true, "yaxis.autorange": true });
1465
+ } finally {
1466
+ btn.disabled = false;
1467
+ btn.textContent = "Save & Recompute";
1468
+ }
1469
+ }
1470
+
1471
+ // ── Recompute t-SNE ───────────────────────────────────────────────────────────
1472
+
1473
+ async function triggerRecompute() {
1474
+ const btn = document.getElementById("btn-recompute");
1475
+ btn.disabled = true;
1476
+ btn.textContent = "β†Ί Running…";
1477
+ document.getElementById("loading").style.display = "flex";
1478
+ try {
1479
+ const res = await fetch("/api/recompute", { method: "POST" });
1480
+ const data = await res.json();
1481
+ data.points.forEach((c, i) => { if (points[i]) { points[i].x = c.x; points[i].y = c.y; } });
1482
+ clusters = []; pointLabels = []; selectedClusterId = null;
1483
+ document.body.classList.remove("has-cluster");
1484
+ renderPlot();
1485
+ Plotly.relayout("plot-wrap", { "xaxis.autorange": true, "yaxis.autorange": true });
1486
+ } finally {
1487
+ btn.disabled = false;
1488
+ btn.textContent = "β†Ί Recompute t-SNE";
1489
+ document.getElementById("loading").style.display = "none";
1490
+ }
1491
+ }
1492
+
1493
+ // ── Clusters ──────────────────────────────────────────────────────────────────
1494
+
1495
+ async function fetchClusters() {
1496
+ const res = await fetch("/api/cluster", { method: "POST" });
1497
+ const data = await res.json();
1498
+ clusters = data.clusters || [];
1499
+ pointLabels = data.point_labels || [];
1500
+ }
1501
+
1502
+ async function toggleClusters() {
1503
+ showClusters = !showClusters;
1504
+ const btn = document.getElementById("btn-cluster");
1505
+ if (showClusters) {
1506
+ btn.textContent = t("clusters_btn_on");
1507
+ btn.style.background = "#7c3aed22";
1508
+ btn.style.borderColor = "#7c3aed";
1509
+ btn.style.color = "#a78bfa";
1510
+ document.getElementById("loading").style.display = "flex";
1511
+ try {
1512
+ await fetchClusters();
1513
+ } finally {
1514
+ document.getElementById("loading").style.display = "none";
1515
+ }
1516
+ } else {
1517
+ btn.textContent = t("clusters_btn");
1518
+ btn.style.background = "#222";
1519
+ btn.style.borderColor = "#333";
1520
+ btn.style.color = "#aaa";
1521
+ clusters = [];
1522
+ pointLabels = [];
1523
+ selectedClusterId = null;
1524
+ document.body.classList.remove("has-cluster");
1525
+ }
1526
+ renderPlot();
1527
+ }
1528
+
1529
+ init();
1530
+ </script>
1531
+ </body>
1532
+ </html>
data/README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data
2
+
3
+ Download from Kaggle (needs an API token from https://www.kaggle.com/settings):
4
+
5
+ pip install kaggle
6
+ python data/download.py
7
+
8
+ Expected layout after download:
9
+
10
+ data/artwork_metadata.csv 3,997 rows β€” filename, religion (1,000 each of
11
+ buddhism / christianity / hinduism; 997 islam),
12
+ sub_religion, artist, title, year, place,
13
+ source, source_id, source_url, image_url
14
+ data/images/ the 3,997 images, filenames match the CSV
15
+ (download.py flattens Kaggle's images/images/)
16
+
17
+ Generated later by the pipeline (gitignored):
18
+
19
+ data/masks/ guarded background masks + verdicts.csv
20
+ data/features/ one parquet per feature family
data/download.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download the Religious Artwork Dataset from Kaggle into data/.
3
+
4
+ Requires the Kaggle API (pip install kaggle) and either ~/.kaggle/kaggle.json
5
+ or the KAGGLE_API_TOKEN environment variable
6
+ (create a token at https://www.kaggle.com/settings).
7
+
8
+ Usage: python data/download.py
9
+ """
10
+
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import zipfile
15
+ from pathlib import Path
16
+
17
+ DATASET = "zigaklun/religious-artwork-dataset"
18
+ DATA = Path(__file__).resolve().parent
19
+
20
+
21
+ def main():
22
+ zip_path = DATA / "religious-artwork-dataset.zip"
23
+ if not zip_path.exists():
24
+ print(f"Downloading {DATASET} (~6 GB) ...")
25
+ r = subprocess.run(["kaggle", "datasets", "download", "-d", DATASET,
26
+ "-p", str(DATA)])
27
+ if r.returncode != 0:
28
+ sys.exit("kaggle CLI failed β€” is your Kaggle API token set up?")
29
+ print("Unpacking ...")
30
+ with zipfile.ZipFile(zip_path) as z:
31
+ z.extractall(DATA)
32
+
33
+ # Kaggle nests the image folder: images/images/*.jpg -> flatten to images/
34
+ nested = DATA / "images" / "images"
35
+ if nested.is_dir():
36
+ for f in nested.iterdir():
37
+ shutil.move(str(f), DATA / "images" / f.name)
38
+ nested.rmdir()
39
+
40
+ n = len(list((DATA / "images").glob("*.jpg")))
41
+ print(f"Done. data/artwork_metadata.csv + data/images/ ({n} images, expected 3997)")
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
evaluation/family_accuracy.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Religion-classification accuracy per feature family, under two protocols:
3
+
4
+ pooled β€” stratified 5-fold CV: "is there any association at all?"
5
+ LOSO β€” leave-one-source-out: "does it transfer to an unseen museum?"
6
+
7
+ The gap between the two columns measures source leakage: features that
8
+ encode museum reproduction style (scan texture, framing) score high pooled
9
+ but collapse under LOSO.
10
+
11
+ Requires all feature parquets in data/features/ (run the extractors first).
12
+ Usage: python evaluation/family_accuracy.py
13
+ """
14
+
15
+ import warnings
16
+
17
+ warnings.filterwarnings("ignore")
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ from sklearn.ensemble import RandomForestClassifier
22
+ from sklearn.linear_model import LogisticRegression
23
+ from sklearn.model_selection import LeaveOneGroupOut, StratifiedKFold, cross_val_score
24
+ from sklearn.pipeline import make_pipeline
25
+ from sklearn.preprocessing import StandardScaler
26
+
27
+ SELECTED = "data/artwork_metadata.csv"
28
+ FEAT = "data/features"
29
+
30
+
31
+ def aligned(order, path, veccol=None, cols_=None):
32
+ df = pd.read_parquet(path).drop_duplicates("filename")
33
+ m = order[["filename"]].merge(df, on="filename", how="left")
34
+ if veccol is not None:
35
+ dim = len(next(v for v in m[veccol] if v is not None and not isinstance(v, float)))
36
+ return np.stack([np.asarray(v, float)
37
+ if v is not None and not isinstance(v, float)
38
+ else np.zeros(dim) for v in m[veccol]])
39
+ return m[cols_].fillna(0).to_numpy(float)
40
+
41
+
42
+ def main():
43
+ gold = pd.read_csv(SELECTED, dtype=str).drop_duplicates("filename")
44
+ hc = gold.merge(pd.read_parquet(f"{FEAT}/handcrafted.parquet"), on="filename")
45
+
46
+ def cols(*prefs):
47
+ return [c for c in hc.columns if c.startswith(prefs)]
48
+
49
+ FAM = {
50
+ "hc color": hc[cols("hc_hist_", "hc_norm_hist_", "hc_h_hist_", "hc_s_hist_",
51
+ "hc_v_hist_") + ["hc_avg_hue", "hc_avg_sat"]].to_numpy(),
52
+ "hc light": hc[["hc_brightness", "hc_contrast", "hc_darkness",
53
+ "hc_edge_density"]].to_numpy(),
54
+ "hc symmetry": hc[["hc_sym_lr", "hc_sym_tb"]].to_numpy(),
55
+ "hc flatness": hc[cols("hc_flatness_")].to_numpy(),
56
+ "hc texture": hc[cols("hc_fft_band_", "hc_lbp_")].to_numpy(),
57
+ "hc lines": hc[cols("hc_hough_", "hc_angle_hist_") + ["hc_straight_ratio"]].to_numpy(),
58
+ }
59
+ FAM["hc ALL"] = np.hstack(list(FAM.values()))
60
+ FAM["clip vector"] = aligned(hc, f"{FEAT}/clip.parquet", "clip_vector")
61
+ FAM["clip scores"] = aligned(hc, f"{FEAT}/clip.parquet", "clip_scores")
62
+ FAM["dino"] = aligned(hc, f"{FEAT}/dino.parquet", "dino_vector")
63
+ FAM["faces"] = aligned(hc, f"{FEAT}/faces.parquet", "face_vector")
64
+ FAM["pose"] = aligned(hc, f"{FEAT}/pose.parquet",
65
+ cols_=[f"main_skel_{i}" for i in range(34)]
66
+ + ["main_area", "n_persons"])
67
+
68
+ y, src = hc["religion"].to_numpy(), hc["source"].to_numpy()
69
+ cv = StratifiedKFold(5, shuffle=True, random_state=0)
70
+ logo = LeaveOneGroupOut()
71
+
72
+ def model(dim):
73
+ if dim > 100:
74
+ return make_pipeline(StandardScaler(), LogisticRegression(max_iter=3000))
75
+ return RandomForestClassifier(300, random_state=0, n_jobs=-1)
76
+
77
+ print(f"{'family':14s} {'dims':>5s} {'pooled':>7s} {'LOSO':>6s} (chance 0.250)")
78
+ for name, X in FAM.items():
79
+ pooled = cross_val_score(model(X.shape[1]), X, y, cv=cv).mean()
80
+ parts = []
81
+ for tr, te in logo.split(X, y, src):
82
+ mdl = model(X.shape[1])
83
+ mdl.fit(X[tr], y[tr])
84
+ parts.append((len(te), mdl.score(X[te], y[te])))
85
+ loso = sum(n * s for n, s in parts) / sum(n for n, s in parts)
86
+ print(f"{name:14s} {X.shape[1]:5d} {pooled:7.3f} {loso:6.3f}")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
features/README.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Feature extractors
2
+
3
+ One script per family. All read `data/artwork_metadata.csv` + `data/images/`, write to
4
+ `data/features/`, checkpoint regularly, and resume if re-run.
5
+
6
+ | family | script | dims | what it measures |
7
+ |--------------|---------------------------|-----:|------------------|
8
+ | hand-crafted | `extract_handcrafted.py` | 380 | color (HSV joint + grey-world + per-channel histograms), light (brightness/contrast/darkness/edge density), symmetry, flatness (SLIC superpixel LAB uniformity), texture (FFT radial bands + 59-bin nri-uniform LBP), lines (Hough count/density, straight ratio, edge-angle histogram) |
9
+ | CLIP | `extract_clip.py` | 512 + 27 | ViT-B/16 image embedding + zero-shot cosine scores against 27 hand-written religious-attribute prompts |
10
+ | DINOv2 | `extract_dino.py` | 768 | self-supervised CLS token β€” visual style/appearance similarity |
11
+ | faces | `extract_faces.py` | 39 | YuNet detections summarized: count, coverage, sizes, positions, tilt/frontality, arrangement, spatial histograms |
12
+ | pose | `extract_pose.py` | 39 | YOLOv8-pose, **largest figure only**: torso-normalised 17-keypoint skeleton + figure area + detection-quality columns + person count |
13
+
14
+ Notes:
15
+
16
+ - Hand-crafted features use the corrected preprocessing pipeline: images are
17
+ border-cropped, guardedly background-masked (see `preprocessing/`), and
18
+ capped at 1024 px on the longest side so texture/edge features measure the
19
+ artwork rather than the museum's scan resolution.
20
+ - The pose vector stores quality indicators (`main_kpt_conf`, `main_torso_px`)
21
+ because photo-trained detectors are unreliable on stylised bodies β€” filter
22
+ on them before interpreting posture.
features/extract_clip.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CLIP (ViT-B/16) features for every image in data/artwork_metadata.csv.
3
+
4
+ Two outputs per image:
5
+ clip_vector β€” 512-dim L2-normalised image embedding
6
+ clip_scores β€” 27 cosine similarities against religious visual attributes
7
+
8
+ Output: data/features/clip.parquet (checkpointed, resumable)
9
+ Usage: python features/extract_clip.py [--batch-size 64]
10
+ """
11
+
12
+ import argparse
13
+ from pathlib import Path
14
+
15
+ import clip
16
+ import numpy as np
17
+ import pandas as pd
18
+ import torch
19
+ from PIL import Image
20
+ from tqdm import tqdm
21
+
22
+ IMAGES = Path("data/images")
23
+ SELECTED = Path("data/artwork_metadata.csv")
24
+ OUTPUT = Path("data/features/clip.parquet")
25
+
26
+ ATTRIBUTES = [
27
+ # Christianity
28
+ "a golden halo around a figure", "a crucifix or cross", "the virgin mary",
29
+ "angels with wings", "a nativity scene", "biblical figures in robes",
30
+ # Islam
31
+ "arabic calligraphy", "geometric arabesque patterns", "a mosque with a minaret",
32
+ "an illuminated quran manuscript", "islamic tile patterns",
33
+ # Buddhism
34
+ "a buddha statue", "a lotus flower", "a thangka painting", "a mandala",
35
+ "a dharma wheel", "a bodhisattva figure",
36
+ # Hinduism
37
+ "a deity with multiple arms", "ganesha the elephant god", "a colorful hindu temple",
38
+ "krishna playing the flute", "dancing shiva nataraja",
39
+ # Cross-religion / general
40
+ "gold leaf background", "religious figures in prayer", "sacred geometry",
41
+ "incense and ritual objects", "a religious manuscript",
42
+ ]
43
+
44
+ Image.MAX_IMAGE_PIXELS = None
45
+
46
+
47
+ def main():
48
+ ap = argparse.ArgumentParser()
49
+ ap.add_argument("--batch-size", type=int, default=64)
50
+ ap.add_argument("--save-every", type=int, default=10, help="batches between checkpoints")
51
+ ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
52
+ args = ap.parse_args()
53
+
54
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
55
+ sel = pd.read_csv(SELECTED, dtype=str).drop_duplicates("filename")
56
+ existing = pd.read_parquet(OUTPUT) if OUTPUT.exists() else pd.DataFrame(columns=["filename"])
57
+ done = set(existing["filename"])
58
+ todo = [f for f in sel["filename"] if f not in done]
59
+ print(f"total={len(sel)} done={len(done)} todo={len(todo)}")
60
+
61
+ model, preprocess = clip.load("ViT-B/16", device=args.device)
62
+ model.eval()
63
+ with torch.no_grad():
64
+ tokens = clip.tokenize(ATTRIBUTES).to(args.device)
65
+ text = model.encode_text(tokens).float()
66
+ text = text / text.norm(dim=-1, keepdim=True)
67
+
68
+ rows = existing.to_dict("records")
69
+ batch, names = [], []
70
+
71
+ def flush():
72
+ nonlocal batch, names
73
+ if not batch:
74
+ return
75
+ with torch.no_grad():
76
+ imgs = torch.stack(batch).to(args.device)
77
+ feats = model.encode_image(imgs).float()
78
+ feats = feats / feats.norm(dim=-1, keepdim=True)
79
+ scores = (feats @ text.T).cpu().numpy()
80
+ vecs = feats.cpu().numpy()
81
+ for fn, v, s in zip(names, vecs, scores):
82
+ rows.append({"filename": fn, "clip_vector": v.tolist(), "clip_scores": s.tolist()})
83
+ batch, names = [], []
84
+
85
+ for i, fn in enumerate(tqdm(todo)):
86
+ try:
87
+ batch.append(preprocess(Image.open(IMAGES / fn).convert("RGB")))
88
+ names.append(fn)
89
+ except Exception as e:
90
+ print(f"FAIL {fn}: {e}")
91
+ if len(batch) >= args.batch_size:
92
+ flush()
93
+ if (i // args.batch_size) % args.save_every == 0:
94
+ pd.DataFrame(rows).to_parquet(OUTPUT, index=False)
95
+ flush()
96
+ pd.DataFrame(rows).to_parquet(OUTPUT, index=False)
97
+ print(f"Wrote {OUTPUT}: {len(rows)} rows")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
features/extract_dino.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DINOv2 (dinov2_vitb14) CLS-token features for every image in data/artwork_metadata.csv.
3
+
4
+ Output: data/features/dino.parquet (checkpointed, resumable)
5
+ filename, dino_vector (768 float32)
6
+
7
+ Usage: python features/extract_dino.py [--batch-size 32]
8
+ """
9
+
10
+ import argparse
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+ import torch
15
+ from PIL import Image
16
+ from torchvision import transforms
17
+ from tqdm import tqdm
18
+
19
+ IMAGES = Path("data/images")
20
+ SELECTED = Path("data/artwork_metadata.csv")
21
+ OUTPUT = Path("data/features/dino.parquet")
22
+
23
+ TRANSFORM = transforms.Compose([
24
+ transforms.Resize(256),
25
+ transforms.CenterCrop(224),
26
+ transforms.ToTensor(),
27
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
28
+ ])
29
+
30
+ Image.MAX_IMAGE_PIXELS = None
31
+
32
+
33
+ def main():
34
+ ap = argparse.ArgumentParser()
35
+ ap.add_argument("--batch-size", type=int, default=32)
36
+ ap.add_argument("--save-every", type=int, default=10)
37
+ ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
38
+ args = ap.parse_args()
39
+
40
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
41
+ sel = pd.read_csv(SELECTED, dtype=str).drop_duplicates("filename")
42
+ existing = pd.read_parquet(OUTPUT) if OUTPUT.exists() else pd.DataFrame(columns=["filename"])
43
+ done = set(existing["filename"])
44
+ todo = [f for f in sel["filename"] if f not in done]
45
+ print(f"total={len(sel)} done={len(done)} todo={len(todo)}")
46
+
47
+ model = torch.hub.load("facebookresearch/dinov2", "dinov2_vitb14", verbose=False)
48
+ model.eval().to(args.device)
49
+
50
+ rows = existing.to_dict("records")
51
+ batch, names = [], []
52
+
53
+ def flush():
54
+ nonlocal batch, names
55
+ if not batch:
56
+ return
57
+ with torch.no_grad():
58
+ feats = model(torch.stack(batch).to(args.device)).cpu().float().numpy()
59
+ for fn, v in zip(names, feats):
60
+ rows.append({"filename": fn, "dino_vector": v.tolist()})
61
+ batch, names = [], []
62
+
63
+ for i, fn in enumerate(tqdm(todo)):
64
+ try:
65
+ batch.append(TRANSFORM(Image.open(IMAGES / fn).convert("RGB")))
66
+ names.append(fn)
67
+ except Exception as e:
68
+ print(f"FAIL {fn}: {e}")
69
+ if len(batch) >= args.batch_size:
70
+ flush()
71
+ if (i // args.batch_size) % args.save_every == 0:
72
+ pd.DataFrame(rows).to_parquet(OUTPUT, index=False)
73
+ flush()
74
+ pd.DataFrame(rows).to_parquet(OUTPUT, index=False)
75
+ print(f"Wrote {OUTPUT}: {len(rows)} rows")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
features/extract_faces.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Face feature extractor β€” YuNet (YOLO-architecture, cv2.FaceDetectorYN).
3
+
4
+ WHY YuNet over BlazeFace:
5
+ YuNet is specifically trained for face detection with a YOLO-style architecture.
6
+ It handles small, rotated, and stylised painted faces better than BlazeFace full-range
7
+ while producing fewer false positives at score_threshold=0.7.
8
+
9
+ Each YuNet detection row: [x, y, w, h, re_x, re_y, le_x, le_y,
10
+ nose_x, nose_y, rm_x, rm_y, lm_x, lm_y, score]
11
+ re = right eye, le = left eye, nose = nose tip, rm/lm = right/left mouth corner.
12
+
13
+ Encodes each image as a 39-dim face vector:
14
+
15
+ Basic presence (3):
16
+ [0] face_detected
17
+ [1] log1p(n_faces) / log1p(10) β€” normalised count
18
+ [2] crowd indicator β€” 1 if n_faces > 5
19
+
20
+ Coverage & size (5):
21
+ [3] total_coverage β€” Ξ£(bbox_area) / img_area
22
+ [4] mean_face_size
23
+ [5] max_face_size β€” largest face / img_area
24
+ [6] dominance_ratio β€” max_size / mean_size
25
+ [7] face_size_std
26
+
27
+ Size distribution (2):
28
+ [8] size_entropy β€” entropy of normalised size distribution
29
+ [9] largest_face_fraction β€” max_size / total_coverage
30
+
31
+ Spatial centroid & spread (4):
32
+ [10] centroid_x β€” area-weighted
33
+ [11] centroid_y
34
+ [12] spread_x β€” Οƒ of face x-centres
35
+ [13] spread_y
36
+
37
+ Orientation (4):
38
+ [14] mean_tilt β€” roll from eye vector / 90 (0 = level)
39
+ [15] tilt_std
40
+ [16] mean_frontal_score β€” 1 = frontal, 0 = profile (nose-eye landmarks)
41
+ [17] frontal_ratio β€” fraction of faces with frontal_score > 0.6
42
+
43
+ Composition (5):
44
+ [18] vertical_bias β€” mean_cy - 0.5 (neg = upper, pos = lower)
45
+ [19] arrangement_rowness β€” std_y / (std_x + Ξ΅): low = row, high = column
46
+ [20] mean_pairwise_dist β€” mean normalised dist between all face pairs
47
+ [21] min_pairwise_dist β€” closest pair distance
48
+ [22] clustering_score β€” fraction of pairs within 0.15 distance
49
+
50
+ Spatial histograms (8):
51
+ [23-26] 4-bin horizontal histogram
52
+ [27-30] 4-bin vertical histogram
53
+
54
+ 4Γ—2 spatial grid (8):
55
+ [31-38] which grid cell(s) hold faces (4 horiz Γ— 2 vert, row-major)
56
+
57
+ Total: 39 dims.
58
+
59
+ Output: Religion_art_dataset/features_faces.parquet
60
+ filename, face_detected, face_count, face_vector (list[float32], 39 dims)
61
+
62
+ Usage:
63
+ python features/extract_faces.py
64
+ python features/extract_faces.py --limit 100
65
+ """
66
+
67
+ import argparse
68
+ import os
69
+ import sys
70
+ import urllib.request
71
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
72
+
73
+ import cv2
74
+ import numpy as np
75
+ import pandas as pd
76
+ from tqdm import tqdm
77
+
78
+ IMAGES_DIR = "data/images"
79
+ METADATA_CSV = "data/artwork_metadata.csv"
80
+
81
+ OUTPUT = "data/features/faces.parquet"
82
+ MODELS_DIR = os.path.join(os.path.dirname(__file__), "models")
83
+ FACE_DIMS = 39
84
+ SCORE_THR = 0.7
85
+ NMS_THR = 0.3
86
+
87
+ MODEL_PATH = os.path.join(MODELS_DIR, "face_detection_yunet_2023mar.onnx")
88
+ MODEL_URL = (
89
+ "https://github.com/opencv/opencv_zoo/raw/main/models/"
90
+ "face_detection_yunet/face_detection_yunet_2023mar.onnx"
91
+ )
92
+
93
+ FACE_COLS = ["filename", "face_detected", "face_count", "face_vector"]
94
+
95
+
96
+ def ensure_model():
97
+ os.makedirs(MODELS_DIR, exist_ok=True)
98
+ if not os.path.exists(MODEL_PATH):
99
+ print(f"Downloading YuNet model (~380 KB) β†’ {MODEL_PATH}")
100
+ urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
101
+ print("Download complete.")
102
+
103
+
104
+ def _zero_vec() -> list:
105
+ return [0.0] * FACE_DIMS
106
+
107
+
108
+ def _size_entropy(sizes: np.ndarray) -> float:
109
+ if len(sizes) <= 1:
110
+ return 0.0
111
+ p = sizes / (sizes.sum() + 1e-10)
112
+ return float(-np.sum(p * np.log(p + 1e-10)))
113
+
114
+
115
+ def _frontal_score(re_x, le_x, nose_x) -> float:
116
+ """
117
+ Estimate how frontal a face is from YuNet landmarks (all in normalised [0,1] coords).
118
+ Returns 1.0 for perfectly frontal, 0.0 for fully in profile.
119
+ """
120
+ eye_mid = (re_x + le_x) / 2.0
121
+ eye_dist = abs(re_x - le_x)
122
+ nose_off = abs(nose_x - eye_mid)
123
+ profile = nose_off / (eye_dist + 1e-6)
124
+ return float(max(0.0, 1.0 - min(profile, 1.0)))
125
+
126
+
127
+ def _pairwise_stats(cx: np.ndarray, cy: np.ndarray) -> tuple:
128
+ n = len(cx)
129
+ if n < 2:
130
+ return 0.0, 0.0, 0.0
131
+ dists = []
132
+ for i in range(n):
133
+ for j in range(i + 1, n):
134
+ dists.append(float(np.sqrt((cx[i]-cx[j])**2 + (cy[i]-cy[j])**2)))
135
+ dists = np.array(dists)
136
+ return float(dists.mean()), float(dists.min()), float((dists < 0.15).mean())
137
+
138
+
139
+ def encode_faces(faces, img_h: int, img_w: int) -> tuple:
140
+ """
141
+ Build a 39-dim face vector from YuNet detections.
142
+ faces: numpy array (N, 15) β€” None or empty β†’ zero vector.
143
+ """
144
+ if faces is None or len(faces) == 0:
145
+ return 0, 0, _zero_vec()
146
+
147
+ vec = np.zeros(FACE_DIMS, dtype=np.float32)
148
+ img_area = float(img_h * img_w) or 1.0
149
+ n = len(faces)
150
+
151
+ # Normalise all coordinates to [0, 1]
152
+ sizes, cx_list, cy_list, tilts, frontal_scores = [], [], [], [], []
153
+
154
+ for f in faces:
155
+ x, y, w, h = f[0], f[1], f[2], f[3]
156
+ # normalised bbox
157
+ nw = w / img_w
158
+ nh = h / img_h
159
+ cx = (x + w / 2.0) / img_w
160
+ cy = (y + h / 2.0) / img_h
161
+ sizes.append(nw * nh)
162
+ cx_list.append(cx)
163
+ cy_list.append(cy)
164
+
165
+ # landmarks (pixel β†’ normalised)
166
+ re_x, re_y = f[4] / img_w, f[5] / img_h
167
+ le_x, le_y = f[6] / img_w, f[7] / img_h
168
+ dx = re_x - le_x
169
+ dy = re_y - le_y
170
+ tilts.append(float(np.degrees(np.arctan2(dy, dx))))
171
+
172
+ nose_x = f[8] / img_w
173
+ frontal_scores.append(_frontal_score(re_x, le_x, nose_x))
174
+
175
+ sizes = np.array(sizes, dtype=np.float32)
176
+ cx_arr = np.array(cx_list, dtype=np.float32)
177
+ cy_arr = np.array(cy_list, dtype=np.float32)
178
+
179
+ # ── [0-2] basic presence ─────────────────────────────────────────────────
180
+ vec[0] = 1.0
181
+ vec[1] = float(np.log1p(n) / np.log1p(10))
182
+ vec[2] = float(n > 5)
183
+
184
+ # ── [3-7] coverage & size ────────────────────────────────────────────────
185
+ vec[3] = float(sizes.sum())
186
+ vec[4] = float(sizes.mean())
187
+ vec[5] = float(sizes.max())
188
+ vec[6] = float(sizes.max() / (sizes.mean() + 1e-6))
189
+ vec[7] = float(sizes.std()) if n > 1 else 0.0
190
+
191
+ # ── [8-9] size distribution ──────────────────────────────────────────────
192
+ vec[8] = _size_entropy(sizes)
193
+ vec[9] = float(sizes.max() / (sizes.sum() + 1e-6))
194
+
195
+ # ── [10-13] spatial centroid & spread ────────────────────────────────────
196
+ w_sum = sizes.sum() + 1e-6
197
+ vec[10] = float((cx_arr * sizes).sum() / w_sum)
198
+ vec[11] = float((cy_arr * sizes).sum() / w_sum)
199
+ vec[12] = float(cx_arr.std()) if n > 1 else 0.0
200
+ vec[13] = float(cy_arr.std()) if n > 1 else 0.0
201
+
202
+ # ── [14-17] orientation ──────────────────────────────────────────────────
203
+ vec[14] = float(np.mean(tilts)) / 90.0
204
+ vec[15] = float(np.std(tilts)) / 90.0 if n > 1 else 0.0
205
+ fs_arr = np.array(frontal_scores, dtype=np.float32)
206
+ vec[16] = float(fs_arr.mean())
207
+ vec[17] = float((fs_arr > 0.6).mean())
208
+
209
+ # ── [18-22] composition ──────────────────────────────────────────────────
210
+ vec[18] = float(cy_arr.mean()) - 0.5
211
+ vec[19] = float(cy_arr.std() / (cx_arr.std() + 1e-6)) if n > 1 else 0.0
212
+ mean_pd, min_pd, clust = _pairwise_stats(cx_arr, cy_arr)
213
+ vec[20] = mean_pd
214
+ vec[21] = min_pd
215
+ vec[22] = clust
216
+
217
+ # ── [23-26] horizontal histogram ─────────────────────────────────────────
218
+ h_hist, _ = np.histogram(cx_arr, bins=4, range=(0.0, 1.0))
219
+ vec[23:27] = h_hist.astype(np.float32) / (n + 1e-6)
220
+
221
+ # ── [27-30] vertical histogram ───────────────────────────────────────────
222
+ v_hist, _ = np.histogram(cy_arr, bins=4, range=(0.0, 1.0))
223
+ vec[27:31] = v_hist.astype(np.float32) / (n + 1e-6)
224
+
225
+ # ── [31-38] 4Γ—2 spatial grid ─────────────────────────────────────────────
226
+ grid = np.zeros((2, 4), dtype=np.float32)
227
+ for cx, cy in zip(cx_list, cy_list):
228
+ r = min(int(cy * 2), 1)
229
+ c = min(int(cx * 4), 3)
230
+ grid[r, c] += 1.0
231
+ grid /= (n + 1e-6)
232
+ vec[31:39] = grid.ravel()
233
+
234
+ return 1, n, vec.tolist()
235
+
236
+
237
+ def _is_correct(v) -> bool:
238
+ return hasattr(v, "__len__") and len(v) == FACE_DIMS
239
+
240
+
241
+ def load_existing() -> pd.DataFrame:
242
+ if os.path.exists(OUTPUT):
243
+ df = pd.read_parquet(OUTPUT)
244
+ if "face_vector" not in df.columns:
245
+ return pd.DataFrame(columns=FACE_COLS)
246
+ return df[FACE_COLS]
247
+ return pd.DataFrame(columns=FACE_COLS)
248
+
249
+
250
+ def main():
251
+ parser = argparse.ArgumentParser()
252
+ parser.add_argument("--limit", type=int, default=None)
253
+ parser.add_argument("--save-every", type=int, default=500)
254
+ parser.add_argument("--score-thr", type=float, default=SCORE_THR)
255
+ parser.add_argument("--nms-thr", type=float, default=NMS_THR)
256
+ parser.add_argument("--subset", default=None,
257
+ help="CSV with a 'filename' column to restrict processing to")
258
+ args = parser.parse_args()
259
+
260
+ ensure_model()
261
+
262
+ meta = pd.read_csv(METADATA_CSV, dtype=str)[["filename"]]
263
+ if args.subset:
264
+ keep = set(pd.read_csv(args.subset, dtype=str)["filename"].tolist())
265
+ meta = meta[meta["filename"].isin(keep)].reset_index(drop=True)
266
+ print(f"Subset: {len(meta)} filenames from {args.subset}")
267
+ existing = load_existing()
268
+
269
+ if "face_vector" in existing.columns and len(existing):
270
+ correct_mask = existing["face_vector"].apply(_is_correct)
271
+ correct = existing[correct_mask].copy()
272
+ else:
273
+ correct = pd.DataFrame(columns=FACE_COLS)
274
+
275
+ done_fns = set(correct["filename"].tolist())
276
+ todo = meta[~meta["filename"].isin(done_fns)].reset_index(drop=True)
277
+ if args.limit:
278
+ todo = todo.head(args.limit)
279
+
280
+ n_stale = len(existing) - len(correct)
281
+ print(f"Faces (YuNet {FACE_DIMS}-dim, score_thr={args.score_thr}): "
282
+ f"{len(correct)} correct, {n_stale} stale, {len(todo)} new.")
283
+
284
+ if todo.empty:
285
+ print("Nothing to do.")
286
+ return
287
+
288
+ new_rows = []
289
+
290
+ for _, meta_row in tqdm(todo.iterrows(), total=len(todo), desc="Faces"):
291
+ fn = meta_row["filename"]
292
+ img_path = os.path.join(IMAGES_DIR, fn)
293
+
294
+ detected, n_faces, face_vec = 0, 0, _zero_vec()
295
+
296
+ if os.path.exists(img_path):
297
+ img = cv2.imread(img_path)
298
+ if img is not None:
299
+ h, w = img.shape[:2]
300
+ detector = cv2.FaceDetectorYN.create(
301
+ MODEL_PATH, "", (w, h),
302
+ score_threshold=args.score_thr,
303
+ nms_threshold=args.nms_thr,
304
+ )
305
+ _, faces = detector.detect(img)
306
+ detected, n_faces, face_vec = encode_faces(faces, h, w)
307
+
308
+ new_rows.append({
309
+ "filename": fn,
310
+ "face_detected": detected,
311
+ "face_count": n_faces,
312
+ "face_vector": face_vec,
313
+ })
314
+
315
+ if len(new_rows) >= args.save_every:
316
+ correct = pd.concat([correct, pd.DataFrame(new_rows)], ignore_index=True)
317
+ correct.to_parquet(OUTPUT, index=False)
318
+ new_rows = []
319
+
320
+ if new_rows:
321
+ correct = pd.concat([correct, pd.DataFrame(new_rows)], ignore_index=True)
322
+
323
+ correct.to_parquet(OUTPUT, index=False)
324
+
325
+ n_det = int(correct["face_detected"].sum())
326
+ n_tot = len(correct)
327
+ print(f"Done. {n_tot} rows, {n_det} faces detected ({100*n_det/n_tot:.1f}%)")
328
+ print(f"Saved β†’ {OUTPUT}")
329
+
330
+
331
+ if __name__ == "__main__":
332
+ main()
features/extract_handcrafted.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hand-crafted features for the gold 4x1000 subset β€” corrected pipeline.
3
+
4
+ Mirrors notebooks/handcrafted.ipynb (2026-07-07):
5
+ 1. crop_padding β€” strip near-black letterbox borders
6
+ 2. guarded rembg mask β€” PRE-COMPUTED: data/masks/
7
+ (verdicts.csv; mask applied only when it removes a
8
+ solid, convex, edge-touching, uniform background)
9
+ 3. applied images β€” spatial views cropped to mask bbox; mask limits
10
+ color/light stats. rejected images β€” full image.
11
+
12
+ Differences vs extract_handcrafted.py: masking is guarded (not unconditional),
13
+ spatial features get bbox-crop instead of nothing, no inline rembg (cache only).
14
+
15
+ Output: data/features/handcrafted.parquet (key: id, str)
16
+ Usage: python features/extract_handcrafted_gold.py [--workers 4]
17
+ """
18
+
19
+ import argparse
20
+ import os
21
+ import sys
22
+ from multiprocessing.pool import ThreadPool
23
+ from pathlib import Path
24
+
25
+ import cv2
26
+ import numpy as np
27
+ import pandas as pd
28
+ from PIL import Image
29
+ from skimage.feature import local_binary_pattern
30
+ from skimage.segmentation import slic
31
+ from tqdm import tqdm
32
+
33
+ ROOT = Path(__file__).resolve().parent.parent
34
+ IMAGES = ROOT / "data/images"
35
+ MASK_DIR = ROOT / "data/masks"
36
+ VERDICT_CSV = MASK_DIR / "verdicts.csv"
37
+ SELECTED = ROOT / "data/artwork_metadata.csv"
38
+ OUTPUT = ROOT / "data/features/handcrafted.parquet"
39
+
40
+ HIST_BINS = (8, 4, 4)
41
+ LBP_P = 8
42
+ LBP_BINS = LBP_P * (LBP_P - 1) + 3
43
+ MAX_SIDE = 1024 # cap resolution: texture/edge features stay comparable across
44
+ # museum scan sizes; unbounded FFT/SLIC on 33 MP scans OOMs
45
+
46
+ Image.MAX_IMAGE_PIXELS = None
47
+
48
+
49
+ def crop_padding(img_rgb, threshold=5):
50
+ gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
51
+ rows = np.where(gray.max(axis=1) > threshold)[0]
52
+ cols = np.where(gray.max(axis=0) > threshold)[0]
53
+ if len(rows) == 0 or len(cols) == 0:
54
+ return img_rgb
55
+ return img_rgb[rows[0]:rows[-1] + 1, cols[0]:cols[-1] + 1]
56
+
57
+
58
+ def grey_world_normalize(img_rgb):
59
+ f = img_rgb.astype(np.float32)
60
+ means = f.reshape(-1, 3).mean(0) + 1e-6
61
+ f *= means.mean() / means
62
+ return np.clip(f, 0, 255).astype(np.uint8)
63
+
64
+
65
+ VER = pd.read_csv(VERDICT_CSV, dtype=str).set_index("filename")
66
+
67
+
68
+ def load_final(filename):
69
+ """(rgb, hsv, gray, edges, mask) under the corrected pipeline."""
70
+ img = crop_padding(np.array(Image.open(IMAGES / filename).convert("RGB")))
71
+ mask = None
72
+ if filename in VER.index:
73
+ v = VER.loc[filename]
74
+ if v["verdict"] == "applied":
75
+ m = cv2.imread(str(MASK_DIR / (Path(filename).stem + ".png")),
76
+ cv2.IMREAD_GRAYSCALE)
77
+ if m is not None and m.shape == img.shape[:2]:
78
+ y0, y1 = int(v["y0"]), int(v["y1"])
79
+ x0, x1 = int(v["x0"]), int(v["x1"])
80
+ img, mask = img[y0:y1 + 1, x0:x1 + 1], m[y0:y1 + 1, x0:x1 + 1]
81
+ scale = MAX_SIDE / max(img.shape[:2])
82
+ if scale < 1.0:
83
+ size = (round(img.shape[1] * scale), round(img.shape[0] * scale))
84
+ img = cv2.resize(img, size, interpolation=cv2.INTER_AREA)
85
+ if mask is not None:
86
+ mask = cv2.resize(mask, size, interpolation=cv2.INTER_NEAREST)
87
+ hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
88
+ gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
89
+ return img, hsv, gray, cv2.Canny(gray, 100, 200), mask
90
+
91
+
92
+ def extract_one(filename):
93
+ try:
94
+ img, hsv, gray, edges, mask = load_final(filename)
95
+ row = {"filename": filename, "hc_fg_applied": int(mask is not None)}
96
+
97
+ # color
98
+ hist = cv2.calcHist([hsv], [0, 1, 2], mask, list(HIST_BINS),
99
+ [0, 180, 0, 256, 0, 256])
100
+ cv2.normalize(hist, hist)
101
+ for i, v in enumerate(hist.flatten()):
102
+ row[f"hc_hist_{i}"] = float(v)
103
+ hsv_n = cv2.cvtColor(grey_world_normalize(img), cv2.COLOR_RGB2HSV)
104
+ hist_n = cv2.calcHist([hsv_n], [0, 1, 2], mask, list(HIST_BINS),
105
+ [0, 180, 0, 256, 0, 256])
106
+ cv2.normalize(hist_n, hist_n)
107
+ for i, v in enumerate(hist_n.flatten()):
108
+ row[f"hc_norm_hist_{i}"] = float(v)
109
+ h, s, v_ = cv2.split(hsv)
110
+ for pref, ch, bins, rng in [("h", h, 16, [0, 180]),
111
+ ("s", s, 8, [0, 256]),
112
+ ("v", v_, 8, [0, 256])]:
113
+ c = cv2.calcHist([ch], [0], mask, [bins], rng).flatten()
114
+ c /= c.sum() + 1e-10
115
+ for i, x in enumerate(c):
116
+ row[f"hc_{pref}_hist_{i}"] = float(x)
117
+
118
+ # light + color scalars (masked), edge density (full frame)
119
+ sel_g = gray if mask is None else gray[mask > 0]
120
+ sel_hsv = hsv.reshape(-1, 3) if mask is None else hsv[mask > 0]
121
+ row["hc_avg_hue"] = float(sel_hsv[:, 0].mean())
122
+ row["hc_avg_sat"] = float(sel_hsv[:, 1].mean())
123
+ row["hc_brightness"] = float(sel_hsv[:, 2].mean())
124
+ row["hc_contrast"] = float(sel_g.std())
125
+ row["hc_darkness"] = float((sel_g < 64).mean())
126
+ row["hc_edge_density"] = float((edges > 0).mean())
127
+
128
+ # symmetry
129
+ hh, ww = gray.shape
130
+ row["hc_sym_lr"] = 1.0 - float(np.abs(
131
+ gray[:, :ww // 2].astype(np.float32)
132
+ - np.fliplr(gray[:, ww - ww // 2:]).astype(np.float32)).mean()) / 255.0
133
+ row["hc_sym_tb"] = 1.0 - float(np.abs(
134
+ gray[:hh // 2, :].astype(np.float32)
135
+ - np.flipud(gray[hh - hh // 2:, :]).astype(np.float32)).mean()) / 255.0
136
+
137
+ # flatness
138
+ lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB).astype(np.float32)
139
+ segments = slic(lab / 255.0, n_segments=200, compactness=10,
140
+ start_label=0, channel_axis=2)
141
+ stds = [[], [], []]
142
+ for sid in np.unique(segments):
143
+ region = lab[segments == sid]
144
+ if len(region) > 1:
145
+ for c in range(3):
146
+ stds[c].append(float(region[:, c].std()))
147
+ sl, sa, sb = (float(np.mean(s_)) if s_ else 0.0 for s_ in stds)
148
+ row["hc_flatness_l"], row["hc_flatness_a"] = sl, sa
149
+ row["hc_flatness_b"], row["hc_flatness_mean"] = sb, (sl + sa + sb) / 3.0
150
+
151
+ # geometry
152
+ power = np.abs(np.fft.fftshift(np.fft.fft2(gray.astype(np.float32)))) ** 2
153
+ ph, pw = power.shape
154
+ cy, cx = ph // 2, pw // 2
155
+ r = np.sqrt((np.arange(pw) - cx) ** 2 + (np.arange(ph)[:, None] - cy) ** 2)
156
+ edges_r = np.logspace(0, np.log10(min(cx, cy)), 9)
157
+ bands = []
158
+ for lo, hi in zip(edges_r[:-1], edges_r[1:]):
159
+ sel = (r >= lo) & (r < hi)
160
+ bands.append(float(power[sel].mean()) if sel.any() else 0.0)
161
+ total = sum(bands) + 1e-10
162
+ for i, v in enumerate(bands):
163
+ row[f"hc_fft_band_{i}"] = v / total
164
+ lbp = local_binary_pattern(gray, P=LBP_P, R=1, method="nri_uniform")
165
+ lh, _ = np.histogram(lbp.ravel(), bins=LBP_BINS, range=(0, LBP_BINS))
166
+ lh = lh / (lh.sum() + 1e-10)
167
+ for i, v in enumerate(lh):
168
+ row[f"hc_lbp_{i}"] = float(v)
169
+
170
+ # lines
171
+ lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=50,
172
+ minLineLength=20, maxLineGap=5)
173
+ if lines is not None:
174
+ total_len = float(sum(np.hypot(x2 - x1, y2 - y1)
175
+ for x1, y1, x2, y2 in lines[:, 0]))
176
+ hough_mask = np.zeros_like(edges)
177
+ for x1, y1, x2, y2 in lines[:, 0]:
178
+ cv2.line(hough_mask, (x1, y1), (x2, y2), 255, 1)
179
+ row["hc_hough_count"] = len(lines)
180
+ row["hc_hough_density"] = total_len / (hh * ww)
181
+ row["hc_straight_ratio"] = (float((hough_mask > 0).sum())
182
+ / (float((edges > 0).sum()) + 1e-10))
183
+ else:
184
+ row["hc_hough_count"] = 0
185
+ row["hc_hough_density"] = 0.0
186
+ row["hc_straight_ratio"] = 0.0
187
+ gx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
188
+ gy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
189
+ mag = np.hypot(gx, gy)
190
+ angle = np.degrees(np.arctan2(gy, gx)) % 180
191
+ ah, _ = np.histogram(angle.ravel(), bins=8, range=(0, 180),
192
+ weights=mag.ravel())
193
+ ah = ah / (ah.sum() + 1e-10)
194
+ for i, v in enumerate(ah):
195
+ row[f"hc_angle_hist_{i}"] = float(v)
196
+
197
+ return row
198
+ except Exception as e:
199
+ sys.stderr.write(f"FAIL {filename}: {e}\n")
200
+ return None
201
+
202
+
203
+ def main():
204
+ ap = argparse.ArgumentParser()
205
+ ap.add_argument("--workers", type=int, default=4)
206
+ ap.add_argument("--chunk", type=int, default=100)
207
+ args = ap.parse_args()
208
+
209
+ gold = (pd.read_csv(SELECTED, dtype=str)
210
+ .drop_duplicates("filename")[["filename"]])
211
+ existing = (pd.read_parquet(OUTPUT) if OUTPUT.exists()
212
+ else pd.DataFrame(columns=["filename"]))
213
+ have = set(existing["filename"])
214
+ todo = gold[~gold["filename"].isin(have)]
215
+ print(f"gold={len(gold)} done={len(have)} todo={len(todo)}")
216
+
217
+ tasks = todo["filename"].tolist()
218
+ new_rows, failed = [], 0
219
+ with ThreadPool(args.workers) as pool:
220
+ for row in tqdm(pool.imap_unordered(extract_one, tasks, chunksize=4),
221
+ total=len(tasks)):
222
+ if row is None:
223
+ failed += 1
224
+ continue
225
+ new_rows.append(row)
226
+ if len(new_rows) >= args.chunk:
227
+ existing = pd.concat([existing, pd.DataFrame(new_rows)],
228
+ ignore_index=True)
229
+ existing.to_parquet(OUTPUT, index=False)
230
+ new_rows = []
231
+ if new_rows:
232
+ existing = pd.concat([existing, pd.DataFrame(new_rows)],
233
+ ignore_index=True)
234
+ existing.to_parquet(OUTPUT, index=False)
235
+ print(f"Wrote {OUTPUT}: {len(existing)} rows, "
236
+ f"{len(existing.columns)} cols. Failures: {failed}")
237
+
238
+
239
+ if __name__ == "__main__":
240
+ main()
features/extract_pose.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main-figure pose extractor β€” gold 4x1000 subset.
3
+
4
+ Tests upgrade (a) from the pose investigation: instead of averaging all
5
+ skeletons in an image (extract_pose.py), store ONLY the largest detected
6
+ figure's torso-normalised skeleton, plus quality indicators so garbage
7
+ detections can be filtered in analysis:
8
+
9
+ filename
10
+ n_persons β€” YOLO detections in the image
11
+ main_conf β€” box confidence of the largest figure
12
+ main_kpt_conf β€” mean keypoint confidence of the largest figure
13
+ main_torso_px β€” torso height in pixels (tiny => unreliable normalisation)
14
+ main_area β€” bbox area fraction of the image
15
+ main_skel_0..33 β€” 17 kpts x (dx,dy), torso-normalised, 0 where invisible
16
+
17
+ Output: data/features/pose.parquet (checkpointed, resumable)
18
+ Usage: python features/extract_pose_main_gold.py [--model yolov8m-pose.pt]
19
+ """
20
+
21
+ import argparse
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ from tqdm import tqdm
28
+
29
+ ROOT = Path(__file__).resolve().parent.parent
30
+ IMAGES = ROOT / "data/images"
31
+ SELECTED = ROOT / "data/artwork_metadata.csv"
32
+ OUTPUT = ROOT / "data/features/pose.parquet"
33
+
34
+ KPT_THR = 0.3
35
+ L_SHOULDER, R_SHOULDER, L_HIP, R_HIP = 5, 6, 11, 12
36
+
37
+
38
+ def normalise_skeleton(kpts, conf):
39
+ """Torso-normalised skeleton (matches extract_pose.py) + torso height px."""
40
+ vis = conf >= KPT_THR
41
+ anchors = [L_SHOULDER, R_SHOULDER, L_HIP, R_HIP]
42
+ if sum(vis[i] for i in anchors) >= 2:
43
+ mid_shoulder = (kpts[L_SHOULDER] + kpts[R_SHOULDER]) / 2.0
44
+ mid_hip = (kpts[L_HIP] + kpts[R_HIP]) / 2.0
45
+ centre = (mid_shoulder + mid_hip) / 2.0
46
+ height = float(np.linalg.norm(mid_shoulder - mid_hip))
47
+ else:
48
+ visible = kpts[vis]
49
+ if len(visible) == 0:
50
+ return np.zeros(34, dtype=np.float32), 0.0
51
+ centre = visible.mean(axis=0)
52
+ height = float(np.linalg.norm(visible.max(axis=0) - visible.min(axis=0)))
53
+ norm = (kpts - centre) / max(height, 1.0)
54
+ norm[~vis] = 0.0
55
+ return norm.ravel().astype(np.float32), height
56
+
57
+
58
+ def main():
59
+ ap = argparse.ArgumentParser()
60
+ ap.add_argument("--model", default="yolov8m-pose.pt")
61
+ ap.add_argument("--save-every", type=int, default=200)
62
+ args = ap.parse_args()
63
+
64
+ from ultralytics import YOLO
65
+ model = YOLO(args.model)
66
+
67
+ gold = (pd.read_csv(SELECTED, dtype=str)
68
+ .drop_duplicates("filename")[["filename"]])
69
+ existing = (pd.read_parquet(OUTPUT) if OUTPUT.exists()
70
+ else pd.DataFrame(columns=["filename"]))
71
+ have = set(existing["filename"])
72
+ todo = gold[~gold["filename"].isin(have)]["filename"].tolist()
73
+ print(f"gold={len(gold)} done={len(have)} todo={len(todo)}")
74
+
75
+ rows, failed = [], 0
76
+ for filename in tqdm(todo):
77
+ try:
78
+ res = model(str(IMAGES / filename), verbose=False)[0]
79
+ row = {"filename": filename, "n_persons": 0, "main_conf": 0.0,
80
+ "main_kpt_conf": 0.0, "main_torso_px": 0.0, "main_area": 0.0,
81
+ **{f"main_skel_{i}": 0.0 for i in range(34)}}
82
+ if res.boxes is not None and len(res.boxes) > 0:
83
+ areas = ((res.boxes.xyxy[:, 2] - res.boxes.xyxy[:, 0])
84
+ * (res.boxes.xyxy[:, 3] - res.boxes.xyxy[:, 1]))
85
+ j = int(areas.argmax())
86
+ kpts = res.keypoints.xy[j].cpu().numpy()
87
+ conf = (res.keypoints.conf[j].cpu().numpy()
88
+ if res.keypoints.conf is not None else np.ones(17))
89
+ skel, torso = normalise_skeleton(kpts, conf)
90
+ ih, iw = res.orig_shape
91
+ row.update({"n_persons": len(res.boxes),
92
+ "main_conf": float(res.boxes.conf[j]),
93
+ "main_kpt_conf": float(conf.mean()),
94
+ "main_torso_px": torso,
95
+ "main_area": float(areas[j]) / (ih * iw)})
96
+ row.update({f"main_skel_{i}": float(v) for i, v in enumerate(skel)})
97
+ rows.append(row)
98
+ except Exception as e:
99
+ failed += 1
100
+ sys.stderr.write(f"FAIL {filename}: {e}\n")
101
+ if len(rows) >= args.save_every:
102
+ existing = pd.concat([existing, pd.DataFrame(rows)], ignore_index=True)
103
+ existing.to_parquet(OUTPUT, index=False)
104
+ rows = []
105
+ if rows:
106
+ existing = pd.concat([existing, pd.DataFrame(rows)], ignore_index=True)
107
+ existing.to_parquet(OUTPUT, index=False)
108
+ print(f"Wrote {OUTPUT}: {len(existing)} rows. Failures: {failed}")
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
preprocessing/README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Preprocessing
2
+
3
+ `generate_masks.py` produces the guarded background masks used by the
4
+ hand-crafted feature extractor.
5
+
6
+ Museum photographs often include a backdrop, mount or frame around the
7
+ artwork. Removing it helps color/light statistics β€” but salient-object
8
+ segmentation (U^2-Net) applied blindly also eats painted borders, halos and
9
+ dark backgrounds that ARE the artwork. So every mask must pass five checks
10
+ before it is applied (see the module docstring); otherwise the full image is
11
+ used. On the gold set this accepts ~10% of images.
12
+
13
+ Downstream use (in `features/extract_handcrafted.py`):
14
+
15
+ - **applied** images: spatial features see the mask's bounding-box crop;
16
+ color/light statistics additionally exclude background pixels inside it.
17
+ Background is never zero-filled β€” that would create fake edges.
18
+ - **rejected** images: full image everywhere.
19
+
20
+ Outputs: `data/masks/<stem>.png` + `data/masks/verdicts.csv`.
preprocessing/generate_masks.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Guarded background masking with U^2-Net (rembg).
3
+
4
+ For every image: strip near-black padding, run salient-object segmentation,
5
+ then ACCEPT the mask only if it clearly removes a plain photographic backdrop
6
+ around the artwork β€” never content. Five checks, all must pass:
7
+
8
+ 1. kept fraction in [0.20, 0.90] β€” mask keeps a plausible artwork share
9
+ 2. no interior holes (> 2%) β€” artwork regions are never punched out
10
+ 3. convex solidity >= 0.97 β€” one solid blob, not scattered figures
11
+ 4. mask must not touch-fill the border β€” something around it was removed
12
+ 5. removed pixels are uniform (std <= 28) β€” what's removed looks like backdrop
13
+
14
+ Accepted masks ("applied") are saved as PNGs + a bbox; everything else is
15
+ "rejected" and downstream features use the full image. On our gold set this
16
+ applies to ~10% of images (museum photos of framed/mounted works).
17
+
18
+ Output: data/masks/<stem>.png + data/masks/verdicts.csv
19
+ (filename, verdict, y0, y1, x0, x1)
20
+ Usage: python preprocessing/generate_masks.py
21
+ """
22
+
23
+ import csv
24
+ from pathlib import Path
25
+
26
+ import cv2
27
+ import numpy as np
28
+ import pandas as pd
29
+ from PIL import Image
30
+ from rembg import new_session, remove
31
+ from tqdm import tqdm
32
+
33
+ IMAGES = Path("data/images")
34
+ SELECTED = Path("data/artwork_metadata.csv")
35
+ MASK_DIR = Path("data/masks")
36
+ VERDICTS = MASK_DIR / "verdicts.csv"
37
+
38
+ Image.MAX_IMAGE_PIXELS = None
39
+
40
+
41
+ def crop_padding(img_rgb, threshold=5):
42
+ gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
43
+ rows = np.where(gray.max(axis=1) > threshold)[0]
44
+ cols = np.where(gray.max(axis=0) > threshold)[0]
45
+ if len(rows) == 0 or len(cols) == 0:
46
+ return img_rgb
47
+ return img_rgb[rows[0]:rows[-1] + 1, cols[0]:cols[-1] + 1]
48
+
49
+
50
+ def mask_verdict(raw, img, lo=0.20, hi=0.90, max_bg_std=28):
51
+ """True (apply) only if the mask removes a solid, uniform border region."""
52
+ if raw is None:
53
+ return False
54
+ m = (raw > 0).astype(np.uint8)
55
+ kept = m.mean()
56
+ if not (lo <= kept <= hi):
57
+ return False
58
+ cnts, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
59
+ if not cnts:
60
+ return False
61
+ filled = m.copy()
62
+ cv2.drawContours(filled, cnts, -1, 1, -1)
63
+ if (filled - m).sum() / max(m.sum(), 1) > 0.02:
64
+ return False
65
+ hull = cv2.convexHull(np.vstack([c.reshape(-1, 2) for c in cnts]))
66
+ if cv2.contourArea(hull) == 0 or m.sum() / cv2.contourArea(hull) < 0.97:
67
+ return False
68
+ border = np.zeros_like(m)
69
+ border[0, :] = border[-1, :] = border[:, 0] = border[:, -1] = 1
70
+ if (border & (1 - m)).sum() == 0:
71
+ return False
72
+ removed = img[m == 0]
73
+ if removed.std(axis=0).mean() > max_bg_std:
74
+ return False
75
+ return True
76
+
77
+
78
+ def main():
79
+ MASK_DIR.mkdir(parents=True, exist_ok=True)
80
+ sel = pd.read_csv(SELECTED, dtype=str).drop_duplicates("filename")
81
+ done = set()
82
+ if VERDICTS.exists():
83
+ done = set(pd.read_csv(VERDICTS, dtype=str)["filename"])
84
+ todo = [f for f in sel["filename"] if f not in done]
85
+ print(f"total={len(sel)} done={len(done)} todo={len(todo)}")
86
+
87
+ session = new_session("u2net")
88
+ mode = "a" if VERDICTS.exists() else "w"
89
+ with open(VERDICTS, mode, newline="") as fh:
90
+ writer = csv.writer(fh)
91
+ if mode == "w":
92
+ writer.writerow(["filename", "verdict", "y0", "y1", "x0", "x1"])
93
+ applied = rejected = 0
94
+ for fn in tqdm(todo):
95
+ try:
96
+ img = crop_padding(np.array(Image.open(IMAGES / fn).convert("RGB")))
97
+ raw = np.array(remove(Image.fromarray(img), session=session,
98
+ only_mask=True))
99
+ if mask_verdict(raw, img):
100
+ m = (raw > 0).astype(np.uint8)
101
+ ys, xs = np.where(m > 0)
102
+ y0, y1, x0, x1 = ys.min(), ys.max(), xs.min(), xs.max()
103
+ cv2.imwrite(str(MASK_DIR / (Path(fn).stem + ".png")), m * 255)
104
+ writer.writerow([fn, "applied", y0, y1, x0, x1])
105
+ applied += 1
106
+ else:
107
+ writer.writerow([fn, "rejected", "", "", "", ""])
108
+ rejected += 1
109
+ except Exception as e:
110
+ print(f"FAIL {fn}: {e}")
111
+ writer.writerow([fn, "rejected", "", "", "", ""])
112
+ rejected += 1
113
+ fh.flush()
114
+ print(f"applied={applied} rejected={rejected}")
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
requirements.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core
2
+ numpy
3
+ pandas
4
+ pyarrow
5
+ tqdm
6
+ pillow
7
+ opencv-python-headless
8
+ scikit-image
9
+ scikit-learn
10
+ scipy
11
+
12
+ # preprocessing
13
+ rembg
14
+
15
+ # deep features
16
+ torch
17
+ torchvision
18
+ git+https://github.com/openai/CLIP.git
19
+ ultralytics
20
+
21
+ # app
22
+ fastapi
23
+ uvicorn[standard]
24
+ openTSNE