euler-preprocess 3.19.0__tar.gz → 3.21.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 (86) hide show
  1. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/CHANGELOG.md +29 -0
  2. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/PKG-INFO +34 -5
  3. euler_preprocess-3.19.0/euler_preprocess.egg-info/PKG-INFO → euler_preprocess-3.21.0/README.md +32 -23
  4. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/docs/plans/coherent-cropping.md +89 -4
  5. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/output.py +23 -28
  6. euler_preprocess-3.21.0/euler_preprocess/crop/label_files.py +183 -0
  7. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/crop/roles.py +41 -2
  8. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/crop/run.py +163 -18
  9. euler_preprocess-3.19.0/README.md → euler_preprocess-3.21.0/euler_preprocess.egg-info/PKG-INFO +52 -3
  10. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess.egg-info/SOURCES.txt +3 -0
  11. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess.egg-info/requires.txt +1 -1
  12. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/pyproject.toml +2 -2
  13. euler_preprocess-3.21.0/tests/test_crop_labels.py +255 -0
  14. euler_preprocess-3.21.0/tests/test_crop_muses.py +267 -0
  15. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/LICENSE +0 -0
  16. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/MANIFEST.in +0 -0
  17. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/docs/spatial.md +0 -0
  18. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/__init__.py +0 -0
  19. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/cli.py +0 -0
  20. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/__init__.py +0 -0
  21. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/color.py +0 -0
  22. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/dataset.py +0 -0
  23. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/device.py +0 -0
  24. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/intrinsics.py +0 -0
  25. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/io.py +0 -0
  26. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/logging.py +0 -0
  27. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/noise.py +0 -0
  28. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/normalize.py +0 -0
  29. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/sampling.py +0 -0
  30. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/common/transform.py +0 -0
  31. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/crop/__init__.py +0 -0
  32. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/crop/calibration.py +0 -0
  33. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/crop/files.py +0 -0
  34. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/crop/spec.py +0 -0
  35. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/crop/verify.py +0 -0
  36. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/__init__.py +0 -0
  37. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  38. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/atmospheric_light.py +0 -0
  39. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/augmentations.py +0 -0
  40. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/capture.py +0 -0
  41. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  42. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  43. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  44. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  45. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/inference.py +0 -0
  46. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/logging.py +0 -0
  47. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/models.py +0 -0
  48. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/pipeline.py +0 -0
  49. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/fog/transform.py +0 -0
  50. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/lidar_motion/__init__.py +0 -0
  51. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/lidar_motion/compensation.py +0 -0
  52. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/lidar_motion/transform.py +0 -0
  53. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/radial/__init__.py +0 -0
  54. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/radial/transform.py +0 -0
  55. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/sky_depth/__init__.py +0 -0
  56. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/sky_depth/transform.py +0 -0
  57. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess/spatial.py +0 -0
  58. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  59. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  60. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  61. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/examples/spatial-five-field.json +0 -0
  62. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/setup.cfg +0 -0
  63. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_airlight_fallback.py +0 -0
  64. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_cli_sample_selection.py +0 -0
  65. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_crop.py +0 -0
  66. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_crop_roles.py +0 -0
  67. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_crop_spec.py +0 -0
  68. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  69. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_dense_gloomy_daylight_config.py +0 -0
  70. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_fog_aux_outputs.py +0 -0
  71. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_fog_aware_auto_exposure.py +0 -0
  72. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_fog_cpu_gpu_parity.py +0 -0
  73. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_fog_input_normalization.py +0 -0
  74. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_fog_integration.py +0 -0
  75. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_intrinsics.py +0 -0
  76. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_lidar_motion.py +0 -0
  77. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_modality_roles.py +0 -0
  78. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_radial.py +0 -0
  79. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_real_drive_sim_scenario_profiles.py +0 -0
  80. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_scene_illumination.py +0 -0
  81. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_sensor_identity.py +0 -0
  82. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_sky_depth.py +0 -0
  83. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_source_backed_output.py +0 -0
  84. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_spatial.py +0 -0
  85. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_tone_map_lut.py +0 -0
  86. {euler_preprocess-3.19.0 → euler_preprocess-3.21.0}/tests/test_zip_output.py +0 -0
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `crop` crops label rasters in the stored file: semantic segmentation (the
6
+ ground truth that also holds the sky), instance and panoptic segmentation,
7
+ and sky masks.
8
+ - PNG, TIFF and BMP keep their mode, palette, bit depth and every channel;
9
+ `.npy`/`.npz` keep their dtype and other arrays.
10
+ - The result must decode exactly as the crop of the source labels.
11
+ - So no class is lost to a writer that re-encodes only what one loader
12
+ function returns: Real Drive Sim's non-red channels, or every class but
13
+ the sky for a dataset read through `sky_mask`. Label modalities no longer
14
+ need a writer.
15
+ - Files that cannot be kept (JPEG, 16-bit colour PNGs) are re-encoded with
16
+ the writer, with a warning.
17
+ - Labels can only be resized with `nearest`; another `resize.interpolation`
18
+ for them is an error.
19
+
20
+ - `crop` supports MUSES (with euler-loading 2.35.0's MUSES writers):
21
+ - `calib.json` holds the frame camera's `K` and every extrinsic. The
22
+ `intrinsics` modality patches it once, and every other modality indexing
23
+ the same file, or a byte-identical copy (`camera_extrinsics`,
24
+ `lidar_to_gnss`), is written with the patched bytes, so the bundle holds
25
+ one `K`. What each of them reads from the patched file must stay as it was;
26
+ two intrinsics modalities patching one file differently are refused.
27
+ - GNSS records (`gnss`) and `meta.json` timing (`sensor_timing`) are copied
28
+ unchanged by default.
29
+ - `configs/crop_muses_hood_config.json` cuts the engine hood off the
30
+ 1920×1080 frame camera images (bottom 160 rows, 1920×920 stay), with
31
+ `configs/crop_muses_dataset_config.json` as the dataset config.
32
+ - Require euler-loading 2.35.0 for the MUSES writers.
33
+
5
34
  - Add `euler-preprocess lidar-motion`, which compensates scanning-lidar points for
6
35
  the vehicle's motion during the sweep. It follows the MUSES SDK, moving each
7
36
  point to the frame camera's mid-exposure using the `gnss`, `sensor_timing`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-preprocess
3
- Version: 3.19.0
3
+ Version: 3.21.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.33.0
12
+ Requires-Dist: euler-loading>=2.35.0
13
13
  Requires-Dist: ds-crawler>=2.14.0
14
14
  Requires-Dist: euler-dataset-contract>=0.5.0
15
15
  Provides-Extra: gpu
@@ -748,7 +748,8 @@ like the original:
748
748
 
749
749
  | Modality kind | Crop |
750
750
  |---|---|
751
- | Rasters: RGB, dense depth, masks | Sliced; no resampling, so values are unchanged |
751
+ | Rasters: RGB, dense depth | Sliced; no resampling, so values are unchanged |
752
+ | Label rasters: semantic segmentation (the ground truth that also holds the sky), instance and panoptic segmentation, sky masks | Cropped in the stored file, so every class, channel, palette and bit depth is kept; resized only by nearest |
752
753
  | Pinhole intrinsics `K` (or `P` for rectified images) | `cx -= left`, `cy -= top`; `fx`, `fy`, skew unchanged |
753
754
  | Lidar point clouds | Copied byte for byte, or pruned to the points that project into the crop |
754
755
  | Extrinsics | Copied byte for byte |
@@ -800,6 +801,7 @@ The crop config (`configs/crop_config.json`) describes the box relative to the
800
801
 
801
802
  | Key | Meaning |
802
803
  |---|---|
804
+ | `reference` | The per-sample raster the box is relative to (default `rgb`). Any image-shaped modality works, so `"reference": "semantic_segmentation"` with only that modality in the dataset config crops the labels alone; other rasters must have its size |
803
805
  | `crop.remove` | What to cut off each edge (`top`, `bottom`, `left`, `right`) |
804
806
  | `crop.keep` + `crop.anchor` | The kept `height` / `width`, placed at `center` (default), `top`, `bottom`, `left`, `right`, `top_left`, `top_right`, `bottom_left` or `bottom_right` |
805
807
  | `crop.unit` | `fraction` (default) or `pixel`. Fractions lie in `[0, 1)` (`keep`: `(0, 1]`) and round half up per edge: `floor(f · size + 0.5)` |
@@ -808,7 +810,7 @@ The crop config (`configs/crop_config.json`) describes the box relative to the
808
810
  | `point_clouds` | `keep` (default): copy every point, consumers clip to the image. `prune_to_crop`: keep only the points with `z > 0` whose pixel `rint(K'·T·X)` lies in the output image, as consumers such as dmd3c-euler project them |
809
811
  | `projection` | For `prune_to_crop`: the `intrinsics` and `extrinsics` modalities to project with, when the dataset has more than one candidate |
810
812
  | `verify` | `all` (default), `none`, or a sample count: reopen the outputs and check every file against the crop (rasters and pruned clouds as encoded, intrinsics to 1e-3 px, copies byte-identical) |
811
- | `roles` | Override a modality's kind with `raster` or `copy`, for loaders whose annotation the crop does not know |
813
+ | `roles` | Override a modality's kind with `raster` or `copy`, for loaders whose annotation the crop does not know, or to copy an intrinsics file of another camera (e.g. MUSES `sensor: event`) instead of cropping it |
812
814
 
813
815
  Pixel units reproduce the official boxes exactly, e.g. `RGB2Gatedv2` is
814
816
  `{"remove": {"top": 202, "bottom": 54, "left": 280, "right": 360}, "unit": "pixel"}`.
@@ -816,7 +818,34 @@ Pixel units reproduce the official boxes exactly, e.g. `RGB2Gatedv2` is
816
818
  SeeingThroughFog rectification leaves on `_lut` frames (top 34, bottom 46,
817
819
  left 16 px), keeping a fully valid 1904×944 image.
818
820
 
819
- Rasters are re-encoded with the dataset's euler-loading writer, so a raster
821
+ MUSES keeps the frame camera's `K` and every extrinsic in one `calib.json`,
822
+ indexed as `intrinsics`, `camera_extrinsics` (and `lidar_to_gnss`). The crop
823
+ patches `intrinsics.rgb.K` once and writes that file for every modality that
824
+ indexes it, also when it is indexed as separate byte-identical copies, so the
825
+ bundle holds one `K`. `D`, the event camera and all extrinsics stay as they
826
+ are. `configs/crop_muses_hood_config.json` cuts the engine hood off the
827
+ 1920×1080 frame camera images: the bottom 160 rows, leaving 1920×920. Rows come
828
+ off the bottom only, so `K` and every `calib.json` stay byte-identical. Run
829
+ `lidar-motion` first and crop its output (`configs/crop_muses_dataset_config.json`).
830
+ GNSS records and `meta.json` sensor timing are copied unchanged, like the
831
+ extrinsics. MUSES label images (`gt_semantic`, `gt_panoptic`) are cropped in place like
832
+ any label raster.
833
+
834
+ Label rasters (loader types `semantic_segmentation`, `semantic_segmentation_color`,
835
+ `instance_segmentation`, `panoptic_segmentation`, `sky_mask`) are cropped in the
836
+ file itself: PNG, TIFF and BMP through Pillow in their own mode and palette,
837
+ `.npy`/`.npz` through NumPy. A semantic segmentation is the ground truth for
838
+ every class including the sky, and consumers read it through different
839
+ functions (`class_segmentation`, or `sky_mask` for sky-depth). Re-encoding the
840
+ decoded labels would drop what one function does not return: Real Drive Sim's
841
+ non-red channels, an indexed PNG's palette, or every class but the sky when a
842
+ dataset only has a `sky_mask` function. The in-place crop must decode exactly
843
+ as the crop of the source labels. Files it cannot keep (JPEG, 16-bit colour
844
+ PNGs, ambiguous array layouts) fall back to the writer with a warning. Labels
845
+ are never interpolated: a `resize.interpolation` other than `nearest` for them
846
+ is an error.
847
+
848
+ Other rasters are re-encoded with the dataset's euler-loading writer, so a raster
820
849
  modality needs one (SeeingThroughFog's raw `rccb` TIFFs have none). Each
821
850
  encoded file is decoded again right away and must give back the cropped values:
822
851
  exactly, or for an interpolated resize within half an 8-bit step. A resize
@@ -1,23 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: euler-preprocess
3
- Version: 3.19.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.33.0
13
- Requires-Dist: ds-crawler>=2.14.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">
@@ -748,7 +728,8 @@ like the original:
748
728
 
749
729
  | Modality kind | Crop |
750
730
  |---|---|
751
- | Rasters: RGB, dense depth, masks | Sliced; no resampling, so values are unchanged |
731
+ | Rasters: RGB, dense depth | Sliced; no resampling, so values are unchanged |
732
+ | Label rasters: semantic segmentation (the ground truth that also holds the sky), instance and panoptic segmentation, sky masks | Cropped in the stored file, so every class, channel, palette and bit depth is kept; resized only by nearest |
752
733
  | Pinhole intrinsics `K` (or `P` for rectified images) | `cx -= left`, `cy -= top`; `fx`, `fy`, skew unchanged |
753
734
  | Lidar point clouds | Copied byte for byte, or pruned to the points that project into the crop |
754
735
  | Extrinsics | Copied byte for byte |
@@ -800,6 +781,7 @@ The crop config (`configs/crop_config.json`) describes the box relative to the
800
781
 
801
782
  | Key | Meaning |
802
783
  |---|---|
784
+ | `reference` | The per-sample raster the box is relative to (default `rgb`). Any image-shaped modality works, so `"reference": "semantic_segmentation"` with only that modality in the dataset config crops the labels alone; other rasters must have its size |
803
785
  | `crop.remove` | What to cut off each edge (`top`, `bottom`, `left`, `right`) |
804
786
  | `crop.keep` + `crop.anchor` | The kept `height` / `width`, placed at `center` (default), `top`, `bottom`, `left`, `right`, `top_left`, `top_right`, `bottom_left` or `bottom_right` |
805
787
  | `crop.unit` | `fraction` (default) or `pixel`. Fractions lie in `[0, 1)` (`keep`: `(0, 1]`) and round half up per edge: `floor(f · size + 0.5)` |
@@ -808,7 +790,7 @@ The crop config (`configs/crop_config.json`) describes the box relative to the
808
790
  | `point_clouds` | `keep` (default): copy every point, consumers clip to the image. `prune_to_crop`: keep only the points with `z > 0` whose pixel `rint(K'·T·X)` lies in the output image, as consumers such as dmd3c-euler project them |
809
791
  | `projection` | For `prune_to_crop`: the `intrinsics` and `extrinsics` modalities to project with, when the dataset has more than one candidate |
810
792
  | `verify` | `all` (default), `none`, or a sample count: reopen the outputs and check every file against the crop (rasters and pruned clouds as encoded, intrinsics to 1e-3 px, copies byte-identical) |
811
- | `roles` | Override a modality's kind with `raster` or `copy`, for loaders whose annotation the crop does not know |
793
+ | `roles` | Override a modality's kind with `raster` or `copy`, for loaders whose annotation the crop does not know, or to copy an intrinsics file of another camera (e.g. MUSES `sensor: event`) instead of cropping it |
812
794
 
813
795
  Pixel units reproduce the official boxes exactly, e.g. `RGB2Gatedv2` is
814
796
  `{"remove": {"top": 202, "bottom": 54, "left": 280, "right": 360}, "unit": "pixel"}`.
@@ -816,7 +798,34 @@ Pixel units reproduce the official boxes exactly, e.g. `RGB2Gatedv2` is
816
798
  SeeingThroughFog rectification leaves on `_lut` frames (top 34, bottom 46,
817
799
  left 16 px), keeping a fully valid 1904×944 image.
818
800
 
819
- Rasters are re-encoded with the dataset's euler-loading writer, so a raster
801
+ MUSES keeps the frame camera's `K` and every extrinsic in one `calib.json`,
802
+ indexed as `intrinsics`, `camera_extrinsics` (and `lidar_to_gnss`). The crop
803
+ patches `intrinsics.rgb.K` once and writes that file for every modality that
804
+ indexes it, also when it is indexed as separate byte-identical copies, so the
805
+ bundle holds one `K`. `D`, the event camera and all extrinsics stay as they
806
+ are. `configs/crop_muses_hood_config.json` cuts the engine hood off the
807
+ 1920×1080 frame camera images: the bottom 160 rows, leaving 1920×920. Rows come
808
+ off the bottom only, so `K` and every `calib.json` stay byte-identical. Run
809
+ `lidar-motion` first and crop its output (`configs/crop_muses_dataset_config.json`).
810
+ GNSS records and `meta.json` sensor timing are copied unchanged, like the
811
+ extrinsics. MUSES label images (`gt_semantic`, `gt_panoptic`) are cropped in place like
812
+ any label raster.
813
+
814
+ Label rasters (loader types `semantic_segmentation`, `semantic_segmentation_color`,
815
+ `instance_segmentation`, `panoptic_segmentation`, `sky_mask`) are cropped in the
816
+ file itself: PNG, TIFF and BMP through Pillow in their own mode and palette,
817
+ `.npy`/`.npz` through NumPy. A semantic segmentation is the ground truth for
818
+ every class including the sky, and consumers read it through different
819
+ functions (`class_segmentation`, or `sky_mask` for sky-depth). Re-encoding the
820
+ decoded labels would drop what one function does not return: Real Drive Sim's
821
+ non-red channels, an indexed PNG's palette, or every class but the sky when a
822
+ dataset only has a `sky_mask` function. The in-place crop must decode exactly
823
+ as the crop of the source labels. Files it cannot keep (JPEG, 16-bit colour
824
+ PNGs, ambiguous array layouts) fall back to the writer with a warning. Labels
825
+ are never interpolated: a `resize.interpolation` other than `nearest` for them
826
+ is an error.
827
+
828
+ Other rasters are re-encoded with the dataset's euler-loading writer, so a raster
820
829
  modality needs one (SeeingThroughFog's raw `rccb` TIFFs have none). Each
821
830
  encoded file is decoded again right away and must give back the cropped values:
822
831
  exactly, or for an interpolated resize within half an 8-bit step. A resize
@@ -3,7 +3,8 @@
3
3
  Status: **phases 1 and 3 implemented** (2026-11-07) as `euler-preprocess crop`
4
4
  (plus `crop-box`), for **SeeingThroughFog** (euler-loading's `princeton_dense`
5
5
  loader) with `rgb`, `sparse_depth`, `intrinsics` and `camera_extrinsics`; see §5
6
- for results. Phase 2 (other datasets) is open. The euler-loading side shipped in
6
+ for results. Phase 2 (other datasets): **MUSES implemented** (2026-11-08, §5,
7
+ including the engine-hood crop); the rest is open. The euler-loading side shipped in
7
8
  2.31.0 (STF fixes) and 2.32.0 (MUSES inputs); phase 3 uses euler-loading 2.33.0
8
9
  and ds-crawler 2.14.0 (both released), which euler-preprocess now requires.
9
10
  euler-view launches it with the `euler-preprocess-crop` addon. Written 2026-11-06 against
@@ -244,10 +245,11 @@ modality name, which is what causes the hazard in §1.
244
245
 
245
246
  | Annotated shape | Role | Operation |
246
247
  |---|---|---|
247
- | `HW`, `1HW`, `HWC`, `CHW`, `3HW` | raster | Slice with the box; must match the reference H×W, else error |
248
+ | `HW`, `1HW`, `HWC`, `CHW`, `3HW` | raster | Slice with the box; must match the reference H×W, else error. Label types (`semantic_segmentation`, `semantic_segmentation_color`, `instance_segmentation`, `panoptic_segmentation`, `sky_mask`) are cropped in the stored file (2026-11-08) |
248
249
  | `NC` | point set | Byte copy (`prune_to_crop` later) |
249
250
  | `3x3` with type `intrinsics` | intrinsics | `K' = crop_intrinsics(K, top, left)` |
250
251
  | `4x4`, `Nx1` | extrinsics | Byte copy (patched copy where the file also holds K) |
252
+ | `dict` with type `gnss` / `sensor_timing` | copy | Byte copy (MUSES side data) |
251
253
  | `dict` (Real Drive Sim `calibration`) | calibration map | Phase 2 |
252
254
  | anything else | — | Error; override with `"roles": {"<name>": "raster" \| "copy"}` |
253
255
 
@@ -372,7 +374,7 @@ euler-preprocess:
372
374
 
373
375
  | Dataset | Missing |
374
376
  |---|---|
375
- | `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). |
377
+ | `muses` | Done (2026-11-08, see "MUSES crop" below). |
376
378
  | `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 |
377
379
  | `vkitti2` | `write_intrinsics` must keep every frame/camera row (it writes one today); `read_extrinsics` header bug (§7) |
378
380
  | `synscapes` | Nothing new (merge writers exist; pass `resx`/`resy`); extrinsics output = patched meta JSON |
@@ -381,7 +383,20 @@ euler-preprocess:
381
383
 
382
384
  Also in phase 2: a format-level raster crop (PNG/TIFF via Pillow, NPY/NPZ via
383
385
  NumPy, preserving mode, dtype and keys) for formats whose writers are missing or
384
- lossy.
386
+ lossy. **Done for label rasters** (2026-11-08,
387
+ `euler_preprocess/crop/label_files.py`), since a semantic segmentation is the
388
+ ground truth for every class, the sky included:
389
+ - The stored pixels are sliced, and for a resize the source pixels are picked
390
+ with euler-loading's own nearest indices, in the same mode, palette and
391
+ format.
392
+ - The result must decode exactly as the crop of the decoded source.
393
+ - Unsupported files fall back to the writer.
394
+ - Labels are never interpolated.
395
+
396
+ euler-view's crop addon offers `semantic_segmentation` instead of `sky_mask`,
397
+ which is only a model output. RGB is optional in the addon: the crop's
398
+ `reference` is the first included of RGB, depth and semantic segmentation, so
399
+ a single modality (e.g. the semantic GT) can be cropped on its own. Depth PNGs (§7.3) could use the same path later.
385
400
 
386
401
  **MUSES lidar needs motion compensation.** The sensor is not a spinning lidar.
387
402
  It scans a ~124° × 25° forward window top to bottom in 84.5 ms: point
@@ -436,6 +451,76 @@ loaders.
436
451
  The side data stays with the local dataset: MUSES license §2.3 forbids
437
452
  distributing it.
438
453
 
454
+ ### MUSES crop — implemented 2026-11-08
455
+
456
+ euler-loading (unreleased, for 2.35.0): MUSES writers for every image and
457
+ calibration modality.
458
+ - `write_rgb` / `write_reference_rgb`: 8-bit PNG, a bit-exact round trip on
459
+ real frames.
460
+ - `write_semantic_segmentation`, `write_semantic_segmentation_color`,
461
+ `write_panoptic_segmentation` and `write_sky_mask`.
462
+ - `write_intrinsics` updates `intrinsics[<sensor>].K` in `calib.json` like
463
+ `princeton_dense.write_intrinsics`: only a crop or rescale of the stored `K`,
464
+ applied to its float64 values. It keeps `D`, the event camera and all
465
+ extrinsics, and does not rewrite an unchanged matrix. The crop/rescale check
466
+ is now shared (`_writer_utils.pixel_grid_transform`).
467
+
468
+ euler-preprocess:
469
+ - A copied file whose source bytes equal a file an intrinsics modality
470
+ patched is written with the patched bytes. This covers MUSES `calib.json`
471
+ as `camera_extrinsics` / `lidar_to_gnss`, whether it is indexed in place
472
+ (metadata scopes) or as separate copies, so the bundle carries one `K`.
473
+ - Each such copy is decoded again and must read as before.
474
+ - Two intrinsics modalities patching one file differently are refused (copy
475
+ the event camera's view with `roles`).
476
+ - Intrinsics are written first in each pass.
477
+ - `dict` loaders of type `gnss` and `sensor_timing` are copied by default.
478
+ - Configs: `configs/crop_muses_hood_config.json` and
479
+ `crop_muses_dataset_config.json`; the euler-view crop addon has a
480
+ `muses-hood` preset.
481
+
482
+ **Engine hood.** Measured on the five sample frames (all REC0176, test/fog/day):
483
+ - **Method.** The hood is rigid with the camera, so its silhouette is the
484
+ vertical edge that is strong in every frame. Take the per-column maximum of
485
+ the across-frame minimum of the blurred vertical luma gradient.
486
+ - **Shape.** The silhouette is a dome over columns 216–1619. At its bottom
487
+ it reaches rows 1061 (column 250) and 1066 (column 1600). Its apex is at
488
+ **row 931–932, near column 915**.
489
+ - **Consistency.** All five frames agree to ±1 px. Luma at the apex steps
490
+ from road (44–53) to hood (65–70) between rows 930 and 933.
491
+ - **Crop.** Cutting the bottom 160 rows (1920×920 stay) leaves an 11-row
492
+ margin. The cut is free in lidar terms: the MUSES lidar's lowest return
493
+ projects to row 811–820 in the three frames with lidar, so no point lands in
494
+ rows 920–1079. Pruning drops only points outside the camera's horizontal
495
+ field of view (66.7 % kept).
496
+ - **Divisibility.** For a /32 height, cut 184 rows (1920×896).
497
+ - **Open.** These are frames of one recording out of 136. The camera is
498
+ calibrated once (`calib.json`), but a remount between recordings could
499
+ shift the hood by a few rows. Check frames from a few other recordings
500
+ (other days, night) before relying on the margin.
501
+
502
+ Real data (three frames with motion-compensated lidar, the real `calib.json`):
503
+ - the CLI crop gives a bit-exact RGB slice;
504
+ - `calib.json` for `intrinsics` and `camera_extrinsics` is byte-identical to
505
+ the source, since K is unchanged;
506
+ - verification passes in both point-cloud modes.
507
+
508
+ Real data, general crop (top 40, bottom 160, left 24, right 8, then 0.5x):
509
+ `K' = S·C·K` to 1e-9, with both `calib.json` outputs identical, `D`, the event
510
+ camera and the extrinsics kept, and verification passing.
511
+
512
+ Tests: euler-loading 1038 passed (15 new writer tests); euler-preprocess 298
513
+ passed. The MUSES-format fixture covers:
514
+ - the hood crop;
515
+ - a general crop patching `calib.json` for three modalities;
516
+ - pruning;
517
+ - the event-camera conflict;
518
+ - a scoped zip like the production `/data/muses.zip`;
519
+ - the configs.
520
+
521
+ The contract's loader evidence was refreshed (only source hashes and line
522
+ numbers changed), and its ecosystem check passes.
523
+
439
524
  ### Phase 3 — implemented 2026-11-07
440
525
 
441
526
  - `point_clouds: "prune_to_crop"`: keeps the points with `z > 0` whose pixel
@@ -569,46 +569,41 @@ class SourceBackedOutputBackend:
569
569
  target_path.write_bytes(data)
570
570
  return target_path
571
571
 
572
- def update_source(
572
+ def updated_bytes(
573
573
  self,
574
- full_id: str,
575
574
  source_entry: Mapping[str, Any],
576
575
  data: bytes,
577
576
  value: Any,
578
577
  meta: dict[str, Any] | None,
579
- *,
580
- attributes: dict[str, Any] | None = None,
581
- ) -> Path:
582
- """Store a copy of the source file, then let the writer update it to *value*.
578
+ ) -> bytes:
579
+ """Return *data*, the source file's bytes, after the writer has updated them to *value*.
583
580
 
584
581
  For writers that merge into an existing file (e.g. camera-info
585
582
  ``write_intrinsics``), so every field the new value does not describe
586
- is kept. The writer always receives a real file, also for zip outputs.
583
+ is kept. The writer works on a temporary copy named like the source.
587
584
  """
588
585
  if self.modality_writer is None:
589
586
  raise ValueError(f"No writer is available for modality '{self.source_modality}'")
590
587
  basename = Path(str(source_entry["path"])).name
591
- entry = dict(source_entry)
592
- merged = self._entry_attributes(source_entry, attributes)
593
- if isinstance(self.dataset_writer, ZipDatasetWriter):
594
- with tempfile.TemporaryDirectory() as tmpdir:
595
- temp_path = Path(tmpdir) / basename
596
- temp_path.write_bytes(data)
597
- self.modality_writer(str(temp_path), value, meta)
598
- self.dataset_writer.write(
599
- full_id,
600
- basename,
601
- temp_path.read_bytes(),
602
- source_entry=entry,
603
- attributes=merged,
604
- )
605
- return self._stored_path(full_id, basename)
606
- target_path = self.dataset_writer.get_path(
607
- full_id, basename, source_entry=entry, attributes=merged
608
- )
609
- target_path.write_bytes(data)
610
- self.modality_writer(str(target_path), value, meta)
611
- return target_path
588
+ with tempfile.TemporaryDirectory() as tmpdir:
589
+ temp_path = Path(tmpdir) / basename
590
+ temp_path.write_bytes(data)
591
+ self.modality_writer(str(temp_path), value, meta)
592
+ return temp_path.read_bytes()
593
+
594
+ def update_source(
595
+ self,
596
+ full_id: str,
597
+ source_entry: Mapping[str, Any],
598
+ data: bytes,
599
+ value: Any,
600
+ meta: dict[str, Any] | None,
601
+ *,
602
+ attributes: dict[str, Any] | None = None,
603
+ ) -> Path:
604
+ """Store a copy of the source file, updated by the writer to *value* (see :meth:`updated_bytes`)."""
605
+ updated = self.updated_bytes(source_entry, data, value, meta)
606
+ return self.copy_source(full_id, source_entry, updated, attributes=attributes)
612
607
 
613
608
  def update_head_meta(self, updates: Mapping[str, Any]) -> None:
614
609
  """Merge *updates* into the output head's ``modality.meta``; ``None`` removes a key."""
@@ -0,0 +1,183 @@
1
+ """Crop label rasters in the stored file, so ground-truth labels keep every class.
2
+
3
+ A semantic segmentation is ground truth: sky, road, the ego vehicle and every
4
+ other class are encoded in one file, which consumers read through different
5
+ functions (``class_segmentation`` for the labels, ``sky_mask`` for the sky).
6
+ Re-encoding the decoded value through a dataset writer keeps what one loader
7
+ returns, but not necessarily the file:
8
+
9
+ - palettes and bit depths change (an indexed or 16-bit PNG comes back as 8-bit);
10
+ - channels the loader ignores are dropped (Real Drive Sim keeps class IDs in
11
+ the red channel of an RGBA PNG; its writer zeroes the others);
12
+ - a semantic map read through ``sky_mask`` would be written back as sky / not sky.
13
+
14
+ So label rasters are cropped in the file itself: the stored pixels are sliced,
15
+ and resized by picking the source pixels nearest resampling picks, in the
16
+ same image mode, palette and format. PNG, TIFF and BMP go through Pillow, and
17
+ ``.npy`` / ``.npz`` through NumPy. The caller then checks that the modality's
18
+ loader reads the result as the crop of the source values. Anything else
19
+ (JPEG, 16-bit colour PNGs Pillow cannot hold, multi-frame images, ambiguous
20
+ array layouts) returns ``None`` and is re-encoded with the dataset's writer.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import io
26
+ import zipfile
27
+ from pathlib import Path
28
+
29
+ import numpy as np
30
+
31
+ from euler_preprocess.crop.spec import CropBox
32
+
33
+ #: Formats Pillow writes back losslessly.
34
+ _LOSSLESS_IMAGE_FORMATS = {"PNG", "TIFF", "BMP"}
35
+ _IMAGE_SUFFIXES = {".png", ".tif", ".tiff", ".bmp"}
36
+ _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
37
+ #: Pillow modes that hold 16-bit samples.
38
+ _WIDE_MODES = {"I", "I;16", "I;16B", "I;16L", "I;16N"}
39
+ #: TIFF compressions that lose nothing.
40
+ _LOSSLESS_TIFF_COMPRESSIONS = {None, "raw", "tiff_lzw", "tiff_adobe_deflate", "tiff_deflate", "packbits"}
41
+
42
+
43
+ def source_grid(box: CropBox) -> tuple[np.ndarray, np.ndarray]:
44
+ """Return the source row of every output row and the source column of every output column.
45
+
46
+ Without a resize these are the crop's rows and columns. With one they are
47
+ the pixels euler-loading's nearest resize picks: it is separable, so
48
+ resizing a column of row indices (and a row of column indices) gives them
49
+ exactly.
50
+ """
51
+ rows = np.arange(box.top, box.top + box.height)
52
+ cols = np.arange(box.left, box.left + box.width)
53
+ if box.resized:
54
+ from euler_loading import resize_raster
55
+
56
+ pick_rows = resize_raster(
57
+ np.arange(box.height, dtype=np.int64)[:, None],
58
+ layout="HW",
59
+ size=(box.output_height, 1),
60
+ interpolation="nearest",
61
+ )
62
+ pick_cols = resize_raster(
63
+ np.arange(box.width, dtype=np.int64)[None, :],
64
+ layout="HW",
65
+ size=(1, box.output_width),
66
+ interpolation="nearest",
67
+ )
68
+ rows = rows[np.asarray(pick_rows)[:, 0]]
69
+ cols = cols[np.asarray(pick_cols)[0, :]]
70
+ return rows, cols
71
+
72
+
73
+ def crop_label_file(
74
+ data: bytes, name: str, rows: np.ndarray, cols: np.ndarray, source_size: tuple[int, int]
75
+ ) -> bytes | None:
76
+ """Return the file *data* with only pixels ``rows`` x ``cols`` kept, or ``None``.
77
+
78
+ *name* gives the format (its suffix) and *source_size* the ``(height,
79
+ width)`` the stored raster must have. ``None`` means the file cannot be
80
+ cropped in place without loss.
81
+ """
82
+ suffix = Path(name).suffix.lower()
83
+ if suffix in _IMAGE_SUFFIXES:
84
+ return _crop_image(data, rows, cols, source_size)
85
+ if suffix == ".npy":
86
+ return _crop_npy(data, rows, cols, source_size)
87
+ if suffix == ".npz":
88
+ return _crop_npz(data, rows, cols, source_size)
89
+ return None
90
+
91
+
92
+ def _png_bit_depth(data: bytes) -> int | None:
93
+ if data[:8] != _PNG_SIGNATURE or data[12:16] != b"IHDR" or len(data) < 25:
94
+ return None
95
+ return data[24]
96
+
97
+
98
+ def _crop_image(data: bytes, rows: np.ndarray, cols: np.ndarray, source_size: tuple[int, int]) -> bytes | None:
99
+ from PIL import Image
100
+
101
+ try:
102
+ image = Image.open(io.BytesIO(data))
103
+ image.load()
104
+ except Exception:
105
+ return None
106
+ with image:
107
+ if image.format not in _LOSSLESS_IMAGE_FORMATS or getattr(image, "n_frames", 1) != 1:
108
+ return None
109
+ if (image.height, image.width) != tuple(source_size):
110
+ return None
111
+ if _png_bit_depth(data) == 16 and image.mode not in _WIDE_MODES:
112
+ # Pillow reads 16-bit colour PNGs as 8-bit: the file cannot be kept.
113
+ return None
114
+ params: dict[str, object] = {}
115
+ if image.format == "TIFF":
116
+ compression = image.info.get("compression")
117
+ if compression not in _LOSSLESS_TIFF_COMPRESSIONS:
118
+ return None
119
+ if compression is not None:
120
+ params["compression"] = compression
121
+ if "transparency" in image.info:
122
+ params["transparency"] = image.info["transparency"]
123
+
124
+ pixels = np.asarray(image)
125
+ cropped = Image.fromarray(np.ascontiguousarray(pixels[rows][:, cols]))
126
+ if image.mode == "P":
127
+ # Pillow turns the indices into an "L" image; the palette makes it "P" again.
128
+ cropped.putpalette(image.getpalette())
129
+ if cropped.mode != image.mode:
130
+ return None
131
+ buffer = io.BytesIO()
132
+ cropped.save(buffer, format=image.format, **params)
133
+ return buffer.getvalue()
134
+
135
+
136
+ def _spatial_axes(shape: tuple[int, ...], source_size: tuple[int, int]) -> tuple[int, int] | None | bool:
137
+ """Return the adjacent ``(height, width)`` axes of *shape*; ``None`` if absent, ``False`` if ambiguous."""
138
+ height, width = source_size
139
+ matches = [axis for axis in range(len(shape) - 1) if shape[axis] == height and shape[axis + 1] == width]
140
+ if not matches:
141
+ return None
142
+ if len(matches) > 1:
143
+ return False
144
+ return matches[0], matches[0] + 1
145
+
146
+
147
+ def _take(array: np.ndarray, axes: tuple[int, int], rows: np.ndarray, cols: np.ndarray) -> np.ndarray:
148
+ return np.ascontiguousarray(np.take(np.take(array, rows, axis=axes[0]), cols, axis=axes[1]))
149
+
150
+
151
+ def _crop_npy(data: bytes, rows: np.ndarray, cols: np.ndarray, source_size: tuple[int, int]) -> bytes | None:
152
+ try:
153
+ array = np.load(io.BytesIO(data), allow_pickle=False)
154
+ except Exception:
155
+ return None
156
+ axes = _spatial_axes(array.shape, source_size)
157
+ if not axes:
158
+ return None
159
+ buffer = io.BytesIO()
160
+ np.save(buffer, _take(array, axes, rows, cols), allow_pickle=False)
161
+ return buffer.getvalue()
162
+
163
+
164
+ def _crop_npz(data: bytes, rows: np.ndarray, cols: np.ndarray, source_size: tuple[int, int]) -> bytes | None:
165
+ try:
166
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
167
+ compressed = any(info.compress_type != zipfile.ZIP_STORED for info in archive.infolist())
168
+ with np.load(io.BytesIO(data), allow_pickle=False) as npz:
169
+ arrays = {key: npz[key] for key in npz.files}
170
+ except Exception:
171
+ return None
172
+ cropped: dict[str, np.ndarray] = {}
173
+ for key, array in arrays.items():
174
+ axes = _spatial_axes(array.shape, source_size)
175
+ if axes is False:
176
+ return None
177
+ # Arrays without the image's axes (class names, metadata) are kept as they are.
178
+ cropped[key] = array if axes is None else _take(array, axes, rows, cols)
179
+ if all(cropped[key] is arrays[key] for key in arrays):
180
+ return None
181
+ buffer = io.BytesIO()
182
+ (np.savez_compressed if compressed else np.savez)(buffer, **cropped)
183
+ return buffer.getvalue()