plaknit 0.3.2__tar.gz → 0.3.3__tar.gz

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.
Files changed (42) hide show
  1. {plaknit-0.3.2/plaknit.egg-info → plaknit-0.3.3}/PKG-INFO +1 -1
  2. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/__init__.py +1 -1
  3. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/cli/ensemble_cli.py +13 -1
  4. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/data/sampling.py +51 -21
  5. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/models/brt.py +35 -4
  6. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/models/ensemble.py +191 -9
  7. {plaknit-0.3.2 → plaknit-0.3.3/plaknit.egg-info}/PKG-INFO +1 -1
  8. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit.egg-info/SOURCES.txt +1 -0
  9. {plaknit-0.3.2 → plaknit-0.3.3}/pyproject.toml +2 -2
  10. plaknit-0.3.3/tests/test_ensemble_prediction.py +47 -0
  11. {plaknit-0.3.2 → plaknit-0.3.3}/LICENSE +0 -0
  12. {plaknit-0.3.2 → plaknit-0.3.3}/MANIFEST.in +0 -0
  13. {plaknit-0.3.2 → plaknit-0.3.3}/README.md +0 -0
  14. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/acquisition/__init__.py +0 -0
  15. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/acquisition/geometry.py +0 -0
  16. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/acquisition/mosaic.py +0 -0
  17. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/acquisition/orders.py +0 -0
  18. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/acquisition/planner.py +0 -0
  19. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/classify.py +0 -0
  20. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/cli/__init__.py +0 -0
  21. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/cli/classify_cli.py +0 -0
  22. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/cli/main.py +0 -0
  23. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/data/__init__.py +0 -0
  24. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/data/raster.py +0 -0
  25. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/models/__init__.py +0 -0
  26. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/models/rf.py +0 -0
  27. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/processing/__init__.py +0 -0
  28. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit/processing/evaluation.py +0 -0
  29. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit.egg-info/dependency_links.txt +0 -0
  30. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit.egg-info/entry_points.txt +0 -0
  31. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit.egg-info/requires.txt +0 -0
  32. {plaknit-0.3.2 → plaknit-0.3.3}/plaknit.egg-info/top_level.txt +0 -0
  33. {plaknit-0.3.2 → plaknit-0.3.3}/requirements.txt +0 -0
  34. {plaknit-0.3.2 → plaknit-0.3.3}/setup.cfg +0 -0
  35. {plaknit-0.3.2 → plaknit-0.3.3}/tests/test_classify.py +0 -0
  36. {plaknit-0.3.2 → plaknit-0.3.3}/tests/test_ensemble_cli.py +0 -0
  37. {plaknit-0.3.2 → plaknit-0.3.3}/tests/test_geometry.py +0 -0
  38. {plaknit-0.3.2 → plaknit-0.3.3}/tests/test_mosaic.py +0 -0
  39. {plaknit-0.3.2 → plaknit-0.3.3}/tests/test_orders.py +0 -0
  40. {plaknit-0.3.2 → plaknit-0.3.3}/tests/test_plaknit.py +0 -0
  41. {plaknit-0.3.2 → plaknit-0.3.3}/tests/test_planner.py +0 -0
  42. {plaknit-0.3.2 → plaknit-0.3.3}/tests/test_shared_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plaknit
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Processing Large-Scale PlanetScope Data
5
5
  Author-email: Dryver Finch <dryver2206@gmail.com>
6
6
  License: MIT License
@@ -10,7 +10,7 @@ from .models.rf import predict_rf, smooth_probs, train_rf
10
10
 
11
11
  __author__ = """Dryver Finch"""
12
12
  __email__ = "dryver2206@gmail.com"
13
- __version__ = "0.3.2"
13
+ __version__ = "0.3.3"
14
14
 
