Skip to content

Rotate every footprint onto its flight line, and refuse the film corner mapping rather than guess it - #49

Merged
NewGraphEnvironment merged 5 commits into
mainfrom
26-bearing-rotation-for-diagonal-flight-lin
Sep 3, 2026
Merged

Rotate every footprint onto its flight line, and refuse the film corner mapping rather than guess it#49
NewGraphEnvironment merged 5 commits into
mainfrom
26-bearing-rotation-for-diagonal-flight-lin

Conversation

@NewGraphEnvironment

Copy link
Copy Markdown
Owner

Closes #26.

#32 built continuous footprint rotation and gated it on the recording format being non-square, so it reached digital frames only and film stayed axis-aligned. This removes the gate.

The issue framed this as a georeferencing bug. It is also, and more consequentially, a coverage bug. A film camera is mounted square-on to the flight line just as a digital one is, so a frame's real footprint is a square rotated onto the bearing. An axis-aligned square is correct only on a cardinal heading, and at 45 degrees it overlaps the true footprint by 2(sqrt(2)-1) = 83% — fly_coverage() was reporting that missing sixth as covered ground.

The film corner mapping was measured, and there is no constant

This is the finding, and it changed the plan. Adjacent-frame overlap correlation — the route that decided #38 — over four contiguous legs pulled from the public catalogue:

roll year bearing best rotation margin top-edge azimuth
bc5282 1968 230° 0 0.089 230
bc83062 1983 150° 90 0.135 240
bc83062 1983 93° 90 0.196 183
bc83062 1983 62° 90 0.152 152

Positive control first: digital through the same harness returns 270 at +0.713 against 90 at +0.425, reproducing #38.

Two conclusions pointing opposite ways. bc83062 returning one answer at three widely separated bearings establishes the mapping is genuinely flight-relative — a geographic convention could not do that — which is the strongest evidence in the issue for the whole architecture, and it arrived as a by-product of failing to find a constant. But a quarter turn separates the two eras, exactly as #26 said at the outset: "same bearing, different eras → camera/scanner orientation difference that 90° quantization can't capture."

So fly_georef() refuses a rotated film frame unless the caller supplies the roll's rotation, naming the roll and the fix. A wrong mapping produces a valid GeoTIFF over the right ground with the picture turned 90°, which nothing downstream would report — the same call georef_one()'s stretch guard already makes, and the same one #30 made for an unknown recording format.

A fixed-geographic rival hypothesis fitted the first two rows to within 10° of azimuth, predicted 180 for the other two legs, and was falsified at both. Detrending was tried and rejected: it collapses the digital control too, because fly's footprints are estimates so fine detail does not align between frames.

Two defects found along the way

fly_bearing() made geometry batch-dependent. It took the azimuth to the next frame present in the object handed to it, with no gap or distance test. Bundled frames bc5282 179 and 199 are 20 apart and 3.3 footprint-sides apart, giving 59.8° on a roll flying 230. centroids[1:2, ] — the slice in fly_georef()'s own example — returned 51.5 for both frames where the full batch returns 318.2 and 231.6, so the same photo covered different ground depending on the subset. A neighbour must now be adjacent by frame_number.

A latent ring-closure defect, on main since #32. Rotation recomputed the closing ring vertex rather than copying it; %*% computes rows independently and an optimised BLAS can return it a few ulps off the first (2.8e-14 at bearing 230). sf::st_polygon() requires exact closure and errors, aborting the whole batch. Data-dependent, so the bundled fixture never showed it and 1338 tests passed over it. Found by restoring the old fly_rectangles() to check the new tests could actually fail.

Breaking changes

  • Diagonal film footprints move. Measured on the bundled AOI: coverage 60.7% → 59.5% at 1:12000, overlap pairs 61 → 63, DEM path unmoved (median area ratio 1.1377 → 1.1372). Only 7 of 20 frames rotate under the adjacency guard, so this understates the effect on a contiguous roll.
  • fly_bearing() returns NA for a non-adjacent neighbour, so sampled input gets fewer bearings than before.
  • The rotation column's meaning changed — it now shifts corners on an already-rotated ring, so a value calibrated by eye against an earlier release must be re-checked.
  • fly_georef() skips rotated film without a supplied rotation.

Tests

1343 passing, 0 failures, 0 warnings. Three tests were found passing vacuously and rewritten — most notably "a mixed batch rotates film by bearing", which used four non-adjacent frames and so contained no rotated film frame at all. The film regression net's two shape assertions were both invariant under rotation; they are replaced by vertex-1 azimuth = bearing + 225 (which pins angle, sign and vertex order together — the only way to pin any of them on a square) and the exact bbox identity |cos b| + |sin b|. Ring closure is swept over 720 bearings, with a premise asserting the old form still fails somewhere in that sweep.

Restoring the pre-change fly_rectangles() fails all three new assertions — vertex-1 azimuth by 180.4°, the stretch identity by 0.409, and the premise outright.

Still open

The per-roll film rotations are not shipped as a table. Two rolls is thin, and a two-row table invites the reading that unlisted rolls are missing rather than unmeasured.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR

