euler-eval 2.8.0__tar.gz → 2.10.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 (55) hide show
  1. {euler_eval-2.8.0 → euler_eval-2.10.0}/PKG-INFO +32 -6
  2. {euler_eval-2.8.0 → euler_eval-2.10.0}/README.md +31 -5
  3. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/cli.py +153 -36
  4. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/data.py +6 -5
  5. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/evaluate.py +1 -0
  6. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/utils.py +1 -0
  7. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval.egg-info/PKG-INFO +32 -6
  8. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval.egg-info/SOURCES.txt +1 -0
  9. {euler_eval-2.8.0 → euler_eval-2.10.0}/pyproject.toml +1 -1
  10. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_config.py +25 -0
  11. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_data_builders.py +23 -0
  12. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_meta_output.py +101 -0
  13. euler_eval-2.10.0/tests/test_metrics_utils.py +57 -0
  14. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_save_results.py +15 -0
  15. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_sparse_depth.py +23 -0
  16. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/__init__.py +0 -0
  17. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/__init__.py +0 -0
  18. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/absrel.py +0 -0
  19. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/daniel_error.py +0 -0
  20. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/depth_binned_error.py +0 -0
  21. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/depth_edge_f1.py +0 -0
  22. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/depth_standard.py +0 -0
  23. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/fid_kid.py +0 -0
  24. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/gpu_depth_batch.py +0 -0
  25. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/gpu_image_batch.py +0 -0
  26. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/high_freq_energy.py +0 -0
  27. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/lpips_metric.py +0 -0
  28. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/normal_consistency.py +0 -0
  29. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/psnr.py +0 -0
  30. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/rgb_edge_f1.py +0 -0
  31. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/rgb_lpips.py +0 -0
  32. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/rgb_psnr_ssim.py +0 -0
  33. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/rho_a.py +0 -0
  34. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/rmse.py +0 -0
  35. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/scale_invariant_log.py +0 -0
  36. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/ssim.py +0 -0
  37. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/metrics/tail_errors.py +0 -0
  38. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/sanity_checker.py +0 -0
  39. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval/utils/hierarchy_parser.py +0 -0
  40. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval.egg-info/dependency_links.txt +0 -0
  41. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval.egg-info/entry_points.txt +0 -0
  42. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval.egg-info/requires.txt +0 -0
  43. {euler_eval-2.8.0 → euler_eval-2.10.0}/euler_eval.egg-info/top_level.txt +0 -0
  44. {euler_eval-2.8.0 → euler_eval-2.10.0}/init_cache.py +0 -0
  45. {euler_eval-2.8.0 → euler_eval-2.10.0}/setup.cfg +0 -0
  46. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_alignment.py +0 -0
  47. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_cli_device.py +0 -0
  48. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_data.py +0 -0
  49. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_depth_alignment_output.py +0 -0
  50. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_depth_standard.py +0 -0
  51. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_evaluate_helpers.py +0 -0
  52. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_init_cache.py +0 -0
  53. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_integration.py +0 -0
  54. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_rgb_fid_output.py +0 -0
  55. {euler_eval-2.8.0 → euler_eval-2.10.0}/tests/test_rho_a.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-eval
3
- Version: 2.8.0
3
+ Version: 2.10.0
4
4
  Summary: Depth map evaluation toolkit with comprehensive metrics
5
5
  Author: Depth Eval Contributors
6
6
  License: MIT
@@ -45,7 +45,7 @@ A comprehensive evaluation toolkit for comparing predicted depth maps, RGB image
45
45
  - **Depth metrics**: PSNR, SSIM, LPIPS, FID, KID, AbsRel, RMSE, Scale-Invariant Log Error, Normal Consistency, Depth Edge F1
46
46
  - **RGB metrics**: PSNR, SSIM, LPIPS, FID, SCE (Structural Chromatic Error), Edge F1, Tail Errors (p95/p99), High-Frequency Energy Ratio, Depth-Binned Photometric Error
47
47
  - **Rays metrics**: ρ_A (AUC of angular accuracy curve), Angular Error statistics and threshold percentages
48
- - **Benchmark binning**: Optional depth-range benchmark that subdivides metrics into log-scaled near/mid/far bins
48
+ - **Benchmark binning**: Optional depth-range benchmark that subdivides metrics into square-root-scaled near/mid/far bins
49
49
  - **Sanity checking**: Automatic validation of metric results against configurable thresholds, with detailed warning reports
50
50
  - **Sky masking**: Optional exclusion of sky regions from metrics using GT segmentation
51
51
  - **Flexible dataset loading**: Automatic loader resolution via euler_loading and ds-crawler index metadata
@@ -141,7 +141,7 @@ This pre-downloads:
141
141
  | `--metrics-config` | `str` | auto-detect | Path to `metrics_config.json` for sanity checking |
142
142
  | `--depth-alignment` | `{none,auto_affine,affine}` | `auto_affine` | Depth calibration mode; outputs are emitted in semantic `native`/`metric` spaces and `depth` aliases the canonical branch |
143
143
  | `--rgb-fid-backend` | `{builtin,clean-fid}` | `builtin` | RGB FID backend; `clean-fid` requires optional dependency |
144
- | `--benchmark-depth-range` | `float float` | none | Depth range `[MIN, MAX]` in meters for benchmark evaluation; computes depth and RGB metrics for pixels within this range, subdivided into log-scaled near/mid/far bins (additive to regular metrics) |
144
+ | `--benchmark-depth-range` | `float float` | none | Depth range `[MIN, MAX]` in meters for benchmark evaluation; computes depth and RGB metrics for pixels within this range, subdivided into square-root-scaled near/mid/far bins (additive to regular metrics) |
145
145
 
146
146
  ### Examples
147
147
 
@@ -168,7 +168,7 @@ depth-eval config.json --depth-alignment affine
168
168
  depth-eval config.json --rgb-fid-backend clean-fid
169
169
 
170
170
  # Benchmark depth and RGB metrics within a depth range (near/mid/far bins)
171
- depth-eval config.json --benchmark-depth-range 0.5 80.0
171
+ depth-eval config.json --benchmark-depth-range 0.01 80.0
172
172
 
173
173
  # Evaluate dense depth predictions against sparse pointcloud GT
174
174
  depth-eval example_sparse_depth_config.json --skip-rgb --skip-rays
@@ -177,6 +177,30 @@ depth-eval example_sparse_depth_config.json --skip-rgb --skip-rays
177
177
  depth-eval config.json --skip-rays
