euler-preprocess 3.18.0__tar.gz → 3.19.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 (84) hide show
  1. euler_preprocess-3.19.0/CHANGELOG.md +44 -0
  2. {euler_preprocess-3.18.0/euler_preprocess.egg-info → euler_preprocess-3.19.0}/PKG-INFO +110 -3
  3. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/README.md +107 -0
  4. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/docs/plans/coherent-cropping.md +112 -37
  5. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/cli.py +44 -2
  6. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/intrinsics.py +26 -6
  7. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/output.py +162 -0
  8. euler_preprocess-3.19.0/euler_preprocess/crop/__init__.py +1 -0
  9. euler_preprocess-3.19.0/euler_preprocess/crop/calibration.py +103 -0
  10. euler_preprocess-3.19.0/euler_preprocess/crop/files.py +84 -0
  11. euler_preprocess-3.19.0/euler_preprocess/crop/roles.py +169 -0
  12. euler_preprocess-3.19.0/euler_preprocess/crop/run.py +770 -0
  13. euler_preprocess-3.19.0/euler_preprocess/crop/spec.py +444 -0
  14. euler_preprocess-3.19.0/euler_preprocess/crop/verify.py +109 -0
  15. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0/euler_preprocess.egg-info}/PKG-INFO +110 -3
  16. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess.egg-info/SOURCES.txt +11 -0
  17. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess.egg-info/requires.txt +2 -2
  18. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/pyproject.toml +3 -3
  19. euler_preprocess-3.19.0/tests/test_crop.py +423 -0
  20. euler_preprocess-3.19.0/tests/test_crop_roles.py +84 -0
  21. euler_preprocess-3.19.0/tests/test_crop_spec.py +195 -0
  22. euler_preprocess-3.19.0/tests/test_intrinsics.py +30 -0
  23. euler_preprocess-3.18.0/CHANGELOG.md +0 -14
  24. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/LICENSE +0 -0
  25. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/MANIFEST.in +0 -0
  26. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/docs/spatial.md +0 -0
  27. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/__init__.py +0 -0
  28. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/__init__.py +0 -0
  29. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/color.py +0 -0
  30. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/dataset.py +0 -0
  31. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/device.py +0 -0
  32. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/io.py +0 -0
  33. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/logging.py +0 -0
  34. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/noise.py +0 -0
  35. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/normalize.py +0 -0
  36. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/sampling.py +0 -0
  37. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/common/transform.py +0 -0
  38. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/__init__.py +0 -0
  39. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  40. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/atmospheric_light.py +0 -0
  41. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/augmentations.py +0 -0
  42. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/capture.py +0 -0
  43. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  44. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  45. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  46. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  47. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/inference.py +0 -0
  48. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/logging.py +0 -0
  49. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/models.py +0 -0
  50. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/pipeline.py +0 -0
  51. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/fog/transform.py +0 -0
  52. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/lidar_motion/__init__.py +0 -0
  53. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/lidar_motion/compensation.py +0 -0
  54. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/lidar_motion/transform.py +0 -0
  55. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/radial/__init__.py +0 -0
  56. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/radial/transform.py +0 -0
  57. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/sky_depth/__init__.py +0 -0
  58. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/sky_depth/transform.py +0 -0
  59. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess/spatial.py +0 -0
  60. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  61. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  62. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  63. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/examples/spatial-five-field.json +0 -0
  64. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/setup.cfg +0 -0
  65. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_airlight_fallback.py +0 -0
  66. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_cli_sample_selection.py +0 -0
  67. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  68. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_dense_gloomy_daylight_config.py +0 -0
  69. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_fog_aux_outputs.py +0 -0
  70. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_fog_aware_auto_exposure.py +0 -0
  71. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_fog_cpu_gpu_parity.py +0 -0
  72. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_fog_input_normalization.py +0 -0
  73. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_fog_integration.py +0 -0
  74. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_lidar_motion.py +0 -0
  75. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_modality_roles.py +0 -0
  76. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_radial.py +0 -0
  77. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_real_drive_sim_scenario_profiles.py +0 -0
  78. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_scene_illumination.py +0 -0
  79. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_sensor_identity.py +0 -0
  80. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_sky_depth.py +0 -0
  81. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_source_backed_output.py +0 -0
  82. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_spatial.py +0 -0
  83. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_tone_map_lut.py +0 -0
  84. {euler_preprocess-3.18.0 → euler_preprocess-3.19.0}/tests/test_zip_output.py +0 -0