NewGraphEnvironment and others added 5 commits September 2, 2026 16:08
…cent neighbour

fly#26 asked that film stop being georeferenced through a 90-degree-quantized
bearing. #32 built the mechanism and gated it on the recording format being
non-square, so it reached digital only.

The gate was wrong about the ground. A film camera is mounted square-on to the
flight line just as a digital one is, so a frame's real footprint is a square
ROTATED onto the bearing. An axis-aligned square is correct only on a cardinal
heading; at 45 degrees it overlaps the true footprint by 2(sqrt(2)-1) = 83%, and
`fly_coverage()` was reporting that missing sixth as covered ground. Removing the
gate moves every diagonal film footprint, deliberately.

Rotating film makes a second, older defect consequential. `fly_bearing()` took the
azimuth to the next frame PRESENT IN THE OBJECT HANDED TO IT, with no test on frame
gap or step distance, so a sampled roll yielded cross-leg artefacts: bc5282 frames
179 and 199 are 20 apart and 3.3 footprint-sides apart, and pairing them gives 59.8
degrees on a roll flying about 230. Harmless while nothing consumed it and mostly
invisible when #32 applied it to digital; now it would rotate a footprint bodily.

Worse, it made geometry depend on the batch. `centroids[1:2, ]` — the slice in
`fly_georef()`'s own example — returned 51.5 for both frames where the full 20-row
batch returns 318.2 and 231.6, so the same photo covered different ground depending
on the subset it was passed in.

A neighbour must now be ADJACENT by frame number. That is demonstrable, where "close
enough to be on one line" would need a threshold in footprint-sides that nothing here
can measure; and it is the choice #30 already made for an unknown recording format —
an NA that reports itself beats a plausible number that does not. Seven of the twenty
bundled frames keep a bearing, including bc5282 231/232 at 230 degrees, which is the
diagonal case the issue is about.

Also from the plan review:

- `footprint_bearing` is NA'd in the `no_geom` block beside the other three reporting
  columns. Without it `is.finite(footprint_bearing)` is TRUE for a frame that has no
  ring at all, and correct only because `fly_georef()` skips empty footprints first.
- The `axis_aligned_no_bearing` tag moved to after `no_geom` is known, and goes on
  through `ifelse()`. It describes a rectangle that was drawn, so an unsized frame must
  not carry it; and `paste0(NA_character_, "; x")` returns the literal string "NA; x",
  which film — never in the camera table — got on every bearingless frame.
- The dead `non_square` local is gone rather than left computed and unread.

The film regression net asserted shape two ways that are both invariant under
rotation — area, and a rotated square's bounding box being still a square — so it
would have stayed green while its stated premise ("Square, so unrotated") died. Area
is kept, since it still pins sizing. The bbox check is replaced by two assertions that
discriminate: ring vertex 1 sits at `bearing + 225` from the centroid, which pins the
rotation angle, its sign and the vertex order together — the only way to pin any of
them on a square, where hc == ha leaves the along-track and cross-track axes
indistinguishable — and bbox width equals s(|cos b| + |sin b|) exactly, which needs no
tolerance band. Both carry premises so a future all-cardinal fixture fails loudly
instead of passing vacuously.

Refs #26

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
Phase 1 of fly#26, and the answer is negative. There is no film equivalent of
`fly_digital_rotation()`.

WHY IT HAD TO BE MEASURED. The aspect invariant rejects a mapping that pairs the
image's long axis with the footprint's short edge. A square footprint has no long
axis, so on film the invariant is vacuous against all four rotations rather than
merely unable to separate two of them. Nothing in the suite can catch a wrong film
mapping; only adjacent-frame overlap can, and it needs real thumbnails, so it lives
in data-raw/ as #38's did.

The bundled fixture cannot carry it. After this branch's adjacency guard exactly one
true frame-to-frame diagonal pair survives in it, and one pair is not a measurement,
so contiguous legs are pulled from the public catalogue.

POSITIVE CONTROL FIRST. Digital through the same harness returns 270 at +0.713
against 90 at +0.425, reproducing #38. Rotations 0 and 180 do not appear because the
stretch guard REFUSES them on a non-square footprint — a refusal is not a low score
and must not compete for which.max(), which cost one debugging round.

RESULT, four legs, two rolls, two eras:

  roll      year  bearing  best rot  margin   top-edge azimuth
  bc5282    1968    230       0       0.089        230
  bc83062   1983    150      90       0.135        240
  bc83062   1983     93      90       0.196        183
  bc83062   1983     62      90       0.152        152

Two things follow and they point opposite ways. The mapping IS flight-relative —
bc83062 returns 90 at three widely separated bearings, which a geographic convention
could not do, and that is what justifies rotating the footprint onto the bearing at
all. But it is NOT a constant: a quarter turn separates the two eras, which is what
fly#26 said at the outset ("same bearing, different eras -> camera/scanner
orientation difference that 90 degree quantization can't capture").

