easydetect 0.2.1__tar.gz → 0.2.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.
- {easydetect-0.2.1 → easydetect-0.2.3}/PKG-INFO +1 -1
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/__init__.py +1 -1
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/cli.py +1 -1
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/exporter.py +19 -1
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/model.py +14 -5
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/predictor.py +131 -3
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/trainer.py +25 -2
- {easydetect-0.2.1 → easydetect-0.2.3}/pyproject.toml +1 -1
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_onnxruntime.py +27 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_predictor.py +54 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_train_export.py +27 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/.gitignore +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/LICENSE +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/NOTICE +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/README.md +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/data/__init__.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/data/augment.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/data/dataset.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/data/labels.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/downloads.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/errors.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/metrics.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/__init__.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/decoder.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/denoising.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/dfine_net.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/encoder.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/hgnetv2.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/ops.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/plotting.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/results.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/sources.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/tracker.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/utils/__init__.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/utils/loss.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/utils/ops.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/validator.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/__init__.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/conftest.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_augment.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_cli.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_coco2yolo.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_dataset.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_downloads.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_lab_api.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_model.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_nn.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_results.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_sources.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_tracker.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tools/bench.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tools/build_mirror.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tools/coco2yolo.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tools/compare_yolo.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tools/convert_dfine.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tools/hub_README.md +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tools/make_demo.py +0 -0
- {easydetect-0.2.1 → easydetect-0.2.3}/tools/make_toyset.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: easydetect
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Easy real-time object detection you can ship: D-FINE with PyTorch training, OpenVINO inference on CPU/GPU/NPU, and a browser app to label and train. Apache-2.0 throughout.
|
|
5
5
|
Project-URL: Homepage, https://github.com/themakerrobot/easydetect
|
|
6
6
|
Project-URL: Source, https://github.com/themakerrobot/easydetect
|
|
@@ -24,7 +24,7 @@ needs numpy/opencv/openvino/pyyaml; training adds
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
-
__version__ = "0.2.
|
|
27
|
+
__version__ = "0.2.3"
|
|
28
28
|
|
|
29
29
|
from .errors import DownloadError, EasyDetectError, ModelNotFoundError
|
|
30
30
|
from .metrics import BoxMetrics, DetMetrics
|
|
@@ -29,7 +29,7 @@ Modes:
|
|
|
29
29
|
Common keys:
|
|
30
30
|
model=dfine-n|s|m|l|x|best.pt|model.xml source=bus.jpg|dir|video.mp4|0|url
|
|
31
31
|
data=data.yaml epochs=100 imgsz=640 batch=8 conf=0.25 iou=0.7|none device=0|cpu|AUTO
|
|
32
|
-
project=runs name=predict save=true show=false format=openvino half=true
|
|
32
|
+
contain=0.8 project=runs name=predict save=true show=false format=openvino half=true
|
|
33
33
|
|
|
34
34
|
Examples:
|
|
35
35
|
easydetect predict model=dfine-s source=bus.jpg conf=0.5
|
|
@@ -62,12 +62,30 @@ def export_onnx(net, names, imgsz=640, out_dir=".", fname="easydetect", half=Fal
|
|
|
62
62
|
opset_version=17,
|
|
63
63
|
dynamo=False,
|
|
64
64
|
)
|
|
65
|
-
_write_labels(out_dir, fname, names)
|
|
65
|
+
table = _write_labels(out_dir, fname, names)
|
|
66
|
+
_embed_names(onnx_path, table)
|
|
66
67
|
if verbose:
|
|
67
68
|
print(f"[easydetect] exported: {onnx_path}")
|
|
68
69
|
return onnx_path
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
def _embed_names(onnx_path: Path, table: dict[int, str]) -> None:
|
|
73
|
+
"""Keep the class names inside the .onnx too, so the file works on its own —
|
|
74
|
+
one download from a Hugging Face page, no labels.txt to fetch beside it."""
|
|
75
|
+
import onnx
|
|
76
|
+
|
|
77
|
+
from .predictor import ONNX_NAMES_KEY
|
|
78
|
+
|
|
79
|
+
model = onnx.load(str(onnx_path))
|
|
80
|
+
kept = [p for p in model.metadata_props if p.key != ONNX_NAMES_KEY]
|
|
81
|
+
del model.metadata_props[:]
|
|
82
|
+
model.metadata_props.extend(kept)
|
|
83
|
+
entry = model.metadata_props.add()
|
|
84
|
+
entry.key = ONNX_NAMES_KEY
|
|
85
|
+
entry.value = json.dumps({str(k): v for k, v in table.items()}, ensure_ascii=False)
|
|
86
|
+
onnx.save(model, str(onnx_path))
|
|
87
|
+
|
|
88
|
+
|
|
71
89
|
def export_openvino(net, names, imgsz=640, out_dir=".", fname="easydetect", half=False,
|
|
72
90
|
verbose=True):
|
|
73
91
|
"""Write ``<out_dir>/<fname>.xml`` (+ .bin, + labels). Returns the .xml path."""
|
|
@@ -120,6 +120,7 @@ class Detector:
|
|
|
120
120
|
max_det: int = 300,
|
|
121
121
|
classes: list[int] | None = None,
|
|
122
122
|
iou: float | None = 0.7,
|
|
123
|
+
contain: float | None = None,
|
|
123
124
|
stream: bool = False,
|
|
124
125
|
save: bool = False,
|
|
125
126
|
show: bool = False,
|
|
@@ -136,6 +137,9 @@ class Detector:
|
|
|
136
137
|
only sane option for a long video or a live camera). ``iou`` drops a
|
|
137
138
|
box covering a higher-scoring one by more than that IoU — the same
|
|
138
139
|
object found twice, sometimes under two classes; ``None`` keeps all.
|
|
140
|
+
``contain=0.8`` also merges a box with 80% of its area inside another
|
|
141
|
+
of its class — the visible pieces of a half hidden object reported
|
|
142
|
+
beside the whole of it — into one box. Off by default.
|
|
139
143
|
"""
|
|
140
144
|
if source is None:
|
|
141
145
|
raise ValueError("predict() needs a source (image, folder, video, url, camera index)")
|
|
@@ -152,6 +156,7 @@ class Detector:
|
|
|
152
156
|
max_det=max_det,
|
|
153
157
|
classes=classes,
|
|
154
158
|
iou=iou,
|
|
159
|
+
contain=contain,
|
|
155
160
|
save=save,
|
|
156
161
|
show=show,
|
|
157
162
|
project=project,
|
|
@@ -168,6 +173,7 @@ class Detector:
|
|
|
168
173
|
source: Any = None,
|
|
169
174
|
conf: float = 0.25,
|
|
170
175
|
iou: float | None = 0.7,
|
|
176
|
+
contain: float | None = None,
|
|
171
177
|
match_iou: float = 0.3,
|
|
172
178
|
max_age: int = 30,
|
|
173
179
|
persist: bool = False,
|
|
@@ -176,7 +182,7 @@ class Detector:
|
|
|
176
182
|
) -> Any:
|
|
177
183
|
"""Predict, and keep an id on each box across frames (``boxes.id``).
|
|
178
184
|
|
|
179
|
-
``iou``
|
|
185
|
+
``iou`` and ``contain`` filter duplicates, as in :meth:`predict`. ``match_iou``
|
|
180
186
|
is how much a box must overlap a track in the previous frame to carry
|
|
181
187
|
its id on; ``max_age`` is how many frames a track waits for its object.
|
|
182
188
|
"""
|
|
@@ -185,7 +191,7 @@ class Detector:
|
|
|
185
191
|
if self.tracker is None or not persist:
|
|
186
192
|
self.tracker = IoUTracker(iou=match_iou, max_age=max_age)
|
|
187
193
|
kwargs.setdefault("name", "track")
|
|
188
|
-
gen = self._run(source, conf=conf, iou=iou, tracker=self.tracker,
|
|
194
|
+
gen = self._run(source, conf=conf, iou=iou, contain=contain, tracker=self.tracker,
|
|
189
195
|
**self._track_kwargs(kwargs))
|
|
190
196
|
return _Stream(gen, "track") if stream else list(gen)
|
|
191
197
|
|
|
@@ -209,6 +215,7 @@ class Detector:
|
|
|
209
215
|
max_det: int = 300,
|
|
210
216
|
classes: list[int] | None = None,
|
|
211
217
|
iou: float | None = 0.7,
|
|
218
|
+
contain: float | None = None,
|
|
212
219
|
save: bool = False,
|
|
213
220
|
show: bool = False,
|
|
214
221
|
project: str = "runs",
|
|
@@ -230,7 +237,7 @@ class Detector:
|
|
|
230
237
|
try:
|
|
231
238
|
for frame in loader:
|
|
232
239
|
det, speed = predictor(frame.img, conf=conf, max_det=max_det, classes=classes,
|
|
233
|
-
iou=iou)
|
|
240
|
+
iou=iou, contain=contain)
|
|
234
241
|
if tracker is not None:
|
|
235
242
|
det = tracker.update(det)
|
|
236
243
|
result = Results(
|
|
@@ -349,7 +356,7 @@ class Detector:
|
|
|
349
356
|
name: str = "train",
|
|
350
357
|
resume: bool = False,
|
|
351
358
|
patience: int = 50,
|
|
352
|
-
lr0: float =
|
|
359
|
+
lr0: float | None = None,
|
|
353
360
|
seed: int = 0,
|
|
354
361
|
val: bool = True,
|
|
355
362
|
amp: bool = True,
|
|
@@ -362,7 +369,9 @@ class Detector:
|
|
|
362
369
|
"""Train on a data.yaml dataset. Returns the path of ``best.pt``.
|
|
363
370
|
|
|
364
371
|
``freeze="backbone"`` trains about twice as fast on CPU and helps when
|
|
365
|
-
the dataset is small. ``
|
|
372
|
+
the dataset is small. ``lr0=None`` sets the learning rate from the
|
|
373
|
+
batch size, ``1e-4 × √(batch / 4)``, so a bigger batch is not a run
|
|
374
|
+
that learned less; a number sets it outright. ``augment=True`` trains on zoomed-out, cropped
|
|
366
375
|
and colour-jittered pictures (D-FINE's recipe) and on plain ones for
|
|
367
376
|
the last tenth of the epochs; ``False`` keeps only the flip.
|
|
368
377
|
``on_epoch_end`` is called with a dict of the epoch's numbers — the
|
|
@@ -37,13 +37,87 @@ def _looks_like_logits(scores: np.ndarray) -> bool:
|
|
|
37
37
|
return bool(scores.size) and (scores.min() < 0.0 or scores.max() > 1.0)
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
#: The ONNX metadata key easydetect keeps the class names under.
|
|
41
|
+
ONNX_NAMES_KEY = "easydetect.names"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _varint(f) -> int | None:
|
|
45
|
+
shift = value = 0
|
|
46
|
+
while True:
|
|
47
|
+
byte = f.read(1)
|
|
48
|
+
if not byte:
|
|
49
|
+
return None
|
|
50
|
+
value |= (byte[0] & 0x7F) << shift
|
|
51
|
+
if byte[0] < 0x80:
|
|
52
|
+
return value
|
|
53
|
+
shift += 7
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def onnx_metadata(path: Path) -> dict[str, str]:
|
|
57
|
+
"""The ``metadata_props`` of an ONNX file, read without the ``onnx`` package.
|
|
58
|
+
|
|
59
|
+
ModelProto keeps them as field 14, each a (key = 1, value = 2) pair. Only
|
|
60
|
+
the top level of the file is walked; the graph and its weights are skipped
|
|
61
|
+
over with a seek, so a large model costs no more than a small one.
|
|
62
|
+
"""
|
|
63
|
+
props: dict[str, str] = {}
|
|
64
|
+
with open(path, "rb") as f:
|
|
65
|
+
while (tag := _varint(f)) is not None:
|
|
66
|
+
field, wire = tag >> 3, tag & 7
|
|
67
|
+
if wire == 0:
|
|
68
|
+
_varint(f)
|
|
69
|
+
elif wire == 1:
|
|
70
|
+
f.seek(8, 1)
|
|
71
|
+
elif wire == 5:
|
|
72
|
+
f.seek(4, 1)
|
|
73
|
+
elif wire == 2:
|
|
74
|
+
size = _varint(f) or 0
|
|
75
|
+
if field != 14:
|
|
76
|
+
f.seek(size, 1)
|
|
77
|
+
continue
|
|
78
|
+
entry, key, value, i = f.read(size), "", "", 0
|
|
79
|
+
while i < len(entry):
|
|
80
|
+
sub_tag, n = entry[i], 0
|
|
81
|
+
i += 1
|
|
82
|
+
shift = 0
|
|
83
|
+
while True: # the length, a varint
|
|
84
|
+
n |= (entry[i] & 0x7F) << shift
|
|
85
|
+
i += 1
|
|
86
|
+
if entry[i - 1] < 0x80:
|
|
87
|
+
break
|
|
88
|
+
shift += 7
|
|
89
|
+
text = entry[i:i + n].decode("utf-8", "replace")
|
|
90
|
+
i += n
|
|
91
|
+
if sub_tag >> 3 == 1:
|
|
92
|
+
key = text
|
|
93
|
+
elif sub_tag >> 3 == 2:
|
|
94
|
+
value = text
|
|
95
|
+
props[key] = value
|
|
96
|
+
else: # not an ONNX file after all
|
|
97
|
+
break
|
|
98
|
+
return props
|
|
99
|
+
|
|
100
|
+
|
|
40
101
|
def read_names(model_path: Path) -> dict[int, str]:
|
|
41
|
-
"""Class names
|
|
102
|
+
"""Class names: ``<stem>.names.json`` beside the model, then the names an
|
|
103
|
+
``.onnx`` carries inside it, then ``labels.txt`` in its folder.
|
|
104
|
+
|
|
105
|
+
The names inside the file come before a ``labels.txt`` because they belong
|
|
106
|
+
to that file: a lone ``best.onnx`` downloaded into a folder that holds
|
|
107
|
+
another model's ``labels.txt`` still names its own classes.
|
|
108
|
+
"""
|
|
42
109
|
model_path = Path(model_path)
|
|
43
110
|
sidecar = model_path.with_suffix(".names.json")
|
|
44
111
|
if sidecar.exists():
|
|
45
112
|
table = json.loads(sidecar.read_text(encoding="utf-8"))
|
|
46
113
|
return {int(k): str(v) for k, v in table.items()}
|
|
114
|
+
if model_path.suffix.lower() == ".onnx" and model_path.is_file():
|
|
115
|
+
try:
|
|
116
|
+
inside = onnx_metadata(model_path).get(ONNX_NAMES_KEY)
|
|
117
|
+
if inside:
|
|
118
|
+
return {int(k): str(v) for k, v in json.loads(inside).items()}
|
|
119
|
+
except (OSError, ValueError, IndexError):
|
|
120
|
+
pass
|
|
47
121
|
labels = model_path.parent / "labels.txt"
|
|
48
122
|
if labels.exists():
|
|
49
123
|
lines = [ln.strip() for ln in labels.read_text(encoding="utf-8").splitlines()]
|
|
@@ -84,6 +158,52 @@ def drop_duplicates(xyxy: np.ndarray, iou: float = IOU) -> np.ndarray:
|
|
|
84
158
|
return np.nonzero(alive)[0]
|
|
85
159
|
|
|
86
160
|
|
|
161
|
+
#: How much less sure an enclosing box may be than the box inside it and still
|
|
162
|
+
#: count as the whole object rather than a loose box around several.
|
|
163
|
+
CONTAIN_MARGIN = 0.1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def drop_contained(xyxy: np.ndarray, scores: np.ndarray, cls: np.ndarray,
|
|
167
|
+
contain: float) -> np.ndarray:
|
|
168
|
+
"""Indices of the boxes to keep when a box lies inside another of its class.
|
|
169
|
+
|
|
170
|
+
IoU cannot see a part inside a whole: a chair half hidden behind a person
|
|
171
|
+
can come back as the whole chair (0.64) plus its two visible pieces (0.67,
|
|
172
|
+
0.61), and each piece overlaps the whole by an IoU of only its share of the
|
|
173
|
+
area. Measured as the overlap over the *smaller* box instead, a piece sits
|
|
174
|
+
near 1.0, so a pair of one class sharing at least ``contain`` of the
|
|
175
|
+
smaller box is one object, and one box goes:
|
|
176
|
+
|
|
177
|
+
* the inner one, when the enclosing box is about as sure (within
|
|
178
|
+
``CONTAIN_MARGIN``) — the whole object and a piece of it;
|
|
179
|
+
* the enclosing one, when it is much less sure than the box inside — a
|
|
180
|
+
loose box around a group, which must not erase the members.
|
|
181
|
+
|
|
182
|
+
NMS's "keep the best" would not do here: the best box is a piece, and once
|
|
183
|
+
it has removed the whole, the other piece no longer overlaps anything and
|
|
184
|
+
stays — two half chairs. Same class only: a cup on a table is not part of
|
|
185
|
+
the table. The cost is a real object inside another of its class, a child
|
|
186
|
+
held by an adult, so this is off unless asked for.
|
|
187
|
+
"""
|
|
188
|
+
x1, y1, x2, y2 = xyxy.T
|
|
189
|
+
area = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None)
|
|
190
|
+
gone = np.zeros(len(xyxy), bool)
|
|
191
|
+
for i in range(len(xyxy)):
|
|
192
|
+
for j in range(i + 1, len(xyxy)):
|
|
193
|
+
if cls[i] != cls[j]:
|
|
194
|
+
continue
|
|
195
|
+
iw = max(0.0, min(x2[i], x2[j]) - max(x1[i], x1[j]))
|
|
196
|
+
ih = max(0.0, min(y2[i], y2[j]) - max(y1[i], y1[j]))
|
|
197
|
+
if iw * ih < contain * max(min(area[i], area[j]), 1e-9):
|
|
198
|
+
continue
|
|
199
|
+
big, small = (i, j) if area[i] >= area[j] else (j, i)
|
|
200
|
+
if scores[big] >= scores[small] - CONTAIN_MARGIN:
|
|
201
|
+
gone[small] = True
|
|
202
|
+
else:
|
|
203
|
+
gone[big] = True
|
|
204
|
+
return np.nonzero(~gone)[0]
|
|
205
|
+
|
|
206
|
+
|
|
87
207
|
def _split_outputs(arrays: list[np.ndarray]) -> tuple[np.ndarray, np.ndarray]:
|
|
88
208
|
"""``(boxes, scores)`` for the one image in a batch, whatever order they come in."""
|
|
89
209
|
boxes = next((a for a in arrays if a.shape[-1] == 4), None)
|
|
@@ -161,12 +281,15 @@ class Predictor:
|
|
|
161
281
|
max_det: int = 300,
|
|
162
282
|
classes: list[int] | None = None,
|
|
163
283
|
iou: float | None = IOU,
|
|
284
|
+
contain: float | None = None,
|
|
164
285
|
) -> np.ndarray:
|
|
165
286
|
"""-> ``(N, 6)`` array of ``x1 y1 x2 y2 conf cls`` in pixels.
|
|
166
287
|
|
|
167
288
|
``iou`` drops a box that covers a higher-scoring one by more than that
|
|
168
289
|
IoU, whatever the two classes (``None`` keeps them all): see
|
|
169
|
-
:func:`drop_duplicates`.
|
|
290
|
+
:func:`drop_duplicates`. ``contain`` (off by default) also drops a box
|
|
291
|
+
lying mostly inside another of its class: see
|
|
292
|
+
:func:`drop_contained`.
|
|
170
293
|
"""
|
|
171
294
|
if _looks_like_logits(scores):
|
|
172
295
|
scores = 1.0 / (1.0 + np.exp(-scores))
|
|
@@ -189,6 +312,9 @@ class Predictor:
|
|
|
189
312
|
if iou is not None and len(xyxy) > 1:
|
|
190
313
|
kept = drop_duplicates(xyxy, iou)
|
|
191
314
|
xyxy, best, cls = xyxy[kept], best[kept], cls[kept]
|
|
315
|
+
if contain is not None and len(xyxy) > 1:
|
|
316
|
+
kept = drop_contained(xyxy, best, cls, contain)
|
|
317
|
+
xyxy, best, cls = xyxy[kept], best[kept], cls[kept]
|
|
192
318
|
return np.concatenate(
|
|
193
319
|
[xyxy, best.astype(np.float32)[:, None], cls.astype(np.float32)[:, None]], axis=1
|
|
194
320
|
)
|
|
@@ -200,6 +326,7 @@ class Predictor:
|
|
|
200
326
|
max_det: int = 300,
|
|
201
327
|
classes: list[int] | None = None,
|
|
202
328
|
iou: float | None = IOU,
|
|
329
|
+
contain: float | None = None,
|
|
203
330
|
) -> tuple[np.ndarray, dict[str, float]]:
|
|
204
331
|
"""Run one image; returns ``(detections (N,6), speed dict in ms)``."""
|
|
205
332
|
import time
|
|
@@ -209,7 +336,8 @@ class Predictor:
|
|
|
209
336
|
t1 = time.perf_counter()
|
|
210
337
|
boxes, scores = self.infer(tensor)
|
|
211
338
|
t2 = time.perf_counter()
|
|
212
|
-
det = self.postprocess(boxes, scores, img.shape[:2], conf, max_det, classes, iou
|
|
339
|
+
det = self.postprocess(boxes, scores, img.shape[:2], conf, max_det, classes, iou,
|
|
340
|
+
contain)
|
|
213
341
|
t3 = time.perf_counter()
|
|
214
342
|
speed = {
|
|
215
343
|
"preprocess": (t1 - t0) * 1e3,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Apache-2.0
|
|
2
2
|
"""Training loop (PyTorch). Kept deliberately small and readable.
|
|
3
3
|
|
|
4
|
-
AdamW with a lower LR on the backbone,
|
|
4
|
+
AdamW with a lower LR on the backbone, the LR set from the batch size unless
|
|
5
|
+
given, linear warmup into a cosine decay, AMP
|
|
5
6
|
on CUDA, ``last.pt``/``best.pt`` after every epoch, early stop on ``patience``,
|
|
6
7
|
and ``resume=True`` to pick a killed run back up where it stopped.
|
|
7
8
|
"""
|
|
@@ -31,6 +32,24 @@ def seed_everything(seed: int) -> None:
|
|
|
31
32
|
torch.cuda.manual_seed_all(seed)
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
#: The learning rate a batch of 4 fine-tunes well at: D-FINE from its COCO
|
|
36
|
+
#: weights reached 0.765 mAP50-95 on a dataset where RT-DETR's own run gave 0.762.
|
|
37
|
+
REF_BATCH, REF_LR = 4, 1e-4
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def auto_lr(batch: int) -> float:
|
|
41
|
+
"""The learning rate for a batch size: ``1e-4 × √(batch / 4)``.
|
|
42
|
+
|
|
43
|
+
A bigger batch means fewer optimizer steps an epoch, and Adam moves each
|
|
44
|
+
weight by about the learning rate per step whatever the batch, so at one
|
|
45
|
+
fixed rate a batch of 32 ends the same epochs having learned an eighth as
|
|
46
|
+
much. The square root is the usual rule for Adam (four times the batch,
|
|
47
|
+
twice the rate); at 32 it gives 2.8e-4, beside the 2–2.5e-4 D-FINE's own
|
|
48
|
+
configs train with at their batch of 32.
|
|
49
|
+
"""
|
|
50
|
+
return REF_LR * math.sqrt(max(int(batch), 1) / REF_BATCH)
|
|
51
|
+
|
|
52
|
+
|
|
34
53
|
#: The backbone learns slower than the rest — D-FINE's per-size ratios.
|
|
35
54
|
BACKBONE_LR_MULT = {"n": 0.5, "s": 0.5, "m": 0.1, "l": 0.05, "x": 0.01}
|
|
36
55
|
|
|
@@ -153,7 +172,7 @@ class Trainer:
|
|
|
153
172
|
epochs=100,
|
|
154
173
|
imgsz=640,
|
|
155
174
|
batch=8,
|
|
156
|
-
lr=
|
|
175
|
+
lr=None,
|
|
157
176
|
lr_backbone_mult=None,
|
|
158
177
|
weight_decay=1e-4,
|
|
159
178
|
warmup_epochs=1,
|
|
@@ -174,6 +193,8 @@ class Trainer:
|
|
|
174
193
|
seed_everything(seed)
|
|
175
194
|
self.augment = augment
|
|
176
195
|
self.seed, self.freeze, self.origin = seed, freeze, origin
|
|
196
|
+
self.lr_auto = lr is None
|
|
197
|
+
lr = auto_lr(batch) if lr is None else float(lr)
|
|
177
198
|
self.lr, self.lr_backbone_mult, self.weight_decay = lr, lr_backbone_mult, weight_decay
|
|
178
199
|
self.net = net
|
|
179
200
|
self.frozen = freeze_modules(net, freeze)
|
|
@@ -288,6 +309,8 @@ class Trainer:
|
|
|
288
309
|
"optimizer": {
|
|
289
310
|
"name": "AdamW",
|
|
290
311
|
"lr": self.lr,
|
|
312
|
+
"lr_from": (f"batch {self.batch}: {REF_LR:g} × √({self.batch}/{REF_BATCH})"
|
|
313
|
+
if self.lr_auto else "set"),
|
|
291
314
|
"lr_backbone": self.lr * self.lr_backbone_mult,
|
|
292
315
|
"weight_decay": self.weight_decay,
|
|
293
316
|
"schedule": "linear warmup, then cosine decay to 1%",
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "easydetect"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Easy real-time object detection you can ship: D-FINE with PyTorch training, OpenVINO inference on CPU/GPU/NPU, and a browser app to label and train. Apache-2.0 throughout."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -104,3 +104,30 @@ def test_a_named_model_downloads_the_onnx_for_onnxruntime(monkeypatch, tmp_path,
|
|
|
104
104
|
monkeypatch.setattr(downloads, "_asset", fake_asset)
|
|
105
105
|
assert downloads.download_onnx("D-FINE-S") == tmp_path / "dfine-s.onnx"
|
|
106
106
|
assert fetched == ["dfine-s.onnx", "labels.txt"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_an_onnx_carries_its_class_names_so_it_works_alone(tiny_ir, tmp_path):
|
|
110
|
+
"""One file from a Hugging Face page: no labels.txt beside it, or someone else's."""
|
|
111
|
+
import shutil
|
|
112
|
+
|
|
113
|
+
lone = tmp_path / "downloads" / "best.onnx"
|
|
114
|
+
lone.parent.mkdir()
|
|
115
|
+
shutil.copy(tiny_ir.with_suffix(".onnx"), lone)
|
|
116
|
+
(lone.parent / "labels.txt").write_text("person\ncar\nbus\n") # another model's
|
|
117
|
+
for backend in ("openvino", "onnxruntime"):
|
|
118
|
+
model = Detector(str(lone), backend=backend, verbose=False)
|
|
119
|
+
assert model(draw(), conf=0.0)[0].names == {0: "can", 1: "bottle"}, backend
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_the_names_are_read_without_the_onnx_package(tiny_ir, tmp_path):
|
|
123
|
+
onnx = pytest.importorskip("onnx")
|
|
124
|
+
from easydetect.predictor import ONNX_NAMES_KEY, onnx_metadata, read_names
|
|
125
|
+
|
|
126
|
+
path = tiny_ir.with_suffix(".onnx")
|
|
127
|
+
expected = {p.key: p.value for p in onnx.load(str(path)).metadata_props}
|
|
128
|
+
assert onnx_metadata(path) == expected and ONNX_NAMES_KEY in expected
|
|
129
|
+
|
|
130
|
+
# not an ONNX file at all: no names, and no crash
|
|
131
|
+
junk = tmp_path / "junk.onnx"
|
|
132
|
+
junk.write_bytes(b"\xff\xff\xff\xff\x0f not a model")
|
|
133
|
+
assert read_names(junk) == {}
|
|
@@ -147,3 +147,57 @@ def test_one_predictor_can_be_shared_between_threads(tiny_ir):
|
|
|
147
147
|
for thread in threads:
|
|
148
148
|
thread.join()
|
|
149
149
|
assert failures == []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_a_half_hidden_object_and_its_pieces_become_one_box():
|
|
153
|
+
from easydetect.predictor import drop_contained, drop_duplicates
|
|
154
|
+
|
|
155
|
+
# best first: a piece (0.67), the whole chair (0.64), another piece (0.61),
|
|
156
|
+
# and a second chair beside it that only touches the first
|
|
157
|
+
xyxy = np.array([[320, 520, 372, 640], # lower right piece
|
|
158
|
+
[285, 455, 373, 640], # the whole chair
|
|
159
|
+
[285, 470, 330, 640], # left piece
|
|
160
|
+
[380, 455, 470, 640]], # another chair
|
|
161
|
+
np.float32)
|
|
162
|
+
scores = np.array([0.67, 0.64, 0.61, 0.60])
|
|
163
|
+
cls = np.array([56, 56, 56, 56])
|
|
164
|
+
assert list(drop_duplicates(xyxy, 0.7)) == [0, 1, 2, 3] # IoU sees four chairs
|
|
165
|
+
# the whole chair stays, its pieces go; the chair beside it is left alone
|
|
166
|
+
assert list(drop_contained(xyxy, scores, cls, 0.8)) == [1, 3]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_a_loose_box_around_a_group_does_not_erase_its_members():
|
|
170
|
+
from easydetect.predictor import drop_contained
|
|
171
|
+
|
|
172
|
+
xyxy = np.array([[0, 0, 50, 100], [50, 0, 100, 100], [0, 0, 100, 100]], np.float32)
|
|
173
|
+
scores = np.array([0.9, 0.85, 0.3]) # two people, and a box around both
|
|
174
|
+
assert list(drop_contained(xyxy, scores, np.zeros(3), 0.8)) == [0, 1]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_containment_never_crosses_classes():
|
|
178
|
+
from easydetect.predictor import drop_contained
|
|
179
|
+
|
|
180
|
+
xyxy = np.array([[0, 0, 100, 100], [10, 10, 40, 40]], np.float32) # a cup on a table
|
|
181
|
+
scores = np.array([0.8, 0.7])
|
|
182
|
+
assert list(drop_contained(xyxy, scores, np.array([60, 41]), 0.8)) == [0, 1]
|
|
183
|
+
assert list(drop_contained(xyxy, scores, np.array([60, 60]), 0.8)) == [0]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_the_whole_path_merges_pieces_only_when_asked():
|
|
187
|
+
"""postprocess as predict() runs it: model outputs in, boxes out, at 640 x 720."""
|
|
188
|
+
from easydetect.predictor import Predictor
|
|
189
|
+
|
|
190
|
+
pieces = [(320, 520, 372, 640, 0.67), (285, 455, 373, 640, 0.64), (285, 470, 330, 640, 0.61),
|
|
191
|
+
(40, 100, 340, 640, 0.97)] # three chair boxes and a person
|
|
192
|
+
boxes = np.array([[(x1 + x2) / 2 / 640, (y1 + y2) / 2 / 720, (x2 - x1) / 640,
|
|
193
|
+
(y2 - y1) / 720] for x1, y1, x2, y2, _ in pieces], np.float32)
|
|
194
|
+
scores = np.zeros((4, 80), np.float32)
|
|
195
|
+
for i, (*_, s) in enumerate(pieces):
|
|
196
|
+
scores[i, 0 if i == 3 else 56] = s
|
|
197
|
+
post = Predictor.postprocess.__get__(object.__new__(Predictor))
|
|
198
|
+
default = post(boxes, scores, (720, 640), conf=0.5)
|
|
199
|
+
merged = post(boxes, scores, (720, 640), conf=0.5, contain=0.8)
|
|
200
|
+
assert len(default) == 4 # NMS at 0.7 keeps every piece
|
|
201
|
+
assert sorted(merged[:, 5].tolist()) == [0, 56] # one person, one chair
|
|
202
|
+
chair = merged[merged[:, 5] == 56][0]
|
|
203
|
+
assert chair[:4].round().tolist() == [285, 455, 373, 640] # the whole of it
|
|
@@ -217,6 +217,9 @@ def test_training_reports_every_epoch_to_a_callback_and_a_csv(dataset, tmp_path,
|
|
|
217
217
|
assert setup["variant"] == "n" and setup["start"] == {"kind": "scratch"}
|
|
218
218
|
assert setup["freeze"] == "backbone" and 0 < setup["trainable_params"] < setup["params"]
|
|
219
219
|
assert setup["optimizer"]["name"] == "AdamW" and setup["amp"] is False
|
|
220
|
+
# no lr0: the rate came from the batch of 2, and the record says so
|
|
221
|
+
assert setup["optimizer"]["lr"] == pytest.approx(1e-4 * (2 / 4) ** 0.5)
|
|
222
|
+
assert setup["optimizer"]["lr_from"].startswith("batch 2:")
|
|
220
223
|
train = setup["data"]["train"]
|
|
221
224
|
assert train["images"] > 0 and sum(train["per_class"]) == train["boxes"] > 0
|
|
222
225
|
assert setup["versions"]["torch"] and setup["device"] == "cpu" and setup["gpu"] is None
|
|
@@ -306,3 +309,27 @@ def test_pretrained_false_never_reaches_for_the_mirror(dataset, tmp_path, monkey
|
|
|
306
309
|
data=str(dataset), project=str(tmp_path), epochs=1, imgsz=64, batch=2, workers=0,
|
|
307
310
|
device="cpu", val=False, amp=False,
|
|
308
311
|
).exists()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_the_learning_rate_follows_the_batch_unless_it_is_given():
|
|
315
|
+
from easydetect.trainer import auto_lr
|
|
316
|
+
|
|
317
|
+
assert auto_lr(4) == pytest.approx(1e-4)
|
|
318
|
+
assert auto_lr(16) == pytest.approx(2e-4)
|
|
319
|
+
assert auto_lr(32) == pytest.approx(2.83e-4, rel=1e-3)
|
|
320
|
+
assert auto_lr(0) == auto_lr(1)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@needs_torch
|
|
324
|
+
def test_a_learning_rate_that_is_given_is_used_as_it_is(dataset, tmp_path, monkeypatch):
|
|
325
|
+
import json
|
|
326
|
+
|
|
327
|
+
monkeypatch.setattr("easydetect.nn.hgnetv2.HGNetv2.load_imagenet", lambda self, name, url: None)
|
|
328
|
+
model = Detector("dfine-n", verbose=False, pretrained=False)
|
|
329
|
+
best = model.train(data=str(dataset), project=str(tmp_path), epochs=1, imgsz=64, batch=2,
|
|
330
|
+
workers=0, device="cpu", val=False, amp=False, lr0=3e-4,
|
|
331
|
+
warmup_epochs=0, weight_decay=0.0, lr_backbone_mult=0.25)
|
|
332
|
+
opt = json.loads((best.parent.parent / "run.json").read_text())["optimizer"]
|
|
333
|
+
assert opt["lr"] == pytest.approx(3e-4) and opt["lr_from"] == "set"
|
|
334
|
+
assert opt["lr_backbone"] == pytest.approx(0.75e-4)
|
|
335
|
+
assert opt["warmup_epochs"] == 0 and opt["weight_decay"] == 0.0
|
|
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
|
|
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
|
|
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
|