feat(benchmark): summarize SCARED reconstruction depth
All checks were successful
Verification / Is-Buildable (push) Successful in 2m43s

- report valid coverage and depth percentiles even when XYZ ground truth is absent

- accept depth bounds and optional color-mapped PNG output for visual inspection

- retain accuracy metrics when point_cloud.obj is available

- add a batch helper that renders Markdown tables and optional JSON and PNG artifacts

- document benchmark inputs, dataset limits, and visualization tradeoffs
This commit is contained in:
Artur Mukhamadiev 2026-09-12 15:50:42 +03:00
parent 508570cb5c
commit 14ae0a901f
3 changed files with 254 additions and 29 deletions

View File

@ -175,6 +175,8 @@ cloud_point:
num_disparities: 160 # fx ~1024 px, baseline ~4.35 mm -> up to ~160 px
min_depth_m: 0.02 # endoscopic working range: 20 mm .. 300 mm
max_depth_m: 0.30
ply_stride: 1 # option 5: 4 = 16x smaller, block-averaged mesh
wls_filter: false # true = smoother mesh for viewers, less accurate
```
Depth limits are a physical bound on the scene: with this rig depth is
@ -198,16 +200,48 @@ slivers across the surface and makes a correct cloud look like a fan of
rays. Pass `PlyOptions{false, 0.0f, false}` to `write_ply` for a points-only ASCII
file.
**Surface roughness vs accuracy.** SGBM's sub-pixel disparity noise
(~0.2 px, correlated over several pixels) is ~1 mm of depth on this rig,
far more than the 0.07 mm lateral pixel pitch, so a mesh built from the raw
cloud is "hairy": face normals sit a median 45° off the camera axis on
tissue that faces the camera. Colour depth maps hide this; shaded mesh
viewers show it as fuzz. `wls_filter: true` applies OpenCV's edge-aware
WLS disparity filter (needs `opencv_ximgproc`, doubles matching time) and
brings the median normal to ~23° with neighbour depth jumps down from 0.15 mm
to 0.06 mm, but it also costs accuracy on SCARED (keyframe 1: MAE 0.84 →
0.92 mm, within 2 mm 80 → 78 %; dataset_3: MAE 1.75 → 2.20 mm). It is
therefore off by default: use it for pictures, not for measurements. SGBM's
own holes are never filled by the filter.
When the keyframe contains `point_cloud.obj`, run the benchmark to compare the
reconstruction with its pixel-aligned XYZ ground truth:
```bash
./build/src/cloud_point/scared_dataset_benchmark \
/path/to/test_dataset_8/keyframe_0 160
/path/to/dataset_1/keyframe_1 160 [depth.png|-] [min_depth_m max_depth_m]
```
The benchmark reports coverage, component-wise and 3-D errors, threshold
accuracy, and matching/reconstruction timings as JSON. OBJ coordinates are
The benchmark always reports the valid-point fraction, depth percentiles
and matching/reconstruction timings as JSON, and optionally writes a
colour-mapped depth image (third argument, `-` to skip) for visual
inspection. The optional depth range applies the same filter as the CLI's
`cloud_point` section; without it the generic 0.0110 m defaults are used,
which lets a few residual mismatches at metres of depth inflate RMSE. When
`point_cloud.obj` is present it also reports coverage, component-wise and
3-D errors and threshold accuracy. Note that the `test_dataset_*` archives
ship without `point_cloud.obj`; ground truth is only in the full
`dataset_N.zip` archives (1340 GB each). The zips are served with HTTP
range support, so single keyframes can be extracted remotely with Python's
`zipfile` over a seekable HTTP file object instead of downloading the whole
archive.
`scripts/scared_overview.py` runs the benchmark over many keyframes and
prints a Markdown table:
```bash
scripts/scared_overview.py --png-dir out/depth --depth-range 0.02 0.30 \
datasets/scared/dataset_1/keyframe_* datasets/scared/test_dataset_8/keyframe_*
``` OBJ coordinates are
converted from millimetres to metres and rectified into the same left-camera
frame as the reconstructed cloud before evaluation. Reference numbers for
`dataset_1/keyframe_1` with the tuned SGBM configuration: coverage ≈ 0.85,

83
scripts/scared_overview.py Executable file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Run scared_dataset_benchmark over many keyframes and print a Markdown table.
Usage:
scripts/scared_overview.py [--bench PATH] [--disparities N] [--png-dir DIR]
[--json-dir DIR] [--depth-range MIN_M MAX_M]
KEYFRAME_DIR...
Each KEYFRAME_DIR must hold Left_Image.png, Right_Image.png and
endoscope_calibration.yaml. Accuracy columns are filled in only for keyframes
that also contain point_cloud.obj.
"""
import argparse
import json
import os
import subprocess
import sys
def run(bench, kf, disparities, png_dir, json_dir, depth_range):
label = "/".join(kf.rstrip("/").split("/")[-2:])
cmd = [bench, kf, str(disparities)]
if png_dir:
os.makedirs(png_dir, exist_ok=True)
cmd.append(os.path.join(png_dir, label.replace("/", "_") + ".png"))
else:
cmd.append("-")
if depth_range:
cmd += [str(depth_range[0]), str(depth_range[1])]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
print(f"{label}: benchmark failed\n{proc.stderr}", file=sys.stderr)
return label, None
result = json.loads(proc.stdout)
if json_dir:
os.makedirs(json_dir, exist_ok=True)
with open(os.path.join(json_dir, label.replace("/", "_") + ".json"), "w") as f:
json.dump(result, f, indent=2)
return label, result
def fmt_mm(v):
return "" if v is None else f"{v * 1000:.2f}"
def fmt_pct(v):
return "" if v is None else f"{v * 100:.1f}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--bench", default="build-opencv/src/cloud_point/scared_dataset_benchmark")
ap.add_argument("--disparities", type=int, default=160)
ap.add_argument("--png-dir")
ap.add_argument("--json-dir")
ap.add_argument("--depth-range", nargs=2, type=float, metavar=("MIN_M", "MAX_M"),
help="depth filter in metres (default: builder defaults 0.01..10)")
ap.add_argument("keyframes", nargs="+")
args = ap.parse_args()
rows = [run(args.bench, kf, args.disparities, args.png_dir, args.json_dir,
args.depth_range)
for kf in args.keyframes]
print("| keyframe | valid % | z p5 / median / p95 (mm) | match ms | GT | coverage % | MAE3D mm | RMSE3D mm | median mm | <1 mm % | <2 mm % | <5 mm % |")
print("|---|---|---|---|---|---|---|---|---|---|---|---|")
for label, r in rows:
if r is None:
print(f"| {label} | failed | | | | | | | | | | |")
continue
z = f"{r['z_p05_m']*1000:.0f} / {r['z_median_m']*1000:.0f} / {r['z_p95_m']*1000:.0f}"
gt = r.get("has_ground_truth", False)
print("| {} | {} | {} | {:.0f} | {} | {} | {} | {} | {} | {} | {} | {} |".format(
label, fmt_pct(r["valid_fraction"]), z, r["matching_ms"],
"yes" if gt else "no",
fmt_pct(r.get("coverage")), fmt_mm(r.get("mae_3d_m")),
fmt_mm(r.get("rmse_3d_m")), fmt_mm(r.get("median_3d_m")),
fmt_pct(r.get("within_1mm")), fmt_pct(r.get("within_2mm")),
fmt_pct(r.get("within_5mm"))))
if __name__ == "__main__":
main()

View File

@ -1,13 +1,20 @@
/// @file scared_dataset_benchmark.cpp
/// @brief Evaluate the CPU stereo reconstruction against SCARED XYZ ground
/// truth.
/// @brief Reconstruct a SCARED keyframe with the CPU stereo pipeline, report
/// depth statistics and, when point_cloud.obj is present, accuracy against
/// the XYZ ground truth.
#include <algorithm>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
#include <glog/logging.h>
#include <nlohmann/json.hpp>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include "cloud_point/imageFactory.h"
@ -37,34 +44,114 @@ cv::Mat to_gray(const score::ImageRPC &rpc) {
return gray;
}
/// Depth percentiles (metres) over the valid points of a cloud.
struct DepthStats {
std::size_t valid_points{0};
double valid_fraction{0.0};
double z_min_m{0.0}, z_p05_m{0.0}, z_median_m{0.0}, z_p95_m{0.0},
z_max_m{0.0};
};
DepthStats depth_stats(const score::PointCloud &cloud) {
std::vector<float> z;
z.reserve(static_cast<std::size_t>(cloud.width) *
static_cast<std::size_t>(cloud.height));
for (const auto &pt : cloud.valid_points())
z.push_back(pt[2]);
DepthStats stats;
stats.valid_points = z.size();
stats.valid_fraction =
static_cast<double>(z.size()) /
(static_cast<double>(cloud.width) * static_cast<double>(cloud.height));
if (z.empty())
return stats;
std::sort(z.begin(), z.end());
const auto at = [&](double q) {
return static_cast<double>(
z[std::min(z.size() - 1, static_cast<std::size_t>(
q * static_cast<double>(z.size())))]);
};
stats.z_min_m = z.front();
stats.z_p05_m = at(0.05);
stats.z_median_m = at(0.5);
stats.z_p95_m = at(0.95);
stats.z_max_m = z.back();
return stats;
}
/// Colour-mapped depth image (invalid pixels black) for visual inspection.
void write_depth_png(const score::PointCloud &cloud, const std::string &path,
double z_lo, double z_hi) {
cv::Mat depth8(cloud.height, cloud.width, CV_8UC1, cv::Scalar(0));
cv::Mat valid(cloud.height, cloud.width, CV_8UC1, cv::Scalar(0));
const double span = std::max(z_hi - z_lo, 1e-6);
for (int r = 0; r < cloud.height; ++r) {
for (int c = 0; c < cloud.width; ++c) {
const std::size_t idx = (static_cast<std::size_t>(r) *
static_cast<std::size_t>(cloud.width) +
static_cast<std::size_t>(c)) *
3u;
const float z = cloud.data[idx + 2];
if (std::isnan(z))
continue;
const double t = std::clamp((z - z_lo) / span, 0.0, 1.0);
depth8.at<uchar>(r, c) = static_cast<uchar>(255.0 * (1.0 - t));
valid.at<uchar>(r, c) = 255;
}
}
cv::Mat colour;
cv::applyColorMap(depth8, colour, cv::COLORMAP_TURBO);
colour.setTo(cv::Scalar(0, 0, 0), valid == 0);
if (!cv::imwrite(path, colour))
throw std::runtime_error("failed to write " + path);
}
} // namespace
int main(int argc, char *argv[]) {
google::InitGoogleLogging(argv[0]);
FLAGS_alsologtostderr = true;
if (argc < 2 || argc > 3) {
if (argc < 2 || argc > 6 || argc == 5) {
std::cerr << "Usage: " << argv[0]
<< " <keyframe_dir> [num_disparities]\n";
<< " <keyframe_dir> [num_disparities] [depth_png|-] "
"[min_depth_m max_depth_m]\n"
<< " Accuracy metrics are reported when "
"<keyframe_dir>/point_cloud.obj exists;\n"
<< " depth statistics are always reported.\n";
return 1;
}
try {
const std::string keyframe_dir = argv[1];
const int num_disparities = argc == 3 ? std::stoi(argv[2]) : 160;
const int num_disparities = argc >= 3 ? std::stoi(argv[2]) : 160;
std::string depth_png = argc >= 4 ? argv[3] : "";
if (depth_png == "-")
depth_png.clear();
score::PointCloudBuilder::Options depth_range;
if (argc == 6) {
depth_range = score::PointCloudBuilder::Options{std::stof(argv[4]),
std::stof(argv[5])};
}
score::ScaredDatasetLoader dataset(keyframe_dir);
const auto &calibration_rpc = dataset.calibration();
score::ScaredGroundTruthLoader ground_truth(
keyframe_dir,
cv::Size(calibration_rpc.width, calibration_rpc.height));
const cv::Size image_size(calibration_rpc.width,
calibration_rpc.height);
std::optional<score::ScaredGroundTruthLoader> ground_truth;
if (std::filesystem::exists(keyframe_dir + "/point_cloud.obj")) {
ground_truth.emplace(keyframe_dir, image_size);
} else {
LOG(WARNING) << "No point_cloud.obj in " << keyframe_dir
<< "; reporting depth statistics only";
}
const auto calibration =
score::StereoRectifier::Calibration::from_rpc(calibration_rpc);
score::StereoRectifier rectifier(calibration);
auto matcher = score::StereoMatcherFactory::create(
score::StereoAlgorithmType::CPU, num_disparities);
score::PointCloudBuilder builder(rectifier.q());
score::PointCloudBuilder builder(rectifier.q(), depth_range);
const auto pair = dataset.image_pair(0);
const cv::Mat left_gray = to_gray(pair.left);
@ -79,10 +166,10 @@ int main(int argc, char *argv[]) {
const score::PointCloud cloud = builder.build(disparity);
const auto reconstruction_end = std::chrono::steady_clock::now();
const cv::Mat rectified_ground_truth =
rectifier.rectify_left_point_map(ground_truth.point_map());
const auto metrics =
score::PointCloudEvaluator::evaluate(cloud, rectified_ground_truth);
const auto stats = depth_stats(cloud);
if (!depth_png.empty()) {
write_depth_png(cloud, depth_png, stats.z_p05_m, stats.z_p95_m);
}
const auto matching_ms =
std::chrono::duration<double, std::milli>(matching_end - start)
@ -92,25 +179,46 @@ int main(int argc, char *argv[]) {
matching_end)
.count();
const nlohmann::json output = {
nlohmann::json output = {
{"keyframe_dir", keyframe_dir},
{"algorithm", "StereoSGBM"},
{"num_disparities", num_disparities},
{"ground_truth_points", metrics.ground_truth_points},
{"matched_points", metrics.matched_points},
{"coverage", metrics.coverage},
{"mae_x_m", metrics.mae_x_m},
{"mae_y_m", metrics.mae_y_m},
{"mae_z_m", metrics.mae_z_m},
{"mae_3d_m", metrics.mae_3d_m},
{"rmse_3d_m", metrics.rmse_3d_m},
{"median_3d_m", metrics.median_3d_m},
{"within_1mm", metrics.within_1mm},
{"within_2mm", metrics.within_2mm},
{"within_5mm", metrics.within_5mm},
{"min_depth_m", depth_range.min_depth_m},
{"max_depth_m", depth_range.max_depth_m},
{"image_width", cloud.width},
{"image_height", cloud.height},
{"valid_points", stats.valid_points},
{"valid_fraction", stats.valid_fraction},
{"z_min_m", stats.z_min_m},
{"z_p05_m", stats.z_p05_m},
{"z_median_m", stats.z_median_m},
{"z_p95_m", stats.z_p95_m},
{"z_max_m", stats.z_max_m},
{"matching_ms", matching_ms},
{"reconstruction_ms", reconstruction_ms},
{"has_ground_truth", ground_truth.has_value()},
};
if (!depth_png.empty())
output["depth_png"] = depth_png;
if (ground_truth) {
const cv::Mat rectified_ground_truth =
rectifier.rectify_left_point_map(ground_truth->point_map());
const auto metrics = score::PointCloudEvaluator::evaluate(
cloud, rectified_ground_truth);
output["ground_truth_points"] = metrics.ground_truth_points;
output["matched_points"] = metrics.matched_points;
output["coverage"] = metrics.coverage;
output["mae_x_m"] = metrics.mae_x_m;
output["mae_y_m"] = metrics.mae_y_m;
output["mae_z_m"] = metrics.mae_z_m;
output["mae_3d_m"] = metrics.mae_3d_m;
output["rmse_3d_m"] = metrics.rmse_3d_m;
output["median_3d_m"] = metrics.median_3d_m;
output["within_1mm"] = metrics.within_1mm;
output["within_2mm"] = metrics.within_2mm;
output["within_5mm"] = metrics.within_5mm;
}
std::cout << output.dump(2) << '\n';
} catch (const std::exception &error) {
std::cerr << "Benchmark failed: " << error.what() << '\n';