easydetect 0.2.1__tar.gz → 0.2.2__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.2}/PKG-INFO +1 -1
  2. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/__init__.py +1 -1
  3. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/exporter.py +19 -1
  4. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/model.py +4 -2
  5. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/predictor.py +75 -1
  6. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/trainer.py +25 -2
  7. {easydetect-0.2.1 → easydetect-0.2.2}/pyproject.toml +1 -1
  8. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_onnxruntime.py +27 -0
  9. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_train_export.py +27 -0
  10. {easydetect-0.2.1 → easydetect-0.2.2}/.gitignore +0 -0
  11. {easydetect-0.2.1 → easydetect-0.2.2}/LICENSE +0 -0
  12. {easydetect-0.2.1 → easydetect-0.2.2}/NOTICE +0 -0
  13. {easydetect-0.2.1 → easydetect-0.2.2}/README.md +0 -0
  14. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/cli.py +0 -0
  15. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/data/__init__.py +0 -0
  16. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/data/augment.py +0 -0
  17. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/data/dataset.py +0 -0
  18. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/data/labels.py +0 -0
  19. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/downloads.py +0 -0
  20. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/errors.py +0 -0
  21. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/metrics.py +0 -0
  22. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/nn/__init__.py +0 -0
  23. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/nn/decoder.py +0 -0
  24. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/nn/denoising.py +0 -0
  25. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/nn/dfine_net.py +0 -0
  26. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/nn/encoder.py +0 -0
  27. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/nn/hgnetv2.py +0 -0
  28. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/nn/ops.py +0 -0
  29. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/plotting.py +0 -0
  30. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/results.py +0 -0
  31. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/sources.py +0 -0
  32. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/tracker.py +0 -0
  33. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/utils/__init__.py +0 -0
  34. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/utils/loss.py +0 -0
  35. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/utils/ops.py +0 -0
  36. {easydetect-0.2.1 → easydetect-0.2.2}/easydetect/validator.py +0 -0
  37. {easydetect-0.2.1 → easydetect-0.2.2}/tests/__init__.py +0 -0
  38. {easydetect-0.2.1 → easydetect-0.2.2}/tests/conftest.py +0 -0
  39. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_augment.py +0 -0
  40. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_cli.py +0 -0
  41. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_coco2yolo.py +0 -0
  42. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_dataset.py +0 -0
  43. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_downloads.py +0 -0
  44. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_lab_api.py +0 -0
  45. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_model.py +0 -0
  46. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_nn.py +0 -0
  47. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_predictor.py +0 -0
  48. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_results.py +0 -0
  49. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_sources.py +0 -0
  50. {easydetect-0.2.1 → easydetect-0.2.2}/tests/test_tracker.py +0 -0
  51. {easydetect-0.2.1 → easydetect-0.2.2}/tools/bench.py +0 -0
  52. {easydetect-0.2.1 → easydetect-0.2.2}/tools/build_mirror.py +0 -0
  53. {easydetect-0.2.1 → easydetect-0.2.2}/tools/coco2yolo.py +0 -0
  54. {easydetect-0.2.1 → easydetect-0.2.2}/tools/compare_yolo.py +0 -0
  55. {easydetect-0.2.1 → easydetect-0.2.2}/tools/convert_dfine.py +0 -0
  56. {easydetect-0.2.1 → easydetect-0.2.2}/tools/hub_README.md +0 -0
  57. {easydetect-0.2.1 → easydetect-0.2.2}/tools/make_demo.py +0 -0
  58. {easydetect-0.2.1 → easydetect-0.2.2}/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.2
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.2"
28
28
 
29
29
  from .errors import DownloadError, EasyDetectError, ModelNotFoundError
30
30
  from .metrics import BoxMetrics, DetMetrics
@@ -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."""
@@ -349,7 +349,7 @@ class Detector:
349
349
  name: str = "train",
350
350
  resume: bool = False,
351
351
  patience: int = 50,
352
- lr0: float = 1e-4,
352
+ lr0: float | None = None,
353
353
  seed: int = 0,
354
354
  val: bool = True,
355
355
  amp: bool = True,
@@ -362,7 +362,9 @@ class Detector:
362
362
  """Train on a data.yaml dataset. Returns the path of ``best.pt``.
363
363
 
364
364
  ``freeze="backbone"`` trains about twice as fast on CPU and helps when
365
- the dataset is small. ``augment=True`` trains on zoomed-out, cropped
365
+ the dataset is small. ``lr0=None`` sets the learning rate from the
366
+ batch size, ``1e-4 × √(batch / 4)``, so a bigger batch is not a run
367
+ that learned less; a number sets it outright. ``augment=True`` trains on zoomed-out, cropped
366
368
  and colour-jittered pictures (D-FINE's recipe) and on plain ones for
367
369
  the last tenth of the epochs; ``False`` keeps only the flip.
368
370
  ``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()]
@@ -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.2"
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) == {}
@@ -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