shuishen
11 hours ago fbb068ec702338d609c1ca6eddbdb9f182d8f211
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
"""Train a local CPU/GPU portable point-wise semantic classifier from human labels.
 
The training input is an immutable annotation revision created in the workbench.
It deliberately does not read the rule-classification labels as training truth.
"""
 
from __future__ import annotations
 
import argparse
import hashlib
import json
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
 
import numpy as np
import open3d as o3d
import torch
from sklearn.metrics import classification_report, confusion_matrix
 
 
CLASS_SCHEMA = {
    1: {"key": "other_unknown", "label": "Other / unknown", "color": [128, 128, 128]},
    2: {"key": "ground", "label": "Ground", "color": [151, 111, 51]},
    5: {"key": "vegetation", "label": "Vegetation", "color": [59, 163, 87]},
    6: {"key": "building_structure", "label": "Building / structure", "color": [224, 115, 55]},
    15: {"key": "pole_tower", "label": "Pole / tower", "color": [149, 89, 210]},
    16: {"key": "power_line", "label": "Power line", "color": [231, 196, 61]},
}
MIN_POINTS_PER_CLASS = 500
 
 
class PointWiseNet(torch.nn.Module):
    """Small PointNet-style shared MLP; same state dict runs on CPU or CUDA."""
 
    def __init__(self, class_count: int) -> None:
        super().__init__()
        self.layers = torch.nn.Sequential(
            torch.nn.Linear(6, 96), torch.nn.ReLU(), torch.nn.BatchNorm1d(96),
            torch.nn.Linear(96, 128), torch.nn.ReLU(), torch.nn.Dropout(0.15),
            torch.nn.Linear(128, 96), torch.nn.ReLU(), torch.nn.Linear(96, class_count),
        )
 
    def forward(self, values: torch.Tensor) -> torch.Tensor:
        return self.layers(values)
 
 
def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()
 
 
def load_features(source: Path) -> tuple[np.ndarray, dict[str, Any]]:
    cloud = o3d.io.read_point_cloud(str(source))
    xyz = np.asarray(cloud.points, dtype=np.float32)
    rgb = np.asarray(cloud.colors, dtype=np.float32)
    if len(xyz) < 1 or xyz.shape != rgb.shape:
        raise ValueError("Annotation source must be a coloured point-cloud PLY.")
    center = xyz.mean(axis=0)
    scale = float(np.maximum(np.abs(xyz - center).max(), 1e-6))
    return np.column_stack(((xyz - center) / scale, np.clip(rgb, 0.0, 1.0))).astype(np.float32), {
        "xyz_center": center.tolist(), "xyz_scale": scale,
    }
 
 
