Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ docs
/doc/
/Meta/
Rplots.pdf

# Calibration report cache for data-raw/make_camera_formats.R (~25 MB, refetchable)
data-raw/.cache/
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ metadata from the BC Data Catalogue, and georeference the images onto their esti

- One exported function per file: `R/fly_footprint.R` → `tests/testthat/test-fly_footprint.R`
- `inst/testdata/` — Upper Bulkley River floodplain near Houston, BC (20 photos, dual scale). All 1968 film,
`Film - BW`, focal 153 — there is no digital frame in it, and `data-raw/make_testdata.R` sources a film-only AOI,
so digital coverage cannot come from there
`Film - BW`, focal 153 — so the *sampled* photos cannot exercise the digital path. The **AOI is not
film-only**, though: it holds 181 `Digital - Colour` frames from two cameras, 24 of which now ship as
`inst/testdata/photo_centroids_digital.gpkg` (fly#32). An earlier note here said digital coverage could not
come from there; that came from a `BBOX(SHAPE, ...)` CQL query which returns 0 features for this AOI even for
a positive control — a broken probe, not an absence. Query bboxes through `bcdata::filter(BBOX(...))`
- `inst/testdata/dem.tif` — MRDEM-30 clip (NRCan 30 m bare-earth DTM), buffered 5.4 km past the centroids.
It is one CRS at one resolution, so it **cannot** exercise the reprojection, coarse-grid, truncating-extent or
wide-spread branches of the terrain code — see `inst/notes/terrain-correction.md`
Expand Down
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: fly
Title: Historic Airphoto Footprints, Selection and Georeferencing for
British Columbia
Version: 0.5.1
Version: 0.6.0
Date: 2026-08-29
Authors@R: c(
person("Allan", "Irvine", , "[email protected]", role = c("aut", "cre"),
Expand Down
13 changes: 13 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# fly (development version)

## 0.6.0 (2026-08-30)

- `fly_footprint()` now sizes digital frames, closing the gap #30 made honest but left open ([#32](https://github.com/NewGraphEnvironment/fly/issues/32)). Province-wide that is 223,667 of 1,670,471 frames — the package was quietly film-only for anything after ~2010
- Sensor dimensions are read from the camera calibration reports the catalogue itself links to through `camera_calibration_url`, and shipped as `inst/extdata/camera_formats.csv` (built by `data-raw/make_camera_formats.R`). 14 calibrations covering 169,688 frames, plus focal-length fallback rows for frames carrying no calibration
- **The catalogue's `SCALE` is not the true image scale for a digital frame, and is no longer used for one.** Measured against terrain on 40 UltraCam Eagle frames it gives 34% of true width: it is a derived nominal figure, implying a pixel pitch of ~12.5 um for every camera regardless of model against real pitches of 3.9-12 um. A digital frame is sized as `pixel count x ground_sample_distance` instead, which needs neither `scale` nor a DEM. `ground_sample_distance` is centimetres
- **Footprints are no longer always square.** Digital sensors run from 1.10:1 (Leica DMC II) to 1.80:1 (Intergraph DMC), so a square footprint was up to 76% too deep. Non-square footprints are rotated onto the flight line via `fly_bearing()`. Film stays square and its output is unchanged
- New `width_source` column names the calibration file or fallback rule behind every digital footprint, and `footprint_terrain` gains `"gsd_scaled"` — `nominal_scale` is documented as "sized from the reported scale", which is the one thing this route never does
- Frames whose calibration could not be corroborated are refused rather than inferred, listed with the reason in `inst/extdata/camera_formats_excluded.csv`. Two are medium-format bodies about half the width of everything else in the record, so inferring one from focal length would have been ~1.95x too wide
- `fly_georef()` excludes rotated footprints with a warning: its corner mapping applies its own bearing rotation, calibrated for axis-aligned squares, and would count the rotation twice
- The shipped numbers are parsed from the reports, never typed, and gated on four checks before the table is written — `px x pitch` against the stated image size, report focal against the catalogue's, plausibility bounds, and an implied ground elevation that must be a real BC elevation. The last two caught a camera whose catalogue metadata contradicts its own report, which is withheld
- `format_size` is unchanged and still takes precedence, so a caller who knows their camera can override the shipped table
- New `inst/testdata/photo_centroids_digital.gpkg`: 24 real digital frames over the AOI that already ships, from two cameras 0.46 apart in aspect ratio

## 0.5.1 (2026-08-29)

- Fix `fly_footprint()` silently dropping `footprint_basis`, `footprint_terrain`, `height_agl` and `dem_coverage` whenever its input carried the `tbl_df` class ([#35](https://github.com/NewGraphEnvironment/fly/issues/35)). `bcdata::collect()` returns exactly that class, so every caller querying `WHSE_IMAGERY_AND_BASE_MAPS.AIMG_PHOTO_CENTROIDS_SP` — the documented source for this package — lost the whole reporting surface 0.4.0 and 0.5.0 added, and the documented "filter on `footprint_basis`" and "filter on `dem_coverage`" workflows were unreachable from it
Expand Down
8 changes: 6 additions & 2 deletions R/fly_bearing.R
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,23 @@ fly_bearing <- function(photos_sf) {

ord <- order(photos_sf$film_roll, photos_sf$frame_number)

# `isTRUE()` on the roll comparisons below, rather than a bare `==`: an NA in
# `film_roll` makes the comparison NA, which aborts an `if` with "missing value where
# TRUE/FALSE needed". A frame with no roll simply has no neighbour to take a bearing
# from, which is what NA already means here.
bearing <- rep(NA_real_, nrow(photos_sf))

rolls <- photos_sf$film_roll[ord]
x <- coords[ord, 1]
y <- coords[ord, 2]

for (i in seq_along(ord)) {
if (i < length(ord) && rolls[i] == rolls[i + 1]) {
if (i < length(ord) && isTRUE(rolls[i] == rolls[i + 1])) {
# Forward bearing to next frame on same roll
dx <- x[i + 1] - x[i]
dy <- y[i + 1] - y[i]
bearing[ord[i]] <- (atan2(dx, dy) * 180 / pi) %% 360
} else if (i > 1 && rolls[i] == rolls[i - 1]) {
} else if (i > 1 && isTRUE(rolls[i] == rolls[i - 1])) {
# Last frame on roll: use bearing from previous
dx <- x[i] - x[i - 1]
dy <- y[i] - y[i - 1]
Expand Down
150 changes: 150 additions & 0 deletions R/fly_camera_format.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Recording-format dimensions for the digital cameras in the BC air photo catalogue.
#
# `AIMG_PHOTO_CENTROIDS_SP` carries no sensor size, which is why #30 refused to size
# digital frames rather than invent one. The number is recoverable from the calibration
# report each frame links to through `camera_calibration_url`, and
# `data-raw/make_camera_formats.R` parses it out of those reports into
# `inst/extdata/camera_formats.csv`. See fly#32.
#
# Two key types share one table so one lookup reads both:
#
# `calib_file` — keyed on the calibration file the frame names. Exact: the report
# gives the array size and pixel pitch, and the millimetres are
# checked against them.
# `focal_length` — keyed on the catalogue's `focal_length`, for the ~20% of digital
# frames carrying no calibration URL. Inferred, and carrying
# `width_spread_pct` so the room for error travels with the number.

fly_camera_cache <- new.env(parent = emptyenv())

fly_camera_read <- function(file) {
if (is.null(fly_camera_cache[[file]])) {
path <- system.file("extdata", file, package = "fly")
if (!nzchar(path)) {
stop("`", file, "` is missing from the installed package.", call. = FALSE)
}
# `key` must stay character: the fallback keys are focal lengths, and read.csv would
# type them numeric, so `"80"` and `80` would stop matching between the two halves
# of the same table.
fly_camera_cache[[file]] <- utils::read.csv(
path, stringsAsFactors = FALSE, colClasses = c(key = "character")
)
}
fly_camera_cache[[file]]
}

# The shipped format table.
fly_camera_table <- function() fly_camera_read("camera_formats.csv")

# Calibrations the catalogue offers that are deliberately not shipped, each with the
# reason. Kept beside the table rather than dropped, so "we have not looked at this"
# and "we looked and could not use it" stay distinguishable.
fly_camera_excluded <- function() fly_camera_read("camera_formats_excluded.csv")


# `GROUND_SAMPLE_DISTANCE` is recorded in CENTIMETRES.
#
# Worth a named function rather than a bare `/ 100`, because getting it wrong is a
# factor of 100 in every digital footprint and the field name says nothing about units.
# Confirmed against the catalogue's own arithmetic: an UltraCam Eagle frame at GSD 30
# gives 20010 px x 0.30 m = 6003 m across, which agrees with sizing the same frame from
# `104.052 mm x (height above ground / focal length)`. In metres it would be 100x.
fly_gsd_m <- function(gsd) gsd / 100


# Resolve each row to a recording format.
#
# Keyed on `camera_calibration_url`, not on `media` or `focal_length`. `media` is a
# single value (`Digital - Colour`) across all 14 cameras in the record, and focal
# length is ambiguous — catalogue focal 92 spans an 87.1 mm DMC II and a 100.3 mm
# DMC III, a 15% difference. The calibration file is exact, and it is the only key that
# separates the two cameras the catalogue files under serial 20814295: an UltraCam
# Eagle through 2017 and a different body in 2018, whose own report numbers it 22814295.
#
# Where no calibration URL is present — about a fifth of digital frames — the catalogue
# focal length is the only remaining discriminator, so it is used and the row is marked
# inferred. That direction is defensible for WIDTH, which spreads 1-3% at a given focal,
# and not for PIXEL COUNT, which spreads 32-83%; the fallback rows carry no pixel counts
# for exactly that reason, which keeps them off the `px * GSD` route by construction.
#
# Returns one row per input row, all-NA where nothing resolved.
fly_camera_format <- function(centroids_sf) {
n <- nrow(centroids_sf)
none <- data.frame(
width_mm = rep(NA_real_, n), height_mm = rep(NA_real_, n),
px_cross = rep(NA_real_, n), px_along = rep(NA_real_, n),
camera = rep(NA_character_, n), width_source = rep(NA_character_, n),
# `resolved` and `inferred` are separate on purpose. A row that resolved to nothing
# is also `inferred = FALSE`, so `!inferred` — the natural filter for "trustworthy"
# — would sweep up every unresolved frame as well.
resolved = rep(FALSE, n), inferred = rep(FALSE, n), stringsAsFactors = FALSE
)
if (n == 0 || !"media" %in% names(centroids_sf)) {
return(none)
}

media <- as.character(centroids_sf$media)
# Film is sized from `negative_size`; this table describes sensors only. Restricting
# to digital also stops a fallback row keyed on focal length from quietly resolving a
# film frame that happens to share the focal length.
digital <- !is.na(media) & !(media %in% fly_film_media())
if (!any(digital)) {
return(none)
}

out <- none
tbl <- fly_camera_table()
calib <- tbl[tbl$key_type == "calib_file", ]
fb <- tbl[tbl$key_type == "focal_length", ]

take <- function(rows, src, from, inferred) {
out$width_mm[rows] <<- from$width_mm
out$height_mm[rows] <<- from$height_mm
out$px_cross[rows] <<- from$px_cross
out$px_along[rows] <<- from$px_along
out$camera[rows] <<- from$camera
out$width_source[rows] <<- src
out$resolved[rows] <<- TRUE
out$inferred[rows] <<- inferred
}

matched <- rep(FALSE, n)
if ("camera_calibration_url" %in% names(centroids_sf)) {
u <- as.character(centroids_sf$camera_calibration_url)
has_url <- digital & !is.na(u) & nzchar(u)
key <- rep(NA_character_, n)
# `basename(character(0))` is character(0), so guard rather than assign into a
# zero-length subscript.
if (any(has_url)) {
key[has_url] <- sub("\\.zip$", "", basename(u[has_url]))
}
m <- match(key, calib$key)
matched <- !is.na(m)
if (any(matched)) {
take(matched, key[matched], calib[m[matched], ], FALSE)
}

# A frame whose calibration was deliberately withheld must not fall through to
# focal-length inference. The two withheld medium-format cameras are about 53 mm
# wide against the ~104 mm large-format bodies that share their focal neighbourhood,
# so inferring one would be ~1.95x too wide and 3.8x too much ground area. Today
# they happen to return NA because no fallback row exists at their focal lengths —
# that is safety by coincidence of the current table, and this makes it structural.
ex <- fly_camera_excluded()
refused <- !is.na(key) & key %in% ex$key[ex$key_type == "calib_file"]
out$width_source[refused] <- paste0("withheld:", key[refused])
matched <- matched | refused
}

if ("focal_length" %in% names(centroids_sf) && nrow(fb)) {
# Match numerically rather than on a formatted string: `as.character(100)` and
# `as.character(100L)` agree, but a double that prints as "1e+02" would not.
m <- match(as.numeric(centroids_sf$focal_length), as.numeric(fb$key))
use <- digital & !matched & !is.na(m)
if (any(use)) {
take(use, paste0("focal_length=", fb$key[m[use]]), fb[m[use], ], TRUE)
}
}

out
}
Loading
Loading