euler-train 2.0.0__tar.gz → 2.1.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.1.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -80,7 +80,7 @@ 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
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.
86
86
 
@@ -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.
@@ -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
@@ -305,7 +307,11 @@ class Run:
305
307
  if slots is None:
306
308
  continue
307
309
  save_output_tree(
308
- base / output_type, slots, self._output_formats, output_type,
310
+ base / output_type,
311
+ slots,
312
+ self._output_formats,
313
+ self._output_visualization,
314
+ output_type,
309
315
  )
310
316
  return base
311
317
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.0.0
3
+ Version: 2.1.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.1.0"
8
8
  description = "Lightweight file-based experiment logger for PyTorch"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = ["numpy", "euler-dataset-contract"]
@@ -1168,6 +1168,61 @@ class TestSaveOutputsOverrides:
1168
1168
  run.finish()
1169
1169
 
1170
1170
 
1171
+ class TestSaveOutputsVisualization:
1172
+ def test_fixed_range_visualization_for_single_channel_png(self, tmp_path):
1173
+ run = euler_train.init(
1174
+ dir=str(tmp_path / "r"),
1175
+ config={},
1176
+ output_visualization={
1177
+ "depth": {"mode": "fixed_range", "vmin": 0.0, "vmax": 10.0},
1178
+ },
1179
+ )
1180
+ depth = np.linspace(0.0, 10.0, 16, dtype=np.float32).reshape(4, 4, 1)
1181
+ run.save_outputs(epoch=0, step=0, depth=dict(pred=depth))
1182
+
1183
+ f = list((run.dir / "outputs" / "epoch_0_step_0" / "depth" / "pred").iterdir())[0]
1184
+ img = np.array(Image.open(f))
1185
+ assert f.suffix == ".png"
1186
+ assert img.min() == 0
1187
+ assert img.max() == 255
1188
+ run.finish()
1189
+
1190
+ def test_visualization_override_specific_dotted_key(self, tmp_path):
1191
+ run = euler_train.init(
1192
+ dir=str(tmp_path / "r"),
1193
+ config={},
1194
+ output_visualization={
1195
+ "depth.pred": {"mode": "fixed_range", "vmin": 5.0, "vmax": 15.0},
1196
+ },
1197
+ )
1198
+ depth = np.linspace(5.0, 15.0, 16, dtype=np.float32).reshape(4, 4, 1)
1199
+ run.save_outputs(epoch=0, step=0, depth=dict(pred=depth, gt=depth))
1200
+
1201
+ base = run.dir / "outputs" / "epoch_0_step_0" / "depth"
1202
+ pred_f = list((base / "pred").iterdir())[0]
1203
+ gt_f = list((base / "gt").iterdir())[0]
1204
+ pred_img = np.array(Image.open(pred_f))
1205
+ gt_img = np.array(Image.open(gt_f))
1206
+ assert pred_img.min() == 0
1207
+ assert pred_img.max() == 255
1208
+ assert gt_img.min() == 255
1209
+ assert gt_img.max() == 255
1210
+ run.finish()
1211
+
1212
+ def test_torch_bfloat16_single_channel_png_promoted_to_float32(self, tmp_path):
1213
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1214
+ t = torch.linspace(0, 1, steps=16, dtype=torch.bfloat16).reshape(4, 4, 1)
1215
+ run.save_outputs(epoch=0, step=0, confidence=dict(pred=t))
1216
+
1217
+ f = list((run.dir / "outputs" / "epoch_0_step_0" / "confidence" / "pred").iterdir())[0]
1218
+ img = np.array(Image.open(f))
1219
+ assert f.suffix == ".png"
1220
+ assert img.dtype == np.uint8
1221
+ assert img.min() == 0
1222
+ assert img.max() >= 254
1223
+ run.finish()
1224
+
1225
+
1171
1226
  # ═══════════════════════════════════════════════════════════════════════════
1172
1227
  # save_outputs — batches, lists, aux, PIL, torch
1173
1228
  # ═══════════════════════════════════════════════════════════════════════════
File without changes