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.
Files changed (58) hide show
  1. {easydetect-0.2.1 → easydetect-0.2.3}/PKG-INFO +1 -1
  2. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/__init__.py +1 -1
  3. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/cli.py +1 -1
  4. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/exporter.py +19 -1
  5. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/model.py +14 -5
  6. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/predictor.py +131 -3
  7. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/trainer.py +25 -2
  8. {easydetect-0.2.1 → easydetect-0.2.3}/pyproject.toml +1 -1
  9. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_onnxruntime.py +27 -0
  10. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_predictor.py +54 -0
  11. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_train_export.py +27 -0
  12. {easydetect-0.2.1 → easydetect-0.2.3}/.gitignore +0 -0
  13. {easydetect-0.2.1 → easydetect-0.2.3}/LICENSE +0 -0
  14. {easydetect-0.2.1 → easydetect-0.2.3}/NOTICE +0 -0
  15. {easydetect-0.2.1 → easydetect-0.2.3}/README.md +0 -0
  16. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/data/__init__.py +0 -0
  17. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/data/augment.py +0 -0
  18. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/data/dataset.py +0 -0
  19. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/data/labels.py +0 -0
  20. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/downloads.py +0 -0
  21. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/errors.py +0 -0
  22. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/metrics.py +0 -0
  23. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/__init__.py +0 -0
  24. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/decoder.py +0 -0
  25. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/denoising.py +0 -0
  26. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/dfine_net.py +0 -0
  27. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/encoder.py +0 -0
  28. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/hgnetv2.py +0 -0
  29. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/nn/ops.py +0 -0
  30. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/plotting.py +0 -0
  31. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/results.py +0 -0
  32. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/sources.py +0 -0
  33. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/tracker.py +0 -0
  34. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/utils/__init__.py +0 -0
  35. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/utils/loss.py +0 -0
  36. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/utils/ops.py +0 -0
  37. {easydetect-0.2.1 → easydetect-0.2.3}/easydetect/validator.py +0 -0
  38. {easydetect-0.2.1 → easydetect-0.2.3}/tests/__init__.py +0 -0
  39. {easydetect-0.2.1 → easydetect-0.2.3}/tests/conftest.py +0 -0
  40. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_augment.py +0 -0
  41. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_cli.py +0 -0
  42. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_coco2yolo.py +0 -0
  43. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_dataset.py +0 -0
  44. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_downloads.py +0 -0
  45. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_lab_api.py +0 -0
  46. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_model.py +0 -0
  47. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_nn.py +0 -0
  48. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_results.py +0 -0
  49. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_sources.py +0 -0
  50. {easydetect-0.2.1 → easydetect-0.2.3}/tests/test_tracker.py +0 -0
  51. {easydetect-0.2.1 → easydetect-0.2.3}/tools/bench.py +0 -0
  52. {easydetect-0.2.1 → easydetect-0.2.3}/tools/build_mirror.py +0 -0
  53. {easydetect-0.2.1 → easydetect-0.2.3}/tools/coco2yolo.py +0 -0
  54. {easydetect-0.2.1 → easydetect-0.2.3}/tools/compare_yolo.py +0 -0
  55. {easydetect-0.2.1 → easydetect-0.2.3}/tools/convert_dfine.py +0 -0
  56. {easydetect-0.2.1 → easydetect-0.2.3}/tools/hub_README.md +0 -0
  57. {easydetect-0.2.1 → easydetect-0.2.3}/tools/make_demo.py +0 -0
  58. {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.1
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.1"
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`` is the duplicate filter, as in :meth:`predict`. ``match_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 = 1e-4,
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. ``augment=True`` trains on zoomed-out, cropped
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 from ``labels.txt`` or ``<stem>.names.json`` beside the model."""
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, linear warmup into a cosine decay, AMP
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=1e-4,
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.1"
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