Skip to content

Commit bb774d3

Browse files
Implement syncing in the backend
- added other_nv and sync_opts attributes to NiiVue class - added broadcast_to, _do_sync_3d, _do_sync_2d, _do_sync_gamma, _do_sync_zoom_pan, _do_sync_crosshair, _do_sync_cal_min, _do_sync_cal_max, _do_sync_slice_type, _do_sync_clip_plane, sync methods to NiiVue class - modified set_state of NiiVue class to prevent notifications for scene updates
1 parent 8baa539 commit bb774d3

File tree

5 files changed

+214
-57
lines changed

5 files changed

+214
-57
lines changed

js/lib.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { NVConfigOptions } from "@niivue/niivue";
2-
import type { AnyModel, TypedBufferPayload } from "./types.ts";
1+
import type { NVConfigOptions, Niivue } from "@niivue/niivue";
2+
import type { AnyModel, TypedBufferPayload, PyScene, Model } from "./types.ts";
33

44
function delay(ms: number) {
55
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -265,4 +265,4 @@ export class Disposer {
265265
}
266266
this.#disposers.clear();
267267
}
268-
}
268+
}

js/types.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@ type Graph = {
6464
lineRGB?: number[][];
6565
};
6666

67-
export type Scene = {
68-
renderAzimuth: number;
69-
renderElevation: number;
70-
volScaleMultiplier: number;
71-
crosshairPos: number[];
72-
clipPlane: number[];
73-
clipPlaneDepthAziElev: number[];
74-
pan2Dxyzmm: number[];
67+
export type PyScene = {
68+
render_azimuth: number;
69+
render_elevation: number;
70+
vol_scale_multiplier: number;
71+
crosshair_pos: number[];
72+
clip_plane: number[];
73+
clip_plane_depth_azi_elev: number[];
74+
pan2d_xyzmm: number[];
7575
gamma: number;
7676
};
7777

js/widget.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
MeshModel,
1212
Model,
1313
NiivueObject3D,
14-
Scene,
14+
PyScene,
1515
VolumeModel,
1616
} from "./types.ts";
1717

@@ -34,9 +34,6 @@ function attachModelEventHandlers(
3434
}
3535
});
3636

37-
// Any time the backend changes the options, we need to update the nv gl
38-
// but...need to filter out changes that are *actually* from the frontend
39-
// so, we don't call updateGLVolume here (instead, it's explicitly called from the backend)
4037
model.on("change:opts", () => {
4138
const serializedOpts = model.get("opts");
4239
const opts = lib.deserializeOptions(serializedOpts);
@@ -291,20 +288,26 @@ function attachNiivueEventHandlers(nv: niivue.Niivue, model: Model) {
291288
isThrottling = true;
292289
setTimeout(() => {
293290
isThrottling = false;
294-
}, 50);
295-
296-
const currentScene: Scene = {
297-
renderAzimuth: nv.scene.renderAzimuth,
298-
renderElevation: nv.scene.renderElevation,
299-
volScaleMultiplier: nv.scene.volScaleMultiplier,
300-
crosshairPos: [...nv.scene.crosshairPos],
301-
clipPlane: nv.scene.clipPlane,
302-
clipPlaneDepthAziElev: nv.scene.clipPlaneDepthAziElev,
303-
pan2Dxyzmm: [...nv.scene.pan2Dxyzmm],
291+
}, 40);
292+
293+
const currentScene: PyScene = {
294+
render_azimuth: nv.scene.renderAzimuth,
295+
render_elevation: nv.scene.renderElevation,
296+
vol_scale_multiplier: nv.scene.volScaleMultiplier,
297+
crosshair_pos: [...nv.scene.crosshairPos],
298+
clip_plane: nv.scene.clipPlane,
299+
clip_plane_depth_azi_elev: nv.scene.clipPlaneDepthAziElev,
300+
pan2d_xyzmm: [...nv.scene.pan2Dxyzmm],
304301
gamma: nv.scene.gamma || 1.0,
305302
};
303+
306304
model.set("scene", currentScene);
307305
model.save_changes();
306+
307+
model.send({
308+
event: "sync",
309+
data: {}
310+
});
308311
},
309312
});
310313

src/ipyniivue/serializers.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
)
1515
from .traits import (
1616
CAMEL_TO_SNAKE_GRAPH,
17-
CAMEL_TO_SNAKE_SCENE,
1817
LUT,
1918
SNAKE_TO_CAMEL_GRAPH,
2019
SNAKE_TO_CAMEL_SCENE,
@@ -312,31 +311,6 @@ def serialize_scene(instance: Scene, widget: object):
312311
return data
313312

314313

315-
def deserialize_scene(serialized_scene: dict, widget: object):
316-
"""
317-
Deserialize serialized scene data, converting camelCase back to snake_case.
318-
319-
Parameters
320-
----------
321-
serialized_scene : dict
322-
The serialized scene dictionary from the frontend.
323-
widget : object
324-
The NiiVue widget the instance is a part of.
325-
326-
Returns
327-
-------
328-
Scene
329-
The deserialized Scene instance.
330-
"""
331-
scene_args = {}
332-
for camel_name, value in serialized_scene.items():
333-
snake_name = CAMEL_TO_SNAKE_SCENE.get(camel_name, camel_name)
334-
if snake_name in Scene.class_traits():
335-
deserialized_value = value
336-
scene_args[snake_name] = deserialized_value
337-
return Scene(**scene_args, parent=widget)
338-
339-
340314
def serialize_enum(instance: enum.Enum, widget: object):
341315
"""
342316
Serialize an Enum instance by returning its value.

src/ipyniivue/widget.py

Lines changed: 186 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
deserialize_hdr,
3434
deserialize_mat4,
3535
deserialize_options,
36-
deserialize_scene,
3736
deserialize_volume_object_3d_data,
3837
serialize_colormap_label,
3938
serialize_enum,
@@ -840,11 +839,43 @@ class NiiVue(BaseAnyWidget):
840839
scene = t.Instance(Scene, allow_none=True).tag(
841840
sync=True,
842841
to_json=serialize_scene,
843-
from_json=deserialize_scene,
844842
)
845843
overlay_outline_width = t.Float(0.0).tag(sync=True) # 0 for none
846844
overlay_alpha_shader = t.Float(1.0).tag(sync=True) # 1 for opaque
847845

846+
other_nv = t.List(t.Instance(object, allow_none=False), default_value=[]).tag(
847+
sync=False
848+
)
849+
sync_opts = t.Dict(
850+
default_value={
851+
"3d": False,
852+
"2d": False,
853+
"zoom_pan": False,
854+
"cal_min": False,
855+
"cal_max": False,
856+
"clip_plane": False,
857+
"gamma": False,
858+
"slice_type": False,
859+
"crosshair": False,
860+
}
861+
).tag(sync=False)
862+
863+
@t.validate("other_nv")
864+
def _validate_other_nv(self, proposal):
865+
value = proposal["value"]
866+
for nv_inst in value:
867+
if nv_inst is self:
868+
raise t.TraitError("Cannot sync to self.")
869+
if (
870+
f"{type(nv_inst).__module__}.{type(nv_inst).__qualname__}"
871+
!= "ipyniivue.widget.NiiVue"
872+
):
873+
raise t.TraitError(
874+
"All items in `other_nv` must be NiiVue instances."
875+
+ str(type(self))
876+
)
877+
return value
878+
848879
def __init__(self, height: int = 300, **options): # noqa: D417
849880
r"""
850881
Initialize the NiiVue widget.
@@ -864,14 +895,15 @@ def __init__(self, height: int = 300, **options): # noqa: D417
864895
opts = ConfigOptions(parent=self, **options)
865896
super().__init__(height=height, opts=opts, volumes=[], meshes=[])
866897

867-
# Handle messages coming from frontend
868-
self._event_handlers = {}
869-
self.on_msg(self._handle_custom_msg)
870-
871898
# Initialize values
872899
self._cluts = self._get_initial_colormaps()
873900
self.graph = Graph(parent=self)
874901
self.scene = Scene(parent=self)
902+
self.other_nv = []
903+
904+
# Handle messages coming from frontend
905+
self._event_handlers = {}
906+
self.on_msg(self._handle_custom_msg)
875907

876908
def __setattr__(self, name, value):
877909
"""todo: remove this starting version 2.4.1."""
@@ -885,6 +917,13 @@ def __setattr__(self, name, value):
885917
setattr(self.opts, name, value)
886918
super().__setattr__(name, value)
887919

920+
def set_state(self, state):
921+
"""Override set_state to silence notifications for certain updates."""
922+
if "scene" in state:
923+
self.scene._trait_values.update(state["scene"])
924+
return
925+
return super().set_state(state)
926+
888927
def _notify_opts_changed(self):
889928
self.notify_change(
890929
{
@@ -941,6 +980,11 @@ def _handle_custom_msg(self, content, buffers):
941980
self._add_mesh_from_frontend(data)
942981
return
943982

983+
# sync
984+
elif event == "sync":
985+
self.sync()
986+
return
987+
944988
# check if the event has a registered handler
945989
handler = self._event_handlers.get(event)
946990
if not handler:
@@ -2933,6 +2977,142 @@ def hover_idx_callback(data):
29332977
"""
29342978
self._register_callback("hover_idx_change", callback, remove=remove)
29352979

2980+
"""
2981+
Sync
2982+
"""
2983+
2984+
def broadcast_to(self, other_nv, sync_opts=None):
2985+
"""
2986+
Sync the scene controls from one NiiVue instance to others.
2987+
2988+
Useful for using one canvas to drive another.
2989+
2990+
Parameters
2991+
----------
2992+
other_nv : :class:`NiiVue` or list of :class:`NiiVue`
2993+
The other NiiVue instance(s) to broadcast state to.
2994+
sync_opts : dict, optional
2995+
Options specifying which properties to sync. E.g., {'2d': True, '3d': True}
2996+
Possible keys are:
2997+
- gamma
2998+
- crosshair
2999+
- zoom_pan
3000+
- slice_type
3001+
- cal_min
3002+
- cal_max
3003+
- clip_plane
3004+
- 2d
3005+
- 3d
3006+
"""
3007+
if not isinstance(sync_opts, dict):
3008+
sync_opts = {"2d": True, "3d": True}
3009+
3010+
if not isinstance(other_nv, (list, tuple)):
3011+
other_nv = [other_nv]
3012+
3013+
self.other_nv = other_nv
3014+
self.sync_opts = sync_opts
3015+
3016+
def _do_sync_3d(self, other_nv):
3017+
"""Synchronize 3D view settings with another NiiVue instance.
3018+
3019+
Do not call this by itself. This should be called by the sync method.
3020+
"""
3021+
other_nv.scene._trait_values["render_azimuth"] = self.scene.render_azimuth
3022+
other_nv.scene._trait_values["render_elevation"] = self.scene.render_elevation
3023+
other_nv.scene._trait_values["vol_scale_multiplier"] = (
3024+
self.scene.vol_scale_multiplier
3025+
)
3026+
3027+
def _do_sync_2d(self, other_nv):
3028+
"""Synchronize 2D crosshair pos + pan settings with another NiiVue instance.
3029+
3030+
Do not call this by itself. This should be called by the sync method.
3031+
"""
3032+
this_mm = self.frac2mm(self.scene.crosshair_pos)
3033+
other_nv.scene._trait_values["crosshair_pos"] = other_nv.mm2frac(this_mm)
3034+
other_nv.scene._trait_values["pan2d_xyzmm"] = list(self.scene.pan2d_xyzmm)
3035+
3036+
def _do_sync_gamma(self, other_nv):
3037+
"""Synchronize gamma correction setting with another NiiVue instance."""
3038+
this_gamma = self.scene.gamma
3039+
other_gamma = other_nv.scene.gamma
3040+
if this_gamma != other_gamma:
3041+
other_nv.set_gamma(this_gamma)
3042+
3043+
def _do_sync_zoom_pan(self, other_nv):
3044+
"""Synchronize zoom/pan settings with another NiiVue instance.
3045+
3046+
Do not call this by itself. This should be called by the sync method.
3047+
"""
3048+
other_nv.scene._trait_values["pan2d_xyzmm"] = list(self.scene.pan2d_xyzmm)
3049+
3050+
def _do_sync_crosshair(self, other_nv):
3051+
"""Synchronize crosshair position with another NiiVue instance.
3052+
3053+
Do not call this by itself. This should be called by the sync method.
3054+
"""
3055+
this_mm = self.frac2mm(self.scene.crosshair_pos)
3056+
other_nv.scene._trait_values["crosshair_pos"] = other_nv.mm2frac(this_mm)
3057+
3058+
def _do_sync_cal_min(self, other_nv):
3059+
"""Synchronize cal_min with another NiiVue instance."""
3060+
if (
3061+
self.volumes
3062+
and other_nv.volumes
3063+
and self.volumes[0].cal_min != other_nv.volumes[0].cal_min
3064+
):
3065+
other_nv.volumes[0].cal_min = self.volumes[0].cal_min
3066+
3067+
def _do_sync_cal_max(self, other_nv):
3068+
"""Synchronize cal_max with another NiiVue instance."""
3069+
if (
3070+
self.volumes
3071+
and other_nv.volumes
3072+
and self.volumes[0].cal_max != other_nv.volumes[0].cal_max
3073+
):
3074+
other_nv.volumes[0].cal_max = self.volumes[0].cal_max
3075+
3076+
def _do_sync_slice_type(self, other_nv):
3077+
"""Synchronize slice view type with another NiiVue instance."""
3078+
other_nv.set_slice_type(self.opts.slice_type)
3079+
3080+
def _do_sync_clip_plane(self, other_nv):
3081+
"""Synchronize clip plane settings with another NiiVue instance."""
3082+
other_nv.scene._trait_values["clip_plane_depth_azi_elev"] = list(
3083+
self.scene.clip_plane_depth_azi_elev
3084+
)
3085+
3086+
def sync(self):
3087+
"""Sync the scene controls from this NiiVue instance to others."""
3088+
for nv_obj in self.other_nv:
3089+
if not nv_obj._canvas_attached:
3090+
# todo: add logging msg here
3091+
continue
3092+
3093+
if self.sync_opts.get("gamma"):
3094+
self._do_sync_gamma(nv_obj)
3095+
if self.sync_opts.get("crosshair"):
3096+
self._do_sync_crosshair(nv_obj)
3097+
if self.sync_opts.get("zoom_pan"):
3098+
self._do_sync_zoom_pan(nv_obj)
3099+
if self.sync_opts.get("slice_type"):
3100+
self._do_sync_slice_type(nv_obj)
3101+
if self.sync_opts.get("cal_min"):
3102+
self._do_sync_cal_min(nv_obj)
3103+
if self.sync_opts.get("cal_max"):
3104+
self._do_sync_cal_max(nv_obj)
3105+
if self.sync_opts.get("clip_plane"):
3106+
self._do_sync_clip_plane(nv_obj)
3107+
3108+
# legacy 2d and 3d opts:
3109+
if self.sync_opts.get("2d"):
3110+
self._do_sync_2d(nv_obj)
3111+
if self.sync_opts.get("3d"):
3112+
self._do_sync_3d(nv_obj)
3113+
3114+
nv_obj._notify_scene_changed()
3115+
29363116
"""
29373117
Custom utils
29383118
"""

0 commit comments

Comments
 (0)