@@ -0,0 +1,44 @@
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
+ - Add `euler-preprocess crop`, which crops RGB, depth, intrinsics and
12
+ extrinsics coherently. The box is given as fractions or pixels, either removed
13
+ per edge or kept with an anchor, relative to a reference raster. Rasters are
14
+ sliced, pinhole intrinsics get `cx -= left`, `cy -= top` merged into a copy of
15
+ their file, and lidar point clouds and extrinsics are copied unchanged. Each
16
+ modality's treatment comes from its loader annotation, not its name. Shared
17
+ calibration files are written once, and samples with different crops cannot
18
+ share one. The outputs are reopened and checked against the crop.
19
+ `configs/crop_stf_valid_region_config.json` removes SeeingThroughFog's black
20
+ rectification border.
21
+ - `crop` phase 3:
22
+ - `point_clouds: "prune_to_crop"` keeps only the lidar points that project
23
+ into the output image (`z > 0`, `rint(K'·T·X)` inside), the projection
24
+ consumers such as dmd3c-euler use;
25
+ - `resize` (`size` or `scale`) after the crop, with `K' = S·C·K`; colour
26
+ images are interpolated bilinearly, all other rasters take the nearest
27
+ pixel;
28
+ - `"output_layout": "scoped"` writes one root (directory or zip) with one
29
+ metadata scope per modality, as dmd3c-euler's `--dataset-root` reads;
30
+ - every encoded raster or pruned cloud is decoded again and must reproduce
31
+ the values;
32
+ - `euler-preprocess crop-box` prints the pixel box and the matching
33
+ `spatial` preprocessing block.
34
+ - `crop` uses the new public euler-loading accessors (`get_loader_meta`,
35
+ `get_hierarchical_files`) and ds-crawler's `set_modality_meta` / `path_prefix`.
36
+ Requires euler-loading 2.33.0 and ds-crawler 2.14.0.
37
+ - `extract_intrinsics` (radial, fog) also uses a hierarchical intrinsics file
38
+ that is not named `intrinsics` when it is the only one, such as SeeingThroughFog's
39
+ `calib_cam_stereo_left`. Several matching files now raise instead of being
40
+ ignored.
41
+
42
+ ## 3.14.0 (unreleased)
43
+
44
+ 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.18.0
3
+ Version: 3.19.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,8 +9,8 @@ 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.32.0
13
- Requires-Dist: ds-crawler>=2.11.0
12
+ Requires-Dist: euler-loading>=2.33.0
13
+ Requires-Dist: ds-crawler>=2.14.0
14
14
  Requires-Dist: euler-dataset-contract>=0.5.0
15
15
  Provides-Extra: gpu
16
16
  Requires-Dist: torch; extra == "gpu"
@@ -65,6 +65,8 @@ qualitative review.
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
67
  | `euler-preprocess lidar-motion` | Compensate scanning-lidar points for the vehicle's motion during a sweep |
68
+ | `euler-preprocess crop` | Crop RGB, depth, intrinsics and extrinsics coherently |
69
+ | `euler-preprocess crop-box` | Print the pixel box a crop config gives for an image size |
68
70
 
69
71
  ## Installation
70
72
 
@@ -79,6 +81,7 @@ euler-preprocess fog -c configs/example_dataset_config.json
79
81
  euler-preprocess sky-depth -c configs/sky_depth_dataset_config.json
80
82
  euler-preprocess radial -c configs/radial_dataset_config.json
81
83
  euler-preprocess lidar-motion -c configs/lidar_motion_dataset_config.json
