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.
- scikit_ops-0.1.0.dev0.dist-info/METADATA +372 -0
- scikit_ops-0.1.0.dev0.dist-info/RECORD +66 -0
- scikit_ops-0.1.0.dev0.dist-info/WHEEL +4 -0
- scikit_ops-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- skop/__init__.py +14 -0
- skop/envs/cellpose3/pixi.toml +46 -0
- skop/envs/cupy/pixi.toml +35 -0
- skop/envs/minimal/pixi.toml +25 -0
- skop/envs/pytorch/pixi.toml +88 -0
- skop/envs/sdeconv/pixi.toml +85 -0
- skop/envs/segment-everything/pixi.toml +96 -0
- skop/envs/skimage/pixi.toml +28 -0
- skop/envs/stardist-tf/pixi.toml +91 -0
- skop/envs/unseg-cv/pixi.toml +27 -0
- skop/models.py +145 -0
- skop/ops/__init__.py +19 -0
- skop/ops/_util.py +115 -0
- skop/ops/deconvolve/__init__.py +18 -0
- skop/ops/deconvolve/_edges.py +92 -0
- skop/ops/deconvolve/_pad.py +117 -0
- skop/ops/deconvolve/richardson_lucy.py +100 -0
- skop/ops/deconvolve/richardson_lucy_cupy.py +116 -0
- skop/ops/detect/__init__.py +28 -0
- skop/ops/detect/_result.py +45 -0
- skop/ops/detect/fastsam.py +103 -0
- skop/ops/detect/jdll_yolo.py +115 -0
- skop/ops/detect/object_aware_yolo.py +112 -0
- skop/ops/detect/yolo.py +441 -0
- skop/ops/edges.py +382 -0
- skop/ops/generate.py +77 -0
- skop/ops/kernels/__init__.py +39 -0
- skop/ops/kernels/_fluorophore.py +42 -0
- skop/ops/kernels/_recenter.py +60 -0
- skop/ops/kernels/gibson_lanni.py +147 -0
- skop/ops/kernels/paraxial.py +128 -0
- skop/ops/kernels/psf.py +59 -0
- skop/ops/labels.py +144 -0
- skop/ops/mask/__init__.py +29 -0
- skop/ops/mask/_result.py +40 -0
- skop/ops/mask/microsam.py +114 -0
- skop/ops/mask/mobilesam.py +176 -0
- skop/ops/morphology.py +214 -0
- skop/ops/normalize.py +85 -0
- skop/ops/segment/__init__.py +24 -0
- skop/ops/segment/cellpose3.py +245 -0
- skop/ops/segment/cellpose4.py +127 -0
- skop/ops/segment/instanseg.py +162 -0
- skop/ops/segment/stardist2d.py +192 -0
- skop/ops/segment/starfun3d.py +104 -0
- skop/ops/segment/unseg/__init__.py +138 -0
- skop/ops/segment/unseg/_algorithm.py +1618 -0
- skop/ops/smooth.py +452 -0
- skop/ops/threshold.py +278 -0
- skop/ops/toy.py +114 -0
- skop/ops/train/__init__.py +81 -0
- skop/ops/train/_util.py +152 -0
- skop/ops/train/cellpose3.py +189 -0
- skop/ops/train/cellpose4.py +164 -0
- skop/ops/train/stardist2d.py +439 -0
- skop/ops/workflows/__init__.py +43 -0
- skop/ops/workflows/deconvolve/__init__.py +7 -0
- skop/ops/workflows/deconvolve/with_psf.py +103 -0
- skop/ops/workflows/mask/__init__.py +7 -0
- skop/ops/workflows/mask/detect_then_mask.py +97 -0
- skop/ops/workflows/segment/__init__.py +7 -0
- 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()))
|
skop/ops/detect/yolo.py
ADDED
|
@@ -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)
|