attr-eomt 1.1.3__tar.gz → 1.2.0__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 (76) hide show
  1. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/PKG-INFO +178 -15
  2. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/README.md +177 -14
  3. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/__init__.py +1 -1
  4. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/api.py +13 -5
  5. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/aux_cls.py +56 -22
  6. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/config.py +60 -1
  7. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/data/coco.py +139 -24
  8. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/data/transforms.py +117 -39
  9. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/engine/track.py +2 -1
  10. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/engine/train.py +242 -106
  11. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/engine/validate.py +97 -3
  12. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/model.py +12 -3
  13. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/plotting.py +41 -25
  14. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/postprocess.py +28 -2
  15. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/preprocess.py +32 -0
  16. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/reid.py +3 -0
  17. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/serialization.py +2 -1
  18. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/tta.py +35 -17
  19. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/visualize.py +5 -3
  20. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/pyproject.toml +1 -1
  21. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/scripts/train.py +38 -2
  22. attr_eomt-1.2.0/scripts/val.py +88 -0
  23. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_augment.py +26 -0
  24. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_recipe.py +22 -0
  25. attr_eomt-1.2.0/tests/test_regression_attr.py +236 -0
  26. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_smoke.py +14 -0
  27. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_tta.py +87 -0
  28. attr_eomt-1.1.3/scripts/val.py +0 -41
  29. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/.github/workflows/pages.yml +0 -0
  30. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/.github/workflows/publish.yml +0 -0
  31. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/.github/workflows/release.yml +0 -0
  32. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/.github/workflows/tests.yml +0 -0
  33. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/.gitignore +0 -0
  34. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/LICENSE +0 -0
  35. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/configs/coco.yaml +0 -0
  36. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/docs/assets/00-hero-banner.png +0 -0
  37. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/docs/assets/00-hero-banner.svg +0 -0
  38. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/docs/examples/billie_eilish.jpg +0 -0
  39. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/docs/examples/rosalia.jpg +0 -0
  40. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/docs/examples/sample.jpg +0 -0
  41. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/docs/examples/taylor_swift.jpg +0 -0
  42. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/box_loss.py +0 -0
  43. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/compress.py +0 -0
  44. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/data/__init__.py +0 -0
  45. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/data/config.py +0 -0
  46. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/data/download.py +0 -0
  47. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/device.py +0 -0
  48. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/ema.py +0 -0
  49. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/engine/__init__.py +0 -0
  50. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/engine/distributed.py +0 -0
  51. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/engine/match.py +0 -0
  52. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/engine/predict.py +0 -0
  53. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/eomt/loss.py +0 -0
  54. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/sample_data/README.md +0 -0
  55. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/sample_data/annotations/instances_train.json +0 -0
  56. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/sample_data/annotations/instances_val.json +0 -0
  57. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/sample_data/data.yaml +0 -0
  58. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/sample_data/images/train/.gitkeep +0 -0
  59. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/sample_data/images/val/.gitkeep +0 -0
  60. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/scripts/info.py +0 -0
  61. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/scripts/match.py +0 -0
  62. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/scripts/predict.py +0 -0
  63. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/scripts/push_to_hub.py +0 -0
  64. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/scripts/track.py +0 -0
  65. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/scripts/train_detect_coco.sh +0 -0
  66. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/site/index.html +0 -0
  67. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_api.py +0 -0
  68. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_architecture.py +0 -0
  69. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_compress.py +0 -0
  70. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_ddp.py +0 -0
  71. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_parity.py +0 -0
  72. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_reid.py +0 -0
  73. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tests/test_sweep.py +0 -0
  74. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tools/attr_tagger/README.md +0 -0
  75. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/tools/attr_tagger/tag.py +0 -0
  76. {attr_eomt-1.1.3 → attr_eomt-1.2.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: attr-eomt
3
- Version: 1.1.3
3
+ Version: 1.2.0
4
4
  Summary: Standalone EoMT (Encoder-only Mask Transformer) for instance segmentation, with DINOv2 init, COCO training/validation and inference.
5
5
  Project-URL: Homepage, https://github.com/imagra93/attr-eomt
6
6
  Project-URL: Documentation, https://imagra93.github.io/attr-eomt
@@ -72,7 +72,7 @@ It's a clean-room, Apache-2.0 reimplementation: the weights you train are yours
72
72
  from eomt import EoMT
73
73
 
74
74
  model = EoMT("l") # fresh large model (DINOv2 backbone)
75
- model.train(data="coco", epochs=50) # COCO 2017 auto-downloads if missing
75
+ model.train(data="coco") # COCO 2017 auto-downloads if missing; 12 epochs, the official recipe
76
76
 
77
77
  model = EoMT("runs/train/eomt-l") # reload a run — size/classes/heads auto-detected
78
78
  model.predict("images/", plot=True) # render masks/boxes + per-instance attributes
@@ -206,14 +206,22 @@ simply reuse that same query→GT assignment and read the answer off the matched
206
206
  query→GT assignment the detection loss used; the attribute is read *after* matching.
207
207
  - **Gate.** An optional IoU gate drops barely-overlapping matched pairs (common early in
208
208
  training) so attributes only learn from queries that actually localize the object.
209
- - **Loss.** Cross-entropy per head over matched queries, summed across heads and scaled
209
+ - **Loss.** Cross-entropy per head over matched queries (a regression head: Huber on its
210
+ standardized value, see below), summed across heads and scaled
210
211
  by `aux_w` (default `1.0`), added to the detector loss. Empty-match batches contribute
211
212
  a graph-preserving zero, and missing labels use `ignore_index` and contribute nothing.
213
+ - **Balancing.** Both losses are O(1), so equal weights are a sane start. `aux_w_per_head`
214
+ weights each head, `aux_class_weights=True` re-weights rare classes (rare values of a
215
+ regression head, by smoothed density), and `aux_balance="uncertainty"` learns one weight
216
+ per head (Kendall et al. 2018) with the detector loss as the fixed anchor. Each epoch
217
+ prints every head's pull on the shared queries (its gradient norm over the detector's,
218
+ `train/aux_grad/<head>`): well above 1 a head steers the encoder, far below it only reads it.
212
219
  - **Checkpoint selection is unchanged.** The attribute "rides along": its per-head
213
220
  matched-query accuracy is shown live and written to `metrics.csv`, but never drives
214
221
  `best.pt` (still `segm/mAP` or `bbox/mAP`).
215
222
  - **Inference.** Each result attaches `aux = {head: {"ids", "probs"}}` for the kept
216
- detections, and `predict(plot=True)` renders each attribute next to the class label
223
+ detections (and `aux_values = {head: values}` in real units for the regression heads), and
224
+ `predict(plot=True)` renders each attribute next to the class label
217
225
  using names stored in the checkpoint.
218
226
 
219
227
  ---
@@ -282,8 +290,76 @@ label set, in one model:
282
290
  At inference a scoped head reports `ids = -1` ("not applicable") for detections whose class it
