From fb8f278b6a124b2021afab30ac62fd57803179ab Mon Sep 17 00:00:00 2001 From: rsasaki0109 Date: Thu, 6 Aug 2026 21:41:32 +0900 Subject: [PATCH] Preserve LAS color as 8-bit pnts RGB in COPC tileset export extract_rgb reads the cloud's u16 red/green/blue fields and shifts each channel right by eight bits into the interleaved 8-bit RGB the pnts feature table expects, when the source COPC carries color. Add a color-bearing COPC round-trip test. --- CHANGELOG.md | 2 + .../src/tiles3d/copc.rs | 88 ++++++++++++++++++- docs/ROADMAP.md | 2 +- notes/2026-08-06_epic147_tiles3d.md | 9 +- 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1031ae..7edaf75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). with one `pnts` tile per node and a per-tile `RTC_CENTER`, without ever materializing the whole cloud. `max_level` bounds the exported hierarchy, and the `tiles3d_copc_export` example converts a `.copc.laz` file directly. + LAS `u16` color channels are preserved as 8-bit `pnts` RGB when the source + carries color fields. - **Epic 147 OGC 3D Tiles 1.1 point-cloud tileset export** (`interchange-tiles3d`): a dependency-light `pnts` binary codec, a validated `tileset.json` model with box bounding volumes and geometric error, and a deterministic octree tileset diff --git a/crates/spatialrust-interchange/src/tiles3d/copc.rs b/crates/spatialrust-interchange/src/tiles3d/copc.rs index 05667d3..c2879ce 100644 --- a/crates/spatialrust-interchange/src/tiles3d/copc.rs +++ b/crates/spatialrust-interchange/src/tiles3d/copc.rs @@ -153,14 +153,14 @@ pub fn export_copc_tileset( let (x, y, z) = cloud.positions3().map_err(|error| { InterchangeError::InvalidConfiguration(format!("COPC schema: {error}")) })?; + let rgb = extract_rgb(&cloud); let mut local_positions = Vec::with_capacity(cloud.len() * 3); for point_index in 0..cloud.len() { local_positions.push(x[point_index] - center[0] as f32); local_positions.push(y[point_index] - center[1] as f32); local_positions.push(z[point_index] - center[2] as f32); } - let table = - PntsFeatureTable { positions: local_positions, rgb: None, rtc_center: Some(center) }; + let table = PntsFeatureTable { positions: local_positions, rgb, rtc_center: Some(center) }; let pnts = encode_pnts(&table)?; let uri = format!("{}.pnts", tiles.len()); let point_count = table.point_count(); @@ -239,6 +239,48 @@ fn bounds_diagonal(min: [f64; 3], max: [f64; 3]) -> f64 { (dx * dx + dy * dy + dz * dz).sqrt() } +/// Extracts interleaved 8-bit RGB from a cloud's color fields when present. +/// +/// LAS/COPC color is stored as `u16` (0–65535); 3D Tiles `pnts` RGB uses +/// `u8` (0–255), so each channel is shifted right by eight bits. Returns +/// `None` when the cloud has no color fields. +fn extract_rgb(cloud: &spatialrust_core::PointCloud) -> Option> { + use spatialrust_core::{FieldSemantic, PointBuffer}; + + let field = |semantic: FieldSemantic| { + let name = match semantic { + FieldSemantic::ColorR => "red", + FieldSemantic::ColorG => "green", + FieldSemantic::ColorB => "blue", + _ => return None, + }; + let buffer = cloud.field(name).ok()?; + match buffer { + PointBuffer::U16(values) => Some(values.as_slice()), + _ => None, + } + }; + + let (r, g, b) = match ( + field(FieldSemantic::ColorR), + field(FieldSemantic::ColorG), + field(FieldSemantic::ColorB), + ) { + (Some(r), Some(g), Some(b)) => (r, g, b), + _ => return None, + }; + if r.len() != cloud.len() || g.len() != cloud.len() || b.len() != cloud.len() { + return None; + } + let mut out = Vec::with_capacity(cloud.len() * 3); + for index in 0..cloud.len() { + out.push((r[index] >> 8) as u8); + out.push((g[index] >> 8) as u8); + out.push((b[index] >> 8) as u8); + } + Some(out) +} + fn io_error(error: std::io::Error) -> InterchangeError { InterchangeError::InvalidConfiguration(format!("tileset IO failure: {error}")) } @@ -249,7 +291,7 @@ mod tests { use crate::tiles3d::pnts::decode_pnts; use crate::tiles3d::tileset::parse_tileset_json; use spatialrust_core::PointCloudBuilder; - use spatialrust_io::{write_copc_file_with_params, CopcWriterParams}; + use spatialrust_io::{write_copc_file, write_copc_file_with_params, CopcWriterParams}; fn dense_grid_cloud(count: usize) -> spatialrust_core::PointCloud { let mut builder = PointCloudBuilder::xyz(); @@ -323,4 +365,44 @@ mod tests { let _ = std::fs::remove_dir_all(&out_dir); let _ = std::fs::remove_file(&copc_path); } + + #[test] + fn preserves_rgb_from_las_color() { + use spatialrust_core::{PointCloudBuilder, StandardSchemas}; + + let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzrgb()); + for index in 0..2_000usize { + let x = (index % 31) as f32 - 15.0; + let y = ((index / 31) % 29) as f32 - 14.0; + let z = ((index / (31 * 29)) % 23) as f32 - 11.0; + let r = ((index % 256) << 8) as f32; + let g = (((index * 7) % 256) << 8) as f32; + let b = (((index * 13) % 256) << 8) as f32; + builder.push_point([x, y, z, r, g, b]).unwrap(); + } + let cloud = builder.build().unwrap(); + + let copc_path = std::env::temp_dir() + .join(format!("spatialrust_tiles3d_copc_rgb_{}.copc.laz", std::process::id())); + write_copc_file(&copc_path, &cloud).unwrap(); + + let out_dir = std::env::temp_dir() + .join(format!("spatialrust_tiles3d_copc_rgb_out_{}", std::process::id())); + let receipt = + export_copc_tileset(&copc_path, &out_dir, &CopcTilesetOptions::default()).unwrap(); + assert_eq!(receipt.point_count, cloud.len() as u64); + + let mut rgb_points = 0usize; + for tile in 0..receipt.tile_count { + let pnts = std::fs::read(out_dir.join(format!("{tile}.pnts"))).unwrap(); + let decoded = decode_pnts(&pnts).unwrap(); + let rgb = decoded.rgb.as_ref().expect("color-bearing COPC must write RGB"); + assert_eq!(rgb.len(), decoded.point_count() * 3); + rgb_points += decoded.point_count(); + } + assert_eq!(rgb_points, cloud.len()); + + let _ = std::fs::remove_dir_all(&out_dir); + let _ = std::fs::remove_file(&copc_path); + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 548e115..ba48cd6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -91,7 +91,7 @@ on `spatialrust-core`, a GPU backend, or serde. | 147B | Complete | `tileset.json` model: box bounding volumes, geometric error, refine, content URIs; strict JSON parse/serialize | `tiles3d` | | 147C | Complete | Deterministic octree tileset builder from interleaved positions with point budgets, per-tile RTC_CENTER, and write receipt | `tiles3d` | | 147D | Complete | Facade `interchange-tiles3d`, runnable example, FEATURE_MATRIX/CHANGELOG/notes | facade | -| 147E | Complete | Bounded COPC → 3D Tiles exporter: `CopcNodeReader` per-node hierarchy walk in `spatialrust-io` plus `export_copc_tileset` in `spatialrust-interchange` | `tiles3d-copc` | +| 147E | Complete | Bounded COPC → 3D Tiles exporter: `CopcNodeReader` per-node hierarchy walk in `spatialrust-io` plus `export_copc_tileset` in `spatialrust-interchange`, with LAS color preserved as 8-bit `pnts` RGB | `tiles3d-copc` | The builder splits octants in a fixed bit order and writes one `pnts` payload per BFS tile id; leaf geometric error is zero and internal errors halve each diff --git a/notes/2026-08-06_epic147_tiles3d.md b/notes/2026-08-06_epic147_tiles3d.md index 7fb5cec..e0e03c4 100644 --- a/notes/2026-08-06_epic147_tiles3d.md +++ b/notes/2026-08-06_epic147_tiles3d.md @@ -31,7 +31,8 @@ differentiator beyond glTF/USDA interchange. - `crates/spatialrust-interchange/src/tiles3d/copc.rs` — `export_copc_tileset` (feature `tiles3d-copc`): mirrors the COPC octree into a `tileset.json` plus one `pnts` tile per node with a per-tile `RTC_CENTER`, bounded by `max_level`, - without materializing the whole cloud. + without materializing the whole cloud. LAS `u16` color channels are shifted + right by eight bits into 8-bit `pnts` RGB when the source has color fields. - `crates/spatialrust-interchange/src/json.rs` — small dependency-free JSON parser/serializer (number text preserved, deterministic output). - Facade features `interchange-tiles3d`/`interchange-tiles3d-copc`, @@ -54,7 +55,7 @@ differentiator beyond glTF/USDA interchange. ## Verification - `cargo test -p spatialrust-interchange --features tiles3d` — 20 tests. -- `cargo test -p spatialrust-interchange --features tiles3d-copc` — 22 tests. +- `cargo test -p spatialrust-interchange --features tiles3d-copc` — 23 tests. - `cargo test -p spatialrust --features "interchange-tiles3d io-pcd" --test tiles3d_smoke`. - Manual run on synthetic PCD: 400,000 points → 8 tiles / 399,989 points after voxel downsample, tileset.json 1,874 B + pnts 4,801,100 B. @@ -64,5 +65,5 @@ differentiator beyond glTF/USDA interchange. ## Next slices Epic 147 is the codec/builder substrate. The COPC exporter (147E) already -streams one node at a time; RGB preservation from LAS color fields and Python -bindings remain as follow-up slices through the meta-crate feature. +streams one node at a time and preserves LAS color; Python bindings remain as a +follow-up slice through the meta-crate feature.