attr-eomt 1.2.0__tar.gz → 2.0.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 (84) hide show
  1. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/.gitignore +4 -1
  2. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/PKG-INFO +208 -199
  3. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/README.md +207 -198
  4. attr_eomt-2.0.0/configs/xview.yaml +26 -0
  5. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/api.py +47 -12
  6. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/aux_cls.py +30 -0
  7. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/compress.py +1 -2
  8. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/config.py +11 -7
  9. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/data/__init__.py +4 -0
  10. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/data/coco.py +35 -4
  11. attr_eomt-2.0.0/eomt/data/config.py +128 -0
  12. attr_eomt-2.0.0/eomt/data/sets.py +272 -0
  13. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/data/transforms.py +5 -4
  14. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/ema.py +2 -1
  15. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/engine/match.py +49 -19
  16. attr_eomt-2.0.0/eomt/engine/predict.py +411 -0
  17. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/engine/track.py +4 -4
  18. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/engine/train.py +552 -168
  19. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/engine/validate.py +207 -11
  20. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/loss.py +1 -2
  21. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/model.py +187 -2
  22. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/postprocess.py +9 -0
  23. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/preprocess.py +52 -7
  24. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/reid.py +1 -3
  25. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/serialization.py +14 -0
  26. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/tta.py +3 -4
  27. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/pyproject.toml +1 -1
  28. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/sample_data/README.md +15 -3
  29. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/sample_data/data.yaml +5 -10
  30. attr_eomt-2.0.0/sample_data/run.yaml +55 -0
  31. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/scripts/match.py +10 -6
  32. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/scripts/predict.py +10 -2
  33. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/scripts/train.py +38 -33
  34. attr_eomt-2.0.0/scripts/train_multiview.py +47 -0
  35. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/site/index.html +2 -2
  36. attr_eomt-2.0.0/tests/test_hires.py +70 -0
  37. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_regression_attr.py +25 -23
  38. attr_eomt-2.0.0/tests/test_run_config.py +102 -0
  39. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_smoke.py +13 -8
  40. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_tta.py +1 -1
  41. attr_eomt-2.0.0/tests/test_xview.py +337 -0
  42. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/uv.lock +1 -1
  43. attr_eomt-1.2.0/eomt/data/config.py +0 -83
  44. attr_eomt-1.2.0/eomt/engine/predict.py +0 -242
  45. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/.github/workflows/pages.yml +0 -0
  46. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/.github/workflows/publish.yml +0 -0
  47. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/.github/workflows/release.yml +0 -0
  48. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/.github/workflows/tests.yml +0 -0
  49. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/LICENSE +0 -0
  50. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/configs/coco.yaml +0 -0
  51. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/docs/assets/00-hero-banner.png +0 -0
  52. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/docs/assets/00-hero-banner.svg +0 -0
  53. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/docs/examples/billie_eilish.jpg +0 -0
  54. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/docs/examples/rosalia.jpg +0 -0
  55. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/docs/examples/sample.jpg +0 -0
  56. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/docs/examples/taylor_swift.jpg +0 -0
  57. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/__init__.py +0 -0
  58. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/box_loss.py +0 -0
  59. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/data/download.py +0 -0
  60. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/device.py +0 -0
  61. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/engine/__init__.py +0 -0
  62. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/engine/distributed.py +0 -0
  63. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/plotting.py +0 -0
  64. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/eomt/visualize.py +0 -0
  65. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/sample_data/annotations/instances_train.json +0 -0
  66. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/sample_data/annotations/instances_val.json +0 -0
  67. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/sample_data/images/train/.gitkeep +0 -0
  68. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/sample_data/images/val/.gitkeep +0 -0
  69. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/scripts/info.py +0 -0
  70. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/scripts/push_to_hub.py +0 -0
  71. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/scripts/track.py +0 -0
  72. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/scripts/train_detect_coco.sh +0 -0
  73. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/scripts/val.py +0 -0
  74. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_api.py +0 -0
  75. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_architecture.py +0 -0
  76. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_augment.py +0 -0
  77. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_compress.py +0 -0
  78. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_ddp.py +0 -0
  79. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_parity.py +0 -0
  80. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_recipe.py +0 -0
  81. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_reid.py +0 -0
  82. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tests/test_sweep.py +0 -0
  83. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tools/attr_tagger/README.md +0 -0
  84. {attr_eomt-1.2.0 → attr_eomt-2.0.0}/tools/attr_tagger/tag.py +0 -0
@@ -20,11 +20,14 @@ weights/
20
20
  *.pt
21
21
  *.pth
22
22
 
23
- # Datasets (auto-downloaded)
23
+ # Datasets (auto-downloaded) and their local converters
24
24
  datasets/
25
25
  /data/coco/
26
26
  scripts/sample_images/
27
27
 
28
+ # Local training: per-dataset train_*.py, their configs/ and converter tests
29
+ local_training_scripts/
30
+
28
31
  # Editor / OS
29
32
  .idea/
30
33
  .vscode/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: attr-eomt
3
- Version: 1.2.0
3
+ Version: 2.0.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
@@ -121,40 +121,14 @@ post-processing automatically. Everything below applies identically to both.
121
121
 
122
122
  ### Models & sizes
123
123
 
