Compare commits

..

16 Commits

Author SHA1 Message Date
4a40c38686 workflow build&test 3 2026-03-02 21:15:12 +03:00
9955c1986c workflow build&test 2
All checks were successful
Verification / Is-Buildable (push) Successful in 2m5s
2026-03-02 21:09:12 +03:00
15fb311c66 workflow build&test
Some checks failed
Verification / Is-Buildable (push) Failing after 1m52s
2026-03-02 21:00:34 +03:00
5c5b886360 build test 12
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m50s
2026-03-02 20:46:39 +03:00
1eea074051 build test 11
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 4s
2026-03-02 20:44:38 +03:00
626cfa64f2 build test 10
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 1m6s
2026-03-02 20:38:50 +03:00
00da1c9f32 build test 9
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 1m6s
2026-03-02 20:36:19 +03:00
c5ede14eaf build test 8
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 13s
2026-03-02 20:35:18 +03:00
981568f104 build test 7
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 39s
2026-03-02 20:32:48 +03:00
638c565702 build test 6
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 27s
2026-03-02 20:31:46 +03:00
81f8f709a2 build test 5
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 11s
2026-03-02 20:31:01 +03:00
6fea0e2450 build test 4
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 21s
2026-03-02 20:27:36 +03:00
e881b6b699 build test 3
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 20s
2026-03-02 20:26:30 +03:00
2157b25a95 build test 2
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 6s
2026-03-02 20:25:25 +03:00
f2dfee7a38 build test
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 10s
2026-03-02 20:21:48 +03:00
5afbf771ca test
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m27s
2026-03-02 20:16:25 +03:00
103 changed files with 415 additions and 11130 deletions

View File

@ -1,4 +0,0 @@
build*/
datasets/
*.ply
*.tar.gz

View File

@ -3,7 +3,7 @@ run-name: ${{ gitea.actor }} runs verification of the project
on: on:
push: push:
branches: branches:
- "master" - 'master'
jobs: jobs:
Is-Buildable: Is-Buildable:
@ -22,12 +22,12 @@ jobs:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
submodules: "true" submodules: 'true'
- name: Build project - name: Build project
run: | run: |
cd ${{ gitea.workspace }} cd ${{ gitea.workspace }}
meson setup build meson setup build
meson compile -C build -j2 meson compile -C build
- name: Unit Test Results - name: Unit Test Results
run: | run: |
meson test -C build meson test -C build

View File

@ -1,51 +0,0 @@
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.

14
.gitignore vendored
View File

@ -6,18 +6,4 @@ subprojects/googletest-*
subprojects/nlohmann_json/ subprojects/nlohmann_json/
subprojects/packagecache/ subprojects/packagecache/
subprojects/yaml-cpp-0.8.0 subprojects/yaml-cpp-0.8.0
subprojects/base64-0.5.2/
subprojects/stdexec/
subprojects/.*
html/
latex/
large_tool_results/
.venv/ .venv/
.worktrees/
# IDE
.idea/
# Point-cloud exports
*.ply
build-docker/

View File

