amora

Photos-RDF — Design Notes

Notes accumulated across the design of the photos/tours RDF substrate: the active vocabulary at ../web/data/ontology.ttl and shapes at ../web/data/shapes.ttl, and the catalog in ../web/data/tours.ttl, maintained via the Tour CRUD endpoints. The production upload path is ../web/upload_images.html, served by the backend. (These notes were written in the former research/photos-rdf/ lab — seed graph, tours.csv, the legacy kit-export upload-form.html — removed in 10/2026; git history has it.)

1. Vocabulary strategy

Reuse external vocabularies first; mint ph: only for project-specific concepts. Per CLAUDE.md. Order of preference, when a concept fits multiple:

  1. PROV-O (primary — per stored memory [[feedback_ontology_prov_preference]])
  2. schema.org (secondary)
  3. Dublin Core (dcterms:) for bibliographic / file metadata
  4. QUDT for quantities & units
  5. NFO for file hashes
  6. EXIF for camera metadata
  7. GeoSPARQL for spatial primitives (currently only schema:GeoCoordinates is used)

Project-minted ph: classes & properties:

Deprecated but retained (with owl:deprecated true + owl:equivalentProperty): ph:linkRWGPS, ph:EventSeriesSequenceNumber, ph:estimate_quilojoules. Old data keeps validating; new data should use the successors.

Naming conventions

2. Shapes & validation

Shapes are organized as one sh:NodeShape per concept, with sh:property blocks for each constraint.

Severities:

Energy & derived intensity. ph:energyEstimate and ph:measuredEnergy are plain xsd:decimal kJ literals on the tour (sh:datatype xsd:decimal, sh:minInclusive 0, sh:maxCount 1, sh:Warning in TourShape). The qualitative intensity label is not stored — it’s derived from the kJ estimate by fixed bands, computed in the readers (censo.html, app.js, backend/main.py _intensity_for):

Range (kJ) Label
0 ≤ x < 150 De boa
150 ≤ x < 300 Ok
300 ≤ x < 500 Endorfinado
500 ≤ x < 1000 Frito
x ≥ 1000 Insano

(Previously each energy pointed at a qudt:QuantityValue node carrying qudt:numericValue + qudt:hasUnit unit:KiloJ + a stored ph:intensityClassification, and ph:EnergyEstimateShape SPARQL-validated the label against the band. The nested nodes, that shape, ph:QuantityValueShape, and ph:intensityClassification were all removed when the energies were flattened to literals.)

License is an IRI, not a string (sh:nodeKind sh:IRI). Use the canonical Creative Commons URL.

Auto-detected booleans: ph:anonymized and ph:compressed are optional warnings. Only emit true — absence implies false; tidies the TTL.

Validation pipeline gotcha

pyshacl.validate(..., ont_graph=ont) makes axioms available for inference but does not expose instance triples in ont to sh:class checks. The fix is to merge data + ont and pass the merged graph as data_graph:

merged = data + ont
pyshacl.validate(merged, shacl_graph=shapes, inference='rdfs', advanced=True)

This was discovered when ph:rwgps (declared as schema:Organization only in ontology.ttl) wasn’t being recognized by ph:RouteReferenceShape’s schema:provider class check. Going forward, any project-wide validation script should always pass the merged graph.

3. Data graphs

IRI schemes

Reification of series membership

schema:organizer was being misused as Tour → Association. Fixed by:

  1. Inventing ph:inSeriesEdition (Tour → Association).
  2. Inventing ph:inEventSeries (Association → EventSeries) and ph:sequenceInSeries (Association → integer).
  3. Leaving schema:organizer available for the actual organizing person/org (currently unfilled).

A Tour can have multiple series memberships (e.g. PH-83 was also BP-3) — the Association reification carries the per-series sequence number cleanly.

Photo provenance

4. Build pipeline (build-tours.py) — HISTORICAL

build-tours.py was removed (recoverable from git history). The CSV conversion below documents how the seed catalog was produced; since then tours.ttl gained data with no CSV counterpart (narratives, announcement images, server-side edits), so a rebuild would lose data. The catalog is maintained via upload_tour.html / the Tour CRUD endpoints today.

Converted a TSV dump of the spreadsheet (data/tours.csv in this folder) into web/data/tours.ttl (at the repo root, where the backend and the web app read it).

Sentinels treated as “no value”: '', -, n/a, ?, sumiu, #DIV/0!, #REF!, #N/A.

Number parsing: strips comma thousand separators (1,700 → 1700).

Dates: assumes São Paulo timezone (-03:00); writes xsd:dateTime.

Routes: only emits ph:linkRoute when the URL host matches ridewithgps.com or strava.com. Provider derived from URL host.

Intensity classification: computed automatically from kJ value using the same bucket table the SHACL rule enforces.

Persons: nicknames slugged → phd:pessoa<Name>, deduped across the dataset, declared with schema:alternateName carrying the raw nickname.

Series titles: hardcoded guesses (Pedais Hidrográficos Regulares, Bicipassarinhadas, Pedais Hidrográficos Suados, Bicicletografia) — update in the script if better titles emerge.

5. Upload form

The production upload path is web/upload_images.html, served by the backend (POSTs each card to /upload-image). These notes were written for its predecessor, the “build a ZIP kit” upload-form.html (removed with research/): same UI primitives, but the output was a downloadable archive instead of live POSTs. The per-card lifecycle, EXIF propagation, defaults panel, etc. apply to the production form unless noted.

Browser-only, dependency-light, but loads several modules from CDN at runtime (see deps below). Needs HTTP (the catalog fetch breaks on file://).

python3 -m http.server -d web 8000
# → http://localhost:8000/upload_images.html (uploads need the backend)

CDN dependencies

Lib Purpose
exifr EXIF parsing (date, GPS, bearing, focal length)
n3 Turtle parsing (loads data/tours.ttl + data/initial-data.ttl for the catalog)
tom-select Searchable, multi-select dropdown with on-the-fly creation for people
jszip Build the update-kit ZIP
heic2any HEIC → JPEG conversion in the browser (libheif-wasm)

All five are tolerated as missing: form degrades to manual entry if any CDN is unreachable. heic2any is the one exception that hard-blocks a specific code path — HEIC files are rejected with a notice if the CDN is down (no fallback, since Chrome/Firefox can’t decode HEIC natively).

Perceptual hash (pHash)

Switched from SHA-256 to a Hacker-Factor-style pHash so near-duplicates cluster naturally:

  1. Decode → 32×32 grayscale
  2. Separable 2D DCT
  3. Top-left 8×8 low-frequency block
  4. Median of 63 (excluding DC)
  5. Bit = coef > median → 64 bits → 16 hex

Hamming distance ≤ PHASH_DUP_THRESHOLD (default 5) → reject the upload as a duplicate. Threshold is tunable at the top of the module script. The notice that fires names both files and the distance, so users can calibrate.

Tour auto-detection

When a photo’s date is set (manually or from EXIF), the form picks the tour whose start time falls in [photo − 2h, photo + 12h]. Ties broken by closest start. Two ways the detection gets locked:

  1. If a default tour was set in the Padrões panel, the card’s skipAutoDetect flag is true from the start.
  2. If the user picks a tour manually after the fact, skipAutoDetect flips on via the select’s change event.

Programmatic select.value = … does not fire change — so the auto-detector setting the value doesn’t accidentally lock itself.

Card lifecycle

Notices placement

Live inside the upload-zone fieldset, next to the file picker (not at the top of the document) — so the message about a pick is adjacent to the picker that caused it.

Update kit ZIP

update.ttl
photos/<phash>/original.<ext|jpg>
photos/<phash>/large.jpg     ≤ 500 KB
photos/<phash>/thumb.jpg     256 px

large.jpg and thumb.jpg are always (re)generated from the cached ImageBitmap (one decode pass per photo). original depends on the per-card toggles:

Compression loop tries quality 0.85 → 0.3 at progressively smaller max dimensions (2400 → 500 px) until the blob fits the 500 KB target.

EXIF propagation

Canvas toBlob('image/jpeg') always drops the source’s APP1/Exif segment. To keep date / GPS / camera fields in the rendered artefacts, we splice the source JPEG’s APP1 segment into the re-encoded blob (copyExifSegment, ~30 lines, no extra CDN dep):

Artefact EXIF when !anon EXIF when anon
original.<ext> (raw) preserved (untouched) n/a — anon forces re-encode
original.jpg (compressed) propagated from source stripped
original.jpg (anon only) n/a stripped
large.jpg propagated from source stripped
thumb.jpg always stripped always stripped

thumb.jpg deliberately stays clean — it’s the public 256 px preview and shouldn’t carry GPS. anon is the single privacy switch; when set, every re-encoded output is bare.

Limitation: APP1 splicing only works when the source is itself a JPEG. HEIC / PNG / WebP sources skip the copy silently (returns the target blob unchanged).

Defaults panel

Single panel on the upload zone with:

Defaults apply at card creation time. Changing the defaults after cards exist does not retroactively touch them — re-upload to re-apply.

Person mint propagation. When a user types a new person in any person-select (defaults panel or any card), mintPerson() adds the option to every existing person-select in the page via a shared personSelects[] registry. The new person is emitted as phd:pessoaX a schema:Person ; schema:alternateName "..." at the top of the generated Turtle when used.

UI strings & code identifiers

6. Cross-cutting conventions

7. Known limitations & open questions