From 7862c05a3163ea0ce4f05412cce125aa35a021fb Mon Sep 17 00:00:00 2001 From: Hyungtae Lim Date: Thu, 21 May 2026 11:30:27 +0900 Subject: [PATCH 1/4] fix(patchwork): three deviations vs original Patchwork (gated by compile flags) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cpp/patchwork/src/patchwork.cpp deviates from the original ~/git/patchwork in three places that together cost about 2.3 F1 on KITTI 00-10 under the Patchwork++ paper evaluation protocol with paper-matched parameters (uprightness_thr=0.707, using_global_thr=false). The fixes are gated behind PW_FIX_1, PW_FIX_2, PW_FIX_3 compile flags so an ablation can measure each independently: PW_FIX_1 — elevation_thr is GROUND-frame; subtract sensor_height to get the sensor-frame cutoff (the YAML in ~/git/patchwork documents this explicitly; the reimpl was using the raw value). PW_FIX_2 — plane-distance comparison: original uses uncentred `normal . p` against `th_dist - d_`. The reimpl uses the centred `normal . (p - mean)` against the same threshold, which shifts the cutoff by an extra `-2 * d_` (about 3.2 m on KITTI ground). PW_FIX_3 — tier index for elevation/flatness uses the GLOBAL ring index across all zones, not the local ring-or-zone-id collapse. Full sweep on KITTI 00-10 (23,201 frames), Patchwork++ paper protocol, paper params: current main : P=89.70 R=98.49 F1=93.73 fix/performance : P=94.64 R=97.58 F1=96.02 ~/git/patchwork : P=94.38 R=97.90 F1=96.05 (reference) paper Table I : P=94.23 R=97.62 F1=95.88 Build a variant with: pip install --no-build-isolation --force-reinstall \\ --config-settings=cmake.define.PW_FIX_1=ON \\ --config-settings=cmake.define.PW_FIX_2=ON \\ --config-settings=cmake.define.PW_FIX_3=ON \\ ./python/ See url-kaist/patchwork-plusplus#89 for the full ablation report. Also adds: - python/examples/evaluate_semantickitti.py — KITTI eval driver with both Patchwork-paper and Patchwork++ paper protocols (#87, #88). - python/examples/aggregate_original_patchwork.py — aggregates the per-sequence txt files from ~/git/patchwork's eval mode. - scripts/ablation_sweep.sh — the 5-variant ablation runner. --- cpp/patchwork/CMakeLists.txt | 8 + cpp/patchwork/src/patchwork.cpp | 37 ++- .../examples/aggregate_original_patchwork.py | 95 ++++++ python/examples/compare_patchwork.py | 81 +++++ python/examples/evaluate_semantickitti.py | 284 ++++++++++++++++++ scripts/ablation_sweep.sh | 47 +++ scripts/run_original_patchwork.sh | 28 ++ 7 files changed, 576 insertions(+), 4 deletions(-) create mode 100644 python/examples/aggregate_original_patchwork.py create mode 100644 python/examples/compare_patchwork.py create mode 100644 python/examples/evaluate_semantickitti.py create mode 100755 scripts/ablation_sweep.sh create mode 100755 scripts/run_original_patchwork.sh diff --git a/cpp/patchwork/CMakeLists.txt b/cpp/patchwork/CMakeLists.txt index daf5a3a..56a1e90 100644 --- a/cpp/patchwork/CMakeLists.txt +++ b/cpp/patchwork/CMakeLists.txt @@ -12,6 +12,14 @@ target_include_directories(${CLASSIC_TARGET} PUBLIC $ ) target_link_libraries(${CLASSIC_TARGET} Eigen3::Eigen ground_seg_cores) + +# Performance-fix ablation toggles (see fix/performance branch) +foreach(_fix PW_FIX_1 PW_FIX_2 PW_FIX_3) + if (${_fix}) + target_compile_definitions(${CLASSIC_TARGET} PUBLIC ${_fix}) + message(STATUS "Patchwork: ${_fix} = ON") + endif() +endforeach() add_library(${PARENT_PROJECT_NAME}::${CLASSIC_TARGET} ALIAS ${CLASSIC_TARGET}) install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/include/ diff --git a/cpp/patchwork/src/patchwork.cpp b/cpp/patchwork/src/patchwork.cpp index fafb31e..3802594 100644 --- a/cpp/patchwork/src/patchwork.cpp +++ b/cpp/patchwork/src/patchwork.cpp @@ -150,11 +150,30 @@ PatchStatus PatchWork::determine_gle_status(int zone_idx, return PatchStatus::TooTilted; } - // The first elevation_thr.size() tiers get tier-specific elevation/flatness thresholds. + // ----------------------------------------------------------------- + // FIX 3: tier mapping uses the GLOBAL ring index across all zones + // (matches the original Patchwork repo). Without FIX_3, the original + // (buggy) per-zone collapse is used. + // ----------------------------------------------------------------- +#ifdef PW_FIX_3 + int tier = ring_idx; + for (int z = 0; z < zone_idx; ++z) tier += params_.num_rings_each_zone[z]; +#else const int tier = (zone_idx == 0) ? ring_idx : zone_idx; +#endif + if (tier < static_cast(params_.elevation_thr.size())) { const double mean_z = feature.mean_(2); - if (mean_z > params_.elevation_thr[tier]) { + // ----------------------------------------------------------------- + // FIX 1: elevation_thr is GROUND-frame; subtract sensor_height to + // get the sensor-frame cutoff (matches original Patchwork). + // ----------------------------------------------------------------- +#ifdef PW_FIX_1 + const double elev_cut = -params_.sensor_height + params_.elevation_thr[tier]; +#else + const double elev_cut = params_.elevation_thr[tier]; +#endif + if (mean_z > elev_cut) { // Recoverable if the patch is very flat if (feature.singular_values_(2) < params_.flatness_thr[tier]) { return PatchStatus::FlatEnough; @@ -204,8 +223,18 @@ void PatchWork::perform_regionwise_segmentation(int zone_idx, std::vector nonground; for (const auto& p : sorted) { Eigen::Vector3f v(p.x, p.y, p.z); - const float distance = feature.normal_.dot(v - feature.mean_); - if (distance < feature.th_dist_d_) { + // ----------------------------------------------------------------- + // FIX 2: original Patchwork uses uncentred normal . p compared + // to (th_dist - d_). Without FIX_2 we keep the buggy centred + // distance compared to (th_dist - d_), which over-shifts by ~ d_. + // ----------------------------------------------------------------- +#ifdef PW_FIX_2 + const float signed_dist = feature.normal_.dot(v); // = normal . p + if (signed_dist < feature.th_dist_d_) { // th_dist_d_ = th_dist - d_ +#else + const float signed_dist = feature.normal_.dot(v - feature.mean_); + if (signed_dist < feature.th_dist_d_) { +#endif ground.push_back(p); } else { nonground.push_back(p); diff --git a/python/examples/aggregate_original_patchwork.py b/python/examples/aggregate_original_patchwork.py new file mode 100644 index 0000000..aa4eb13 --- /dev/null +++ b/python/examples/aggregate_original_patchwork.py @@ -0,0 +1,95 @@ +"""Aggregate per-sequence txt outputs produced by the original Patchwork +(`~/git/patchwork`) eval mode into a summary CSV matching the format produced +by `evaluate_semantickitti.py`. + +Per-frame row format in ~/patchwork/.txt: + Legacy (6 cols): frame_idx, time, precision, recall, precision_naive, recall_naive + New (8 cols): + precision_pp, recall_pp (Patchwork++ paper protocol, Sec IV.A) +""" + +import argparse +import csv +import os + +import numpy as np + + +def f1(p: float, r: float) -> float: + return 2.0 * p * r / (p + r) if (p + r) > 0 else 0.0 + + +def aggregate_seq(path: str, protocol: str = "patchwork") -> dict: + rows = np.loadtxt(path, delimiter=",") + if rows.ndim == 1: + rows = rows.reshape(1, -1) + if protocol == "patchworkpp": + if rows.shape[1] < 8: + raise ValueError(f"{path} only has {rows.shape[1]} columns; rerun patchwork " + "with the patched main.cpp/utils.hpp to produce the 8-col format.") + p = rows[:, 6] + r = rows[:, 7] + p_n = rows[:, 6] + r_n = rows[:, 7] + else: + p = rows[:, 2] + r = rows[:, 3] + p_n = rows[:, 4] + r_n = rows[:, 5] + return { + "num_frames": int(rows.shape[0]), + "precision": float(p.mean()), + "recall": float(r.mean()), + "f1": float(np.mean([f1(pi, ri) for pi, ri in zip(p, r)])), + "precision_naive": float(p_n.mean()), + "recall_naive": float(r_n.mean()), + "f1_naive": float(np.mean([f1(pi, ri) for pi, ri in zip(p_n, r_n)])), + } + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--txt_dir", default=os.path.expanduser("~/patchwork")) + ap.add_argument("--output_csv", + default="/home/url/git/patchwork-plusplus/summary_patchwork_original.csv") + ap.add_argument("--seqs", nargs="+", + default=[f"{i:02d}" for i in range(11)]) + ap.add_argument("--protocol", choices=["patchwork", "patchworkpp"], default="patchwork") + args = ap.parse_args() + + rows: list[tuple[str, dict]] = [] + for seq in args.seqs: + path = os.path.join(args.txt_dir, f"{seq}.txt") + if not os.path.isfile(path): + print(f"[WARN] missing {path}") + continue + rows.append((seq, aggregate_seq(path, args.protocol))) + + avg = { + k: float(np.mean([m[k] for _, m in rows])) + for k in ("precision", "recall", "f1", + "precision_naive", "recall_naive", "f1_naive") + } + avg["num_frames"] = int(sum(m["num_frames"] for _, m in rows)) + rows.append(("Avg", avg)) + + with open(args.output_csv, "w", newline="") as fp: + w = csv.writer(fp) + w.writerow(["seq", "num_frames", "precision", "recall", "f1", + "precision_naive", "recall_naive", "f1_naive"]) + for name, m in rows: + w.writerow([name, m["num_frames"], + f"{m['precision']:.4f}", f"{m['recall']:.4f}", f"{m['f1']:.4f}", + f"{m['precision_naive']:.4f}", f"{m['recall_naive']:.4f}", + f"{m['f1_naive']:.4f}"]) + + header = f"{'seq':>5} | {'frames':>6} | {'P':>6} {'R':>6} {'F1':>6}" + print(header) + print("-" * len(header)) + for name, m in rows: + print(f"{name:>5} | {m['num_frames']:>6d} | " + f"{m['precision']:6.2f} {m['recall']:6.2f} {m['f1']:6.2f}") + print(f"\nWritten {args.output_csv}") + + +if __name__ == "__main__": + main() diff --git a/python/examples/compare_patchwork.py b/python/examples/compare_patchwork.py new file mode 100644 index 0000000..d62a0f8 --- /dev/null +++ b/python/examples/compare_patchwork.py @@ -0,0 +1,81 @@ +"""Side-by-side comparison of Patchwork vs Patchwork++ evaluation summaries. + +Reads two summary CSVs produced by `evaluate_semantickitti.py --method ...` +and prints a merged table with per-sequence and average metrics, plus the +paper's Table I baseline for context. +""" + +import argparse +import csv +import os + + +PAPER_TABLE_I = { + "Patchwork [1]": {"precision": 94.23, "recall": 97.62, "f1": 95.88}, + "Patchwork++ w/o TGR": {"precision": 94.98, "recall": 97.64, "f1": 96.28}, + "Patchwork++ (Ours)": {"precision": 94.92, "recall": 98.18, "f1": 96.51}, +} + + +def load_summary(path: str) -> dict[str, dict[str, float]]: + out: dict[str, dict[str, float]] = {} + with open(path, "r", newline="") as fp: + reader = csv.DictReader(fp) + for row in reader: + out[row["seq"]] = { + "num_frames": int(row["num_frames"]), + "precision": float(row["precision"]), + "recall": float(row["recall"]), + "f1": float(row["f1"]), + } + return out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--patchwork_csv", default="summary_patchwork.csv") + ap.add_argument("--patchworkpp_csv", default="summary_patchworkpp.csv") + args = ap.parse_args() + + pw = load_summary(args.patchwork_csv) + pp = load_summary(args.patchworkpp_csv) + + seqs = sorted([s for s in pw.keys() if s != "Avg"]) + ["Avg"] + + header = ( + f"{'seq':>5} | " + f"{'Patchwork P / R / F1':>26} | " + f"{'Patchwork++ P / R / F1':>26} | " + f"{'Delta F1':>8}" + ) + print(header) + print("-" * len(header)) + for s in seqs: + a, b = pw[s], pp[s] + df1 = b["f1"] - a["f1"] + line = ( + f"{s:>5} | " + f"{a['precision']:6.2f} / {a['recall']:6.2f} / {a['f1']:6.2f} | " + f"{b['precision']:6.2f} / {b['recall']:6.2f} / {b['f1']:6.2f} | " + f"{df1:+7.2f}" + ) + print(line) + + print() + print("Paper Table I baseline (SemanticKITTI, paper's eval protocol):") + for k, v in PAPER_TABLE_I.items(): + print(f" {k:<22} P={v['precision']:.2f} R={v['recall']:.2f} F1={v['f1']:.2f}") + + print() + print( + "Note: this port follows the original Patchwork repo's " + "`calculate_precision_recall` which counts VEGETATION (label 70) " + "as ground iff z < -1.30 m. The Patchwork++ paper instead excludes " + "VEGETATION from the evaluation entirely (Sec. IV.A), so absolute " + "numbers differ slightly. The relative ordering " + "Patchwork++ > Patchwork on F1 is preserved." + ) + + +if __name__ == "__main__": + main() diff --git a/python/examples/evaluate_semantickitti.py b/python/examples/evaluate_semantickitti.py new file mode 100644 index 0000000..cf2e1e4 --- /dev/null +++ b/python/examples/evaluate_semantickitti.py @@ -0,0 +1,284 @@ +"""Evaluate Patchwork++ ground segmentation on SemanticKITTI sequences 00-10. + +Reports per-sequence Precision / Recall / F1 (both outlier-aware and naive +variants) and a macro-average across sequences. Mirrors the metric definition +in `patchwork/include/patchwork/utils.hpp::calculate_precision_recall`. +""" + +import argparse +import csv +import os +import sys +import time + +import numpy as np +import pypatchworkpp + +GROUND_CLASSES_PATCHWORK = np.array([40, 44, 48, 49, 60, 70, 72], dtype=np.uint16) +GROUND_CLASSES_PP = np.array([40, 44, 48, 49, 60, 72], dtype=np.uint16) +OUTLIER_CLASSES = np.array([0, 1], dtype=np.uint16) +VEGETATION = 70 +SENSOR_HEIGHT = 1.73 +VEGETATION_THR = -SENSOR_HEIGHT * 3.0 / 4.0 +DEFAULT_SEQS = [f"{i:02d}" for i in range(11)] + + +def is_ground_mask_patchwork(labels: np.ndarray, z: np.ndarray) -> np.ndarray: + """Patchwork paper / original repo protocol: VEGETATION counts as ground iff z < -1.30 m.""" + in_ground = np.isin(labels, GROUND_CLASSES_PATCHWORK) + veg_mask = labels == VEGETATION + veg_keep = veg_mask & (z < VEGETATION_THR) + return (in_ground & ~veg_mask) | veg_keep + + +def is_ground_mask_pp(labels: np.ndarray) -> np.ndarray: + """Patchwork++ paper protocol: VEGETATION excluded; ground = road/parking/sidewalk/other_ground/lane_marking/terrain.""" + return np.isin(labels, GROUND_CLASSES_PP) + + +def is_excluded_mask_pp(labels: np.ndarray) -> np.ndarray: + """Patchwork++ paper protocol: VEGETATION & UNLABELED/OUTLIER are excluded from eval.""" + return (labels == VEGETATION) | np.isin(labels, OUTLIER_CLASSES) + + +def is_outlier_mask(labels: np.ndarray) -> np.ndarray: + return np.isin(labels, OUTLIER_CLASSES) + + +def f1(p: float, r: float) -> float: + return 2.0 * p * r / (p + r) if (p + r) > 0 else 0.0 + + +def load_bin(path: str) -> np.ndarray: + return np.fromfile(path, dtype=np.float32).reshape(-1, 4) + + +def load_label(path: str, num_points: int) -> np.ndarray: + raw = np.fromfile(path, dtype=np.uint32) + if raw.size != num_points: + raise ValueError( + f"Label count {raw.size} != point count {num_points} for {path}" + ) + return (raw & 0xFFFF).astype(np.uint16) + + +def build_estimator(method: str, sensor_height: float): + if method == "patchworkpp": + params = pypatchworkpp.Parameters() + params.sensor_height = sensor_height + params.verbose = False + return pypatchworkpp.patchworkpp(params) + if method == "patchwork": + params = pypatchworkpp.PatchworkParams() + params.sensor_height = sensor_height + # Paper / original-repo config (config/velodyne64.yaml): + # uprightness_thr: 0.707 (45°) [binding default is 0.5 (60°)] + # using_global_elevation: false [binding default is true] + # The paper explicitly states theta_tau = 45° (Sec III.B). + params.uprightness_thr = 0.707 + params.using_global_thr = False + params.verbose = False + return pypatchworkpp.patchwork(params) + raise ValueError(f"Unknown method '{method}'. Use 'patchwork' or 'patchworkpp'.") + + +def evaluate_sequence( + seq_dir: str, + estimator, + max_frames: int | None, + verbose: bool, + eval_protocol: str = "patchwork", +) -> dict: + velodyne_dir = os.path.join(seq_dir, "velodyne") + labels_dir = os.path.join(seq_dir, "labels") + if not os.path.isdir(velodyne_dir) or not os.path.isdir(labels_dir): + raise FileNotFoundError(f"Missing velodyne/ or labels/ in {seq_dir}") + + bin_files = sorted(f for f in os.listdir(velodyne_dir) if f.endswith(".bin")) + if max_frames is not None: + bin_files = bin_files[:max_frames] + + precisions, recalls = [], [] + precisions_naive, recalls_naive = [], [] + f1s, f1s_naive = [], [] + skipped = 0 + + for i, fname in enumerate(bin_files): + cloud = load_bin(os.path.join(velodyne_dir, fname)) + label_path = os.path.join(labels_dir, fname.replace(".bin", ".label")) + labels = load_label(label_path, cloud.shape[0]) + z = cloud[:, 2] + + estimator.estimateGround(cloud) + gnd_idx = np.asarray(estimator.getGroundIndices(), dtype=np.int64) + if gnd_idx.size == 0: + skipped += 1 + continue + + gnd_labels = labels[gnd_idx] + gnd_z = z[gnd_idx] + + if eval_protocol == "patchworkpp": + # Patchwork++ paper Sec IV.A: VEGETATION (+UNLABELED/OUTLIER) excluded entirely. + gt_ground = is_ground_mask_pp(labels) + num_ground_gt = int(gt_ground.sum()) + est_excluded = is_excluded_mask_pp(gnd_labels) + num_ground_est = int((~est_excluded).sum()) + num_TP = int(is_ground_mask_pp(gnd_labels).sum()) + denom = num_ground_est + p_n = p = 100.0 * num_TP / denom if denom > 0 else 0.0 + r_n = r = 100.0 * num_TP / num_ground_gt if num_ground_gt > 0 else 0.0 + else: + num_ground_gt = int(is_ground_mask_patchwork(labels, z).sum()) + num_ground_est = int(gnd_idx.size) + num_TP = int(is_ground_mask_patchwork(gnd_labels, gnd_z).sum()) + num_outliers_est = int(is_outlier_mask(gnd_labels).sum()) + denom = num_ground_est - num_outliers_est + if num_ground_gt == 0 or denom <= 0 or num_ground_est == 0: + skipped += 1 + continue + p = 100.0 * num_TP / denom + r = 100.0 * num_TP / num_ground_gt + p_n = 100.0 * num_TP / num_ground_est + r_n = r + + precisions.append(p) + recalls.append(r) + f1s.append(f1(p, r)) + precisions_naive.append(p_n) + recalls_naive.append(r_n) + f1s_naive.append(f1(p_n, r_n)) + + if verbose: + print( + f" [{i:05d}] P={p:6.2f} R={r:6.2f} F1={f1s[-1]:6.2f} " + f"| P_naive={p_n:6.2f} R_naive={r_n:6.2f} F1_naive={f1s_naive[-1]:6.2f}" + ) + + if not precisions: + raise RuntimeError(f"No valid frames evaluated in {seq_dir}") + + return { + "num_frames": len(precisions), + "skipped": skipped, + "precision": float(np.mean(precisions)), + "recall": float(np.mean(recalls)), + "f1": float(np.mean(f1s)), + "precision_naive": float(np.mean(precisions_naive)), + "recall_naive": float(np.mean(recalls_naive)), + "f1_naive": float(np.mean(f1s_naive)), + } + + +def print_table(rows: list[tuple[str, dict]]) -> None: + header = ( + f"{'seq':>5} | {'frames':>6} | {'P':>6} {'R':>6} {'F1':>6} " + f"| {'P_n':>6} {'R_n':>6} {'F1_n':>6}" + ) + print(header) + print("-" * len(header)) + for name, m in rows: + print( + f"{name:>5} | {m['num_frames']:>6d} | " + f"{m['precision']:6.2f} {m['recall']:6.2f} {m['f1']:6.2f} | " + f"{m['precision_naive']:6.2f} {m['recall_naive']:6.2f} {m['f1_naive']:6.2f}" + ) + + +def write_csv(path: str, rows: list[tuple[str, dict]]) -> None: + with open(path, "w", newline="") as fp: + writer = csv.writer(fp) + writer.writerow( + ["seq", "num_frames", "precision", "recall", "f1", + "precision_naive", "recall_naive", "f1_naive"] + ) + for name, m in rows: + writer.writerow([ + name, + m["num_frames"], + f"{m['precision']:.4f}", + f"{m['recall']:.4f}", + f"{m['f1']:.4f}", + f"{m['precision_naive']:.4f}", + f"{m['recall_naive']:.4f}", + f"{m['f1_naive']:.4f}", + ]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--dataset_path", + default="/home/url/datasets/kitti/dataset/sequences", + help="Path containing seq subdirectories (00, 01, ...).", + ) + parser.add_argument( + "--seqs", + nargs="+", + default=DEFAULT_SEQS, + help="Sequence ids to evaluate (default: 00..10).", + ) + parser.add_argument("--output_csv", default="summary.csv") + parser.add_argument( + "--method", + choices=["patchwork", "patchworkpp"], + default="patchworkpp", + help="Which ground segmenter to evaluate.", + ) + parser.add_argument("--sensor_height", type=float, default=SENSOR_HEIGHT) + parser.add_argument( + "--eval_protocol", + choices=["patchwork", "patchworkpp"], + default="patchwork", + help="patchwork = original Patchwork repo protocol " + "(VEGETATION-low-z counted as ground). " + "patchworkpp = Patchwork++ paper Sec IV.A " + "(VEGETATION excluded from eval entirely).", + ) + parser.add_argument("--max_frames", type=int, default=None, + help="Limit frames per sequence (smoke testing).") + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + estimator = build_estimator(args.method, args.sensor_height) + print(f"[method] {args.method}") + + rows: list[tuple[str, dict]] = [] + for seq in args.seqs: + seq_dir = os.path.join(args.dataset_path, seq) + if not os.path.isdir(seq_dir): + print(f"[WARN] Skipping {seq}: {seq_dir} does not exist", file=sys.stderr) + continue + print(f"[seq {seq}] evaluating ...") + t0 = time.time() + metrics = evaluate_sequence(seq_dir, estimator, args.max_frames, args.verbose, args.eval_protocol) + dt = time.time() - t0 + print( + f"[seq {seq}] {metrics['num_frames']} frames " + f"(skipped {metrics['skipped']}) in {dt:.1f}s | " + f"P={metrics['precision']:.2f} R={metrics['recall']:.2f} " + f"F1={metrics['f1']:.2f}" + ) + rows.append((seq, metrics)) + + if not rows: + print("No sequences evaluated.", file=sys.stderr) + sys.exit(1) + + avg = { + key: float(np.mean([m[key] for _, m in rows])) + for key in ("precision", "recall", "f1", + "precision_naive", "recall_naive", "f1_naive") + } + avg["num_frames"] = int(sum(m["num_frames"] for _, m in rows)) + avg["skipped"] = int(sum(m["skipped"] for _, m in rows)) + rows.append(("Avg", avg)) + + print() + print_table(rows) + write_csv(args.output_csv, rows) + print(f"\nSummary written to {args.output_csv}") + + +if __name__ == "__main__": + main() diff --git a/scripts/ablation_sweep.sh b/scripts/ablation_sweep.sh new file mode 100755 index 0000000..9fc71ff --- /dev/null +++ b/scripts/ablation_sweep.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Ablation sweep on the fix/performance branch: build 5 variants of the +# `pypatchworkpp.patchwork` reimplementation and run KITTI 00-10 under +# both `patchwork` and `patchworkpp` evaluation protocols. +set -eo pipefail + +REPO=/home/url/git/patchwork-plusplus +DATASET=/home/url/datasets/kitti/dataset/sequences +OUT=${REPO}/ablation_results +mkdir -p "${OUT}" + +source /home/url/.anaconda3/etc/profile.d/conda.sh +conda activate patchworkpp + +declare -A VARIANTS=( + [baseline]="" + [fix1]="--config-settings=cmake.define.PW_FIX_1=ON" + [fix2]="--config-settings=cmake.define.PW_FIX_2=ON" + [fix3]="--config-settings=cmake.define.PW_FIX_3=ON" + [fixall]="--config-settings=cmake.define.PW_FIX_1=ON --config-settings=cmake.define.PW_FIX_2=ON --config-settings=cmake.define.PW_FIX_3=ON" +) + +for name in baseline fix1 fix2 fix3 fixall; do + defs=${VARIANTS[$name]} + echo "====================================================================" + echo "[${name}] building with: ${defs:-}" + echo "====================================================================" + pip uninstall -y pypatchworkpp >/dev/null 2>&1 || true + pip install --no-build-isolation --force-reinstall \ + ${defs} "${REPO}/python/" 2>&1 | tail -8 + + for proto in patchwork patchworkpp; do + out_csv=${OUT}/${name}_${proto}.csv + echo " [${name}] running KITTI sweep (500 frames/seq) under ${proto} protocol -> ${out_csv}" + python "${REPO}/python/examples/evaluate_semantickitti.py" \ + --method patchwork \ + --eval_protocol ${proto} \ + --dataset_path "${DATASET}" \ + --max_frames 500 \ + --output_csv "${out_csv}" 2>&1 | tail -3 + done +done + +echo "====================================================================" +echo "[ALL DONE] $(date +%T)" +echo "Results under ${OUT}/" +ls -la "${OUT}/" diff --git a/scripts/run_original_patchwork.sh b/scripts/run_original_patchwork.sh new file mode 100755 index 0000000..51b7adc --- /dev/null +++ b/scripts/run_original_patchwork.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Runs the original Patchwork (ROS 2 / ~/git/patchwork) eval mode on each +# SemanticKITTI sequence 00..10. Writes per-sequence txt files to ~/patchwork/. +set -eo pipefail + +SEQUENCES=(00 01 02 03 04 05 06 07 08 09 10) +DATASET_ROOT=/home/url/datasets/kitti/dataset/sequences +WS=/home/url/git/patchwork_ws + +source /home/url/.anaconda3/etc/profile.d/conda.sh +conda activate patchworkpp +source /opt/ros/humble/setup.bash +cd "$WS" +source install/setup.bash + +mkdir -p ~/patchwork +for seq in "${SEQUENCES[@]}"; do + out=~/patchwork/${seq}.txt + if [[ -f "$out" ]]; then rm "$out"; fi + echo "[seq $seq] starting at $(date +%T)" + ros2 launch patchwork evaluate.launch.yaml \ + start_rviz:=false \ + dataset_path:="${DATASET_ROOT}/${seq}" \ + > /tmp/patchwork_seq_${seq}.log 2>&1 || true + lines=$(wc -l < "$out") + echo "[seq $seq] done; $lines lines in $out" +done +echo "[ALL DONE] $(date +%T)" From ed0fdaf4eff7717db49bb040afb720449e128d4f Mon Sep 17 00:00:00 2001 From: Hyungtae Lim Date: Thu, 21 May 2026 11:49:42 +0900 Subject: [PATCH 2/4] fix(patchwork): apply the three Patchwork deviations unconditionally + USAGE.md The previous commit on this branch gated the three fixes behind compile flags (PW_FIX_1/2/3) for ablation. Measurement showed all three are needed to match the original Patchwork; this commit removes the toggles and applies the fixes unconditionally: 1. tier index = global ring index across all zones (not the previous `(zone==0) ? ring : zone` collapse). 2. elevation_thr is converted to the sensor frame by subtracting sensor_height (matches the comment in the original config/velodyne64.yaml). 3. plane-distance comparison uses uncentred `normal . p` so the cutoff of `th_dist - d_` actually represents "signed distance < th_dist" (the previous centred form shifted the cutoff by an extra -d_). The ablation runner (scripts/ablation_sweep.sh) is removed since it referenced the compile flags. Adds USAGE.md covering: - the two SemanticKITTI evaluation protocols (Patchwork vs Patchwork++ paper) and which to pick when reproducing each paper's Table I, - parameter tuning order (sensor_height -> uprightness_thr -> range bounds -> plane-fit thresholds -> elevation_thr / RNR / RVPF / TGR), - a copy-pasteable command to reproduce Patchwork++ Table I. See url-kaist/patchwork-plusplus#87, #88, #89. --- USAGE.md | 154 ++++++++++++++++++++++++++++++++ cpp/patchwork/CMakeLists.txt | 8 -- cpp/patchwork/src/patchwork.cpp | 40 +++------ scripts/ablation_sweep.sh | 47 ---------- 4 files changed, 166 insertions(+), 83 deletions(-) create mode 100644 USAGE.md delete mode 100755 scripts/ablation_sweep.sh diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..f316d68 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,154 @@ +# Patchwork++ — Usage Guide + +This guide covers three things that are easy to get wrong on first contact: + +1. [Choosing a SemanticKITTI evaluation protocol](#1-evaluation-protocols) — picks the right ground-truth definition so numbers match the paper. +2. [Tuning the algorithm parameters for your sensor](#2-parameter-tuning) — what each knob does and which ones to touch first when results look bad. +3. [Reproducing the paper's Table I](#3-reproducing-paper-table-i) — a one-command sweep. + +For a quick start, jump to [§3](#3-reproducing-paper-table-i). + +--- + +## 1. Evaluation protocols + +The Patchwork and Patchwork++ papers use **different** ground-truth definitions on SemanticKITTI. The eval driver `python/examples/evaluate_semantickitti.py` supports both via `--eval_protocol {patchwork, patchworkpp}`. + +### A. `--eval_protocol patchwork` (original Patchwork repo protocol) + +- **Ground GT** = `{ROAD (40), PARKING (44), SIDEWALK (48), OTHER_GROUND (49), LANE_MARKING (60), VEGETATION (70, only if z < −1.30 m), TERRAIN (72)}` +- VEGETATION above −1.30 m → counts as **non-ground**. +- UNLABELED (0) and OUTLIER (1) can be excluded from the precision denominator (`--consider_outliers`, default on). +- Source: Patchwork paper, *"the points annotated with selected classes, i.e. lane marking, road, parking, sidewalk, other ground, vegetation, and terrain, are considered to be ground-truth ground points... only points whose z values are below −1.3 m with respect to the sensor frame are considered as ground truths"*. + +Use this when comparing against numbers from the **original Patchwork paper / `url-kaist/patchwork`**. + +### B. `--eval_protocol patchworkpp` (Patchwork++ paper Table I protocol — DEFAULT for reproducing the Patchwork++ paper) + +- **Ground GT** = `{ROAD, PARKING, SIDEWALK, OTHER_GROUND, LANE_MARKING, TERRAIN}` — **no VEGETATION**. +- VEGETATION, UNLABELED, OUTLIER are **fully excluded** from both numerator and denominator. +- Source: Patchwork++ paper Sec. IV.A, *"unlike our previous work, the points labeled as vegetation are not evaluated as ground nor non-ground points exceptionally because it is impractical to regard the vegetation as a single ground or non-ground class. Note that this implies the points labeled as vegetation are only excluded in the evaluation step; the points are still included in the input point cloud"*. + +Use this when comparing against numbers from the **Patchwork++ paper**. + +### Why it matters + +Same Patchwork++ inference, KITTI 00–10 macro average, two protocols: + +| Protocol | Precision | Recall | F1 | +|---|---|---|---| +| `--eval_protocol patchwork` | 93.72 | 92.33 | 92.87 | +| `--eval_protocol patchworkpp` | **95.55** | **97.16** | **96.29** | +| Patchwork++ paper Table I | 94.92 | 98.18 | 96.51 | + +3.4 F1 difference, entirely from the protocol switch. If your reproduction is 3 F1 low, this is almost certainly the cause. + +--- + +## 2. Parameter tuning + +If results look wrong on a new sensor (Velodyne 16/32, Ouster 64/128, Livox, etc.), tune in roughly this order. Defaults are in `cpp/patchworkpp/include/patchwork/patchworkpp.h` (Patchwork++) and `cpp/patchwork/include/patchwork/patchwork.h` (classic Patchwork). + +### Step 1 — Get `sensor_height` right (the most important parameter) + +`sensor_height` is the **height of the LiDAR origin above the ground** when the vehicle is stationary on flat pavement. + +- KITTI / HDL-64E on a passenger car: `1.723` m (default). +- Ouster OS0-128 on a UGV: typically 0.6–1.0 m. +- Livox Mid-360 on a quadruped: typically 0.3–0.6 m. + +**How to tell it is wrong**: precision is fine on far-range patches but ground points near the sensor are split between ground and non-ground in a striped pattern. The elevation threshold and adaptive seed selection both reference `sensor_height` directly. + +If you cannot measure it, leave `ATAT_ON = true` and the All-Terrain Automatic heighT estimator will recover it from the first scan. + +### Step 2 — Tune `uprightness_thr` for the surface roughness you expect + +`uprightness_thr` is the cosine of the maximum tilt angle accepted for a patch's normal vs. world-up. Higher = stricter. + +| Setting | Max tilt | When to use | +|---|---|---| +| 0.5 | ~60° | very rough terrain, off-road; library default for Patchwork++ | +| **0.707** | **~45°** | **Patchwork paper / on-road / structured driving — recommended for KITTI** | +| 0.866 | ~30° | flat indoor floors, parking lots | + +If precision is low and you see ramps, low walls, or curbs being labelled as ground: increase to 0.707 or 0.866. +If recall is low on hills, ramps, or rough pavement: lower to 0.5 or 0.4. + +### Step 3 — Set range bounds `min_range` / `max_range` + +- `min_range` (default 2.7 m): exclude the cone right under the sensor where points are noisy (vehicle body, multipath). Decrease only if your sensor mount is very low. +- `max_range` (default 80.0 m): the cap of the concentric zone model. The CZM zone sizes scale with this; resetting it requires rebuilding `min_ranges`. For most rotating LiDARs leave at 80 m. + +### Step 4 — Tune the plane-fit thresholds + +- `th_seeds` (default 0.5 m): a point is an LPR seed if its `z` is within `th_seeds` of the lowest-z mean. Larger → more seeds → tolerates undulating ground but admits more outliers. Lower for very flat scenes. +- `th_dist` (default 0.125 m): distance from the fitted plane below which a point counts as ground. **This is the single biggest precision/recall knob.** Increase (0.15–0.2 m) on rough/sloped ground if recall is low. Decrease (0.05–0.1 m) on parking lots if precision is low. +- `num_iter` (default 3): plane-refit iterations per patch. 2–3 is enough; more is wasted CPU. + +### Step 5 — `elevation_thr` and `flatness_thr` (only if you've changed the sensor mount or scene scale) + +`elevation_thr = {0.523, 0.746, 0.879, 1.125}` are the **ground-frame** height cutoffs for the four closest CZM rings — patches whose mean is more than this above the ground are rejected unless their planarity (`flatness_thr`) saves them. The library converts these to sensor-frame internally by subtracting `sensor_height`. + +Rule of thumb: scale them ∝ `expected_terrain_undulation / 1.723 m` if your sensor sits lower or higher than KITTI. Most users do **not** need to touch these. + +### Step 6 — Patchwork++ extras (`pypatchworkpp.patchworkpp` only) + +- `enable_RNR` (default true) — Reflected Noise Removal. Turn off only if your sensor has very clean returns near the bottom rings (most rotating LiDARs need it on). +- `enable_RVPF` (default true) — Region-wise Vertical Plane Fitting. Helps on retaining walls / curbs. Keep on. +- `enable_TGR` (default true) — Temporal Ground Revert. Reverts FN under-segmentation. Keep on. +- `RNR_intensity_thr` (default 0.2) — RNR's intensity gate. Calibrate to your sensor's intensity scale: if intensities are 0–255, set to ~50. + +--- + +## 3. Reproducing paper Table I + +```bash +# 1. Install once +pip install -v ./python/ + +# 2. Reproduce Patchwork++ Table I row on KITTI 00–10 +python python/examples/evaluate_semantickitti.py \ + --method patchworkpp \ + --eval_protocol patchworkpp \ + --dataset_path /path/to/SemanticKITTI/sequences \ + --output_csv summary_patchworkpp.csv +``` + +Expected output (full sweep, 23,201 frames): + +| seq | frames | P | R | F1 | +|---|---|---|---|---| +| Avg | 23201 | **95.55** | **97.16** | **96.29** | + +Paper Table I: P=94.92, R=98.18, F1=96.51 — match within ±0.22 F1. + +### Quick smoke test (3 frames per seq, ~5 s total) + +```bash +python python/examples/evaluate_semantickitti.py \ + --method patchworkpp \ + --eval_protocol patchworkpp \ + --dataset_path /path/to/SemanticKITTI/sequences \ + --max_frames 3 --verbose +``` + +### Apples-to-apples vs. the original Patchwork repo + +```bash +# Compare the in-repo classic Patchwork against the original ROS 2 patchwork +python python/examples/evaluate_semantickitti.py \ + --method patchwork \ + --eval_protocol patchworkpp \ + --dataset_path /path/to/SemanticKITTI/sequences \ + --output_csv summary_patchwork.csv +``` + +`--method patchwork` will be paper-faithful after the fixes on this branch (#89) land — until then it is ~2.3 F1 below the original Patchwork on the same protocol. + +--- + +## See also + +- [`python/examples/demo_visualize.py`](python/examples/demo_visualize.py) — single-frame visualisation. +- [`python/examples/demo_sequential.py`](python/examples/demo_sequential.py) — iterate over a folder of `.bin` files. +- Issues: [#87](https://github.com/url-kaist/patchwork-plusplus/issues/87) (reproduce paper), [#88](https://github.com/url-kaist/patchwork-plusplus/issues/88) (evaluation protocol), [#89](https://github.com/url-kaist/patchwork-plusplus/issues/89) (performance enhancement). diff --git a/cpp/patchwork/CMakeLists.txt b/cpp/patchwork/CMakeLists.txt index 56a1e90..daf5a3a 100644 --- a/cpp/patchwork/CMakeLists.txt +++ b/cpp/patchwork/CMakeLists.txt @@ -12,14 +12,6 @@ target_include_directories(${CLASSIC_TARGET} PUBLIC $ ) target_link_libraries(${CLASSIC_TARGET} Eigen3::Eigen ground_seg_cores) - -# Performance-fix ablation toggles (see fix/performance branch) -foreach(_fix PW_FIX_1 PW_FIX_2 PW_FIX_3) - if (${_fix}) - target_compile_definitions(${CLASSIC_TARGET} PUBLIC ${_fix}) - message(STATUS "Patchwork: ${_fix} = ON") - endif() -endforeach() add_library(${PARENT_PROJECT_NAME}::${CLASSIC_TARGET} ALIAS ${CLASSIC_TARGET}) install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/include/ diff --git a/cpp/patchwork/src/patchwork.cpp b/cpp/patchwork/src/patchwork.cpp index 3802594..5afd99c 100644 --- a/cpp/patchwork/src/patchwork.cpp +++ b/cpp/patchwork/src/patchwork.cpp @@ -150,29 +150,18 @@ PatchStatus PatchWork::determine_gle_status(int zone_idx, return PatchStatus::TooTilted; } - // ----------------------------------------------------------------- - // FIX 3: tier mapping uses the GLOBAL ring index across all zones - // (matches the original Patchwork repo). Without FIX_3, the original - // (buggy) per-zone collapse is used. - // ----------------------------------------------------------------- -#ifdef PW_FIX_3 + // Use the GLOBAL ring index across all zones for tier lookup so that + // each of the first elevation_thr.size() rings gets its own threshold, + // matching the original Patchwork. int tier = ring_idx; for (int z = 0; z < zone_idx; ++z) tier += params_.num_rings_each_zone[z]; -#else - const int tier = (zone_idx == 0) ? ring_idx : zone_idx; -#endif if (tier < static_cast(params_.elevation_thr.size())) { const double mean_z = feature.mean_(2); - // ----------------------------------------------------------------- - // FIX 1: elevation_thr is GROUND-frame; subtract sensor_height to - // get the sensor-frame cutoff (matches original Patchwork). - // ----------------------------------------------------------------- -#ifdef PW_FIX_1 + // elevation_thr is GROUND-frame (see config/velodyne64.yaml in the + // original Patchwork repo); convert to the sensor frame by + // subtracting sensor_height. const double elev_cut = -params_.sensor_height + params_.elevation_thr[tier]; -#else - const double elev_cut = params_.elevation_thr[tier]; -#endif if (mean_z > elev_cut) { // Recoverable if the patch is very flat if (feature.singular_values_(2) < params_.flatness_thr[tier]) { @@ -223,18 +212,13 @@ void PatchWork::perform_regionwise_segmentation(int zone_idx, std::vector nonground; for (const auto& p : sorted) { Eigen::Vector3f v(p.x, p.y, p.z); - // ----------------------------------------------------------------- - // FIX 2: original Patchwork uses uncentred normal . p compared - // to (th_dist - d_). Without FIX_2 we keep the buggy centred - // distance compared to (th_dist - d_), which over-shifts by ~ d_. - // ----------------------------------------------------------------- -#ifdef PW_FIX_2 - const float signed_dist = feature.normal_.dot(v); // = normal . p - if (signed_dist < feature.th_dist_d_) { // th_dist_d_ = th_dist - d_ -#else - const float signed_dist = feature.normal_.dot(v - feature.mean_); + // Original Patchwork compares the uncentred normal . p to + // th_dist_d_ = th_dist - d_, which is equivalent to "signed + // distance to plane < th_dist". The previous centred form here + // shifted the cutoff by an extra -d_ ~ |normal . mean|, which on + // KITTI ground is ~1.6 m and effectively disabled the cutoff. + const float signed_dist = feature.normal_.dot(v); if (signed_dist < feature.th_dist_d_) { -#endif ground.push_back(p); } else { nonground.push_back(p); diff --git a/scripts/ablation_sweep.sh b/scripts/ablation_sweep.sh deleted file mode 100755 index 9fc71ff..0000000 --- a/scripts/ablation_sweep.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# Ablation sweep on the fix/performance branch: build 5 variants of the -# `pypatchworkpp.patchwork` reimplementation and run KITTI 00-10 under -# both `patchwork` and `patchworkpp` evaluation protocols. -set -eo pipefail - -REPO=/home/url/git/patchwork-plusplus -DATASET=/home/url/datasets/kitti/dataset/sequences -OUT=${REPO}/ablation_results -mkdir -p "${OUT}" - -source /home/url/.anaconda3/etc/profile.d/conda.sh -conda activate patchworkpp - -declare -A VARIANTS=( - [baseline]="" - [fix1]="--config-settings=cmake.define.PW_FIX_1=ON" - [fix2]="--config-settings=cmake.define.PW_FIX_2=ON" - [fix3]="--config-settings=cmake.define.PW_FIX_3=ON" - [fixall]="--config-settings=cmake.define.PW_FIX_1=ON --config-settings=cmake.define.PW_FIX_2=ON --config-settings=cmake.define.PW_FIX_3=ON" -) - -for name in baseline fix1 fix2 fix3 fixall; do - defs=${VARIANTS[$name]} - echo "====================================================================" - echo "[${name}] building with: ${defs:-}" - echo "====================================================================" - pip uninstall -y pypatchworkpp >/dev/null 2>&1 || true - pip install --no-build-isolation --force-reinstall \ - ${defs} "${REPO}/python/" 2>&1 | tail -8 - - for proto in patchwork patchworkpp; do - out_csv=${OUT}/${name}_${proto}.csv - echo " [${name}] running KITTI sweep (500 frames/seq) under ${proto} protocol -> ${out_csv}" - python "${REPO}/python/examples/evaluate_semantickitti.py" \ - --method patchwork \ - --eval_protocol ${proto} \ - --dataset_path "${DATASET}" \ - --max_frames 500 \ - --output_csv "${out_csv}" 2>&1 | tail -3 - done -done - -echo "====================================================================" -echo "[ALL DONE] $(date +%T)" -echo "Results under ${OUT}/" -ls -la "${OUT}/" From ea66618c163916f7e5072ffe90825446507866c0 Mon Sep 17 00:00:00 2001 From: Hyungtae Lim Date: Thu, 21 May 2026 11:56:41 +0900 Subject: [PATCH 3/4] style: apply pre-commit auto-fixes (black, isort, mdformat) and drop unused import --- USAGE.md | 12 ++-- .../examples/aggregate_original_patchwork.py | 65 ++++++++++++++----- python/examples/compare_patchwork.py | 12 ++-- python/examples/evaluate_semantickitti.py | 62 ++++++++++++------ 4 files changed, 102 insertions(+), 49 deletions(-) diff --git a/USAGE.md b/USAGE.md index f316d68..33ee785 100644 --- a/USAGE.md +++ b/USAGE.md @@ -3,12 +3,12 @@ This guide covers three things that are easy to get wrong on first contact: 1. [Choosing a SemanticKITTI evaluation protocol](#1-evaluation-protocols) — picks the right ground-truth definition so numbers match the paper. -2. [Tuning the algorithm parameters for your sensor](#2-parameter-tuning) — what each knob does and which ones to touch first when results look bad. -3. [Reproducing the paper's Table I](#3-reproducing-paper-table-i) — a one-command sweep. +1. [Tuning the algorithm parameters for your sensor](#2-parameter-tuning) — what each knob does and which ones to touch first when results look bad. +1. [Reproducing the paper's Table I](#3-reproducing-paper-table-i) — a one-command sweep. For a quick start, jump to [§3](#3-reproducing-paper-table-i). ---- +______________________________________________________________________ ## 1. Evaluation protocols @@ -43,7 +43,7 @@ Same Patchwork++ inference, KITTI 00–10 macro average, two protocols: 3.4 F1 difference, entirely from the protocol switch. If your reproduction is 3 F1 low, this is almost certainly the cause. ---- +______________________________________________________________________ ## 2. Parameter tuning @@ -98,7 +98,7 @@ Rule of thumb: scale them ∝ `expected_terrain_undulation / 1.723 m` if your se - `enable_TGR` (default true) — Temporal Ground Revert. Reverts FN under-segmentation. Keep on. - `RNR_intensity_thr` (default 0.2) — RNR's intensity gate. Calibrate to your sensor's intensity scale: if intensities are 0–255, set to ~50. ---- +______________________________________________________________________ ## 3. Reproducing paper Table I @@ -145,7 +145,7 @@ python python/examples/evaluate_semantickitti.py \ `--method patchwork` will be paper-faithful after the fixes on this branch (#89) land — until then it is ~2.3 F1 below the original Patchwork on the same protocol. ---- +______________________________________________________________________ ## See also diff --git a/python/examples/aggregate_original_patchwork.py b/python/examples/aggregate_original_patchwork.py index aa4eb13..adb1eda 100644 --- a/python/examples/aggregate_original_patchwork.py +++ b/python/examples/aggregate_original_patchwork.py @@ -24,8 +24,10 @@ def aggregate_seq(path: str, protocol: str = "patchwork") -> dict: rows = rows.reshape(1, -1) if protocol == "patchworkpp": if rows.shape[1] < 8: - raise ValueError(f"{path} only has {rows.shape[1]} columns; rerun patchwork " - "with the patched main.cpp/utils.hpp to produce the 8-col format.") + raise ValueError( + f"{path} only has {rows.shape[1]} columns; rerun patchwork " + "with the patched main.cpp/utils.hpp to produce the 8-col format." + ) p = rows[:, 6] r = rows[:, 7] p_n = rows[:, 6] @@ -49,11 +51,14 @@ def aggregate_seq(path: str, protocol: str = "patchwork") -> dict: def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--txt_dir", default=os.path.expanduser("~/patchwork")) - ap.add_argument("--output_csv", - default="/home/url/git/patchwork-plusplus/summary_patchwork_original.csv") - ap.add_argument("--seqs", nargs="+", - default=[f"{i:02d}" for i in range(11)]) - ap.add_argument("--protocol", choices=["patchwork", "patchworkpp"], default="patchwork") + ap.add_argument( + "--output_csv", + default="/home/url/git/patchwork-plusplus/summary_patchwork_original.csv", + ) + ap.add_argument("--seqs", nargs="+", default=[f"{i:02d}" for i in range(11)]) + ap.add_argument( + "--protocol", choices=["patchwork", "patchworkpp"], default="patchwork" + ) args = ap.parse_args() rows: list[tuple[str, dict]] = [] @@ -66,28 +71,54 @@ def main(): avg = { k: float(np.mean([m[k] for _, m in rows])) - for k in ("precision", "recall", "f1", - "precision_naive", "recall_naive", "f1_naive") + for k in ( + "precision", + "recall", + "f1", + "precision_naive", + "recall_naive", + "f1_naive", + ) } avg["num_frames"] = int(sum(m["num_frames"] for _, m in rows)) rows.append(("Avg", avg)) with open(args.output_csv, "w", newline="") as fp: w = csv.writer(fp) - w.writerow(["seq", "num_frames", "precision", "recall", "f1", - "precision_naive", "recall_naive", "f1_naive"]) + w.writerow( + [ + "seq", + "num_frames", + "precision", + "recall", + "f1", + "precision_naive", + "recall_naive", + "f1_naive", + ] + ) for name, m in rows: - w.writerow([name, m["num_frames"], - f"{m['precision']:.4f}", f"{m['recall']:.4f}", f"{m['f1']:.4f}", - f"{m['precision_naive']:.4f}", f"{m['recall_naive']:.4f}", - f"{m['f1_naive']:.4f}"]) + w.writerow( + [ + name, + m["num_frames"], + f"{m['precision']:.4f}", + f"{m['recall']:.4f}", + f"{m['f1']:.4f}", + f"{m['precision_naive']:.4f}", + f"{m['recall_naive']:.4f}", + f"{m['f1_naive']:.4f}", + ] + ) header = f"{'seq':>5} | {'frames':>6} | {'P':>6} {'R':>6} {'F1':>6}" print(header) print("-" * len(header)) for name, m in rows: - print(f"{name:>5} | {m['num_frames']:>6d} | " - f"{m['precision']:6.2f} {m['recall']:6.2f} {m['f1']:6.2f}") + print( + f"{name:>5} | {m['num_frames']:>6d} | " + f"{m['precision']:6.2f} {m['recall']:6.2f} {m['f1']:6.2f}" + ) print(f"\nWritten {args.output_csv}") diff --git a/python/examples/compare_patchwork.py b/python/examples/compare_patchwork.py index d62a0f8..8686830 100644 --- a/python/examples/compare_patchwork.py +++ b/python/examples/compare_patchwork.py @@ -7,13 +7,11 @@ import argparse import csv -import os - PAPER_TABLE_I = { - "Patchwork [1]": {"precision": 94.23, "recall": 97.62, "f1": 95.88}, - "Patchwork++ w/o TGR": {"precision": 94.98, "recall": 97.64, "f1": 96.28}, - "Patchwork++ (Ours)": {"precision": 94.92, "recall": 98.18, "f1": 96.51}, + "Patchwork [1]": {"precision": 94.23, "recall": 97.62, "f1": 95.88}, + "Patchwork++ w/o TGR": {"precision": 94.98, "recall": 97.64, "f1": 96.28}, + "Patchwork++ (Ours)": {"precision": 94.92, "recall": 98.18, "f1": 96.51}, } @@ -64,7 +62,9 @@ def main(): print() print("Paper Table I baseline (SemanticKITTI, paper's eval protocol):") for k, v in PAPER_TABLE_I.items(): - print(f" {k:<22} P={v['precision']:.2f} R={v['recall']:.2f} F1={v['f1']:.2f}") + print( + f" {k:<22} P={v['precision']:.2f} R={v['recall']:.2f} F1={v['f1']:.2f}" + ) print() print( diff --git a/python/examples/evaluate_semantickitti.py b/python/examples/evaluate_semantickitti.py index cf2e1e4..77a58ae 100644 --- a/python/examples/evaluate_semantickitti.py +++ b/python/examples/evaluate_semantickitti.py @@ -189,20 +189,30 @@ def write_csv(path: str, rows: list[tuple[str, dict]]) -> None: with open(path, "w", newline="") as fp: writer = csv.writer(fp) writer.writerow( - ["seq", "num_frames", "precision", "recall", "f1", - "precision_naive", "recall_naive", "f1_naive"] + [ + "seq", + "num_frames", + "precision", + "recall", + "f1", + "precision_naive", + "recall_naive", + "f1_naive", + ] ) for name, m in rows: - writer.writerow([ - name, - m["num_frames"], - f"{m['precision']:.4f}", - f"{m['recall']:.4f}", - f"{m['f1']:.4f}", - f"{m['precision_naive']:.4f}", - f"{m['recall_naive']:.4f}", - f"{m['f1_naive']:.4f}", - ]) + writer.writerow( + [ + name, + m["num_frames"], + f"{m['precision']:.4f}", + f"{m['recall']:.4f}", + f"{m['f1']:.4f}", + f"{m['precision_naive']:.4f}", + f"{m['recall_naive']:.4f}", + f"{m['f1_naive']:.4f}", + ] + ) def main(): @@ -231,12 +241,16 @@ def main(): choices=["patchwork", "patchworkpp"], default="patchwork", help="patchwork = original Patchwork repo protocol " - "(VEGETATION-low-z counted as ground). " - "patchworkpp = Patchwork++ paper Sec IV.A " - "(VEGETATION excluded from eval entirely).", + "(VEGETATION-low-z counted as ground). " + "patchworkpp = Patchwork++ paper Sec IV.A " + "(VEGETATION excluded from eval entirely).", + ) + parser.add_argument( + "--max_frames", + type=int, + default=None, + help="Limit frames per sequence (smoke testing).", ) - parser.add_argument("--max_frames", type=int, default=None, - help="Limit frames per sequence (smoke testing).") parser.add_argument("--verbose", action="store_true") args = parser.parse_args() @@ -251,7 +265,9 @@ def main(): continue print(f"[seq {seq}] evaluating ...") t0 = time.time() - metrics = evaluate_sequence(seq_dir, estimator, args.max_frames, args.verbose, args.eval_protocol) + metrics = evaluate_sequence( + seq_dir, estimator, args.max_frames, args.verbose, args.eval_protocol + ) dt = time.time() - t0 print( f"[seq {seq}] {metrics['num_frames']} frames " @@ -267,8 +283,14 @@ def main(): avg = { key: float(np.mean([m[key] for _, m in rows])) - for key in ("precision", "recall", "f1", - "precision_naive", "recall_naive", "f1_naive") + for key in ( + "precision", + "recall", + "f1", + "precision_naive", + "recall_naive", + "f1_naive", + ) } avg["num_frames"] = int(sum(m["num_frames"] for _, m in rows)) avg["skipped"] = int(sum(m["skipped"] for _, m in rows)) From 8caa016fe6da0ccac1f990ee220f16653b73f608 Mon Sep 17 00:00:00 2001 From: Hyungtae Lim Date: Thu, 21 May 2026 11:58:31 +0900 Subject: [PATCH 4/4] chore(release): v1.3.0 with performance enhancement for pypatchworkpp.patchwork Bumps: - python/pyproject.toml 1.2.0 -> 1.3.0 - cpp/CMakeLists.txt 1.2.0 -> 1.3.0 Adds CHANGELOG.md with a v1.3.0 entry documenting the three performance fixes in cpp/patchwork/src/patchwork.cpp (full sweep: F1 93.73 -> 96.02 under the Patchwork++ paper evaluation protocol; matches the original Patchwork ROS 2 build and paper Table I within paper variance). pypatchworkpp.patchworkpp is unaffected. See #87, #88, #89, #90. --- CHANGELOG.md | 60 +++++++++++++++++++++++++++++++++++++++++++ cpp/CMakeLists.txt | 2 +- python/pyproject.toml | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..32e1507 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +## v1.3.0 + +### Performance enhancement — `pypatchworkpp.patchwork` (classic Patchwork reimpl) + +Three deviations between `cpp/patchwork/src/patchwork.cpp` and the original +Patchwork (`url-kaist/patchwork`) were identified and fixed. With paper-matched +parameters (`uprightness_thr=0.707`, `using_global_thr=false`) on SemanticKITTI +sequences 00–10 (23,201 frames), under the Patchwork++ paper evaluation +protocol (Sec. IV.A — VEGETATION excluded): + +| Configuration | Precision | Recall | F1 | +| --- | --- | --- | --- | +| v1.2.0 (`pypatchworkpp.patchwork`) | 89.70 | 98.49 | 93.73 | +| **v1.3.0 (`pypatchworkpp.patchwork`)** | **94.64** | **97.58** | **96.02** | +| Original Patchwork ROS 2 (reference) | 94.38 | 97.90 | 96.05 | +| Patchwork++ paper Table I, Patchwork \[1\] | 94.23 | 97.62 | 95.88 | + +**+2.29 F1** vs v1.2.0; within ±0.14 F1 of the original Patchwork ROS 2 build +and within paper run-to-run variance of Table I. + +Fixes: + +1. `elevation_thr` is now converted to the sensor frame by subtracting + `sensor_height` (the YAML in the original repo documents these as + ground-frame). Previously the raw value was used, so the elevation gate + effectively never fired for normal ground. +1. Plane-distance comparison now uses uncentred `normal · p` against + `th_dist_d_ = th_dist − d_`, which is equivalent to "signed distance to + plane \< th_dist". The previous centred form shifted the cutoff by an + extra `−d_ ≈ |normal · mean| ≈ 1.6 m` on KITTI ground. +1. The elevation/flatness tier index is now the GLOBAL ring index across all + zones, so each of the first `elevation_thr.size()` rings gets its own + threshold. The previous `(zone==0) ? ring : zone` collapse destroyed the + per-ring tuning for zones 1+. + +`pypatchworkpp.patchworkpp` (the actual Patchwork++) is **unaffected** — +all three deviations were in `cpp/patchwork/`, not `cpp/patchworkpp/`. + +### Documentation + +- Added `USAGE.md` covering (1) the two SemanticKITTI evaluation protocols + and which to pick when reproducing each paper's Table I, (2) the parameter + tuning order for a new sensor, (3) a copy-pasteable command to reproduce + Patchwork++ Table I. +- Added `python/examples/evaluate_semantickitti.py` (a paper-faithful + evaluation driver with `--eval_protocol {patchwork, patchworkpp}`) and + `python/examples/aggregate_original_patchwork.py`. + +### References + +- #87 — How to reproduce the performance on the paper? +- #88 — Explanation about the evaluation protocol +- #89 — Performance enhancement step-by-step ablation +- #90 — PR landing the three fixes + +## v1.2.0 and earlier + +See the git log. diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index bdab7d7..1f76426 100755 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.11) -project(patchworkpp VERSION 1.2.0) +project(patchworkpp VERSION 1.3.0) option(USE_SYSTEM_EIGEN3 "Use system pre-installed Eigen" OFF) option(INCLUDE_CPP_EXAMPLES "Include C++ example codes, which require Open3D for visualization" OFF) diff --git a/python/pyproject.toml b/python/pyproject.toml index 78ebdc2..42fd0ae 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "pypatchworkpp" -version = "1.2.0" +version = "1.3.0" requires-python = ">=3.8" description = "ground segmentation" dependencies = [