euler-preprocess 3.13.0__tar.gz → 3.16.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 (69) hide show
  1. euler_preprocess-3.16.0/CHANGELOG.md +5 -0
  2. euler_preprocess-3.16.0/MANIFEST.in +5 -0
  3. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/PKG-INFO +23 -11
  4. euler_preprocess-3.13.0/euler_preprocess.egg-info/PKG-INFO → euler_preprocess-3.16.0/README.md +19 -27
  5. euler_preprocess-3.16.0/docs/spatial.md +61 -0
  6. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/cli.py +22 -0
  7. euler_preprocess-3.16.0/euler_preprocess/common/dataset.py +212 -0
  8. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/intrinsics.py +3 -2
  9. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/output.py +72 -24
  10. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/transform.py +9 -0
  11. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/transform.py +3 -2
  12. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/radial/transform.py +3 -3
  13. euler_preprocess-3.16.0/euler_preprocess/spatial.py +110 -0
  14. euler_preprocess-3.13.0/README.md → euler_preprocess-3.16.0/euler_preprocess.egg-info/PKG-INFO +39 -9
  15. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess.egg-info/SOURCES.txt +6 -0
  16. euler_preprocess-3.16.0/euler_preprocess.egg-info/requires.txt +11 -0
  17. euler_preprocess-3.16.0/examples/spatial-five-field.json +262 -0
  18. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/pyproject.toml +4 -2
  19. euler_preprocess-3.16.0/tests/test_modality_roles.py +192 -0
  20. euler_preprocess-3.16.0/tests/test_spatial.py +130 -0
  21. euler_preprocess-3.13.0/MANIFEST.in +0 -1
  22. euler_preprocess-3.13.0/euler_preprocess/common/dataset.py +0 -56
  23. euler_preprocess-3.13.0/euler_preprocess.egg-info/requires.txt +0 -9
  24. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/LICENSE +0 -0
  25. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/__init__.py +0 -0
  26. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/__init__.py +0 -0
  27. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/color.py +0 -0
  28. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/device.py +0 -0
  29. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/io.py +0 -0
  30. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/logging.py +0 -0
  31. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/noise.py +0 -0
  32. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/normalize.py +0 -0
  33. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/common/sampling.py +0 -0
  34. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/__init__.py +0 -0
  35. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  36. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/atmospheric_light.py +0 -0
  37. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/augmentations.py +0 -0
  38. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/capture.py +0 -0
  39. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  40. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  41. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  42. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  43. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/inference.py +0 -0
  44. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/logging.py +0 -0
  45. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/models.py +0 -0
  46. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/pipeline.py +0 -0
  47. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/radial/__init__.py +0 -0
  48. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/sky_depth/__init__.py +0 -0
  49. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess/sky_depth/transform.py +0 -0
  50. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  51. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  52. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  53. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/setup.cfg +0 -0
  54. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_airlight_fallback.py +0 -0
  55. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_cli_sample_selection.py +0 -0
  56. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  57. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_dense_gloomy_daylight_config.py +0 -0
  58. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_fog_aux_outputs.py +0 -0
  59. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_fog_aware_auto_exposure.py +0 -0
  60. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_fog_cpu_gpu_parity.py +0 -0
  61. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_fog_integration.py +0 -0
  62. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_radial.py +0 -0
  63. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_real_drive_sim_scenario_profiles.py +0 -0
  64. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_scene_illumination.py +0 -0
  65. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_sensor_identity.py +0 -0
  66. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_sky_depth.py +0 -0
  67. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_source_backed_output.py +0 -0
  68. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_tone_map_lut.py +0 -0
  69. {euler_preprocess-3.13.0 → euler_preprocess-3.16.0}/tests/test_zip_output.py +0 -0
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 3.14.0 (unreleased)
4
+
5
+ Consume shared preprocessing config and add the opt-in spatial producer, explicit variants/mappings and captured source-backed outputs. Preserve ordinary pipeline APIs.
@@ -0,0 +1,5 @@
1
+ recursive-exclude tests/fixtures *.zip
2
+
3
+ include CHANGELOG.md
4
+ recursive-include docs *.md
5
+ recursive-include examples *.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-preprocess
3
- Version: 3.13.0
3
+ Version: 3.16.0
4
4
  Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
5
  Author-email: Daniel Rothenpieler <rothenpielerdaniel@gmail.com>
6
6
  License-Expression: MIT
@@ -9,7 +9,9 @@ Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: numpy
11
11
  Requires-Dist: Pillow
12
- Requires-Dist: euler-loading
12
+ Requires-Dist: euler-loading>=2.24.0
13
+ Requires-Dist: ds-crawler>=2.11.0
14
+ Requires-Dist: euler-dataset-contract>=0.5.0
13
15
  Provides-Extra: gpu
14
16
  Requires-Dist: torch; extra == "gpu"
15
17
  Provides-Extra: progress
@@ -92,9 +94,7 @@ loader.
92
94
  "modalities": {
93
95
  "rgb": {"path": "/path/to/rgb", "split": "train"},
94
96
  "depth": "/path/to/depth",
95
- "semantic_segmentation": "/path/to/classSegmentation"
96
- },
97
- "hierarchical_modalities": {
97
+ "semantic_segmentation": "/path/to/classSegmentation",
98
98
  "intrinsics": {"path": "/path/to/intrinsics"}
99
99
  }
100
100
  }
@@ -107,17 +107,27 @@ loader.
107
107
  | `output_slot` | Optional slot selector when `pipeline.output_targets` has several entries. Defaults to `rgb` for `fog` and `depth` for `sky-depth` / `radial`. |
108
108
  | `sample` | Optional 0-based dataset index. Only `dataset[sample]` is transformed — useful for small benchmark slices of large datasets. |
109
109
  | `samples` | Optional multi-sample selector: a list of indices (`[0, 10, 20]`) or a slice object (`{"start": 0, "stop": 1000, "step": 2, "count": 100}`). `stop` is exclusive, `count` caps the result. Mutually exclusive with `sample`. |
110
- | `modalities` | Modalities that participate in sample-ID intersection. Each value is a path string or an object with `path` and optional `split`. |
111
- | `hierarchical_modalities` | Per-scene data (e.g. intrinsics), same format. Loaded once per scene and cached. |
110
+ | `modalities` | All input modalities. Each value is a path string or an object with `path` and optional `split`. Whether a modality is per-sample or hierarchical is inferred (see below). |
111
+ | `hierarchical_modalities` | Legacy, optional. Same format; entries are pooled with `modalities` and resolved the same way. |
112
112
  | `pipeline` | Optional runtime routing block compatible with `euler-inference`. |
113
113
 
114
114
  **Required modalities per transform:**
115
115
 
116
- | Transform | `modalities` | `hierarchical_modalities` |
116
+ | Transform | Required | Optional |
117
117
  |---|---|---|