A fixed-geographic alternative was tested and falsified rather than left as a loose
end. The first two rows agree to within 10 degrees of azimuth, which looked like a
scanner delivering a constant orientation; it predicts 180 for the 93- and 62-degree
legs and both measured 90.

Detrending was tried and is not used: subtracting a local mean collapses the digital
control to +0.091 against +0.005. fly's footprints are estimates, so fine detail does
not align between frames and a high-pass filter removes the broad tone carrying the
signal. #38 correlated raw at 25 m for the same reason.

So `fly_georef()` now refuses a rotated square footprint unless `photos_sf` carries a
`rotation` value for it, and the warning names the roll and the remedy. That remedy
is a real per-roll measurement by the caller, not a coercion that would walk back
through the branch, and the test executes it rather than describing it. This is the
call `georef_one()`'s stretch guard already makes and the one #30 made for an unknown
recording format: a wrong mapping here is a valid GeoTIFF over the right ground with
the picture turned a quarter turn, which nothing downstream would report.

Routing moves from `fly_is_square()` to `is.finite(footprint_bearing)`. Squareness
stood in for "was it rotated" and can no longer, now that film is rotated too.

Also from the plan review: the no-bearing warning was gated on `non_square` and so
never fired for film, which needs it as much as digital does; it now reads from
`rotated` and `empty_fp` instead of grepping `width_source`, and says that
`fly_bearing()` wants an ADJACENT frame rather than merely a neighbouring one. The
`@param rotation` text and the whole Rotation section are rewritten — the argument
now only ever reaches bearingless frames, and the `rotation` column's meaning changed
under it. The example used two non-adjacent frames and warned; it now uses the
adjacent pair and demonstrates the supported film workflow.

Refs #26

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
Phase 4 of fly#26. The restore-the-bug check found a real latent defect that 1338
passing tests had not.

RING CLOSURE. `fly_rectangles()` built a closed five-vertex ring and rotated the whole
matrix, so the fifth row was recomputed rather than copied. `%*%` computes rows
independently and an optimised BLAS may block or vectorise them differently, so the
recomputed closing vertex can land a few ulps off the first — measured at bearing 230
on a 1000 m half-side, 2.8e-14 in y. `sf::st_polygon()` requires EXACT closure and
raises an error rather than a warning, so a single unlucky frame aborts the whole
batch.

It is data-dependent, which is why the bundled fixture never showed it. It is also not
new: `main` has it for every rotated digital frame since #32. Now four vertices are
rotated and the first is appended again, which removes the possibility rather than
narrowing it. Swept over 720 bearings for both square and non-square, with a premise
asserting the recomputed form does still fail somewhere in that sweep — otherwise the
test would look identical once it can no longer catch anything.

TESTS THAT COULD NOT FAIL. Three, all found by the plan review or by restoring the
defect:

- "a mixed batch rotates film by bearing" used film rows 1-4, which are frames 176,
  221, 232 and 202 — no two adjacent, so no bearing, so not one rotated film frame in
  a test named for them. Rewritten around three populations with a premise per
  population, and it now covers the refusal and the `rotation`-column override.
- The DEM two-pass test reconstructs pass one with `endCapStyle = "SQUARE"`, which is
  axis-aligned and no longer reproduces a rotated frame's sampling rectangle. It would
  have kept passing by a wider margin for a reason unrelated to the iteration it
  exists to measure. Restricted to unrotated frames, with the restriction asserted.
- The film regression net's shape assertions were replaced in the previous commit;
  restoring the old `fly_rectangles()` confirms all three of the new ones fail — the
  vertex-1 azimuth by 180.4 degrees, the stretch identity by 0.409, and the premise
  outright.

MEASURED, on the bundled AOI (20 frames, 7 of them rotated):

  fly_coverage 1:12000   15.1 km2 60.7%  ->  14.8 km2 59.5%
  fly_overlap pairs             61       ->  63
  fly_filter rows            20/20       ->  20/20
  DEM area ratio (median)   1.1377       ->  1.1372
  dem_coverage range     0.9996-1        ->  0.9996-1

Small and downward, which is the point — the axis-aligned square was claiming ground
the frame does not photograph. Only 7 of 20 rotate under the adjacency guard, so this
understates the effect on a contiguous roll; it is a fact about this fixture. The DEM
path is unmoved, which is worth stating because rotating the sampling rectangle could
have disturbed it and `inst/notes/terrain-correction.md` is explicit that a green
suite says little there.

`inst/notes/georeferencing.md` gains the film section and the closure rule, and its
opening is corrected: the ring contract now covers film, and routing is on rotation
rather than on shape. The vignette gains a "Rotation onto the flight line" section
that says why the bearing needs an adjacent frame and what that means for a subset.

Refs #26

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBKqedyBysV7hB4DuL98ZR
@NewGraphEnvironment
NewGraphEnvironment merged commit 9703bf8 into main Sep 3, 2026
1 check passed
@NewGraphEnvironment
NewGraphEnvironment deleted the 26-bearing-rotation-for-diagonal-flight-lin branch September 3, 2026 01:02
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.

Bearing rotation for diagonal flight lines — film half (resolved: the mapping is per-roll, not a constant)

1 participant