scikit-ops 0.1.0.dev0__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.
Files changed (66) hide show
  1. scikit_ops-0.1.0.dev0.dist-info/METADATA +372 -0
  2. scikit_ops-0.1.0.dev0.dist-info/RECORD +66 -0
  3. scikit_ops-0.1.0.dev0.dist-info/WHEEL +4 -0
  4. scikit_ops-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  5. skop/__init__.py +14 -0
  6. skop/envs/cellpose3/pixi.toml +46 -0
  7. skop/envs/cupy/pixi.toml +35 -0
  8. skop/envs/minimal/pixi.toml +25 -0
  9. skop/envs/pytorch/pixi.toml +88 -0
  10. skop/envs/sdeconv/pixi.toml +85 -0
  11. skop/envs/segment-everything/pixi.toml +96 -0
  12. skop/envs/skimage/pixi.toml +28 -0
  13. skop/envs/stardist-tf/pixi.toml +91 -0
  14. skop/envs/unseg-cv/pixi.toml +27 -0
  15. skop/models.py +145 -0
  16. skop/ops/__init__.py +19 -0
  17. skop/ops/_util.py +115 -0
  18. skop/ops/deconvolve/__init__.py +18 -0
  19. skop/ops/deconvolve/_edges.py +92 -0
  20. skop/ops/deconvolve/_pad.py +117 -0
  21. skop/ops/deconvolve/richardson_lucy.py +100 -0
  22. skop/ops/deconvolve/richardson_lucy_cupy.py +116 -0
  23. skop/ops/detect/__init__.py +28 -0
  24. skop/ops/detect/_result.py +45 -0
  25. skop/ops/detect/fastsam.py +103 -0
  26. skop/ops/detect/jdll_yolo.py +115 -0
  27. skop/ops/detect/object_aware_yolo.py +112 -0
  28. skop/ops/detect/yolo.py +441 -0
  29. skop/ops/edges.py +382 -0
  30. skop/ops/generate.py +77 -0
  31. skop/ops/kernels/__init__.py +39 -0
  32. skop/ops/kernels/_fluorophore.py +42 -0
  33. skop/ops/kernels/_recenter.py +60 -0
  34. skop/ops/kernels/gibson_lanni.py +147 -0
  35. skop/ops/kernels/paraxial.py +128 -0
  36. skop/ops/kernels/psf.py +59 -0
  37. skop/ops/labels.py +144 -0
  38. skop/ops/mask/__init__.py +29 -0
  39. skop/ops/mask/_result.py +40 -0
  40. skop/ops/mask/microsam.py +114 -0
  41. skop/ops/mask/mobilesam.py +176 -0
  42. skop/ops/morphology.py +214 -0
  43. skop/ops/normalize.py +85 -0
  44. skop/ops/segment/__init__.py +24 -0
  45. skop/ops/segment/cellpose3.py +245 -0
  46. skop/ops/segment/cellpose4.py +127 -0
  47. skop/ops/segment/instanseg.py +162 -0
  48. skop/ops/segment/stardist2d.py +192 -0
  49. skop/ops/segment/starfun3d.py +104 -0
  50. skop/ops/segment/unseg/__init__.py +138 -0
  51. skop/ops/segment/unseg/_algorithm.py +1618 -0
  52. skop/ops/smooth.py +452 -0
  53. skop/ops/threshold.py +278 -0
  54. skop/ops/toy.py +114 -0
  55. skop/ops/train/__init__.py +81 -0
  56. skop/ops/train/_util.py +152 -0
  57. skop/ops/train/cellpose3.py +189 -0
  58. skop/ops/train/cellpose4.py +164 -0
  59. skop/ops/train/stardist2d.py +439 -0
  60. skop/ops/workflows/__init__.py +43 -0
  61. skop/ops/workflows/deconvolve/__init__.py +7 -0
  62. skop/ops/workflows/deconvolve/with_psf.py +103 -0
  63. skop/ops/workflows/mask/__init__.py +7 -0
  64. skop/ops/workflows/mask/detect_then_mask.py +97 -0
  65. skop/ops/workflows/segment/__init__.py +7 -0
  66. skop/ops/workflows/segment/connect_2d_in_3d.py +145 -0