@ -1,99 +0,0 @@
---
name: commit-task-tags
description: Manage kanban task tags (TG-<NUMBER> #<state>) in git commit messages. Use when creating, squashing, or rebasing commits to ensure task state trailers are correct.
---
# Commit Task Tags
This project tracks kanban board tasks inside git commit messages using trailer
lines in the format:
```
TG-<NUMBER> #<state>
```
Multiple trailers may appear in a single commit (one per related task). Trailers
go at the bottom of the commit body, separated from the prose by a blank line.
## States
| State | Meaning |
|-------|---------|
| `new` | Task created on the board; no work started yet. |
| `ready` | Task is defined and ready to be picked up. |
| `in-progress` | This commit contributes to the task, but the full task/epic is **not** yet implemented. Use for every intermediate commit. |
| `ready-for-test` | The **full implementation** of the task/epic is completed across all its commits. Place on the final commit of the task. |
| `done` | Property of the **full task**, not of an individual commit. Reserved for final board confirmation after testing. **Never** use `#done` in a code commit. |
### Key rules
1. **`#done` is not for commits.** A commit can at most mark a task
`#ready-for-test`. The `#done` state is set on the kanban board after
verification, not in git.
2. **Multi-commit epics use `#in-progress` then `#ready-for-test`.** Every
intermediate commit for a task carries `#in-progress`. Only the final commit
that completes the implementation switches to `#ready-for-test`.
3. **Single-commit tasks use `#ready-for-test`.** If a task is fully implemented
in one commit, that commit carries `#ready-for-test` (not `#done`).
4. **One task may span many commits.** Add the same `TG-<N>` trailer to every
commit that touches that task's work, updating the state as appropriate.
## Example commit
```
feat(cloud_point): stereo rectification and point cloud pipeline
- StereoRectifier wrapping cv::stereoRectify
- PointCloudBuilder with depth filtering
- E2E synthetic-scene test
TG-9 #ready-for-test
TG-2 #in-progress
```
## Adding tags to existing commits
### During interactive rebase (recommended)
Use `git rebase -i` with `edit` stops, then amend each commit:
```bash
# Strip old TG lines and append new ones in one command:
git log -1 --format=%B \
| sed '/^TG-[0-9]/d' \
| { cat; printf '\nTG-<N> #<state>\n'; } \
| git commit --amend --no-verify -F -
git rebase --continue
```
> **Avoid `reword`** with a shared `GIT_EDITOR` script — it can shift tags by one
> commit. Use `edit` stops with explicit `git commit --amend` instead.
### Helper script
A reusable script lives at `scripts/tag-commit.sh` in this skill directory.
```bash
# At a rebase edit stop, add or replace tags:
../scripts/tag-commit.sh "TG-5 #ready-for-test" "TG-2 #in-progress"
git rebase --continue
```
The script strips any existing `TG-*` lines from the current commit message,
appends the supplied trailers, and amends the commit.
## Squashing commits
When squashing many commits into fewer logical commits, reassign task tags to
the resulting squashed commits following the same state rules. After squashing:
1. Identify which tasks each squashed commit covers.
2. Mark intermediate squash commits `#in-progress` for tasks that continue in
later squash commits.
3. Mark the final squash commit for a task `#ready-for-test`.
Use `git reset --hard <group-end>` + `git reset --soft <prev-group-commit>` to
snapshot each group's tree, then `git commit` with the appropriate trailers.

View File

@ -1,26 +0,0 @@
#!/bin/sh
# tag-commit.sh — Add or replace TG-<NUMBER> #<state> trailers on the current commit.
#
# Usage (at a rebase edit stop or any HEAD you want to amend):
# ./tag-commit.sh "TG-5 #ready-for-test" "TG-2 #in-progress"
#
# Strips existing TG-* lines, appends the given trailers, and amends the commit.
set -eu
if [ "$#" -eq 0 ]; then
echo "Usage: $0 \"TG-<N> #<state>\" [\"TG-<N> #<state>\" ...]" >&2
exit 1
fi
# Build the trailer block from arguments.
trailers=""
for tag in "$@"; do
trailers="${trailers}${tag}"$'\n'
done
# Strip existing TG-* lines, append new trailers, amend commit.
git log -1 --format=%B \
| sed '/^TG-[0-9]/d' \
| { cat; printf '\n%s' "$trailers"; } \
| git commit --amend --no-verify -F -

View File

@ -1,14 +0,0 @@
---
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*`.

View File

@ -1,6 +1,6 @@
# Cloud Point RPC Agent Guide # Cloud Point RPC Agent Guide
This repository contains a C++23 implementation of a JSON RPC protocol for communicating with a Unity Scene. This repository contains a C++20 implementation of a JSON RPC protocol for communicating with a Unity Scene.
Agents working on this codebase must adhere to the following guidelines and conventions. Agents working on this codebase must adhere to the following guidelines and conventions.
## 1. Build, Lint, and Test ## 1. Build, Lint, and Test
@ -54,10 +54,10 @@ The project uses the **Meson** build system.
## 2. Code Style & Conventions ## 2. Code Style & Conventions
Adhere strictly to **Modern C++23** standards. Adhere strictly to **Modern C++20** standards.
### General Guidelines ### General Guidelines
- **Standard:** C++23. Use concepts, ranges, `std::expected`, and smart pointers. Avoid raw `new`/`delete`. - **Standard:** C++20. Use concepts, ranges, and smart pointers. Avoid raw `new`/`delete`.
- **Memory Management:** Use `std::unique_ptr` and `std::shared_ptr`. - **Memory Management:** Use `std::unique_ptr` and `std::shared_ptr`.
- **Const Correctness:** Use `const` (and `constexpr`/`consteval`) whenever possible. - **Const Correctness:** Use `const` (and `constexpr`/`consteval`) whenever possible.
- **Includes:** Use absolute paths for project headers (e.g., `#include "rpc/server.hpp"`). - **Includes:** Use absolute paths for project headers (e.g., `#include "rpc/server.hpp"`).
@ -66,18 +66,16 @@ Adhere strictly to **Modern C++23** standards.
### Naming Conventions ### Naming Conventions
- **Files:** `snake_case.cpp`, `snake_case.hpp`. - **Files:** `snake_case.cpp`, `snake_case.hpp`.
- **Classes/Structs:** `PascalCase`. - **Classes/Structs:** `PascalCase`.
- **Functions/Methods:** `snake_case`. - **Functions/Methods:** `snake_case` (or `camelCase` if adhering strictly to a specific external library style, but default to snake_case).
- **Variables:** `snake_case`. - **Variables:** `snake_case`.
- **Private Members:** `snake_case_` (trailing underscore). - **Private Members:** `snake_case_` (trailing underscore).
- **Constants:** `kPascalCase` or `ALL_CAPS` for macros. - **Constants:** `kPascalCase` or `ALL_CAPS` for macros (avoid macros).
- **Namespaces:** `score` (primary project namespace). - **Namespaces:** `snake_case`.
- **Interfaces:** `IPascalCase`. - **Interfaces:** `IPascalCase` (optional, but consistent if used).
### Project Structure ### Project Structure
- `include/cloud_point_rpc/`: Public header files (RPC server/client, TCP, config, serialization, coder, DTOs). - `include/cloud_point_rpc/`: Public header files.
- `include/cloud_point/`: OpenCV compute library headers (StereoRectifier, PointCloudBuilder, CloudPointClient). Optional; requires opencv4. - `src/`: Implementation files.
- `src/`: Implementation files and executable entrypoints.
- `src/cloud_point/`: OpenCV compute library implementation (optional, requires opencv4).
- `tests/`: Unit and integration tests. - `tests/`: Unit and integration tests.
- `subprojects/`: Meson wrap files for dependencies. - `subprojects/`: Meson wrap files for dependencies.
- `meson.build`: Build configuration. - `meson.build`: Build configuration.
@ -94,7 +92,7 @@ Adhere strictly to **Modern C++23** standards.
### Example Class ### Example Class
```cpp ```cpp
namespace score { namespace cloud_point_rpc {
/// @brief Manages camera parameters. /// @brief Manages camera parameters.
class CameraController { class CameraController {
@ -116,19 +114,14 @@ class CameraController {
std::vector<double> cached_intrinsics_; std::vector<double> cached_intrinsics_;
}; };
} // namespace score } // namespace cloud_point_rpc
``` ```
### Implementation Details ### Implementation Details
- **JSON Library:** Use `nlohmann/json` (likely via `subprojects/nlohmann_json.wrap`). - **JSON Library:** Use `nlohmann/json` (likely via `subprojects/nlohmann_json.wrap`).
- **Concurrency:** Use `std::jthread` (auto-joining) over `std::thread`. - **Concurrency:** Use `std::jthread` (auto-joining) over `std::thread`.
- **Error handling:** Use `std::expected<T, E>` (C++23) for recoverable errors in the `cloud_point` compute library. - **RPC Methods:**
- **RPC Methods** (served by Unity or the C++ mock in `server_main.cpp`): - Implement handlers for: `get-cloud-point`, `get-intrinsic-params`, `get-extrinsic-params`.
- `get-available-methods` — list registered method names.
- `get-stereo-calibration` — full stereo rig calibration (intrinsics, R, T, image size).
- `get-image-pair` — synchronised stereo frame as two base64-encoded images.
- `get-intrinsic-params` *(legacy)* — left-camera intrinsic matrix (9 doubles).
- `get-extrinsic-params` *(legacy)* — left-camera extrinsic matrix (16 doubles).
- Ensure thread safety if the RPC server is multi-threaded. - Ensure thread safety if the RPC server is multi-threaded.
## 3. Workflow & Git ## 3. Workflow & Git
@ -150,24 +143,3 @@ class CameraController {
- **Verification:** Write unit tests for new features in `tests/`. - **Verification:** Write unit tests for new features in `tests/`.
- **Refactoring:** When refactoring, ensure existing behavior is preserved via tests. - **Refactoring:** When refactoring, ensure existing behavior is preserved via tests.
- **Dependencies:** Do not introduce new dependencies without updating `meson.build` and `subprojects/`. - **Dependencies:** Do not introduce new dependencies without updating `meson.build` and `subprojects/`.
## OpenWiki
This repository has documentation located in the /openwiki directory.
Start here:
- [OpenWiki quickstart](openwiki/quickstart.md)
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 -->

189
API.md
View File

@ -15,7 +15,7 @@ All requests and responses are JSON objects.
"id": <integer|string> "id": <integer|string>
} }
``` ```
*`params` is currently ignored by all handlers but is valid per JSON-RPC 2.0.* *Note: `params` is currently ignored by the implemented methods but is part of the standard.*
### Response (Success) ### Response (Success)
```json ```json
@ -40,153 +40,86 @@ All requests and responses are JSON objects.
--- ---
## Conventions ## Methods
- **Matrices:** Row-major storage. A 3×3 matrix `M` with rows `[r0, r1, r2]` serialises as a flat 9-element JSON array `[r0[0], r0[1], r0[2], r1[0], ...]`. ### `get-intrinsic-params`
- **Units:** Translation in **metres**. No pixel units unless stated.
- **Extrinsics convention (OpenCV):** `x_right = R · x_left + T`. For a parallel rig with baseline `b`, `R = I` and `T = [-b, 0, 0]` (e.g. `[-0.06, 0, 0]` for a 6 cm baseline).
- **Image layout:** Top-left origin, row-major, packed channels (BGR order unless otherwise noted). Unity must flip GPU readback vertically before encoding.
- **Image data encoding:** Raw pixel bytes encoded as **Base64** (standard alphabet, no line breaks). The `type` field indicates channel layout.
- **Calibration arrays:** Plain JSON double arrays — **not** base64. Only image pixel data uses base64.
--- Retrieves the intrinsic camera parameters as a flat 3x3 matrix (row-major).
## Methods Served by the Unity Side
These methods are implemented server-side (in Unity, or in the C++ test mock in `src/server_main.cpp`).
---
### `get-available-methods`
Returns the list of method names registered on this server instance.
**Request:** **Request:**
```json ```json
{ "jsonrpc": "2.0", "method": "get-available-methods", "id": 0 }
```
**Response `result`:** `["method-name-1", "method-name-2", ...]`
---
### `get-stereo-calibration`
Returns full stereo rig calibration: intrinsics for both cameras, stereo rotation and translation.
**Request:**
```json
{ "jsonrpc": "2.0", "method": "get-stereo-calibration", "id": 1 }
```
**Response `result`:**
```json
{ {
"left": { "jsonrpc": "2.0",
"camera_matrix": [fx, 0, cx, 0, fy, cy, 0, 0, 1], "method": "get-intrinsic-params",
"dist_coeffs": [k1, k2, p1, p2, k3] "id": 1
},
"right": {
"camera_matrix": [fx, 0, cx, 0, fy, cy, 0, 0, 1],
"dist_coeffs": [k1, k2, p1, p2, k3]
},
"rotation": [r00, r01, r02, r10, r11, r12, r20, r21, r22],
"translation": [tx, ty, tz],
"image_size": { "width": 640, "height": 480 }
} }
``` ```
Field details: **Response:**
```json
{
"jsonrpc": "2.0",
"result": [
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0
],
"id": 1
}
```
*Type: `vector<double>` (size 9)*
| Field | Type | Size | Description | ### `get-extrinsic-params`
|-------|------|------|-------------|
| `camera_matrix` | `double[]` | 9 | Row-major 3×3 intrinsic matrix: `[fx, 0, cx, 0, fy, cy, 0, 0, 1]` |
| `dist_coeffs` | `double[]` | 5 | Radial/tangential coefficients `[k1, k2, p1, p2, k3]` |
| `rotation` | `double[]` | 9 | Row-major rotation matrix R (left-to-right frame, OpenCV convention) |
| `translation` | `double[]` | 3 | Translation vector in metres |
| `image_size` | object | — | Sensor resolution before any rectification |
**Test mock defaults:** fx=fy=800, cx=320, cy=240, zero distortion, R=identity, T=[-0.06, 0, 0], 640×480. Retrieves the extrinsic camera parameters as a flat 4x4 matrix (row-major).
---
### `get-image-pair`
Returns a synchronised stereo frame as two base64-encoded images.
**Request:** **Request:**
```json ```json
{ "jsonrpc": "2.0", "method": "get-image-pair", "id": 2 }
```
**Response `result`:**
```json
{ {
"frame": 42, "jsonrpc": "2.0",
"left": { "width": 640, "height": 480, "type": "BGR", "data": "<base64>" }, "method": "get-extrinsic-params",
"right": { "width": 640, "height": 480, "type": "BGR", "data": "<base64>" } "id": 2
} }
``` ```
Field details: **Response:**
| Field | Description |
|-------|-------------|
| `frame` | Monotonically increasing counter per server instance; wraps at `uint64_t` max. |
| `type` | Channel layout: `"BGR"` (3 ch), `"RGBA"` (4 ch), or `"DEPTH"` (1 ch float32). |
| `data` | Base64-encoded raw pixel bytes. Size = `width × height × channels`. |
Unity must supply images in top-left-origin row-major order; flip GPU readback vertically before encoding.
---
### `get-intrinsic-params` *(legacy)*
Returns left-camera intrinsic matrix as a flat 9-element double array.
**Request:**
```json
{ "jsonrpc": "2.0", "method": "get-intrinsic-params", "id": 3 }
```
**Response `result`:** `[fx, 0, cx, 0, fy, cy, 0, 0, 1]`
9 plain JSON doubles, row-major 3×3. **Not base64.**
---
### `get-extrinsic-params` *(legacy)*
Returns a flat 16-element double array (4×4 row-major extrinsic matrix).
**Request:**
```json
{ "jsonrpc": "2.0", "method": "get-extrinsic-params", "id": 4 }
```
**Response `result`:** `[r00, r01, r02, tx, r10, r11, r12, ty, r20, r21, r22, tz, 0, 0, 0, 1]`
16 plain JSON doubles. **Not base64.**
---
## Client-Side Outputs
These are computed locally by the C++ client (`RpcClient`) from data received via the methods above. They are **not** JSON-RPC methods callable on the server.
---
### `get-cloud-point` *(computed by client — spec in progress)*
Reconstructs a dense 3-D point cloud from the rectified stereo pair. The `RpcClient::get_cloud_point()` method will call `get-stereo-calibration` and `get-image-pair`, run stereo rectification and disparity computation locally (OpenCV SGBM or CUDA stereo), and reproject to 3-D.
**Expected future result shape** (subject to change in Phase 2):
```json ```json
{ {
"width": <int>, "jsonrpc": "2.0",
"height": <int>, "result": [
"data": "<base64-encoded float32 XYZ triplets, row-major>" 1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0
],
"id": 2
}
```
*Type: `vector<double>` (size 16)*
### `get-cloud-point`
Retrieves the current field of view point cloud.
**Request:**
```json
{
"jsonrpc": "2.0",
"method": "get-cloud-point",
"id": 3
} }
``` ```
`data` will encode `width × height × 3` little-endian `float32` values (x, y, z in metres per pixel, `NaN` for invalid/occluded depth). Full specification and encoding details are deferred to Phase 2. **Response:**
```json
{
"jsonrpc": "2.0",
"result": [
[0.1, 0.2, 0.3],
[1.1, 1.2, 1.3],
[5.5, 6.6, 7.7]
],
"id": 3
}
```
*Type: `vector<vector<double>>` (List of [x, y, z] points)*

View File

@ -1,9 +0,0 @@
<!-- 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 -->

View File

@ -1,39 +1,38 @@
# Build-environment image: contains the toolchain and dependencies only. # Use Ubuntu 24.04 as base (matching development environment)
# The source tree is bind-mounted at runtime and compiled inside the container:
#
# docker build -t cloud-point-rpc-dev .
# docker run -d --name cprpc-dev --network=host -v "$(pwd)":/app cloud-point-rpc-dev
# docker exec -it cprpc-dev meson setup build-docker
# docker exec -it cprpc-dev meson compile -C build-docker
# docker exec -it cprpc-dev ./build-docker/src/cloud_point_rpc_cli config.yml
#
# Use a dedicated build directory (build-docker) so host and container
# builds never share Meson state.
FROM ubuntu:24.04 FROM ubuntu:24.04
# Avoid interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
# - build-essential, meson, ninja-build, cmake, pkg-config: toolchain # Install dependencies
# - git: Meson subprojects (asio, json, yaml-cpp, glog, jsonrpccxx, gtest) # - build-essential: Compiler (gcc/g++)
# - libopencv-dev: OpenCV 4 incl. contrib modules (ximgproc for the WLS filter) # - meson/ninja-build: Build system
# - libunwind-dev: glog stack traces # - git: For fetching subprojects
RUN apt-get update && apt-get install -y --no-install-recommends \ # - pkg-config, cmake: For dependency resolution
# - libssl-dev: Often needed for cmake fetches/networking
RUN apt-get update && apt-get install -y \
build-essential \ build-essential \
meson \ meson \
ninja-build \ ninja-build \
cmake \
pkg-config \
git \ git \
pkg-config \
cmake \
ca-certificates \ ca-certificates \
libunwind-dev \ libunwind-dev \
libopencv-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# The mounted checkout is usually owned by a different uid than root inside # Set working directory
# the container; git refuses to touch it otherwise (Meson subprojects need git).
RUN git config --global --add safe.directory '*'
WORKDIR /app WORKDIR /app
# Keep the container alive; build and run via `docker exec`. # Copy project files
CMD ["tail", "-F", "/dev/null"] COPY . .
# Setup build directory and compile
# We allow git to fetch subprojects (glog, gtest, asio, etc.)
RUN meson setup build && \
meson compile -C build
# Run the cli by default
# We assume the config.yaml is in the root /app or we copy it.
# The build output is in build/src/cloud_point_rpc_cli
CMD ["./build/src/cloud_point_rpc_cli", "config.yaml"]

2868
Doxyfile

File diff suppressed because it is too large Load Diff

270
README.md
View File

@ -2,100 +2,35 @@
Communication JSON RPC protocol and implementation with Unity Scene. Communication JSON RPC protocol and implementation with Unity Scene.
## Project Structure ## TODO
- `include/`: Header files for the RPC server, TCP server, and C-API. - [ ] Server implementation with C-API for Unity
- `src/`: Implementation of the RPC logic, networking, and C-API. - [ ] Client correct implementation with OpenCV
- `src/cloud_point/`: OpenCV-based image processing and rectification logic.
- `docs/`: Documentation diagrams and models.
- `subprojects/`: Dependencies managed by Meson.
## Status
Done:
- [x] Server implementation with C-API for Unity
- [x] OpenCV stereo client (StereoRectifier, CPU/GPU matchers, PointCloudBuilder, CloudPointClient facade)
- [x] Tuned SGBM for the SCARED rig (P1/P2 penalties, uniqueness, speckle, LRC) and depth-range clipping
- [x] Binary PLY export with grid triangulation and `ply_stride` decimation
- [x] Optional WLS / median disparity post-filters (`wls_filter`, `CpuStereoMatcher::Params`)
- [x] SCARED benchmark with ground-truth metrics, depth PNGs and `scripts/scared_overview.py`
- [x] Unity-side C# implementation per [docs/unity-integration.md](docs/unity-integration.md)
To do:
- [ ] Remove sliver triangles at the border of the valid region (long spikes are still visible in
shaded viewers at `ply_stride: 4` because 5 % of local depth allows 3 mm edges)
- [ ] Visually compare raw vs WLS meshes in a shaded web viewer and decide the visualisation default
- [ ] Expose the remaining `CpuStereoMatcher::Params` (block size, uniqueness, speckle, median kernel,
WLS lambda/sigma) in the YAML `cloud_point` section
- [ ] Validate the CUDA `StereoSGM` path on a GPU machine (currently only exercised via CPU fallback)
- [ ] Reduce sub-pixel SGBM noise without the WLS accuracy loss (e.g. bilateral or guided filter on depth)
## API Documentation ## API Documentation
See [API.md](API.md) for detailed request/response formats. See [API.md](API.md) for detailed request/response formats.
## Pipeline
Unity acts as a data source: it serves stereo image pairs (`get-image-pair`) and full stereo calibration (`get-stereo-calibration`) over JSON-RPC 2.0. The C++ `CloudPointClient` calls `connect()` once to fetch calibration, then on each `compute_cloud()` call it fetches a synchronised image pair and runs the stages below.
| Stage | Class | Algorithm |
|-------|-------|-----------|
| Rectification | `StereoRectifier` | `cv::stereoRectify` + `initUndistortRectifyMap`/`remap`; also yields the Q reprojection matrix |
| Disparity (CPU) | `CpuStereoMatcher` | `cv::StereoSGBM` (semi-global block matching, `MODE_SGBM`, 16× fixed point) with optional median blur or `cv::ximgproc` WLS post-filter |
| Disparity (GPU) | `GpuStereoMatcher` | `cv::cuda::StereoSGM` (`MODE_HH4`, 64/128/256 disparity levels); default, falls back to CPU without CUDA |
| Reprojection | `PointCloudBuilder` | `cv::reprojectImageTo3D` with Q, then rejects disparity ≤ 0, OpenCV sentinels and depth outside `[min_depth_m, max_depth_m]` |
| Export | `write_ply` | Binary little-endian PLY with grid triangulation (edges ≤ 5 % of local depth) and optional `stride` block averaging |
`StereoMatcherFactory` picks the matcher from `CloudPointConfig::algorithm` and forwards `CpuStereoMatcher::Params`. Depth follows `z = fx·B / disparity`; on the SCARED rig that is about `4.45 m / disparity_px`, which is why the disparity range and depth limits in `config.scared.yml` matter.
See [API.md](API.md) for wire schemas and [docs/unity-integration.md](docs/unity-integration.md) for the Unity C# design spec.
## Development ## Development
The project uses **Meson** build system and **C++23**. The project uses **Meson** build system and **C++20**.
### Dependencies ### Dependencies
- Meson (>= 1.1.0), Ninja - Meson, Ninja
- GCC/Clang (C++23 support) - GCC/Clang (C++20 support)
- Git (for subprojects) - Git (for subprojects)
- OpenCV 4 (optional; required for stereo point cloud compute)
The following dependencies are managed via Meson subprojects:
- [ASIO](https://think-async.com/Asio/) (Networking)
- [nlohmann/json](https://github.com/nlohmann/json) (JSON serialization)
- [yaml-cpp](https://github.com/jbeder/yaml-cpp) (Configuration loading)
- [glog](https://github.com/google/glog) (Logging)
- [jsonrpccxx](https://github.com/uS-S/jsonrpccxx) (JSON-RPC 2.0 implementation)
### Build & Run ### Build & Run
```bash ```bash
git submodule init
git submodule update
meson setup build meson setup build
meson compile -C build meson compile -C build
./build/src/cloud_point_rpc_server config.yaml ./build/src/cloud_point_rpc_server config.yaml
``` ```
*Note: You need a `config.yaml` file. See `config.yaml.example` for the required format.*
Run the interactive CLI client:
```bash
./build/src/cloud_point_rpc_cli config.yaml
```
CLI menu options (OpenCV options are hidden when built without opencv4):
| Option | Action |
|--------|--------|
| 1 | List available RPC methods |
| 2 | Get intrinsic params (legacy) |
| 3 | Get extrinsic params (legacy) |
| 4 | Compute point cloud — prints point count and bounding box |
| 5 | Compute point cloud and save to `output.ply` |
| 0 | Exit |
#### Build on windows #### Build on windows
It's assumed that you have `GCC` and `make`/`ninja` installed on your system (and available in `PATH`) It's assumed that you have `GCC` and `make`/`ninja` installed on your system (and available in `PATH`)
@ -106,9 +41,7 @@ git submodule init
git submodule update git submodule update
# Next python: # Next python:
python3 -m venv .\venv python3 -m venv .\venv
.\venv\Scripts\Activate.ps1 .\.venv\Scripts\Activate.ps1
# or
.\venv\bin\Activate.ps1
pip install meson cmake pip install meson cmake
meson setup -Ddefault_library=static build meson setup -Ddefault_library=static build
meson compile -C build meson compile -C build
@ -126,188 +59,27 @@ meson test -C build -v
## Docker ## Docker
The `Dockerfile` builds a **development environment** image only: toolchain You can build and run the cli using `Docker`.
(GCC, Meson, Ninja, CMake), git for the Meson subprojects and OpenCV 4 with
contrib modules. Nothing is compiled at image build time. The source tree is
bind-mounted into the running container and compiled there, so edits on the
host are picked up immediately and the build artefacts land in your checkout.
### 1. Build the environment image ### 1. Build Image
```bash ```bash
docker build -t cloud-point-rpc-dev . docker build -t cloud-point-rpc .
``` ```
### 2. Start the container with the source mounted ### 2. Run Container
The cli will try to connect to a **running server** on ip and port defined in config.yml file. (defined in `config.yaml` inside the image).
For simplicity, it's better to use a host network, so you will not have any headache with accessability.
> _Server is not configured to run through container, if you need, contact me_
You also can mount your own `config.yaml` to override the default settings:
```bash ```bash
docker run -d --name cprpc-dev --network=host -v "$(pwd)":/app cloud-point-rpc-dev docker run --network=host -it -v $(pwd)/my_config.yaml:/app/config.yaml cloud-point-rpc
``` ```
The container idles (`tail -F /dev/null`); `--network=host` lets the CLI
reach a server running on the host and lets the server be reached from Unity.
### 3. Build and run inside the container
```bash
docker exec -it cprpc-dev meson setup build-docker
docker exec -it cprpc-dev meson compile -C build-docker
docker exec -it cprpc-dev meson test -C build-docker
docker exec -it cprpc-dev ./build-docker/src/cloud_point_rpc_cli config.yml
```
Use a dedicated build directory such as `build-docker`: Meson stores absolute
compiler paths, so a build directory configured on the host cannot be reused
inside the container and vice versa. With rootless Docker the container's
root maps to your host user, so `build-docker/` stays owned by you; with a
rootful daemon add `--user "$(id -u):$(id -g)"` to `docker run` to avoid
root-owned build files.
## Validation with SCARED Dataset
The `scared_dataset_server` executable lets you validate the stereo point-cloud
pipeline against real endoscopic images from the
[SCARED dataset](https://huggingface.co/datasets/maxhallan7/scared).
### Obtaining the data
1. Download `test_dataset_8.zip` from
<https://huggingface.co/datasets/maxhallan7/scared>.
2. Extract so that `keyframe_0/` through `keyframe_4/` exist under
`test_dataset_8/`.
Each keyframe directory contains:
- `Left_Image.png`, `Right_Image.png` — 1280×1024 unrectified RGBA images.
- `endoscope_calibration.yaml` — OpenCV FileStorage with `M1`, `D1`, `M2`,
`D2`, `R`, `T` nodes.
**Note:** `T` is stored in **millimetres** in the YAML file (baseline ≈ 4.35 mm).
`scared_dataset_server` divides `T` by 1000 before placing it on the wire
(the wire protocol uses metres).
### Running the server
```bash
./build/src/cloud_point/scared_dataset_server \
/path/to/test_dataset_8/keyframe_0 8080
```
If port 8080 is already taken on your machine (Docker's `rootlesskit`
commonly holds it) pass another port and update `server.port` in the CLI
config accordingly. Connecting the CLI to a foreign service on 8080 shows up
as `invalid JSON response from server` / `std::bad_alloc` errors.
### Connecting with the CLI
In a second terminal run the interactive CLI with the SCARED-tuned config:
```bash
./build/src/cloud_point_rpc_cli config.scared.yml
# Option 4 — compute point cloud and print valid point count + bounding box
# Option 5 — compute point cloud and save a triangulated PLY mesh
```
`config.scared.yml` sets the optional `cloud_point` section that options 4/5
honour:
```yaml
cloud_point:
algorithm: cpu # "gpu" falls back to CPU when CUDA is unavailable
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
roughly `4.45 m / disparity_px`, so any mismatch with a disparity below
~15 px reprojects metres away. Without the section the CLI falls back to the
generic defaults (GPU, 128 disparities, 0.0110 m). The SGBM matcher itself
is configured with OpenCV's recommended smoothness penalties (P1 = 8·bs²,
P2 = 32·bs²), a 5×5 block, uniqueness ratio 10, speckle filtering and a
left-right consistency check; see `CpuStereoMatcher::Params`.
### Checking the result
Option 4 should report a bounding box with z inside roughly
`[0.03, 0.16]` m for `test_dataset_8` keyframes. Option 5 writes a binary
little-endian PLY containing every valid point **and** a mesh triangulated
from the pixel grid (triangles are dropped where the longest edge exceeds 5 % of the local
depth, so the mesh breaks at occlusions). The mesh is what makes web viewers
usable: viewers such as Meshy's online PLY viewer fabricate a triangle from
every three consecutive vertices of a vertex-only PLY, which draws long
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/dataset_1/keyframe_1 160 [depth.png|-] [min_depth_m max_depth_m]
```
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_*
```
Ground-truth OBJ coordinates are converted from millimetres to metres and
rectified into the same left-camera frame as the reconstructed cloud before
evaluation. Reference numbers with the tuned SGBM configuration and the
0.020.30 m depth range (raw disparity, no WLS):
| Keyframe | Valid points | MAE₃D | RMSE₃D | Within 2 mm |
|----------|--------------|-------|--------|-------------|
| dataset_1 kf1 | 84.6 % | 0.84 mm | 1.43 mm | 80 % |
| dataset_1 kf2 | 86.3 % | 1.15 mm | 2.05 mm | 76 % |
| dataset_1 kf3 | 86.9 % | 1.09 mm | 8.15 mm | 76 % |
| dataset_1 kf4 | 84.7 % | 0.66 mm | 1.26 mm | 85 % |
| dataset_1 kf5 | 84.6 % | 0.79 mm | 1.51 mm | 77 % |
| dataset_2 kf1 | 72 % | 1.10 mm | 3.76 mm | — (near tissue at the disparity limit) |
| dataset_3 kf1 | 83.6 % | 1.75 mm | 4.06 mm | 67 % |
| test_dataset_8 kf04 | 7986 % | no ground truth; median depth 56115 mm | | |
The previous unregularised SGBM gave MAE₃D ≈ 44 mm and RMSE₃D ≈ 289 mm on
`dataset_1/keyframe_1`. Matching takes ~325 ms per 1280×1024 frame on the
CPU (~650 ms with WLS).
The E2E test (`tests/test_scared_dataset.cpp`) exercises the same pipeline
with `num_disparities = 160` and asserts >50 000 valid points and a median
depth in `[0.02, 0.20]` m.
## Communication model ## Communication model
![Communicatoin model plantuml diagram](docs/cm.png) ![Communicatoin model plantuml diagram](docs/cm.png)

View File

@ -1,13 +0,0 @@
# CLI configuration for validating against a SCARED keyframe served by
# scared_dataset_server (default port 8080; pick another if 8080 is busy).
server:
ip: "127.0.0.1"
port: 8080
cloud_point:
algorithm: cpu # "gpu" falls back to CPU when CUDA is unavailable
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 mesh, block-averaged
wls_filter: false # true = smoother mesh for viewers, ~10 % less accurate

View File

@ -1,11 +0,0 @@
server:
ip: "127.0.0.1"
port: 8080
test_data:
intrinsic_params: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
extrinsic_params: [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
cloud_point:
- [0.1, 0.2, 0.3]
- [1.1, 1.2, 1.3]
- [5.5, 6.6, 7.7]

View File

@ -1,11 +0,0 @@
server:
ip: "127.0.0.1"
port: 9095
# Optional stereo reconstruction settings for CLI options 4/5.
# See config.scared.yml for values tuned to the SCARED endoscopic rig.
# cloud_point:
# algorithm: gpu # or cpu
# num_disparities: 128 # positive multiple of 16
# min_depth_m: 0.01
# max_depth_m: 10.0

View File

@ -2,81 +2,65 @@
box ClientProcess #LightBlue box ClientProcess #LightBlue
Participant Caller Participant Caller
Participant CloudPointClient Participant CloudPointClient
Participant StereoRectifier
Participant PointCloudBuilder
Participant TCPClient Participant TCPClient
end box end box
box UnityProcess #LightGreen box UnityProcess #LightGreen
Participant TCPServer Participant TCPServer
Participant CloudPointServer Participant CloudPointServer
Participant MainThreadQueue
Participant UnityWorld Participant UnityWorld
end box end box
UnityWorld -> CloudPointServer : init thread
== Initialization ==
UnityWorld -> CloudPointServer : Awake() — crpc_init()
activate CloudPointServer activate CloudPointServer
CloudPointServer -> TCPServer : start (await connections) CloudPointServer -> TCPServer : await for connection
activate TCPServer activate TCPServer
->CloudPointClient : init thread
Caller -> CloudPointClient : connect()
activate CloudPointClient activate CloudPointClient
CloudPointClient -> TCPClient : establish TCP connection CloudPointClient -> TCPClient : createConnection
TCPClient -> TCPServer : TCP handshake TCPClient -> TCPServer : establish connection
TCPServer -> TCPServer : spawn per-client handler thread (loops) TCPServer -> CloudPointServer : established
deactivate TCPServer
CloudPointServer -> TCPServer : await for calls
CloudPointClient -> TCPClient : get-stereo-calibration TCPServer -> TCPServer : await for packet
TCPClient -> TCPServer : send request Caller -> CloudPointClient : I want something
TCPServer -> CloudPointServer : dispatch
CloudPointServer -> MainThreadQueue : enqueue task
MainThreadQueue -> UnityWorld : Update() dequeues
activate UnityWorld
UnityWorld -> UnityWorld : read camera params
UnityWorld -> MainThreadQueue : tcs.SetResult(calibration JSON)
deactivate UnityWorld
MainThreadQueue -> CloudPointServer : task complete
CloudPointServer -> TCPServer : send response
TCPServer -> TCPClient : response
TCPClient -> CloudPointClient : calibration received
CloudPointClient -> StereoRectifier : init(calibration)\ncv::stereoRectify + remap maps
deactivate CloudPointClient
== Per compute_cloud() call ==
Caller -> CloudPointClient : compute_cloud()
activate CloudPointClient activate CloudPointClient
CloudPointClient -> TCPClient : get-image-pair CloudPointClient -> CloudPointClient : CallMethod<Something>
TCPClient -> TCPServer : send request CloudPointClient -> TCPClient : send(message)
TCPServer -> CloudPointServer : dispatch activate TCPClient
CloudPointServer -> MainThreadQueue : enqueue task TCPClient -> TCPServer : packet send
MainThreadQueue -> UnityWorld : Update() dequeues TCPServer -> TCPServer : await for packet
activate TCPServer
TCPServer -> TCPServer : read packet
TCPServer -> TCPClient : packet read
TCPClient -> CloudPointClient : done
deactivate TCPClient
CloudPointClient -> TCPClient : await for response
activate TCPClient
TCPClient -> TCPClient : await for packet
TCPServer -> CloudPointServer : callMethod
activate CloudPointServer
CloudPointServer -> UnityWorld : addToStaticQueue
UnityWorld -> UnityWorld : read from queue
activate UnityWorld activate UnityWorld
UnityWorld -> UnityWorld : Render() both cameras\nReadPixels + vertical flip\nBase64 encode UnityWorld -> UnityWorld : callMethod
UnityWorld -> MainThreadQueue : tcs.SetResult(image pair JSON) UnityWorld -> CloudPointServer: set task return value
deactivate UnityWorld deactivate UnityWorld
MainThreadQueue -> CloudPointServer : task complete CloudPointServer -> TCPServer : return task
CloudPointServer -> TCPServer : send response deactivate CloudPointServer
TCPServer -> TCPClient : response (base64 left + right) TCPServer -> TCPClient : send response
TCPClient -> CloudPointClient : image pair received TCPClient -> TCPServer : response read
TCPClient -> CloudPointClient : response received
CloudPointClient -> StereoRectifier : rectify(left, right) TCPServer -> CloudPointServer : done
StereoRectifier -> StereoRectifier : cv::remap both images deactivate TCPServer
StereoRectifier -> CloudPointClient : rectified pair CloudPointClient -> Caller : here what you wanted
CloudPointClient -> PointCloudBuilder : build(rectified, Q)
PointCloudBuilder -> PointCloudBuilder : cv::StereoSGBM disparity\n(1/16 scale)\ncv::reprojectImageTo3D\nNaN filter
PointCloudBuilder -> CloudPointClient : PointCloud
CloudPointClient -> Caller : std::expected<PointCloud, Error>
deactivate CloudPointClient deactivate CloudPointClient
== Teardown ==
Caller -> CloudPointClient : destruct Caller -> CloudPointClient : destruct
CloudPointClient -> TCPClient : finish waiting
deactivate TCPClient
deactivate CloudPointClient deactivate CloudPointClient
UnityWorld -> CloudPointServer : OnDestroy() — crpc_deinit() UnityWorld -> CloudPointServer : destruct
deactivate CloudPointServer deactivate CloudPointServer
deactivate TCPServer
@enduml @enduml

View File

@ -1,198 +0,0 @@
# Unity Integration Design
This document specifies the C# architecture the Unity side (the
`UnityLaparoscopicSceneSimulator` project) must implement to serve stereo
images and calibration to the C++ `CloudPointClient` over the embedded RPC
server. It is a design spec — the C# code lives in the Unity repository.
The existing prototypes there (`Assets/Scripts/CrpcApi.cs`,
`Assets/Scripts/RpcTest.cs`) are a starting point but contain several
correctness bugs called out explicitly below.
## Data flow
```
Unity process (server) C++ client process
────────────────────── ──────────────────
CloudPointServer (C#)
crpc_init / crpc_add_method
get-stereo-calibration ◄────────────── CloudPointClient::connect() (once)
get-image-pair ◄────────────── CloudPointClient::compute_cloud()
│ rectify → SGBM → reproject
PointCloud → caller
```
Unity is a **data source only**. The point cloud is computed and consumed on
the client side. See [API.md](../API.md) for the wire schemas.
## 1. `CloudPointServer` facade
A single MonoBehaviour owning the server lifecycle
(`Assets/Scripts/CloudPointRpc/CloudPointServer.cs`):
- `Awake()` — call `crpc_init(configPath)` (config.yaml under
`Application.streamingAssetsPath`, contains `server: {ip, port}`), then
register handlers.
- `OnDestroy()` / `OnApplicationQuit()` — shut down in the order specified in
§3 (Shutdown ordering).
- JSON: use Newtonsoft Json.NET (`com.unity.nuget.newtonsoft-json`).
`JsonUtility` cannot serialize dictionaries or nested arrays.
### Callback registration rules (IL2CPP + GC safety)
- Handler methods must be **static** and annotated with
`[AOT.MonoPInvokeCallback(typeof(RpcStringCallback))]` — instance methods
crash under IL2CPP.
- The delegate instance passed to `crpc_add_method` must be stored in a
**static field** for the lifetime of the server. The current `RpcTest.cs`
passes a method group directly; the marshalled thunk can be garbage
collected while C++ still holds the function pointer — a latent crash.
```csharp
private static readonly CrpcTestApi.RpcStringCallback s_calibrationCb = OnGetStereoCalibration;
// ...
CrpcExtensions.CrpcAddMethod(s_calibrationCb, "get-stereo-calibration");
```
## 2. `rpc_string` ownership rules
The C API (`server_api.h`) uses `rpc_string*` in both directions with
**different ownership**:
| Pointer | Owner | C# obligation |
|---|---|---|
| Handler **input** (`params` string) | C++ — a stack object inside `RpcServer::process` | Copy the data out immediately. **Never** call `crpc_str_destroy` on it. |
| Handler **return** value | Transfers to C++ — `rpc_server.cpp` destroys it after parsing | Create with `crpc_str_create`, return the handle, and **relinquish** C#-side ownership. |
Both rules are violated by the current `RpcTest.cs`:
it `Dispose()`s the input handle (destroying an object it doesn't own) and
returns a handle whose C# finalizer will later call `crpc_str_destroy` on
memory C++ has already freed — a double-destroy race.
Spec: split the wrapper into two types.
```csharp
/// Non-owning view over a C++-owned rpc_string. No finalizer, no Dispose.
readonly ref struct BorrowedRpcString { /* Data property only */ }
/// C#-created rpc_string. Dispose() destroys it; Release() transfers
/// ownership (nulls the handle and calls GC.SuppressFinalize).
sealed class OwnedRpcString : IDisposable
{
public IntPtr Release() { var h = _handle; _handle = IntPtr.Zero;
GC.SuppressFinalize(this); return h; }
}
```
Handlers end with `return new OwnedRpcString(json).Release();`.
## 3. Main-thread dispatcher
RPC callbacks arrive on a C++ per-client thread; Unity APIs are
main-thread-only. The callback must block until the main thread produces the
result. The `task.Wait()` idea in `RpcTest.cs` is directionally right but must
be hardened (`Assets/Scripts/CloudPointRpc/MainThreadDispatcher.cs`):
- Per request, create
`new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously)`
and enqueue `(Func<string> handler, tcs)` into a `ConcurrentQueue`.
- `Update()` drains the queue: run the handler, `tcs.TrySetResult(json)`
(or `TrySetException`).
- The callback thread waits with a **bounded timeout**:
`tcs.Task.Wait(TimeoutMs)` with ~5000 ms. On timeout, return a JSON string
with an `"error"` field (the C++ side passes the string through as the
result). An unbounded wait deadlocks permanently when the editor is paused
or the component is destroyed.
- Note that `server_api.cpp` holds a global `server_mtx` while dispatching, so
**one stuck handler stalls every client** — another reason the timeout is
mandatory.
### Shutdown ordering (deadlock hazard)
`crpc_deinit()` joins server threads. A server thread may be blocked inside a
callback waiting for the main thread — which is the thread calling
`crpc_deinit()`. Required order in `OnDestroy`:
1. Set a `volatile bool _shuttingDown` — new callbacks fail-fast with an error
result; pending queue entries get `TrySetCanceled()`.
2. Drain/clear the queue.
3. Only then call `crpc_deinit()`.
## 4. Required handlers
### `get-stereo-calibration`
Derive intrinsics from the rendering camera, extrinsics from the two camera
transforms (schema in API.md):
- `fx = width / (2 * tan(hFov / 2))`, `fy = height / (2 * tan(vFov / 2))`
where `vFov = Camera.fieldOfView` (degrees → radians) and
`hFov = 2 * atan(tan(vFov/2) * aspect)`. Use the render-texture resolution,
not the screen resolution.
- `cx = width / 2`, `cy = height / 2`, skew 0. `dist_coeffs` = five zeros
(ideal pinhole).
- Extrinsics: right camera relative to left in OpenCV convention
(`x_r = R·x_l + T`), converted per §5. For the standard parallel rig this is
`R = I`, `T = [-baseline, 0, 0]` with
`baseline = Vector3.Distance(left.position, right.position)` in meters.
- `image_size` = the RenderTexture size served by `get-image-pair`.
### `get-image-pair`
Both eyes must be captured on the **same rendered frame**:
- Give both cameras `targetTexture` RenderTextures; inside the (main-thread)
handler call `leftCam.Render(); rightCam.Render();` then read both back.
- Readback: synchronous `Texture2D.ReadPixels` inside the handler is
acceptable and simplest — the RPC thread is blocked waiting anyway.
`AsyncGPUReadback` is the documented optimization: issue the request in the
handler and complete the `TaskCompletionSource` from the readback callback
(the dispatcher design above already supports deferred completion).
- **Vertical flip**: GPU readbacks are bottom-up; the wire format is
top-left-origin row-major (API.md). Flip rows before encoding.
- Encode with `Convert.ToBase64String`; fill the `get-image-pair` schema with
`type: "RGBA"` (or convert to BGR to save 25% payload).
- Include a monotonically increasing `frame` counter
(`Time.frameCount` is fine).
### Legacy methods
`get-intrinsic-params` / `get-extrinsic-params` may be kept, backed by the
left camera, for backward compatibility. They are not used by
`CloudPointClient`.
## 5. Coordinate conventions
- Unity: left-handed, +Y up, +Z forward. OpenCV camera frame: right-handed,
+X right, **+Y down**, +Z forward.
- Conversion with `S = diag(1, -1, 1)`:
`R_cv = S · R_unity · S`, `t_cv = S · t_unity`.
- The reconstructed cloud is in the **OpenCV left-camera frame**. Consumers
that need Unity world space must apply the inverse conversion plus the left
camera pose; if needed, add an optional `left_pose` (16 doubles, row-major
4x4) field to `get-stereo-calibration`.
- All matrices row-major flattened; all lengths in meters (1 Unity unit = 1 m);
disparity in pixels.
## 6. Performance notes
- 1920x1080 RGBA is 8.3 MB raw ≈ 11 MB base64 per eye, ~22 MB per
`get-image-pair` — of the order 100300 ms per fetch on loopback. For
interactive rates use 960x540 and/or BGR.
- `ReadPixels` stalls the GPU pipeline (~15 ms at 1080p): fine for on-demand
capture; do not capture every frame unconditionally.
- Base64 of large buffers allocates heavily; reuse buffers
(`Convert.TryToBase64Chars`) where practical.
- The server serializes requests globally (`server_mtx`), so handlers need no
reentrancy protection — but a slow handler blocks all clients (see §3).
## 7. Verification checklist
1. Start the Unity scene (server on the configured port).
2. From this repo: `./build/src/cloud_point_rpc_cli config.yaml`, option
`4` (compute-cloud) — expect a plausible point count and bounding box.
3. Option `5` writes a PLY; inspect it in MeshLab against the visible scene
geometry (a plane at 1.5 m should reconstruct at z ≈ 1.5).
4. Kill the client mid-request and re-connect — the server must keep serving
(per-client threads are independent; the TCP loop tolerates EOF).

View File

@ -1,112 +0,0 @@
#pragma once
#include "cloud_point/point_cloud_builder.hpp"
#include "cloud_point/stereo_matcher.hpp"
#include "cloud_point/stereo_matcher_factory.hpp"
#include "cloud_point/stereo_rectifier.hpp"
#include <expected>
#include <memory>
#include <string>
namespace score {
class TCPConnector;
class RpcClient;
/// @brief End-to-end stereo point cloud client.
///
/// Connects to a JSON-RPC stereo server, fetches calibration once on connect(),
/// then produces point clouds on demand via compute_cloud().
///
/// Thread safety: not thread-safe. Do not call methods concurrently.
class CloudPointClient {
public:
/// @brief Recoverable per-frame error.
struct Error {
std::string message;
};
/// @brief Construct client (does not connect).
/// @param ip Server IP address.
/// @param port Server port.
/// @param algo Stereo matching algorithm (GPU falls back to CPU
/// if
/// unavailable).
/// @param opts Depth filtering options.
/// @param num_disparities SGBM disparity levels (default 128; use 160 for
/// small-baseline rigs such as SCARED).
/// @param matcher_params SGBM tuning / post-filters for the CPU matcher.
CloudPointClient(
std::string ip, int port,
StereoAlgorithmType algo = StereoAlgorithmType::GPU,
PointCloudBuilder::Options opts = {}, int num_disparities = 128,
CpuStereoMatcher::Params matcher_params = CpuStereoMatcher::Params{});
~CloudPointClient();
/// @brief Connect to server and fetch calibration once.
/// @throws std::runtime_error on connection or calibration failure.
void connect();
/// @brief Return true if connected and all pipeline components are ready.
[[nodiscard]] bool connected() const noexcept;
/// @brief Compute one point cloud: fetch image pair → gray → rectify →
/// disparity → reproject.
///
/// Recoverable per-frame failures (RPC error, decode error) are returned as
/// Error. Calling before connect() returns Error immediately.
[[nodiscard]] std::expected<PointCloud, Error> compute_cloud();
private:
std::string ip_;
int port_;
StereoAlgorithmType algo_;
PointCloudBuilder::Options opts_;
int num_disparities_;
CpuStereoMatcher::Params matcher_params_;
std::unique_ptr<TCPConnector> connector_;
std::unique_ptr<RpcClient> client_;
std::unique_ptr<StereoRectifier> rectifier_;
std::unique_ptr<IStereoMatcher> matcher_;
std::unique_ptr<PointCloudBuilder> builder_;
};
/// @brief PLY export settings.
struct PlyOptions {
/// Emit a triangle mesh built from the organised pixel grid. Web viewers
/// (e.g. Meshy) fabricate triangles from consecutive vertices of a
/// vertex-only PLY, which draws long slivers across the surface; a real
/// mesh renders correctly everywhere and still contains every point.
bool triangulate;
/// Reject triangles whose longest edge exceeds this fraction of the
/// triangle's mean depth (breaks the mesh at occlusion boundaries).
float max_edge_depth_ratio;
/// Write binary_little_endian instead of ASCII. A full 1280x1024 mesh
/// is ~40 MB in binary versus ~85 MB in ASCII and parses much faster.
bool binary;
/// Decimate the grid by this factor (1 = full resolution). Each output
/// vertex is the mean of the valid pixels in its stride x stride block
/// (blocks less than half valid are dropped), so stride 4 both shrinks
/// the mesh 16x and cuts per-pixel disparity noise ~4x. Web viewers
/// cope far better with such a mesh than with the raw 1.1 M vertices.
int stride;
PlyOptions() noexcept
: triangulate(true), max_edge_depth_ratio(0.05f), binary(true),
stride(1) {}
PlyOptions(bool tri, float ratio, bool bin = true, int step = 1) noexcept
: triangulate(tri), max_edge_depth_ratio(ratio), binary(bin),
stride(step) {}
};
/// @brief Write valid points (and optionally a grid-triangulated mesh) as
/// PLY for MeshLab / web-viewer inspection.
/// @param cloud Source point cloud.
/// @param path Output file path.
/// @param opts Export settings; defaults to a binary triangulated mesh.
/// @return Number of faces written (0 when not triangulating).
/// @throws std::invalid_argument if opts.stride < 1.
size_t write_ply(const PointCloud &cloud, const std::string &path,
const PlyOptions &opts = PlyOptions{});
} // namespace score

View File

@ -1,68 +0,0 @@
#pragma once
#include "cloud_point/stereo_matcher.hpp"
#include <opencv2/calib3d.hpp>
namespace score {
/// @brief CPU-based stereo matcher using cv::StereoSGBM.
///
/// The matcher is configured with the smoothness penalties and post-filters
/// recommended by OpenCV (P1 = 8·bs², P2 = 32·bs², uniqueness ratio,
/// left-right consistency check, speckle filter). Without them SGBM
/// degenerates to unregularised winner-take-all block matching, which
/// produces a heavy tail of low-disparity outliers that reproject metres
/// away from the true surface.
class CpuStereoMatcher : public IStereoMatcher {
public:
/// @brief True when this build can apply the WLS disparity filter.
[[nodiscard]] static bool wls_available() noexcept;
/// @brief SGBM tuning parameters.
struct Params {
int block_size; ///< Odd matching block size (SADWindowSize).
int uniqueness_ratio; ///< Best/second-best cost margin (%).
int speckle_window_size; ///< Max blob size flagged as speckle (0 off).
int speckle_range; ///< Max disparity variation inside a blob.
int disp12_max_diff; ///< Max left-right disparity mismatch (px).
int pre_filter_cap; ///< x-derivative clipping value.
/// Median filter applied to the disparity map (0 disables, else odd
/// 3 or 5). Only trims isolated spikes; SGBM's sub-pixel noise is
/// correlated over several pixels, so prefer the WLS filter.
int median_kernel;
/// Edge-aware weighted-least-squares smoothing of the disparity
/// (cv::ximgproc::DisparityWLSFilter). Off by default: on SCARED it
/// halves the fine-scale surface roughness (mesh normals 45 -> 23
/// deg off-axis) but costs accuracy (MAE 0.84 -> 0.92 mm, within
/// 2 mm 80 -> 78 %) and a second SGBM pass (~2x matching time).
/// Enable for visualisation, keep off for measurement. Ignored,
/// with a warning, when OpenCV was built without ximgproc.
bool wls_filter;
double wls_lambda; ///< Smoothness weight (2000; 8000 over-smooths).
double wls_sigma; ///< Edge sensitivity (typical 0.8-2.0).
// Explicit constructor avoids a GCC limitation with nested-struct
// default-member-initialisers used as default function arguments.
Params() noexcept
: block_size(5), uniqueness_ratio(10), speckle_window_size(100),
speckle_range(2), disp12_max_diff(1), pre_filter_cap(31),
median_kernel(0), wls_filter(false), wls_lambda(2000.0),
wls_sigma(1.5) {}
};
CpuStereoMatcher(int min_disparity = 0, int num_disparities = 128,
Params params = Params{});
~CpuStereoMatcher() override = default;
[[nodiscard]] cv::Mat compute(const cv::Mat &left,
const cv::Mat &right) override;
private:
cv::Ptr<cv::StereoSGBM> sgbm_;
cv::Ptr<cv::StereoMatcher> right_matcher_; ///< Only with WLS.
cv::Ptr<cv::Algorithm> wls_; ///< DisparityWLSFilter.
int median_kernel_;
double wls_lambda_;
double wls_sigma_;
};
} // namespace score

View File

@ -1,36 +0,0 @@
#pragma once
#include "cloud_point/stereo_matcher.hpp"
namespace score {
/// @brief GPU-based stereo matcher using cv::cuda::StereoSGM.
/// Falls back to runtime error if CUDA is unavailable.
///
/// cv::cuda::StereoSGM only supports 64, 128 or 256 disparity levels; the
/// requested count is rounded up to the next supported value.
class GpuStereoMatcher : public IStereoMatcher {
public:
/// @param min_disparity Minimum disparity (px).
/// @param num_disparities Requested disparity levels (rounded up to
/// 64/128/256).
/// @param uniqueness_ratio Best/second-best cost margin (%).
GpuStereoMatcher(int min_disparity = 0, int num_disparities = 128,
int uniqueness_ratio = 10);
~GpuStereoMatcher() override = default;
[[nodiscard]] cv::Mat compute(const cv::Mat &left,
const cv::Mat &right) override;
/// @brief Round a disparity count up to the nearest value supported by
/// cv::cuda::StereoSGM (64, 128 or 256).
/// @throws std::invalid_argument if num_disparities exceeds 256.
[[nodiscard]] static int supported_num_disparities(int num_disparities);
private:
#ifdef HAVE_OPENCV_CUDA
cv::Ptr<cv::cuda::StereoSGM> sgm_;
#endif
};
} // namespace score

View File

@ -1,21 +0,0 @@
//
// Created by vptyp on 11.03.2026.
//
#pragma once
#include <opencv4/opencv2/opencv.hpp>
namespace score {
class Image {
public:
Image();
explicit Image(const cv::Mat &image);
~Image();
/// @note data_ could be changed through this
[[nodiscard]] cv::Mat get();
protected:
cv::Mat data_;
};
} // namespace score

View File

@ -1,47 +0,0 @@
//
// Created by vptyp on 12.03.2026.
//
#pragma once
#include <cloud_point/image.h>
#include <cloud_point_rpc/imageRpc.h>
namespace score {
class ImageFactory {
public:
/**
* @brief tries to decode available RPC image type to opencv compliant
* @return opencv compliant image type
* @throw runtime_error if type is unknown
*/
static int pixelType(const ImageRPC::Type &type) {
switch (type) {
case ImageRPC::Type::BGR:
return CV_8UC3;
case ImageRPC::Type::RGBA:
return CV_8UC4;
case ImageRPC::Type::DEPTH:
return CV_64FC1;
default:
throw std::runtime_error("Unknown image type");
}
}
/**
* @brief tries to create Image object from ImageRPC
* @throw runtime_error if type is unknown or data size does not match
* dimensions
*/
static Image create(const ImageRPC &image) {
const int cv_type = pixelType(image.type);
const size_t expected = static_cast<size_t>(image.width) *
static_cast<size_t>(image.height) *
CV_ELEM_SIZE(cv_type);
if (image.data.size() != expected)
throw std::runtime_error("Image data size does not match "
"width*height*channels");
cv::Mat imageMat(image.height, image.width, cv_type,
const_cast<unsigned char *>(image.data.data()));
return Image{imageMat.clone()};
}
};
} // namespace score

View File

@ -1,23 +0,0 @@
#pragma once
#include "opencv2/core/hal/interface.h"
#include "opencv2/core/mat.hpp"
#include <stdexcept>
namespace score {
class CameraMatrixFactory {
public:
/**
* @param rpc < vector of size Width*Height
* @throw runtime_error if size is not Width*Height
*/
template <size_t Width, size_t Height>
static cv::Mat create(const std::vector<double> &rpc) {
if (rpc.size() != Width * Height)
throw std::runtime_error("Vector size is not Width*Height");
return cv::Mat(Width, Height, CV_64F, const_cast<double *>(rpc.data()))
.clone();
}
};
} // namespace score

View File

@ -1,66 +0,0 @@
#pragma once
#include <array>
#include <limits>
#include <opencv2/core.hpp>
#include <vector>
namespace score {
/// @brief Dense point cloud in row-major XYZ layout.
///
/// Invalid (occluded or out-of-range) points are represented as quiet NaN on
/// all three coordinates. valid_points() filters them out.
struct PointCloud {
int width{0};
int height{0};
std::vector<float> data; ///< width * height * 3 floats, XYZ row-major
/// @brief Return only the non-NaN points.
[[nodiscard]] std::vector<std::array<float, 3>> valid_points() const;
};
/// @brief Reprojects a disparity map to a 3-D point cloud using the
/// reprojection matrix Q produced by cv::stereoRectify.
///
/// Thread safety: build() is const and safe to call concurrently once the
/// object is constructed.
class PointCloudBuilder {
public:
/// @brief Depth filtering thresholds.
struct Options {
float min_depth_m;
float max_depth_m;
// Explicit constructor avoids a GCC limitation with nested-struct
// default-member-initialisers used as default function arguments.
Options() noexcept : min_depth_m(0.01f), max_depth_m(10.0f) {}
Options(float min_m, float max_m) noexcept
: min_depth_m(min_m), max_depth_m(max_m) {}
};
/// @brief Construct builder.
/// @param q 4x4 CV_64F reprojection matrix from cv::stereoRectify.
/// @param opts Optional depth-range filter.
/// @throws std::invalid_argument if q is not 4x4 CV_64F.
explicit PointCloudBuilder(cv::Mat q, Options opts = Options{});
/// @brief Reproject disparity to a point cloud.
///
/// Accepts:
/// - CV_16S SGBM fixed-point output (values = disparity * 16)
/// - CV_32F already in pixel units
///
/// Invalid points (disparity ≤ 0, |z| ≥ 10000, or z outside
/// [min_depth_m, max_depth_m]) are stored as quiet NaN.
///
/// @param disparity Disparity map (CV_16S or CV_32F).
/// @return Populated PointCloud.
/// @throws std::invalid_argument on unsupported disparity type.
[[nodiscard]] PointCloud build(const cv::Mat &disparity) const;
private:
cv::Mat q_;
Options opts_;
};
} // namespace score

View File

@ -1,50 +0,0 @@
#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

View File

@ -1,38 +0,0 @@
#pragma once
#include "cloud_point_rpc/rpc_dto.hpp"
#include <string>
namespace score {
/// @brief Loads stereo calibration and image pair from a SCARED dataset
/// keyframe directory.
///
/// Expected directory layout:
/// <keyframe_dir>/endoscope_calibration.yaml — OpenCV FileStorage
/// <keyframe_dir>/Left_Image.png — 1280x1024 RGBA PNG
/// <keyframe_dir>/Right_Image.png — 1280x1024 RGBA PNG
///
/// The YAML node T is in millimetres; this loader converts to metres before
/// populating StereoCalibrationRPC.translation.
class ScaredDatasetLoader {
public:
/// @brief Load calibration and images from @p keyframe_dir.
/// @throws std::runtime_error if any file cannot be opened or parsed,
/// or if the left and right images have different dimensions.
explicit ScaredDatasetLoader(const std::string &keyframe_dir);
/// @brief Return the stereo calibration DTO (translation in metres).
[[nodiscard]] const StereoCalibrationRPC &calibration() const noexcept;
/// @brief Return an image pair DTO with the given frame index.
/// The images are the same for every call (single keyframe).
[[nodiscard]] ImagePairRPC image_pair(uint64_t frame) const;
private:
StereoCalibrationRPC calib_;
ImageRPC left_image_;
ImageRPC right_image_;
};
} // namespace score

View File

@ -1,40 +0,0 @@
#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

View File

@ -1,20 +0,0 @@
#pragma once
#include <opencv2/core.hpp>
namespace score {
/// @brief Abstract interface for stereo disparity computation.
class IStereoMatcher {
public:
virtual ~IStereoMatcher() = default;
/// @brief Compute disparity map from a rectified stereo pair.
/// @param left Left image (grayscale, CV_8UC1).
/// @param right Right image (grayscale, CV_8UC1).
/// @return Disparity map (CV_16S for CPU, type depends on backend for GPU).
[[nodiscard]] virtual cv::Mat compute(const cv::Mat &left,
const cv::Mat &right) = 0;
};
} // namespace score

View File

@ -1,28 +0,0 @@
#pragma once
#include "cloud_point/cpu_stereo_matcher.hpp"
#include "cloud_point/stereo_matcher.hpp"
#include <memory>
namespace score {
enum class StereoAlgorithmType { CPU, GPU };
/// @brief Factory for creating CPU or GPU stereo matchers.
class StereoMatcherFactory {
public:
/// @brief Create a stereo matcher of the requested type.
/// If GPU is requested but unavailable, falls back to CPU.
/// @param num_disparities Number of disparity levels for SGBM (default
/// 128).
/// Must be a positive multiple of 16.
/// @param cpu_params SGBM tuning used for the CPU matcher (and the
/// CPU fallback of the GPU matcher).
/// @throws std::invalid_argument if @p num_disparities is not a positive
/// multiple of 16.
[[nodiscard]] static std::unique_ptr<IStereoMatcher>
create(StereoAlgorithmType type, int num_disparities = 128,
CpuStereoMatcher::Params cpu_params = CpuStereoMatcher::Params{});
};
} // namespace score

View File

@ -1,72 +0,0 @@
#pragma once
#include <opencv2/calib3d.hpp>
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include "cloud_point_rpc/rpc_dto.hpp"
namespace score {
/// @brief Stereo rectifier that computes rectification maps from calibration
/// data and applies them to image pairs.
///
/// 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.
struct Calibration {
cv::Mat k_left; ///< 3x3 CV_64F intrinsic matrix, left camera
cv::Mat d_left; ///< 1x5 CV_64F distortion coefficients, left camera
cv::Mat k_right; ///< 3x3 CV_64F intrinsic matrix, right camera
cv::Mat d_right; ///< 1x5 CV_64F distortion coefficients, right camera
cv::Mat r; ///< 3x3 CV_64F rotation from left to right camera
cv::Mat
t; ///< 3x1 CV_64F translation from left to right camera (metres)
cv::Size image_size;
/// @brief Build calibration from the wire DTO using
/// CameraMatrixFactory.
/// @param rpc Stereo calibration received over JSON-RPC.
/// @return Populated Calibration struct.
static Calibration from_rpc(const StereoCalibrationRPC &rpc);
};
/// @brief Construct rectifier from calibration data.
///
/// Calls cv::stereoRectify (CALIB_ZERO_DISPARITY, alpha=0) and
/// cv::initUndistortRectifyMap (CV_16SC2) for both sides.
///
/// @throws std::invalid_argument if any Mat has the wrong size or type.
explicit StereoRectifier(const Calibration &calib);
/// @brief Apply rectification maps to a stereo pair.
/// @param left Left input image (any type accepted by cv::remap).
/// @param right Right input image.
/// @return {rectified_left, rectified_right}.
[[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
};
} // namespace score

View File

@ -1,24 +1,9 @@
#pragma once #pragma once
#include "export.h"
#include <iostream> #include <iostream>
#include <string> #include <string>
namespace score { #include "export.h"
namespace cloud_point_rpc {
/**
* @brief Stereo reconstruction settings for CLI options 4 and 5.
*
* Mirrors CloudPointConfig from config.hpp without pulling OpenCV into the
* CLI interface.
*/
struct CliStereoOptions {
bool use_gpu{true}; ///< GPU matcher (falls back to CPU) or CPU
int num_disparities{128}; ///< SGBM levels, positive multiple of 16
float min_depth_m{0.01f}; ///< Reject points nearer than this (m)
float max_depth_m{10.0f}; ///< Reject points farther than this (m)
int ply_stride{1}; ///< Grid decimation for PLY export
bool wls_filter{false}; ///< WLS disparity smoothing (visualisation)
};
/** /**
* @brief Runs the CLI client. * @brief Runs the CLI client.
@ -27,11 +12,9 @@ struct CliStereoOptions {
* @param output Output stream (usually std::cout) * @param output Output stream (usually std::cout)
* @param ip Server IP * @param ip Server IP
* @param port Server Port * @param port Server Port
* @param stereo Stereo reconstruction settings (options 4/5)
* @return int exit code * @return int exit code
*/ */
int CRPC_EXPORT run_cli(std::istream &input, std::ostream &output, int CRPC_EXPORT run_cli(std::istream &input, std::ostream &output, const std::string &ip,
const std::string &ip, int port, int port);
const CliStereoOptions &stereo = CliStereoOptions{});
} // namespace score } // namespace cloud_point_rpc

View File

@ -6,11 +6,11 @@
#include <vector> #include <vector>
#include <yaml-cpp/yaml.h> #include <yaml-cpp/yaml.h>
namespace score { namespace cloud_point_rpc {
struct ServerConfig { struct ServerConfig {
std::string ip; std::string ip;
int port{0}; int port;
}; };
struct TestData { struct TestData {
@ -19,28 +19,9 @@ struct TestData {
std::vector<std::vector<double>> cloud_point; std::vector<std::vector<double>> cloud_point;
}; };
/// @brief Stereo reconstruction settings consumed by the CLI (option 4/5).
///
/// Depth limits are physical bounds on the scene: anything reprojected
/// outside [min_depth_m, max_depth_m] is discarded as a mismatch. For the
/// SCARED endoscopic rig use roughly 0.020.3 m; the defaults are wide enough
/// for a generic Unity scene.
struct CloudPointConfig {
std::string algorithm{"gpu"}; ///< "gpu" (falls back to CPU) or "cpu"
int num_disparities{128}; ///< SGBM levels, positive multiple of 16
double min_depth_m{0.01};
double max_depth_m{10.0};
int ply_stride{1}; ///< Grid decimation for PLY export (1 = full res)
/// Edge-aware WLS smoothing of the disparity map (needs opencv
/// ximgproc). Smoother meshes, slightly lower accuracy, ~2x matching
/// time; intended for visualisation.
bool wls_filter{false};
};
struct Config { struct Config {
ServerConfig server; ServerConfig server;
TestData test_data; TestData test_data;
CloudPointConfig cloud_point;
}; };
class ConfigLoader { class ConfigLoader {
@ -80,39 +61,6 @@ class ConfigLoader {
LOG(WARNING) << "No 'test_data' section, using empty/defaults."; LOG(WARNING) << "No 'test_data' section, using empty/defaults.";
} }
// Cloud point (optional)
if (config["cloud_point"]) {
const auto &cp = config["cloud_point"];
CloudPointConfig d;
c.cloud_point.algorithm =
cp["algorithm"].as<std::string>(d.algorithm);
c.cloud_point.num_disparities =
cp["num_disparities"].as<int>(d.num_disparities);
c.cloud_point.min_depth_m =
cp["min_depth_m"].as<double>(d.min_depth_m);
c.cloud_point.max_depth_m =
cp["max_depth_m"].as<double>(d.max_depth_m);
c.cloud_point.ply_stride =
cp["ply_stride"].as<int>(d.ply_stride);
c.cloud_point.wls_filter =
cp["wls_filter"].as<bool>(d.wls_filter);
if (c.cloud_point.ply_stride < 1) {
throw std::runtime_error(
"cloud_point.ply_stride must be >= 1");
}
if (c.cloud_point.algorithm != "gpu" &&
c.cloud_point.algorithm != "cpu") {
throw std::runtime_error(
"cloud_point.algorithm must be \"gpu\" or \"cpu\"");
}
if (c.cloud_point.min_depth_m <= 0.0 ||
c.cloud_point.max_depth_m <= c.cloud_point.min_depth_m) {
throw std::runtime_error(
"cloud_point depth range must satisfy "
"0 < min_depth_m < max_depth_m");
}
}
return c; return c;
} catch (const YAML::Exception &e) { } catch (const YAML::Exception &e) {
LOG(ERROR) << "Failed to load config: " << e.what(); LOG(ERROR) << "Failed to load config: " << e.what();
@ -121,4 +69,4 @@ class ConfigLoader {
} }
}; };
} // namespace score } // namespace cloud_point_rpc

View File

@ -1,21 +0,0 @@
//
// Created by vptyp on 12.03.2026.
//
#pragma once
#include <vector>
namespace score {
struct ImageRPC {
int width{0};
int height{0};
enum class Type {
UNKNOWN,
BGR,
RGBA,
DEPTH,
} type{Type::UNKNOWN};
std::vector<unsigned char> data;
};
} // namespace score

View File

@ -1,18 +1,16 @@
#pragma once #pragma once
#include "cloud_point_rpc/rpc_dto.hpp" #include "tcp_connector.hpp"
#include "cloud_point_rpc/tcp_connector.hpp"
#include <asio.hpp> #include <asio.hpp>
#include <glog/logging.h> #include <glog/logging.h>
#include <jsonrpccxx/client.hpp> #include <jsonrpccxx/client.hpp>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <vector> #include <vector>
namespace cloud_point_rpc {
namespace score {
class RpcClient : public jsonrpccxx::JsonRpcClient { class RpcClient : public jsonrpccxx::JsonRpcClient {
public: public:
explicit RpcClient(TCPConnector &connector) RpcClient(TCPConnector &connector)
: jsonrpccxx::JsonRpcClient(connector, jsonrpccxx::version::v2) {} : jsonrpccxx::JsonRpcClient(connector, jsonrpccxx::version::v2) {}
[[nodiscard]] std::vector<double> get_intrinsic_params() { [[nodiscard]] std::vector<double> get_intrinsic_params() {
@ -27,36 +25,15 @@ class RpcClient : public jsonrpccxx::JsonRpcClient {
return call<std::vector<std::vector<double>>>("get-cloud-point"); return call<std::vector<std::vector<double>>>("get-cloud-point");
} }
[[nodiscard]] StereoCalibrationRPC get_stereo_calibration() {
return call<nlohmann::json>("get-stereo-calibration")
.get<StereoCalibrationRPC>();
}
[[nodiscard]] ImagePairRPC get_image_pair() {
return call<nlohmann::json>("get-image-pair").get<ImagePairRPC>();
}
/// @brief Call a method with no params.
template <typename ReturnType> template <typename ReturnType>
[[nodiscard]] ReturnType call(std::string_view name) { [[nodiscard]] ReturnType call(std::string_view name) {
return this->CallMethod<ReturnType>(id_++, name.data()); return this->CallMethod<ReturnType>(id++, name.data());
}
/// @brief Call a method with named params (JSON object keys → values).
template <typename ReturnType>
[[nodiscard]] ReturnType call(std::string_view name,
const nlohmann::json &params) {
jsonrpccxx::named_parameter named;
for (auto &[k, v] : params.items()) {
named[k] = v;
}
return this->CallMethodNamed<ReturnType>(id_++, name.data(), named);
} }
~RpcClient() = default; ~RpcClient() = default;
private: private:
int id_{0}; int id{0};
}; };
} // namespace score } // namespace cloud_point_rpc

View File

@ -1,28 +0,0 @@
//
// Created by vptyp on 11.03.2026.
//
#pragma once
#include "export.h"
#include <string>
#include <vector>
namespace score {
class CRPC_EXPORT IRPCCoder {
public:
virtual ~IRPCCoder() = default;
virtual std::vector<char> decode(const std::string &encoded) = 0;
virtual std::string encode(const std::vector<char> &data) = 0;
};
class CRPC_EXPORT Base64RPCCoder final : public IRPCCoder {
public:
Base64RPCCoder();
~Base64RPCCoder() override;
std::vector<char> decode(const std::string &encoded) override;
std::string encode(const std::vector<char> &data) override;
};
} // namespace score

View File

@ -1,160 +0,0 @@
// Created as part of Phase 1: stereo calibration and image-pair wire protocol.
#pragma once
#include "cloud_point_rpc/imageRpc.h"
#include "cloud_point_rpc/rpc_coder.hpp"
#include <nlohmann/json.hpp>
#include <stdexcept>
#include <string>
#include <vector>
namespace score {
// ---------------------------------------------------------------------------
// CameraCalib
// ---------------------------------------------------------------------------
/// @brief Intrinsic parameters for a single camera.
struct CameraCalib {
std::vector<double> camera_matrix; ///< 9 elements, row-major 3x3
std::vector<double> dist_coeffs; ///< 5 elements: k1 k2 p1 p2 k3
};
// ---------------------------------------------------------------------------
// StereoCalibrationRPC
// ---------------------------------------------------------------------------
/// @brief Full stereo rig calibration result.
struct StereoCalibrationRPC {
CameraCalib left;
CameraCalib right;
std::vector<double> rotation; ///< 9 elements, row-major 3x3
std::vector<double> translation; ///< 3 elements, metres (OpenCV convention)
int width{0};
int height{0};
};
// ---------------------------------------------------------------------------
// ImagePairRPC
// ---------------------------------------------------------------------------
/// @brief A synchronised stereo frame.
struct ImagePairRPC {
uint64_t frame{0};
ImageRPC left;
ImageRPC right;
};
// ---------------------------------------------------------------------------
// nlohmann ADL hooks — ImageRPC
// ---------------------------------------------------------------------------
inline std::string image_type_to_string(ImageRPC::Type t) {
switch (t) {
case ImageRPC::Type::BGR:
return "BGR";
case ImageRPC::Type::RGBA:
return "RGBA";
case ImageRPC::Type::DEPTH:
return "DEPTH";
default:
return "UNKNOWN";
}
}
inline ImageRPC::Type image_type_from_string(const std::string &s) {
if (s == "BGR")
return ImageRPC::Type::BGR;
if (s == "RGBA")
return ImageRPC::Type::RGBA;
if (s == "DEPTH")
return ImageRPC::Type::DEPTH;
throw std::runtime_error("Unknown ImageRPC type: " + s);
}
inline void to_json(nlohmann::json &j, const ImageRPC &img) {
Base64RPCCoder coder;
// ImageRPC::data is vector<unsigned char>; encode() needs vector<char>
const std::vector<char> as_char(img.data.begin(), img.data.end());
j = {{"width", img.width},
{"height", img.height},
{"type", image_type_to_string(img.type)},
{"data", coder.encode(as_char)}};
}
inline void from_json(const nlohmann::json &j, ImageRPC &img) {
j.at("width").get_to(img.width);
j.at("height").get_to(img.height);
img.type = image_type_from_string(j.at("type").get<std::string>());
Base64RPCCoder coder;
const auto decoded = coder.decode(j.at("data").get<std::string>());
img.data.assign(decoded.begin(), decoded.end());
}
// ---------------------------------------------------------------------------
// nlohmann ADL hooks — CameraCalib
// ---------------------------------------------------------------------------
inline void to_json(nlohmann::json &j, const CameraCalib &c) {
j = {{"camera_matrix", c.camera_matrix}, {"dist_coeffs", c.dist_coeffs}};
}
inline void from_json(const nlohmann::json &j, CameraCalib &c) {
j.at("camera_matrix").get_to(c.camera_matrix);
j.at("dist_coeffs").get_to(c.dist_coeffs);
if (c.camera_matrix.size() != 9) {
throw std::runtime_error("camera_matrix must have 9 elements, got " +
std::to_string(c.camera_matrix.size()));
}
if (c.dist_coeffs.size() != 5) {
throw std::runtime_error("dist_coeffs must have 5 elements, got " +
std::to_string(c.dist_coeffs.size()));
}
}
// ---------------------------------------------------------------------------
// nlohmann ADL hooks — StereoCalibrationRPC
// ---------------------------------------------------------------------------
inline void to_json(nlohmann::json &j, const StereoCalibrationRPC &s) {
j = {{"left", s.left},
{"right", s.right},
{"rotation", s.rotation},
{"translation", s.translation},
{"image_size", {{"width", s.width}, {"height", s.height}}}};
}
inline void from_json(const nlohmann::json &j, StereoCalibrationRPC &s) {
j.at("left").get_to(s.left);
j.at("right").get_to(s.right);
j.at("rotation").get_to(s.rotation);
j.at("translation").get_to(s.translation);
s.width = j.at("image_size").at("width").get<int>();
s.height = j.at("image_size").at("height").get<int>();
if (s.rotation.size() != 9) {
throw std::runtime_error("rotation must have 9 elements, got " +
std::to_string(s.rotation.size()));
}
if (s.translation.size() != 3) {
throw std::runtime_error("translation must have 3 elements, got " +
std::to_string(s.translation.size()));
}
}
// ---------------------------------------------------------------------------
// nlohmann ADL hooks — ImagePairRPC
// ---------------------------------------------------------------------------
inline void to_json(nlohmann::json &j, const ImagePairRPC &p) {
j = {{"frame", p.frame}, {"left", p.left}, {"right", p.right}};
}
inline void from_json(const nlohmann::json &j, ImagePairRPC &p) {
j.at("frame").get_to(p.frame);
j.at("left").get_to(p.left);
j.at("right").get_to(p.right);
}
} // namespace score

View File

@ -1,12 +1,11 @@
#pragma once #pragma once
#include "export.h"
#include <functional> #include <functional>
#include <jsonrpccxx/server.hpp> #include <jsonrpccxx/server.hpp>
#include <map> #include <map>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <string> #include <string>
#include <variant> #include "export.h"
extern "C" { extern "C" {
struct rpc_string { struct rpc_string {
@ -17,31 +16,20 @@ struct rpc_string {
}; };
} }
namespace score { namespace cloud_point_rpc {
class CRPC_EXPORT RpcServer { class CRPC_EXPORT RpcServer {
public: public:
using Handler = std::function<std::variant<nlohmann::json, std::string>( using Handler = std::function<nlohmann::json(const nlohmann::json &)>;
const nlohmann::json &)>;
using callback_t = rpc_string*(*)(rpc_string*); using callback_t = rpc_string*(*)(rpc_string*);
public:
/// @note +1 method implicitly added: get-available-methods
RpcServer();
void register_method(const std::string &name, Handler handler); void register_method(const std::string &name, Handler handler);
void register_method(const std::string &name, callback_t handler); void register_method(const std::string &name, callback_t handler);
uint64_t get_count() noexcept;
std::span<std::string_view> get_method_names() noexcept;
std::string_view get_method_name_by_id(uint64_t id) noexcept;
///@param request_str json rpc 2.0 formatted string ///@param request_str json rpc 2.0 formatted string
[[nodiscard]] std::string process(const std::string &request_str); [[nodiscard]] std::string process(const std::string &request_str);
private: private:
std::vector<std::string_view> handler_names_;
std::map<std::string, Handler> handlers_; std::map<std::string, Handler> handlers_;
}; };
} // namespace score } // namespace cloud_point_rpc

View File

@ -5,7 +5,7 @@
#include <type_traits> #include <type_traits>
#include <vector> #include <vector>
namespace score { namespace cloud_point_rpc {
template <typename T> template <typename T>
concept NumericType = requires(T param) { concept NumericType = requires(T param) {
@ -34,4 +34,4 @@ template <NumericType T> T deserialize(const std::vector<uint8_t> &buffer) {
return *reinterpret_cast<const T *>(buffer.data()); return *reinterpret_cast<const T *>(buffer.data());
} }
} // namespace score } // namespace cloud_point_rpc

View File

@ -1,12 +1,9 @@
#pragma once #pragma once
#include "cloud_point_rpc/config.hpp" #include "cloud_point_rpc/config.hpp"
#include "cloud_point_rpc/rpc_dto.hpp"
#include "export.h"
#include <cstdint>
#include <vector> #include <vector>
#include "export.h"
namespace score { namespace cloud_point_rpc {
class CRPC_EXPORT Service { class CRPC_EXPORT Service {
public: public:
@ -16,12 +13,8 @@ class CRPC_EXPORT Service {
[[nodiscard]] std::vector<double> get_extrinsic_params() const; [[nodiscard]] std::vector<double> get_extrinsic_params() const;
[[nodiscard]] std::vector<std::vector<double>> get_cloud_point() const; [[nodiscard]] std::vector<std::vector<double>> get_cloud_point() const;
[[nodiscard]] StereoCalibrationRPC get_stereo_calibration() const;
[[nodiscard]] ImagePairRPC get_image_pair();
private: private:
TestData data_; TestData data_;
uint64_t frame_counter_{0};
}; };
} // namespace score } // namespace cloud_point_rpc

View File

@ -1,12 +1,12 @@
#pragma once #pragma once
#include "cloud_point_rpc/serialize.hpp" #include "cloud_point_rpc/serialize.hpp"
#include "export.h"
#include "jsonrpccxx/iclientconnector.hpp" #include "jsonrpccxx/iclientconnector.hpp"
#include <asio.hpp> #include <asio.hpp>
#include <cloud_point_rpc/tcp_read.hpp> #include <cloud_point_rpc/tcp_read.hpp>
#include <glog/logging.h> #include <glog/logging.h>
#include <string> #include <string>
namespace score { #include "export.h"
namespace cloud_point_rpc {
/** /**
* TCPConnector main purpose is to implement jsonrpccxx::IClientConnector Send * TCPConnector main purpose is to implement jsonrpccxx::IClientConnector Send
* method As an internal implementation, TCPConnector adds to the beginning of * method As an internal implementation, TCPConnector adds to the beginning of
@ -43,4 +43,4 @@ class CRPC_EXPORT TCPConnector : public jsonrpccxx::IClientConnector {
asio::ip::tcp::socket socket_; asio::ip::tcp::socket socket_;
}; };
} // namespace score } // namespace cloud_point_rpc

View File

@ -3,7 +3,7 @@
#include <asio.hpp> #include <asio.hpp>
#include <cloud_point_rpc/serialize.hpp> #include <cloud_point_rpc/serialize.hpp>
#include <glog/logging.h> #include <glog/logging.h>
namespace score { namespace cloud_point_rpc {
static inline std::string tcp_read(asio::ip::tcp::socket &socket, static inline std::string tcp_read(asio::ip::tcp::socket &socket,
std::string_view prefix) { std::string_view prefix) {
@ -40,4 +40,4 @@ static inline std::string tcp_read(asio::ip::tcp::socket &socket,
return result; return result;
} }
} // namespace score } // namespace cloud_point_rpc

View File

@ -1,16 +1,16 @@
#pragma once #pragma once
#include "export.h"
#include <asio.hpp> #include <asio.hpp>
#include <atomic> #include <atomic>
#include <cloud_point_rpc/tcp_read.hpp> #include <cloud_point_rpc/tcp_read.hpp>
#include <functional> #include <functional>
#include <glog/logging.h> #include <glog/logging.h>
#include <list>
#include <ranges>
#include <string> #include <string>
#include <thread> #include <thread>
namespace score { #include "export.h"
#include <list>
#include <ranges>
namespace cloud_point_rpc {
class CRPC_EXPORT TcpServer { class CRPC_EXPORT TcpServer {
public: public:
@ -22,8 +22,7 @@ class CRPC_EXPORT TcpServer {
~TcpServer() { ~TcpServer() {
stop(); stop();
std::lock_guard lock(cliThrMtx_); for (auto &thread : client_threads | std::views::keys) {
for (auto &thread : client_threads_ | std::views::keys) {
thread.join(); thread.join();
} }
} }
@ -44,38 +43,27 @@ class CRPC_EXPORT TcpServer {
accept_thread_ = std::jthread([this]() { accept_thread_ = std::jthread([this]() {
LOG(INFO) << "Accept thread started"; LOG(INFO) << "Accept thread started";
while (running_) { while (running_) {
{ std::ranges::remove_if(client_threads.begin(), client_threads.end(), [](auto& client_info) {
std::lock_guard lock(cliThrMtx_);
client_threads_.remove_if([](auto &client_info) {
bool result = false; bool result = false;
if (client_info.second.wait_for(0ms) == if (client_info.second.wait_for(0ms) == std::future_status::ready) {
std::future_status::ready) {
client_info.first.join(); client_info.first.join();
result = true; result = true;
} }
return result; return result;
}); });
}
try { try {
auto socket = std::make_shared<asio::ip::tcp::socket>( auto socket = std::make_shared<asio::ip::tcp::socket>(
io_context_); io_context_);
{
std::lock_guard lock(acceptorMtx_);
acceptor_.accept(*socket); acceptor_.accept(*socket);
}
LOG(INFO) LOG(INFO)
<< "New connection from " << "New connection from "
<< socket->remote_endpoint().address().to_string(); << socket->remote_endpoint().address().to_string();
auto done = std::make_shared<std::promise<bool>>(); auto done = std::make_shared<std::promise<bool>>();
{ client_threads.push_back(std::make_pair(std::jthread([this, socket, done]() {
std::lock_guard lock(cliThrMtx_);
client_threads_.push_back(std::make_pair(
std::jthread([this, socket, done]() {
handle_client(socket); handle_client(socket);
done->set_value(true); done->set_value(true);
}), }),done->get_future()));
done->get_future()));
}
} catch (const std::system_error &e) { } catch (const std::system_error &e) {
LOG(INFO) << "Accept exception: " << e.what(); LOG(INFO) << "Accept exception: " << e.what();
if (running_) { if (running_) {
@ -96,9 +84,15 @@ class CRPC_EXPORT TcpServer {
return; return;
LOG(INFO) << "Stopping server..."; LOG(INFO) << "Stopping server...";
running_ = false; running_ = false;
// Ensure accept unblocks by connecting a dummy socket FIRST, // Closing acceptor unblocks accept() call usually, but sometimes we
// while the acceptor is still open. This avoids a race where close() // need to prod it
// removes the listen endpoint before the connect completes. asio::error_code ec;
std::ignore = acceptor_.close(ec);
if (ec.value()) {
LOG(ERROR) << std::format(
"acceptor closed with a value returned = {}", ec.value());
}
// Ensure accept unblocks by connecting a dummy socket
try { try {
asio::ip::tcp::endpoint endpoint(asio::ip::make_address(ip_), asio::ip::tcp::endpoint endpoint(asio::ip::make_address(ip_),
port_); port_);
@ -108,16 +102,6 @@ class CRPC_EXPORT TcpServer {
} catch (...) { } catch (...) {
// Ignore // Ignore
} }
// Now close the acceptor to unblock any pending accept()
asio::error_code ec;
{
std::lock_guard lock(acceptorMtx_);
std::ignore = acceptor_.close(ec);
}
if (ec.value()) {
LOG(ERROR) << std::format(
"acceptor closed with a value returned = {}", ec.value());
}
LOG(INFO) << "Acceptor closed"; LOG(INFO) << "Acceptor closed";
} }
@ -132,21 +116,19 @@ class CRPC_EXPORT TcpServer {
private: private:
void handle_client(std::shared_ptr<asio::ip::tcp::socket> socket) { void handle_client(std::shared_ptr<asio::ip::tcp::socket> socket) {
LOG(INFO) << "Server reading from client..."; LOG(INFO) << "Server reading from client...";
while (true) {
try { try {
auto payload = tcp_read(*socket, "TCPServer] "); auto payload = tcp_read(*socket, "TCPServer] ");
if (payload.empty()) size_t payload_length = payload.size();
break; // connection closed or read error if (payload_length > 0) {
std::string response = processor_(payload); std::string response = processor_(payload);
response += "\n"; response += "\n";
DLOG(INFO) << "Server sending response: " << response; DLOG(INFO) << "Server sending response: " << response;
inplace_size_embedding(response); inplace_size_embedding(response);
asio::write(*socket, asio::buffer(response)); asio::write(*socket, asio::buffer(response));
LOG(INFO) << "Server sent response"; LOG(INFO) << "Server sent response";
}
} catch (const std::exception &e) { } catch (const std::exception &e) {
LOG(WARNING) << "Client handling error: " << e.what(); LOG(WARNING) << "Client handling error: " << e.what();
break;
}
} }
} }
@ -158,10 +140,8 @@ class CRPC_EXPORT TcpServer {
asio::ip::tcp::acceptor acceptor_; asio::ip::tcp::acceptor acceptor_;
std::atomic<bool> running_; std::atomic<bool> running_;
std::list<std::pair<std::jthread, std::future<bool>>> client_threads_; std::list<std::pair<std::jthread, std::future<bool>>> client_threads;
std::mutex cliThrMtx_;
std::mutex acceptorMtx_;
std::jthread accept_thread_; std::jthread accept_thread_;
}; };
} // namespace score } // namespace cloud_point_rpc

View File

@ -27,11 +27,8 @@ CRPC_EXPORT void crpc_str_destroy(rpc_string*);
typedef rpc_string*(*callback_t)(rpc_string*); typedef rpc_string*(*callback_t)(rpc_string*);
CRPC_EXPORT void crpc_init(const char* config_path); CRPC_EXPORT void crpc_init(const char* config_path);
CRPC_EXPORT void crpc_init_with_address(const char* ip, int port);
CRPC_EXPORT void crpc_deinit(); CRPC_EXPORT void crpc_deinit();
CRPC_EXPORT rpc_string* crpc_get_method_name_by_id(uint64_t id);
CRPC_EXPORT uint64_t crpc_get_methods_count();
CRPC_EXPORT void crpc_add_method(callback_t cb, rpc_string* name); CRPC_EXPORT void crpc_add_method(callback_t cb, rpc_string* name);
#ifdef __cplusplus #ifdef __cplusplus

View File

@ -1,19 +1,18 @@
project('cloud_point_rpc', 'cpp', project('cloud_point_rpc', 'cpp',
version : '0.2.0', version : '0.1',
default_options : ['warning_level=3', 'cpp_std=c++23']) default_options : ['warning_level=3', 'cpp_std=c++20'])
# Dependencies # Dependencies
json_dep = dependency('nlohmann_json', fallback : ['nlohmann_json', 'nlohmann_json_dep']) json_dep = dependency('nlohmann_json', fallback : ['nlohmann_json', 'nlohmann_json_dep'])
thread_dep = dependency('threads') thread_dep = dependency('threads')
asio_dep = dependency('asio', fallback : ['asio', 'asio_dep']) asio_dep = dependency('asio', fallback : ['asio', 'asio_dep'])
base64_dep = dependency('base64', fallback: ['aklomp-base64', 'base64'])
# GLog via CMake fallback # GLog via CMake fallback
cmake = import('cmake') cmake = import('cmake')
glog_opt = cmake.subproject_options() glog_opt = cmake.subproject_options()
glog_opt.add_cmake_defines({ glog_opt.add_cmake_defines({
'WITH_GFLAGS': 'OFF', 'WITH_GFLAGS': 'OFF',
'WITH_GTEST': 'OFF', 'WITH_GTEST': 'OFF',
'CMAKE_POLICY_VERSION_MINIMUM': '3.5'
}) })
libtype = get_option('default_library') libtype = get_option('default_library')
@ -22,7 +21,6 @@ if libtype == 'static'
glog_opt.add_cmake_defines({ glog_opt.add_cmake_defines({
'BUILD_SHARED_LIBS': 'OFF', 'BUILD_SHARED_LIBS': 'OFF',
}) })
add_project_arguments('-DBASE64_STATIC_DEFINE', '-DYAML_CPP_STATIC_DEFINE', language: 'cpp')
endif endif
glog_proj = cmake.subproject('glog', options: glog_opt) glog_proj = cmake.subproject('glog', options: glog_opt)

View File

@ -1,6 +0,0 @@
{
"updatedAt": "2026-07-16T21:56:19.566Z",
"command": "update",
"gitHead": "ea99cd62eec0605661c2d8237a16000d0aae7839",
"model": "glm-5.2:cloud"
}

View File

@ -1 +0,0 @@
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.

View File

@ -1,187 +0,0 @@
---
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
The system follows a layered architecture for JSON-RPC 2.0 communication over TCP:
```
┌──────────────────────────────────────────────────────┐
│ Application / Unity │
│ (registers callbacks via C API or server_main.cpp) │
├──────────────────────────────────────────────────────┤
│ 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) │
├──────────────────────────────────────────────────────┤
│ RPC Layer │
│ RpcServer (method dispatch, JSON-RPC 2.0 handling) │
│ RpcClient (typed method calls via jsonrpccxx) │
├──────────────────────────────────────────────────────┤
│ Service Layer │
│ Service (mock camera data: intrinsics, extrinsics, │
│ stereo calibration, image pairs) │
├──────────────────────────────────────────────────────┤
│ Transport Layer │
│ TcpServer (accept loop, per-client threads — loops) │
│ TCPConnector (client-side connector for jsonrpccxx) │
│ tcp_read (framed read with size prefix) │
├──────────────────────────────────────────────────────┤
│ Serialization │
│ serialize.hpp (size embedding, numeric (de)serialize)│
│ rpc_coder (Base64 encode/decode) │
├──────────────────────────────────────────────────────┤
│ Config │
│ ConfigLoader (YAML → ServerConfig + TestData) │
└──────────────────────────────────────────────────────┘
```
All C++ code lives in the `score` namespace. The `rpc/` git submodule provides `jsonrpcxx` headers used by the client side.
## Communication model
The PlantUML diagram at `docs/communication_model.pu` describes the interaction flow:
![Communication model sequence diagram](../docs/cm.png)
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.
3. On each `compute_cloud()` call, the client fetches a synchronised image pair via `get-image-pair`, passes the decoded images through `StereoRectifier::rectify()`, then `PointCloudBuilder::build()` (SGBM disparity at 1/16 scale → `cv::reprojectImageTo3D` → NaN filter), and returns `std::expected<PointCloud, Error>` to the caller.
4. The server reads each request, dispatches it to `RpcServer`, which calls the registered handler (a C callback from Unity).
5. The Unity integration uses a **main-thread dispatcher**: RPC callbacks arrive on a C++ per-client thread and enqueue a `TaskCompletionSource` onto a `ConcurrentQueue`; the Unity `Update()` loop drains the queue and completes the task. A bounded timeout (~5000 ms) prevents deadlocks when the editor is paused.
6. The handler result is serialized back as a JSON-RPC response and sent over TCP; the per-client handler thread loops back to await the next request on the same connection.
See [docs/unity-integration.md](../docs/unity-integration.md) for the full Unity C# design spec.
## Wire framing
Every TCP message (both directions) uses a simple length-prefixed framing protocol:
- **Header**: 8 bytes — a `uint64_t` in native byte order containing the payload size.
- **Payload**: exactly `payload_size` bytes of JSON-RPC text.
This is implemented in:
- **`inplace_size_embedding()`** (`include/cloud_point_rpc/serialize.hpp`): Prepends the serialized `uint64_t` size to a `std::string`. Used by `TcpServer::handle_client` and `TCPConnector::Send`.
- **`tcp_read()`** (`include/cloud_point_rpc/tcp_read.hpp`): Reads the 8-byte header, deserializes the size, then reads the full payload — continuing to read if the payload arrives in multiple TCP segments.
```cpp
// Sending: embed size before the JSON payload
std::string response = processor_(payload);
response += "\n";
inplace_size_embedding(response);
asio::write(*socket, asio::buffer(response));
// Receiving: read 8-byte header, then full payload
std::array<char, 8> header;
asio::read(socket, asio::buffer(header, header.size()));
uint64_t packet_size = deserialize<uint64_t>(v);
std::vector<char> payload(packet_size);
asio::read(socket, asio::buffer(payload));
```
## TcpServer
`include/cloud_point_rpc/tcp_server.hpp`
- Accepts connections on a configurable IP/port using **asio**.
- Runs an **accept thread** (`std::jthread`) that blocks on `acceptor_.accept()`.
- Each client connection is handled in its own **`std::jthread`**; finished threads are cleaned up via a `std::list<pair<jthread, future<bool>>>` with periodic `remove_if`.
- The `RequestProcessor` (a `std::function<std::string(const std::string&)>`) is called for each incoming request — typically `RpcServer::process`.
- **`stop()`** unblocks the accept loop by first connecting a dummy socket to the listening endpoint (to avoid a race with `close()`), then closing the acceptor.
- Thread safety: `cliThrMtx_` guards the client thread list; `acceptorMtx_` guards the acceptor.
Key source: `include/cloud_point_rpc/tcp_server.hpp`
## RpcServer
`include/cloud_point_rpc/rpc_server.hpp`, `src/rpc_server.cpp`
- Maintains a `std::map<std::string, Handler>` of registered methods.
- **Handler** type: `std::function<std::variant<nlohmann::json, std::string>(const nlohmann::json&)>` — handlers can return either a JSON object or a raw string.
- Supports **two registration overloads**:
- `register_method(name, Handler)` — for C++ lambdas/functors.
- `register_method(name, callback_t)` — for C function pointers (`rpc_string* (*)(rpc_string*)`). The C callback receives the JSON params as a string and returns a string that is parsed as JSON if possible, or kept as a raw string otherwise.
- **`process(request_str)`** parses the JSON-RPC 2.0 request, validates required fields (`jsonrpc`, `method`, `id`), dispatches to the handler, and builds the response. Returns JSON-RPC error objects for parse errors (`-32700`), invalid requests (`-32600`), method not found (`-32601`), and server errors (`-32000`).
### Known issue
Non-object JSON requests (arrays, strings, numbers, null) throw `nlohmann::json::type_error` instead of returning a `-32600 Invalid Request` error. This is documented in `tests/test_rpc_edge_cases.cpp`.
## RpcClient
`include/cloud_point_rpc/rpc_client.hpp`
- Extends `jsonrpccxx::JsonRpcClient` from the json-rpc-cxx submodule.
- Wraps a `TCPConnector` as the transport connector.
- Provides typed methods: `get_intrinsic_params()`, `get_extrinsic_params()`, `get_cloud_point()`.
- Template `call<ReturnType>(name)` wraps `CallMethod` with an auto-incrementing request ID.
## TCPConnector
`include/cloud_point_rpc/tcp_connector.hpp`
- Implements `jsonrpccxx::IClientConnector`.
- On construction, opens a TCP connection to the server.
- `Send(request)` embeds the size prefix, writes the full message, then calls `tcp_read()` to receive the response.
## Service
`include/cloud_point_rpc/service.hpp`, `src/service.cpp`
- Holds `TestData` (intrinsic params, extrinsic params, cloud point).
- Returns configured data if available, otherwise returns identity-matrix fallbacks.
- Used by `server_main.cpp` for the standalone mock server. The C API path does not use `Service` — Unity provides its own handlers via callbacks.
## Config
`include/cloud_point_rpc/config.hpp`
- `ConfigLoader::load(path)` parses a YAML file into a `Config` struct.
- `Config` contains `ServerConfig` (ip, port) and `TestData` (camera parameters).
- Falls back to `127.0.0.1:8080` if no `server` section is present.
- Sample config: `config.yml` (server IP `127.0.0.1`, port `9095`).
## Executables
| Executable | Source | Description |
|---|---|---|
| `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_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 |
## Threading model
- **Server**: Accept thread + per-client handler threads (all `std::jthread`). Each client handler loops: it reads one request, processes it, sends the response, and then loops back to read the next request on the same connection. The connection remains open until the client disconnects (EOF) or an error occurs.
- **C API server** (`server_api.cpp`): The global `RpcServer` and `TcpServer` are guarded by `server_mtx`. The `rpc_string` garbage collector is guarded by `gc_mtx`.
- **Test API** (`test_api.cpp`): A `TestThread` runs a `std::jthread` with a condition-variable-driven loop that can auto-call registered methods at a configurable interval or process queued one-shot calls.

View File

@ -1,239 +0,0 @@
---
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
The project uses **Meson** with **Ninja** and requires a **C++23** compiler (GCC or Clang). The root `meson.build` declares the project and dependencies; `src/meson.build` and `tests/meson.build` define build targets.
### Dependencies
All dependencies are resolved via Meson wrap files in `subprojects/` or system packages:
| Dependency | Wrap file | Purpose |
|---|---|---|
| nlohmann_json | `nlohmann_json.wrap` | JSON parsing |
| asio | `asio.wrap` | TCP networking (header-only) |
| glog | `glog.wrap` (CMake subproject) | Logging |
| yaml-cpp | `yaml-cpp.wrap` | Config file parsing |
| aklomp-base64 | `aklomp-base64.wrap` | Base64 encode/decode |
| GoogleTest | `gtest.wrap` | Unit testing (gtest + gmock) |
The `rpc/` directory is a git submodule pointing to [json-rpc-cxx](https://github.com/jsonrpcx/json-rpc-cxx), providing `jsonrpccxx/` headers used by the client. It is included via `include_directories` in the root `meson.build`.
### Build targets
Defined in `src/meson.build`:
**Shared libraries:**
| Library | Sources | Notes |
|---|---|---|
| `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 |
|---|---|---|
| `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
```bash
git submodule init && git submodule update
meson setup build
meson compile -C build
```
Run the server:
```bash
./build/src/cloud_point_rpc_server config.yaml
```
Run the CLI client:
```bash
./build/src/cloud_point_rpc_cli config.yaml
```
### Windows build
Windows requires static linking and a Python venv for Meson:
```powershell
git submodule init
git submodule update
python3 -m venv .\venv
.\venv\Scripts\Activate.ps1
pip install meson cmake
meson setup -Ddefault_library=static build
meson compile -C build
# To get DLLs on PATH:
meson devenv -C build
```
The root `meson.build` adds a `devenv` on Windows that appends subproject DLL directories to `PATH` when `default_library=shared`. When building static, the build defines `BASE64_STATIC_DEFINE` and `YAML_CPP_STATIC_DEFINE`.
### Clean build
```bash
meson compile --clean -C build
```
## Configuration
The server reads a YAML config file. Sample: `config.yml`:
```yaml
server:
ip: "127.0.0.1"
port: 9095
```
Full config schema (parsed by `ConfigLoader` in `include/cloud_point_rpc/config.hpp`):
| Section | Field | Type | Default | Description |
|---|---|---|---|---|
| `server.ip` | string | `127.0.0.1` | Server bind address |
| `server.port` | int | `8080` | Server listen port |
| `test_data.intrinsic_params` | list of double | empty (fallback to identity 3×3) | Camera intrinsic parameters |
| `test_data.extrinsic_params` | list of double | empty (fallback to identity 4×4) | Camera extrinsic parameters |
| `test_data.cloud_point` | list of lists of double | empty (fallback to 3 sample points) | Point cloud data |
The `test_data` section is only used by the standalone mock server (`server_main.cpp`). The C API path (`crpc_init`) loads config for server address but does not use `test_data` — Unity provides its own handlers.
## Docker
The `Dockerfile` uses Ubuntu 24.04 and builds the CLI client. It installs build dependencies, copies the project, runs `meson setup build && meson compile -C build`, and starts the CLI by default.
```bash
docker build -t cloud-point-rpc .
docker run --network=host -it cloud-point-rpc
```
Mount a custom config:
```bash
docker run --network=host -it -v $(pwd)/my_config.yaml:/app/config.yaml cloud-point-rpc
```
> The server is not configured to run inside Docker — only the CLI client. The `--network=host` flag simplifies connectivity to a server running on the host.
## CI
`.gitea/workflows/test.yaml` defines a Gitea Actions workflow named "Verification" that runs on push to `master`:
1. Install build tools (cmake, make, ninja, gcc)
2. Install Meson via pip in a venv
3. Checkout with submodules
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.
### Run tests
```bash
meson test -C build # all tests
meson test -C build -v # verbose
meson test -C build unit_tests # explicit
```
### Test suites
| File | Area | Description |
|---|---|---|
| `test_rpc.cpp` | RPC server | Basic request/response, method dispatch |
| `test_rpc_edge_cases.cpp` | RPC server | Edge cases: invalid JSON, missing fields, non-object requests |
| `test_tcp.cpp` | TCP server | TCP connection and message round-trip |
| `test_tcp_edge_cases.cpp` | TCP server | TCP edge cases |
| `test_integration.cpp` | Integration | Full server+client stack with mock data, real TCP |
| `test_cli.cpp` | CLI | CLI client menu and output |
| `test_c_api.cpp` | C API | `crpc_test_*` functions, callback registration, auto-call |
| `test_c_api_edge_cases.cpp` | C API | Multiple methods, removal, scheduling |
| `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_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`. 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`.
- 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
The project uses clang-format with LLVM base style and 4-space indent (`.clang-format`):
```bash
ninja -C build clang-format
# or
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
- `src/meson.build` — Library and executable targets
- `tests/meson.build` — Test executable definition
- `config.yml` — Sample config
- `Dockerfile` — Container build
- `.gitea/workflows/test.yaml` — CI pipeline
- `.clang-format` — Code formatting config

View File

@ -1,136 +0,0 @@
---
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.
> **Unity C# design spec**: For the complete Unity-side implementation guide — including `rpc_string` ownership rules, IL2CPP callback pinning, main-thread dispatcher design, and shutdown ordering — see [docs/unity-integration.md](../docs/unity-integration.md). The C API described here is the C++ side of the interface; the C# side is the Unity project's responsibility.
Two C API surfaces exist:
| API | Header | Library | Purpose |
|---|---|---|---|
| Server API | `include/server_api.h` | `libcloud_point_rpc` | Start/stop the TCP server, register RPC methods |
| Test API | `include/test_api.h` | `test_cloud_point` | Internal test harness: register methods, schedule calls, auto-call loop |
Both APIs use the `rpc_string` type and `callback_t` function pointer typedef.
## Export macros
`include/export.h` defines `CRPC_EXPORT`. On Windows, it resolves to `__declspec(dllexport)` when `CRPC_SERVER_API_EXPORT` is defined (set in `src/meson.build`) and `__declspec(dllimport)` otherwise. On GCC/Clang, it uses `__attribute__((visibility("default")))`. This allows the same headers to be used when building the library and when consuming it.
## `rpc_string`
Defined in `include/cloud_point_rpc/rpc_server.hpp` inside an `extern "C"` block:
```c
struct rpc_string {
std::string s; // C++ std::string, but the struct is C-ABI compatible
};
```
Although the struct contains a `std::string`, it is allocated and managed by the library. Consumers interact with it through opaque pointers and accessor functions:
| Function | Description |
|---|---|
| `crpc_str_create(data, size)` | Allocate a new `rpc_string` with the given data. Tracked by internal GC. |
| `crpc_str_destroy(ptr)` | Manually free a `rpc_string`. |
| `crpc_str_get_data(ptr)` | Get the raw C string pointer. |
| `crpc_str_get_size(ptr)` | Get the string length. |
### Garbage collector
`src/server_api.cpp` maintains a static `std::list<std::unique_ptr<rpc_string>> gc` protected by `gc_mtx`. All `crpc_str_create` allocations are tracked in this list. `crpc_deinit()` clears the entire list. `crpc_str_destroy` removes a specific entry. This prevents memory leaks if Unity forgets to call `destroy`, though manual destruction is recommended to avoid excessive memory usage.
> **Important**: `rpc_string` pointers returned from callbacks are owned by the library's GC. Do not `free()` them — use `crpc_str_destroy()`.
## Server API (`server_api.h`)
### Lifecycle
```c
// Initialize and start the server from a config file
crpc_init("config.yaml");
// Or initialize with an explicit address (no config file needed)
crpc_init_with_address("127.0.0.1", 9095);
// ... register methods and serve ...
// Stop server and free all GC-tracked rpc_strings
crpc_deinit();
```
- `crpc_init(config_path)` — Loads YAML config via `ConfigLoader`, creates a `TcpServer` with the configured IP/port, and starts it. Initializes Google Logging if not already initialized. See [Architecture → Config](architecture.md#config).
- `crpc_init_with_address(ip, port)` — Same but without a config file. Used when the consumer wants to set the address directly.
- `crpc_deinit()` — Stops the server (resets the `TcpServer` unique_ptr) and clears the `rpc_string` GC list.
### Registering methods
```c
rpc_string* my_handler(rpc_string* params_json) {
// params_json->s contains the JSON params as a string
// Build your result (JSON or raw string)
return crpc_str_create("{\"key\":\"value\"}", 15);
}
rpc_string method_name;
method_name.s = "my-method";
crpc_add_method(my_handler, &method_name);
```
- `callback_t` is `rpc_string* (*)(rpc_string*)` — a C function pointer.
- The callback receives the JSON `params` object serialized as a string in `rpc_string->s`.
- The callback returns a `rpc_string*` whose string is parsed as JSON if possible, or used as a raw string in the `result` field. See [RPC Protocol → Handler registration](rpc-protocol.md#handler-registration).
- `crpc_add_method` is guarded by `server_mtx` and registers the callback on the global `RpcServer`.
### Global state
`server_api.cpp` uses file-level statics: `rpc_server` (the global `RpcServer`), `server` (the `TcpServer` unique_ptr), `gc` (the string GC list), and two mutexes (`gc_mtx`, `server_mtx`). This means only one server instance is supported per process.
## Test API (`test_api.h`)
The test API is built into a separate shared library (`test_cloud_point`) and provides a `TestThread` class (in `src/test_api.cpp`) that runs a background `std::jthread` for testing registered methods without a real TCP connection.
### Lifecycle
```c
crpc_test_init(); // Start the test thread + Google Logging
// ... register methods, schedule calls ...
crpc_test_deinit(); // Stop thread, call crpc_deinit(), reset state
```
### Methods
| Function | Description |
|---|---|
| `crpc_test_add_method(cb, name)` | Register a method on the test `RpcServer`. Duplicates are ignored. |
| `crpc_test_remove_method(name)` | Remove a registered method. Returns 0 on success, -1 if not found. |
| `crpc_test_schedule_call(name)` | Enqueue a one-shot call to the named method (processed by the test thread). |
| `crpc_test_change_duration(ms)` | Set the auto-call sleep interval (default 50ms). |
| `crpc_test_duration()` | Get the current sleep interval. |
| `crpc_test_auto_call(state)` | Enable (1) or disable (0) auto-calling registered methods on each sleep cycle. |
### Test thread behavior
The `TestThread::routine()` loop:
1. If there are queued one-shot calls, process them (build a JSON-RPC request and call `server.process()`).
2. If auto-call is enabled and methods exist, call the next method in round-robin order.
3. If auto-call is enabled and the queue is empty, wait on a condition variable for the configured duration (or until stop is requested).
4. Stop when `jthread` stop is requested via `crpc_test_deinit()`.
> **Note**: The test API does **not** start a TCP server. It processes JSON-RPC requests directly through `RpcServer::process()`, logging results. It is designed for testing handler registration and callback behavior in C, as demonstrated in `tests/test_c_api.cpp` and `tests/test_c_api_edge_cases.cpp`.
## Source references
- `include/server_api.h` — Server C API declarations
- `src/server_api.cpp` — Server C API implementation, `rpc_string` GC
- `include/test_api.h` — Test C API declarations
- `src/test_api.cpp``TestThread` implementation and test C API
- `include/export.h``CRPC_EXPORT` macro
- `include/cloud_point_rpc/rpc_server.hpp``rpc_string` struct and `callback_t` typedef

View File

@ -1,13 +0,0 @@
---
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.

View File

@ -1,86 +0,0 @@
---
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?
Cloud Point RPC is a **C++23 JSON-RPC 2.0** server and client implementation designed to bridge a C++ backend with a **Unity Scene** over TCP. Unity serves stereo camera data (`get-stereo-calibration`, `get-image-pair`) over the embedded RPC server; the C++ `CloudPointClient` fetches calibration once on `connect()`, then on each `compute_cloud()` call retrieves a synchronised image pair, runs stereo rectification + SGBM disparity + `cv::reprojectImageTo3D`, and returns a filtered `PointCloud`. A C API (`server_api.h`) allows Unity to embed the server, register custom RPC handlers, and manage the server lifecycle from native code.
The server side with C-API is fully implemented. The C++ stereo point-cloud client (`CloudPointClient`, `StereoRectifier`, `PointCloudBuilder`) is implemented. The remaining item is the Unity-side C# implementation; see [docs/unity-integration.md](../docs/unity-integration.md).
## Repository layout
| Path | Purpose |
|---|---|
| `include/cloud_point_rpc/` | C++ public headers: TCP server/client, RPC server/client, config, serialization, service, coder |
| `include/server_api.h` | C API for embedding the server in Unity/native consumers |
| `include/test_api.h` | C API for test-driven method registration and scheduled calls |
| `include/export.h` | Cross-platform shared-library export macros (`CRPC_EXPORT`) |
| `src/` | Implementation files and executable entrypoints |
| `tests/` | GTest/GMock unit and integration tests (single `unit_tests` executable) |
| `rpc/` | Git submodule — [json-rpc-cxx](https://github.com/jsonrpcx/json-rpc-cxx) providing `jsonrpccxx` headers |
| `subprojects/` | Meson wrap dependencies (asio, nlohmann_json, glog, yaml-cpp, base64, gtest) |
| `include/cloud_point/` | OpenCV compute library headers: `StereoRectifier`, `PointCloudBuilder`, `CloudPointClient` |
| `src/cloud_point/` | OpenCV compute library implementation (optional, requires opencv4) |
| `docs/` | PlantUML communication model diagram and Unity integration design spec |
| `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
```bash
git submodule init && git submodule update
meson setup build
meson compile -C build
```
Start the test server (uses mock camera data from `config.yml`):
```bash
./build/src/cloud_point_rpc_server config.yaml
```
Run the interactive CLI client:
```bash
./build/src/cloud_point_rpc_cli config.yaml
```
CLI menu options (options 4 and 5 are hidden when built without opencv4):
| Option | Action |
|--------|--------|
| 4 | Compute point cloud — prints point count and bounding box |
| 5 | Compute point cloud and save to `output.ply` |
Run all tests:
```bash
meson test -C build -v
```
For Windows build instructions and Docker usage, see [Build & Testing](build-and-testing.md).
## Documentation sections
- [Architecture](architecture.md) — Layered design, TCP framing, threading model, communication flow
- [RPC Protocol](rpc-protocol.md) — JSON-RPC 2.0 methods, request/response format, error codes, Base64 encoding
- [C API](c-api.md) — C interface for Unity integration, `rpc_string` memory management, test API
- [Build & Testing](build-and-testing.md) — Meson build system, dependencies, config, Docker, CI, test suite overview
## Key concepts
- **Namespace**: All C++ code lives in `score` (renamed from `cloud_point_rpc` early in development).
- **Wire framing**: Every TCP message is prefixed with an 8-byte little-endian `uint64_t` payload size, then the JSON-RPC payload follows. See [Architecture → Wire framing](architecture.md#wire-framing).
- **Two server entrypoints**: `server_main.cpp` is a standalone executable with mock data; `server_api.cpp` provides the embeddable C API that Unity uses to start the server and register callbacks.
- **Base64**: Image pixel payloads (`get-image-pair`) are Base64-encoded for ASCII-safe transport over JSON. Calibration arrays are plain JSON doubles — not Base64. Encoding on the Unity side; decoding in `Base64RPCCoder` on the client side per API.md.
- **Persistent connections**: `TcpServer::handle_client` loops per connection — multiple RPC round-trips share one TCP connection without reconnecting.
- **std::expected**: The `cloud_point` compute library uses `std::expected<PointCloud, Error>` (C++23) as its return type. Callers check `has_value()` before accessing the result.

View File

@ -1,243 +0,0 @@
---
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
The server implements **JSON-RPC 2.0** over TCP with length-prefixed framing (see [Architecture → Wire framing](architecture.md#wire-framing)).
Batch requests are not supported — only single request objects are processed.
### Request format
```json
{
"jsonrpc": "2.0",
"method": "<method_name>",
"params": {},
"id": <integer|string>
}
```
The `params` field is accepted but currently ignored by all implemented methods. The `id` field is required; requests without it receive a `-32600 Invalid Request` error.
### Success response
```json
{
"jsonrpc": "2.0",
"result": <method_specific_result>,
"id": <matching_request_id>
}
```
### Error response
```json
{
"jsonrpc": "2.0",
"error": {
"code": <integer>,
"message": "<string>"
},
"id": <matching_request_id>
}
```
### Error codes
| Code | Meaning | When |
|---|---|---|
| `-32700` | Parse error | Request is not valid JSON |
| `-32600` | Invalid Request | Missing `jsonrpc`, `method`, or `id` fields, or `jsonrpc != "2.0"` |
| `-32601` | Method not found | No handler registered for the requested method name |
| `-32000` | Server error | Handler threw an exception |
> **Known issue**: Non-object JSON (arrays, strings, numbers, null) causes a `nlohmann::json::type_error` instead of a clean `-32600` response. See `tests/test_rpc_edge_cases.cpp`.
Source: `src/rpc_server.cpp``RpcServer::process()` and `create_error()`.
## Methods
### `get-stereo-calibration`
Returns full stereo rig calibration: intrinsics for both cameras, stereo rotation and translation, and sensor resolution. Called once by `CloudPointClient::connect()`.
**Request:**
```json
{"jsonrpc": "2.0", "method": "get-stereo-calibration", "id": 1}
```
**Response:**
```json
{
"left": { "camera_matrix": [9 doubles], "dist_coeffs": [5 doubles] },
"right": { "camera_matrix": [9 doubles], "dist_coeffs": [5 doubles] },
"rotation": [9 doubles],
"translation": [3 doubles],
"image_size": { "width": 640, "height": 480 }
}
```
All matrices are row-major. `camera_matrix` is the 3×3 intrinsic matrix; `rotation` is the 3×3 left-to-right rotation (OpenCV convention); `translation` is in metres. Calibration arrays are **plain JSON doubles — not Base64**.
Test mock defaults: fx=fy=800, cx=320, cy=240, zero distortion, R=I, T=[-0.06, 0, 0], 640×480.
---
### `get-image-pair`
Returns a synchronised stereo frame as two base64-encoded images. Called on each `CloudPointClient::compute_cloud()`.
**Request:**
```json
{"jsonrpc": "2.0", "method": "get-image-pair", "id": 2}
```
**Response:**
```json
{
"frame": 42,
"left": { "width": 640, "height": 480, "type": "BGR", "data": "<base64>" },
"right": { "width": 640, "height": 480, "type": "BGR", "data": "<base64>" }
}
```
`data` is Base64-encoded raw pixel bytes (standard alphabet, no line breaks). `type` is `"BGR"` (3 ch), `"RGBA"` (4 ch), or `"DEPTH"` (1 ch float32). Unity must flip GPU readback vertically before encoding (wire format is top-left-origin row-major).
DTOs: `StereoCalibrationDto` and `ImagePairDto` in `include/cloud_point_rpc/rpc_dto.hpp`. Decoded client-side via `Base64RPCCoder`.
---
### `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.
**Request:**
```json
{"jsonrpc": "2.0", "method": "get-intrinsic-params", "id": 3}
```
**Response:**
```json
{"jsonrpc": "2.0", "result": [fx, 0, cx, 0, fy, cy, 0, 0, 1], "id": 3}
```
Result: 9 plain JSON doubles. **Not Base64.**
### `get-extrinsic-params` *(legacy)*
Retrieves left-camera extrinsic matrix as a flat 4×4 matrix (row-major, 16 doubles). Not used by `CloudPointClient`; kept for backward compatibility.
**Request:**
```json
{"jsonrpc": "2.0", "method": "get-extrinsic-params", "id": 4}
```
**Response:**
```json
{"jsonrpc": "2.0", "result": [16 doubles], "id": 4}
```
Result: 16 plain JSON doubles. **Not Base64.**
### `get-cloud-point`
Retrieves the current field-of-view point cloud.
**Request:**
```json
{"jsonrpc": "2.0", "method": "get-cloud-point", "id": 3}
```
**Response:**
```json
{"jsonrpc": "2.0", "result": {"width": int, "height": int, "data": <base64-encoded-array>}, "id": 3}
```
Result type: `matrix WxH` (list of `[x, y, z]` points), Base64-encoded.
> **Note**: The standalone mock server (`server_main.cpp`) returns these as raw JSON arrays (not Base64-encoded) via `Service`. The Base64 encoding is expected to be implemented on the Unity side per `API.md`.
## Handler registration
Handlers are registered with `RpcServer::register_method()`. Two forms exist:
### C++ handler
```cpp
rpc_server.register_method("get-intrinsic-params", [&](const json& params) {
return service.get_intrinsic_params(); // returns vector<double>
});
```
The handler returns `std::variant<nlohmann::json, std::string>`. If a `json` is returned, it is placed directly in the `result` field. If a `std::string` is returned, it is placed as-is.
### C callback handler
```cpp
rpc_string* my_callback(rpc_string* params_json) {
// params_json->s contains the JSON params as a string
// return a result string (JSON or raw)
return crpc_str_create("result_data", 11);
}
rpc_server.register_method("my-method", my_callback);
```
The C callback receives the JSON `params` as a string in `rpc_string->s`. The return value's string is parsed as JSON if possible; otherwise it is used as a raw string in the `result` field.
Source: `src/rpc_server.cpp``register_method(name, callback_t)` overload.
## Base64 encoding
`include/cloud_point_rpc/rpc_coder.hpp`, `src/rpc_coder.cpp`
- `Base64RPCCoder` implements the `IRPCCoder` interface with `encode()` and `decode()` methods.
- Uses the [aklomp/base64](https://github.com/aklomp/base64) library (Meson wrap `subprojects/aklomp-base64.wrap`).
- `encode(vector<char>) → string` and `decode(string) → vector<char>`.
- Includes overflow protection: throws `std::length_error` if input exceeds safe size limits.
> Per `API.md`: **only image pixel data** (`get-image-pair` `data` fields) uses Base64. Calibration arrays (`camera_matrix`, `dist_coeffs`, `rotation`, `translation`) and legacy params are plain JSON doubles — never Base64. Unity encodes images before sending; the C++ client decodes them via `Base64RPCCoder`.
## Client-side usage
`include/cloud_point_rpc/rpc_client.hpp`
`RpcClient` wraps `jsonrpccxx::JsonRpcClient` and provides typed access:
```cpp
score::TCPConnector connector(ip, port);
score::RpcClient client(connector);
auto intrinsic = client.get_intrinsic_params(); // vector<double>
auto extrinsic = client.get_extrinsic_params(); // vector<double>
auto cloud = client.get_cloud_point(); // vector<vector<double>>
```
The interactive CLI (`src/cli.cpp`) and `minimal_client` (`src/minimal_client.cpp`) demonstrate client usage.

View File

@ -1,83 +0,0 @@
#!/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

@ -3,22 +3,13 @@
#include <glog/logging.h> #include <glog/logging.h>
#include <string> #include <string>
#ifdef HAVE_CLOUD_POINT_COMPUTE namespace cloud_point_rpc {
#include "cloud_point/cloud_point_client.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
#endif
namespace score {
void print_menu(std::ostream &output) { void print_menu(std::ostream &output) {
output << "\n=== Cloud Point RPC CLI ===" << std::endl; output << "\n=== Cloud Point RPC CLI ===" << std::endl;
output << "1. get-intrinsic-params" << std::endl; output << "1. get-intrinsic-params" << std::endl;
output << "2. get-extrinsic-params" << std::endl; output << "2. get-extrinsic-params" << std::endl;
output << "3. get-cloud-point" << std::endl; output << "3. get-cloud-point" << std::endl;
output << "4. compute-cloud" << std::endl;
output << "5. compute-cloud + save PLY" << std::endl;
output << "0. Exit" << std::endl; output << "0. Exit" << std::endl;
output << "Select an option: "; output << "Select an option: ";
} }
@ -41,7 +32,7 @@ std::string vector_to_string(const std::vector<std::vector<T>> &v) {
} }
int run_cli(std::istream &input, std::ostream &output, const std::string &ip, int run_cli(std::istream &input, std::ostream &output, const std::string &ip,
int port, const CliStereoOptions &stereo) { int port) {
try { try {
TCPConnector connector(ip, port); TCPConnector connector(ip, port);
RpcClient client(connector); RpcClient client(connector);
@ -57,17 +48,20 @@ int run_cli(std::istream &input, std::ostream &output, const std::string &ip,
if (choice == "0") if (choice == "0")
break; break;
if (choice == "1" || choice == "2" || choice == "3") {
std::string method; std::string method;
if (choice == "1") { if (choice == "1") {
method = "get-intrinsic-params"; method = "get-intrinsic-params";
} else if (choice == "2") { } else if (choice == "2") {
method = "get-extrinsic-params"; method = "get-extrinsic-params";
} else { } else if (choice == "3") {
method = "get-cloud-point"; method = "get-cloud-point";
} else {
output << "Invalid option: " << choice << std::endl;
continue;
} }
try { try {
if (method == "get-intrinsic-params") { if (method == "get-intrinsic-params") {
auto response = client.get_intrinsic_params(); auto response = client.get_intrinsic_params();
output << vector_to_string(response); output << vector_to_string(response);
@ -83,77 +77,6 @@ int run_cli(std::istream &input, std::ostream &output, const std::string &ip,
} catch (const std::exception &e) { } catch (const std::exception &e) {
output << "\nRPC Error: " << e.what() << std::endl; output << "\nRPC Error: " << e.what() << std::endl;
} }
} else if (choice == "4" || choice == "5") {
#ifdef HAVE_CLOUD_POINT_COMPUTE
try {
const auto algo = stereo.use_gpu ? StereoAlgorithmType::GPU
: StereoAlgorithmType::CPU;
CpuStereoMatcher::Params matcher;
matcher.wls_filter = stereo.wls_filter;
CloudPointClient cpc(
ip, port, algo,
PointCloudBuilder::Options{stereo.min_depth_m,
stereo.max_depth_m},
stereo.num_disparities, matcher);
cpc.connect();
auto result = cpc.compute_cloud();
if (!result) {
output << "Error: " << result.error().message
<< std::endl;
} else {
const auto &cloud = *result;
const auto valid = cloud.valid_points();
float x_min = std::numeric_limits<float>::max();
float x_max = std::numeric_limits<float>::lowest();
float y_min = std::numeric_limits<float>::max();
float y_max = std::numeric_limits<float>::lowest();
float z_min = std::numeric_limits<float>::max();
float z_max = std::numeric_limits<float>::lowest();
for (const auto &pt : valid) {
x_min = std::min(x_min, pt[0]);
x_max = std::max(x_max, pt[0]);
y_min = std::min(y_min, pt[1]);
y_max = std::max(y_max, pt[1]);
z_min = std::min(z_min, pt[2]);
z_max = std::max(z_max, pt[2]);
}
output << "Cloud: " << cloud.width << "x"
<< cloud.height << " valid_pts=" << valid.size()
<< "\n";
if (!valid.empty()) {
output << " bbox x=[" << x_min << "," << x_max
<< "]"
<< " y=[" << y_min << "," << y_max << "]"
<< " z=[" << z_min << "," << z_max << "]\n";
}
if (choice == "5") {
output << "PLY output path: ";
std::string path;
if (input >> path) {
PlyOptions ply;
ply.stride = stereo.ply_stride;
const auto faces = write_ply(cloud, path, ply);
output << "Saved mesh (stride " << ply.stride
<< ", " << faces << " faces) from "
<< valid.size() << " valid points to "
<< path << "\n";
}
}
}
} catch (const std::exception &e) {
output << "Error: " << e.what() << std::endl;
}
#else
output << "OpenCV support not built" << std::endl;
#endif
} else {
output << "Invalid option: " << choice << std::endl;
}
} }
} catch (const std::exception &e) { } catch (const std::exception &e) {
LOG(ERROR) << "CLI Error: " << e.what(); LOG(ERROR) << "CLI Error: " << e.what();
@ -163,4 +86,4 @@ int run_cli(std::istream &input, std::ostream &output, const std::string &ip,
return 0; return 0;
} }
} // namespace score } // namespace cloud_point_rpc

View File

@ -1,266 +0,0 @@
#include "cloud_point/cloud_point_client.hpp"
#include "cloud_point/imageFactory.h"
#include "cloud_point_rpc/rpc_client.hpp"
#include "cloud_point_rpc/tcp_connector.hpp"
#include <algorithm>
#include <array>
#include <bit>
#include <cmath>
#include <fstream>
#include <jsonrpccxx/common.hpp>
#include <opencv2/imgproc.hpp>
#include <stdexcept>
#include <vector>
namespace score {
CloudPointClient::CloudPointClient(std::string ip, int port,
StereoAlgorithmType algo,
PointCloudBuilder::Options opts,
int num_disparities,
CpuStereoMatcher::Params matcher_params)
: ip_(std::move(ip)), port_(port), algo_(algo), opts_(opts),
num_disparities_(num_disparities), matcher_params_(matcher_params) {}
CloudPointClient::~CloudPointClient() = default;
void CloudPointClient::connect() {
connector_ = std::make_unique<TCPConnector>(ip_, port_);
client_ = std::make_unique<RpcClient>(*connector_);
const auto calib_rpc = client_->get_stereo_calibration();
const auto calib = StereoRectifier::Calibration::from_rpc(calib_rpc);
rectifier_ = std::make_unique<StereoRectifier>(calib);
matcher_ = StereoMatcherFactory::create(algo_, num_disparities_,
matcher_params_);
builder_ = std::make_unique<PointCloudBuilder>(rectifier_->q(), opts_);
}
bool CloudPointClient::connected() const noexcept {
return connector_ != nullptr && client_ != nullptr &&
rectifier_ != nullptr && matcher_ != nullptr && builder_ != nullptr;
}
std::expected<PointCloud, CloudPointClient::Error>
CloudPointClient::compute_cloud() {
if (!connected()) {
return std::unexpected(Error{"not connected — call connect() first"});
}
try {
// Fetch image pair.
const auto pair = client_->get_image_pair();
// Decode raw bytes into cv::Mat via ImageFactory.
auto left_img = ImageFactory::create(pair.left);
auto right_img = ImageFactory::create(pair.right);
// Convert to grayscale according to image type.
auto to_gray = [](ImageRPC::Type type,
cv::Mat mat) -> std::expected<cv::Mat, Error> {
cv::Mat gray;
switch (type) {
case ImageRPC::Type::BGR:
cv::cvtColor(mat, gray, cv::COLOR_BGR2GRAY);
return gray;
case ImageRPC::Type::RGBA:
cv::cvtColor(mat, gray, cv::COLOR_RGBA2GRAY);
return gray;
case ImageRPC::Type::DEPTH:
return std::unexpected(
Error{"depth images not supported by stereo pipeline"});
default:
return std::unexpected(Error{"unknown image type"});
}
};
auto left_gray_r = to_gray(pair.left.type, left_img.get());
if (!left_gray_r)
return std::unexpected(left_gray_r.error());
auto right_gray_r = to_gray(pair.right.type, right_img.get());
if (!right_gray_r)
return std::unexpected(right_gray_r.error());
const cv::Mat &left_gray = *left_gray_r;
const cv::Mat &right_gray = *right_gray_r;
// Rectify, compute disparity, reproject.
auto [rect_left, rect_right] =
rectifier_->rectify(left_gray, right_gray);
const auto disparity = matcher_->compute(rect_left, rect_right);
return builder_->build(disparity);
} catch (const jsonrpccxx::JsonRpcException &e) {
return std::unexpected(Error{e.what()});
} catch (const std::exception &e) {
return std::unexpected(Error{e.what()});
}
}
// ---------------------------------------------------------------------------
// PLY helper
// ---------------------------------------------------------------------------
namespace {
struct Vertex {
float x, y, z;
};
/// Longest edge of a triangle must not exceed max_ratio * mean depth.
bool triangle_ok(const Vertex &a, const Vertex &b, const Vertex &c,
float max_ratio) {
const auto dist2 = [](const Vertex &p, const Vertex &q) {
const float dx = p.x - q.x, dy = p.y - q.y, dz = p.z - q.z;
return dx * dx + dy * dy + dz * dz;
};
const float longest2 = std::max({dist2(a, b), dist2(b, c), dist2(c, a)});
const float limit = max_ratio * (a.z + b.z + c.z) / 3.0f;
return longest2 <= limit * limit;
}
} // namespace
size_t write_ply(const PointCloud &cloud, const std::string &path,
const PlyOptions &opts) {
if (opts.stride < 1) {
throw std::invalid_argument("write_ply: stride must be >= 1, got " +
std::to_string(opts.stride));
}
// Decimated grid dimensions; pixel (r, c) of the sub-grid maps to
// (r * stride, c * stride) of the source cloud.
const int grid_w = (cloud.width + opts.stride - 1) / opts.stride;
const int grid_h = (cloud.height + opts.stride - 1) / opts.stride;
// Map every valid sub-grid cell to its index in the vertex list. With
// stride > 1 each vertex is the mean of the valid pixels in its
// stride x stride block, which divides the per-pixel disparity noise by
// roughly the stride and yields a far smoother mesh than sub-sampling.
const size_t n_px =
static_cast<size_t>(grid_w) * static_cast<size_t>(grid_h);
std::vector<int> index(n_px, -1);
std::vector<Vertex> vertices;
vertices.reserve(n_px);
for (int gr = 0; gr < grid_h; ++gr) {
for (int gc = 0; gc < grid_w; ++gc) {
double sx = 0.0, sy = 0.0, sz = 0.0;
int count = 0;
const int row_begin = gr * opts.stride;
const int col_begin = gc * opts.stride;
const int row_end = std::min(row_begin + opts.stride, cloud.height);
const int col_end = std::min(col_begin + opts.stride, cloud.width);
for (int r = row_begin; r < row_end; ++r) {
for (int c = col_begin; c < col_end; ++c) {
const size_t src = (static_cast<size_t>(r) *
static_cast<size_t>(cloud.width) +
static_cast<size_t>(c)) *
3u;
const float x = cloud.data[src];
const float y = cloud.data[src + 1];
const float z = cloud.data[src + 2];
if (!std::isnan(x) && !std::isnan(y) && !std::isnan(z)) {
sx += x;
sy += y;
sz += z;
++count;
}
}
}
// Require at least half the block to be valid so that a lone
// pixel cannot fabricate a vertex inside a hole.
const int block = (row_end - row_begin) * (col_end - col_begin);
if (count * 2 >= block && count > 0) {
index[static_cast<size_t>(gr) * static_cast<size_t>(grid_w) +
static_cast<size_t>(gc)] =
static_cast<int>(vertices.size());
vertices.push_back({static_cast<float>(sx / count),
static_cast<float>(sy / count),
static_cast<float>(sz / count)});
}
}
}
// Grid triangulation: each 2x2 cell yields up to two triangles, wound so
// the normal faces the camera (-z in OpenCV coordinates).
std::vector<std::array<int, 3>> faces;
if (opts.triangulate) {
faces.reserve(2 * n_px);
const auto at = [&](int r, int c) {
return index[static_cast<size_t>(r) * static_cast<size_t>(grid_w) +
static_cast<size_t>(c)];
};
const auto emit = [&](int i0, int i1, int i2) {
if (i0 < 0 || i1 < 0 || i2 < 0)
return;
if (triangle_ok(vertices[static_cast<size_t>(i0)],
vertices[static_cast<size_t>(i1)],
vertices[static_cast<size_t>(i2)],
opts.max_edge_depth_ratio)) {
faces.push_back({i0, i1, i2});
}
};
for (int r = 0; r + 1 < grid_h; ++r) {
for (int c = 0; c + 1 < grid_w; ++c) {
const int i00 = at(r, c), i01 = at(r, c + 1);
const int i10 = at(r + 1, c), i11 = at(r + 1, c + 1);
const int valid =
(i00 >= 0) + (i01 >= 0) + (i10 >= 0) + (i11 >= 0);
if (valid == 4) {
emit(i00, i10, i11);
emit(i00, i11, i01);
} else if (valid == 3) {
// One missing corner: keep the single remaining triangle.
if (i00 < 0)
emit(i10, i11, i01);
else if (i01 < 0)
emit(i00, i10, i11);
else if (i10 < 0)
emit(i00, i11, i01);
else
emit(i00, i10, i01);
}
}
}
}
std::ofstream out(path, std::ios::binary);
out << "ply\n"
<< (opts.binary ? "format binary_little_endian 1.0\n"
: "format ascii 1.0\n")
<< "element vertex " << vertices.size() << "\n"
<< "property float x\n"
<< "property float y\n"
<< "property float z\n";
if (opts.triangulate) {
out << "element face " << faces.size() << "\n"
<< "property list uchar int vertex_indices\n";
}
out << "end_header\n";
if (opts.binary) {
static_assert(std::endian::native == std::endian::little,
"binary PLY writer assumes a little-endian host");
static_assert(sizeof(Vertex) == 3 * sizeof(float));
out.write(
reinterpret_cast<const char *>(vertices.data()),
static_cast<std::streamsize>(vertices.size() * sizeof(Vertex)));
for (const auto &f : faces) {
const unsigned char count = 3;
out.write(reinterpret_cast<const char *>(&count), 1);
out.write(reinterpret_cast<const char *>(f.data()),
static_cast<std::streamsize>(3 * sizeof(int)));
}
} else {
for (const auto &v : vertices) {
out << v.x << " " << v.y << " " << v.z << "\n";
}
for (const auto &f : faces) {
out << "3 " << f[0] << " " << f[1] << " " << f[2] << "\n";
}
}
return faces.size();
}
} // namespace score

View File

@ -1,103 +0,0 @@
#include "cloud_point/cpu_stereo_matcher.hpp"
#include <algorithm>
#include <glog/logging.h>
#include <opencv2/imgproc.hpp>
#include <stdexcept>
#include <string>
#ifdef CLOUD_POINT_HAVE_OPENCV_XIMGPROC
#include <opencv2/ximgproc/disparity_filter.hpp>
#endif
namespace score {
bool CpuStereoMatcher::wls_available() noexcept {
#ifdef CLOUD_POINT_HAVE_OPENCV_XIMGPROC
return true;
#else
return false;
#endif
}
CpuStereoMatcher::CpuStereoMatcher(int min_disparity, int num_disparities,
Params params)
: median_kernel_(params.median_kernel), wls_lambda_(params.wls_lambda),
wls_sigma_(params.wls_sigma) {
if (params.median_kernel != 0 && params.median_kernel != 3 &&
params.median_kernel != 5) {
throw std::invalid_argument(
"CpuStereoMatcher: median_kernel must be 0, 3 or 5, got " +
std::to_string(params.median_kernel));
}
if (params.block_size < 1 || params.block_size % 2 == 0) {
throw std::invalid_argument(
"CpuStereoMatcher: block_size must be a positive odd number, got " +
std::to_string(params.block_size));
}
const int bs2 = params.block_size * params.block_size;
// OpenCV's recommended penalties for a single-channel input.
const int p1 = 8 * bs2;
const int p2 = 32 * bs2;
sgbm_ = cv::StereoSGBM::create(
min_disparity, num_disparities, params.block_size, p1, p2,
params.disp12_max_diff, params.pre_filter_cap, params.uniqueness_ratio,
params.speckle_window_size, params.speckle_range,
cv::StereoSGBM::MODE_SGBM);
if (params.wls_filter) {
#ifdef CLOUD_POINT_HAVE_OPENCV_XIMGPROC
// NB: createDisparityWLSFilter(sgbm_) would silently disable the
// left matcher's uniqueness/speckle/LRC post-filters to obtain a
// dense map for its own confidence estimate. The generic factory
// leaves our tuned matcher alone; we then keep SGBM's holes invalid.
right_matcher_ = cv::ximgproc::createRightMatcher(sgbm_);
auto wls = cv::ximgproc::createDisparityWLSFilterGeneric(
/*use_confidence=*/true);
wls->setLambda(params.wls_lambda);
wls->setSigmaColor(params.wls_sigma);
wls->setLRCthresh(24); // 1.5 px in SGBM's 16x fixed point
wls->setDepthDiscontinuityRadius(
std::max(1, params.block_size / 2 + 1));
wls_ = wls;
#else
LOG(WARNING) << "CpuStereoMatcher: WLS filter requested but OpenCV "
"was built without ximgproc; disparity is unfiltered";
#endif
}
}
cv::Mat CpuStereoMatcher::compute(const cv::Mat &left, const cv::Mat &right) {
cv::Mat disparity;
sgbm_->compute(left, right, disparity);
#ifdef CLOUD_POINT_HAVE_OPENCV_XIMGPROC
// The WLS filter needs a valid ROI to the right of the disparity search
// range; skip it (raw SGBM output) for images narrower than that.
const int roi_width = sgbm_->getMinDisparity() +
sgbm_->getNumDisparities() + sgbm_->getBlockSize();
if (wls_ && left.cols > roi_width) {
// Snapshot SGBM's holes first: the filter may modify its inputs.
const cv::Mat holes = disparity <= 0;
cv::Mat right_disparity, filtered;
right_matcher_->compute(right, left, right_disparity);
auto wls = wls_.dynamicCast<cv::ximgproc::DisparityWLSFilter>();
wls->filter(disparity, left, filtered, right_disparity);
// WLS extrapolates into pixels SGBM rejected; keep those invalid so
// holes stay holes and coverage is not inflated with guesses.
filtered.setTo(cv::Scalar(sgbm_->getMinDisparity() * 16 - 16), holes);
disparity = filtered;
}
#endif
if (median_kernel_ > 0) {
cv::Mat filtered;
cv::medianBlur(disparity, filtered, median_kernel_);
disparity = filtered;
}
return disparity;
}
} // namespace score

View File

@ -1,61 +0,0 @@
#include "cloud_point/gpu_stereo_matcher.hpp"
#include <stdexcept>
#include <string>
#ifdef HAVE_OPENCV_CUDA
#include <opencv2/cudaimgproc.hpp>
#include <opencv2/cudastereo.hpp>
#endif
namespace score {
int GpuStereoMatcher::supported_num_disparities(int num_disparities) {
if (num_disparities <= 64)
return 64;
if (num_disparities <= 128)
return 128;
if (num_disparities <= 256)
return 256;
throw std::invalid_argument(
"GpuStereoMatcher: cv::cuda::StereoSGM supports at most 256 "
"disparities, got " +
std::to_string(num_disparities));
}
GpuStereoMatcher::GpuStereoMatcher(int min_disparity, int num_disparities,
int uniqueness_ratio) {
const int levels = supported_num_disparities(num_disparities);
#ifdef HAVE_OPENCV_CUDA
if (cv::cuda::getCudaEnabledDeviceCount() == 0) {
throw std::runtime_error("No CUDA devices available");
}
// P1/P2 follow the cv::cuda::StereoSGM defaults (10/120), which are
// expressed on the census-transform cost scale rather than SAD.
sgm_ = cv::cuda::createStereoSGM(min_disparity, levels, /*P1=*/10,
/*P2=*/120, uniqueness_ratio,
cv::cuda::StereoSGM::MODE_HH4);
#else
(void)min_disparity;
(void)levels;
(void)uniqueness_ratio;
throw std::runtime_error("OpenCV CUDA modules not available in this build");
#endif
}
cv::Mat GpuStereoMatcher::compute(const cv::Mat &left, const cv::Mat &right) {
#ifdef HAVE_OPENCV_CUDA
cv::cuda::GpuMat d_left(left);
cv::cuda::GpuMat d_right(right);
cv::cuda::GpuMat d_disparity;
sgm_->compute(d_left, d_right, d_disparity);
cv::Mat disparity;
d_disparity.download(disparity);
return disparity;
#else
(void)left;
(void)right;
throw std::runtime_error("OpenCV CUDA modules not available in this build");
#endif
}
} // namespace score

View File

@ -1,18 +0,0 @@
//
// Created by vptyp on 12.03.2026.
//
#include "cloud_point/image.h"
namespace score {
Image::Image() {
// no work
}
Image::Image(const cv::Mat &image) { this->data_ = image; }
Image::~Image() = default;
cv::Mat Image::get() { return this->data_; }
} // namespace score

View File

@ -1,72 +0,0 @@
opencv_dep = dependency('opencv4',
fallback: ['libopencv', 'libopencv4'],
required: false)
if not opencv_dep.found()
message('\'opencv\' was not found. Try install libopencv-dev or similar package on your system')
message('cloud_point_compute library removed from compilation')
subdir_done()
endif
cxx = meson.get_compiler('cpp')
opencv_cuda_available = cxx.has_header('opencv2/cudastereo.hpp', dependencies: opencv_dep)
cloud_point_sources = files(
'image.cpp',
'cpu_stereo_matcher.cpp',
'gpu_stereo_matcher.cpp',
'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 ]
cpp_args = []
if opencv_cuda_available
cpp_args += '-DHAVE_OPENCV_CUDA'
endif
# Optional ximgproc (opencv_contrib) for the WLS disparity post-filter.
opencv_ximgproc_lib = disabler()
if cxx.has_header('opencv2/ximgproc/disparity_filter.hpp', dependencies: opencv_dep)
opencv_ximgproc_lib = cxx.find_library('opencv_ximgproc', required: false)
endif
if opencv_ximgproc_lib.found()
cpc_deps += opencv_ximgproc_lib
cpp_args += '-DCLOUD_POINT_HAVE_OPENCV_XIMGPROC'
message('opencv_ximgproc found: WLS disparity filter enabled')
else
message('opencv_ximgproc not found: WLS disparity filter disabled')
endif
cloud_point_compute_lib = shared_library('cloud_point_compute',
sources: cloud_point_sources,
include_directories: inc,
dependencies: cpc_deps,
cpp_args: cpp_args,
)
cloud_point_compute_dep = declare_dependency(
include_directories: inc,
link_with: cloud_point_compute_lib,
dependencies: cpc_deps
)
executable(
'scared_dataset_server',
'scared_dataset_server.cpp',
dependencies: [cloud_point_compute_dep],
install: true,
)
executable(
'scared_dataset_benchmark',
'scared_dataset_benchmark.cpp',
dependencies: [cloud_point_compute_dep],
install: true,
)

View File

@ -1,107 +0,0 @@
#include "cloud_point/point_cloud_builder.hpp"
#include <cmath>
#include <limits>
#include <stdexcept>
#include <opencv2/calib3d.hpp>
namespace score {
// ---------------------------------------------------------------------------
// PointCloud::valid_points
// ---------------------------------------------------------------------------
std::vector<std::array<float, 3>> PointCloud::valid_points() const {
std::vector<std::array<float, 3>> pts;
pts.reserve(static_cast<size_t>(width * height));
for (int i = 0; i < height * width; ++i) {
const float x = data[static_cast<size_t>(i) * 3];
const float y = data[static_cast<size_t>(i) * 3 + 1];
const float z = data[static_cast<size_t>(i) * 3 + 2];
if (!std::isnan(x) && !std::isnan(y) && !std::isnan(z)) {
pts.push_back({x, y, z});
}
}
return pts;
}
// ---------------------------------------------------------------------------
// PointCloudBuilder constructor
// ---------------------------------------------------------------------------
PointCloudBuilder::PointCloudBuilder(cv::Mat q, Options opts)
: q_(std::move(q)), opts_(opts) {
if (q_.rows != 4 || q_.cols != 4 || q_.type() != CV_64F) {
throw std::invalid_argument("Q matrix must be 4x4 CV_64F, got " +
std::to_string(q_.rows) + "x" +
std::to_string(q_.cols) +
" type=" + std::to_string(q_.type()));
}
}
// ---------------------------------------------------------------------------
// PointCloudBuilder::build
// ---------------------------------------------------------------------------
PointCloud PointCloudBuilder::build(const cv::Mat &disparity) const {
cv::Mat disp32;
if (disparity.type() == CV_16S) {
// SGBM stores disparity in fixed-point with a factor of 16
disparity.convertTo(disp32, CV_32F, 1.0 / 16.0);
} else if (disparity.type() == CV_32F) {
disp32 = disparity;
} else {
throw std::invalid_argument(
"Disparity must be CV_16S or CV_32F, got type=" +
std::to_string(disparity.type()));
}
// Reproject to 3-D. handleMissingValues=false is used intentionally:
// OpenCV 4.6 incorrectly marks all CV_32F pixels as "missing" when
// handleMissingValues=true (empirically verified). Our own filter loop
// below already covers all sentinel cases (d≤0, |z|≥10000, depth range).
cv::Mat xyz;
cv::reprojectImageTo3D(disp32, xyz, q_, /*handleMissingValues=*/false);
PointCloud cloud;
cloud.width = disparity.cols;
cloud.height = disparity.rows;
cloud.data.resize(static_cast<size_t>(cloud.width) *
static_cast<size_t>(cloud.height) * 3u);
const float kNaN = std::numeric_limits<float>::quiet_NaN();
for (int r = 0; r < cloud.height; ++r) {
for (int c = 0; c < cloud.width; ++c) {
const auto &pt = xyz.at<cv::Vec3f>(r, c);
const float z = pt[2];
const float d = disp32.at<float>(r, c);
const size_t idx =
(static_cast<size_t>(r) * static_cast<size_t>(cloud.width) +
static_cast<size_t>(c)) *
3u;
// Mark invalid when: disparity ≤ 0, OpenCV sentinel |z|≥10000,
// or depth outside the user-specified range.
const bool invalid = (d <= 0.0f) || (std::abs(z) >= 10000.0f) ||
(z < opts_.min_depth_m) ||
(z > opts_.max_depth_m);
if (invalid) {
cloud.data[idx] = kNaN;
cloud.data[idx + 1] = kNaN;
cloud.data[idx + 2] = kNaN;
} else {
cloud.data[idx] = pt[0];
cloud.data[idx + 1] = pt[1];
cloud.data[idx + 2] = z;
}
}
}
return cloud;
}
} // namespace score

View File

@ -1,124 +0,0 @@
#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

View File

@ -1,229 +0,0 @@
/// @file scared_dataset_benchmark.cpp
/// @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"
#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;
}
/// 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 > 6 || argc == 5) {
std::cerr << "Usage: " << argv[0]
<< " <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;
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();
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(), depth_range);
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 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)
.count();
const auto reconstruction_ms =
std::chrono::duration<double, std::milli>(reconstruction_end -
matching_end)
.count();
nlohmann::json output = {
{"keyframe_dir", keyframe_dir},
{"algorithm", "StereoSGBM"},
{"num_disparities", num_disparities},
{"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';
return 1;
}
return 0;
}

View File

@ -1,127 +0,0 @@
#include "cloud_point/scared_dataset_loader.hpp"
#include <cstring>
#include <stdexcept>
#include <vector>
#include <glog/logging.h>
#include <opencv2/core.hpp>
#include <opencv2/imgcodecs.hpp>
namespace score {
namespace {
/// Flatten a cv::Mat (row-major) to a std::vector<double>.
std::vector<double> mat_to_vec(const cv::Mat &m) {
cv::Mat d64;
m.convertTo(d64, CV_64F);
std::vector<double> v(static_cast<size_t>(d64.total()));
std::copy(d64.begin<double>(), d64.end<double>(), v.begin());
return v;
}
/// Load a PNG from @p path and encode as BGR ImageRPC.
/// Reads with IMREAD_COLOR (→ BGR 8-bit); throws on failure.
ImageRPC load_bgr_image(const std::string &path) {
const cv::Mat img = cv::imread(path, cv::IMREAD_COLOR);
if (img.empty()) {
throw std::runtime_error(
"ScaredDatasetLoader: cannot read image: " + path);
}
ImageRPC rpc;
rpc.width = img.cols;
rpc.height = img.rows;
rpc.type = ImageRPC::Type::BGR;
const size_t sz = static_cast<size_t>(img.cols) * img.rows * 3;
rpc.data.resize(sz);
std::memcpy(rpc.data.data(), img.data, sz);
return rpc;
}
} // namespace
ScaredDatasetLoader::ScaredDatasetLoader(const std::string &keyframe_dir) {
const std::string yaml_path = keyframe_dir + "/endoscope_calibration.yaml";
const std::string left_path = keyframe_dir + "/Left_Image.png";
const std::string right_path = keyframe_dir + "/Right_Image.png";
LOG(INFO) << "ScaredDatasetLoader: loading calibration from " << yaml_path;
cv::FileStorage fs(yaml_path, cv::FileStorage::READ);
if (!fs.isOpened()) {
throw std::runtime_error(
"ScaredDatasetLoader: cannot open calibration YAML: " + yaml_path);
}
cv::Mat M1, D1, M2, D2, R, T;
fs["M1"] >> M1;
fs["D1"] >> D1;
fs["M2"] >> M2;
fs["D2"] >> D2;
fs["R"] >> R;
fs["T"] >> T;
fs.release();
if (M1.empty() || D1.empty() || M2.empty() || D2.empty() || R.empty() ||
T.empty()) {
throw std::runtime_error(
"ScaredDatasetLoader: missing node in calibration YAML: " +
yaml_path);
}
// Load images first so we can populate width/height from actual dimensions.
LOG(INFO) << "ScaredDatasetLoader: loading images";
left_image_ = load_bgr_image(left_path);
right_image_ = load_bgr_image(right_path);
if (left_image_.width != right_image_.width ||
left_image_.height != right_image_.height) {
throw std::runtime_error(
"ScaredDatasetLoader: stereo pair dimension mismatch: left " +
std::to_string(left_image_.width) + "x" +
std::to_string(left_image_.height) + " vs right " +
std::to_string(right_image_.width) + "x" +
std::to_string(right_image_.height));
}
calib_.width = left_image_.width;
calib_.height = left_image_.height;
calib_.left.camera_matrix = mat_to_vec(M1);
calib_.left.dist_coeffs = mat_to_vec(D1);
calib_.right.camera_matrix = mat_to_vec(M2);
calib_.right.dist_coeffs = mat_to_vec(D2);
calib_.rotation = mat_to_vec(R);
// T is stored as 1x3 in millimetres; convert to metres.
const auto t_mm = mat_to_vec(T);
calib_.translation.resize(3);
calib_.translation[0] = t_mm[0] / 1000.0;
calib_.translation[1] = t_mm[1] / 1000.0;
calib_.translation[2] = t_mm[2] / 1000.0;
LOG(INFO) << "ScaredDatasetLoader: T(mm)=["
<< t_mm[0] << "," << t_mm[1] << "," << t_mm[2]
<< "] -> T(m)=["
<< calib_.translation[0] << ","
<< calib_.translation[1] << ","
<< calib_.translation[2] << "]";
LOG(INFO) << "ScaredDatasetLoader: image size "
<< calib_.width << "x" << calib_.height;
}
const StereoCalibrationRPC &
ScaredDatasetLoader::calibration() const noexcept {
return calib_;
}
ImagePairRPC ScaredDatasetLoader::image_pair(uint64_t frame) const {
ImagePairRPC pair;
pair.frame = frame;
pair.left = left_image_;
pair.right = right_image_;
return pair;
}
} // namespace score

View File

@ -1,75 +0,0 @@
/// @file scared_dataset_server.cpp
/// @brief RPC server backed by a SCARED dataset keyframe directory.
///
/// Usage: scared_dataset_server <keyframe_dir> [port]
///
/// Serves get-stereo-calibration and get-image-pair matching the wire
/// protocol consumed by CloudPointClient. The same images are returned on
/// every get-image-pair call (single-keyframe source); the frame counter
/// increments so the client can detect stale frames if desired.
#include "cloud_point/scared_dataset_loader.hpp"
#include "cloud_point_rpc/rpc_dto.hpp"
#include "cloud_point_rpc/rpc_server.hpp"
#include "cloud_point_rpc/tcp_server.hpp"
#include <atomic>
#include <glog/logging.h>
#include <nlohmann/json.hpp>
#include <string>
using json = nlohmann::json;
int main(int argc, char *argv[]) {
google::InitGoogleLogging(argv[0]);
google::InstallFailureSignalHandler();
FLAGS_alsologtostderr = 1;
if (argc < 2) {
LOG(ERROR) << "Usage: " << argv[0] << " <keyframe_dir> [port]";
return 1;
}
const std::string keyframe_dir = argv[1];
LOG(INFO) << "SCARED dataset server starting";
LOG(INFO) << " keyframe_dir = " << keyframe_dir;
try {
const int port = (argc >= 3) ? std::stoi(argv[2]) : 8080;
LOG(INFO) << " port = " << port;
score::ScaredDatasetLoader loader(keyframe_dir);
std::atomic<uint64_t> frame_counter{0};
score::RpcServer rpc_server;
rpc_server.register_method(
"get-stereo-calibration", [&](const json &) -> json {
json j;
score::to_json(j, loader.calibration());
return j;
});
rpc_server.register_method(
"get-image-pair", [&](const json &) -> json {
json j;
score::to_json(j, loader.image_pair(frame_counter++));
return j;
});
score::TcpServer server(
"0.0.0.0", port,
[&](const std::string &request) {
return rpc_server.process(request);
});
server.start();
LOG(INFO) << "SCARED dataset server ready on port " << port;
server.join();
} catch (const std::exception &e) {
LOG(ERROR) << "Fatal error: " << e.what();
return 1;
}
return 0;
}

View File

@ -1,112 +0,0 @@
#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

View File

@ -1,36 +0,0 @@
#include "cloud_point/stereo_matcher_factory.hpp"
#include "cloud_point/cpu_stereo_matcher.hpp"
#include "cloud_point/gpu_stereo_matcher.hpp"
#include <glog/logging.h>
#include <stdexcept>
#include <string>
namespace score {
std::unique_ptr<IStereoMatcher>
StereoMatcherFactory::create(StereoAlgorithmType type, int num_disparities,
CpuStereoMatcher::Params cpu_params) {
if (num_disparities <= 0 || num_disparities % 16 != 0) {
throw std::invalid_argument(
"StereoMatcherFactory: num_disparities must be a positive "
"multiple of 16, got " +
std::to_string(num_disparities));
}
switch (type) {
case StereoAlgorithmType::CPU:
return std::make_unique<CpuStereoMatcher>(0, num_disparities,
cpu_params);
case StereoAlgorithmType::GPU:
try {
return std::make_unique<GpuStereoMatcher>(0, num_disparities);
} catch (const std::exception &e) {
LOG(WARNING) << "GPU stereo matcher unavailable: " << e.what()
<< ". Falling back to CPU.";
return std::make_unique<CpuStereoMatcher>(0, num_disparities,
cpu_params);
}
}
return nullptr;
}
} // namespace score

View File

@ -1,137 +0,0 @@
#include "cloud_point/stereo_rectifier.hpp"
#include <cmath>
#include <limits>
#include <stdexcept>
#include <string>
#include <opencv2/calib3d.hpp>
#include <opencv2/imgproc.hpp>
#include "cloud_point/matrixFactory.h"
namespace score {
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
namespace {
void require_mat(const cv::Mat &mat, int rows, int cols, int type,
const char *name) {
if (mat.rows != rows || mat.cols != cols || mat.type() != type) {
throw std::invalid_argument(
std::string(name) + " must be " + std::to_string(rows) + "x" +
std::to_string(cols) + " CV_64F (type " + std::to_string(CV_64F) +
"), got " + std::to_string(mat.rows) + "x" +
std::to_string(mat.cols) + " type=" + std::to_string(mat.type()));
}
}
} // namespace
// ---------------------------------------------------------------------------
// StereoRectifier::Calibration::from_rpc
// ---------------------------------------------------------------------------
StereoRectifier::Calibration
StereoRectifier::Calibration::from_rpc(const StereoCalibrationRPC &rpc) {
Calibration calib;
calib.k_left = CameraMatrixFactory::create<3, 3>(rpc.left.camera_matrix);
calib.d_left = CameraMatrixFactory::create<1, 5>(rpc.left.dist_coeffs);
calib.k_right = CameraMatrixFactory::create<3, 3>(rpc.right.camera_matrix);
calib.d_right = CameraMatrixFactory::create<1, 5>(rpc.right.dist_coeffs);
calib.r = CameraMatrixFactory::create<3, 3>(rpc.rotation);
calib.t = CameraMatrixFactory::create<3, 1>(rpc.translation);
calib.image_size = cv::Size(rpc.width, rpc.height);
return calib;
}
// ---------------------------------------------------------------------------
// StereoRectifier constructor
// ---------------------------------------------------------------------------
StereoRectifier::StereoRectifier(const Calibration &calib) {
require_mat(calib.k_left, 3, 3, CV_64F, "k_left");
require_mat(calib.k_right, 3, 3, CV_64F, "k_right");
require_mat(calib.d_left, 1, 5, CV_64F, "d_left");
require_mat(calib.d_right, 1, 5, CV_64F, "d_right");
require_mat(calib.r, 3, 3, CV_64F, "r");
require_mat(calib.t, 3, 1, CV_64F, "t");
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_,
cv::CALIB_ZERO_DISPARITY, /*alpha=*/0);
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_);
}
// ---------------------------------------------------------------------------
// StereoRectifier::rectify
// ---------------------------------------------------------------------------
std::pair<cv::Mat, cv::Mat>
StereoRectifier::rectify(const cv::Mat &left, const cv::Mat &right) const {
cv::Mat rect_left, rect_right;
cv::remap(left, rect_left, map_lx_, map_ly_, cv::INTER_LINEAR);
cv::remap(right, rect_right, map_rx_, map_ry_, cv::INTER_LINEAR);
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
// ---------------------------------------------------------------------------
const cv::Mat &StereoRectifier::q() const noexcept { return q_; }
} // namespace score

View File

@ -24,16 +24,9 @@ int main(int argc, char *argv[]) {
f.close(); f.close();
try { try {
auto config = score::ConfigLoader::load(config_path); auto config = cloud_point_rpc::ConfigLoader::load(config_path);
score::CliStereoOptions stereo; return cloud_point_rpc::run_cli(std::cin, std::cout, config.server.ip,
stereo.use_gpu = config.cloud_point.algorithm == "gpu"; config.server.port);
stereo.num_disparities = config.cloud_point.num_disparities;
stereo.min_depth_m = static_cast<float>(config.cloud_point.min_depth_m);
stereo.max_depth_m = static_cast<float>(config.cloud_point.max_depth_m);
stereo.ply_stride = config.cloud_point.ply_stride;
stereo.wls_filter = config.cloud_point.wls_filter;
return score::run_cli(std::cin, std::cout, config.server.ip,
config.server.port, stereo);
} catch (const std::exception &e) { } catch (const std::exception &e) {
std::cerr << "Failed to start CLI: " << e.what() << std::endl; std::cerr << "Failed to start CLI: " << e.what() << std::endl;
return 1; return 1;

View File

@ -1,94 +1,55 @@
add_project_arguments('-DCRPC_SERVER_API_EXPORT', language: 'cpp') add_project_arguments('-DCRPC_SERVER_API_EXPORT -pthread', language: 'cpp')
deps = [json_dep, thread_dep, glog_dep, yaml_dep, asio_dep, base64_dep]
cloud_point_rpc_sources = files( cloud_point_rpc_sources = files(
'rpc_coder.cpp',
'rpc_dto.cpp',
'rpc_server.cpp', 'rpc_server.cpp',
'server_api.cpp',
'service.cpp', 'service.cpp',
'server_api.cpp'
) )
libcloud_point_rpc = shared_library( libcloud_point_rpc = shared_library('cloud_point_rpc',
'cloud_point_rpc',
cloud_point_rpc_sources, cloud_point_rpc_sources,
include_directories : inc, include_directories : inc,
dependencies: deps, dependencies : [json_dep, thread_dep, glog_dep, yaml_dep, asio_dep],
install: true, install : true)
install_rpath: '$ORIGIN',
)
cloud_point_rpc_dep = declare_dependency( cloud_point_rpc_dep = declare_dependency(
include_directories : inc, include_directories : inc,
link_with : libcloud_point_rpc, link_with : libcloud_point_rpc,
dependencies: deps, dependencies : [json_dep, glog_dep, yaml_dep, asio_dep])
)
# Test lib # Test lib
libcloud_point_rpc_test = shared_library( libcloud_point_rpc_test = shared_library('test_cloud_point',
'test_cloud_point',
'test_api.cpp', 'test_api.cpp',
dependencies: cloud_point_rpc_dep, dependencies: cloud_point_rpc_dep,
install: true, install : true)
install_rpath: '$ORIGIN',
)
cloud_point_rpc_test_dep = declare_dependency( cloud_point_rpc_test_dep = declare_dependency(
include_directories: inc, include_directories: inc,
link_with: libcloud_point_rpc_test, link_with: libcloud_point_rpc_test,
dependencies: [cloud_point_rpc_dep], dependencies: [cloud_point_rpc_dep]
) )
subdir('cloud_point') libcloud_point_rpc_cli = shared_library('libcloud_point_rpc_cli',
# CLI lib — links cloud_point_compute when OpenCV is available so that
# options 4 and 5 (compute-cloud) are compiled in.
cli_deps = [cloud_point_rpc_dep]
cli_cpp_args = []
if opencv_dep.found()
cli_deps += [cloud_point_compute_dep]
cli_cpp_args += ['-DHAVE_CLOUD_POINT_COMPUTE']
endif
libcloud_point_rpc_cli = shared_library(
'libcloud_point_rpc_cli',
'cli.cpp', 'cli.cpp',
include_directories : inc, include_directories : inc,
dependencies: cli_deps, dependencies : [json_dep, thread_dep, glog_dep, yaml_dep, asio_dep, cloud_point_rpc_dep],
cpp_args: cli_cpp_args, install : true)
install: true,
)
cloud_point_rpc_cli_dep = declare_dependency( cloud_point_rpc_cli_dep = declare_dependency(
include_directories: inc, include_directories: inc,
link_with: libcloud_point_rpc_cli, link_with: libcloud_point_rpc_cli,
dependencies: cli_deps, dependencies: [cloud_point_rpc_dep]
) )
# Client/CLI tool (legacy stdin/stdout) # Client/CLI tool (legacy stdin/stdout)
executable( executable('cloud_point_rpc_cli',
'cloud_point_rpc_cli', ['main.cpp', ],
[
'main.cpp',
],
dependencies : cloud_point_rpc_cli_dep, dependencies : cloud_point_rpc_cli_dep,
install: true, install : true)
)
# Server executable (TCP) # Server executable (TCP)
executable( executable('cloud_point_rpc_server',
'cloud_point_rpc_server',
'server_main.cpp', 'server_main.cpp',
dependencies : cloud_point_rpc_dep, dependencies : cloud_point_rpc_dep,
link_args : '-pthread', link_args : '-pthread',
install: true, install : true)
)
# Minimal client example
executable(
'minimal_client',
'minimal_client.cpp',
dependencies: cloud_point_rpc_dep,
install: true,
)

View File

@ -1,34 +0,0 @@
#include "cloud_point_rpc/config.hpp"
#include "cloud_point_rpc/tcp_connector.hpp"
#include <glog/logging.h>
#include <iostream>
#include <string>
int main(int argc, char *argv[]) {
google::InitGoogleLogging(argv[0]);
FLAGS_logtostderr = 1;
std::string config_path = "config.yml";
if (argc > 1) {
config_path = argv[1];
}
try {
auto config = score::ConfigLoader::load(config_path);
score::TCPConnector connector(config.server.ip,
static_cast<size_t>(config.server.port));
const std::string request =
R"({"jsonrpc":"2.0","method":"ping","params":{},"id":1})";
std::string response = connector.Send(request);
std::cout << response << std::endl;
} catch (const std::exception &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}

View File

@ -1,46 +0,0 @@
//
// Created by vptyp on 11.03.2026.
//
#include "cloud_point_rpc/rpc_coder.hpp"
#include "libbase64.h"
#include <glog/logging.h>
namespace score {
Base64RPCCoder::Base64RPCCoder() = default;
Base64RPCCoder::~Base64RPCCoder() = default;
/**
* Tries to decode ASCII complained string to the raw bytes
* @param encoded ASCII complained base64 encoded string
* @return vector of raw bytes << allocated on encoded.size() / 4 * 3 + 1 size
*/
std::vector<char> Base64RPCCoder::decode(const std::string &encoded) {
if (encoded.length() > (std::numeric_limits<size_t>::max() / 3) * 4)
throw std::length_error("Base64 input too large");
DLOG(INFO) << "Base64RPCCoder::decode";
std::vector<char> result((encoded.length() >> 2) * 3 + 1);
size_t result_len = 0;
base64_decode(encoded.data(), encoded.size(), result.data(), &result_len,
0);
DLOG(INFO) << "result_len: " << result_len;
result.resize(result_len);
return result;
}
/**
*
* @param data raw byte stream
* @return encoded base64 string
*/
std::string Base64RPCCoder::encode(const std::vector<char> &data) {
if (data.size() > (std::numeric_limits<size_t>::max() / 4) * 3)
throw std::length_error("raw input is too large");
DLOG(INFO) << "Base64RPCCoder::encode";
size_t result_len = 0;
std::string result((data.size() + 2) / 3 * 4, 0);
base64_encode(data.data(), data.size(), result.data(), &result_len, 0);
DLOG(INFO) << "result_len: " << result_len;
result.resize(result_len);
return result;
}
} // namespace score

View File

@ -1,3 +0,0 @@
// Compilation unit for rpc_dto.hpp — pulls in Base64RPCCoder linkage used by
// the inline to_json/from_json hooks, ensuring a single object definition.
#include "cloud_point_rpc/rpc_dto.hpp"

View File

@ -1,10 +1,8 @@
#include "cloud_point_rpc/rpc_server.hpp" #include "cloud_point_rpc/rpc_server.hpp"
#include "server_api.h"
#include <glog/logging.h> #include <glog/logging.h>
#include <variant>
using json = nlohmann::json; using json = nlohmann::json;
namespace score { namespace cloud_point_rpc {
namespace { namespace {
json create_error(int code, const std::string &message, json create_error(int code, const std::string &message,
@ -14,83 +12,22 @@ json create_error(int code, const std::string &message,
{"id", id}}; {"id", id}};
} }
struct CreateSuccess { json create_success(const json &result, const json &id) {
json obj; return {{"jsonrpc", "2.0"}, {"result", result}, {"id", id}};
json id;
void operator()(const json &result) {
obj = {{"jsonrpc", "2.0"}, {"result", result}, {"id", id}};
} }
void operator()(const std::string &result) {
obj = {{"jsonrpc", "2.0"}, {"result", result}, {"id", id}};
}
};
} // namespace } // namespace
template <typename T> struct Deleter {
void operator()(T *element) {
(void)element;
LOG(ERROR) << "Called default deleter";
}
};
template <> struct Deleter<rpc_string> {
void operator()(rpc_string *element) {
if (element) {
crpc_str_destroy(element);
}
}
};
using rpcStringPtr = std::unique_ptr<rpc_string, Deleter<rpc_string>>;
RpcServer::RpcServer() {
register_method("get-available-methods",
[&](const json &) { return get_method_names(); });
}
void RpcServer::register_method(const std::string &name, Handler handler) { void RpcServer::register_method(const std::string &name, Handler handler) {
handlers_[name] = std::move(handler); handlers_[name] = std::move(handler);
handler_names_.push_back(handlers_.find(name)->first);
} }
void RpcServer::register_method(const std::string& name, callback_t handler) { void RpcServer::register_method(const std::string& name, callback_t handler) {
handlers_[name] = [handler](const nlohmann::json &j) handlers_[name] = [handler](const nlohmann::json& j) -> nlohmann::json {
-> std::variant<nlohmann::json, std::string> {
rpc_string tmp; rpc_string tmp;
tmp.s = j.dump(); tmp.s = j.dump();
auto res = rpcStringPtr(handler(&tmp)); rpc_string* res = handler(&tmp);
if (!res) { return {res->s};
LOG(ERROR) << "Method is invalid";
return {};
}
std::variant<nlohmann::json, std::string> ret;
try {
ret = json::parse(res->s);
} catch (std::exception &e) {
DLOG(INFO) << "return value is not a json";
ret = res->s;
}
return ret;
}; };
handler_names_.push_back(handlers_.find(name)->first);
}
std::span<std::string_view> RpcServer::get_method_names() noexcept {
return this->handler_names_;
}
uint64_t RpcServer::get_count() noexcept { return this->handler_names_.size(); }
std::string_view RpcServer::get_method_name_by_id(uint64_t id) noexcept {
if (id >= handler_names_.size()) {
LOG(ERROR) << __func__
<< std::format(
": called with id = {} which is bigger, than size={}",
id, handler_names_.size());
return {};
}
return handler_names_.at(id);
} }
std::string RpcServer::process(const std::string &request_str) { std::string RpcServer::process(const std::string &request_str) {
@ -122,14 +59,11 @@ std::string RpcServer::process(const std::string &request_str) {
} }
try { try {
auto result = it->second(params); json result = it->second(params);
CreateSuccess visitor; return create_success(result, id).dump();
visitor.id = id;
std::visit(visitor, result);
return visitor.obj.dump();
} catch (const std::exception &e) { } catch (const std::exception &e) {
return create_error(-32000, e.what(), id).dump(); // Server error return create_error(-32000, e.what(), id).dump(); // Server error
} }
} }
} // namespace score } // namespace cloud_point_rpc

View File

@ -1,74 +1,52 @@
#include "server_api.h"
#include "cloud_point_rpc/config.hpp" #include "cloud_point_rpc/config.hpp"
#include "cloud_point_rpc/rpc_server.hpp" #include "cloud_point_rpc/rpc_server.hpp"
#include "cloud_point_rpc/tcp_server.hpp" #include "cloud_point_rpc/tcp_server.hpp"
#include <algorithm>
#include <glog/logging.h> #include <glog/logging.h>
#include <list> #include "server_api.h"
#include <algorithm>
#include <memory> #include <memory>
#include <string> #include <string>
#include <list>
static std::list<std::unique_ptr<rpc_string>> gc; static std::list<std::unique_ptr<rpc_string>> gc;
std::mutex gc_mtx; cloud_point_rpc::RpcServer rpc_server;
std::mutex server_mtx; std::unique_ptr<cloud_point_rpc::TcpServer> server = nullptr;
score::RpcServer rpc_server;
std::unique_ptr<score::TcpServer> server = nullptr;
extern "C" { extern "C" {
const char* crpc_str_get_data(const rpc_string* that) { const char* crpc_str_get_data(const rpc_string* that) {
if (!that) {
LOG(ERROR) << "Tried to get data on nullptr";
return nullptr;
}
return that->s.c_str(); return that->s.c_str();
} }
uint64_t crpc_str_get_size(const rpc_string* that){ uint64_t crpc_str_get_size(const rpc_string* that){
if (!that) {
LOG(ERROR) << "Tried to get size on nullptr";
return 0;
}
return that->s.size(); return that->s.size();
} }
rpc_string* crpc_str_create(const char* data, uint64_t size){ rpc_string* crpc_str_create(const char* data, uint64_t size){
if (!data) {
LOG(ERROR) << "Tried to create with nullptr data";
return nullptr;
}
std::lock_guard lock(gc_mtx);
gc.push_back(std::make_unique<rpc_string>(data, size)); gc.push_back(std::make_unique<rpc_string>(data, size));
return gc.back().get(); return gc.back().get();
} }
void crpc_str_destroy(rpc_string* that){ void crpc_str_destroy(rpc_string* that){
if (!that) {
LOG(ERROR) << "Tried to destroy on nullptr";
return;
}
std::lock_guard lock(gc_mtx);
auto it = std::ranges::find(gc, that, &std::unique_ptr<rpc_string>::get); auto it = std::ranges::find(gc, that, &std::unique_ptr<rpc_string>::get);
if(it != gc.end()) if(it != gc.end())
gc.erase(it); gc.erase(it);
} }
void crpc_init(const char* config_path) { void crpc_init(const char* config_path) {
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("CloudPointRPC"); google::InitGoogleLogging("CloudPointRPC");
if(config_path == nullptr) { if(config_path == nullptr) {
LOG(ERROR) << "config_path was not provided"; LOG(INFO) << "config_path was not provided";
return;
} }
try { try {
auto config = score::ConfigLoader::load(config_path); auto config = cloud_point_rpc::ConfigLoader::load(config_path);
LOG(INFO) << "Loaded config from " << config_path; LOG(INFO) << "Loaded config from " << config_path;
server = std::make_unique<score::TcpServer>( server = std::make_unique<cloud_point_rpc::TcpServer>(config.server.ip, config.server.port,
config.server.ip, config.server.port,
[&](const std::string &request) { [&](const std::string &request) {
std::lock_guard lock(server_mtx); return rpc_server.process(
return rpc_server.process(request); request);
}); });
server->start(); server->start();
} catch (const std::exception &e) { } catch (const std::exception &e) {
@ -76,54 +54,15 @@ void crpc_init(const char *config_path) {
} }
} }
void crpc_init_with_address(const char *ip, int port) {
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("CloudPointRPC");
if (!ip) {
LOG(ERROR) << "ip was not provided";
return;
}
try {
server = std::make_unique<score::TcpServer>(
std::string(ip), static_cast<size_t>(port),
[&](const std::string &request) {
std::lock_guard lock(server_mtx);
return rpc_server.process(request);
});
server->start();
LOG(INFO) << "Server started on " << ip << ":" << port;
} catch (const std::exception &e) {
LOG(ERROR) << "Fatal error: " << e.what();
}
}
void crpc_deinit() { 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->join();
}
server.reset(); server.reset();
std::lock_guard lock(gc_mtx);
gc.clear(); gc.clear();
} }
void crpc_add_method(callback_t cb, rpc_string* name) { void crpc_add_method(callback_t cb, rpc_string* name) {
if (!name || !cb) {
LOG(ERROR) << "Invalid arguments (nullptr)";
return;
}
std::lock_guard lock(server_mtx);
rpc_server.register_method(name->s, cb); rpc_server.register_method(name->s, cb);
} }
rpc_string *crpc_get_method_name_by_id(uint64_t id) {
auto value = rpc_server.get_method_name_by_id(id);
return crpc_str_create(value.data(), value.size());
} }
uint64_t crpc_get_methods_count() { return rpc_server.get_count(); }
}

View File

@ -1,5 +1,4 @@
#include "cloud_point_rpc/config.hpp" #include "cloud_point_rpc/config.hpp"
#include "cloud_point_rpc/rpc_dto.hpp"
#include "cloud_point_rpc/rpc_server.hpp" #include "cloud_point_rpc/rpc_server.hpp"
#include "cloud_point_rpc/service.hpp" #include "cloud_point_rpc/service.hpp"
#include "cloud_point_rpc/tcp_server.hpp" #include "cloud_point_rpc/tcp_server.hpp"
@ -21,12 +20,12 @@ int main(int argc, char *argv[]) {
LOG(INFO) << "Starting Cloud Point RPC Server (Test Mock)..."; LOG(INFO) << "Starting Cloud Point RPC Server (Test Mock)...";
try { try {
auto config = score::ConfigLoader::load(config_path); auto config = cloud_point_rpc::ConfigLoader::load(config_path);
LOG(INFO) << "Loaded config from " << config_path; LOG(INFO) << "Loaded config from " << config_path;
// Inject test data into service // Inject test data into service
score::Service service(config.test_data); cloud_point_rpc::Service service(config.test_data);
score::RpcServer rpc_server; cloud_point_rpc::RpcServer rpc_server;
rpc_server.register_method("get-intrinsic-params", [&](const json &) { rpc_server.register_method("get-intrinsic-params", [&](const json &) {
return service.get_intrinsic_params(); return service.get_intrinsic_params();
@ -40,21 +39,10 @@ int main(int argc, char *argv[]) {
return service.get_cloud_point(); return service.get_cloud_point();
}); });
rpc_server.register_method("get-stereo-calibration", [&](const json &) { cloud_point_rpc::TcpServer server(config.server.ip, config.server.port,
nlohmann::json j;
score::to_json(j, service.get_stereo_calibration());
return j;
});
rpc_server.register_method("get-image-pair", [&](const json &) {
nlohmann::json j;
score::to_json(j, service.get_image_pair());
return j;
});
score::TcpServer server(config.server.ip, config.server.port,
[&](const std::string &request) { [&](const std::string &request) {
return rpc_server.process(request); return rpc_server.process(
request);
}); });
server.start(); server.start();

View File

@ -1,8 +1,8 @@
#include "cloud_point_rpc/service.hpp" #include "cloud_point_rpc/service.hpp"
namespace score { namespace cloud_point_rpc {
Service::Service(const TestData &data) : data_(data), frame_counter_(0) {} Service::Service(const TestData &data) : data_(data) {}
std::vector<double> Service::get_intrinsic_params() const { std::vector<double> Service::get_intrinsic_params() const {
if (data_.intrinsic_params.empty()) { if (data_.intrinsic_params.empty()) {
@ -27,66 +27,4 @@ std::vector<std::vector<double>> Service::get_cloud_point() const {
return data_.cloud_point; return data_.cloud_point;
} }
StereoCalibrationRPC Service::get_stereo_calibration() const { } // namespace cloud_point_rpc
StereoCalibrationRPC calib;
calib.width = 640;
calib.height = 480;
CameraCalib cam;
// fx=fy=800, cx=320, cy=240 — identity rotation-scale, principal centre
cam.camera_matrix = {800.0, 0.0, 320.0, 0.0, 800.0, 240.0, 0.0, 0.0, 1.0};
cam.dist_coeffs = {0.0, 0.0, 0.0, 0.0, 0.0};
calib.left = cam;
calib.right = cam;
// Parallel rig: R = identity
calib.rotation = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0};
// T = [-baseline, 0, 0], baseline = 0.06 m
calib.translation = {-0.06, 0.0, 0.0};
return calib;
}
ImagePairRPC Service::get_image_pair() {
constexpr int kWidth = 640;
constexpr int kHeight = 480;
constexpr int kShift = 8;
ImagePairRPC pair;
pair.frame = frame_counter_++;
// Left image: BGR, each pixel value = (x + y) % 256 for all channels
pair.left.width = kWidth;
pair.left.height = kHeight;
pair.left.type = ImageRPC::Type::BGR;
pair.left.data.resize(static_cast<size_t>(kWidth) * kHeight * 3);
for (int y = 0; y < kHeight; ++y) {
for (int x = 0; x < kWidth; ++x) {
const auto val = static_cast<unsigned char>((x + y) % 256);
const size_t base = static_cast<size_t>(y * kWidth + x) * 3;
pair.left.data[base + 0] = val;
pair.left.data[base + 1] = val;
pair.left.data[base + 2] = val;
}
}
// Right image: same pattern shifted 8 px horizontally
pair.right.width = kWidth;
pair.right.height = kHeight;
pair.right.type = ImageRPC::Type::BGR;
pair.right.data.resize(static_cast<size_t>(kWidth) * kHeight * 3);
for (int y = 0; y < kHeight; ++y) {
for (int x = 0; x < kWidth; ++x) {
const auto val = static_cast<unsigned char>((x + kShift + y) % 256);
const size_t base = static_cast<size_t>(y * kWidth + x) * 3;
pair.right.data[base + 0] = val;
pair.right.data[base + 1] = val;
pair.right.data[base + 2] = val;
}
}
return pair;
}
} // namespace score

View File

@ -4,6 +4,7 @@
#include <condition_variable> #include <condition_variable>
#include <glog/logging.h> #include <glog/logging.h>
#include <list>
#include <mutex> #include <mutex>
#include <queue> #include <queue>
#include <set> #include <set>
@ -28,7 +29,6 @@ class TestThread {
~TestThread() { join(); } ~TestThread() { join(); }
void routine() { void routine() {
LOG(INFO) << "Started routine";
size_t distance{0}; size_t distance{0};
std::unique_lock lock(mtx); std::unique_lock lock(mtx);
const std::stop_token stoken = thr.get_stop_token(); const std::stop_token stoken = thr.get_stop_token();
@ -48,8 +48,9 @@ class TestThread {
} }
if (state.load() && calls_queue.empty()) if (state.load() && calls_queue.empty())
cv.wait_for(lock, thr_sleep, cv.wait_for(lock, thr_sleep, [&] {
[&] { return stoken.stop_requested(); }); return stoken.stop_requested();
});
lock.unlock(); lock.unlock();
} }
@ -66,10 +67,6 @@ class TestThread {
} }
} }
void add_method(const callback_t cb, rpc_string *name) { void add_method(const callback_t cb, rpc_string *name) {
if (!name || !name->s.size()) {
LOG(ERROR) << "Tried to add method with invalid name";
return;
}
LOG(INFO) << "Trying to add method: " << name->s; LOG(INFO) << "Trying to add method: " << name->s;
std::lock_guard lock(mtx); std::lock_guard lock(mtx);
if (methods.contains(name->s)) { if (methods.contains(name->s)) {
@ -81,10 +78,6 @@ class TestThread {
} }
int remove_method(const rpc_string *name) { int remove_method(const rpc_string *name) {
if (!name || !name->s.size()) {
LOG(ERROR) << "Tried to remove method with invalid name";
return -1;
}
LOG(INFO) << "Trying to remove method: " << name->s; LOG(INFO) << "Trying to remove method: " << name->s;
std::lock_guard lock(mtx); std::lock_guard lock(mtx);
int result = 0; int result = 0;
@ -99,10 +92,6 @@ class TestThread {
} }
void call(const rpc_string *name) { void call(const rpc_string *name) {
if (!name) {
LOG(ERROR) << "Called with nullptr name";
return;
}
std::lock_guard lock(mtx); std::lock_guard lock(mtx);
LOG(INFO) << server.process(name->s); LOG(INFO) << server.process(name->s);
} }
@ -132,7 +121,7 @@ class TestThread {
calls_queue = std::queue<std::string>(); calls_queue = std::queue<std::string>();
methods.clear(); methods.clear();
state.store(true, std::memory_order_relaxed); state.store(true, std::memory_order_relaxed);
server = score::RpcServer(); server = cloud_point_rpc::RpcServer();
} }
private: private:
@ -142,17 +131,15 @@ class TestThread {
std::set<std::string> methods{}; std::set<std::string> methods{};
std::jthread thr; std::jthread thr;
std::mutex mtx; std::mutex mtx;
score::RpcServer server; cloud_point_rpc::RpcServer server;
std::chrono::duration<int64_t, std::milli> thr_sleep{50}; std::chrono::duration<int64_t, std::milli> thr_sleep{50};
} test; } test;
extern "C" { extern "C" {
void crpc_test_init() { void crpc_test_init() {
if (!google::IsGoogleLoggingInitialized()) { if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestRPC"); google::InitGoogleLogging("TestRPC");
google::LogToStderr();
}
try { try {
test.start(); test.start();
} catch (const std::exception &e) { } catch (const std::exception &e) {
@ -179,13 +166,7 @@ int crpc_test_remove_method(rpc_string *name) {
return test.remove_method(name); return test.remove_method(name);
} }
void crpc_test_schedule_call(rpc_string *name) { void crpc_test_schedule_call(rpc_string *name) { test.add_queue_call(name->s); }
if (!name) {
LOG(ERROR) << "Called with name nullptr";
return;
}
test.add_queue_call(name->s);
}
void crpc_test_auto_call(uint32_t state) { void crpc_test_auto_call(uint32_t state) {
test.auto_call(static_cast<bool>(state)); test.auto_call(static_cast<bool>(state));

View File

@ -1,14 +0,0 @@
[wrap-file]
directory = base64-0.5.2
source_url = https://github.com/aklomp/base64/archive/refs/tags/v0.5.2.tar.gz
source_filename = base64-0.5.2.tar.gz
source_hash = 723a0f9f4cf44cf79e97bcc315ec8f85e52eb104c8882942c3f2fba95acc080d
source_fallback_url = https://wrapdb.mesonbuild.com/v2/aklomp-base64_0.5.2-1/get_source/base64-0.5.2.tar.gz
patch_filename = aklomp-base64_0.5.2-1_patch.zip
patch_url = https://wrapdb.mesonbuild.com/v2/aklomp-base64_0.5.2-1/get_patch
patch_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/aklomp-base64_0.5.2-1/aklomp-base64_0.5.2-1_patch.zip
patch_hash = 9805354b8c0333fe0123c10d8c62356ef1d0d67a2689a348d18f73bddc1e2b10
wrapdb_version = 0.5.2-1
[provide]
dependency_names = base64

View File

@ -3,35 +3,11 @@ test_sources = files(
'test_integration.cpp', 'test_integration.cpp',
'test_tcp.cpp', 'test_tcp.cpp',
'test_cli.cpp', 'test_cli.cpp',
'test_config.cpp', 'test_c_api.cpp'
'test_c_api.cpp',
'test_base64.cpp',
'test_serialize_image.cpp'
) )
test_deps = [cloud_point_rpc_dep, cloud_point_rpc_cli_dep,
cloud_point_rpc_test_dep, json_dep, gtest_dep,
gtest_main_dep, gmock_dep]
if opencv_dep.found()
message('found cloud_point_compute dependency')
test_sources += files(
'test_image.cpp',
'test_stereo_matcher.cpp',
'test_stereo_rectifier.cpp',
'test_point_cloud_builder.cpp',
'test_point_cloud_evaluator.cpp',
'test_cloud_point_client.cpp',
'test_ply_export.cpp',
'test_scared_dataset.cpp',
'test_scared_ground_truth_loader.cpp'
)
test_deps += [cloud_point_compute_dep]
else
message('cpc_dep was not found')
endif
test_exe = executable('unit_tests', test_exe = executable('unit_tests',
test_sources, test_sources,
dependencies : test_deps) dependencies : [cloud_point_rpc_dep, cloud_point_rpc_cli_dep, cloud_point_rpc_test_dep, json_dep, gtest_dep, gtest_main_dep, gmock_dep])
test('unit_tests', test_exe) test('unit_tests', test_exe)

View File

@ -1,30 +0,0 @@
//
// Created by vptyp on 11.03.2026.
//
#include <chrono>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <thread>
#include "cloud_point_rpc/config.hpp"
#include "cloud_point_rpc/rpc_coder.hpp"
class Base64Test : public ::testing::Test {
protected:
void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestCli");
}
void TearDown() override {}
};
TEST_F(Base64Test, EncodeDecode) {
std::vector raw{'H', 'e', 'l', 'l', 'o', 'w', '\0'};
score::Base64RPCCoder coder;
auto encoded = coder.encode(raw);
LOG(INFO) << "encoded: " << encoded;
auto decoded = coder.decode(encoded);
EXPECT_EQ(std::ranges::equal(decoded, raw), true);
LOG(INFO) << "done";
}

View File

@ -1,187 +0,0 @@
#include "cloud_point_rpc/rpc_coder.hpp"
#include <glog/logging.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
using namespace score;
class Base64EdgeCaseTest : public ::testing::Test {
protected:
void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestRPC");
}
Base64RPCCoder coder;
};
// Empty input
TEST_F(Base64EdgeCaseTest, EmptyEncode) {
std::vector<char> empty;
auto encoded = coder.encode(empty);
EXPECT_TRUE(encoded.empty());
}
TEST_F(Base64EdgeCaseTest, EmptyDecode) {
std::string empty;
auto decoded = coder.decode(empty);
EXPECT_TRUE(decoded.empty());
}
// 1 byte input
TEST_F(Base64EdgeCaseTest, OneByteEncode) {
std::vector<char> data{'A'};
auto encoded = coder.encode(data);
EXPECT_EQ(encoded, "QQ==");
}
TEST_F(Base64EdgeCaseTest, OneByteRoundTrip) {
std::vector<char> data{'A'};
auto encoded = coder.encode(data);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, data);
}
// 2 bytes input
TEST_F(Base64EdgeCaseTest, TwoBytesEncode) {
std::vector<char> data{'A', 'B'};
auto encoded = coder.encode(data);
EXPECT_EQ(encoded, "QUI=");
}
TEST_F(Base64EdgeCaseTest, TwoBytesRoundTrip) {
std::vector<char> data{'A', 'B'};
auto encoded = coder.encode(data);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, data);
}
// 3 bytes input (no padding)
TEST_F(Base64EdgeCaseTest, ThreeBytesEncode) {
std::vector<char> data{'A', 'B', 'C'};
auto encoded = coder.encode(data);
EXPECT_EQ(encoded, "QUJD");
}
TEST_F(Base64EdgeCaseTest, ThreeBytesRoundTrip) {
std::vector<char> data{'A', 'B', 'C'};
auto encoded = coder.encode(data);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, data);
}
// Standard test vectors
TEST_F(Base64EdgeCaseTest, StandardVectors) {
struct TestCase {
std::vector<char> input;
std::string expected;
};
std::vector<TestCase> cases = {
{{'f'}, "Zg=="},
{{'f', 'o'}, "Zm8="},
{{'f', 'o', 'o'}, "Zm9v"},
{{'f', 'o', 'o', 'b'}, "Zm9vYg=="},
{{'f', 'o', 'o', 'b', 'a'}, "Zm9vYmE="},
{{'f', 'o', 'o', 'b', 'a', 'r'}, "Zm9vYmFy"},
};
for (const auto &tc : cases) {
auto encoded = coder.encode(tc.input);
EXPECT_EQ(encoded, tc.expected);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, tc.input);
}
}
// Binary data with null bytes
TEST_F(Base64EdgeCaseTest, BinaryWithNullBytes) {
std::vector<char> data{'H', 'e', 'l', 'l', 'o', '\0',
'W', 'o', 'r', 'l', 'd'};
auto encoded = coder.encode(data);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, data);
}
// All byte values 0-255
TEST_F(Base64EdgeCaseTest, AllByteValues) {
std::vector<char> data(256);
for (int i = 0; i < 256; ++i) {
data[i] = static_cast<char>(i);
}
auto encoded = coder.encode(data);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, data);
}
// Repeated patterns
TEST_F(Base64EdgeCaseTest, RepeatedPattern) {
std::vector<char> data(1024, 'A');
auto encoded = coder.encode(data);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, data);
}
// Invalid base64 characters
TEST_F(Base64EdgeCaseTest, InvalidCharactersDecode) {
// base64_decode should handle invalid chars gracefully or fail
std::string invalid = "!!!";
auto decoded = coder.decode(invalid);
// libbase64 may return empty or partial result; just verify no crash
(void)decoded;
}
TEST_F(Base64EdgeCaseTest, MixedValidInvalid) {
std::string mixed = "QU!!JD";
auto decoded = coder.decode(mixed);
(void)decoded; // no crash expected
}
// Padding edge cases
TEST_F(Base64EdgeCaseTest, NoPaddingDecode) {
std::string no_pad = "QUJD"; // "ABC" without explicit padding
auto decoded = coder.decode(no_pad);
std::vector<char> expected{'A', 'B', 'C'};
EXPECT_EQ(decoded, expected);
}
TEST_F(Base64EdgeCaseTest, ExtraPadding) {
std::string extra_pad = "QQ===";
auto decoded = coder.decode(extra_pad);
(void)decoded; // no crash expected
}
// Large input
TEST_F(Base64EdgeCaseTest, LargeInputRoundTrip) {
std::vector<char> data(100000, 'x');
auto encoded = coder.encode(data);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, data);
}
// Very large input (1MB)
TEST_F(Base64EdgeCaseTest, OneMegabyteRoundTrip) {
std::vector<char> data(1024 * 1024);
for (size_t i = 0; i < data.size(); ++i) {
data[i] = static_cast<char>(i % 256);
}
auto encoded = coder.encode(data);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, data);
}
// Whitespace in encoded string
TEST_F(Base64EdgeCaseTest, WhitespaceInEncoded) {
std::string with_space = "Q U J D";
auto decoded = coder.decode(with_space);
(void)decoded; // libbase64 behavior varies; ensure no crash
}
// Non-ASCII characters in input (UTF-8)
TEST_F(Base64EdgeCaseTest, Utf8RoundTrip) {
std::string utf8 = "Hello, 世界! 🌍";
std::vector<char> data(utf8.begin(), utf8.end());
auto encoded = coder.encode(data);
auto decoded = coder.decode(encoded);
EXPECT_EQ(decoded, data);
std::string decoded_str(decoded.begin(), decoded.end());
EXPECT_EQ(decoded_str, utf8);
}

View File

@ -5,7 +5,6 @@
#include <glog/logging.h> #include <glog/logging.h>
#include <gmock/gmock.h> #include <gmock/gmock.h>
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <thread>
class TestCApi : public ::testing::Test { class TestCApi : public ::testing::Test {
protected: protected:
@ -20,6 +19,7 @@ class TestCApi : public ::testing::Test {
}; };
TEST_F(TestCApi, Base) { TEST_F(TestCApi, Base) {
rpc_string name; rpc_string name;
name.s = "test"; name.s = "test";
static std::promise<bool> task; static std::promise<bool> task;
@ -35,7 +35,7 @@ TEST_F(TestCApi, Base) {
task.set_value(installed); task.set_value(installed);
} }
DLOG(INFO) << "Go out"; DLOG(INFO) << "Go out";
return crpc_str_create("res", sizeof("res") - 1); return crpc_str_create("res", sizeof("res"));
}, },
&name); &name);
@ -63,16 +63,14 @@ TEST_F(TestCApi, AddedMultiple) {
} }
auto register_idx = [&]<size_t I>() { auto register_idx = [&]<size_t I>() {
crpc_test_add_method( crpc_test_add_method(+[](rpc_string*) -> rpc_string* {
+[](rpc_string *) -> rpc_string * {
static bool installed = false; static bool installed = false;
if (!installed) { if (!installed) {
installed = true; installed = true;
(*bridge)[I].set_value(true); (*bridge)[I].set_value(true);
} }
return crpc_str_create("res", sizeof("res")); return crpc_str_create("res", sizeof("res"));
}, }, &called[I].second);
&called[I].second);
}; };
register_idx.template operator()<0>(); register_idx.template operator()<0>();
@ -123,17 +121,14 @@ TEST_F(TestCApi, ScheduleCall) {
} }
auto register_idx = [&]<size_t I>() { auto register_idx = [&]<size_t I>() {
crpc_test_add_method( crpc_test_add_method(+[](rpc_string*) -> rpc_string* {
+[](rpc_string *) -> rpc_string * {
static bool installed = false; static bool installed = false;
if (!installed) { if (!installed) {
installed = true; installed = true;
(*bridge)[I].set_value(true); (*bridge)[I].set_value(true);
} }
std::string_view res = "res"; return crpc_str_create("res", sizeof("res"));
return crpc_str_create(res.data(), res.size()); }, &called[I].second);
},
&called[I].second);
}; };
auto test_idx = [&]<size_t I>() { auto test_idx = [&]<size_t I>() {
using namespace std::chrono_literals; using namespace std::chrono_literals;
@ -163,37 +158,12 @@ TEST_F(TestCApi, ScheduleCall) {
test_idx.template operator()<3>(); test_idx.template operator()<3>();
} }
TEST(TestTestCApi, InitDeinitOnLongRunningTask) {
using namespace std::chrono_literals;
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestRPC");
EXPECT_NO_THROW(crpc_test_init());
static std::promise<bool> bridge;
std::string_view name = "long running task";
crpc_test_add_method(
+[](rpc_string *) -> rpc_string * {
static bool installed = false;
if (!installed) {
installed = true;
bridge.set_value(true);
}
std::string_view res = "res";
std::this_thread::sleep_for(3s);
return crpc_str_create(res.data(), res.size());
},
crpc_str_create(name.data(), name.size()));
std::this_thread::sleep_for(500ms);
crpc_test_deinit();
}
TEST_F(TestCApi, String) { TEST_F(TestCApi, String) {
rpc_string name; rpc_string name;
name.s = "test"; name.s = "test";
EXPECT_EQ(name.s.c_str(), crpc_str_get_data(&name)); EXPECT_EQ(name.s.c_str(), crpc_str_get_data(&name));
EXPECT_EQ(name.s.size(), crpc_str_get_size(&name)); EXPECT_EQ(name.s.size(), crpc_str_get_size(&name));
std::string_view testString = "test 2222";
auto creation = crpc_str_create(testString.data(), testString.size()); auto creation = crpc_str_create("test 2222", sizeof("test 2222"));
EXPECT_EQ(std::string_view(crpc_str_get_data(creation)), testString);
EXPECT_NO_THROW(crpc_str_destroy(creation)); EXPECT_NO_THROW(crpc_str_destroy(creation));
} }

View File

@ -1,213 +0,0 @@
#include "cloud_point_rpc/rpc_server.hpp"
#include "server_api.h"
#include "test_api.h"
#include <fstream>
#include <glog/logging.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
class CApiEdgeCaseTest : public ::testing::Test {
protected:
void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestRPC");
}
};
// Null pointer tests for string functions
TEST_F(CApiEdgeCaseTest, StrGetDataNullptr) {
EXPECT_EQ(crpc_str_get_data(nullptr), nullptr);
}
TEST_F(CApiEdgeCaseTest, StrGetSizeNullptr) {
EXPECT_EQ(crpc_str_get_size(nullptr), 0);
}
TEST_F(CApiEdgeCaseTest, StrCreateNullptrData) {
EXPECT_EQ(crpc_str_create(nullptr, 10), nullptr);
}
TEST_F(CApiEdgeCaseTest, StrCreateEmptyString) {
auto str = crpc_str_create("", 0);
EXPECT_NE(str, nullptr);
EXPECT_EQ(crpc_str_get_size(str), 0);
EXPECT_EQ(std::string_view(crpc_str_get_data(str)), "");
crpc_str_destroy(str);
}
TEST_F(CApiEdgeCaseTest, StrDestroyNullptr) {
// Should not crash
EXPECT_NO_THROW(crpc_str_destroy(nullptr));
}
// Double destroy should be safe-ish (will just not find it)
TEST_F(CApiEdgeCaseTest, StrDoubleDestroy) {
auto str = crpc_str_create("test", 4);
ASSERT_NE(str, nullptr);
crpc_str_destroy(str);
// Second destroy should not crash (pointer not in gc anymore)
EXPECT_NO_THROW(crpc_str_destroy(str));
}
// Create and destroy many strings
TEST_F(CApiEdgeCaseTest, StrCreateDestroyMany) {
constexpr int N = 1000;
std::vector<rpc_string *> ptrs;
ptrs.reserve(N);
for (int i = 0; i < N; ++i) {
auto str = crpc_str_create("x", 1);
ASSERT_NE(str, nullptr);
ptrs.push_back(str);
}
// Destroy half
for (int i = 0; i < N / 2; ++i) {
crpc_str_destroy(ptrs[i]);
}
// Create more
for (int i = 0; i < N / 2; ++i) {
auto str = crpc_str_create("y", 1);
ASSERT_NE(str, nullptr);
}
// Destroy remaining original
for (int i = N / 2; i < N; ++i) {
crpc_str_destroy(ptrs[i]);
}
}
// Null pointer tests for add_method
TEST_F(CApiEdgeCaseTest, AddMethodNullName) {
auto cb =
+[](rpc_string *) -> rpc_string * { return crpc_str_create("res", 3); };
// Should not crash, just log and return
EXPECT_NO_THROW(crpc_add_method(cb, nullptr));
}
TEST_F(CApiEdgeCaseTest, AddMethodNullCallback) {
rpc_string name{"test", 4};
EXPECT_NO_THROW(crpc_add_method(nullptr, &name));
}
TEST_F(CApiEdgeCaseTest, AddMethodBothNull) {
EXPECT_NO_THROW(crpc_add_method(nullptr, nullptr));
}
// crpc_init edge cases
TEST_F(CApiEdgeCaseTest, InitWithNullptr) {
// Should not crash, just log error and return
EXPECT_NO_THROW(crpc_init(nullptr));
}
TEST_F(CApiEdgeCaseTest, InitWithInvalidPath) {
// Should catch exception and log, not crash
EXPECT_NO_THROW(crpc_init("/nonexistent/path/config.yaml"));
}
// Full lifecycle: init -> add method -> deinit
TEST_F(CApiEdgeCaseTest, FullLifecycle) {
std::ofstream config_file("test_config.yaml");
config_file << "server:\n"
<< " ip: \"127.0.0.1\"\n"
<< " port: 19191\n";
config_file.close();
EXPECT_NO_THROW(crpc_init("test_config.yaml"));
rpc_string name{"echo", 4};
auto cb = +[](rpc_string *req) -> rpc_string * {
return crpc_str_create(req->s.data(), req->s.size());
};
EXPECT_NO_THROW(crpc_add_method(cb, &name));
EXPECT_NO_THROW(crpc_deinit());
std::remove("test_config.yaml");
}
// Deinit without init should not crash
TEST_F(CApiEdgeCaseTest, DeinitWithoutInit) { EXPECT_NO_THROW(crpc_deinit()); }
// Multiple init/deinit cycles
TEST_F(CApiEdgeCaseTest, MultipleInitDeinitCycles) {
std::ofstream config_file("test_config.yaml");
config_file << "server:\n"
<< " ip: \"127.0.0.1\"\n"
<< " port: 19192\n";
config_file.close();
for (int i = 0; i < 3; ++i) {
EXPECT_NO_THROW(crpc_init("test_config.yaml"));
EXPECT_NO_THROW(crpc_deinit());
}
std::remove("test_config.yaml");
}
// GC cleanup on deinit
TEST_F(CApiEdgeCaseTest, GcCleanupOnDeinit) {
auto str1 = crpc_str_create("one", 3);
auto str2 = crpc_str_create("two", 3);
ASSERT_NE(str1, nullptr);
ASSERT_NE(str2, nullptr);
// Destroy one, leave one
crpc_str_destroy(str1);
// deinit should clear gc including str2
EXPECT_NO_THROW(crpc_deinit());
}
// Large string creation
TEST_F(CApiEdgeCaseTest, LargeStringCreate) {
std::string large(1000000, 'x');
auto str = crpc_str_create(large.data(), large.size());
ASSERT_NE(str, nullptr);
EXPECT_EQ(crpc_str_get_size(str), large.size());
EXPECT_EQ(std::string(crpc_str_get_data(str), large.size()), large);
crpc_str_destroy(str);
}
// String with embedded null bytes
TEST_F(CApiEdgeCaseTest, StringWithNullBytes) {
std::string data("Hello\0World", 11);
auto str = crpc_str_create(data.data(), data.size());
ASSERT_NE(str, nullptr);
EXPECT_EQ(crpc_str_get_size(str), 11);
EXPECT_EQ(std::string(crpc_str_get_data(str), 11), data);
crpc_str_destroy(str);
}
// Test API edge cases
TEST_F(CApiEdgeCaseTest, TestInitDeinit) {
EXPECT_NO_THROW(crpc_test_init());
EXPECT_NO_THROW(crpc_test_deinit());
}
TEST_F(CApiEdgeCaseTest, TestRemoveNonexistentMethod) {
crpc_test_init();
rpc_string name{"nonexistent", 11};
EXPECT_EQ(crpc_test_remove_method(&name), -1);
crpc_test_deinit();
}
TEST_F(CApiEdgeCaseTest, TestAutoCallToggle) {
crpc_test_init();
EXPECT_NO_THROW(crpc_test_auto_call(0));
EXPECT_NO_THROW(crpc_test_auto_call(1));
EXPECT_NO_THROW(crpc_test_auto_call(0));
crpc_test_deinit();
}
TEST_F(CApiEdgeCaseTest, TestChangeDuration) {
crpc_test_init();
EXPECT_NO_THROW(crpc_test_change_duration(100));
EXPECT_EQ(crpc_test_duration(), 100);
EXPECT_NO_THROW(crpc_test_change_duration(500));
EXPECT_EQ(crpc_test_duration(), 500);
crpc_test_deinit();
}
TEST_F(CApiEdgeCaseTest, TestScheduleCallNonexistent) {
crpc_test_init();
rpc_string name{"nonexistent", 11};
EXPECT_NO_THROW(crpc_test_schedule_call(&name));
crpc_test_deinit();
}

View File

@ -9,17 +9,16 @@
#include "cloud_point_rpc/rpc_server.hpp" #include "cloud_point_rpc/rpc_server.hpp"
#include "cloud_point_rpc/tcp_server.hpp" #include "cloud_point_rpc/tcp_server.hpp"
using namespace score; using namespace cloud_point_rpc;
class CliTest : public ::testing::Test { class CliTest : public ::testing::Test {
public: public:
void start() { tcp_server->start(); } void start() {
tcp_server->start();
}
protected: protected:
void SetUp() override { void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestCli");
server_ip = "127.0.0.1"; server_ip = "127.0.0.1";
server_port = 9096; server_port = 9096;

View File

@ -1,294 +0,0 @@
// E2E tests for CloudPointClient: in-process TcpServer + RpcServer with a
// synthetic scene providing known ground-truth depth.
//
// Calibration: fx=fy=800, cx=320, cy=240, 640×480, baseline=0.06 m,
// R=I, T=[-0.06,0,0]. Disparity = 32 px → z = 1.5 m.
#include <algorithm>
#include <chrono>
#include <cmath>
#include <limits>
#include <numeric>
#include <sstream>
#include <thread>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include "cloud_point/cloud_point_client.hpp"
#include "cloud_point_rpc/cli.hpp"
#include "cloud_point_rpc/rpc_dto.hpp"
#include "cloud_point_rpc/rpc_server.hpp"
#include "cloud_point_rpc/tcp_server.hpp"
using namespace score;
using json = nlohmann::json;
// ---------------------------------------------------------------------------
// Shared synthetic calibration helpers
// ---------------------------------------------------------------------------
namespace {
constexpr double kFx = 800.0;
constexpr double kFy = 800.0;
constexpr double kCx = 320.0;
constexpr double kCy = 240.0;
constexpr int kWidth = 640;
constexpr int kHeight = 480;
constexpr double kBaseline = 0.06;
constexpr double kTx = -kBaseline; // OpenCV T convention
/// Standard stereo calibration DTO (parallel rig, no distortion).
StereoCalibrationRPC make_stereo_calib() {
StereoCalibrationRPC c;
c.width = kWidth;
c.height = kHeight;
c.left.camera_matrix = {kFx, 0, kCx, 0, kFy, kCy, 0, 0, 1};
c.left.dist_coeffs = {0, 0, 0, 0, 0};
c.right = c.left;
c.rotation = {1, 0, 0, 0, 1, 0, 0, 0, 1};
c.translation = {kTx, 0.0, 0.0};
return c;
}
/// Synthetic image pair: random left (seed 42), right = left shifted +32 px.
/// Convention: right(x, y) = left(x+d, y) → disparity d = 32 for positive
/// depth (matches service.cpp and OpenCV SGBM sign convention with Tx < 0).
ImagePairRPC make_image_pair(int disparity = 32) {
// Generate textured left grayscale image with fixed seed.
cv::theRNG().state = 42;
cv::Mat left_gray(kHeight, kWidth, CV_8UC1);
cv::randu(left_gray, 0, 256);
// Build right by shifting: right(x) = left(x+d), 0 at right border.
cv::Mat right_gray = cv::Mat::zeros(kHeight, kWidth, CV_8UC1);
for (int y = 0; y < kHeight; ++y) {
for (int x = 0; x < kWidth - disparity; ++x) {
right_gray.at<uchar>(y, x) = left_gray.at<uchar>(y, x + disparity);
}
}
// Convert to BGR for the wire protocol.
cv::Mat left_bgr, right_bgr;
cv::cvtColor(left_gray, left_bgr, cv::COLOR_GRAY2BGR);
cv::cvtColor(right_gray, right_bgr, cv::COLOR_GRAY2BGR);
auto mat_to_rpc = [](const cv::Mat &img, int w, int h) -> ImageRPC {
ImageRPC rpc;
rpc.width = w;
rpc.height = h;
rpc.type = ImageRPC::Type::BGR;
const size_t sz = static_cast<size_t>(w) * h * 3;
rpc.data.resize(sz);
std::memcpy(rpc.data.data(), img.data, sz);
return rpc;
};
ImagePairRPC pair;
pair.frame = 0;
pair.left = mat_to_rpc(left_bgr, kWidth, kHeight);
pair.right = mat_to_rpc(right_bgr, kWidth, kHeight);
return pair;
}
} // namespace
// ---------------------------------------------------------------------------
// Test fixture: in-process TcpServer + RpcServer on port 9201
// ---------------------------------------------------------------------------
class CloudPointClientTest : public ::testing::Test {
protected:
void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestCloudPointClient");
}
void TearDown() override {
if (server_) {
server_->stop();
}
}
/// Start an in-process server with the given RpcServer and wait for it.
void start_server(int port, std::unique_ptr<RpcServer> rpc) {
rpc_server_ = std::move(rpc);
server_ = std::make_unique<TcpServer>(
"127.0.0.1", port, [this](const std::string &req) {
return rpc_server_->process(req);
});
server_->start();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
std::unique_ptr<RpcServer> rpc_server_;
std::unique_ptr<TcpServer> server_;
};
// ---------------------------------------------------------------------------
// Test 1: connect succeeds; compute_cloud() returns a cloud with median z ≈ 1.5
// m
// ---------------------------------------------------------------------------
TEST_F(CloudPointClientTest, ComputeCloudReturnsCorrectDepth) {
constexpr int kPort = 9201;
// Build RPC server with known stereo calibration and image pair.
auto rpc = std::make_unique<RpcServer>();
rpc->register_method("get-stereo-calibration", [](const json &) -> json {
json j;
to_json(j, make_stereo_calib());
return j;
});
rpc->register_method("get-image-pair", [](const json &) -> json {
json j;
to_json(j, make_image_pair(32));
return j;
});
start_server(kPort, std::move(rpc));
CloudPointClient client("127.0.0.1", kPort, StereoAlgorithmType::CPU);
ASSERT_NO_THROW(client.connect());
ASSERT_TRUE(client.connected());
auto result = client.compute_cloud();
ASSERT_TRUE(result.has_value())
<< "compute_cloud returned Error: " << result.error().message;
const auto &cloud = *result;
EXPECT_EQ(cloud.width, kWidth);
EXPECT_EQ(cloud.height, kHeight);
EXPECT_FALSE(cloud.valid_points().empty())
<< "Expected at least some valid points";
// Collect z values in the central region (avoid SGBM borders).
// Horizontal: [150, 490) to skip left border (SGBM invalid) and
// right border where right image has no data (last 32 cols).
// Vertical: [50, 430).
constexpr int kXMin = 150;
constexpr int kXMax = 490;
constexpr int kYMin = 50;
constexpr int kYMax = 430;
std::vector<float> z_vals;
z_vals.reserve(static_cast<size_t>((kXMax - kXMin) * (kYMax - kYMin)));
for (int y = kYMin; y < kYMax; ++y) {
for (int x = kXMin; x < kXMax; ++x) {
const float z =
cloud.data[static_cast<size_t>(y * kWidth + x) * 3 + 2];
if (!std::isnan(z))
z_vals.push_back(z);
}
}
ASSERT_FALSE(z_vals.empty()) << "No valid points in central region";
// Compute median z.
const auto mid = z_vals.begin() + static_cast<ptrdiff_t>(z_vals.size() / 2);
std::nth_element(z_vals.begin(), mid, z_vals.end());
const float median_z = *mid;
constexpr float kExpectedZ = static_cast<float>(kFx * kBaseline / 32.0);
constexpr float kToleranceZ = kExpectedZ * 0.05f; // 5%
EXPECT_NEAR(median_z, kExpectedZ, kToleranceZ)
<< "Median z in central region should be ~" << kExpectedZ
<< " m (expected=" << kExpectedZ << ", got=" << median_z << ")";
}
// ---------------------------------------------------------------------------
// Test 2: server returns garbage image data → compute_cloud() returns Error
// ---------------------------------------------------------------------------
TEST_F(CloudPointClientTest, GarbageImageDataReturnsError) {
constexpr int kPort = 9202;
auto rpc = std::make_unique<RpcServer>();
rpc->register_method("get-stereo-calibration", [](const json &) -> json {
json j;
to_json(j, make_stereo_calib());
return j;
});
// Return an image pair whose data size doesn't match width*height*channels.
rpc->register_method("get-image-pair", [](const json &) -> json {
ImagePairRPC bad_pair;
bad_pair.frame = 0;
bad_pair.left.width = kWidth;
bad_pair.left.height = kHeight;
bad_pair.left.type = ImageRPC::Type::BGR;
bad_pair.left.data = {0x01, 0x02}; // wrong size: 2 bytes, not 640*480*3
bad_pair.right = bad_pair.left;
json j;
to_json(j, bad_pair);
return j;
});
start_server(kPort, std::move(rpc));
CloudPointClient client("127.0.0.1", kPort, StereoAlgorithmType::CPU);
ASSERT_NO_THROW(client.connect());
auto result = client.compute_cloud();
EXPECT_FALSE(result.has_value())
<< "Expected Error for garbage image data, got a cloud instead";
if (!result.has_value()) {
EXPECT_FALSE(result.error().message.empty());
}
}
// ---------------------------------------------------------------------------
// Test 3: connect() to a closed port throws
// ---------------------------------------------------------------------------
TEST_F(CloudPointClientTest, ConnectToClosedPortThrows) {
// Port 9203 has no server running.
CloudPointClient client("127.0.0.1", 9203, StereoAlgorithmType::CPU);
EXPECT_THROW(client.connect(), std::runtime_error);
EXPECT_FALSE(client.connected());
}
// ---------------------------------------------------------------------------
// Test 4: CLI smoke — option 4 against the mock server
// ---------------------------------------------------------------------------
TEST_F(CloudPointClientTest, CliOption4ComputeCloud) {
constexpr int kPort = 9204;
auto rpc = std::make_unique<RpcServer>();
rpc->register_method("get-stereo-calibration", [](const json &) -> json {
json j;
to_json(j, make_stereo_calib());
return j;
});
rpc->register_method("get-image-pair", [](const json &) -> json {
json j;
to_json(j, make_image_pair(32));
return j;
});
// The CLI also uses the legacy RPC methods; register stubs so it can
// connect.
rpc->register_method("get-intrinsic-params",
[](const json &) { return std::vector<double>{1.0}; });
rpc->register_method("get-extrinsic-params",
[](const json &) { return std::vector<double>{1.0}; });
rpc->register_method("get-cloud-point", [](const json &) {
return std::vector<std::vector<double>>{};
});
start_server(kPort, std::move(rpc));
std::istringstream input("4\n0\n");
std::ostringstream output;
const int rc = run_cli(input, output, "127.0.0.1", kPort);
EXPECT_EQ(rc, 0);
const std::string out = output.str();
// Expect either a point count line ("valid_pts=") or an error message.
EXPECT_THAT(out, ::testing::AnyOf(::testing::HasSubstr("valid_pts="),
::testing::HasSubstr("Error")))
<< "CLI output was:\n"
<< out;
}

View File

@ -1,76 +0,0 @@
#include <cstdio>
#include <fstream>
#include <gtest/gtest.h>
#include <string>
#include <tuple>
#include "cloud_point_rpc/config.hpp"
namespace {
struct TempConfig {
std::string path;
explicit TempConfig(const std::string &yaml)
: path("test_config_" +
std::to_string(reinterpret_cast<uintptr_t>(this)) + ".yaml") {
std::ofstream(path) << yaml;
}
~TempConfig() { std::remove(path.c_str()); }
};
} // namespace
TEST(ConfigLoaderTest, CloudPointSectionDefaultsWhenAbsent) {
TempConfig cfg("server:\n ip: \"127.0.0.1\"\n port: 8080\n");
const auto c = score::ConfigLoader::load(cfg.path);
EXPECT_EQ(c.cloud_point.algorithm, "gpu");
EXPECT_EQ(c.cloud_point.num_disparities, 128);
EXPECT_DOUBLE_EQ(c.cloud_point.min_depth_m, 0.01);
EXPECT_DOUBLE_EQ(c.cloud_point.max_depth_m, 10.0);
}
TEST(ConfigLoaderTest, CloudPointSectionIsParsed) {
TempConfig cfg("server:\n ip: \"127.0.0.1\"\n port: 8080\n"
"cloud_point:\n algorithm: cpu\n num_disparities: 160\n"
" min_depth_m: 0.02\n max_depth_m: 0.3\n");
const auto c = score::ConfigLoader::load(cfg.path);
EXPECT_EQ(c.cloud_point.algorithm, "cpu");
EXPECT_EQ(c.cloud_point.num_disparities, 160);
EXPECT_DOUBLE_EQ(c.cloud_point.min_depth_m, 0.02);
EXPECT_DOUBLE_EQ(c.cloud_point.max_depth_m, 0.3);
}
TEST(ConfigLoaderTest, CloudPointExportAndFilterKeys) {
TempConfig cfg("server:\n ip: \"127.0.0.1\"\n port: 8080\n"
"cloud_point:\n ply_stride: 4\n wls_filter: true\n");
const auto c = score::ConfigLoader::load(cfg.path);
EXPECT_EQ(c.cloud_point.ply_stride, 4);
EXPECT_TRUE(c.cloud_point.wls_filter);
TempConfig bad("server:\n ip: \"127.0.0.1\"\n port: 8080\n"
"cloud_point:\n ply_stride: 0\n");
EXPECT_THROW(std::ignore = score::ConfigLoader::load(bad.path),
std::runtime_error);
}
TEST(ConfigLoaderTest, CloudPointPartialSectionKeepsDefaults) {
TempConfig cfg("server:\n ip: \"127.0.0.1\"\n port: 8080\n"
"cloud_point:\n max_depth_m: 0.5\n");
const auto c = score::ConfigLoader::load(cfg.path);
EXPECT_EQ(c.cloud_point.algorithm, "gpu");
EXPECT_EQ(c.cloud_point.num_disparities, 128);
EXPECT_DOUBLE_EQ(c.cloud_point.max_depth_m, 0.5);
}
TEST(ConfigLoaderTest, CloudPointRejectsBadAlgorithm) {
TempConfig cfg("server:\n ip: \"127.0.0.1\"\n port: 8080\n"
"cloud_point:\n algorithm: fpga\n");
EXPECT_THROW(std::ignore = score::ConfigLoader::load(cfg.path),
std::runtime_error);
}
TEST(ConfigLoaderTest, CloudPointRejectsInvertedDepthRange) {
TempConfig cfg("server:\n ip: \"127.0.0.1\"\n port: 8080\n"
"cloud_point:\n min_depth_m: 1.0\n max_depth_m: 0.5\n");
EXPECT_THROW(std::ignore = score::ConfigLoader::load(cfg.path),
std::runtime_error);
}

View File

@ -1,160 +0,0 @@
//
// Created by vptyp on 12.03.2026.
//
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <cloud_point/imageFactory.h>
#include <cloud_point/matrixFactory.h>
class ImageTest : public ::testing::Test {
protected:
void SetUp() override {}
void TearDown() override {}
};
TEST_F(ImageTest, DefaultConstructor) {
score::Image image;
cv::Mat mat = image.get();
EXPECT_TRUE(mat.empty());
}
TEST_F(ImageTest, ConstructorWithMat) {
cv::Mat input = cv::Mat::zeros(5, 5, CV_8UC1);
score::Image image(input);
cv::Mat output = image.get();
EXPECT_EQ(output.rows, 5);
EXPECT_EQ(output.cols, 5);
EXPECT_EQ(output.type(), CV_8UC1);
}
TEST_F(ImageTest, PixelTypeMapping) {
EXPECT_EQ(score::ImageFactory::pixelType(score::ImageRPC::Type::BGR),
CV_8UC3);
EXPECT_EQ(score::ImageFactory::pixelType(score::ImageRPC::Type::RGBA),
CV_8UC4);
EXPECT_EQ(score::ImageFactory::pixelType(score::ImageRPC::Type::DEPTH),
CV_64FC1);
EXPECT_THROW(score::ImageFactory::pixelType(score::ImageRPC::Type::UNKNOWN),
std::runtime_error);
}
TEST_F(ImageTest, CreateBGR) {
score::ImageRPC rpc;
rpc.width = 10;
rpc.height = 20;
rpc.type = score::ImageRPC::Type::BGR;
rpc.data.resize(rpc.width * rpc.height * 3, 128);
score::Image image = score::ImageFactory::create(rpc);
cv::Mat mat = image.get();
EXPECT_EQ(mat.rows, 20);
EXPECT_EQ(mat.cols, 10);
EXPECT_EQ(mat.type(), CV_8UC3);
EXPECT_EQ(mat.at<cv::Vec3b>(0, 0)[0], 128);
}
TEST_F(ImageTest, CreateRGBA) {
score::ImageRPC rpc;
rpc.width = 15;
rpc.height = 25;
rpc.type = score::ImageRPC::Type::RGBA;
rpc.data.resize(rpc.width * rpc.height * 4, 255);
score::Image image = score::ImageFactory::create(rpc);
cv::Mat mat = image.get();
EXPECT_EQ(mat.rows, 25);
EXPECT_EQ(mat.cols, 15);
EXPECT_EQ(mat.type(), CV_8UC4);
EXPECT_EQ(mat.at<cv::Vec4b>(0, 0)[0], 255);
}
TEST_F(ImageTest, CreateDepth) {
score::ImageRPC rpc;
rpc.width = 5;
rpc.height = 10;
rpc.type = score::ImageRPC::Type::DEPTH;
rpc.data.resize(rpc.width * rpc.height * sizeof(double));
auto *dataPtr = reinterpret_cast<double *>(rpc.data.data());
for (int i = 0; i < 50; ++i)
dataPtr[i] = static_cast<double>(i);
score::Image image = score::ImageFactory::create(rpc);
cv::Mat mat = image.get();
EXPECT_EQ(mat.rows, 10);
EXPECT_EQ(mat.cols, 5);
EXPECT_EQ(mat.type(), CV_64FC1);
EXPECT_DOUBLE_EQ(mat.at<double>(0, 0), 0.0);
// row 9, col 4 → flat index 9*5+4 = 49
EXPECT_DOUBLE_EQ(mat.at<double>(9, 4), 49.0);
}
TEST_F(ImageTest, CreateOwnsDataAfterSourceDestroyed) {
// Use asymmetric size (width=4, height=2) with known BGR values
constexpr int kWidth = 4;
constexpr int kHeight = 2;
constexpr int kBytes = kWidth * kHeight * 3;
score::Image image;
{
score::ImageRPC rpc;
rpc.width = kWidth;
rpc.height = kHeight;
rpc.type = score::ImageRPC::Type::BGR;
rpc.data.resize(kBytes);
for (int i = 0; i < kBytes; ++i)
rpc.data[i] = static_cast<unsigned char>(i);
image = score::ImageFactory::create(rpc);
// rpc goes out of scope here; image must own its data
}
cv::Mat mat = image.get();
EXPECT_EQ(mat.rows, kHeight);
EXPECT_EQ(mat.cols, kWidth);
EXPECT_EQ(mat.type(), CV_8UC3);
// Verify pixel values match the original source bytes
for (int r = 0; r < kHeight; ++r) {
for (int c = 0; c < kWidth; ++c) {
const int flat = (r * kWidth + c) * 3;
const auto px = mat.at<cv::Vec3b>(r, c);
EXPECT_EQ(px[0], static_cast<unsigned char>(flat));
EXPECT_EQ(px[1], static_cast<unsigned char>(flat + 1));
EXPECT_EQ(px[2], static_cast<unsigned char>(flat + 2));
}
}
}
TEST_F(ImageTest, CreateThrowsOnWrongDataSize) {
score::ImageRPC rpc;
rpc.width = 4;
rpc.height = 2;
rpc.type = score::ImageRPC::Type::BGR;
// Intentionally wrong size (1 byte short)
rpc.data.resize(4 * 2 * 3 - 1, 0);
EXPECT_THROW(score::ImageFactory::create(rpc), std::runtime_error);
}
TEST_F(ImageTest, CameraMatrixCreateOwnsDataAfterVectorDestroyed) {
cv::Mat mat;
{
std::vector<double> vals = {1, 2, 3, 4, 5, 6, 7, 8, 9};
mat = score::CameraMatrixFactory::create<3, 3>(vals);
// vals goes out of scope here; mat must own its data
}
ASSERT_EQ(mat.rows, 3);
ASSERT_EQ(mat.cols, 3);
for (int i = 0; i < 9; ++i)
EXPECT_DOUBLE_EQ(mat.at<double>(i / 3, i % 3),
static_cast<double>(i + 1));
}
TEST_F(ImageTest, CameraMatrixCreateThrowsOnWrongSize) {
std::vector<double> vals(8, 0.0); // 8 elements, need 9 for 3x3
EXPECT_THROW((score::CameraMatrixFactory::create<3, 3>(vals)),
std::runtime_error);
}

View File

@ -11,14 +11,11 @@
#include <fstream> #include <fstream>
using namespace score; using namespace cloud_point_rpc;
class IntegrationTest : public ::testing::Test { class IntegrationTest : public ::testing::Test {
protected: protected:
void SetUp() override { void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestCli");
// Create a temporary config file for testing // Create a temporary config file for testing
std::ofstream config_file("config.yaml"); std::ofstream config_file("config.yaml");
config_file config_file
@ -72,7 +69,7 @@ class IntegrationTest : public ::testing::Test {
std::remove("config.yaml"); std::remove("config.yaml");
} }
Config config_{}; Config config_;
std::unique_ptr<Service> service_; std::unique_ptr<Service> service_;
std::unique_ptr<RpcServer> rpc_server_; std::unique_ptr<RpcServer> rpc_server_;
std::unique_ptr<TcpServer> tcp_server_; std::unique_ptr<TcpServer> tcp_server_;
@ -98,11 +95,3 @@ TEST_F(IntegrationTest, ClientCanConnectAndRetrieveValues) {
TEST_F(IntegrationTest, ClientHandlesConnectionError) { TEST_F(IntegrationTest, ClientHandlesConnectionError) {
EXPECT_THROW(TCPConnector connector("127.0.0.1", 9999), std::runtime_error); EXPECT_THROW(TCPConnector connector("127.0.0.1", 9999), std::runtime_error);
} }
TEST_F(IntegrationTest, ClientRetrieveRemoteMethods) {
TCPConnector connector(config_.server.ip, config_.server.port);
RpcClient client(connector);
auto res = client.call<std::vector<std::string>>("get-available-methods");
EXPECT_EQ(res.size(), 2);
}

View File

@ -1,157 +0,0 @@
#include <cmath>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <gtest/gtest.h>
#include <limits>
#include <sstream>
#include <string>
#include <tuple>
#include "cloud_point/cloud_point_client.hpp"
using namespace score;
namespace {
/// 3x3 organised cloud on a plane z = 1 m with 1 cm pixel spacing.
PointCloud make_grid_cloud() {
PointCloud cloud;
cloud.width = 3;
cloud.height = 3;
cloud.data.resize(27);
for (int r = 0; r < 3; ++r) {
for (int c = 0; c < 3; ++c) {
const size_t i = static_cast<size_t>(r * 3 + c) * 3;
cloud.data[i] = 0.01f * static_cast<float>(c);
cloud.data[i + 1] = 0.01f * static_cast<float>(r);
cloud.data[i + 2] = 1.0f;
}
}
return cloud;
}
void set_nan(PointCloud &cloud, int r, int c) {
const size_t i = static_cast<size_t>(r * cloud.width + c) * 3;
const float nan = std::numeric_limits<float>::quiet_NaN();
cloud.data[i] = cloud.data[i + 1] = cloud.data[i + 2] = nan;
}
struct TempFile {
std::string path{"test_ply_export.ply"};
~TempFile() { std::remove(path.c_str()); }
std::string read() const {
std::ifstream in(path);
std::stringstream ss;
ss << in.rdbuf();
return ss.str();
}
};
} // namespace
TEST(PlyExportTest, PointsOnlyHasNoFaceElement) {
TempFile file;
const auto faces =
write_ply(make_grid_cloud(), file.path, PlyOptions{false, 0.05f});
EXPECT_EQ(faces, 0u);
const auto text = file.read();
EXPECT_NE(text.find("element vertex 9\n"), std::string::npos);
EXPECT_EQ(text.find("element face"), std::string::npos);
}
TEST(PlyExportTest, FullGridProducesTwoTrianglesPerCell) {
TempFile file;
const auto faces =
write_ply(make_grid_cloud(), file.path, PlyOptions{true, 0.05f, false});
EXPECT_EQ(faces, 8u); // 2x2 cells * 2 triangles
EXPECT_NE(file.read().find("format ascii 1.0\n"), std::string::npos);
const auto text = file.read();
EXPECT_NE(text.find("element face 8\n"), std::string::npos);
EXPECT_NE(text.find("property list uchar int vertex_indices\n"),
std::string::npos);
// First cell, first triangle: (0,0) -> (1,0) -> (1,1) = indices 0,3,4
EXPECT_NE(text.find("\n3 0 3 4\n"), std::string::npos);
}
TEST(PlyExportTest, MissingCornerKeepsSingleTriangle) {
auto cloud = make_grid_cloud();
set_nan(cloud, 0, 0); // top-left cell has 3 valid corners
TempFile file;
const auto faces =
write_ply(cloud, file.path, PlyOptions{true, 0.05f, false});
EXPECT_EQ(faces, 7u);
const auto text = file.read();
EXPECT_NE(text.find("element vertex 8\n"), std::string::npos);
}
TEST(PlyExportTest, DepthJumpBreaksMesh) {
auto cloud = make_grid_cloud();
// Push the centre column 20 cm away: every triangle touching it now has
// an edge longer than 5% of its mean depth and must be dropped.
for (int r = 0; r < 3; ++r) {
cloud.data[static_cast<size_t>(r * 3 + 1) * 3 + 2] = 1.2f;
}
TempFile file;
EXPECT_EQ(write_ply(cloud, file.path), 0u);
}
TEST(PlyExportTest, StrideAveragesBlocks) {
// 3x3 grid with stride 2 -> 2x2 blocks: one full 2x2 block, two 2x1 /
// 1x2 edge blocks and the single corner pixel -> 4 vertices, 2 faces.
TempFile file;
const auto faces = write_ply(make_grid_cloud(), file.path,
PlyOptions{true, 0.05f, false, 2});
EXPECT_EQ(faces, 2u);
const auto text = file.read();
EXPECT_NE(text.find("element vertex 4\n"), std::string::npos);
// First vertex is the mean of pixels (0,0),(0,1),(1,0),(1,1).
EXPECT_NE(text.find("\n0.005 0.005 1\n"), std::string::npos);
// Last vertex is the lone corner pixel (2,2).
EXPECT_NE(text.find("\n0.02 0.02 1\n"), std::string::npos);
}
TEST(PlyExportTest, StrideDropsMostlyInvalidBlocks) {
auto cloud = make_grid_cloud();
// Invalidate 3 of the 4 pixels of the top-left 2x2 block.
set_nan(cloud, 0, 0);
set_nan(cloud, 0, 1);
set_nan(cloud, 1, 0);
TempFile file;
write_ply(cloud, file.path, PlyOptions{true, 0.05f, false, 2});
EXPECT_NE(file.read().find("element vertex 3\n"), std::string::npos);
}
TEST(PlyExportTest, InvalidStrideThrows) {
TempFile file;
EXPECT_THROW(std::ignore = write_ply(make_grid_cloud(), file.path,
PlyOptions{true, 0.05f, false, 0}),
std::invalid_argument);
}
TEST(PlyExportTest, BinaryLayoutMatchesHeader) {
TempFile file;
const auto faces = write_ply(make_grid_cloud(), file.path); // binary
ASSERT_EQ(faces, 8u);
const auto text = file.read();
EXPECT_NE(text.find("format binary_little_endian 1.0\n"),
std::string::npos);
const auto header_end = text.find("end_header\n") + 11;
ASSERT_NE(header_end, std::string::npos + 11);
// 9 vertices * 12 bytes + 8 faces * (1 + 12) bytes
EXPECT_EQ(text.size() - header_end, 9u * 12u + 8u * 13u);
// First vertex is (0, 0, 1)
float v[3];
std::memcpy(v, text.data() + header_end, sizeof(v));
EXPECT_FLOAT_EQ(v[0], 0.0f);
EXPECT_FLOAT_EQ(v[1], 0.0f);
EXPECT_FLOAT_EQ(v[2], 1.0f);
// First face: count byte 3 then indices 0,3,4
const char *f = text.data() + header_end + 9 * 12;
EXPECT_EQ(static_cast<unsigned char>(f[0]), 3u);
int idx[3];
std::memcpy(idx, f + 1, sizeof(idx));
EXPECT_EQ(idx[0], 0);
EXPECT_EQ(idx[1], 3);
EXPECT_EQ(idx[2], 4);
}

View File

@ -1,254 +0,0 @@
// Tests for PointCloudBuilder: CV_16S / CV_32F handling, depth filtering,
// NaN propagation, and valid_points() correctness.
#include <gtest/gtest.h>
#include <array>
#include <cmath>
#include <vector>
#include <opencv2/core.hpp>
#include "cloud_point/point_cloud_builder.hpp"
#include "cloud_point/stereo_rectifier.hpp"
namespace {
// ---------------------------------------------------------------------------
// Shared synthetic calibration (same as test_stereo_rectifier.cpp)
// ---------------------------------------------------------------------------
constexpr double kFx = 800.0;
constexpr double kFy = 800.0;
constexpr double kCx = 320.0;
constexpr double kCy = 240.0;
constexpr int kWidth = 640;
constexpr int kHeight = 480;
constexpr double kBaseline = 0.06; // metres
constexpr double kTx = -kBaseline;
score::StereoRectifier::Calibration make_calib() {
score::StereoRectifier::Calibration calib;
// clang-format off
calib.k_left = (cv::Mat_<double>(3, 3) <<
kFx, 0, kCx,
0, kFy, kCy,
0, 0, 1);
calib.k_right = calib.k_left.clone();
calib.d_left = cv::Mat::zeros(1, 5, CV_64F);
calib.d_right = cv::Mat::zeros(1, 5, CV_64F);
calib.r = cv::Mat::eye(3, 3, CV_64F);
calib.t = (cv::Mat_<double>(3, 1) << kTx, 0.0, 0.0);
// clang-format on
calib.image_size = cv::Size(kWidth, kHeight);
return calib;
}
cv::Mat make_q() {
score::StereoRectifier rectifier(make_calib());
return rectifier.q().clone();
}
// ---------------------------------------------------------------------------
// Helper: count non-NaN (valid) pixels in the cloud
// ---------------------------------------------------------------------------
int count_valid(const score::PointCloud &cloud) {
int n = 0;
for (int i = 0; i < cloud.height * cloud.width; ++i) {
if (!std::isnan(cloud.data[static_cast<size_t>(i) * 3]))
++n;
}
return n;
}
} // namespace
// ---------------------------------------------------------------------------
// Constant-disparity CV_16S → expected depth = fx * baseline / disparity
// ---------------------------------------------------------------------------
TEST(PointCloudBuilderTest, ConstantDisparityCV16S) {
const cv::Mat q = make_q();
score::PointCloudBuilder builder(q);
// d = 32 px, stored as SGBM fixed-point (d * 16)
constexpr float kDisp = 32.0f;
constexpr float kExpectedZ =
static_cast<float>(kFx * kBaseline / kDisp); // 1.5 m
cv::Mat disparity(kHeight, kWidth, CV_16S,
cv::Scalar(static_cast<short>(kDisp * 16)));
const auto cloud = builder.build(disparity);
EXPECT_EQ(cloud.width, kWidth);
EXPECT_EQ(cloud.height, kHeight);
EXPECT_EQ(static_cast<int>(cloud.data.size()), kWidth * kHeight * 3);
// Every point should be valid and at z ≈ 1.5 m
const auto valid = cloud.valid_points();
EXPECT_EQ(static_cast<int>(valid.size()), kWidth * kHeight);
for (const auto &pt : valid) {
EXPECT_NEAR(pt[2], kExpectedZ, 1e-3f)
<< "z should be fx*baseline/disparity = " << kExpectedZ << " m";
}
}
TEST(PointCloudBuilderTest, CenterPixelXYNearZero) {
const cv::Mat q = make_q();
score::PointCloudBuilder builder(q);
constexpr float kDisp = 32.0f;
cv::Mat disparity(kHeight, kWidth, CV_16S,
cv::Scalar(static_cast<short>(kDisp * 16)));
const auto cloud = builder.build(disparity);
// At the image center (row=cy=240, col=cx=320), x≈0 and y≈0.
const int center_idx =
(static_cast<int>(kCy) * kWidth + static_cast<int>(kCx)) * 3;
EXPECT_NEAR(cloud.data[static_cast<size_t>(center_idx)], 0.0f, 0.05f)
<< "x at image center should be near 0";
EXPECT_NEAR(cloud.data[static_cast<size_t>(center_idx) + 1], 0.0f, 0.05f)
<< "y at image center should be near 0";
}
// ---------------------------------------------------------------------------
// Zero / negative disparity → NaN
// ---------------------------------------------------------------------------
TEST(PointCloudBuilderTest, ZeroDisparityProducesNaN) {
const cv::Mat q = make_q();
score::PointCloudBuilder builder(q);
cv::Mat disparity = cv::Mat::zeros(kHeight, kWidth, CV_16S);
const auto cloud = builder.build(disparity);
EXPECT_EQ(count_valid(cloud), 0)
<< "All points should be NaN when disparity is zero";
EXPECT_EQ(static_cast<int>(cloud.valid_points().size()), 0);
}
TEST(PointCloudBuilderTest, NegativeDisparityProducesNaN) {
const cv::Mat q = make_q();
score::PointCloudBuilder builder(q);
cv::Mat disparity(kHeight, kWidth, CV_16S, cv::Scalar(-16)); // -1 px
const auto cloud = builder.build(disparity);
EXPECT_EQ(count_valid(cloud), 0)
<< "All points should be NaN when disparity is negative";
}
// ---------------------------------------------------------------------------
// Out-of-range depth filter → all NaN
// ---------------------------------------------------------------------------
TEST(PointCloudBuilderTest, MaxDepthFilterRejectsAll) {
const cv::Mat q = make_q();
// kExpectedZ = 1.5 m; set max_depth_m = 1.0 to reject all points.
score::PointCloudBuilder::Options opts;
opts.max_depth_m = 1.0f;
score::PointCloudBuilder builder(q, opts);
constexpr float kDisp = 32.0f; // → z = 1.5 m, which exceeds max
cv::Mat disparity(kHeight, kWidth, CV_16S,
cv::Scalar(static_cast<short>(kDisp * 16)));
const auto cloud = builder.build(disparity);
EXPECT_EQ(count_valid(cloud), 0)
<< "All points should be NaN when z=1.5 m exceeds max_depth_m=1.0 m";
}
TEST(PointCloudBuilderTest, MinDepthFilterRejectsAll) {
const cv::Mat q = make_q();
// kExpectedZ = 1.5 m; set min_depth_m = 2.0 to reject all points.
score::PointCloudBuilder::Options opts;
opts.min_depth_m = 2.0f;
score::PointCloudBuilder builder(q, opts);
constexpr float kDisp = 32.0f; // → z = 1.5 m, below min
cv::Mat disparity(kHeight, kWidth, CV_16S,
cv::Scalar(static_cast<short>(kDisp * 16)));
const auto cloud = builder.build(disparity);
EXPECT_EQ(count_valid(cloud), 0)
<< "All points should be NaN when z=1.5 m is below min_depth_m=2.0 m";
}
// ---------------------------------------------------------------------------
// valid_points() count matches non-NaN count
// ---------------------------------------------------------------------------
TEST(PointCloudBuilderTest, ValidPointsCountMatchesNonNaN) {
const cv::Mat q = make_q();
score::PointCloudBuilder builder(q);
// Half the image has positive disparity, half has zero
cv::Mat disparity(kHeight, kWidth, CV_16S, cv::Scalar(0));
const cv::Rect left_half(0, 0, kWidth / 2, kHeight);
disparity(left_half).setTo(cv::Scalar(32 * 16));
const auto cloud = builder.build(disparity);
const int manual_count = count_valid(cloud);
const int method_count = static_cast<int>(cloud.valid_points().size());
EXPECT_EQ(manual_count, method_count)
<< "valid_points() size must equal the number of non-NaN pixels";
}
// ---------------------------------------------------------------------------
// CV_32F input works identically
// ---------------------------------------------------------------------------
TEST(PointCloudBuilderTest, CV32FInputMatchesCV16S) {
const cv::Mat q = make_q();
score::PointCloudBuilder builder(q);
constexpr float kDisp = 32.0f;
// CV_16S reference
cv::Mat disp16(kHeight, kWidth, CV_16S,
cv::Scalar(static_cast<short>(kDisp * 16)));
const auto cloud16 = builder.build(disp16);
// CV_32F equivalent
cv::Mat disp32(kHeight, kWidth, CV_32F, cv::Scalar(kDisp));
const auto cloud32 = builder.build(disp32);
ASSERT_EQ(cloud16.data.size(), cloud32.data.size());
// Every z-coordinate should match within floating-point tolerance
for (int i = 0; i < kHeight * kWidth; ++i) {
const float z16 = cloud16.data[static_cast<size_t>(i) * 3 + 2];
const float z32 = cloud32.data[static_cast<size_t>(i) * 3 + 2];
EXPECT_NEAR(z16, z32, 1e-3f) << "at pixel " << i;
}
}
// ---------------------------------------------------------------------------
// Unsupported disparity type → throws
// ---------------------------------------------------------------------------
TEST(PointCloudBuilderTest, CV8UInputThrows) {
const cv::Mat q = make_q();
score::PointCloudBuilder builder(q);
cv::Mat bad_disparity(kHeight, kWidth, CV_8U, cv::Scalar(32));
EXPECT_THROW(builder.build(bad_disparity), std::invalid_argument);
}
// ---------------------------------------------------------------------------
// Invalid Q matrix → constructor throws
// ---------------------------------------------------------------------------
TEST(PointCloudBuilderTest, InvalidQSizeThrows) {
cv::Mat bad_q = cv::Mat::eye(3, 4, CV_64F); // not 4x4
EXPECT_THROW(score::PointCloudBuilder{bad_q}, std::invalid_argument);
}
TEST(PointCloudBuilderTest, InvalidQTypeThrows) {
cv::Mat bad_q = cv::Mat::eye(4, 4, CV_32F); // not CV_64F
EXPECT_THROW(score::PointCloudBuilder{bad_q}, std::invalid_argument);
}

View File

@ -1,101 +0,0 @@
#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);
}

View File

@ -7,7 +7,7 @@
#include <vector> #include <vector>
using json = nlohmann::json; using json = nlohmann::json;
using namespace score; using namespace cloud_point_rpc;
class RpcServerTest : public ::testing::Test { class RpcServerTest : public ::testing::Test {
protected: protected:
@ -15,9 +15,6 @@ class RpcServerTest : public ::testing::Test {
Service service; Service service;
void SetUp() override { void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestCli");
server.register_method("get-intrinsic-params", [&](const json &) { server.register_method("get-intrinsic-params", [&](const json &) {
return service.get_intrinsic_params(); return service.get_intrinsic_params();
}); });
@ -64,16 +61,3 @@ TEST_F(RpcServerTest, InvalidJsonReturnsParseError) {
ASSERT_TRUE(response.contains("error")); ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32700); EXPECT_EQ(response["error"]["code"], -32700);
} }
TEST_F(RpcServerTest, GetMethod) {
EXPECT_EQ(server.get_count(), 2);
EXPECT_EQ(server.get_method_name_by_id(1), "get-intrinsic-params");
EXPECT_EQ(server.get_method_names()[1], "get-intrinsic-params");
server.register_method("get-test-2", [&](const json &) {
return std::variant<json, std::string>{std::string("test")};
});
EXPECT_EQ(server.get_count(), 3);
EXPECT_EQ(server.get_method_name_by_id(2), "get-test-2");
EXPECT_EQ(server.get_method_names()[2], "get-test-2");
}

View File

@ -1,257 +0,0 @@
#include "cloud_point_rpc/rpc_server.hpp"
#include "server_api.h"
#include <glog/logging.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
#include <string>
#include <thread>
#include <vector>
using json = nlohmann::json;
using namespace score;
class RpcServerEdgeCaseTest : public ::testing::Test {
protected:
RpcServer server;
void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestCli");
server.register_method(
"echo", [&](const json &j) { return j.get<std::string>(); });
server.register_method("thrower", [&](const json &) -> std::string {
throw std::runtime_error("intentional error");
});
}
};
// Empty request string
TEST_F(RpcServerEdgeCaseTest, EmptyRequestReturnsParseError) {
std::string response_str = server.process("");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32700);
}
// Valid JSON but primitive types (not object)
// NOTE: These currently throw nlohmann::json::type_error instead of returning
// Invalid Request. This documents a known bug in type validation.
TEST_F(RpcServerEdgeCaseTest, JsonArrayThrowsTypeError) {
EXPECT_THROW(server.process(R"([1, 2, 3])"), nlohmann::json::type_error);
}
TEST_F(RpcServerEdgeCaseTest, JsonStringThrowsTypeError) {
EXPECT_THROW(server.process(R"("just a string")"),
nlohmann::json::type_error);
}
TEST_F(RpcServerEdgeCaseTest, JsonNumberThrowsTypeError) {
EXPECT_THROW(server.process("42"), nlohmann::json::type_error);
}
TEST_F(RpcServerEdgeCaseTest, JsonNullThrowsTypeError) {
EXPECT_THROW(server.process("null"), nlohmann::json::type_error);
}
// Missing required fields
TEST_F(RpcServerEdgeCaseTest, MissingJsonrpcField) {
std::string response_str = server.process(R"({"method": "echo", "id": 1})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32600);
}
TEST_F(RpcServerEdgeCaseTest, WrongJsonrpcVersion) {
std::string response_str =
server.process(R"({"jsonrpc": "1.0", "method": "echo", "id": 1})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32600);
}
TEST_F(RpcServerEdgeCaseTest, MissingMethodField) {
std::string response_str = server.process(R"({"jsonrpc": "2.0", "id": 1})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32600);
}
TEST_F(RpcServerEdgeCaseTest, MissingIdField) {
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "echo"})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32600);
}
// Method field type validation
// NOTE: These currently throw nlohmann::json::type_error instead of returning
// Invalid Request. This documents a known bug in type validation.
TEST_F(RpcServerEdgeCaseTest, MethodIsNumberThrowsTypeError) {
EXPECT_THROW(
server.process(R"({"jsonrpc": "2.0", "method": 123, "id": 1})"),
nlohmann::json::type_error);
}
TEST_F(RpcServerEdgeCaseTest, MethodIsNullThrowsTypeError) {
EXPECT_THROW(
server.process(R"({"jsonrpc": "2.0", "method": null, "id": 1})"),
nlohmann::json::type_error);
}
TEST_F(RpcServerEdgeCaseTest, MethodIsArrayThrowsTypeError) {
EXPECT_THROW(
server.process(R"({"jsonrpc": "2.0", "method": ["echo"], "id": 1})"),
nlohmann::json::type_error);
}
TEST_F(RpcServerEdgeCaseTest, MethodIsObjectThrowsTypeError) {
EXPECT_THROW(
server.process(
R"({"jsonrpc": "2.0", "method": {"name": "echo"}, "id": 1})"),
nlohmann::json::type_error);
}
// Handler exceptions
TEST_F(RpcServerEdgeCaseTest, HandlerThrowsReturnsServerError) {
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "thrower", "id": 42})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32000);
// Should not leak internal details ideally, but current impl does
EXPECT_EQ(response["error"]["message"], "intentional error");
}
// Valid request with params
TEST_F(RpcServerEdgeCaseTest, RequestWithParams) {
server.register_method("add", [&](const json &j) {
return j.at("a").get<int>() + j.at("b").get<int>();
});
std::string response_str = server.process(
R"({"jsonrpc": "2.0", "method": "add", "id": 1, "params": {"a": 2, "b": 3}})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("result"));
EXPECT_EQ(response["result"], 5);
}
// Request with empty params object
// The echo handler expects a string but gets an empty object, so it throws.
TEST_F(RpcServerEdgeCaseTest, RequestWithEmptyParamsHandlerThrows) {
std::string response_str = server.process(
R"({"jsonrpc": "2.0", "method": "echo", "id": 1, "params": {}})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32000);
}
// C callback edge cases
// NOTE: When the C callback returns nullptr, the wrapper returns {} which
// value-initializes the variant's first alternative (json null). This is a
// bug: it should throw to trigger a proper error response.
TEST_F(RpcServerEdgeCaseTest, CCallbackReturnsNullProducesNullResult) {
server.register_method(
"null_cb", [](rpc_string *) -> rpc_string * { return nullptr; });
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "null_cb", "id": 1})");
json response = json::parse(response_str);
// Current behavior: returns success with null result due to variant
// value-initialization bug
ASSERT_TRUE(response.contains("result"));
EXPECT_TRUE(response["result"].is_null());
}
TEST_F(RpcServerEdgeCaseTest, CCallbackReturnsNonJsonString) {
server.register_method("raw_cb", [](rpc_string *) -> rpc_string * {
return crpc_str_create("hello world", 11);
});
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "raw_cb", "id": 1})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("result"));
EXPECT_EQ(response["result"], "hello world");
}
TEST_F(RpcServerEdgeCaseTest, CCallbackReturnsValidJson) {
server.register_method("json_cb", [](rpc_string *) -> rpc_string * {
return crpc_str_create(R"({"key": "value"})", 16);
});
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "json_cb", "id": 1})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("result"));
EXPECT_EQ(response["result"]["key"], "value");
}
// Thread safety: concurrent register and process
TEST_F(RpcServerEdgeCaseTest, ConcurrentRegisterAndProcess) {
constexpr int kIterations = 100;
std::atomic<int> success_count{0};
std::thread registrar([&]() {
for (int i = 0; i < kIterations; ++i) {
server.register_method("dyn_" + std::to_string(i),
[&](const json &j) { return j.get<int>(); });
}
});
std::thread processor([&]() {
for (int i = 0; i < kIterations; ++i) {
std::string req = R"({"jsonrpc": "2.0", "method": "echo", "id": )" +
std::to_string(i) + "}";
try {
auto res = server.process(req);
if (!res.empty())
++success_count;
} catch (...) {
// ignore races
}
}
});
registrar.join();
processor.join();
EXPECT_EQ(success_count, kIterations);
}
// Unicode and special characters in method name
TEST_F(RpcServerEdgeCaseTest, UnicodeMethodNameNotFound) {
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "метод", "id": 1})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32601);
}
TEST_F(RpcServerEdgeCaseTest, MethodWithNewlineNotFound) {
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "echo\n", "id": 1})");
json response = json::parse(response_str);
ASSERT_TRUE(response.contains("error"));
EXPECT_EQ(response["error"]["code"], -32601);
}
// Id edge cases
TEST_F(RpcServerEdgeCaseTest, StringIdPreserved) {
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "echo", "id": "abc"})");
json response = json::parse(response_str);
EXPECT_EQ(response["id"], "abc");
}
TEST_F(RpcServerEdgeCaseTest, NullIdPreserved) {
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "echo", "id": null})");
json response = json::parse(response_str);
EXPECT_TRUE(response["id"].is_null());
}
TEST_F(RpcServerEdgeCaseTest, ZeroIdPreserved) {
std::string response_str =
server.process(R"({"jsonrpc": "2.0", "method": "echo", "id": 0})");
json response = json::parse(response_str);
EXPECT_EQ(response["id"], 0);
}

