Rotate every footprint onto its flight line, and refuse the film corner mapping rather than guess it - #49
Merged
NewGraphEnvironment merged 5 commits intoSep 3, 2026
Conversation
…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
deleted the
26-bearing-rotation-for-diagonal-flight-lin
branch
September 3, 2026 01:02
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 #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:
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 callgeoref_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 infly_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 byframe_number.A latent ring-closure defect, on
mainsince #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 oldfly_rectangles()to check the new tests could actually fail.Breaking changes
fly_bearing()returnsNAfor a non-adjacent neighbour, so sampled input gets fewer bearings than before.rotationcolumn'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