euler-preprocess 3.17.0__tar.gz → 3.18.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 (73) hide show
  1. euler_preprocess-3.18.0/CHANGELOG.md +14 -0
  2. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/PKG-INFO +36 -2
  3. euler_preprocess-3.17.0/euler_preprocess.egg-info/PKG-INFO → euler_preprocess-3.18.0/README.md +34 -20
  4. euler_preprocess-3.18.0/docs/plans/coherent-cropping.md +446 -0
  5. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/cli.py +13 -1
  6. euler_preprocess-3.18.0/euler_preprocess/lidar_motion/compensation.py +78 -0
  7. euler_preprocess-3.18.0/euler_preprocess/lidar_motion/transform.py +162 -0
  8. euler_preprocess-3.18.0/euler_preprocess/sky_depth/__init__.py +0 -0
  9. euler_preprocess-3.17.0/README.md → euler_preprocess-3.18.0/euler_preprocess.egg-info/PKG-INFO +54 -0
  10. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess.egg-info/SOURCES.txt +5 -0
  11. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess.egg-info/requires.txt +1 -1
  12. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/pyproject.toml +2 -2
  13. euler_preprocess-3.18.0/tests/test_lidar_motion.py +227 -0
  14. euler_preprocess-3.17.0/CHANGELOG.md +0 -5
  15. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/LICENSE +0 -0
  16. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/MANIFEST.in +0 -0
  17. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/docs/spatial.md +0 -0
  18. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/__init__.py +0 -0
  19. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/__init__.py +0 -0
  20. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/color.py +0 -0
  21. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/dataset.py +0 -0
  22. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/device.py +0 -0
  23. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/intrinsics.py +0 -0
  24. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/io.py +0 -0
  25. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/logging.py +0 -0
  26. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/noise.py +0 -0
  27. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/normalize.py +0 -0
  28. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/output.py +0 -0
  29. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/sampling.py +0 -0
  30. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/common/transform.py +0 -0
  31. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/__init__.py +0 -0
  32. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  33. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/atmospheric_light.py +0 -0
  34. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/augmentations.py +0 -0
  35. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/capture.py +0 -0
  36. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  37. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  38. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  39. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  40. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/inference.py +0 -0
  41. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/logging.py +0 -0
  42. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/models.py +0 -0
  43. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/pipeline.py +0 -0
  44. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/fog/transform.py +0 -0
  45. {euler_preprocess-3.17.0/euler_preprocess/radial → euler_preprocess-3.18.0/euler_preprocess/lidar_motion}/__init__.py +0 -0
  46. {euler_preprocess-3.17.0/euler_preprocess/sky_depth → euler_preprocess-3.18.0/euler_preprocess/radial}/__init__.py +0 -0
  47. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/radial/transform.py +0 -0
  48. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/sky_depth/transform.py +0 -0
  49. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess/spatial.py +0 -0
  50. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  51. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  52. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  53. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/examples/spatial-five-field.json +0 -0
  54. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/setup.cfg +0 -0
  55. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_airlight_fallback.py +0 -0
  56. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_cli_sample_selection.py +0 -0
  57. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  58. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_dense_gloomy_daylight_config.py +0 -0
  59. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_fog_aux_outputs.py +0 -0
  60. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_fog_aware_auto_exposure.py +0 -0
  61. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_fog_cpu_gpu_parity.py +0 -0
  62. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_fog_input_normalization.py +0 -0
  63. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_fog_integration.py +0 -0
  64. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_modality_roles.py +0 -0
  65. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_radial.py +0 -0
  66. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_real_drive_sim_scenario_profiles.py +0 -0
  67. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_scene_illumination.py +0 -0
  68. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_sensor_identity.py +0 -0
  69. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_sky_depth.py +0 -0
  70. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_source_backed_output.py +0 -0
  71. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_spatial.py +0 -0
  72. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_tone_map_lut.py +0 -0
  73. {euler_preprocess-3.17.0 → euler_preprocess-3.18.0}/tests/test_zip_output.py +0 -0
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Add `euler-preprocess lidar-motion`, which compensates scanning-lidar points for
6
+ the vehicle's motion during the sweep. It follows the MUSES SDK, moving each
7
+ point to the frame camera's mid-exposure using the `gnss`, `sensor_timing`
8
+ and `lidar_to_gnss` modalities.
9
+ - Require euler-loading 2.32.0, the first release with `muses.gnss`,
10
+ `muses.sensor_timing`, `muses.read_lidar_to_gnss` and `muses.write_sparse_depth`.
11
+
12
+ ## 3.14.0 (unreleased)
13
+
14
+ Consume shared preprocessing config and add the opt-in spatial producer, explicit variants/mappings and captured source-backed outputs. Preserve ordinary pipeline APIs.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-preprocess
3
- Version: 3.17.0
3
+ Version: 3.18.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,7 @@ 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>=2.29.0
12
+ Requires-Dist: euler-loading>=2.32.0
13
13
  Requires-Dist: ds-crawler>=2.11.0
14
14
  Requires-Dist: euler-dataset-contract>=0.5.0
15
15
  Provides-Extra: gpu
@@ -64,6 +64,7 @@ qualitative review.
64
64
  | `euler-preprocess fog` | Synthetic fog via the Koschmieder atmospheric scattering model |
65
65
  | `euler-preprocess sky-depth` | Override depth values in sky regions with a constant |
66
66
  | `euler-preprocess radial` | Convert planar (z-buffer) depth to radial (Euclidean) depth |
67
+ | `euler-preprocess lidar-motion` | Compensate scanning-lidar points for the vehicle's motion during a sweep |
67
68
 
68
69
  ## Installation
69
70
 
@@ -77,6 +78,7 @@ uv pip install "euler-preprocess[gpu,progress]"
77
78
  euler-preprocess fog -c configs/example_dataset_config.json
