Add OBS Spout render packet bridge

This commit is contained in:
Meta Vens 2026-05-18 16:08:14 +01:00
commit 63606980e3
13 changed files with 4021 additions and 11 deletions

View file

@ -5,6 +5,14 @@
"min_confidence": 0.1,
"cache_ttl_ns": 500000000
},
"render_bridge": {
"spout_sender_name": "LocalCastBridge Point Cloud",
"target_width": 1920,
"target_height": 1080,
"visual_delay_ns": 250000000,
"audio_alignment_delay_ns": 250000000,
"default_point_radius_m": 0.025
},
"cameras": [
{
"id": "ps3eye_left",

View file

@ -36,13 +36,17 @@ flowchart TD
D --> F
F --> G["TrackCache"]
G --> H["PointCloud PLY"]
G --> I["Future brush/splat packets"]
G --> I["RenderFramePacket"]
I --> J["Aquarium GPU renderer"]
J --> K["Spout2 sender texture"]
K --> L["OBS Spout2 source"]
```
## Ownership
- `localcast.sensor_fusion.core` owns calibrated camera models, 2D observations, DLT triangulation, reprojection gating, confidence scoring, point-cloud export, and track cache expiry.
- `localcast.sensor_fusion.adapters` owns driver-facing frame ingress. Its first concrete adapter is an FFmpeg raw BGR reader for the PS3 Eye DirectShow path.
- `localcast.sensor_fusion.render_bridge` owns the render-frame ABI: cached point claims plus target dimensions, Spout sender name, source timestamp range, intended visual presentation time, and ambisonic/audio alignment time.
- `scripts/sensor_fusion.py` owns CLI composition for offline observation files.
- `config/sensor-fusion.example.json` owns the declarative rig shape: capture hints, intrinsics, extrinsics, latency, and fusion thresholds.
@ -57,8 +61,10 @@ sensor observation
-> calibrated world ray
-> triangulated marker claim
-> cached track
-> point cloud / brush packet / splat packet
-> renderer
-> render frame packet
-> Aquarium brush/splat packet
-> Spout sender texture
-> OBS source
```
The cache is not a bucket. `TrackCache` has a TTL and confidence update rule so the system can buffer and converge without pretending stale points are fresh. Later GPU buffers should mirror this shape: observation SoA, track SoA, brush packet SoA.
@ -74,17 +80,51 @@ The core can be tested without cameras:
Those tests matter because camera drivers are not unit-test dependencies. They are weather.
## OBS And Spout Boundary
OBS should see a named Spout sender, not a camera window, file tail, or desktop capture. Current references confirm the ordinary Windows path: the Off World Live OBS Spout2 plugin receives Spout shared textures, while the Spout2 SDK is the app-side texture sharing surface.
The intended production handoff is:
```text
LocalCastBridge TrackCache
-> RenderFramePacket JSON/binary stream
-> Aquarium Engine typed runtime state
-> GPU point/brush/splat buffer
-> D3D render target
-> Spout2 sender named "LocalCastBridge Point Cloud"
-> OBS Spout2 Capture source
```
`RenderFramePacket` is a scaffold ABI, not the final hot path. JSON is useful for inspection, tests, and handoff. Once Aquarium consumes the shape, switch the transport to a binary or shared-memory ring with the same fields. The invariant is the packet contract, not JSON.
## Latency Contract
Latency is allowed. Drift is not.
Each render frame carries:
- `source_time_min_ns` and `source_time_max_ns`: the observation window that produced the cached points.
- `present_time_ns`: when the visual frame wants to be presented after visual buffering.
- `audio_alignment_time_ns`: the corresponding ambisonic output time.
That lets the final stream compositor buffer point-cloud visuals until they align with the bounded audio-field cache. The renderer may show a slightly older world if the packet is coherent and labeled. It may not quietly mix fresh audio with stale visual geometry and call that sync.
## Next Cut
1. Add detector adapters that turn PS3 Eye frames into `Observation2D` marker detections.
2. Load real ChArUco intrinsics/extrinsics into `config/sensor-fusion.json`.
3. Record an observation JSONL/JSON run from the two PS3 Eyes.
4. Generate a live sparse PLY stream or debug preview.
5. Lower cached tracks into brush/splat packets once the point cloud is stable.
5. Feed `RenderFramePacket` into Aquarium Engine.
6. Add Aquarium-side GPU point/brush rendering to a D3D render target.
7. Publish the render target as a Spout2 sender and receive it in OBS.
8. Replace JSON packet transport with a binary/shared-memory ring once the contract is stable.
## References
- OpenCV multi-camera calibration and triangulation are the geometry spine. See `research/visual-spatial-map/summary.md`.
- Jacob and Haeb-Umbach 2015 motivates audio-visual coordinate alignment. See `research/visual-spatial-map/mirrors/krekovic-2015-audio-visual-geometry-calibration.pdf`.
- Kerbl et al. 2023 and RTG-SLAM motivate splats as render packets, not state ownership. See `research/visual-spatial-map/summary.md` and `research/gpu-fusion-splatting/summary.md`.
- Spout2 and the OBS Spout2 plugin are the Windows/OBS texture-sharing bridge. See `research/spout-obs-bridge/summary.md`.
- `E:\Projects\gamecult-site\GameCult\Blog\fractal-domains-cache-that-bites.md` supplies the cache discipline: semantic authority first, backend packet lowering second.

View file

@ -11,6 +11,7 @@ from .core import (
TriangulatedPoint,
load_fusion_config,
)
from .render_bridge import RenderBridgeConfig, RenderFramePacket, RenderPointPacket, lower_points_to_render_frame
__all__ = [
"CameraModel",
@ -22,4 +23,8 @@ __all__ = [
"TrackCache",
"TriangulatedPoint",
"load_fusion_config",
"RenderBridgeConfig",
"RenderFramePacket",
"RenderPointPacket",
"lower_points_to_render_frame",
]

View file

@ -0,0 +1,125 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
import numpy as np
from .core import TriangulatedPoint
@dataclass(frozen=True)
class RenderBridgeConfig:
spout_sender_name: str = "LocalCastBridge Point Cloud"
target_width: int = 1920
target_height: int = 1080
visual_delay_ns: int = 250_000_000
audio_alignment_delay_ns: int = 250_000_000
default_point_radius_m: float = 0.025
@staticmethod
def from_dict(data: dict) -> "RenderBridgeConfig":
return RenderBridgeConfig(
spout_sender_name=data.get("spout_sender_name", "LocalCastBridge Point Cloud"),
target_width=int(data.get("target_width", 1920)),
target_height=int(data.get("target_height", 1080)),
visual_delay_ns=int(data.get("visual_delay_ns", 250_000_000)),
audio_alignment_delay_ns=int(data.get("audio_alignment_delay_ns", 250_000_000)),
default_point_radius_m=float(data.get("default_point_radius_m", 0.025)),
)
@dataclass(frozen=True)
class RenderPointPacket:
stable_key: str
xyz: np.ndarray
radius_m: float
color_rgba: tuple[float, float, float, float]
confidence: float
source_timestamp_ns: int
def to_dict(self) -> dict:
return {
"stable_key": self.stable_key,
"xyz": [float(v) for v in self.xyz],
"radius_m": float(self.radius_m),
"color_rgba": [float(v) for v in self.color_rgba],
"confidence": float(self.confidence),
"source_timestamp_ns": int(self.source_timestamp_ns),
}
@dataclass(frozen=True)
class RenderFramePacket:
schema: str
frame_id: int
created_monotonic_ns: int
source_time_min_ns: int
source_time_max_ns: int
present_time_ns: int
audio_alignment_time_ns: int
spout_sender_name: str
target_width: int
target_height: int
points: tuple[RenderPointPacket, ...]
def to_dict(self) -> dict:
return {
"schema": self.schema,
"frame_id": self.frame_id,
"created_monotonic_ns": self.created_monotonic_ns,
"source_time_min_ns": self.source_time_min_ns,
"source_time_max_ns": self.source_time_max_ns,
"present_time_ns": self.present_time_ns,
"audio_alignment_time_ns": self.audio_alignment_time_ns,
"spout_sender_name": self.spout_sender_name,
"target_width": self.target_width,
"target_height": self.target_height,
"points": [point.to_dict() for point in self.points],
}
def write_json(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
def lower_points_to_render_frame(
points: tuple[TriangulatedPoint, ...],
config: RenderBridgeConfig,
frame_id: int,
created_monotonic_ns: int,
) -> RenderFramePacket:
if points:
source_min = min(point.timestamp_ns for point in points)
source_max = max(point.timestamp_ns for point in points)
else:
source_min = created_monotonic_ns
source_max = created_monotonic_ns
render_points = tuple(point_to_render_packet(point, config) for point in points)
present_time = source_max + config.visual_delay_ns
return RenderFramePacket(
schema="localcast.sensor_fusion.render_frame.v1",
frame_id=frame_id,
created_monotonic_ns=created_monotonic_ns,
source_time_min_ns=source_min,
source_time_max_ns=source_max,
present_time_ns=present_time,
audio_alignment_time_ns=source_max + config.audio_alignment_delay_ns,
spout_sender_name=config.spout_sender_name,
target_width=config.target_width,
target_height=config.target_height,
points=render_points,
)
def point_to_render_packet(point: TriangulatedPoint, config: RenderBridgeConfig) -> RenderPointPacket:
confidence = max(0.0, min(1.0, float(point.confidence)))
return RenderPointPacket(
stable_key=point.marker_id,
xyz=point.xyz.astype(np.float64),
radius_m=config.default_point_radius_m,
color_rgba=(0.25 + 0.75 * confidence, 0.65, 1.0, confidence),
confidence=confidence,
source_timestamp_ns=point.timestamp_ns,
)

View file

@ -0,0 +1,5 @@
# Spout OBS Bridge Bibliography
- Off World Live, `obs-spout2-plugin`, OBS plugin implementing Spout2 input/output. Mirror: `mirrors/obs-spout2-plugin-github.html`.
- leadedge, `Spout2`, Windows video frame sharing system and SDK. Mirror: `mirrors/leadedge-spout2-github.html`.
- leadedge, Spout project home. Mirror: `mirrors/spout-home.html`.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html>
<head>
<title>Spout</title>
<link rel="stylesheet" type="text/css" href="styles\style2.css">
<meta name="viewport" content="width=device-width">
</head>
<body>
<div class="layout">
<script>
function goBack() {
window.history.back();
}
</script>
<center>
<div class="subheading">
<h1>Spout</h1>
<p>
<a href="https://spout.zeal.co/" target="_blank">Spout</a>
is a system for Microsoft Windows for GPU accelerated sharing<br>
of graphics between applications in a similar way to <a href="http://syphon.v002.info/" target="_blank">Syphon</a> for OSX.<br>
Spout supports DirectX 9, DirectX 11 and OpenGL textures.<br>
A comprehensive SDK can be used for creating applications.<br>
Explore the other <a href="https://github.com/leadedge/" target="_blank">Spout repositories</a> for more detail.</a><br>
Spout setup or the Spout SDK can be downloaded below.
</p>
<h2><a href="spout-download.html">Download</a></h2>
</p><br>
<h1>Open source projects</h1>
<p>
As a convenient start, either for development or for practical use<br>
explore a <a href="spout-projects.html">sorted list</a></h2> of open source projects that use Spout.
</p><br>
<h1>SpoutCam</h1>
<p>"SpoutCam" is a virtual webcam that is also a Spout receiver. A setup program provides options for frame rate and resolution and can be used to register for both 32 bit and 64 bit host programs.
<br><br>
Download the latest release from the repository Releases page below.<br>
Refer to the instructions and "readme.txt" in the archive for further details.
</p>
<h2><a href="https://github.com/leadedge/SpoutCam/releases">Download</a></h2>
<br>
<h1>Spout To NDI</h1>
<p>
<a href="https://raw.githubusercontent.com/leadedge/leadedge.github.io/downloads/SPOUTtoNDI_2030.pdf" target="_blank">Spout to NDI</a> is a set of programs that allow Spout senders and receivers to share video and audio by way of a network using the <a href="https://ndi.video/" target="_blank">NDI</a> (“Network Device Interface”) protocol, a video connectivity standard that enables multimedia systems to communicate with one another over IP. Options are available for Spout to NDI to capture system audio and send it together with image data and for NDI to Spout to receive audio from the NDI source as well as the graphics and play it through the speakers. Conversion from GPU textures is done using Openframeworks shaders by way of the <a href="https://github.com/leadedge/ofxNDI" target="_blank">ofxNDI</a> addon. Unzip the distribution file to any convenient folder.<br>
<h2><a href="https://github.com/leadedge/leadedge.github.io/raw/downloads/SPOUTtoNDI_v2030.zip">Download</a></h2>
<br>
<h1>Spout To OMT</h1>
<p>
<a href="https://raw.githubusercontent.com/leadedge/leadedge.github.io/downloads/SPOUT to OMT.pdf" target="_blank">Spout to OMT</a> is a set of programs that allow Spout senders and receivers to share video by way of a network using the <a href="https://www.openmediatransport.org/" target="_blank">OMT</a> (“Open Media Transport”) protocol. This is an open-source network protocol for high performance, low latency video over a local area network. Conversion from GPU textures is done using high speed compute shaders for minimum overhead. Unzip the distribution file to any convenient folder.<br>
<h2><a href="https://github.com/leadedge/leadedge.github.io/raw/downloads/Spout to OMT_1000.zip">Download</a></h2>
</p><br>
<h1>Contact and support</h1>
<p>
Please visit the <a href="https://spout.discourse.group/" target="_blank">Spout Discourse group</a>.
</p><br>
<h1>Donations</h1>
<p>Spout is open source software and there is no company or income to assist ongoing development, maintenance and support. So please consider becoming a supporter to help keep the project going. All contributions are most welcome.</p>
Show your support and become a <a href="https://github.com/leadedge/" target="_blank"><font size="4">Sponsor</font></b></a> through GitHub<br><br>
or<br><br>
Make a donation through PayPal<br>
<form action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_top">
<input type="hidden" name="cmd" value="_s-xclick">
<input type="hidden" name="hosted_button_id" value="P4P4QJZBT87PJ">
<input type="image" src="https://www.paypalobjects.com/en_AU/i/btn/btn_donate_SM.gif" border="0" name="submit" alt="PayPal – The safer, easier way to pay online!">
<img alt="" border="0" src="https://www.paypalobjects.com/en_AU/i/scr/pixel.gif" width="1" height="1">
</form>
</p><br>
<button
style="font-family: Arial, Helvetica, sans-serif;"
onclick="goBack()">Back</button></br></br></br></br>
</center>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,7 @@
# Spout OBS Bridge Summary
Spout is the Windows-local GPU texture sharing bridge for this rig. The OBS path is the Off World Live OBS Spout2 plugin, which creates an OBS source from a Spout shared texture and can also send the OBS canvas back to Spout. The Spout2 SDK is the application-side sender/receiver surface; its modern path centers on DirectX shared textures and has native DirectX/OpenGL integration options.
Decision: LocalCastBridge should not render final pixels itself. It should publish typed render-frame packets from the fusion cache. Aquarium Engine should consume those packets, render them into a GPU render target, and publish that texture as a named Spout sender. OBS then receives that sender through the OBS Spout2 plugin.
Latency decision: the render packet carries both source timestamps and intended presentation time. The stream compositor can buffer visual frames until they align with the ambisonic audio field's presentation timeline. Latency is allowed; unbounded drift is not.

View file

@ -7,7 +7,14 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from localcast.sensor_fusion import Observation2D, PointCloud, TrackCache, load_fusion_config
from localcast.sensor_fusion import (
Observation2D,
PointCloud,
RenderBridgeConfig,
TrackCache,
load_fusion_config,
lower_points_to_render_frame,
)
@ -19,7 +26,9 @@ def load_observations(path: Path) -> list[Observation2D]:
def fuse_file(args) -> None:
rig = load_fusion_config(Path(args.config))
config_path = Path(args.config)
config_data = json.loads(config_path.read_text(encoding="utf-8"))
rig = load_fusion_config(config_path)
observations = load_observations(Path(args.observations))
result = rig.fuse(observations)
cache = TrackCache(ttl_ns=rig.config.cache_ttl_ns)
@ -30,8 +39,20 @@ def fuse_file(args) -> None:
cloud = PointCloud(cache.points())
output = Path(args.output)
cloud.write_ply(output)
render_frame_output = None
if args.render_frame_json:
render_config = RenderBridgeConfig.from_dict(config_data.get("render_bridge", {}))
frame = lower_points_to_render_frame(
cloud.points,
render_config,
frame_id=args.frame_id,
created_monotonic_ns=args.created_ns,
)
render_frame_output = Path(args.render_frame_json)
frame.write_json(render_frame_output)
summary = {
"output": str(output),
"render_frame_json": None if render_frame_output is None else str(render_frame_output),
"points": len(cloud.points),
"dropped": list(result.dropped),
}
@ -47,6 +68,9 @@ def main() -> None:
p.add_argument("--observations", required=True)
p.add_argument("--output", required=True)
p.add_argument("--now-ns", type=int)
p.add_argument("--render-frame-json")
p.add_argument("--frame-id", type=int, default=0)
p.add_argument("--created-ns", type=int, default=0)
p.set_defaults(func=fuse_file)
args = parser.parse_args()

View file

@ -30,3 +30,4 @@
{"ts": "2026-05-18T14:50:58+00:00", "type": "verification", "status": "partial", "note": "Audio profile updated for actual distributed rig: local Focusrite shielded cardioid and neighbor Focusrite shotgun are dialogue anchors; Kiyo/PS Eye mics are spatial/context sources. Local PortAudio/WASAPI probe: Scarlett input accepts 48 kHz and rejects 96 kHz; Scarlett output accepts 44.1 kHz and rejects 48/96 kHz, so ASIO/native Focusrite access must be checked before high-rate calibration."}
{"ts": "2026-05-18T14:55:13+00:00", "type": "decision", "status": "ok", "note": "Audio field live output may use bounded buffering: latency is acceptable while delay/SRO estimates settle, but cache depth must converge toward real-time. Core audio code now has testable seams for buffers, latency policy, injectable capture/alignment/encoder/sink ports, and pipeline orchestration."}
{"ts": "2026-05-18T14:57:32+00:00", "type": "decision", "status": "ok", "note": "Sensor-fusion sidecar starts with semantic observation authority: calibrated cameras plus timestamped 2D marker observations triangulate into cached 3D point claims, then lower to PLY/brush/splat packets. Capture adapters and render lowerings stay outside the pure fusion core so unit tests can inject synthetic cameras, frames, and observations."}
{"ts": "2026-05-18T15:07:24+00:00", "type": "decision", "status": "ok", "note": "OBS visualization boundary: LocalCastBridge should emit typed render-frame packets from the sensor-fusion cache; Aquarium Engine should consume them, render points/brushes/splats to a GPU render target, and publish that texture as Spout sender 'LocalCastBridge Point Cloud'. Render packets carry visual presentation and audio-alignment timestamps so latency can converge with the ambisonic output timeline."}

View file

@ -97,7 +97,7 @@ research:
before adding RGB projection or Gaussian splat rendering. For a
Dreams-like volumetric brushstroke look, keep the world model as truth and
lower semantic scene claims into cached brush/splat packets under budget.
gpu_fusion_splatting:
gpu_fusion_splatting:
summary: research/gpu-fusion-splatting/summary.md
bibliography: research/gpu-fusion-splatting/bibliography.md
key_decision: >
@ -106,6 +106,15 @@ research:
GPU-shaped observation and brush packet buffers, and CUDA/Vulkan/D3D
splat rendering with minimal readback. CPU owns orchestration and
semantics; kernels do not own calibration truth.
spout_obs_bridge:
summary: research/spout-obs-bridge/summary.md
bibliography: research/spout-obs-bridge/bibliography.md
key_decision: >
OBS should receive a named Spout2 shared texture from Aquarium Engine,
not desktop capture or file polling. LocalCastBridge emits typed
render-frame packets with source timestamps, intended presentation time,
audio-alignment time, target size, Spout sender name, and point/brush
claims. Aquarium owns GPU rendering and Spout publication.
sensor_fusion_architecture:
summary: docs/sensor-fusion-architecture.md
key_decision: >
@ -156,14 +165,21 @@ current_status:
timestamped 2D observations, DLT triangulation, reprojection/confidence
gating, a TTL track cache, and PLY point-cloud export; adapters own
FFmpeg/raw-frame ingress; `scripts/sensor_fusion.py fuse-file` can turn an
observation file into a sparse PLY. Unit tests inject synthetic cameras and
frames so the core does not depend on live camera drivers.
observation file into a sparse PLY. The render bridge now lowers cached
points into `localcast.sensor_fusion.render_frame.v1` packets carrying
source time ranges, intended visual presentation time, audio-alignment
time, target render size, Spout sender name, and point draw metadata for
Aquarium/Spout. Unit tests inject synthetic cameras and frames so the core
does not depend on live camera drivers.
next_action: >
Capture real ChArUco intrinsics/extrinsics for the PS3 Eyes and Kiyos,
replace `config/sensor-fusion.example.json` placeholder transforms with a
local `config/sensor-fusion.json`, then add a detector adapter that turns
FFmpeg PS3 Eye frames into marker `Observation2D` records for the fusion
core. In parallel, create local `config/audio-field.json`, confirm local
core. Then add an Aquarium Engine consumer for render-frame packets that
renders cached points/brushes into a GPU render target and publishes it as
Spout sender `LocalCastBridge Point Cloud` for OBS. In parallel, create local
`config/audio-field.json`, confirm local
Kiyo/PS Eye/Focusrite device matches, confirm the neighbor Focusrite
shotgun capture/transport path, replace placeholder mic/speaker geometry
with measured world coordinates, then build the delay/SRO alignment stage

View file

@ -3,7 +3,16 @@ import unittest
import numpy as np
from localcast.sensor_fusion import CameraModel, FusionConfig, Observation2D, PointCloud, SensorRig, TrackCache
from localcast.sensor_fusion import (
CameraModel,
FusionConfig,
Observation2D,
PointCloud,
RenderBridgeConfig,
SensorRig,
TrackCache,
lower_points_to_render_frame,
)
from localcast.sensor_fusion.adapters import read_raw_bgr_frames
@ -112,6 +121,31 @@ class SensorFusionTests(unittest.TestCase):
self.assertEqual("eye", frames[0].sensor_id)
self.assertEqual((2, 2, 3), frames[0].image_bgr.shape)
def test_render_frame_packet_carries_spout_and_audio_alignment_metadata(self):
left = camera("left", -0.25)
right = camera("right", 0.25)
point = SensorRig(cameras={"left": left, "right": right}).fuse(
[
Observation2D("left", "ball", 1_000, left.project_world(np.array([0.0, 0.0, 2.0])), 1.0),
Observation2D("right", "ball", 2_000, right.project_world(np.array([0.0, 0.0, 2.0])), 0.8),
]
).points[0]
config = RenderBridgeConfig(
spout_sender_name="test-spout",
visual_delay_ns=100,
audio_alignment_delay_ns=200,
default_point_radius_m=0.05,
)
frame = lower_points_to_render_frame((point,), config, frame_id=7, created_monotonic_ns=3_000)
data = frame.to_dict()
self.assertEqual("localcast.sensor_fusion.render_frame.v1", data["schema"])
self.assertEqual("test-spout", data["spout_sender_name"])
self.assertEqual(2_100, data["present_time_ns"])
self.assertEqual(2_200, data["audio_alignment_time_ns"])
self.assertEqual(0.05, data["points"][0]["radius_m"])
if __name__ == "__main__":
unittest.main()