shuishen
7 days ago 3435f33697f40c956af9be72700fead2980fea45
fix: validate empty point-cloud inference inputs
5 files modified
1 files added
87 ■■■■■ changed files
PROJECT_CONTEXT.md 5 ●●●● patch | view | raw | blame | history
capabilities/05-3d-pointcloud/README.md 5 ●●●●● patch | view | raw | blame | history
capabilities/05-3d-pointcloud/apply_pointcloud_semantic_model.py 4 ●●●● patch | view | raw | blame | history
capabilities/05-3d-pointcloud/tests/test_apply_pointcloud_semantic_model.py 25 ●●●●● patch | view | raw | blame | history
scripts/serve_workbench_console.py 26 ●●●●● patch | view | raw | blame | history
tests/test_serve_workbench_console.py 22 ●●●●● patch | view | raw | blame | history
PROJECT_CONTEXT.md
@@ -365,7 +365,10 @@
  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 /
capabilities/05-3d-pointcloud/README.md
@@ -396,6 +396,11 @@
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 `
capabilities/05-3d-pointcloud/apply_pointcloud_semantic_model.py
@@ -61,6 +61,10 @@
            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.")
capabilities/05-3d-pointcloud/tests/test_apply_pointcloud_semantic_model.py
New file
@@ -0,0 +1,25 @@
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()
scripts/serve_workbench_console.py
@@ -496,6 +496,31 @@
        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()})
@@ -1709,6 +1734,7 @@
        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.")
tests/test_serve_workbench_console.py
@@ -123,6 +123,28 @@
            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"):