View File

@ -1,147 +0,0 @@
/// @file test_scared_dataset.cpp
/// @brief E2E test: in-process server backed by SCARED dataset + CloudPointClient.
///
/// Skipped unless env var SCARED_KEYFRAME_DIR is set (CI has no dataset).
/// Run locally:
/// SCARED_KEYFRAME_DIR=/path/to/test_dataset_8/keyframe_0 \
/// ./build/tests/unit_tests --gtest_filter=ScaredDataset*
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <string>
#include <thread>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
#include "cloud_point/cloud_point_client.hpp"
#include "cloud_point/scared_dataset_loader.hpp"
#include "cloud_point_rpc/rpc_dto.hpp"
#include "cloud_point_rpc/rpc_server.hpp"
#include "cloud_point_rpc/tcp_server.hpp"
using namespace score;
using json = nlohmann::json;
// ---------------------------------------------------------------------------
// Fixture: in-process TcpServer + RpcServer backed by the SCARED loader
// ---------------------------------------------------------------------------
class ScaredDatasetTest : public ::testing::Test {
protected:
void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestScaredDataset");
const char *env = std::getenv("SCARED_KEYFRAME_DIR");
if (!env || std::string(env).empty()) {
GTEST_SKIP() << "SCARED_KEYFRAME_DIR not set; "
"skipping SCARED E2E test";
}
keyframe_dir_ = env;
}
void TearDown() override {
if (server_) {
server_->stop();
}
}
void start_server(int port, std::unique_ptr<RpcServer> rpc) {
rpc_server_ = std::move(rpc);
server_ = std::make_unique<TcpServer>(
"127.0.0.1", port,
[this](const std::string &req) {
return rpc_server_->process(req);
});
server_->start();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
std::string keyframe_dir_;
std::unique_ptr<RpcServer> rpc_server_;
std::unique_ptr<TcpServer> server_;
};
// ---------------------------------------------------------------------------
// Test: cloud non-empty and median z within plausible endoscopy range
// ---------------------------------------------------------------------------
TEST_F(ScaredDatasetTest, ComputeCloudFromRealData) {
constexpr int kPort = 9301;
// SCARED rig: fx~1024, B~4.35 mm -> max disparity ~160 needed
constexpr int kNumDisparities = 160;
// Expected depth range for endoscopy: 20 mm - 200 mm
constexpr float kMinExpectedZ = 0.02f;
constexpr float kMaxExpectedZ = 0.20f;
// Minimum valid points for a non-trivial cloud
constexpr size_t kMinValidPts = 50'000;
ScaredDatasetLoader loader(keyframe_dir_);
std::atomic<uint64_t> frame_counter{0};
auto rpc = std::make_unique<RpcServer>();
rpc->register_method(
"get-stereo-calibration", [&](const json &) -> json {
json j;
to_json(j, loader.calibration());
return j;
});
rpc->register_method(
"get-image-pair", [&](const json &) -> json {
json j;
to_json(j, loader.image_pair(frame_counter++));
return j;
});
start_server(kPort, std::move(rpc));
CloudPointClient client("127.0.0.1", kPort, StereoAlgorithmType::CPU,
PointCloudBuilder::Options{}, kNumDisparities);
ASSERT_NO_THROW(client.connect());
ASSERT_TRUE(client.connected());
auto result = client.compute_cloud();
ASSERT_TRUE(result.has_value())
<< "compute_cloud returned Error: " << result.error().message;
const auto &cloud = *result;
const auto valid_points = cloud.valid_points();
EXPECT_GE(valid_points.size(), kMinValidPts)
<< "Expected >" << kMinValidPts << " valid points, got "
<< valid_points.size();
// Collect z values and compute median.
std::vector<float> z_vals;
z_vals.reserve(valid_points.size());
for (const auto &pt : valid_points) {
z_vals.push_back(pt[2]);
}
ASSERT_FALSE(z_vals.empty()) << "No valid points in cloud";
const auto mid =
z_vals.begin() + static_cast<ptrdiff_t>(z_vals.size() / 2);
std::nth_element(z_vals.begin(), mid, z_vals.end());
const float median_z = *mid;
// Report for the task summary.
std::cout << "[SCARED] valid_points=" << valid_points.size()
<< " median_z=" << median_z << " m\n";
EXPECT_GE(median_z, kMinExpectedZ)
<< "Median z " << median_z
<< " m is below minimum expected " << kMinExpectedZ
<< " m (check mm->m conversion)";
EXPECT_LE(median_z, kMaxExpectedZ)
<< "Median z " << median_z
<< " m exceeds maximum expected " << kMaxExpectedZ
<< " m (check mm->m conversion: T must be divided by 1000)";
}

View File

@ -1,79 +0,0 @@
#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);
}

View File

@ -1,104 +0,0 @@
#include "cloud_point_rpc/serialize.hpp"
#include <glog/logging.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <limits>
using namespace score;
class SerializeEdgeCaseTest : public ::testing::Test {
protected:
void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestRPC");
}
};
// uint8_t round-trip
TEST_F(SerializeEdgeCaseTest, Uint8RoundTrip) {
uint8_t value = 42;
auto buf = serialize(value);
EXPECT_EQ(buf.size(), sizeof(uint8_t));
EXPECT_EQ(deserialize<uint8_t>(buf), value);
}
// int32_t round-trip
TEST_F(SerializeEdgeCaseTest, Int32RoundTrip) {
int32_t value = -12345;
auto buf = serialize(value);
EXPECT_EQ(buf.size(), sizeof(int32_t));
EXPECT_EQ(deserialize<int32_t>(buf), value);
}
// uint64_t round-trip with max value
TEST_F(SerializeEdgeCaseTest, Uint64MaxRoundTrip) {
uint64_t value = std::numeric_limits<uint64_t>::max();
auto buf = serialize(value);
EXPECT_EQ(buf.size(), sizeof(uint64_t));
EXPECT_EQ(deserialize<uint64_t>(buf), value);
}
// int64_t round-trip with min value
TEST_F(SerializeEdgeCaseTest, Int64MinRoundTrip) {
int64_t value = std::numeric_limits<int64_t>::min();
auto buf = serialize(value);
EXPECT_EQ(buf.size(), sizeof(int64_t));
EXPECT_EQ(deserialize<int64_t>(buf), value);
}
// float round-trip
TEST_F(SerializeEdgeCaseTest, FloatRoundTrip) {
float value = 3.14159f;
auto buf = serialize(value);
EXPECT_EQ(buf.size(), sizeof(float));
EXPECT_FLOAT_EQ(deserialize<float>(buf), value);
}
// double round-trip
TEST_F(SerializeEdgeCaseTest, DoubleRoundTrip) {
double value = 2.718281828459045;
auto buf = serialize(value);
EXPECT_EQ(buf.size(), sizeof(double));
EXPECT_DOUBLE_EQ(deserialize<double>(buf), value);
}
// zero values
TEST_F(SerializeEdgeCaseTest, ZeroValues) {
EXPECT_EQ(deserialize<uint64_t>(serialize<uint64_t>(0)), 0);
EXPECT_EQ(deserialize<int32_t>(serialize<int32_t>(0)), 0);
EXPECT_FLOAT_EQ(deserialize<float>(serialize<float>(0.0f)), 0.0f);
EXPECT_DOUBLE_EQ(deserialize<double>(serialize<double>(0.0)), 0.0);
}
// inplace_size_embedding
TEST_F(SerializeEdgeCaseTest, InplaceSizeEmbedding) {
std::string msg = "Hello";
inplace_size_embedding(msg);
EXPECT_EQ(msg.size(), 5 + sizeof(uint64_t));
// First 8 bytes should be the size (5)
uint64_t size = deserialize<uint64_t>(
std::vector<uint8_t>(msg.begin(), msg.begin() + sizeof(uint64_t)));
EXPECT_EQ(size, 5);
// Remaining bytes should be the message
EXPECT_EQ(msg.substr(sizeof(uint64_t)), "Hello");
}
TEST_F(SerializeEdgeCaseTest, InplaceSizeEmbeddingEmpty) {
std::string msg;
inplace_size_embedding(msg);
EXPECT_EQ(msg.size(), sizeof(uint64_t));
uint64_t size = deserialize<uint64_t>(
std::vector<uint8_t>(msg.begin(), msg.begin() + sizeof(uint64_t)));
EXPECT_EQ(size, 0);
}
// Buffer too small for deserialize (unsafe but should not crash in test)
TEST_F(SerializeEdgeCaseTest, DeserializeSmallBuffer) {
std::vector<uint8_t> small_buf{0x01, 0x02};
// This is undefined behavior in current implementation, but we document it
// In a hardened implementation, this should throw
// For now, just verify it compiles and runs (it's unsafe API usage)
// EXPECT_THROW(deserialize<uint64_t>(small_buf), std::runtime_error);
(void)small_buf;
}