118
- | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics` when available; used for radial depth conversion and camera-profile optics |
118
+ | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics`; used for radial depth conversion and camera-profile optics |
119
119
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
120
- | `radial` | `depth` | `intrinsics` |
120
+ | `radial` | `depth`, `intrinsics` | — |
121
+
122
+ **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
123
+ it is inferred from the ds-crawler indexes. A modality whose qualified IDs
124
+ (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
125
+ sample, e.g. Synscapes' per-image `meta/<id>.json` intrinsics. A modality whose
126
+ files share no sample IDs but sit at or above the samples' hierarchy level is
127
+ matched hierarchically and shared by every sample beneath it, e.g. one intrinsics
128
+ file per scene or camera. A modality that is neither raises an error, and the
129
+ inferred roles are logged. Listing a per-sample modality under
130
+ `hierarchical_modalities` logs a warning and loads it per sample.
121
131
 
122
132
  When a modality directory contains ds-crawler split files
123
133
  (`.ds_crawler/split_<name>.json`), set `split` on that modality to select a subset.
@@ -675,7 +685,7 @@ d_radial(u, v) = d_planar(u, v) * sqrt(((u - cx)/fx)^2 + ((v - cy)/fy)^2 + 1)
675
685
  ```
676
686
 
677
687
  The transform config takes no parameters (`{}`); intrinsics are read from the
678
- `intrinsics` hierarchical modality.
688
+ `intrinsics` modality, stored either per sample or per scene/camera.
679
689
 
680
690
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
681
691
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
@@ -686,3 +696,5 @@ Standalone `RadialTransform(...)` usage keeps the legacy `.npy` output behaviour
686
696
  ## License
687
697
 
688
698
  [MIT](LICENSE) © Daniel Rothenpieler
699
+
700
+ See [Phase 2 spatial](docs/spatial.md) for the opt-in captured spatial workflow (3.14.0, unreleased).
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: euler-preprocess
3
- Version: 3.13.0
4
- Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
- Author-email: Daniel Rothenpieler <rothenpielerdaniel@gmail.com>
6
- License-Expression: MIT
7
- Requires-Python: >=3.9
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: numpy
11
- Requires-Dist: Pillow
12
- Requires-Dist: euler-loading
13
- Provides-Extra: gpu
14
- Requires-Dist: torch; extra == "gpu"
15
- Provides-Extra: progress
16
- Requires-Dist: tqdm; extra == "progress"
17
- Dynamic: license-file
18
-
19
1
  <!-- euler header — shared across the euler packages.
20
2
  Per package, change only: the <h1>, the tagline, and the badge URLs. -->
21
3
  <p align="center">
@@ -92,9 +74,7 @@ loader.
92
74
  "modalities": {
93
75
  "rgb": {"path": "/path/to/rgb", "split": "train"},
94
76
  "depth": "/path/to/depth",
95
- "semantic_segmentation": "/path/to/classSegmentation"
96
- },
97
- "hierarchical_modalities": {
77
+ "semantic_segmentation": "/path/to/classSegmentation",
98
78
  "intrinsics": {"path": "/path/to/intrinsics"}
99
79
  }
100
80
  }
@@ -107,17 +87,27 @@ loader.
107
87
  | `output_slot` | Optional slot selector when `pipeline.output_targets` has several entries. Defaults to `rgb` for `fog` and `depth` for `sky-depth` / `radial`. |
108
88
  | `sample` | Optional 0-based dataset index. Only `dataset[sample]` is transformed — useful for small benchmark slices of large datasets. |
109
89
  | `samples` | Optional multi-sample selector: a list of indices (`[0, 10, 20]`) or a slice object (`{"start": 0, "stop": 1000, "step": 2, "count": 100}`). `stop` is exclusive, `count` caps the result. Mutually exclusive with `sample`. |
110
- | `modalities` | Modalities that participate in sample-ID intersection. Each value is a path string or an object with `path` and optional `split`. |
111
- | `hierarchical_modalities` | Per-scene data (e.g. intrinsics), same format. Loaded once per scene and cached. |
90
+ | `modalities` | All input modalities. Each value is a path string or an object with `path` and optional `split`. Whether a modality is per-sample or hierarchical is inferred (see below). |
91
+ | `hierarchical_modalities` | Legacy, optional. Same format; entries are pooled with `modalities` and resolved the same way. |
112
92
  | `pipeline` | Optional runtime routing block compatible with `euler-inference`. |
113
93
 
114
94
  **Required modalities per transform:**
115
95
 
116
- | Transform | `modalities` | `hierarchical_modalities` |
96
+ | Transform | Required | Optional |
117
97
  |---|---|---|
118
- | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics` when available; used for radial depth conversion and camera-profile optics |
98
+ | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics`; used for radial depth conversion and camera-profile optics |
119
99
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
120
- | `radial` | `depth` | `intrinsics` |
100
+ | `radial` | `depth`, `intrinsics` | — |
101
+
102
+ **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
103
+ it is inferred from the ds-crawler indexes. A modality whose qualified IDs
104
+ (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
105
+ sample, e.g. Synscapes' per-image `meta/<id>.json` intrinsics. A modality whose
106
+ files share no sample IDs but sit at or above the samples' hierarchy level is
107
+ matched hierarchically and shared by every sample beneath it, e.g. one intrinsics
108
+ file per scene or camera. A modality that is neither raises an error, and the
109
+ inferred roles are logged. Listing a per-sample modality under
110
+ `hierarchical_modalities` logs a warning and loads it per sample.
121
111
 
122
112
  When a modality directory contains ds-crawler split files
123
113
  (`.ds_crawler/split_<name>.json`), set `split` on that modality to select a subset.
@@ -675,7 +665,7 @@ d_radial(u, v) = d_planar(u, v) * sqrt(((u - cx)/fx)^2 + ((v - cy)/fy)^2 + 1)
675
665
  ```
676
666
 
677
667
  The transform config takes no parameters (`{}`); intrinsics are read from the
678
- `intrinsics` hierarchical modality.
668
+ `intrinsics` modality, stored either per sample or per scene/camera.
679
669
 
680
670
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
681
671
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
@@ -686,3 +676,5 @@ Standalone `RadialTransform(...)` usage keeps the legacy `.npy` output behaviour
686
676
  ## License
687
677
 
688
678
  [MIT](LICENSE) © Daniel Rothenpieler
679
+
680
+ See [Phase 2 spatial](docs/spatial.md) for the opt-in captured spatial workflow (3.14.0, unreleased).
@@ -0,0 +1,61 @@
1
+ # Deterministic spatial exports (3.14.0, unreleased)
2
+
3
+ `euler-preprocess spatial --config examples/spatial-five-field.json` is the
4
+ opt-in crop/resize producer. It uses loading 2.24.0, crawler 2.11.0 and contract
5
+ 0.5.0. These source versions must be installed together. No benchmark evaluation
6
+ is enabled. Fog/model/value effects are not labelled replayable spatial exports.
7
+
8
+ The example config expects five source modality datasets. Change their paths to
9
+ your sources, and supply decoded profiles, image plane, qualified calibration
10
+ selection and source revision assertions that describe those files. The producer
11
+ hashes the actual source content itself; it does not accept supplied hashes as
12
+ independently checked. Paths in CLI configs are resolved against the config file.
13
+
14
+ ```python
15
+ from euler_preprocess.spatial import export_spatial
16
+ paths = export_spatial(config)
17
+ ```
18
+
19
+ `preprocessing` uses the shared `SamplePreprocessor` config. `bindings` has
20
+ `fields`, `image_planes`, `revisions` (source alias to revision digest) and an
21
+ optional explicit CPU `backend` (`torch_cpu` by default). `outputs` selects
22
+ fields; each needs `path`, a distinct derived `dataset_id`, and `revision`.
23
+ Optional output options are `metadata_scope`, `resume` (directories only), and
24
+ `encoding: {"id":"npy"}` or a supported PNG codec, e.g.
25
+ `{"id":"png_depth16","scale":0.001}`. NPY preserves K/rays exactly. PNG RGB
26
+ expects normalized float32 values, masks require bool, and depth requires meters.
27
+ Clipping is rejected. Codec parameters, profiles and real dimensions are stored
28
+ alongside the bytes; source metadata cannot override them.
29
+
30
+ `spatial_variants` replaces top-level preprocessing/bindings with explicit named
31
+ `{preprocessing, bindings}` entries. Every variant is frozen in the output plan
32
+ before writing. Variants can select different qualified calibrations or concrete
33
+ geometries. A `samples` list selects source indices. Optional
34
+ `mapping: {variant: {source_full_id: output_full_id}}` supplies exact output IDs.
35
+ Otherwise a single variant preserves source IDs; multiple variants append the
36
+ explicit variant ID as a hierarchy component. Conflicting mappings are rejected.
37
+ The producer computes the complete expected output set before starting writes.
38
+ Use explicit mappings/subsets, rather than inferring relationships from filenames.
39
+
40
+ Each sample carries the actual producer receipt into `SourceBackedOutputBackend`.
41
+ `prepare_output_backend` and `prepare_output_backends` also accept explicitly
42
+ configured `materialization_outputs` for Python integrations; each output option
43
+ includes a frozen `derivation` and optional `expected_full_ids`. The dataset must
44
+ already have source-backed transform capture enabled. An arbitrary transform's
45
+ changed values will fail the receipt checks. Ordinary attributes are preserved,
46
+ while the new receipt and output encoding metadata are authoritative.
47
+
48
+ `receipt_sidecars: true` moves receipts to relative content-addressed records.
49
+ Failed encoding cannot register a receipt. Finalization verifies source-backed
50
+ replay, output bytes, expected IDs and references before publishing each logical
51
+ output dataset. There is no all-or-nothing transaction across several output
52
+ roots: each head is independently complete for its one selected modality.
53
+ Directory resume is idempotent with the same source/content/recipe/revision;
54
+ ZIP supports one owner/one finalization. Interrupted outputs do not advertise
55
+ complete metadata.
56
+
57
+ The common dataset builder now consumes `preprocessing` for ordinary pipelines
58
+ as well. Existing callable pipelines retain their APIs. Strict receipts are
59
+ created only by the explicit spatial/materialization path. Reload through
60
+ `MultiModalDataset` loads stored bytes once; explicit loading replay and typed
61
+ `CalibrationView` resolve the original geometry without evaluator heuristics.
@@ -244,6 +244,23 @@ def _cmd_radial(args: argparse.Namespace) -> int:
244
244
  return _run_transform(args, RadialTransform)
245
245
 
246
246
 
247
+ def _cmd_spatial(args: argparse.Namespace) -> int:
248
+ from euler_preprocess.spatial import export_spatial
249
+
250
+ config_path = Path(args.config).resolve()
251
+ config = json.loads(config_path.read_text(encoding="utf-8"))
252
+ for group in ("modalities", "hierarchical_modalities"):
253
+ for key, entry in config.get(group, {}).items():
254
+ if isinstance(entry, str):
255
+ config[group][key] = str(_resolve(entry, config_path.parent))
256
+ else:
257
+ entry["path"] = str(_resolve(entry["path"], config_path.parent))
258
+ for entry in config["outputs"].values():
259
+ entry["path"] = str(_resolve(entry["path"], config_path.parent))
260
+ export_spatial(config)
261
+ return 0
262
+
263
+
247
264
  def parse_args() -> argparse.Namespace:
248
265
  parser = argparse.ArgumentParser(
249
266
  description="Preprocessing transforms for RGB+depth datasets.",
@@ -281,6 +298,11 @@ def parse_args() -> argparse.Namespace:
281
298
  )
282
299
  radial_parser.set_defaults(func=_cmd_radial)
283
300
 
301
+ spatial_parser = subparsers.add_parser(
302
+ "spatial", parents=[common], help="Export deterministic crop/resize with verified execution receipts.",
303
+ )
304
+ spatial_parser.set_defaults(func=_cmd_spatial)
305
+
284
306
  args = parser.parse_args()
285
307
  if not hasattr(args, "func"):
286
308
  parser.print_help()
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+
5
+ from euler_preprocess.common.logging import get_logger
6
+
7
+
8
+ def _make_modality(entry: str | dict):
9
+ from euler_loading import Modality
10
+
11
+ if isinstance(entry, str):
12
+ return Modality(entry)
13
+ return Modality(
14
+ entry["path"],
15
+ **{
16
+ key: entry[key]
17
+ for key in (
18
+ "split",
19
+ "metadata_scope",
20
+ "collapse_single",
21
+ "cache",
22
+ "applies_to",
23
+ "hierarchy_scope",
24
+ )
25
+ if key in entry
26
+ },
27
+ )
28
+
29
+
30
+ def _load_index_tree(name: str, modality) -> dict:
31
+ """Load a modality's ds-crawler tree the same way ``MultiModalDataset`` does."""
32
+ from ds_crawler import index_dataset_from_path
33
+ from euler_loading._ds_crawler_utils import load_index_output
34
+ from euler_loading.dataset import _get_index_tree, _resolve_metadata_scope
35
+
36
+ modality = _resolve_metadata_scope(name, modality)
37
+ index = load_index_output(
38
+ modality.path,
39
+ split=modality.split,
40
+ metadata_scope=modality.metadata_scope,
41
+ index_dataset_from_path_fn=index_dataset_from_path,
42
+ )
43
+ return _get_index_tree(index)
44
+
45
+
46
+ def _check_attachment(
47
+ name: str, seed: str, tree: dict, samples_per_level: Counter
48
+ ) -> None:
49
+ """Check that hierarchical modality *name* applies to the samples by tree position."""
50
+ from euler_loading.indexing import collect_hierarchical_files, match_hierarchical_files
51
+
52
+ files_by_level = collect_hierarchical_files(tree)
53
+ attached = {
54
+ level: len(match_hierarchical_files(level, files_by_level))
55
+ for level in samples_per_level
56
+ }
57
+ covered = sum(count for level, count in samples_per_level.items() if attached[level])
58
+ if not covered:
59
+ raise ValueError(
60
+ f"Modality '{name}' shares no sample IDs with '{seed}' and none of its "
61
+ "files sit at or above the hierarchy level of any sample, so it can be "
62
+ "neither joined per sample nor matched hierarchically. Check that its "
63
+ f"ds-crawler IDs and hierarchy follow the same conventions as '{seed}'."
64
+ )
65
+ logger = get_logger()
66
+ total = sum(samples_per_level.values())
67
+ log = logger.info if covered == total else logger.warning
68
+ log(
69
+ "Modality '%s' is hierarchical (no sample IDs shared with '%s'); it applies "
70
+ "to %d/%d samples by hierarchy position.",
71
+ name,
72
+ seed,
73
+ covered,
74
+ total,
75
+ )
76
+ most = max(attached.values())
77
+ if most > 1:
78
+ logger.warning(
79
+ "Hierarchical modality '%s' attaches up to %d files to one sample. If it "
80
+ "holds per-sample data, its ds-crawler IDs do not match '%s'.",
81
+ name,
82
+ most,
83
+ seed,
84
+ )
85
+
86
+
87
+ def _resolve_roles(
88
+ modalities: dict, per_sample: set[str]
89
+ ) -> tuple[list[str], list[str]]:
90
+ """Split modality names into regular and hierarchical ones.
91
+
92
+ The role follows from the ds-crawler indexes. A modality is regular
93
+ (joined per sample) when its qualified file IDs -- hierarchy path plus
94
+ file ID -- overlap those of the seed modality. Otherwise it is
95
+ hierarchical (matched by tree position, like per-scene intrinsics) and
96
+ must sit at or above the hierarchy level of at least one sample.
97
+
98
+ *per_sample* modalities are regular by contract and the first one is the
99
+ seed. Without one, the seed is the modality aligned with the most others,
100
+ then the one with the most files, then the first listed.
101
+ """
102
+ from euler_loading.indexing import collect_files
103
+
104
+ names = list(modalities)
105
+ if len(names) < 2 or per_sample.issuperset(names):
106
+ return names, []
107
+
108
+ trees = {name: _load_index_tree(name, modalities[name]) for name in names}
109
+ # Qualified sample ID -> hierarchy path, keyed like euler-loading's lookups.
110
+ sample_ids = {}
111
+ for name, tree in trees.items():
112
+ sample_ids[name] = {
113
+ "/".join(r.hierarchy_path + (r.file_entry["id"],)): r.hierarchy_path
114
+ for r in collect_files(tree)
115
+ }
116
+
117
+ def aligned(name: str, other: str) -> bool:
118
+ return not sample_ids[name].keys().isdisjoint(sample_ids[other].keys())
119
+
120
+ anchors = [name for name in names if name in per_sample]
121
+ seed = anchors[0] if anchors else max(
122
+ names,
123
+ key=lambda name: (
124
+ sum(aligned(other, name) for other in names),
125
+ len(sample_ids[name]),
126
+ ),
127
+ )
128
+ regular = [
129
+ name
130
+ for name in names
131
+ if name in per_sample or name == seed or aligned(name, seed)
132
+ ]
133
+ hierarchical = [name for name in names if name not in regular]
134
+ get_logger().info(
135
+ "Modality roles inferred from ds-crawler indexes: per-sample %s; "
136
+ "hierarchical %s",
137
+ ", ".join(regular),
138
+ ", ".join(hierarchical) or "none",
139
+ )
140
+
141
+ # Without common samples MultiModalDataset raises its own, clearer error.
142
+ common = set(sample_ids[seed]).intersection(*(sample_ids[name] for name in regular))
143
+ if common:
144
+ samples_per_level = Counter(sample_ids[seed][sample_id] for sample_id in common)
145
+ for name in hierarchical:
146
+ _check_attachment(name, seed, trees[name], samples_per_level)
147
+ return regular, hierarchical
148
+
149
+
150
+ def build_dataset(
151
+ config: dict,
152
+ required_modalities: set[str],
153
+ required_hierarchical: set[str] | None = None,
154
+ ):
155
+ """Build a ``MultiModalDataset`` from a config dict.
156
+
157
+ Each modality entry is either a plain path string or a dict with
158
+ ``path`` and optional ``split``. Loader resolution (which function to
159
+ call, which module to use) is handled by euler-loading via the
160
+ ds-crawler index at each path — point the config at a path whose
161
+ index declares the function you want (e.g. a ``sky_mask`` index for
162
+ boolean sky masks vs. a ``class_segmentation`` index for raw class
163
+ id maps).
164
+
165
+ Every modality can be listed under ``modalities``: whether it is joined
166
+ per sample or matched hierarchically (e.g. per-scene intrinsics) is
167
+ inferred from the ds-crawler indexes. Entries under the legacy
168
+ ``hierarchical_modalities`` key are pooled with them and resolved the
169
+ same way. *required_modalities* are per-sample inputs and anchor that
170
+ inference; *required_hierarchical* names further required modalities,
171
+ whose role is inferred like any other.
172
+ """
173
+ from euler_loading import MultiModalDataset, SamplePreprocessor
174
+
175
+ raw_modalities = config.get("modalities", {})
176
+ raw_hierarchical = config.get("hierarchical_modalities", {})
177
+
178
+ duplicated = raw_modalities.keys() & raw_hierarchical.keys()
179
+ if duplicated:
180
+ raise ValueError(
181
+ "Modalities listed under both 'modalities' and "
182
+ f"'hierarchical_modalities': {', '.join(sorted(duplicated))}"
183
+ )
184
+ configured = {**raw_modalities, **raw_hierarchical}
185
+
186
+ required = set(required_modalities) | set(required_hierarchical or ())
187
+ missing = required - configured.keys()
188
+ if missing:
189
+ raise ValueError(
190
+ f"Missing required modalities in config: {', '.join(sorted(missing))}. "
191
+ f"'modalities' must contain at least: {', '.join(sorted(required))}"
192
+ )
193
+
194
+ modalities = {name: _make_modality(entry) for name, entry in configured.items()}
195
+ regular, hierarchical = _resolve_roles(modalities, set(required_modalities))
196
+ logger = get_logger()
197
+ for name in regular:
198
+ if name in raw_hierarchical:
199
+ logger.warning(
200
+ "Modality '%s' is listed under 'hierarchical_modalities' but holds "
201
+ "per-sample data; loading it per sample.",
202
+ name,
203
+ )
204
+
205
+ return MultiModalDataset(
206
+ modalities={name: modalities[name] for name in regular},
207
+ hierarchical_modalities={name: modalities[name] for name in hierarchical}
208
+ or None,
209
+ transforms=[SamplePreprocessor.from_config(config["preprocessing"])]
210
+ if config.get("preprocessing")
211
+ else None,
212
+ )
@@ -13,8 +13,9 @@ except ImportError:
13
13
  def extract_intrinsics(sample: dict) -> np.ndarray | None:
14
14
  """Extract the 3x3 intrinsics matrix from a sample dict.
15
15
 
16
- Hierarchical modalities are stored as ``sample[name][file_id]``.
17
- For intrinsics the convention is ``sample["intrinsics"]["intrinsics"]``.
16
+ Per-sample intrinsics are stored directly as ``sample["intrinsics"]``.
17
+ Hierarchical modalities are stored as ``sample[name][file_id]``; for
18
+ intrinsics the convention is ``sample["intrinsics"]["intrinsics"]``.
18
19
  """
19
20
  intr_dict = sample.get("intrinsics")
20
21
  if intr_dict is None:
@@ -8,19 +8,20 @@ from dataclasses import dataclass, field
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
- from ds_crawler import DatasetWriter, ZipDatasetWriter
11
+ from ds_crawler import DatasetWriter, ValidatedDatasetWriter, ZipDatasetWriter
12
12
  from ds_crawler.zip_utils import (
13
13
  METADATA_DIR,
14
- OUTPUT_FILENAME as DS_CRAWLER_INDEX_FILENAME,
15
14
  get_metadata_entry_name,
16
15
  )
16
+ from ds_crawler.zip_utils import (
17
+ OUTPUT_FILENAME as DS_CRAWLER_INDEX_FILENAME,
18
+ )
17
19
  from euler_loading import MultiModalDataset
18
20
  from euler_loading.dataset import create_dataset_writer_from_index
19
21
  from euler_loading.loaders._writer_utils import supports_stream_target
20
22
 
21
23
  from euler_preprocess.common.io import OutputWriter
22
24
 
23
-
24
25
  PIPELINE_OUTPUTS_MANIFEST_VERSION = 1
25
26
  _PIPELINE_OUTPUT_STORAGE_KINDS = {"directory", "zip", "file"}
26
27
  _IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
@@ -420,9 +421,32 @@ class SourceBackedOutputBackend:
420
421
  output_full_id: str | None = None,
421
422
  output_basename: str | None = None,
422
423
  attributes: dict[str, Any] | None = None,
424
+ variant_id: str | None = None,
425
+ sidecar: bool = False,
423
426
  ) -> Path:
