euler-train 2.0.0__tar.gz → 2.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.
- {euler_train-2.0.0 → euler_train-2.2.0}/PKG-INFO +1 -1
- {euler_train-2.0.0 → euler_train-2.2.0}/README.md +50 -3
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train/__init__.py +11 -1
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train/outputs.py +145 -18
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train/run.py +149 -6
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train.egg-info/PKG-INFO +1 -1
- {euler_train-2.0.0 → euler_train-2.2.0}/pyproject.toml +1 -1
- {euler_train-2.0.0 → euler_train-2.2.0}/tests/test_runlog.py +112 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train/_dataset_contract.py +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train/architecture.py +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train/environment.py +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train/git_info.py +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train/serialization.py +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train/slurm.py +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train.egg-info/SOURCES.txt +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train.egg-info/dependency_links.txt +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train.egg-info/requires.txt +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/euler_train.egg-info/top_level.txt +0 -0
- {euler_train-2.0.0 → euler_train-2.2.0}/setup.cfg +0 -0
|
@@ -80,9 +80,9 @@ The run ID and directory are available as `run.run_id` and `run.dir`.
|
|
|
80
80
|
|
|
81
81
|
## API reference
|
|
82
82
|
|
|
83
|
-
### `euler_train.init(dir, config=None, meta=None, output_formats=None, run_id=None, datasets=None, run_name=None, evaluations=None, mode=None) → Run`
|
|
83
|
+
### `euler_train.init(dir, config=None, meta=None, output_formats=None, output_visualization=None, run_id=None, datasets=None, run_name=None, evaluations=None, mode=None) → Run`
|
|
84
84
|
|
|
85
|
-
Creates the run directory and writes `meta.json`, `config.json`, `code_ref.json`, and `run_environment.json`. On resume (`run_id` provided), only `meta.json` and `config.json` are updated.
|
|
85
|
+
Creates the run directory and writes `meta.json`, `config.json`, `code_ref.json`, and `run_environment.json`. On resume (`run_id` provided), only `meta.json` and `config.json` are updated. `meta.json` also maintains an `updated_at` map with last-write timestamps for tracked artifacts.
|
|
86
86
|
|
|
87
87
|
| Parameter | Type | Description |
|
|
88
88
|
|---|---|---|
|
|
@@ -90,6 +90,7 @@ Creates the run directory and writes `meta.json`, `config.json`, `code_ref.json`
|
|
|
90
90
|
| `config` | `dict \| str \| Path \| Namespace \| dataclass` | Hyperparameters. Paths to `.json` / `.yaml` files are loaded automatically. |
|
|
91
91
|
| `meta` | `dict \| None` | Extra fields merged into `meta.json` (e.g. `{"tags": ["baseline"]}`). |
|
|
92
92
|
| `output_formats` | `dict[str, str] \| None` | Override format inference (see [Format inference](#format-inference)). |
|
|
93
|
+
| `output_visualization` | `dict[str, Any] \| None` | Override PNG visualization policy per output type / slot (see [Output visualization](#output-visualization)). |
|
|
93
94
|
| `run_id` | `str \| None` | Resume an existing run at `{dir}/runs/{run_id}` instead of creating a new one. |
|
|
94
95
|
| `datasets` | `dict[str, Any] \| None` | Optional split → dataset map. If a dataset exposes `describe_for_runlog()`, that contract is used directly; otherwise euler_train infers structured modality metadata (`path`, `used_as`, `slot`, `modality_type`, and hierarchical fields), resolving fixed namespaced properties from `properties.euler_loading` and `properties.euler_train` before heuristics. |
|
|
95
96
|
| `run_name` | `str \| None` | Optional human-readable run label stored in `meta.json`. |
|
|
@@ -218,7 +219,7 @@ Arrays are saved as `.png` or `.npy` based on shape and dtype:
|
|
|
218
219
|
|---|---|
|
|
219
220
|
| `uint8` with shape `(H,W)` | `.png` (grayscale) |
|
|
220
221
|
| Any dtype with shape `(H,W,1)`, `(H,W,3)`, `(H,W,4)` | `.png` |
|
|
221
|
-
| Float `.png` |
|
|
222
|
+
| Float `.png` | defaults to `unit_range`: clip to `[0,1]`, then scale to `[0,255]` |
|
|
222
223
|
| Everything else (e.g. `float32 (H,W)`) | `.npy` |
|
|
223
224
|
| PIL Image | `.png` |
|
|
224
225
|
|
|
@@ -240,6 +241,33 @@ run = euler_train.init(
|
|
|
240
241
|
|
|
241
242
|
Supported formats: `"png"`, `"npy"`, `"npz"`.
|
|
242
243
|
|
|
244
|
+
## Output visualization
|
|
245
|
+
|
|
246
|
+
Float PNG outputs use a rendering policy before quantization to `uint8`. Policies are configured once at `init()` via `output_visualization`, and keys resolve most-specific-first just like `output_formats`:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
run = euler_train.init(
|
|
250
|
+
dir="runs/exp",
|
|
251
|
+
config=cfg,
|
|
252
|
+
output_visualization={
|
|
253
|
+
"depth": {"mode": "percentile", "pmin": 1, "pmax": 99},
|
|
254
|
+
"depth.pred": {"mode": "fixed_range", "vmin": 0.0, "vmax": 80.0},
|
|
255
|
+
"confidence": "minmax",
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Supported visualization modes:
|
|
261
|
+
|
|
262
|
+
| Mode | Parameters | Description |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| `unit_range` | none | Clip float data to `[0,1]` before saving. |
|
|
265
|
+
| `minmax` | none | Normalize using the finite-value min / max of the saved item. |
|
|
266
|
+
| `percentile` | `pmin`, `pmax` | Normalize using finite-value percentiles, useful for depth-like outputs. |
|
|
267
|
+
| `fixed_range` | `vmin`, `vmax` | Normalize against an explicit numeric range. |
|
|
268
|
+
|
|
269
|
+
All float PNG policies also accept `invert: true` to flip black ↔ white after normalization.
|
|
270
|
+
|
|
243
271
|
## `meta.json` schema
|
|
244
272
|
|
|
245
273
|
Auto-managed, not written to directly.
|
|
@@ -337,6 +365,24 @@ Auto-managed, not written to directly.
|
|
|
337
365
|
"traceback": "Traceback (most recent call last):\n ..."
|
|
338
366
|
}
|
|
339
367
|
},
|
|
368
|
+
"updated_at": {
|
|
369
|
+
"meta.json": {
|
|
370
|
+
"time": 1706403300.0,
|
|
371
|
+
"iso": "2025-01-28T16:25:00"
|
|
372
|
+
},
|
|
373
|
+
"config.json": {
|
|
374
|
+
"time": 1706400000.0,
|
|
375
|
+
"iso": "2025-01-28T15:30:42"
|
|
376
|
+
},
|
|
377
|
+
"train.jsonl": {
|
|
378
|
+
"time": 1706402999.0,
|
|
379
|
+
"iso": "2025-01-28T16:19:59"
|
|
380
|
+
},
|
|
381
|
+
"outputs/epoch_12_step_4800": {
|
|
382
|
+
"time": 1706403010.0,
|
|
383
|
+
"iso": "2025-01-28T16:20:10"
|
|
384
|
+
}
|
|
385
|
+
},
|
|
340
386
|
"error": "RuntimeError: CUDA OOM",
|
|
341
387
|
"traceback": "Traceback (most recent call last):\n ..."
|
|
342
388
|
}
|
|
@@ -347,6 +393,7 @@ Auto-managed, not written to directly.
|
|
|
347
393
|
- `datasets` is only present when `datasets=...` is passed to `euler_train.init`.
|
|
348
394
|
- `evaluations` is only present when evaluations are provided via `evaluations=...` on `init()` or added via `run.add_evaluation()`.
|
|
349
395
|
- `modes` is only present when `mode=...` is passed to `euler_train.init`; each key stores the latest lifecycle snapshot for that mode.
|
|
396
|
+
- `updated_at` stores last-write timestamps for tracked artifacts. Keys are run-relative paths when the artifact lives inside the run directory (for example `train.jsonl` or `outputs/epoch_2_step_500`) and absolute paths for external artifacts such as custom checkpoint locations.
|
|
350
397
|
- `error` is only present when `status` is `"crashed"` (context manager / excepthook) or `"interrupted"` (SIGTERM/SIGINT). `traceback` is only present when `status` is `"crashed"`. When `mode=...` is set, the same fields are mirrored under `modes[mode]`.
|
|
351
398
|
|
|
352
399
|
A formal JSON Schema for `meta.json` is available at [`meta-schema.json`](meta-schema.json).
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""runlog — lightweight file-based experiment logging."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
4
6
|
from . import _dataset_contract # noqa: F401
|
|
5
7
|
from .run import Run
|
|
6
8
|
from .architecture import export_architecture
|
|
@@ -14,6 +16,7 @@ def init(
|
|
|
14
16
|
config=None,
|
|
15
17
|
meta: dict | None = None,
|
|
16
18
|
output_formats: dict[str, str] | None = None,
|
|
19
|
+
output_visualization: dict[str, Any] | None = None,
|
|
17
20
|
run_id: str | None = None,
|
|
18
21
|
datasets: dict | None = None,
|
|
19
22
|
run_name: str | None = None,
|
|
@@ -51,6 +54,11 @@ def init(
|
|
|
51
54
|
(``"depth"``), a slot / aux name (``"transmission"``), or a
|
|
52
55
|
dotted combination (``"depth.pred"``). Values are ``"png"``,
|
|
53
56
|
``"npy"``, or ``"npz"``.
|
|
57
|
+
output_visualization:
|
|
58
|
+
Optional PNG rendering policy overrides. Keys resolve the same way
|
|
59
|
+
as *output_formats* (``"depth.pred"`` → ``"depth"`` → ``"pred"``).
|
|
60
|
+
Values can be a string mode (for example ``"percentile"``) or a
|
|
61
|
+
mapping such as ``{"mode": "fixed_range", "vmin": 0, "vmax": 80}``.
|
|
54
62
|
run_id:
|
|
55
63
|
If given, resume an existing run instead of creating a new one.
|
|
56
64
|
The run directory ``{dir}/runs/{run_id}/`` must already exist.
|
|
@@ -84,7 +92,9 @@ def init(
|
|
|
84
92
|
"""
|
|
85
93
|
return Run(
|
|
86
94
|
dir=dir, config=config, meta=meta,
|
|
87
|
-
output_formats=output_formats,
|
|
95
|
+
output_formats=output_formats,
|
|
96
|
+
output_visualization=output_visualization,
|
|
97
|
+
run_id=run_id,
|
|
88
98
|
datasets=datasets, run_name=run_name,
|
|
89
99
|
evaluations=evaluations, mode=mode,
|
|
90
100
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Save prediction / ground-truth / auxiliary outputs to disk."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
from collections.abc import Mapping
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
@@ -15,6 +16,7 @@ def save_output_tree(
|
|
|
15
16
|
type_dir: Path,
|
|
16
17
|
slots: dict[str, Any],
|
|
17
18
|
format_overrides: dict[str, str],
|
|
19
|
+
visualization_overrides: dict[str, Any],
|
|
18
20
|
output_type: str,
|
|
19
21
|
) -> None:
|
|
20
22
|
"""Persist all slots (pred, gt, input, aux/…) for one *output_type*.
|
|
@@ -35,13 +37,21 @@ def save_output_tree(
|
|
|
35
37
|
if aux_data is None:
|
|
36
38
|
continue
|
|
37
39
|
_save_slot(
|
|
38
|
-
type_dir / "aux" / aux_name,
|
|
39
|
-
|
|
40
|
+
type_dir / "aux" / aux_name,
|
|
41
|
+
aux_data,
|
|
42
|
+
output_type,
|
|
43
|
+
aux_name,
|
|
44
|
+
format_overrides,
|
|
45
|
+
visualization_overrides,
|
|
40
46
|
)
|
|
41
47
|
else:
|
|
42
48
|
_save_slot(
|
|
43
|
-
type_dir / slot_name,
|
|
44
|
-
|
|
49
|
+
type_dir / slot_name,
|
|
50
|
+
data,
|
|
51
|
+
output_type,
|
|
52
|
+
slot_name,
|
|
53
|
+
format_overrides,
|
|
54
|
+
visualization_overrides,
|
|
45
55
|
)
|
|
46
56
|
|
|
47
57
|
|
|
@@ -55,12 +65,16 @@ def _save_slot(
|
|
|
55
65
|
output_type: str,
|
|
56
66
|
leaf_name: str,
|
|
57
67
|
format_overrides: dict[str, str],
|
|
68
|
+
visualization_overrides: dict[str, Any],
|
|
58
69
|
) -> None:
|
|
59
70
|
slot_dir.mkdir(parents=True, exist_ok=True)
|
|
60
71
|
items = _unpack(data)
|
|
72
|
+
visualization = _resolve_visualization(
|
|
73
|
+
output_type, leaf_name, visualization_overrides,
|
|
74
|
+
)
|
|
61
75
|
for idx, item in enumerate(items):
|
|
62
76
|
fmt = _resolve_format(item, output_type, leaf_name, format_overrides)
|
|
63
|
-
_save_item(slot_dir / f"{idx:04d}.{fmt}", item, fmt)
|
|
77
|
+
_save_item(slot_dir / f"{idx:04d}.{fmt}", item, fmt, visualization)
|
|
64
78
|
|
|
65
79
|
|
|
66
80
|
# ---- normalisation -------------------------------------------------------
|
|
@@ -88,7 +102,12 @@ def _prepare(data: Any) -> Any:
|
|
|
88
102
|
|
|
89
103
|
# torch Tensor → numpy, channels-first → channels-last
|
|
90
104
|
if hasattr(data, "detach"):
|
|
91
|
-
|
|
105
|
+
tensor = data.detach().cpu()
|
|
106
|
+
try:
|
|
107
|
+
arr: np.ndarray = tensor.numpy()
|
|
108
|
+
except TypeError:
|
|
109
|
+
# NumPy cannot represent some torch dtypes (for example bfloat16).
|
|
110
|
+
arr = tensor.float().numpy()
|
|
92
111
|
# (C, H, W) → (H, W, C) when C looks like a channel dim
|
|
93
112
|
if (
|
|
94
113
|
arr.ndim == 3
|
|
@@ -119,14 +138,11 @@ def _is_image_like(arr: np.ndarray) -> bool:
|
|
|
119
138
|
return False
|
|
120
139
|
|
|
121
140
|
|
|
122
|
-
def
|
|
123
|
-
item: Any,
|
|
141
|
+
def _resolve_override(
|
|
124
142
|
output_type: str,
|
|
125
143
|
leaf_name: str,
|
|
126
|
-
overrides:
|
|
127
|
-
) ->
|
|
128
|
-
"""Pick save format: check overrides (most-specific first), then infer."""
|
|
129
|
-
# "rgb.pred" > "rgb" > "pred"
|
|
144
|
+
overrides: Mapping[str, Any],
|
|
145
|
+
) -> Any:
|
|
130
146
|
specific = f"{output_type}.{leaf_name}"
|
|
131
147
|
if specific in overrides:
|
|
132
148
|
return overrides[specific]
|
|
@@ -134,6 +150,19 @@ def _resolve_format(
|
|
|
134
150
|
return overrides[output_type]
|
|
135
151
|
if leaf_name in overrides:
|
|
136
152
|
return overrides[leaf_name]
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_format(
|
|
157
|
+
item: Any,
|
|
158
|
+
output_type: str,
|
|
159
|
+
leaf_name: str,
|
|
160
|
+
overrides: dict[str, str],
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Pick save format: check overrides (most-specific first), then infer."""
|
|
163
|
+
override = _resolve_override(output_type, leaf_name, overrides)
|
|
164
|
+
if override is not None:
|
|
165
|
+
return str(override)
|
|
137
166
|
|
|
138
167
|
# PIL Image
|
|
139
168
|
try:
|
|
@@ -148,11 +177,34 @@ def _resolve_format(
|
|
|
148
177
|
return "npy"
|
|
149
178
|
|
|
150
179
|
|
|
180
|
+
def _resolve_visualization(
|
|
181
|
+
output_type: str,
|
|
182
|
+
leaf_name: str,
|
|
183
|
+
overrides: Mapping[str, Any],
|
|
184
|
+
) -> dict[str, Any] | None:
|
|
185
|
+
override = _resolve_override(output_type, leaf_name, overrides)
|
|
186
|
+
if override is None:
|
|
187
|
+
return None
|
|
188
|
+
if isinstance(override, str):
|
|
189
|
+
return {"mode": override}
|
|
190
|
+
if isinstance(override, Mapping):
|
|
191
|
+
return dict(override)
|
|
192
|
+
raise TypeError(
|
|
193
|
+
"Visualization overrides must be strings or mappings, "
|
|
194
|
+
f"got {type(override)!r}",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
151
198
|
# ---- writers -------------------------------------------------------------
|
|
152
199
|
|
|
153
|
-
def _save_item(
|
|
200
|
+
def _save_item(
|
|
201
|
+
path: Path,
|
|
202
|
+
item: Any,
|
|
203
|
+
fmt: str,
|
|
204
|
+
visualization: dict[str, Any] | None = None,
|
|
205
|
+
) -> None:
|
|
154
206
|
if fmt == "png":
|
|
155
|
-
_save_png(path, item)
|
|
207
|
+
_save_png(path, item, visualization)
|
|
156
208
|
elif fmt == "npy":
|
|
157
209
|
np.save(str(path), item if isinstance(item, np.ndarray) else np.asarray(item))
|
|
158
210
|
elif fmt == "npz":
|
|
@@ -164,7 +216,11 @@ def _save_item(path: Path, item: Any, fmt: str) -> None:
|
|
|
164
216
|
raise ValueError(f"Unsupported format: {fmt!r}")
|
|
165
217
|
|
|
166
218
|
|
|
167
|
-
def _save_png(
|
|
219
|
+
def _save_png(
|
|
220
|
+
path: Path,
|
|
221
|
+
item: Any,
|
|
222
|
+
visualization: dict[str, Any] | None = None,
|
|
223
|
+
) -> None:
|
|
168
224
|
from PIL import Image
|
|
169
225
|
|
|
170
226
|
# PIL Image — save directly
|
|
@@ -174,10 +230,9 @@ def _save_png(path: Path, item: Any) -> None:
|
|
|
174
230
|
|
|
175
231
|
arr: np.ndarray = item
|
|
176
232
|
|
|
177
|
-
# float →
|
|
233
|
+
# float → visualization policy → uint8
|
|
178
234
|
if np.issubdtype(arr.dtype, np.floating):
|
|
179
|
-
arr =
|
|
180
|
-
arr = (arr * 255).astype(np.uint8)
|
|
235
|
+
arr = _render_float_png(arr, visualization)
|
|
181
236
|
elif arr.dtype != np.uint8:
|
|
182
237
|
arr = arr.astype(np.uint8)
|
|
183
238
|
|
|
@@ -192,3 +247,75 @@ def _save_png(path: Path, item: Any) -> None:
|
|
|
192
247
|
Image.fromarray(plane, mode=mode).save(str(path))
|
|
193
248
|
else:
|
|
194
249
|
raise ValueError(f"Cannot save {arr.ndim}D array as PNG")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _render_float_png(
|
|
253
|
+
arr: np.ndarray,
|
|
254
|
+
visualization: dict[str, Any] | None,
|
|
255
|
+
) -> np.ndarray:
|
|
256
|
+
policy = {"mode": "unit_range"}
|
|
257
|
+
if visualization is not None:
|
|
258
|
+
policy.update(visualization)
|
|
259
|
+
|
|
260
|
+
mode = str(policy.get("mode", "unit_range"))
|
|
261
|
+
finite = np.isfinite(arr)
|
|
262
|
+
|
|
263
|
+
if mode == "unit_range":
|
|
264
|
+
scaled = np.asarray(arr, dtype=np.float32)
|
|
265
|
+
elif mode == "minmax":
|
|
266
|
+
scaled = _normalize_minmax(arr, finite)
|
|
267
|
+
elif mode == "percentile":
|
|
268
|
+
pmin = float(policy.get("pmin", 1.0))
|
|
269
|
+
pmax = float(policy.get("pmax", 99.0))
|
|
270
|
+
if pmax <= pmin:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
"Percentile visualization requires pmax > pmin, "
|
|
273
|
+
f"got pmin={pmin} pmax={pmax}",
|
|
274
|
+
)
|
|
275
|
+
scaled = _normalize_percentile(arr, finite, pmin, pmax)
|
|
276
|
+
elif mode == "fixed_range":
|
|
277
|
+
vmin = float(policy.get("vmin", 0.0))
|
|
278
|
+
vmax = float(policy.get("vmax", 1.0))
|
|
279
|
+
if vmax <= vmin:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
"Fixed-range visualization requires vmax > vmin, "
|
|
282
|
+
f"got vmin={vmin} vmax={vmax}",
|
|
283
|
+
)
|
|
284
|
+
scaled = (np.asarray(arr, dtype=np.float32) - vmin) / (vmax - vmin)
|
|
285
|
+
else:
|
|
286
|
+
raise ValueError(f"Unsupported visualization mode: {mode!r}")
|
|
287
|
+
|
|
288
|
+
scaled = np.nan_to_num(scaled, nan=0.0, posinf=1.0, neginf=0.0)
|
|
289
|
+
if bool(policy.get("invert", False)):
|
|
290
|
+
scaled = 1.0 - scaled
|
|
291
|
+
scaled = np.clip(scaled, 0.0, 1.0)
|
|
292
|
+
return (scaled * 255).astype(np.uint8)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _normalize_minmax(arr: np.ndarray, finite: np.ndarray) -> np.ndarray:
|
|
296
|
+
values = arr[finite]
|
|
297
|
+
if values.size == 0:
|
|
298
|
+
return np.zeros_like(arr, dtype=np.float32)
|
|
299
|
+
|
|
300
|
+
vmin = float(values.min())
|
|
301
|
+
vmax = float(values.max())
|
|
302
|
+
if vmax <= vmin:
|
|
303
|
+
return np.zeros_like(arr, dtype=np.float32)
|
|
304
|
+
return (np.asarray(arr, dtype=np.float32) - vmin) / (vmax - vmin)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _normalize_percentile(
|
|
308
|
+
arr: np.ndarray,
|
|
309
|
+
finite: np.ndarray,
|
|
310
|
+
pmin: float,
|
|
311
|
+
pmax: float,
|
|
312
|
+
) -> np.ndarray:
|
|
313
|
+
values = arr[finite]
|
|
314
|
+
if values.size == 0:
|
|
315
|
+
return np.zeros_like(arr, dtype=np.float32)
|
|
316
|
+
|
|
317
|
+
lo = float(np.percentile(values, pmin))
|
|
318
|
+
hi = float(np.percentile(values, pmax))
|
|
319
|
+
if hi <= lo:
|
|
320
|
+
return np.zeros_like(arr, dtype=np.float32)
|
|
321
|
+
return (np.asarray(arr, dtype=np.float32) - lo) / (hi - lo)
|
|
@@ -38,6 +38,7 @@ class Run:
|
|
|
38
38
|
config: Any = None,
|
|
39
39
|
meta: dict | None = None,
|
|
40
40
|
output_formats: dict[str, str] | None = None,
|
|
41
|
+
output_visualization: dict[str, Any] | None = None,
|
|
41
42
|
gpu_stats_every: int = 100,
|
|
42
43
|
run_id: str | None = None,
|
|
43
44
|
datasets: dict[str, Any] | None = None,
|
|
@@ -73,6 +74,7 @@ class Run:
|
|
|
73
74
|
|
|
74
75
|
self.checkpoint_dir: Path | None = None
|
|
75
76
|
self._output_formats: dict[str, str] = output_formats or {}
|
|
77
|
+
self._output_visualization: dict[str, Any] = output_visualization or {}
|
|
76
78
|
self._start_time = time.time()
|
|
77
79
|
self._finished = False
|
|
78
80
|
self._gpu_handle: Any | None = None
|
|
@@ -80,6 +82,7 @@ class Run:
|
|
|
80
82
|
self._gpu_stats_every: int = gpu_stats_every
|
|
81
83
|
self.run_name: str | None = run_name
|
|
82
84
|
self.mode: str | None = _normalize_mode(mode)
|
|
85
|
+
self._pending_updated_at: dict[str, dict[str, Any]] = {}
|
|
83
86
|
|
|
84
87
|
# ── config ────────────────────────────────────────────────
|
|
85
88
|
if resuming:
|
|
@@ -90,12 +93,19 @@ class Run:
|
|
|
90
93
|
else:
|
|
91
94
|
self.config: dict = normalize_config(config)
|
|
92
95
|
|
|
93
|
-
|
|
96
|
+
config_path = self.dir / "config.json"
|
|
97
|
+
write_json(config_path, self.config)
|
|
98
|
+
self._record_artifact_update(config_path)
|
|
94
99
|
|
|
95
100
|
# ── code ref & run environment (fresh runs only) ─────────
|
|
96
101
|
if not resuming:
|
|
97
|
-
|
|
98
|
-
write_json(
|
|
102
|
+
code_ref_path = self.dir / "code_ref.json"
|
|
103
|
+
write_json(code_ref_path, get_code_ref())
|
|
104
|
+
self._record_artifact_update(code_ref_path)
|
|
105
|
+
|
|
106
|
+
run_environment_path = self.dir / "run_environment.json"
|
|
107
|
+
write_json(run_environment_path, get_run_environment())
|
|
108
|
+
self._record_artifact_update(run_environment_path)
|
|
99
109
|
|
|
100
110
|
# ── meta ──────────────────────────────────────────────────
|
|
101
111
|
if resuming:
|
|
@@ -236,7 +246,10 @@ class Run:
|
|
|
236
246
|
record.update(self._get_gpu_stats())
|
|
237
247
|
|
|
238
248
|
filename = "train.jsonl" if mode == "train" else "val.jsonl"
|
|
239
|
-
|
|
249
|
+
log_path = self.dir / filename
|
|
250
|
+
append_jsonl(log_path, record)
|
|
251
|
+
self._record_artifact_update(log_path)
|
|
252
|
+
self._flush_meta()
|
|
240
253
|
|
|
241
254
|
# ── GPU stats ─────────────────────────────────────────────────
|
|
242
255
|
|
|
@@ -305,8 +318,15 @@ class Run:
|
|
|
305
318
|
if slots is None:
|
|
306
319
|
continue
|
|
307
320
|
save_output_tree(
|
|
308
|
-
base / output_type,
|
|
321
|
+
base / output_type,
|
|
322
|
+
slots,
|
|
323
|
+
self._output_formats,
|
|
324
|
+
self._output_visualization,
|
|
325
|
+
output_type,
|
|
309
326
|
)
|
|
327
|
+
if base.exists():
|
|
328
|
+
self._record_artifact_update(base)
|
|
329
|
+
self._flush_meta()
|
|
310
330
|
return base
|
|
311
331
|
|
|
312
332
|
# ── checkpoints ───────────────────────────────────────────────
|
|
@@ -333,6 +353,7 @@ class Run:
|
|
|
333
353
|
if self.checkpoint_dir is not None:
|
|
334
354
|
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
|
335
355
|
self._meta["checkpoint_dir"] = str(self.checkpoint_dir)
|
|
356
|
+
self._record_artifact_update(self.checkpoint_dir)
|
|
336
357
|
self._flush_meta()
|
|
337
358
|
log.info("Checkpoint dir: %s", self.checkpoint_dir)
|
|
338
359
|
return self.checkpoint_dir
|
|
@@ -351,6 +372,7 @@ class Run:
|
|
|
351
372
|
)
|
|
352
373
|
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
|
353
374
|
self._meta["checkpoint_dir"] = str(self.checkpoint_dir)
|
|
375
|
+
self._record_artifact_update(self.checkpoint_dir)
|
|
354
376
|
self._flush_meta()
|
|
355
377
|
log.info("Checkpoint dir: %s", self.checkpoint_dir)
|
|
356
378
|
return self.checkpoint_dir
|
|
@@ -409,6 +431,7 @@ class Run:
|
|
|
409
431
|
:meth:`save_checkpoint` call it automatically. When *is_best*
|
|
410
432
|
is ``True``, any previous checkpoint marked as best is cleared.
|
|
411
433
|
"""
|
|
434
|
+
checkpoint_path = Path(path)
|
|
412
435
|
checkpoints: list[dict[str, Any]] = self._meta.get("checkpoints", [])
|
|
413
436
|
|
|
414
437
|
if is_best:
|
|
@@ -416,7 +439,7 @@ class Run:
|
|
|
416
439
|
entry.pop("is_best", None)
|
|
417
440
|
|
|
418
441
|
record: dict[str, Any] = {
|
|
419
|
-
"path": str(
|
|
442
|
+
"path": str(checkpoint_path),
|
|
420
443
|
"epoch": epoch,
|
|
421
444
|
"step": step,
|
|
422
445
|
}
|
|
@@ -425,6 +448,7 @@ class Run:
|
|
|
425
448
|
|
|
426
449
|
checkpoints.append(record)
|
|
427
450
|
self._meta["checkpoints"] = checkpoints
|
|
451
|
+
self._record_artifact_update(checkpoint_path)
|
|
428
452
|
self._flush_meta()
|
|
429
453
|
|
|
430
454
|
# ── architecture export ─────────────────────────────────────────
|
|
@@ -461,6 +485,7 @@ class Run:
|
|
|
461
485
|
model, dummy_input, self.dir / "architecture.onnx"
|
|
462
486
|
)
|
|
463
487
|
self._meta["architecture"] = "architecture.onnx"
|
|
488
|
+
self._record_artifact_update(output_path)
|
|
464
489
|
self._flush_meta()
|
|
465
490
|
return output_path
|
|
466
491
|
|
|
@@ -646,7 +671,45 @@ class Run:
|
|
|
646
671
|
self._meta["modes"] = modes
|
|
647
672
|
return modes
|
|
648
673
|
|
|
674
|
+
def _updated_at_store(self) -> dict[str, Any]:
|
|
675
|
+
if not hasattr(self, "_meta"):
|
|
676
|
+
return self._pending_updated_at
|
|
677
|
+
|
|
678
|
+
updated_at = self._meta.get("updated_at")
|
|
679
|
+
if not isinstance(updated_at, dict):
|
|
680
|
+
updated_at = {}
|
|
681
|
+
self._meta["updated_at"] = updated_at
|
|
682
|
+
|
|
683
|
+
if self._pending_updated_at:
|
|
684
|
+
updated_at.update(self._pending_updated_at)
|
|
685
|
+
self._pending_updated_at.clear()
|
|
686
|
+
|
|
687
|
+
return updated_at
|
|
688
|
+
|
|
689
|
+
def _record_artifact_update(
|
|
690
|
+
self,
|
|
691
|
+
path: str | Path,
|
|
692
|
+
*,
|
|
693
|
+
when: float | None = None,
|
|
694
|
+
) -> None:
|
|
695
|
+
artifact_path = Path(path)
|
|
696
|
+
ts = when if when is not None else _artifact_mtime(artifact_path)
|
|
697
|
+
if ts is None:
|
|
698
|
+
ts = time.time()
|
|
699
|
+
self._updated_at_store()[self._artifact_key(artifact_path)] = (
|
|
700
|
+
_updated_at_entry(ts)
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
def _artifact_key(self, path: Path) -> str:
|
|
704
|
+
if path.is_absolute():
|
|
705
|
+
try:
|
|
706
|
+
return path.relative_to(self.dir).as_posix()
|
|
707
|
+
except ValueError:
|
|
708
|
+
return str(path)
|
|
709
|
+
return path.as_posix()
|
|
710
|
+
|
|
649
711
|
def _flush_meta(self) -> None:
|
|
712
|
+
self._record_artifact_update(self.dir / "meta.json", when=time.time())
|
|
650
713
|
write_json(self.dir / "meta.json", self._meta)
|
|
651
714
|
|
|
652
715
|
def __repr__(self) -> str:
|
|
@@ -726,6 +789,20 @@ def _isotime(ts: float) -> str:
|
|
|
726
789
|
return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(ts))
|
|
727
790
|
|
|
728
791
|
|
|
792
|
+
def _updated_at_entry(ts: float) -> dict[str, Any]:
|
|
793
|
+
return {
|
|
794
|
+
"time": ts,
|
|
795
|
+
"iso": _isotime(ts),
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _artifact_mtime(path: Path) -> float | None:
|
|
800
|
+
try:
|
|
801
|
+
return path.stat().st_mtime
|
|
802
|
+
except OSError:
|
|
803
|
+
return None
|
|
804
|
+
|
|
805
|
+
|
|
729
806
|
def _normalize_mode(mode: str | None) -> str | None:
|
|
730
807
|
if mode is None:
|
|
731
808
|
return None
|
|
@@ -1044,6 +1121,9 @@ def _read_ds_crawler_descriptor(path: str) -> dict[str, Any]:
|
|
|
1044
1121
|
try:
|
|
1045
1122
|
cfg = load_dataset_config({"path": path})
|
|
1046
1123
|
except Exception:
|
|
1124
|
+
descriptor = _read_output_json_descriptor(path)
|
|
1125
|
+
if descriptor:
|
|
1126
|
+
return descriptor
|
|
1047
1127
|
try:
|
|
1048
1128
|
contract = get_dataset_contract(path)
|
|
1049
1129
|
except Exception:
|
|
@@ -1076,6 +1156,69 @@ def _read_ds_crawler_descriptor(path: str) -> dict[str, Any]:
|
|
|
1076
1156
|
return descriptor
|
|
1077
1157
|
|
|
1078
1158
|
|
|
1159
|
+
def _read_output_json_descriptor(path: str) -> dict[str, Any]:
|
|
1160
|
+
try:
|
|
1161
|
+
from ds_crawler import zip_utils
|
|
1162
|
+
except Exception:
|
|
1163
|
+
return {}
|
|
1164
|
+
|
|
1165
|
+
try:
|
|
1166
|
+
raw = zip_utils.read_metadata_json(Path(path), "output.json")
|
|
1167
|
+
except Exception:
|
|
1168
|
+
return {}
|
|
1169
|
+
|
|
1170
|
+
return _descriptor_from_output_json(raw, path)
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _descriptor_from_output_json(raw: Any, path: str) -> dict[str, Any]:
|
|
1174
|
+
if isinstance(raw, Mapping):
|
|
1175
|
+
candidates: list[Any]
|
|
1176
|
+
modalities = raw.get("modalities")
|
|
1177
|
+
if isinstance(modalities, list):
|
|
1178
|
+
candidates = modalities
|
|
1179
|
+
else:
|
|
1180
|
+
candidates = [raw]
|
|
1181
|
+
elif isinstance(raw, list):
|
|
1182
|
+
candidates = raw
|
|
1183
|
+
else:
|
|
1184
|
+
return {}
|
|
1185
|
+
|
|
1186
|
+
selected: Mapping[str, Any] | None = None
|
|
1187
|
+
path_name = Path(path).name
|
|
1188
|
+
for candidate in candidates:
|
|
1189
|
+
if not isinstance(candidate, Mapping):
|
|
1190
|
+
continue
|
|
1191
|
+
if _as_non_empty_str(candidate.get("name")) == path_name:
|
|
1192
|
+
selected = candidate
|
|
1193
|
+
break
|
|
1194
|
+
if selected is None:
|
|
1195
|
+
selected = candidate
|
|
1196
|
+
|
|
1197
|
+
if selected is None:
|
|
1198
|
+
return {}
|
|
1199
|
+
|
|
1200
|
+
descriptor: dict[str, Any] = {}
|
|
1201
|
+
properties = selected.get("properties")
|
|
1202
|
+
merged_properties = dict(properties) if isinstance(properties, Mapping) else {}
|
|
1203
|
+
|
|
1204
|
+
euler_train_props = selected.get("euler_train")
|
|
1205
|
+
if isinstance(euler_train_props, Mapping):
|
|
1206
|
+
merged_properties["euler_train"] = dict(euler_train_props)
|
|
1207
|
+
|
|
1208
|
+
if merged_properties:
|
|
1209
|
+
descriptor["properties"] = merged_properties
|
|
1210
|
+
|
|
1211
|
+
modality_key = _as_non_empty_str(selected.get("type"))
|
|
1212
|
+
if modality_key is not None:
|
|
1213
|
+
descriptor["modality_key"] = modality_key
|
|
1214
|
+
|
|
1215
|
+
hierarchy_regex = _as_non_empty_str(selected.get("hierarchy_regex"))
|
|
1216
|
+
if hierarchy_regex is not None:
|
|
1217
|
+
descriptor["hierarchy_regex"] = hierarchy_regex
|
|
1218
|
+
|
|
1219
|
+
return descriptor
|
|
1220
|
+
|
|
1221
|
+
|
|
1079
1222
|
def _build_modality_entry(
|
|
1080
1223
|
name: str,
|
|
1081
1224
|
path: str,
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "euler-train"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "Lightweight file-based experiment logger for PyTorch"
|
|
9
9
|
requires-python = ">=3.9"
|
|
10
10
|
dependencies = ["numpy", "euler-dataset-contract"]
|
|
@@ -27,6 +27,12 @@ def _read_jsonl(path: Path) -> list[dict]:
|
|
|
27
27
|
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def _assert_updated_at_entry(entry: dict) -> None:
|
|
31
|
+
assert isinstance(entry["time"], (int, float))
|
|
32
|
+
assert isinstance(entry["iso"], str)
|
|
33
|
+
assert entry["iso"]
|
|
34
|
+
|
|
35
|
+
|
|
30
36
|
class _DummyModality:
|
|
31
37
|
def __init__(self, path: str) -> None:
|
|
32
38
|
self.path = path
|
|
@@ -228,6 +234,57 @@ class TestInit:
|
|
|
228
234
|
run.finish()
|
|
229
235
|
|
|
230
236
|
|
|
237
|
+
class TestUpdatedAt:
|
|
238
|
+
def test_init_tracks_bootstrap_artifacts(self, tmp_path):
|
|
239
|
+
run = euler_train.init(dir=str(tmp_path / "r"), config={})
|
|
240
|
+
|
|
241
|
+
meta = _read_json(run.dir / "meta.json")
|
|
242
|
+
updated_at = meta["updated_at"]
|
|
243
|
+
|
|
244
|
+
for key in (
|
|
245
|
+
"meta.json",
|
|
246
|
+
"config.json",
|
|
247
|
+
"code_ref.json",
|
|
248
|
+
"run_environment.json",
|
|
249
|
+
):
|
|
250
|
+
assert key in updated_at
|
|
251
|
+
_assert_updated_at_entry(updated_at[key])
|
|
252
|
+
run.finish()
|
|
253
|
+
|
|
254
|
+
def test_log_tracks_train_and_val_jsonl(self, tmp_path):
|
|
255
|
+
run = euler_train.init(dir=str(tmp_path / "r"), config={})
|
|
256
|
+
|
|
257
|
+
run.log({"loss": 1.0}, step=0, epoch=0)
|
|
258
|
+
run.log({"metric": 0.9}, step=1, epoch=0, mode="val")
|
|
259
|
+
|
|
260
|
+
meta = _read_json(run.dir / "meta.json")
|
|
261
|
+
updated_at = meta["updated_at"]
|
|
262
|
+
_assert_updated_at_entry(updated_at["train.jsonl"])
|
|
263
|
+
_assert_updated_at_entry(updated_at["val.jsonl"])
|
|
264
|
+
_assert_updated_at_entry(updated_at["meta.json"])
|
|
265
|
+
run.finish()
|
|
266
|
+
|
|
267
|
+
def test_save_outputs_tracks_output_directory(self, tmp_path):
|
|
268
|
+
run = euler_train.init(dir=str(tmp_path / "r"), config={})
|
|
269
|
+
arr = np.zeros((4, 4, 3), dtype=np.uint8)
|
|
270
|
+
|
|
271
|
+
run.save_outputs(epoch=0, step=0, rgb=dict(pred=arr))
|
|
272
|
+
|
|
273
|
+
meta = _read_json(run.dir / "meta.json")
|
|
274
|
+
_assert_updated_at_entry(meta["updated_at"]["outputs/epoch_0_step_0"])
|
|
275
|
+
run.finish()
|
|
276
|
+
|
|
277
|
+
def test_log_saved_checkpoint_tracks_checkpoint_path(self, tmp_path):
|
|
278
|
+
run = euler_train.init(dir=str(tmp_path / "r"), config={})
|
|
279
|
+
checkpoint_path = tmp_path / "external" / "epoch_1.pt"
|
|
280
|
+
|
|
281
|
+
run.log_saved_checkpoint(checkpoint_path, epoch=1, step=10)
|
|
282
|
+
|
|
283
|
+
meta = _read_json(run.dir / "meta.json")
|
|
284
|
+
_assert_updated_at_entry(meta["updated_at"][str(checkpoint_path)])
|
|
285
|
+
run.finish()
|
|
286
|
+
|
|
287
|
+
|
|
231
288
|
class TestDatasetMetadata:
|
|
232
289
|
def test_prefers_dataset_describe_for_runlog_contract(
|
|
233
290
|
self, tmp_path, monkeypatch
|
|
@@ -1168,6 +1225,61 @@ class TestSaveOutputsOverrides:
|
|
|
1168
1225
|
run.finish()
|
|
1169
1226
|
|
|
1170
1227
|
|
|
1228
|
+
class TestSaveOutputsVisualization:
|
|
1229
|
+
def test_fixed_range_visualization_for_single_channel_png(self, tmp_path):
|
|
1230
|
+
run = euler_train.init(
|
|
1231
|
+
dir=str(tmp_path / "r"),
|
|
1232
|
+
config={},
|
|
1233
|
+
output_visualization={
|
|
1234
|
+
"depth": {"mode": "fixed_range", "vmin": 0.0, "vmax": 10.0},
|
|
1235
|
+
},
|
|
1236
|
+
)
|
|
1237
|
+
depth = np.linspace(0.0, 10.0, 16, dtype=np.float32).reshape(4, 4, 1)
|
|
1238
|
+
run.save_outputs(epoch=0, step=0, depth=dict(pred=depth))
|
|
1239
|
+
|
|
1240
|
+
f = list((run.dir / "outputs" / "epoch_0_step_0" / "depth" / "pred").iterdir())[0]
|
|
1241
|
+
img = np.array(Image.open(f))
|
|
1242
|
+
assert f.suffix == ".png"
|
|
1243
|
+
assert img.min() == 0
|
|
1244
|
+
assert img.max() == 255
|
|
1245
|
+
run.finish()
|
|
1246
|
+
|
|
1247
|
+
def test_visualization_override_specific_dotted_key(self, tmp_path):
|
|
1248
|
+
run = euler_train.init(
|
|
1249
|
+
dir=str(tmp_path / "r"),
|
|
1250
|
+
config={},
|
|
1251
|
+
output_visualization={
|
|
1252
|
+
"depth.pred": {"mode": "fixed_range", "vmin": 5.0, "vmax": 15.0},
|
|
1253
|
+
},
|
|
1254
|
+
)
|
|
1255
|
+
depth = np.linspace(5.0, 15.0, 16, dtype=np.float32).reshape(4, 4, 1)
|
|
1256
|
+
run.save_outputs(epoch=0, step=0, depth=dict(pred=depth, gt=depth))
|
|
1257
|
+
|
|
1258
|
+
base = run.dir / "outputs" / "epoch_0_step_0" / "depth"
|
|
1259
|
+
pred_f = list((base / "pred").iterdir())[0]
|
|
1260
|
+
gt_f = list((base / "gt").iterdir())[0]
|
|
1261
|
+
pred_img = np.array(Image.open(pred_f))
|
|
1262
|
+
gt_img = np.array(Image.open(gt_f))
|
|
1263
|
+
assert pred_img.min() == 0
|
|
1264
|
+
assert pred_img.max() == 255
|
|
1265
|
+
assert gt_img.min() == 255
|
|
1266
|
+
assert gt_img.max() == 255
|
|
1267
|
+
run.finish()
|
|
1268
|
+
|
|
1269
|
+
def test_torch_bfloat16_single_channel_png_promoted_to_float32(self, tmp_path):
|
|
1270
|
+
run = euler_train.init(dir=str(tmp_path / "r"), config={})
|
|
1271
|
+
t = torch.linspace(0, 1, steps=16, dtype=torch.bfloat16).reshape(4, 4, 1)
|
|
1272
|
+
run.save_outputs(epoch=0, step=0, confidence=dict(pred=t))
|
|
1273
|
+
|
|
1274
|
+
f = list((run.dir / "outputs" / "epoch_0_step_0" / "confidence" / "pred").iterdir())[0]
|
|
1275
|
+
img = np.array(Image.open(f))
|
|
1276
|
+
assert f.suffix == ".png"
|
|
1277
|
+
assert img.dtype == np.uint8
|
|
1278
|
+
assert img.min() == 0
|
|
1279
|
+
assert img.max() >= 254
|
|
1280
|
+
run.finish()
|
|
1281
|
+
|
|
1282
|
+
|
|
1171
1283
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
1172
1284
|
# save_outputs — batches, lists, aux, PIL, torch
|
|
1173
1285
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|