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
2 changes: 2 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@
^\.lintr$
^doc$
^Meta$
^CLAUDE\.md$
^\.claude$
3 changes: 2 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: fly
Title: Airphoto Footprint Estimation and Coverage Selection
Version: 0.1.0
Version: 0.1.1
Authors@R: c(
person("Allan", "Irvine", , "al@newgraphenvironment.com", role = c("aut", "cre"),
comment = c(ORCID = "0000-0002-3495-2128")),
Expand All @@ -27,6 +27,7 @@ Imports:
rlang,
stringr
Suggests:
bookdown,
DBI,
RPostgres,
testthat (>= 3.0.0),
Expand Down
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# fly (development version)

## 0.1.1 (2026-03-07)

- Add `ensure_components` parameter to `fly_select()` for multi-polygon AOIs — guarantees at least one photo per disconnected component before greedy selection ([#12](https://github.com/NewGraphEnvironment/fly/issues/12))
- Vignette uses bookdown with numbered sections and figure cross-references
- Add `bookdown` to Suggests

## 0.1.0 (2026-03-04)

Initial release. Airphoto footprint estimation and coverage selection,
Expand Down
88 changes: 85 additions & 3 deletions R/fly_select.R
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
#' (every photo touching the AOI).
#' @param target_coverage Stop when this fraction is reached (default 0.95).
#' Only used when `mode = "minimal"`.
#' @param ensure_components If `TRUE` (default `FALSE`), guarantee that every
#' polygon component of `aoi_sf` is covered by at least one photo before
#' running the greedy selection. Useful for multi-polygon AOIs (e.g. patchy
#' floodplain fragments) where small components might otherwise get zero
#' coverage. Only used when `mode = "minimal"`.
#' @return An sf object (subset of `photos_sf`). For `mode = "minimal"`,
#' includes `selection_order` and `cumulative_coverage_pct` columns.
#'
Expand All @@ -21,19 +26,24 @@
#' # Fewest photos to reach 80% coverage
#' fly_select(centroids, aoi, mode = "minimal", target_coverage = 0.80)
#'
#' # Ensure every AOI component gets at least one photo
#' fly_select(centroids, aoi, mode = "minimal", target_coverage = 0.80,
#' ensure_components = TRUE)
#'
#' # All photos touching the AOI
#' fly_select(centroids, aoi, mode = "all")
#'
#' @export
fly_select <- function(photos_sf, aoi_sf, mode = "minimal",
target_coverage = 0.95) {
target_coverage = 0.95,
ensure_components = FALSE) {
mode <- match.arg(mode, c("minimal", "all"))

if (mode == "all") {
return(fly_select_all(photos_sf, aoi_sf))
}

fly_select_minimal(photos_sf, aoi_sf, target_coverage)
fly_select_minimal(photos_sf, aoi_sf, target_coverage, ensure_components)
}

#' @noRd
Expand All @@ -53,8 +63,43 @@ fly_select_all <- function(photos_sf, aoi_sf) {
result
}

#' Pick one photo per uncovered AOI component
#'
#' For each polygon component that has no coverage yet, find the photo whose
#' footprint covers the most area of that component.
#' @noRd
fly_select_minimal <- function(photos_sf, aoi_sf, target_coverage) {
ensure_component_coverage <- function(footprints, aoi_albers) {
components <- sf::st_cast(aoi_albers, "POLYGON")
must_keep <- integer(0)

for (k in seq_along(components)) {
comp <- components[k]
hits <- sf::st_intersects(footprints, comp, sparse = FALSE)[, 1]
if (!any(hits)) next

candidates <- footprints[hits, ]
areas <- vapply(seq_len(nrow(candidates)), function(i) {
tryCatch({
isect <- sf::st_intersection(
sf::st_geometry(candidates[i, ]), comp
) |> sf::st_make_valid()
if (length(isect) == 0) return(0)
as.numeric(sf::st_area(isect))
}, error = function(e) 0)
}, numeric(1))

if (max(areas) > 0) {
best <- candidates$photo_idx[which.max(areas)]
must_keep <- c(must_keep, best)
}
}

unique(must_keep)
}

#' @noRd
fly_select_minimal <- function(photos_sf, aoi_sf, target_coverage,
ensure_components) {
sf::sf_use_s2(FALSE)
on.exit(sf::sf_use_s2(TRUE))

Expand All @@ -71,9 +116,46 @@ fly_select_minimal <- function(photos_sf, aoi_sf, target_coverage) {
coverage_pcts <- numeric(0)
covered_so_far <- sf::st_sfc(sf::st_polygon(), crs = 3005)

# Seed with must-keep photos for component coverage
if (ensure_components) {
seed_idx <- ensure_component_coverage(footprints, aoi_albers)
if (length(seed_idx) > 0) {
message("Seeding ", length(seed_idx),
" photos for component coverage...")
for (idx in seed_idx) {
selected_idx <- c(selected_idx, idx)
fp <- sf::st_geometry(footprints[footprints$photo_idx == idx, ])
covered_so_far <- sf::st_union(covered_so_far, fp) |>
sf::st_make_valid()
covered_in_aoi <- tryCatch(
sf::st_intersection(covered_so_far, aoi_albers) |>
sf::st_make_valid(),
error = function(e) covered_so_far
)
uncovered <- tryCatch(
sf::st_difference(aoi_albers, covered_so_far) |>
sf::st_make_valid(),
error = function(e) aoi_albers
)
pct <- sum(as.numeric(sf::st_area(covered_in_aoi))) / aoi_area
coverage_pcts <- c(coverage_pcts, pct)
}
message(" ", length(selected_idx), " seed photos -> ",
round(coverage_pcts[length(coverage_pcts)] * 100, 1),
"% coverage")
}
}

message("Selecting photos (target: ", target_coverage * 100, "% coverage)...")

while (TRUE) {
cur_pct <- if (length(coverage_pcts) > 0) {
coverage_pcts[length(coverage_pcts)]
} else {
0
}
if (cur_pct >= target_coverage) break

remaining <- footprints[!footprints$photo_idx %in% selected_idx, ]
if (nrow(remaining) == 0) break

Expand Down
18 changes: 17 additions & 1 deletion man/fly_select.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions tests/testthat/test-fly_select.R
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,93 @@ test_that("fly_select minimal handles full coverage without error", {
# coverage should be scalar, not length 0
expect_length(result$cumulative_coverage_pct, nrow(result))
})

# --- ensure_components tests ---

test_that("ensure_components selects at least as many photos as without", {
centroids <- sf::st_read(testdata_path("photo_centroids.gpkg"), quiet = TRUE)
aoi <- sf::st_read(testdata_path("aoi.gpkg"), quiet = TRUE)
result_plain <- fly_select(centroids, aoi, mode = "minimal",
target_coverage = 0.80)
result_ec <- fly_select(centroids, aoi, mode = "minimal",
target_coverage = 0.80, ensure_components = TRUE)
expect_gte(nrow(result_ec), nrow(result_plain))
})

test_that("ensure_components covers more AOI components", {
centroids <- sf::st_read(testdata_path("photo_centroids.gpkg"), quiet = TRUE)
aoi <- sf::st_read(testdata_path("aoi.gpkg"), quiet = TRUE)

sf::sf_use_s2(FALSE)
components <- sf::st_cast(
sf::st_transform(aoi, 3005) |> sf::st_union() |> sf::st_make_valid(),
"POLYGON"
)

count_covered <- function(selected) {
fp <- fly_footprint(selected) |> sf::st_transform(3005)
fp_union <- sf::st_union(fp) |> sf::st_make_valid()
sum(vapply(seq_along(components), function(k) {
any(sf::st_intersects(fp_union, components[k], sparse = FALSE))
}, logical(1)))
}

result_plain <- fly_select(centroids, aoi, mode = "minimal",
target_coverage = 0.80)
result_ec <- fly_select(centroids, aoi, mode = "minimal",
target_coverage = 0.80, ensure_components = TRUE)

covered_plain <- count_covered(result_plain)
covered_ec <- count_covered(result_ec)

expect_gte(covered_ec, covered_plain)
})

test_that("ensure_components returns valid selection columns", {
centroids <- sf::st_read(testdata_path("photo_centroids.gpkg"), quiet = TRUE)
aoi <- sf::st_read(testdata_path("aoi.gpkg"), quiet = TRUE)
result <- fly_select(centroids, aoi, mode = "minimal",
target_coverage = 0.80, ensure_components = TRUE)
expect_s3_class(result, "sf")
expect_true("selection_order" %in% names(result))
expect_true("cumulative_coverage_pct" %in% names(result))
# selection_order should be sequential
expect_equal(result$selection_order, seq_len(nrow(result)))
# coverage should increase monotonically
if (nrow(result) > 1) {
diffs <- diff(result$cumulative_coverage_pct)
expect_true(all(diffs >= 0))
}
})

test_that("ensure_components FALSE is the default", {
centroids <- sf::st_read(testdata_path("photo_centroids.gpkg"), quiet = TRUE)
aoi <- sf::st_read(testdata_path("aoi.gpkg"), quiet = TRUE)
result_default <- fly_select(centroids, aoi, mode = "minimal",
target_coverage = 0.80)
result_false <- fly_select(centroids, aoi, mode = "minimal",
target_coverage = 0.80,
ensure_components = FALSE)
expect_equal(nrow(result_default), nrow(result_false))
})

test_that("ensure_components works on single-polygon AOI", {
centroids <- sf::st_read(testdata_path("photo_centroids.gpkg"), quiet = TRUE)
aoi <- sf::st_read(testdata_path("aoi.gpkg"), quiet = TRUE)
# Create single polygon from convex hull of AOI
single_aoi <- sf::st_convex_hull(sf::st_union(aoi))
single_aoi <- sf::st_sf(geometry = single_aoi, crs = sf::st_crs(aoi))
result <- fly_select(centroids, single_aoi, mode = "minimal",
target_coverage = 0.80, ensure_components = TRUE)
expect_s3_class(result, "sf")
expect_gt(nrow(result), 0)
})

test_that("ensure_components is ignored in all mode", {
centroids <- sf::st_read(testdata_path("photo_centroids.gpkg"), quiet = TRUE)
aoi <- sf::st_read(testdata_path("aoi.gpkg"), quiet = TRUE)
result_plain <- fly_select(centroids, aoi, mode = "all")
result_ec <- fly_select(centroids, aoi, mode = "all",
ensure_components = TRUE)
expect_equal(nrow(result_plain), nrow(result_ec))
})
Loading