178
178
  ```
179
179
 
180
+ ### Benchmark Depth Bins
181
+
182
+ `--benchmark-depth-range MIN MAX` first filters valid GT depth pixels to
183
+ `MIN <= depth <= MAX`. The `near`, `mid`, and `far` bins are then computed as
184
+ three equal-width intervals in square-root depth space:
185
+
186
+ ```text
187
+ sqrt_min = sqrt(MIN)
188
+ sqrt_max = sqrt(MAX)
189
+ step = (sqrt_max - sqrt_min) / 3
190
+ near_max = (sqrt_min + step)^2
191
+ mid_max = (sqrt_min + 2 * step)^2
192
+ ```
193
+
194
+ The interval bounds are `near=[MIN, near_max)`, `mid=[near_max, mid_max)`, and
195
+ `far=[mid_max, MAX]`. For `--benchmark-depth-range 0.01 80.0`, this gives:
196
+
197
+ | Bin | Depth interval |
198
+ |---|---|
199
+ | `all` | `[0.01, 80.0]` |
200
+ | `near` | `[0.01, 9.290856529)` |
201
+ | `mid` | `[9.290856529, 35.954189863)` |
202
+ | `far` | `[35.954189863, 80.0]` |
203
+
180
204
  ## Configuration
181
205
 
182
206
  ### `config.json`
@@ -215,7 +239,7 @@ Defines GT modalities, prediction datasets to evaluate, and optional euler_train
215
239
  }
216
240
  ```
217
241
 
218
- Each modality entry can optionally include a `split` field to select a specific split from the dataset (e.g. `{ "path": "/data/gt/depth", "split": "test" }`).
242
+ Each modality entry can optionally include a `split` field to select a specific split from the dataset (e.g. `{ "path": "/data/gt/depth", "split": "test" }`). euler-loading inline selectors are also accepted in `path`, such as `/data/muses.zip:test` or `/data/muses.zip:test#scope=rgb`.
219
243
 
220
244
  For sparse pointcloud depth evaluation, use `gt.sparse_depth` instead of `gt.depth`. The prediction still uses `datasets[].depth` because the model output is a dense depth map. The evaluator projects the sparse GT point cloud into the prediction image plane using `gt.intrinsics` and `gt.camera_extrinsics`, then computes pointwise depth metrics only at projected valid pixels.
221
245
 
