Georeference digital frames (#38) - #41
Merged
NewGraphEnvironment merged 7 commits intoAug 31, 2026
Merged
Conversation
Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
`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
Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
NewGraphEnvironment
deleted the
38-georeference-digital-frames-the-corner-m
branch
August 31, 2026 05:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(), sobearing_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 columnfly_fetch()already supports — and consecutive frames overlap enough to check eachother. 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:
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.mdrecords 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.Rwith the negative half stated as measured numbers rather thana 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
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_sizeonto a square footprint.Checked against every row of
camera_formats.csv, fallback rows included.fly_bearing()needs aneighbour, 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_colis captured before the auto path overwriteshas_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.
fly_is_square()reports an EMPTY geometry as square, so squareness alone cannot standin for "has ground control".
fly_georef_gcps()split out ofgeoref_one()as a pure function, verified against thepre-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.
format_sizeonto a square footprint — the exact case it exists for, measured writing a 1.556x stretch silently. Also: therotationcolumn 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 codelog(1.10) = 0.09531against the DMC II's0.09490), and the test guarding it asserted the square-footprint case using the UltraCam at 0.442 — the most eccentric camera, which any tolerance clearsRound 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 thetarget —
.packagenames the target. PassingasNamespace("fly")to.envinstalls thestub 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
TRUEmakes assertions pass. It surfaced only because a later test asserted afile existed that the stub never wrote.
testthatpinned to >= 3.2.0; recorded inCLAUDE.md.
Verification
digital gives film the bearing rule and digital the constant, 6 of 6, no warning
format_sizeonto a square footprint is now refused with a warning, and the ordinarydigital route still georeferences 6 of 6
lintr:R/fly_georef.Rat 1 against amainbaseline of 5, and that one is thedocumented installed-vs-source artifact
rotation argument, patched in both
asNamespace("fly")andas.environment("package:fly"), with a printed ground coordinate proving the patch tookdevtools::document()writesfly_georef.Rdonly; exports steady at 9🤖 Generated with Claude Code
https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR