Compare commits
4 Commits
ea99cd62ee
...
cd97e7b3f1
| Author | SHA1 | Date | |
|---|---|---|---|
| cd97e7b3f1 | |||
| 7c9df13628 | |||
| 98b64020e4 | |||
| b8d8272f76 |
51
.github/workflows/openwiki-update.yml
vendored
Normal file
51
.github/workflows/openwiki-update.yml
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
name: OpenWiki Update
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 8 * * *"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install OpenWiki
|
||||
run: npm install --global openwiki
|
||||
|
||||
- name: Run OpenWiki
|
||||
run: openwiki code --update --print
|
||||
env:
|
||||
OPENWIKI_PROVIDER: openrouter
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENWIKI_MODEL_ID: z-ai/glm-5.2
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
LANGCHAIN_PROJECT: openwiki
|
||||
LANGCHAIN_TRACING_V2: "true"
|
||||
|
||||
- name: Create OpenWiki update pull request
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
add-paths: |
|
||||
openwiki
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
.github/workflows/openwiki-update.yml
|
||||
branch: openwiki/update
|
||||
commit-message: "docs: update OpenWiki"
|
||||
title: "docs: update OpenWiki"
|
||||
body: |
|
||||
Automated OpenWiki documentation update.
|
||||
|
||||
This PR was generated by the scheduled OpenWiki workflow.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -9,6 +9,9 @@ subprojects/yaml-cpp-0.8.0
|
||||
subprojects/base64-0.5.2/
|
||||
subprojects/stdexec/
|
||||
subprojects/.*
|
||||
html/
|
||||
latex/
|
||||
large_tool_results/
|
||||
.venv/
|
||||
.worktrees/
|
||||
|
||||
|
||||
14
.skills/pointcloud-ops/SKILL.md
Normal file
14
.skills/pointcloud-ops/SKILL.md
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
name: pointcloud-ops
|
||||
description: PointCloud processing operations, builder architecture, and test verification guide.
|
||||
---
|
||||
|
||||
# PointCloud Operations Skill
|
||||
|
||||
## Overview
|
||||
This skill provides guidance for developing and testing PointCloud operations in CloudPointRPC.
|
||||
|
||||
## Key Guidelines
|
||||
1. **Source Code**: `src/cloud_point/point_cloud_builder.cpp` and `include/cloud_point/point_cloud_builder.hpp`.
|
||||
2. **Reprojection**: Stereo images are converted into 3D points via `cv::reprojectImageTo3D`.
|
||||
3. **Verification**: Run unit tests using `build/tests/unit_tests --gtest_filter=*PointCloud*`.
|
||||
10
AGENTS.md
10
AGENTS.md
@ -161,3 +161,13 @@ Start here:
|
||||
OpenWiki includes repository overview, architecture notes, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
|
||||
|
||||
When working in this repository, read the OpenWiki quickstart first, then follow its links to the relevant architecture, workflow, domain, operation, and testing notes.
|
||||
|
||||
<!-- OPENWIKI:START -->
|
||||
|
||||
## OpenWiki
|
||||
|
||||
This repository uses OpenWiki for recurring code documentation. Start with `openwiki/quickstart.md`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
|
||||
|
||||
The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate.
|
||||
|
||||
<!-- OPENWIKI:END -->
|
||||
|
||||
9
CLAUDE.md
Normal file
9
CLAUDE.md
Normal file
@ -0,0 +1,9 @@
|
||||
<!-- OPENWIKI:START -->
|
||||
|
||||
## OpenWiki
|
||||
|
||||
This repository uses OpenWiki for recurring code documentation. Start with `openwiki/quickstart.md`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
|
||||
|
||||
The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate.
|
||||
|
||||
<!-- OPENWIKI:END -->
|
||||
14
README.md
14
README.md
@ -162,8 +162,18 @@ In a second terminal run the interactive CLI against the same host and port:
|
||||
# Option 5 — compute point cloud and save to output.ply (inspect in MeshLab)
|
||||
```
|
||||
|
||||
The SCARED test set contains no ground-truth depth, so validation is
|
||||
qualitative (inspect the PLY in MeshLab or similar).
|
||||
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
|
||||
```
|
||||
|
||||
The benchmark reports coverage, component-wise and 3-D errors, threshold
|
||||
accuracy, and matching/reconstruction timings as JSON. OBJ coordinates are
|
||||
converted from millimetres to metres and rectified into the same left-camera
|
||||
frame as the reconstructed cloud before evaluation.
|
||||
|
||||
**Disparity range caveat:** the CLI constructs `CloudPointClient` with the
|
||||
default of 128 disparity levels, while this rig (fx ≈ 1024 px, baseline
|
||||
|
||||
50
include/cloud_point/point_cloud_evaluator.hpp
Normal file
50
include/cloud_point/point_cloud_evaluator.hpp
Normal file
@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
|
||||
#include <opencv2/core.hpp>
|
||||
|
||||
#include "cloud_point/point_cloud_builder.hpp"
|
||||
|
||||
namespace score {
|
||||
|
||||
/// @brief Accuracy and completeness measurements for an XYZ reconstruction.
|
||||
struct PointCloudMetrics {
|
||||
std::size_t ground_truth_points{0};
|
||||
std::size_t matched_points{0};
|
||||
|
||||
/// Fraction of valid ground-truth pixels with a finite prediction.
|
||||
double coverage{0.0};
|
||||
|
||||
/// Absolute component errors over matched points, in metres.
|
||||
double mae_x_m{std::numeric_limits<double>::quiet_NaN()};
|
||||
double mae_y_m{std::numeric_limits<double>::quiet_NaN()};
|
||||
double mae_z_m{std::numeric_limits<double>::quiet_NaN()};
|
||||
|
||||
/// Euclidean XYZ errors over matched points, in metres. These are NaN when
|
||||
/// no ground-truth pixel has a valid prediction.
|
||||
double mae_3d_m{std::numeric_limits<double>::quiet_NaN()};
|
||||
double rmse_3d_m{std::numeric_limits<double>::quiet_NaN()};
|
||||
double median_3d_m{std::numeric_limits<double>::quiet_NaN()};
|
||||
|
||||
/// Fractions of all valid ground-truth pixels reconstructed within the
|
||||
/// threshold. Missing predictions therefore count as failures.
|
||||
double within_1mm{0.0};
|
||||
double within_2mm{0.0};
|
||||
double within_5mm{0.0};
|
||||
};
|
||||
|
||||
/// @brief Pixel-aligned evaluation of a reconstructed point cloud.
|
||||
class PointCloudEvaluator {
|
||||
public:
|
||||
/// @param predicted Dense point cloud in the rectified-left frame, metres.
|
||||
/// @param ground_truth CV_32FC3 point map in the same frame and dimensions,
|
||||
/// metres. Any point with a non-finite component is unknown.
|
||||
/// @throws std::invalid_argument on a type, size, or storage mismatch, or
|
||||
/// when the ground-truth map has no valid points.
|
||||
[[nodiscard]] static PointCloudMetrics
|
||||
evaluate(const PointCloud &predicted, const cv::Mat &ground_truth);
|
||||
};
|
||||
|
||||
} // namespace score
|
||||
40
include/cloud_point/scared_ground_truth_loader.hpp
Normal file
40
include/cloud_point/scared_ground_truth_loader.hpp
Normal file
@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
#include <opencv2/core.hpp>
|
||||
|
||||
namespace score {
|
||||
|
||||
/// @brief Loads the semi-dense XYZ point map supplied with a SCARED keyframe.
|
||||
///
|
||||
/// The OBJ vertex order is preserved: vertex r * width + c belongs to pixel
|
||||
/// (r, c). Unknown vertices are represented as NaN in all three coordinates.
|
||||
/// The returned points are in the original left-camera coordinate frame.
|
||||
class ScaredGroundTruthLoader {
|
||||
public:
|
||||
/// @brief Load <keyframe_dir>/point_cloud.obj.
|
||||
/// @param keyframe_dir Directory containing the SCARED keyframe files.
|
||||
/// @param image_size Expected point-map dimensions.
|
||||
/// @param units_to_metres Scale applied to finite OBJ coordinates. SCARED
|
||||
/// ground truth is normally expressed in millimetres.
|
||||
/// @throws std::invalid_argument for invalid dimensions or scale.
|
||||
/// @throws std::runtime_error if the OBJ cannot be read or does not contain
|
||||
/// exactly image_size.area() vertex records.
|
||||
explicit ScaredGroundTruthLoader(const std::string &keyframe_dir,
|
||||
cv::Size image_size,
|
||||
float units_to_metres = 0.001f);
|
||||
|
||||
/// @brief Ground-truth XYZ point map, CV_32FC3 in metres.
|
||||
[[nodiscard]] const cv::Mat &point_map() const noexcept;
|
||||
|
||||
/// @brief Number of pixels with finite XYZ ground truth.
|
||||
[[nodiscard]] std::size_t valid_point_count() const noexcept;
|
||||
|
||||
private:
|
||||
cv::Mat point_map_;
|
||||
std::size_t valid_point_count_{0};
|
||||
};
|
||||
|
||||
} // namespace score
|
||||
@ -11,8 +11,8 @@ namespace score {
|
||||
/// @brief Stereo rectifier that computes rectification maps from calibration
|
||||
/// data and applies them to image pairs.
|
||||
///
|
||||
/// Thread safety: const methods (rectify, q) are safe to call concurrently.
|
||||
/// The object must not be modified after construction.
|
||||
/// Thread safety: const methods (rectify, rectify_left_point_map, q) are safe
|
||||
/// to call concurrently. The object must not be modified after construction.
|
||||
class StereoRectifier {
|
||||
public:
|
||||
/// @brief Calibration parameters for a stereo rig.
|
||||
@ -48,12 +48,24 @@ class StereoRectifier {
|
||||
[[nodiscard]] std::pair<cv::Mat, cv::Mat>
|
||||
rectify(const cv::Mat &left, const cv::Mat &right) const;
|
||||
|
||||
/// @brief Rectify an XYZ point map from the original left-camera frame.
|
||||
///
|
||||
/// The map is resampled with nearest-neighbour interpolation to avoid
|
||||
/// blending geometry or NaN values, then finite points are rotated into
|
||||
/// the rectified-left coordinate frame.
|
||||
/// @param point_map CV_32FC3 map with the calibration image dimensions.
|
||||
/// @return CV_32FC3 point map in the rectified-left frame.
|
||||
/// @throws std::invalid_argument for an invalid type or dimensions.
|
||||
[[nodiscard]] cv::Mat
|
||||
rectify_left_point_map(const cv::Mat &point_map) const;
|
||||
|
||||
/// @brief Access the 4x4 reprojection matrix Q produced by stereoRectify.
|
||||
[[nodiscard]] const cv::Mat &q() const noexcept;
|
||||
|
||||
private:
|
||||
cv::Mat map_lx_, map_ly_; ///< Rectification maps for the left image
|
||||
cv::Mat map_rx_, map_ry_; ///< Rectification maps for the right image
|
||||
cv::Mat r1_; ///< Rotation into the rectified-left frame
|
||||
cv::Mat q_; ///< 4x4 CV_64F reprojection matrix
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"updatedAt": "2026-07-02T22:45:41.669Z",
|
||||
"command": "init",
|
||||
"gitHead": "9de6f5a82d5e9cc1c1d52b60a5493f71ede6e700",
|
||||
"updatedAt": "2026-07-16T21:56:19.566Z",
|
||||
"command": "update",
|
||||
"gitHead": "ea99cd62eec0605661c2d8237a16000d0aae7839",
|
||||
"model": "glm-5.2:cloud"
|
||||
}
|
||||
|
||||
1
openwiki/INSTRUCTIONS.md
Normal file
1
openwiki/INSTRUCTIONS.md
Normal file
@ -0,0 +1 @@
|
||||
A code wiki for this local repository. Prioritize a concise quickstart, architecture overview, source map, key workflows, domain concepts, operations/runbook notes, testing guidance, and integration points. Inspect git history to understand reasoning behind code changes and the progression of the repository. Keep pages grounded in the repository structure and recent code changes. Prefer practical navigation for engineers over generic summaries.
|
||||
@ -1,3 +1,10 @@
|
||||
---
|
||||
type: Architecture
|
||||
title: Architecture
|
||||
description: Layered architecture for JSON-RPC 2.0 over TCP, including the OpenCV compute layer, RPC/server/transport layers, threading model, and the SCARED dataset validation server.
|
||||
tags: [architecture, tcp, threading, opencv, stereo-pipeline]
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
## Overview
|
||||
@ -12,6 +19,8 @@ The system follows a layered architecture for JSON-RPC 2.0 communication over TC
|
||||
│ OpenCV Compute Layer (optional — requires opencv4) │
|
||||
│ CloudPointClient (facade: connect, compute_cloud, │
|
||||
│ write_ply) │
|
||||
│ ScaredDatasetLoader (SCARED stereo calibration + │
|
||||
│ image pair from disk; mm→m translation) │
|
||||
│ StereoRectifier (cv::stereoRectify + remap, Q mat) │
|
||||
│ PointCloudBuilder (SGBM disparity, reproject, NaN │
|
||||
│ filter) │
|
||||
@ -42,7 +51,9 @@ All C++ code lives in the `score` namespace. The `rpc/` git submodule provides `
|
||||
|
||||
## Communication model
|
||||
|
||||
The PlantUML diagram at `docs/communication_model.pu` (rendered as `docs/cm.png`) describes the interaction flow:
|
||||
The PlantUML diagram at `docs/communication_model.pu` describes the interaction flow:
|
||||
|
||||

|
||||
|
||||
1. **Unity side** initializes a `CloudPointServer` via `crpc_init()`, which starts a `TcpServer` awaiting connections.
|
||||
2. **Client side** calls `CloudPointClient::connect()`, which opens a TCP connection and immediately fetches `get-stereo-calibration` once, initialising `StereoRectifier` with the returned intrinsics and geometry.
|
||||
@ -149,12 +160,23 @@ Non-object JSON requests (arrays, strings, numbers, null) throw `nlohmann::json:
|
||||
| `cloud_point_rpc_server` | `src/server_main.cpp` | Standalone server with mock `Service` data |
|
||||
| `cloud_point_rpc_cli` | `src/main.cpp` | Interactive CLI client (menu-driven) |
|
||||
| `minimal_client` | `src/minimal_client.cpp` | Minimal client that sends a hardcoded `ping` request |
|
||||
| `scared_dataset_server` | `src/cloud_point/scared_dataset_server.cpp` | RPC server backed by a SCARED dataset keyframe directory for real-data validation (requires opencv4) |
|
||||
|
||||
### SCARED dataset validation
|
||||
|
||||
`scared_dataset_server` (`src/cloud_point/scared_dataset_server.cpp`) is a standalone RPC server that serves `get-stereo-calibration` and `get-image-pair` from a SCARED [endoscopic stereo dataset](https://huggingface.co/datasets/maxhallan7/scared) keyframe directory, so the full `CloudPointClient` stereo pipeline can be validated against real data instead of mock images. It reuses the standard wire protocol (see [RPC Protocol](rpc-protocol.md)) — no new RPC methods.
|
||||
|
||||
`ScaredDatasetLoader` (`include/cloud_point/scared_dataset_loader.hpp`) reads `endoscope_calibration.yaml` (OpenCV FileStorage with `M1`, `D1`, `M2`, `D2`, `R`, `T`) plus `Left_Image.png` / `Right_Image.png` (1280×1024 RGBA) from a keyframe directory. The YAML `T` is stored in millimetres; the loader divides by 1000 before populating `StereoCalibrationRPC.translation` (metres on the wire). The same images are returned on every `get-image-pair` call (single-keyframe source); the frame counter is `std::atomic<uint64_t>` (incremented per call) so it is safe under TcpServer's per-client handler threads. The constructor throws `std::runtime_error` if the left and right images have different dimensions.
|
||||
|
||||
Usage: `scared_dataset_server <keyframe_dir> [port]` (default port 8080).
|
||||
|
||||
The SCARED rig (fx ≈ 1024 px, baseline ≈ 4.35 mm) produces disparities above 128 px for tissue nearer than ~35 mm. `CloudPointClient` accepts a `num_disparities` constructor parameter (default 128; use 160 for SCARED) which is validated and forwarded to `StereoMatcherFactory::create` — it must be a positive multiple of 16 or `std::invalid_argument` is thrown. See [Build & Testing → SCARED Dataset E2E Test](build-and-testing.md#scared-dataset-e2e-test) for the test and run instructions.
|
||||
|
||||
## Shared libraries
|
||||
|
||||
| Library | Sources | Description |
|
||||
|---|---|---|
|
||||
| `libcloud_point_rpc` | `rpc_coder.cpp`, `rpc_server.cpp`, `server_api.cpp`, `service.cpp` | Core RPC + server + config |
|
||||
| `libcloud_point_rpc` | `rpc_coder.cpp`, `rpc_dto.cpp`, `rpc_server.cpp`, `server_api.cpp`, `service.cpp` | Core RPC + server + config |
|
||||
| `libcloud_point_rpc_cli` | `cli.cpp` | CLI client logic (links against core lib) |
|
||||
| `test_cloud_point` | `test_api.cpp` | Test API library for method scheduling and auto-calling |
|
||||
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
---
|
||||
type: Reference
|
||||
title: Build & Testing
|
||||
description: Meson build system, dependencies, build targets, Linux/Windows build instructions, Docker, CI pipelines (Gitea + GitHub Actions), Doxygen, test suite overview, and SCARED dataset E2E test guidance.
|
||||
tags: [build, testing, meson, docker, ci, doxygen, scared]
|
||||
---
|
||||
|
||||
# Build & Testing
|
||||
|
||||
## Build system
|
||||
@ -26,9 +33,10 @@ Defined in `src/meson.build`:
|
||||
**Shared libraries:**
|
||||
| Library | Sources | Notes |
|
||||
|---|---|---|
|
||||
| `libcloud_point_rpc` | `rpc_coder.cpp`, `rpc_server.cpp`, `server_api.cpp`, `service.cpp` | Core library, installed with `install_rpath: '$ORIGIN'` |
|
||||
| `libcloud_point_rpc` | `rpc_coder.cpp`, `rpc_dto.cpp`, `rpc_server.cpp`, `server_api.cpp`, `service.cpp` | Core library, installed with `install_rpath: '$ORIGIN'` |
|
||||
| `libcloud_point_rpc_cli` | `cli.cpp` | CLI client logic |
|
||||
| `test_cloud_point` | `test_api.cpp` | Test API library |
|
||||
| `cloud_point_compute` | `src/cloud_point/*.cpp` | OpenCV compute library (optional — only built when `opencv4` is found). Links against `libcloud_point_rpc` + OpenCV. Sources: `image`, `cpu_stereo_matcher`, `gpu_stereo_matcher`, `stereo_matcher_factory`, `stereo_rectifier`, `point_cloud_builder`, `cloud_point_client`, `scared_dataset_loader`. |
|
||||
|
||||
**Executables:**
|
||||
| Executable | Source | Description |
|
||||
@ -36,6 +44,7 @@ Defined in `src/meson.build`:
|
||||
| `cloud_point_rpc_server` | `server_main.cpp` | Standalone mock server |
|
||||
| `cloud_point_rpc_cli` | `main.cpp` | Interactive CLI client |
|
||||
| `minimal_client` | `minimal_client.cpp` | Minimal client sending a hardcoded `ping` |
|
||||
| `scared_dataset_server` | `cloud_point/scared_dataset_server.cpp` | RPC server backed by a SCARED dataset keyframe directory (requires opencv4) |
|
||||
|
||||
### Linux build
|
||||
|
||||
@ -127,6 +136,16 @@ docker run --network=host -it -v $(pwd)/my_config.yaml:/app/config.yaml cloud-po
|
||||
4. `meson setup build && meson compile -C build -j2`
|
||||
5. `meson test -C build`
|
||||
|
||||
`.github/workflows/openwiki-update.yml` defines a GitHub Actions workflow that runs on a daily schedule (`0 8 * * *`) and on manual dispatch. It installs OpenWiki, runs `openwiki code --update --print` using the OpenRouter provider, and opens a pull request with the regenerated `openwiki/` content. This is how the repository wiki stays current without manual intervention.
|
||||
|
||||
## Doxygen
|
||||
|
||||
The `Doxyfile` configures Doxygen to generate API documentation from `openwiki/`, `docs/`, `include/`, `src/`, `README.md`, and `API.md`. HTML output goes to `html/` and LaTeX output to `latex/` (both git-ignored). Run with:
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
All tests are in `tests/` and compiled into a single `unit_tests` executable (defined in `tests/meson.build`) linked against `cloud_point_rpc_dep`, `cloud_point_rpc_cli_dep`, `cloud_point_rpc_test_dep`, and GoogleTest/GMock.
|
||||
@ -154,14 +173,21 @@ meson test -C build unit_tests # explicit
|
||||
| `test_base64.cpp` | Base64 | Encode/decode round-trip |
|
||||
| `test_base64_edge_cases.cpp` | Base64 | Edge cases (empty input, binary with nulls) |
|
||||
| `test_serialize.cpp` | Serialization | `serialize`/`deserialize` for numeric types, `inplace_size_embedding` |
|
||||
| `test_service.cpp` | Service | Default fallbacks, configured data, empty data |
|
||||
| `test_serialize_image.cpp` | Serialization | Image serialization round-trip |
|
||||
| `test_service.cpp` | Service | Default fallbacks, configured data, empty data, stereo calibration + image pair mocks |
|
||||
| `test_stereo_matcher.cpp` | Stereo matching | CPU/GPU stereo matcher factory, disparity output, `num_disparities` validation |
|
||||
| `test_stereo_rectifier.cpp` | Stereo rectification | `StereoRectifier` rectified image pair dimensions and validity |
|
||||
| `test_point_cloud_builder.cpp` | Point cloud | `PointCloudBuilder` SGBM → reproject → NaN filter pipeline |
|
||||
| `test_cloud_point_client.cpp` | CloudPointClient | End-to-end facade: connect, compute_cloud, PLY export |
|
||||
| `test_scared_dataset.cpp` | SCARED E2E | Full stereo pipeline against real SCARED endoscopic data (skipped unless `SCARED_KEYFRAME_DIR` is set) |
|
||||
| `test_image.cpp` | Image | `Image`/`ImageFactory` construction, Mat dimensions, pixel round-trip |
|
||||
|
||||
### Test conventions
|
||||
|
||||
- All test fixtures initialize Google Logging in `SetUp()` with `FLAGS_logtostderr = true`.
|
||||
- Integration tests (`test_integration.cpp`) create a temporary `config.yaml`, start a real `TcpServer` in a thread, and connect via `TCPConnector`/`RpcClient`.
|
||||
- Integration tests (`test_integration.cpp`) create a temporary `config.yaml`, start a real `TcpServer` in a thread, and connect via `TCPConnector`/`RpcClient`. Includes `ClientRetrieveRemoteMethods` which verifies the auto-registered `get-available-methods` method.
|
||||
- C API tests use `crpc_test_init()` / `crpc_test_deinit()` and verify callback invocation via `std::promise`/`std::future`.
|
||||
- The latest commit (`9de6f5a`) added `google::InitGoogleLogging` calls in test files to ensure logging is initialized before glog macros are used.
|
||||
- Stereo pipeline tests (`test_stereo_matcher`, `test_stereo_rectifier`, `test_point_cloud_builder`, `test_cloud_point_client`) require OpenCV and are only compiled when `opencv4` is found.
|
||||
|
||||
### Linting
|
||||
|
||||
@ -173,6 +199,35 @@ ninja -C build clang-format
|
||||
find src include tests -name "*.cpp" -o -name "*.hpp" | xargs clang-format -i
|
||||
```
|
||||
|
||||
## SCARED Dataset E2E Test
|
||||
|
||||
The `ScaredDatasetTest.ComputeCloudFromRealData` test (compiled when OpenCV is
|
||||
found) validates the full stereo pipeline against real endoscopic data from the
|
||||
[SCARED dataset](https://huggingface.co/datasets/maxhallan7/scared).
|
||||
|
||||
The test is **skipped in CI** (no dataset on CI runners). To run it locally:
|
||||
|
||||
```bash
|
||||
export SCARED_KEYFRAME_DIR=/path/to/test_dataset_8/keyframe_0
|
||||
./build/tests/unit_tests '--gtest_filter=ScaredDataset*'
|
||||
# or via meson (the test shows as skipped when env var is absent):
|
||||
meson test -C build -v
|
||||
```
|
||||
|
||||
The test asserts:
|
||||
- Cloud has more than 50,000 valid points.
|
||||
- Median z is in `[0.02, 0.20]` m (20 mm – 200 mm, typical endoscopy range).
|
||||
|
||||
**Important:** the YAML file stores `T` in millimetres (baseline ≈ −4.35 mm).
|
||||
`ScaredDatasetLoader` divides `T` by 1000 before populating
|
||||
`StereoCalibrationRPC.translation` (which is in metres on the wire).
|
||||
If the median depth looks ~1000× too large, the mm→m conversion is missing.
|
||||
|
||||
The SCARED rig has fx ≈ 1024 px and B ≈ 4.35 mm, giving a maximum disparity
|
||||
of only ~127 px at ~35 mm depth. `scared_dataset_server` and the E2E test
|
||||
both use `num_disparities = 160` via the `CloudPointClient` constructor's
|
||||
new `num_disparities` parameter (default 128 — backward-compatible).
|
||||
|
||||
## Source references
|
||||
|
||||
- `meson.build` — Root build config, dependency declarations
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
---
|
||||
type: API
|
||||
title: C API for Unity Integration
|
||||
description: C API surface for embedding the RPC server in Unity or other native consumers — lifecycle functions, rpc_string memory management, method registration, and the test API for handler testing.
|
||||
tags: [c-api, unity, server-api, test-api, rpc_string, integration]
|
||||
---
|
||||
|
||||
# C API for Unity Integration
|
||||
|
||||
The C API allows Unity (or any C/C++ consumer) to embed the RPC server as a shared library, register custom RPC handlers as C function pointers, and manage the server lifecycle without touching C++ directly.
|
||||
|
||||
13
openwiki/index.md
Normal file
13
openwiki/index.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
type: Documentation Index
|
||||
title: "OpenWiki"
|
||||
description: "Files and subdirectories in OpenWiki."
|
||||
---
|
||||
|
||||
# Files
|
||||
|
||||
- [Architecture](architecture.md) - Layered architecture for JSON-RPC 2.0 over TCP, including the OpenCV compute layer, RPC/server/transport layers, threading model, and the SCARED dataset validation server.
|
||||
- [Build & Testing](build-and-testing.md) - Meson build system, dependencies, build targets, Linux/Windows build instructions, Docker, CI pipelines (Gitea + GitHub Actions), Doxygen, test suite overview, and SCARED dataset E2E test guidance.
|
||||
- [C API for Unity Integration](c-api.md) - C API surface for embedding the RPC server in Unity or other native consumers — lifecycle functions, rpc_string memory management, method registration, and the test API for handler testing.
|
||||
- [Cloud Point RPC Quickstart](quickstart.md) - Entry point for the CloudPointRPC code wiki. Covers what the project is, repository layout, build/run instructions, and links to all major documentation sections.
|
||||
- [RPC Protocol](rpc-protocol.md) - JSON-RPC 2.0 wire protocol over TCP — request/response formats, error codes, all RPC methods (get-stereo-calibration, get-image-pair, get-available-methods, legacy methods), and handler registration.
|
||||
@ -1,3 +1,10 @@
|
||||
---
|
||||
type: Quickstart
|
||||
title: Cloud Point RPC Quickstart
|
||||
description: Entry point for the CloudPointRPC code wiki. Covers what the project is, repository layout, build/run instructions, and links to all major documentation sections.
|
||||
tags: [quickstart, overview, navigation]
|
||||
---
|
||||
|
||||
# Cloud Point RPC — Quickstart
|
||||
|
||||
## What is this?
|
||||
@ -24,6 +31,8 @@ The server side with C-API is fully implemented. The C++ stereo point-cloud clie
|
||||
| `config.yml` | Sample server configuration (IP and port) |
|
||||
| `Dockerfile` | Container image for the CLI client |
|
||||
| `.gitea/workflows/test.yaml` | CI pipeline (build + test on push to master) |
|
||||
| `.github/workflows/openwiki-update.yml` | Scheduled GitHub Actions workflow that refreshes OpenWiki docs daily and opens a PR |
|
||||
| `Doxyfile` | Doxygen config — generates HTML/LaTeX API docs from `openwiki/`, `docs/`, `include/`, `src/`, `README.md`, `API.md` (output in `html/` and `latex/`, git-ignored) |
|
||||
|
||||
## Build and run
|
||||
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
---
|
||||
type: Protocol
|
||||
title: RPC Protocol
|
||||
description: JSON-RPC 2.0 wire protocol over TCP — request/response formats, error codes, all RPC methods (get-stereo-calibration, get-image-pair, get-available-methods, legacy methods), and handler registration.
|
||||
tags: [rpc, json-rpc, protocol, api, wire-format]
|
||||
---
|
||||
|
||||
# RPC Protocol
|
||||
|
||||
## JSON-RPC 2.0
|
||||
@ -107,6 +114,26 @@ DTOs: `StereoCalibrationDto` and `ImagePairDto` in `include/cloud_point_rpc/rpc_
|
||||
|
||||
---
|
||||
|
||||
### `get-available-methods`
|
||||
|
||||
Returns the names of all methods registered on the server. Auto-registered in the `RpcServer` constructor — not added by the application. Useful for client-side discovery.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{"jsonrpc": "2.0", "method": "get-available-methods", "id": 5}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{"jsonrpc": "2.0", "result": ["get-available-methods", "get-stereo-calibration", "get-image-pair"], "id": 5}
|
||||
```
|
||||
|
||||
Result is a JSON array of method-name strings, including `get-available-methods` itself. Tested in `tests/test_integration.cpp` (`ClientRetrieveRemoteMethods`).
|
||||
|
||||
Source: `src/rpc_server.cpp` — `RpcServer::RpcServer()` constructor; `get_method_names()`.
|
||||
|
||||
---
|
||||
|
||||
### `get-intrinsic-params` *(legacy)*
|
||||
|
||||
Retrieves left-camera intrinsic parameters as a flat 3×3 matrix (row-major, 9 doubles). Not used by `CloudPointClient`; kept for backward compatibility.
|
||||
|
||||
@ -18,8 +18,10 @@ cloud_point_sources = files(
|
||||
'stereo_matcher_factory.cpp',
|
||||
'stereo_rectifier.cpp',
|
||||
'point_cloud_builder.cpp',
|
||||
'point_cloud_evaluator.cpp',
|
||||
'cloud_point_client.cpp',
|
||||
'scared_dataset_loader.cpp',
|
||||
'scared_ground_truth_loader.cpp',
|
||||
)
|
||||
|
||||
cpc_deps = [ cloud_point_rpc_dep, opencv_dep ]
|
||||
@ -48,3 +50,10 @@ executable(
|
||||
dependencies: [cloud_point_compute_dep],
|
||||
install: true,
|
||||
)
|
||||
|
||||
executable(
|
||||
'scared_dataset_benchmark',
|
||||
'scared_dataset_benchmark.cpp',
|
||||
dependencies: [cloud_point_compute_dep],
|
||||
install: true,
|
||||
)
|
||||
|
||||
124
src/cloud_point/point_cloud_evaluator.cpp
Normal file
124
src/cloud_point/point_cloud_evaluator.cpp
Normal file
@ -0,0 +1,124 @@
|
||||
#include "cloud_point/point_cloud_evaluator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
namespace score {
|
||||
|
||||
namespace {
|
||||
|
||||
bool finite(const cv::Vec3f &point) {
|
||||
return std::isfinite(point[0]) && std::isfinite(point[1]) &&
|
||||
std::isfinite(point[2]);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PointCloudMetrics PointCloudEvaluator::evaluate(const PointCloud &predicted,
|
||||
const cv::Mat &ground_truth) {
|
||||
if (ground_truth.type() != CV_32FC3) {
|
||||
throw std::invalid_argument(
|
||||
"PointCloudEvaluator: ground truth must be CV_32FC3");
|
||||
}
|
||||
if (predicted.width != ground_truth.cols ||
|
||||
predicted.height != ground_truth.rows) {
|
||||
throw std::invalid_argument(
|
||||
"PointCloudEvaluator: prediction and ground-truth dimensions do "
|
||||
"not match");
|
||||
}
|
||||
|
||||
const auto pixel_count = static_cast<std::size_t>(predicted.width) *
|
||||
static_cast<std::size_t>(predicted.height);
|
||||
if (predicted.data.size() != pixel_count * 3u) {
|
||||
throw std::invalid_argument(
|
||||
"PointCloudEvaluator: prediction storage size is invalid");
|
||||
}
|
||||
|
||||
PointCloudMetrics metrics;
|
||||
std::vector<double> errors;
|
||||
errors.reserve(pixel_count);
|
||||
double sum_abs_x = 0.0;
|
||||
double sum_abs_y = 0.0;
|
||||
double sum_abs_z = 0.0;
|
||||
double sum_error = 0.0;
|
||||
double sum_error_squared = 0.0;
|
||||
std::size_t within_1mm = 0;
|
||||
std::size_t within_2mm = 0;
|
||||
std::size_t within_5mm = 0;
|
||||
|
||||
for (int row = 0; row < ground_truth.rows; ++row) {
|
||||
for (int column = 0; column < ground_truth.cols; ++column) {
|
||||
const cv::Vec3f truth = ground_truth.at<cv::Vec3f>(row, column);
|
||||
if (!finite(truth)) {
|
||||
continue;
|
||||
}
|
||||
++metrics.ground_truth_points;
|
||||
|
||||
const auto index =
|
||||
(static_cast<std::size_t>(row) * predicted.width + column) * 3u;
|
||||
const cv::Vec3f estimate(predicted.data[index],
|
||||
predicted.data[index + 1],
|
||||
predicted.data[index + 2]);
|
||||
if (!finite(estimate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
++metrics.matched_points;
|
||||
const cv::Vec3f delta = estimate - truth;
|
||||
const double abs_x = std::abs(static_cast<double>(delta[0]));
|
||||
const double abs_y = std::abs(static_cast<double>(delta[1]));
|
||||
const double abs_z = std::abs(static_cast<double>(delta[2]));
|
||||
const double error =
|
||||
std::sqrt(abs_x * abs_x + abs_y * abs_y + abs_z * abs_z);
|
||||
|
||||
sum_abs_x += abs_x;
|
||||
sum_abs_y += abs_y;
|
||||
sum_abs_z += abs_z;
|
||||
sum_error += error;
|
||||
sum_error_squared += error * error;
|
||||
errors.push_back(error);
|
||||
within_1mm += error <= 0.001;
|
||||
within_2mm += error <= 0.002;
|
||||
within_5mm += error <= 0.005;
|
||||
}
|
||||
}
|
||||
|
||||
if (metrics.ground_truth_points == 0) {
|
||||
throw std::invalid_argument(
|
||||
"PointCloudEvaluator: ground truth has no valid points");
|
||||
}
|
||||
|
||||
const double ground_truth_count =
|
||||
static_cast<double>(metrics.ground_truth_points);
|
||||
metrics.coverage =
|
||||
static_cast<double>(metrics.matched_points) / ground_truth_count;
|
||||
metrics.within_1mm = static_cast<double>(within_1mm) / ground_truth_count;
|
||||
metrics.within_2mm = static_cast<double>(within_2mm) / ground_truth_count;
|
||||
metrics.within_5mm = static_cast<double>(within_5mm) / ground_truth_count;
|
||||
|
||||
if (metrics.matched_points == 0) {
|
||||
return metrics;
|
||||
}
|
||||
|
||||
const double matched_count = static_cast<double>(metrics.matched_points);
|
||||
metrics.mae_x_m = sum_abs_x / matched_count;
|
||||
metrics.mae_y_m = sum_abs_y / matched_count;
|
||||
metrics.mae_z_m = sum_abs_z / matched_count;
|
||||
metrics.mae_3d_m = sum_error / matched_count;
|
||||
metrics.rmse_3d_m = std::sqrt(sum_error_squared / matched_count);
|
||||
|
||||
const auto middle =
|
||||
errors.begin() + static_cast<std::ptrdiff_t>(errors.size() / 2);
|
||||
std::nth_element(errors.begin(), middle, errors.end());
|
||||
metrics.median_3d_m = *middle;
|
||||
if (errors.size() % 2 == 0) {
|
||||
const auto lower = std::max_element(errors.begin(), middle);
|
||||
metrics.median_3d_m = (*lower + *middle) / 2.0;
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
} // namespace score
|
||||
121
src/cloud_point/scared_dataset_benchmark.cpp
Normal file
121
src/cloud_point/scared_dataset_benchmark.cpp
Normal file
@ -0,0 +1,121 @@
|
||||
/// @file scared_dataset_benchmark.cpp
|
||||
/// @brief Evaluate the CPU stereo reconstruction against SCARED XYZ ground
|
||||
/// truth.
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#include <glog/logging.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <opencv2/imgproc.hpp>
|
||||
|
||||
#include "cloud_point/imageFactory.h"
|
||||
#include "cloud_point/point_cloud_builder.hpp"
|
||||
#include "cloud_point/point_cloud_evaluator.hpp"
|
||||
#include "cloud_point/scared_dataset_loader.hpp"
|
||||
#include "cloud_point/scared_ground_truth_loader.hpp"
|
||||
#include "cloud_point/stereo_matcher_factory.hpp"
|
||||
#include "cloud_point/stereo_rectifier.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
cv::Mat to_gray(const score::ImageRPC &rpc) {
|
||||
auto image = score::ImageFactory::create(rpc);
|
||||
cv::Mat gray;
|
||||
switch (rpc.type) {
|
||||
case score::ImageRPC::Type::BGR:
|
||||
cv::cvtColor(image.get(), gray, cv::COLOR_BGR2GRAY);
|
||||
break;
|
||||
case score::ImageRPC::Type::RGBA:
|
||||
cv::cvtColor(image.get(), gray, cv::COLOR_RGBA2GRAY);
|
||||
break;
|
||||
default:
|
||||
throw std::invalid_argument(
|
||||
"scared_dataset_benchmark: expected a colour stereo image");
|
||||
}
|
||||
return gray;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
google::InitGoogleLogging(argv[0]);
|
||||
FLAGS_alsologtostderr = true;
|
||||
|
||||
if (argc < 2 || argc > 3) {
|
||||
std::cerr << "Usage: " << argv[0]
|
||||
<< " <keyframe_dir> [num_disparities]\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const std::string keyframe_dir = argv[1];
|
||||
const int num_disparities = argc == 3 ? std::stoi(argv[2]) : 160;
|
||||
|
||||
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 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());
|
||||
|
||||
const auto pair = dataset.image_pair(0);
|
||||
const cv::Mat left_gray = to_gray(pair.left);
|
||||
const cv::Mat right_gray = to_gray(pair.right);
|
||||
auto [rectified_left, rectified_right] =
|
||||
rectifier.rectify(left_gray, right_gray);
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const cv::Mat disparity =
|
||||
matcher->compute(rectified_left, rectified_right);
|
||||
const auto matching_end = std::chrono::steady_clock::now();
|
||||
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 matching_ms =
|
||||
std::chrono::duration<double, std::milli>(matching_end - start)
|
||||
.count();
|
||||
const auto reconstruction_ms =
|
||||
std::chrono::duration<double, std::milli>(reconstruction_end -
|
||||
matching_end)
|
||||
.count();
|
||||
|
||||
const 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},
|
||||
{"matching_ms", matching_ms},
|
||||
{"reconstruction_ms", reconstruction_ms},
|
||||
};
|
||||
std::cout << output.dump(2) << '\n';
|
||||
} catch (const std::exception &error) {
|
||||
std::cerr << "Benchmark failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
112
src/cloud_point/scared_ground_truth_loader.cpp
Normal file
112
src/cloud_point/scared_ground_truth_loader.cpp
Normal file
@ -0,0 +1,112 @@
|
||||
#include "cloud_point/scared_ground_truth_loader.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace score {
|
||||
|
||||
namespace {
|
||||
|
||||
float parse_coordinate(const std::string &token, const std::string &path,
|
||||
std::size_t line_number) {
|
||||
try {
|
||||
std::size_t parsed = 0;
|
||||
const float value = std::stof(token, &parsed);
|
||||
if (parsed != token.size()) {
|
||||
throw std::invalid_argument("trailing characters");
|
||||
}
|
||||
return value;
|
||||
} catch (const std::exception &) {
|
||||
throw std::runtime_error(
|
||||
"ScaredGroundTruthLoader: invalid coordinate at " + path + ":" +
|
||||
std::to_string(line_number) + ": " + token);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ScaredGroundTruthLoader::ScaredGroundTruthLoader(
|
||||
const std::string &keyframe_dir, cv::Size image_size,
|
||||
float units_to_metres) {
|
||||
if (image_size.width <= 0 || image_size.height <= 0) {
|
||||
throw std::invalid_argument(
|
||||
"ScaredGroundTruthLoader: image dimensions must be positive");
|
||||
}
|
||||
if (!std::isfinite(units_to_metres) || units_to_metres <= 0.0f) {
|
||||
throw std::invalid_argument(
|
||||
"ScaredGroundTruthLoader: units_to_metres must be finite and "
|
||||
"positive");
|
||||
}
|
||||
|
||||
const std::string path = keyframe_dir + "/point_cloud.obj";
|
||||
std::ifstream input(path);
|
||||
if (!input) {
|
||||
throw std::runtime_error(
|
||||
"ScaredGroundTruthLoader: cannot open point cloud: " + path);
|
||||
}
|
||||
|
||||
const auto expected_count = static_cast<std::size_t>(image_size.width) *
|
||||
static_cast<std::size_t>(image_size.height);
|
||||
std::vector<cv::Vec3f> vertices;
|
||||
vertices.reserve(expected_count);
|
||||
|
||||
std::string line;
|
||||
std::size_t line_number = 0;
|
||||
const float nan = std::numeric_limits<float>::quiet_NaN();
|
||||
while (std::getline(input, line)) {
|
||||
++line_number;
|
||||
std::istringstream stream(line);
|
||||
std::string record;
|
||||
stream >> record;
|
||||
if (record != "v") {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string x_token, y_token, z_token;
|
||||
if (!(stream >> x_token >> y_token >> z_token)) {
|
||||
throw std::runtime_error(
|
||||
"ScaredGroundTruthLoader: incomplete vertex at " + path + ":" +
|
||||
std::to_string(line_number));
|
||||
}
|
||||
|
||||
const float x = parse_coordinate(x_token, path, line_number);
|
||||
const float y = parse_coordinate(y_token, path, line_number);
|
||||
const float z = parse_coordinate(z_token, path, line_number);
|
||||
if (std::isfinite(x) && std::isfinite(y) && std::isfinite(z)) {
|
||||
vertices.emplace_back(x * units_to_metres, y * units_to_metres,
|
||||
z * units_to_metres);
|
||||
++valid_point_count_;
|
||||
} else {
|
||||
vertices.emplace_back(nan, nan, nan);
|
||||
}
|
||||
}
|
||||
|
||||
if (vertices.size() != expected_count) {
|
||||
throw std::runtime_error("ScaredGroundTruthLoader: expected " +
|
||||
std::to_string(expected_count) +
|
||||
" vertices in " + path + ", got " +
|
||||
std::to_string(vertices.size()));
|
||||
}
|
||||
|
||||
point_map_ = cv::Mat(image_size, CV_32FC3);
|
||||
for (std::size_t index = 0; index < vertices.size(); ++index) {
|
||||
point_map_.at<cv::Vec3f>(static_cast<int>(index / image_size.width),
|
||||
static_cast<int>(index % image_size.width)) =
|
||||
vertices[index];
|
||||
}
|
||||
}
|
||||
|
||||
const cv::Mat &ScaredGroundTruthLoader::point_map() const noexcept {
|
||||
return point_map_;
|
||||
}
|
||||
|
||||
std::size_t ScaredGroundTruthLoader::valid_point_count() const noexcept {
|
||||
return valid_point_count_;
|
||||
}
|
||||
|
||||
} // namespace score
|
||||
@ -1,5 +1,7 @@
|
||||
#include "cloud_point/stereo_rectifier.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
@ -58,12 +60,12 @@ StereoRectifier::StereoRectifier(const Calibration &calib) {
|
||||
require_mat(calib.r, 3, 3, CV_64F, "r");
|
||||
require_mat(calib.t, 3, 1, CV_64F, "t");
|
||||
|
||||
cv::Mat R1, R2, P1, P2;
|
||||
cv::Mat R2, P1, P2;
|
||||
cv::stereoRectify(calib.k_left, calib.d_left, calib.k_right, calib.d_right,
|
||||
calib.image_size, calib.r, calib.t, R1, R2, P1, P2, q_,
|
||||
calib.image_size, calib.r, calib.t, r1_, R2, P1, P2, q_,
|
||||
cv::CALIB_ZERO_DISPARITY, /*alpha=*/0);
|
||||
|
||||
cv::initUndistortRectifyMap(calib.k_left, calib.d_left, R1, P1,
|
||||
cv::initUndistortRectifyMap(calib.k_left, calib.d_left, r1_, P1,
|
||||
calib.image_size, CV_16SC2, map_lx_, map_ly_);
|
||||
cv::initUndistortRectifyMap(calib.k_right, calib.d_right, R2, P2,
|
||||
calib.image_size, CV_16SC2, map_rx_, map_ry_);
|
||||
@ -81,6 +83,51 @@ StereoRectifier::rectify(const cv::Mat &left, const cv::Mat &right) const {
|
||||
return {rect_left, rect_right};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StereoRectifier::rectify_left_point_map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
cv::Mat
|
||||
StereoRectifier::rectify_left_point_map(const cv::Mat &point_map) const {
|
||||
if (point_map.type() != CV_32FC3) {
|
||||
throw std::invalid_argument("point_map must be CV_32FC3, got type=" +
|
||||
std::to_string(point_map.type()));
|
||||
}
|
||||
if (point_map.size() != map_lx_.size()) {
|
||||
throw std::invalid_argument(
|
||||
"point_map dimensions must match the calibration image size");
|
||||
}
|
||||
|
||||
const float nan = std::numeric_limits<float>::quiet_NaN();
|
||||
cv::Mat rectified;
|
||||
cv::remap(point_map, rectified, map_lx_, map_ly_, cv::INTER_NEAREST,
|
||||
cv::BORDER_CONSTANT, cv::Scalar(nan, nan, nan));
|
||||
|
||||
cv::Mat rotation;
|
||||
r1_.convertTo(rotation, CV_32F);
|
||||
for (int row = 0; row < rectified.rows; ++row) {
|
||||
for (int column = 0; column < rectified.cols; ++column) {
|
||||
auto &point = rectified.at<cv::Vec3f>(row, column);
|
||||
if (!std::isfinite(point[0]) || !std::isfinite(point[1]) ||
|
||||
!std::isfinite(point[2])) {
|
||||
point = cv::Vec3f(nan, nan, nan);
|
||||
continue;
|
||||
}
|
||||
|
||||
point = cv::Vec3f(rotation.at<float>(0, 0) * point[0] +
|
||||
rotation.at<float>(0, 1) * point[1] +
|
||||
rotation.at<float>(0, 2) * point[2],
|
||||
rotation.at<float>(1, 0) * point[0] +
|
||||
rotation.at<float>(1, 1) * point[1] +
|
||||
rotation.at<float>(1, 2) * point[2],
|
||||
rotation.at<float>(2, 0) * point[0] +
|
||||
rotation.at<float>(2, 1) * point[1] +
|
||||
rotation.at<float>(2, 2) * point[2]);
|
||||
}
|
||||
}
|
||||
return rectified;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StereoRectifier::q
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -98,8 +98,13 @@ void crpc_init_with_address(const char *ip, int port) {
|
||||
}
|
||||
|
||||
void crpc_deinit() {
|
||||
if (server)
|
||||
if (server) {
|
||||
// Must stop() before join(): stop() clears running_ so the accept
|
||||
// thread can exit; join() alone would block forever while a client
|
||||
// thread is blocked in a read (deadlock observed in Unity e2e).
|
||||
server->stop();
|
||||
server->join();
|
||||
}
|
||||
server.reset();
|
||||
std::lock_guard lock(gc_mtx);
|
||||
gc.clear();
|
||||
|
||||
@ -18,8 +18,10 @@ if opencv_dep.found()
|
||||
'test_stereo_matcher.cpp',
|
||||
'test_stereo_rectifier.cpp',
|
||||
'test_point_cloud_builder.cpp',
|
||||
'test_point_cloud_evaluator.cpp',
|
||||
'test_cloud_point_client.cpp',
|
||||
'test_scared_dataset.cpp'
|
||||
'test_scared_dataset.cpp',
|
||||
'test_scared_ground_truth_loader.cpp'
|
||||
)
|
||||
test_deps += [cloud_point_compute_dep]
|
||||
else
|
||||
|
||||
101
tests/test_point_cloud_evaluator.cpp
Normal file
101
tests/test_point_cloud_evaluator.cpp
Normal file
@ -0,0 +1,101 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
#include <opencv2/core.hpp>
|
||||
|
||||
#include "cloud_point/point_cloud_evaluator.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
score::PointCloud make_cloud(const cv::Mat &points) {
|
||||
score::PointCloud cloud;
|
||||
cloud.width = points.cols;
|
||||
cloud.height = points.rows;
|
||||
cloud.data.reserve(static_cast<std::size_t>(points.total()) * 3u);
|
||||
for (int row = 0; row < points.rows; ++row) {
|
||||
for (int column = 0; column < points.cols; ++column) {
|
||||
const auto point = points.at<cv::Vec3f>(row, column);
|
||||
cloud.data.push_back(point[0]);
|
||||
cloud.data.push_back(point[1]);
|
||||
cloud.data.push_back(point[2]);
|
||||
}
|
||||
}
|
||||
return cloud;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(PointCloudEvaluatorTest, ExactPredictionHasPerfectMetrics) {
|
||||
const cv::Mat truth(1, 2, CV_32FC3, cv::Scalar(0.1f, 0.2f, 0.3f));
|
||||
const auto metrics =
|
||||
score::PointCloudEvaluator::evaluate(make_cloud(truth), truth);
|
||||
|
||||
EXPECT_EQ(metrics.ground_truth_points, 2u);
|
||||
EXPECT_EQ(metrics.matched_points, 2u);
|
||||
EXPECT_DOUBLE_EQ(metrics.coverage, 1.0);
|
||||
EXPECT_DOUBLE_EQ(metrics.rmse_3d_m, 0.0);
|
||||
EXPECT_DOUBLE_EQ(metrics.within_1mm, 1.0);
|
||||
EXPECT_DOUBLE_EQ(metrics.within_2mm, 1.0);
|
||||
EXPECT_DOUBLE_EQ(metrics.within_5mm, 1.0);
|
||||
}
|
||||
|
||||
TEST(PointCloudEvaluatorTest, MeasuresFullXyzEuclideanError) {
|
||||
cv::Mat truth(1, 1, CV_32FC3, cv::Scalar(0.0f, 0.0f, 1.0f));
|
||||
cv::Mat prediction(1, 1, CV_32FC3, cv::Scalar(0.001f, 0.002f, 1.002f));
|
||||
|
||||
const auto metrics =
|
||||
score::PointCloudEvaluator::evaluate(make_cloud(prediction), truth);
|
||||
const double expected = 0.003;
|
||||
|
||||
EXPECT_NEAR(metrics.mae_x_m, 0.001, 1e-7);
|
||||
EXPECT_NEAR(metrics.mae_y_m, 0.002, 1e-7);
|
||||
EXPECT_NEAR(metrics.mae_z_m, 0.002, 1e-7);
|
||||
EXPECT_NEAR(metrics.mae_3d_m, expected, 1e-7);
|
||||
EXPECT_NEAR(metrics.rmse_3d_m, expected, 1e-7);
|
||||
EXPECT_DOUBLE_EQ(metrics.within_2mm, 0.0);
|
||||
EXPECT_DOUBLE_EQ(metrics.within_5mm, 1.0);
|
||||
}
|
||||
|
||||
TEST(PointCloudEvaluatorTest, MissingPredictionReducesCoverageAndAccuracy) {
|
||||
cv::Mat truth(1, 2, CV_32FC3, cv::Scalar(0.0f, 0.0f, 1.0f));
|
||||
cv::Mat prediction = truth.clone();
|
||||
const float nan = std::numeric_limits<float>::quiet_NaN();
|
||||
prediction.at<cv::Vec3f>(0, 1) = cv::Vec3f(nan, nan, nan);
|
||||
|
||||
const auto metrics =
|
||||
score::PointCloudEvaluator::evaluate(make_cloud(prediction), truth);
|
||||
|
||||
EXPECT_EQ(metrics.ground_truth_points, 2u);
|
||||
EXPECT_EQ(metrics.matched_points, 1u);
|
||||
EXPECT_DOUBLE_EQ(metrics.coverage, 0.5);
|
||||
EXPECT_DOUBLE_EQ(metrics.within_1mm, 0.5);
|
||||
}
|
||||
|
||||
TEST(PointCloudEvaluatorTest, NoPredictionsReportsNaNErrors) {
|
||||
cv::Mat truth(1, 1, CV_32FC3, cv::Scalar(0.0f, 0.0f, 1.0f));
|
||||
const float nan = std::numeric_limits<float>::quiet_NaN();
|
||||
cv::Mat prediction(1, 1, CV_32FC3, cv::Scalar(nan, nan, nan));
|
||||
|
||||
const auto metrics =
|
||||
score::PointCloudEvaluator::evaluate(make_cloud(prediction), truth);
|
||||
|
||||
EXPECT_DOUBLE_EQ(metrics.coverage, 0.0);
|
||||
EXPECT_TRUE(std::isnan(metrics.mae_3d_m));
|
||||
EXPECT_TRUE(std::isnan(metrics.rmse_3d_m));
|
||||
}
|
||||
|
||||
TEST(PointCloudEvaluatorTest, IgnoresUnknownGroundTruthPixels) {
|
||||
const float nan = std::numeric_limits<float>::quiet_NaN();
|
||||
cv::Mat truth(1, 2, CV_32FC3);
|
||||
truth.at<cv::Vec3f>(0, 0) = cv::Vec3f(0.0f, 0.0f, 1.0f);
|
||||
truth.at<cv::Vec3f>(0, 1) = cv::Vec3f(nan, nan, nan);
|
||||
cv::Mat prediction(1, 2, CV_32FC3, cv::Scalar(0.0f, 0.0f, 1.0f));
|
||||
|
||||
const auto metrics =
|
||||
score::PointCloudEvaluator::evaluate(make_cloud(prediction), truth);
|
||||
EXPECT_EQ(metrics.ground_truth_points, 1u);
|
||||
EXPECT_EQ(metrics.matched_points, 1u);
|
||||
EXPECT_DOUBLE_EQ(metrics.coverage, 1.0);
|
||||
}
|
||||
79
tests/test_scared_ground_truth_loader.cpp
Normal file
79
tests/test_scared_ground_truth_loader.cpp
Normal file
@ -0,0 +1,79 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include "cloud_point/scared_ground_truth_loader.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
class ScaredGroundTruthLoaderTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
const auto suffix =
|
||||
std::chrono::steady_clock::now().time_since_epoch().count();
|
||||
directory_ = std::filesystem::temp_directory_path() /
|
||||
("scared-ground-truth-" + std::to_string(suffix));
|
||||
std::filesystem::create_directories(directory_);
|
||||
}
|
||||
|
||||
void TearDown() override { std::filesystem::remove_all(directory_); }
|
||||
|
||||
void write_obj(const std::string &contents) const {
|
||||
std::ofstream output(directory_ / "point_cloud.obj");
|
||||
output << contents;
|
||||
}
|
||||
|
||||
std::filesystem::path directory_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_F(ScaredGroundTruthLoaderTest, PreservesOrderNaNsAndConvertsToMetres) {
|
||||
write_obj("# two by two point map\n"
|
||||
"v 1000 2000 3000\n"
|
||||
"v nan nan nan\n"
|
||||
"v -500 0 250\n"
|
||||
"v 1 2 3\n"
|
||||
"f 1 3 4\n");
|
||||
|
||||
const score::ScaredGroundTruthLoader loader(directory_.string(),
|
||||
cv::Size(2, 2));
|
||||
const cv::Mat &points = loader.point_map();
|
||||
|
||||
EXPECT_EQ(points.type(), CV_32FC3);
|
||||
EXPECT_EQ(points.size(), cv::Size(2, 2));
|
||||
EXPECT_EQ(loader.valid_point_count(), 3u);
|
||||
|
||||
const cv::Vec3f first = points.at<cv::Vec3f>(0, 0);
|
||||
EXPECT_FLOAT_EQ(first[0], 1.0f);
|
||||
EXPECT_FLOAT_EQ(first[1], 2.0f);
|
||||
EXPECT_FLOAT_EQ(first[2], 3.0f);
|
||||
|
||||
const cv::Vec3f missing = points.at<cv::Vec3f>(0, 1);
|
||||
EXPECT_TRUE(std::isnan(missing[0]));
|
||||
EXPECT_TRUE(std::isnan(missing[1]));
|
||||
EXPECT_TRUE(std::isnan(missing[2]));
|
||||
|
||||
const cv::Vec3f third = points.at<cv::Vec3f>(1, 0);
|
||||
EXPECT_FLOAT_EQ(third[0], -0.5f);
|
||||
EXPECT_FLOAT_EQ(third[1], 0.0f);
|
||||
EXPECT_FLOAT_EQ(third[2], 0.25f);
|
||||
}
|
||||
|
||||
TEST_F(ScaredGroundTruthLoaderTest, RejectsIncorrectVertexCount) {
|
||||
write_obj("v 1 2 3\n");
|
||||
EXPECT_THROW(
|
||||
score::ScaredGroundTruthLoader(directory_.string(), cv::Size(2, 2)),
|
||||
std::runtime_error);
|
||||
}
|
||||
|
||||
TEST_F(ScaredGroundTruthLoaderTest, RejectsInvalidScale) {
|
||||
write_obj("v 1 2 3\n");
|
||||
EXPECT_THROW(score::ScaredGroundTruthLoader(directory_.string(),
|
||||
cv::Size(1, 1), 0.0f),
|
||||
std::invalid_argument);
|
||||
}
|
||||
@ -207,6 +207,31 @@ TEST(StereoRectifierTest, RectifyNearIdentityWithZeroDistortion) {
|
||||
<< " — expected near-identity for zero-distortion identical cameras";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ground-truth point-map rectification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST(StereoRectifierTest, RectifyLeftPointMapPreservesIdentityGeometry) {
|
||||
score::StereoRectifier rectifier(make_calib());
|
||||
cv::Mat points(kHeight, kWidth, CV_32FC3, cv::Scalar(0.1f, -0.2f, 1.5f));
|
||||
|
||||
const cv::Mat rectified = rectifier.rectify_left_point_map(points);
|
||||
|
||||
EXPECT_EQ(rectified.type(), CV_32FC3);
|
||||
EXPECT_EQ(rectified.size(), points.size());
|
||||
const cv::Vec3f center = rectified.at<cv::Vec3f>(kHeight / 2, kWidth / 2);
|
||||
EXPECT_NEAR(center[0], 0.1f, 1e-6f);
|
||||
EXPECT_NEAR(center[1], -0.2f, 1e-6f);
|
||||
EXPECT_NEAR(center[2], 1.5f, 1e-6f);
|
||||
}
|
||||
|
||||
TEST(StereoRectifierTest, RectifyLeftPointMapRejectsWrongType) {
|
||||
score::StereoRectifier rectifier(make_calib());
|
||||
cv::Mat points(kHeight, kWidth, CV_32FC1, cv::Scalar(1.0f));
|
||||
EXPECT_THROW(rectifier.rectify_left_point_map(points),
|
||||
std::invalid_argument);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invalid calibration → throws
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user