78
79
  euler-preprocess sky-depth -c configs/sky_depth_dataset_config.json
79
80
  euler-preprocess radial -c configs/radial_dataset_config.json
81
+ euler-preprocess lidar-motion -c configs/lidar_motion_dataset_config.json
80
82
  ```
81
83
 
82
84
  ## Dataset Config
@@ -118,6 +120,7 @@ loader.
118
120
  | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics`; used for radial depth conversion and camera-profile optics |
119
121
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
120
122
  | `radial` | `depth`, `intrinsics` | — |
123
+ | `lidar-motion` | `sparse_depth`, `gnss`, `sensor_timing`, `lidar_to_gnss` | — |
121
124
 
122
125
  `fog` and `sky-depth` need `semantic_segmentation` as a boolean sky mask. They load
123
126
  it with the `sky_mask` function of the euler-loading module that the index's
@@ -703,6 +706,37 @@ Standalone `RadialTransform(...)` usage keeps the legacy `.npy` output behaviour
703
706
 
704
707
  ---
705
708
 
709
+ ## LiDAR Motion Compensation
710
+
711
+ A scanning lidar measures its points one after another while the vehicle moves.
712
+ The MUSES lidar sweeps its field of view top to bottom in about 85 ms, so
713
+ uncorrected points projected into the frame camera match tall objects at their
714
+ base and drift away towards the top. Across MUSES, 92 % of frames are taken
715
+ above 10 km/h. `lidar-motion` moves every point to where it was at the
716
+ frame camera's mid-exposure. It follows the official MUSES SDK
717
+ (`motion_compensate_pcd`): constant ground speed along the GNSS frame's `x`,
718
+ constant roll, pitch and heading rates.
719
+
720
+ | Modality | Content | MUSES loader (euler-loading) |
721
+ |---|---|---|
722
+ | `sparse_depth` | `(N, C)` points with each point's time in seconds | `muses.sparse_depth` |
723
+ | `gnss` | The frame's GNSS/IMU record | `muses.gnss` |
724
+ | `sensor_timing` | `camera_exposure_start_us` / `camera_exposure_end_us` | `muses.sensor_timing` (`meta.json`) |
725
+ | `lidar_to_gnss` | `(4, 4)` lidar → GNSS/IMU transform | `muses.read_lidar_to_gnss` (`calib.json`) |
726
+
727
+ Index `gnss/` like the `lidar` folder, so samples join it by ID. Index
728
+ `meta.json` and `calib.json` as root-level files; the euler-loading docs
729
+ describe the MUSES ego-motion inputs. The transform config takes one optional
730
+ key, `timestamp_column` (default `5`, MUSES' `timestamp` column).
731
+
732
+ The output is a `sparse_depth` dataset mirroring the input's paths, file names
733
+ and `.bin` format. Only `x, y, z` change. The head records
734
+ `meta.motion_compensated`, and each file's `attributes.motion_compensation`
735
+ holds the target time and the speed and rates used. Point
736
+ `dmd3c-euler`, `euler-eval` or a crop at it in place of the raw lidar.
737
+
738
+ ---
739
+
706
740
  ## License
707
741
 
708
742
  [MIT](LICENSE) © Daniel Rothenpieler
@@ -1,23 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: euler-preprocess
3
- Version: 3.17.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>=2.29.0
13
- Requires-Dist: ds-crawler>=2.11.0
14
- Requires-Dist: euler-dataset-contract>=0.5.0
15
- Provides-Extra: gpu
16
- Requires-Dist: torch; extra == "gpu"
17
- Provides-Extra: progress
18
- Requires-Dist: tqdm; extra == "progress"
19
- Dynamic: license-file
20
-
21
1
  <!-- euler header — shared across the euler packages.
22
2
  Per package, change only: the <h1>, the tagline, and the badge URLs. -->
23
3
  <p align="center">
@@ -64,6 +44,7 @@ qualitative review.
64
44
  | `euler-preprocess fog` | Synthetic fog via the Koschmieder atmospheric scattering model |
65
45
  | `euler-preprocess sky-depth` | Override depth values in sky regions with a constant |
66
46
  | `euler-preprocess radial` | Convert planar (z-buffer) depth to radial (Euclidean) depth |
47
+ | `euler-preprocess lidar-motion` | Compensate scanning-lidar points for the vehicle's motion during a sweep |
67
48
 
68
49
  ## Installation
69
50
 
@@ -77,6 +58,7 @@ uv pip install "euler-preprocess[gpu,progress]"
77
58
  euler-preprocess fog -c configs/example_dataset_config.json
78
59
  euler-preprocess sky-depth -c configs/sky_depth_dataset_config.json
79
60
  euler-preprocess radial -c configs/radial_dataset_config.json
61
+ euler-preprocess lidar-motion -c configs/lidar_motion_dataset_config.json
80
62
  ```
81
63
 
82
64
  ## Dataset Config
@@ -118,6 +100,7 @@ loader.
118
100
  | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics`; used for radial depth conversion and camera-profile optics |
119
101
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
120
102
  | `radial` | `depth`, `intrinsics` | — |
103
+ | `lidar-motion` | `sparse_depth`, `gnss`, `sensor_timing`, `lidar_to_gnss` | — |
121
104
 
122
105
  `fog` and `sky-depth` need `semantic_segmentation` as a boolean sky mask. They load
123
106
  it with the `sky_mask` function of the euler-loading module that the index's
@@ -703,6 +686,37 @@ Standalone `RadialTransform(...)` usage keeps the legacy `.npy` output behaviour
703
686
 
704
687
  ---
705
688
 
689
+ ## LiDAR Motion Compensation
690
+
691
+ A scanning lidar measures its points one after another while the vehicle moves.
692
+ The MUSES lidar sweeps its field of view top to bottom in about 85 ms, so
693
+ uncorrected points projected into the frame camera match tall objects at their
694
+ base and drift away towards the top. Across MUSES, 92 % of frames are taken
695
+ above 10 km/h. `lidar-motion` moves every point to where it was at the
696
+ frame camera's mid-exposure. It follows the official MUSES SDK
697
+ (`motion_compensate_pcd`): constant ground speed along the GNSS frame's `x`,
698
+ constant roll, pitch and heading rates.
699
+
700
+ | Modality | Content | MUSES loader (euler-loading) |
701
+ |---|---|---|
702
+ | `sparse_depth` | `(N, C)` points with each point's time in seconds | `muses.sparse_depth` |
703
+ | `gnss` | The frame's GNSS/IMU record | `muses.gnss` |
704
+ | `sensor_timing` | `camera_exposure_start_us` / `camera_exposure_end_us` | `muses.sensor_timing` (`meta.json`) |
705
+ | `lidar_to_gnss` | `(4, 4)` lidar → GNSS/IMU transform | `muses.read_lidar_to_gnss` (`calib.json`) |
706
+
707
+ Index `gnss/` like the `lidar` folder, so samples join it by ID. Index
708
+ `meta.json` and `calib.json` as root-level files; the euler-loading docs
709
+ describe the MUSES ego-motion inputs. The transform config takes one optional
710
+ key, `timestamp_column` (default `5`, MUSES' `timestamp` column).
711
+
712
+ The output is a `sparse_depth` dataset mirroring the input's paths, file names
713
+ and `.bin` format. Only `x, y, z` change. The head records
714
+ `meta.motion_compensated`, and each file's `attributes.motion_compensation`
715
+ holds the target time and the speed and rates used. Point
716
+ `dmd3c-euler`, `euler-eval` or a crop at it in place of the raw lidar.
717
+
718
+ ---
719
+
706
720
  ## License
707
721
 
708
722
  [MIT](LICENSE) © Daniel Rothenpieler
@@ -0,0 +1,446 @@
1
+ # Coherent cropping plan
2
+
3
+ Status: plan only, nothing implemented yet. First target: **SeeingThroughFog**
4
+ (euler-loading's `princeton_dense` loader) with `rgb`, `sparse_depth`,
5
+ `intrinsics` and `camera_extrinsics`. Written 2026-11-06 against
6
+ euler-preprocess 3.17.0 and euler-loading 2.30.0.
7
+
8
+ ## 1. Is there a pathway today?
9
+
10
+ Not one that covers RGB, sparse depth, intrinsics and extrinsics together.
11
+
12
+ | Path | What it does | Why it doesn't fit |
13
+ |---|---|---|
14
+ | `euler-preprocess spatial` (`euler_preprocess/spatial.py`, `docs/spatial.md`) | Fixed-pixel crop/resize export with verified receipts | No percentages. Rejects passthrough fields (`euler_loading/transform_descriptors.py:174`), so lidar point clouds and extrinsics can't be exported. Writes NPY/PNG `materialized` datasets, not native formats. Needs a ~260-line binding config (`examples/spatial-five-field.json`). |
15
+ | `preprocessing` block in any dataset config (`euler_preprocess/common/dataset.py:221`) | Crops in memory before fog / sky-depth / radial run | Only the transform's own output is written, and it destroys sparse depth and extrinsics (table below). |
16
+ | `euler_loading.crop_intrinsics` / `geometry.crop_matrix` | The K maths | Building block only. |
17
+
18
+ Verified hazard (euler-loading 2.30.0,
19
+ `SamplePreprocessor.from_config({"crop": {"size": [576, 1024]}})` on one sample):
20
+
21
+ | Field | In | Out |
22
+ |---|---|---|
23
+ | `rgb` | 720×1280×3 | 576×1024×3 ✓ |
24
+ | `intrinsics` (3×3) | cx 640 | cx 512 ✓ |
25
+ | `sparse_depth` (N, 6) | 120000×6 | **576×0** |
26
+ | `camera_extrinsics` (4×4) | 4×4 | **0×0** |
27
+ | `intrinsics` as `{file_id: K}` (hierarchical) | — | **AttributeError** |
28
+
29
+ Cause: the field kind is inferred from the name (`sparse_depth` → `depth`,
30
+ `preprocessing.py:100-112`) or from a 2-D shape (4×4 → `generic`, `:447`), and
31
+ `_crop_spatial_value` (`:695`) then slices axes 0 and 1 of any 2-D array.
32
+
33
+ ## 2. What a crop changes
34
+
35
+ Box `(top, left, h, w)` in RGB pixels.
36
+
37
+ | Item | Correction |
38
+ |---|---|
39
+ | RGB, dense depth, sparse-depth rasters, masks | Slice. No resampling, so values are unchanged (planar and radial depth alike). |
40
+ | Pinhole matrix (`K`, or `P` for rectified images) | `K' = C·K` with `C = [[1,0,-left],[0,1,-top],[0,0,1]]`: `cx -= left`, `cy -= top`; fx, fy and skew unchanged |
41
+ | Distortion coefficients | Unchanged |
42
+ | Image-size metadata | Update: head `modality.meta.dimensions`, ROS `width`/`height`, Synscapes `resx`/`resy`, RDS `fov` |
43
+ | Extrinsics | **Unchanged** |
44
+ | Lidar point clouds | **Unchanged** |
45
+
46
+ The extrinsics need no correction because `u' = C·u = C·K·[R|t]·X = K'·[R|t]·X`:
47
+ the whole correction lives in the pixel matrix. The official SeeingThroughFog
48
+ tooling does exactly this. `resize.get_image_scaling()` returns
49
+ `[[sx,0,-sx·left],[0,sy,-sy·top],[0,0,1]]`, which is left-multiplied onto
50
+ `P·velodyne_to_camera`, and the lidar→camera transform is left alone
51
+ (`tools/CreateTFRecords/generic_tf_tools/resize.py:173`,
52
+ `tools/ProjectionTools/Lidar2RGB/lib/visi.py:41`).
53
+
54
+ So the cropped bundle carries the extrinsics and point clouds as unchanged
55
+ copies. Consumers that project at load time (dmd3c-euler `project_sparse_depth`,
56
+ `euler_complete.py:175`, and euler-eval) then get exactly the crop of the
57
+ original projection, bounded by the cropped RGB's h×w.
58
+
59
+ Later datasets have one exception. Where a single file holds both K and the
60
+ extrinsics (Real Drive Sim and MUSES `calib.json`, Synscapes `meta/<id>.json`),
61
+ the extrinsics output must be the patched file too. Otherwise the bundle carries
62
+ two different K. SeeingThroughFog keeps them in separate files.
63
+
64
+ ## 3. SeeingThroughFog
65
+
66
+ ### 3.1 Inputs and outputs
67
+
68
+ | Modality | Source (paths illustrative) | Loader (`princeton_dense`) | Stored as | Crop role | Output |
69
+ |---|---|---|---|---|---|
70
+ | `rgb` | `cam_stereo_left_lut/*.png` | `rgb` (HWC float32) | 8-bit PNG, 1920×1024, rectified | raster | Cropped PNG via `write_rgb`; the round trip is bit-exact (checked) |
71
+ | `sparse_depth` | `lidar_hdl64_strongest/*.bin` (or `_last`) | `sparse_depth` (`NC`) | (N, 5) float32 `x, y, z, intensity, ring` in `lidar_hdl64_s3_roof` | point set | Byte copy |
72
+ | `intrinsics` | `calib_cam_stereo_left.json`, one file, matched hierarchically | `read_intrinsics` (3×3) | ROS CameraInfo: `K, D, R, P, width, height, roi, …` | intrinsics | Patched copy via `princeton_dense.write_intrinsics`: K and P `cx`/`cy`, `width`, `height` |
73
+ | `camera_extrinsics` | `calib_tf_tree_full.json`, one file | `read_extrinsics` (4×4, HDL64 → `cam_stereo_left_optical`) | ROS TF list | unchanged | Byte copy |
74
+
75
+ The byte copies need no writer. `princeton_dense.write_intrinsics` now exists
76
+ in the euler-loading working tree (§3.2), so the intrinsics patch is covered.
77
+
78
+ ### 3.2 Fixed in euler-loading (not yet committed): `read_intrinsics` returned the wrong matrix for `_lut` images
79
+
80
+ - `princeton_dense.read_intrinsics` returns `K`
81
+ (`euler_loading/loaders/_princeton_dense.py:170`).
82
+ - The `cam_stereo_left_lut` PNGs are rectified to `P`. The official conversion
83
+ calls `rectifyImage` → `cv2.initUndistortRectifyMap(K, D, R, P)`
84
+ (`tools/Raw2LUTImages/conversion_lib/process.py:115`,
85
+ `pinhole_camera_model.py:125`). The official lidar projection uses `P`:
86
+ `vtc = P @ R @ velodyne_to_camera` with `R = I`
87
+ (`tools/DatasetViewer/lib/read.py:176-183`).
88
+ - The two matrices differ a lot: fx 2612.86 vs 2355.72, cx 966.53 vs 988.14.
89
+ Projecting lidar with `K` into a `_lut` image misplaces points by −21.6 px at
90
+ the principal point, −129 px at the left edge, +80 px at the right edge and
91
+ about ±55 px at the top and bottom.
92
+ - This is independent of cropping, but a crop corrects whichever matrix the
93
+ loader returns, so it had to be settled first.
94
+ - Done in the euler-loading working tree: `read_intrinsics` now returns the
95
+ rectified `P[:, :3]` by default (a behaviour change, in its CHANGELOG).
96
+ `intrinsics_matrix: "K"` (alias `"raw"`) in per-file attributes or head
97
+ meta selects the raw K.
98
+ - `write_intrinsics` writes that matrix back into a camera-info JSON. An
99
+ existing file is updated: the change from the stored matrix must be a crop
100
+ or rescale, and it is applied to K and P alike (whole-pixel crops as exact
101
+ float64 integer shifts). D, R, roi and header are kept; `meta["width"]` and
102
+ `meta["height"]` set the size. Any other change is refused. New files and
103
+ streams get an ideal pinhole camera.
104
+ - `K` (with `D`) belongs only to the raw 12-bit `cam_stereo_left` TIFFs (`rccb`).
105
+ - `R` (a 0.256° rotation, about 10 px) is left out, as the official tools and
106
+ their KITTI-format calibration (`R0_rect = I`) do. On two real dense-fog
107
+ frames (2018-10-08_08-18-59_00650 / 01050), a road-marking correlation scan
108
+ puts the best fit for `P` at (−3, +8) px, against (+7, +13) px for `P·R`.
109
+ The raw TIFFs agree independently: `K+D` with `Rᵀ` is closer than plain
110
+ `K+D` (12 vs 24 px). So the TF camera frame is the rectified one. Both image
111
+ types keep a ~+8–10 px vertical residual, which looks like lidar/camera
112
+ timing or a small pitch error. Re-check on static, clear-weather frames.
113
+ Plain `K` is off by 26 px and visibly misses a road sign by about 45 px.
114
+
115
+ ### 3.3 Other STF pitfalls
116
+
117
+ - The `lidar_hdl64_*_stereo_left` and `*_gated` point clouds are already in a
118
+ camera frame (official README table). Don't pair them with the default
119
+ HDL64→camera extrinsics; use `lidar_hdl64_strongest` or `_last`.
120
+ - `rccb` (raw Bayer TIFF) has no writer. A crop would need even offsets to keep
121
+ the Bayer phase, and demosaicing makes the 1-px border differ from cropping
122
+ the demosaiced image. Out of scope for phase 1.
123
+ - Raw TIFF datasets (`princeton_stf+rgb`):
124
+ - `princeton_dense.rgb` silently returns an all-white image for them: Pillow
125
+ clips 12-bit data. Their head must name `rccb`.
126
+ - `rccb` swapped red and blue; it now demosaics as GRBG (fixed in the
127
+ euler-loading working tree).
128
+ - The raw frames are not pinhole images (k1 = −0.57), so use the `_lut`
129
+ images for depth evaluation.
130
+ - The official crops are pixel boxes, e.g. `RGB2Gatedv2` keeps rows 202–970 and
131
+ columns 280–1560 (1280×768, `resize.py:91`). Accept pixel units alongside
132
+ fractions so these reproduce exactly.
133
+ - Invalid rectification border. By the calibration, 5.1 % of each `_lut` frame
134
+ samples outside the raw sensor: bands at the middle of the top, bottom and
135
+ left edges. OpenCV's default `remap` fills those with black.
136
+ `remove: {top: 34, bottom: 46, left: 16, right: 0}` (pixel units) leaves a
137
+ fully valid 1904×944 image (91.4 % of the frame). That's a sensible default
138
+ crop for depth evaluation, or else mask those pixels. Computed from
139
+ `calib_cam_stereo_left.json` and the official conversion code; confirm on a
140
+ real frame.
141
+ - Resampling in the official rectification is mild but uneven: about 1.11 raw
142
+ px per output px at the centre, but 0.76–0.81 near the left and right edges
143
+ (≈1.3× upsampled, so softer there). The `_lut` tone mapping also applies
144
+ CLAHE, which raises local contrast in fog. A pinhole image re-rendered from
145
+ the raw TIFFs with the same K, D, R and P keeps the geometry (and `P`) but
146
+ can avoid CLAHE. That's a photometric choice, not needed for geometric
147
+ correctness.
148
+
149
+ ### 3.4 Phase 1 acceptance
150
+
151
+ Run on an STF-format fixture (official calibration file layout, image size
152
+ consistent with `width`/`height`), and optionally on real frames via
153
+ `EULER_PREPROCESS_STF_ROOT`, the same opt-in pattern as the RDS test.
154
+
155
+ 1. `rgb_out == rgb_in[top:top+h, left:left+w]`, bit-exact.
156
+ 2. The `.bin` files and `calib_tf_tree_full.json` are byte-identical.
157
+ 3. In the calibration JSON, `K[2]` and `P[2]` drop by `left` and `K[5]` and
158
+ `P[6]` by `top`, computed in float64 (966.525 → 686.525, not 686.5250244).
159
+ `width`/`height` become w/h. `D`, `R`, `roi`, `header` and `binning_*` are
160
+ untouched.
161
+ 4. Projecting the points with the cropped `P` and unchanged `T` into h×w equals
162
+ the crop of the uncropped projection. Compare pixel coordinates to 1e-6 px.
163
+ Rasters must match except for points within 1e-6 px of a rounding boundary,
164
+ because `np.rint` rounds half to even.
165
+ 5. The output roots reload through `MultiModalDataset` with the roles
166
+ re-inferred (`sparse_depth` per sample, calibration hierarchical), and
167
+ `dmd3c-euler/euler_complete.py` runs on them unchanged.
168
+
169
+ ## 4. Design
170
+
171
+ ### 4.1 Command and config
172
+
173
+ ```bash
174
+ euler-preprocess crop -c configs/crop_stf_dataset_config.json
175
+ ```
176
+
177
+ The dataset config keeps the existing shape:
178
+
179
+ ```json
180
+ {
181
+ "transform_config_path": "crop_config.json",
182
+ "output_path": "/data/stf_crop",
183
+ "modalities": {
184
+ "rgb": "/data/stf/cam_stereo_left_lut",
185
+ "sparse_depth": "/data/stf/lidar_hdl64_strongest",
186
+ "intrinsics": "/data/stf/calib_cam_stereo_left",
187
+ "camera_extrinsics": "/data/stf/calib_tf_tree"
188
+ },
189
+ "samples": {"start": 0, "count": 100}
190
+ }
191
+ ```
192
+
193
+ Each modality is written to `<output_path>/<name>`, or wherever a
194
+ `pipeline.output_targets[]` entry with `slot: "<name>"` points (directory or
195
+ zip). A crop config containing `preprocessing` is rejected, since it would crop
196
+ twice.
197
+
198
+ `crop_config.json`:
199
+
200
+ ```json
201
+ {
202
+ "reference": "rgb",
203
+ "crop": {"remove": {"top": 0.20, "bottom": 0.05, "left": 0.15, "right": 0.19}},
204
+ "point_clouds": "keep",
205
+ "verify": "all"
206
+ }
207
+ ```
208
+
209
+ Other `crop` forms:
210
+
211
+ - `{"keep": {"height": 0.75, "width": 0.67}, "anchor": "bottom"}`. Anchors:
212
+ `center`, `top`, `bottom`, `left`, `right`, `top_left`, `top_right`,
213
+ `bottom_left`, `bottom_right`.
214
+ - `{"remove": {"top": 202, "bottom": 54, "left": 280, "right": 360}, "unit": "pixel"}`,
215
+ which equals `RGB2Gatedv2`.
216
+ - Optional `"multiple_of": 32` trims the kept size down to a multiple (DMD3C
217
+ pads to its stride today).
218
+
219
+ Fractions become pixels once per distinct reference size, and the result is
220
+ logged:
221
+
222
+ - `remove`: per edge, `px = floor(f·size + 0.5)`. That is round half up, not
223
+ Python's half-to-even `round`.
224
+ - `keep`: `out = floor(k·size + 0.5)`, with offsets from the anchor (center is
225
+ `(H - h) // 2`, as `euler_loading.Crop.resolve_box` does).
226
+ - Fractions must lie in [0, 1) and leave at least 1 px. A value like `10`, meant
227
+ as 10 %, is an error.
228
+
229
+ The resolved box is recorded per file (`attributes.crop`: `top`, `left`,
230
+ `height`, `width`, `source_height`, `source_width`) and in each output head's
231
+ meta.
232
+
233
+ ### 4.2 Roles come from the loader annotation, not the name
234
+
235
+ Every euler-loading loader carries `@modality_meta(shape=…, type=…)`, available
236
+ as `loader._modality_meta`. The crop reads that and never guesses from the
237
+ modality name, which is what causes the hazard in §1.
238
+
239
+ | Annotated shape | Role | Operation |
240
+ |---|---|---|
241
+ | `HW`, `1HW`, `HWC`, `CHW`, `3HW` | raster | Slice with the box; must match the reference H×W, else error |
242
+ | `NC` | point set | Byte copy (`prune_to_crop` later) |
243
+ | `3x3` with type `intrinsics` | intrinsics | `K' = crop_intrinsics(K, top, left)` |
244
+ | `4x4`, `Nx1` | extrinsics | Byte copy (patched copy where the file also holds K) |
245
+ | `dict` (Real Drive Sim `calibration`) | calibration map | Phase 2 |
246
+ | anything else | — | Error; override with `"roles": {"<name>": "raster" \| "copy"}` |
247
+
248
+ ### 4.3 Writing
249
+
250
+ 1. **Rasters**: a `SourceBackedOutputBackend` per modality, using the dataset's
251
+ writer. The head is mirrored from the source with
252
+ `modality.meta.dimensions` replaced (if present) and a `crop` record added.
253
+ This is the hook radial uses for `radial_depth` (`common/output.py:685-691`),
254
+ made independent of `transform_class`.
255
+ 2. **Point sets**: a new byte-copy path. Read the source file (directory or
256
+ zip) and register it at the same full ID and basename through
257
+ `DatasetWriter.get_path` or `ZipDatasetWriter.write`, keeping its
258
+ attributes. Bit-exact, and no writer needed.
259
+ 3. **Hierarchical calibration** (both STF calibration files): walk the
260
+ modality's own index (`collect_hierarchical_files`), not the samples. The
261
+ current backend can't write these (`common/output.py:463`): hierarchical
262
+ values arrive as `{file_id: value}` with no `sample["meta"]` entry
263
+ (euler-loading `dataset.py:1396`), and `write_sample` refuses them
264
+ (`dataset.py:1157`). For each file:
265
+ - collect the boxes of the selected samples that attach to it
266
+ (`match_hierarchical_files`);
267
+ - require exactly one box, else raise an error naming the conflicting sizes;
268
+ - write it once at its original full ID.
269
+
270
+ Files that no selected sample uses are skipped.
271
+ 4. **Intrinsics files**: copy the source file to the destination (a temp file
272
+ for zip), then call the module's merge-capable
273
+ `write_intrinsics(dest, K', meta | {"width": w, "height": h})`. It derives
274
+ the crop from the stored matrix and rewrites only the camera fields;
275
+ Synscapes' writer already merges this way. Before writing, check that the
276
+ stored `width`/`height` equal the source RGB size, which catches a K
277
+ describing another resolution.
278
+ 5. **Verify** (default: every file): reopen the output roots with
279
+ `build_dataset` and compare each sample with the cropped source. Rasters and
280
+ K' must be equal, and point sets and extrinsics identical. On a mismatch,
281
+ fail with the file name. This guards against writer surprises (§7).
282
+
283
+ ### 4.4 Code layout (euler-preprocess)
284
+
285
+ - `euler_preprocess/crop/spec.py`: config parsing and box resolution (pure
286
+ functions).
287
+ - `euler_preprocess/crop/roles.py`: role from the loader annotation, plus
288
+ overrides.
289
+ - `euler_preprocess/crop/run.py`: sample loop, raster slicing (NumPy HWC or
290
+ torch CHW, from the annotation), hierarchical bookkeeping.
291
+ - `euler_preprocess/crop/calibration.py`: hierarchical writer (unique-box
292
+ check, copy then merge).
293
+ - `euler_preprocess/crop/verify.py`.
294
+ - `euler_preprocess/common/output.py`: byte-copy registration helper, and a
295
+ source-backed backend factory for any modality with head-meta overrides.
296
+ - `euler_preprocess/common/intrinsics.py`: `single_calibration(value)`. A
297
+ `{id: K}` with one entry yields K; several entries are an error. Today
298
+ `extract_intrinsics` only finds the file ID `"intrinsics"` (`:23`), so it
299
+ misses STF's `calib_cam_stereo_left`.
300
+ - `euler_preprocess/cli.py`: a `crop` subcommand with its own `_cmd_crop`, like
301
+ `_cmd_spatial`, because the config drives the modality set.
302
+ - `configs/crop_config.json`, `configs/crop_stf_dataset_config.json`, a README
303
+ section and a CHANGELOG entry. Bump `pyproject.toml` to
304
+ `euler-loading>=2.31.0`.
305
+ - Optional `tools/check_crop_projection.py`: overlay projected lidar on the
306
+ cropped RGB for a few frames, as a visual K-vs-P and crop check.
307
+
308
+ ## 5. Work plan
309
+
310
+ ### Phase 1: SeeingThroughFog end to end
311
+
312
+ euler-loading (done in the working tree, uncommitted; ships with the next
313
+ release):
314
+
315
+ 1. `princeton_dense.write_intrinsics` (CPU and GPU share one function) merges
316
+ into an existing ROS CameraInfo JSON, as described in §3.2.
317
+ 2. `princeton_dense.read_intrinsics` defaults to `P[:, :3]`, with an
318
+ `intrinsics_matrix` selector (`"P"` | `"K"`).
319
+ 3. Tests in `tests/test_princeton_dense.py`, including a lidar projection
320
+ check that a crop needs no extrinsics change. The full suite passes.
321
+
322
+ euler-preprocess:
323
+
324
+ 4. `spec.py` and tests.
325
+ 5. `roles.py` and tests, including that `sparse_depth` and `camera_extrinsics`
326
+ are never sliced.
327
+ 6. Output helpers (byte copy, head-meta overrides) and tests for directory and
328
+ zip outputs.
329
+ 7. `run.py`, `calibration.py`, `verify.py` and the `crop` CLI.
330
+ 8. The STF fixture test (§3.4), a hierarchical conflict test (two image sizes
331
+ under one calibration file → error), and the optional real-data test.
332
+ 9. Docs and config examples.
333
+
334
+ ### Phase 2: other euler-loading datasets, when needed
335
+
336
+ | Dataset | Missing |
337
+ |---|---|
338
+ | `muses` | `write_rgb`; merge `write_intrinsics` for `calib.json` `intrinsics[sensor].K`; extrinsics output = patched `calib.json`. The lidar must be ego-motion compensated first (see below). |
339
+ | `real_drive_sim` | Merge `write_intrinsics` for the calibration JSON (`CS_FRONT` fx/fy/cx/cy; `fov` is the horizontal FOV for 2048 px, so recompute or drop it); extrinsics output = patched file; RGBA sources become RGB PNGs |
340
+ | `vkitti2` | `write_intrinsics` must keep every frame/camera row (it writes one today); `read_extrinsics` header bug (§7) |
341
+ | `synscapes` | Nothing new (merge writers exist; pass `resx`/`resy`); extrinsics output = patched meta JSON |
342
+ | `generic_dense_depth` | K lives in the head's `meta["intrinsics"]`, so patch the output head instead; PNG depth with a scale override can't be re-encoded (§7), so it needs a format-level crop |
343
+ | `generic` | Nothing |
344
+
345
+ Also in phase 2: a format-level raster crop (PNG/TIFF via Pillow, NPY/NPZ via
346
+ NumPy, preserving mode, dtype and keys) for formats whose writers are missing or
347
+ lossy.
348
+
349
+ **MUSES lidar needs motion compensation.** The sensor is not a spinning lidar.
350
+ It scans a ~124° × 25° forward window top to bottom in 84.5 ms: point
351
+ timestamps correlate −0.86 to −0.94 with elevation, and there are 5 rings.
352
+ euler-loading's `muses.sparse_depth` returns the raw points, so a moving car
353
+ smears them. Upright poles in real frames lean by 0.1–0.2 m (forward motion,
354
+ about 15–30 km/h) or about 1.5° (yaw while turning). That's the "matches at
355
+ the bottom, misses at the top" seen in projections, about 20–30 px.
356
+
357
+ The official SDK (`timbroed/MUSES`, `processing/utils.py:motion_compensate_pcd`)
358
+ moves each point to mid-exposure of the camera. It uses GNSS `gSpeed` and the
359
+ angular rates (`meta.json`, `gnss/`, `calib.json:lidar2gnss`) and writes the
360
+ same `(N, 6)` `.bin`.
361
+
362
+ Verified with the real side data:
363
+ - A port of it straightens the poles (lean 0.12–0.25 m → ≤0.06 m), and the
364
+ projections land on the poles in both a straight-ahead and a turning frame.
365
+ - Projecting with `K` + `D` is clearly wrong, so the released frame-camera
366
+ PNGs are already undistorted and plain `K` is right.
367
+ - Across all 2500 frames: median 29 km/h; 92 % above 10 km/h; 20 % turning
368
+ faster than 5°/s; the camera fires 29–89 % of the way through the sweep.
369
+ - The GNSS timestamp precedes mid-exposure by up to 16.6 ms, so `meta.json`'s
370
+ exposure times are needed.
371
+
372
+ Implemented (2026-11-07; uncommitted in both repos). The side data became
373
+ ordinary euler-loading modalities, consumed by euler-preprocess. ds-crawler is
374
+ unchanged: it only interprets paths, never file contents.
375
+
376
+ | Modality | ds-crawler indexing | euler-loading |
377
+ |---|---|---|
378
+ | `gnss` | `gnss/` as its own dataset, same hierarchy and frame-number ID regex as `lidar`, so it joins per sample. `include_regex` `_gnss\.txt$` leaves out `dataformat.txt`. | `muses.gnss`: the u-blox record in physical units |
379
+ | `sensor_timing` | `meta.json` as a root-level file (like `calib.json`), `include_regex` `^meta\.json$` | `muses.sensor_timing` decodes it once. A new generic hook, `selects_per_sample` / `SampleContext`, gives each sample its record, matched through the `path_to_*` file names `meta.json` lists. |
380
+ | `lidar_to_gnss` | `calib.json` indexed once more | `muses.read_lidar_to_gnss` (`extrinsics.lidar2gnss`) |
381
+ | output | — | `muses.write_sparse_depth`: `(N, 6)` float64 `.bin` |
382
+
383
+ `euler-preprocess lidar-motion -c <config>` reads the four modalities per
384
+ sample. It writes a `sparse_depth` dataset mirroring the input paths, with
385
+ `meta.motion_compensated` and per-file `attributes.motion_compensation`.
386
+
387
+ Results:
388
+ - **Tests:** euler-loading 891 passed; euler-preprocess 215 passed.
389
+ - **Real data:** on the three real frames the CLI output reproduces the
390
+ verified pole straightening.
391
+
392
+ Registered in euler-dataset-contract (uncommitted) as `kinematics.platform.state`,
393
+ `timing.platform.acquisition` and `geometry.platform.extrinsics`. Still open:
394
+ release euler-loading with these loaders, then raise euler-preprocess's
395
+ `euler-loading` pin.
396
+
397
+ The side data stays with the local dataset: MUSES license §2.3 forbids
398
+ distributing it.
399
+
400
+ ### Phase 3: optional
401
+
402
+ - `point_clouds: "prune_to_crop"`: drop points that project outside the box.
403
+ This needs sparse-depth writers, is lossy, and must use the same projection as
404
+ the consumers.
405
+ - A single scoped output root (one `metadata_scope` per modality), matching
406
+ dmd3c's `--dataset-root`.
407
+ - Resize, with the same maths: `K' = S·C·K`. The official `Sweden2PSMNet` mode
408
+ halves 1920×1024.
409
+ - A percentage helper for `spatial` that emits `crop: {size, offset}`.
410
+
411
+ ## 6. Decisions needed
412
+
413
+ | # | Decision | Recommendation |
414
+ |---|---|---|
415
+ | D1 | STF matrix | Resolved: the default is now `P`, with `intrinsics_matrix: "K"` for the raw matrix |
416
+ | D2 | Crop semantics: does "percentage" mean the share removed or the share kept? | Support both (`remove` per edge, `keep` + `anchor`) plus `unit: "pixel"`, with no default guessing |
417
+ | D3 | Point clouds: keep every point or prune to the crop | Keep: lossless, and consumers already clip to the image |
418
+ | D4 | Where calibration writers live | euler-loading, next to the loaders that parse the same formats |
419
+ | D5 | Output layout: one root per modality or one scoped root | One root per modality (current convention); scoped root in phase 3 |
420
+ | D6 | Build on `spatial`, or a separate source-backed `crop` command | Separate `crop`. Extending `spatial` first needs contract support for passthrough fields. Reuse `euler_loading.crop_intrinsics` so the K maths matches. |
421
+
422
+ ## 7. Pre-existing issues found while planning
423
+
424
+ 1. `SamplePreprocessor` crops silently destroy point clouds and 4×4 extrinsics
425
+ (§1). Anyone using a `preprocessing` block with STF lidar is affected. Fix in
426
+ euler-loading: crop or resize only values whose spatial size matches the
427
+ reference, or classify fields from the loader annotations.
428
+ 2. `princeton_dense.read_intrinsics` returned raw `K` for rectified `_lut`
429
+ images (§3.2). Fixed in the euler-loading working tree.
430
+ 3. `generic_dense_depth` PNG depth with `scale_to_meters_override` doesn't
431
+ survive decode → write: 10.0 m reloads as 0.039 m. The loader applies the
432
+ scale (`generic_dense_depth.py:133`), `write_depth` (`:226`) writes
433
+ `rint(metres)` as uint16, and the output entry keeps the override. The same
434
+ applies to sky-depth and radial on such data.
435
+ 4. `vkitti2.read_extrinsics` calls `np.loadtxt(path)` without `skiprows`
436
+ (`vkitti2.py:208`), so it raises on the documented header line
437
+ `frame cameraID r1,1 …`. This is also open question 4 in
438
+ `euler-dataset-contract/docs/ecosystem-streamlining-plan.md`.
439
+ 5. `extract_intrinsics` only resolves hierarchical intrinsics whose file ID is
440
+ `"intrinsics"`, so radial and fog miss STF's `calib_cam_stereo_left`.
441
+ 6. RGB writers always emit PNG bytes. VKITTI2 `.jpg` sources become PNG data
442
+ under `.jpg` names, and Real Drive Sim RGBA sources lose their alpha channel.
443
+ 7. `princeton_dense.rccb` demosaiced as GBRG, swapping red and blue. Fixed in
444
+ the euler-loading working tree (GRBG).
445
+ 8. `princeton_dense.rgb` on a 12-bit TIFF returns an all-white image without
446
+ an error. Not fixed: a guard that raises would be a small follow-up.
@@ -1,7 +1,8 @@
1
1
  """CLI entry point for euler-preprocess.
2
2
 
3
3
  Used both by ``python main.py`` and by the installed ``euler-preprocess`` console
4
- script. Supports subcommands: ``fog``, ``sky-depth``, ``radial``.
4
+ script. Supports subcommands: ``fog``, ``sky-depth``, ``radial``, ``lidar-motion``
5
+ and ``spatial``.
5
6
  """
6
7
  from __future__ import annotations
7
8
 
@@ -251,6 +252,11 @@ def _cmd_radial(args: argparse.Namespace) -> int:
251
252
  return _run_transform(args, RadialTransform)
252
253
 
253
254
 
255
+ def _cmd_lidar_motion(args: argparse.Namespace) -> int:
256
+ from euler_preprocess.lidar_motion.transform import LidarMotionTransform
257
+ return _run_transform(args, LidarMotionTransform)
258
+
259
+
254
260
  def _cmd_spatial(args: argparse.Namespace) -> int:
255
261
  from euler_preprocess.spatial import export_spatial
256
262
 
@@ -305,6 +311,12 @@ def parse_args() -> argparse.Namespace:
305
311
  )
306
312
  radial_parser.set_defaults(func=_cmd_radial)
307
313
 
314
+ lidar_motion_parser = subparsers.add_parser(
315
+ "lidar-motion", parents=[common],
316
+ help="Compensate scanning-lidar points for ego motion during the sweep.",
317
+ )
318
+ lidar_motion_parser.set_defaults(func=_cmd_lidar_motion)
319
+
308
320
  spatial_parser = subparsers.add_parser(
309
321
  "spatial", parents=[common], help="Export deterministic crop/resize with verified execution receipts.",
310
322
  )