shuishen
16 hours ago 385be2eca72eb3833efa4be0a0088b34e764788a
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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
"""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 copy
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 annotation_schema(annotation: dict[str, Any]) -> dict[int, dict[str, Any]]:
    """Use the immutable revision snapshot, while accepting old six-class revisions."""
    schema = {code: {"code": code, **value} for code, value in CLASS_SCHEMA.items()}
    values = annotation.get("class_schema")
    if not isinstance(values, dict):
        return schema
    for raw_code, raw_value in values.items():
        try:
            code = int(raw_code)
        except (TypeError, ValueError):
            raise SystemExit("Annotation class schema has an invalid class code.")
        if not 1 <= code <= 255:
            raise SystemExit("Annotation class schema has a non-LAS-compatible class code.")
        value = raw_value if isinstance(raw_value, dict) else {}
        inherited = schema.get(code, {})
        key = value.get("key", inherited.get("key"))
        label = value.get("label", inherited.get("label"))
        color = value.get("color", inherited.get("color"))
        if not isinstance(key, str) or not isinstance(label, str) or not isinstance(color, list) or len(color) != 3 or not all(isinstance(item, int) and 0 <= item <= 255 for item in color):
            raise SystemExit("Annotation class schema is incomplete for a labelled class.")
        schema[code] = {"code": code, "key": key, "label": label, "color": color}
    return schema
 
 
def metrics(y_true: np.ndarray, y_pred: np.ndarray, classes: list[int], schema: dict[int, dict[str, Any]]) -> dict[str, Any]:
    names = [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)
    parser.add_argument("--seed", type=int, default=42)
    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.")
    class_schema = annotation_schema(annotation)
    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(args.seed)
    if device_name == "cuda":
        torch.cuda.manual_seed_all(args.seed)
        torch.backends.cudnn.benchmark = False
        torch.backends.cudnn.deterministic = True
    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)
    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)
 
    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)
 
    started = time.perf_counter()
    best_epoch = 0
    best_validation_macro_f1 = -1.0
    best_state: dict[str, torch.Tensor] | None = None
    for epoch in range(1, args.epochs + 1):
        order = np.random.default_rng(args.seed).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()
        validation_metrics = metrics(validation_true, np.asarray([class_codes[value] for value in predict(validation_idx)], dtype=np.int64), class_codes, class_schema)
        macro_f1 = float(validation_metrics["report"]["macro avg"]["f1-score"])
        if macro_f1 > best_validation_macro_f1:
            best_epoch = epoch
            best_validation_macro_f1 = macro_f1
            best_state = copy.deepcopy(model.state_dict())
    if best_state is None:
        raise RuntimeError("Training did not produce a validation checkpoint.")
    model.load_state_dict(best_state)
    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, class_schema), "test": metrics(test_true, test_pred, class_codes, class_schema),
        "normalizer": normalizer, "epochs": args.epochs, "best_epoch": best_epoch, "best_validation_macro_f1": best_validation_macro_f1, "batch_size": args.batch_size, "seed": args.seed,
        "elapsed_seconds": round(time.perf_counter() - started, 3),
        "limitations": ["Metrics cover only human-confirmed points in this annotation revision.", "The retained checkpoint is selected by validation macro F1; the test split remains separate from that selection.", "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, "classes": {str(code): class_schema[code] for code in class_codes}, "normalizer": normalizer, "schema_version": 2}, 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())