Spaces:
Sleeping
Sleeping
Commit Β·
fb91fdc
0
Parent(s):
First commit.
Browse files- .gitignore +9 -0
- Dockerfile +15 -0
- LICENSE +21 -0
- README.md +69 -0
- app/README.md +25 -0
- app/clip_labels.json +120 -0
- app/main.py +1118 -0
- app/static/index.html +1532 -0
- data/README.md +20 -0
- data/download.py +45 -0
- evaluation/family_accuracy.py +90 -0
- features/README.md +22 -0
- features/extract_clip.py +101 -0
- features/extract_dino.py +79 -0
- features/extract_faces.py +332 -0
- features/extract_handcrafted.py +240 -0
- features/extract_pose.py +112 -0
- preprocessing/README.md +20 -0
- preprocessing/generate_masks.py +118 -0
- requirements.txt +24 -0
.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 & 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
|