def spatial_split(xyz: np.ndarray, indices: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Split complete XY blocks so adjacent tower/line points cannot leak."""
    block_size = max(float(np.ptp(xyz[:, 0])) / 8.0, float(np.ptp(xyz[:, 1])) / 8.0, 1.0)
    blocks = np.floor(xyz[indices, :2] / block_size).astype(np.int64)
    hashes = (blocks[:, 0] * 73856093 + blocks[:, 1] * 19349663) % 10
    train, validation, test = indices[hashes < 7], indices[(hashes >= 7) & (hashes < 9)], indices[hashes >= 9]
    if not len(validation) or not len(test) or not len(train):
        ordered = np.sort(indices)
        train, validation, test = ordered[::3], ordered[1::3], ordered[2::3]
    return train, validation, test
 
 
def metrics(y_true: np.ndarray, y_pred: np.ndarray, classes: list[int]) -> dict[str, Any]:
    names = [CLASS_SCHEMA[code]["key"] for code in classes]
    return {
        "class_codes": classes,
        "class_keys": names,
        "confusion_matrix": confusion_matrix(y_true, y_pred, labels=classes).tolist(),
        "report": classification_report(y_true, y_pred, labels=classes, target_names=names, output_dict=True, zero_division=0),
        "sample_count": int(len(y_true)),
    }
 
 
def main() -> int:
    parser = argparse.ArgumentParser(description="Train semantic point-cloud model from human-confirmed labels.")
    parser.add_argument("--annotation", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--device", choices={"auto", "cpu", "cuda"}, default="auto")
    parser.add_argument("--epochs", type=int, default=40)
    parser.add_argument("--batch-size", type=int, default=4096)
    args = parser.parse_args()
    if args.output.exists() and any(args.output.iterdir()):
        raise SystemExit("Output directory must be new or empty.")
    annotation = json.loads(args.annotation.read_text(encoding="utf-8"))
    if annotation.get("schema_version") != 1:
        raise SystemExit("Unsupported annotation schema.")
    source = Path(str(annotation.get("source_path") or ""))
    if not source.is_file() or sha256(source) != annotation.get("source_sha256"):
        raise SystemExit("Annotation source is unavailable or its checksum changed.")
    labels = annotation.get("labels")
    if not isinstance(labels, list):
        raise SystemExit("Annotation labels are missing.")
    features, normalizer = load_features(source)
    source_cloud = o3d.io.read_point_cloud(str(source))
    xyz = np.asarray(source_cloud.points, dtype=np.float32)
    assigned: dict[int, int] = {}
    for item in labels:
        if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item):
            raise SystemExit("Annotation contains an invalid point label.")
        index, code = item
        if not 0 <= index < len(features) or code not in CLASS_SCHEMA:
            raise SystemExit("Annotation contains an out-of-range point label.")
        assigned[index] = code
    indices = np.asarray(sorted(assigned), dtype=np.int64)
    target = np.asarray([assigned[int(index)] for index in indices], dtype=np.int64)
    class_codes = sorted(set(target.tolist()))
    per_class = {code: int((target == code).sum()) for code in class_codes}
    if len(class_codes) < 2 or any(count < MIN_POINTS_PER_CLASS for count in per_class.values()):
        raise SystemExit(f"Need at least two classes and {MIN_POINTS_PER_CLASS} confirmed points per class; current={per_class}.")
    train_idx, validation_idx, test_idx = spatial_split(xyz, indices)
    if min(len(train_idx), len(validation_idx), len(test_idx)) < len(class_codes):
        raise SystemExit("Labels do not cover enough spatial blocks for train/validation/test evaluation.")
    code_to_class = {code: offset for offset, code in enumerate(class_codes)}
    label_by_index = {int(index): code_to_class[int(code)] for index, code in zip(indices, target, strict=True)}
    device_name = "cuda" if args.device == "cuda" or (args.device == "auto" and torch.cuda.is_available()) else "cpu"
    if args.device == "cuda" and not torch.cuda.is_available():
        raise SystemExit("CUDA was requested but is unavailable.")
    device = torch.device(device_name)
    torch.manual_seed(42)
    model = PointWiseNet(len(class_codes)).to(device)
    optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=1e-4)
    weight = torch.tensor([len(train_idx) / max(1, sum(label_by_index[int(i)] == group for i in train_idx)) for group in range(len(class_codes))], dtype=torch.float32, device=device)
    criterion = torch.nn.CrossEntropyLoss(weight=weight)
    train_labels = np.asarray([label_by_index[int(index)] for index in train_idx], dtype=np.int64)
    started = time.perf_counter()
    for _ in range(args.epochs):
        order = np.random.default_rng(42).permutation(len(train_idx))
        model.train()
        for start in range(0, len(order), args.batch_size):
            subset = train_idx[order[start:start + args.batch_size]]
            x = torch.from_numpy(features[subset]).to(device)
            y = torch.from_numpy(np.asarray([label_by_index[int(index)] for index in subset], dtype=np.int64)).to(device)
            optimizer.zero_grad(set_to_none=True)
            criterion(model(x), y).backward()
            optimizer.step()
    def predict(indices_to_predict: np.ndarray) -> np.ndarray:
        model.eval(); parts: list[np.ndarray] = []
        with torch.no_grad():
            for start in range(0, len(indices_to_predict), args.batch_size):
                logits = model(torch.from_numpy(features[indices_to_predict[start:start + args.batch_size]]).to(device))
                parts.append(logits.argmax(dim=1).cpu().numpy())
        return np.concatenate(parts)
    validation_true = np.asarray([target[np.searchsorted(indices, index)] for index in validation_idx], dtype=np.int64)
    test_true = np.asarray([target[np.searchsorted(indices, index)] for index in test_idx], dtype=np.int64)
    validation_pred = np.asarray([class_codes[value] for value in predict(validation_idx)], dtype=np.int64)
    test_pred = np.asarray([class_codes[value] for value in predict(test_idx)], dtype=np.int64)
    all_pred = np.asarray([class_codes[value] for value in predict(np.arange(len(features), dtype=np.int64))], dtype=np.uint8)
    args.output.mkdir(parents=True, exist_ok=True)
    predicted = o3d.geometry.PointCloud()
    predicted.points = o3d.utility.Vector3dVector(xyz)
    predicted.colors = o3d.utility.Vector3dVector(np.asarray([CLASS_SCHEMA[int(code)]["color"] for code in all_pred], dtype=np.float64) / 255.0)
    o3d.io.write_point_cloud(str(args.output / "predicted-semantic-preview.ply"), predicted, write_ascii=False)
    payload = {
        "capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(),
        "model": "PointWiseNet shared MLP (human-confirmed point labels)", "device": device_name,
        "torch_version": torch.__version__, "annotation": str(args.annotation), "source": str(source),
        "source_sha256": annotation["source_sha256"], "classes": {str(code): CLASS_SCHEMA[code] for code in class_codes},
        "label_counts": {str(code): count for code, count in per_class.items()},
        "split_counts": {"train": int(len(train_idx)), "validation": int(len(validation_idx)), "test": int(len(test_idx))},
        "validation": metrics(validation_true, validation_pred, class_codes), "test": metrics(test_true, test_pred, class_codes),
        "normalizer": normalizer, "epochs": args.epochs, "batch_size": args.batch_size,
        "elapsed_seconds": round(time.perf_counter() - started, 3),
        "limitations": ["Metrics cover only human-confirmed points in this annotation revision.", "Spatial blocks reduce leakage but one small source cannot establish field-wide generalization.", "Rule candidate colours were not used as labels or input features."],
    }
    torch.save({"state_dict": model.cpu().state_dict(), "class_codes": class_codes, "normalizer": normalizer, "schema_version": 1}, args.output / "model.pt")
    (args.output / "metrics.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps(payload, ensure_ascii=False))
    return 0
 
 
if __name__ == "__main__":
    raise SystemExit(main())