brian4dwell commited on
Commit
31dbf53
·
1 Parent(s): f1e0138

voxel reduction

Browse files
design_docs/post_reduction_meshing.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ```markdown
2
+ # Design Doc: Post-Reduction Meshing for Clean Surface Export
3
+
4
+ **Author:** Brian Clark
5
+ **Last Updated:** 2025-11-07
6
+ **Target Component:** Optional stage after `predictions_to_glb` point processing
7
+ **Goal:** Convert cleaned point clouds into lightweight surface meshes for users who prefer shaded geometry over splatty point clouds.
8
+
9
+ ---
10
+
11
+ ## 1. Overview
12
+
13
+ After confidence filtering, voxel reduction, and denoising we hold a sparse, high-confidence point cloud.
14
+ This design adds an optional meshing stage (ball-pivoting, Poisson, marching cubes) to produce a triangle mesh that can be exported alongside or instead of the GLB point cloud.
15
+
16
+ ---
17
+
18
+ ## 2. Use Cases
19
+
20
+ - Interactive viewers requiring solid surfaces (lighting, shadowing).
21
+ - Downstream pipelines that expect meshes (CAD tools, 3D printing previews).
22
+ - Scenes where splatty points look sparse even after reinflation.
23
+
24
+ We do **not** aim for watertight printable models—just visually continuous surfaces.
25
+
26
+ ---
27
+
28
+ ## 3. Meshing Strategies
29
+
30
+ | Method | Pros | Cons | Dependencies |
31
+ | ------ | ---- | ---- | ------------ |
32
+ | **Ball-Pivoting (BPA)** | Stable for uneven sampling, preserves detail | Needs normals, parameter tuning | Open3D |
33
+ | **Poisson Reconstruction** | Smooth surfaces, fills gaps | Blurs thin structures, more compute | Open3D |
34
+ | **Marching Cubes on fused depth** | Works without normals | Requires voxel grid, might need extra fusion step | Open3D / custom |
35
+
36
+ Initial plan: start with Open3D’s **Ball-Pivoting**, fall back to Poisson when BPA fails.
37
+
38
+ ---
39
+
40
+ ## 4. Pipeline Integration
41
+
42
+ 1. Existing point-cleaning (voxel, support filtering, optional reinflation).
43
+ 2. Normal estimation (Open3D `estimate_normals`, with KD-tree search radius tied to voxel size).
44
+ 3. Mesh reconstruction (BPA default, Poisson fallback).
45
+ 4. Mesh simplification (`simplify_quadric_decimation`) to hit target face count.
46
+ 5. Export as GLB/OBJ/PLY; attach materials/colors using per-vertex colors (sampled from original points).
47
+
48
+ ### Configuration options
49
+ | Option | Default | Notes |
50
+ | ------ | ------- | ----- |
51
+ | `meshing_enabled` | `False` | Opt-in feature |
52
+ | `meshing_method` | `"ball_pivoting"` | `"poisson"` available |
53
+ | `bpa_radii` | `[voxel_size, 2×, 4×]` | Radii list for BPA |
54
+ | `poisson_depth` | 8 | Tree depth, controls detail |
55
+ | `target_face_count` | 200k | Post-simplification triangle budget |
56
+ | `keep_point_cloud` | `True` | Export both mesh + original cloud |
57
+
58
+ ---
59
+
60
+ ## 5. Implementation Plan
61
+
62
+ ### 5.1 Helper module
63
+ `stream3r/utils/mesh_utils.py`
64
+ ```python
65
+ def build_surface_mesh(points, colors, *, config) -> trimesh.Trimesh:
66
+ # open3d conversions, normal estimation
67
+ # reconstruction via BPA/Poisson
68
+ # color baking (nearest neighbor)
69
+ ```
70
+
71
+ ### 5.2 Export integration
72
+ In `_generate_core_outputs` after point cloud saved:
73
+ ```python
74
+ if settings.meshing_enabled:
75
+ mesh = build_surface_mesh(vertices_3d, colors_rgb, config)
76
+ mesh_url = _save_mesh(runtime, scene_id, mesh, temp_dir)
77
+ artifacts["mesh_url"] = mesh_url
78
+ ```
79
+
80
+ ### 5.3 Storage
81
+ - Upload under `models/meshes/scene_mesh.glb`
82
+ - Optionally provide OBJ + MTL for compatibility.
83
+
84
+ ---
85
+
86
+ ## 6. Validation
87
+
88
+ 1. Compare GLB mesh size vs. original point cloud.
89
+ 2. Manual visual QA (Viewer, Blender) to spot holes or artifacts.
90
+ 3. Automated checks:
91
+ - Mesh exists and contains triangles.
92
+ - Vertex count under budget.
93
+ - Color channels preserved (mean deviation < ε).
94
+ 4. Benchmark runtime per scene and ensure it fits within job timeouts.
95
+
96
+ ---
97
+
98
+ ## 7. Risks & Mitigations
99
+
100
+ | Risk | Mitigation |
101
+ | ---- | ---------- |
102
+ | Thin structures lost | Tune BPA radii; detect failure and revert to point cloud |
103
+ | Open3D dependency bloat | Gate meshing behind `pip install open3d`; log when unavailable |
104
+ | Runtime overhead | Make stage optional; expose `meshing_timeout` |
105
+ | Large meshes | Apply decimation & optional texture baking |
106
+
107
+ ---
108
+
109
+ ## 8. Deliverables
110
+
111
+ 1. Helper module for meshing + tests.
112
+ 2. Scene artifacts update (mesh export, metadata).
113
+ 3. New config flags (`STREAM3R_MESHING_ENABLED`, etc.).
114
+ 4. Documentation/tutorial for users toggling the mesh output.
115
+
116
+ ---
117
+
118
+ **Outcome:** An optional mesh artifact that gives viewers a solid-looking scene without fully abandoning the point-based pipeline.
119
+
120
+ ```
design_docs/splat_rendering.md ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ```markdown
2
+ # Design Doc: Splat-Aware Export with Per-Point Radii & View-Dependent Color
3
+
4
+ **Author:** Brian Clark
5
+ **Last Updated:** 2025-11-07
6
+ **Target Components:** Export pipeline (`predictions_to_glb`), downstream visualization stack
7
+ **Goal:** Preserve the rich “Gaussian splat” appearance by exporting per-point radius/orientation/color information and using a splat-aware renderer instead of vanilla GLB point clouds.
8
+
9
+ ---
10
+
11
+ ## 1. Overview
12
+
13
+ The current GLB export stores XYZ + RGB points. Rendering them as fixed-size pixels loses the soft, blended look achieved by overlapping splats in the original predictions.
14
+ This design introduces a splat representation (Gaussian or disk) with explicit radius and optional anisotropy, and changes the rendering path to honor those attributes.
15
+
16
+ ---
17
+
18
+ ## 2. Requirements
19
+
20
+ 1. **Data export**
21
+ - Include at least:
22
+ - Center (`x,y,z`)
23
+ - Radius (`r`) or covariance matrix (`Σ`)
24
+ - Base color (RGB) and optional view-dependent coefficients (e.g., SH).
25
+ - Allow fallback to disk splats (isotropic) if covariance unavailable.
26
+
27
+ 2. **File format**
28
+ - Options:
29
+ - Extend GLB with custom vertex attributes + custom shader (three.js, deck.gl).
30
+ - Use established Gaussian splat formats (e.g., `.splat`, `.ply` with extra attributes, or `gaussian-splatting` binary).
31
+ - Include metadata describing attribute semantics for the viewer.
32
+
33
+ 3. **Renderer**
34
+ - Web: Three.js shader or deck.gl layer capable of drawing splats.
35
+ - Native: Optionally integrate with existing Gaussian splatting viewers (Instant-NGP, Splatfacto, etc.).
36
+
37
+ 4. **Performance**
38
+ - Maintain reasonable splat counts (prefiltered via voxel reduction & density tests).
39
+ - Provide LOD strategies (e.g., radius-aware culling, multi-resolution export).
40
+
41
+ ---
42
+
43
+ ## 3. Proposed Pipeline
44
+
45
+ ### 3.1 Data preparation
46
+ 1. Start with voxel-reduced points.
47
+ 2. Estimate per-point covariance:
48
+ - Option A: Use original support’s covariance (requires storing full neighbor set).
49
+ - Option B: Compute isotropic radius from average neighbor distance (k-NN) or voxel size.
50
+ 3. Store color as linear RGB; optionally store SH coefficients computed during inference (if available).
51
+
52
+ ### 3.2 Export formats
53
+
54
+ | Format | Pros | Cons |
55
+ | ------ | ---- | ---- |
56
+ | **GLB + custom vertex attributes** | Simple integration with existing pipeline | Requires custom shader; not portable |
57
+ | **.splat binary (Gaussian Splatting)** | Compatibility with emerging viewers | New dependency; not widely standardized |
58
+ | **PLY/NPZ with attributes** | Readable, quick prototyping | Requires loader adaptation |
59
+
60
+ Initial recommendation: use GLB with custom attributes (e.g., `RADIUS`, `COV3x3`) and provide sample shaders for three.js.
61
+
62
+ ### 3.3 Rendering changes
63
+ 1. Publish a three.js script that:
64
+ - Loads GLB.
65
+ - Extracts attributes into buffers.
66
+ - Renders via custom shader (screen-space splatting).
67
+ 2. Provide deck.gl example using `PointCloudLayer` with `radiusPixels` or custom shader module.
68
+ 3. Eventually support native Gaussian Splat format for plug-and-play compatibility.
69
+
70
+ ---
71
+
72
+ ## 4. API changes
73
+
74
+ Add new export options:
75
+ - `export_mode: {"glb", "splat_glb", "gaussian_binary"}`
76
+ - `splat_settings` (radius multiplier, anisotropy toggle, max anisotropy)
77
+ - `lod_strategy` (none, subsample, multi-file)
78
+
79
+ Ensure backward compatibility by keeping current GLB path as default.
80
+
81
+ ---
82
+
83
+ ## 5. Validation & Testing
84
+
85
+ 1. Compare visual output in existing viewer vs. splat viewer (visual parity).
86
+ 2. Measure load time, FPS for large scenes.
87
+ 3. Unit tests for:
88
+ - Attribute packing/unpacking.
89
+ - Radius estimation.
90
+ - Export file integrity.
91
+ 4. Integration test: pipeline -> viewer round-trip (screenshot diff, metrics).
92
+
93
+ ---
94
+
95
+ ## 6. Risks & Mitigations
96
+
97
+ | Risk | Mitigation |
98
+ | ---- | ---------- |
99
+ | Custom GLB attributes unsupported by some tools | Provide fallback GLB path, document requirements |
100
+ | Increased file size due to extra attributes | Leverage quantization/compression, allow isotropic mode |
101
+ | Viewer complexity | Ship reference shader + deck.gl layer; adopt existing open-source splat renderer |
102
+ | Lack of covariance data | Start with isotropic radii derived from voxel size |
103
+
104
+ ---
105
+
106
+ ## 7. Timeline (rough)
107
+
108
+ 1. Week 1: Data prep & attribute computation (radius/covariance).
109
+ 2. Week 2: GLB exporter modifications + tests.
110
+ 3. Week 3: Viewer shader integration, documentation.
111
+ 4. Week 4: Optional Gaussian binary export + performance tuning.
112
+
113
+ ---
114
+
115
+ ## 8. Deliverables
116
+
117
+ 1. Updated export pipeline producing splat-aware asset.
118
+ 2. Viewer example repo (three.js + deck.gl).
119
+ 3. Documentation covering format, tuning knobs, and integration steps.
120
+ 4. Automated tests validating attribute correctness & rendering.
121
+
122
+ ---
123
+
124
+ ```
design_docs/voxel_reduction.md ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ```markdown
2
+ # Design Doc: Stream3R → Clean GLB Export with Voxel Reduction + Open3D Outlier Removal
3
+
4
+ **Author:** Brian Clark
5
+ **Last Updated:** 2025-11-03
6
+ **Target Component:** `predictions_to_glb()` (Stream3R repo)
7
+ **Goal:** Integrate a high-fidelity voxel reduction and denoising stage for cleaner, lighter `.glb` outputs suitable for downstream r3f visualization.
8
+
9
+ ---
10
+
11
+ ## 1. Overview
12
+
13
+ The existing `predictions_to_glb()` function builds a large unfiltered point cloud directly from Stream3R predictions.
14
+ This design adds two optional cleanup stages that:
15
+ - **(1) Voxel-reduce** redundant points into weighted centroids.
16
+ - **(2) Filter residual noise** with Open3D’s statistical & radius outlier removal.
17
+
18
+ This yields cleaner geometry, lower GLB size, and faster loading, while preserving color and geometry fidelity.
19
+
20
+ ---
21
+
22
+ ## 2. Key Additions
23
+
24
+ ### 2.1 New helpers
25
+
26
+ #### `voxel_reduce(points_f32, colors_u8, conf_f32, voxel_size)`
27
+ - Merges points falling within the same voxel grid cell.
28
+ - Weighted average of position, color (in *linear* space), and optional confidence.
29
+ - Returns `(points, colors, support)`.
30
+
31
+ #### `o3d_outlier_filter(points_f32, colors_u8, voxel_size, radius_mult, nb_points, nb_neighbors, std_ratio)`
32
+ - Converts NumPy arrays → Open3D `PointCloud`.
33
+ - Applies:
34
+ 1. **Radius Outlier Removal:** ensures local density.
35
+ 2. **Statistical Outlier Removal:** drops sparse noise.
36
+ - Converts filtered result back to NumPy + `uint8` sRGB.
37
+
38
+ ### 2.2 Optional parameters added to `predictions_to_glb()`
39
+ | Parameter | Type | Default | Description |
40
+ |------------|------|----------|--------------|
41
+ | `voxel_size` | float \| None | None | Enables voxel reduction if >0 |
42
+ | `voxel_after_conf` | bool | True | Reduce after confidence & mask filtering |
43
+ | `o3d_denoise` | bool | True | Enables Open3D outlier filtering |
44
+ | `o3d_params` | dict \| None | None | Override Open3D defaults (e.g. radius_mult) |
45
+
46
+ ### 2.3 Processing order
47
+
48
+ ```
49
+
50
+ predictions → confidence/bg masking
51
+ → [optional] voxel_reduce()
52
+ → [optional] o3d_outlier_filter()
53
+ → trimesh.PointCloud → GLB
54
+
55
+ ````
56
+
57
+ Both stages are fully optional; default behavior is unchanged.
58
+
59
+ ---
60
+
61
+ ## 3. Implementation Plan
62
+
63
+ ### 3.1 Import dependencies
64
+ ```python
65
+ import open3d as o3d # optional heavy dependency
66
+ import numpy as np
67
+ import trimesh
68
+ ````
69
+
70
+ ### 3.2 Helper: `voxel_reduce()`
71
+
72
+ ```python
73
+ def voxel_reduce(points_f32, colors_u8, conf_f32=None, voxel_size=0.02, origin=None):
74
+ # sRGB→linear, weighted average, linear→sRGB
75
+ # Hash each voxel using large primes (avoids collisions)
76
+ # Return reduced arrays
77
+ ```
78
+
79
+ ### 3.3 Helper: `o3d_outlier_filter()`
80
+
81
+ ```python
82
+ def o3d_outlier_filter(points_f32, colors_u8,
83
+ voxel_size=0.02,
84
+ radius_mult=3.0,
85
+ nb_points=16,
86
+ nb_neighbors=48,
87
+ std_ratio=1.5):
88
+ # Construct Open3D cloud, remove outliers, return filtered arrays
89
+ ```
90
+
91
+ ### 3.4 Patch in `predictions_to_glb()`
92
+
93
+ Insert after confidence masking:
94
+
95
+ ```python
96
+ if voxel_size is not None and voxel_size > 0:
97
+ vertices_3d, colors_rgb, _support = voxel_reduce(
98
+ vertices_3d, colors_rgb, conf_f32=conf_used, voxel_size=float(voxel_size)
99
+ )
100
+
101
+ if o3d_denoise and vertices_3d.size:
102
+ params = dict(
103
+ voxel_size=float(voxel_size or 0.02),
104
+ radius_mult=3.0,
105
+ nb_points=16,
106
+ nb_neighbors=48,
107
+ std_ratio=1.5,
108
+ )
109
+ if o3d_params: params.update(o3d_params)
110
+ vertices_3d, colors_rgb = o3d_outlier_filter(vertices_3d, colors_rgb, **params)
111
+ ```
112
+
113
+ The rest of the GLB creation (scene scale, camera meshes, alignment) remains unchanged.
114
+
115
+ ---
116
+
117
+ ## 4. Default Parameters & Behavior
118
+
119
+ | Context | Setting | Recommended |
120
+ | ------------------ | --------------------------------------- | -------------------- |
121
+ | Indoor scenes | `voxel_size=0.02` | 2 cm grid |
122
+ | Fast preview | `voxel_size=0.06` | Coarse 6 cm grid |
123
+ | Radius filter | `radius = 3×voxel_size` | 0.06 m for 2 cm grid |
124
+ | Statistical filter | `nb_neighbors=48`, `std_ratio=1.5` | Safe defaults |
125
+ | Weighting | Confidence scores (`world_points_conf`) | Use for averages |
126
+
127
+ ---
128
+
129
+ ## 5. Expected Outcomes
130
+
131
+ | Metric | Before | After |
132
+ | ---------------------- | --------- | ---------------- |
133
+ | GLB file size | 1× | ↓ 3–8× |
134
+ | Visual duplicates | High | Minimal |
135
+ | Noise/speckle | Frequent | Strongly reduced |
136
+ | Load time (r3f viewer) | Long | Near-instant |
137
+ | Fidelity | Unchanged | Preserved |
138
+
139
+ ---
140
+
141
+ ## 6. Validation Steps
142
+
143
+ 1. **Run baseline:**
144
+ `predictions_to_glb(preds, voxel_size=None, o3d_denoise=False)`
145
+ → export size / load time baseline.
146
+
147
+ 2. **Run optimized:**
148
+ `predictions_to_glb(preds, voxel_size=0.02, o3d_denoise=True)`
149
+ → compare GLB size, visual quality, and FPS in viewer.
150
+
151
+ 3. **Stress-test:**
152
+
153
+ * High-conf scenes with many frames.
154
+ * Scenes with thin structures (shelves, walls).
155
+ * Ensure no noticeable geometric bias or color shift.
156
+
157
+ ---
158
+
159
+ ## 7. Future Extensions
160
+
161
+ | Feature | Description |
162
+ | --------------------- | ------------------------------------------------------------------ |
163
+ | **Normals averaging** | Extend `voxel_reduce()` to merge normals & store as GLB attributes |
164
+ | **Support weighting** | Save per-voxel support count → possible LOD weighting |
165
+ | **Covariance export** | Optionally compute per-voxel covariance for Gaussian splats |
166
+ | **Tile-based batch** | Enable out-of-core fusion for huge rooms |
167
+ | **Dual GLB export** | Auto-save coarse (preview) + fine (full-res) versions |
168
+
169
+ ---
170
+
171
+ ## 8. Example Usage
172
+
173
+ ```python
174
+ scene = predictions_to_glb(
175
+ preds,
176
+ conf_thres=50.0,
177
+ mask_white_bg=True,
178
+ voxel_size=0.02,
179
+ o3d_denoise=True,
180
+ o3d_params={"nb_neighbors": 64, "std_ratio": 1.3}
181
+ )
182
+ trimesh.exchange.gltf.export_glb(scene, "room_clean.glb")
183
+ ```
184
+
185
+ ---
186
+
187
+ ## 9. Deliverables for Codex Implementation
188
+
189
+ 1. **New helper functions**
190
+
191
+ * `voxel_reduce()`
192
+ * `o3d_outlier_filter()`
193
+
194
+ 2. **Modified signature** of `predictions_to_glb()` to include new optional args.
195
+
196
+ 3. **Integration** of both steps before `trimesh.PointCloud`.
197
+
198
+ 4. **Minimal dependency injection**
199
+ (`open3d` imported lazily; safe fail if missing).
200
+
201
+ 5. **Unit test / validation script**
202
+
203
+ * Compare point counts & file sizes pre-/post-cleanup.
204
+ * Assert geometry type remains `PointCloud`.
205
+
206
+ ---
207
+
208
+ **Outcome:**
209
+ A drop-in replacement for `predictions_to_glb()` producing denser, visually identical but much smaller and cleaner `.glb` point clouds for Stream3R → r3f workflows.
210
+
211
+ ```
212
+ ```
design_docs/voxel_reinflation.md ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ```markdown
2
+ # Design Doc: Voxel Reinflation to Recover Dense Splat Coverage
3
+
4
+ **Author:** Brian Clark
5
+ **Last Updated:** 2025-11-07
6
+ **Target Component:** `predictions_to_glb()` (Stream3R repo)
7
+ **Goal:** Restore the “splatty” look of point clouds after voxel reduction by respawning micro-clusters around each reduced voxel center without bloating GLB size back to the original.
8
+
9
+ ---
10
+
11
+ ## 1. Overview
12
+
13
+ The current voxel reduction keeps one weighted centroid per cell. Great for size, bad for perceived density.
14
+ This design “re-inflates” each voxel into a small jittered cluster proportional to its support, so standard GLB point rendering still looks like a filled surface.
15
+
16
+ ---
17
+
18
+ ## 2. Key Concepts
19
+
20
+ ### 2.1 Inputs from existing pipeline
21
+ - `voxel_reduce()` already returns:
22
+ - `points`: voxel centroids
23
+ - `support`: sum of confidences (or point counts) per voxel
24
+ - We can use `support` to estimate how many original samples a voxel represents.
25
+
26
+ ### 2.2 Re-inflation strategy
27
+ - Emit `k = clamp(round(alpha * support), min_samples, max_samples)` points per voxel.
28
+ - Each point = centroid + jitter sampled uniformly within the voxel cube (or Gaussian with σ tied to voxel size).
29
+ - Jittered colors copied from centroid (optionally add mild hue jitter for variety).
30
+ - Optional normal estimation by reusing local PCA from original neighbors (future extension).
31
+
32
+ ### 2.3 Controls
33
+ | Parameter | Description | Default |
34
+ | --------- | ----------- | ------- |
35
+ | `reinflate_enabled` | Toggle the entire stage | `False` (opt-in) |
36
+ | `support_scale` | α multiplier converting support → sample count | 0.5 |
37
+ | `min_samples` | Minimum points per voxel | 1 |
38
+ | `max_samples` | Ceiling per voxel to cap explosion | 12 |
39
+ | `jitter_mode` | `cube` (uniform) or `gaussian` | `cube` |
40
+ | `jitter_sigma` | For gaussian mode; fraction of voxel size | 0.35 |
41
+ | `seed` | Deterministic RNG seed for reproducibility | 0 (disabled) |
42
+
43
+ ---
44
+
45
+ ## 3. Implementation Plan
46
+
47
+ ### 3.1 Helper function
48
+ ```python
49
+ def reinflate_voxels(points, colors, support, voxel_size, *, config):
50
+ # Determine sample counts per voxel
51
+ # Generate jitter offsets (vectorized, deterministic optional)
52
+ # Repeat colors + points with offsets
53
+ # Return expanded arrays
54
+ ```
55
+ - Use `np.repeat` to build index arrays, avoid Python loops.
56
+ - Optional RNG seeded via `np.random.default_rng`.
57
+
58
+ ### 3.2 Integration point
59
+ Insert after voxel reduction & support filtering, before Open3D/density filter.
60
+ Why? We want re-inflated points to still be denoised and deduped if needed.
61
+
62
+ ```
63
+ if reinflate_enabled and vertices_3d.size:
64
+ vertices_3d, colors_rgb = reinflate_voxels(..., support=conf_used, ...)
65
+ ```
66
+
67
+ ### 3.3 Performance considerations
68
+ - Re-inflation runs in-memory; ensure we pre-allocate arrays (`np.empty`) to avoid Python loops.
69
+ - Keep `max_samples` conservative to prevent 2 GB GLBs.
70
+ - Skip stage when `support` missing (e.g., streaming mode without confidences).
71
+
72
+ ---
73
+
74
+ ## 4. Expected Outcomes
75
+ | Metric | Before | After |
76
+ | ------ | ------ | ----- |
77
+ | GLB size | ~0.5× raw | ~0.7× raw (depends on support_scale) |
78
+ | Visual density | Sparse | Near original “splat” look |
79
+ | Compute cost | negligible | + small vectorized jitter step |
80
+
81
+ ---
82
+
83
+ ## 5. Validation Plan
84
+ 1. Compare point counts vs. original per-scene.
85
+ 2. Visual inspection in r3f viewer for surface coverage.
86
+ 3. Ensure jitter uses consistent seed for reproducible exports.
87
+ 4. Capture metrics: GLB size, avg points per voxel, render FPS.
88
+
89
+ ---
90
+
91
+ ## 6. Future Extensions
92
+ - Encode support as per-point radius attribute for richer viewers.
93
+ - Adaptive jitter: anisotropic offsets aligned with local normals.
94
+ - Auto-tune `support_scale` based on desired point budget.
95
+
96
+ ---
97
+
98
+ **Deliverables**
99
+ 1. `reinflate_voxels()` helper with tests.
100
+ 2. Config plumbing (env vars & `WorkerSettings`).
101
+ 3. Integration hooks + logging of pre/post point counts.
102
+ 4. Update docs / README to explain new options.
103
+
104
+ ---
105
+
106
+ ```
requirements.txt CHANGED
@@ -42,6 +42,7 @@ seaborn
42
  pyglet<2
43
  huggingface-hub[torch]>=0.22
44
  spaces
 
45
 
46
  # --------- worker --------- #
47
  redis
 
42
  pyglet<2
43
  huggingface-hub[torch]>=0.22
44
  spaces
45
+ open3d
46
 
47
  # --------- worker --------- #
48
  redis
stream3r/utils/__pycache__/visual_utils.cpython-311.pyc CHANGED
Binary files a/stream3r/utils/__pycache__/visual_utils.cpython-311.pyc and b/stream3r/utils/__pycache__/visual_utils.cpython-311.pyc differ
 
stream3r/utils/visual_utils.py CHANGED
@@ -4,14 +4,213 @@
4
  # This source code is licensed under the license found in the
5
  # LICENSE file in the root directory of this source tree.
6
 
7
- import trimesh
8
- import numpy as np
9
- import matplotlib
10
- from scipy.spatial.transform import Rotation
11
  import copy
12
- import cv2
13
  import os
 
 
 
 
14
  import requests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
  def predictions_to_glb(
@@ -26,6 +225,13 @@ def predictions_to_glb(
26
  prediction_mode="Predicted Pointmap",
27
  extra_cameras=None,
28
  extra_camera_color=(255, 0, 0),
 
 
 
 
 
 
 
29
  ) -> trimesh.Scene:
30
  """
31
  Converts predictions to a 3D scene represented as a GLB file.
@@ -47,6 +253,13 @@ def predictions_to_glb(
47
  extra_cameras (Optional[List[np.ndarray]]): Additional camera extrinsics (3x4 or 4x4)
48
  to visualize even when show_cam=False. Useful for highlighting localized poses.
49
  extra_camera_color (tuple or list[tuple]): RGB color(s) for extra cameras.
 
 
 
 
 
 
 
50
 
51
  Returns:
52
  trimesh.Scene: Processed 3D scene containing point cloud and cameras
@@ -152,7 +365,27 @@ def predictions_to_glb(
152
  colors_rgb = images
153
  colors_rgb = (colors_rgb.reshape(-1, 3) * 255).astype(np.uint8)
154
 
155
- conf = pred_world_points_conf.reshape(-1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  # Convert percentage threshold to actual confidence value
157
  if conf_thres == 0.0:
158
  conf_threshold = 0.0
@@ -173,6 +406,48 @@ def predictions_to_glb(
173
 
174
  vertices_3d = vertices_3d[conf_mask]
175
  colors_rgb = colors_rgb[conf_mask]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
  if vertices_3d is None or np.asarray(vertices_3d).size == 0:
178
  vertices_3d = np.array([[1, 0, 0]])
 
4
  # This source code is licensed under the license found in the
5
  # LICENSE file in the root directory of this source tree.
6
 
7
+ import logging
 
 
 
8
  import copy
 
9
  import os
10
+
11
+ import cv2
12
+ import matplotlib
13
+ import numpy as np
14
  import requests
15
+ import trimesh
16
+ from scipy.spatial import cKDTree
17
+ from scipy.spatial.transform import Rotation
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _srgb_to_linear(colors: np.ndarray) -> np.ndarray:
24
+ colors = np.clip(colors, 0.0, 1.0)
25
+ threshold = 0.04045
26
+ below = colors <= threshold
27
+ linear = np.empty_like(colors, dtype=np.float64)
28
+ linear[below] = colors[below] / 12.92
29
+ linear[~below] = ((colors[~below] + 0.055) / 1.055) ** 2.4
30
+ return linear
31
+
32
+
33
+ def _linear_to_srgb(colors: np.ndarray) -> np.ndarray:
34
+ colors = np.clip(colors, 0.0, 1.0)
35
+ threshold = 0.0031308
36
+ srgb = np.empty_like(colors, dtype=np.float64)
37
+ below = colors <= threshold
38
+ srgb[below] = colors[below] * 12.92
39
+ srgb[~below] = 1.055 * np.power(colors[~below], 1 / 2.4) - 0.055
40
+ return np.clip(np.round(srgb * 255.0), 0, 255).astype(np.uint8)
41
+
42
+
43
+ def voxel_reduce(
44
+ points_f32: np.ndarray,
45
+ colors_u8: np.ndarray,
46
+ conf_f32: np.ndarray | None = None,
47
+ voxel_size: float = 0.02,
48
+ origin: np.ndarray | None = None,
49
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
50
+ points = np.asarray(points_f32, dtype=np.float32)
51
+ colors = np.asarray(colors_u8, dtype=np.uint8)
52
+
53
+ if points.size == 0:
54
+ return (
55
+ points.reshape(-1, 3).astype(np.float32),
56
+ colors.reshape(-1, 3).astype(np.uint8),
57
+ np.zeros((points.shape[0],), dtype=np.float32),
58
+ )
59
+
60
+ if voxel_size is None or voxel_size <= 0:
61
+ weights = (
62
+ np.asarray(conf_f32, dtype=np.float32).reshape(-1)
63
+ if conf_f32 is not None
64
+ else np.ones(points.shape[0], dtype=np.float32)
65
+ )
66
+ return points.astype(np.float32), colors.astype(np.uint8), weights
67
+
68
+ weights = (
69
+ np.asarray(conf_f32, dtype=np.float32).reshape(-1)
70
+ if conf_f32 is not None
71
+ else np.ones(points.shape[0], dtype=np.float32)
72
+ )
73
+ if weights.shape[0] != points.shape[0]:
74
+ raise ValueError("conf_f32 must match the shape of points.")
75
+
76
+ base = (
77
+ np.asarray(origin, dtype=np.float32)
78
+ if origin is not None
79
+ else points.min(axis=0).astype(np.float32)
80
+ )
81
+ voxel_indices = np.floor((points - base) / voxel_size).astype(np.int64)
82
+ voxel_keys, inverse_indices, counts = np.unique(
83
+ voxel_indices, axis=0, return_inverse=True, return_counts=True
84
+ )
85
+
86
+ reduced_count = voxel_keys.shape[0]
87
+ accum_weights = np.bincount(inverse_indices, weights=weights, minlength=reduced_count)
88
+ accum_weights = np.where(accum_weights <= 0, 1e-6, accum_weights)
89
+
90
+ reduced_points = np.zeros((reduced_count, 3), dtype=np.float64)
91
+ for dim in range(3):
92
+ reduced_points[:, dim] = np.bincount(
93
+ inverse_indices,
94
+ weights=weights * points[:, dim],
95
+ minlength=reduced_count,
96
+ )
97
+ reduced_points /= accum_weights[:, None]
98
+
99
+ colors_linear = _srgb_to_linear(colors.astype(np.float32) / 255.0)
100
+ reduced_colors_linear = np.zeros((reduced_count, 3), dtype=np.float64)
101
+ for dim in range(3):
102
+ reduced_colors_linear[:, dim] = np.bincount(
103
+ inverse_indices,
104
+ weights=weights * colors_linear[:, dim],
105
+ minlength=reduced_count,
106
+ )
107
+ reduced_colors_linear /= accum_weights[:, None]
108
+
109
+ reduced_colors = _linear_to_srgb(reduced_colors_linear)
110
+ support = (
111
+ accum_weights.astype(np.float32)
112
+ if conf_f32 is not None
113
+ else counts.astype(np.float32)
114
+ )
115
+
116
+ return reduced_points.astype(np.float32), reduced_colors.astype(np.uint8), support
117
+
118
+
119
+ def _filter_by_support(
120
+ points: np.ndarray,
121
+ colors: np.ndarray,
122
+ support: np.ndarray,
123
+ min_support: float | None,
124
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
125
+ if (
126
+ support is None
127
+ or support.size == 0
128
+ or min_support is None
129
+ or min_support <= 0
130
+ ):
131
+ return points, colors, support
132
+
133
+ mask = support >= float(min_support)
134
+ if not np.any(mask):
135
+ return points, colors, support
136
+ return points[mask], colors[mask], support[mask]
137
+
138
+
139
+ def _log_point_count(stage: str, before: int, after: int) -> None:
140
+ if logger.isEnabledFor(logging.INFO):
141
+ logger.info("Point cloud %s: %d -> %d", stage, before, after)
142
+
143
+
144
+ def o3d_outlier_filter(
145
+ points_f32: np.ndarray,
146
+ colors_u8: np.ndarray,
147
+ *,
148
+ voxel_size: float = 0.02,
149
+ radius_mult: float = 3.0,
150
+ nb_points: int = 16,
151
+ nb_neighbors: int = 48,
152
+ std_ratio: float = 1.5,
153
+ ) -> tuple[np.ndarray, np.ndarray]:
154
+ points = np.asarray(points_f32, dtype=np.float32)
155
+ colors = np.asarray(colors_u8, dtype=np.uint8)
156
+
157
+ if points.size == 0:
158
+ return points.reshape(-1, 3), colors.reshape(-1, 3)
159
+
160
+ try:
161
+ import open3d as o3d # type: ignore
162
+ except ImportError:
163
+ logger.warning("Open3D not available; skipping outlier filtering.")
164
+ return points.astype(np.float32), colors.astype(np.uint8)
165
+
166
+ pcd = o3d.geometry.PointCloud()
167
+ pcd.points = o3d.utility.Vector3dVector(points.astype(np.float64))
168
+ pcd.colors = o3d.utility.Vector3dVector(colors.astype(np.float32) / 255.0)
169
+
170
+ effective_voxel = float(voxel_size) if voxel_size and voxel_size > 0 else 0.02
171
+ radius = max(float(radius_mult) * effective_voxel, 1e-4)
172
+
173
+ if nb_points > 0:
174
+ pcd, _ = pcd.remove_radius_outlier(nb_points=int(nb_points), radius=radius)
175
+ if len(pcd.points) == 0:
176
+ return np.empty((0, 3), dtype=np.float32), np.empty((0, 3), dtype=np.uint8)
177
+
178
+ if nb_neighbors > 0:
179
+ pcd, _ = pcd.remove_statistical_outlier(
180
+ nb_neighbors=int(nb_neighbors),
181
+ std_ratio=float(std_ratio),
182
+ )
183
+ if len(pcd.points) == 0:
184
+ return np.empty((0, 3), dtype=np.float32), np.empty((0, 3), dtype=np.uint8)
185
+
186
+ filtered_points = np.asarray(pcd.points, dtype=np.float32)
187
+ filtered_colors = np.asarray(pcd.colors, dtype=np.float32)
188
+ filtered_colors = np.clip(np.round(filtered_colors * 255.0), 0, 255).astype(np.uint8)
189
+
190
+ return filtered_points, filtered_colors
191
+
192
+
193
+ def density_filter_points(
194
+ points_f32: np.ndarray,
195
+ colors_u8: np.ndarray,
196
+ *,
197
+ radius: float,
198
+ min_neighbors: int,
199
+ ) -> tuple[np.ndarray, np.ndarray]:
200
+ points = np.asarray(points_f32, dtype=np.float32)
201
+ colors = np.asarray(colors_u8, dtype=np.uint8)
202
+
203
+ if points.size == 0:
204
+ return points.reshape(-1, 3), colors.reshape(-1, 3)
205
+
206
+ radius = max(float(radius), 1e-4)
207
+ min_neighbors = max(int(min_neighbors), 1)
208
+
209
+ tree = cKDTree(points)
210
+ neighbor_lists = tree.query_ball_point(points, radius)
211
+ mask = np.fromiter((len(nlist) >= min_neighbors for nlist in neighbor_lists), dtype=bool, count=len(neighbor_lists))
212
+
213
+ return points[mask], colors[mask]
214
 
215
 
216
  def predictions_to_glb(
 
225
  prediction_mode="Predicted Pointmap",
226
  extra_cameras=None,
227
  extra_camera_color=(255, 0, 0),
228
+ voxel_size: float | None = 0.01,
229
+ voxel_after_conf: bool = True,
230
+ min_voxel_support: float | None = 3,
231
+ o3d_denoise: bool = True,
232
+ o3d_params: dict | None = {"radius_mult": 3.0, "nb_points": 16, "nb_neighbors": 48, "std_ratio": 1.5},
233
+ density_filter: bool = True,
234
+ density_params: dict | None = {"radius": 0.05, "min_neighbors": 6},
235
  ) -> trimesh.Scene:
236
  """
237
  Converts predictions to a 3D scene represented as a GLB file.
 
253
  extra_cameras (Optional[List[np.ndarray]]): Additional camera extrinsics (3x4 or 4x4)
254
  to visualize even when show_cam=False. Useful for highlighting localized poses.
255
  extra_camera_color (tuple or list[tuple]): RGB color(s) for extra cameras.
256
+ voxel_size (Optional[float]): Size of voxel grid cells (>0 enables reduction).
257
+ voxel_after_conf (bool): Apply voxel reduction after confidence/background filtering.
258
+ min_voxel_support (Optional[float]): Minimum aggregated support (confidence/count) per voxel.
259
+ o3d_denoise (bool): Enable Open3D outlier filtering.
260
+ o3d_params (Optional[dict]): Overrides for Open3D filtering parameters.
261
+ density_filter (bool): Apply KD-tree based density filtering.
262
+ density_params (Optional[dict]): Overrides for density filter parameters.
263
 
264
  Returns:
265
  trimesh.Scene: Processed 3D scene containing point cloud and cameras
 
365
  colors_rgb = images
366
  colors_rgb = (colors_rgb.reshape(-1, 3) * 255).astype(np.uint8)
367
 
368
+ conf = pred_world_points_conf.reshape(-1).astype(np.float32)
369
+
370
+ effective_voxel_size = float(voxel_size) if voxel_size is not None else None
371
+ if effective_voxel_size is not None and effective_voxel_size <= 0:
372
+ effective_voxel_size = None
373
+
374
+ if effective_voxel_size is not None and not voxel_after_conf:
375
+ before_count = vertices_3d.shape[0]
376
+ vertices_3d, colors_rgb, conf = voxel_reduce(
377
+ vertices_3d,
378
+ colors_rgb,
379
+ conf,
380
+ voxel_size=effective_voxel_size,
381
+ )
382
+ vertices_3d, colors_rgb, conf = _filter_by_support(
383
+ vertices_3d,
384
+ colors_rgb,
385
+ conf,
386
+ min_voxel_support,
387
+ )
388
+ _log_point_count("voxel_reduce_pre_conf", before_count, vertices_3d.shape[0])
389
  # Convert percentage threshold to actual confidence value
390
  if conf_thres == 0.0:
391
  conf_threshold = 0.0
 
406
 
407
  vertices_3d = vertices_3d[conf_mask]
408
  colors_rgb = colors_rgb[conf_mask]
409
+ conf_used = conf[conf_mask]
410
+
411
+ if effective_voxel_size is not None and voxel_after_conf and vertices_3d.size:
412
+ before_count = vertices_3d.shape[0]
413
+ vertices_3d, colors_rgb, conf_used = voxel_reduce(
414
+ vertices_3d,
415
+ colors_rgb,
416
+ conf_used,
417
+ voxel_size=effective_voxel_size,
418
+ )
419
+ vertices_3d, colors_rgb, conf_used = _filter_by_support(
420
+ vertices_3d,
421
+ colors_rgb,
422
+ conf_used,
423
+ min_voxel_support,
424
+ )
425
+ _log_point_count("voxel_reduce_post_conf", before_count, vertices_3d.shape[0])
426
+
427
+ if o3d_denoise and vertices_3d.size:
428
+ before_count = vertices_3d.shape[0]
429
+ params = {
430
+ "voxel_size": effective_voxel_size or 0.02,
431
+ "radius_mult": 3.0,
432
+ "nb_points": 16,
433
+ "nb_neighbors": 48,
434
+ "std_ratio": 1.5,
435
+ }
436
+ if o3d_params:
437
+ params.update(o3d_params)
438
+ vertices_3d, colors_rgb = o3d_outlier_filter(vertices_3d, colors_rgb, **params)
439
+ _log_point_count("o3d_denoise", before_count, vertices_3d.shape[0])
440
+
441
+ if density_filter and vertices_3d.size:
442
+ before_count = vertices_3d.shape[0]
443
+ params = {
444
+ "radius": (effective_voxel_size or 0.02) * 2.5,
445
+ "min_neighbors": 5,
446
+ }
447
+ if density_params:
448
+ params.update(density_params)
449
+ vertices_3d, colors_rgb = density_filter_points(vertices_3d, colors_rgb, **params)
450
+ _log_point_count("density_filter", before_count, vertices_3d.shape[0])
451
 
452
  if vertices_3d is None or np.asarray(vertices_3d).size == 0:
453
  vertices_3d = np.array([[1, 0, 0]])
stream3r/worker/config.py CHANGED
@@ -73,7 +73,7 @@ class WorkerSettings:
73
  gpu_lock_timeout: int = 3600
74
  gpu_lock_blocking_timeout: int = 600
75
 
76
- storage_prefix: str = "scene"
77
  s3_bucket: str | None = None
78
  s3_endpoint_url: str | None = None
79
  s3_region: str | None = None
@@ -114,9 +114,10 @@ class WorkerSettings:
114
  scene_media_api_base_url: str | None = None
115
  scene_media_api_token: str | None = None
116
  scene_media_page_size: int = 200
117
- stream_window_size: int = 20
118
  max_frames_per_job: int = 0
119
- default_job_timeout: int = 15 * 60
 
120
 
121
  @classmethod
122
  def from_env(cls) -> "WorkerSettings":
@@ -212,6 +213,9 @@ class WorkerSettings:
212
  "default_job_timeout": _env_int(
213
  "STREAM3R_JOB_TIMEOUT", base.default_job_timeout
214
  ),
 
 
 
215
  }
216
 
217
  return cls(**kwargs)
 
73
  gpu_lock_timeout: int = 3600
74
  gpu_lock_blocking_timeout: int = 600
75
 
76
+ storage_prefix: str = "scenes"
77
  s3_bucket: str | None = None
78
  s3_endpoint_url: str | None = None
79
  s3_region: str | None = None
 
114
  scene_media_api_base_url: str | None = None
115
  scene_media_api_token: str | None = None
116
  scene_media_page_size: int = 200
117
+ stream_window_size: int = 14
118
  max_frames_per_job: int = 0
119
+ default_job_timeout: int = 45 * 60
120
+ upload_session_cache: bool = True
121
 
122
  @classmethod
123
  def from_env(cls) -> "WorkerSettings":
 
213
  "default_job_timeout": _env_int(
214
  "STREAM3R_JOB_TIMEOUT", base.default_job_timeout
215
  ),
216
+ "upload_session_cache": _env_bool(
217
+ "STREAM3R_UPLOAD_CACHE", base.upload_session_cache
218
+ ),
219
  }
220
 
221
  return cls(**kwargs)
stream3r/worker/tasks.py CHANGED
@@ -15,6 +15,7 @@ from dataclasses import dataclass, field
15
  from datetime import datetime, timezone
16
  from pathlib import Path
17
  from contextlib import nullcontext
 
18
  from typing import Any, Callable, Mapping
19
 
20
  import numpy as np
@@ -471,6 +472,12 @@ def _upload_cache(
471
  ) -> str | None:
472
  if cache_path is None or not cache_path.exists():
473
  return None
 
 
 
 
 
 
474
  key = runtime.storage.build_key(
475
  scene_id,
476
  runtime.settings.models_dir,
@@ -575,7 +582,7 @@ def _save_scene_glb(
575
  filter_by_frames=payload.get("frame_filter", "All"),
576
  mask_black_bg=_as_bool(payload.get("mask_black_bg"), False),
577
  mask_white_bg=_as_bool(payload.get("mask_white_bg"), False),
578
- show_cam=_as_bool(payload.get("show_cam"), True),
579
  mask_sky=_as_bool(payload.get("mask_sky"), False),
580
  target_dir=str(temp_dir),
581
  prediction_mode=payload.get("prediction_mode", "Predicted Pointmap"),
@@ -837,6 +844,30 @@ def _execute_job(job_type: str, payload: Mapping[str, Any], handler: JobHandler)
837
  "scene_id": scene_id,
838
  }
839
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
  runtime.db.upsert_job(
841
  job_id=job_id,
842
  job_type=job_type,
@@ -862,6 +893,7 @@ def _execute_job(job_type: str, payload: Mapping[str, Any], handler: JobHandler)
862
  with tempfile.TemporaryDirectory(prefix=f"stream3r_{job_id}_") as tmp_dir:
863
  temp_path = Path(tmp_dir)
864
  frame_records = _collect_frames(runtime, scene_id, payload, temp_path)
 
865
  cache_path = temp_path / runtime.settings.session_cache_filename if streaming else None
866
 
867
  tracker = ProgressTracker(runtime, job_meta)
@@ -874,6 +906,7 @@ def _execute_job(job_type: str, payload: Mapping[str, Any], handler: JobHandler)
874
  progress_cb=tracker,
875
  window_size=window_size if streaming and mode == "window" else None,
876
  )
 
877
 
878
  session_settings = _prepare_session_settings(
879
  payload,
@@ -895,6 +928,7 @@ def _execute_job(job_type: str, payload: Mapping[str, Any], handler: JobHandler)
895
  session_settings=session_settings,
896
  temp_dir=temp_path,
897
  )
 
898
 
899
  except Exception as exc:
900
  error_text = traceback.format_exc()
@@ -914,9 +948,17 @@ def _execute_job(job_type: str, payload: Mapping[str, Any], handler: JobHandler)
914
  "error": str(exc),
915
  },
916
  )
917
- logger.exception("Job %s failed", job_id)
 
 
 
 
 
 
918
  raise
919
 
 
 
920
  runtime.db.upsert_job(
921
  job_id=job_id,
922
  job_type=job_type,
 
15
  from datetime import datetime, timezone
16
  from pathlib import Path
17
  from contextlib import nullcontext
18
+ from time import perf_counter
19
  from typing import Any, Callable, Mapping
20
 
21
  import numpy as np
 
472
  ) -> str | None:
473
  if cache_path is None or not cache_path.exists():
474
  return None
475
+ if not runtime.settings.upload_session_cache:
476
+ logger.debug(
477
+ "Skipping session cache upload for scene %s (disabled via settings)",
478
+ scene_id,
479
+ )
480
+ return None
481
  key = runtime.storage.build_key(
482
  scene_id,
483
  runtime.settings.models_dir,
 
582
  filter_by_frames=payload.get("frame_filter", "All"),
583
  mask_black_bg=_as_bool(payload.get("mask_black_bg"), False),
584
  mask_white_bg=_as_bool(payload.get("mask_white_bg"), False),
585
+ show_cam=_as_bool(payload.get("show_cam"), False),
586
  mask_sky=_as_bool(payload.get("mask_sky"), False),
587
  target_dir=str(temp_dir),
588
  prediction_mode=payload.get("prediction_mode", "Predicted Pointmap"),
 
844
  "scene_id": scene_id,
845
  }
846
 
847
+ logger.info(
848
+ "Job %s (%s) started for scene %s (timeout=%s)",
849
+ job_id,
850
+ job_type,
851
+ scene_id,
852
+ applied_timeout or desired_timeout or "default",
853
+ )
854
+
855
+ start_time = perf_counter()
856
+ last_time = start_time
857
+
858
+ def log_progress(stage: str) -> None:
859
+ nonlocal last_time
860
+ now = perf_counter()
861
+ logger.info(
862
+ "Job %s (%s): %s [delta=%.2fs total=%.2fs]",
863
+ job_id,
864
+ job_type,
865
+ stage,
866
+ now - last_time,
867
+ now - start_time,
868
+ )
869
+ last_time = now
870
+
871
  runtime.db.upsert_job(
872
  job_id=job_id,
873
  job_type=job_type,
 
893
  with tempfile.TemporaryDirectory(prefix=f"stream3r_{job_id}_") as tmp_dir:
894
  temp_path = Path(tmp_dir)
895
  frame_records = _collect_frames(runtime, scene_id, payload, temp_path)
896
+ log_progress(f"collected frames ({len(frame_records)} items)")
897
  cache_path = temp_path / runtime.settings.session_cache_filename if streaming else None
898
 
899
  tracker = ProgressTracker(runtime, job_meta)
 
906
  progress_cb=tracker,
907
  window_size=window_size if streaming and mode == "window" else None,
908
  )
909
+ log_progress(f"inference completed ({inference.total_frames} frames)")
910
 
911
  session_settings = _prepare_session_settings(
912
  payload,
 
928
  session_settings=session_settings,
929
  temp_dir=temp_path,
930
  )
931
+ log_progress("artifact generation completed")
932
 
933
  except Exception as exc:
934
  error_text = traceback.format_exc()
 
948
  "error": str(exc),
949
  },
950
  )
951
+ logger.exception(
952
+ "Job %s (%s) failed after %.2fs: %s",
953
+ job_id,
954
+ job_type,
955
+ perf_counter() - start_time,
956
+ exc,
957
+ )
958
  raise
959
 
960
+ log_progress("job finished")
961
+
962
  runtime.db.upsert_job(
963
  job_id=job_id,
964
  job_type=job_type,
tests/test_voxel_reduction.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+ import trimesh
4
+
5
+ from stream3r.utils.visual_utils import (
6
+ density_filter_points,
7
+ o3d_outlier_filter,
8
+ predictions_to_glb,
9
+ voxel_reduce,
10
+ )
11
+
12
+
13
+ def test_voxel_reduce_merges_points():
14
+ points = np.array(
15
+ [
16
+ [0.0, 0.0, 0.0],
17
+ [0.01, 0.0, 0.0],
18
+ [0.2, 0.0, 0.0],
19
+ ],
20
+ dtype=np.float32,
21
+ )
22
+ colors = np.array(
23
+ [
24
+ [255, 0, 0],
25
+ [255, 0, 0],
26
+ [0, 255, 0],
27
+ ],
28
+ dtype=np.uint8,
29
+ )
30
+ conf = np.array([1.0, 3.0, 1.0], dtype=np.float32)
31
+
32
+ reduced_points, reduced_colors, support = voxel_reduce(points, colors, conf, voxel_size=0.05)
33
+
34
+ assert reduced_points.shape[0] == 2
35
+ assert reduced_colors.shape[0] == 2
36
+ assert np.isclose(support.sum(), conf.sum(), atol=1e-3)
37
+ merged_idx = int(np.argmin(reduced_points[:, 0]))
38
+ assert pytest.approx(reduced_points[merged_idx, 0], rel=1e-3) == 0.0075
39
+ assert reduced_colors[merged_idx, 0] >= 250
40
+ assert reduced_colors[1 - merged_idx, 1] >= 250
41
+
42
+
43
+ def test_o3d_outlier_filter_removes_outlier():
44
+ pytest.importorskip("open3d", reason="Open3D not installed")
45
+
46
+ cluster = np.zeros((20, 3), dtype=np.float32)
47
+ outlier = np.array([[1.0, 1.0, 1.0]], dtype=np.float32)
48
+ points = np.vstack([cluster, outlier])
49
+ colors = np.tile(np.array([[128, 128, 255]], dtype=np.uint8), (points.shape[0], 1))
50
+
51
+ filtered_points, _ = o3d_outlier_filter(
52
+ points,
53
+ colors,
54
+ voxel_size=0.05,
55
+ radius_mult=2.5,
56
+ nb_points=4,
57
+ nb_neighbors=8,
58
+ std_ratio=1.0,
59
+ )
60
+
61
+ assert filtered_points.shape[0] < points.shape[0]
62
+
63
+
64
+ def test_predictions_to_glb_voxel_pipeline():
65
+ world_points = np.array(
66
+ [
67
+ [
68
+ [[0.0, 0.0, 0.0], [0.02, 0.0, 0.0]],
69
+ [[0.0, 0.02, 0.0], [0.02, 0.02, 0.0]],
70
+ ]
71
+ ],
72
+ dtype=np.float32,
73
+ )
74
+ predictions = {
75
+ "world_points": world_points,
76
+ "world_points_conf": np.ones((1, 2, 2), dtype=np.float32),
77
+ "world_points_from_depth": world_points,
78
+ "depth_conf": np.ones((1, 2, 2), dtype=np.float32),
79
+ "images": np.ones((1, 2, 2, 3), dtype=np.float32) * 0.5,
80
+ "extrinsic": np.array(
81
+ [
82
+ [
83
+ [1.0, 0.0, 0.0, 0.0],
84
+ [0.0, 1.0, 0.0, 0.0],
85
+ [0.0, 0.0, 1.0, 0.0],
86
+ ]
87
+ ],
88
+ dtype=np.float32,
89
+ ),
90
+ }
91
+
92
+ scene = predictions_to_glb(
93
+ predictions,
94
+ conf_thres=0.0,
95
+ voxel_size=0.05,
96
+ o3d_denoise=False,
97
+ density_filter=False,
98
+ )
99
+
100
+ assert isinstance(scene, trimesh.Scene)
101
+ assert len(scene.geometry) >= 1
102
+
103
+
104
+ def test_density_filter_points_removes_isolated_samples():
105
+ rng = np.random.default_rng(0)
106
+ cluster = rng.normal(scale=0.005, size=(50, 3)).astype(np.float32)
107
+ outliers = np.array([[0.4, 0.4, 0.4], [0.6, 0.6, 0.6]], dtype=np.float32)
108
+ points = np.vstack([cluster, outliers])
109
+ colors = np.tile(np.array([[200, 200, 200]], dtype=np.uint8), (points.shape[0], 1))
110
+
111
+ filtered_points, _ = density_filter_points(points, colors, radius=0.05, min_neighbors=5)
112
+
113
+ assert filtered_points.shape[0] < points.shape[0]
114
+ assert np.all(filtered_points.max(axis=0) < 0.2)