@@ -269,7 +293,7 @@ Dataset metadata (e.g. `radial_depth`, `rgb_range`, sparse point columns, and co
269
293
 
270
294
  ### Dataset Metadata
271
295
 
272
- Each dataset directory must contain ds-crawler metadata artifacts, typically generated under `.ds_crawler/`:
296
+ Each dataset directory or archive must contain ds-crawler metadata artifacts, typically generated under `.ds_crawler/`:
273
297
 
274
298
  ```text
275
299
  dataset-root/
@@ -279,6 +303,8 @@ dataset-root/
279
303
  index.json
280
304
  ```
281
305
 
306
+ When one physical root or archive contains several logical modalities, the artifacts may instead be scoped under `.ds_crawler/<modality>/` with a `.ds_crawler/scopes.json` manifest. `euler-eval` passes modality scopes to euler-loading so paths such as `/data/muses.zip:test` can load `.ds_crawler/rgb/index.json`, `.ds_crawler/depth/index.json`, and related scoped artifacts from the same archive.
307
+
282
308
  GT and prediction datasets are matched by hierarchy path and file ID through `MultiModalDataset`.
283
309
 
284
310
  ### `metrics_config.json`
@@ -7,7 +7,7 @@ A comprehensive evaluation toolkit for comparing predicted depth maps, RGB image
7
7
  - **Depth metrics**: PSNR, SSIM, LPIPS, FID, KID, AbsRel, RMSE, Scale-Invariant Log Error, Normal Consistency, Depth Edge F1
8
8
  - **RGB metrics**: PSNR, SSIM, LPIPS, FID, SCE (Structural Chromatic Error), Edge F1, Tail Errors (p95/p99), High-Frequency Energy Ratio, Depth-Binned Photometric Error
9
9
  - **Rays metrics**: ρ_A (AUC of angular accuracy curve), Angular Error statistics and threshold percentages
10
- - **Benchmark binning**: Optional depth-range benchmark that subdivides metrics into log-scaled near/mid/far bins
10
+ - **Benchmark binning**: Optional depth-range benchmark that subdivides metrics into square-root-scaled near/mid/far bins
11
11
  - **Sanity checking**: Automatic validation of metric results against configurable thresholds, with detailed warning reports
12
12
  - **Sky masking**: Optional exclusion of sky regions from metrics using GT segmentation
13
13
  - **Flexible dataset loading**: Automatic loader resolution via euler_loading and ds-crawler index metadata
@@ -103,7 +103,7 @@ This pre-downloads:
103
103
  | `--metrics-config` | `str` | auto-detect | Path to `metrics_config.json` for sanity checking |
104
104
  | `--depth-alignment` | `{none,auto_affine,affine}` | `auto_affine` | Depth calibration mode; outputs are emitted in semantic `native`/`metric` spaces and `depth` aliases the canonical branch |
105
105
  | `--rgb-fid-backend` | `{builtin,clean-fid}` | `builtin` | RGB FID backend; `clean-fid` requires optional dependency |
106
- | `--benchmark-depth-range` | `float float` | none | Depth range `[MIN, MAX]` in meters for benchmark evaluation; computes depth and RGB metrics for pixels within this range, subdivided into log-scaled near/mid/far bins (additive to regular metrics) |
106
+ | `--benchmark-depth-range` | `float float` | none | Depth range `[MIN, MAX]` in meters for benchmark evaluation; computes depth and RGB metrics for pixels within this range, subdivided into square-root-scaled near/mid/far bins (additive to regular metrics) |
107
107
 
108
108
  ### Examples
109
109
 
@@ -130,7 +130,7 @@ depth-eval config.json --depth-alignment affine
130
130
  depth-eval config.json --rgb-fid-backend clean-fid
131
131
 
132
132
  # Benchmark depth and RGB metrics within a depth range (near/mid/far bins)
133
- depth-eval config.json --benchmark-depth-range 0.5 80.0
133
+ depth-eval config.json --benchmark-depth-range 0.01 80.0
134
134
 
135
135
  # Evaluate dense depth predictions against sparse pointcloud GT
136
136
  depth-eval example_sparse_depth_config.json --skip-rgb --skip-rays
@@ -139,6 +139,30 @@ depth-eval example_sparse_depth_config.json --skip-rgb --skip-rays
139
139
  depth-eval config.json --skip-rays
140
140
  ```
141
141
 
142
+ ### Benchmark Depth Bins
143
+
144
+ `--benchmark-depth-range MIN MAX` first filters valid GT depth pixels to
145
+ `MIN <= depth <= MAX`. The `near`, `mid`, and `far` bins are then computed as
146
+ three equal-width intervals in square-root depth space:
147
+
148
+ ```text
149
+ sqrt_min = sqrt(MIN)
150
+ sqrt_max = sqrt(MAX)
151
+ step = (sqrt_max - sqrt_min) / 3
152
+ near_max = (sqrt_min + step)^2
153
+ mid_max = (sqrt_min + 2 * step)^2
154
+ ```
155
+
156
+ The interval bounds are `near=[MIN, near_max)`, `mid=[near_max, mid_max)`, and
157
+ `far=[mid_max, MAX]`. For `--benchmark-depth-range 0.01 80.0`, this gives:
158
+
159
+ | Bin | Depth interval |
160
+ |---|---|
161
+ | `all` | `[0.01, 80.0]` |
162
+ | `near` | `[0.01, 9.290856529)` |
163
+ | `mid` | `[9.290856529, 35.954189863)` |
164
+ | `far` | `[35.954189863, 80.0]` |
165
+
142
166
  ## Configuration
143
167
 
144
168
  ### `config.json`
@@ -177,7 +201,7 @@ Defines GT modalities, prediction datasets to evaluate, and optional euler_train
177
201
  }
178
202
  ```
179
203
 
180
- Each modality entry can optionally include a `split` field to select a specific split from the dataset (e.g. `{ "path": "/data/gt/depth", "split": "test" }`).
204
+ Each modality entry can optionally include a `split` field to select a specific split from the dataset (e.g. `{ "path": "/data/gt/depth", "split": "test" }`). euler-loading inline selectors are also accepted in `path`, such as `/data/muses.zip:test` or `/data/muses.zip:test#scope=rgb`.
181
205
 
182
206
  For sparse pointcloud depth evaluation, use `gt.sparse_depth` instead of `gt.depth`. The prediction still uses `datasets[].depth` because the model output is a dense depth map. The evaluator projects the sparse GT point cloud into the prediction image plane using `gt.intrinsics` and `gt.camera_extrinsics`, then computes pointwise depth metrics only at projected valid pixels.
183
207
 
@@ -231,7 +255,7 @@ Dataset metadata (e.g. `radial_depth`, `rgb_range`, sparse point columns, and co
231
255
 
232
256
  ### Dataset Metadata
233
257
 
234
- Each dataset directory must contain ds-crawler metadata artifacts, typically generated under `.ds_crawler/`:
258
+ Each dataset directory or archive must contain ds-crawler metadata artifacts, typically generated under `.ds_crawler/`:
235
259
 
236
260
  ```text
237
261
  dataset-root/
@@ -241,6 +265,8 @@ dataset-root/
241
265
  index.json
242
266
  ```
243
267
 
268
+ When one physical root or archive contains several logical modalities, the artifacts may instead be scoped under `.ds_crawler/<modality>/` with a `.ds_crawler/scopes.json` manifest. `euler-eval` passes modality scopes to euler-loading so paths such as `/data/muses.zip:test` can load `.ds_crawler/rgb/index.json`, `.ds_crawler/depth/index.json`, and related scoped artifacts from the same archive.
269
+
244
270
  GT and prediction datasets are matched by hierarchy path and file ID through `MultiModalDataset`.
245
271
 
246
272
  ### `metrics_config.json`
@@ -13,6 +13,7 @@ import zipfile
13
13
  from pathlib import Path
14
14
 
15
15
  import torch
16
+ from euler_loading import Modality
16
17
 
17
18
  from .data import (
18
19
  build_depth_eval_dataset,
@@ -40,6 +41,22 @@ except ImportError:
40
41
  from euler_metric_naming import AxisDeclaration, MetricDescription, MetricNamespace
41
42
 
42
43
 
44
+ def _normalize_modality_path(
45
+ path: str | Path,
46
+ *,
47
+ modality_key: str,
48
+ split: str | None = None,
49
+ ) -> Path:
50
+ """Return the filesystem/archive root for an euler-loading modality path."""
51
+ modality = Modality(
52
+ str(path),
53
+ modality_type=modality_key,
54
+ metadata_scope=modality_key,
55
+ split=split,
56
+ )
57
+ return Path(modality.path)
58
+
59
+
43
60
  # ── Eval namespace ──────────────────────────────────────────────────────────
44
61
  # MetricNamespace subclass for eval context with custom axis declarations,
45
62
  # following the Normed Metric Namespacing convention (§3.6, §4, §5.2).
@@ -292,6 +309,10 @@ _SPARSE_DEPTH_EVAL_DESCRIPTIONS = {
292
309
  "silog.p90",
293
310
  }
294
311
  }
312
+ _SPARSE_DEPTH_EVAL_DESCRIPTIONS["valid_pixel_count"] = MetricDescription(
313
+ unit="pixels",
314
+ display_name="Valid Pixel Count",
315
+ )
295
316
 
296
317
  _RGB_EVAL_DESCRIPTIONS = {
297
318
  "psnr": MetricDescription(is_higher_better=True, unit="dB", display_name="PSNR"),
@@ -389,7 +410,11 @@ def _sparse_depth_metric_set_envelope(
389
410
  return envelope
390
411
 
391
412
 
392
- def _clean_metric_tree(tree: dict) -> dict:
413
+ def _clean_metric_tree(
414
+ tree: dict,
415
+ *,
416
+ preserve_empty_dict_keys: set[str] | None = None,
417
+ ) -> dict:
393
418
  """Recursively sanitize a metric dict for JSON schema compliance.
394
419
 
395
420
  - Removes entries where the value is ``None``
@@ -397,6 +422,7 @@ def _clean_metric_tree(tree: dict) -> dict:
397
422
  - Recursively processes nested dicts and list items
398
423
  - Prunes empty dicts after cleaning
399
424
  """
425
+ preserve_empty_dict_keys = preserve_empty_dict_keys or set()
400
426
  cleaned = {}
401
427
  for key, value in tree.items():
402
428
  if value is None:
@@ -404,12 +430,20 @@ def _clean_metric_tree(tree: dict) -> dict:
404
430
  if isinstance(value, float) and not math.isfinite(value):
405
431
  continue
406
432
  if isinstance(value, dict):
407
- sub = _clean_metric_tree(value)
408
- if sub:
433
+ sub = _clean_metric_tree(
434
+ value,
435
+ preserve_empty_dict_keys=preserve_empty_dict_keys,
436
+ )
437
+ if sub or key in preserve_empty_dict_keys:
409
438
  cleaned[key] = sub
410
439
  elif isinstance(value, list):
411
440
  cleaned[key] = [
412
- _clean_metric_tree(item) if isinstance(item, dict) else item
441
+ _clean_metric_tree(
442
+ item,
443
+ preserve_empty_dict_keys=preserve_empty_dict_keys,
444
+ )
445
+ if isinstance(item, dict)
446
+ else item
413
447
  for item in value
414
448
  ]
415
449
  else:
@@ -417,6 +451,71 @@ def _clean_metric_tree(tree: dict) -> dict:
417
451
  return cleaned
418
452
 
419
453
 
454
+ def _empty_per_file_metric_ids(
455
+ node: dict,
456
+ hierarchy: tuple[str, ...] = (),
457
+ ) -> list[str]:
458
+ """Return file identifiers whose cleaned per-file metrics are empty."""
459
+ ids = []
460
+ for file_entry in node.get("files", []):
461
+ if not isinstance(file_entry, dict):
462
+ continue
463
+ if file_entry.get("metrics") == {}:
464
+ file_id = str(file_entry.get("id", "<missing-id>"))
465
+ ids.append("/".join((*hierarchy, file_id)))
466
+
467
+ children = node.get("children", {})
468
+ if isinstance(children, dict):
469
+ for child_name, child_node in children.items():
470
+ if isinstance(child_node, dict):
471
+ ids.extend(
472
+ _empty_per_file_metric_ids(
473
+ child_node,
474
+ (*hierarchy, str(child_name)),
475
+ )
476
+ )
477
+ return ids
478
+
479
+
480
+ def _clean_per_file_metrics(tree: dict, *, label: str = "per_file_metrics") -> dict:
481
+ """Clean a per-file metric tree while preserving file-entry shape.
482
+
483
+ Some per-file metric entries can legitimately clean down to no scalar
484
+ metrics, for example when every metric for a file is ``None``/NaN after a
485
+ failed computation or an invalid sample. The generic cleaner prunes empty
486
+ dicts, but per-file entries are schema-shaped as ``{"id", "metrics"}``;
487
+ dropping the empty ``metrics`` object leaves an id-only file entry.
488
+ """
489
+ cleaned = _clean_metric_tree(tree, preserve_empty_dict_keys={"metrics"})
490
+ empty_ids = _empty_per_file_metric_ids(cleaned)
491
+ if empty_ids:
492
+ sample = ", ".join(empty_ids[:10])
493
+ suffix = "" if len(empty_ids) <= 10 else f", ... +{len(empty_ids) - 10} more"
494
+ print(
495
+ f"Warning: {label} has {len(empty_ids)} file entries with no finite "
496
+ f"metrics after cleaning: {sample}{suffix}"
497
+ )
498
+ return cleaned
499
+
500
+
501
+ def _wrap_depth_space_pfm_metrics(
502
+ metrics: dict,
503
+ *,
504
+ metric_root: str,
505
+ canonical_key: str,
506
+ canonical_space: str | None = "metric",
507
+ ) -> dict:
508
+ """Wrap per-file depth-like metrics under the serialized namespace path."""
509
+ space_metrics = {
510
+ space: metrics[f"{canonical_key}_{space}"]
511
+ for space in ("native", "metric")
512
+ if f"{canonical_key}_{space}" in metrics
513
+ }
514
+ if not space_metrics and canonical_key in metrics:
515
+ space_metrics[canonical_space or "metric"] = metrics[canonical_key]
516
+ return {metric_root: {"eval": space_metrics}}
517
+
518
+
420
519
  def _wrap_pfm_metrics(pfm: dict, wrapper_fn) -> dict:
421
520
  """Walk a ``perFileHierarchyNode`` tree, applying *wrapper_fn* to each
422
521
  file entry's ``metrics`` dict. This restructures per-file metric keys
@@ -517,7 +616,11 @@ def validate_gt_config(gt: dict) -> None:
517
616
  "camera_extrinsics",
518
617
  ):
519
618
  if modality in gt and "path" in gt[modality]:
520
- p = Path(gt[modality]["path"])
619
+ p = _normalize_modality_path(
620
+ gt[modality]["path"],
621
+ modality_key=modality,
622
+ split=gt[modality].get("split"),
623
+ )
521
624
  if not p.exists():
522
625
  raise ValueError(f"gt.{modality}.path does not exist: {p}")
523
626
 
@@ -542,7 +645,11 @@ def validate_dataset_entry(entry: dict, index: int) -> None:
542
645
 
543
646
  for modality in ("rgb", "depth", "rays"):
544
647
  if modality in entry and "path" in entry[modality]:
545
- p = Path(entry[modality]["path"])
648
+ p = _normalize_modality_path(
649
+ entry[modality]["path"],
650
+ modality_key=modality,
651
+ split=entry[modality].get("split"),
652
+ )
546
653
  if not p.exists():
547
654
  raise ValueError(f"{label}.{modality}.path does not exist: {p}")
548
655
 
@@ -667,12 +774,26 @@ def save_results(
667
774
  and modality in dataset_config
668
775
  and "path" in dataset_config[modality]
669
776
  ):
670
- output_file = Path(dataset_config[modality]["path"]) / "eval.json"
777
+ output_file = (
778
+ _normalize_modality_path(
779
+ dataset_config[modality]["path"],
780
+ modality_key=modality,
781
+ split=dataset_config[modality].get("split"),
782
+ )
783
+ / "eval.json"
784
+ )
671
785
  else:
672
786
  # Default: save alongside first available modality path
673
787
  for mod in ("depth", "rgb", "rays"):
674
788
  if mod in dataset_config and "path" in dataset_config[mod]:
675
- output_file = Path(dataset_config[mod]["path"]) / "eval.json"
789
+ output_file = (
790
+ _normalize_modality_path(
791
+ dataset_config[mod]["path"],
792
+ modality_key=mod,
793
+ split=dataset_config[mod].get("split"),
794
+ )
795
+ / "eval.json"
796
+ )
676
797
  break
677
798
  if output_file is None:
678
799
  output_file = Path("eval.json")
@@ -1065,21 +1186,18 @@ def main():
1065
1186
  all_results[depth_key] = depth_results[depth_key]
1066
1187
  depth_pfm = depth_results.get("per_file_metrics", {})
1067
1188
  if depth_pfm:
1068
- depth_save["per_file_metrics"] = _clean_metric_tree(
1189
+ canonical_space = space_info.get("canonical_space", "metric")
1190
+ depth_save["per_file_metrics"] = _clean_per_file_metrics(
1069
1191
  _wrap_pfm_metrics(
1070
1192
  depth_pfm,
1071
- lambda m: (
1072
- {
1073
- "depth": {
1074
- "eval": {
1075
- space: m[f"depth_{space}"]
1076
- for space in ("native", "metric")
1077
- if f"depth_{space}" in m
1078
- },
1079
- },
1080
- }
1193
+ lambda m: _wrap_depth_space_pfm_metrics(
1194
+ m,
1195
+ metric_root="depth",
1196
+ canonical_key="depth",
1197
+ canonical_space=canonical_space,
1081
1198
  ),
1082
- )
1199
+ ),
1200
+ label="depth per_file_metrics",
1083
1201
  )
1084
1202
  all_results.setdefault("per_file_metrics", {}).update(depth_pfm)
1085
1203
 
@@ -1258,21 +1376,18 @@ def main():
1258
1376
  all_results[depth_key] = sparse_depth_results[depth_key]
1259
1377
  sparse_depth_pfm = sparse_depth_results.get("per_file_metrics", {})
1260
1378
  if sparse_depth_pfm:
1261
- depth_save["per_file_metrics"] = _clean_metric_tree(
1379
+ canonical_space = space_info.get("canonical_space", "metric")
1380
+ depth_save["per_file_metrics"] = _clean_per_file_metrics(
1262
1381
  _wrap_pfm_metrics(
1263
1382
  sparse_depth_pfm,
1264
- lambda m: (
1265
- {
1266
- _SPARSE_DEPTH_METRIC_ROOT: {
1267
- "eval": {
1268
- space: m[f"sparse_depth_{space}"]
1269
- for space in ("native", "metric")
1270
- if f"sparse_depth_{space}" in m
1271
- },
1272
- },
1273
- }
1383
+ lambda m: _wrap_depth_space_pfm_metrics(
1384
+ m,
1385
+ metric_root=_SPARSE_DEPTH_METRIC_ROOT,
1386
+ canonical_key="sparse_depth",
1387
+ canonical_space=canonical_space,
1274
1388
  ),
1275
- )
1389
+ ),
1390
+ label="sparse_depth per_file_metrics",
1276
1391
  )
1277
1392
  all_results.setdefault("per_file_metrics", {}).update(sparse_depth_pfm)
1278
1393
 
@@ -1417,11 +1532,12 @@ def main():
1417
1532
  all_results["rgb_benchmark"] = rgb_benchmark
1418
1533
  rgb_pfm = rgb_results.get("per_file_metrics", {})
1419
1534
  if rgb_pfm:
1420
- rgb_save["per_file_metrics"] = _clean_metric_tree(
1535
+ rgb_save["per_file_metrics"] = _clean_per_file_metrics(
1421
1536
  _wrap_pfm_metrics(
1422
1537
  rgb_pfm,
1423
1538
  lambda m: {"rgb": {"eval": m.get("rgb", {})}},
1424
- )
1539
+ ),
1540
+ label="rgb per_file_metrics",
1425
1541
  )
1426
1542
  all_results.setdefault("per_file_metrics", {}).update(rgb_pfm)
1427
1543
 
@@ -1519,11 +1635,12 @@ def main():
1519
1635
  all_results["rays"] = rays_metrics
1520
1636
  rays_pfm = rays_results.get("per_file_metrics", {})
1521
1637
  if rays_pfm:
1522
- rays_save["per_file_metrics"] = _clean_metric_tree(
1638
+ rays_save["per_file_metrics"] = _clean_per_file_metrics(
1523
1639
  _wrap_pfm_metrics(
1524
1640
  rays_pfm,
1525
1641
  lambda m: {"rays": {"eval": m.get("rays", {})}},
1526
- )
1642
+ ),
1643
+ label="rays per_file_metrics",
1527
1644
  )
1528
1645
  all_results.setdefault("per_file_metrics", {}).update(rays_pfm)
1529
1646
 
@@ -525,13 +525,14 @@ def _resolve_sky_mask_loader(segmentation_path: str) -> Callable[..., Any]:
525
525
  its ``sky_mask`` function so that raw class-segmentation data is
526
526
  converted to a boolean sky mask at load time.
527
527
  """
528
+ modality = _modality(path=segmentation_path, modality_key="segmentation")
528
529
  try:
529
530
  index = index_dataset_from_path(
530
- segmentation_path,
531
- metadata_scope="segmentation",
531
+ modality.path,
532
+ metadata_scope=modality.metadata_scope,
532
533
  )
533
534
  except FileNotFoundError:
534
- index = index_dataset_from_path(segmentation_path)
535
+ index = index_dataset_from_path(modality.path)
535
536
  # Resolve euler_loading metadata the same way euler-loading does:
536
537
  # try the contract addon API first (handles addons.euler_loading),
537
538
  # then fall back to top-level euler_loading for legacy indices.
@@ -546,14 +547,14 @@ def _resolve_sky_mask_loader(segmentation_path: str) -> Callable[..., Any]:
546
547
  loader_name = euler_meta.get("loader")
547
548
  if loader_name is None:
548
549
  raise ValueError(
549
- f"Segmentation dataset at {segmentation_path!r} does not declare "
550
+ f"Segmentation dataset at {modality.path!r} does not declare "
550
551
  f"an 'euler_loading.loader' in its ds-crawler index."
551
552
  )
552
553
  module = resolve_loader_module(loader_name)
553
554
  sky_fn = getattr(module, "sky_mask", None)
554
555
  if sky_fn is None or not callable(sky_fn):
555
556
  raise ValueError(
556
- f"Loader module {loader_name!r} (resolved from {segmentation_path!r}) "
557
+ f"Loader module {loader_name!r} (resolved from {modality.path!r}) "
557
558
  f"does not expose a 'sky_mask' function."
558
559
  )
559
560
  return sky_fn
@@ -1613,6 +1613,7 @@ def evaluate_sparse_depth_samples(
1613
1613
  for key, value in metrics["standard_metrics"].items()
1614
1614
  },
1615
1615
  "depth_metrics": {
1616
+ "valid_pixel_count": metrics["valid_pixel_count"],
1616
1617
  "absrel": float(np.mean(absrel_arr)) if len(absrel_arr) > 0 else None,
1617
1618
  "rmse": (
1618
1619
  float(np.sqrt(np.mean(rmse_arr))) if len(rmse_arr) > 0 else None
@@ -145,6 +145,7 @@ def get_benchmark_depth_bins(
145
145
  well-behaved as range_min approaches zero (unlike log).
146
146
 
147
147
  For example:
148
+ [0.01, 80] -> near=[0.01, 9.29), mid=[9.29, 35.95), far=[35.95, 80].
148
149
  [0.1, 80] -> near=[0.1, 10.19), mid=[10.19, 36.83), far=[36.83, 80].
149
150
  [1, 80] -> near=[1, 13.31), mid=[13.31, 39.64), far=[39.64, 80].
150
151
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-eval
3
- Version: 2.8.0
3
+ Version: 2.10.0
4
4
  Summary: Depth map evaluation toolkit with comprehensive metrics
5
5
  Author: Depth Eval Contributors
6
6
  License: MIT
@@ -45,7 +45,7 @@ A comprehensive evaluation toolkit for comparing predicted depth maps, RGB image
45
45
  - **Depth metrics**: PSNR, SSIM, LPIPS, FID, KID, AbsRel, RMSE, Scale-Invariant Log Error, Normal Consistency, Depth Edge F1
46
46
  - **RGB metrics**: PSNR, SSIM, LPIPS, FID, SCE (Structural Chromatic Error), Edge F1, Tail Errors (p95/p99), High-Frequency Energy Ratio, Depth-Binned Photometric Error
47
47
  - **Rays metrics**: ρ_A (AUC of angular accuracy curve), Angular Error statistics and threshold percentages
48
- - **Benchmark binning**: Optional depth-range benchmark that subdivides metrics into log-scaled near/mid/far bins
48
+ - **Benchmark binning**: Optional depth-range benchmark that subdivides metrics into square-root-scaled near/mid/far bins
49
49
  - **Sanity checking**: Automatic validation of metric results against configurable thresholds, with detailed warning reports
50
50
  - **Sky masking**: Optional exclusion of sky regions from metrics using GT segmentation
51
51
  - **Flexible dataset loading**: Automatic loader resolution via euler_loading and ds-crawler index metadata
@@ -141,7 +141,7 @@ This pre-downloads:
141
141
  | `--metrics-config` | `str` | auto-detect | Path to `metrics_config.json` for sanity checking |
142
142
  | `--depth-alignment` | `{none,auto_affine,affine}` | `auto_affine` | Depth calibration mode; outputs are emitted in semantic `native`/`metric` spaces and `depth` aliases the canonical branch |
143
143
  | `--rgb-fid-backend` | `{builtin,clean-fid}` | `builtin` | RGB FID backend; `clean-fid` requires optional dependency |
144
- | `--benchmark-depth-range` | `float float` | none | Depth range `[MIN, MAX]` in meters for benchmark evaluation; computes depth and RGB metrics for pixels within this range, subdivided into log-scaled near/mid/far bins (additive to regular metrics) |
144
+ | `--benchmark-depth-range` | `float float` | none | Depth range `[MIN, MAX]` in meters for benchmark evaluation; computes depth and RGB metrics for pixels within this range, subdivided into square-root-scaled near/mid/far bins (additive to regular metrics) |
145
145
 
146
146
  ### Examples
147
147
 
@@ -168,7 +168,7 @@ depth-eval config.json --depth-alignment affine
168
168
  depth-eval config.json --rgb-fid-backend clean-fid
169
169
 
170
170
  # Benchmark depth and RGB metrics within a depth range (near/mid/far bins)
171
- depth-eval config.json --benchmark-depth-range 0.5 80.0
171
+ depth-eval config.json --benchmark-depth-range 0.01 80.0
172
172
 
173
173
  # Evaluate dense depth predictions against sparse pointcloud GT
174
174
  depth-eval example_sparse_depth_config.json --skip-rgb --skip-rays
@@ -177,6 +177,30 @@ depth-eval example_sparse_depth_config.json --skip-rgb --skip-rays
177
177
  depth-eval config.json --skip-rays
178
178
  ```
179
179
 
180
+ ### Benchmark Depth Bins
181
+
182
+ `--benchmark-depth-range MIN MAX` first filters valid GT depth pixels to
183
+ `MIN <= depth <= MAX`. The `near`, `mid`, and `far` bins are then computed as
184
+ three equal-width intervals in square-root depth space:
185
+
186
+ ```text
187
+ sqrt_min = sqrt(MIN)
188
+ sqrt_max = sqrt(MAX)
189
+ step = (sqrt_max - sqrt_min) / 3
190
+ near_max = (sqrt_min + step)^2
191
+ mid_max = (sqrt_min + 2 * step)^2
192
+ ```
193
+
194
+ The interval bounds are `near=[MIN, near_max)`, `mid=[near_max, mid_max)`, and
195
+ `far=[mid_max, MAX]`. For `--benchmark-depth-range 0.01 80.0`, this gives:
196
+
197
+ | Bin | Depth interval |
198
+ |---|---|
199
+ | `all` | `[0.01, 80.0]` |
200
+ | `near` | `[0.01, 9.290856529)` |
201
+ | `mid` | `[9.290856529, 35.954189863)` |
202
+ | `far` | `[35.954189863, 80.0]` |
203
+
180
204
  ## Configuration
181
205
 
182
206
  ### `config.json`
@@ -215,7 +239,7 @@ Defines GT modalities, prediction datasets to evaluate, and optional euler_train
215
239
  }
216
240
  ```
217
241
 
218
- Each modality entry can optionally include a `split` field to select a specific split from the dataset (e.g. `{ "path": "/data/gt/depth", "split": "test" }`).
242
+ Each modality entry can optionally include a `split` field to select a specific split from the dataset (e.g. `{ "path": "/data/gt/depth", "split": "test" }`). euler-loading inline selectors are also accepted in `path`, such as `/data/muses.zip:test` or `/data/muses.zip:test#scope=rgb`.
219
243
 
220
244
  For sparse pointcloud depth evaluation, use `gt.sparse_depth` instead of `gt.depth`. The prediction still uses `datasets[].depth` because the model output is a dense depth map. The evaluator projects the sparse GT point cloud into the prediction image plane using `gt.intrinsics` and `gt.camera_extrinsics`, then computes pointwise depth metrics only at projected valid pixels.
221
245
 
@@ -269,7 +293,7 @@ Dataset metadata (e.g. `radial_depth`, `rgb_range`, sparse point columns, and co
269
293
 
270
294
  ### Dataset Metadata
271
295
 
272
- Each dataset directory must contain ds-crawler metadata artifacts, typically generated under `.ds_crawler/`:
296
+ Each dataset directory or archive must contain ds-crawler metadata artifacts, typically generated under `.ds_crawler/`:
273
297
 
274
298
  ```text
275
299
  dataset-root/
@@ -279,6 +303,8 @@ dataset-root/
279
303
  index.json
280
304
  ```
281
305
 
306
+ When one physical root or archive contains several logical modalities, the artifacts may instead be scoped under `.ds_crawler/<modality>/` with a `.ds_crawler/scopes.json` manifest. `euler-eval` passes modality scopes to euler-loading so paths such as `/data/muses.zip:test` can load `.ds_crawler/rgb/index.json`, `.ds_crawler/depth/index.json`, and related scoped artifacts from the same archive.
307
+
282
308
  GT and prediction datasets are matched by hierarchy path and file ID through `MultiModalDataset`.
283
309
 
284
310
  ### `metrics_config.json`
@@ -46,6 +46,7 @@ tests/test_evaluate_helpers.py
46
46
  tests/test_init_cache.py
47
47
  tests/test_integration.py
48
48
  tests/test_meta_output.py
49
+ tests/test_metrics_utils.py
49
50
  tests/test_rgb_fid_output.py
50
51
  tests/test_rho_a.py
51
52
  tests/test_save_results.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "euler-eval"
7
- version = "2.8.0"
7
+ version = "2.10.0"
8
8
  description = "Depth map evaluation toolkit with comprehensive metrics"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,6 +1,7 @@
1
1
  """Tests for main.py config validation."""
2
2
 
3
3
  import json
4
+ import zipfile
4
5
  import pytest
5
6
 
6
7
  from euler_eval.cli import load_config, validate_dataset_entry, validate_gt_config
@@ -50,6 +51,20 @@ class TestValidateGtConfig:
50
51
  gt = {"rgb": {"path": str(rgb_path)}}
51
52
  validate_gt_config(gt)
52
53
 
54
+ def test_inline_zip_split_gt_path_is_valid(self, tmp_path):
55
+ rgb_path = tmp_path / "reference.zip"
56
+ with zipfile.ZipFile(rgb_path, "w") as zf:
57
+ zf.writestr("dummy.txt", "x")
58
+ gt = {"rgb": {"path": f"{rgb_path}:fog_day"}}
59
+ validate_gt_config(gt)
60
+
61
+ def test_inline_scope_gt_path_is_valid(self, tmp_path):
62
+ rgb_path = tmp_path / "reference.zip"
63
+ with zipfile.ZipFile(rgb_path, "w") as zf:
64
+ zf.writestr("dummy.txt", "x")
65
+ gt = {"rgb": {"path": f"{rgb_path}#scope=rgb"}}
66
+ validate_gt_config(gt)
67
+
53
68
  def test_sparse_depth_requires_projection_modalities(self, tmp_path):
54
69
  sparse_path = tmp_path / "sparse_depth"
55
70
  sparse_path.mkdir()
@@ -132,6 +147,16 @@ class TestValidateDatasetEntry:
132
147
  with pytest.raises(ValueError, match="does not exist"):
133
148
  validate_dataset_entry(entry, 0)
134
149
 
150
+ def test_inline_zip_split_dataset_path_is_valid(self, tmp_path):
151
+ rgb_path = tmp_path / "prediction.zip"
152
+ with zipfile.ZipFile(rgb_path, "w") as zf:
153
+ zf.writestr("dummy.txt", "x")
154
+ entry = {
155
+ "name": "model_a",
156
+ "rgb": {"path": f"{rgb_path}:fog_day"},
157
+ }
158
+ validate_dataset_entry(entry, 0)
159
+
135
160
  def test_index_in_error_message(self, tmp_path):
136
161
  entry = {"name": "test"}
137
162
  with pytest.raises(ValueError, match=r"datasets\[3\]"):
@@ -169,3 +169,26 @@ def test_sky_mask_loader_resolution_uses_segmentation_scope(monkeypatch):
169
169
  assert calls == [
170
170
  ("/datasets/shared", {"metadata_scope": "segmentation"}),
171
171
  ]
172
+
173
+
174
+ def test_sky_mask_loader_resolution_strips_inline_split(monkeypatch):
175
+ calls = []
176
+
177
+ def fake_index_dataset_from_path(path, **kwargs):
178
+ calls.append((path, kwargs))
179
+ return {"euler_loading": {"loader": "vkitti2"}}
180
+
181
+ def sky_mask(path, meta=None):
182
+ return None
183
+
184
+ monkeypatch.setattr(data, "index_dataset_from_path", fake_index_dataset_from_path)
185
+ monkeypatch.setattr(
186
+ data,
187
+ "resolve_loader_module",
188
+ lambda name: SimpleNamespace(sky_mask=sky_mask),
189
+ )
190
+
191
+ assert data._resolve_sky_mask_loader("/datasets/shared.zip:fog_day") is sky_mask
192
+ assert calls == [
193
+ ("/datasets/shared.zip", {"metadata_scope": "segmentation"}),
194
+ ]
@@ -4,6 +4,7 @@ import re
4
4
 
5
5
  from euler_eval.cli import (
6
6
  _clean_metric_tree,
7
+ _clean_per_file_metrics,
7
8
  _depth_eval_axes,
8
9
  _DEPTH_EVAL_DESCRIPTIONS,
9
10
  _sparse_depth_eval_axes,
@@ -15,6 +16,7 @@ from euler_eval.cli import (
15
16
  _RGB_EVAL_DESCRIPTIONS,
16
17
  _RAYS_EVAL_AXES,
17
18
  _RAYS_EVAL_DESCRIPTIONS,
19
+ _wrap_depth_space_pfm_metrics,
18
20
  )
19
21
 
20
22
  # Build base (non-benchmark) axis dicts for testing
@@ -97,6 +99,105 @@ class TestMetaBlockStructure:
97
99
  cleaned = _clean_metric_tree(meta)
98
100
  assert "eval_params" not in cleaned
99
101
 
102
+ def test_per_file_metrics_object_preserved_when_metrics_clean_empty(self):
103
+ """Per-file entries keep a metrics object even if all values are pruned."""
104
+ per_file = {
105
+ "files": [
106
+ {
107
+ "id": "frame_0001",
108
+ "metrics": {
109
+ "rgb": {
110
+ "eval": {
111
+ "image_quality": {
112
+ "psnr": None,
113
+ "ssim": float("nan"),
114
+ },
115
+ },
116
+ },
117
+ },
118
+ },
119
+ ],
120
+ }
121
+
122
+ assert _clean_metric_tree(per_file) == {"files": [{"id": "frame_0001"}]}
123
+ assert _clean_per_file_metrics(per_file) == {
124
+ "files": [{"id": "frame_0001", "metrics": {}}]
125
+ }
126
+
127
+ def test_depth_per_file_wrapper_falls_back_to_canonical_metrics(self):
128
+ """Canonical-only per-file depth metrics still serialize under a space."""
129
+ metrics = {
130
+ "depth": {
131
+ "standard": {
132
+ "absrel": 0.1,
133
+ },
134
+ },
135
+ }
136
+
137
+ assert _wrap_depth_space_pfm_metrics(
138
+ metrics,
139
+ metric_root="depth",
140
+ canonical_key="depth",
141
+ canonical_space="native",
142
+ ) == {
143
+ "depth": {
144
+ "eval": {
145
+ "native": metrics["depth"],
146
+ },
147
+ },
148
+ }
149
+
150
+ def test_sparse_depth_zero_valid_count_survives_cleaning(self):
151
+ """Zero-support sparse entries keep a finite support metric in eval.json."""
152
+ metrics = {
153
+ "sparse_depth": {
154
+ "standard": {
155
+ "absrel": None,
156
+ },
157
+ "depth_metrics": {
158
+ "valid_pixel_count": 0,
159
+ "absrel": None,
160
+ "rmse": None,
161
+ },
162
+ },
163
+ }
164
+
165
+ wrapped = _wrap_depth_space_pfm_metrics(
166
+ metrics,
167
+ metric_root=_SPARSE_DEPTH_METRIC_ROOT,
168
+ canonical_key="sparse_depth",
169
+ canonical_space="metric",
170
+ )
171
+ cleaned = _clean_per_file_metrics(
172
+ {
173
+ "files": [
174
+ {
175
+ "id": "frame",
176
+ "metrics": wrapped,
177
+ },
178
+ ],
179
+ }
180
+ )
181
+
182
+ assert cleaned == {
183
+ "files": [
184
+ {
185
+ "id": "frame",
186
+ "metrics": {
187
+ _SPARSE_DEPTH_METRIC_ROOT: {
188
+ "eval": {
189
+ "metric": {
190
+ "depth_metrics": {
191
+ "valid_pixel_count": 0,
192
+ },
193
+ },
194
+ },
195
+ },
196
+ },
197
+ },
198
+ ],
199
+ }
200
+
100
201
  def test_meta_in_full_save_dict(self):
101
202
  """meta sits alongside metricSet and dataset_info without conflict."""
102
203
  save_dict = {
@@ -0,0 +1,57 @@
1
+ """Tests for shared metric utility helpers."""
2
+
3
+ import numpy as np
4
+
5
+ from euler_eval.metrics.utils import get_benchmark_depth_bins
6
+
7
+
8
+ def test_benchmark_depth_bins_for_001_to_80m_range():
9
+ range_min = 0.01
10
+ range_max = 80.0
11
+ sqrt_min = np.sqrt(range_min)
12
+ sqrt_max = np.sqrt(range_max)
13
+ step = (sqrt_max - sqrt_min) / 3.0
14
+ near_max = float((sqrt_min + step) ** 2)
15
+ mid_max = float((sqrt_min + 2 * step) ** 2)
16
+
17
+ depth = np.array(
18
+ [
19
+ 0.0,
20
+ range_min,
21
+ np.nextafter(near_max, range_min),
22
+ near_max,
23
+ np.nextafter(mid_max, near_max),
24
+ mid_max,
25
+ range_max,
26
+ np.nextafter(range_max, np.inf),
27
+ ],
28
+ dtype=np.float64,
29
+ )
30
+
31
+ bins = get_benchmark_depth_bins(depth, range_min, range_max)
32
+
33
+ assert bins["boundaries"] == {
34
+ "range": [range_min, range_max],
35
+ "near": [range_min, near_max],
36
+ "mid": [near_max, mid_max],
37
+ "far": [mid_max, range_max],
38
+ }
39
+ assert np.isclose(near_max, 9.290856529333297)
40
+ assert np.isclose(mid_max, 35.95418986266663)
41
+
42
+ np.testing.assert_array_equal(
43
+ bins["all"],
44
+ np.array([False, True, True, True, True, True, True, False]),
45
+ )
46
+ np.testing.assert_array_equal(
47
+ bins["near"],
48
+ np.array([False, True, True, False, False, False, False, False]),
49
+ )
50
+ np.testing.assert_array_equal(
51
+ bins["mid"],
52
+ np.array([False, False, False, True, True, False, False, False]),
53
+ )
54
+ np.testing.assert_array_equal(
55
+ bins["far"],
56
+ np.array([False, False, False, False, False, True, True, False]),
57
+ )
@@ -214,6 +214,21 @@ class TestSaveResultsZip:
214
214
  stored = json.loads(zf.read("eval.json"))
215
215
  assert stored == results
216
216
 
217
+ def test_inline_zip_split_writes_eval_json_into_zip(self, tmp_path):
218
+ zp = tmp_path / "dataset.zip"
219
+ with zipfile.ZipFile(zp, "w") as zf:
220
+ zf.writestr("image_001.png", b"\x89PNG")
221
+
222
+ results = {"depth": {"rmse": 0.42}}
223
+ config = {"name": "model", "depth": {"path": f"{zp}:fog_day"}}
224
+ out = save_results(results, config)
225
+
226
+ assert out == zp / "eval.json"
227
+ with zipfile.ZipFile(zp, "r") as zf:
228
+ assert "eval.json" in zf.namelist()
229
+ stored = json.loads(zf.read("eval.json"))
230
+ assert stored == results
231
+
217
232
  def test_preserves_existing_zip_contents(self, tmp_path):
218
233
  zp = tmp_path / "dataset.zip"
219
234
  with zipfile.ZipFile(zp, "w") as zf:
@@ -93,6 +93,12 @@ class _OneSampleSparseDataset:
93
93
  return {"gt": {"path": "/gt"}, "pred": {"path": "/pred"}}
94
94
 
95
95
 
96
+ class _ZeroValidSparseDataset(_OneSampleSparseDataset):
97
+ def __init__(self):
98
+ super().__init__()
99
+ self.pred = np.zeros((3, 3), dtype=np.float32)
100
+
101
+
96
102
  def test_sparse_depth_eval_reports_only_pointwise_depth_metrics():
97
103
  dataset = _OneSampleSparseDataset()
98
104
 
@@ -111,3 +117,20 @@ def test_sparse_depth_eval_reports_only_pointwise_depth_metrics():
111
117
  np.testing.assert_allclose(metrics["standard"]["pixel_pool"]["absrel"], 0.0)
112
118
  np.testing.assert_allclose(metrics["standard"]["pixel_pool"]["delta1"], 1.0)
113
119
  np.testing.assert_allclose(metrics["depth_metrics"]["rmse"]["median"], 0.0)
120
+
121
+
122
+ def test_sparse_depth_per_file_reports_zero_valid_support():
123
+ result = evaluate_sparse_depth_samples(
124
+ _ZeroValidSparseDataset(),
125
+ pred_is_radial=True,
126
+ num_workers=0,
127
+ alignment_mode="none",
128
+ )
129
+
130
+ assert result["dataset_info"]["projected_pixels"] == 2
131
+ assert result["dataset_info"]["evaluated_pixels"] == 0
132
+
133
+ per_file = result["per_file_metrics"]["files"][0]["metrics"]["sparse_depth"]
134
+ assert per_file["depth_metrics"]["valid_pixel_count"] == 0
135
+ assert per_file["depth_metrics"]["absrel"] is None
136
+ assert per_file["depth_metrics"]["rmse"] is None
File without changes
File without changes