
Estimate photo footprint polygons from centroids and scale
Source:R/fly_footprint.R
fly_footprint.RdCreates rectangular polygons representing the estimated ground coverage of each airphoto, based on film negative dimensions and the reported scale.
Arguments
- centroids_sf
An sf point object with a
scalecolumn (e.g. "1:31680"). Amediacolumn (e.g."Film - BW","Digital - Colour") selects the recording format per frame when present. Geometry must be POINT.sf::st_coordinates()returns one row per vertex for anything else, so a POLYGON input silently multiplies the rows by the vertex count; this is refused rather than coerced, because re-estimating a footprint from the centroid of an estimated footprint is not meaningful.- negative_size
Negative dimension in inches (default 9 for standard 9" x 9"). Applies to film frames, and to every frame when there is no
mediacolumn. It never sizes a digital frame — seeformat_size.- format_size
Named numeric vector of recording-format widths in inches, keyed by
mediavalue, merged over the shipped film defaults. Frames it names are sized from the reportedscale, as film is, and it takes precedence over the shipped camera table — it is the escape hatch for a cameraflydoes not know. See Details.- dem
Optional elevation raster used to size each frame from its true height above ground rather than the reported scale. A
terra::SpatRaster, a file path, or a/vsicurl/URL. Requiresflying_heightandfocal_lengthcolumns, and theterrapackage.NULL(default) keeps the flat-terrain behaviour — see Terrain below.
Value
An sf polygon object in the same CRS as input, with footprint
rectangles, a footprint_basis column recording how each was sized, a
footprint_terrain column recording which terrain treatment was applied,
footprint_bearing giving the flight azimuth each rectangle was rotated
onto (NA where it was drawn axis-aligned because no bearing could be
computed), height_agl giving the metres above ground each was sized from,
and dem_coverage giving the fraction of each footprint the DEM actually
covered (0 where it covered none, NA only where there is no footprint).
Frames whose format could not be resolved get an empty geometry. Every
class the input carries is carried through, so a tibble-backed sf — which
is what bcdata::collect() returns — comes back tibble-backed. The order
is not preserved: sf::st_transform() moves sf to the front, so a
bcdc_sf input returns sf, bcdc_sf, ..., as it always has.
Details
Ground coverage is computed as negative_size * scale_number * 0.0254 metres
per side. Rectangles are constructed in BC Albers (EPSG:3005) for accurate
metric distances, then transformed back to the input CRS.
The scale denominator is parsed from the scale column string (e.g.
"1:12000" becomes 12000).
Film and digital are not the same measurement. The 9-inch default
reflects the standard 228 mm negative used by BC aerial survey cameras
(e.g. Wild RC-10, Zeiss RMK). A digital frame has no negative, and the
catalogue mixes the two in one layer — roughly a fifth of frames in a
sampled area are Digital - Colour. Ground width scales with the recording
format, so applying a negative dimension to a sensor produces a rectangle
that is wrong by an unknown factor while still drawing, still overlapping
neighbours, and still yielding a coverage percentage.
Each row is therefore sized from its media value, and footprint_basis
records the outcome:
- the
mediavalue format resolved from the format table
"inferred_format"digital frame with no calibration, sized from a format inferred from its
focal_length"assumed_default"no
mediacolumn;negative_sizeapplied"unknown_format"mediapresent but unknown; empty geometry
Digital frames
A digital frame has no negative, and the catalogue mixes film and digital in one
layer — 223,667 of 1,670,471 frames province-wide are Digital - Colour. Sensor
dimensions are not in the centroid metadata, but they are recoverable from the
calibration report each frame links to through camera_calibration_url, and fly
ships them (inst/extdata/camera_formats.csv, built by
data-raw/make_camera_formats.R).
Digital frames are sized as pixel count x ground_sample_distance, which needs
neither scale nor a DEM. scale is never used for a digital frame fly sized
itself. That field is not the true image scale for digital: measured against
terrain on 40 UltraCam Eagle frames it gives 34% of true width, because it is a
derived nominal figure — the pixel pitch it implies is about 12.5 um for every
camera regardless of model, against real pitches of 3.9 to 12 um.
ground_sample_distance is in centimetres.
Where a frame carries no camera_calibration_url — about a fifth of digital frames —
the format is inferred from focal_length and footprint_basis records
"inferred_format". Sensor width spreads only 1-3% at a given focal length, but
pixel count spreads 32-83%, so an inferred frame can only be sized through a DEM
(width x height above ground / focal length) and never from its GSD.
width_source names the calibration file or fallback rule per row, so every
footprint traces back to a source. Calibrations that could not be corroborated are
listed in inst/extdata/camera_formats_excluded.csv with the reason, and frames
naming one are refused rather than inferred.
Every footprint is rotated onto the flight line using fly_bearing(), and
footprint_bearing records the azimuth each was rotated onto. Where no bearing can
be computed — a single-frame roll, or absent film_roll / frame_number columns —
the rectangle stays axis-aligned, footprint_bearing is NA and width_source
says so.
Digital sensors are not square, running from 1.10:1 (Leica DMC II) to 1.80:1 (Intergraph DMC), so rotating them is visibly load-bearing. Film is square, which is why rotating it was deferred until fly#26 — but a square rotated 45 degrees overlaps its axis-aligned self by only about 83%, so leaving film alone was not neutral. It was reporting coverage of ground the frame does not photograph. Film footprints on off-cardinal headings therefore move as of v0.9.0.
Supply format_size to size a frame fly cannot, or to override it:
fly_footprint(photos, format_size = c("Digital - Colour" = 3.54))Filter on footprint_basis to keep only frames sized from a known format.
Focal length and flying height are available. FOCAL_LENGTH,
FLYING_HEIGHT and SCALE are fully populated in
WHSE_IMAGERY_AND_BASE_MAPS.AIMG_PHOTO_CENTROIDS_SP and carried through to
the bundled test data. Note the field is SCALE, not PHOTO_SCALE — the
latter returns all NULL, which reads as missing data rather than a wrong
field name.
Terrain
Without dem, footprints are sized from the reported scale, which assumes
flat ground at whatever elevation the scale was computed for. That assumption
costs more than it looks: on the bundled Upper Bulkley AOI the reported scale
understates footprint area by a median 14%, ranging to 26% — and always in
the same direction, because the scale is referenced to an elevation above the
valley floor the photos actually cover.
Supplying dem removes that bias. FLYING_HEIGHT is metres above sea level,
not height above ground, so subtracting terrain elevation is what turns it
into the height ground coverage actually scales with:
height above ground = flying_height - terrain elevation
ground width = format width * (height above ground / focal length)Elevation is the mean under the whole footprint, not a reading at the centroid — on a 7.2 km wide 1:31680 frame the two differ by up to 140 m. That is measured in two passes, because the footprint being averaged over is itself what the correction changes: the first pass averages over the nominal-scale rectangle, the second over the rectangle the first produced. The second pass is a refinement rather than the substance — it moves area by at most 0.5% against the correction's own 14% — and a third moves it by 0.03%, so two is where this settles.
footprint_terrain records what happened to each frame:
"nominal_scale"sized from the reported scale (no
dem, or a fallback — see below)"gsd_scaled"digital frame sized from its pixel count and ground sample distance; used neither the reported scale nor a DEM, so
height_aglanddem_coverageareNA"dem_agl"sized from height above ground
"no_dem_coverage"demsupplied but does not cover the frameNAno footprint to place — see
footprint_basis
A frame the DEM cannot correct falls back to nominal scale with a warning,
rather than being dropped. The same applies where the DEM puts terrain at or
above the aircraft, which means flying_height is not in metres ASL.
Still assumed, with or without a DEM: the camera points straight down. The BC catalogue carries no tilt, roll or crab, so footprints stay axis-aligned rectangles and corner rays are not projected individually. On this AOI that per-corner refinement is worth roughly 2%, against the 14% the DEM addresses.
DEM sources. Any raster terra can open works. Three that suit BC:
MRDEM-30 — NRCan's 30 m bare-earth DTM, all of Canada, public and unauthenticated. A good default, and what the bundled
dem.tifis cut from:/vsicurl/https://canelevation-dem.s3.ca-central-1.amazonaws.com/mrdem-30/mrdem-30-dtm.tifLidarBC — sub-10 m where coverage exists; query the
stac-elevation-bcSTAC catalogue and pass an item's COG URL.BC TRIM — 25 m provincial DEM via the
bcdataCLI (bcdata get-dem).
Resolution matters less here than extent. A 30 m DEM resolves a 2.7 km
footprint's mean elevation perfectly well; a DEM that stops short of the
frame edges does not, and this is the ordinary failure rather than an exotic
one — a DEM cropped to an AOI simply stops. no_dem_coverage is reached only
when a footprint finds no elevation at all. A footprint that is merely
truncated is still corrected, from the mean of the part the DEM described,
and warns once that falls below 95%. dem_coverage reports the fraction per
frame — measured against the cells the footprint should have covered, not the
cells that came back — so a truncated footprint can be filtered rather than
merely noticed.
Buffer past the corner of the widest footprint, not its half-side: the
far point of a square is half_side * sqrt(2), which at 1:31680 is 5.1 km
rather than 3.6 km. Allow more again for the correction itself, which
enlarges footprints before the second pass samples them.
Coverage and overlap downstream (e.g. fly_coverage(), fly_overlap())
accept the same dem argument and inherit whichever basis you give them.
Examples
centroids <- sf::st_read(system.file("testdata/photo_centroids.gpkg", package = "fly"))
#> Reading layer `photo_centroids' from data source
#> `/home/runner/work/_temp/Library/fly/testdata/photo_centroids.gpkg'
#> using driver `GPKG'
#> Simple feature collection with 20 features and 16 fields
#> Geometry type: POINT
#> Dimension: XY
#> Bounding box: xmin: -126.7631 ymin: 54.34512 xmax: -126.449 ymax: 54.47635
#> Geodetic CRS: WGS 84
footprints <- fly_footprint(centroids)
plot(sf::st_geometry(footprints))
# How each footprint was sized
table(footprints$footprint_basis)
#>
#> Film - BW
#> 20
# Keep only frames sized from a known recording format
sized <- footprints[footprints$footprint_basis != "unknown_format", ]
nrow(sized)
#> [1] 20
# Terrain-adjusted: size each frame from its height above ground instead of
# the reported scale. On this AOI every footprint grows, by a median 14%.
# terra is Suggests-only, so the DEM path is guarded here.
if (requireNamespace("terra", quietly = TRUE)) {
terrain <- fly_footprint(
centroids,
dem = system.file("testdata/dem.tif", package = "fly")
)
print(round(100 * (as.numeric(sf::st_area(sf::st_transform(terrain, 3005))) /
as.numeric(sf::st_area(sf::st_transform(footprints, 3005))) - 1), 1))
print(table(terrain$footprint_terrain))
}
#> [1] 16.9 11.1 15.3 4.9 0.5 13.4 14.0 2.1 18.1 12.8 16.1 16.0 15.0 11.0 9.0
#> [16] 26.4 6.2 9.1 16.3 15.5
#>
#> dem_agl
#> 20