Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,12 @@ jobs:
run: |
python -m venv .venv
. .venv/bin/activate
pip install maturin pytest numpy mypy
pip install maturin pytest numpy mypy "anywidget>=0.9,<0.10" nbclient nbformat ipykernel hatchling
maturin develop --release --manifest-path crates/spatialrust-py/Cargo.toml
pip install --no-deps -e python/spatialrust-jupyter
python crates/spatialrust-py/examples/video_tracking_e2e.py --no-gif
pytest crates/spatialrust-py/tests
pytest python/spatialrust-jupyter/tests
# The comparison report contract stays stdlib-only. OpenCV itself is
# intentionally not a production or default-CI dependency.
python bench/opencv_comparison/test_report.py
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`).
exact HTTP Range fetches plus deterministic request/cache budgets, response
length validation, explicit JS/WASM copies, and LRU eviction receipts.

- **Python and Jupyter viewer adapters (Epic 139)**: the abi3 Python wheel
exposes the canonical viewer state, shared input reducer, validated native
launch, retained zero-copy NumPy SoA columns, and byte-exact explicit-copy
receipts. `spatialrust-jupyter` adds a versioned AnyWidget transport with
strict origin/source checks, canonical frontend state validation, and an
executable notebook smoke fixture for the Web viewer embed.

## [1.2.0] — 2026-07-27

### Added
Expand Down
3 changes: 3 additions & 0 deletions crates/spatialrust-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py38"] }
numpy = "0.22"
serde_json = "1"
spatialrust = { path = "../spatialrust", features = [
"mvp",
"search-graph",
Expand Down Expand Up @@ -51,6 +52,8 @@ spatialrust = { path = "../spatialrust", features = [
"pipeline-streaming",
"io-laz",
"records-receipt-json",
"viewer-native",
"web",
] }

# Keep this crate out of the main Rust workspace so `cargo test --workspace`
Expand Down
28 changes: 28 additions & 0 deletions crates/spatialrust-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,34 @@ cross-check, and maximum-distance filters.
Geometry bindings expose `estimate_homography_ransac`, `solve_pnp`, and
`stereo_block_match` for NumPy `float64` / grayscale workflows.

## Viewer and NumPy ownership

`ViewerState` uses the same strict, versioned JSON contract as the native,
WebAssembly, and Jupyter viewers. Input messages are applied by the shared Rust
reducer. `launch_native()` opens the opt-in native shell on the calling thread;
it does not upload geometry.

```python
import numpy as np
import spatialrust as sr

state = sr.ViewerState(1280, 720)
state.apply_input_json('{"kind":"zoom","delta":1.0}')

x = np.arange(100, dtype=np.float32)
points = sr.ViewerPointSource.borrow_numpy(x, x + 1, x + 2)
assert points.source_pointers[0] == x.__array_interface__["data"][0]
print(points.transfer_receipt_json()) # zero host-to-host bytes

owned = sr.ViewerPointSource.copy_from_numpy(np.column_stack([x, x, x]))
snapshot, copy_receipt = owned.copy_to_numpy()
```

Borrowed sources retain the three NumPy owners and require contiguous
`float32` SoA columns. Copying is never inferred: `copy_from_numpy()` and
`copy_to_numpy()` report exact host bytes, while all CPU/GPU transfer counters
remain zero until a separate renderer upload is requested.

## Test

The bindings have a pytest suite (`tests/`) that exercises the NumPy ⇄ Rust
Expand Down
37 changes: 37 additions & 0 deletions crates/spatialrust-py/spatialrust.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ __all__: list[str] = [
"__version__", "ImageMetadata", "Tensor", "Keypoint2", "OnnxRuntimeSession",
"GaussianBlurWorkspace",
"DLPackTensorView", "PointCloud", "PointCloudStream", "PipelineResult", "RegionResult",
"ViewerState", "ViewerPointSource",
"DbscanResult", "GroundResult", "MultiPlaneResult", "SphereResult",
"CylinderResult", "RegistrationResult", "MultiObjectTracker", "read_image",
"tensor_copy_from_numpy", "tensor_view_from_dlpack", "harris_keypoints",
Expand Down Expand Up @@ -57,6 +58,42 @@ _Vec3 = tuple[float, float, float]
_U8Array = NDArray[np.uint8]
_U16Array = NDArray[np.uint16]

@final
class ViewerState:
"""Portable viewer state shared with native, Web, and Jupyter adapters."""

def __new__(
cls, width: int = ..., height: int = ...
) -> ViewerState: ...
@staticmethod
def from_json(state_json: str) -> ViewerState: ...
def to_json(self) -> str: ...
def apply_input_json(self, input_json: str) -> None: ...
@property
def version(self) -> int: ...
@property
def revision(self) -> int: ...
def native_launch_receipt(self, title: str) -> str: ...
def launch_native(self, title: str = ...) -> None: ...

@final
class ViewerPointSource:
"""Explicit borrowed or copied point columns for viewer adapters."""

@staticmethod
def borrow_numpy(
x: _F32Array, y: _F32Array, z: _F32Array
) -> ViewerPointSource: ...
@staticmethod
def copy_from_numpy(positions: _F32Array) -> ViewerPointSource: ...
@property
def ownership(self) -> str: ...
@property
def source_pointers(self) -> tuple[int, int, int]: ...
def __len__(self) -> int: ...
def transfer_receipt_json(self) -> str: ...
def copy_to_numpy(self) -> tuple[_F32Array, str]: ...

@final
class ImageMetadata:
"""Container, sample type, and Exif orientation from image decoding."""
Expand Down
5 changes: 5 additions & 0 deletions crates/spatialrust-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

#[allow(unsafe_code)]
mod dlpack_capsule;
mod viewer;

use viewer::{PyViewerPointSource, PyViewerState};

use numpy::ndarray::{Array2, Array3};
use numpy::{
Expand Down Expand Up @@ -4451,6 +4454,8 @@ fn spatialrust_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PySphereResult>()?;
m.add_class::<PyCylinderResult>()?;
m.add_class::<PyRegistrationResult>()?;
m.add_class::<PyViewerState>()?;
m.add_class::<PyViewerPointSource>()?;
m.add_function(wrap_pyfunction!(read_image, m)?)?;
m.add_function(wrap_pyfunction!(tensor_copy_from_numpy, m)?)?;
m.add_function(wrap_pyfunction!(tensor_view_from_dlpack, m)?)?;
Expand Down
Loading
Loading