124
- | size | backbone | hidden | layers | heads | queries |
125
- |------|-----------------|--------|--------|-------|---------|
126
- | `s` | DINOv2-small | 384 | 12 | 6 | 100 |
127
- | `b` | DINOv2-base | 768 | 12 | 12 | 200 |
128
- | `l` | DINOv2-large | 1024 | 24 | 16 | 200 |
124
+ | size | backbone | hidden | layers | heads | queries | params |
125
+ |------|-----------------|--------|--------|-------|---------|--------|
126
+ | `s` | DINOv2-small | 384 | 12 | 6 | 100 | 24 M |
127
+ | `b` | DINOv2-base | 768 | 12 | 12 | 200 | 94 M |
128
+ | `l` | DINOv2-large | 1024 | 24 | 16 | 200 | 317 M |
129
129
 
130
- Default input is a patch-14-aligned square (`644 = 14 × 46`) so DINOv2 weights load 1:1.
131
-
132
- ### Compute & inference speed
133
-
134
- Measured on a single **NVIDIA GeForce RTX 5090**, `644 × 644` input, batch size 1.
135
- GFLOPs are multiply-accumulates at that resolution (attention included); latency /
136
- throughput are the median over 50 runs after warm-up, under `torch.amp.autocast`
137
- (fp16) — the package's own inference path.
138
-
139
- **`instance` family** (masks + boxes + class):
140
-
141
- | size | params | GFLOPs | latency (fp16) | throughput (fp16) | throughput (fp32) |
142
- |------|--------|--------|----------------|-------------------|-------------------|
143
- | `s` | 24.0 M | 128 | 8.4 ms | 119 img/s | 70 img/s |
144
- | `b` | 93.9 M | 430 | 17.4 ms | 58 img/s | 32 img/s |
145
- | `l` | 317 M | 1144 | 30.2 ms | 33 img/s | 15 img/s |
146
-
147
- **`detect` family** (boxes + class, no mask head):
148
-
149
- | size | params | GFLOPs | latency (fp16) | throughput (fp16) | throughput (fp32) |
150
- |------|--------|--------|----------------|-------------------|-------------------|
151
- | `s` | 22.7 M | 89 | 2.9 ms | 348 img/s | 120 img/s |
152
- | `b` | 88.6 M | 276 | 5.3 ms | 190 img/s | 60 img/s |
153
- | `l` | 308 M | 881 | 13.6 ms | 74 img/s | 21 img/s |
154
-
155
- Dropping the mask-upsampling head makes `detect` substantially lighter and ~1.3–3×
156
- faster. Figures are for the detector itself (backbone + queries + heads); the
157
- attribute heads add a thin linear/MLP per head and are negligible by design.
130
+ Default input is a patch-14-aligned square (`644 = 14 × 46`) so DINOv2 weights load 1:1. The `detect` family drops the
131
+ mask-upsampling head and is lighter; each attribute head adds only a thin linear / MLP layer.
158
132
 
159
133
  ---
160
134
 
@@ -230,8 +204,10 @@ simply reuse that same query→GT assignment and read the answer off the matched
230
204
 
231
205
  Attributes live **inside the COCO annotations** — each annotation is already a
232
206
  per-instance object, so alignment is automatic and `pycocotools` still parses it. Just
233
- two additions to a standard COCO file; **no YAML changes** — heads (count, classes,
234
- names) are discovered from the JSON, the same as `nc`.
207
+ two additions to a standard COCO file — heads (count, classes, names) are discovered from the
208
+ JSON, the same as `nc`. The dataset holds **only data** (each attribute's name, type and
209
+ categories); which attributes get a head and how each one learns (`applies_to`, `flip_aware`,
210
+ `log`, `range`, `crop`) is the **run config**'s `attributes` section — see *Run config* below.
235
211
 
236
212
  **1. A top-level `attributes` list** — one entry per head, defining its vocabulary:
237
213
 
@@ -275,30 +251,36 @@ Notes:
275
251
  instances that actually carry its value. A JSON with **no** `attributes` ⇒ detection-only,
276
252
  exactly as before.
277
253
 
278
- **Class-conditional heads.** Give an attribute definition an optional `applies_to` list of
279
- primary-class names or ids, and that head is only trained on — and only emitted for —
254
+ **Class-conditional heads.** Give an attribute an optional `applies_to` list of primary-class
255
+ names or ids in the run config, and that head is only trained on — and only emitted for —
280
256
  instances of those classes (hard routing on the primary class). Omit it and the head applies
281
257
  to every class. So different attributes can attach to different classes, each with its own
282
258
  label set, in one model:
283
259
 
284
- ```jsonc
285
- "attributes": [
286
- {"name": "posture", "categories": [...], "applies_to": ["cat", "dog"]}
287
- ]
260
+ ```yaml
261
+ # run config
262
+ attributes:
263
+ posture: {applies_to: [cat, dog]}
288
264
  ```
289
265
 
290
266
  At inference a scoped head reports `ids = -1` ("not applicable") for detections whose class it
291
267
  does not cover. The scope is stored in the checkpoint, so it survives reload.
292
268
 
293
269
  **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
270
+ that encodes a side (a left shoe shows up as a right one) is corrupted by `flip_prob > 0`. Give
271
+ it `flip_aware` in the run config, a map of category names to the ones they become on a horizontal
296
272
  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):
273
+ ways, so `{left: right}` is enough; unlisted categories stay as they are):
298
274
 
299
275
  ```jsonc
