Skip to main content

connectivity_core/
coverage.rs

1use crate::affine::Affine;
2use ndarray::parallel::prelude::*;
3use ndarray::{s, Array2, Axis};
4
5/// One polygon as `(exterior, holes)`: rings of (x, y) world-coordinate vertices, in the
6/// coordinates of the grid's transform. Rings may be open or closed and of either orientation.
7pub type Polygon = (Vec<(f64, f64)>, Vec<Vec<(f64, f64)>>);
8
9/// Compute fractional pixel coverage for a list of polygons.
10///
11/// Each pixel of the `nrows` x `ncols` grid mapped by `transform` gets the fraction of its
12/// area covered by `polygons`: exterior rings add their area and holes subtract it. Polygons
13/// are assumed already unioned by the caller (the Python wrapper unions them first); any
14/// residual overlap is clipped to 1.0. Rings with fewer than three vertices are ignored.
15/// Rows are processed in parallel on the current Rayon thread pool.
16///
17/// # Returns
18/// A 2D array of shape (nrows, ncols) with values in [0, 1].
19pub fn pixel_coverage(
20    polygons: &[Polygon],
21    transform: &Affine,
22    nrows: usize,
23    ncols: usize,
24) -> Array2<f32> {
25    let mut out = Array2::<f32>::zeros((nrows, ncols));
26
27    for (exterior, holes) in polygons {
28        let ext_px = to_pixel(exterior, transform);
29        accumulate_ring(&mut out, &ext_px, 1.0, nrows, ncols);
30        for hole in holes {
31            let hole_px = to_pixel(hole, transform);
32            accumulate_ring(&mut out, &hole_px, -1.0, nrows, ncols);
33        }
34    }
35
36    out.mapv_inplace(|v| v.clamp(0.0, 1.0));
37    out
38}
39
40/// Convert a ring of (x, y) world coords to continuous (col, row) pixel coords.
41fn to_pixel(ring: &[(f64, f64)], tr: &Affine) -> Vec<(f64, f64)> {
42    ring.iter().map(|&(x, y)| tr.col_row(x, y)).collect()
43}
44
45/// Accumulate fractional coverage for a single ring into `out`. `sign` is +1 for
46/// exterior rings and -1 for holes.
47fn accumulate_ring(
48    out: &mut Array2<f32>,
49    ring: &[(f64, f64)],
50    sign: f32,
51    nrows: usize,
52    ncols: usize,
53) {
54    if ring.len() < 3 {
55        return;
56    }
57
58    // Bounding box of the ring in pixel coords (col=x, row=y after transform)
59    let (mut xmin, mut ymin) = (f64::INFINITY, f64::INFINITY);
60    let (mut xmax, mut ymax) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
61    for &(x, y) in ring {
62        if x < xmin { xmin = x; }
63        if x > xmax { xmax = x; }
64        if y < ymin { ymin = y; }
65        if y > ymax { ymax = y; }
66    }
67
68    // Clip bbox to raster extent
69    let c0 = clamp_to_usize(xmin.floor(), ncols);
70    let c1 = clamp_to_usize(xmax.ceil(), ncols);
71    let r0 = clamp_to_usize(ymin.floor(), nrows);
72    let r1 = clamp_to_usize(ymax.ceil(), nrows);
73    if c0 >= c1 || r0 >= r1 {
74        return;
75    }
76
77    // Parallel over rows in the bbox; each thread writes a disjoint row slice.
78    out.slice_mut(s![r0..r1, c0..c1])
79        .axis_iter_mut(Axis(0))
80        .into_par_iter()
81        .enumerate()
82        .for_each(|(local_r, mut row)| {
83            let r = r0 + local_r;
84            let r_top = r as f64;
85            let r_bot = r_top + 1.0;
86
87            // Pre-clip ring to the row strip [r_top, r_bot] so per-pixel clips are cheaper.
88            let strip = clip_half(ring, 1, r_top, true);
89            let strip = clip_half(&strip, 1, r_bot, false);
90            if strip.len() < 3 {
91                return;
92            }
93
94            // Restrict cols to the strip's actual x-range, intersected with bbox cols.
95            let mut sxmin = f64::INFINITY;
96            let mut sxmax = f64::NEG_INFINITY;
97            for &(x, _) in &strip {
98                if x < sxmin { sxmin = x; }
99                if x > sxmax { sxmax = x; }
100            }
101            let row_c0 = clamp_to_usize(sxmin.floor(), ncols).max(c0);
102            let row_c1 = clamp_to_usize(sxmax.ceil(), ncols).min(c1);
103
104            for c in row_c0..row_c1 {
105                let x_left = c as f64;
106                let x_right = x_left + 1.0;
107                let cell = clip_half(&strip, 0, x_left, true);
108                let cell = clip_half(&cell, 0, x_right, false);
109                let area = shoelace_abs(&cell) as f32;
110                if area > 0.0 {
111                    row[c - c0] += sign * area;
112                }
113            }
114        });
115}
116
117/// Sutherland-Hodgman clip against an axis-aligned half-plane.
118/// `axis` is 0 for x and 1 for y. `keep_geq=true` keeps coord >= threshold;
119/// otherwise keeps coord <= threshold.
120fn clip_half(poly: &[(f64, f64)], axis: usize, threshold: f64, keep_geq: bool) -> Vec<(f64, f64)> {
121    let n = poly.len();
122    if n == 0 {
123        return Vec::new();
124    }
125
126    let coord = |p: (f64, f64)| if axis == 0 { p.0 } else { p.1 };
127    let inside = |p: (f64, f64)| {
128        let v = coord(p);
129        if keep_geq { v >= threshold } else { v <= threshold }
130    };
131    let intersect = |s: (f64, f64), p: (f64, f64)| -> (f64, f64) {
132        let cs = coord(s);
133        let cp = coord(p);
134        let t = (threshold - cs) / (cp - cs);
135        (s.0 + t * (p.0 - s.0), s.1 + t * (p.1 - s.1))
136    };
137
138    let mut out = Vec::with_capacity(n + 2);
139    let mut s = poly[n - 1];
140    let mut s_in = inside(s);
141    for &p in poly {
142        let p_in = inside(p);
143        if p_in {
144            if !s_in {
145                out.push(intersect(s, p));
146            }
147            out.push(p);
148        } else if s_in {
149            out.push(intersect(s, p));
150        }
151        s = p;
152        s_in = p_in;
153    }
154    out
155}
156
157/// Shoelace-formula area (orientation-independent).
158fn shoelace_abs(poly: &[(f64, f64)]) -> f64 {
159    if poly.len() < 3 {
160        return 0.0;
161    }
162    let n = poly.len();
163    let mut sum = 0.0;
164    for i in 0..n {
165        let (x1, y1) = poly[i];
166        let (x2, y2) = poly[(i + 1) % n];
167        sum += x1 * y2 - x2 * y1;
168    }
169    (sum * 0.5).abs()
170}
171
172/// Saturating cast from f64 to a usize clipped to [0, max].
173fn clamp_to_usize(v: f64, max: usize) -> usize {
174    if !v.is_finite() || v <= 0.0 {
175        0
176    } else if v >= max as f64 {
177        max
178    } else {
179        v as usize
180    }
181}