View File

@ -1,303 +0,0 @@
// Unit and in-process integration tests for rpc_dto.hpp serialisation.
// No OpenCV dependency — safe to compile unconditionally.
#include "cloud_point_rpc/rpc_dto.hpp"
#include "cloud_point_rpc/rpc_server.hpp"
#include "cloud_point_rpc/service.hpp"
#include <glog/logging.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
#include <stdexcept>
using namespace score;
using json = nlohmann::json;
class SerializeImageTest : public ::testing::Test {
protected:
void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("SerializeImageTest");
}
};
// ---------------------------------------------------------------------------
// ImageRPC round-trip — binary data with zero bytes and 255s
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, ImageRPCRoundTrip) {
ImageRPC img;
img.width = 2;
img.height = 2;
img.type = ImageRPC::Type::BGR;
// 4 pixels x 3 channels = 12 bytes, including 0s and 255s
img.data = {0, 255, 0, 255, 0, 255, 0, 0, 0, 255, 255, 255};
json j;
to_json(j, img);
EXPECT_EQ(j["width"].get<int>(), 2);
EXPECT_EQ(j["height"].get<int>(), 2);
EXPECT_EQ(j["type"].get<std::string>(), "BGR");
EXPECT_TRUE(j["data"].is_string());
ImageRPC decoded;
from_json(j, decoded);
EXPECT_EQ(decoded.width, img.width);
EXPECT_EQ(decoded.height, img.height);
EXPECT_EQ(decoded.type, img.type);
EXPECT_EQ(decoded.data, img.data);
}
// ---------------------------------------------------------------------------
// ImageRPC — unknown type string throws
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, ImageRPCUnknownTypeThrows) {
json j = {{"width", 1}, {"height", 1}, {"type", "XYZ"}, {"data", "AAAA"}};
ImageRPC img;
EXPECT_THROW(from_json(j, img), std::runtime_error);
}
// ---------------------------------------------------------------------------
// ImageRPC — RGBA type string survives round-trip
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, ImageRPCRGBARoundTrip) {
ImageRPC img;
img.width = 1;
img.height = 1;
img.type = ImageRPC::Type::RGBA;
img.data = {10, 20, 30, 40};
json j;
to_json(j, img);
EXPECT_EQ(j["type"].get<std::string>(), "RGBA");
ImageRPC out;
from_json(j, out);
EXPECT_EQ(out.type, ImageRPC::Type::RGBA);
EXPECT_EQ(out.data, img.data);
}
// ---------------------------------------------------------------------------
// ImageRPC — DEPTH type string survives round-trip
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, ImageRPCDEPTHRoundTrip) {
ImageRPC img;
img.width = 1;
img.height = 1;
img.type = ImageRPC::Type::DEPTH;
img.data = {0x3f, 0x80, 0x00, 0x00}; // 1.0f as little-endian float32
json j;
to_json(j, img);
EXPECT_EQ(j["type"].get<std::string>(), "DEPTH");
ImageRPC out;
from_json(j, out);
EXPECT_EQ(out.type, ImageRPC::Type::DEPTH);
EXPECT_EQ(out.data, img.data);
}
// ---------------------------------------------------------------------------
// CameraCalib round-trip
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, CameraCalibRoundTrip) {
CameraCalib c;
c.camera_matrix = {800, 0, 320, 0, 800, 240, 0, 0, 1};
c.dist_coeffs = {0.1, 0.2, 0.0, 0.0, 0.05};
json j;
to_json(j, c);
CameraCalib out;
from_json(j, out);
EXPECT_EQ(out.camera_matrix, c.camera_matrix);
EXPECT_EQ(out.dist_coeffs, c.dist_coeffs);
}
// ---------------------------------------------------------------------------
// CameraCalib — wrong camera_matrix size (8 elements) → throws
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, CameraCalibWrongCameraMatrixSizeThrows) {
json j = {{"camera_matrix", {1, 2, 3, 4, 5, 6, 7, 8}},
{"dist_coeffs", {0, 0, 0, 0, 0}}};
CameraCalib c;
EXPECT_THROW(from_json(j, c), std::runtime_error);
}
// ---------------------------------------------------------------------------
// CameraCalib — wrong dist_coeffs size → throws
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, CameraCalibWrongDistCoeffsSizeThrows) {
json j = {{"camera_matrix", {800, 0, 320, 0, 800, 240, 0, 0, 1}},
{"dist_coeffs", {0, 0, 0}}};
CameraCalib c;
EXPECT_THROW(from_json(j, c), std::runtime_error);
}
// ---------------------------------------------------------------------------
// StereoCalibrationRPC round-trip
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, StereoCalibrationRoundTrip) {
StereoCalibrationRPC calib;
calib.left.camera_matrix = {800, 0, 320, 0, 800, 240, 0, 0, 1};
calib.left.dist_coeffs = {0, 0, 0, 0, 0};
calib.right = calib.left;
calib.rotation = {1, 0, 0, 0, 1, 0, 0, 0, 1};
calib.translation = {-0.06, 0.0, 0.0};
calib.width = 640;
calib.height = 480;
json j;
to_json(j, calib);
StereoCalibrationRPC out;
from_json(j, out);
EXPECT_EQ(out.left.camera_matrix, calib.left.camera_matrix);
EXPECT_EQ(out.right.dist_coeffs, calib.right.dist_coeffs);
EXPECT_EQ(out.rotation, calib.rotation);
EXPECT_DOUBLE_EQ(out.translation[0], -0.06);
EXPECT_DOUBLE_EQ(out.translation[1], 0.0);
EXPECT_DOUBLE_EQ(out.translation[2], 0.0);
EXPECT_EQ(out.width, 640);
EXPECT_EQ(out.height, 480);
}
// ---------------------------------------------------------------------------
// StereoCalibrationRPC — wrong rotation size → throws
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, StereoCalibWrongRotationSizeThrows) {
// rotation has only 5 elements — must throw
json j = {{"left",
{{"camera_matrix", {800, 0, 320, 0, 800, 240, 0, 0, 1}},
{"dist_coeffs", {0, 0, 0, 0, 0}}}},
{"right",
{{"camera_matrix", {800, 0, 320, 0, 800, 240, 0, 0, 1}},
{"dist_coeffs", {0, 0, 0, 0, 0}}}},
{"rotation", {1, 0, 0, 0, 1}},
{"translation", {-0.06, 0, 0}},
{"image_size", {{"width", 640}, {"height", 480}}}};
StereoCalibrationRPC calib;
EXPECT_THROW(from_json(j, calib), std::runtime_error);
}
// ---------------------------------------------------------------------------
// StereoCalibrationRPC — wrong translation size → throws
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, StereoCalibWrongTranslationSizeThrows) {
json j = {{"left",
{{"camera_matrix", {800, 0, 320, 0, 800, 240, 0, 0, 1}},
{"dist_coeffs", {0, 0, 0, 0, 0}}}},
{"right",
{{"camera_matrix", {800, 0, 320, 0, 800, 240, 0, 0, 1}},
{"dist_coeffs", {0, 0, 0, 0, 0}}}},
{"rotation", {1, 0, 0, 0, 1, 0, 0, 0, 1}},
{"translation", {-0.06}}, // only 1 element — wrong
{"image_size", {{"width", 640}, {"height", 480}}}};
StereoCalibrationRPC calib;
EXPECT_THROW(from_json(j, calib), std::runtime_error);
}
// ---------------------------------------------------------------------------
// ImagePairRPC round-trip
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, ImagePairRPCRoundTrip) {
ImagePairRPC pair;
pair.frame = 42;
pair.left.width = 2;
pair.left.height = 1;
pair.left.type = ImageRPC::Type::BGR;
pair.left.data = {0, 128, 255, 10, 20, 30};
pair.right = pair.left;
pair.right.data = {5, 6, 7, 8, 9, 10};
json j;
to_json(j, pair);
ImagePairRPC out;
from_json(j, out);
EXPECT_EQ(out.frame, 42u);
EXPECT_EQ(out.left.data, pair.left.data);
EXPECT_EQ(out.right.data, pair.right.data);
EXPECT_EQ(out.left.type, ImageRPC::Type::BGR);
}
// ---------------------------------------------------------------------------
// In-process integration: Service -> RpcServer -> process() -> from_json
// ---------------------------------------------------------------------------
TEST_F(SerializeImageTest, ServiceStereoCalibrationViaRpcServer) {
Service service;
RpcServer rpc;
rpc.register_method("get-stereo-calibration", [&](const json &) -> json {
json j;
score::to_json(j, service.get_stereo_calibration());
return j;
});
const std::string request =
R"({"jsonrpc":"2.0","method":"get-stereo-calibration","id":1})";
const std::string response_str = rpc.process(request);
auto resp = json::parse(response_str);
ASSERT_TRUE(resp.contains("result")) << "Response was: " << response_str;
StereoCalibrationRPC calib;
from_json(resp["result"], calib);
EXPECT_DOUBLE_EQ(calib.left.camera_matrix[0], 800.0); // fx
EXPECT_DOUBLE_EQ(calib.left.camera_matrix[4], 800.0); // fy
EXPECT_DOUBLE_EQ(calib.left.camera_matrix[2], 320.0); // cx
EXPECT_DOUBLE_EQ(calib.left.camera_matrix[5], 240.0); // cy
EXPECT_DOUBLE_EQ(calib.translation[0], -0.06);
EXPECT_DOUBLE_EQ(calib.translation[1], 0.0);
EXPECT_EQ(calib.width, 640);
EXPECT_EQ(calib.height, 480);
// Rotation should be identity
EXPECT_DOUBLE_EQ(calib.rotation[0], 1.0);
EXPECT_DOUBLE_EQ(calib.rotation[4], 1.0);
EXPECT_DOUBLE_EQ(calib.rotation[8], 1.0);
}
TEST_F(SerializeImageTest, ServiceImagePairViaRpcServer) {
Service service;
RpcServer rpc;
rpc.register_method("get-image-pair", [&](const json &) -> json {
json j;
score::to_json(j, service.get_image_pair());
return j;
});
const std::string request =
R"({"jsonrpc":"2.0","method":"get-image-pair","id":1})";
const std::string response_str = rpc.process(request);
auto resp = json::parse(response_str);
ASSERT_TRUE(resp.contains("result")) << "Response was: " << response_str;
ImagePairRPC pair;
from_json(resp["result"], pair);
EXPECT_EQ(pair.frame, 0u);
EXPECT_EQ(pair.left.width, 640);
EXPECT_EQ(pair.left.height, 480);
EXPECT_EQ(pair.left.type, ImageRPC::Type::BGR);
EXPECT_EQ(pair.right.width, 640);
EXPECT_EQ(pair.right.height, 480);
// Pixel at (x=0, y=0): left = (0+0)%256 = 0
EXPECT_EQ(pair.left.data[0], static_cast<unsigned char>(0));
// Pixel at (x=0, y=0): right = (0+8+0)%256 = 8
EXPECT_EQ(pair.right.data[0], static_cast<unsigned char>(8));
// Second call: frame counter should increment
const std::string request2 =
R"({"jsonrpc":"2.0","method":"get-image-pair","id":2})";
const std::string response_str2 = rpc.process(request2);
auto resp2 = json::parse(response_str2);
ImagePairRPC pair2;
from_json(resp2["result"], pair2);
EXPECT_EQ(pair2.frame, 1u);
}

View File

@ -1,121 +0,0 @@
#include "cloud_point_rpc/service.hpp"
#include <gmock/gmock.h>
#include <gtest/gtest.h>
using namespace score;
class ServiceEdgeCaseTest : public ::testing::Test {
protected:
void SetUp() override {
FLAGS_logtostderr = true;
if (!google::IsGoogleLoggingInitialized())
google::InitGoogleLogging("TestRPC");
}
};
// Default constructor (no data)
TEST_F(ServiceEdgeCaseTest, DefaultConstructorFallbacks) {
Service service;
auto intrinsic = service.get_intrinsic_params();
EXPECT_EQ(intrinsic.size(), 9);
EXPECT_EQ(intrinsic[0], 1.0);
EXPECT_EQ(intrinsic[4], 1.0);
EXPECT_EQ(intrinsic[8], 1.0);
auto extrinsic = service.get_extrinsic_params();
EXPECT_EQ(extrinsic.size(), 16);
EXPECT_EQ(extrinsic[0], 1.0);
EXPECT_EQ(extrinsic[5], 1.0);
EXPECT_EQ(extrinsic[10], 1.0);
EXPECT_EQ(extrinsic[15], 1.0);
auto cloud = service.get_cloud_point();
EXPECT_EQ(cloud.size(), 3);
EXPECT_EQ(cloud[0], std::vector<double>({0.1, 0.2, 0.3}));
}
// Empty TestData explicitly
TEST_F(ServiceEdgeCaseTest, ExplicitEmptyData) {
TestData empty_data;
Service service(empty_data);
auto intrinsic = service.get_intrinsic_params();
EXPECT_EQ(intrinsic.size(), 9);
auto extrinsic = service.get_extrinsic_params();
EXPECT_EQ(extrinsic.size(), 16);
auto cloud = service.get_cloud_point();
EXPECT_EQ(cloud.size(), 3);
}
// Custom intrinsic params
TEST_F(ServiceEdgeCaseTest, CustomIntrinsicParams) {
TestData data;
data.intrinsic_params = {100.0, 0.0, 50.0, 0.0, 100.0, 50.0, 0.0, 0.0, 1.0};
Service service(data);
auto intrinsic = service.get_intrinsic_params();
EXPECT_EQ(intrinsic, data.intrinsic_params);
}
// Custom extrinsic params
TEST_F(ServiceEdgeCaseTest, CustomExtrinsicParams) {
TestData data;
data.extrinsic_params = {1, 0, 0, 1, 0, 1, 0, 2, 0, 0, 1, 3, 0, 0, 0, 1};
Service service(data);
auto extrinsic = service.get_extrinsic_params();
EXPECT_EQ(extrinsic, data.extrinsic_params);
}
// Custom cloud point
TEST_F(ServiceEdgeCaseTest, CustomCloudPoint) {
TestData data;
data.cloud_point = {{1.0, 2.0, 3.0}, {4.0, 5.0, 6.0}};
Service service(data);
auto cloud = service.get_cloud_point();
EXPECT_EQ(cloud.size(), 2);
EXPECT_EQ(cloud[0], std::vector<double>({1.0, 2.0, 3.0}));
EXPECT_EQ(cloud[1], std::vector<double>({4.0, 5.0, 6.0}));
}
// Large point cloud
TEST_F(ServiceEdgeCaseTest, LargePointCloud) {
TestData data;
for (int i = 0; i < 10000; ++i) {
data.cloud_point.push_back({static_cast<double>(i),
static_cast<double>(i + 1),
static_cast<double>(i + 2)});
}
Service service(data);
auto cloud = service.get_cloud_point();
EXPECT_EQ(cloud.size(), 10000);
EXPECT_EQ(cloud[9999], std::vector<double>({9999.0, 10000.0, 10001.0}));
}
// Single point cloud
TEST_F(ServiceEdgeCaseTest, SinglePointCloud) {
TestData data;
data.cloud_point = {{0.0, 0.0, 0.0}};
Service service(data);
auto cloud = service.get_cloud_point();
EXPECT_EQ(cloud.size(), 1);
EXPECT_EQ(cloud[0], std::vector<double>({0.0, 0.0, 0.0}));
}
// Negative values
TEST_F(ServiceEdgeCaseTest, NegativeValues) {
TestData data;
data.intrinsic_params = {-100.0, 0.0, -50.0, 0.0, -100.0,
-50.0, 0.0, 0.0, -1.0};
Service service(data);
auto intrinsic = service.get_intrinsic_params();
EXPECT_EQ(intrinsic[0], -100.0);
EXPECT_EQ(intrinsic[8], -1.0);
}