84
+ euler-preprocess crop -c configs/crop_stf_dataset_config.json
82
85
  ```
83
86
 
84
87
  ## Dataset Config
@@ -737,6 +740,110 @@ holds the target time and the speed and rates used. Point
737
740
 
738
741
  ---
739
742
 
743
+ ## Coherent Crop
744
+
745
+ `crop` cuts the same box out of every image-shaped modality and corrects
746
+ everything that refers to the pixel grid, so a cropped bundle projects exactly
747
+ like the original:
748
+
749
+ | Modality kind | Crop |
750
+ |---|---|
751
+ | Rasters: RGB, dense depth, masks | Sliced; no resampling, so values are unchanged |
752
+ | Pinhole intrinsics `K` (or `P` for rectified images) | `cx -= left`, `cy -= top`; `fx`, `fy`, skew unchanged |
753
+ | Lidar point clouds | Copied byte for byte, or pruned to the points that project into the crop |
754
+ | Extrinsics | Copied byte for byte |
755
+
756
+ The extrinsics need no change, because `C·K·[R|t]·X = K'·[R|t]·X` with
757
+ `C = [[1,0,-left],[0,1,-top],[0,0,1]]`: the whole correction lives in the
758
+ camera matrix, which is also what the official SeeingThroughFog tools do. An
759
+ optional resize afterwards adds `S` (an `align_corners=False` rescale), so
760
+ `K' = S·C·K`, still with untouched extrinsics.
761
+ Which kind a modality is comes from its euler-loading loader annotation
762
+ (`shape`, `type`), never from its name.
763
+
764
+ The dataset config lists every modality to crop; whether a modality is
765
+ per-sample or hierarchical (shared calibration) is inferred from its index:
766
+
767
+ ```json
768
+ {
769
+ "transform_config_path": "crop_stf_valid_region_config.json",
770
+ "output_path": "/data/stf_crop",
771
+ "modalities": {
772
+ "rgb": "/data/stf/cam_stereo_left_lut",
773
+ "sparse_depth": "/data/stf/lidar_hdl64_strongest",
774
+ "intrinsics": "/data/stf/calib_cam_stereo_left",
775
+ "camera_extrinsics": "/data/stf/calib_tf_tree"
776
+ },
777
+ "samples": {"start": 0, "count": 100}
778
+ }
779
+ ```
780
+
781
+ Each modality is written to `<output_path>/<name>`, or to its
782
+ `pipeline.output_targets[]` entry (`slot` = the modality name; directory or zip).
783
+ With pipeline targets, every modality needs one and the outputs manifest lists
784
+ them all. `"output_layout": "scoped"` writes one root at `output_path` instead
785
+ (a directory or `.zip`): one ds-crawler metadata scope per modality, named after
786
+ it, with its files below `<name>/`. That is the layout dmd3c-euler's
787
+ `--dataset-root` reads. The dataset config must not contain `preprocessing`.
788
+
789
+ The crop config (`configs/crop_config.json`) describes the box relative to the
790
+ `reference` raster:
791
+
792
+ ```json
793
+ {
794
+ "reference": "rgb",
795
+ "crop": {"remove": {"top": 0.20, "bottom": 0.05, "left": 0.15, "right": 0.19}},
796
+ "point_clouds": "keep",
797
+ "verify": "all"
798
+ }
799
+ ```
800
+
801
+ | Key | Meaning |
802
+ |---|---|
803
+ | `crop.remove` | What to cut off each edge (`top`, `bottom`, `left`, `right`) |
804
+ | `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
+ | `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)` |
806
+ | `crop.multiple_of` | Trim the kept size down to a multiple, e.g. a network stride; the extra rows and columns come off both sides evenly |
807
+ | `resize` | Optional, after the crop: `{"size": [h, w]}` or `{"scale": 0.5}` (or `[sy, sx]`); `interpolation` maps modality names to `nearest`, `bilinear` or `bicubic` |
808
+ | `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
+ | `projection` | For `prune_to_crop`: the `intrinsics` and `extrinsics` modalities to project with, when the dataset has more than one candidate |
810
+ | `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 |
812
+
813
+ Pixel units reproduce the official boxes exactly, e.g. `RGB2Gatedv2` is
814
+ `{"remove": {"top": 202, "bottom": 54, "left": 280, "right": 360}, "unit": "pixel"}`.
815
+ `configs/crop_stf_valid_region_config.json` removes the black border the
816
+ SeeingThroughFog rectification leaves on `_lut` frames (top 34, bottom 46,
817
+ left 16 px), keeping a fully valid 1904×944 image.
818
+
819
+ Rasters are re-encoded with the dataset's euler-loading writer, so a raster
820
+ modality needs one (SeeingThroughFog's raw `rccb` TIFFs have none). Each
821
+ encoded file is decoded again right away and must give back the cropped values:
822
+ exactly, or for an interpolated resize within half an 8-bit step. A resize
823
+ interpolates colour images (`rgb`, `rccb`) bilinearly and takes the nearest
824
+ pixel for everything else, so depth and label values stay real values;
825
+ sparse-depth rasters are not resized. Pruned point clouds need a writer too
826
+ (`princeton_dense.write_sparse_depth`, `muses.write_sparse_depth`). Intrinsics
827
+ are written by copying the source file and letting its writer move the matrices
828
+ onto the new grid: for a ROS CameraInfo file such as
829
+ `calib_cam_stereo_left.json`, `K` and `P` shift by exact integers, `width` and
830
+ `height` become the crop size, and `D`, `R`, `roi` and the header are kept. A
831
+ calibration file that records another image size than the reference rasters
832
+ is refused. A hierarchical file is written once, so every sample sharing it
833
+ must get the same box; mixed image sizes under one calibration file are an
834
+ error. Every output head records `meta.crop` (reference, spec and boxes), and
835
+ every file its own `attributes.crop` box; pruned clouds also record
836
+ `attributes.point_clouds` (source and kept point counts).
837
+
838
+ To reproduce a crop with `euler-preprocess spatial`, print its pixel box and
839
+ the matching `preprocessing` block for a reference size:
840
+
841
+ ```bash
842
+ euler-preprocess crop-box -c configs/crop_config.json --size 1024 1920
843
+ ```
844
+
845
+ ---
846
+
740
847
  ## License
741
848
 
742
849
  [MIT](LICENSE) © Daniel Rothenpieler
@@ -45,6 +45,8 @@ qualitative review.
45
45
  | `euler-preprocess sky-depth` | Override depth values in sky regions with a constant |
46
46
  | `euler-preprocess radial` | Convert planar (z-buffer) depth to radial (Euclidean) depth |
47
47
  | `euler-preprocess lidar-motion` | Compensate scanning-lidar points for the vehicle's motion during a sweep |
48
+ | `euler-preprocess crop` | Crop RGB, depth, intrinsics and extrinsics coherently |
49
+ | `euler-preprocess crop-box` | Print the pixel box a crop config gives for an image size |
48
50
 
49
51
  ## Installation
50
52
 
@@ -59,6 +61,7 @@ euler-preprocess fog -c configs/example_dataset_config.json
59
61
  euler-preprocess sky-depth -c configs/sky_depth_dataset_config.json
60
62
  euler-preprocess radial -c configs/radial_dataset_config.json
61
63
  euler-preprocess lidar-motion -c configs/lidar_motion_dataset_config.json
64
+ euler-preprocess crop -c configs/crop_stf_dataset_config.json
62
65
  ```