300
- {"name": "laterality", "categories": [{"id": 0, "name": "left"}, {"id": 1, "name": "center"}, {"id": 2, "name": "right"}],
301
- "flip_aware": {"left": "right"}}
276
+ // data: the attribute's categories
277
+ {"name": "side", "categories": [{"id": 0, "name": "left"}, {"id": 1, "name": "center"}, {"id": 2, "name": "right"}]}
278
+ ```
279
+
280
+ ```yaml
281
+ # run config: how it learns
282
+ attributes:
283
+ side: {flip_aware: {left: right}}
302
284
  ```
303
285
 
304
286
  Mosaic, mixup and copy-paste flip each source image independently, and the swap follows each
@@ -308,18 +290,26 @@ attribute. It is categorical only. The mapping is stored in the checkpoint, and
308
290
  mapping was stored takes it from the val JSON, with a warning). The training banner lists each head's swapped pairs. A
309
291
  regression head needs no mapping: its value must not change under a flip (see below).
310
292
 
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:
293
+ **Regression attributes.** `"type": "regression"` in the data makes a head regress one real number
294
+ per instance instead of choosing a category; categorical and regression heads mix freely in one
295
+ dataset. In the run config, `log` (regress `log1p` of the value, for heavy-tailed positives such as
296
+ costs) and `range` (valid values; predictions are clipped to it) are optional, and `applies_to` works as above:
315
297
 
316
298
  ```jsonc
299
+ // data
317
300
  "attributes": [
318
- {"name": "severity", "categories": [...]},
319
- {"name": "repair_cost", "type": "regression", "log": true, "range": [0, 20000], "applies_to": ["bumper"]}
301
+ {"name": "grade", "categories": [...]},
302
+ {"name": "price", "type": "regression", "unit": "EUR"}
320
303
  ]
321
304
  // per annotation:
322
- "attributes": {"severity": 3, "repair_cost": 812.5}
305
+ "attributes": {"grade": 3, "price": 812.5}
306
+ ```
307
+
308
+ ```yaml
309
+ # run config
310
+ attributes:
311
+ grade:
312
+ price: {log: true, range: [0, 20000], applies_to: [sofa]}
323
313
  ```
324
314
 
325
315
  A missing, non-numeric or out-of-range value is ignored. The head is trained on the value
@@ -334,29 +324,21 @@ A value that depends on how much of the instance is in view can say so with `cro
334
324
  training augmentation measures, per instance, the fraction it keeps in view (mask mass, or box
335
325
  area, inside the crop window, after copy-paste occlusion), and the value follows it.
336
326
  `"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
327
+ object's surface in cm² (half the object cropped away ⇒ half the surface). `"crop": "ignore"` drops the
338
328
  value when less than `min_visible` (default `0.9`) is kept — for a property of the whole object
339
329
  that a cut view no longer shows. Validation and inference see whole images, so the head predicts
340
330
  the full value there:
341
331
 
342
- ```jsonc
343
- {"name": "damage_area", "type": "regression", "log": true, "crop": "scale"},
344
- {"name": "part_length", "type": "regression", "crop": "ignore", "min_visible": 0.95}
332
+ ```yaml
333
+ # run config
334
+ attributes:
335
+ surface_cm2: {log: true, crop: scale}
336
+ length_cm: {crop: ignore, min_visible: 0.95}
345
337
  ```
346
338
 
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.
339
+ The fraction is the share of the instance's mask mass kept inside the window (box overlap for the detect family),
340
+ exposed as `TrainAugment.last_info["visible"]`. `crop` applies to regression heads only, is training-only (not stored in
341
+ the checkpoint), and is not applied to a custom `transform=`.
360
342
 
361
343
  **Sidecar format (optional).** You can keep the COCO JSON as plain, standard COCO and put the
362
344
  attributes beside it instead of inside it: an `attributes.yaml` schema (same keys, regression heads included) in the dataset root plus
@@ -366,101 +348,140 @@ dataset always works and the sidecar is picked up automatically when you add it.
366
348
  `attributes` in the JSON take precedence.
367
349
 
368
350
  A tiny, self-contained example (two heads, including a non-contiguous id set) lives in
369
- [sample_data/](sample_data/).
351
+ [sample_data/](sample_data/), with its run config [sample_data/run.yaml](sample_data/run.yaml).
352
+
353
+ Datasets built before the run config may still carry `applies_to` / `flip_aware` / `log` / `range` / `crop` in their
354
+ attribute definitions: they are still read, and the run config's `attributes` win over them.
370
355
 
371
356
  ---
372
357
 
