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.)
Reuse external vocabularies first; mint ph: only for project-specific concepts.
Per CLAUDE.md. Order of preference, when a concept fits multiple:
[[feedback_ontology_prov_preference]])dcterms:) for bibliographic / file metadataschema:GeoCoordinates is used)Project-minted ph: classes & properties:
ph:Tour (⊑ prov:Activity, schema:Event), ph:Image
(⊑ prov:Entity, schema:ImageObject), ph:Association (reification of
Tour↔EventSeries membership), ph:RouteReference (URL + provider).ph:capturedDuring (Image → Tour, ⊑ prov:wasGeneratedBy),
ph:linkRoute, ph:inSeriesEdition, ph:inEventSeries.ph:energyEstimate / ph:measuredEnergy (plain
xsd:decimal kJ literals on the tour), ph:linkInstagram, ph:countAttendee,
ph:countNewcomer, ph:sequenceInSeries,
ph:mediaCount, ph:anonymized, ph:compressed.ph:rwgps, ph:strava declared as
schema:Organization so route references can use schema:provider.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.
PascalCase.lowerCamelCase.ph:estimate_quilojoules → ph:energyEstimate; the unit — kJ — is implicit in the property, documented in its rdfs:comment).Shapes are organized as one sh:NodeShape per concept, with sh:property blocks for each constraint.
Severities:
sh:Violation — must hold (date format, GPS presence, valid IRIs, route reference).sh:Warning — encouraged but not required (attribution, license, camera metadata, optional flags).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.
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.
phd:tour_<eH> where eH is the integer “ever” counter from the CSV.phd:pessoa<Capitalized> (slugified, accents stripped). The original
nickname/full name lives in schema:alternateName.phd:image_<phash16> — uses the full 16-hex perceptual hash as the
IRI suffix. Near-duplicate uploads share an IRI, which is the clustering
behavior described in CLAUDE.md.phd:PH, phd:BP, phd:S (Suados), phd:BT (Bicicletografia).phd:assoc_<series>_<n> (one per (series, sequence) pair).ph:rwgps, ph:strava (vocabulary-level instances).schema:organizer was being misused as Tour → Association. Fixed by:
ph:inSeriesEdition (Tour → Association).ph:inEventSeries (Association → EventSeries) and
ph:sequenceInSeries (Association → integer).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.
prov:wasAttributedTo (the author — cartographer/copywriter/
artist roles all funneled to the same predicate based on the CSV’s 🗺️📝🎨
columns).pav:providedBy (who actually uploaded). Often the
same person as the author; data file has them separate.
build-tours.pywas removed (recoverable from git history). The CSV conversion below documents how the seed catalog was produced; since thentours.ttlgained data with no CSV counterpart (narratives, announcement images, server-side edits), so a rebuild would lose data. The catalog is maintained viaupload_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.
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)
| 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).
Switched from SHA-256 to a Hacker-Factor-style pHash so near-duplicates cluster naturally:
coef > median → 64 bits → 16 hexHamming 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.
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:
skipAutoDetect flag is true from the start.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.
#files) at the bottom of the page (below #cards), so it
naturally moves down as cards accumulate.pickerBusy flag re-entry-guards the change handler against browsers that
fire change twice on a single dialog (Safari quirk).clearNotices() at the start of each pick so stale messages from prior
uploads don’t bleed into the new batch.cards[].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.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:
Comprimir → 500 KB compressed JPEG (re-encoded)Anonimizar (alone) → re-encoded JPEG, EXIF stripped as a side effectCompression loop tries quality 0.85 → 0.3 at progressively smaller max dimensions (2400 → 500 px) until the blob fits the 500 KB target.
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).
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.
-03:00 for SP).CC BY-SA 4.0.true (omit-default convention to keep TTL terse).pav:providedBy warning fires on every backfilled tour (102/102). The CSV has no provider column; we either need to drop the requirement from TourShape or treat 🗺️ (cartographer) as the provider too.PH=n/a). Intentional per the spreadsheet.phd:assoc_PH_1 points to phd:BP and phd:assoc_BP_1 to phd:PH. Possibly a data error in the original spreadsheet; the converter doesn’t second-guess.PH-S and BT are guesses — confirm with the user../data/tours.ttl) breaks under file:// — documented above. Could be bypassed by pre-baking the catalog into the HTML at build time if a fully offline mode is needed.