63
66
 
64
67
  ## Dataset Config
@@ -717,6 +720,110 @@ holds the target time and the speed and rates used. Point
717
720
 
718
721
  ---
719
722
 
723
+ ## Coherent Crop
724
+
725
+ `crop` cuts the same box out of every image-shaped modality and corrects
726
+ everything that refers to the pixel grid, so a cropped bundle projects exactly
727
+ like the original:
728
+
729
+ | Modality kind | Crop |
730
+ |---|---|
731
+ | Rasters: RGB, dense depth, masks | Sliced; no resampling, so values are unchanged |
732
+ | Pinhole intrinsics `K` (or `P` for rectified images) | `cx -= left`, `cy -= top`; `fx`, `fy`, skew unchanged |
733
+ | Lidar point clouds | Copied byte for byte, or pruned to the points that project into the crop |
734
+ | Extrinsics | Copied byte for byte |
735
+
736
+ The extrinsics need no change, because `C·K·[R|t]·X = K'·[R|t]·X` with
737
+ `C = [[1,0,-left],[0,1,-top],[0,0,1]]`: the whole correction lives in the
738
+ camera matrix, which is also what the official SeeingThroughFog tools do. An
739
+ optional resize afterwards adds `S` (an `align_corners=False` rescale), so
740
+ `K' = S·C·K`, still with untouched extrinsics.
741
+ Which kind a modality is comes from its euler-loading loader annotation
742
+ (`shape`, `type`), never from its name.
743
+
744
+ The dataset config lists every modality to crop; whether a modality is
745
+ per-sample or hierarchical (shared calibration) is inferred from its index:
746
+
747
+ ```json
748
+ {
749
+ "transform_config_path": "crop_stf_valid_region_config.json",
750
+ "output_path": "/data/stf_crop",
751
+ "modalities": {
752
+ "rgb": "/data/stf/cam_stereo_left_lut",
753
+ "sparse_depth": "/data/stf/lidar_hdl64_strongest",
754
+ "intrinsics": "/data/stf/calib_cam_stereo_left",
755
+ "camera_extrinsics": "/data/stf/calib_tf_tree"
756
+ },
757
+ "samples": {"start": 0, "count": 100}
758
+ }
759
+ ```
760
+
761
+ Each modality is written to `<output_path>/<name>`, or to its
762
+ `pipeline.output_targets[]` entry (`slot` = the modality name; directory or zip).
763
+ With pipeline targets, every modality needs one and the outputs manifest lists
764
+ them all. `"output_layout": "scoped"` writes one root at `output_path` instead
765
+ (a directory or `.zip`): one ds-crawler metadata scope per modality, named after
766
+ it, with its files below `<name>/`. That is the layout dmd3c-euler's
767
+ `--dataset-root` reads. The dataset config must not contain `preprocessing`.
768
+
769
+ The crop config (`configs/crop_config.json`) describes the box relative to the
770
+ `reference` raster:
771
+
772
+ ```json
773
+ {
774
+ "reference": "rgb",
775
+ "crop": {"remove": {"top": 0.20, "bottom": 0.05, "left": 0.15, "right": 0.19}},
776
+ "point_clouds": "keep",
777
+ "verify": "all"
778
+ }
779
+ ```
780
+
781
+ | Key | Meaning |
782
+ |---|---|
783
+ | `crop.remove` | What to cut off each edge (`top`, `bottom`, `left`, `right`) |
784
+ | `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` |
785
+ | `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)` |
786
+ | `crop.multiple_of` | Trim the kept size down to a multiple, e.g. a network stride; the extra rows and columns come off both sides evenly |
787
+ | `resize` | Optional, after the crop: `{"size": [h, w]}` or `{"scale": 0.5}` (or `[sy, sx]`); `interpolation` maps modality names to `nearest`, `bilinear` or `bicubic` |
788
+ | `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 |
789
+ | `projection` | For `prune_to_crop`: the `intrinsics` and `extrinsics` modalities to project with, when the dataset has more than one candidate |
790
+ | `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) |
791
+ | `roles` | Override a modality's kind with `raster` or `copy`, for loaders whose annotation the crop does not know |
792
+
793
+ Pixel units reproduce the official boxes exactly, e.g. `RGB2Gatedv2` is
794
+ `{"remove": {"top": 202, "bottom": 54, "left": 280, "right": 360}, "unit": "pixel"}`.
795
+ `configs/crop_stf_valid_region_config.json` removes the black border the
796
+ SeeingThroughFog rectification leaves on `_lut` frames (top 34, bottom 46,
797
+ left 16 px), keeping a fully valid 1904×944 image.
798
+
799
+ Rasters are re-encoded with the dataset's euler-loading writer, so a raster
800
+ modality needs one (SeeingThroughFog's raw `rccb` TIFFs have none). Each
801
+ encoded file is decoded again right away and must give back the cropped values:
802
+ exactly, or for an interpolated resize within half an 8-bit step. A resize
803
+ interpolates colour images (`rgb`, `rccb`) bilinearly and takes the nearest
804
+ pixel for everything else, so depth and label values stay real values;
805
+ sparse-depth rasters are not resized. Pruned point clouds need a writer too
806
+ (`princeton_dense.write_sparse_depth`, `muses.write_sparse_depth`). Intrinsics
807
+ are written by copying the source file and letting its writer move the matrices
808
+ onto the new grid: for a ROS CameraInfo file such as
809
+ `calib_cam_stereo_left.json`, `K` and `P` shift by exact integers, `width` and
810
+ `height` become the crop size, and `D`, `R`, `roi` and the header are kept. A
811
+ calibration file that records another image size than the reference rasters
812
+ is refused. A hierarchical file is written once, so every sample sharing it
813
+ must get the same box; mixed image sizes under one calibration file are an
814
+ error. Every output head records `meta.crop` (reference, spec and boxes), and
815
+ every file its own `attributes.crop` box; pruned clouds also record
816
+ `attributes.point_clouds` (source and kept point counts).
817
+
818
+ To reproduce a crop with `euler-preprocess spatial`, print its pixel box and
819
+ the matching `preprocessing` block for a reference size:
820
+
821
+ ```bash
822
+ euler-preprocess crop-box -c configs/crop_config.json --size 1024 1920
823
+ ```
824
+
825
+ ---
826
+
720
827
  ## License
721
828
 
722
829
  [MIT](LICENSE) © Daniel Rothenpieler
@@ -1,8 +1,12 @@
1
1
  # Coherent cropping plan
2
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
3
+ Status: **phases 1 and 3 implemented** (2026-11-07) as `euler-preprocess crop`
4
+ (plus `crop-box`), for **SeeingThroughFog** (euler-loading's `princeton_dense`
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
7
+ 2.31.0 (STF fixes) and 2.32.0 (MUSES inputs); phase 3 uses euler-loading 2.33.0
8
+ and ds-crawler 2.14.0 (both released), which euler-preprocess now requires.
9
+ euler-view launches it with the `euler-preprocess-crop` addon. Written 2026-11-06 against
6
10
  euler-preprocess 3.17.0 and euler-loading 2.30.0.
7
11
 
8
12
  ## 1. Is there a pathway today?
@@ -72,10 +76,10 @@ two different K. SeeingThroughFog keeps them in separate files.
72
76
  | `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
77
  | `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
78
 
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.
79
+ The byte copies need no writer. `princeton_dense.write_intrinsics` (euler-loading
80
+ 2.31.0, §3.2) covers the intrinsics patch.
77
81
 
78
- ### 3.2 Fixed in euler-loading (not yet committed): `read_intrinsics` returned the wrong matrix for `_lut` images
82
+ ### 3.2 Fixed in euler-loading 2.31.0: `read_intrinsics` returned the wrong matrix for `_lut` images
79
83
 
80
84
  - `princeton_dense.read_intrinsics` returns `K`
81
85
  (`euler_loading/loaders/_princeton_dense.py:170`).
@@ -91,7 +95,7 @@ in the euler-loading working tree (§3.2), so the intrinsics patch is covered.
91
95
  about ±55 px at the top and bottom.
92
96
  - This is independent of cropping, but a crop corrects whichever matrix the
93
97
  loader returns, so it had to be settled first.
94
- - Done in the euler-loading working tree: `read_intrinsics` now returns the
98
+ - Released in euler-loading 2.31.0: `read_intrinsics` now returns the
95
99
  rectified `P[:, :3]` by default (a behaviour change, in its CHANGELOG).
96
100
  `intrinsics_matrix: "K"` (alias `"raw"`) in per-file attributes or head
97
101
  meta selects the raw K.
@@ -123,8 +127,8 @@ in the euler-loading working tree (§3.2), so the intrinsics patch is covered.
123
127
  - Raw TIFF datasets (`princeton_stf+rgb`):
124
128
  - `princeton_dense.rgb` silently returns an all-white image for them: Pillow
125
129
  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).
130
+ - `rccb` swapped red and blue; it now demosaics as GRBG (fixed in
131
+ euler-loading 2.31.0).
128
132
  - The raw frames are not pinhole images (k1 = −0.57), so use the `_lut`
129
133
  images for depth evaluation.
130
134
  - The official crops are pixel boxes, e.g. `RGB2Gatedv2` keeps rows 202–970 and
@@ -136,8 +140,10 @@ in the euler-loading working tree (§3.2), so the intrinsics patch is covered.
136
140
  `remove: {top: 34, bottom: 46, left: 16, right: 0}` (pixel units) leaves a
137
141
  fully valid 1904×944 image (91.4 % of the frame). That's a sensible default
138
142
  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.
143
+ `calib_cam_stereo_left.json` and the official conversion code, and confirmed
144
+ on two real dense-fog frames: the black pixels reach 33 rows at the top, 45 at
145
+ the bottom and 15 columns on the left, and none remain inside the box. Shipped
146
+ as `configs/crop_stf_valid_region_config.json`.
141
147
  - Resampling in the official rectification is mild but uneven: about 1.11 raw
142
148
  px per output px at the centre, but 0.76–0.81 near the left and right edges
143
149
  (≈1.3× upsampled, so softer there). The `_lut` tone mapping also applies
@@ -291,6 +297,9 @@ modality name, which is what causes the hazard in §1.
291
297
  - `euler_preprocess/crop/calibration.py`: hierarchical writer (unique-box
292
298
  check, copy then merge).
293
299
  - `euler_preprocess/crop/verify.py`.
300
+ - `euler_preprocess/crop/files.py` (added while implementing): zip-aware reads
301
+ of source files, value fingerprints for the verify step, and the
302
+ recorded-image-size check for JSON calibration files.
294
303
  - `euler_preprocess/common/output.py`: byte-copy registration helper, and a
295
304
  source-backed backend factory for any modality with head-meta overrides.
296
305
  - `euler_preprocess/common/intrinsics.py`: `single_calibration(value)`. A
@@ -300,17 +309,45 @@ modality name, which is what causes the hazard in §1.
300
309
  - `euler_preprocess/cli.py`: a `crop` subcommand with its own `_cmd_crop`, like
301
310
  `_cmd_spatial`, because the config drives the modality set.
302
311
  - `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`.
312
+ section and a CHANGELOG entry. The `euler-loading` pin (`>=2.32.0`, raised
313
+ for `lidar-motion`) already covers 2.31.0.
305
314
  - Optional `tools/check_crop_projection.py`: overlay projected lidar on the
306
315
  cropped RGB for a few frames, as a visual K-vs-P and crop check.
307
316
 
308
317
  ## 5. Work plan
309
318
 
310
- ### Phase 1: SeeingThroughFog end to end
319
+ ### Phase 1: SeeingThroughFog end to end — implemented 2026-11-07
311
320
 
312
- euler-loading (done in the working tree, uncommitted; ships with the next
313
- release):
321
+ Results:
322
+ - **Tests:** `tests/test_crop.py` (STF-format fixture: directory and zip
323
+ outputs with the pipeline manifest, sample selection, the hierarchical
324
+ conflict, a calibration for another resolution, a raster without a writer,
325
+ unsafe configs), `test_crop_spec.py`, `test_crop_roles.py`,
326
+ `test_intrinsics.py`. Full suite 273 passed, 3 skipped. The opt-in real-data
327
+ test (`EULER_PREPROCESS_STF_ROOT`) passes on two real frames.
328
+ - **Real frames** (two dense-fog `_lut` frames, valid-region crop): every §3.4
329
+ criterion holds. RGB is the bit-exact slice, lidar and the TF tree are
330
+ byte-identical, and `K`/`P` shift by exact float64 integers with `D`, `R`,
331
+ `roi` and the header untouched. Projections with the cropped `P` agree with the
332
+ shifted originals to 1e-10 px.
333
+ - **dmd3c-euler:** its own `build_dataset` loads the cropped roots unchanged, and
334
+ its `project_sparse_depth` raster equals the crop of the uncropped projection
335
+ (0 differing pixels). DMD3C inference itself was not run (no checkpoint or GPU
336
+ available).
337
+
338
+ Notes from implementing:
339
+ - euler-loading's datasets resolve the GPU (torch, CHW) loader variant, so the
340
+ crop slices CHW tensors in practice; the role's layout comes from the
341
+ annotation of that loader.
342
+ - Each output head's `meta.crop` holds `reference`, `spec` and `boxes` (one
343
+ box per reference size), set at finalize. `meta.dimensions` follows the crop
344
+ when present.
345
+ - With `pipeline.output_targets`, every modality needs a target (slot = name,
346
+ `datasetType` must match the source); the manifest lists them all and is
347
+ written after verification passes.
348
+ - Outputs inside a source dataset are refused.
349
+
350
+ euler-loading (released in 2.31.0):
314
351
 