358
+ ## Run config (one YAML per run)
359
+
360
+ **The dataset holds the data; one YAML holds the run.** The COCO JSONs carry the images, the instances and each
361
+ attribute's name, type and categories — nothing about training. Everything else is written out in the YAML that
362
+ `--data` points at, the **run config**: the data paths, which attributes get a head and how each learns, the
363
+ augmentation, the `train()` settings and the inference settings saved with the model. One file says how a model was
364
+ made, so a run can be read, repeated and compared from it. Start from the commented template
365
+ [`sample_data/run.yaml`](sample_data/run.yaml):
366
+
367
+ ```yaml
368
+ # --- data
369
+ path: .
370
+ train_images: images/train
371
+ train_json: annotations/instances_train.json
372
+ val_images: images/val
373
+ val_json: annotations/instances_val.json
374
+
375
+ # --- the heads, and how each learns: flip_aware, crop, min_visible, log, range, applies_to
376
+ # (an attribute of the data left out gets no head; no section: every attribute as the data declares it)
377
+ attributes:
378
+ scale:
379
+ viewpoint:
380
+ # side: {flip_aware: {left: right}}
381
+ # price: {log: true, range: [0, 20000], applies_to: [sofa]}
382
+ # surface_cm2: {log: true, crop: scale}
383
+
384
+ # --- augmentation: AugConfig fields over its defaults (`train_aug`, the older name, is the same section)
385
+ aug:
386
+ flip_prob: 0.5
387
+ min_scale: 0.4
388
+ max_scale: 1.6
389
+
390
+ # --- train() keywords: anything train() takes but the data paths (the size: --size, the device: --device)
391
+ train:
392
+ project: ../runs/train
393
+ name: sample
394
+ epochs: 12
395
+ imgsz: 644
396
+ batch: 4
397
+ # init_weights: ../runs/train/eomt-l/weights/best.pt # warm start from a checkpoint
398
+
399
+ # --- saved in every checkpoint: the defaults of predict() / track() with that model
400
+ inference:
401
+ conf_thres: 0.3
402
+ max_det: 100
403
+ ```
404
+
405
+ ```bash
406
+ python scripts/train.py --data sample_data/run.yaml --size s --device 0,1 # the whole run from the file
407
+ python scripts/train.py --data sample_data/run.yaml --size s --epochs 1 # a flag beats the file
408
+ python scripts/predict.py runs/train/sample/weights/best.pt images/ # with the conf_thres it was saved with
409
+ ```
410
+
411
+ - **Precedence:** explicit arguments (command line, `train()` / `EoMT.train()` keywords) beat the YAML, which beats the
412
+ library's defaults. Relative paths (`path`, `train.init_weights`, `train.project`) are relative to the YAML.
413
+ - **Recorded:** `args.yaml` in the run folder and every checkpoint (`run_config`) hold the resolved run — every
414
+ hyper-parameter, the augmentation and how each head learned (with its regression statistics); the checkpoint also
415
+ keeps `inference`, which `predict()` / `track()` (and `scripts/predict.py`) use for any setting the call leaves out.
416
+ - **Errors early:** an attribute the YAML configures but the data does not define, an unknown behavior or inference key,
417
+ or a data path / size / device inside `train` stops the run before it starts.
418
+
373
419
  ## Cross-photo re-identification
374
420
 
375
- Several photos of the same scene from different viewpoints, and some instance appears
376
- in three of them — is that one instance seen three times, or three separate ones?
377
- `infer_match()` answers that at **inference time, with no second model and nothing
378
- retrained**: every detected instance already carries a fingerprint, the per-query
379
- embedding that feeds the class head, and two detections are the same physical instance
380
- when those embeddings agree.
421
+ Given several photos of one subject, `infer_match()` decides which detections are the same physical instance, with no
422
+ second model and nothing retrained: the per-query embedding that feeds the class head is the fingerprint. Photos are
423
+ matched pairwise with the Hungarian matcher and linked into identities with union-find (at most one instance per photo,
424
+ plus a merge guard against transitive chaining). `group_by` limits which pairs may compete (same class by default; add
425
+ attribute heads to tighten it) and `sim_thres` (cosine, default `0.7`) is the floor for "same instance". Each run
426
+ writes its candidate pairs and similarity diagnostics to JSON, so the threshold can be retuned without re-running
427
+ inference.
381
428
 
382
429
  ```python
383
- from eomt import EoMT
384
-
385
- model = EoMT("runs/train/eomt-l/weights/best.pt")
386
- result = model.infer_match("photos/subject_42/", group_by=("class",))
430
+ result = EoMT("runs/train/eomt-l/weights/best.pt").infer_match("photos/subject_42/", group_by=("class",))
431
+ ```
387
432
 
388
- for rec in result["identities"]:
389
- print(rec["identity_id"], rec["class_name"], rec["attributes"], rec["num_photos"])
433
+ ```bash
434
+ python scripts/match.py weights/best.pt photos/ --each-subdir # one folder per subject
390
435
  ```
391
436
 
392
- One call handles one folder = one subject. Each detection is stamped with an
393
- `identity_ids` entry, `identities` summarizes each identity (dominant class, smoothed
394
- attributes, how many photos it appears in), and `matches` records every candidate pair
395
- — accepted or not, with the reason — so a threshold can be retuned from the JSON
396
- without re-running inference. With `plot` you get a grid image: every photo, instances
397
- colored by identity, plain lines linking the matched pairs (thickness = similarity, on
398
- an absolute scale that does not vary with panel size), and a legend.
437
+ Every option is documented in [`eomt/engine/match.py`](eomt/engine/match.py) and `python scripts/match.py --help`.
438
+
439
+ ### Predicting the photos together (cross-view)
440
+
441
+ Re-identification links what each photo found on its own. A **cross-view** model predicts
442
+ the photos of one subject (1–4) in **one** forward pass: after each of the four query
443
+ blocks, the queries of every photo attend to the patches and queries of the *other*
444
+ photos (plus a null token to abstain). An instance that is tiny or covered in one photo
445
+ can be found because another shows it clearly, and an ambiguous close-up can take its
446
+ class and attributes from the wide shot. Each photo's masks still cover only what that
447
+ photo shows, and a photo alone is predicted bit for bit as before. A small matching head
448
+ embeds every instance for the cross-photo links.
449
+
450
+ It is a post-training of an existing checkpoint, and it needs no multi-photo annotations:
451
+ each set is made from one annotated photo — a hard view (shrunken, degraded, occluded or a
452
+ close-up), a view that clears it up, and optionally a distractor photo and a mirrored copy
453
+ — and every instance keeps its annotation id across the views:
399
454
 
