A scene record exists only where a corridor has been licensed, captured, and run. The counts below are current as of this request, and they move — corridors are being read in overnight batches. Do not assume a panorama you can see in the viewer has a record behind it: ask the endpoint, and expect a 404 for most of the fleet today.
Coverage is read at one of two depths, and the difference matters more than the totals do. Check tier before you assume a field exists.
| Tier | Panoramas | Corridors | Payload sections | What it carries |
|---|---|---|---|---|
| lite | 24,114 | 54 | texts · tags · scalars · caption | Caption, tags, transcribed text with legibility, surface and water character, people count, scene rating, capture issues. No entities array — nothing located, no bearings. |
| full | 4,779 | 21 | entities · texts · tags · scalars · caption | Adds an entities array: located objects with a source face, an anchor, distance and condition. Whether an object is pixel-grounded depends on when its corridor was read — see the reliability tiers below for the live position. |
The record is additive: it sits beside the existing corridor and panorama tables and never rewrites them. Column names, types, and nullability below are generated from the database catalog when this page is served — this is the live schema, not a description of it. The notes are the only hand-written part.
One row per panorama that has been read. The flat columns are the scene-level facts; the graph column holds the full structured reading.
| Column | Type | Null | Notes |
|---|---|---|---|
| scene_id | integer | no | Primary key. The panorama this record describes; the same id the read endpoints take. |
| trail_id | integer | no | Corridor the panorama belongs to. Every public read is gated on this corridor being public. |
| tier | text | no | Depth of the pass that produced the record. full carries an entities array; lite does not, so a lite scene has no located objects and no bearings. Check this before assuming a field is present. |
| schema_v | smallint | no | Structure version of the stored graph. Compare this before parsing, not the extraction date. |
| model | text | yes | Internal provenance of the pass. Not served by the public endpoints. |
| batch_id | text | yes | Internal job identity for reconciliation. Not served publicly. |
| graph | jsonb | no | Full structured reading of the scene. The flat columns beside it are denormalised for query speed. |
| caption | text | yes | One-sentence factual description of the scene. |
| scenic | smallint | yes | Openness / composition indicator for the view. |
| season | text | yes | Season as it appears in the imagery, not as inferred from the capture date. |
| water_kind | text | yes | Character of the water visible in the scene, where any is. |
| trail_surface | text | yes | Surface underfoot as read from the imagery. |
| people_count | smallint | yes | People visible. Drives review escalation; no identification is performed or stored. |
| escalated | boolean | no | True when the scene was re-read at higher resolution for one of the escalation triggers. |
| extracted_at | timestamp with time zone | no | When this record was written. |
scene_intel_tier_check CHECK ((tier = ANY (ARRAY['lite'::text, 'full'::text])))
One row per observed object. This is where the located record lives — face, anchor pixel, computed angles, and the reliability tier that decides whether it may be drawn.
| Column | Type | Null | Notes |
|---|---|---|---|
| id | bigint | no | Surrogate key. |
| scene_id | integer | no | Panorama the observation was read from. |
| trail_id | integer | no | Corridor, denormalised for the public gate and for corridor-wide queries. |
| type | text | no | Coarse class of the observed object. The live vocabulary is listed below. |
| label | text | yes | Free-text label as read. Trigram-indexed for fuzzy matching. |
| face | character | yes | Cube face the object was seen on: f, r, b, l, u, d. |
| bearing_deg | smallint | yes | Legacy captured bearing. Kept for pre-v2 rows; new rows derive bearing from pano_ath_deg instead. |
| dist_m | integer | yes | Estimated distance from the camera, in metres. An estimate, and labelled as one. |
| attributes | jsonb | yes | Type-specific detail. Reviewer audit history is held privately inside this column and is never served. |
| condition | text | yes | Condition as observed, where the type supports one. |
| confidence | real | yes | Confidence in the SEMANTIC identification. Separate from grounding confidence. |
| source | text | no | ai or human. Human rows survive every subsequent automated re-read. |
| hidden | boolean | no | Reviewer-suppressed. Hidden rows are excluded from every public read. |
| elev_deg | numeric | yes | Elevation angle above (+) or below (−) the horizon, computed from the anchor pixel. |
| source_image_index | smallint | yes | One-based cube face index returned by the reading pass. Must agree with face. |
| anchor_x | numeric | yes | Normalised horizontal position of the object centre within that face image. 0 is the left edge. |
| anchor_y | numeric | yes | Normalised vertical position within that face image. 0 is the TOP edge. |
| pano_ath_deg | numeric | yes | Panorama-relative azimuth computed from the anchor pixel. Immutable pixel geometry — see Coordinates below. |
| grounding_quality | text | no | Which rung of the reliability ladder this observation sits on. |
| grounding_source | text | no | How the position was established: computed from a reading, clicked by a reviewer, or inherited from before pixel grounding existed. |
| grounding_confidence | real | yes | Confidence in the COORDINATE only. This is what the ladder thresholds on. |
| grounding_issue | text | yes | Why an observation failed validation, when it did. |
| corrected_by | bigint | yes | Reviewer account that corrected the position. Never served publicly. |
| corrected_at | timestamp with time zone | yes | When the correction was made. Never served publicly. |
scene_entities_anchor_pair_check CHECK (((grounding_quality = ANY (ARRAY['unreliable'::text, 'legacy'::text])) OR ((anchor_x IS NULL) = (anchor_y IS NULL)))) scene_entities_anchor_x_check CHECK (((anchor_x IS NULL) OR ((anchor_x >= (0)::numeric) AND (anchor_x <= (1)::numeric)))) scene_entities_anchor_y_check CHECK (((anchor_y IS NULL) OR ((anchor_y >= (0)::numeric) AND (anchor_y <= (1)::numeric)))) scene_entities_face_image_match_check CHECK (((grounding_quality = ANY (ARRAY['unreliable'::text, 'legacy'::text])) OR (source_image_index IS NULL) OR ((face = 'f'::bpchar) AND (source_image_index = 1)) OR ((face = 'r'::bpchar) AND (source_image_index = 2)) OR ((face = 'b'::bpchar) AND (source_image_index = 3)) OR ((face = 'l'::bpchar) AND (source_image_index = 4)) OR ((face = 'u'::bpchar) AND (source_image_index = 5)) OR ((face = 'd'::bpchar) AND (source_image_index = 6)))) scene_entities_grounding_confidence_check CHECK (((grounding_confidence IS NULL) OR ((grounding_confidence >= (0)::double precision) AND (grounding_confidence <= (1)::double precision)))) scene_entities_grounding_quality_check CHECK ((grounding_quality = ANY (ARRAY['verified'::text, 'reliable'::text, 'approximate'::text, 'unreliable'::text, 'legacy'::text]))) scene_entities_grounding_source_check CHECK ((grounding_source = ANY (ARRAY['ai'::text, 'manual'::text, 'legacy'::text]))) scene_entities_source_check CHECK ((source = ANY (ARRAY['ai'::text, 'human'::text]))) scene_entities_source_image_index_check CHECK (((source_image_index IS NULL) OR ((source_image_index >= 1) AND (source_image_index <= 6))))
One row per piece of text read off a sign, marker, or plate in the scene.
| Column | Type | Null | Notes |
|---|---|---|---|
| id | bigint | no | Surrogate key. |
| scene_id | integer | no | Panorama the text was read from. |
| trail_id | integer | no | Corridor, denormalised for the public gate. |
| text | text | no | Transcription as read from the imagery. Trigram-indexed. |
| object | text | yes | What carried the text — sign, marker, gauge plate, posting. |
| legibility | text | yes | clear or partial. Read the Sign text section below before building on this column. |
| verified | boolean | no | True once a human has confirmed the transcription against the imagery. |
| source | text | no | ai or human. |
scene_texts_source_check CHECK ((source = ANY (ARRAY['ai'::text, 'human'::text])))
Controlled vocabulary of tag terms, built from what has actually been read rather than from a fixed list.
| Column | Type | Null | Notes |
|---|---|---|---|
| tag_id | integer | no | Surrogate key referenced by scene_intel_tags. |
| tag | text | no | The term itself. Unique. |
Join between panoramas and terms.
| Column | Type | Null | Notes |
|---|---|---|---|
| scene_id | integer | no | Panorama. |
| tag_id | integer | no | Term from the dictionary. |
| source | text | no | ai or human. |
scene_intel_tags_source_check CHECK ((source = ANY (ARRAY['ai'::text, 'human'::text])))
Search index over the record. Backs the search and facets endpoints.
| Column | Type | Null | Notes |
|---|---|---|---|
| scene_id | integer | no | Panorama. |
| trail_id | integer | no | Corridor. |
| doc | tsvector | yes | Full-text vector over caption, labels, and transcribed text. GIN-indexed; queried with websearch syntax. |
| tags | int4[] | no | Term ids as an integer array, GIN-indexed for containment queries. |
This is the part of the record most worth understanding before you build on it. Reading a panorama and locating something in it are two different jobs, and they are done by two different things. The reading pass identifies pixels. Deterministic code converts pixels into angles. A compass bearing is never estimated, requested, or returned by a reading pass — it is computed, and it is reproducible from the stored inputs.
For every renderable observation, exactly this, and nothing directional:
{
"source_image_index": 2,
"face": "RIGHT",
"bbox_center": { "x": 0.80, "y": 0.55 },
"grounding_confidence": 0.96
}
Stored face codes, and the one-based image index each maps to. Read from the converter's own constant:
Only the four horizontal faces can carry a panorama marker. A point on the zenith or nadir face has no meaningful compass direction, so the converter refuses to produce one rather than inventing a plausible number. Observations on those faces can still exist as context; they simply never become located markers.
Normalise the anchor into face-local coordinates, project onto the unit cube, then take the angles. One shared implementation serves every lens; a lens may not carry its own copy of this arithmetic.
// face-local, from the normalised anchor u = 2x - 1 // left −1 … right +1 v = 1 - 2y // top +1 … bottom −1 (y is measured DOWN) // direction vector, per horizontal face FRONT ( 1, v, u) RIGHT (-u, v, 1) BACK (-1, v, -u) LEFT ( u, v, -1) // angles pano_ath_deg = atan2(z, x) // panorama-relative azimuth, 0–360 elev_deg = atan2(v, sqrt(x² + z²)) // +up / −down from the horizon // absolute compass bearing, computed at read time bearing_deg = normalize(panorama_front_bearing + pano_ath_deg)
Computed by calling the shipping converter while this page was rendered, so they cannot drift from it. Values are panorama-relative; add the corridor's calibrated front bearing for an absolute compass direction.
| Face | x | y | pano_ath_deg | elev_deg | Reading |
|---|---|---|---|---|---|
| f · FRONT | 0.50 | 0.50 | 0.000 | 0.000 | Centre of the FRONT face |
| f · FRONT | 0.00 | 0.50 | 315.000 | 0.000 | Left edge of FRONT |
| f · FRONT | 1.00 | 0.50 | 45.000 | 0.000 | Right edge of FRONT |
| f · FRONT | 0.50 | 0.00 | 0.000 | 45.000 | Top edge of FRONT |
| f · FRONT | 0.50 | 1.00 | 0.000 | -45.000 | Bottom edge of FRONT |
| r · RIGHT | 0.50 | 0.50 | 90.000 | 0.000 | Centre of the RIGHT face |
| b · BACK | 0.50 | 0.50 | 180.000 | 0.000 | Centre of the BACK face |
| l · LEFT | 0.50 | 0.50 | 270.000 | 0.000 | Centre of the LEFT face |
Every observation carries a grounding tier, and the tier travels with it through the API. An observation is placed as a precise marker only if its geometry holds up. Everything below that bar is kept, stays queryable, and stays reviewable — it is simply never presented as located. The counts are live.
| Tier | Condition | Rendering | Rows now |
|---|---|---|---|
| verified | A reviewer clicked the object in the source pixels. Grounding source is recorded as manual. | Placed as a precise marker. | 0 |
| reliable | Valid face, index, and anchor, with grounding confidence at or above 0.75. | Placed as a precise marker. | 0 |
| approximate | Valid geometry, grounding confidence from 0.55 up to but not including 0.75. | Retained for review. Not drawn as a precise marker, and not delivered as located. | 0 |
| unreliable | Missing or invalid geometry, a face/index mismatch, or grounding confidence below 0.55. | Retained for review. Not drawn. | 0 |
| legacy | A pre-grounding row whose direction was estimated rather than computed from pixels. | Labelled legacy and left labelled. Never silently reclassified upward. | 50,658 |
Text read off signs, markers, gauge plates, and regulatory postings is stored with a legibility flag on every row. The distinction is preserved rather than smoothed over, because the two are not the same kind of evidence.
The text was readable at capture resolution. Treat it as a transcription of what the sign says. 1,440 rows.
The sign was too small, too oblique, too distant, or too degraded to read fully. The transcription is an unverified reading and is carried as one. 1,890 rows.
There is no fixed enumeration of object types. The vocabulary is whatever has actually been read out of captured corridors, which means it grows as coverage grows and as new corridor kinds are captured. Below are the most common types on record right now, out of 28 in use. Query /api/scene-intel/facets for the current list rather than hardcoding one.
Tag terms are a separate, larger free vocabulary over the same scenes — 54,828 distinct terms are in the dictionary today. The facets endpoint returns the top terms by count for whatever area you scope it to.
A lens is a named, versioned interpretation layered over the same universal scene record. A lens may join its own assessment record; it may never rewrite universal entities, text, tags, or captions. The table is generated from the server-side theme catalog when this page is served, so a theme cannot appear here as available before it is.
| Theme | URL key | Table id | Scopes | Schema / prompt | Public read |
|---|---|---|---|---|---|
| Scene Intelligence | default | default | scene | 2 / — | served |
| RAPG Access Sites | rapg | access | scene, trail, site, collection | 2 / 1.3 | served |
| NPS Visual Resource | nps-vri | nps-vri | scene, trail, corridor, collection | — / — | 404 — adapter_required |
| MS4 Stream Assessment | ms4 | ms4 | scene, trail, corridor, collection | — / — | 404 — adapter_required |
No key, no account, no registration. Cross-origin requests are permitted, so these can be called directly from a browser client. Responses are JSON. Requests are limited to 120 per minute per address; exceeding that returns 429 with a Retry-After header. Send a User-Agent that identifies your organisation — clients presenting generic scripted-library user agents are additionally capped and will start receiving 403 with a contact address. If you need sustained bulk access, ask; it is a conversation, not a scraping problem.
GET/api/scene-intel/scene/{id}
The full record for one panorama, plus gazetteer landmark cards for named places it can see. {id} is a panorama id, digits only.
{
"scene_id": 1533202,
"trail_id": 1459,
"tier": "full", // "full" | "lite" | "gnis"
"caption": "…",
"scenic": 3,
"season": "summer",
"water_kind": "river",
"trail_surface": "gravel",
"people_count": 0,
"escalated": false,
"tags": [ "…" ],
"entities": [ {
"type": "sign", "label": "…", "face": "r",
"source_image_index": 2,
"anchor_x": 0.82, "anchor_y": 0.53,
"pano_ath_deg": 122.619, // panorama-relative, immutable
"bearing_deg": 260.219, // computed per request from the current heading
"elev_deg": -1.2,
"dist_m": 14, "condition": "…", "confidence": 0.9,
"grounding_quality": "reliable",
"grounding_source": "ai", // "ai" | "manual" | "legacy"
"grounding_confidence": 0.96
} ],
"texts": [ { "text": "…", "object": "sign",
"legibility": "clear", "verified": false } ],
"landmarks": [ { "name": "…", "class": "Stream", "dist_m": 325,
"bearing": 96.4, "vision_seen": true,
"feature_id": 123456, "blurb": "…", "teaser": "…" } ]
}
Hidden observations are excluded. tier: "gnis" means the panorama has no reading pass behind it but does sit near named gazetteer features: you get the landmark cards and a caption synthesised from them, with every reading-derived field null. Check tier before assuming entities or texts will be populated. A panorama with neither a reading nor a landmark returns 404. Responses are cached server-side for an hour and invalidated on write, so a correction appears without waiting out the TTL.
GET/api/scene-intel/search
Search across the record. All parameters are optional and combine with AND.
| Param | Behaviour |
|---|---|
| q | Full-text query over caption, labels, and transcribed text. Supports quoted phrases and -exclusion. Results rank by relevance, then by panorama id so paging is stable. |
| tags | Comma-separated terms. Containment, not union: a result must carry all of them. An unknown term yields an empty result set rather than being ignored. |
| type | Restrict to panoramas holding at least one visible observation of this object type. |
| bbox | Four comma-separated degrees, minLon,minLat,maxLon,maxLat, matched against panorama geometry. |
| map_id | Scope to the public member corridors of one collection. Non-numeric input returns 422 rather than silently dropping the filter. |
| limit | Default 24, hard ceiling 100. Values above the ceiling are clamped, not rejected. |
| offset | Zero-based. Negative values are treated as zero. |
{
"total": 128, // full match count, ignoring limit/offset
"results": [ {
"scene_id": 1533202, "trail_id": 1459,
"trail": "…", "slug": "…", "scene": 12,
"lat": 48.7712, "lon": -114.2831,
"thumb": "https://…", "caption": "…", "tier": "full"
} ]
}
GET/api/scene-intel/facets
The live vocabulary with counts: the most frequent tag terms and object types, each list capped. Accepts the same bbox parameter to scope counts to an area, in which case the counts describe exactly that box. Treat what it returns as the vocabulary — build your filter UI from this response rather than hardcoding an enumeration that will go stale as coverage grows.
{ "tags": [ { "tag": "…", "n": 4211 } ],
"types": [ { "type": "sign", "n": 5359 } ] }
GET/api/lens/themes
Capability discovery. Pass an optional ?scene_id= to ask which themes have approved, public results for that specific panorama; a non-numeric value returns 422. Read available, not the presence of a row — every catalog theme is listed, including the ones with no adapter.
{ "themes": [ { "id": "access", "key": "rapg", "label": "…",
"scopes": [ "scene", "trail", "site", "collection" ],
"adapter_status": "active", "available": true } ] }
GET/api/lens/scene/{id}?theme=
The bounded render payload for one theme on one panorama. theme is required and accepts the URL key, the table id, or a documented legacy alias. Omitting it returns 422; asking for the universal record here returns 422 with a pointer to the scene endpoint. An unknown theme, a theme without a runtime adapter, a result that is not both approved and public, or a non-public corridor all return 404.
{
"theme_id": "access",
"theme_key": "rapg",
"scene_id": 1533202,
"schema_version": "2",
"render": { "markers": [ { "…": "…", "panoAth": 122.619,
"bearing": 260.219 } ] },
"analyzed_at": "2026-07-29T14:02:11+00:00"
}
Bounded is load-bearing. The payload carries the reviewed render model only. Raw provider responses, unapproved recommendations, private sign text, reviewer identity, and account or authorisation data are excluded by construction, not by filtering at the edge. Marker bearings are recombined with the corridor's current heading on each read, exactly as in the universal payload.
The read endpoints above are the whole of the public surface. Scene Intelligence has no public GeoJSON, CSV, or PDF download, and no anonymous bulk dump. Being explicit about that is cheaper for everyone than letting an integration get designed around one.
A staff-run command exports a complete, approved lens run as a single JSON document under the contract terrain360-lens-scene-export/v1. It refuses anything that is not both complete and approved, and it refuses a run whose scene count does not reconcile. The document carries theme and run provenance, exact panorama identity and location, schema version, and each bounded render payload — and deliberately excludes raw provider responses, private sign text, unapproved recommendations, account data, and authorisation data. It is produced per delivery, on request.
Public PDF, CSV, and GeoJSON exports do exist at /trail/{slug}/ai-analysis/export/…. They are real and they work, but they read an older, unrelated per-scene assessment table — not the scene record documented on this page. Do not treat their output as a Scene Intelligence export or expect the fields above to appear in it. The two record systems are not joined.
Tell us the corridor, the system you are joining it to, and the identifiers you need to match on. If something on this page is wrong, ambiguous, or missing, that is worth knowing too — it is generated from the running system, so an error here is an error there.
Schema, constraints, and counts on this page were read from the running system at 2026-07-30 20:45 UTC.