315
352
  1. `princeton_dense.write_intrinsics` (CPU and GPU share one function) merges
316
353
  into an existing ROS CameraInfo JSON, as described in §3.2.
@@ -369,9 +406,11 @@ Verified with the real side data:
369
406
  - The GNSS timestamp precedes mid-exposure by up to 16.6 ms, so `meta.json`'s
370
407
  exposure times are needed.
371
408
 
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.
409
+ Implemented 2026-11-07 and released (euler-loading 2.32.0; euler-preprocess
410
+ `lidar-motion`, plus the euler-view addon `euler-preprocess-lidar-motion`). The
411
+ side data became ordinary euler-loading modalities, consumed by
412
+ euler-preprocess. ds-crawler is unchanged: it only interprets paths, never file
413
+ contents.
375
414
 
376
415
  | Modality | ds-crawler indexing | euler-loading |
377
416
  |---|---|---|
@@ -389,26 +428,60 @@ Results:
389
428
  - **Real data:** on the three real frames the CLI output reproduces the
390
429
  verified pole straightening.
391
430
 
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.
431
+ Registered in euler-dataset-contract as `kinematics.platform.state`,
432
+ `timing.platform.acquisition` and `geometry.platform.extrinsics`.
433
+ euler-preprocess requires `euler-loading>=2.32.0`, the first release with these
434
+ loaders.
396
435
 