400
455
  ```bash
401
- python scripts/match.py weights/best.pt photos/subject_42/
402
- python scripts/match.py weights/best.pt photos/ --each-subdir # a folder per subject
456
+ # post-train a trained model -> runs/train/multiview/weights/best.pt
457
+ python scripts/train_multiview.py --data my/data.yaml --weights runs/train/eomt-l/weights/best.pt --device 0,1
458
+
459
+ # predict the photos of each subject together: photos/ holds one subject's photos, or a subfolder per subject
460
+ python scripts/predict.py runs/train/multiview photos/ --subject
403
461
  ```
404
462
 
405
- ### How it works
406
-
407
- It is the DeepSORT recipe — detect, describe, associate — applied *between photos*
408
- instead of between video frames, which is also what [`track()`](eomt/engine/track.py)
409
- does for video. Three EoMT properties make the fingerprint free:
410
-
411
- 1. the model is **NMS-free**, so two overlapping instances stay two distinct queries;
412
- 2. each query owns one instance, so its embedding describes that instance alone;
413
- 3. the embedding is already computed on the way to the class head.
414
-
415
- Matching runs the **Hungarian** matcher per pair of photos, then links the accepted
416
- pairs into identities with union-find. Two constraints keep that honest: one physical
417
- instance appears **at most once per photo** (union-find would otherwise chain two
418
- instances of the same photo together through a third), and a **merge guard** rejects a
419
- merge whose cross-cut similarity falls below the threshold — without it, A↔B and B↔C
420
- chain into one identity even when A and C are nothing alike.
421
-
422
- ### Gating and thresholds
423
-
424
- `group_by` decides which pairs may match at all. The default `("class",)` means only
425
- same-class instances compete. Adding attribute heads tightens it, which matters
426
- whenever the primary class is coarser than the distinction you care about: if one class
427
- covers instances that sit at different places on the subject, an attribute head that
428
- separates them — `("class", "position")`, say — stops an instance in one location from
429
- ever matching one in another, however alike they look. Any head in the checkpoint can
430
- be named; unknown names raise before the first forward pass. `group_by=None` disables
431
- gating entirely and needs a much higher `sim_thres`, since far more pairs then compete
432
- with no structural prior ruling any of them out.
433
-
434
- `sim_thres` (default `0.7`) is the cosine floor for "same instance". Hungarian always
435
- returns a full assignment, so the threshold is what turns *best available partner* into
436
- *no partner — this is a new instance*. Measured on one checkpoint across four real
437
- photo sets, with the gate on:
438
-
439
- | | non-candidate pairs | true-match pairs |
440
- |---|---|---|
441
- | mean | 0.11 – 0.16 | 0.85 – 0.98 (the true-match mode) |
442
- | p99 / max | 0.40 – 0.67 / 0.49 – 0.79 | — |
443
-
444
- The default was raised from `0.6` to `0.7` after a 30-case run showed non-candidate
445
- pairs reaching `0.73` and the two distributions overlapping in over half the cases —
446
- `0.6` was admitting matches with no margin at all. Every run
447
- writes its own `diagnostics` (within-identity vs across-identity percentiles) into
448
- `<subject>_identities.json`; if those two distributions overlap, no threshold will
449
- save the run.
450
-
451
- ### Limitations
452
-
453
- - **A gate group with one instance per photo gets no benefit from the embedding.**
454
- Hungarian has no choice to make, and only `sim_thres` can veto the pairing. The
455
- fingerprint earns its keep where several instances of one group compete in the same
456
- photo.
457
- - **Small-instance recall caps what can be matched.** An instance that is never
458
- detected in a view cannot be linked to it.
459
- - **One folder must be one subject.** A folder holding photos of more than one subject
460
- will happily link generic-looking instances across them; that is a data problem, not
461
- a matching one.
462
- - **Letterboxing shifts scale with orientation**, so a landscape and a portrait shot of
463
- the same subject are not on quite equal footing.
463
+ `scripts/train_multiview.py` is `scripts/train.py` with the recipe as defaults (`--multiview configs/xview.yaml
464
+ --batch 1 --epochs 2 --val-interval 1 --name multiview`); any other flag of `scripts/train.py` can be added and wins.
465
+ In Python, `EoMT(path).predict("photos/", subject=True)`.
466
+
467
+ The default config is stage A: only the new modules train (about 18 M parameters on `l`),
468
+ the checkpoint stays frozen; stage B adds `query_blocks: 0.1` to `trainable`. `best.pt` is
469
+ chosen on fixed synthetic validation sets: the hard photo's segm mAP predicted with its set
470
+ (`val/sets/segm/mAP_set`, next to `_alone`), its attributes, and the link F1 of the
471
+ matching head against plain re-ID (`val/sets/match/*` vs `val/sets/reid/*`). Synthetic sets
472
+ show neither real viewpoint changes nor real reflections: measure on real multi-photo sets
473
+ before trusting a gain. `infer_match()` and `scripts/match.py` predict a cross-view
474
+ checkpoint jointly (`joint=False` for one pass per photo), with its calibrated threshold
475
+ and no class gate; `eomt.engine.predict.predict_set` returns the per-photo results alone.
476
+
477
+ For a subject with more photos than a set holds, `eomt.engine.predict.predict_subject` groups
478
+ them (`subject_sets`) and returns one result per photo: `mode="order"` (the default) predicts
479
+ them `max_views` at a time in the given order, every photo once (about the cost of predicting
480
+ them one by one); `"partition"` groups similar photos (mean patch token of a small pass);
481
+ `"anchor"` predicts each photo with its `k` most similar others. On real multi-photo sets
482
+ (close-ups plus overviews of each subject) the three gave the same gain: what helps is seeing
483
+ other photos of the subject, not a particular one. `scripts/predict.py --subject [MODE]` and
484
+ `predict(subject=MODE)` take the mode (`--subject` alone is `"order"`).
464
485
 