15
15
  __all__ = [
16
16
  "BRTEnsemble",
@@ -263,7 +263,18 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
263
263
  "--jobs",
264
264
  type=int,
265
265
  default=1,
266
- help="Parallel workers for block prediction (default: 1). Use -1 for all cores.",
266
+ help=(
267
+ "Parallel workers for block prediction (default: 1). Use -1 for all "
268
+ "cores on CPU, or up to the number of visible GPUs with --gpu."
269
+ ),
270
+ )
271
+ predict_parser.add_argument(
272
+ "--gpu",
273
+ action="store_true",
274
+ help=(
275
+ "Use CUDA for XGBoost prediction when available. With --jobs > 1, "
276
+ "models are distributed across persistent GPU workers."
277
+ ),
267
278
  )
268
279
  predict_parser.add_argument(
269
280
  "--block-overlap",
@@ -338,6 +349,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
338
349
  feature_importance_out=args.feature_importance_out,
339
350
  block_overlap=args.block_overlap,
340
351
  jobs=args.jobs,
352
+ gpu=args.gpu,
341
353
  )
342
354
  return 0
343
355
 
@@ -189,33 +189,63 @@ def _collect_pseudo_absence_candidate_pool(
189
189
  *,
190
190
  buffer_meters: float,
191
191
  ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
192
- """Collect valid background features and coordinates eligible for sampling."""
192
+ """Collect valid background features and coordinates eligible for sampling.
193
+
194
+ Raster data is read in bounded windows so large VRTs do not require a full
195
+ raster-sized float32 allocation during candidate collection.
196
+ """
193
197
 
194
198
  if buffer_meters < 0:
195
199
  raise ValueError("training buffer must be non-negative.")
196
200
 
197
- full_window = windows.Window(0, 0, stack.width, stack.height)
198
- data = stack.read(window=full_window, out_dtype="float32")
199
- samples = data.reshape(stack.count, -1).T
200
- valid = ~_nodata_pixel_mask(samples, stack.nodata_values)
201
-
202
201
  geometries = _buffer_training_geometries(stack, gdf, buffer_meters)
203
- if geometries:
204
- excluded = features.rasterize(
205
- [(geom, 1) for geom in geometries],
206
- out_shape=(stack.height, stack.width),
207
- transform=stack.transform,
208
- fill=0,
209
- dtype="uint8",
210
- ).reshape(-1)
211
- valid &= excluded == 0
212
-
213
- candidate_indices = np.flatnonzero(valid)
214
- rows, cols = np.unravel_index(candidate_indices, (stack.height, stack.width))
202
+ feature_chunks: List[np.ndarray] = []
203
+ row_chunks: List[np.ndarray] = []
204
+ col_chunks: List[np.ndarray] = []
205
+ block_size = 512
206
+ for row_off in range(0, stack.height, block_size):
207
+ for col_off in range(0, stack.width, block_size):
208
+ win = windows.Window(
209
+ col_off=col_off,
210
+ row_off=row_off,
211
+ width=min(block_size, stack.width - col_off),
212
+ height=min(block_size, stack.height - row_off),
213
+ )
214
+ data = stack.read(window=win, out_dtype="float32")
215
+ samples = data.reshape(stack.count, -1).T
216
+ valid = ~_nodata_pixel_mask(samples, stack.nodata_values)
217
+
218
+ if geometries:
219
+ block_transform = windows.transform(win, stack.transform)
220
+ excluded = features.rasterize(
221
+ [(geom, 1) for geom in geometries],
222
+ out_shape=(int(win.height), int(win.width)),
223
+ transform=block_transform,
224
+ fill=0,
225
+ dtype="uint8",
226
+ ).reshape(-1)
227
+ valid &= excluded == 0
228
+
229
+ candidate_indices = np.flatnonzero(valid)
230
+ if candidate_indices.size == 0:
231
+ continue
232
+ rows, cols = np.unravel_index(
233
+ candidate_indices, (int(win.height), int(win.width))
234
+ )
235
+ feature_chunks.append(samples[candidate_indices])
236
+ row_chunks.append((rows + int(win.row_off)).astype("int32", copy=False))
237
+ col_chunks.append((cols + int(win.col_off)).astype("int32", copy=False))
238
+
239
+ if not feature_chunks:
240
+ return (
241
+ np.empty((0, stack.count), dtype="float32"),
242
+ np.empty(0, dtype="int32"),
243
+ np.empty(0, dtype="int32"),
244
+ )
215
245
  return (
216
- samples[candidate_indices],
217
- rows.astype("int32", copy=False),
218
- cols.astype("int32", copy=False),
246
+ np.vstack(feature_chunks),
247
+ np.concatenate(row_chunks),
248
+ np.concatenate(col_chunks),
219
249
  )
220
250
 
221
251
 
@@ -151,12 +151,38 @@ def _prepare_brt_training_data(
151
151
 
152
152
  def _detect_gpu_available() -> bool:
153
153
  """Detect if GPU is available for XGBoost."""
154
+ return bool(_available_cuda_devices())
155
+
156
+
157
+ def _available_cuda_devices() -> List[int]:
158
+ """Return logical CUDA device IDs visible to the current process."""
154
159
  try:
155
- import cupy as cp # noqa: F401
160
+ import cupy as cp
161
+
162
+ count = int(cp.cuda.runtime.getDeviceCount())
163
+ except (ImportError, RuntimeError, AttributeError):
164
+ return []
165
+ return list(range(max(0, count)))
166
+
156
167
 
157
- return True
158
- except ImportError:
159
- return False
168
+ def _configure_prediction_device(
169
+ model: xgb.XGBClassifier, gpu: bool, device_id: Optional[int] = None
170
+ ) -> None:
171
+ """Configure the loaded XGBoost model for CPU or CUDA inference."""
172
+ if not gpu:
173
+ return
174
+ if not _detect_gpu_available():
175
+ raise RuntimeError(
176
+ "GPU prediction requested, but CuPy/CUDA is unavailable. "
177
+ "Install the GPU dependencies or omit --gpu."
178
+ )
179
+ device = "cuda" if device_id is None else f"cuda:{device_id}"
180
+ try:
181
+ model.set_params(device=device, tree_method="hist", n_jobs=1)
182
+ except (AttributeError, TypeError, ValueError):
183
+ model.set_params(
184
+ predictor="gpu_predictor", tree_method="gpu_hist", n_jobs=1
185
+ )
160
186
 
161
187
 
162
188
  def train_brt(
@@ -658,6 +684,7 @@ def _init_predict_worker(
658
684
  out_dtype: str,
659
685
  nodata_value: Union[int, float],
660
686
  return_probs: bool,
687
+ gpu: bool,
661
688
  ) -> None:
662
689
  global _PREDICT_STACK
663
690
  global _PREDICT_MODEL
@@ -669,6 +696,7 @@ def _init_predict_worker(
669
696
  stack.__enter__()
670
697
  _PREDICT_STACK = stack
671
698
  _PREDICT_MODEL = joblib.load(model_path)
699
+ _configure_prediction_device(_PREDICT_MODEL, gpu)
672
700
  _PREDICT_OUT_DTYPE = out_dtype
673
701
  _PREDICT_NODATA = nodata_value
674
702
  _PREDICT_RETURN_PROBS = return_probs
@@ -706,6 +734,7 @@ def predict_brt(
706
734
  binary_out: Optional[PathLike] = None,
707
735
  block_overlap: int = 0,
708
736
  jobs: int = 1,
737
+ gpu: bool = False,
709
738
  ) -> Path:
710
739
  """Apply a trained BRT (XGBoost) model to a raster stack and write a classified GeoTIFF.
711
740
 
@@ -724,6 +753,7 @@ def predict_brt(
724
753
 
725
754
  _log("[bold cyan]Loading model...")
726
755
  model: xgb.XGBClassifier = joblib.load(model_path)
756
+ _configure_prediction_device(model, gpu)
727
757
  classes = getattr(model, "classes_", None)
728
758
  classes_dtype = getattr(classes, "dtype", np.int32)
729
759
  if np.issubdtype(classes_dtype, np.integer):
@@ -828,6 +858,7 @@ def predict_brt(
828
858
  out_dtype,
829
859
  nodata_value,
830
860
  return_probs,
861
+ gpu,
831
862
  ),
832
863
  ) as executor:
833
864
  futures: Dict[concurrent.futures.Future, windows.Window] = {}
@@ -7,7 +7,9 @@ import concurrent.futures
7
7
  import contextlib
8
8
  import csv
9
9
  import json
10
+ import multiprocessing
10
11
  import os
12
+ import queue
11
13
  import tempfile
12
14
  import warnings
13
15
  from pathlib import Path
@@ -35,6 +37,20 @@ from ..processing.evaluation import (
35
37
 
36
38
  PathLike = Union[str, Path]
37
39
  WindowTuple = Tuple[int, int, int, int]
40
+ _WorkerResult = Tuple[int, List[int], np.ndarray]
41
+
42
+
43
+ def _assign_models_to_workers(n_models: int, n_workers: int) -> List[List[int]]:
44
+ """Assign every model exactly once across workers in round-robin order."""
45
+ if n_models < 1:
46
+ raise ValueError("n_models must be at least 1.")
47
+ if n_workers < 1:
48
+ raise ValueError("n_workers must be at least 1.")
49
+ worker_count = min(n_models, n_workers)
50
+ return [
51
+ [model_idx for model_idx in range(n_models) if model_idx % worker_count == worker_id]
52
+ for worker_id in range(worker_count)
53
+ ]
38
54
 
39
55
 
40
56
  def _write_model_summary_csv(
@@ -131,6 +147,23 @@ def _write_feature_importance_csv(path: Path, models: Sequence[Any]) -> None:
131
147
  writer.writerow(row)
132
148
 
133
149
 
150
+ def _aggregate_ensemble_probabilities(
151
+ member_probs: np.ndarray,
152
+ ci_t_crit: Optional[float],
153
+ ) -> Tuple[np.ndarray, Optional[np.ndarray], Optional[np.ndarray]]:
154
+ """Aggregate complete ensemble probabilities for one output window."""
155
+ mean = member_probs.mean(axis=0)
156
+ if ci_t_crit is None or member_probs.shape[0] < 2:
157
+ return mean, None, None
158
+ std = member_probs.std(axis=0, ddof=1)
159
+ half_width = ci_t_crit * std / np.sqrt(member_probs.shape[0])
160
+ return (
161
+ mean,
162
+ np.clip(mean - half_width, 0.0, 1.0),
163
+ np.clip(mean + half_width, 0.0, 1.0),
164
+ )
165
+
166
+
134
167
  def _predict_ensemble_block(
135
168
  stack: _RasterStack,
136
169
  models: List[Any],
@@ -192,7 +225,9 @@ def _predict_ensemble_block(
192
225
  ],
193
226
  axis=0,
194
227
  )
195
- mean = probs_stack.mean(axis=0)
228
+ mean, lower, upper = _aggregate_ensemble_probabilities(
229
+ probs_stack, ci_t_crit
230
+ )
196
231
 
197
232
  def _scatter(values: np.ndarray) -> np.ndarray:
198
233
  full = np.full((samples.shape[0], num_classes), np.nan, dtype="float32")
@@ -203,12 +238,8 @@ def _predict_ensemble_block(
203
238
 
204
239
  mean_cube = _scatter(mean)
205
240
 
206
- lower_cube = upper_cube = None
207
- if n_models > 1 and ci_t_crit is not None:
208
- std = probs_stack.std(axis=0, ddof=1)
209
- half_width = ci_t_crit * std / np.sqrt(n_models)
210
- lower_cube = _scatter(np.clip(mean - half_width, 0.0, 1.0))
211
- upper_cube = _scatter(np.clip(mean + half_width, 0.0, 1.0))
241
+ lower_cube = _scatter(lower) if lower is not None else None
242
+ upper_cube = _scatter(upper) if upper is not None else None
212
243
 
213
244
  member_cube = np.full(
214
245
  (n_models, samples.shape[0], num_classes), np.nan, dtype="float32"
@@ -251,6 +282,7 @@ def _init_ensemble_predict_worker(
251
282
  model_paths: List[str],
252
283
  classes: List[Any],
253
284
  ci_t_crit: Optional[float],
285
+ gpu: bool,
254
286
  ) -> None:
255
287
  global _ENSEMBLE_PREDICT_STACK
256
288
  global _ENSEMBLE_PREDICT_MODELS
@@ -261,6 +293,8 @@ def _init_ensemble_predict_worker(
261
293
  stack.__enter__()
262
294
  _ENSEMBLE_PREDICT_STACK = stack
263
295
  _ENSEMBLE_PREDICT_MODELS = [joblib.load(path) for path in model_paths]
296
+ for model in _ENSEMBLE_PREDICT_MODELS:
297
+ brt._configure_prediction_device(model, gpu)
264
298
  _ENSEMBLE_PREDICT_CLASSES = np.asarray(classes)
265
299
  _ENSEMBLE_PREDICT_CI_T_CRIT = ci_t_crit
266
300
  atexit.register(_close_ensemble_predict_worker)
@@ -285,6 +319,43 @@ def _predict_ensemble_block_worker(
285
319
  )
286
320
 
287
321
 
322
+ def _ensemble_gpu_worker(
323
+ image_paths: List[str],
324
+ band_indices: Optional[Sequence[int]],
325
+ model_paths: List[str],
326
+ model_indices: List[int],
327
+ classes: List[Any],
328
+ device_id: int,
329
+ task_queue: Any,
330
+ result_queue: Any,
331
+ ) -> None:
332
+ """Run assigned ensemble models on one persistent CUDA worker."""
333
+ try:
334
+ with _open_raster_stack(image_paths, band_indices=band_indices) as stack:
335
+ models = [joblib.load(model_paths[idx]) for idx in model_indices]
336
+ for model in models:
337
+ brt._configure_prediction_device(model, True, device_id=device_id)
338
+
339
+ while True:
340
+ task = task_queue.get()
341
+ if task is None:
342
+ return
343
+ window_id, win_tuple, block_overlap = task
344
+ member_probs, _, _, _ = _predict_ensemble_block(
345
+ stack,
346
+ models,
347
+ brt._tuple_to_window(win_tuple),
348
+ classes=np.asarray(classes),
349
+ block_overlap=block_overlap,
350
+ ci_t_crit=None,
351
+ )
352
+ result_queue.put(
353
+ ("result", window_id, model_indices, member_probs)
354
+ )
355
+ except BaseException as exc:
356
+ result_queue.put(("error", repr(exc)))
357
+
358
+
288
359
  class BRTEnsemble:
289
360
  """Ensemble of independent BRT models with probability summaries.
290
361
 
@@ -508,6 +579,7 @@ class BRTEnsemble:
508
579
  feature_importance_out: Optional[PathLike] = None,
509
580
  block_overlap: int = 0,
510
581
  jobs: int = 1,
582
+ gpu: bool = False,
511
583
  ) -> Path:
512
584
  """Write unweighted ensemble probability summaries for a raster stack.
513
585
 
@@ -534,7 +606,11 @@ class BRTEnsemble:
534
606
  block_overlap
535
607
  Number of pixels to overlap blocks for smoothing edge artifacts.
536
608
  jobs
537
- Number of parallel worker processes.
609
+ Number of parallel worker processes. In GPU mode, this is capped by
610
+ the number of visible CUDA devices and ensemble models.
611
+ gpu
612
+ If True, configure each XGBoost model for CUDA prediction. Worker
613
+ processes run concurrently when ``jobs`` is greater than one.
538
614
 
539
615
  Returns
540
616
  -------
@@ -566,6 +642,14 @@ class BRTEnsemble:
566
642
  )
567
643
 
568
644
  models = [joblib.load(path) for path in model_paths]
645
+ cuda_devices = brt._available_cuda_devices() if gpu else []
646
+ if gpu and not cuda_devices:
647
+ raise RuntimeError(
648
+ "GPU prediction requested, but no visible CUDA devices were found."
649
+ )
650
+ if gpu and jobs <= 1:
651
+ for model in models:
652
+ brt._configure_prediction_device(model, True, device_id=0)
569
653
  self.models_ = models
570
654
 
571
655
  # Verify class consistency
@@ -676,7 +760,104 @@ class BRTEnsemble:
676
760
  if upper_dst is not None and upper_probs is not None:
677
761
  upper_dst.write(upper_probs, window=win)
678
762
 
679
- if jobs > 1:
763
+ if gpu and jobs > 1:
764
+ worker_count = min(jobs, len(models), len(cuda_devices))
765
+ assignments = _assign_models_to_workers(
766
+ len(models), worker_count
767
+ )
768
+ context = multiprocessing.get_context("spawn")
769
+ task_queues = [context.Queue(maxsize=2) for _ in assignments]
770
+ result_queue = context.Queue()
771
+ workers = [
772
+ context.Process(
773
+ target=_ensemble_gpu_worker,
774
+ args=(
775
+ [str(path) for path in stack.paths],
776
+ band_indices_to_use,
777
+ [str(path) for path in model_paths],
778
+ assignment,
779
+ classes_arr.tolist(),
780
+ cuda_devices[worker_id],
781
+ task_queue,
782
+ result_queue,
783
+ ),
784
+ )
785
+ for worker_id, (assignment, task_queue) in enumerate(
786
+ zip(assignments, task_queues)
787
+ )
788
+ ]
789
+ for worker in workers:
790
+ worker.start()
791
+ try:
792
+ for window_id, win in enumerate(window_iter):
793
+ task = (
794
+ window_id,
795
+ brt._window_to_tuple(win),
796
+ block_overlap,
797
+ )
798
+ for task_queue in task_queues:
799
+ task_queue.put(task)
800
+
801
+ partial_results: Dict[int, np.ndarray] = {}
802
+ for _ in workers:
803
+ try:
804
+ message = result_queue.get(timeout=300)
805
+ except queue.Empty as exc:
806
+ dead = [
807
+ worker.pid
808
+ for worker in workers
809
+ if not worker.is_alive()
810
+ ]
811
+ raise RuntimeError(
812
+ "GPU prediction worker stopped before "
813
+ f"completing window {window_id}; pids={dead}."
814
+ ) from exc
815
+ if message[0] == "error":
816
+ raise RuntimeError(
817
+ f"GPU prediction worker failed: {message[1]}"
818
+ )
819
+ _, returned_window_id, indices, member_probs = message
820
+ if returned_window_id != window_id:
821
+ raise RuntimeError(
822
+ "GPU prediction returned an unexpected window."
823
+ )
824
+ for local_idx, model_idx in enumerate(indices):
825
+ if model_idx in partial_results:
826
+ raise RuntimeError(
827
+ f"Duplicate GPU prediction for model {model_idx}."
828
+ )
829
+ partial_results[model_idx] = member_probs[local_idx]
830
+
831
+ if len(partial_results) != len(models):
832
+ raise RuntimeError(
833
+ "GPU prediction returned an incomplete ensemble "
834
+ f"for window {window_id}."
835
+ )
836
+ full_member_probs = np.stack(
837
+ [partial_results[idx] for idx in range(len(models))],
838
+ axis=0,
839
+ )
840
+ mean_probs, lower_probs, upper_probs = (
841
+ _aggregate_ensemble_probabilities(
842
+ full_member_probs, ci_t_crit
843
+ )
844
+ )
845
+ _write_block_result(
846
+ win,
847
+ full_member_probs,
848
+ mean_probs,
849
+ lower_probs,
850
+ upper_probs,
851
+ )
852
+ finally:
853
+ for task_queue in task_queues:
854
+ task_queue.put(None)
855
+ for worker in workers:
856
+ worker.join(timeout=10)
857
+ if worker.is_alive():
858
+ worker.terminate()
859
+ worker.join()
860
+ elif jobs > 1:
680
861
  max_workers = jobs
681
862
  max_pending = max_workers * 2
682
863
  image_paths = [str(path) for path in stack.paths]
@@ -690,6 +871,7 @@ class BRTEnsemble:
690
871
  model_path_strs,
691
872
  classes_arr.tolist(),
692
873
  ci_t_crit,
874
+ gpu,
693
875
  ),
694
876
  ) as executor:
695
877
  futures: Dict[concurrent.futures.Future, windows.Window] = (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plaknit
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Processing Large-Scale PlanetScope Data
5
5
  Author-email: Dryver Finch <dryver2206@gmail.com>
6
6
  License: MIT License
@@ -31,6 +31,7 @@ plaknit/processing/__init__.py
31
31
  plaknit/processing/evaluation.py
32
32
  tests/test_classify.py
33
33
  tests/test_ensemble_cli.py
34
+ tests/test_ensemble_prediction.py
34
35
  tests/test_geometry.py
35
36
  tests/test_mosaic.py
36
37
  tests/test_orders.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plaknit"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "Processing Large-Scale PlanetScope Data"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.8"
@@ -43,7 +43,7 @@ exclude = ["docs*"]
43
43
  dependencies = { file = ["requirements.txt"] }
44
44
 
45
45
  [tool.bumpversion]
46
- current_version = "0.3.2"
46
+ current_version = "0.3.3"
47
47
  commit = true
48
48
  tag = true
49
49
  tag_name = "v{new_version}"
@@ -0,0 +1,47 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from plaknit.models.ensemble import (
5
+ _aggregate_ensemble_probabilities,
6
+ _assign_models_to_workers,
7
+ )
8
+
9
+
10
+ def test_assign_models_to_workers_round_robin() -> None:
11
+ assert _assign_models_to_workers(5, 3) == [[0, 3], [1, 4], [2]]
12
+
13
+
14
+ def test_assign_models_to_workers_caps_workers_at_model_count() -> None:
15
+ assert _assign_models_to_workers(2, 8) == [[0], [1]]
16
+
17
+
18
+ def test_assign_models_to_workers_rejects_empty_inputs() -> None:
19
+ with pytest.raises(ValueError):
20
+ _assign_models_to_workers(0, 1)
21
+ with pytest.raises(ValueError):
22
+ _assign_models_to_workers(1, 0)
23
+
24
+
25
+ def test_aggregate_ensemble_probabilities_matches_sample_ci() -> None:
26
+ member_probs = np.asarray(
27
+ [
28
+ [[[0.1, 0.9]]],
29
+ [[[0.2, 0.8]]],
30
+ [[[0.3, 0.7]]],
31
+ ],
32
+ dtype="float32",
33
+ )
34
+ mean, lower, upper = _aggregate_ensemble_probabilities(member_probs, 2.0)
35
+ expected_mean = member_probs.mean(axis=0)
36
+ expected_half_width = 2.0 * member_probs.std(axis=0, ddof=1) / np.sqrt(3)
37
+ np.testing.assert_allclose(mean, expected_mean)
38
+ np.testing.assert_allclose(lower, np.clip(expected_mean - expected_half_width, 0, 1))
39
+ np.testing.assert_allclose(upper, np.clip(expected_mean + expected_half_width, 0, 1))
40
+
41
+
42
+ def test_aggregate_single_model_has_no_interval() -> None:
43
+ member_probs = np.asarray([[[[0.4]]]], dtype="float32")
44
+ mean, lower, upper = _aggregate_ensemble_probabilities(member_probs, None)
45
+ np.testing.assert_array_equal(mean, member_probs[0])
46
+ assert lower is None
47
+ assert upper is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes