Complete Milestone 7 playground application

This commit is contained in:
Christian Johannesen 2026-10-08 11:42:17 +02:00
parent 1503cbca8c
commit 8b7b897347
36 changed files with 3225 additions and 10 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
.git
.venv
__pycache__
.pytest_cache
.ruff_cache
web/node_modules
web/dist
data/raw
data/interim
data/processed
data/reports
data/cache
*.log

View file

@ -11,3 +11,6 @@ SMK_EN_FILE=./data/raw/smk_all_en.json
# They are NOT the primary bulk-ingest path for v1.
SMK_API_BASE=https://api.smk.dk/api/v1
SMK_ENRICHMENT_BASE=https://enrichment.api.smk.dk/api/enrichment
# Docker Compose publishes the single application service here.
HOST_PORT=8135

4
.gitignore vendored
View file

@ -12,6 +12,8 @@ venv/
dist/
build/
*.egg-info/
node_modules/
*.tsbuildinfo
# Environment / secrets
.env
@ -41,6 +43,8 @@ data/reports/*
*.duckdb.wal
*.db
.cache/
data/cache/*
!data/cache/.gitkeep
# Generated columnar/export formats
*.parquet

View file

@ -49,6 +49,11 @@ Milestone 6 keeps enrichment as a separate append-only source/cache. Weighted pa
must never overwrite bulk uniform v1. Corpus membership remains unchanged when enrichment is
missing. Palette transport uses exact OKLab min-cost flow, and weighted similarity is a new profile.
Milestone 7 serves the frozen analytical artifacts through one FastAPI + React application.
Processed data is read-only at runtime. Browser audio is user-triggered and uses versioned,
deterministic sonification mappings. Weighted similarity is an explicit deep action and may write
only profile-hash-keyed results under `data/cache/`.
Never overwrite source data. Never silently invent missing values.
## Primary bulk source
@ -397,7 +402,8 @@ Use fast feature-vector candidate retrieval, then optionally rerank top candidat
Sonification is a separate mapping layer, never an objective artwork feature.
Initial profile: `art-tone-v1`.
Initial profile set: `weighted-color-tone-v1`, `physical-artwork-tone-v1`, and
`palette-arpeggio-v1`.
Tentative mapping:

18
Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM node:22-alpine AS frontend
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ ./src/
RUN pip install --no-cache-dir .
COPY --from=frontend /web/dist ./web/dist
ENV SMK_ROOT=/app \
PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "smk_playground.api.app:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -2,7 +2,9 @@
A small, reproducible analysis pipeline for visual and colour features in artworks from **Statens Museum for Kunst (SMK)**.
The first goal is deliberately not a web app. The goal is to turn the locally downloaded SMK dataset into a trustworthy, versioned analytical foundation that can be reused for colour search, similarity, clustering, timelines, mosaics, visual experiments and data sonification.
The project first established a trustworthy, versioned analytical foundation from the locally
downloaded SMK dataset. It now includes a small exploratory web application for colour search,
similarity, weighted palettes, and deterministic data sonification.
## Local source files
@ -121,14 +123,16 @@ See:
## Current status
Milestones 0–6 complete: source structure and audits are manifested, the language snapshots have
Milestones 0–7 complete: source structure and audits are manifested, the language snapshots have
bounded-memory source Parquets, DA/EN rows are paired into stable SMK entities, and the exact
`smk-public-visual-v1` membership rule is frozen. Deterministic raw visual features and analytical
palettes now exist. Versioned corpus statistics, normalized scalar features and three unweighted
candidate feature families are materialized. Three deterministic, explainable exact-distance
profiles now support color, visual-character and specialist geometry queries; ANN is not used.
Separately cached SMK enrichment responses add weighted palettes, v2 weighted features and an
exact palette-transport similarity profile without mutating the bulk-derived v1 artifacts.
exact palette-transport similarity profile without mutating the bulk-derived v1 artifacts. A
single-service FastAPI + React application now exposes those frozen artifacts, explicit deep
weighted similarity, and deterministic browser sonification.
Audit results are documented in:
@ -148,6 +152,8 @@ Audit results are documented in:
- [`docs/ENRICHMENT_V1.md`](docs/ENRICHMENT_V1.md)
- [`docs/WEIGHTED_PALETTES_V1.md`](docs/WEIGHTED_PALETTES_V1.md)
- [`docs/WEIGHTED_SIMILARITY_V1.md`](docs/WEIGHTED_SIMILARITY_V1.md)
- [`docs/SONIFICATION_V1.md`](docs/SONIFICATION_V1.md)
- [`docs/WEB_UI_V1.md`](docs/WEB_UI_V1.md)
Run the audited ingest with:
@ -160,4 +166,24 @@ smk milestone5
smk similar KMS8892 --profile color --limit 10
smk enrichment status
smk similar KMS8892 --profile weighted-color --limit 10
smk web-catalog
```
## Run the application
The existing Milestones 1–6 processed artifacts must be present locally. Build the compact app
catalog once after those artifacts change:
```bash
smk web-catalog
```
Then start the single application service:
```bash
cp .env.example .env
docker compose up --build
```
Open [http://localhost:8135](http://localhost:8135). The processed directory is mounted read-only;
only `data/cache/` is writable. Set `HOST_PORT` in `.env` to publish another host port.

1
data/cache/.gitkeep vendored Normal file
View file

@ -0,0 +1 @@

16
docker-compose.yml Normal file
View file

@ -0,0 +1,16 @@
services:
app:
build: .
ports:
- "${HOST_PORT:-8135}:8000"
environment:
SMK_ROOT: /app
volumes:
- ./data/processed:/app/data/processed:ro
- ./data/cache:/app/data/cache
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s

View file

@ -81,13 +81,11 @@ resumable append-only source cache, weighted palettes/features v2, bulk/enrichme
an exact palette-transport similarity profile. See `ENRICHMENT_V1.md`, `WEIGHTED_PALETTES_V1.md`
and `WEIGHTED_SIMILARITY_V1.md`.
## Milestone 7 — Full dataset build
## Milestone 7 — First usable application + sonification
Run the complete local source snapshots.
Definition of done:
Every canonical artwork has valid applicable features or explicit nulls/flags explaining missing/invalid prerequisites. Every normalization result references a frozen profile and exact source manifest.
Status: complete. A compact read-only web catalog feeds one FastAPI + React service with search,
artwork detail, existing exact similarity profiles, explicit cached weighted similarity, and three
versioned deterministic browser-audio mappings. See `SONIFICATION_V1.md` and `WEB_UI_V1.md`.
## Milestone 8 — Similarity

View file

@ -26,6 +26,8 @@ select/freeze normalization corpus
normalized features
↓
similarity / sonification experiments
↓
compact web catalog + read-only application
```
## Recommended CLI stages
@ -99,6 +101,14 @@ separate source manifest, parse repeated palette tokens into explicit weights, e
palette/features v2, and add exact OKLab palette transport under a new similarity profile. Existing
bulk v1 corpus, features, normalization and profiles remain byte-independent inputs.
## Stage 7 — exploratory application
Join only the fields needed for interactive browsing into `web-catalog-v1.parquet`, freeze the
sonification profile manifest, and serve the built React client and FastAPI routes from one
container. Runtime startup validates artifacts but never triggers ingestion, enrichment, or
normalization. Processed inputs remain read-only; deep weighted-similarity responses use the
separate invalidatable cache.
## Ingest performance rule
The source files are large. Use bounded-memory streaming where practical. A one-time conversion to Parquet/Arrow as an interim representation is encouraged if it simplifies all later stages.

38
docs/SONIFICATION_V1.md Normal file
View file

@ -0,0 +1,38 @@
# Sonification v1
Sonification is an interpretive presentation layer, not an objective artwork feature. The API
returns deterministic synthesis parameters; sound is produced only after a user action by the
browser Web Audio API. The frozen profile set is `1.0.0` and is materialized as
`data/processed/sonification-profiles-v1.json`.
## Weighted color tone
`weighted-color-tone-v1` maps the enrichment-weighted OKLab centroid to one sustained tone:
- hue uses twelve equal 30-degree bins, `floor(hue / 30)`, mapped C through B;
- undefined achromatic hue maps explicitly to A and reports `achromatic_neutral`;
- centroid OKLab lightness sets a target register from MIDI 36 through 84, rounded to the nearest
MIDI note in the selected pitch class;
- centroid chroma controls velocity, palette spread controls harmonic richness, normalized
palette entropy controls a small noise component, hue concentration controls tonal purity, and
dominant weight controls attack and release.
All controls are clamped to documented safe ranges in `sonification/profiles.py`.
## Physical artwork tone
`physical-artwork-tone-v1` uses the same hue and timbre mapping, but inverse log-area percentile
sets register: larger physical works sound lower. Signed log aspect ratio produces subtle stereo
pan via `0.6 * tanh(log_aspect)`. The profile is explicitly unavailable unless both a weighted
palette and reliable Netto width/height-derived normalized geometry exist. Pixel dimensions never
substitute for physical measurements.
## Palette arpeggio
`palette-arpeggio-v1` plays weighted palette entries in descending weight, with palette rank as a
stable tie-break. Each color maps hue to pitch class and lightness to octave; its weight controls
duration and velocity. The API includes the color, weight, note, MIDI number, frequency, duration,
and pitch-source explanation for every step.
These mappings are deliberately simple, explainable, and deterministic. They do not claim that an
artwork has an inherent sound or that musical distance is visual similarity.

36
docs/WEB_UI_V1.md Normal file
View file

@ -0,0 +1,36 @@
# Web UI v1
Milestone 7 is one FastAPI application serving both a built React/TypeScript frontend and a JSON
API. It reads existing processed artifacts without mutating them. Only weighted-similarity query
results are written, under `data/cache/weighted-similarity/`.
## User flows
- `/` searches title, artist, classification, and object number; it also offers a deterministic
seeded random entry point and corpus coverage figures.
- `/artwork/:id` presents the image and bilingual metadata, a proportional weighted-palette strip,
weighted feature values, user-triggered sonification, and similarity tabs.
- `/about` explains provenance, interpretation, missingness, and the distinction between source,
derived features, and artistic mappings.
Color similarity is the default. Visual-character and geometry profiles remain separate. The
weighted-color profile is labelled as a slower, explicit deep comparison; it is never launched by
page load or tab hover. Its exact result is cached by canonical entity ID and the SHA-256 of
`similarity-profiles-v2.json`, so profile changes invalidate old cache entries.
Audio never autoplays. Play and stop controls are keyboard accessible, palette values have visible
text labels, focus indicators are explicit, and reduced-motion preferences disable decorative
motion. Missing physical geometry is shown as unavailable rather than inferred from pixels.
## API
- `GET /api/health` and `/api/stats`
- `GET /api/artworks?q=&limit=&offset=&classification=&has_physical=&chromatic=`
- `GET /api/artworks/random?seed=`
- `GET /api/resolve/{object_number}` (retains ambiguous object-number groups)
- `GET /api/artworks/{smk_entity_id}`
- `GET /api/artworks/{smk_entity_id}/similar?profile=color|visual|geometry|weighted-color`
The compact web catalog is reproducibly built with `smk web-catalog`. Application startup fails
with an explicit artifact list when required processed files are absent; it never rebuilds or
downloads analytical data automatically.

View file

@ -10,16 +10,19 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"duckdb>=1",
"fastapi>=0.115",
"ijson>=3.3",
"numpy>=2",
"polars>=1",
"pyarrow>=18",
"typer>=0.12",
"uvicorn>=0.34",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"httpx>=0.28",
"ruff>=0.8",
]

View file

@ -0,0 +1 @@
"""HTTP application package."""

View file

@ -0,0 +1,97 @@
"""FastAPI application serving the API and built React frontend."""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from smk_playground.api.catalog import Catalog
def repository_root() -> Path:
configured = os.environ.get("SMK_ROOT")
return Path(configured).resolve() if configured else Path.cwd().resolve()
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.catalog = Catalog(repository_root())
yield
app = FastAPI(title="SMK Playground API", version="1.0.0", lifespan=lifespan)
def catalog() -> Catalog:
return app.state.catalog
@app.get("/api/health")
def health() -> dict[str, Any]:
return {"status": "ok", "catalog_rows": len(catalog().rows)}
@app.get("/api/stats")
def stats() -> dict[str, Any]:
return catalog().stats()
@app.get("/api/artworks")
def artworks(
q: str = "", limit: int = Query(24, ge=1, le=100), offset: int = Query(0, ge=0),
classification: str | None = None, has_physical: bool | None = None,
chromatic: bool | None = None,
) -> dict[str, Any]:
return catalog().search(q, limit, offset, classification, has_physical, chromatic)
@app.get("/api/artworks/random")
def random_artwork(seed: int | None = None) -> dict[str, Any]:
return catalog().random(seed)
@app.get("/api/resolve/{object_number}")
def resolve_object_number(object_number: str) -> dict[str, Any]:
matches = catalog().resolve(object_number)
if not matches:
raise HTTPException(404, "Object number not found in corpus")
return {"object_number": object_number, "ambiguous": len(matches) > 1, "items": matches}
@app.get("/api/artworks/{entity_id}/similar")
def similar_artworks(entity_id: str, profile: str = "color",
limit: int = Query(10, ge=1, le=50)) -> dict[str, Any]:
if profile not in {"color", "visual", "geometry", "weighted-color"}:
raise HTTPException(400, "Unknown similarity profile")
try:
return catalog().similar(entity_id, profile, limit)
except KeyError:
raise HTTPException(404, "Artwork not found") from None
except ValueError as error:
raise HTTPException(422, str(error)) from error
@app.get("/api/artworks/{entity_id}")
def artwork(entity_id: str) -> dict[str, Any]:
try:
return catalog().artwork(entity_id)
except KeyError:
raise HTTPException(404, "Artwork not found") from None
frontend = repository_root() / "web" / "dist"
if frontend.exists():
app.mount("/assets", StaticFiles(directory=frontend / "assets"), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
def frontend_route(full_path: str) -> FileResponse:
candidate = frontend / full_path
if full_path and candidate.is_file():
return FileResponse(candidate)
return FileResponse(frontend / "index.html")

View file

@ -0,0 +1,173 @@
"""Read-only in-memory catalog over frozen compact Parquet artifacts."""
from __future__ import annotations
import json
import random
from pathlib import Path
from typing import Any
import pyarrow.parquet as pq
from smk_playground.enrichment.milestone6 import WeightedSimilarityEngine
from smk_playground.normalize.milestone4 import sha256_file
from smk_playground.similarity.engine import SimilarityEngine
from smk_playground.sonification.profiles import (
palette_arpeggio,
physical_artwork_tone,
weighted_color_tone,
)
class Catalog:
def __init__(self, root: Path):
self.root = root
required = [
root / "data" / "processed" / "web-catalog-v1.parquet",
root / "data" / "processed" / "artwork-weighted-palettes-v1.parquet",
root / "data" / "processed" / "similarity-profiles-v1.json",
root / "data" / "processed" / "similarity-profiles-v2.json",
root / "data" / "processed" / "sonification-profiles-v1.json",
]
missing = [str(path) for path in required if not path.exists()]
if missing:
raise RuntimeError("Missing required processed artifacts: " + ", ".join(missing))
self.rows = pq.read_table(required[0]).to_pylist()
self.by_id = {row["smk_entity_id"]: row for row in self.rows}
self.by_number: dict[str, list[dict[str, Any]]] = {}
for row in self.rows:
self.by_number.setdefault(row["object_number"], []).append(row)
palette_rows = pq.read_table(required[1]).to_pylist()
self.palettes: dict[str, list[dict[str, Any]]] = {}
for row in palette_rows:
self.palettes.setdefault(row["smk_entity_id"], []).append(row)
for values in self.palettes.values():
values.sort(key=lambda row: row["palette_rank"])
self.fast_similarity = SimilarityEngine(root)
self._weighted_similarity: WeightedSimilarityEngine | None = None
self.weighted_profile_hash = sha256_file(required[3])
self.cache_dir = root / "data" / "cache" / "weighted-similarity"
self.cache_dir.mkdir(parents=True, exist_ok=True)
@property
def weighted_similarity(self) -> WeightedSimilarityEngine:
if self._weighted_similarity is None:
self._weighted_similarity = WeightedSimilarityEngine(self.root)
return self._weighted_similarity
def public_card(self, row: dict[str, Any]) -> dict[str, Any]:
return {key: row.get(key) for key in (
"smk_entity_id", "object_number", "display_title", "artist_display", "date_text",
"classification", "image_thumbnail", "centroid_chroma", "dominant_color_hex",
)}
def search(self, query: str, limit: int, offset: int, classification: str | None,
has_physical: bool | None, chromatic: bool | None) -> dict[str, Any]:
needle = query.casefold().strip()
matches = []
for row in self.rows:
if needle and needle not in row["search_text"]:
continue
if classification and classification.casefold() not in (row.get("classification") or "").casefold():
continue
if has_physical is not None:
present = row.get("net_width_cm") is not None and row.get("net_height_cm") is not None
if present != has_physical:
continue
if chromatic is not None:
present = row.get("centroid_hue_deg") is not None
if present != chromatic:
continue
matches.append(row)
return {"total": len(matches), "limit": limit, "offset": offset,
"items": [self.public_card(row) for row in matches[offset:offset + limit]]}
def artwork(self, entity_id: str) -> dict[str, Any]:
if entity_id not in self.by_id:
raise KeyError(entity_id)
row = self.by_id[entity_id]
palette = self.palettes.get(entity_id, [])
feature = row
normalized = row
return {
**{key: row.get(key) for key in (
"smk_entity_id", "object_number", "title_da", "title_en", "display_title",
"artist_display", "date_text", "classification", "image_iiif_id",
"image_thumbnail", "image_native", "image_width", "image_height",
"rights_semantic", "net_width_cm", "net_height_cm", "quality_flags",
)},
"weighted_palette": [{key: item.get(key) for key in (
"palette_rank", "hex", "token_count", "weight", "oklab_l", "oklab_a",
"oklab_b", "oklch_c", "oklch_h_deg")}
for item in palette],
"weighted_features": {key: row.get(key) for key in (
"centroid_l", "centroid_a", "centroid_b", "centroid_chroma",
"centroid_hue_deg", "palette_entropy", "palette_entropy_01",
"effective_palette_size", "palette_spread_oklab", "chroma_stddev",
"hue_concentration_01", "dominant_color_hex", "dominant_color_weight",
"weighted_palette_status")},
"sonification": {
"weighted_color": weighted_color_tone(feature),
"physical": physical_artwork_tone(feature, normalized),
"palette_arpeggio": palette_arpeggio(palette),
},
"provenance": {
"metadata": "SMK bulk open data",
"palette_weights": "SMK enrichment colorextractor token frequencies",
"features": "SMK Playground derived weighted features v2",
"sonification": "SMK Playground deterministic artistic mappings v1",
},
}
def resolve(self, object_number: str) -> list[dict[str, Any]]:
return [self.public_card(row) for row in self.by_number.get(object_number, [])]
def random(self, seed: int | None) -> dict[str, Any]:
rng = random.Random(seed) if seed is not None else random.SystemRandom()
return self.public_card(self.rows[rng.randrange(len(self.rows))])
def similar(self, entity_id: str, profile: str, limit: int) -> dict[str, Any]:
if entity_id not in self.by_id:
raise KeyError(entity_id)
if profile == "weighted-color":
return self._weighted_cached(entity_id, limit)
query = self.fast_similarity.resolve(smk_entity_id=entity_id)
results = self.fast_similarity.search_row(query, profile, limit)
return {"profile": profile, "cached": False,
"items": [self._similar_item(item) for item in results]}
def _similar_item(self, result: dict[str, Any]) -> dict[str, Any]:
catalog = self.by_id[result["smk_entity_id"]]
return {**self.public_card(catalog), "rank": result["rank"],
"distance": result["total_distance"],
"active_components": result["active_components"],
"components": result["components"]}
def _weighted_cached(self, entity_id: str, limit: int) -> dict[str, Any]:
cache_limit = max(50, limit)
cache_path = self.cache_dir / f"{entity_id}.json"
if cache_path.exists():
payload = json.loads(cache_path.read_text(encoding="utf-8"))
if (payload.get("profile_hash") == self.weighted_profile_hash
and payload.get("max_results", 0) >= limit):
return {"profile": "weighted-color", "cached": True,
"items": payload["items"][:limit]}
query = self.weighted_similarity.resolve(smk_entity_id=entity_id)
results = self.weighted_similarity.search_row(query, cache_limit)
items = [self._similar_item(item) for item in results]
payload = {"profile": "weighted-color", "profile_hash": self.weighted_profile_hash,
"max_results": cache_limit, "items": items}
temporary = cache_path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temporary.replace(cache_path)
return {"profile": "weighted-color", "cached": False, "items": items[:limit]}
def stats(self) -> dict[str, Any]:
return {
"artwork_count": len(self.rows),
"weighted_palette_count": sum(bool(row["has_weighted_palette_v1"]) for row in self.rows),
"physical_geometry_count": sum(
row.get("net_width_cm") is not None and row.get("net_height_cm") is not None
for row in self.rows
),
}

View file

@ -76,6 +76,15 @@ def milestone6(root: Path | None = None) -> None:
typer.echo(f"Milestone 6 complete: {summary['weighted_palette_entity_count']} weighted entities")
@app.command("web-catalog")
def web_catalog(root: Path | None = None) -> None:
"""Build the compact read-only catalog and sonification manifest for the app."""
from smk_playground.web_catalog.build import run
summary = run((root or Path.cwd()).resolve())
typer.echo(f"Web catalog complete: {summary['rows']} rows")
@enrichment_app.command("status")
def enrichment_status(root: Path | None = None) -> None:
"""Report resumable enrichment-cache status."""

View file

@ -0,0 +1,122 @@
"""Deterministic artistic mappings from artwork features to browser-audio parameters."""
from __future__ import annotations
import math
from typing import Any
PROFILE_VERSION = "1.0.0"
PITCH_CLASSES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
def clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
return min(high, max(low, value))
def hue_pitch_class(hue_degrees: float | None) -> tuple[int, str, str]:
if hue_degrees is None:
return 9, "A", "achromatic_neutral"
index = int((hue_degrees % 360.0) // 30.0)
return index, PITCH_CLASSES[index], "weighted_centroid_hue"
def nearest_midi(target: float, pitch_class: int) -> int:
candidates = [midi for midi in range(36, 85) if midi % 12 == pitch_class]
return min(candidates, key=lambda midi: (abs(midi - target), midi))
def midi_frequency(midi: int) -> float:
return 440.0 * (2.0 ** ((midi - 69) / 12.0))
def _shared_timbre(feature: dict[str, Any]) -> dict[str, float]:
return {
"velocity": clamp(0.28 + 4.2 * float(feature.get("centroid_chroma") or 0.0), 0.25, 0.92),
"attack": 0.08 + 0.18 * (1.0 - float(feature.get("dominant_color_weight") or 0.0)),
"release": 0.65 + 1.35 * float(feature.get("dominant_color_weight") or 0.0),
"harmonic_richness": clamp(float(feature.get("palette_spread_oklab") or 0.0) / 0.35),
"noise_amount": 0.10 * clamp(float(feature.get("palette_entropy_01") or 0.0)),
"tonal_purity": clamp(float(feature.get("hue_concentration_01") or 0.0)),
}
def weighted_color_tone(feature: dict[str, Any]) -> dict[str, Any]:
available = bool(feature.get("has_weighted_palette_v1"))
if not available:
return {"profile": "weighted-color-tone-v1", "version": PROFILE_VERSION,
"available": False, "reason": "weighted palette unavailable"}
pitch_class, note, pitch_source = hue_pitch_class(feature.get("centroid_hue_deg"))
lightness = clamp(float(feature["centroid_l"]))
midi = nearest_midi(36.0 + 48.0 * lightness, pitch_class)
return {
"profile": "weighted-color-tone-v1", "version": PROFILE_VERSION, "available": True,
"note": note, "octave": midi // 12 - 1, "midi": midi,
"frequency_hz": midi_frequency(midi), "pitch_source": pitch_source,
**_shared_timbre(feature),
"stereo_pan": 0.0,
"explanation": {
"pitch": "weighted centroid hue; A is the explicit achromatic neutral",
"register": "weighted centroid OKLab lightness mapped to MIDI 36–84",
"timbre": "weighted chroma, spread, entropy, hue concentration, and dominant weight",
},
}
def physical_artwork_tone(feature: dict[str, Any], normalized: dict[str, Any]) -> dict[str, Any]:
area_percentile = normalized.get("log_net_area_cm2_percentile_01")
log_aspect = normalized.get("log_net_aspect_ratio")
if not feature.get("has_weighted_palette_v1") or area_percentile is None or log_aspect is None:
return {"profile": "physical-artwork-tone-v1", "version": PROFILE_VERSION,
"available": False, "reason": "reliable physical Netto geometry unavailable"}
pitch_class, note, pitch_source = hue_pitch_class(feature.get("centroid_hue_deg"))
midi = nearest_midi(84.0 - 48.0 * clamp(float(area_percentile)), pitch_class)
return {
"profile": "physical-artwork-tone-v1", "version": PROFILE_VERSION, "available": True,
"note": note, "octave": midi // 12 - 1, "midi": midi,
"frequency_hz": midi_frequency(midi), "pitch_source": pitch_source,
**_shared_timbre(feature),
"stereo_pan": 0.6 * math.tanh(float(log_aspect)),
"explanation": {
"pitch": "weighted centroid hue; A is the explicit achromatic neutral",
"register": "inverse physical log-area percentile: larger works sound lower",
"stereo_pan": "subtle signed physical log-aspect mapping",
"timbre": "weighted chroma, spread, entropy, hue concentration, and dominant weight",
},
}
def palette_arpeggio(palette: list[dict[str, Any]]) -> dict[str, Any]:
notes = []
for row in sorted(palette, key=lambda item: (-item["weight"], item["palette_rank"])):
pitch_class, note, pitch_source = hue_pitch_class(row.get("oklch_h_deg"))
midi = nearest_midi(48.0 + 24.0 * clamp(float(row["oklab_l"])), pitch_class)
notes.append({
"hex": row["hex"], "weight": row["weight"], "note": note,
"octave": midi // 12 - 1, "midi": midi, "frequency_hz": midi_frequency(midi),
"velocity": clamp(0.30 + 0.70 * row["weight"], 0.3, 0.9),
"duration_seconds": 0.18 + 1.15 * row["weight"], "pitch_source": pitch_source,
})
return {"profile": "palette-arpeggio-v1", "version": PROFILE_VERSION,
"available": bool(notes), "notes": notes,
"explanation": "descending palette weight; hue→pitch, lightness→octave, weight→duration/intensity"}
def profile_manifest(input_hash: str) -> dict[str, Any]:
return {
"profile_set_version": PROFILE_VERSION, "input_feature_sha256": input_hash,
"profiles": {
"weighted-color-tone-v1": {
"version": PROFILE_VERSION, "pitch": "floor(hue_deg/30) over C..B; achromatic=A",
"register": "nearest matching pitch class to 36 + 48*centroid_l",
},
"physical-artwork-tone-v1": {
"version": PROFILE_VERSION, "required": ["weighted palette", "physical Netto geometry"],
"register": "nearest matching pitch class to 84 - 48*log-area percentile",
"aspect": "stereo_pan = 0.6*tanh(log_net_aspect_ratio)",
},
"palette-arpeggio-v1": {
"version": PROFILE_VERSION, "order": "weight descending, palette rank ascending",
"mapping": "hue→pitch; lightness→octave; weight→duration and velocity",
},
},
}

View file

@ -0,0 +1 @@
"""Compact web catalog generation."""

View file

@ -0,0 +1,108 @@
"""Build deterministic compact catalog and sonification profile manifest."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pyarrow as pa
import pyarrow.parquet as pq
from smk_playground.normalize.milestone4 import sha256_file
from smk_playground.sonification.profiles import profile_manifest
def _json(value: str | None) -> Any:
if not value:
return None
try:
return json.loads(value)
except json.JSONDecodeError:
return None
def _first_text(value: str | None, *keys: str) -> str | None:
parsed = _json(value)
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, str) and item.strip():
return item.strip()
if isinstance(item, dict):
for key in keys:
candidate = item.get(key)
if candidate:
return str(candidate).strip()
return None
def run(root: Path) -> dict[str, Any]:
artworks_path = root / "data" / "processed" / "artworks.parquet"
features_path = root / "data" / "processed" / "artwork-visual-features-v2.parquet"
normalized_path = root / "data" / "processed" / "artwork-normalized-features-v1.parquet"
artwork_columns = [
"smk_entity_id", "object_number", "titles_da_json", "titles_en_json",
"artists_da_json", "artists_en_json", "production_dates_da_json",
"object_names_da_json", "object_names_en_json", "classifications_da_json",
"classifications_en_json", "image_iiif_id", "image_thumbnail", "image_native",
"image_width", "image_height", "rights_semantic", "net_width_cm", "net_height_cm",
"quality_flags", "corpus_smk_public_visual_v1",
]
canonical = {
row["smk_entity_id"]: row for row in pq.read_table(
artworks_path, columns=artwork_columns,
filters=[("corpus_smk_public_visual_v1", "=", True)],
).to_pylist()
}
feature_columns = [
"smk_entity_id", "centroid_l", "centroid_a", "centroid_b", "centroid_chroma",
"centroid_hue_deg", "palette_entropy", "palette_entropy_01", "effective_palette_size",
"palette_spread_oklab", "chroma_stddev", "hue_concentration_01",
"dominant_color_hex", "dominant_color_weight", "has_weighted_palette_v1",
"weighted_palette_status",
]
features = {row["smk_entity_id"]: row for row in pq.read_table(
features_path, columns=feature_columns
).to_pylist()}
normalized = {row["smk_entity_id"]: row for row in pq.read_table(
normalized_path, columns=["smk_entity_id", "log_net_area_cm2_percentile_01",
"log_net_aspect_ratio"]
).to_pylist()}
rows = []
for entity_id in sorted(canonical):
source, feature, norm = canonical[entity_id], features[entity_id], normalized[entity_id]
title_da = _first_text(source["titles_da_json"], "title")
title_en = _first_text(source["titles_en_json"], "title")
artist = _first_text(source["artists_da_json"]) or _first_text(source["artists_en_json"])
date_text = _first_text(source["production_dates_da_json"], "period", "start")
classification = (
_first_text(source["classifications_da_json"], "name", "classification")
or _first_text(source["object_names_da_json"], "name")
or _first_text(source["classifications_en_json"], "name", "classification")
or _first_text(source["object_names_en_json"], "name")
)
rows.append({
"smk_entity_id": entity_id, "object_number": source["object_number"],
"title_da": title_da, "title_en": title_en, "display_title": title_da or title_en or source["object_number"],
"artist_display": artist, "date_text": date_text, "classification": classification,
"search_text": " ".join(filter(None, (source["object_number"], title_da, title_en, artist, classification))).casefold(),
"image_iiif_id": source["image_iiif_id"], "image_thumbnail": source["image_thumbnail"],
"image_native": source["image_native"], "image_width": source["image_width"],
"image_height": source["image_height"], "rights_semantic": source["rights_semantic"],
"net_width_cm": source["net_width_cm"], "net_height_cm": source["net_height_cm"],
"quality_flags": source["quality_flags"], **feature,
"log_net_area_cm2_percentile_01": norm["log_net_area_cm2_percentile_01"],
"log_net_aspect_ratio": norm["log_net_aspect_ratio"],
})
metadata = {
b"smk.web_catalog_version": b"1.0.0",
b"smk.artworks_sha256": sha256_file(artworks_path).encode(),
b"smk.weighted_features_sha256": sha256_file(features_path).encode(),
}
output = root / "data" / "processed" / "web-catalog-v1.parquet"
table = pa.Table.from_pylist(rows).replace_schema_metadata(metadata)
pq.write_table(table, output, compression="zstd", version="2.6", write_statistics=True)
profile_path = root / "data" / "processed" / "sonification-profiles-v1.json"
profile_path.write_text(json.dumps(profile_manifest(sha256_file(features_path)), indent=2) + "\n", encoding="utf-8")
return {"rows": len(rows), "catalog_sha256": sha256_file(output),
"sonification_profiles_sha256": sha256_file(profile_path)}

31
tests/test_api.py Normal file
View file

@ -0,0 +1,31 @@
"""Focused API acceptance tests over the frozen processed artifacts."""
import pytest
from fastapi.testclient import TestClient
from smk_playground.api.app import app
def test_health_search_and_kms8892_detail():
with TestClient(app) as client:
health = client.get("/api/health")
assert health.status_code == 200
assert health.json()["catalog_rows"] > 0
search = client.get("/api/artworks", params={"q": "KMS8892", "limit": 5}).json()
assert search["total"] >= 1
match = next(item for item in search["items"] if item["object_number"] == "KMS8892")
detail = client.get(f"/api/artworks/{match['smk_entity_id']}")
assert detail.status_code == 200
payload = detail.json()
assert payload["weighted_palette"]
assert sum(item["weight"] for item in payload["weighted_palette"]) == pytest.approx(1.0)
assert payload["sonification"]["weighted_color"]["available"]
def test_not_found_and_profile_validation():
with TestClient(app) as client:
assert client.get("/api/artworks/not-an-id").status_code == 404
response = client.get("/api/artworks/not-an-id/similar", params={"profile": "invented"})
assert response.status_code == 400

View file

@ -0,0 +1,62 @@
"""Focused invariants for deterministic sonification profiles."""
import pytest
from smk_playground.sonification.profiles import (
hue_pitch_class,
palette_arpeggio,
physical_artwork_tone,
weighted_color_tone,
)
def feature(**updates):
values = {
"has_weighted_palette_v1": True,
"centroid_hue_deg": 30.0,
"centroid_l": 0.5,
"centroid_chroma": 0.1,
"dominant_color_weight": 0.4,
"palette_spread_oklab": 0.15,
"palette_entropy_01": 0.6,
"hue_concentration_01": 0.7,
}
values.update(updates)
return values
def test_hue_bins_and_explicit_achromatic_neutral():
assert hue_pitch_class(0)[:2] == (0, "C")
assert hue_pitch_class(30)[:2] == (1, "C#")
assert hue_pitch_class(359)[:2] == (11, "B")
assert hue_pitch_class(None) == (9, "A", "achromatic_neutral")
def test_weighted_tone_is_deterministic_and_bounded():
first = weighted_color_tone(feature())
assert first == weighted_color_tone(feature())
assert first["note"] == "C#"
assert 36 <= first["midi"] <= 84
assert 0 <= first["velocity"] <= 1
def test_physical_profile_requires_geometry_and_large_works_sound_lower():
assert not physical_artwork_tone(feature(), {})["available"]
small = physical_artwork_tone(
feature(), {"log_net_area_cm2_percentile_01": 0.1, "log_net_aspect_ratio": -0.5}
)
large = physical_artwork_tone(
feature(), {"log_net_area_cm2_percentile_01": 0.9, "log_net_aspect_ratio": 0.5}
)
assert large["midi"] < small["midi"]
assert small["stereo_pan"] == pytest.approx(-large["stereo_pan"])
def test_palette_arpeggio_orders_by_weight_then_rank():
palette = [
{"palette_rank": 2, "hex": "#aaaaaa", "weight": 0.3, "oklch_h_deg": None, "oklab_l": 0.5},
{"palette_rank": 1, "hex": "#ff0000", "weight": 0.7, "oklch_h_deg": 0.0, "oklab_l": 0.7},
]
notes = palette_arpeggio(palette)["notes"]
assert [note["hex"] for note in notes] == ["#ff0000", "#aaaaaa"]
assert notes[1]["pitch_source"] == "achromatic_neutral"

10
web/index.html Normal file
View file

@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#15140f" />
<title>SMK Playground — Colour into sound</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>

2159
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
web/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "smk-playground-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest",
"typescript": "latest",
"react": "latest",
"react-dom": "latest",
"react-router-dom": "latest",
"lucide-react": "latest",
"tailwindcss": "latest",
"@tailwindcss/vite": "latest"
},
"devDependencies": {
"@types/react": "latest",
"@types/react-dom": "latest",
"vitest": "latest"
}
}

50
web/src/App.tsx Normal file
View file

@ -0,0 +1,50 @@
import { FormEvent, useEffect, useState } from "react";
import { Link, NavLink, Route, Routes, useNavigate, useParams } from "react-router-dom";
import { ChevronLeft, Dices, Headphones, Pause, Play, Search, Sparkles } from "lucide-react";
import { api } from "./api";
import { playArpeggio, playTone, stopAudio } from "./audio";
import { ArtworkTile, Loading, PaletteBar } from "./components";
import type { Artwork, ArtworkCard, SimilarItem } from "./types";
function Shell({ children }: { children: React.ReactNode }) {
return <><header className="site-header"><Link to="/" className="brand"><span className="brand-mark" />SMK Playground</Link><nav><NavLink to="/">Explore</NavLink><NavLink to="/about">About</NavLink></nav></header>{children}<footer><span>SMK open data, transformed with care.</span><span>Colour → sound is an artistic mapping.</span></footer></>;
}
function Home() {
const [query, setQuery] = useState(""); const [items, setItems] = useState<ArtworkCard[]>([]);
const [stats, setStats] = useState<{ artwork_count: number; weighted_palette_count: number; physical_geometry_count: number }>();
const [loading, setLoading] = useState(true); const navigate = useNavigate();
const load = (q = "") => { setLoading(true); api.search(q).then((r) => setItems(r.items)).finally(() => setLoading(false)); };
useEffect(() => { load(); api.stats().then(setStats); }, []);
const submit = (event: FormEvent) => { event.preventDefault(); load(query); };
const surprise = () => api.random().then((art) => navigate(`/artwork/${art.smk_entity_id}`));
return <Shell><main><section className="hero"><div><p className="kicker"><Sparkles size={14} /> An experimental colour archive</p><h1>See art.<br /><em>Hear colour.</em></h1><p className="hero-copy">Explore 27,254 open artworks from SMK through weighted palettes, perceptual colour and deterministic sound.</p><form className="search-box" onSubmit={submit}><Search /><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search title, artist, object number…" aria-label="Search artworks" /><button>Search</button></form><button className="text-button" onClick={surprise}><Dices size={17} /> Surprise me</button></div><div className="hero-orbit" aria-hidden="true"><span /><span /><span /><div className="tone-dot"><Headphones /></div></div></section>{stats && <section className="stat-strip"><div><strong>{stats.artwork_count.toLocaleString()}</strong><span>open works</span></div><div><strong>{stats.weighted_palette_count.toLocaleString()}</strong><span>weighted palettes</span></div><div><strong>{stats.physical_geometry_count.toLocaleString()}</strong><span>measured works</span></div></section>}<section className="collection"><div className="section-heading"><p className="eyebrow">The collection</p><h2>{query ? `Results for “${query}”` : "A field of colour"}</h2></div>{loading ? <Loading /> : <div className="art-grid">{items.map((item) => <ArtworkTile key={item.smk_entity_id} artwork={item} />)}</div>}</section></main></Shell>;
}
const profileNames: Record<string, string> = { color: "Color", visual: "Visual character", geometry: "Physical geometry", "weighted-color": "Deep weighted color" };
function SimilarWorks({ id }: { id: string }) {
const [profile, setProfile] = useState("color"); const [items, setItems] = useState<SimilarItem[]>([]);
const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState<Record<string, boolean>>({});
const load = (next: string) => { setProfile(next); if (next === "weighted-color" && !loaded[next]) return; setLoading(true); api.similar(id, next).then((r) => { setItems(r.items); setLoaded((x) => ({ ...x, [next]: true })); }).finally(() => setLoading(false)); };
useEffect(() => load("color"), [id]);
return <section className="similar-section"><div className="section-heading"><p className="eyebrow">Connections</p><h2>Works nearby</h2></div><div className="tabs">{Object.entries(profileNames).map(([key, label]) => <button className={profile === key ? "active" : ""} key={key} onClick={() => load(key)}>{label}</button>)}</div>{profile === "weighted-color" && !loaded[profile] ? <div className="deep-callout"><div><strong>Deep palette comparison</strong><p>Exact transport across weighted colours takes around 12 seconds the first time, then it is cached.</p></div><button onClick={() => { setLoading(true); api.similar(id, profile).then((r) => { setItems(r.items); setLoaded((x) => ({ ...x, [profile]: true })); }).finally(() => setLoading(false)); }}>Run deep comparison</button></div> : loading ? <Loading label="Comparing visual features" /> : <div className="similar-grid">{items.map((item) => <div key={item.smk_entity_id}><ArtworkTile artwork={item} /><p className="distance">#{item.rank} · distance {item.distance.toFixed(4)}</p></div>)}</div>}</section>;
}
function ToneCard({ art }: { art: Artwork }) {
const [selected, setSelected] = useState<"weighted_color" | "physical">("weighted_color");
const tone = art.sonification[selected];
return <section className="tone-card"><div className="tone-top"><div><p className="eyebrow">Sonification</p><h2>The artwork as tone</h2></div>{tone.available && <div className="note-display"><strong>{tone.note}{tone.octave}</strong><span>{tone.frequency_hz?.toFixed(2)} Hz</span></div>}</div><div className="tone-tabs"><button className={selected === "weighted_color" ? "active" : ""} onClick={() => setSelected("weighted_color")}>Color tone</button><button disabled={!art.sonification.physical.available} className={selected === "physical" ? "active" : ""} onClick={() => setSelected("physical")}>Physical tone</button></div>{tone.available ? <><div className="audio-actions"><button className="play-button" onClick={() => playTone(tone)}><Play fill="currentColor" /> Play tone</button><button className="icon-button" aria-label="Stop audio" onClick={stopAudio}><Pause /></button><button className="outline-button" onClick={() => playArpeggio(art.sonification.palette_arpeggio.notes)}><Play size={16} /> Play palette</button></div><div className="why"><strong>Why this sound?</strong><p>{tone.explanation?.pitch}. {tone.explanation?.register}.</p></div></> : <p className="unavailable">{tone.reason}</p>}</section>;
}
function ArtworkPage() {
const { id = "" } = useParams(); const [art, setArt] = useState<Artwork>(); const [error, setError] = useState("");
useEffect(() => { stopAudio(); setArt(undefined); api.artwork(id).then(setArt).catch((e) => setError(String(e))); return stopAudio; }, [id]);
if (error) return <Shell><main className="message">{error}</main></Shell>;
if (!art) return <Shell><Loading label="Opening artwork" /></Shell>;
const f = art.weighted_features;
return <Shell><main className="artwork-page"><Link className="back" to="/"><ChevronLeft /> Back to collection</Link><section className="artwork-hero"><div className="artwork-image-stage"><img src={art.image_thumbnail || art.image_native} alt={`${art.display_title} by ${art.artist_display || "unknown artist"}`} /></div><div className="artwork-info"><p className="eyebrow">{art.classification || "Artwork"} · {art.object_number}</p><h1>{art.display_title}</h1><p className="artwork-byline">{art.artist_display || "Unknown artist"}</p><p className="muted">{art.date_text}{art.net_width_cm ? ` · ${art.net_width_cm} × ${art.net_height_cm} cm` : ""}</p><div className="feature-pills"><span>L {Number(f.centroid_l).toFixed(3)}</span><span>C {Number(f.centroid_chroma).toFixed(3)}</span><span>spread {Number(f.palette_spread_oklab).toFixed(3)}</span></div><PaletteBar palette={art.weighted_palette} /></div></section><ToneCard art={art} /><SimilarWorks id={id} /><section className="provenance"><p className="eyebrow">What comes from where</p><div>{Object.entries(art.provenance).map(([key, value]) => <p key={key}><strong>{key.replaceAll("_", " ")}</strong>{value}</p>)}</div></section></main></Shell>;
}
function About() { return <Shell><main className="about"><p className="kicker"><Sparkles size={14} /> About the experiment</p><h1>Colour is data.<br /><em>Sound is interpretation.</em></h1><div className="about-grid"><p>SMK Playground uses open collection metadata and images from Statens Museum for Kunst. The corpus contains public-domain works with usable image and palette data.</p><p>Palette weights come from SMK’s enrichment data. Perceptual colour features, similarity distances and sonification parameters are derived here and versioned separately.</p><p>Hue-to-pitch and size-to-register are deterministic artistic conventions—not natural laws or claims about how paintings “really” sound.</p></div></main></Shell> }
export default function App() { return <Routes><Route path="/" element={<Home />} /><Route path="/artwork/:id" element={<ArtworkPage />} /><Route path="/about" element={<About />} /></Routes>; }

16
web/src/api.ts Normal file
View file

@ -0,0 +1,16 @@
import type { Artwork, ArtworkCard, SimilarItem } from "./types";
async function get<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error((await response.text()) || `Request failed: ${response.status}`);
return response.json() as Promise<T>;
}
export const api = {
stats: () => get<{ artwork_count: number; weighted_palette_count: number; physical_geometry_count: number }>("/api/stats"),
search: (q = "", offset = 0) => get<{ total: number; items: ArtworkCard[] }>(`/api/artworks?q=${encodeURIComponent(q)}&limit=24&offset=${offset}`),
random: () => get<ArtworkCard>("/api/artworks/random"),
artwork: (id: string) => get<Artwork>(`/api/artworks/${encodeURIComponent(id)}`),
similar: (id: string, profile: string, limit = 10) =>
get<{ profile: string; cached: boolean; items: SimilarItem[] }>(`/api/artworks/${encodeURIComponent(id)}/similar?profile=${profile}&limit=${limit}`),
};

41
web/src/audio.ts Normal file
View file

@ -0,0 +1,41 @@
import type { PaletteNote, Tone } from "./types";
let context: AudioContext | null = null;
let active: AudioNode[] = [];
function ctx() { context ??= new AudioContext(); return context; }
export function stopAudio() { active.forEach((node) => { try { (node as OscillatorNode).stop?.(); } catch {} }); active = []; }
export function playTone(tone: Tone) {
if (!tone.available || !tone.frequency_hz) return;
stopAudio();
const audio = ctx();
const now = audio.currentTime;
const master = audio.createGain();
const pan = audio.createStereoPanner(); pan.pan.value = tone.stereo_pan ?? 0;
master.gain.setValueAtTime(0.0001, now);
master.gain.exponentialRampToValueAtTime(Math.max(0.02, (tone.velocity ?? .5) * .28), now + (tone.attack ?? .1));
master.gain.exponentialRampToValueAtTime(0.0001, now + (tone.release ?? 1.2) + (tone.attack ?? .1));
master.connect(pan).connect(audio.destination);
const harmonics = 1 + Math.round((tone.harmonic_richness ?? 0) * 3);
for (let harmonic = 1; harmonic <= harmonics; harmonic++) {
const oscillator = audio.createOscillator();
const gain = audio.createGain(); gain.gain.value = 1 / (harmonic * harmonics);
oscillator.type = harmonic === 1 ? "sine" : "triangle";
oscillator.frequency.value = tone.frequency_hz * harmonic;
oscillator.connect(gain).connect(master); oscillator.start(now); oscillator.stop(now + (tone.release ?? 1.2) + .4);
active.push(oscillator);
}
}
export function playArpeggio(notes: PaletteNote[]) {
stopAudio(); const audio = ctx(); let cursor = audio.currentTime;
for (const note of notes) {
const oscillator = audio.createOscillator(); const gain = audio.createGain();
oscillator.type = "sine"; oscillator.frequency.value = note.frequency_hz;
gain.gain.setValueAtTime(.0001, cursor); gain.gain.exponentialRampToValueAtTime(note.velocity * .22, cursor + .04);
gain.gain.exponentialRampToValueAtTime(.0001, cursor + note.duration_seconds);
oscillator.connect(gain).connect(audio.destination); oscillator.start(cursor); oscillator.stop(cursor + note.duration_seconds + .05);
active.push(oscillator); cursor += note.duration_seconds;
}
}

28
web/src/components.tsx Normal file
View file

@ -0,0 +1,28 @@
import { Link } from "react-router-dom";
import { ArrowUpRight, Music2 } from "lucide-react";
import type { ArtworkCard } from "./types";
export function ArtworkTile({ artwork }: { artwork: ArtworkCard }) {
return <Link className="group artwork-tile" to={`/artwork/${artwork.smk_entity_id}`}>
<div className="tile-image-wrap">
{artwork.image_thumbnail ? <img className="tile-image" src={artwork.image_thumbnail} alt={artwork.display_title} loading="lazy" /> : <div className="image-fallback"><Music2 /></div>}
<span className="tile-arrow"><ArrowUpRight size={18} /></span>
</div>
<div className="pt-3">
<p className="eyebrow">{artwork.object_number}</p>
<h3 className="tile-title">{artwork.display_title}</h3>
<p className="muted line-clamp-1">{artwork.artist_display || "Unknown artist"}{artwork.date_text ? ` · ${artwork.date_text}` : ""}</p>
</div>
</Link>;
}
export function PaletteBar({ palette }: { palette: Array<{ hex: string; weight: number }> }) {
return <div className="palette-wrap" aria-label="Weighted colour palette">
<div className="palette-bar">{palette.map((color) => <div key={color.hex} style={{ background: color.hex, flexGrow: Math.max(color.weight, .025) }} title={`${color.hex} ${(color.weight * 100).toFixed(1)}%`} />)}</div>
<div className="palette-labels">{palette.map((color) => <div key={color.hex} className="palette-label"><span className="swatch" style={{ background: color.hex }} /><span>{color.hex}</span><strong>{(color.weight * 100).toFixed(1)}%</strong></div>)}</div>
</div>;
}
export function Loading({ label = "Loading collection" }: { label?: string }) {
return <div className="loading"><span /><p>{label}</p></div>;
}

7
web/src/main.tsx Normal file
View file

@ -0,0 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>);

39
web/src/styles.css Normal file
View file

@ -0,0 +1,39 @@
@import "tailwindcss";
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #201f19; background: #f3f0e8; font-synthesis: none; --ink:#201f19; --paper:#f3f0e8; --acid:#dbe85b; --line:rgba(32,31,25,.17); }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
a { color: inherit; text-decoration: none; }
button,input { font: inherit; }
button { cursor: pointer; }
button:focus-visible,a:focus-visible,input:focus-visible { outline: 3px solid #756dff; outline-offset: 3px; }
.site-header { height: 74px; padding: 0 clamp(22px,4vw,70px); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); position:relative; z-index:4; }
.brand { font-weight:750; letter-spacing:-.02em; display:flex; align-items:center; gap:10px; }
.brand-mark { width:17px;height:17px;border-radius:50%;background:conic-gradient(#dbe85b,#f49b67,#7987ef,#dbe85b); }
nav { display:flex; gap:28px; font-size:14px; } nav a { opacity:.55; } nav a.active,nav a:hover { opacity:1; }
main { min-height:calc(100vh - 150px); }
.hero { min-height:660px; padding:90px clamp(22px,7vw,120px) 70px; display:grid; grid-template-columns:minmax(0,1.15fr) minmax(300px,.85fr); align-items:center; overflow:hidden; }
.kicker,.eyebrow { text-transform:uppercase; letter-spacing:.12em; font-weight:700; font-size:11px; display:flex; align-items:center; gap:7px; }
.hero h1,.about h1 { font-family:Georgia,serif; font-size:clamp(64px,8.5vw,132px); line-height:.82; letter-spacing:-.065em; margin:22px 0 34px; font-weight:400; }
.hero h1 em,.about h1 em { font-weight:400;color:#5967db; }
.hero-copy { max-width:590px;font-size:19px;line-height:1.55;color:#545248; }
.search-box { margin-top:38px;max-width:650px;background:#fff;border:1px solid var(--line);border-radius:2px;padding:8px 8px 8px 18px;display:flex;align-items:center;gap:12px;box-shadow:0 12px 45px rgba(42,39,25,.08); }
.search-box input { flex:1;border:0;outline:0;background:transparent;padding:13px 0;min-width:0; }
.search-box button,.deep-callout button { border:0;background:var(--ink);color:#fff;padding:13px 22px;border-radius:2px;font-weight:650; }
.text-button { border:0;background:none;padding:18px 0;display:flex;align-items:center;gap:8px;font-weight:650; }
.hero-orbit { position:relative; width:min(38vw,500px); aspect-ratio:1; margin:auto; border:1px solid var(--line); border-radius:50%; animation:float 10s ease-in-out infinite; }
.hero-orbit:before,.hero-orbit:after { content:"";position:absolute;border:1px solid var(--line);border-radius:50%;inset:14%; }.hero-orbit:after{inset:31%;}
.hero-orbit>span { position:absolute;width:68px;height:68px;border-radius:50%;filter:saturate(.8);box-shadow:0 8px 28px #0002; }.hero-orbit>span:nth-child(1){background:#908d41;left:8%;top:18%}.hero-orbit>span:nth-child(2){background:#aed7ea;right:4%;top:44%;width:92px;height:92px}.hero-orbit>span:nth-child(3){background:#795025;left:20%;bottom:2%;width:48px;height:48px}.tone-dot{position:absolute;inset:42%;border-radius:50%;display:grid;place-items:center;background:var(--acid)}
@keyframes float { 50% { transform:translateY(-12px) rotate(4deg); } }
.stat-strip { border-block:1px solid var(--line);display:grid;grid-template-columns:repeat(3,1fr);padding:28px clamp(22px,7vw,120px); }.stat-strip div{display:flex;gap:12px;align-items:baseline;border-right:1px solid var(--line);padding-left:30px}.stat-strip div:first-child{padding-left:0}.stat-strip div:last-child{border:0}.stat-strip strong{font:42px Georgia,serif}.stat-strip span{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#6c695d}
.collection,.similar-section { padding:90px clamp(22px,5vw,80px); }.section-heading h2,.tone-card h2 { font:48px Georgia,serif;margin:8px 0 35px;letter-spacing:-.04em; }
.art-grid { display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:42px 22px; }.tile-image-wrap { aspect-ratio:4/5;background:#e4e0d5;position:relative;overflow:hidden; }.tile-image { width:100%;height:100%;object-fit:cover;transition:transform .5s cubic-bezier(.2,.7,.2,1); }.group:hover .tile-image{transform:scale(1.025)}.tile-arrow{position:absolute;right:10px;top:10px;width:34px;height:34px;background:#f7f4eddd;display:grid;place-items:center;border-radius:50%;opacity:0;transform:translateY(5px);transition:.25s}.group:hover .tile-arrow{opacity:1;transform:none}.tile-title{font:21px Georgia,serif;margin:5px 0 5px;line-height:1.18}.muted{color:#747064;font-size:14px}.image-fallback{height:100%;display:grid;place-items:center;color:#999}
.loading { min-height:280px;display:grid;place-items:center;align-content:center;gap:16px;color:#777 }.loading span{width:30px;height:30px;border:2px solid #bbb;border-top-color:#333;border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
.artwork-page { padding:42px clamp(20px,5vw,80px) 100px; }.back{display:inline-flex;align-items:center;gap:3px;font-size:13px;margin-bottom:35px}.artwork-hero{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(350px,.85fr);gap:clamp(45px,7vw,120px);align-items:center}.artwork-image-stage{background:#ddd8cc;min-height:600px;display:grid;place-items:center;padding:40px}.artwork-image-stage img{max-width:100%;max-height:75vh;object-fit:contain;box-shadow:0 18px 60px #342d1f2b}.artwork-info h1{font:clamp(48px,5.2vw,84px)/.95 Georgia,serif;letter-spacing:-.055em;margin:20px 0}.artwork-byline{font-size:20px}.feature-pills{display:flex;flex-wrap:wrap;gap:7px;margin:25px 0}.feature-pills span{font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:20px;padding:7px 10px}
.palette-wrap{margin-top:35px}.palette-bar{height:74px;display:flex;box-shadow:0 8px 25px #0001}.palette-bar div{min-width:3px}.palette-labels{display:grid;grid-template-columns:repeat(2,1fr);gap:8px 18px;margin-top:18px}.palette-label{display:grid;grid-template-columns:13px 1fr auto;gap:8px;align-items:center;font:11px ui-monospace,monospace}.swatch{width:11px;height:11px;border-radius:50%;border:1px solid #0002}
.tone-card{margin:100px auto 40px;max-width:1180px;background:#171812;color:#f4f1e8;padding:clamp(28px,5vw,70px);border-radius:3px}.tone-top{display:flex;justify-content:space-between;align-items:start}.tone-card .eyebrow{color:var(--acid)}.note-display{text-align:right}.note-display strong{display:block;font:76px Georgia,serif;color:var(--acid)}.note-display span{font:12px ui-monospace,monospace;color:#aaa}.tone-tabs,.tabs{display:flex;gap:7px;flex-wrap:wrap}.tone-tabs button,.tabs button{border:1px solid currentColor;background:transparent;color:inherit;padding:9px 13px;border-radius:30px;font-size:12px;opacity:.55}.tone-tabs button.active,.tabs button.active{opacity:1;background:var(--acid);color:#15150f;border-color:var(--acid)}.tone-tabs button:disabled{opacity:.2;cursor:not-allowed}.audio-actions{display:flex;align-items:center;gap:10px;margin:35px 0}.play-button,.outline-button,.icon-button{border:0;padding:14px 20px;display:flex;align-items:center;gap:8px;border-radius:2px}.play-button{background:var(--acid);color:#111;font-weight:750}.outline-button,.icon-button{background:transparent;color:#fff;border:1px solid #ffffff55}.icon-button{padding:13px}.why{border-top:1px solid #ffffff2b;padding-top:24px}.why p{color:#bcbdb3}.unavailable{color:#aaa}
.similar-section{padding-inline:0}.tabs{margin-bottom:30px}.tabs button{color:var(--ink);border-color:var(--line)}.tabs button.active{background:var(--ink);color:#fff;border-color:var(--ink)}.similar-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:18px}.distance{font:11px ui-monospace,monospace;color:#777}.deep-callout{padding:30px;border:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}.deep-callout p{color:#777;margin-bottom:0}.provenance{margin-top:80px;border-top:1px solid var(--line);padding-top:30px}.provenance>div{display:grid;grid-template-columns:repeat(4,1fr);gap:25px}.provenance p{font-size:13px;color:#777}.provenance strong{display:block;color:#222;text-transform:capitalize;margin-bottom:6px}
.about{padding:110px clamp(22px,8vw,140px)}.about h1{font-size:clamp(62px,8vw,120px)}.about-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:45px;max-width:1100px;font-size:18px;line-height:1.7}.message{padding:100px}footer{border-top:1px solid var(--line);display:flex;justify-content:space-between;padding:30px clamp(22px,4vw,70px);font-size:11px;text-transform:uppercase;letter-spacing:.09em;color:#777}
@media(max-width:1000px){.art-grid{grid-template-columns:repeat(3,1fr)}.similar-grid{grid-template-columns:repeat(3,1fr)}.artwork-hero{grid-template-columns:1fr}.artwork-image-stage{min-height:450px}.hero{grid-template-columns:1fr}.hero-orbit{display:none}.provenance>div{grid-template-columns:repeat(2,1fr)}}
@media(max-width:650px){.site-header{height:62px}.hero{padding-top:60px;min-height:600px}.hero h1{font-size:60px}.art-grid{grid-template-columns:repeat(2,1fr);gap:30px 12px}.stat-strip{grid-template-columns:1fr;padding-block:18px}.stat-strip div{border-right:0;border-bottom:1px solid var(--line);padding:12px 0}.similar-grid{grid-template-columns:repeat(2,1fr)}.palette-labels{grid-template-columns:1fr}.tone-top{display:block}.note-display{text-align:left}.provenance>div,.about-grid{grid-template-columns:1fr}.deep-callout{align-items:flex-start;gap:18px;flex-direction:column}footer{flex-direction:column;gap:8px}.artwork-image-stage{padding:20px;min-height:360px}.collection{padding-top:60px}}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}

29
web/src/types.ts Normal file
View file

@ -0,0 +1,29 @@
export type ArtworkCard = {
smk_entity_id: string; object_number: string; display_title: string;
artist_display?: string; date_text?: string; classification?: string;
image_thumbnail?: string; centroid_chroma?: number; dominant_color_hex?: string;
};
export type Tone = {
profile: string; version: string; available: boolean; reason?: string; note?: string;
octave?: number; midi?: number; frequency_hz?: number; velocity?: number; attack?: number;
release?: number; harmonic_richness?: number; noise_amount?: number; tonal_purity?: number;
stereo_pan?: number; explanation?: Record<string, string>;
};
export type PaletteNote = { hex: string; weight: number; note: string; octave: number;
frequency_hz: number; velocity: number; duration_seconds: number };
export type Artwork = ArtworkCard & {
title_da?: string; title_en?: string; image_native?: string; image_width?: number;
image_height?: number; rights_semantic?: string; net_width_cm?: number; net_height_cm?: number;
quality_flags: string[];
weighted_palette: Array<{ palette_rank: number; hex: string; token_count: number; weight: number;
oklab_l: number; oklch_c: number; oklch_h_deg?: number }>;
weighted_features: Record<string, number | string | null>;
sonification: { weighted_color: Tone; physical: Tone;
palette_arpeggio: { available: boolean; notes: PaletteNote[]; explanation: string } };
provenance: Record<string, string>;
};
export type SimilarItem = ArtworkCard & { rank: number; distance: number; active_components: string[] };

1
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

20
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

4
web/tsconfig.json Normal file
View file

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}

8
web/vite.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: { proxy: { "/api": "http://localhost:8000" } },
});