283
291
  does not cover. The scope is stored in the checkpoint, so it survives reload.
284
292
 
293
+ **Flip-aware attributes.** A horizontal flip mirrors the photo but not the labels, so an attribute
294
+ that encodes a side (a left door shows up as a right one) is corrupted by `flip_prob > 0`. Give
295
+ its definition `flip_aware`, a map of category names to the ones they become on a horizontal
296
+ flip, and the training augmentation swaps them for every instance it flips (each pair swaps both
297
+ ways, so `{"left": "right"}` is enough; unlisted categories stay as they are):
298
+
299
+ ```jsonc
300
+ {"name": "laterality", "categories": [{"id": 0, "name": "left"}, {"id": 1, "name": "center"}, {"id": 2, "name": "right"}],
301
+ "flip_aware": {"left": "right"}}
302
+ ```
303
+
304
+ Mosaic, mixup and copy-paste flip each source image independently, and the swap follows each
305
+ instance. Only the horizontal flip is handled: keep `vflip_prob` and `rot90_prob` off for such an
306
+ attribute. It is categorical only. The mapping is stored in the checkpoint, and test-time augmentation with
307
+ `flip` swaps it back in each mirrored view's prediction before the views are merged (a checkpoint saved before the
308
+ mapping was stored takes it from the val JSON, with a warning). The training banner lists each head's swapped pairs. A
309
+ regression head needs no mapping: its value must not change under a flip (see below).
310
+
311
+ **Regression attributes.** `"type": "regression"` makes a head regress one real number per
312
+ instance instead of choosing a category; categorical and regression heads mix freely in one
313
+ dataset. `log` (regress `log1p` of the value, for heavy-tailed positives such as costs) and
314
+ `range` (valid values; predictions are clipped to it) are optional, and `applies_to` works as above:
315
+
316
+ ```jsonc
317
+ "attributes": [
318
+ {"name": "severity", "categories": [...]},
319
+ {"name": "repair_cost", "type": "regression", "log": true, "range": [0, 20000], "applies_to": ["bumper"]}
320
+ ]
321
+ // per annotation:
322
+ "attributes": {"severity": 3, "repair_cost": 812.5}
323
+ ```
324
+
325
+ A missing, non-numeric or out-of-range value is ignored. The head is trained on the value
326
+ standardized by the train split's mean and std (stored in the checkpoint), validated as the
327
+ mean absolute error in real units (`val/aux_mae/<head>`; `metrics.png` gives each regression
328
+ head its own MAE panel, `aux_per_class.png` its MAE per primary class), and predicted in real units
329
+ (`aux_values`). The value must not change under the augmentations (scale, crop, flip,
330
+ rotation): a cost or a score is fine, a pixel area or an angle is not. Tracking votes on the
331
+ categorical heads only, and re-identification cannot gate on a regression head.
332
+
333
+ A value that depends on how much of the instance is in view can say so with `crop`: the
334
+ training augmentation measures, per instance, the fraction it keeps in view (mask mass, or box
335
+ area, inside the crop window, after copy-paste occlusion), and the value follows it.
336
+ `"crop": "scale"` multiplies the value by that fraction — for an extensive quantity such as the
337
+ object's area in cm² (half the damage cropped away ⇒ half the area). `"crop": "ignore"` drops the
338
+ value when less than `min_visible` (default `0.9`) is kept — for a property of the whole object
339
+ that a cut view no longer shows. Validation and inference see whole images, so the head predicts
340
+ the full value there:
341
+
342
+ ```jsonc
343
+ {"name": "damage_area", "type": "regression", "log": true, "crop": "scale"},
344
+ {"name": "part_length", "type": "regression", "crop": "ignore", "min_visible": 0.95}
345
+ ```
346
+
347
+ How the fraction is measured: for masks, the share of the instance's mask mass whose source pixels
348
+ the crop (and rotation / shear / perspective warp) lands inside the window: exact, so an instance
349
+ the window does not cut keeps exactly 1 (a warp alone is not a cut); copy-paste occlusion counts as
350
+ hidden, and instances brought in by mosaic, mixup or copy-paste are measured against their own crops. For boxes (detect family), the ratio of
351
+ the box areas: exact for a crop, approximate under rotation. Each call of the built-in
352
+ augmentation exposes it as `TrainAugment.last_info["visible"]`.
353
+
354
+ Limits: the head's mean / std and its `aux_class_weights` density come from the unscaled values,
355
+ so with `"scale"` the values trained on sit somewhat lower than those statistics. Only the visible
356
+ fraction is tracked, not the scale jitter, so a pixel area is still not supported. A custom
357
+ `transform=` gets no adjustment. `crop` is rejected on a categorical attribute, and it is a
358
+ training-only setting (not stored in the checkpoint). The training banner prints each head's crop
359
+ mode.
360
+
285
361
  **Sidecar format (optional).** You can keep the COCO JSON as plain, standard COCO and put the
286
- attributes beside it instead of inside it: an `attributes.yaml` schema in the dataset root plus
362
+ attributes beside it instead of inside it: an `attributes.yaml` schema (same keys, regression heads included) in the dataset root plus
287
363
  `attributes/<split>.json` values keyed by annotation id (`{ann_id: {head: value}}`). If present
288
364
  (and the JSON has no embedded `attributes`), it is merged in memory at load — so a plain COCO
289
365
  dataset always works and the sidecar is picked up automatically when you add it. Embedded
@@ -406,7 +482,7 @@ size, normalization and any auxiliary heads are auto-detected from the `.pt`):
406
482
  from eomt import EoMT
407
483
 
408
484
  # Train on COCO 2017 (auto-downloaded on first run):
409
- EoMT("l").train(data="coco", epochs=50, batch=4)
485
+ EoMT("l").train(data="coco", batch=4) # 12 epochs, validated every 2: the official recipe
410
486
 
411
487
  # ...or any COCO-format dataset (point at its data.yaml):
412
488
  EoMT("s").train(data="sample_data/data.yaml", epochs=1, batch=1)
@@ -424,7 +500,7 @@ GPUs; the main process scores them and alone logs and writes checkpoints. The pr
424
500
  argument must be picklable.
425
501
 
426
502
  ```python
427
- EoMT("l", device="0,1").train(data="coco", epochs=50, batch=2) # 2 per GPU x accum 4 x 2 GPUs = effective 16
503
+ EoMT("l", device="0,1").train(data="coco", batch=2) # 2 per GPU x accum 4 x 2 GPUs = effective 16
428
504
  ```
429
505
 