465
486
  ---
466
487
 
@@ -484,8 +505,9 @@ from eomt import EoMT
484
505
  # Train on COCO 2017 (auto-downloaded on first run):
485
506
  EoMT("l").train(data="coco", batch=4) # 12 epochs, validated every 2: the official recipe
486
507
 
487
- # ...or any COCO-format dataset (point at its data.yaml):
488
- EoMT("s").train(data="sample_data/data.yaml", epochs=1, batch=1)
508
+ # ...or any COCO-format dataset, the run written out in its run config (see "Run config"):
509
+ EoMT("s").train(data="sample_data/run.yaml")
510
+ EoMT("s").train(data="sample_data/run.yaml", epochs=1) # a keyword beats the file
489
511
 
490
512
  # Validate and predict from a trained run:
491
513
  EoMT("runs/train/eomt-l").val(data="coco")
@@ -494,9 +516,7 @@ EoMT("runs/train/eomt-l").predict("images/", plot=True) # writes annotated ima
494
516
 
495
517
  **Several GPUs.** `device="0,1"` trains on both GPUs, one process each (DistributedDataParallel); `"auto"` is GPU 0.
496
518
  `batch` is per GPU and `nominal_batch` (16) stays the global effective batch, so the optimizer steps, LR schedule and
497
- masked-attention annealing are exactly those of one GPU, with an epoch 1.9× faster on two RTX 5090s (1.8× end to end with
498
- validation and checkpoints). Validation splits the val images between the
499
- GPUs; the main process scores them and alone logs and writes checkpoints. The processes are spawned, so every `train()`
519
+ masked-attention annealing are exactly those of one GPU. Validation splits the val images between the GPUs; the main process scores them and alone logs and writes checkpoints. The processes are spawned, so every `train()`
500
520
  argument must be picklable.
501
521
 
502
522
  ```python
@@ -521,9 +541,7 @@ m.predict("images/", augment={"flip": True}, tiles=1024) # 1x / 1.5x
521
541
  ```
522
542
 