424
427
  del default_path
425
428
 
429
+ if isinstance(self.dataset_writer, ValidatedDatasetWriter):
430
+ from euler_loading import write_materialized
431
+
432
+ provenance = sample.get("provenance")
433
+ if variant_id is not None and (
434
+ not provenance or provenance["variant_id"] != variant_id
435
+ ):
436
+ raise ValueError("output variant disagrees with captured execution")
437
+ source_attrs = dict(
438
+ sample.get("attributes", {}).get(self.source_modality, {})
439
+ )
440
+ source_attrs.pop("euler_transforms", None)
441
+ source_attrs.pop("output_encoding", None)
442
+ source_attrs.update(attributes or {})
443
+ return Path(write_materialized(
444
+ self.dataset_writer, value, provenance,
445
+ output_full_id=output_full_id or sample["full_id"],
446
+ output_basename=output_basename,
447
+ attributes=source_attrs, sidecar=sidecar,
448
+ ))
449
+
426
450
  sample_id = sample.get("id", "?")
427
451
  full_id = str(output_full_id or sample.get("full_id") or f"/{sample_id}")
428
452
  meta = sample.get("meta")
@@ -505,15 +529,11 @@ class SourceBackedOutputBackend:
505
529
 
506
530
  def set_hierarchy_separator(self, separator: str) -> None:
