score-back/docs/unity-integration.md
Artur Mukhamadiev 162c210a7d feat(cloud_point): stereo rectification, point cloud pipeline, and CloudPointClient facade
- 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
2026-07-12 22:28:21 +03:00

199 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).