+
+
+ You signed in with another tab or window. Reload to refresh your session.
+ You signed out in another tab or window. Reload to refresh your session.
+ You switched accounts on another tab or window. Reload to refresh your session.
+
+ Dismiss alert
+
+
+
+
Spout is a video frame sharing system for Microsoft Windows, which allows applications to share OpenGL textures in a similar way to Syphon for the Mac.
+
Spout supports DirectX 9, DirectX 11, DirectX 12 and OpenGL textures and includes a developer SDK and sample applications.
+
For questions and suggestions please contact the Spout Discourse Group. Please reserve repository Issues for code development.
+
+
+ You signed in with another tab or window. Reload to refresh your session.
+ You signed out in another tab or window. Reload to refresh your session.
+ You switched accounts on another tab or window. Reload to refresh your session.
+
+ Dismiss alert
+
+
+
+
This plugin enables the import and export of shared textures at high resolution to and from SPOUT2 compatible
+programs.
+
Why
+
Previously the only way to import shared textures from SPOUT was via the DirectShow SpoutCam interface or by screen-capturing
+the full-screen output of the SpoutReceiver program.
+
The SpoutCam is limited to standard webcam resolutions and capped at 1920x1080 and capturing the SpoutReceiver is both
+inefficient and limited by your current screen resolution.
+
Previously, there was no way of outputting Spout video textures from OBS.
+
This plugin implements the SPOUT2 SDK, creates an OBS Source from the SPOUT shared texture and a Spout output which sends the content of the OBS canvas to Spout.
+ Spout
+ is a system for Microsoft Windows for GPU accelerated sharing
+ of graphics between applications in a similar way to Syphon for OSX.
+ Spout supports DirectX 9, DirectX 11 and OpenGL textures.
+ A comprehensive SDK can be used for creating applications.
+ Explore the other Spout repositories for more detail.
+ Spout setup or the Spout SDK can be downloaded below.
+
+ As a convenient start, either for development or for practical use
+ explore a sorted list of open source projects that use Spout.
+
+
+
SpoutCam
+
+
"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.
+
+ Download the latest release from the repository Releases page below.
+ Refer to the instructions and "readme.txt" in the archive for further details.
+
+ Spout to NDI is a set of programs that allow Spout senders and receivers to share video and audio by way of a network using the NDI (“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 ofxNDI addon. Unzip the distribution file to any convenient folder.
+
+
+ Spout to OMT is a set of programs that allow Spout senders and receivers to share video by way of a network using the OMT (“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.
+
+
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.
+
+ Show your support and become a Sponsor through GitHub
+ or
+
+ Make a donation through PayPal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/research/spout-obs-bridge/summary.md b/research/spout-obs-bridge/summary.md
new file mode 100644
index 0000000..b2faa99
--- /dev/null
+++ b/research/spout-obs-bridge/summary.md
@@ -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.
diff --git a/scripts/sensor_fusion.py b/scripts/sensor_fusion.py
index 614ac8c..eec5dbf 100644
--- a/scripts/sensor_fusion.py
+++ b/scripts/sensor_fusion.py
@@ -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()
diff --git a/state/evidence.jsonl b/state/evidence.jsonl
index 3c61954..373e182 100644
--- a/state/evidence.jsonl
+++ b/state/evidence.jsonl
@@ -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."}
diff --git a/state/map.yaml b/state/map.yaml
index 171fe58..6289d43 100644
--- a/state/map.yaml
+++ b/state/map.yaml
@@ -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
diff --git a/tests/test_sensor_fusion.py b/tests/test_sensor_fusion.py
index 769bbe4..e4afdd8 100644
--- a/tests/test_sensor_fusion.py
+++ b/tests/test_sensor_fusion.py
@@ -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()