507
531
  """Set the writer hierarchy separator used for future entries."""
508
- setattr(self.dataset_writer, "_separator", separator)
532
+ self.dataset_writer.set_hierarchy_separator(separator)
509
533
 
510
534
  def add_head_addon(self, name: str, payload: dict[str, Any]) -> None:
511
535
  """Add a dataset-head addon before the writer saves its artifacts."""
512
- head = getattr(self.dataset_writer, "_dataset_head", None)
513
- addons = getattr(head, "addons", None)
514
- if not isinstance(addons, dict):
515
- raise RuntimeError("Unsupported dataset writer head object")
516
- addons[name] = dict(payload)
536
+ self.dataset_writer.set_head_addon(name, payload)
517
537
  self.index_overrides[name] = dict(payload)
518
538
 
519
539
  def write_json(self, path: Path, data: dict[str, Any]) -> None:
@@ -522,17 +542,13 @@ class SourceBackedOutputBackend:
522
542
  )
523
543
 
524
544
  def finalize(self) -> None:
525
- self.dataset_writer.save_index(filename=DS_CRAWLER_INDEX_FILENAME)
526
- _patch_output_index(
527
- self.dataset_writer,
528
- self.index_overrides,
529
- filename=DS_CRAWLER_INDEX_FILENAME,
530
- )
545
+ if isinstance(self.dataset_writer, ValidatedDatasetWriter):
546
+ self.dataset_writer.finalize()
547
+ else:
548
+ self.dataset_writer.save_index(filename=DS_CRAWLER_INDEX_FILENAME)
549
+ _patch_output_index(self.dataset_writer, self.index_overrides, filename=DS_CRAWLER_INDEX_FILENAME)
531
550
  if self.pipeline_manifest_path and self.pipeline_manifest_targets:
