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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -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` | clipped to `[0,1]`, scaled to `[0,255]` |
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, run_id=run_id,
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, aux_data,
39
- output_type, aux_name, format_overrides,
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, data,
44
- output_type, slot_name, format_overrides,
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
- arr: np.ndarray = data.detach().cpu().numpy()
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 _resolve_format(
123
- item: Any,
141
+ def _resolve_override(
124
142
  output_type: str,
125
143
  leaf_name: str,
126
- overrides: dict[str, str],
127
- ) -> str:
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(path: Path, item: Any, fmt: str) -> None:
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(path: Path, item: Any) -> None:
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 → [0,1] → uint8
233
+ # float → visualization policy → uint8
178
234
  if np.issubdtype(arr.dtype, np.floating):
179
- arr = np.clip(arr, 0.0, 1.0)
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
- write_json(self.dir / "config.json", self.config)
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
- write_json(self.dir / "code_ref.json", get_code_ref())
98
- write_json(self.dir / "run_environment.json", get_run_environment())
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
- append_jsonl(self.dir / filename, record)
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, slots, self._output_formats, 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(path),
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "euler-train"
7
- version = "2.0.0"
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