spatialsample 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
spatialsample/io.py ADDED
@@ -0,0 +1,161 @@
1
+ """Reading sampling frames and sites from GeoJSON, CSV or (optionally) GeoPandas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ import os
8
+ from typing import Any, Dict, Hashable, List, Optional, Tuple, Union
9
+
10
+ import numpy as np
11
+
12
+ from .geometry import Region, _iter_polygons
13
+
14
+ __all__ = [
15
+ "read_geojson",
16
+ "read_region",
17
+ "read_strata",
18
+ "read_points",
19
+ "read_lines",
20
+ "read_matrix_csv",
21
+ ]
22
+
23
+ PathLike = Union[str, "os.PathLike[str]"]
24
+
25
+
26
+ def read_geojson(path: PathLike) -> Dict[str, Any]:
27
+ """Load a GeoJSON file as a Python mapping."""
28
+ with open(path, encoding="utf-8") as fh:
29
+ data = json.load(fh)
30
+ if not isinstance(data, dict) or "type" not in data:
31
+ raise ValueError(f"{path} is not a GeoJSON object")
32
+ return data
33
+
34
+
35
+ def _load(src: Any) -> Dict[str, Any]:
36
+ if isinstance(src, dict):
37
+ return src
38
+ if hasattr(src, "__geo_interface__"):
39
+ return dict(src.__geo_interface__)
40
+ path = os.fspath(src)
41
+ if path.lower().endswith((".geojson", ".json")):
42
+ return read_geojson(path)
43
+ if not os.path.exists(path):
44
+ raise FileNotFoundError(path)
45
+ try: # other vector formats through GeoPandas, when installed
46
+ import geopandas as gpd
47
+ except ImportError as exc:
48
+ raise ValueError(
49
+ f"cannot read {path!r}: only GeoJSON is supported without geopandas "
50
+ "(pip install 'spatialsample[geo]')"
51
+ ) from exc
52
+ return dict(gpd.read_file(path).__geo_interface__) # pragma: no cover
53
+
54
+
55
+ def read_region(src: Any) -> Region:
56
+ """All polygons of a GeoJSON file/mapping (or GeoPandas object) as one Region."""
57
+ return Region.from_geojson(_load(src))
58
+
59
+
60
+ def read_strata(src: Any, field: str) -> Dict[Hashable, Region]:
61
+ """Polygon features grouped by the value of property ``field``."""
62
+ data = _load(src)
63
+ feats = data.get("features") if data.get("type") == "FeatureCollection" else [data]
64
+ groups: Dict[Hashable, List[Any]] = {}
65
+ for f in feats or []:
66
+ props = f.get("properties") or {}
67
+ if field not in props:
68
+ raise KeyError(f"feature without property {field!r}")
69
+ polys = list(_iter_polygons(f))
70
+ if polys:
71
+ groups.setdefault(props[field], []).extend(polys)
72
+ if not groups:
73
+ raise ValueError("no polygon features found")
74
+ return {k: Region(v) for k, v in groups.items()}
75
+
76
+
77
+ def read_points(src: Any) -> Tuple[np.ndarray, List[Dict[str, Any]]]:
78
+ """Point coordinates and properties from GeoJSON (Point/MultiPoint) or CSV.
79
+
80
+ CSV files need ``x`` and ``y`` columns (``lon``/``lat`` also accepted).
81
+ """
82
+ if not isinstance(src, dict) and os.fspath(src).lower().endswith(".csv"):
83
+ return _read_points_csv(os.fspath(src))
84
+ data = _load(src)
85
+ xy: List[List[float]] = []
86
+ props: List[Dict[str, Any]] = []
87
+
88
+ def visit(obj: Dict[str, Any], p: Dict[str, Any]) -> None:
89
+ t = obj.get("type")
90
+ if t == "FeatureCollection":
91
+ for f in obj.get("features", []):
92
+ visit(f, {})
93
+ elif t == "Feature" and obj.get("geometry"):
94
+ visit(obj["geometry"], obj.get("properties") or {})
95
+ elif t == "Point":
96
+ xy.append(list(obj["coordinates"][:2]))
97
+ props.append(p)
98
+ elif t == "MultiPoint":
99
+ for c in obj["coordinates"]:
100
+ xy.append(list(c[:2]))
101
+ props.append(p)
102
+
103
+ visit(data, {})
104
+ if not xy:
105
+ raise ValueError("no point geometries found")
106
+ return np.asarray(xy, dtype=float), props
107
+
108
+
109
+ def _read_points_csv(path: str) -> Tuple[np.ndarray, List[Dict[str, Any]]]:
110
+ with open(path, newline="", encoding="utf-8") as fh:
111
+ rows = list(csv.DictReader(fh))
112
+ if not rows:
113
+ raise ValueError("empty CSV")
114
+ keys = {k.lower(): k for k in rows[0]}
115
+ xk = keys.get("x") or keys.get("lon") or keys.get("longitude")
116
+ yk = keys.get("y") or keys.get("lat") or keys.get("latitude")
117
+ if xk is None or yk is None:
118
+ raise ValueError("CSV needs x/y (or lon/lat) columns")
119
+ xy = np.array([[float(r[xk]), float(r[yk])] for r in rows])
120
+ return xy, [dict(r) for r in rows]
121
+
122
+
123
+ def read_lines(src: Any) -> List[np.ndarray]:
124
+ """LineString / MultiLineString coordinates from GeoJSON."""
125
+ from .designs import _as_lines
126
+
127
+ return _as_lines(_load(src))
128
+
129
+
130
+ def read_matrix_csv(path: PathLike) -> Tuple[np.ndarray, Optional[List[str]]]:
131
+ """Read a square error matrix from CSV.
132
+
133
+ A header row and/or a first label column are detected automatically
134
+ (rows = map classes, columns = reference classes).
135
+ """
136
+ with open(path, newline="", encoding="utf-8") as fh:
137
+ rows = [r for r in csv.reader(fh) if any(c.strip() for c in r)]
138
+ if not rows:
139
+ raise ValueError("empty matrix file")
140
+
141
+ def numeric(s: str) -> bool:
142
+ try:
143
+ float(s)
144
+ return True
145
+ except ValueError:
146
+ return False
147
+
148
+ labels: Optional[List[str]] = None
149
+ if not all(numeric(c) for c in rows[0] if c.strip()):
150
+ header = rows[0]
151
+ rows = rows[1:]
152
+ labels = [c.strip() for c in header]
153
+ first_col_labels = any(not numeric(r[0]) for r in rows)
154
+ if first_col_labels:
155
+ row_labels = [r[0].strip() for r in rows]
156
+ rows = [r[1:] for r in rows]
157
+ labels = row_labels
158
+ mat = np.array([[float(c) for c in r if c.strip() != ""] for r in rows])
159
+ if labels is not None and len(labels) != mat.shape[0]:
160
+ labels = labels[-mat.shape[0] :] if len(labels) > mat.shape[0] else None
161
+ return mat, labels
spatialsample/lpm.py ADDED
@@ -0,0 +1,133 @@
1
+ """Local Pivotal Method (LPM2), Grafström, Lundström & Schelin (2012).
2
+
3
+ The pivotal method repeatedly picks two undecided units and moves inclusion
4
+ probability between them so that at least one of the two is decided (its
5
+ probability becomes 0 or 1), while the expected value of each unit's
6
+ probability stays the same. Choosing the pair as a randomly drawn unit and its
7
+ *nearest* undecided neighbour (LPM2) makes nearby units unlikely to be
8
+ selected together, which spreads the sample in space. Each unit is included
9
+ with exactly its prescribed first-order inclusion probability.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Optional
15
+
16
+ import numpy as np
17
+
18
+ from ._frame import ProbLike, frame_sizes, grid_frame, jitter_in_cells, resolve
19
+ from ._types import FloatArray, SeedLike, as_rng
20
+ from .probabilities import inclusion_probabilities
21
+ from .sample import Sample
22
+
23
+ __all__ = ["lpm", "lpm2_select"]
24
+
25
+ _EPS = 1e-10
26
+
27
+
28
+ def lpm2_select(xy: FloatArray, pi: FloatArray, seed: SeedLike = None) -> np.ndarray:
29
+ """Run LPM2 and return the indices of the selected units.
30
+
31
+ ``pi`` are the target inclusion probabilities (they should sum to an
32
+ integer). Runs in ``O(N^2)`` time; frames up to a few tens of thousands of
33
+ units are practical.
34
+ """
35
+ rng = as_rng(seed)
36
+ p = np.asarray(pi, dtype=float).copy()
37
+ pts = np.asarray(xy, dtype=float)
38
+ if len(p) != len(pts):
39
+ raise ValueError("xy and pi must have the same length")
40
+ if np.any(p < -_EPS) or np.any(p > 1 + _EPS):
41
+ raise ValueError("inclusion probabilities must lie in [0, 1]")
42
+ active = np.flatnonzero((p > _EPS) & (p < 1 - _EPS))
43
+ ax = pts[active].copy()
44
+ na = len(active)
45
+ while na > 1:
46
+ a = int(rng.integers(na))
47
+ d = (ax[:na, 0] - ax[a, 0]) ** 2 + (ax[:na, 1] - ax[a, 1]) ** 2
48
+ d[a] = np.inf
49
+ dmin = d.min()
50
+ ties = np.flatnonzero(d == dmin)
51
+ b = int(ties[rng.integers(len(ties))]) if len(ties) > 1 else int(ties[0])
52
+ i, j = active[a], active[b]
53
+ pi_i, pi_j = p[i], p[j]
54
+ s = pi_i + pi_j
55
+ u = rng.random()
56
+ if s < 1.0:
57
+ if u * s < pi_j:
58
+ p[i], p[j] = 0.0, s
59
+ else:
60
+ p[i], p[j] = s, 0.0
61
+ else:
62
+ if u * (2.0 - s) < 1.0 - pi_j:
63
+ p[i], p[j] = 1.0, s - 1.0
64
+ else:
65
+ p[i], p[j] = s - 1.0, 1.0
66
+ # drop decided units from the active set (swap with the last active one)
67
+ for pos in sorted((a, b), reverse=True):
68
+ q = p[active[pos]]
69
+ if q <= _EPS or q >= 1 - _EPS:
70
+ na -= 1
71
+ active[pos], active[na] = active[na], active[pos]
72
+ ax[[pos, na]] = ax[[na, pos]]
73
+ if na == 1: # numerical leftover
74
+ k = active[0]
75
+ p[k] = 1.0 if rng.random() < p[k] else 0.0
76
+ return np.flatnonzero(p >= 0.5)
77
+
78
+
79
+ def lpm(
80
+ frame: Any,
81
+ n: int,
82
+ prob: ProbLike = None,
83
+ frame_size: Optional[int] = None,
84
+ seed: SeedLike = None,
85
+ ) -> Sample:
86
+ """Draw a spatially balanced sample with the local pivotal method (LPM2).
87
+
88
+ Parameters
89
+ ----------
90
+ frame:
91
+ Polygon/raster domain (continuous design over a dense grid frame) or an
92
+ ``(N, 2)`` array of candidate sites (finite design).
93
+ n:
94
+ Sample size.
95
+ prob:
96
+ Optional size measure; inclusion probabilities are proportional to it
97
+ (units that would exceed 1 are taken with certainty).
98
+ frame_size:
99
+ Grid cells used for a continuous domain (default ``max(2000, 40 * n)``).
100
+ seed:
101
+ Seed or generator.
102
+ """
103
+ rng = as_rng(seed)
104
+ if n < 1:
105
+ raise ValueError("n must be >= 1")
106
+ domain, pts = resolve(frame)
107
+ if pts is not None:
108
+ pi = inclusion_probabilities(frame_sizes(prob, pts), n)
109
+ sel = lpm2_select(pts, pi, rng)
110
+ return Sample(
111
+ xy=pts[sel],
112
+ pi=pi[sel],
113
+ weight=1.0 / pi[sel],
114
+ design="lpm",
115
+ kind="finite",
116
+ index=sel,
117
+ meta={"n": n, "population_size": len(pts)},
118
+ )
119
+ assert domain is not None
120
+ size = frame_size or max(2000, 40 * n)
121
+ cells, h = grid_frame(domain, size, rng, min_points=2 * n)
122
+ pi = inclusion_probabilities(frame_sizes(prob, cells), n)
123
+ sel = lpm2_select(cells, pi, rng)
124
+ xy = jitter_in_cells(cells[sel], h, domain, rng)
125
+ density = np.full(len(sel), n / domain.area) if prob is None else pi[sel] / (h * h)
126
+ return Sample(
127
+ xy=xy,
128
+ pi=density,
129
+ weight=1.0 / density,
130
+ design="lpm",
131
+ kind="continuous",
132
+ meta={"n": n, "area": domain.area, "frame_size": len(cells)},
133
+ )
@@ -0,0 +1,202 @@
1
+ """Inclusion probabilities, sample allocation and nearest-neighbour helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Hashable, List, Mapping, Optional, Sequence, Union
6
+
7
+ import numpy as np
8
+
9
+ from ._types import FloatArray
10
+
11
+ __all__ = [
12
+ "inclusion_probabilities",
13
+ "allocate",
14
+ "systematic_pps",
15
+ "nearest_index",
16
+ ]
17
+
18
+
19
+ def inclusion_probabilities(size: Any, n: float) -> FloatArray:
20
+ """Inclusion probabilities proportional to ``size`` that sum to ``n``.
21
+
22
+ Units whose scaled value would exceed one are taken with certainty
23
+ (probability 1) and the remainder is rescaled, iteratively, exactly as in
24
+ the classic ``inclusionprobabilities`` routine of Tillé.
25
+ """
26
+ a = np.asarray(size, dtype=float).ravel()
27
+ if np.any(a < 0) or not np.all(np.isfinite(a)):
28
+ raise ValueError("size measures must be finite and non-negative")
29
+ if n < 0 or n > np.count_nonzero(a):
30
+ raise ValueError(f"n={n} must be between 0 and the number of positive sizes")
31
+ pi = np.zeros_like(a)
32
+ if n == 0:
33
+ return pi
34
+ cert = np.zeros(len(a), dtype=bool)
35
+ for _ in range(len(a) + 1):
36
+ rest = ~cert & (a > 0)
37
+ left = n - cert.sum()
38
+ pi[cert] = 1.0
39
+ tot = a[rest].sum()
40
+ pi[rest] = left * a[rest] / tot if tot > 0 else 0.0
41
+ over = rest & (pi >= 1.0)
42
+ if not over.any():
43
+ break
44
+ cert |= over
45
+ return np.minimum(pi, 1.0)
46
+
47
+
48
+ def systematic_pps(pi: FloatArray, rng: np.random.Generator) -> np.ndarray:
49
+ """Systematic selection along the given order with probabilities ``pi``.
50
+
51
+ ``pi`` must sum to an integer ``n``; returns the selected positions
52
+ (exactly ``n``), each unit chosen with probability ``pi_i``.
53
+ """
54
+ cum = np.cumsum(pi)
55
+ n = int(round(float(cum[-1]))) if len(cum) else 0
56
+ if n == 0:
57
+ return np.empty(0, dtype=np.int64)
58
+ cum = cum * (n / cum[-1])
59
+ targets = rng.uniform() + np.arange(n)
60
+ pos = np.searchsorted(cum, targets, side="right")
61
+ return np.minimum(pos, len(pi) - 1)
62
+
63
+
64
+ def _largest_remainder(
65
+ n: int, share: FloatArray, minimum: FloatArray, cap: FloatArray
66
+ ) -> np.ndarray:
67
+ k = len(share)
68
+ alloc = np.minimum(minimum.astype(np.int64), cap.astype(np.int64))
69
+ for _ in range(4 * k + 10):
70
+ left = n - alloc.sum()
71
+ if left <= 0:
72
+ break
73
+ open_ = alloc < cap
74
+ if not open_.any():
75
+ break
76
+ s = np.where(open_, share, 0.0)
77
+ if s.sum() <= 0:
78
+ s = open_.astype(float)
79
+ ideal = left * s / s.sum()
80
+ add = np.minimum(np.floor(ideal).astype(np.int64), (cap - alloc).astype(np.int64))
81
+ if add.sum() == 0:
82
+ rem = np.where(open_, ideal - np.floor(ideal) + 1e-12 * s, -np.inf)
83
+ j = int(np.argmax(rem))
84
+ add = np.zeros(k, dtype=np.int64)
85
+ add[j] = 1
86
+ alloc = alloc + add
87
+ return alloc
88
+
89
+
90
+ def allocate(
91
+ n: int,
92
+ sizes: Union[Mapping[Hashable, float], Sequence[float]],
93
+ method: str = "proportional",
94
+ sd: Optional[Union[Mapping[Hashable, float], Sequence[float]]] = None,
95
+ min_per_stratum: int = 0,
96
+ caps: Optional[Union[Mapping[Hashable, float], Sequence[float]]] = None,
97
+ ) -> Any:
98
+ """Split a total sample size ``n`` among strata.
99
+
100
+ Parameters
101
+ ----------
102
+ n:
103
+ Total sample size.
104
+ sizes:
105
+ Stratum sizes (areas or unit counts), as a mapping or a sequence.
106
+ method:
107
+ ``"proportional"`` (n_h proportional to size), ``"equal"`` or
108
+ ``"neyman"`` (n_h proportional to size times standard deviation).
109
+ sd:
110
+ Stratum standard deviations, required for Neyman allocation.
111
+ min_per_stratum:
112
+ Lower bound for every stratum (useful for rare map classes).
113
+ caps:
114
+ Optional upper bounds (for finite strata: the number of units).
115
+
116
+ Returns
117
+ -------
118
+ A mapping when ``sizes`` is a mapping, otherwise a list of ints. Rounding
119
+ uses the largest-remainder method so the result sums to ``n``.
120
+ """
121
+ keys: List[Hashable]
122
+ if isinstance(sizes, Mapping):
123
+ keys = list(sizes)
124
+ sz = np.array([float(sizes[k]) for k in keys])
125
+ else:
126
+ keys = list(range(len(sizes)))
127
+ sz = np.asarray(sizes, dtype=float)
128
+
129
+ def _vec(v: Any) -> FloatArray:
130
+ if isinstance(v, Mapping):
131
+ return np.array([float(v[k]) for k in keys])
132
+ return np.asarray(v, dtype=float)
133
+
134
+ if np.any(sz < 0):
135
+ raise ValueError("stratum sizes must be non-negative")
136
+ method = method.lower()
137
+ if method == "proportional":
138
+ share = sz
139
+ elif method == "equal":
140
+ share = (sz > 0).astype(float)
141
+ elif method == "neyman":
142
+ if sd is None:
143
+ raise ValueError("Neyman allocation needs per-stratum standard deviations (sd=)")
144
+ share = sz * _vec(sd)
145
+ else:
146
+ raise ValueError("method must be 'proportional', 'equal' or 'neyman'")
147
+ cap = _vec(caps) if caps is not None else np.full(len(sz), float(n))
148
+ cap = np.floor(cap)
149
+ if n > cap.sum():
150
+ raise ValueError("n exceeds the total capacity of the strata")
151
+ minimum = np.full(len(sz), float(min_per_stratum))
152
+ if np.minimum(minimum, cap).sum() > n:
153
+ raise ValueError("min_per_stratum * number of strata exceeds n")
154
+ alloc = _largest_remainder(int(n), share.astype(float), minimum, cap)
155
+ if isinstance(sizes, Mapping):
156
+ return {k: int(a) for k, a in zip(keys, alloc)}
157
+ return [int(a) for a in alloc]
158
+
159
+
160
+ def nearest_index(query: FloatArray, ref: FloatArray) -> np.ndarray:
161
+ """Index of the nearest ``ref`` point for each ``query`` point.
162
+
163
+ Uses ``scipy.spatial.cKDTree`` when SciPy is installed, otherwise a chunked
164
+ NumPy brute-force search.
165
+ """
166
+ q = np.asarray(query, dtype=float).reshape(-1, 2)
167
+ r = np.asarray(ref, dtype=float).reshape(-1, 2)
168
+ if len(r) == 0:
169
+ raise ValueError("reference set is empty")
170
+ try:
171
+ from scipy.spatial import cKDTree
172
+ except ImportError: # pragma: no cover - depends on environment
173
+ cKDTree = None
174
+ if cKDTree is not None:
175
+ _, idx = cKDTree(r).query(q)
176
+ return np.asarray(idx, dtype=np.int64)
177
+ return _brute_nearest(q, r)
178
+
179
+
180
+ def _brute_nearest(q: FloatArray, r: FloatArray) -> np.ndarray:
181
+ out = np.empty(len(q), dtype=np.int64)
182
+ step = max(1, int(4_000_000 // max(len(r), 1)))
183
+ for s in range(0, len(q), step):
184
+ blk = q[s : s + step]
185
+ d = (blk[:, None, 0] - r[None, :, 0]) ** 2 + (blk[:, None, 1] - r[None, :, 1]) ** 2
186
+ out[s : s + step] = np.argmin(d, axis=1)
187
+ return out
188
+
189
+
190
+ def knn_indices(xy: FloatArray, k: int) -> np.ndarray:
191
+ """``(n, k)`` indices of each point's ``k`` nearest points, itself first."""
192
+ p = np.asarray(xy, dtype=float).reshape(-1, 2)
193
+ k = min(k, len(p))
194
+ d = ((p[:, None, :] - p[None, :, :]) ** 2).sum(-1)
195
+ np.fill_diagonal(d, -1.0)
196
+ return np.argsort(d, axis=1, kind="stable")[:, :k]
197
+
198
+
199
+ def as_label_dict(values: Any, keys: Sequence[Hashable]) -> Dict[Hashable, float]:
200
+ if isinstance(values, Mapping):
201
+ return {k: float(values[k]) for k in keys}
202
+ return {k: float(v) for k, v in zip(keys, values)}
spatialsample/py.typed ADDED
File without changes
@@ -0,0 +1,170 @@
1
+ """Raster masks and classified rasters as sampling domains.
2
+
3
+ A :class:`RasterMask` wraps a 2D array (for example a land-cover map) together
4
+ with a north-up geotransform. It behaves like a polygon :class:`~spatialsample.Region`
5
+ (``area``, ``bounds``, ``contains``, ``random_points``) so every continuous design
6
+ works on it, and it can also expose its cells as a finite population of pixels
7
+ for pixel-based designs such as :func:`spatialsample.stratified_pixels`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Iterable, Optional, Sequence, Tuple, Union
13
+
14
+ import numpy as np
15
+
16
+ from ._types import FloatArray, SeedLike, as_rng
17
+
18
+ __all__ = ["RasterMask"]
19
+
20
+
21
+ class RasterMask:
22
+ """A north-up raster whose valid cells form the sampling domain.
23
+
24
+ Parameters
25
+ ----------
26
+ data:
27
+ 2D array of cell values (class codes or a 0/1 mask).
28
+ origin:
29
+ ``(x_min, y_max)`` of the upper-left corner of the raster.
30
+ cell_size:
31
+ Cell width, or ``(width, height)``; both positive.
32
+ nodata:
33
+ Value marking cells outside the domain. NaN cells are always excluded.
34
+ classes:
35
+ Optional subset of values that count as part of the domain. When the
36
+ array is boolean and ``classes`` is ``None``, only ``True`` cells count.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ data: Any,
42
+ origin: Tuple[float, float] = (0.0, 0.0),
43
+ cell_size: Union[float, Tuple[float, float]] = 1.0,
44
+ nodata: Optional[float] = None,
45
+ classes: Optional[Iterable[Any]] = None,
46
+ ) -> None:
47
+ arr = np.asarray(data)
48
+ if arr.ndim != 2:
49
+ raise ValueError("raster data must be 2D")
50
+ cs = np.atleast_1d(np.asarray(cell_size, dtype=float))
51
+ cw = float(cs[0])
52
+ ch = float(cs[-1])
53
+ if cw <= 0 or ch <= 0:
54
+ raise ValueError("cell_size must be positive")
55
+ self.data = arr
56
+ self.origin = (float(origin[0]), float(origin[1]))
57
+ self.cell_size = (cw, ch)
58
+ self.nodata = nodata
59
+ valid = np.ones(arr.shape, dtype=bool)
60
+ if arr.dtype == bool and classes is None:
61
+ valid &= arr
62
+ if np.issubdtype(arr.dtype, np.floating):
63
+ valid &= np.isfinite(arr)
64
+ if nodata is not None:
65
+ valid &= arr != nodata
66
+ self.classes: Optional[Tuple[Any, ...]] = None
67
+ if classes is not None:
68
+ self.classes = tuple(classes)
69
+ valid &= np.isin(arr, np.asarray(self.classes))
70
+ self.valid = valid
71
+ self._flat = np.flatnonzero(valid.ravel())
72
+ if len(self._flat) == 0:
73
+ raise ValueError("raster mask has no valid cells")
74
+ rows, cols = np.divmod(self._flat, arr.shape[1])
75
+ x0 = self.origin[0] + cols.min() * cw
76
+ x1 = self.origin[0] + (cols.max() + 1) * cw
77
+ y1 = self.origin[1] - rows.min() * ch
78
+ y0 = self.origin[1] - (rows.max() + 1) * ch
79
+ self.bounds: Tuple[float, float, float, float] = (
80
+ float(x0),
81
+ float(y0),
82
+ float(x1),
83
+ float(y1),
84
+ )
85
+
86
+ @classmethod
87
+ def from_affine(cls, data: Any, transform: Sequence[float], **kwargs: Any) -> RasterMask:
88
+ """Build from a rasterio/GDAL-style affine ``(a, b, c, d, e, f)``.
89
+
90
+ Only north-up transforms (``b == d == 0``, ``e < 0``) are supported.
91
+ """
92
+ a, b, c, d, e, f = (float(v) for v in list(transform)[:6])
93
+ if b != 0 or d != 0 or e >= 0:
94
+ raise ValueError("only north-up rasters (no rotation, negative e) are supported")
95
+ return cls(data, origin=(c, f), cell_size=(a, -e), **kwargs)
96
+
97
+ # ------------------------------------------------------------------ properties
98
+ @property
99
+ def cell_area(self) -> float:
100
+ return self.cell_size[0] * self.cell_size[1]
101
+
102
+ @property
103
+ def n_cells(self) -> int:
104
+ return int(len(self._flat))
105
+
106
+ @property
107
+ def area(self) -> float:
108
+ return self.n_cells * self.cell_area
109
+
110
+ def select(self, classes: Iterable[Any]) -> RasterMask:
111
+ """A new mask restricted to the given class values."""
112
+ return RasterMask(
113
+ self.data, self.origin, self.cell_size, nodata=self.nodata, classes=classes
114
+ )
115
+
116
+ def class_areas(self) -> Dict[Any, float]:
117
+ """Area of every distinct value among the valid cells."""
118
+ vals, counts = np.unique(self.data.ravel()[self._flat], return_counts=True)
119
+ return {_py(v): float(c) * self.cell_area for v, c in zip(vals, counts)}
120
+
121
+ def class_counts(self) -> Dict[Any, int]:
122
+ vals, counts = np.unique(self.data.ravel()[self._flat], return_counts=True)
123
+ return {_py(v): int(c) for v, c in zip(vals, counts)}
124
+
125
+ # ------------------------------------------------------------------ geometry
126
+ def rowcol(self, xy: Any) -> Tuple[np.ndarray, np.ndarray]:
127
+ pts = np.atleast_2d(np.asarray(xy, dtype=float))
128
+ col = np.floor((pts[:, 0] - self.origin[0]) / self.cell_size[0]).astype(np.int64)
129
+ row = np.floor((self.origin[1] - pts[:, 1]) / self.cell_size[1]).astype(np.int64)
130
+ return row, col
131
+
132
+ def contains(self, xy: Any) -> np.ndarray:
133
+ row, col = self.rowcol(xy)
134
+ h, w = self.data.shape
135
+ ok = (row >= 0) & (row < h) & (col >= 0) & (col < w)
136
+ out = np.zeros(len(row), dtype=bool)
137
+ out[ok] = self.valid[row[ok], col[ok]]
138
+ return out
139
+
140
+ def values_at(self, xy: Any) -> np.ndarray:
141
+ """Cell values at points (points must fall inside the raster extent)."""
142
+ row, col = self.rowcol(xy)
143
+ return self.data[row, col]
144
+
145
+ def cell_centers(self) -> Tuple[FloatArray, np.ndarray, np.ndarray]:
146
+ """``(xy, row, col)`` of every valid cell centre."""
147
+ h, w = self.data.shape
148
+ row, col = np.divmod(self._flat, w)
149
+ x = self.origin[0] + (col + 0.5) * self.cell_size[0]
150
+ y = self.origin[1] - (row + 0.5) * self.cell_size[1]
151
+ return np.column_stack([x, y]), row, col
152
+
153
+ def random_points(self, n: int, seed: SeedLike = None) -> FloatArray:
154
+ """``n`` independent points uniform over the valid cells."""
155
+ rng = as_rng(seed)
156
+ if n < 0:
157
+ raise ValueError("n must be non-negative")
158
+ pick = self._flat[rng.integers(0, len(self._flat), n)]
159
+ w = self.data.shape[1]
160
+ row, col = np.divmod(pick, w)
161
+ x = self.origin[0] + (col + rng.random(n)) * self.cell_size[0]
162
+ y = self.origin[1] - (row + rng.random(n)) * self.cell_size[1]
163
+ return np.column_stack([x, y])
164
+
165
+ def __repr__(self) -> str:
166
+ return f"RasterMask(shape={self.data.shape}, cells={self.n_cells}, area={self.area:.6g})"
167
+
168
+
169
+ def _py(v: Any) -> Any:
170
+ return v.item() if hasattr(v, "item") else v