@@ -0,0 +1,115 @@
1
+ """YOLO detection with any ultralytics weights, ported from JDLL.
2
+
3
+ JDLL (https://github.com/bioimage-io/JDLL) runs YOLO from Java by building
4
+ Python as strings in ``model/special/yolo/Yolo.java`` and sending it to an
5
+ Appose worker. This is that Python, as an op. The readable copy it came from
6
+ is docs/spec/jdll-yolo-infer.py.
7
+
8
+ Kept from JDLL: the device choice, the hand-written letterbox, and handing
9
+ ultralytics a tensor, which skips its own preprocessing. Dropped: the shared
10
+ memory, the axis-letter reordering, and the UUID-suffixed names, all of which
11
+ the runner already does or makes unnecessary.
12
+
13
+ Unlike ``fastsam`` and ``object_aware_yolo`` this is not class-agnostic: it
14
+ runs whatever model it is given, trained for whatever classes. The classes
15
+ are dropped for now, as everywhere in this package -- see _result.py.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import Annotated
22
+
23
+ import numpy as np
24
+
25
+ import ophelper.boxes
26
+ from opspec.op import Axes, op
27
+ from opspec.runtime import progress
28
+ from opspec.types import ImageOf
29
+
30
+ from .._util import to_rgb
31
+ from ._result import Boxes
32
+
33
+
34
+ @op(env="pytorch")
35
+ def jdll_yolo(
36
+ image: Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")],
37
+ weights: Path,
38
+ conf: Annotated[
39
+ float,
40
+ {"widget_type": "FloatSlider", "min": 0.0, "max": 1.0, "step": 0.05},
41
+ ] = 0.25,
42
+ iou: Annotated[
43
+ float,
44
+ {"widget_type": "FloatSlider", "min": 0.0, "max": 1.0, "step": 0.05},
45
+ ] = 0.7,
46
+ imgsz: int = 640,
47
+ ) -> Boxes:
48
+ """Find objects with a trained YOLO model.
49
+
50
+ Args:
51
+ image: Image to detect in. 2-D or RGB; anything else is stretched to
52
+ 8-bit RGB first.
53
+ weights: A YOLO ``.pt`` file, such as the ``best.pt`` training writes.
54
+ conf: Confidence threshold. Lower finds more, and more spuriously.
55
+ iou: NMS threshold. Two detections overlapping by more than this are
56
+ treated as one object.
57
+ imgsz: Size the longest side is resized to. JDLL fixes this at 640.
58
+
59
+ Returns:
60
+ boxes: (N, 4) as [min_y, min_x, max_y, max_x], in image coordinates.
61
+ """
62
+ import numpy as np
63
+ import torch
64
+ import torch.nn.functional as F
65
+ from ultralytics import YOLO
66
+
67
+ if torch.cuda.is_available():
68
+ device = "cuda"
69
+ elif torch.backends.mps.is_available():
70
+ device = "mps"
71
+ else:
72
+ device = "cpu"
73
+
74
+ # An empty file picker sends Path('.'), which YOLO accepts and only
75
+ # rejects deep inside predict.
76
+ if not Path(weights).is_file():
77
+ raise FileNotFoundError(
78
+ f"weights must be a YOLO .pt file, got {str(weights)!r}"
79
+ )
80
+
81
+ progress(f"Loading {Path(weights).name}")
82
+ model = YOLO(str(weights))
83
+
84
+ # (Y, X, 3) uint8 -> (1, 3, Y, X) float, still 0-255. Ultralytics sees
85
+ # values above 1 and divides by 255 itself, with a warning.
86
+ rgb = to_rgb(image)
87
+ x = torch.as_tensor(
88
+ np.ascontiguousarray(rgb.transpose(2, 0, 1)[None]), dtype=torch.float32
89
+ )
90
+ x = x.to(device)
91
+
92
+ # JDLL's letterbox: longest side to imgsz, then pad each side to a
93
+ # multiple of 32, centred, with ultralytics' grey.
94
+ _, _, height, width = x.shape
95
+ scale = imgsz / max(height, width)
96
+ new_h, new_w = round(height * scale), round(width * scale)
97
+ x = F.interpolate(x, size=(new_h, new_w), mode="bilinear", align_corners=False)
98
+ pad_h, pad_w = (-new_h) % 32, (-new_w) % 32
99
+ top, left = pad_h // 2, pad_w // 2
100
+ x = F.pad(x, (left, pad_w - left, top, pad_h - top), value=114.0)
101
+
102
+ progress("Detecting objects")
103
+ results = model(x.contiguous(), conf=conf, iou=iou, device=device, verbose=False)
104
+
105
+ if not results or results[0].boxes is None or len(results[0].boxes) == 0:
106
+ return Boxes(ophelper.boxes.EMPTY.copy())
107
+
108
+ found = results[0].boxes
109
+ progress(f"Found {len(found)} objects")
110
+
111
+ # Undo the letterbox: shift off the padding, scale back, clip to the image.
112
+ xyxy = found.xyxy.cpu().numpy()
113
+ xyxy[:, [0, 2]] = np.clip((xyxy[:, [0, 2]] - left) / scale, 0, width)
114
+ xyxy[:, [1, 3]] = np.clip((xyxy[:, [1, 3]] - top) / scale, 0, height)
115
+ return Boxes(ophelper.boxes.from_xyxy(xyxy))
@@ -0,0 +1,112 @@
1
+ """Class-agnostic object detection with MobileSAMv2's object-aware YOLO.
2
+
3
+ A thin wrapper over ``segment-everything``, which vendors MobileSAMv2 from
4
+ https://github.com/ChaoningZhang/MobileSAM. The model is a YOLOv8 fine-tuned
5
+ to detect objects without classifying them -- built to produce box prompts for
6
+ SAM, which is exactly what it is used for here.
7
+
8
+ Wrapped rather than ported. The vendored tree is 28,000 lines across
9
+ efficientvit, tinyvit and a trimmed ultralytics 8.0.120 fork, with local
10
+ modifications; reimplementing it would mean maintaining a fork of a fork, and
11
+ an environment costs one pixi.toml. See docs/design/0007.
12
+
13
+ That vendored ultralytics is also why this op has an environment to itself:
14
+ it goes on sys.path at load time, so a maintained ultralytics installed
15
+ beside it would shadow or be shadowed by it. The FastSAM op lives in the
16
+ shared 'pytorch' environment for that reason.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Annotated
22
+
23
+ import numpy as np
24
+
25
+ import ophelper.boxes
26
+ from ophelper.assets import file_from_url
27
+ from opspec.op import Axes, op
28
+ from opspec.runtime import progress
29
+ from opspec.types import ImageOf
30
+
31
+ from .._util import to_rgb
32
+ from ._result import Boxes
33
+
34
+ # The same weights segment_everything.weights_helper would fetch, from the
35
+ # HuggingFace mirror already listed in its WEIGHTS_URLS rather than from its
36
+ # Google Drive entry. Fetched here rather than through that module because
37
+ # weights_helper imports toolz and napari.utils.progress, neither of which
38
+ # segment-everything declares as a dependency -- so importing it means
39
+ # installing napari into a worker process to download a file.
40
+ _WEIGHTS = "https://huggingface.co/RogerQi/MobileSAMV2/resolve/main/ObjectAwareModel.pt"
41
+
42
+
43
+ @op(env="segment-everything")
44
+ def object_aware_yolo(
45
+ image: Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")],
46
+ conf: Annotated[
47
+ float,
48
+ {"widget_type": "FloatSlider", "min": 0.0, "max": 1.0, "step": 0.05},
49
+ ] = 0.4,
50
+ iou: Annotated[
51
+ float,
52
+ {"widget_type": "FloatSlider", "min": 0.0, "max": 1.0, "step": 0.05},
53
+ ] = 0.9,
54
+ max_det: int = 400,
55
+ imgsz: int = 1024,
56
+ ) -> Boxes:
57
+ """Find every object in an image, without regard to what it is.
58
+
59
+ Args:
60
+ image: Image to detect in. 2-D or RGB; anything else is stretched to
61
+ 8-bit RGB first, which is what the model was trained on.
62
+ conf: Confidence threshold. Lower finds more, and more spuriously.
63
+ iou: NMS threshold. Two detections overlapping by more than this are
64
+ treated as one object, so dense or touching objects need it high.
65
+ max_det: Cap on how many objects to report.
66
+ imgsz: Size the image is resized to for inference.
67
+
68
+ Returns:
69
+ boxes: (N, 4) as [min_y, min_x, max_y, max_x], in image coordinates.
70
+
71
+ Note: the weights are 140 MB, downloaded into skop's asset cache on first
72
+ use.
73
+ """
74
+ import torch
75
+ from segment_everything.object_detectors.yolo_detector import YoloDetector
76
+
77
+ weights = file_from_url(_WEIGHTS, "object_aware_yolo/ObjectAwareModel.pt")
78
+
79
+ if torch.cuda.is_available():
80
+ device = "cuda"
81
+ elif torch.backends.mps.is_available():
82
+ device = "mps"
83
+ else:
84
+ device = "cpu"
85
+
86
+ progress("Loading the object-aware model")
87
+ detector = YoloDetector(
88
+ str(weights),
89
+ "ObjectAwareModelFromMobileSamV2",
90
+ device=device,
91
+ )
92
+
93
+ progress("Detecting objects")
94
+ # get_bounding_boxes() would do the same thing, but get_results keeps the
95
+ # per-box confidences reachable for when they have somewhere to go.
96
+ results = detector.get_results(
97
+ to_rgb(image),
98
+ imgsz=imgsz,
99
+ conf=conf,
100
+ iou=iou,
101
+ max_det=max_det,
102
+ )
103
+
104
+ # Its postprocess returns an empty list, not an empty result, when the
105
+ # model finds nothing -- and prints "No object detected." on the way.
106
+ if not results or results[0].boxes is None:
107
+ return Boxes(ophelper.boxes.EMPTY.copy())
108
+
109
+ found = results[0].boxes
110
+ progress(f"Found {len(found)} objects")
111
+ # found.conf holds a confidence per box. Not returned -- see _result.py.
112
+ return Boxes(ophelper.boxes.from_xyxy(found.xyxy.cpu().numpy()))
@@ -0,0 +1,441 @@
1
+ """YOLO on overlapping square tiles, with a measured GPU batch size."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import numpy as np
9
+
10
+ from opspec.op import Axes, op
11
+ from opspec.runtime import cancel_requested, progress
12
+ from opspec.types import ImageOf
13
+
14
+ from .._util import to_rgb
15
+ from ._result import Detections
16
+
17
+
18
+ @op(env="pytorch")
19
+ def yolo(
20
+ image: Annotated[ImageOf[np.ndarray], Axes("y", "x", "c?")],
21
+ weights: Path,
22
+ object_size: float | None = None,
23
+ overlap: Annotated[float, {"min": 0.0, "max": 0.95}] = 0.15,
24
+ gpu_fraction: Annotated[float, {"min": 0.01, "max": 1.0}] = 0.9,
25
+ conf: Annotated[float, {"min": 0.0, "max": 1.0}] = 0.25,
26
+ iou: Annotated[float, {"min": 0.0, "max": 1.0}] = 0.5,
27
+ max_det: int = 300,
28
+ merge_threshold: Annotated[float, {"min": 0.0, "max": 1.0}] = 0.5,
29
+ ) -> Detections:
30
+ """Detect objects in a large image with a YOLO .pt checkpoint.
31
+
32
+ Args:
33
+ image: A 2-D image or trailing RGB(A) channels. Only the current
34
+ tiles are converted to RGB and resized; the image stays on CPU.
35
+ weights: Local YOLO .pt file. Its saved training size sets the
36
+ inference size; it is rounded up to the model's stride.
37
+ object_size: Typical object area in pixels squared. None runs the
38
+ whole image once. An area outside 0.1%-50% of the image selects
39
+ square tiles of side ceil(sqrt(1.5 * object_size / 0.001)).
40
+ overlap: Fraction of a tile shared with its neighbour. The final
41
+ tile along each axis is shifted back to meet the image edge.
42
+ gpu_fraction: Fraction of free memory after loading the model to
43
+ budget for batches after warm-up. Actual batch peaks adjust the
44
+ batch size up or down; out-of-memory batches are halved. MPS
45
+ samples driver memory against its recommended working set.
46
+ conf: Minimum detection confidence.
47
+ iou: Per-tile NMS IoU threshold.
48
+ max_det: Maximum detections per tile; there is no whole-image cap.
49
+ merge_threshold: IoS threshold for class-aware GreedyNMM, matching
50
+ SAHI 0.12.8. Matching boxes, including within one tile, become an
51
+ enclosing box with the maximum confidence. Equal overlap matches.
52
+
53
+ Returns:
54
+ boxes: (N, 4) as [min_y, min_x, max_y, max_x], in image coordinates.
55
+ confidences: One confidence per box. Outputs follow SAHI's class order,
56
+ then the original keeper order within each class.
57
+ classes: One integer class ID per box, in the same order.
58
+
59
+ Square tiles only need resizing. If an image axis is shorter than the
60
+ tile, its unused region is padded at inference resolution, preserving
61
+ aspect ratio without allocating a large padded image. The input itself
62
+ must fit in host memory; this is not an out-of-core runner.
63
+ """
64
+ import contextvars
65
+ import gc
66
+ import math
67
+ import threading
68
+ from concurrent.futures import ThreadPoolExecutor
69
+
70
+ import cv2
71
+ import numpy as np
72
+ import psutil
73
+ import torch
74
+ from ultralytics import YOLO
75
+
76
+ # Ultralytics moved NMS out of ops during the 8.3 series.
77
+ try:
78
+ from ultralytics.utils.nms import non_max_suppression
79
+ except ImportError:
80
+ from ultralytics.utils.ops import non_max_suppression
81
+
82
+ if not Path(weights).is_file() or Path(weights).suffix.lower() != ".pt":
83
+ raise ValueError("weights must name an existing YOLO .pt file")
84
+ if not 0 <= overlap < 1 or not 0 < gpu_fraction <= 1:
85
+ raise ValueError("overlap must be in [0, 1), gpu_fraction in (0, 1]")
86
+ if object_size is not None and (not math.isfinite(object_size) or object_size <= 0):
87
+ raise ValueError("object_size must be a positive area or None")
88
+ if not 0 <= merge_threshold <= 1:
89
+ raise ValueError("merge_threshold must be in [0, 1]")
90
+ if image.ndim not in (2, 3) or (image.ndim == 3 and image.shape[-1] not in (3, 4)):
91
+ raise ValueError("expected a 2-D image or trailing RGB(A) channels")
92
+
93
+ height, width = image.shape[:2]
94
+ if not height or not width:
95
+ raise ValueError("image dimensions must be nonzero")
96
+ side = max(height, width)
97
+ if object_size is not None and not 0.001 <= object_size / (height * width) <= 0.5:
98
+ side = math.ceil(math.sqrt(1.5 * object_size / 0.001))
99
+ step = max(1, int(side * (1 - overlap)))
100
+ ys = list(range(0, max(0, height - side), step)) + [max(0, height - side)]
101
+ xs = list(range(0, max(0, width - side), step)) + [max(0, width - side)]
102
+ origins = [(y, x) for y in ys for x in xs]
103
+
104
+ device = "cpu"
105
+ if torch.cuda.is_available():
106
+ device = "cuda:0"
107
+ elif torch.backends.mps.is_available():
108
+ device = "mps"
109
+ gpu = getattr(torch, device.split(":")[0]) if device != "cpu" else None
110
+ progress(f"Loading {Path(weights).name} on {device}")
111
+ model = YOLO(str(weights))
112
+ if model.task != "detect":
113
+ raise ValueError("weights must be a YOLO object-detection checkpoint")
114
+ imgsz = model.model.args["imgsz"]
115
+ imgsz = max(imgsz) if isinstance(imgsz, (list, tuple)) else imgsz
116
+ stride = int(model.model.stride.max())
117
+ imgsz = math.ceil(imgsz / stride) * stride
118
+
119
+ def prepare(coords):
120
+ inputs = torch.empty(
121
+ (len(coords), 3, imgsz, imgsz),
122
+ dtype=torch.float32,
123
+ pin_memory=device.startswith("cuda"),
124
+ )
125
+ pixels = inputs.numpy()
126
+ pixels.fill(114 / 255)
127
+ scales = []
128
+ for slot, (y, x) in enumerate(coords):
129
+ if cancel_requested():
130
+ raise RuntimeError("YOLO cancelled")
131
+ crop = to_rgb(image[y : y + side, x : x + side])
132
+ h, w = crop.shape[:2]
133
+ rh, rw = max(1, round(h * imgsz / side)), max(1, round(w * imgsz / side))
134
+ np.multiply(
135
+ cv2.resize(crop, (rw, rh)).transpose(2, 0, 1),
136
+ 1 / 255,
137
+ out=pixels[slot, :, :rh, :rw],
138
+ )
139
+ scales.append((rw / w, rh / h))
140
+ return inputs, coords, scales
141
+
142
+ def infer(prepared, profile=False):
143
+ inputs, coords, scales = prepared
144
+ peak = [baseline]
145
+ stop = threading.Event()
146
+
147
+ def sample_memory():
148
+ while not stop.wait(0.001):
149
+ peak[0] = max(peak[0], gpu.driver_allocated_memory())
150
+
151
+ monitor = None
152
+ if profile:
153
+ if device.startswith("cuda"):
154
+ gpu.reset_peak_memory_stats()
155
+ else:
156
+ peak[0] = gpu.driver_allocated_memory()
157
+ monitor = threading.Thread(target=sample_memory)
158
+ monitor.start()
159
+ try:
160
+ with torch.inference_mode():
161
+ outputs = non_max_suppression(
162
+ model.model(
163
+ inputs.to(device, non_blocking=device.startswith("cuda"))
164
+ ),
165
+ conf_thres=conf,
166
+ iou_thres=iou,
167
+ max_det=max_det,
168
+ nc=len(model.names),
169
+ )
170
+ counts = [len(boxes) for boxes in outputs]
171
+ # Copy detections once; skip predict()'s image/Results conversion.
172
+ rows = torch.cat([boxes[:, :6] for boxes in outputs]).cpu().numpy()
173
+ if profile:
174
+ gpu.synchronize()
175
+ peak[0] = (
176
+ gpu.max_memory_reserved()
177
+ if device.startswith("cuda")
178
+ else max(peak[0], gpu.driver_allocated_memory())
179
+ )
180
+ finally:
181
+ stop.set()
182
+ if monitor is not None:
183
+ monitor.join()
184
+
185
+ found = []
186
+ for boxes, (y, x), (sx, sy) in zip(
187
+ np.split(rows, np.cumsum(counts)[:-1]),
188
+ coords,
189
+ scales,
190
+ ):
191
+ boxes = boxes.astype(np.float64)
192
+ boxes[:, [0, 2]] = np.clip(
193
+ boxes[:, [0, 2]] / sx + x, x, min(x + side, width)
194
+ )
195
+ boxes[:, [1, 3]] = np.clip(
196
+ boxes[:, [1, 3]] / sy + y, y, min(y + side, height)
197
+ )
198
+ found.append(
199
+ boxes[(boxes[:, 2] > boxes[:, 0]) & (boxes[:, 3] > boxes[:, 1])]
200
+ )
201
+ return found, max(peak[0] - baseline, inputs.numel() * inputs.element_size())
202
+
203
+ baseline = 0
204
+ try:
205
+ model.to(device)
206
+ model.model.fuse(verbose=False).eval().float()
207
+ progress(f"Warming model on the first of {len(origins)} tiles ({side} px)")
208
+ found, _ = infer(prepare(origins[:1]))
209
+ batch_size, best_size, batch_limit = 1, 1, len(origins)
210
+ if gpu is not None and len(origins) > 1:
211
+ # Keep setup costs resident, but return unused activation caches.
212
+ gpu.synchronize()
213
+ gpu.empty_cache()
214
+ if device.startswith("cuda"):
215
+ baseline = gpu.memory_reserved()
216
+ free, _ = gpu.mem_get_info()
217
+ else:
218
+ baseline = gpu.driver_allocated_memory()
219
+ free = min(
220
+ gpu.recommended_max_memory() - baseline,
221
+ psutil.virtual_memory().available,
222
+ )
223
+ budget = gpu_fraction * free
224
+ # Current and prefetched batches coexist in host memory.
225
+ batch_limit = min(
226
+ batch_limit,
227
+ max(
228
+ 1,
229
+ int(
230
+ 0.9
231
+ * psutil.virtual_memory().available
232
+ / (2 * 3 * imgsz * imgsz * 4)
233
+ ),
234
+ ),
235
+ )
236
+
237
+ done = 1
238
+ with ThreadPoolExecutor(max_workers=1) as loader:
239
+
240
+ def submit(start, count):
241
+ return loader.submit(
242
+ contextvars.copy_context().run,
243
+ prepare,
244
+ origins[start : start + count],
245
+ )
246
+
247
+ current = submit(done, batch_size) if done < len(origins) else None
248
+ while current is not None:
249
+ if cancel_requested():
250
+ current.cancel()
251
+ raise RuntimeError("YOLO cancelled")
252
+ prepared = current.result()
253
+ if len(prepared[1]) > batch_size:
254
+ prepared = tuple(part[:batch_size] for part in prepared)
255
+ count = len(prepared[1])
256
+ next_start = done + count
257
+ pending = (
258
+ submit(next_start, batch_size)
259
+ if next_start < len(origins)
260
+ else None
261
+ )
262
+ try:
263
+ batch, used = infer(prepared, profile=gpu is not None)
264
+ except RuntimeError as exc:
265
+ if (
266
+ gpu is None
267
+ or count == 1
268
+ or "out of memory" not in str(exc).lower()
269
+ ):
270
+ raise
271
+ if pending is not None:
272
+ pending.cancel()
273
+ batch_limit = min(batch_limit, count - 1)
274
+ best_size = min(best_size, batch_limit)
275
+ batch_size = max(1, count // 2)
276
+ progress(f"Retrying with batch size {batch_size}")
277
+ else:
278
+ found.extend(batch)
279
+ done += count
280
+ if gpu is not None:
281
+ # Feedback from real, warmed batches corrects the estimate.
282
+ if used <= budget:
283
+ best_size = max(best_size, count)
284
+ else:
285
+ batch_limit = min(batch_limit, max(1, count - 1))
286
+ best_size = min(best_size, batch_limit)
287
+ estimate = max(1, int(count * budget / used))
288
+ # Small-batch allocator plans extrapolate poorly.
289
+ if count < 32:
290
+ estimate = min(estimate, max(8, count * 2))
291
+ batch_size = min(batch_limit, estimate)
292
+ if estimate > batch_limit and best_size < batch_limit:
293
+ batch_size = (best_size + batch_limit + 1) // 2
294
+ if batch_size != count:
295
+ gpu.empty_cache()
296
+ progress(
297
+ f"Detected {done} of {len(origins)} tiles; "
298
+ f"batch {count}, peak {used / 2**20:.0f} MiB, next {batch_size}",
299
+ done,
300
+ len(origins),
301
+ )
302
+ else:
303
+ progress(
304
+ f"Detected {done} of {len(origins)} tiles",
305
+ done,
306
+ len(origins),
307
+ )
308
+ del prepared
309
+ current = pending
310
+ continue
311
+ # Drop failed-batch tracebacks before releasing the GPU cache.
312
+ del prepared
313
+ gpu.empty_cache()
314
+ current = submit(done, batch_size)
315
+
316
+ detections = _greedy_nmm(np.concatenate(found), merge_threshold)
317
+ progress(f"Found {len(detections)} objects", len(origins), len(origins))
318
+ return Detections(
319
+ detections[:, [1, 0, 3, 2]],
320
+ detections[:, 4].tolist(),
321
+ detections[:, 5].astype(int).tolist(),
322
+ )
323
+ finally:
324
+ model = None
325
+ gc.collect()
326
+ if gpu is not None:
327
+ gpu.empty_cache()
328
+
329
+
330
+ def _greedy_nmm(detections, threshold=0.5):
331
+ """SAHI 0.12.8 GreedyNMM, with a multiscale grid for nearby candidates."""
332
+ import math
333
+
334
+ import numpy as np
335
+
336
+ original = np.asarray(detections, dtype=np.float64)[:, :6]
337
+ matching = original.astype(np.float32)
338
+ areas = (matching[:, 2] - matching[:, 0]) * (matching[:, 3] - matching[:, 1])
339
+ merged = []
340
+ active = np.ones(len(original), dtype=bool)
341
+ rank = np.empty(len(original), dtype=np.intp)
342
+
343
+ for category in np.unique(matching[:, 5]):
344
+ indices = np.flatnonzero(matching[:, 5] == category)
345
+ rows = matching[indices]
346
+ order = indices[
347
+ np.lexsort((rows[:, 3], rows[:, 2], rows[:, 1], rows[:, 0], -rows[:, 4]))
348
+ ]
349
+ rank[order] = np.arange(len(order))
350
+ levels, positions = {}, {}
351
+
352
+ if threshold > 0:
353
+ for index in order:
354
+ x1, y1, x2, y2 = map(float, matching[index, :4])
355
+ level = math.ceil(math.log2(max(1, x2 - x1, y2 - y1)))
356
+ size = 2.0**level
357
+ cell = (
358
+ math.floor((x1 + x2) / (2 * size)),
359
+ math.floor((y1 + y2) / (2 * size)),
360
+ )
361
+ levels.setdefault(level, {}).setdefault(cell, set()).add(int(index))
362
+ positions[int(index)] = (level, cell)
363
+
364
+ def remove(index, positions=positions, levels=levels):
365
+ level, cell = positions[index]
366
+ members = levels[level][cell]
367
+ members.remove(index)
368
+ if not members:
369
+ del levels[level][cell]
370
+
371
+ def neighbours(box, levels=levels):
372
+ candidates = []
373
+ x1, y1, x2, y2 = map(float, box)
374
+ for level, cells in levels.items():
375
+ size = 2.0**level
376
+ left, top = math.floor(x1 / size - 0.5), math.floor(y1 / size - 0.5)
377
+ right, bottom = math.floor(x2 / size + 0.5), math.floor(y2 / size + 0.5)
378
+ if (right - left + 1) * (bottom - top + 1) < len(cells):
379
+ for y in range(top, bottom + 1):
380
+ for x in range(left, right + 1):
381
+ candidates.extend(cells.get((x, y), ()))
382
+ else:
383
+ for (x, y), members in cells.items():
384
+ if left <= x <= right and top <= y <= bottom:
385
+ candidates.extend(members)
386
+ return np.asarray(candidates, dtype=np.intp)
387
+
388
+ for index in order:
389
+ if cancel_requested():
390
+ raise RuntimeError("YOLO cancelled")
391
+ if not active[index]:
392
+ continue
393
+
394
+ if threshold <= 0:
395
+ candidates = order[1:]
396
+ else:
397
+ remove(int(index))
398
+ candidates = neighbours(matching[index, :4])
399
+ if candidates.size:
400
+ boxes = matching[candidates, :4]
401
+ intersection = np.maximum(
402
+ 0,
403
+ np.minimum(matching[index, 2:4], boxes[:, 2:4])
404
+ - np.maximum(matching[index, :2], boxes[:, :2]),
405
+ ).prod(axis=1)
406
+ denominator = np.minimum(areas[index], areas[candidates])
407
+ ios = np.divide(
408
+ intersection,
409
+ denominator,
410
+ out=np.zeros_like(intersection),
411
+ where=denominator > 0,
412
+ )
413
+ candidates = candidates[ios >= threshold]
414
+ candidates = candidates[np.argsort(rank[candidates])]
415
+ for candidate in candidates:
416
+ remove(int(candidate))
417
+
418
+ # Claim against original boxes before growing the keeper. GreedyNMM
419
+ # does not acquire new matches from the enlarged bounding box.
420
+ active[candidates] = False
421
+ keeper = original[index].copy()
422
+ for candidate in candidates:
423
+ if cancel_requested():
424
+ raise RuntimeError("YOLO cancelled")
425
+ box = original[candidate]
426
+ intersection = np.maximum(
427
+ 0,
428
+ np.minimum(keeper[2:4], box[2:4]) - np.maximum(keeper[:2], box[:2]),
429
+ ).prod()
430
+ denominator = min(
431
+ (keeper[2] - keeper[0]) * (keeper[3] - keeper[1]),
432
+ (box[2] - box[0]) * (box[3] - box[1]),
433
+ )
434
+ if denominator > 0 and intersection / denominator >= threshold:
435
+ keeper[:2] = np.minimum(keeper[:2], box[:2])
436
+ keeper[2:4] = np.maximum(keeper[2:4], box[2:4])
437
+ if box[4] >= keeper[4]:
438
+ keeper[4:6] = box[4:6]
439
+ merged.append(keeper)
440
+
441
+ return np.asarray(merged, dtype=np.float64).reshape(-1, 6)