- StereoRectifier wrapping cv::stereoRectify + cv::initUndistortRectifyMap (CV_16SC2 maps) - PointCloudBuilder with cv::reprojectImageTo3D and depth/NaN filtering - CloudPointClient high-level facade: connect(), compute_cloud() -> std::expected<PointCloud, Error> - write_ply() ASCII PLY export helper - CLI options 4 (compute-cloud) and 5 (compute-cloud + save PLY) - Fix TcpServer to loop over multiple requests per connection - Expose num_disparities parameter through CloudPointClient and StereoMatcherFactory - Unity C# integration design spec - Sync README, AGENTS, openwiki with stereo pipeline (C++23) - E2E synthetic-scene test with constant-disparity stereo pair TG-9 #ready-for-test TG-4 #ready-for-test TG-2 #in-progress
199 lines
9.0 KiB
Markdown
199 lines
9.0 KiB
Markdown
# 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 100–300 ms per fetch on loopback. For
|
||
interactive rates use 960x540 and/or BGR.
|
||
- `ReadPixels` stalls the GPU pipeline (~1–5 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).
|