523
543
  `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`
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
526
- whole image.
544
+ in [Command-line scripts](#command-line-scripts-arguments-and-things-to-take-into-account)). Each view is one more forward pass.
527
545
 
528
546
  For the full training recipe, every `train()` knob, and int8 compression, see the
529
547
  **[annotated explainer →](https://imagra93.github.io/attr-eomt)** — it's the deep dive.
@@ -547,9 +565,8 @@ close to the head's speed: scaling the whole encoder by 0.1 (as detector-style r
547
565
  LR decay and the annealing span `epochs`.
548
566
 
549
567
  **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
568
+ the epoch's end. A model that still masks is far worse without its masks, so a mask-free curve before the last sixth follows the
569
+ annealing and jumps as each block finishes it, not the model. `best.pt` is deployed mask-free, so it is only chosen once every block's annealing is
553
570
  over (a run stopped earlier, e.g. with `stop_after_epochs`, has `last.pt` alone), and every checkpoint is saved mask-free.
554
571
  Training prints the windows in epochs when it starts.
555
572
 
@@ -587,9 +604,10 @@ Things to take into account when training:
587
604
  every validation is eligible.
588
605
  - **Validated epochs only.** With `--val-interval N` the other epochs have empty validation cells in `metrics.csv`; the curves in
589
606
  `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.
607
+ - **Attribute heads follow the run config.** Which attributes train and their `applies_to`, `flip_aware`, `crop`, `log` and
608
+ `range` come from the YAML's `attributes` section (see *Run config*), else from the dataset's definitions; the training
609
+ banner lists them. The flip mapping, scope and regression statistics are stored in the checkpoint, and the whole run
610
+ config in `args.yaml` and the checkpoint's `run_config`.
593
611
 
594
612
  **`scripts/val.py`.**
595
613
 
@@ -629,41 +647,32 @@ Things to take into account when validating:
629
647
  All are keyword arguments of `train()` (and flags of `scripts/train.py`); checkpoints record them, so `EoMT(path)` rebuilds
630
648
  the model with the same modules, and checkpoints without them load as before.
631
649
 
632
- | option | what it does | cost, ViT-L at 644 px, batch 2, bf16 (measured, relative to the default) |
633
- |---|---|---|
634
- | `fpn_scales=None` | drop the multi-scale FPN (default `(2, 1, 0.5)`) | +12 % throughput, −0.5 GB |
635
- | `box_head=True` | auxiliary box head: L1 + GIoU loss on the matched queries, and the same terms in the matching cost (a localisation signal that does not depend on the mask grid). Predicted boxes are returned as `aux_boxes` / `head_boxes` | −7 % |
636
- | `deep_supervision=False` | stop supervising a query block's prediction once that block's masked attention is annealed away (while it masks, the prediction builds its attention mask and stays supervised: unsupervised, it made noise masks). Blocks drop out at 2/6, 3/6, 4/6, 5/6 of training | none while blocks mask; +26 % per step once all are annealed (last sixth) |
637
- | `num_upscale_blocks=3` | 368² mask logits at 644 px (small masks); the block predictions stay at 184² (they only build 46² attention masks), only the output is 368²; cannot warm-start a 2-block checkpoint | −17 % (10.1 vs 12.1 img/s), 22.2 GB |
638
- | `iou_aware_cls=True` | train a matched query's class probability towards `(1 + mask IoU) / 2` instead of 1 (IoU-aware targets, as in VarifocalNet / Stable-DINO), so the score ranks loose masks below tight ones; above 0.5, so matched queries keep their class for the attribute gate. Hard targets reduce exactly to the weighted CE. Windscreen damage, 12 epochs at 644 px, against the same recipe: segm AP 0.0296 -> 0.0758, AP small 0.0033 -> 0.0106, AP large 0.128 -> 0.210, bbox AP 0.177 -> 0.217, attribute accuracy unchanged | one upsample of the matched masks |
639
- | `quality_weight=2` | mask-quality head: a small MLP on the query embedding predicts each matched query's mask IoU (BCE against the measured IoU, final prediction only; Mask Scoring R-CNN), and postprocess scores class probability x predicted IoU instead of the mean mask probability. Windscreen damage, with `iou_aware_cls`, two seeds against the same recipe without the head: segm AP +7 / +13 %, AP small +19 % / 3.6x, AP medium +21 / +22 %, AR100 +2 / +5 %, bbox AP and attribute accuracy unchanged | one MLP on the queries |
640
- | `val_imgsz=N` | validate (and pick `best.pt`) at another input size than `imgsz`. Scale jitter shows the objects larger, on average, than a val image resized to `imgsz`: validate at `imgsz` × the mean training scale. Windscreens (training scale 0.4-1.0 of the photo, val 0.5), same checkpoint: segm AP 0.0113 at 644, 0.0173 at 896 | val time grows with the pixels |
641
- | `stop_after_epochs=N` | stop after N epochs while the LR schedule and mask annealing still span `epochs` (truncated A/B runs) | |
650
+ | option | what it does |
651
+ |---|---|
652
+ | `fpn_scales=None` | drop the multi-scale FPN (default `(2, 1, 0.5)`) |
653
+ | `box_head=True` | auxiliary box head: L1 + GIoU loss on the matched queries, and the same terms in the matching cost (a localisation signal that does not depend on the mask grid). Predicted boxes are returned as `aux_boxes` / `head_boxes` |
654
+ | `deep_supervision=False` | stop supervising a query block's prediction once that block's masked attention is annealed away (while it masks, the prediction builds its attention mask and stays supervised). Blocks drop out at 2/6, 3/6, 4/6, 5/6 of training |
655
+ | `num_upscale_blocks=3` | finer mask logits (368² at 644 px) for small objects; cannot warm-start a 2-block checkpoint |
656
+ | `iou_aware_cls=True` | train a matched query's class probability towards `(1 + mask IoU) / 2` instead of 1 (IoU-aware targets, as in VarifocalNet / Stable-DINO), so the score ranks loose masks below tight ones; above 0.5, so matched queries keep their class for the attribute gate. Hard targets reduce exactly to the weighted CE |
657
+ | `quality_weight=2` | mask-quality head: a small MLP on the query embedding predicts each matched query's mask IoU (BCE against the measured IoU, final prediction only; Mask Scoring R-CNN), and postprocess scores class probability x predicted IoU instead of the mean mask probability |
658
+ | `val_imgsz=N` | validate (and pick `best.pt`) at another input size than `imgsz`. Scale jitter shows the objects larger, on average, than a val image resized to `imgsz`: validate at `imgsz` × the mean training scale |
659
+ | `stop_after_epochs=N` | stop after N epochs while the LR schedule and mask annealing still span `epochs` (truncated A/B runs) |
642
660
 
643
661
  Always on (training-side; inference of existing checkpoints is unchanged):
644
662
 
645
- - **Masked attention keeps small objects in view.** A query may attend to every patch its predicted mask touches (max-pool of
663
+ - **Masked attention keeps small objects in view.** A query attends to every patch its predicted mask touches (max-pool of
646
664
  the mask logits onto the patch grid), and a query whose mask touches no patch attends to all of them, as in Mask2Former. The
647
- reference EoMT downsamples bilinearly (only the centre of each patch counts) and leaves an empty-mask query blind, which in
648
- practice blinded the queries of most objects smaller than a patch during the masked phase. The mask is a boolean
649
- `[B, 1, N, N]` and is not built in a block whose annealing probability has reached 0.
650
- - **Mask terms that see small objects.** Matching scores every query on every cell of the mask-logit grid against the GT
651
- area-averaged onto it, instead of on 12,544 random points (one per ~33 px² at 644 px, so an object of a few dozen pixels often
652
- got no point and its assigned query changed from step to step). The mask loss keeps the reference's BCE on 12,544 points
653
- concentrated where the prediction is uncertain (the boundaries), and computes dice on every GT pixel against the upsampled
654
- logits, so every instance is supervised however small. A fully dense loss gave coarser masks (AP75 down) on thin objects;
655
- dropping the points altogether lost the boundaries. The `train_num_points` / `oversample_ratio` / `importance_sample_ratio`
656
- settings are gone (fixed at the reference values; checkpoints that carry them still load).
665
+ reference EoMT samples the mask bilinearly at patch centres and leaves an empty-mask query blind.
666
+ - **Mask terms that see small objects.** Matching scores every query on every cell of the mask-logit grid instead of on
667
+ 12,544 random points, and dice is computed on every GT pixel, so every instance is supervised however small. BCE keeps the
668
+ reference's 12,544 points concentrated on the uncertain boundaries.
657
669
  - **Attribute IoU gate at full resolution.** The gate that decides which matched queries train the attribute heads compares the
658
- upsampled prediction with the GT, instead of shrinking the GT to the logit grid with "nearest" (which erased many objects of a
659
- few pixels, so they never trained the attribute heads).
670
+ upsampled prediction with the GT, so objects of a few pixels are not erased by a "nearest" downscale.
660
671
 
661
- Inference: `predict_image(..., amp=True)` (and `predict(..., amp=True)`) runs the network under bf16 autocast, ~2.5x faster at 644 px
662
- on an RTX 5090 with the same accuracy on a trained ViT-L model (AP within 0.002 in every size bucket). Off by default.
672
+ Inference: `predict_image(..., amp=True)` (and `predict(..., amp=True)`) runs the network under bf16 autocast. Off by default.
663
673
 
664
- **What is not established:** the accuracy effect of `box_head`, `deep_supervision=False`,
665
- `iou_aware_cls` and `quality_weight` (beyond the windscreen data), dropping the FPN and the always-on changes above depends on the data. Measure it with truncated runs (`stop_after_epochs`), which follow the schedule of a full run, against a reference run at equal
666
- epochs.
674
+ The accuracy effect of these options depends on the data. Measure it with truncated runs (`stop_after_epochs`), which
675
+ follow the schedule of a full run, against a reference run at equal epochs.
667
676
 
668
677
  ---
669
678
 
@@ -685,7 +694,7 @@ mass-preserving), so thin objects are not shredded into dots by nearest-neighbou
685
694
  | masks | `mask_resize="area"` (or `"nearest"`) |
686
695
 
687
696
  Override per run, per dataset, or from the CLI — precedence is explicit keywords (`flip_prob`, `min_scale`,
688
- `max_scale`) **>** `aug=` **>** the dataset YAML's `train_aug` block **>** defaults:
697
+ `max_scale`) **>** `aug=` **>** the YAML's `aug` (or `train_aug`) block **>** defaults:
689
698
 
690
699
  ```python
691
700
  model.train(data="coco", aug={"rotate_prob": 0.5, "blur_prob": 0}) # in code
@@ -712,16 +721,16 @@ Things worth knowing:
712
721
  orientation (e.g. a `viewpoint` head) set `flip_prob=0` and `rotate_prob=0`, or, for a left / right attribute,
713
722
  mark it `flip_aware` (see *Flip-aware attributes*) and keep the horizontal flip; `vflip_prob` / `rot90_prob` stay
714
723
  off unless your scenes have no canonical "up".
715
- - **Thin or tiny objects.** Large-Scale Jitter at 0.1 shrinks a 5 px crack to under a pixel; keep the effective
724
+ - **Thin or tiny objects.** Large-Scale Jitter at 0.1 shrinks a 5 px wide line to under a pixel; keep the effective
716
725
  scale within ~[0.4, 1.0] (`min_scale = 0.4·long_side/imgsz`, `max_scale = 1.0·long_side/imgsz`). The instance-aware
717
726
  crop pays off when the crop is much smaller than the image (high zoom, small `imgsz`), and
718
727
  `instance_crop_rarity_attr` makes rare attribute values the crop's focus more often.
719
728
  - **Close-ups.** `zoom_prob` makes an occasional sample a close-up: scaled by `s ~ U(zoom_scale)` (default 4–5x
720
729
  the whole-image view, past the photo's own resolution) instead of the LSJ range, with the window on one instance
721
730
  (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.
731
+ attribute and to the samples that have one, e.g. `{"zoom_prob": 0.1, "zoom_attr": "surface_cm2"}` so a crop-scaled
732
+ head also learns that an object filling the frame keeps its real surface, and that a cut object keeps the share in view.
733
+ It resizes the whole photo first, so keep it occasional.
725
734
  - **Multi-image ops** (`mosaic`, `mixup`, `copy_paste`) add instances from other samples and are *off*: they splice or
726
735
  blend long thin structures, so try them deliberately and look at the result.
727
736
  - **Negatives.** Images with no annotations are dropped from the train set by default. `train(keep_empty=True)` keeps