fix: validate empty point-cloud inference inputs
5 files modified
1 files added
| | |
| | | It exposes a polling job, in-page PLY prediction preview, classified LAS, |
| | | class-count CSV, summary JSON, metadata, and weight download. Browser paths, |
| | | arbitrary model paths, and XYZ-only inputs are rejected. Outputs are review |
| | | candidates, not assets or inspection conclusions. |
| | | candidates, not assets or inspection conclusions. Empty files, incomplete |
| | | LAS/LAZ headers, and LAS/LAZ headers with zero point records are rejected |
| | | before a CPU job is created; the script repeats the zero-readable-point check |
| | | for valid-looking files. |
| | | - Verified 2026-08-24: the real model |
| | | `semantic-model-20260824-023343-571983` applied to the 400,000-point RGB |
| | | annotation PLY through the CLI in 1.608 seconds and through the HTTP upload / |
| | |
| | | definition used during training without forcing the old scene coordinates onto a |
| | | new site. |
| | | |
| | | The console rejects an empty point-cloud file and a LAS/LAZ file shorter than a |
| | | valid LAS header before creating an inference job. The script also rejects a |
| | | syntactically readable LAS/LAZ file with zero actual points. Select the complete |
| | | export rather than a placeholder, manifest, or partially downloaded tile. |
| | | |
| | | ```powershell |
| | | .\.venvs\05-3d-pointcloud\Scripts\python.exe ` |
| | | .\capabilities\05-3d-pointcloud\apply_pointcloud_semantic_model.py ` |
| | |
| | | raise ValueError("LAS/LAZ input has no RGB dimensions; this model requires observed RGB.") |
| | | xyz = np.column_stack((las.x, las.y, las.z)).astype(np.float32) |
| | | raw_rgb = np.column_stack((las.red, las.green, las.blue)).astype(np.float32) |
| | | if len(xyz) < 1 or xyz.shape != raw_rgb.shape: |
| | | raise ValueError( |
| | | "LAS/LAZ 中没有可读取的点。请选择完整的点云导出文件,不要上传空文件或未完成下载的分块。" |
| | | ) |
| | | maximum = float(raw_rgb.max()) |
| | | if maximum <= 0: |
| | | raise ValueError("LAS/LAZ input RGB values are all zero; observed RGB is required.") |
| New file |
| | |
| | | from __future__ import annotations |
| | | |
| | | import sys |
| | | import tempfile |
| | | import unittest |
| | | from pathlib import Path |
| | | |
| | | import laspy |
| | | |
| | | sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| | | from apply_pointcloud_semantic_model import load_cloud # noqa: E402 |
| | | |
| | | |
| | | class ApplyPointCloudSemanticModelTests(unittest.TestCase): |
| | | def test_empty_las_reports_a_clear_input_error(self) -> None: |
| | | with tempfile.TemporaryDirectory() as directory: |
| | | source = Path(directory) / "empty.las" |
| | | laspy.LasData(laspy.LasHeader(point_format=3, version="1.2")).write(source) |
| | | |
| | | with self.assertRaisesRegex(ValueError, "没有可读取的点"): |
| | | load_cloud(source) |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | unittest.main() |
| | |
| | | return dict(value) if value else None |
| | | |
| | | |
| | | def validate_pointcloud_model_input(path: Path) -> None: |
| | | """Reject obviously incomplete uploads before consuming a CPU inference job.""" |
| | | size = path.stat().st_size |
| | | if size < 1: |
| | | raise ApiError("点云文件为空。请选择包含 RGB 点位的完整点云导出文件。") |
| | | if path.suffix.lower() not in {".las", ".laz"}: |
| | | return |
| | | if size < 227: |
| | | raise ApiError("LAS/LAZ 文件不完整(小于有效 LAS 文件头)。请选择完整点云文件,不要上传空白或未完成下载的分块。") |
| | | with path.open("rb") as stream: |
| | | header = stream.read(375) |
| | | if len(header) < 227 or header[:4] != b"LASF": |
| | | raise ApiError("LAS/LAZ 文件头无效。请选择完整的 LAS/LAZ 点云导出文件。") |
| | | header_size = int.from_bytes(header[94:96], "little") |
| | | if header_size < 227 or header_size > size: |
| | | raise ApiError("LAS/LAZ 文件头不完整。请选择完整的 LAS/LAZ 点云导出文件。") |
| | | version_minor = header[25] |
| | | point_count_offset, point_count_size = (247, 8) if version_minor >= 4 else (107, 4) |
| | | if len(header) < point_count_offset + point_count_size: |
| | | raise ApiError("LAS/LAZ 文件头不完整。请选择完整的 LAS/LAZ 点云导出文件。") |
| | | point_count = int.from_bytes(header[point_count_offset:point_count_offset + point_count_size], "little") |
| | | if point_count < 1: |
| | | raise ApiError("LAS/LAZ 文件没有点记录。请选择包含实际 RGB 点位的完整点云文件,而不是空分块。") |
| | | |
| | | |
| | | def execute_pointcloud_inference_job(root: Path, job_id: str, model: Path, source: Path, output: Path) -> None: |
| | | with POINTCLOUD_INFERENCE_JOBS_LOCK: |
| | | POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "running", "stage": "inference", "startedAt": datetime.now(UTC).isoformat()}) |
| | |
| | | suffixes = {".ply", ".pcd", ".xyz", ".xyzn", ".xyzrgb", ".las", ".laz"} |
| | | if Path(name).suffix.lower() not in suffixes: |
| | | raise ApiError("Model inference requires a PLY, PCD, XYZ, LAS, or LAZ point cloud.") |
| | | validate_pointcloud_model_input(staged_path) |
| | | python = self.root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe" |
| | | if not python.is_file(): |
| | | raise ApiError("3D point-cloud virtual environment is unavailable. Run the capability setup first.") |
| | |
| | | self.assertEqual(result["name"], "corridor.las") |
| | | self.assertEqual(staged.read_bytes(), b"las-bytes") |
| | | |
| | | def test_model_inference_rejects_empty_and_incomplete_las_uploads(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | empty = root / "empty.ply" |
| | | empty.write_bytes(b"") |
| | | with self.assertRaisesRegex(MODULE.ApiError, "文件为空"): |
| | | MODULE.validate_pointcloud_model_input(empty) |
| | | |
| | | incomplete = root / "tile_000_000.las" |
| | | incomplete.write_bytes(b"las-bytes") |
| | | with self.assertRaisesRegex(MODULE.ApiError, "文件不完整"): |
| | | MODULE.validate_pointcloud_model_input(incomplete) |
| | | |
| | | empty_las = root / "empty.las" |
| | | header = bytearray(227) |
| | | header[:4] = b"LASF" |
| | | header[24:26] = bytes((1, 2)) |
| | | header[94:96] = (227).to_bytes(2, "little") |
| | | empty_las.write_bytes(header) |
| | | with self.assertRaisesRegex(MODULE.ApiError, "没有点记录"): |
| | | MODULE.validate_pointcloud_model_input(empty_las) |
| | | |
| | | def test_photo_reconstruction_request_requires_three_to_thirty_photos(self) -> None: |
| | | handler = self.make_handler() |
| | | with self.assertRaisesRegex(MODULE.ApiError, "at least three"): |