Skip to content

Georeference digital frames (#38) - #41

Merged
NewGraphEnvironment merged 7 commits into
mainfrom
38-georeference-digital-frames-the-corner-m
Aug 31, 2026
Merged

Georeference digital frames (#38)#41
NewGraphEnvironment merged 7 commits into
mainfrom
38-georeference-digital-frames-the-corner-m

Conversation

@NewGraphEnvironment

Copy link
Copy Markdown
Owner

Closes #38.

fly_georef() excluded every non-square footprint with a warning, so the digital frames
#32 had just given footprints to were sizeable but not georeferenceable. That exclusion
is gone.

The corner mapping is rotation 270, and it was measured

The top-left pixel maps to the footprint ring's rear-left corner — equivalently, image
columns run in the flight direction and image rows run flight-right. A digital footprint
is already rotated onto its flight line by fly_rectangles(), so bearing_to_rotation()
is not applied to it; doing so would count the bearing twice.

The issue assumed this needed licence-restricted orthophoto imagery. It did not. The
catalogue publishes per-frame exterior orientation through patb_georef_url — a column
fly_fetch() already supports — and consecutive frames overlap enough to check each
other. Everything below is public, so the whole derivation ships in
data-raw/georef_calibrate-corner_mapping.R.

Three independent routes, all agreeing on 270 for both bundled cameras:

route result
exterior orientation UltraCam Eagle's mount rigid to 0.18° (MAD 0.47) over 6839 frames, 32 compass bins; reflected reading excluded at 14.1% against 98.7%
adjacent-frame overlap correlation +0.616 (Eagle) and +0.659 (DMC II) against at most +0.43 for the alternatives
FWA lake darkness 270 darkest on both frames tested, water 442 m and 1684 m off the footprint centre so the test could discriminate

One of them was wrong, which is the part worth keeping

Read naively, the exterior orientation puts the DMC II at 90, on 97.6% agreement
across four pooled projects. It is the most authoritative-looking number here and it is
wrong: that camera's project flies east and west only, so its own data separates the
rigid and reflected hypotheses at 97.6% against 98.4% — which is to say not at all.
inst/notes/georeferencing.md records it as the cautionary half rather than dropping it.

What geometry could settle, and what it could not

Half the question needs no imagery at all. The image's long pixel axis must land on the
footprint's long ground edge, which rejects rotations 0 and 180 outright — pinned in
test-fly_georef_aspect.R with the negative half stated as measured numbers rather than
a threshold (a wrong pairing stretches by the aspect squared: 1.21x on the DMC II, 2.42x
on the UltraCam, so the UltraCam frames are what make the test discriminating).

It cannot separate 270 from 90 — a rectangle is symmetric under the 180° between them —
and it cannot see a mirrored mapping either, so the signed area of the ground quad is
asserted separately.

Also in here

  • Refuse rather than stretch. A frame whose delivered image aspect disagrees with its
    footprint's by more than 8% is skipped with a warning. A wrong pairing produces a valid
    GeoTIFF, in the right CRS, over the right ground, squashed — which nothing downstream
    would report. The threshold is computed, not chosen: the admissible band is
    (1.0667, 1.0995), between a full-resolution 9-inch scan carrying the negative's
    rebate and a Leica DMC II frame sized through format_size onto a square footprint.
    Checked against every row of camera_formats.csv, fallback rows included.
  • A non-square footprint with no bearing is warned about. fly_bearing() needs a
    neighbour, so georeferencing a single frame on its own draws it axis-aligned and warps
    it as though the flight line ran due north. That is the shape of every existing test in
    test-fly_georef.R, so it is not an exotic case.
  • user_rotation_col is captured before the auto path overwrites has_rotation_col.
    Without it nothing downstream could tell a user-supplied column from a bearing-derived
    one, and the documented override would have silently stopped working.
  • Three-way classification — empty / square / non-square — computed before the loop.
    fly_is_square() reports an EMPTY geometry as square, so squareness alone cannot stand
    in for "has ground control".
  • fly_georef_gcps() split out of georef_one() as a pure function, verified against the
    pre-change implementation lifted from git over 1024 cases with 0 differences.

Three rounds of code-check, each finding a defect inside the last one's fix

The suite was green after every round. None of these were visible to it.

round found
1 the guard refused non-square film scans — on a square footprint anisotropy is the image's own inverse aspect and no rotation changes it, so a 9600x9000 full-resolution scan was rejected where it used to work
2 the shape gate that fixed round 1 switched the guard off for a digital frame sized through format_size onto a square footprint — the exact case it exists for, measured writing a 1.556x stretch silently. Also: the rotation column had three read sites and round 1's fix converted two, so an unparseable factor level reached the third and was applied as its level code
3 the tolerance replacing that gate was 0.4% too loose (log(1.10) = 0.09531 against the DMC II's 0.09490), and the test guarding it asserted the square-footprint case using the UltraCam at 0.442 — the most eccentric camera, which any tolerance clears

Round 3's is the one worth repeating: picking the vivid example rather than the binding
one is how the threshold came to be wrong. The test now computes over every row of the
shipped table and asserts none slips.

Convergence never arrived within three rounds. The reviews are archived beside the PWF at
planning/archive/2026-08-issue-38-georeference-digital/.

A trap found on the way

local_mocked_bindings(.env = ) is the environment the mock unwinds with, not the
target — .package names the target. Passing asNamespace("fly") to .env installs the
stub correctly and then never removes it, because a namespace does not exit, so every
later test in the run keeps it. It leaks in the direction that reads as success: a stub
returning TRUE makes assertions pass. It surfaced only because a later test asserted a
file existed that the stub never wrote. testthat pinned to >= 3.2.0; recorded in
CLAUDE.md.

Verification

  • Full suite green — 1255 passing, 0 failures
  • 6 UltraCam frames georeference 6 of 6 in EPSG:3005; a mixed batch of 3 film and 3
    digital gives film the bearing rule and digital the constant, 6 of 6, no warning
  • Round 3's exact case re-checked end to end after the fix: a DMC II frame sized through
    format_size onto a square footprint is now refused with a warning, and the ordinary
    digital route still georeferences 6 of 6
  • Restoring the 1.10 tolerance takes the suite from FAIL=0 to FAIL=2
  • lintr: R/fly_georef.R at 1 against a main baseline of 5, and that one is the
    documented installed-vs-source artifact
  • Restore-the-bug: the aspect invariant goes red against a mapping that ignores its
    rotation argument, patched in both asNamespace("fly") and
    as.environment("package:fly"), with a printed ground coordinate proving the patch took
  • devtools::document() writes fly_georef.Rd only; exports steady at 9

🤖 Generated with Claude Code

https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR

NewGraphEnvironment and others added 7 commits August 30, 2026 14:59
`fly_georef_gcps()` is the part of georeferencing that can be wrong while
everything around it looks healthy: a wrong-by-90 corner mapping still writes a
valid GeoTIFF, in the right CRS, with the right extent, and nothing in this
package looks at pixels. Pure, so it is checkable offline.

Verified against the implementation lifted from git rather than rewritten from
memory: 1024 cases (20 bundled film footprints, 24 digital, 20 random rings, at
four image shapes and four rotations), 0 differences. Golden values are written
into the test so the pin survives that sha becoming history.

One real difference surfaced doing it — the old loop produced a named character
vector whenever the ring came from `sf::st_coordinates()`, since `coords[4, 1:2]`
carries X/Y dimnames. GDAL ignores names; `identical()` does not.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
Half of #38's question is settled by geometry and needs no reference imagery:
the image's long pixel axis must land on the footprint's long ground edge. That
rejects rotations 0 and 180 for every digital frame in the fixture and leaves
90 and 270, which differ by 180 degrees about the footprint centre and cannot be
told apart geometrically.

The negative is pinned with measured numbers rather than a threshold — a
wrong-by-90 mapping squashes by the footprint's aspect squared, 1.21x on the
DMC II and 2.42x on the UltraCam Eagle M3. The DMC II alone would survive a
loose tolerance, so the test says which frames make it discriminating.

Restored the pre-#38 defect (a mapping that ignores its rotation argument, which
is what `bearing_to_rotation()` produces for these frames) in both the namespace
and the attached package environment, with a printed ground coordinate proving
the patch took: the invariant goes red.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
The issue assumed this needed licence-restricted orthophoto imagery. It does
not. The catalogue publishes per-frame exterior orientation through
`patb_georef_url` — a column `fly_fetch()` already supports — and consecutive
frames overlap enough to check each other, so the whole derivation is public and
lives in `data-raw/georef_calibrate-corner_mapping.R`.

Three independent measurements agree on 270 for both bundled cameras: the
UltraCam Eagle's mount is rigid to 0.18 degrees over 6839 frames spanning the
compass; adjacent-frame overlap correlation is +0.616 and +0.659 against at most
+0.43 for the alternatives; and FWA lake polygons come out darkest at 270 on
frames whose water sits 442 m and 1684 m off the footprint centre.

One measurement disagreed and was wrong, which is the part worth keeping. Read
naively the exterior orientation puts the DMC II at 90, on 97.6% agreement across
four pooled projects — but that camera's project flies east and west only, so its
own data separates the rigid and reflected hypotheses at 97.6% against 98.4%. The
note records it as the cautionary half rather than dropping it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
Removes the exclusion that made every non-square footprint unreachable, so the
post-2010 catalogue is georeferenceable rather than only sizeable.

A non-square footprint is already rotated onto its flight line by
`fly_rectangles()`, so `bearing_to_rotation()` is not applied to it. Those frames
use the measured mapping instead: the top-left pixel maps to the ring's rear-left
corner. Film keeps the bearing rule unchanged, and a `rotation` column still
overrides per-photo for both.

A frame whose delivered image aspect disagrees with its footprint's by more than
8% is skipped with a warning rather than written stretched. A wrong mapping
produces a valid GeoTIFF, in the right CRS, over the right ground, squashed by
the aspect ratio squared — which nothing downstream reports. The threshold is
computed, not chosen: the band is (1.0667, 1.0995), between a full-resolution
9-inch scan carrying the negative's rebate and a Leica DMC II frame sized through
`format_size` onto a square footprint. Checked against every row of the shipped
camera table.

Also warns when a non-square footprint has no flight bearing, which is the
ordinary result of georeferencing a single frame, since `fly_bearing()` needs a
neighbour.

Three rounds of code-check, each of which found a defect inside the previous
round's fix:

  1  the guard refused non-square FILM scans, where anisotropy is the image's own
     inverse aspect and no rotation changes it — a 9600x9000 scan was rejected
  2  the shape gate that fixed round 1 switched the guard off for a digital frame
     sized onto a square footprint, the exact case it exists for; and the
     `rotation` column had three read sites where the fix converted two, so an
     unparseable factor level was applied as its level code
  3  the tolerance replacing that gate was 0.4% too loose, and the test guarding
     it asserted against the most eccentric camera rather than the least

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
@NewGraphEnvironment
NewGraphEnvironment merged commit 7cb1774 into main Aug 31, 2026
1 check passed
@NewGraphEnvironment
NewGraphEnvironment deleted the 38-georeference-digital-frames-the-corner-m branch August 31, 2026 05:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Georeference digital frames: the corner mapping assumes a square, axis-aligned footprint

1 participant