Perspective projection & multi-segment merge
Summary
Both requests are feasible. (1) Perspective rendering works by transforming points before rasterization: world → camera frame → frustum cull → perspective divide, then reuse the existing PyTorch scatter kernel — the topmost (max-Z) aggregation becomes "nearest to camera" by feeding negated depth as Z. One safety fix is mandatory first: the kernel clamps out-of-canvas points onto border pixels instead of dropping them, which is harmless for top-down bbox framing but smears garbage in perspective. (2) Multi-segment merging already works today — process_tile accepts any list of NPZ files and frames them jointly — as long as all files share one geoshift frame. Confirmed 2026-07-07: segments of a run do share one run3_geoshift.json, so merging needs no code changes; a rebasing safety net stays in the plan as optional. Camera poses are trajectory-derived: ~1 per 10 m along the road axis, ~10 m above it, pitched slightly down — generated by a cameras_from_road_axis helper. A per-pixel depth map ships as a second output (free from the existing max-Z buffer).
Performance requirement (added 2026-07-07): 10+ camera positions per segment, deployed on cheap Azure CPU nodes (the earlier GPU→CPU move stands: fleet of CPU nodes beats fewer GPU nodes on cost). Consequence: the projection must be written in torch, not NumPy, so the whole per-camera loop runs vectorized on whatever device the rasterizer was constructed with — fast on CPU via MKL/OpenMP today, and a device="cuda:0" flag flip away from GPU if the economics ever change. The speed win comes from amortization: stage points + colors on the device once per segment, then each camera costs only a (3×3)·(3×N) matmul + cull + scatter. Per-camera NumPy round-trips are the anti-pattern to avoid.
Feasibility analysis
Q1 — Perspective via camera position: will it work?
Yes, because rasterize_tile is secretly projection-agnostic. Its core is: map (x, y) to a pixel index, resolve collisions by keeping the point with maximum Z (scatter_reduce_ amax). Nothing in that kernel knows about "top-down" — it renders whatever 2D coordinates you hand it. So perspective = a pre-transform:
1. p_cam = R @ (p_world − camera_pos) # extrinsics
2. keep only p_cam.z > z_near # frustum cull (mandatory!)
3. u = fx · x/z + cx ; v = fy · y/z + cy # perspective divide
4. rasterize (u, v, −z) with a pixel-unit framing
→ amax on −z ≡ nearest-to-camera wins → correct occlusion
Three things do not transfer and need handling:
- Clamp → cull.
px.clamp(0, W−1)currently pins every out-of-view point onto the image border. In top-down mode the bbox framing guarantees all points are inside, so it's dead code; in perspective it would paint the entire off-screen cloud onto the edge pixels. Points behind the camera are worse — they project point-mirrored into the image. Both are fixed by filtering in the projection step (kernel unchanged) plus an assert. - Framing semantics.
compute_framingis meters + geoshift + auto-bbox. A perspective view has fixed image size and FOV instead. Build a syntheticTileFramingin pixel units (ppm=1,x_min=0,apply_geoshift=False) — no changes to the dataclass needed. - Metadata. The georeferencing sidecar (geoshift, bbox, ppm) is meaningless for a perspective image. Emit a camera sidecar instead: position, rotation, FOV/intrinsics, image size, near-plane.
Quality caveat Points are 1 px each. Near the camera the cloud spreads out and the image gets holes; far away it's dense. Acceptable for a first version; a splat radius (render each point as a small disk) is the standard follow-up if needed.
Q2 — Two+ segments in one image: possible today?
Yes, mostly already built. process_tile(npz_files=[...]) takes any list of files, concatenates all points, computes one shared framing, and renders one merged image (plus aligned per-file images with save_each). gather_segment_files(from_seg=…, to_seg=…) already collects a segment range — just flatten its dict values into one list:
files_by_seg = gather_segment_files(base, from_seg=16, to_seg=17)
all_files = [f for fs in files_by_seg.values() for f in fs]
merged, per_file, meta = rasterizer.process_tile(all_files, geoshift=gs)
The one gap: Step-3 NPZs are geoshift-relative, and each segment can carry its own run3_geoshift.json (centroid of its trajectory splines). Concatenating two segments with different geoshifts misaligns them by the difference of their origins — silently. The fix is a small rebasing helper: pick a common origin (first segment's geoshift), shift every other segment's points by (own_gs − common_gs) in XY(Z), then proceed exactly as today. If all files come from the same run and share one geoshift JSON, the current code is already correct with zero changes.
Key decisions
| Decision | Choice | Why |
|---|---|---|
| How to add perspective | Projection as a pre-transform + reuse scatter kernel; new perspective.py module, new PerspectiveRasterizer (or method) |
Kernel already does the hard part (vectorized occlusion resolve on CPU/CUDA); no fork of the rasterization code |
| Device strategy for 10+ cameras/segment | Projection written in torch on self.device; points/colors staged once per segment, reused across all cameras; NumPy only at load and final image export |
Same code is fast on CPU nodes (current Azure deployment) and GPU-ready via the existing device param; amortizing the upload/conversion makes per-camera cost ≈ one matmul + scatter |
| Occlusion / depth test | Feed −depth as Z into existing topmost aggregation |
amax(−z) = nearest wins; zero kernel changes; "average" mode stays available too |
| Out-of-view points | Filter in projection step (frustum cull incl. z ≤ z_near); keep kernel clamp as-is |
Kernel untouched → top-down behavior provably unchanged; behind-camera points must die before divide anyway |
| Camera specification | CameraPose dataclass: position + look-at (3×3 rotation kept as escape hatch), vertical FOV, image size, z_near |
Confirmed use case is trajectory-derived poses, which map naturally to position + look-at; FOV+size derive fx/fy/cx/cy |
| Camera generation answered | cameras_from_road_axis(axis_points, spacing=10.0, height=10.0, pitch_down_deg=…) helper: one camera per ~10 m along the road axis, ~10 m above it, looking slightly down along the driving direction |
Per Miro 2026-07-07: cameras come from the road axis, not free-flying; a helper keeps the geometry in one tested place |
| Depth map output answered | Yes — return per-pixel distance alongside RGBA. In perspective mode the max_z buffer holds −depth, so depth = −max_z; empty pixels → 0 (or NaN), decided in implementation |
Nearly free (buffer already computed); useful downstream |
save_each for perspective answered |
Reuse the existing save_each flag; per-file perspective renders share the same camera |
Miro has no preference — cheapest option is reusing the flag, no new config surface |
| Multi-segment merge | Keep process_tile as the merge engine; add rebase_geoshift helper + optional per-file geoshift list |
Merging & shared framing already exist and are tested; only the frame mismatch is new |
| Metadata for perspective | New camera sidecar dict, not framing_metadata |
Geo sidecar consumers must never receive fake geo data from a perspective render |
Phases
Phase 1 — Multi-segment merge: safety net only S · downgraded
Confirmed 2026-07-07: all segments of a run share a single run3_geoshift.json, so cross-segment merging works today with zero code changes — flatten gather_segment_files() output into one process_tile call. This phase shrinks to a guard: accept an optional per-file geoshift sequence, and if they differ, rebase to the first file's frame (p_common = p_own + (gs_own − gs_common)) with a warning log. Implement opportunistically or skip until a mixed-run merge actually appears.
Tests: two synthetic NPZs with different geoshifts whose absolute points touch → merged image shows them adjacent, not offset; single-geoshift path byte-identical to current output.
Phase 2 — Projection module + CameraPose (torch, device-agnostic) M
New file rasterization/perspective.py. The projection math is torch, executed on the rasterizer's device — NumPy appears only at the NPZ boundary. This keeps the per-camera loop free of host↔device transfers and makes CPU execution vectorized (MKL/OpenMP) rather than interpreter-bound:
@dataclass
class CameraPose:
position: np.ndarray # (3,) world (or geoshift-relative) coords
look_at: np.ndarray | None # convenience; else rotation
rotation: np.ndarray | None # (3,3) world→camera
fov_deg: float = 60.0 # vertical FOV
image_size: tuple[int, int] = (1920, 1080)
z_near: float = 0.1
def project_perspective(pts: torch.Tensor, cols: torch.Tensor,
camera: CameraPose):
"""pts/cols already staged on device.
→ (proj_pts (M,3): u, v, −depth), cols (M,4), all on device.
Culls z ≤ z_near and u/v outside the image."""
Unit-testable without any rasterization: a point straight ahead lands at image center; nearer of two collinear points has larger −depth; behind-camera point is culled; off-FOV point is culled. Decide the up-vector convention here (Z-up world, image v grows downward) and document it in the docstring.
Camera generation helper (confirmed use case — poses derived from the road axis):
def cameras_from_road_axis(axis_points, # (M,3) polyline, geoshift-relative
spacing: float = 10.0, # one camera per ~10 m
height: float = 10.0, # meters above the axis
pitch_down_deg: float = 15.0,
**camera_kwargs) -> list[CameraPose]:
# resample axis at `spacing` arc-length; at each sample:
# position = axis_pt + (0, 0, height)
# look_at = point ahead along the axis, dropped by tan(pitch) · dist
# → forward-facing drive-through views, slightly downward
Tests: straight synthetic axis → evenly spaced cameras, all looking forward-down; curved axis → look-at follows the curve. Exact pitch/FOV defaults are tuning knobs, not architecture.
Phase 3 — Batch multi-camera rasterization + camera sidecar M
First a small refactor: extract the scatter core of rasterize_tile into a tensor-native _scatter_rasterize(pts_t, cols_t, W, H) that takes tensors already on device (rasterize_tile becomes a thin NumPy wrapper around it — top-down path byte-identical). Then the batch orchestrator, which is where the 10+-cameras-per-segment speed lives:
def process_perspective(self, npz_files, cameras: list[CameraPose],
color_modes=None, geoshift=None):
# per SEGMENT, once:
# load NPZs, rebase geoshifts (Phase 1), colors_for_mode,
# pts_t / cols_t = torch tensors on self.device
# per CAMERA (cheap loop, all on device):
# proj, cols = project_perspective(pts_t, cols_t, cam)
# img_t, maxz_t = self._scatter_rasterize(proj, cols, W, H)
# images[cam_id] = img_t.cpu().numpy() # only transfer per camera
# depths[cam_id] = (-maxz_t).cpu().numpy() # per-pixel distance, ~free
→ (images: {camera_id: {mode: HxWx4}},
depths: {camera_id: HxW float32}, # 0 where no point landed
camera_metadata list)
Per-camera cost is one (3×3)·(3×N) matmul + divide + mask + scatter — no reload, no color re-conversion, no re-staging. On CPU nodes this is memory-bandwidth-bound and parallelizes across cores via torch; on CUDA the identical code applies. Check the Y-flip: rasterize_tile flips Y (north-up); with image-v-down projection coords, either pre-flip v in the projection or account for it — one deliberate sign choice, locked by a test (point above camera axis appears in the upper image half). Multi-segment perspective comes free via Phase-1 merging. Sidecar per camera: position (absolute world, i.e. geoshift added back), rotation, fov, image size, z_near. Add a benchmark script (N points × C cameras timing on CPU) so regressions are measurable.
Phase 4 — Integration tests, docs, release S
End-to-end test: synthetic scene (colored ground plane + a wall), camera looking at the wall → wall pixels occlude ground behind it; empty-input and all-culled inputs return the transparent background. Update CLAUDE.md/README (two projection modes, geoshift rebasing rules). Version bump + Nexus publish under the Jira tag.
Risks
| Risk | Severity | Mitigation |
|---|---|---|
Behind-camera / off-screen points clamped onto border pixels (kernel clamp) |
high | Frustum + viewport cull in project_perspective before the kernel ever sees the points; dedicated test |
| Silent misalignment when merging segments with different geoshifts | high | Phase 1 rebasing; log a warning if per-file geoshifts differ and no rebase path was taken |
| Holes / see-through foreground near the camera (1-px points) | med | Accept in v1; document; follow-up: splat radius or downscale-render |
| Y-flip / handedness sign error → mirrored images | med | Asymmetric synthetic test scene (distinct colors left/right, up/down) locks orientation |
| float32 precision: absolute-world coords (~10⁶ m) through the projection | med | Do the camera transform in float64 relative coords (subtract geoshift first — Step-3 inputs already are), cast to float32 only after |
| CPU throughput too low for 10+ cameras × tens of millions of points | med | Amortized staging keeps per-camera work minimal; benchmark in Phase 3 sets the baseline; escape hatches: pre-cull points to a per-camera radius before projecting, chunk the point array, or flip device to CUDA — code is already device-agnostic |
| Per-camera host↔device round-trips sneaking in (NumPy in the hot loop) | med | Projection API takes/returns tensors only; single .cpu().numpy() per finished image; benchmark would expose regressions |
| Memory when merging many segments (points concatenated per color mode) | low | Existing pattern already re-concatenates per mode; fine for 2–5 segments, revisit only if whole-run merges appear |
Open questions
- Where does the road axis come from as input — the Step-3 trajectory splines directly (which file/format), or a separate lane/axis product? Determines the input contract of
cameras_from_road_axis. - Exact pitch angle and FOV defaults ("slightly down" ≈ 10–20°?) — tuning knobs; propose 15° pitch, 60° vertical FOV, adjust after first real renders.
- Typical point count per segment and target time budget per segment (10 cameras)? Needed to judge whether CPU-node throughput suffices or per-camera radius pre-culling is required from day one.
- Perspective image resolution — 10+ images/segment at 1920×1080 vs smaller? Output size drives both scatter cost and PNG-encode/IO time.
Resolved 2026-07-07
- Camera source: derived from the road axis — one pose per ~10 m, ~10 m above the axis, looking slightly down. →
cameras_from_road_axishelper in Phase 2. - Segments of one run share a single
run3_geoshift.json. → Phase 1 downgraded to an optional safety net; merging works today. - Depth map: yes, emit per-pixel distance from the existing
max_zbuffer. save_each: no preference — reuse the existing flag for perspective.