532
- _write_pipeline_outputs_manifest(
533
- self.pipeline_manifest_path,
534
- self.pipeline_manifest_targets,
535
- )
551
+ _write_pipeline_outputs_manifest(self.pipeline_manifest_path, self.pipeline_manifest_targets)
536
552
 
537
553
 
538
554
  def _resolve_output_slot(
@@ -644,6 +660,10 @@ def prepare_output_backend(
644
660
  f"{transform_class.__name__} does not declare SOURCE_MODALITY"
645
661
  )
646
662
 
663
+ materialized = config.get("materialization_outputs", {}).get(source_modality)
664
+ if materialized is not None:
665
+ return prepare_materialized_backend(dataset, source_modality, materialized)
666
+
647
667
  pipeline = parse_pipeline_config(config)
648
668
  pipeline_target = _select_pipeline_target(config, pipeline, transform_class)
649
669
  root = _resolve_output_root(config, pipeline, pipeline_target)
@@ -834,6 +854,12 @@ def prepare_output_backends(
834
854
  ``OUTPUT_SLOTS`` order.
835
855
  """
836
856
 
857
+ if config.get("materialization_outputs"):
858
+ return {
859
+ field: prepare_materialized_backend(dataset, field, options)
860
+ for field, options in config["materialization_outputs"].items()
861
+ }
862
+
837
863
  primary_slot = _resolve_primary_slot(transform_class)
838
864
  primary_backend = prepare_output_backend(config, dataset, transform_class)
839
865
  backends: dict[str, SourceBackedOutputBackend] = {primary_slot: primary_backend}
@@ -859,11 +885,7 @@ def prepare_output_backends(
859
885
 
860
886
  # Aggregate every slot we actually wrote into the manifest the primary
861
887
  # backend will emit, so a single manifest documents the full set.
862
- if (
863
- pipeline is not None
864
- and pipeline.outputs_manifest_path
865
- and len(backends) > 1
866
- ):
888
+ if pipeline is not None and pipeline.outputs_manifest_path and len(backends) > 1:
867
889
  manifest_targets: list[PipelineOutputTargetConfig] = list(
868
890
  primary_backend.pipeline_manifest_targets
869
891
  )
@@ -876,3 +898,29 @@ def prepare_output_backends(
876
898
  primary_backend.pipeline_manifest_targets = manifest_targets
877
899
 
878
900
  return backends
901
+
902
+
903
+ def prepare_materialized_backend(dataset, field, options):
904
+ """Explicit bound-output slot. Arbitrary producer changes fail receipt checks."""
905
+ from euler_loading import output_encoding
906
+
907
+ codec_config = options.get("encoding", {"id": "npy"})
908
+ codec = output_encoding(codec_config["id"], scale=codec_config.get("scale"))
909
+ writer = dataset.create_output_writer(
910
+ field,
911
+ options["path"],
912
+ derivation=options["derivation"],
913
+ dataset_id=options["dataset_id"],
914
+ revision=options["revision"],
915
+ encoding=codec,
916
+ expected_full_ids=options.get("expected_full_ids"),
917
+ metadata_scope=options.get("metadata_scope"),
918
+ resume=options.get("resume", False),
919
+ )
920
+ return SourceBackedOutputBackend(
921
+ source_modality=field,
922
+ root=Path(options["path"]),
923
+ dataset_writer=writer,
924
+ modality_writer=None,
925
+ modality_meta=None,
926
+ )