430
506
  **Test-time augmentation and tiled inference** (instance family). `predict()` and `val()` take `augment=True` (the
@@ -433,8 +509,9 @@ image at `imgsz` and zoomed to 1.5x `imgsz`) and `tiles=True` (overlapping tiles
433
509
  mask IoU >= 0.5 measured where both views can see, so an object cut by a tile border is put back together; the mask
434
510
  logits are averaged and the score is the mean over the views that could see the object (one that missed it votes 0).
435
511
  Each takes a dict of overrides; see
436
- [`eomt/tta.py`](eomt/tta.py). `augment={"flip": True}` also merges mirrored views: never with left / right classes or
437
- attributes, which a mirrored view predicts swapped.
512
+ [`eomt/tta.py`](eomt/tta.py). `augment={"flip": True}` also merges mirrored views. Attributes marked `flip_aware` are swapped
513
+ back in a mirrored view; left / right *primary classes* are not (a mirrored view predicts them swapped), so keep `flip` off
514
+ for those, and for attributes that encode a side without being flip-aware.
438
515
 
439
516
  ```python
440
517
  m = EoMT("runs/train/eomt-l")
@@ -443,7 +520,8 @@ m.val(data="data.yaml", tiles=True) # native til
443
520
  m.predict("images/", augment={"flip": True}, tiles=1024) # 1x / 1.5x and mirrored; 1024 px tiles
444
521
  ```
445
522
 
446
- `scripts/val.py` and `scripts/predict.py` take `--augment` and `--tiles [SIZE]`. Each view is a forward pass: `augment`
523
+ `scripts/val.py` and `scripts/predict.py` take `--augment` and `--tiles [SIZE]` (`scripts/val.py` also `--flip`; its arguments are
524
+ in [Command-line scripts](#command-line-scripts-arguments-and-things-to-take-into-account)). Each view is a forward pass: `augment`
447
525
  costs ~3.5x one prediction (the 1.5x view has 2.25x the pixels); a 1280 x 960 photo at `imgsz` 644 makes 6 tiles + the
448
526
  whole image.
449
527
 
@@ -462,11 +540,89 @@ LR, every step of the LR schedule, and the masked-attention annealing agree exac
462
540
  | encoder LR | layer-wise decay `llrd=0.8`, `backbone_lr_mult=1.0`: the **top ViT block is at the full `lr0`** and each block below is 0.8× the one above; embeddings follow the first block, the final norm is at `lr0` |
463
541
  | schedule | polynomial decay (`poly_power=0.9`) to 0 after a two-stage warmup in optimizer steps `warmup_steps=(500, 1000)`: the head warms up while the ViT LR is held at 0, then the ViT ramps up |
464
542
  | masked attention | annealed block by block (`mask_anneal_start/end`, fractions of training: 1/6–2/6, 2/6–3/6, 3/6–4/6, 4/6–5/6, polynomial); the last sixth of training is mask-free |
543
+ | length | `epochs=12`, validated every `val_interval=2` epochs and after the last (official COCO instance recipe, about 89k optimizer steps at batch 16) |
465
544
 
466
545
  In EoMT the last four transformer blocks are the ones that process the queries and predict the masks, so they must learn at
467
546
  close to the head's speed: scaling the whole encoder by 0.1 (as detector-style recipes do for a backbone) starves them. The
468
- LR decay and the annealing span `epochs`, and validation runs mask-free, so metrics logged early in a long run understate
469
- the model. The official COCO recipe trains for 12 epochs (about 89k optimizer steps at batch 16).
547
+ LR decay and the annealing span `epochs`.
548
+
549
+ **Validation follows the training regime**, as in the official EoMT: masked attention at the annealing probabilities of
550
+ the epoch's end. A model that still masks is far worse without its masks (part damage, ViT-L at epoch 11 of 50: 0.034
551
+ segm mAP mask-free against 0.114 masked), so a mask-free curve before the last sixth follows the annealing and jumps as each
552
+ block finishes it, not the model. `best.pt` is deployed mask-free, so it is only chosen once every block's annealing is
553
+ over (a run stopped earlier, e.g. with `stop_after_epochs`, has `last.pt` alone), and every checkpoint is saved mask-free.
554
+ Training prints the windows in epochs when it starts.
555
+
556
+ ### Command-line scripts: arguments and things to take into account
557
+
558
+ **`scripts/train.py`.** Every flag is a keyword of `train()`; a flag left out keeps `train()`'s default.
559
+
560
+ | flag | `train()` keyword | what it does |
561
+ |---|---|---|
562
+ | `--data` | `data` | dataset YAML, or `coco` (auto-downloads) |
563
+ | `--size` / `--task` | | model size `s` `b` `l`; head family `instance` (masks) or `detect` (boxes) |
564
+ | `--epochs` | `epochs` | total epochs: the LR schedule and the masked-attention annealing span them |
565
+ | `--batch` / `--nominal-batch` | `batch` / `nominal_batch` | micro-batch **per GPU** / global effective batch (16); the difference is gradient accumulation |
566
+ | `--device` | `device` | `auto`, `cpu`, `cuda:N`, or `0,1` for several GPUs (one process each) |
567
+ | `--weights` / `--resume` | `init_weights` / `resume` | warm-start a fresh run from a checkpoint / continue a run. **One of the two, not both** |
568
+ | `--lr`, `--warmup HEAD,VIT` | `lr0`, `warmup_steps` | peak head LR (the encoder follows with layer-wise decay) and the two-stage warmup in optimizer steps |
569
+ | `--ema-decay`, `--ema-tau` | `ema_decay`, `ema_tau` | EMA of the weights (what is validated and exported as `best.pt`) |
570
+ | `--mask-free` | `mask_anneal_start/end` | no masked attention from step 0 (annealing windows 0 -> 0) |
571
+ | `--aux-dropout` | `aux_head_dropout` | dropout in the attribute heads |
572
+ | `--val-interval`, `--workers`, `--seed` | same | validate every N epochs (and after the last); data-loader workers per GPU; seed |
573
+ | `--aug KEY=VALUE`, `--aug-preset` | `aug` | augmentation overrides (see [Augmentation](#augmentation)) |
574
+ | `--fpn-scales`, `--box-head`, `--no-deep-supervision`, `--upscale-blocks`, `--iou-aware-cls`, `--quality-weight`, `--val-imgsz` | same | [architecture options](#architecture-options) |
575
+
576
+ Things to take into account when training:
577
+
578
+ - **Resume or warm start.** `--resume` continues a run exactly (epoch, optimizer, schedule, EMA, best metric) and re-reads the
579
+ architecture from the checkpoint: keep the other flags as they were, and the seed is ignored so the shuffle is not replayed.
580
+ `--weights` loads the weights only and starts a new run at epoch 0. Start from a `best.pt` (the EMA, saved mask-free); a
581
+ `last.pt` holds the live weights.
582
+ - **A short fine-tune needs its own recipe.** Lower `--lr`, shorten `--warmup`, and lower `--ema-decay` (at 0.9999 the EMA
583
+ averages over ~10k steps and barely leaves its starting point in a few epochs). If the model is deployed mask-free, pass
584
+ `--mask-free`: with the default annealing a warm-started model is trained *masked* for the first sixth of the run, a regime it
585
+ no longer runs in.
586
+ - **`best.pt` is only chosen mask-free.** With the default annealing that means from 5/6 of the schedule on; with `--mask-free`
587
+ every validation is eligible.
588
+ - **Validated epochs only.** With `--val-interval N` the other epochs have empty validation cells in `metrics.csv`; the curves in
589
+ `metrics.png` join the validated points.
590
+ - **Attribute heads follow the dataset's definitions.** Categories, `applies_to`, `flip_aware` and `crop` / zoom settings come
591
+ from the dataset (the training banner lists them). The flip mapping, scope and regression statistics are stored in the
592
+ checkpoint; `crop` and the zoom settings are training-only.
593
+
594
+ **`scripts/val.py`.**
595
+
596
+ | flag | what it does |
597
+ |---|---|
598
+ | `weights` | checkpoint `.pt` or a run / weights folder (`best.pt` preferred) |
599
+ | `--data`, `--batch`, `--workers`, `--device` | dataset YAML (`coco`), batch, data-loader workers, device |
600
+ | `--imgsz` | evaluation input size (default: the trained one; the positional embeddings are interpolated) |
601
+ | `--augment` | add the image at 1.5x `imgsz` |
602
+ | `--tiles [SIZE]` | overlapping tiles (default `imgsz` px, native resolution) plus the whole image |
603
+ | `--flip` | also run every view mirrored (alone: `--augment` with mirrored views) |
604
+ | `--no-attributes` | mAP only |
605
+ | `--ema`, `--mask annealed\|free` | validate the EMA weights of a `last.pt` (see below) |
606
+
607
+ Things to take into account when validating:
608
+
609
+ - **What is reported.** `segm/*` and `bbox/*` mAP and, for a model with secondary heads, `aux_acc/<head>` (accuracy) and
610
+ `aux_mae/<head>` (regression heads, mean absolute error in real units). They are scored on the same predictions as the mAP, so
611
+ they also work with `--augment`, `--tiles` and `--flip`.
612
+ - **The attribute population is the deployed one.** Each GT instance is matched to the kept detection of its class with the
613
+ highest mask IoU (one to one, IoU >= 0.5). The trainer's `val/aux_*` columns score matched *queries* instead, so the two differ
614
+ slightly: compare TTA against plain within the script's numbers, not against `metrics.csv`. A regression head's MAE is
615
+ dominated by its large values, so also look at a relative error before concluding.
616
+ - **Regime.** A model that is still annealing is only meaningful with its masks: `--ema --mask annealed` reproduces the trainer's
617
+ validation of a `last.pt` (EMA weights, the masked-attention probabilities of that epoch, read from the run's `args.yaml`),
618
+ `--ema --mask free` is the deployed regime, and `best.pt` is already the EMA, mask-free.
619
+ - **TTA is not free and not uniform.** It costs one forward pass per view and mostly helps small objects and strict-IoU mAP; the
620
+ classification heads often do not move. A regression head's prediction is *averaged over views at different zoom*, so a value
621
+ that depends on the object's apparent size can drift under TTA: check it against the plain run before relying on it.
622
+ - **`--flip` and the heads.** Heads the checkpoint stores as flip-aware are swapped back in mirrored views; an older checkpoint
623
+ takes the mapping from the dataset's JSON, with a warning. Left / right *primary classes* are not handled: keep `--flip` off for
624
+ them. The val categories of every head must match the model's, or the run stops instead of scoring against the wrong ids.
625
+ - **One process.** `scripts/val.py` runs on one device; the trainer's own validation uses all the GPUs.
470
626
 
471
627
  ### Architecture options
472
628
 
@@ -525,7 +681,7 @@ mass-preserving), so thin objects are not shredded into dots by nearest-neighbou
525
681
  | geometry | `flip_prob` 0.5 · `vflip_prob` 0 · `rot90_prob` 0 · LSJ `min_scale`/`max_scale` 0.1–2.0 · `rotate_prob` 0.3 (±10°) · `shear_prob` 0.2 (±5°) · `perspective_prob` 0.15 |
526
682
  | optics / sensor | colour jitter 1.0 · `gamma_prob` 0.3 · `grayscale_prob` 0.05 · `blur_prob` 0.2 · `noise_prob` 0.2 · `jpeg_prob` 0.3 · `glare_prob` 0.15 · `erasing_prob` 0.2 |
527
683
  | multi-image (instance family) | `mosaic_prob` 0 · `mixup_prob` 0 · `copy_paste_prob` 0 |
528
- | crop | `instance_crop_prob` 0 (instance-aware crop) · `instance_crop_empty_prob` 0 |
684
+ | crop | `instance_crop_prob` 0 (instance-aware crop) · `instance_crop_empty_prob` 0 · `zoom_prob` 0 (close-ups, `zoom_scale` 4–5x) |
529
685
  | masks | `mask_resize="area"` (or `"nearest"`) |
530
686
 
531
687
  Override per run, per dataset, or from the CLI — precedence is explicit keywords (`flip_prob`, `min_scale`,
@@ -553,12 +709,19 @@ python scripts/train.py --data my/data.yaml --aug min_scale=0.6 --aug instance_c
553
709
  Things worth knowing:
554
710
 
555
711
  - **Orientation.** Flips, 90° turns and rotations change the pixels but not the label. If an attribute encodes
556
- orientation (e.g. a `viewpoint` head) set `flip_prob=0` and `rotate_prob=0`; `vflip_prob` / `rot90_prob` stay off
557
- unless your scenes have no canonical "up".
712
+ orientation (e.g. a `viewpoint` head) set `flip_prob=0` and `rotate_prob=0`, or, for a left / right attribute,
713
+ mark it `flip_aware` (see *Flip-aware attributes*) and keep the horizontal flip; `vflip_prob` / `rot90_prob` stay
714
+ off unless your scenes have no canonical "up".
558
715
  - **Thin or tiny objects.** Large-Scale Jitter at 0.1 shrinks a 5 px crack to under a pixel; keep the effective
559
716
  scale within ~[0.4, 1.0] (`min_scale = 0.4·long_side/imgsz`, `max_scale = 1.0·long_side/imgsz`). The instance-aware
560
717
  crop pays off when the crop is much smaller than the image (high zoom, small `imgsz`), and
561
718
  `instance_crop_rarity_attr` makes rare attribute values the crop's focus more often.
719
+ - **Close-ups.** `zoom_prob` makes an occasional sample a close-up: scaled by `s ~ U(zoom_scale)` (default 4–5x
720
+ the whole-image view, past the photo's own resolution) instead of the LSJ range, with the window on one instance
721
+ (whole when it fits, else a window inside it). `zoom_attr` restricts it to instances with a value of that
722
+ attribute and to the samples that have one, e.g. `{"zoom_prob": 0.1, "zoom_attr": "area"}` so a crop-scaled area
723
+ head also learns that a part filling the frame keeps its real area, and that a cut part keeps the share in view.
724
+ It resizes the whole photo first (~1.5x the time of a normal sample at 5x), so keep it occasional.
562
725
  - **Multi-image ops** (`mosaic`, `mixup`, `copy_paste`) add instances from other samples and are *off*: they splice or
563
726
  blend long thin structures, so try them deliberately and look at the result.
564
727
  - **Negatives.** Images with no annotations are dropped from the train set by default. `train(keep_empty=True)` keeps
@@ -32,7 +32,7 @@ It's a clean-room, Apache-2.0 reimplementation: the weights you train are yours
32
32
  from eomt import EoMT
33
33
 
34
34
  model = EoMT("l") # fresh large model (DINOv2 backbone)
35
- model.train(data="coco", epochs=50) # COCO 2017 auto-downloads if missing
35
+ model.train(data="coco") # COCO 2017 auto-downloads if missing; 12 epochs, the official recipe
36
36
 
37
37
  model = EoMT("runs/train/eomt-l") # reload a run — size/classes/heads auto-detected
38
38
  model.predict("images/", plot=True) # render masks/boxes + per-instance attributes
@@ -166,14 +166,22 @@ simply reuse that same query→GT assignment and read the answer off the matched
166
166
  query→GT assignment the detection loss used; the attribute is read *after* matching.
167
167
  - **Gate.** An optional IoU gate drops barely-overlapping matched pairs (common early in
168
168
  training) so attributes only learn from queries that actually localize the object.
169
- - **Loss.** Cross-entropy per head over matched queries, summed across heads and scaled
169
+ - **Loss.** Cross-entropy per head over matched queries (a regression head: Huber on its
170
+ standardized value, see below), summed across heads and scaled
170
171
  by `aux_w` (default `1.0`), added to the detector loss. Empty-match batches contribute
171
172
  a graph-preserving zero, and missing labels use `ignore_index` and contribute nothing.
173
+ - **Balancing.** Both losses are O(1), so equal weights are a sane start. `aux_w_per_head`
174
+ weights each head, `aux_class_weights=True` re-weights rare classes (rare values of a
175
+ regression head, by smoothed density), and `aux_balance="uncertainty"` learns one weight
176
+ per head (Kendall et al. 2018) with the detector loss as the fixed anchor. Each epoch
177
+ prints every head's pull on the shared queries (its gradient norm over the detector's,
178
+ `train/aux_grad/<head>`): well above 1 a head steers the encoder, far below it only reads it.
172
179
  - **Checkpoint selection is unchanged.** The attribute "rides along": its per-head
173
180
  matched-query accuracy is shown live and written to `metrics.csv`, but never drives
174
181
  `best.pt` (still `segm/mAP` or `bbox/mAP`).
175
182
  - **Inference.** Each result attaches `aux = {head: {"ids", "probs"}}` for the kept
176
- detections, and `predict(plot=True)` renders each attribute next to the class label
183
+ detections (and `aux_values = {head: values}` in real units for the regression heads), and
184
+ `predict(plot=True)` renders each attribute next to the class label
177
185
  using names stored in the checkpoint.
178
186
 
179
187
  ---
@@ -242,8 +250,76 @@ label set, in one model:
242
250
  At inference a scoped head reports `ids = -1` ("not applicable") for detections whose class it
243
251
  does not cover. The scope is stored in the checkpoint, so it survives reload.
244
252
 
253
+ **Flip-aware attributes.** A horizontal flip mirrors the photo but not the labels, so an attribute
254
+ that encodes a side (a left door shows up as a right one) is corrupted by `flip_prob > 0`. Give
255
+ its definition `flip_aware`, a map of category names to the ones they become on a horizontal
256
+ flip, and the training augmentation swaps them for every instance it flips (each pair swaps both
257
+ ways, so `{"left": "right"}` is enough; unlisted categories stay as they are):
258
+
259
+ ```jsonc
260
+ {"name": "laterality", "categories": [{"id": 0, "name": "left"}, {"id": 1, "name": "center"}, {"id": 2, "name": "right"}],
261
+ "flip_aware": {"left": "right"}}
262
+ ```
263
+
264
+ Mosaic, mixup and copy-paste flip each source image independently, and the swap follows each
265
+ instance. Only the horizontal flip is handled: keep `vflip_prob` and `rot90_prob` off for such an
266
+ attribute. It is categorical only. The mapping is stored in the checkpoint, and test-time augmentation with
267
+ `flip` swaps it back in each mirrored view's prediction before the views are merged (a checkpoint saved before the
268
+ mapping was stored takes it from the val JSON, with a warning). The training banner lists each head's swapped pairs. A
269
+ regression head needs no mapping: its value must not change under a flip (see below).
270
+
271
+ **Regression attributes.** `"type": "regression"` makes a head regress one real number per
272
+ instance instead of choosing a category; categorical and regression heads mix freely in one
273
+ dataset. `log` (regress `log1p` of the value, for heavy-tailed positives such as costs) and
274
+ `range` (valid values; predictions are clipped to it) are optional, and `applies_to` works as above:
275
+
276
+ ```jsonc
277
+ "attributes": [
278
+ {"name": "severity", "categories": [...]},
279
+ {"name": "repair_cost", "type": "regression", "log": true, "range": [0, 20000], "applies_to": ["bumper"]}
280
+ ]
281
+ // per annotation:
282
+ "attributes": {"severity": 3, "repair_cost": 812.5}
283
+ ```
284
+
285
+ A missing, non-numeric or out-of-range value is ignored. The head is trained on the value
286
+ standardized by the train split's mean and std (stored in the checkpoint), validated as the
287
+ mean absolute error in real units (`val/aux_mae/<head>`; `metrics.png` gives each regression
288
+ head its own MAE panel, `aux_per_class.png` its MAE per primary class), and predicted in real units
289
+ (`aux_values`). The value must not change under the augmentations (scale, crop, flip,
290
+ rotation): a cost or a score is fine, a pixel area or an angle is not. Tracking votes on the
291
+ categorical heads only, and re-identification cannot gate on a regression head.
292
+
293
+ A value that depends on how much of the instance is in view can say so with `crop`: the
294
+ training augmentation measures, per instance, the fraction it keeps in view (mask mass, or box
295
+ area, inside the crop window, after copy-paste occlusion), and the value follows it.
296
+ `"crop": "scale"` multiplies the value by that fraction — for an extensive quantity such as the
297
+ object's area in cm² (half the damage cropped away ⇒ half the area). `"crop": "ignore"` drops the
298
+ value when less than `min_visible` (default `0.9`) is kept — for a property of the whole object
299
+ that a cut view no longer shows. Validation and inference see whole images, so the head predicts
300
+ the full value there:
301
+
302
+ ```jsonc
303
+ {"name": "damage_area", "type": "regression", "log": true, "crop": "scale"},
304
+ {"name": "part_length", "type": "regression", "crop": "ignore", "min_visible": 0.95}
305
+ ```
306
+
307
+ How the fraction is measured: for masks, the share of the instance's mask mass whose source pixels
308
+ the crop (and rotation / shear / perspective warp) lands inside the window: exact, so an instance
309
+ the window does not cut keeps exactly 1 (a warp alone is not a cut); copy-paste occlusion counts as
310
+ hidden, and instances brought in by mosaic, mixup or copy-paste are measured against their own crops. For boxes (detect family), the ratio of
311
+ the box areas: exact for a crop, approximate under rotation. Each call of the built-in
312
+ augmentation exposes it as `TrainAugment.last_info["visible"]`.
313
+
314
+ Limits: the head's mean / std and its `aux_class_weights` density come from the unscaled values,
315
+ so with `"scale"` the values trained on sit somewhat lower than those statistics. Only the visible
316
+ fraction is tracked, not the scale jitter, so a pixel area is still not supported. A custom
317
+ `transform=` gets no adjustment. `crop` is rejected on a categorical attribute, and it is a
318
+ training-only setting (not stored in the checkpoint). The training banner prints each head's crop
319
+ mode.
320
+
245
321
  **Sidecar format (optional).** You can keep the COCO JSON as plain, standard COCO and put the
246
- attributes beside it instead of inside it: an `attributes.yaml` schema in the dataset root plus
322
+ attributes beside it instead of inside it: an `attributes.yaml` schema (same keys, regression heads included) in the dataset root plus
247
323
  `attributes/<split>.json` values keyed by annotation id (`{ann_id: {head: value}}`). If present
248
324
  (and the JSON has no embedded `attributes`), it is merged in memory at load — so a plain COCO
249
325
  dataset always works and the sidecar is picked up automatically when you add it. Embedded
@@ -366,7 +442,7 @@ size, normalization and any auxiliary heads are auto-detected from the `.pt`):
366
442
  from eomt import EoMT
367
443
 
368
444
  # Train on COCO 2017 (auto-downloaded on first run):
369
- EoMT("l").train(data="coco", epochs=50, batch=4)
445
+ EoMT("l").train(data="coco", batch=4) # 12 epochs, validated every 2: the official recipe
370
446
 
371
447
  # ...or any COCO-format dataset (point at its data.yaml):
372
448
  EoMT("s").train(data="sample_data/data.yaml", epochs=1, batch=1)
@@ -384,7 +460,7 @@ GPUs; the main process scores them and alone logs and writes checkpoints. The pr
384
460
  argument must be picklable.
385
461
 
386
462
  ```python
387
- EoMT("l", device="0,1").train(data="coco", epochs=50, batch=2) # 2 per GPU x accum 4 x 2 GPUs = effective 16
463
+ EoMT("l", device="0,1").train(data="coco", batch=2) # 2 per GPU x accum 4 x 2 GPUs = effective 16
388
464
  ```
389
465
 
390
466
  **Test-time augmentation and tiled inference** (instance family). `predict()` and `val()` take `augment=True` (the
@@ -393,8 +469,9 @@ image at `imgsz` and zoomed to 1.5x `imgsz`) and `tiles=True` (overlapping tiles
393
469
  mask IoU >= 0.5 measured where both views can see, so an object cut by a tile border is put back together; the mask
394
470
  logits are averaged and the score is the mean over the views that could see the object (one that missed it votes 0).
395
471
  Each takes a dict of overrides; see
396
- [`eomt/tta.py`](eomt/tta.py). `augment={"flip": True}` also merges mirrored views: never with left / right classes or
397
- attributes, which a mirrored view predicts swapped.
472
+ [`eomt/tta.py`](eomt/tta.py). `augment={"flip": True}` also merges mirrored views. Attributes marked `flip_aware` are swapped
473
+ back in a mirrored view; left / right *primary classes* are not (a mirrored view predicts them swapped), so keep `flip` off
474
+ for those, and for attributes that encode a side without being flip-aware.
398
475
 
399
476
  ```python
400
477
  m = EoMT("runs/train/eomt-l")
@@ -403,7 +480,8 @@ m.val(data="data.yaml", tiles=True) # native til
403
480
  m.predict("images/", augment={"flip": True}, tiles=1024) # 1x / 1.5x and mirrored; 1024 px tiles
404
481
  ```
405
482
 
406
- `scripts/val.py` and `scripts/predict.py` take `--augment` and `--tiles [SIZE]`. Each view is a forward pass: `augment`
483
+ `scripts/val.py` and `scripts/predict.py` take `--augment` and `--tiles [SIZE]` (`scripts/val.py` also `--flip`; its arguments are
484
+ in [Command-line scripts](#command-line-scripts-arguments-and-things-to-take-into-account)). Each view is a forward pass: `augment`
407
485
  costs ~3.5x one prediction (the 1.5x view has 2.25x the pixels); a 1280 x 960 photo at `imgsz` 644 makes 6 tiles + the
408
486
  whole image.
409
487
 
@@ -422,11 +500,89 @@ LR, every step of the LR schedule, and the masked-attention annealing agree exac
422
500
  | encoder LR | layer-wise decay `llrd=0.8`, `backbone_lr_mult=1.0`: the **top ViT block is at the full `lr0`** and each block below is 0.8× the one above; embeddings follow the first block, the final norm is at `lr0` |
423
501
  | schedule | polynomial decay (`poly_power=0.9`) to 0 after a two-stage warmup in optimizer steps `warmup_steps=(500, 1000)`: the head warms up while the ViT LR is held at 0, then the ViT ramps up |
424
502
  | masked attention | annealed block by block (`mask_anneal_start/end`, fractions of training: 1/6–2/6, 2/6–3/6, 3/6–4/6, 4/6–5/6, polynomial); the last sixth of training is mask-free |
503
+ | length | `epochs=12`, validated every `val_interval=2` epochs and after the last (official COCO instance recipe, about 89k optimizer steps at batch 16) |
425
504
 
426
505
  In EoMT the last four transformer blocks are the ones that process the queries and predict the masks, so they must learn at
427
506
  close to the head's speed: scaling the whole encoder by 0.1 (as detector-style recipes do for a backbone) starves them. The
428
- LR decay and the annealing span `epochs`, and validation runs mask-free, so metrics logged early in a long run understate
429
- the model. The official COCO recipe trains for 12 epochs (about 89k optimizer steps at batch 16).
507
+ LR decay and the annealing span `epochs`.
508
+
509
+ **Validation follows the training regime**, as in the official EoMT: masked attention at the annealing probabilities of
510
+ the epoch's end. A model that still masks is far worse without its masks (part damage, ViT-L at epoch 11 of 50: 0.034
511
+ segm mAP mask-free against 0.114 masked), so a mask-free curve before the last sixth follows the annealing and jumps as each
512
+ block finishes it, not the model. `best.pt` is deployed mask-free, so it is only chosen once every block's annealing is
513
+ over (a run stopped earlier, e.g. with `stop_after_epochs`, has `last.pt` alone), and every checkpoint is saved mask-free.
514
+ Training prints the windows in epochs when it starts.
515
+
516
+ ### Command-line scripts: arguments and things to take into account
517
+
518
+ **`scripts/train.py`.** Every flag is a keyword of `train()`; a flag left out keeps `train()`'s default.
519
+
520
+ | flag | `train()` keyword | what it does |
521
+ |---|---|---|
522
+ | `--data` | `data` | dataset YAML, or `coco` (auto-downloads) |
523
+ | `--size` / `--task` | | model size `s` `b` `l`; head family `instance` (masks) or `detect` (boxes) |
524
+ | `--epochs` | `epochs` | total epochs: the LR schedule and the masked-attention annealing span them |
525
+ | `--batch` / `--nominal-batch` | `batch` / `nominal_batch` | micro-batch **per GPU** / global effective batch (16); the difference is gradient accumulation |
526
+ | `--device` | `device` | `auto`, `cpu`, `cuda:N`, or `0,1` for several GPUs (one process each) |
527
+ | `--weights` / `--resume` | `init_weights` / `resume` | warm-start a fresh run from a checkpoint / continue a run. **One of the two, not both** |
528
+ | `--lr`, `--warmup HEAD,VIT` | `lr0`, `warmup_steps` | peak head LR (the encoder follows with layer-wise decay) and the two-stage warmup in optimizer steps |
529
+ | `--ema-decay`, `--ema-tau` | `ema_decay`, `ema_tau` | EMA of the weights (what is validated and exported as `best.pt`) |
530
+ | `--mask-free` | `mask_anneal_start/end` | no masked attention from step 0 (annealing windows 0 -> 0) |
531
+ | `--aux-dropout` | `aux_head_dropout` | dropout in the attribute heads |
532
+ | `--val-interval`, `--workers`, `--seed` | same | validate every N epochs (and after the last); data-loader workers per GPU; seed |
533
+ | `--aug KEY=VALUE`, `--aug-preset` | `aug` | augmentation overrides (see [Augmentation](#augmentation)) |
534
+ | `--fpn-scales`, `--box-head`, `--no-deep-supervision`, `--upscale-blocks`, `--iou-aware-cls`, `--quality-weight`, `--val-imgsz` | same | [architecture options](#architecture-options) |
535
+
536
+ Things to take into account when training:
537
+
538
+ - **Resume or warm start.** `--resume` continues a run exactly (epoch, optimizer, schedule, EMA, best metric) and re-reads the
539
+ architecture from the checkpoint: keep the other flags as they were, and the seed is ignored so the shuffle is not replayed.
540
+ `--weights` loads the weights only and starts a new run at epoch 0. Start from a `best.pt` (the EMA, saved mask-free); a
541
+ `last.pt` holds the live weights.
542
+ - **A short fine-tune needs its own recipe.** Lower `--lr`, shorten `--warmup`, and lower `--ema-decay` (at 0.9999 the EMA
543
+ averages over ~10k steps and barely leaves its starting point in a few epochs). If the model is deployed mask-free, pass
544
+ `--mask-free`: with the default annealing a warm-started model is trained *masked* for the first sixth of the run, a regime it
545
+ no longer runs in.
546
+ - **`best.pt` is only chosen mask-free.** With the default annealing that means from 5/6 of the schedule on; with `--mask-free`
547
+ every validation is eligible.
548
+ - **Validated epochs only.** With `--val-interval N` the other epochs have empty validation cells in `metrics.csv`; the curves in
549
+ `metrics.png` join the validated points.
550
+ - **Attribute heads follow the dataset's definitions.** Categories, `applies_to`, `flip_aware` and `crop` / zoom settings come
551
+ from the dataset (the training banner lists them). The flip mapping, scope and regression statistics are stored in the
552
+ checkpoint; `crop` and the zoom settings are training-only.
553
+
554
+ **`scripts/val.py`.**
555
+
556
+ | flag | what it does |
557
+ |---|---|
558
+ | `weights` | checkpoint `.pt` or a run / weights folder (`best.pt` preferred) |
559
+ | `--data`, `--batch`, `--workers`, `--device` | dataset YAML (`coco`), batch, data-loader workers, device |
560
+ | `--imgsz` | evaluation input size (default: the trained one; the positional embeddings are interpolated) |
561
+ | `--augment` | add the image at 1.5x `imgsz` |
562
+ | `--tiles [SIZE]` | overlapping tiles (default `imgsz` px, native resolution) plus the whole image |
563
+ | `--flip` | also run every view mirrored (alone: `--augment` with mirrored views) |
564
+ | `--no-attributes` | mAP only |
565
+ | `--ema`, `--mask annealed\|free` | validate the EMA weights of a `last.pt` (see below) |
566
+
567
+ Things to take into account when validating:
568
+
569
+ - **What is reported.** `segm/*` and `bbox/*` mAP and, for a model with secondary heads, `aux_acc/<head>` (accuracy) and
570
+ `aux_mae/<head>` (regression heads, mean absolute error in real units). They are scored on the same predictions as the mAP, so
571
+ they also work with `--augment`, `--tiles` and `--flip`.
572
+ - **The attribute population is the deployed one.** Each GT instance is matched to the kept detection of its class with the
573
+ highest mask IoU (one to one, IoU >= 0.5). The trainer's `val/aux_*` columns score matched *queries* instead, so the two differ
574
+ slightly: compare TTA against plain within the script's numbers, not against `metrics.csv`. A regression head's MAE is
575
+ dominated by its large values, so also look at a relative error before concluding.
576
+ - **Regime.** A model that is still annealing is only meaningful with its masks: `--ema --mask annealed` reproduces the trainer's
577
+ validation of a `last.pt` (EMA weights, the masked-attention probabilities of that epoch, read from the run's `args.yaml`),
578
+ `--ema --mask free` is the deployed regime, and `best.pt` is already the EMA, mask-free.
579
+ - **TTA is not free and not uniform.** It costs one forward pass per view and mostly helps small objects and strict-IoU mAP; the
580
+ classification heads often do not move. A regression head's prediction is *averaged over views at different zoom*, so a value
581
+ that depends on the object's apparent size can drift under TTA: check it against the plain run before relying on it.
582
+ - **`--flip` and the heads.** Heads the checkpoint stores as flip-aware are swapped back in mirrored views; an older checkpoint
583
+ takes the mapping from the dataset's JSON, with a warning. Left / right *primary classes* are not handled: keep `--flip` off for
584
+ them. The val categories of every head must match the model's, or the run stops instead of scoring against the wrong ids.
585
+ - **One process.** `scripts/val.py` runs on one device; the trainer's own validation uses all the GPUs.
430
586
 
431
587
  ### Architecture options
432
588
 
@@ -485,7 +641,7 @@ mass-preserving), so thin objects are not shredded into dots by nearest-neighbou
485
641
  | geometry | `flip_prob` 0.5 · `vflip_prob` 0 · `rot90_prob` 0 · LSJ `min_scale`/`max_scale` 0.1–2.0 · `rotate_prob` 0.3 (±10°) · `shear_prob` 0.2 (±5°) · `perspective_prob` 0.15 |
486
642
  | optics / sensor | colour jitter 1.0 · `gamma_prob` 0.3 · `grayscale_prob` 0.05 · `blur_prob` 0.2 · `noise_prob` 0.2 · `jpeg_prob` 0.3 · `glare_prob` 0.15 · `erasing_prob` 0.2 |
487
643
  | multi-image (instance family) | `mosaic_prob` 0 · `mixup_prob` 0 · `copy_paste_prob` 0 |
488
- | crop | `instance_crop_prob` 0 (instance-aware crop) · `instance_crop_empty_prob` 0 |
644
+ | crop | `instance_crop_prob` 0 (instance-aware crop) · `instance_crop_empty_prob` 0 · `zoom_prob` 0 (close-ups, `zoom_scale` 4–5x) |
489
645
  | masks | `mask_resize="area"` (or `"nearest"`) |
490
646
 
491
647
  Override per run, per dataset, or from the CLI — precedence is explicit keywords (`flip_prob`, `min_scale`,
@@ -513,12 +669,19 @@ python scripts/train.py --data my/data.yaml --aug min_scale=0.6 --aug instance_c
513
669
  Things worth knowing:
514
670
 
515
671
  - **Orientation.** Flips, 90° turns and rotations change the pixels but not the label. If an attribute encodes
516
- orientation (e.g. a `viewpoint` head) set `flip_prob=0` and `rotate_prob=0`; `vflip_prob` / `rot90_prob` stay off
517
- unless your scenes have no canonical "up".
672
+ orientation (e.g. a `viewpoint` head) set `flip_prob=0` and `rotate_prob=0`, or, for a left / right attribute,
673
+ mark it `flip_aware` (see *Flip-aware attributes*) and keep the horizontal flip; `vflip_prob` / `rot90_prob` stay
674
+ off unless your scenes have no canonical "up".
518
675
  - **Thin or tiny objects.** Large-Scale Jitter at 0.1 shrinks a 5 px crack to under a pixel; keep the effective
519
676
  scale within ~[0.4, 1.0] (`min_scale = 0.4·long_side/imgsz`, `max_scale = 1.0·long_side/imgsz`). The instance-aware
520
677
  crop pays off when the crop is much smaller than the image (high zoom, small `imgsz`), and
521
678
  `instance_crop_rarity_attr` makes rare attribute values the crop's focus more often.
679
+ - **Close-ups.** `zoom_prob` makes an occasional sample a close-up: scaled by `s ~ U(zoom_scale)` (default 4–5x
680
+ the whole-image view, past the photo's own resolution) instead of the LSJ range, with the window on one instance
681
+ (whole when it fits, else a window inside it). `zoom_attr` restricts it to instances with a value of that
682
+ attribute and to the samples that have one, e.g. `{"zoom_prob": 0.1, "zoom_attr": "area"}` so a crop-scaled area
683
+ head also learns that a part filling the frame keeps its real area, and that a cut part keeps the share in view.
684
+ It resizes the whole photo first (~1.5x the time of a normal sample at 5x), so keep it occasional.
522
685
  - **Multi-image ops** (`mosaic`, `mixup`, `copy_paste`) add instances from other samples and are *off*: they splice or
523
686
  blend long thin structures, so try them deliberately and look at the result.
524
687
  - **Negatives.** Images with no annotations are dropped from the train set by default. `train(keep_empty=True)` keeps
@@ -4,7 +4,7 @@ Public API:
4
4
 
5
5
  >>> from eomt import EoMT
6
6
  >>> model = EoMT("l") # fresh model (DINOv2 backbone)
7
- >>> model.train(data="coco", epochs=50) # COCO auto-downloads if missing
7
+ >>> model.train(data="coco") # COCO auto-downloads if missing; 12 epochs, the official recipe
8
8
  >>> model = EoMT("runs/train/eomt-l") # reload a run (everything auto-detected)
9
9
  >>> results = model.predict("images/", plot=True)
10
10
 
@@ -4,7 +4,7 @@
4
4
  from eomt import EoMT
5
5
 
6
6
  model = EoMT("l") # fresh large model (DINOv2 backbone)
7
- model.train(data="coco", epochs=50) # COCO auto-downloads if missing
7
+ model.train(data="coco") # COCO auto-downloads if missing; 12 epochs, the official recipe
8
8
 
9
9
  model = EoMT("runs/train/eomt-l") # reload a run (everything auto-detected)
10
10
  model.val(data="coco")
@@ -286,7 +286,8 @@ class EoMT:
286
286
  # -------------------------------------------------------------------- val
287
287
  def val(self, data: str | Path = "coco", *, batch: int = 4, workers: int = 4,
288
288
  conf_thres: float = 0.0, max_det: int = 100, letterbox: bool | None = None,
289
- imgsz: int | None = None, **kw) -> dict:
289
+ imgsz: int | None = None, augment: bool | dict = False, tiles: bool | int | dict = False,
290
+ attributes: bool = True, **kw) -> dict:
290
291
  """Evaluate on a dataset's val split, returning COCO mAP metrics.
291
292
 
292
293
  Segmentation models report ``segm/*`` (+ ``bbox/*``); detection
@@ -294,6 +295,11 @@ class EoMT:
294
295
  evaluation image size (defaults to the trained size); the model interpolates
295
296
  its positional embeddings to the new resolution, so any multiple of the patch
296
297
  size works.
298
+
299
+ ``augment`` / ``tiles`` evaluate with test-time augmentation / tiled inference (instance family; see
300
+ :mod:`eomt.tta`; a head marked ``flip_aware`` is swapped back in mirrored views). For a segmentation model with
301
+ secondary heads, ``attributes`` (default on) adds ``aux_acc/<head>`` (accuracy) and ``aux_mae/<head>`` (regression
302
+ heads, real units), scored on the same predictions as the mAP; see :func:`eomt.engine.validate.evaluate`.
297
303
  """
298
304
  cfg = _resolve_data(data)
299
305
  if not (cfg["val_images"] and cfg["val_json"]):
@@ -304,12 +310,14 @@ class EoMT:
304
310
  lb = letterbox if letterbox is not None else bool(getattr(model, "preprocess_letterbox", False))
305
311
  val_ds = CocoValImages(
306
312
  cfg["val_images"], cfg["val_json"], imgsz=_resolve_imgsz(imgsz, model), letterbox=lb,
307
- mean=getattr(model, "pixel_mean", None), std=getattr(model, "pixel_std", None),
313
+ mean=getattr(model, "pixel_mean", None), std=getattr(model, "pixel_std", None), attributes=attributes,
308
314
  )
309
- eval_fn = _evaluate_detection if getattr(model, "family", "instance") == "detect" else _evaluate
315
+ detect = getattr(model, "family", "instance") == "detect"
316
+ eval_fn = _evaluate_detection if detect else _evaluate
310
317
  return eval_fn(
311
318
  model, val_ds, device=dev, batch_size=batch, num_workers=workers,
312
- conf_thres=conf_thres, max_det=max_det, **kw,
319
+ conf_thres=conf_thres, max_det=max_det, augment=augment, tiles=tiles,
320
+ **({} if detect else {"attributes": attributes}), **kw,
313
321
  )
314
322
 
315
323
  # -------------------------------------------------------------- compress