397
436
  The side data stays with the local dataset: MUSES license §2.3 forbids
398
437
  distributing it.
399
438
 
400
- ### Phase 3: optional
439
+ ### Phase 3 — implemented 2026-11-07
440
+
441
+ - `point_clouds: "prune_to_crop"`: keeps the points with `z > 0` whose pixel
442
+ `rint(K'·T·X)` lies in the output image, the projection dmd3c-euler's
443
+ `project_sparse_depth` uses, with a 1e-6 px margin so a rounding boundary
444
+ never drops a visible point. Intrinsics and extrinsics are found by role
445
+ (`projection` names them when ambiguous). Needed a SeeingThroughFog writer:
446
+ `princeton_dense.write_sparse_depth` (euler-loading). Per-file
447
+ `attributes.point_clouds` records the source and kept counts.
448
+ - A single scoped output root (`"output_layout": "scoped"`): one metadata scope
449
+ per modality, its files below `<name>/` (ds-crawler writer `path_prefix`).
450
+ Zip roots are staged as a directory and packed once, since one zip writer
451
+ owns an archive. dmd3c's `--dataset-root` reads it.
452
+ - Resize after the crop (`resize: {size | scale}`): `K' = S·C·K` through
453
+ euler-loading's `crop_intrinsics` and `resize_intrinsics`. Rasters go through
454
+ the new public `euler_loading.resize_raster`, the same code as the online
455
+ `Resize`: bilinear for `rgb`/`rccb`, nearest otherwise (depth, masks, labels).
456
+ Sparse-depth rasters are refused.
457
+ - `euler-preprocess crop-box -c <crop config> --size H W` prints the box and a
458
+ `spatial` `preprocessing` block (`crop` with `size`/`offset`, then `resize`).
459
+ - Every encoded raster or pruned cloud is decoded right away with the
460
+ modality's loader and must match (exactly, or within half an 8-bit step after
461
+ interpolation); the verify step then compares the reloaded datasets with those
462
+ decoded values.
463
+
464
+ The two private hooks of phase 1 became public APIs:
465
+ - euler-loading: `MultiModalDataset.get_loader`, `get_loader_meta` and
466
+ `get_hierarchical_files`, plus `get_modality_meta(loader)`;
467
+ - ds-crawler: `DatasetWriter.set_modality_meta` (and `path_prefix`).
468
+ euler-preprocess requires euler-loading 2.33.0 and ds-crawler 2.14.0.
469
+
470
+ Results:
471
+ - **Tests:** euler-preprocess 292 passed, 3 skipped; euler-loading 1022 passed;
472
+ ds-crawler's new writer tests pass.
473
+ - **Real STF frames** (valid-region crop):
474
+ - pruning keeps 7.4 % of the HDL64 points;
475
+ - dmd3c-euler's projection of the pruned clouds equals the projection of
476
+ all points through the output camera, for the crop alone and with a
477
+ 0.5x resize (472×952).
478
+
479
+ An euler-view addon (`euler-preprocess-crop`) launches the crop for a dataset
480
+ family.
401
481
 
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}`.
482
+ ## 6. Decisions
410
483
 
411
- ## 6. Decisions needed
484
+ Phase 1 adopted every recommendation below.
412
485
 
413
486
  | # | Decision | Recommendation |
414
487
  |---|---|---|
@@ -426,7 +499,7 @@ distributing it.
426
499
  euler-loading: crop or resize only values whose spatial size matches the
427
500
  reference, or classify fields from the loader annotations.
428
501
  2. `princeton_dense.read_intrinsics` returned raw `K` for rectified `_lut`
429
- images (§3.2). Fixed in the euler-loading working tree.
502
+ images (§3.2). Fixed in euler-loading 2.31.0.
430
503
  3. `generic_dense_depth` PNG depth with `scale_to_meters_override` doesn't
431
504
  survive decode → write: 10.0 m reloads as 0.039 m. The loader applies the
432
505
  scale (`generic_dense_depth.py:133`), `write_depth` (`:226`) writes
@@ -436,11 +509,13 @@ distributing it.
436
509
  (`vkitti2.py:208`), so it raises on the documented header line
437
510
  `frame cameraID r1,1 …`. This is also open question 4 in
438
511
  `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`.
512
+ 5. `extract_intrinsics` only resolved hierarchical intrinsics whose file ID is
513
+ `"intrinsics"`, so radial and fog missed STF's `calib_cam_stereo_left`.
514
+ Fixed with phase 1 (`single_calibration`): a single file is used whatever
515
+ its ID; several files raise.
441
516
  6. RGB writers always emit PNG bytes. VKITTI2 `.jpg` sources become PNG data
442
517
  under `.jpg` names, and Real Drive Sim RGBA sources lose their alpha channel.
443
518
  7. `princeton_dense.rccb` demosaiced as GBRG, swapping red and blue. Fixed in
444
- the euler-loading working tree (GRBG).
519
+ euler-loading 2.31.0 (GRBG).
445
520
  8. `princeton_dense.rgb` on a 12-bit TIFF returns an all-white image without
446
521
  an error. Not fixed: a guard that raises would be a small follow-up.
@@ -1,8 +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``, ``lidar-motion``
5
- and ``spatial``.
4
+ script. Supports subcommands: ``fog``, ``sky-depth``, ``radial``, ``lidar-motion``,
5
+ ``crop``, ``crop-box`` and ``spatial``.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
@@ -257,6 +257,29 @@ def _cmd_lidar_motion(args: argparse.Namespace) -> int:
257
257
  return _run_transform(args, LidarMotionTransform)
258
258
 
259
259
 
260
+ def _cmd_crop(args: argparse.Namespace) -> int:
261
+ from euler_preprocess.crop.run import run_crop_command
262
+
263
+ config_path = Path(args.config).resolve()
264
+ config = json.loads(config_path.read_text(encoding="utf-8"))
265
+ get_logger().info("Config: %s", config_path)
266
+ run_crop_command(config, config_path.parent)
267
+ return 0
268
+
269
+
270
+ def _cmd_crop_box(args: argparse.Namespace) -> int:
271
+ from euler_preprocess.crop.spec import parse_crop_config, spatial_preprocessing
272
+
273
+ config = parse_crop_config(json.loads(Path(args.config).read_text(encoding="utf-8")))
274
+ height, width = args.size
275
+ box = config.geometry(height, width)
276
+ print(json.dumps(
277
+ {"box": box.to_dict(), "preprocessing": spatial_preprocessing(box, reference=config.reference)},
278
+ indent=2,
279
+ ))
280
+ return 0
281
+
282
+
260
283
  def _cmd_spatial(args: argparse.Namespace) -> int:
261
284
  from euler_preprocess.spatial import export_spatial
262
285
 
@@ -317,6 +340,25 @@ def parse_args() -> argparse.Namespace:
317
340
  )
318
341
  lidar_motion_parser.set_defaults(func=_cmd_lidar_motion)
319
342
 
343
+ crop_parser = subparsers.add_parser(
344
+ "crop", parents=[common],
345
+ help="Crop RGB, depth and calibration coherently (K' = C K; lidar and extrinsics unchanged).",
346
+ )
347
+ crop_parser.set_defaults(func=_cmd_crop)
348
+
349
+ crop_box_parser = subparsers.add_parser(
350
+ "crop-box",
351
+ help="Print the pixel box a crop config resolves to, and the matching spatial preprocessing block.",
352
+ )
353
+ crop_box_parser.add_argument(
354
+ "--config", "-c", type=str, required=True, help="Path to the crop config JSON file.",
355
+ )
356
+ crop_box_parser.add_argument(
357
+ "--size", type=int, nargs=2, required=True, metavar=("HEIGHT", "WIDTH"),
358
+ help="Size of the reference raster in pixels.",
359
+ )
360
+ crop_box_parser.set_defaults(func=_cmd_crop_box)
361
+
320
362
  spatial_parser = subparsers.add_parser(
321
363
  "spatial", parents=[common], help="Export deterministic crop/resize with verified execution receipts.",
322
364
  )