View File

@ -1,162 +0,0 @@
#include <cmath>
#include <gtest/gtest.h>
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include <tuple>
#include "cloud_point/cpu_stereo_matcher.hpp"
#include "cloud_point/gpu_stereo_matcher.hpp"
#include "cloud_point/stereo_matcher_factory.hpp"
using namespace score;
namespace {
/// @brief Create a simple synthetic stereo pair with a horizontal shift.
std::pair<cv::Mat, cv::Mat> make_synthetic_stereo(int shift = 2) {
cv::Mat left = cv::Mat::zeros(100, 100, CV_8UC1);
cv::Mat right = cv::Mat::zeros(100, 100, CV_8UC1);
for (int y = 0; y < 100; ++y) {
for (int x = 0; x < 100; ++x) {
left.at<uchar>(y, x) = static_cast<uchar>(x % 256);
right.at<uchar>(y, x) = static_cast<uchar>((x + shift) % 256);
}
}
return {left, right};
}
} // namespace
TEST(StereoMatcherTest, CpuMatcherComputesDisparity) {
auto [left, right] = make_synthetic_stereo();
CpuStereoMatcher matcher;
cv::Mat disparity = matcher.compute(left, right);
EXPECT_FALSE(disparity.empty());
EXPECT_EQ(disparity.rows, left.rows);
EXPECT_EQ(disparity.cols, left.cols);
}
TEST(StereoMatcherTest, FactoryCpuCreatesNonNull) {
auto matcher = StereoMatcherFactory::create(StereoAlgorithmType::CPU);
ASSERT_NE(matcher, nullptr);
auto [left, right] = make_synthetic_stereo();
cv::Mat disparity = matcher->compute(left, right);
EXPECT_FALSE(disparity.empty());
}
TEST(StereoMatcherTest, CpuMatcherRejectsEvenBlockSize) {
CpuStereoMatcher::Params params;
params.block_size = 4;
EXPECT_THROW(CpuStereoMatcher(0, 64, params), std::invalid_argument);
}
TEST(StereoMatcherTest, CpuMatcherRejectsBadMedianKernel) {
CpuStereoMatcher::Params params;
params.median_kernel = 4;
EXPECT_THROW(CpuStereoMatcher(0, 64, params), std::invalid_argument);
}
TEST(StereoMatcherTest, WlsKeepsSgbmHolesInvalid) {
if (!CpuStereoMatcher::wls_available())
GTEST_SKIP() << "OpenCV built without ximgproc";
// Left 64 px of the right image have no counterpart -> SGBM leaves the
// left border invalid; WLS must not fill it with extrapolated values.
cv::Mat left(120, 200, CV_8UC1);
cv::randu(left, 0, 255);
cv::blur(left, left, cv::Size(3, 3));
cv::Mat right = cv::Mat::zeros(left.size(), CV_8UC1);
left(cv::Rect(8, 0, left.cols - 8, left.rows))
.copyTo(right(cv::Rect(0, 0, left.cols - 8, left.rows)));
CpuStereoMatcher::Params raw;
raw.wls_filter = false;
CpuStereoMatcher::Params wls;
wls.wls_filter = true;
cv::Mat d_raw = CpuStereoMatcher(0, 64, raw).compute(left, right);
cv::Mat d_wls = CpuStereoMatcher(0, 64, wls).compute(left, right);
ASSERT_EQ(d_wls.type(), CV_16S);
ASSERT_EQ(d_wls.size(), d_raw.size());
const int raw_invalid = cv::countNonZero(d_raw <= 0);
EXPECT_GT(raw_invalid, 0);
// No SGBM hole may be filled with an extrapolated disparity.
const int filled = cv::countNonZero((d_raw <= 0) & (d_wls > 0));
EXPECT_EQ(filled, 0);
// The filter must still produce a valid map elsewhere.
EXPECT_GT(cv::countNonZero(d_wls > 0), 0);
}
TEST(StereoMatcherTest, CpuMatcherRecoversKnownShift) {
// Textured synthetic pair: right image is the left shifted by 8 px, so
// the (16x fixed-point) disparity in the interior should be ~8 px.
constexpr int kShift = 8;
cv::Mat left(120, 200, CV_8UC1);
cv::randu(left, 0, 255);
cv::blur(left, left, cv::Size(3, 3));
cv::Mat right = cv::Mat::zeros(left.size(), CV_8UC1);
left(cv::Rect(kShift, 0, left.cols - kShift, left.rows))
.copyTo(right(cv::Rect(0, 0, left.cols - kShift, left.rows)));
CpuStereoMatcher matcher(0, 64);
cv::Mat disparity = matcher.compute(left, right);
ASSERT_EQ(disparity.type(), CV_16S);
int good = 0, total = 0;
for (int y = 10; y < left.rows - 10; ++y) {
for (int x = 70; x < left.cols - 20; ++x) {
const float d = disparity.at<short>(y, x) / 16.0f;
if (d <= 0)
continue;
++total;
if (std::abs(d - kShift) <= 1.0f)
++good;
}
}
ASSERT_GT(total, 0);
EXPECT_GT(static_cast<double>(good) / total, 0.9);
}
TEST(StereoMatcherTest, GpuSupportedNumDisparitiesRoundsUp) {
EXPECT_EQ(GpuStereoMatcher::supported_num_disparities(16), 64);
EXPECT_EQ(GpuStereoMatcher::supported_num_disparities(64), 64);
EXPECT_EQ(GpuStereoMatcher::supported_num_disparities(96), 128);
EXPECT_EQ(GpuStereoMatcher::supported_num_disparities(160), 256);
EXPECT_THROW(std::ignore = GpuStereoMatcher::supported_num_disparities(272),
std::invalid_argument);
}
TEST(StereoMatcherTest, FactoryRejectsInvalidNumDisparities) {
EXPECT_THROW(std::ignore =
StereoMatcherFactory::create(StereoAlgorithmType::CPU, 0),
std::invalid_argument);
EXPECT_THROW(std::ignore = StereoMatcherFactory::create(
StereoAlgorithmType::CPU, -16),
std::invalid_argument);
EXPECT_THROW(std::ignore = StereoMatcherFactory::create(
StereoAlgorithmType::CPU, 150),
std::invalid_argument);
}
TEST(StereoMatcherTest, FactoryAcceptsValidNumDisparities) {
auto matcher = StereoMatcherFactory::create(StereoAlgorithmType::CPU, 160);
ASSERT_NE(matcher, nullptr);
}
TEST(StereoMatcherTest, FactoryGpuFallsBackToCpuWhenUnavailable) {
// On this machine CUDA is absent; factory should fall back to CPU.
auto matcher = StereoMatcherFactory::create(StereoAlgorithmType::GPU);
ASSERT_NE(matcher, nullptr);
auto [left, right] = make_synthetic_stereo();
EXPECT_NO_THROW(std::ignore = matcher->compute(left, right));
}
TEST(StereoMatcherTest, GpuMatcherThrowsOnThisMachine) {
// Direct construction of GpuStereoMatcher should throw because
// HAVE_OPENCV_CUDA is undefined here.
EXPECT_THROW(GpuStereoMatcher(), std::runtime_error);
}

Some files were not shown because too many files have changed in this diff Show More