From 029df28c0abbfed3ea7d9d49f761f87d3bc4f9de Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Sep 2025 11:19:46 +0200 Subject: [PATCH 01/65] feat(python): add first version of linear reg workflow --- .../examples/example_linear_registration.py | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 python/examples/example_linear_registration.py diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py new file mode 100644 index 000000000..e5fd8e61d --- /dev/null +++ b/python/examples/example_linear_registration.py @@ -0,0 +1,233 @@ +import argparse +import webbrowser +from time import time + +import neuroglancer +import neuroglancer.cli + + +def create_demo_data(size=(64, 64, 64), radius=20): + import numpy as np + + data = np.zeros(size, dtype=np.uint8) + zz, yy, xx = np.indices(data.shape) + center = np.array(data.shape) / 2 + sphere_mask = (xx - center[2]) ** 2 + (yy - center[1]) ** 2 + ( + zz - center[0] + ) ** 2 < radius**2 + data[sphere_mask] = 255 + return data + + +def create_dimensions(): + return neuroglancer.CoordinateSpace( + names=["x", "y", "z"], units="nm", scales=[1, 1, 1] + ) + + +class LinearRegistrationWorkflow: + def __init__(self, template_url, source_url): + self.template_url = template_url + self.source_url = source_url + + if template_url is None or source_url is None: + self.demo_data = create_demo_data() + + self.status_timers = {} + self.setup_viewer() + self.last_run_points = 0 + + def _clear_status_messages(self): + to_pop = [] + for k, v in self.status_timers.items(): + if time() - v > 5: + to_pop.append(k) + for k in to_pop: + with self.viewer.config_state.txn() as s: + s.status_messages.pop(k, None) + self.status_timers.pop(k) + + def _set_status_message(self, key, message): + with self.viewer.config_state.txn() as s: + s.status_messages[key] = message + self.status_timers[key] = time() + + def setup_viewer(self): + self.viewer = viewer = neuroglancer.Viewer() + source_layer = self.create_source_image() + template_layer = self.create_template_image() + + with viewer.txn() as s: + s.layers["template"] = template_layer + s.layers["source"] = source_layer + s.layers["registered"] = source_layer + s.layers["registered"].visible = False + s.layers["markers"] = neuroglancer.LocalAnnotationLayer( + dimensions=create_dimensions(), + annotation_color="#00FF00", + ) + s.layout = neuroglancer.row_layout( + [ + neuroglancer.LayerGroupViewer(layers=["template", "markers"]), + neuroglancer.LayerGroupViewer( + layers=["source", "registered", "markers"] + ), + ] + ) + s.layers["markers"].tool = "annotatePoint" + s.selected_layer.layer = "markers" + s.selected_layer.visible = True + + self._set_status_message( + "help", + "Place markers in pairs, starting with the template, and then the source. The registered layer will automatically update as you add markers.", + ) + + self.viewer.shared_state.add_changed_callback( + lambda: self.viewer.defer_callback(self.on_state_changed) + ) + + def on_state_changed(self): + self.viewer.defer_callback(self.update) + + def update(self): + print("Updating") + with self.viewer.txn() as s: + self.estimate_affine(s) + self._clear_status_messages() + + def create_template_image(self): + if self.template_url is None: + return neuroglancer.ImageLayer( + source=[ + neuroglancer.LayerDataSource( + neuroglancer.LocalVolume( + self.demo_data, dimensions=create_dimensions() + ) + ) + ] + ) + else: + return neuroglancer.ImageLayer(source=self.template_url) + + def create_source_image(self): + if self.source_url is None: + import scipy.ndimage + + transformed = scipy.ndimage.affine_transform( + self.demo_data, + matrix=[[1, 0, 0], [0, 1, 0], [0.2, 0, 1]], + offset=1.0, + order=1, + ) + return neuroglancer.ImageLayer( + source=[ + neuroglancer.LayerDataSource( + neuroglancer.LocalVolume( + transformed, dimensions=create_dimensions() + ) + ) + ] + ) + else: + return neuroglancer.ImageLayer(source=self.source_url) + + def estimate_affine(self, s): + annotations = s.layers["markers"].annotations + # TODO expand this to estimate different types of transforms + # Depending on the number of points + # For now, just ignore non pairs + annotations = annotations[: (len(annotations) // 2) * 2] + if len(annotations) < 2: + return False + + print(len(annotations) // 2, self.last_run_points) + if len(annotations) // 2 == self.last_run_points: + return False + + template_points = [] + source_points = [] + for i, a in enumerate(annotations): + if i % 2 == 0: + template_points.append(a.point) + else: + source_points.append(a.point) + + import numpy as np + + template_points = np.array(template_points) + source_points = np.array(source_points) + + # Estimate affine transform using least squares, for now using + # https://stackoverflow.com/questions/20546182/how-to-perform-coordinates-affine-transformation-using-python-part-2 + # but can replace later + + n = template_points.shape[0] + pad = lambda x: np.hstack([x, np.ones((x.shape[0], 1))]) + unpad = lambda x: x[:, :-1] + X = pad(template_points) + Y = pad(source_points) + + # Solve the least squares problem X * A = Y + # to find our transformation matrix A + A, res, rank, sd = np.linalg.lstsq(X, Y) + # Zero out really small values on A + A[np.abs(A) < 1e-8] = 0 + + transform = lambda x: unpad(np.dot(pad(x), A)) + + transformed = transform(source_points) + print(f"Transformed points: {transformed}") + + # Set the transformation on the layer that is being registered + print(A.T[:3]) + # s.layers["registered"].source.transform = neuroglancer.CoordinateSpaceTransform( + # matrix=A.T[:3], + # ) + s.layers["registered"].source.transform = ( + neuroglancer.CoordinateSpaceTransform( + output_dimensions=create_dimensions(), + matrix=[[1, 0, 0, 0], [1, 1, 0, 0], [0, 0, 1, 0]], + ), + ) + + self._set_status_message( + ("info"), f"Estimated affine transform with {n} point pairs" + ) + # TODO actually want to check if the number of points or the points + # themselves have changed + self.last_run_points = n + return True + + +def handle_args(): + ap = argparse.ArgumentParser() + neuroglancer.cli.add_server_arguments(ap) + ap.add_argument( + "--template", + type=str, + help="Source URL for the template image", + ) + ap.add_argument( + "--source", + type=str, + help="Source URL for the image to be registered", + ) + args = ap.parse_args() + neuroglancer.cli.handle_server_arguments(args) + return args + + +def main(): + args = handle_args() + + demo = LinearRegistrationWorkflow( + template_url=args.template, + source_url=args.source, + ) + + webbrowser.open_new(demo.viewer.get_viewer_url()) + + +if __name__ == "__main__": + main() From eaf602a0ac72f18fced54d02a9fb694ee2b6e3e9 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 12 Sep 2025 12:54:57 +0200 Subject: [PATCH 02/65] feat(python): update the lin reg with more functions and TODOs --- .../examples/example_linear_registration.py | 73 +++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index e5fd8e61d..631e89941 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -2,6 +2,9 @@ import webbrowser from time import time +import scipy.ndimage +import numpy as np + import neuroglancer import neuroglancer.cli @@ -56,19 +59,24 @@ def setup_viewer(self): self.viewer = viewer = neuroglancer.Viewer() source_layer = self.create_source_image() template_layer = self.create_template_image() + registered_layer = self.create_registered_image() with viewer.txn() as s: s.layers["template"] = template_layer s.layers["source"] = source_layer - s.layers["registered"] = source_layer + s.layers["registered"] = registered_layer s.layers["registered"].visible = False s.layers["markers"] = neuroglancer.LocalAnnotationLayer( dimensions=create_dimensions(), annotation_color="#00FF00", ) + # TODO set these to be in 3D layout + # TODO unlink any controls that should be unlinked s.layout = neuroglancer.row_layout( [ - neuroglancer.LayerGroupViewer(layers=["template", "markers"]), + neuroglancer.LayerGroupViewer( + layers=["template", "registered", "markers"] + ), neuroglancer.LayerGroupViewer( layers=["source", "registered", "markers"] ), @@ -110,27 +118,47 @@ def create_template_image(self): else: return neuroglancer.ImageLayer(source=self.template_url) - def create_source_image(self): + # TODO probably need to be a little more careful about the size of the T matrix + # based on the number of input dims + def create_source_image(self, registration_matrix=None): + if registration_matrix is None: + registration_matrix = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]] if self.source_url is None: - import scipy.ndimage - + # TODO might be helpful to randomize this and check how close after registration + desired_output_matrix_homogenous = [ + [0.8, 0, 0, 0], + [0, 0.2, 0, 0], + [0, 0, 0.9, 0], + [0, 0, 0, 1], + ] + inverse_matrix = np.linalg.inv(desired_output_matrix_homogenous) transformed = scipy.ndimage.affine_transform( self.demo_data, - matrix=[[1, 0, 0], [0, 1, 0], [0.2, 0, 1]], - offset=1.0, - order=1, + matrix=inverse_matrix, ) return neuroglancer.ImageLayer( source=[ neuroglancer.LayerDataSource( neuroglancer.LocalVolume( transformed, dimensions=create_dimensions() - ) + ), + transform=neuroglancer.CoordinateSpaceTransform( + output_dimensions=create_dimensions(), + matrix=registration_matrix, + ), ) ] ) else: - return neuroglancer.ImageLayer(source=self.source_url) + return neuroglancer.ImageLayer( + source=self.source_url, + transform=neuroglancer.CoordinateSpaceTransform( + matrix=registration_matrix + ), + ) + + def create_registered_image(self, registration_matrix=None): + return self.create_source_image(registration_matrix=registration_matrix) def estimate_affine(self, s): annotations = s.layers["markers"].annotations @@ -141,6 +169,9 @@ def estimate_affine(self, s): if len(annotations) < 2: return False + # TODO allow a different way to group, such as by description + # TODO color points differently based on whether template or source + # in the shader print(len(annotations) // 2, self.last_run_points) if len(annotations) // 2 == self.last_run_points: return False @@ -172,7 +203,9 @@ def estimate_affine(self, s): # to find our transformation matrix A A, res, rank, sd = np.linalg.lstsq(X, Y) # Zero out really small values on A - A[np.abs(A) < 1e-8] = 0 + A[np.abs(A) < 1e-4] = 0 + # Round all other values to 4 decimal places + A = np.round(A, 4) transform = lambda x: unpad(np.dot(pad(x), A)) @@ -180,16 +213,14 @@ def estimate_affine(self, s): print(f"Transformed points: {transformed}") # Set the transformation on the layer that is being registered - print(A.T[:3]) - # s.layers["registered"].source.transform = neuroglancer.CoordinateSpaceTransform( - # matrix=A.T[:3], - # ) - s.layers["registered"].source.transform = ( - neuroglancer.CoordinateSpaceTransform( - output_dimensions=create_dimensions(), - matrix=[[1, 0, 0, 0], [1, 1, 0, 0], [0, 0, 1, 0]], - ), - ) + # Something seems to go wrong with the state updates once this happens + # s.layers["registered"].source[0].transform.matrix = A.T[:3] + # Because of this, trying to replace the whole layer to see if that helps + # TODO in theory should be able to just update the matrix, + # but if cannot, can replace whole layer and restore settings + old_visible = s.layers["registered"].visible + s.layers["registered"] = self.create_registered_image(A[:3]) + s.layers["registered"].visible = old_visible self._set_status_message( ("info"), f"Estimated affine transform with {n} point pairs" From dbbfef47d0bce8ea416e63b403de12a347e3fdcc Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 15 Sep 2025 16:20:42 +0200 Subject: [PATCH 03/65] feat: lin reg export and lin reg unlink --- .../examples/example_linear_registration.py | 152 ++++++++++++------ 1 file changed, 101 insertions(+), 51 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 631e89941..4263f809e 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -2,11 +2,14 @@ import webbrowser from time import time -import scipy.ndimage -import numpy as np - import neuroglancer import neuroglancer.cli +import numpy as np +import scipy.ndimage + +MESSAGE_DURATION = 5 # seconds +# TODO maybe can avoid this being a param or const +NUM_DIMS = 3 def create_demo_data(size=(64, 64, 64), radius=20): @@ -28,22 +31,32 @@ def create_dimensions(): ) +def create_identity_matrix(): + id_list = [[int(i == j) for j in range(NUM_DIMS + 1)] for i in range(NUM_DIMS)] + return np.array(id_list) + + class LinearRegistrationWorkflow: def __init__(self, template_url, source_url): self.template_url = template_url self.source_url = source_url + self.status_timers = {} + self.stored_points = [[], []] + self.affine = create_identity_matrix() if template_url is None or source_url is None: self.demo_data = create_demo_data() - self.status_timers = {} self.setup_viewer() - self.last_run_points = 0 + + def __str__(self): + with self.viewer.txn() as s: + return str(s) def _clear_status_messages(self): to_pop = [] for k, v in self.status_timers.items(): - if time() - v > 5: + if time() - v > MESSAGE_DURATION: to_pop.append(k) for k in to_pop: with self.viewer.config_state.txn() as s: @@ -55,6 +68,20 @@ def _set_status_message(self, key, message): s.status_messages[key] = message self.status_timers[key] = time() + def transform_template_points(self, template_points): + # Apply the current affine transform to the template points + n = template_points.shape[0] + pad = lambda x: np.hstack([x, np.ones((x.shape[0], 1))]) + unpad = lambda x: x[:, :-1] + X = pad(template_points) + transformed = X @ self.affine.T + return unpad(transformed) + + def toggle_registered_visibility(self, _): + with self.viewer.txn() as s: + s.layers["registered"].visible = not s.layers["registered"].visible + s.layers["template"].visible = not s.layers["registered"].visible + def setup_viewer(self): self.viewer = viewer = neuroglancer.Viewer() source_layer = self.create_source_image() @@ -70,25 +97,35 @@ def setup_viewer(self): dimensions=create_dimensions(), annotation_color="#00FF00", ) - # TODO set these to be in 3D layout - # TODO unlink any controls that should be unlinked s.layout = neuroglancer.row_layout( [ neuroglancer.LayerGroupViewer( - layers=["template", "registered", "markers"] + layers=["template", "registered", "markers"], layout="xy-3d" ), neuroglancer.LayerGroupViewer( - layers=["source", "registered", "markers"] + layers=["source", "markers"], layout="xy-3d" ), ] ) + s.layout.children[1].position.link = "unlinked" + s.layout.children[1].crossSectionOrientation.link = "unlinked" + s.layout.children[1].crossSectionScale.link = "unlinked" + s.layout.children[1].projectionOrientation.link = "unlinked" + s.layout.children[1].projectionScale.link = "unlinked" s.layers["markers"].tool = "annotatePoint" s.selected_layer.layer = "markers" s.selected_layer.visible = True + viewer.actions.add( + "toggle-registered-visibility", self.toggle_registered_visibility + ) + + with viewer.config_state.txn() as s: + s.input_event_bindings.viewer["keyt"] = "toggle-registered-visibility" + self._set_status_message( "help", - "Place markers in pairs, starting with the template, and then the source. The registered layer will automatically update as you add markers.", + "Place markers in pairs, starting with the template, and then the source. The registered layer will automatically update as you add markers. Press 't' to toggle between viewing the template and registered layers.", ) self.viewer.shared_state.add_changed_callback( @@ -99,7 +136,6 @@ def on_state_changed(self): self.viewer.defer_callback(self.update) def update(self): - print("Updating") with self.viewer.txn() as s: self.estimate_affine(s) self._clear_status_messages() @@ -118,11 +154,12 @@ def create_template_image(self): else: return neuroglancer.ImageLayer(source=self.template_url) - # TODO probably need to be a little more careful about the size of the T matrix - # based on the number of input dims def create_source_image(self, registration_matrix=None): - if registration_matrix is None: - registration_matrix = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]] + registration_matrix = ( + list(create_identity_matrix()) + if registration_matrix is None + else registration_matrix + ) if self.source_url is None: # TODO might be helpful to randomize this and check how close after registration desired_output_matrix_homogenous = [ @@ -157,25 +194,11 @@ def create_source_image(self, registration_matrix=None): ), ) - def create_registered_image(self, registration_matrix=None): - return self.create_source_image(registration_matrix=registration_matrix) - - def estimate_affine(self, s): - annotations = s.layers["markers"].annotations - # TODO expand this to estimate different types of transforms - # Depending on the number of points - # For now, just ignore non pairs - annotations = annotations[: (len(annotations) // 2) * 2] - if len(annotations) < 2: - return False + def create_registered_image(self): + return self.create_source_image(registration_matrix=self.affine) + def split_points_into_pairs(self, annotations): # TODO allow a different way to group, such as by description - # TODO color points differently based on whether template or source - # in the shader - print(len(annotations) // 2, self.last_run_points) - if len(annotations) // 2 == self.last_run_points: - return False - template_points = [] source_points = [] for i, a in enumerate(annotations): @@ -183,11 +206,30 @@ def estimate_affine(self, s): template_points.append(a.point) else: source_points.append(a.point) + return np.array(template_points), np.array(source_points) + + def estimate_affine(self, s): + # TODO do we need to throttle this update or make it manually triggered? + annotations = s.layers["markers"].annotations + if len(annotations) < 2: + return False + # TODO expand this to estimate different types of transforms + # Depending on the number of points - import numpy as np + # Ignore annotations not part of a pair + annotations = annotations[: (len(annotations) // 2) * 2] + template_points, source_points = self.split_points_into_pairs(annotations) + if len(self.stored_points[0]) == len(template_points) and len( + self.stored_points[1] + ) == len(source_points): + if np.all(np.isclose(self.stored_points[0], template_points)) and np.all( + np.isclose(self.stored_points[1], source_points) + ): + return False - template_points = np.array(template_points) - source_points = np.array(source_points) + # TODO color points differently based on whether template or source + # in the shader + template_points, source_points = self.split_points_into_pairs(annotations) # Estimate affine transform using least squares, for now using # https://stackoverflow.com/questions/20546182/how-to-perform-coordinates-affine-transformation-using-python-part-2 @@ -195,7 +237,6 @@ def estimate_affine(self, s): n = template_points.shape[0] pad = lambda x: np.hstack([x, np.ones((x.shape[0], 1))]) - unpad = lambda x: x[:, :-1] X = pad(template_points) Y = pad(source_points) @@ -206,11 +247,7 @@ def estimate_affine(self, s): A[np.abs(A) < 1e-4] = 0 # Round all other values to 4 decimal places A = np.round(A, 4) - - transform = lambda x: unpad(np.dot(pad(x), A)) - - transformed = transform(source_points) - print(f"Transformed points: {transformed}") + self.affine = A[:NUM_DIMS] # Set the transformation on the layer that is being registered # Something seems to go wrong with the state updates once this happens @@ -219,17 +256,34 @@ def estimate_affine(self, s): # TODO in theory should be able to just update the matrix, # but if cannot, can replace whole layer and restore settings old_visible = s.layers["registered"].visible - s.layers["registered"] = self.create_registered_image(A[:3]) + s.layers["registered"] = self.create_registered_image() s.layers["registered"].visible = old_visible self._set_status_message( ("info"), f"Estimated affine transform with {n} point pairs" ) - # TODO actually want to check if the number of points or the points - # themselves have changed - self.last_run_points = n + self.stored_points = [template_points, source_points] return True + def get_registration_info(self): + info = {} + with self.viewer.txn() as s: + annotations = s.layers["markers"].annotations + template_points, source_points = self.split_points_into_pairs(annotations) + # transformed_points = self.transform_template_points(template_points) + info["template"] = template_points.tolist() + info["source"] = source_points.tolist() + # info["transformed_template"] = transformed_points.tolist() + info["transform"] = self.affine.tolist() + return info + + def dump_info(self, path: str): + import json + + info = self.get_registration_info() + with open(path, "w") as f: + json.dump(info, f, indent=4) + def handle_args(): ap = argparse.ArgumentParser() @@ -249,7 +303,7 @@ def handle_args(): return args -def main(): +if __name__ == "__main__": args = handle_args() demo = LinearRegistrationWorkflow( @@ -258,7 +312,3 @@ def main(): ) webbrowser.open_new(demo.viewer.get_viewer_url()) - - -if __name__ == "__main__": - main() From 97837bfa50201a63564feae17f3bdc8275651e3f Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 15 Sep 2025 16:46:17 +0200 Subject: [PATCH 04/65] feat: add shader and annotation props to lin reg --- .../examples/example_linear_registration.py | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 4263f809e..099f7c801 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -11,6 +11,20 @@ # TODO maybe can avoid this being a param or const NUM_DIMS = 3 +MARKERS_SHADER = """ +#uicontrol vec3 templatePointColor color(default="#00FF00") +#uicontrol vec3 sourcePointColor color(default="#0000FF") +#uicontrol float pointSize slider(min=1, max=16, default=6) +void main() { + if (int(prop_index()) % 2 == 0) { + setColor(templatePointColor); + } else { + setColor(sourcePointColor); + } + setPointMarkerSize(pointSize); +} +""" + def create_demo_data(size=(64, 64, 64), radius=20): import numpy as np @@ -95,7 +109,26 @@ def setup_viewer(self): s.layers["registered"].visible = False s.layers["markers"] = neuroglancer.LocalAnnotationLayer( dimensions=create_dimensions(), - annotation_color="#00FF00", + annotation_properties=[ + neuroglancer.AnnotationPropertySpec( + id="label", + type="uint32", + default=0, + ), + neuroglancer.AnnotationPropertySpec( + id="group", + type="uint8", + default=0, + enum_labels=["template", "source"], + enum_values=[0, 1], + ), + neuroglancer.AnnotationPropertySpec( + id="index", + type="uint32", + default=0, + ), + ], + shader=MARKERS_SHADER, ) s.layout = neuroglancer.row_layout( [ @@ -136,6 +169,12 @@ def on_state_changed(self): self.viewer.defer_callback(self.update) def update(self): + # TODO would either remove or throttle this + # It could be useful to keep it with a throttle + # because sometimes the state updates can get killed + # and this would help for seeing that to the user + # since it might be hard for us to catch all cases + print("State updated, handling") with self.viewer.txn() as s: self.estimate_affine(s) self._clear_status_messages() @@ -210,6 +249,7 @@ def split_points_into_pairs(self, annotations): def estimate_affine(self, s): # TODO do we need to throttle this update or make it manually triggered? + # While updating by moving a point, this can break right now annotations = s.layers["markers"].annotations if len(annotations) < 2: return False @@ -226,9 +266,10 @@ def estimate_affine(self, s): np.isclose(self.stored_points[1], source_points) ): return False + else: + for i, a in enumerate(s.layers["markers"].annotations): + a.props = [i // 2, i % 2, i] - # TODO color points differently based on whether template or source - # in the shader template_points, source_points = self.split_points_into_pairs(annotations) # Estimate affine transform using least squares, for now using @@ -260,7 +301,7 @@ def estimate_affine(self, s): s.layers["registered"].visible = old_visible self._set_status_message( - ("info"), f"Estimated affine transform with {n} point pairs" + "info", f"Estimated affine transform with {n} point pairs" ) self.stored_points = [template_points, source_points] return True From 8fbfeb4be164d184ee65f2305e7cf49b9935e5a4 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 15 Sep 2025 16:50:23 +0200 Subject: [PATCH 05/65] chore: add light typing to lin reg --- python/examples/example_linear_registration.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 099f7c801..f28c49022 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -26,7 +26,7 @@ """ -def create_demo_data(size=(64, 64, 64), radius=20): +def create_demo_data(size: tuple[int, int, int] = (64, 64, 64), radius: float = 20): import numpy as np data = np.zeros(size, dtype=np.uint8) @@ -51,7 +51,7 @@ def create_identity_matrix(): class LinearRegistrationWorkflow: - def __init__(self, template_url, source_url): + def __init__(self, template_url: str, source_url: str): self.template_url = template_url self.source_url = source_url self.status_timers = {} @@ -77,12 +77,12 @@ def _clear_status_messages(self): s.status_messages.pop(k, None) self.status_timers.pop(k) - def _set_status_message(self, key, message): + def _set_status_message(self, key: str, message: str): with self.viewer.config_state.txn() as s: s.status_messages[key] = message self.status_timers[key] = time() - def transform_template_points(self, template_points): + def transform_template_points(self, template_points : np.ndarray): # Apply the current affine transform to the template points n = template_points.shape[0] pad = lambda x: np.hstack([x, np.ones((x.shape[0], 1))]) @@ -193,7 +193,7 @@ def create_template_image(self): else: return neuroglancer.ImageLayer(source=self.template_url) - def create_source_image(self, registration_matrix=None): + def create_source_image(self, registration_matrix : list | np.ndarray | None = None): registration_matrix = ( list(create_identity_matrix()) if registration_matrix is None @@ -247,7 +247,7 @@ def split_points_into_pairs(self, annotations): source_points.append(a.point) return np.array(template_points), np.array(source_points) - def estimate_affine(self, s): + def estimate_affine(self, s : neuroglancer.ViewerState): # TODO do we need to throttle this update or make it manually triggered? # While updating by moving a point, this can break right now annotations = s.layers["markers"].annotations From 990ee0d45679d0fbbbe1855498d429645f830135 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 15 Sep 2025 17:11:28 +0200 Subject: [PATCH 06/65] feat: small update to print of updates --- .../examples/example_linear_registration.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index f28c49022..a5a7338fe 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -62,6 +62,7 @@ def __init__(self, template_url: str, source_url: str): self.demo_data = create_demo_data() self.setup_viewer() + self.last_updated_print = -1 def __str__(self): with self.viewer.txn() as s: @@ -82,7 +83,7 @@ def _set_status_message(self, key: str, message: str): s.status_messages[key] = message self.status_timers[key] = time() - def transform_template_points(self, template_points : np.ndarray): + def transform_template_points(self, template_points: np.ndarray): # Apply the current affine transform to the template points n = template_points.shape[0] pad = lambda x: np.hstack([x, np.ones((x.shape[0], 1))]) @@ -169,12 +170,11 @@ def on_state_changed(self): self.viewer.defer_callback(self.update) def update(self): - # TODO would either remove or throttle this - # It could be useful to keep it with a throttle - # because sometimes the state updates can get killed - # and this would help for seeing that to the user - # since it might be hard for us to catch all cases - print("State updated, handling") + current_time = time() + if current_time - self.last_updated_print > 1: + # TODO format the time nicely in the print + print(f"Viewer states are successfully syncing at {current_time}") + self.last_updated_print = current_time with self.viewer.txn() as s: self.estimate_affine(s) self._clear_status_messages() @@ -193,14 +193,13 @@ def create_template_image(self): else: return neuroglancer.ImageLayer(source=self.template_url) - def create_source_image(self, registration_matrix : list | np.ndarray | None = None): + def create_source_image(self, registration_matrix: list | np.ndarray | None = None): registration_matrix = ( list(create_identity_matrix()) if registration_matrix is None else registration_matrix ) if self.source_url is None: - # TODO might be helpful to randomize this and check how close after registration desired_output_matrix_homogenous = [ [0.8, 0, 0, 0], [0, 0.2, 0, 0], @@ -237,7 +236,12 @@ def create_registered_image(self): return self.create_source_image(registration_matrix=self.affine) def split_points_into_pairs(self, annotations): - # TODO allow a different way to group, such as by description + # TODO allow a different way to group. Right now the order informs + # the properties + # But ideally we'd like to allow the other way around as well + # This needs to be reworked just a bit to allow that + # As a first step for that, this should inspect the properties + # and group based on that instead of this grouping template_points = [] source_points = [] for i, a in enumerate(annotations): @@ -247,7 +251,7 @@ def split_points_into_pairs(self, annotations): source_points.append(a.point) return np.array(template_points), np.array(source_points) - def estimate_affine(self, s : neuroglancer.ViewerState): + def estimate_affine(self, s: neuroglancer.ViewerState): # TODO do we need to throttle this update or make it manually triggered? # While updating by moving a point, this can break right now annotations = s.layers["markers"].annotations @@ -267,6 +271,8 @@ def estimate_affine(self, s : neuroglancer.ViewerState): ): return False else: + # TODO instead of directly doing the update, debounce it and only do + # it after a short delay if no other updates for i, a in enumerate(s.layers["markers"].annotations): a.props = [i // 2, i % 2, i] From f5eba1033f423b7cf68ece0586e83202e3fe81b8 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 18 Sep 2025 11:46:02 +0200 Subject: [PATCH 07/65] feat: remove dims const --- .../examples/example_linear_registration.py | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index a5a7338fe..3406c79f2 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -8,8 +8,6 @@ import scipy.ndimage MESSAGE_DURATION = 5 # seconds -# TODO maybe can avoid this being a param or const -NUM_DIMS = 3 MARKERS_SHADER = """ #uicontrol vec3 templatePointColor color(default="#00FF00") @@ -45,8 +43,8 @@ def create_dimensions(): ) -def create_identity_matrix(): - id_list = [[int(i == j) for j in range(NUM_DIMS + 1)] for i in range(NUM_DIMS)] +def create_identity_matrix(num_dims: int): + id_list = [[int(i == j) for j in range(num_dims + 1)] for i in range(num_dims)] return np.array(id_list) @@ -56,7 +54,7 @@ def __init__(self, template_url: str, source_url: str): self.source_url = source_url self.status_timers = {} self.stored_points = [[], []] - self.affine = create_identity_matrix() + self.affine = [] if template_url is None or source_url is None: self.demo_data = create_demo_data() @@ -194,11 +192,9 @@ def create_template_image(self): return neuroglancer.ImageLayer(source=self.template_url) def create_source_image(self, registration_matrix: list | np.ndarray | None = None): - registration_matrix = ( - list(create_identity_matrix()) - if registration_matrix is None - else registration_matrix - ) + transform_kwargs = {} + if registration_matrix is not None: + transform_kwargs["matrix"] = registration_matrix if self.source_url is None: desired_output_matrix_homogenous = [ [0.8, 0, 0, 0], @@ -219,7 +215,7 @@ def create_source_image(self, registration_matrix: list | np.ndarray | None = No ), transform=neuroglancer.CoordinateSpaceTransform( output_dimensions=create_dimensions(), - matrix=registration_matrix, + **transform_kwargs, ), ) ] @@ -227,9 +223,7 @@ def create_source_image(self, registration_matrix: list | np.ndarray | None = No else: return neuroglancer.ImageLayer( source=self.source_url, - transform=neuroglancer.CoordinateSpaceTransform( - matrix=registration_matrix - ), + transform=neuroglancer.CoordinateSpaceTransform(**transform_kwargs), ) def create_registered_image(self): @@ -294,7 +288,8 @@ def estimate_affine(self, s: neuroglancer.ViewerState): A[np.abs(A) < 1e-4] = 0 # Round all other values to 4 decimal places A = np.round(A, 4) - self.affine = A[:NUM_DIMS] + num_dims = X.shape[1] - 1 + self.affine = A[:num_dims] # Set the transformation on the layer that is being registered # Something seems to go wrong with the state updates once this happens From f18f20796ca4ba7215d63082c12a379e5167894e Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 18 Sep 2025 14:49:41 +0200 Subject: [PATCH 08/65] feat: scaffold n dim lsqs affine --- .../examples/example_linear_registration.py | 117 ++++++++++++------ 1 file changed, 81 insertions(+), 36 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 3406c79f2..e56725fdb 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -8,6 +8,7 @@ import scipy.ndimage MESSAGE_DURATION = 5 # seconds +NUM_DEMO_DIMS = 2 # Currently can be 2D or 3D MARKERS_SHADER = """ #uicontrol vec3 templatePointColor color(default="#00FF00") @@ -24,10 +25,57 @@ """ -def create_demo_data(size: tuple[int, int, int] = (64, 64, 64), radius: float = 20): +def affine_fit(template_points: np.ndarray, target_points: np.ndarray): + # Source points and target points are NxD arrays + assert template_points.shape == target_points.shape + N = template_points.shape[0] + D = template_points.shape[1] + T = template_points + + # Target values (B) is a D * N array + # Input values (A) is a D * N, (D * (D + 1)) array + # Output estimation is a (D * (D + 1)) array + A = np.zeros(((D * N), D * (D + 1))) + for i in range(N): + for j in range(D): + start_index = j * D + end_index = (j + 1) * D + A[D * i + j, start_index:end_index] = T[i] + A[D * i + j, D * D + j] = 1 + B = target_points.T.flatten() + + print(A.shape, B.shape) + print(A, B) + # The estimated affine transform params will be flattened + # and there will be D * (D + 1) of them + # Format is x1, x2, ..., b1, b2, ... + tvec, res, rank, sd = np.linalg.lstsq(A, B) + print(A, target_points, tvec) + # Put the flattened version back into the matrix + affine = np.zeros((D, D + 1)) + for i in range(D): + start_index = i * D + end_index = start_index + D + affine[i, :D] = tvec[start_index:end_index] + affine[i, -1] = tvec[D * D + i] + + # Round to close decimal + affine = np.round(affine, decimals=2) + print(affine) + return affine + + +def create_demo_data(size: int | tuple = 60, radius: float = 20): import numpy as np - data = np.zeros(size, dtype=np.uint8) + data_size = (size,) * NUM_DEMO_DIMS if isinstance(size, int) else size + data = np.zeros(data_size, dtype=np.uint8) + if NUM_DEMO_DIMS == 2: + yy, xx = np.indices(data.shape) + center = np.array(data.shape) / 2 + circle_mask = (xx - center[1]) ** 2 + (yy - center[0]) ** 2 < radius**2 + data[circle_mask] = 255 + return data zz, yy, xx = np.indices(data.shape) center = np.array(data.shape) / 2 sphere_mask = (xx - center[2]) ** 2 + (yy - center[1]) ** 2 + ( @@ -38,6 +86,8 @@ def create_demo_data(size: tuple[int, int, int] = (64, 64, 64), radius: float = def create_dimensions(): + if NUM_DEMO_DIMS == 2: + return neuroglancer.CoordinateSpace(names=["x", "y"], units="nm", scales=[1, 1]) return neuroglancer.CoordinateSpace( names=["x", "y", "z"], units="nm", scales=[1, 1, 1] ) @@ -54,7 +104,8 @@ def __init__(self, template_url: str, source_url: str): self.source_url = source_url self.status_timers = {} self.stored_points = [[], []] - self.affine = [] + # Will be an Nx(N+1) matrix for N input dimensions + self.affine = None if template_url is None or source_url is None: self.demo_data = create_demo_data() @@ -81,14 +132,13 @@ def _set_status_message(self, key: str, message: str): s.status_messages[key] = message self.status_timers[key] = time() - def transform_template_points(self, template_points: np.ndarray): - # Apply the current affine transform to the template points - n = template_points.shape[0] - pad = lambda x: np.hstack([x, np.ones((x.shape[0], 1))]) - unpad = lambda x: x[:, :-1] - X = pad(template_points) - transformed = X @ self.affine.T - return unpad(transformed) + def transform_points(self, points: np.ndarray): + # Apply the current affine transform to the points + transformed = np.zeros_like(points) + padded = np.pad(points, ((0, 0), (0, 1)), constant_values=1) + for i in range(len(points)): + transformed[i] = self.affine @ padded[i] + return transformed def toggle_registered_visibility(self, _): with self.viewer.txn() as s: @@ -196,17 +246,25 @@ def create_source_image(self, registration_matrix: list | np.ndarray | None = No if registration_matrix is not None: transform_kwargs["matrix"] = registration_matrix if self.source_url is None: - desired_output_matrix_homogenous = [ - [0.8, 0, 0, 0], - [0, 0.2, 0, 0], - [0, 0, 0.9, 0], - [0, 0, 0, 1], - ] + if NUM_DEMO_DIMS == 2: + desired_output_matrix_homogenous = [ + [0.8, 0, 0], + [0, 0.2, 0], + [0, 0, 1], + ] + else: + desired_output_matrix_homogenous = [ + [0.8, 0, 0, 0], + [0, 0.2, 0, 0], + [0, 0, 0.9, 0], + [0, 0, 0, 1], + ] inverse_matrix = np.linalg.inv(desired_output_matrix_homogenous) transformed = scipy.ndimage.affine_transform( self.demo_data, matrix=inverse_matrix, ) + print(inverse_matrix) return neuroglancer.ImageLayer( source=[ neuroglancer.LayerDataSource( @@ -272,24 +330,8 @@ def estimate_affine(self, s: neuroglancer.ViewerState): template_points, source_points = self.split_points_into_pairs(annotations) - # Estimate affine transform using least squares, for now using - # https://stackoverflow.com/questions/20546182/how-to-perform-coordinates-affine-transformation-using-python-part-2 - # but can replace later - - n = template_points.shape[0] - pad = lambda x: np.hstack([x, np.ones((x.shape[0], 1))]) - X = pad(template_points) - Y = pad(source_points) - - # Solve the least squares problem X * A = Y - # to find our transformation matrix A - A, res, rank, sd = np.linalg.lstsq(X, Y) - # Zero out really small values on A - A[np.abs(A) < 1e-4] = 0 - # Round all other values to 4 decimal places - A = np.round(A, 4) - num_dims = X.shape[1] - 1 - self.affine = A[:num_dims] + # Estimate transform + self.affine = affine_fit(template_points, source_points) # Set the transformation on the layer that is being registered # Something seems to go wrong with the state updates once this happens @@ -302,8 +344,11 @@ def estimate_affine(self, s: neuroglancer.ViewerState): s.layers["registered"].visible = old_visible self._set_status_message( - "info", f"Estimated affine transform with {n} point pairs" + "info", + f"Estimated affine transform with {len(annotations) // 2} point pairs", ) + print("Estimated points are", self.transform_points(source_points)) + print("Original points are", template_points) self.stored_points = [template_points, source_points] return True From b9ec70cd84f8f1d08d5c0c81c1f7a9fd72021084 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 18 Sep 2025 15:23:58 +0200 Subject: [PATCH 09/65] fix: correct calling affine --- python/examples/example_linear_registration.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index e56725fdb..a748c0d91 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -42,10 +42,8 @@ def affine_fit(template_points: np.ndarray, target_points: np.ndarray): end_index = (j + 1) * D A[D * i + j, start_index:end_index] = T[i] A[D * i + j, D * D + j] = 1 - B = target_points.T.flatten() + B = target_points.flatten() - print(A.shape, B.shape) - print(A, B) # The estimated affine transform params will be flattened # and there will be D * (D + 1) of them # Format is x1, x2, ..., b1, b2, ... @@ -62,9 +60,19 @@ def affine_fit(template_points: np.ndarray, target_points: np.ndarray): # Round to close decimal affine = np.round(affine, decimals=2) print(affine) + print(transform_points(affine, template_points)) return affine +def transform_points(affine: np.ndarray, points: np.ndarray): + # Apply the current affine transform to the points + transformed = np.zeros_like(points) + padded = np.pad(points, ((0, 0), (0, 1)), constant_values=1) + for i in range(len(points)): + transformed[i] = affine @ padded[i] + return transformed + + def create_demo_data(size: int | tuple = 60, radius: float = 20): import numpy as np @@ -331,7 +339,7 @@ def estimate_affine(self, s: neuroglancer.ViewerState): template_points, source_points = self.split_points_into_pairs(annotations) # Estimate transform - self.affine = affine_fit(template_points, source_points) + self.affine = affine_fit(source_points, template_points) # Set the transformation on the layer that is being registered # Something seems to go wrong with the state updates once this happens From e58ca3a9c341df33f0aeb3f80e4a9795994dcf62 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 18 Sep 2025 15:53:58 +0200 Subject: [PATCH 10/65] refactor: standardise terms --- .../examples/example_linear_registration.py | 155 ++++++++---------- 1 file changed, 71 insertions(+), 84 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index a748c0d91..8a0315b60 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -1,6 +1,6 @@ import argparse import webbrowser -from time import time +from time import time, ctime import neuroglancer import neuroglancer.cli @@ -11,26 +11,26 @@ NUM_DEMO_DIMS = 2 # Currently can be 2D or 3D MARKERS_SHADER = """ -#uicontrol vec3 templatePointColor color(default="#00FF00") -#uicontrol vec3 sourcePointColor color(default="#0000FF") +#uicontrol vec3 fixedPointColor color(default="#00FF00") +#uicontrol vec3 movingPointColor color(default="#0000FF") #uicontrol float pointSize slider(min=1, max=16, default=6) void main() { - if (int(prop_index()) % 2 == 0) { - setColor(templatePointColor); + if (int(prop_group()) == 0) { + setColor(fixedPointColor); } else { - setColor(sourcePointColor); + setColor(movingPointColor); } setPointMarkerSize(pointSize); } """ -def affine_fit(template_points: np.ndarray, target_points: np.ndarray): - # Source points and target points are NxD arrays - assert template_points.shape == target_points.shape - N = template_points.shape[0] - D = template_points.shape[1] - T = template_points +def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): + # Points are NxD arrays + assert fixed_points.shape == moving_points.shape + N = fixed_points.shape[0] + D = fixed_points.shape[1] + T = fixed_points # Target values (B) is a D * N array # Input values (A) is a D * N, (D * (D + 1)) array @@ -42,13 +42,13 @@ def affine_fit(template_points: np.ndarray, target_points: np.ndarray): end_index = (j + 1) * D A[D * i + j, start_index:end_index] = T[i] A[D * i + j, D * D + j] = 1 - B = target_points.flatten() + B = moving_points.flatten() # The estimated affine transform params will be flattened # and there will be D * (D + 1) of them # Format is x1, x2, ..., b1, b2, ... tvec, res, rank, sd = np.linalg.lstsq(A, B) - print(A, target_points, tvec) + # Put the flattened version back into the matrix affine = np.zeros((D, D + 1)) for i in range(D): @@ -57,10 +57,8 @@ def affine_fit(template_points: np.ndarray, target_points: np.ndarray): affine[i, :D] = tvec[start_index:end_index] affine[i, -1] = tvec[D * D + i] - # Round to close decimal - affine = np.round(affine, decimals=2) - print(affine) - print(transform_points(affine, template_points)) + # Round to close decimals + affine = np.round(affine, decimals=3) return affine @@ -74,8 +72,6 @@ def transform_points(affine: np.ndarray, points: np.ndarray): def create_demo_data(size: int | tuple = 60, radius: float = 20): - import numpy as np - data_size = (size,) * NUM_DEMO_DIMS if isinstance(size, int) else size data = np.zeros(data_size, dtype=np.uint8) if NUM_DEMO_DIMS == 2: @@ -93,6 +89,7 @@ def create_demo_data(size: int | tuple = 60, radius: float = 20): return data +# TODO can we avoid calling this at all? Can the layers just be created and dims inferred? def create_dimensions(): if NUM_DEMO_DIMS == 2: return neuroglancer.CoordinateSpace(names=["x", "y"], units="nm", scales=[1, 1]) @@ -101,21 +98,15 @@ def create_dimensions(): ) -def create_identity_matrix(num_dims: int): - id_list = [[int(i == j) for j in range(num_dims + 1)] for i in range(num_dims)] - return np.array(id_list) - - class LinearRegistrationWorkflow: - def __init__(self, template_url: str, source_url: str): - self.template_url = template_url - self.source_url = source_url + def __init__(self, fixed_url: str, moving_url: str): + self.fixed_url = fixed_url + self.moving_url = moving_url self.status_timers = {} self.stored_points = [[], []] - # Will be an Nx(N+1) matrix for N input dimensions self.affine = None - if template_url is None or source_url is None: + if fixed_url is None or moving_url is None: self.demo_data = create_demo_data() self.setup_viewer() @@ -140,28 +131,26 @@ def _set_status_message(self, key: str, message: str): s.status_messages[key] = message self.status_timers[key] = time() - def transform_points(self, points: np.ndarray): - # Apply the current affine transform to the points - transformed = np.zeros_like(points) - padded = np.pad(points, ((0, 0), (0, 1)), constant_values=1) - for i in range(len(points)): - transformed[i] = self.affine @ padded[i] - return transformed + def transform_points_with_affine(self, points: np.ndarray): + if self.affine is not None: + return transform_points(self.affine, points) def toggle_registered_visibility(self, _): with self.viewer.txn() as s: s.layers["registered"].visible = not s.layers["registered"].visible - s.layers["template"].visible = not s.layers["registered"].visible + s.layers["fixed"].visible = not s.layers["registered"].visible + s.layers["markers"].visible = not s.layers["registered"].visible + s.layers["mappedMarkers"].visible = s.layers["registered"].visible def setup_viewer(self): self.viewer = viewer = neuroglancer.Viewer() - source_layer = self.create_source_image() - template_layer = self.create_template_image() + fixed_layer = self.create_fixed_image() + moving_layer = self.create_moving_image() registered_layer = self.create_registered_image() with viewer.txn() as s: - s.layers["template"] = template_layer - s.layers["source"] = source_layer + s.layers["fixed"] = fixed_layer + s.layers["moving"] = moving_layer s.layers["registered"] = registered_layer s.layers["registered"].visible = False s.layers["markers"] = neuroglancer.LocalAnnotationLayer( @@ -176,7 +165,7 @@ def setup_viewer(self): id="group", type="uint8", default=0, - enum_labels=["template", "source"], + enum_labels=["fixed", "moving"], enum_values=[0, 1], ), neuroglancer.AnnotationPropertySpec( @@ -190,10 +179,10 @@ def setup_viewer(self): s.layout = neuroglancer.row_layout( [ neuroglancer.LayerGroupViewer( - layers=["template", "registered", "markers"], layout="xy-3d" + layers=["fixed", "registered", "markers"], layout="xy-3d" ), neuroglancer.LayerGroupViewer( - layers=["source", "markers"], layout="xy-3d" + layers=["moving", "markers"], layout="xy-3d" ), ] ) @@ -215,7 +204,7 @@ def setup_viewer(self): self._set_status_message( "help", - "Place markers in pairs, starting with the template, and then the source. The registered layer will automatically update as you add markers. Press 't' to toggle between viewing the template and registered layers.", + "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle between viewing the fixed and registered layers.", ) self.viewer.shared_state.add_changed_callback( @@ -227,16 +216,15 @@ def on_state_changed(self): def update(self): current_time = time() - if current_time - self.last_updated_print > 1: - # TODO format the time nicely in the print - print(f"Viewer states are successfully syncing at {current_time}") + if current_time - self.last_updated_print > 5: + print(f"Viewer states are successfully syncing at {ctime()}") self.last_updated_print = current_time with self.viewer.txn() as s: self.estimate_affine(s) self._clear_status_messages() - def create_template_image(self): - if self.template_url is None: + def create_fixed_image(self): + if self.fixed_url is None: return neuroglancer.ImageLayer( source=[ neuroglancer.LayerDataSource( @@ -247,13 +235,13 @@ def create_template_image(self): ] ) else: - return neuroglancer.ImageLayer(source=self.template_url) + return neuroglancer.ImageLayer(source=self.fixed_url) - def create_source_image(self, registration_matrix: list | np.ndarray | None = None): + def create_moving_image(self, registration_matrix: list | np.ndarray | None = None): transform_kwargs = {} if registration_matrix is not None: transform_kwargs["matrix"] = registration_matrix - if self.source_url is None: + if self.moving_url is None: if NUM_DEMO_DIMS == 2: desired_output_matrix_homogenous = [ [0.8, 0, 0], @@ -288,12 +276,12 @@ def create_source_image(self, registration_matrix: list | np.ndarray | None = No ) else: return neuroglancer.ImageLayer( - source=self.source_url, + source=self.moving_url, transform=neuroglancer.CoordinateSpaceTransform(**transform_kwargs), ) def create_registered_image(self): - return self.create_source_image(registration_matrix=self.affine) + return self.create_moving_image(registration_matrix=self.affine) def split_points_into_pairs(self, annotations): # TODO allow a different way to group. Right now the order informs @@ -302,14 +290,14 @@ def split_points_into_pairs(self, annotations): # This needs to be reworked just a bit to allow that # As a first step for that, this should inspect the properties # and group based on that instead of this grouping - template_points = [] - source_points = [] + fixed_points = [] + moving_points = [] for i, a in enumerate(annotations): if i % 2 == 0: - template_points.append(a.point) + fixed_points.append(a.point) else: - source_points.append(a.point) - return np.array(template_points), np.array(source_points) + moving_points.append(a.point) + return np.array(fixed_points), np.array(moving_points) def estimate_affine(self, s: neuroglancer.ViewerState): # TODO do we need to throttle this update or make it manually triggered? @@ -322,12 +310,12 @@ def estimate_affine(self, s: neuroglancer.ViewerState): # Ignore annotations not part of a pair annotations = annotations[: (len(annotations) // 2) * 2] - template_points, source_points = self.split_points_into_pairs(annotations) - if len(self.stored_points[0]) == len(template_points) and len( + fixed_points, moving_points = self.split_points_into_pairs(annotations) + if len(self.stored_points[0]) == len(fixed_points) and len( self.stored_points[1] - ) == len(source_points): - if np.all(np.isclose(self.stored_points[0], template_points)) and np.all( - np.isclose(self.stored_points[1], source_points) + ) == len(moving_points): + if np.all(np.isclose(self.stored_points[0], fixed_points)) and np.all( + np.isclose(self.stored_points[1], moving_points) ): return False else: @@ -336,10 +324,8 @@ def estimate_affine(self, s: neuroglancer.ViewerState): for i, a in enumerate(s.layers["markers"].annotations): a.props = [i // 2, i % 2, i] - template_points, source_points = self.split_points_into_pairs(annotations) - - # Estimate transform - self.affine = affine_fit(source_points, template_points) + fixed_points, moving_points = self.split_points_into_pairs(annotations) + self.affine = affine_fit(moving_points, fixed_points) # Set the transformation on the layer that is being registered # Something seems to go wrong with the state updates once this happens @@ -355,21 +341,20 @@ def estimate_affine(self, s: neuroglancer.ViewerState): "info", f"Estimated affine transform with {len(annotations) // 2} point pairs", ) - print("Estimated points are", self.transform_points(source_points)) - print("Original points are", template_points) - self.stored_points = [template_points, source_points] + self.stored_points = [fixed_points, moving_points] return True def get_registration_info(self): info = {} with self.viewer.txn() as s: annotations = s.layers["markers"].annotations - template_points, source_points = self.split_points_into_pairs(annotations) - # transformed_points = self.transform_template_points(template_points) - info["template"] = template_points.tolist() - info["source"] = source_points.tolist() - # info["transformed_template"] = transformed_points.tolist() - info["transform"] = self.affine.tolist() + fixed_points, moving_points = self.split_points_into_pairs(annotations) + transformed_points = self.transform_points_with_affine(moving_points) + info["fixedPoints"] = fixed_points.tolist() + info["movingPoints"] = moving_points.tolist() + if self.affine is not None and transformed_points is not None: + info["transformedPoints"] = transformed_points.tolist() + info["affineTransform"] = self.affine.tolist() return info def dump_info(self, path: str): @@ -384,12 +369,14 @@ def handle_args(): ap = argparse.ArgumentParser() neuroglancer.cli.add_server_arguments(ap) ap.add_argument( - "--template", + "--fixed", + "-f", type=str, - help="Source URL for the template image", + help="Source URL for the fixed image", ) ap.add_argument( - "--source", + "--moving", + "-m", type=str, help="Source URL for the image to be registered", ) @@ -402,8 +389,8 @@ def handle_args(): args = handle_args() demo = LinearRegistrationWorkflow( - template_url=args.template, - source_url=args.source, + fixed_url=args.fixed, + moving_url=args.moving, ) webbrowser.open_new(demo.viewer.get_viewer_url()) From dfd654c6455e98e472e1141c71662bfcd8b646f6 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 18 Sep 2025 16:45:14 +0200 Subject: [PATCH 11/65] feat: improve perf with debounce of lin reg --- .../examples/example_linear_registration.py | 79 +++++++++++++------ 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 8a0315b60..088966f2f 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -1,6 +1,8 @@ import argparse +import threading import webbrowser -from time import time, ctime +from time import ctime, time +from typing import Optional import neuroglancer import neuroglancer.cli @@ -25,6 +27,24 @@ """ +def debounce(wait: float): + def decorator(fn): + timer = None + + def debounced(*args, **kwargs): + nonlocal timer + + if timer is not None: + timer.cancel() + + timer = threading.Timer(wait, lambda: fn(*args, **kwargs)) + timer.start() + + return debounced + + return decorator + + def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): # Points are NxD arrays assert fixed_points.shape == moving_points.shape @@ -104,6 +124,7 @@ def __init__(self, fixed_url: str, moving_url: str): self.moving_url = moving_url self.status_timers = {} self.stored_points = [[], []] + self.stored_group_number = -1 self.affine = None if fixed_url is None or moving_url is None: @@ -168,11 +189,6 @@ def setup_viewer(self): enum_labels=["fixed", "moving"], enum_values=[0, 1], ), - neuroglancer.AnnotationPropertySpec( - id="index", - type="uint32", - default=0, - ), ], shader=MARKERS_SHADER, ) @@ -219,9 +235,17 @@ def update(self): if current_time - self.last_updated_print > 5: print(f"Viewer states are successfully syncing at {ctime()}") self.last_updated_print = current_time + # with self.viewer.txn() as s: + # self.automatically_group_markers(s) + # self.estimate_affine(s) + self._clear_status_messages() + # TODO for some reason I need to keep the layer change for states + self.update_affine() + + @debounce(1.0) + def update_affine(self): with self.viewer.txn() as s: self.estimate_affine(s) - self._clear_status_messages() def create_fixed_image(self): if self.fixed_url is None: @@ -260,7 +284,7 @@ def create_moving_image(self, registration_matrix: list | np.ndarray | None = No self.demo_data, matrix=inverse_matrix, ) - print(inverse_matrix) + print("target demo affine", inverse_matrix) return neuroglancer.ImageLayer( source=[ neuroglancer.LayerDataSource( @@ -284,29 +308,42 @@ def create_registered_image(self): return self.create_moving_image(registration_matrix=self.affine) def split_points_into_pairs(self, annotations): - # TODO allow a different way to group. Right now the order informs - # the properties - # But ideally we'd like to allow the other way around as well - # This needs to be reworked just a bit to allow that - # As a first step for that, this should inspect the properties - # and group based on that instead of this grouping fixed_points = [] moving_points = [] for i, a in enumerate(annotations): - if i % 2 == 0: + props = a.props + if props[1] == 0: fixed_points.append(a.point) else: moving_points.append(a.point) + # If the moving points is not evenly split, instead split differently + if len(moving_points) != len(fixed_points): + fixed_points = [] + moving_points = [] + for i, a in enumerate(annotations): + if i % 2 == 0: + fixed_points.append(a.point) + else: + moving_points.append(a.point) return np.array(fixed_points), np.array(moving_points) + def automatically_group_markers(self, s: neuroglancer.ViewerState): + annotations = s.layers["markers"].annotations + if len(annotations) < 2: + return False + if len(annotations) == self.stored_group_number: + return False + print("Updating marker groups") + for i, a in enumerate(s.layers["markers"].annotations): + a.props = [i // 2, i % 2] + print(a.props, i // 2, i % 2) + self.stored_group_number = len(annotations) + return True + def estimate_affine(self, s: neuroglancer.ViewerState): - # TODO do we need to throttle this update or make it manually triggered? - # While updating by moving a point, this can break right now annotations = s.layers["markers"].annotations if len(annotations) < 2: return False - # TODO expand this to estimate different types of transforms - # Depending on the number of points # Ignore annotations not part of a pair annotations = annotations[: (len(annotations) // 2) * 2] @@ -322,9 +359,7 @@ def estimate_affine(self, s: neuroglancer.ViewerState): # TODO instead of directly doing the update, debounce it and only do # it after a short delay if no other updates for i, a in enumerate(s.layers["markers"].annotations): - a.props = [i // 2, i % 2, i] - - fixed_points, moving_points = self.split_points_into_pairs(annotations) + a.props = [i // 2, i % 2] self.affine = affine_fit(moving_points, fixed_points) # Set the transformation on the layer that is being registered From b8da450730d5c4ede1657d7085379f63de41da13 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Sep 2025 10:48:42 +0200 Subject: [PATCH 12/65] feat: two diff debounces in lin reg update --- .../examples/example_linear_registration.py | 69 ++++++++----------- 1 file changed, 29 insertions(+), 40 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 088966f2f..c2839924e 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -2,7 +2,6 @@ import threading import webbrowser from time import ctime, time -from typing import Optional import neuroglancer import neuroglancer.cli @@ -158,10 +157,9 @@ def transform_points_with_affine(self, points: np.ndarray): def toggle_registered_visibility(self, _): with self.viewer.txn() as s: - s.layers["registered"].visible = not s.layers["registered"].visible - s.layers["fixed"].visible = not s.layers["registered"].visible - s.layers["markers"].visible = not s.layers["registered"].visible - s.layers["mappedMarkers"].visible = s.layers["registered"].visible + is_registered_visible = s.layers["registered"].visible + s.layers["registered"].visible = not is_registered_visible + s.layers["fixed"].visible = is_registered_visible def setup_viewer(self): self.viewer = viewer = neuroglancer.Viewer() @@ -235,12 +233,14 @@ def update(self): if current_time - self.last_updated_print > 5: print(f"Viewer states are successfully syncing at {ctime()}") self.last_updated_print = current_time - # with self.viewer.txn() as s: - # self.automatically_group_markers(s) - # self.estimate_affine(s) - self._clear_status_messages() - # TODO for some reason I need to keep the layer change for states + self.automatically_group_markers_and_update() self.update_affine() + self._clear_status_messages() + + @debounce(0.2) + def automatically_group_markers_and_update(self): + with self.viewer.txn() as s: + self.automatically_group_markers(s) @debounce(1.0) def update_affine(self): @@ -308,23 +308,17 @@ def create_registered_image(self): return self.create_moving_image(registration_matrix=self.affine) def split_points_into_pairs(self, annotations): - fixed_points = [] - moving_points = [] + num_points = len(annotations) // 2 + num_dims = len(annotations[0].point) + fixed_points = np.zeros((num_points, num_dims)) + moving_points = np.zeros((num_points, num_dims)) for i, a in enumerate(annotations): props = a.props if props[1] == 0: - fixed_points.append(a.point) + fixed_points[props[0]] = a.point else: - moving_points.append(a.point) - # If the moving points is not evenly split, instead split differently - if len(moving_points) != len(fixed_points): - fixed_points = [] - moving_points = [] - for i, a in enumerate(annotations): - if i % 2 == 0: - fixed_points.append(a.point) - else: - moving_points.append(a.point) + moving_points[props[0]] = a.point + return np.array(fixed_points), np.array(moving_points) def automatically_group_markers(self, s: neuroglancer.ViewerState): @@ -333,13 +327,22 @@ def automatically_group_markers(self, s: neuroglancer.ViewerState): return False if len(annotations) == self.stored_group_number: return False - print("Updating marker groups") for i, a in enumerate(s.layers["markers"].annotations): a.props = [i // 2, i % 2] - print(a.props, i // 2, i % 2) self.stored_group_number = len(annotations) return True + def update_registered_layer(self, s: neuroglancer.ViewerState): + existing_transform = s.layers["registered"].source[0].transform + if existing_transform is None: + # TODO again the create dimensions call needs to be fixed + existing_transform = neuroglancer.CoordinateSpaceTransform( + output_dimensions=create_dimensions() + ) + s.layers["registered"].source[0].transform = existing_transform + if self.affine is not None: + s.layers["registered"].source[0].transform.matrix = self.affine.tolist() + def estimate_affine(self, s: neuroglancer.ViewerState): annotations = s.layers["markers"].annotations if len(annotations) < 2: @@ -355,22 +358,8 @@ def estimate_affine(self, s: neuroglancer.ViewerState): np.isclose(self.stored_points[1], moving_points) ): return False - else: - # TODO instead of directly doing the update, debounce it and only do - # it after a short delay if no other updates - for i, a in enumerate(s.layers["markers"].annotations): - a.props = [i // 2, i % 2] self.affine = affine_fit(moving_points, fixed_points) - - # Set the transformation on the layer that is being registered - # Something seems to go wrong with the state updates once this happens - # s.layers["registered"].source[0].transform.matrix = A.T[:3] - # Because of this, trying to replace the whole layer to see if that helps - # TODO in theory should be able to just update the matrix, - # but if cannot, can replace whole layer and restore settings - old_visible = s.layers["registered"].visible - s.layers["registered"] = self.create_registered_image() - s.layers["registered"].visible = old_visible + self.update_registered_layer(s) self._set_status_message( "info", From 61488cf97a856ac2356944ff873edcf5f798b677 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Sep 2025 10:54:38 +0200 Subject: [PATCH 13/65] fix: correct caching of number --- python/examples/example_linear_registration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index c2839924e..8ef7598b8 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -323,13 +323,13 @@ def split_points_into_pairs(self, annotations): def automatically_group_markers(self, s: neuroglancer.ViewerState): annotations = s.layers["markers"].annotations - if len(annotations) < 2: - return False if len(annotations) == self.stored_group_number: return False + self.stored_group_number = len(annotations) + if len(annotations) < 2: + return False for i, a in enumerate(s.layers["markers"].annotations): a.props = [i // 2, i % 2] - self.stored_group_number = len(annotations) return True def update_registered_layer(self, s: neuroglancer.ViewerState): From c91e036b039950226350c3e1a933e1e34ae9d5e9 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Sep 2025 12:08:18 +0200 Subject: [PATCH 14/65] feat: remove fixed dims --- .../examples/example_linear_registration.py | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 8ef7598b8..d3a437be2 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -108,12 +108,10 @@ def create_demo_data(size: int | tuple = 60, radius: float = 20): return data -# TODO can we avoid calling this at all? Can the layers just be created and dims inferred? -def create_dimensions(): - if NUM_DEMO_DIMS == 2: - return neuroglancer.CoordinateSpace(names=["x", "y"], units="nm", scales=[1, 1]) +def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace): + print(viewer_dims) return neuroglancer.CoordinateSpace( - names=["x", "y", "z"], units="nm", scales=[1, 1, 1] + names=viewer_dims.names, units=viewer_dims.units, scales=viewer_dims.scales ) @@ -125,6 +123,7 @@ def __init__(self, fixed_url: str, moving_url: str): self.stored_points = [[], []] self.stored_group_number = -1 self.affine = None + self.ready = False if fixed_url is None or moving_url is None: self.demo_data = create_demo_data() @@ -132,9 +131,12 @@ def __init__(self, fixed_url: str, moving_url: str): self.setup_viewer() self.last_updated_print = -1 - def __str__(self): + def get_state(self): with self.viewer.txn() as s: - return str(s) + return s + + def __str__(self): + return str(self.get_state()) def _clear_status_messages(self): to_pop = [] @@ -172,8 +174,29 @@ def setup_viewer(self): s.layers["moving"] = moving_layer s.layers["registered"] = registered_layer s.layers["registered"].visible = False + + viewer.actions.add( + "toggle-registered-visibility", self.toggle_registered_visibility + ) + + with viewer.config_state.txn() as s: + s.input_event_bindings.viewer["keyt"] = "toggle-registered-visibility" + self.viewer.shared_state.add_changed_callback( + lambda: self.viewer.defer_callback(self.on_state_changed) + ) + + self._set_status_message( + "help", + "Waiting for viewer to initialize...", + ) + + @debounce(0.5) + def post_setup_viewer(self): + with self.viewer.txn() as s: + if s.dimensions.names == []: + return s.layers["markers"] = neuroglancer.LocalAnnotationLayer( - dimensions=create_dimensions(), + dimensions=create_dimensions(s.dimensions), annotation_properties=[ neuroglancer.AnnotationPropertySpec( id="label", @@ -190,6 +213,10 @@ def setup_viewer(self): ], shader=MARKERS_SHADER, ) + s.layers["markers"].tool = "annotatePoint" + s.selected_layer.layer = "markers" + s.selected_layer.visible = True + s.layout = neuroglancer.row_layout( [ neuroglancer.LayerGroupViewer( @@ -205,30 +232,20 @@ def setup_viewer(self): s.layout.children[1].crossSectionScale.link = "unlinked" s.layout.children[1].projectionOrientation.link = "unlinked" s.layout.children[1].projectionScale.link = "unlinked" - s.layers["markers"].tool = "annotatePoint" - s.selected_layer.layer = "markers" - s.selected_layer.visible = True - - viewer.actions.add( - "toggle-registered-visibility", self.toggle_registered_visibility - ) - - with viewer.config_state.txn() as s: - s.input_event_bindings.viewer["keyt"] = "toggle-registered-visibility" self._set_status_message( "help", "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle between viewing the fixed and registered layers.", ) - - self.viewer.shared_state.add_changed_callback( - lambda: self.viewer.defer_callback(self.on_state_changed) - ) + self.ready = True def on_state_changed(self): self.viewer.defer_callback(self.update) def update(self): + if not self.ready: + self.post_setup_viewer() + return current_time = time() if current_time - self.last_updated_print > 5: print(f"Viewer states are successfully syncing at {ctime()}") @@ -252,19 +269,14 @@ def create_fixed_image(self): return neuroglancer.ImageLayer( source=[ neuroglancer.LayerDataSource( - neuroglancer.LocalVolume( - self.demo_data, dimensions=create_dimensions() - ) + neuroglancer.LocalVolume(self.demo_data) ) ] ) else: return neuroglancer.ImageLayer(source=self.fixed_url) - def create_moving_image(self, registration_matrix: list | np.ndarray | None = None): - transform_kwargs = {} - if registration_matrix is not None: - transform_kwargs["matrix"] = registration_matrix + def create_moving_image(self): if self.moving_url is None: if NUM_DEMO_DIMS == 2: desired_output_matrix_homogenous = [ @@ -287,25 +299,16 @@ def create_moving_image(self, registration_matrix: list | np.ndarray | None = No print("target demo affine", inverse_matrix) return neuroglancer.ImageLayer( source=[ - neuroglancer.LayerDataSource( - neuroglancer.LocalVolume( - transformed, dimensions=create_dimensions() - ), - transform=neuroglancer.CoordinateSpaceTransform( - output_dimensions=create_dimensions(), - **transform_kwargs, - ), - ) + neuroglancer.LayerDataSource(neuroglancer.LocalVolume(transformed)) ] ) else: return neuroglancer.ImageLayer( source=self.moving_url, - transform=neuroglancer.CoordinateSpaceTransform(**transform_kwargs), ) def create_registered_image(self): - return self.create_moving_image(registration_matrix=self.affine) + return self.create_moving_image() def split_points_into_pairs(self, annotations): num_points = len(annotations) // 2 @@ -335,9 +338,8 @@ def automatically_group_markers(self, s: neuroglancer.ViewerState): def update_registered_layer(self, s: neuroglancer.ViewerState): existing_transform = s.layers["registered"].source[0].transform if existing_transform is None: - # TODO again the create dimensions call needs to be fixed existing_transform = neuroglancer.CoordinateSpaceTransform( - output_dimensions=create_dimensions() + output_dimensions=create_dimensions(s.dimensions) ) s.layers["registered"].source[0].transform = existing_transform if self.affine is not None: From 085563e7493e5c9e896a94d4f8e2e6e424d59d8c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Sep 2025 12:26:49 +0200 Subject: [PATCH 15/65] fix: handle channel dims --- .../examples/example_linear_registration.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index d3a437be2..cb4cf6544 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -343,7 +343,31 @@ def update_registered_layer(self, s: neuroglancer.ViewerState): ) s.layers["registered"].source[0].transform = existing_transform if self.affine is not None: - s.layers["registered"].source[0].transform.matrix = self.affine.tolist() + transform = self.affine.tolist() + if s.layers["registered"].source[0].transform is not None: + final_transform = [] + layer_transform = s.layers["registered"].source[0].transform + local_channel_indices = [ + i + for i, name in enumerate(layer_transform.outputDimensions.names) + if name.endswith(("'", "^", "#")) + ] + num_local_count = 0 + for i, name in enumerate(layer_transform.outputDimensions.names): + is_local = i in local_channel_indices + if is_local: + final_transform.append(layer_transform.matrix[i].tolist()) + num_local_count += 1 + else: + row = transform[i - num_local_count] + # At the indices corresponding to local channels, insert 0s + for j in local_channel_indices: + row.insert(j, 0) + final_transform.append(row) + else: + final_transform = transform + print("Updated affine transform:", final_transform) + s.layers["registered"].source[0].transform.matrix = final_transform def estimate_affine(self, s: neuroglancer.ViewerState): annotations = s.layers["markers"].annotations From 79793a7edb227e92cdc50583e0700213b1146d78 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 19 Sep 2025 16:34:03 +0200 Subject: [PATCH 16/65] feat: use input state in lin reg to be more robust --- .../examples/example_linear_registration.py | 170 ++++++++---------- 1 file changed, 79 insertions(+), 91 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index cb4cf6544..13f80d631 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -116,20 +116,28 @@ def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace): class LinearRegistrationWorkflow: - def __init__(self, fixed_url: str, moving_url: str): - self.fixed_url = fixed_url - self.moving_url = moving_url + def __init__(self, starting_state=neuroglancer.ViewerState | None): self.status_timers = {} self.stored_points = [[], []] self.stored_group_number = -1 self.affine = None self.ready = False + self.last_updated_print = -1 + self.viewer = neuroglancer.Viewer() + self.viewer.shared_state.add_changed_callback( + lambda: self.viewer.defer_callback(self.on_state_changed) + ) - if fixed_url is None or moving_url is None: + if starting_state is None: self.demo_data = create_demo_data() + self.setup_demo_viewer() + else: + self.viewer.set_state(starting_state) - self.setup_viewer() - self.last_updated_print = -1 + self._set_status_message( + "help", + "Waiting for viewer to initialize with one layer called fixed and one layer called moving.", + ) def get_state(self): with self.viewer.txn() as s: @@ -163,38 +171,37 @@ def toggle_registered_visibility(self, _): s.layers["registered"].visible = not is_registered_visible s.layers["fixed"].visible = is_registered_visible - def setup_viewer(self): - self.viewer = viewer = neuroglancer.Viewer() - fixed_layer = self.create_fixed_image() - moving_layer = self.create_moving_image() - registered_layer = self.create_registered_image() + def setup_demo_viewer(self): + viewer = self.viewer + fixed_layer = self.create_demo_fixed_image() + moving_layer = self.create_demo_moving_image() with viewer.txn() as s: s.layers["fixed"] = fixed_layer s.layers["moving"] = moving_layer - s.layers["registered"] = registered_layer - s.layers["registered"].visible = False + def setup_viewer(self): + viewer = self.viewer viewer.actions.add( "toggle-registered-visibility", self.toggle_registered_visibility ) with viewer.config_state.txn() as s: s.input_event_bindings.viewer["keyt"] = "toggle-registered-visibility" - self.viewer.shared_state.add_changed_callback( - lambda: self.viewer.defer_callback(self.on_state_changed) - ) - - self._set_status_message( - "help", - "Waiting for viewer to initialize...", - ) @debounce(0.5) def post_setup_viewer(self): with self.viewer.txn() as s: - if s.dimensions.names == []: + if ( + s.dimensions.names == [] + or s.layers.index("fixed") == -1 + or s.layers.index("moving") == -1 + ): return + # registered_layer = self.create_registered_image() + s.layers["moving1"] = self.create_registered_image() + s.layers["moving1"].name = "registered" + s.layers["registered"].visible = False s.layers["markers"] = neuroglancer.LocalAnnotationLayer( dimensions=create_dimensions(s.dimensions), annotation_properties=[ @@ -213,18 +220,23 @@ def post_setup_viewer(self): ], shader=MARKERS_SHADER, ) + s.layers["moving"].visible = True + s.layers["fixed"].visible = True s.layers["markers"].tool = "annotatePoint" s.selected_layer.layer = "markers" s.selected_layer.visible = True + all_layer_names = [layer.name for layer in s.layers] + group_1_names = [name for name in all_layer_names if name != "moving"] + group_2_names = [ + name + for name in all_layer_names + if name != "fixed" and name != "registered" + ] s.layout = neuroglancer.row_layout( [ - neuroglancer.LayerGroupViewer( - layers=["fixed", "registered", "markers"], layout="xy-3d" - ), - neuroglancer.LayerGroupViewer( - layers=["moving", "markers"], layout="xy-3d" - ), + neuroglancer.LayerGroupViewer(layers=group_1_names, layout="xy-3d"), + neuroglancer.LayerGroupViewer(layers=group_2_names, layout="xy-3d"), ] ) s.layout.children[1].position.link = "unlinked" @@ -238,77 +250,67 @@ def post_setup_viewer(self): "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle between viewing the fixed and registered layers.", ) self.ready = True + self.setup_viewer() def on_state_changed(self): self.viewer.defer_callback(self.update) def update(self): - if not self.ready: - self.post_setup_viewer() - return current_time = time() if current_time - self.last_updated_print > 5: print(f"Viewer states are successfully syncing at {ctime()}") self.last_updated_print = current_time + if not self.ready: + self.post_setup_viewer() + return self.automatically_group_markers_and_update() self.update_affine() self._clear_status_messages() - @debounce(0.2) + @debounce(0.25) def automatically_group_markers_and_update(self): with self.viewer.txn() as s: self.automatically_group_markers(s) - @debounce(1.0) + @debounce(1.5) def update_affine(self): with self.viewer.txn() as s: self.estimate_affine(s) - def create_fixed_image(self): - if self.fixed_url is None: - return neuroglancer.ImageLayer( - source=[ - neuroglancer.LayerDataSource( - neuroglancer.LocalVolume(self.demo_data) - ) - ] - ) - else: - return neuroglancer.ImageLayer(source=self.fixed_url) - - def create_moving_image(self): - if self.moving_url is None: - if NUM_DEMO_DIMS == 2: - desired_output_matrix_homogenous = [ - [0.8, 0, 0], - [0, 0.2, 0], - [0, 0, 1], - ] - else: - desired_output_matrix_homogenous = [ - [0.8, 0, 0, 0], - [0, 0.2, 0, 0], - [0, 0, 0.9, 0], - [0, 0, 0, 1], - ] - inverse_matrix = np.linalg.inv(desired_output_matrix_homogenous) - transformed = scipy.ndimage.affine_transform( - self.demo_data, - matrix=inverse_matrix, - ) - print("target demo affine", inverse_matrix) - return neuroglancer.ImageLayer( - source=[ - neuroglancer.LayerDataSource(neuroglancer.LocalVolume(transformed)) - ] - ) + def create_demo_fixed_image(self): + return neuroglancer.ImageLayer( + source=[ + neuroglancer.LayerDataSource(neuroglancer.LocalVolume(self.demo_data)) + ] + ) + + def create_demo_moving_image(self): + if NUM_DEMO_DIMS == 2: + desired_output_matrix_homogenous = [ + [0.8, 0, 0], + [0, 0.2, 0], + [0, 0, 1], + ] else: - return neuroglancer.ImageLayer( - source=self.moving_url, - ) + desired_output_matrix_homogenous = [ + [0.8, 0, 0, 0], + [0, 0.2, 0, 0], + [0, 0, 0.9, 0], + [0, 0, 0, 1], + ] + inverse_matrix = np.linalg.inv(desired_output_matrix_homogenous) + transformed = scipy.ndimage.affine_transform( + self.demo_data, + matrix=inverse_matrix, + ) + print("target demo affine", inverse_matrix) + return neuroglancer.ImageLayer( + source=[neuroglancer.LayerDataSource(neuroglancer.LocalVolume(transformed))] + ) def create_registered_image(self): - return self.create_moving_image() + with self.viewer.txn() as s: + return s.layers["moving"] def split_points_into_pairs(self, annotations): num_points = len(annotations) // 2 @@ -417,19 +419,8 @@ def dump_info(self, path: str): def handle_args(): ap = argparse.ArgumentParser() + neuroglancer.cli.add_state_arguments(ap, required=False) neuroglancer.cli.add_server_arguments(ap) - ap.add_argument( - "--fixed", - "-f", - type=str, - help="Source URL for the fixed image", - ) - ap.add_argument( - "--moving", - "-m", - type=str, - help="Source URL for the image to be registered", - ) args = ap.parse_args() neuroglancer.cli.handle_server_arguments(args) return args @@ -438,9 +429,6 @@ def handle_args(): if __name__ == "__main__": args = handle_args() - demo = LinearRegistrationWorkflow( - fixed_url=args.fixed, - moving_url=args.moving, - ) + demo = LinearRegistrationWorkflow(args.state) webbrowser.open_new(demo.viewer.get_viewer_url()) From 38d56f7bc33f343fbfe4f71e544f598510e5f686 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 22 Sep 2025 12:30:48 +0200 Subject: [PATCH 17/65] feat: making lin reg pipeline more custom --- .../examples/example_linear_registration.py | 218 ++++++++++++------ 1 file changed, 151 insertions(+), 67 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 13f80d631..ac6004180 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -44,6 +44,8 @@ def debounced(*args, **kwargs): return decorator +# TODO add other types of fits +# Inspired by https://github.com/AllenInstitute/render-python/blob/master/renderapi/transform/leaf/affine_models.py def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): # Points are NxD arrays assert fixed_points.shape == moving_points.shape @@ -109,14 +111,16 @@ def create_demo_data(size: int | tuple = 60, radius: float = 20): def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace): - print(viewer_dims) return neuroglancer.CoordinateSpace( names=viewer_dims.names, units=viewer_dims.units, scales=viewer_dims.scales ) class LinearRegistrationWorkflow: - def __init__(self, starting_state=neuroglancer.ViewerState | None): + def __init__(self, args): + starting_state = args.state + self.moving_name = args.moving_name + self.annotations_name = args.annotations_name self.status_timers = {} self.stored_points = [[], []] self.stored_group_number = -1 @@ -130,7 +134,7 @@ def __init__(self, starting_state=neuroglancer.ViewerState | None): if starting_state is None: self.demo_data = create_demo_data() - self.setup_demo_viewer() + self.add_fake_data_to_viewer() else: self.viewer.set_state(starting_state) @@ -165,20 +169,21 @@ def transform_points_with_affine(self, points: np.ndarray): if self.affine is not None: return transform_points(self.affine, points) + # TODO change this to replace the transform matrix directly? + # so then only need one layer def toggle_registered_visibility(self, _): with self.viewer.txn() as s: is_registered_visible = s.layers["registered"].visible s.layers["registered"].visible = not is_registered_visible - s.layers["fixed"].visible = is_registered_visible - def setup_demo_viewer(self): + def add_fake_data_to_viewer(self): viewer = self.viewer fixed_layer = self.create_demo_fixed_image() moving_layer = self.create_demo_moving_image() with viewer.txn() as s: s.layers["fixed"] = fixed_layer - s.layers["moving"] = moving_layer + s.layers[self.moving_name] = moving_layer def setup_viewer(self): viewer = self.viewer @@ -191,48 +196,48 @@ def setup_viewer(self): @debounce(0.5) def post_setup_viewer(self): + # TODO why do we need the moving? In theory only at setup? with self.viewer.txn() as s: - if ( - s.dimensions.names == [] - or s.layers.index("fixed") == -1 - or s.layers.index("moving") == -1 - ): + if s.dimensions.names == [] or s.layers.index(self.moving_name) == -1: return # registered_layer = self.create_registered_image() - s.layers["moving1"] = self.create_registered_image() - s.layers["moving1"].name = "registered" + # TODO might be able to use deepcopy to avoid this akwardness + # of rename + s.layers[self.moving_name + "1"] = self.create_registered_image() + s.layers[self.moving_name + "1"].name = "registered" s.layers["registered"].visible = False - s.layers["markers"] = neuroglancer.LocalAnnotationLayer( - dimensions=create_dimensions(s.dimensions), - annotation_properties=[ - neuroglancer.AnnotationPropertySpec( - id="label", - type="uint32", - default=0, - ), - neuroglancer.AnnotationPropertySpec( - id="group", - type="uint8", - default=0, - enum_labels=["fixed", "moving"], - enum_values=[0, 1], - ), - ], - shader=MARKERS_SHADER, - ) - s.layers["moving"].visible = True - s.layers["fixed"].visible = True - s.layers["markers"].tool = "annotatePoint" - s.selected_layer.layer = "markers" + if s.layers.index("registered") == -1: + s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( + dimensions=create_dimensions(s.dimensions), + annotation_properties=[ + neuroglancer.AnnotationPropertySpec( + id="label", + type="uint32", + default=0, + ), + neuroglancer.AnnotationPropertySpec( + id="group", + type="uint8", + default=0, + enum_labels=["fixed", "moving"], + enum_values=[0, 1], + ), + ], + shader=MARKERS_SHADER, + ) + s.layers[self.moving_name].visible = True + s.layers[self.annotations_name].tool = "annotatePoint" + s.selected_layer.layer = self.annotations_name s.selected_layer.visible = True all_layer_names = [layer.name for layer in s.layers] - group_1_names = [name for name in all_layer_names if name != "moving"] - group_2_names = [ - name - for name in all_layer_names - if name != "fixed" and name != "registered" + group_1_names = [ + name for name in all_layer_names if name != self.moving_name ] + group_2_names = [name for name in all_layer_names if name != "registered"] + # TODO in two coord space set the main one for group 2 + # TODO could load a link with layout already setup + # in which case should just add registered to group 1 s.layout = neuroglancer.row_layout( [ neuroglancer.LayerGroupViewer(layers=group_1_names, layout="xy-3d"), @@ -310,34 +315,63 @@ def create_demo_moving_image(self): def create_registered_image(self): with self.viewer.txn() as s: - return s.layers["moving"] - - def split_points_into_pairs(self, annotations): - num_points = len(annotations) // 2 - num_dims = len(annotations[0].point) - fixed_points = np.zeros((num_points, num_dims)) - moving_points = np.zeros((num_points, num_dims)) - for i, a in enumerate(annotations): - props = a.props - if props[1] == 0: - fixed_points[props[0]] = a.point - else: - moving_points[props[0]] = a.point - - return np.array(fixed_points), np.array(moving_points) - + return s.layers[self.moving_name] + + # TODO this check could maybe be more robust + def check_for_two_coord_spaces(self, dim_names): + set_of_names = set() + for name in dim_names: + # rstrip any number off the end + stripped_name = name.rstrip("0123456789") + set_of_names.add(stripped_name) + return len(set_of_names) * 2 == len(dim_names) + + def split_points_into_pairs(self, annotations, dim_names): + two_coord_spaces = self.check_for_two_coord_spaces(dim_names) + # TODO need some way to indicate which coord space is which + if two_coord_spaces: + num_points = len(annotations) + num_dims = len(annotations[0].point) // 2 + fixed_points = np.zeros((num_points, num_dims)) + moving_points = np.zeros((num_points, num_dims)) + for i, a in enumerate(annotations): + for j in range(num_dims): + fixed_points[i, j] = a.point[j + num_dims] + moving_points[i, j] = a.point[j] + return np.array(fixed_points), np.array(moving_points) + else: + num_points = len(annotations) // 2 + num_dims = len(annotations[0].point) + fixed_points = np.zeros((num_points, num_dims)) + moving_points = np.zeros((num_points, num_dims)) + for i, a in enumerate(annotations): + props = a.props + if props[1] == 0: + fixed_points[props[0]] = a.point + else: + moving_points[props[0]] = a.point + + return np.array(fixed_points), np.array(moving_points) + + # TODO can disable this check completely if using two coord spaces def automatically_group_markers(self, s: neuroglancer.ViewerState): - annotations = s.layers["markers"].annotations + dimensions = s.dimensions.names + if self.check_for_two_coord_spaces(dimensions): + return False + annotations = s.layers[self.annotations_name].annotations if len(annotations) == self.stored_group_number: return False self.stored_group_number = len(annotations) if len(annotations) < 2: return False - for i, a in enumerate(s.layers["markers"].annotations): + for i, a in enumerate(s.layers[self.annotations_name].annotations): a.props = [i // 2, i % 2] return True def update_registered_layer(self, s: neuroglancer.ViewerState): + # TODO might need to add some mechanism to neuroglancer to do this + # this seems off that we can't directly set the transform matrix + # without first creating a new transform object existing_transform = s.layers["registered"].source[0].transform if existing_transform is None: existing_transform = neuroglancer.CoordinateSpaceTransform( @@ -346,6 +380,7 @@ def update_registered_layer(self, s: neuroglancer.ViewerState): s.layers["registered"].source[0].transform = existing_transform if self.affine is not None: transform = self.affine.tolist() + # TODO this is where that mapping needs to happen of affine dims if s.layers["registered"].source[0].transform is not None: final_transform = [] layer_transform = s.layers["registered"].source[0].transform @@ -369,16 +404,36 @@ def update_registered_layer(self, s: neuroglancer.ViewerState): else: final_transform = transform print("Updated affine transform:", final_transform) + # TODO for some reason with global dims not matching local dims + # nothing happens at this step + # TODO if global dims don't match local dims then the + # tranform matrix doesn't match properly + # the easiest to fix this would be to make the local dims of the + # markers be the same as the local dims of the image layers + # but we don't seem to always be able to access that info + # we could try to check similar to the above if a transform + # has been setup on viewer init and use that to get the + # names of the local dims which might be easier overall + # than trying to fiddle with these dims + # Worst comes to worst you could ask for a copy of the layer by the user + # with the dims if command line not specified + # but command line could spec the markers layer dims + # Try https://neuroglancer-demo.appspot.com/#!%7B%22dimensions%22:%7B%22x%22:%5B6.500000000000001e-7%2C%22m%22%5D%2C%22y%22:%5B6.500000000000001e-7%2C%22m%22%5D%2C%22z%22:%5B0.00003%2C%22m%22%5D%7D%2C%22position%22:%5B14231.224609375%2C30510.12109375%2C0%5D%2C%22crossSectionScale%22:121.51041751873487%2C%22projectionScale%22:131072%2C%22layers%22:%5B%7B%22type%22:%22image%22%2C%22source%22:%22s3://allen-genetic-tools/epifluorescence/1383646325/ome_zarr_conversion/1383646325.zarr/%7Czarr2:%22%2C%22localDimensions%22:%7B%22c%27%22:%5B1%2C%22%22%5D%7D%2C%22localPosition%22:%5B1%5D%2C%22tab%22:%22source%22%2C%22name%22:%221383646325.zarr%22%7D%5D%2C%22selectedLayer%22:%7B%22size%22:379%2C%22visible%22:true%2C%22layer%22:%221383646325.zarr%22%7D%2C%22layout%22:%224panel-alt%22%7D for this + print(s.layers["registered"].source[0].transform) + print(final_transform) s.layers["registered"].source[0].transform.matrix = final_transform + print(s.layers["registered"].source[0].transform) def estimate_affine(self, s: neuroglancer.ViewerState): - annotations = s.layers["markers"].annotations - if len(annotations) < 2: + annotations = s.layers[self.annotations_name].annotations + if len(annotations) < 1: return False - # Ignore annotations not part of a pair - annotations = annotations[: (len(annotations) // 2) * 2] - fixed_points, moving_points = self.split_points_into_pairs(annotations) + dim_names = s.dimensions.names + # TODO likely broken right now for non pairs non two coord spaces + fixed_points, moving_points = self.split_points_into_pairs( + annotations, dim_names + ) if len(self.stored_points[0]) == len(fixed_points) and len( self.stored_points[1] ) == len(moving_points): @@ -391,7 +446,7 @@ def estimate_affine(self, s: neuroglancer.ViewerState): self._set_status_message( "info", - f"Estimated affine transform with {len(annotations) // 2} point pairs", + f"Estimated affine transform with {len(moving_points)} point pairs", ) self.stored_points = [fixed_points, moving_points] return True @@ -399,8 +454,11 @@ def estimate_affine(self, s: neuroglancer.ViewerState): def get_registration_info(self): info = {} with self.viewer.txn() as s: - annotations = s.layers["markers"].annotations - fixed_points, moving_points = self.split_points_into_pairs(annotations) + annotations = s.layers[self.annotations_name].annotations + dim_names = s.dimensions.names + fixed_points, moving_points = self.split_points_into_pairs( + annotations, dim_names + ) transformed_points = self.transform_points_with_affine(moving_points) info["fixedPoints"] = fixed_points.tolist() info["movingPoints"] = moving_points.tolist() @@ -417,10 +475,36 @@ def dump_info(self, path: str): json.dump(info, f, indent=4) +def add_mapping_args(ap: argparse.ArgumentParser): + ap.add_argument( + "--annotations-name", + "-a", + type=str, + help="Name of the annotation layer (default is annotations)", + default="annotation", + required=False, + ) + ap.add_argument( + "--moving-name", + "-m", + type=str, + help="Name of the moving image layer (default is moving)", + default="moving", + required=False, + ) + + +# TODO need to add some way to handle mapping the output affine +# For e.g. the points can be in XYZ order, but the image dims +# are CZYX order +# TODO allow input arg of layer name to be the moving and the fixed +# TODO don't actually need fixed in theory? +# TODO same for markers / annotations name def handle_args(): ap = argparse.ArgumentParser() neuroglancer.cli.add_state_arguments(ap, required=False) neuroglancer.cli.add_server_arguments(ap) + add_mapping_args(ap) args = ap.parse_args() neuroglancer.cli.handle_server_arguments(args) return args @@ -429,6 +513,6 @@ def handle_args(): if __name__ == "__main__": args = handle_args() - demo = LinearRegistrationWorkflow(args.state) + demo = LinearRegistrationWorkflow(args) webbrowser.open_new(demo.viewer.get_viewer_url()) From 0d467f5d4e1b20a219967c6d0444420e4fc95a68 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 22 Sep 2025 14:12:30 +0200 Subject: [PATCH 18/65] fix: correct a number of TODOs for lin reg --- .../examples/example_linear_registration.py | 163 ++++++++++-------- 1 file changed, 92 insertions(+), 71 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index ac6004180..b9924cc63 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -1,6 +1,7 @@ import argparse import threading import webbrowser +from copy import deepcopy from time import ctime, time import neuroglancer @@ -110,10 +111,22 @@ def create_demo_data(size: int | tuple = 60, radius: float = 20): return data -def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace): - return neuroglancer.CoordinateSpace( - names=viewer_dims.names, units=viewer_dims.units, scales=viewer_dims.scales - ) +# TODO this should be more intelligent for the copy +# in that case it should take the original layers dimensions +# and actually map the names +# the problem is that right now we can't query those names from python +# since it wraps the state and that info isn't in the state if it is still the +# default information +def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace, indices=None): + names = viewer_dims.names + units = viewer_dims.units + scales = viewer_dims.scales + if indices is not None: + names = [viewer_dims.names[i] for i in indices] + units = [viewer_dims.units[i] for i in indices] + scales = [viewer_dims.scales[i] for i in indices] + + return neuroglancer.CoordinateSpace(names=names, units=units, scales=scales) class LinearRegistrationWorkflow: @@ -127,6 +140,7 @@ def __init__(self, args): self.affine = None self.ready = False self.last_updated_print = -1 + self.two_coord_spaces = False self.viewer = neuroglancer.Viewer() self.viewer.shared_state.add_changed_callback( lambda: self.viewer.defer_callback(self.on_state_changed) @@ -140,7 +154,7 @@ def __init__(self, args): self._set_status_message( "help", - "Waiting for viewer to initialize with one layer called fixed and one layer called moving.", + "Waiting for viewer to initialize with one layer called moving.", ) def get_state(self): @@ -169,8 +183,6 @@ def transform_points_with_affine(self, points: np.ndarray): if self.affine is not None: return transform_points(self.affine, points) - # TODO change this to replace the transform matrix directly? - # so then only need one layer def toggle_registered_visibility(self, _): with self.viewer.txn() as s: is_registered_visible = s.layers["registered"].visible @@ -185,7 +197,7 @@ def add_fake_data_to_viewer(self): s.layers["fixed"] = fixed_layer s.layers[self.moving_name] = moving_layer - def setup_viewer(self): + def setup_viewer_actions(self): viewer = self.viewer viewer.actions.add( "toggle-registered-visibility", self.toggle_registered_visibility @@ -194,19 +206,40 @@ def setup_viewer(self): with viewer.config_state.txn() as s: s.input_event_bindings.viewer["keyt"] = "toggle-registered-visibility" + def is_fixed_image_space_last(self, dim_names): + first_name = dim_names[0] + return first_name[-1] in "0123456789" + + def init_registered_transform(self, s: neuroglancer.ViewerState, force=True): + if not force and s.layers["registered"].source[0].transform is not None: + return + # TODO this can fail to solve the no matrix issue if ends up as the identity + # think I need to change something in neuroglancer python for this instead + indices = None + if self.check_for_two_coord_spaces(s.dimensions.names): + self.coord_spaces = True + num_dims = len(s.dimensions.names) // 2 + if self.is_fixed_image_space_last(s.dimensions.names): + indices = list( + range(len(s.dimensions.names) - num_dims, len(s.dimensions.names)) + ) + else: + indices = list(range(num_dims)) + existing_transform = neuroglancer.CoordinateSpaceTransform( + output_dimensions=create_dimensions(s.dimensions, indices) + ) + s.layers["registered"].source[0].transform = existing_transform + print(s.layers["registered"].source[0].transform.matrix) + @debounce(0.5) - def post_setup_viewer(self): - # TODO why do we need the moving? In theory only at setup? + def check_viewer_ready_and_setup(self): with self.viewer.txn() as s: if s.dimensions.names == [] or s.layers.index(self.moving_name) == -1: return - # registered_layer = self.create_registered_image() - # TODO might be able to use deepcopy to avoid this akwardness - # of rename - s.layers[self.moving_name + "1"] = self.create_registered_image() - s.layers[self.moving_name + "1"].name = "registered" + s.layers["registered"] = self.create_registered_image() s.layers["registered"].visible = False - if s.layers.index("registered") == -1: + self.init_registered_transform(s) + if s.layers.index(self.annotations_name) == -1: s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( dimensions=create_dimensions(s.dimensions), annotation_properties=[ @@ -235,27 +268,32 @@ def post_setup_viewer(self): name for name in all_layer_names if name != self.moving_name ] group_2_names = [name for name in all_layer_names if name != "registered"] - # TODO in two coord space set the main one for group 2 - # TODO could load a link with layout already setup - # in which case should just add registered to group 1 - s.layout = neuroglancer.row_layout( - [ - neuroglancer.LayerGroupViewer(layers=group_1_names, layout="xy-3d"), - neuroglancer.LayerGroupViewer(layers=group_2_names, layout="xy-3d"), - ] - ) - s.layout.children[1].position.link = "unlinked" - s.layout.children[1].crossSectionOrientation.link = "unlinked" - s.layout.children[1].crossSectionScale.link = "unlinked" - s.layout.children[1].projectionOrientation.link = "unlinked" - s.layout.children[1].projectionScale.link = "unlinked" + if isinstance(s.layout, neuroglancer.DataPanelLayout): + s.layout = neuroglancer.row_layout( + [ + neuroglancer.LayerGroupViewer( + layers=group_1_names, layout="xy-3d" + ), + neuroglancer.LayerGroupViewer( + layers=group_2_names, layout="xy-3d" + ), + ] + ) + s.layout.children[1].position.link = "unlinked" + s.layout.children[1].crossSectionOrientation.link = "unlinked" + s.layout.children[1].crossSectionScale.link = "unlinked" + s.layout.children[1].projectionOrientation.link = "unlinked" + s.layout.children[1].projectionScale.link = "unlinked" + # TODO expand to unlink coords and make two coord spaces + else: + s.layout.children[0].layers.append("registered") self._set_status_message( "help", - "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle between viewing the fixed and registered layers.", + "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle visiblity of the registered layer.", ) self.ready = True - self.setup_viewer() + self.setup_viewer_actions() def on_state_changed(self): self.viewer.defer_callback(self.update) @@ -266,9 +304,10 @@ def update(self): print(f"Viewer states are successfully syncing at {ctime()}") self.last_updated_print = current_time if not self.ready: - self.post_setup_viewer() + self.check_viewer_ready_and_setup() return - self.automatically_group_markers_and_update() + if not self.two_coord_spaces: + self.automatically_group_markers_and_update() self.update_affine() self._clear_status_messages() @@ -315,10 +354,14 @@ def create_demo_moving_image(self): def create_registered_image(self): with self.viewer.txn() as s: - return s.layers[self.moving_name] + layer = deepcopy(s.layers[self.moving_name]) + layer.name = "registered" + return layer - # TODO this check could maybe be more robust def check_for_two_coord_spaces(self, dim_names): + # Dims should be exactly double the number of unique names + if len(dim_names) == 0 or len(dim_names) % 2 != 0: + return False set_of_names = set() for name in dim_names: # rstrip any number off the end @@ -327,20 +370,25 @@ def check_for_two_coord_spaces(self, dim_names): return len(set_of_names) * 2 == len(dim_names) def split_points_into_pairs(self, annotations, dim_names): + if len(annotations) == 0: + return np.zeros((0, 0)), np.zeros((0, 0)) two_coord_spaces = self.check_for_two_coord_spaces(dim_names) - # TODO need some way to indicate which coord space is which if two_coord_spaces: + real_dims_last = self.is_fixed_image_space_last(dim_names) num_points = len(annotations) num_dims = len(annotations[0].point) // 2 fixed_points = np.zeros((num_points, num_dims)) moving_points = np.zeros((num_points, num_dims)) for i, a in enumerate(annotations): for j in range(num_dims): - fixed_points[i, j] = a.point[j + num_dims] - moving_points[i, j] = a.point[j] + fixed_index = j + num_dims if real_dims_last else j + moving_index = j if real_dims_last else j + num_dims + fixed_points[i, j] = a.point[fixed_index] + moving_points[i, j] = a.point[moving_index] return np.array(fixed_points), np.array(moving_points) else: num_points = len(annotations) // 2 + annotations = annotations[: num_points * 2] num_dims = len(annotations[0].point) fixed_points = np.zeros((num_points, num_dims)) moving_points = np.zeros((num_points, num_dims)) @@ -353,7 +401,6 @@ def split_points_into_pairs(self, annotations, dim_names): return np.array(fixed_points), np.array(moving_points) - # TODO can disable this check completely if using two coord spaces def automatically_group_markers(self, s: neuroglancer.ViewerState): dimensions = s.dimensions.names if self.check_for_two_coord_spaces(dimensions): @@ -369,18 +416,14 @@ def automatically_group_markers(self, s: neuroglancer.ViewerState): return True def update_registered_layer(self, s: neuroglancer.ViewerState): - # TODO might need to add some mechanism to neuroglancer to do this - # this seems off that we can't directly set the transform matrix - # without first creating a new transform object - existing_transform = s.layers["registered"].source[0].transform - if existing_transform is None: - existing_transform = neuroglancer.CoordinateSpaceTransform( - output_dimensions=create_dimensions(s.dimensions) - ) - s.layers["registered"].source[0].transform = existing_transform + self.init_registered_transform(s, force=False) if self.affine is not None: + print(s.layers["registered"].source[0].transform.matrix) transform = self.affine.tolist() # TODO this is where that mapping needs to happen of affine dims + # overall this is a bit awkward right now, we need a lot of + # mapping info which we just don't have + # right now you can't input it from the command line if s.layers["registered"].source[0].transform is not None: final_transform = [] layer_transform = s.layers["registered"].source[0].transform @@ -404,21 +447,6 @@ def update_registered_layer(self, s: neuroglancer.ViewerState): else: final_transform = transform print("Updated affine transform:", final_transform) - # TODO for some reason with global dims not matching local dims - # nothing happens at this step - # TODO if global dims don't match local dims then the - # tranform matrix doesn't match properly - # the easiest to fix this would be to make the local dims of the - # markers be the same as the local dims of the image layers - # but we don't seem to always be able to access that info - # we could try to check similar to the above if a transform - # has been setup on viewer init and use that to get the - # names of the local dims which might be easier overall - # than trying to fiddle with these dims - # Worst comes to worst you could ask for a copy of the layer by the user - # with the dims if command line not specified - # but command line could spec the markers layer dims - # Try https://neuroglancer-demo.appspot.com/#!%7B%22dimensions%22:%7B%22x%22:%5B6.500000000000001e-7%2C%22m%22%5D%2C%22y%22:%5B6.500000000000001e-7%2C%22m%22%5D%2C%22z%22:%5B0.00003%2C%22m%22%5D%7D%2C%22position%22:%5B14231.224609375%2C30510.12109375%2C0%5D%2C%22crossSectionScale%22:121.51041751873487%2C%22projectionScale%22:131072%2C%22layers%22:%5B%7B%22type%22:%22image%22%2C%22source%22:%22s3://allen-genetic-tools/epifluorescence/1383646325/ome_zarr_conversion/1383646325.zarr/%7Czarr2:%22%2C%22localDimensions%22:%7B%22c%27%22:%5B1%2C%22%22%5D%7D%2C%22localPosition%22:%5B1%5D%2C%22tab%22:%22source%22%2C%22name%22:%221383646325.zarr%22%7D%5D%2C%22selectedLayer%22:%7B%22size%22:379%2C%22visible%22:true%2C%22layer%22:%221383646325.zarr%22%7D%2C%22layout%22:%224panel-alt%22%7D for this print(s.layers["registered"].source[0].transform) print(final_transform) s.layers["registered"].source[0].transform.matrix = final_transform @@ -430,7 +458,6 @@ def estimate_affine(self, s: neuroglancer.ViewerState): return False dim_names = s.dimensions.names - # TODO likely broken right now for non pairs non two coord spaces fixed_points, moving_points = self.split_points_into_pairs( annotations, dim_names ) @@ -494,12 +521,6 @@ def add_mapping_args(ap: argparse.ArgumentParser): ) -# TODO need to add some way to handle mapping the output affine -# For e.g. the points can be in XYZ order, but the image dims -# are CZYX order -# TODO allow input arg of layer name to be the moving and the fixed -# TODO don't actually need fixed in theory? -# TODO same for markers / annotations name def handle_args(): ap = argparse.ArgumentParser() neuroglancer.cli.add_state_arguments(ap, required=False) From c812212655ce4d0ef66d2e254e06762467a2a242 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 25 Sep 2025 11:07:44 +0200 Subject: [PATCH 19/65] feat: add different fits to reg workflow --- .../examples/example_linear_registration.py | 83 +++++++++++++++++-- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index b9924cc63..1aeb460ac 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -45,14 +45,81 @@ def debounced(*args, **kwargs): return decorator -# TODO add other types of fits # Inspired by https://github.com/AllenInstitute/render-python/blob/master/renderapi/transform/leaf/affine_models.py -def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): - # Points are NxD arrays + + +def fit_model(fixed_points: np.ndarray, moving_points: np.ndarray): assert fixed_points.shape == moving_points.shape - N = fixed_points.shape[0] - D = fixed_points.shape[1] - T = fixed_points + N, D = fixed_points.shape + + if N == 1: + return translation_fit(fixed_points, moving_points) + if N == 2: + return rigid_or_similarity_fit(fixed_points, moving_points, rigid=True) + if N == 3 and D == 2: + return affine_fit(fixed_points, moving_points) + if N == 3 and D > 2: + return rigid_or_similarity_fit(fixed_points, moving_points, rigid=False) + return affine_fit(fixed_points, moving_points) + + +# See https://en.wikipedia.org/wiki/Orthogonal_Procrustes_problem +# and https://math.nist.gov/~JBernal/kujustf.pdf +def rigid_or_similarity_fit( + fixed_points: np.ndarray, moving_points: np.ndarray, rigid: bool = True +): + N, D = fixed_points.shape + + # Remove translation aspect to first determine rotation/scale + X = fixed_points - fixed_points.mean(axis=0) + Y = moving_points - moving_points.mean(axis=0) + + # Cross-covariance + sigma = (Y.T @ X) / N + + # SVD - Unitary matrix, Diagonal, conjugate transpose of unitary matrix + U, S, Vt = np.linalg.svd(sigma) # Sigma ≈ U diag(S) V* + + d = np.ones(D) + if np.linalg.det(U @ Vt) < 0: + d[-1] = -1 + R = U @ np.diag(d) @ Vt + + # Scale + if rigid: + s = 1.0 + else: + var_src = (X**2).sum() / N # sum of variances across dims + s = (S * d).sum() / var_src + + # Translation + t = Y - s * (R @ X) + + # Homogeneous (D+1)x(D+1) + T = np.zeros((D, D + 1)) + T[:D, :D] = s * R + T[:, -1] = t + + affine = np.round(T, decimals=3) + return affine + + +# TODO bring these fits together a bit more nicely +def translation_fit(fixed_points: np.ndarray, moving_points: np.ndarray): + N, D = fixed_points.shape + + estimated_translation = np.mean(moving_points - fixed_points, axis=0) + + affine = np.zeros((D, D + 1)) + affine[:, :D] = np.eye(D) + affine[:, -1] = estimated_translation + + affine = np.round(affine, decimals=3) + return affine + + +def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): + N, D = fixed_points.shape # Target values (B) is a D * N array # Input values (A) is a D * N, (D * (D + 1)) array @@ -62,7 +129,7 @@ def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): for j in range(D): start_index = j * D end_index = (j + 1) * D - A[D * i + j, start_index:end_index] = T[i] + A[D * i + j, start_index:end_index] = fixed_points[i] A[D * i + j, D * D + j] = 1 B = moving_points.flatten() @@ -468,7 +535,7 @@ def estimate_affine(self, s: neuroglancer.ViewerState): np.isclose(self.stored_points[1], moving_points) ): return False - self.affine = affine_fit(moving_points, fixed_points) + self.affine = fit_model(moving_points, fixed_points) self.update_registered_layer(s) self._set_status_message( From 4cddc66aabfe46e32b9790e320b7118018042ba0 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 25 Sep 2025 12:09:38 +0200 Subject: [PATCH 20/65] feat(python): add layout display dims unlinked to state --- python/neuroglancer/__init__.py | 1 + python/neuroglancer/viewer_state.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/python/neuroglancer/__init__.py b/python/neuroglancer/__init__.py index 4799b4724..51e179fa6 100644 --- a/python/neuroglancer/__init__.py +++ b/python/neuroglancer/__init__.py @@ -107,6 +107,7 @@ LocalAnnotationLayer, # noqa: F401 ManagedLayer, # noqa: F401 Layers, # noqa: F401 + LinkedDisplayDimensions, # noqa: F401 LinkedPosition, # noqa: F401 LinkedZoomFactor, # noqa: F401 LinkedDepthRange, # noqa: F401 diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index 3aff9d396..7f384b011 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -1482,6 +1482,14 @@ class Linked(LinkedType): return Linked +if typing.TYPE_CHECKING or _BUILDING_DOCS: + _LinkedDisplayDimensionsBase = LinkedType[list[str]] +else: + _LinkedDisplayDimensionsBase = make_linked_navigation_type(typed_list(str), lambda x: x) + +@export +class LinkedDisplayDimensions(_LinkedDisplayDimensionsBase): + __slots__ = () if typing.TYPE_CHECKING or _BUILDING_DOCS: _LinkedPositionBase = LinkedType[np.typing.NDArray[np.float32]] @@ -1710,6 +1718,9 @@ class LayerGroupViewer(JsonObjectWrapper): layers = wrapped_property("layers", typed_list(str)) layout = wrapped_property("layout", data_panel_layout_wrapper("xy")) position = wrapped_property("position", LinkedPosition) + display_dimensions = displayDimensions = wrapped_property( + "displayDimensions", LinkedDisplayDimensions + ) velocity = wrapped_property( "velocity", typed_map(key_type=str, value_type=DimensionPlaybackVelocity) ) From 83a1b308ef944ebad4690c25edfa368546b377cb Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 25 Sep 2025 12:09:57 +0200 Subject: [PATCH 21/65] refactor: in progress with large lin reg workflow improvement --- .../examples/example_linear_registration.py | 308 +++++++++--------- 1 file changed, 154 insertions(+), 154 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 1aeb460ac..d9755c20b 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -12,6 +12,7 @@ MESSAGE_DURATION = 5 # seconds NUM_DEMO_DIMS = 2 # Currently can be 2D or 3D +# TODO may not be needed, depends on how we handle the two coord spaces MARKERS_SHADER = """ #uicontrol vec3 fixedPointColor color(default="#00FF00") #uicontrol vec3 movingPointColor color(default="#0000FF") @@ -160,7 +161,8 @@ def transform_points(affine: np.ndarray, points: np.ndarray): return transformed -def create_demo_data(size: int | tuple = 60, radius: float = 20): +# Only used if no data provided +def _create_demo_data(size: int | tuple = 60, radius: float = 20): data_size = (size,) * NUM_DEMO_DIMS if isinstance(size, int) else size data = np.zeros(data_size, dtype=np.uint8) if NUM_DEMO_DIMS == 2: @@ -178,6 +180,40 @@ def create_demo_data(size: int | tuple = 60, radius: float = 20): return data +# Only used if no data provided +def _create_demo_fixed_image(): + return neuroglancer.ImageLayer( + source=[ + neuroglancer.LayerDataSource(neuroglancer.LocalVolume(_create_demo_data())) + ] + ) + + +def _create_demo_moving_image(): + if NUM_DEMO_DIMS == 2: + desired_output_matrix_homogenous = [ + [0.8, 0, 0], + [0, 0.2, 0], + [0, 0, 1], + ] + else: + desired_output_matrix_homogenous = [ + [0.8, 0, 0, 0], + [0, 0.2, 0, 0], + [0, 0, 0.9, 0], + [0, 0, 0, 1], + ] + inverse_matrix = np.linalg.inv(desired_output_matrix_homogenous) + transformed = scipy.ndimage.affine_transform( + _create_demo_data(), + matrix=inverse_matrix, + ) + print("target demo affine", inverse_matrix) + return neuroglancer.ImageLayer( + source=[neuroglancer.LayerDataSource(neuroglancer.LocalVolume(transformed))] + ) + + # TODO this should be more intelligent for the copy # in that case it should take the original layers dimensions # and actually map the names @@ -199,7 +235,6 @@ def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace, indices=None): class LinearRegistrationWorkflow: def __init__(self, args): starting_state = args.state - self.moving_name = args.moving_name self.annotations_name = args.annotations_name self.status_timers = {} self.stored_points = [[], []] @@ -214,55 +249,101 @@ def __init__(self, args): ) if starting_state is None: - self.demo_data = create_demo_data() - self.add_fake_data_to_viewer() + self._add_demo_data_to_viewer() else: self.viewer.set_state(starting_state) self._set_status_message( "help", - "Waiting for viewer to initialize with one layer called moving.", + "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup.", ) - - def get_state(self): with self.viewer.txn() as s: - return s + self.create_two_panel_layout(s) + self.setup_viewer_actions() - def __str__(self): - return str(self.get_state()) + def update(self): + """Primary update loop, called whenever the viewer state changes.""" + current_time = time() + if current_time - self.last_updated_print > 5: + print(f"Viewer states are successfully syncing at {ctime()}") + self.last_updated_print = current_time + return # for now don't do regular actions + if not self.two_coord_spaces: + self.automatically_group_markers_and_update() + self.update_affine() + self._clear_status_messages() - def _clear_status_messages(self): - to_pop = [] - for k, v in self.status_timers.items(): - if time() - v > MESSAGE_DURATION: - to_pop.append(k) - for k in to_pop: - with self.viewer.config_state.txn() as s: - s.status_messages.pop(k, None) - self.status_timers.pop(k) + def setup_viewer(self): + with self.viewer.txn() as s: + self.init_coord_spaces(s) + self.create_registration_layers(s) + self._set_status_message( + "help", + "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle visiblity of the registered layer.", + ) + self.ready = True - def _set_status_message(self, key: str, message: str): - with self.viewer.config_state.txn() as s: - s.status_messages[key] = message - self.status_timers[key] = time() + def create_two_panel_layout(self, s: neuroglancer.ViewerState): + all_layer_names = [layer.name for layer in s.layers] + half_point = len(all_layer_names) // 2 + group1_names = all_layer_names[:half_point] + group2_names = all_layer_names[half_point:] + s.layout = neuroglancer.row_layout( + [ + neuroglancer.LayerGroupViewer(layers=group1_names, layout="xy-3d"), + neuroglancer.LayerGroupViewer(layers=group2_names, layout="xy-3d"), + ] + ) + s.layout.children[1].position.link = "unlinked" + s.layout.children[1].position.value = [512, 0, 40] + s.layout.children[1].crossSectionOrientation.link = "unlinked" + s.layout.children[1].crossSectionScale.link = "unlinked" + s.layout.children[1].projectionOrientation.link = "unlinked" + s.layout.children[1].projectionScale.link = "unlinked" + + def create_registration_layers(self, s: neuroglancer.ViewerState): + # TODO consider making a copy of registered layers in left panel + self.init_registered_transform(s) + # Make the annotation layer if needed + # TODO probably don't need the properties, to be confirmed if + # one co-ord space is fine or need two + if s.layers.index(self.annotations_name) == -1: + s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( + dimensions=create_dimensions(s.dimensions), + # annotation_properties=[ + # neuroglancer.AnnotationPropertySpec( + # id="label", + # type="uint32", + # default=0, + # ), + # neuroglancer.AnnotationPropertySpec( + # id="group", + # type="uint8", + # default=0, + # enum_labels=["fixed", "moving"], + # enum_values=[0, 1], + # ), + # ], + # shader=MARKERS_SHADER, + ) + s.layers[self.annotations_name].tool = "annotatePoint" + s.selected_layer.layer = self.annotations_name + s.selected_layer.visible = True + s.layout.children[0].layers.append(self.annotations_name) + s.layout.children[1].layers.append(self.annotations_name) - def transform_points_with_affine(self, points: np.ndarray): - if self.affine is not None: - return transform_points(self.affine, points) + def init_coord_spaces(self, s: neuroglancer.ViewerState): + dimensions = s.dimensions.names + s.layout.children[1].displayDimensions.link = "unlinked" + print(s.layout.children[1].displayDimensions.value) + s.layout.children[1].displayDimensions.value = [d + "2" for d in dimensions] + # Now I think we need some help from Python, which would be to get the + # current transform and replace all the names by the above for the output def toggle_registered_visibility(self, _): - with self.viewer.txn() as s: - is_registered_visible = s.layers["registered"].visible - s.layers["registered"].visible = not is_registered_visible - - def add_fake_data_to_viewer(self): - viewer = self.viewer - fixed_layer = self.create_demo_fixed_image() - moving_layer = self.create_demo_moving_image() - - with viewer.txn() as s: - s.layers["fixed"] = fixed_layer - s.layers[self.moving_name] = moving_layer + self.setup_viewer() + # is_registered_visible = s.layers["registered"].visible + # s.layers["registered"].visible = not is_registered_visible def setup_viewer_actions(self): viewer = self.viewer @@ -277,6 +358,7 @@ def is_fixed_image_space_last(self, dim_names): first_name = dim_names[0] return first_name[-1] in "0123456789" + # TODO this shouldn't be needed, need to check main python side def init_registered_transform(self, s: neuroglancer.ViewerState, force=True): if not force and s.layers["registered"].source[0].transform is not None: return @@ -298,86 +380,9 @@ def init_registered_transform(self, s: neuroglancer.ViewerState, force=True): s.layers["registered"].source[0].transform = existing_transform print(s.layers["registered"].source[0].transform.matrix) - @debounce(0.5) - def check_viewer_ready_and_setup(self): - with self.viewer.txn() as s: - if s.dimensions.names == [] or s.layers.index(self.moving_name) == -1: - return - s.layers["registered"] = self.create_registered_image() - s.layers["registered"].visible = False - self.init_registered_transform(s) - if s.layers.index(self.annotations_name) == -1: - s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( - dimensions=create_dimensions(s.dimensions), - annotation_properties=[ - neuroglancer.AnnotationPropertySpec( - id="label", - type="uint32", - default=0, - ), - neuroglancer.AnnotationPropertySpec( - id="group", - type="uint8", - default=0, - enum_labels=["fixed", "moving"], - enum_values=[0, 1], - ), - ], - shader=MARKERS_SHADER, - ) - s.layers[self.moving_name].visible = True - s.layers[self.annotations_name].tool = "annotatePoint" - s.selected_layer.layer = self.annotations_name - s.selected_layer.visible = True - - all_layer_names = [layer.name for layer in s.layers] - group_1_names = [ - name for name in all_layer_names if name != self.moving_name - ] - group_2_names = [name for name in all_layer_names if name != "registered"] - if isinstance(s.layout, neuroglancer.DataPanelLayout): - s.layout = neuroglancer.row_layout( - [ - neuroglancer.LayerGroupViewer( - layers=group_1_names, layout="xy-3d" - ), - neuroglancer.LayerGroupViewer( - layers=group_2_names, layout="xy-3d" - ), - ] - ) - s.layout.children[1].position.link = "unlinked" - s.layout.children[1].crossSectionOrientation.link = "unlinked" - s.layout.children[1].crossSectionScale.link = "unlinked" - s.layout.children[1].projectionOrientation.link = "unlinked" - s.layout.children[1].projectionScale.link = "unlinked" - # TODO expand to unlink coords and make two coord spaces - else: - s.layout.children[0].layers.append("registered") - - self._set_status_message( - "help", - "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle visiblity of the registered layer.", - ) - self.ready = True - self.setup_viewer_actions() - def on_state_changed(self): self.viewer.defer_callback(self.update) - def update(self): - current_time = time() - if current_time - self.last_updated_print > 5: - print(f"Viewer states are successfully syncing at {ctime()}") - self.last_updated_print = current_time - if not self.ready: - self.check_viewer_ready_and_setup() - return - if not self.two_coord_spaces: - self.automatically_group_markers_and_update() - self.update_affine() - self._clear_status_messages() - @debounce(0.25) def automatically_group_markers_and_update(self): with self.viewer.txn() as s: @@ -388,37 +393,6 @@ def update_affine(self): with self.viewer.txn() as s: self.estimate_affine(s) - def create_demo_fixed_image(self): - return neuroglancer.ImageLayer( - source=[ - neuroglancer.LayerDataSource(neuroglancer.LocalVolume(self.demo_data)) - ] - ) - - def create_demo_moving_image(self): - if NUM_DEMO_DIMS == 2: - desired_output_matrix_homogenous = [ - [0.8, 0, 0], - [0, 0.2, 0], - [0, 0, 1], - ] - else: - desired_output_matrix_homogenous = [ - [0.8, 0, 0, 0], - [0, 0.2, 0, 0], - [0, 0, 0.9, 0], - [0, 0, 0, 1], - ] - inverse_matrix = np.linalg.inv(desired_output_matrix_homogenous) - transformed = scipy.ndimage.affine_transform( - self.demo_data, - matrix=inverse_matrix, - ) - print("target demo affine", inverse_matrix) - return neuroglancer.ImageLayer( - source=[neuroglancer.LayerDataSource(neuroglancer.LocalVolume(transformed))] - ) - def create_registered_image(self): with self.viewer.txn() as s: layer = deepcopy(s.layers[self.moving_name]) @@ -568,6 +542,40 @@ def dump_info(self, path: str): with open(path, "w") as f: json.dump(info, f, indent=4) + def get_state(self): + with self.viewer.txn() as s: + return s + + def __str__(self): + return str(self.get_state()) + + def _clear_status_messages(self): + to_pop = [] + for k, v in self.status_timers.items(): + if time() - v > MESSAGE_DURATION: + to_pop.append(k) + for k in to_pop: + with self.viewer.config_state.txn() as s: + s.status_messages.pop(k, None) + self.status_timers.pop(k) + + def _set_status_message(self, key: str, message: str): + with self.viewer.config_state.txn() as s: + s.status_messages[key] = message + self.status_timers[key] = time() + + def transform_points_with_affine(self, points: np.ndarray): + if self.affine is not None: + return transform_points(self.affine, points) + + def _add_demo_data_to_viewer(self): + fixed_layer = _create_demo_fixed_image() + moving_layer = _create_demo_moving_image() + + with self.viewer.txn() as s: + s.layers["fixed"] = fixed_layer + s.layers["moving"] = moving_layer + def add_mapping_args(ap: argparse.ArgumentParser): ap.add_argument( @@ -578,14 +586,6 @@ def add_mapping_args(ap: argparse.ArgumentParser): default="annotation", required=False, ) - ap.add_argument( - "--moving-name", - "-m", - type=str, - help="Name of the moving image layer (default is moving)", - default="moving", - required=False, - ) def handle_args(): From bcafa3cf4e4c93286118067706b156361f2b1e2a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 29 Sep 2025 12:17:05 +0200 Subject: [PATCH 22/65] feat: filling out full lin reg workflow --- .../examples/example_linear_registration.py | 267 ++++++++++-------- 1 file changed, 151 insertions(+), 116 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index d9755c20b..a5980424c 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -11,6 +11,7 @@ MESSAGE_DURATION = 5 # seconds NUM_DEMO_DIMS = 2 # Currently can be 2D or 3D +AFFINE_NUM_DECIMALS = 4 # TODO may not be needed, depends on how we handle the two coord spaces MARKERS_SHADER = """ @@ -46,10 +47,12 @@ def debounced(*args, **kwargs): return decorator -# Inspired by https://github.com/AllenInstitute/render-python/blob/master/renderapi/transform/leaf/affine_models.py - - def fit_model(fixed_points: np.ndarray, moving_points: np.ndarray): + """ + Choose the appropriate model based on number of points and dimensions. + + Inspired by https://github.com/AllenInstitute/render-python/blob/master/renderapi/transform/leaf/affine_models.py + """ assert fixed_points.shape == moving_points.shape N, D = fixed_points.shape @@ -99,13 +102,12 @@ def rigid_or_similarity_fit( # Homogeneous (D+1)x(D+1) T = np.zeros((D, D + 1)) T[:D, :D] = s * R - T[:, -1] = t + T[:, -1] = np.diagonal(t) - affine = np.round(T, decimals=3) + affine = np.round(T, decimals=AFFINE_NUM_DECIMALS) return affine -# TODO bring these fits together a bit more nicely def translation_fit(fixed_points: np.ndarray, moving_points: np.ndarray): N, D = fixed_points.shape @@ -115,7 +117,7 @@ def translation_fit(fixed_points: np.ndarray, moving_points: np.ndarray): affine[:, :D] = np.eye(D) affine[:, -1] = estimated_translation - affine = np.round(affine, decimals=3) + affine = np.round(affine, decimals=AFFINE_NUM_DECIMALS) return affine @@ -130,9 +132,9 @@ def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): for j in range(D): start_index = j * D end_index = (j + 1) * D - A[D * i + j, start_index:end_index] = fixed_points[i] + A[D * i + j, start_index:end_index] = moving_points[i] A[D * i + j, D * D + j] = 1 - B = moving_points.flatten() + B = fixed_points.flatten() # The estimated affine transform params will be flattened # and there will be D * (D + 1) of them @@ -148,12 +150,12 @@ def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): affine[i, -1] = tvec[D * D + i] # Round to close decimals - affine = np.round(affine, decimals=3) + affine = np.round(affine, decimals=AFFINE_NUM_DECIMALS) return affine def transform_points(affine: np.ndarray, points: np.ndarray): - # Apply the current affine transform to the points + # Apply the affine transform to the points transformed = np.zeros_like(points) padded = np.pad(points, ((0, 0), (0, 1)), constant_values=1) for i in range(len(points)): @@ -189,6 +191,7 @@ def _create_demo_fixed_image(): ) +# Only used if no data provided def _create_demo_moving_image(): if NUM_DEMO_DIMS == 2: desired_output_matrix_homogenous = [ @@ -214,20 +217,22 @@ def _create_demo_moving_image(): ) -# TODO this should be more intelligent for the copy -# in that case it should take the original layers dimensions -# and actually map the names -# the problem is that right now we can't query those names from python -# since it wraps the state and that info isn't in the state if it is still the -# default information +def change_coord_names(dims: neuroglancer.CoordinateSpace, name_mod): + return neuroglancer.CoordinateSpace( + names=[n + name_mod for n in dims.names], + units=dims.units, + scales=dims.scales, + ) + + def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace, indices=None): names = viewer_dims.names units = viewer_dims.units scales = viewer_dims.scales if indices is not None: - names = [viewer_dims.names[i] for i in indices] - units = [viewer_dims.units[i] for i in indices] - scales = [viewer_dims.scales[i] for i in indices] + names = [names[i] for i in indices] + units = [units[i] for i in indices] + scales = [scales[i] for i in indices] return neuroglancer.CoordinateSpace(names=names, units=units, scales=scales) @@ -235,14 +240,17 @@ def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace, indices=None): class LinearRegistrationWorkflow: def __init__(self, args): starting_state = args.state + self.two_coord_spaces = not args.single_coord_space self.annotations_name = args.annotations_name self.status_timers = {} self.stored_points = [[], []] + self.stored_moving_dims = {} + self.moving_layer_names = [] self.stored_group_number = -1 self.affine = None + self.co_ords_ready = False self.ready = False self.last_updated_print = -1 - self.two_coord_spaces = False self.viewer = neuroglancer.Viewer() self.viewer.shared_state.add_changed_callback( lambda: self.viewer.defer_callback(self.on_state_changed) @@ -258,7 +266,7 @@ def __init__(self, args): "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup.", ) with self.viewer.txn() as s: - self.create_two_panel_layout(s) + self.setup_two_panel_layout(s) self.setup_viewer_actions() def update(self): @@ -267,43 +275,63 @@ def update(self): if current_time - self.last_updated_print > 5: print(f"Viewer states are successfully syncing at {ctime()}") self.last_updated_print = current_time - return # for now don't do regular actions - if not self.two_coord_spaces: - self.automatically_group_markers_and_update() - self.update_affine() - self._clear_status_messages() + # TODO make ready a status instead of two vars + # TODO overall update the class attributes at the end to cleaner + if self.co_ords_ready and not self.ready: + with self.viewer.txn() as s: + self.setup_registration_layers(s) + if self.ready: + if not self.two_coord_spaces: + self.automatically_group_markers_and_update() + self.update_affine() + self._clear_status_messages() def setup_viewer(self): - with self.viewer.txn() as s: - self.init_coord_spaces(s) - self.create_registration_layers(s) + self.setup_second_coord_space() self._set_status_message( "help", "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle visiblity of the registered layer.", ) - self.ready = True + self.co_ords_ready = True - def create_two_panel_layout(self, s: neuroglancer.ViewerState): + def setup_two_panel_layout(self, s: neuroglancer.ViewerState): all_layer_names = [layer.name for layer in s.layers] - half_point = len(all_layer_names) // 2 - group1_names = all_layer_names[:half_point] - group2_names = all_layer_names[half_point:] + if len(all_layer_names) >= 2: + half_point = len(all_layer_names) // 2 + group1_names = all_layer_names[:half_point] + group2_names = all_layer_names[half_point:] + else: + group1_names = all_layer_names + group2_names = all_layer_names s.layout = neuroglancer.row_layout( [ neuroglancer.LayerGroupViewer(layers=group1_names, layout="xy-3d"), neuroglancer.LayerGroupViewer(layers=group2_names, layout="xy-3d"), ] ) - s.layout.children[1].position.link = "unlinked" - s.layout.children[1].position.value = [512, 0, 40] + # Unliked position solves rendering problem but makes navigation awkward + # s.layout.children[1].position.link = "unlinked" + # In theory we could make keep unlinked and then on state change check + # but that could be not worth compared to trying to improve rendering s.layout.children[1].crossSectionOrientation.link = "unlinked" s.layout.children[1].crossSectionScale.link = "unlinked" s.layout.children[1].projectionOrientation.link = "unlinked" s.layout.children[1].projectionScale.link = "unlinked" - def create_registration_layers(self, s: neuroglancer.ViewerState): - # TODO consider making a copy of registered layers in left panel - self.init_registered_transform(s) + def setup_second_coord_space(self): + if not self.moving_layer_names: + moving_layers = self.get_state().layout.children[1].layers + self.moving_layer_names = moving_layers + self._moving_idx = 0 + layer_name = self.moving_layer_names[self._moving_idx] + info_future = self.viewer.volume_info(layer_name) + info_future.add_done_callback(lambda f: self.save_coord_space_info(f)) + + def setup_registration_layers(self, s: neuroglancer.ViewerState): + dimensions = s.dimensions + # It is possible that the dimensions are not ready yet, return if so + if len(dimensions.names) != self.num_dims: + return # Make the annotation layer if needed # TODO probably don't need the properties, to be confirmed if # one co-ord space is fine or need two @@ -331,14 +359,39 @@ def create_registration_layers(self, s: neuroglancer.ViewerState): s.selected_layer.visible = True s.layout.children[0].layers.append(self.annotations_name) s.layout.children[1].layers.append(self.annotations_name) + self.setup_panel_coordinates(s) + self.ready = True - def init_coord_spaces(self, s: neuroglancer.ViewerState): + def setup_panel_coordinates(self, s: neuroglancer.ViewerState): dimensions = s.dimensions.names s.layout.children[1].displayDimensions.link = "unlinked" - print(s.layout.children[1].displayDimensions.value) - s.layout.children[1].displayDimensions.value = [d + "2" for d in dimensions] - # Now I think we need some help from Python, which would be to get the - # current transform and replace all the names by the above for the output + s.layout.children[1].displayDimensions.value = self.output_dim_names[:3] + s.layout.children[0].displayDimensions.link = "unlinked" + s.layout.children[0].displayDimensions.value = self.input_dim_names[:3] + + def save_coord_space_info(self, info_future): + result = info_future.result() + self.moving_name = self.moving_layer_names[self._moving_idx] + self.stored_moving_dims[self.moving_name] = result.dimensions + done = len(self.stored_moving_dims) == len(self.moving_layer_names) + if not done: + self._moving_idx += 1 + self.setup_second_coord_space() + return + # If we get here we have all the coord spaces ready and can update viewer + with self.viewer.txn() as s: + for layer_name in self.moving_layer_names: + input_dims = self.stored_moving_dims[layer_name] + output_dims = change_coord_names(input_dims, "2") + self.input_dim_names = input_dims.names + self.output_dim_names = output_dims.names + self.num_dims = len(input_dims.names) * 2 + new_coord_space = neuroglancer.CoordinateSpaceTransform( + input_dimensions=input_dims, + output_dimensions=output_dims, + ) + for source in s.layers[layer_name].source: + source.transform = new_coord_space def toggle_registered_visibility(self, _): self.setup_viewer() @@ -348,37 +401,16 @@ def toggle_registered_visibility(self, _): def setup_viewer_actions(self): viewer = self.viewer viewer.actions.add( - "toggle-registered-visibility", self.toggle_registered_visibility + "toggleRegisteredVisibility", self.toggle_registered_visibility ) with viewer.config_state.txn() as s: - s.input_event_bindings.viewer["keyt"] = "toggle-registered-visibility" + s.input_event_bindings.viewer["keyt"] = "toggleRegisteredVisibility" + s.input_event_bindings.viewer["keyp"] = "screenshotStatistics" def is_fixed_image_space_last(self, dim_names): first_name = dim_names[0] - return first_name[-1] in "0123456789" - - # TODO this shouldn't be needed, need to check main python side - def init_registered_transform(self, s: neuroglancer.ViewerState, force=True): - if not force and s.layers["registered"].source[0].transform is not None: - return - # TODO this can fail to solve the no matrix issue if ends up as the identity - # think I need to change something in neuroglancer python for this instead - indices = None - if self.check_for_two_coord_spaces(s.dimensions.names): - self.coord_spaces = True - num_dims = len(s.dimensions.names) // 2 - if self.is_fixed_image_space_last(s.dimensions.names): - indices = list( - range(len(s.dimensions.names) - num_dims, len(s.dimensions.names)) - ) - else: - indices = list(range(num_dims)) - existing_transform = neuroglancer.CoordinateSpaceTransform( - output_dimensions=create_dimensions(s.dimensions, indices) - ) - s.layers["registered"].source[0].transform = existing_transform - print(s.layers["registered"].source[0].transform.matrix) + return first_name not in self.input_dim_names def on_state_changed(self): self.viewer.defer_callback(self.update) @@ -399,22 +431,10 @@ def create_registered_image(self): layer.name = "registered" return layer - def check_for_two_coord_spaces(self, dim_names): - # Dims should be exactly double the number of unique names - if len(dim_names) == 0 or len(dim_names) % 2 != 0: - return False - set_of_names = set() - for name in dim_names: - # rstrip any number off the end - stripped_name = name.rstrip("0123456789") - set_of_names.add(stripped_name) - return len(set_of_names) * 2 == len(dim_names) - def split_points_into_pairs(self, annotations, dim_names): if len(annotations) == 0: return np.zeros((0, 0)), np.zeros((0, 0)) - two_coord_spaces = self.check_for_two_coord_spaces(dim_names) - if two_coord_spaces: + if self.two_coord_spaces: real_dims_last = self.is_fixed_image_space_last(dim_names) num_points = len(annotations) num_dims = len(annotations[0].point) // 2 @@ -444,7 +464,7 @@ def split_points_into_pairs(self, annotations, dim_names): def automatically_group_markers(self, s: neuroglancer.ViewerState): dimensions = s.dimensions.names - if self.check_for_two_coord_spaces(dimensions): + if self.two_coord_spaces: return False annotations = s.layers[self.annotations_name].annotations if len(annotations) == self.stored_group_number: @@ -456,46 +476,53 @@ def automatically_group_markers(self, s: neuroglancer.ViewerState): a.props = [i // 2, i % 2] return True - def update_registered_layer(self, s: neuroglancer.ViewerState): - self.init_registered_transform(s, force=False) + def update_registered_layers(self, s: neuroglancer.ViewerState): if self.affine is not None: - print(s.layers["registered"].source[0].transform.matrix) transform = self.affine.tolist() + # TODO handle layer being renamed + for k, v in self.stored_moving_dims.items(): + # TODO not sure if need to handle local channels here + # keeping code below just in case + for source in s.layers[k].source: + source.transform = neuroglancer.CoordinateSpaceTransform( + input_dimensions=v, + output_dimensions=change_coord_names(v, "2"), + matrix=transform, + ) + + # print(s.layers["registered"].source[0].transform.matrix) # TODO this is where that mapping needs to happen of affine dims # overall this is a bit awkward right now, we need a lot of # mapping info which we just don't have # right now you can't input it from the command line - if s.layers["registered"].source[0].transform is not None: - final_transform = [] - layer_transform = s.layers["registered"].source[0].transform - local_channel_indices = [ - i - for i, name in enumerate(layer_transform.outputDimensions.names) - if name.endswith(("'", "^", "#")) - ] - num_local_count = 0 - for i, name in enumerate(layer_transform.outputDimensions.names): - is_local = i in local_channel_indices - if is_local: - final_transform.append(layer_transform.matrix[i].tolist()) - num_local_count += 1 - else: - row = transform[i - num_local_count] - # At the indices corresponding to local channels, insert 0s - for j in local_channel_indices: - row.insert(j, 0) - final_transform.append(row) - else: - final_transform = transform - print("Updated affine transform:", final_transform) - print(s.layers["registered"].source[0].transform) - print(final_transform) - s.layers["registered"].source[0].transform.matrix = final_transform + # if s.layers["registered"].source[0].transform is not None: + # final_transform = [] + # layer_transform = s.layers["registered"].source[0].transform + # local_channel_indices = [ + # i + # for i, name in enumerate(layer_transform.outputDimensions.names) + # if name.endswith(("'", "^", "#")) + # ] + # num_local_count = 0 + # for i, name in enumerate(layer_transform.outputDimensions.names): + # is_local = i in local_channel_indices + # if is_local: + # final_transform.append(layer_transform.matrix[i].tolist()) + # num_local_count += 1 + # else: + # row = transform[i - num_local_count] + # # At the indices corresponding to local channels, insert 0s + # for j in local_channel_indices: + # row.insert(j, 0) + # final_transform.append(row) + # else: + # final_transform = transform + print("Updated affine transform:", transform) print(s.layers["registered"].source[0].transform) def estimate_affine(self, s: neuroglancer.ViewerState): annotations = s.layers[self.annotations_name].annotations - if len(annotations) < 1: + if len(annotations) == 0: return False dim_names = s.dimensions.names @@ -509,8 +536,8 @@ def estimate_affine(self, s: neuroglancer.ViewerState): np.isclose(self.stored_points[1], moving_points) ): return False - self.affine = fit_model(moving_points, fixed_points) - self.update_registered_layer(s) + self.affine = fit_model(fixed_points, moving_points) + self.update_registered_layers(s) self._set_status_message( "info", @@ -586,6 +613,14 @@ def add_mapping_args(ap: argparse.ArgumentParser): default="annotation", required=False, ) + ap.add_argument( + "--single-coord-space", + "-s", + action="store_true", + help="Use a single coordinate space for both fixed and moving layers (default is two coord spaces)", + default=False, + required=False, + ) def handle_args(): From db7f2347f8eb62f8de8e5040b7c1cba2dfb8d3b9 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 29 Sep 2025 12:53:55 +0200 Subject: [PATCH 23/65] feat: apply affine to annotations --- .../examples/example_linear_registration.py | 110 +++++++++++++----- 1 file changed, 83 insertions(+), 27 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index a5980424c..0406dc6f1 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -13,7 +13,6 @@ NUM_DEMO_DIMS = 2 # Currently can be 2D or 3D AFFINE_NUM_DECIMALS = 4 -# TODO may not be needed, depends on how we handle the two coord spaces MARKERS_SHADER = """ #uicontrol vec3 fixedPointColor color(default="#00FF00") #uicontrol vec3 movingPointColor color(default="#0000FF") @@ -102,7 +101,7 @@ def rigid_or_similarity_fit( # Homogeneous (D+1)x(D+1) T = np.zeros((D, D + 1)) T[:D, :D] = s * R - T[:, -1] = np.diagonal(t) + T[:, -1] = -1 * np.diagonal(t) affine = np.round(T, decimals=AFFINE_NUM_DECIMALS) return affine @@ -246,6 +245,8 @@ def __init__(self, args): self.stored_points = [[], []] self.stored_moving_dims = {} self.moving_layer_names = [] + self.input_dim_names = [] + self.output_dim_names = [] self.stored_group_number = -1 self.affine = None self.co_ords_ready = False @@ -314,9 +315,9 @@ def setup_two_panel_layout(self, s: neuroglancer.ViewerState): # In theory we could make keep unlinked and then on state change check # but that could be not worth compared to trying to improve rendering s.layout.children[1].crossSectionOrientation.link = "unlinked" - s.layout.children[1].crossSectionScale.link = "unlinked" + # s.layout.children[1].crossSectionScale.link = "unlinked" s.layout.children[1].projectionOrientation.link = "unlinked" - s.layout.children[1].projectionScale.link = "unlinked" + # s.layout.children[1].projectionScale.link = "unlinked" def setup_second_coord_space(self): if not self.moving_layer_names: @@ -327,33 +328,69 @@ def setup_second_coord_space(self): info_future = self.viewer.volume_info(layer_name) info_future.add_done_callback(lambda f: self.save_coord_space_info(f)) + def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): + all_dims = s.dimensions.names + moving_dims = self.output_dim_names + # The affine matrix only applies to the moving dims + # so we need to create a larger matrix that applies to all dims + # by adding identity transforms for the real dims + full_matrix = np.zeros((len(all_dims), len(all_dims) + 1)) + for i, dim in enumerate(all_dims): + for j, dim2 in enumerate(all_dims): + if dim in moving_dims and dim2 in moving_dims: + moving_i = moving_dims.index(dim) + moving_j = moving_dims.index(dim2) + full_matrix[i, j] = affine[moving_i, moving_j] + elif dim == dim2: + full_matrix[i, j] = 1 + if dim in moving_dims: + moving_i = moving_dims.index(dim) + full_matrix[i, -1] = affine[moving_i, -1] + return full_matrix + def setup_registration_layers(self, s: neuroglancer.ViewerState): dimensions = s.dimensions # It is possible that the dimensions are not ready yet, return if so if len(dimensions.names) != self.num_dims: return + # Make the annotation layer if needed - # TODO probably don't need the properties, to be confirmed if - # one co-ord space is fine or need two if s.layers.index(self.annotations_name) == -1: - s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( - dimensions=create_dimensions(s.dimensions), - # annotation_properties=[ - # neuroglancer.AnnotationPropertySpec( - # id="label", - # type="uint32", - # default=0, - # ), - # neuroglancer.AnnotationPropertySpec( - # id="group", - # type="uint8", - # default=0, - # enum_labels=["fixed", "moving"], - # enum_values=[0, 1], - # ), - # ], - # shader=MARKERS_SHADER, - ) + if self.two_coord_spaces: + s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( + dimensions=create_dimensions(s.dimensions) + ) + else: + s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( + dimensions=create_dimensions(s.dimensions), + annotation_properties=[ + neuroglancer.AnnotationPropertySpec( + id="label", + type="uint32", + default=0, + ), + neuroglancer.AnnotationPropertySpec( + id="group", + type="uint8", + default=0, + enum_labels=["fixed", "moving"], + enum_values=[0, 1], + ), + ], + shader=MARKERS_SHADER, + ) + + # Make a copy of all the moving layers but in original coord space + # and as part of the left hand panel + for layer_name in self.moving_layer_names: + copy = deepcopy(s.layers[layer_name]) + copy.name = layer_name + "_registered" + copy.visible = False + for source in copy.source: + # TODO might need mapping + source.transform = None + s.layers[copy.name] = copy + s.layout.children[0].layers.append(copy.name) s.layers[self.annotations_name].tool = "annotatePoint" s.selected_layer.layer = self.annotations_name s.selected_layer.visible = True @@ -394,9 +431,14 @@ def save_coord_space_info(self, info_future): source.transform = new_coord_space def toggle_registered_visibility(self, _): - self.setup_viewer() - # is_registered_visible = s.layers["registered"].visible - # s.layers["registered"].visible = not is_registered_visible + if not self.ready: + self.setup_viewer() + return + with self.viewer.txn() as s: + for layer_name in self.moving_layer_names: + registered_name = layer_name + "_registered" + is_registered_visible = s.layers[registered_name].visible + s.layers[registered_name].visible = not is_registered_visible def setup_viewer_actions(self): viewer = self.viewer @@ -489,6 +531,20 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): output_dimensions=change_coord_names(v, "2"), matrix=transform, ) + for source in s.layers[k + "_registered"].source: + source.transform = neuroglancer.CoordinateSpaceTransform( + input_dimensions=v, + output_dimensions=v, + matrix=transform, + ) + print(self.combine_affine_across_dims(s, self.affine).tolist()) + s.layers[self.annotations_name].source[ + 0 + ].transform = neuroglancer.CoordinateSpaceTransform( + input_dimensions=create_dimensions(s.dimensions), + output_dimensions=create_dimensions(s.dimensions), + matrix=self.combine_affine_across_dims(s, self.affine).tolist(), + ) # print(s.layers["registered"].source[0].transform.matrix) # TODO this is where that mapping needs to happen of affine dims From d9855e64bf4f29d1d085011e1148f3338ebb4858 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 3 Oct 2025 14:05:32 +0200 Subject: [PATCH 24/65] feat: small updates --- .../examples/example_linear_registration.py | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 0406dc6f1..278856454 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -276,8 +276,6 @@ def update(self): if current_time - self.last_updated_print > 5: print(f"Viewer states are successfully syncing at {ctime()}") self.last_updated_print = current_time - # TODO make ready a status instead of two vars - # TODO overall update the class attributes at the end to cleaner if self.co_ords_ready and not self.ready: with self.viewer.txn() as s: self.setup_registration_layers(s) @@ -310,13 +308,14 @@ def setup_two_panel_layout(self, s: neuroglancer.ViewerState): neuroglancer.LayerGroupViewer(layers=group2_names, layout="xy-3d"), ] ) - # Unliked position solves rendering problem but makes navigation awkward - # s.layout.children[1].position.link = "unlinked" - # In theory we could make keep unlinked and then on state change check - # but that could be not worth compared to trying to improve rendering + # Unlinked position solves rendering problem but makes navigation awkward + if not self.two_coord_spaces + s.layout.children[1].position.link = "unlinked" s.layout.children[1].crossSectionOrientation.link = "unlinked" - # s.layout.children[1].crossSectionScale.link = "unlinked" s.layout.children[1].projectionOrientation.link = "unlinked" + + # Can also unlink scales if desired + # s.layout.children[1].crossSectionScale.link = "unlinked" # s.layout.children[1].projectionScale.link = "unlinked" def setup_second_coord_space(self): @@ -329,12 +328,15 @@ def setup_second_coord_space(self): info_future.add_done_callback(lambda f: self.save_coord_space_info(f)) def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): + """ + The affine matrix only applies to the moving dims + but the annotation layer in the two coord space case + applies to all dims so we need to create a larger matrix + """ all_dims = s.dimensions.names moving_dims = self.output_dim_names - # The affine matrix only applies to the moving dims - # so we need to create a larger matrix that applies to all dims - # by adding identity transforms for the real dims full_matrix = np.zeros((len(all_dims), len(all_dims) + 1)) + for i, dim in enumerate(all_dims): for j, dim2 in enumerate(all_dims): if dim in moving_dims and dim2 in moving_dims: @@ -350,8 +352,7 @@ def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): def setup_registration_layers(self, s: neuroglancer.ViewerState): dimensions = s.dimensions - # It is possible that the dimensions are not ready yet, return if so - if len(dimensions.names) != self.num_dims: + if len(dimensions.names) != self.num_dims # loading: return # Make the annotation layer if needed @@ -448,11 +449,6 @@ def setup_viewer_actions(self): with viewer.config_state.txn() as s: s.input_event_bindings.viewer["keyt"] = "toggleRegisteredVisibility" - s.input_event_bindings.viewer["keyp"] = "screenshotStatistics" - - def is_fixed_image_space_last(self, dim_names): - first_name = dim_names[0] - return first_name not in self.input_dim_names def on_state_changed(self): self.viewer.defer_callback(self.update) @@ -477,7 +473,8 @@ def split_points_into_pairs(self, annotations, dim_names): if len(annotations) == 0: return np.zeros((0, 0)), np.zeros((0, 0)) if self.two_coord_spaces: - real_dims_last = self.is_fixed_image_space_last(dim_names) + first_name = dim_names[0] + real_dims_last = first_name not in self.input_dim_names num_points = len(annotations) num_dims = len(annotations[0].point) // 2 fixed_points = np.zeros((num_points, num_dims)) @@ -647,7 +644,7 @@ def _set_status_message(self, key: str, message: str): s.status_messages[key] = message self.status_timers[key] = time() - def transform_points_with_affine(self, points: np.ndarray): + def _transform_points_with_affine(self, points: np.ndarray): if self.affine is not None: return transform_points(self.affine, points) From d6ef631991425af06c0745af84c9c5e91f4ad63e Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 6 Oct 2025 13:08:29 +0200 Subject: [PATCH 25/65] refactor: clarify function names and class --- .../examples/example_linear_registration.py | 230 ++++++++---------- 1 file changed, 104 insertions(+), 126 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 278856454..b03a57c28 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -3,6 +3,7 @@ import webbrowser from copy import deepcopy from time import ctime, time +from enum import Enum import neuroglancer import neuroglancer.cli @@ -216,15 +217,15 @@ def _create_demo_moving_image(): ) -def change_coord_names(dims: neuroglancer.CoordinateSpace, name_mod): +def new_coord_space_names(dims: neuroglancer.CoordinateSpace, name_suffix): return neuroglancer.CoordinateSpace( - names=[n + name_mod for n in dims.names], + names=[n + name_suffix for n in dims.names], units=dims.units, scales=dims.scales, ) -def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace, indices=None): +def create_coord_space_matching_global_dims(viewer_dims: neuroglancer.CoordinateSpace, indices=None): names = viewer_dims.names units = viewer_dims.units scales = viewer_dims.scales @@ -235,6 +236,10 @@ def create_dimensions(viewer_dims: neuroglancer.CoordinateSpace, indices=None): return neuroglancer.CoordinateSpace(names=names, units=units, scales=scales) +class ReadyState(Enum): + NOT_READY = 0 + COORDS_READY = 1 + READY = 2 class LinearRegistrationWorkflow: def __init__(self, args): @@ -243,15 +248,14 @@ def __init__(self, args): self.annotations_name = args.annotations_name self.status_timers = {} self.stored_points = [[], []] - self.stored_moving_dims = {} + self.stored_map_moving_name_to_coords = {} self.moving_layer_names = [] self.input_dim_names = [] self.output_dim_names = [] - self.stored_group_number = -1 self.affine = None self.co_ords_ready = False - self.ready = False - self.last_updated_print = -1 + self.ready_state = ReadyState.NOT_READY + self.last_updated_print_time = -1 self.viewer = neuroglancer.Viewer() self.viewer.shared_state.add_changed_callback( lambda: self.viewer.defer_callback(self.on_state_changed) @@ -266,57 +270,53 @@ def __init__(self, args): "help", "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup.", ) - with self.viewer.txn() as s: - self.setup_two_panel_layout(s) + self.setup_initial_two_panel_layout() self.setup_viewer_actions() def update(self): """Primary update loop, called whenever the viewer state changes.""" current_time = time() - if current_time - self.last_updated_print > 5: + if current_time - self.last_updated_print_time > 5: print(f"Viewer states are successfully syncing at {ctime()}") - self.last_updated_print = current_time - if self.co_ords_ready and not self.ready: - with self.viewer.txn() as s: - self.setup_registration_layers(s) - if self.ready: - if not self.two_coord_spaces: - self.automatically_group_markers_and_update() + self.last_updated_print_time = current_time + if self.ready_state == ReadyState.COORDS_READY: + self.setup_registration_layers() + elif self.ready_state == ReadyState.READY: self.update_affine() self._clear_status_messages() - def setup_viewer(self): + def setup_viewer_after_user_ready(self): self.setup_second_coord_space() self._set_status_message( "help", "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle visiblity of the registered layer.", ) - self.co_ords_ready = True - - def setup_two_panel_layout(self, s: neuroglancer.ViewerState): - all_layer_names = [layer.name for layer in s.layers] - if len(all_layer_names) >= 2: - half_point = len(all_layer_names) // 2 - group1_names = all_layer_names[:half_point] - group2_names = all_layer_names[half_point:] - else: - group1_names = all_layer_names - group2_names = all_layer_names - s.layout = neuroglancer.row_layout( - [ - neuroglancer.LayerGroupViewer(layers=group1_names, layout="xy-3d"), - neuroglancer.LayerGroupViewer(layers=group2_names, layout="xy-3d"), - ] - ) - # Unlinked position solves rendering problem but makes navigation awkward - if not self.two_coord_spaces - s.layout.children[1].position.link = "unlinked" - s.layout.children[1].crossSectionOrientation.link = "unlinked" - s.layout.children[1].projectionOrientation.link = "unlinked" + self.ready_state = ReadyState.COORDS_READY + + def setup_initial_two_panel_layout(self): + with self.viewer.txn() as s: + all_layer_names = [layer.name for layer in s.layers] + if len(all_layer_names) >= 2: + half_point = len(all_layer_names) // 2 + group1_names = all_layer_names[:half_point] + group2_names = all_layer_names[half_point:] + else: + group1_names = all_layer_names + group2_names = all_layer_names + s.layout = neuroglancer.row_layout( + [ + neuroglancer.LayerGroupViewer(layers=group1_names, layout="xy-3d"), + neuroglancer.LayerGroupViewer(layers=group2_names, layout="xy-3d"), + ] + ) + if not self.two_coord_spaces + s.layout.children[1].position.link = "unlinked" + s.layout.children[1].crossSectionOrientation.link = "unlinked" + s.layout.children[1].projectionOrientation.link = "unlinked" - # Can also unlink scales if desired - # s.layout.children[1].crossSectionScale.link = "unlinked" - # s.layout.children[1].projectionScale.link = "unlinked" + # Can also unlink scales if desired + # s.layout.children[1].crossSectionScale.link = "unlinked" + # s.layout.children[1].projectionScale.link = "unlinked" def setup_second_coord_space(self): if not self.moving_layer_names: @@ -350,55 +350,56 @@ def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): full_matrix[i, -1] = affine[moving_i, -1] return full_matrix - def setup_registration_layers(self, s: neuroglancer.ViewerState): - dimensions = s.dimensions - if len(dimensions.names) != self.num_dims # loading: - return - - # Make the annotation layer if needed - if s.layers.index(self.annotations_name) == -1: - if self.two_coord_spaces: - s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( - dimensions=create_dimensions(s.dimensions) - ) - else: - s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( - dimensions=create_dimensions(s.dimensions), - annotation_properties=[ - neuroglancer.AnnotationPropertySpec( - id="label", - type="uint32", - default=0, - ), - neuroglancer.AnnotationPropertySpec( - id="group", - type="uint8", - default=0, - enum_labels=["fixed", "moving"], - enum_values=[0, 1], - ), - ], - shader=MARKERS_SHADER, - ) + def setup_registration_layers(self): + with self.viewer.txn() as s: + dimensions = s.dimensions + if len(dimensions.names) != self.num_dims # loading: + return + + # Make the annotation layer if needed + if s.layers.index(self.annotations_name) == -1: + if self.two_coord_spaces: + s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( + dimensions=create_coord_space_matching_global_dims(s.dimensions) + ) + else: + s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( + dimensions=create_coord_space_matching_global_dims(s.dimensions), + annotation_properties=[ + neuroglancer.AnnotationPropertySpec( + id="label", + type="uint32", + default=0, + ), + neuroglancer.AnnotationPropertySpec( + id="group", + type="uint8", + default=0, + enum_labels=["fixed", "moving"], + enum_values=[0, 1], + ), + ], + shader=MARKERS_SHADER, + ) - # Make a copy of all the moving layers but in original coord space - # and as part of the left hand panel - for layer_name in self.moving_layer_names: - copy = deepcopy(s.layers[layer_name]) - copy.name = layer_name + "_registered" - copy.visible = False - for source in copy.source: - # TODO might need mapping - source.transform = None - s.layers[copy.name] = copy - s.layout.children[0].layers.append(copy.name) - s.layers[self.annotations_name].tool = "annotatePoint" - s.selected_layer.layer = self.annotations_name - s.selected_layer.visible = True - s.layout.children[0].layers.append(self.annotations_name) - s.layout.children[1].layers.append(self.annotations_name) - self.setup_panel_coordinates(s) - self.ready = True + # Make a copy of all the moving layers but in original coord space + # and as part of the left hand panel + for layer_name in self.moving_layer_names: + copy = deepcopy(s.layers[layer_name]) + copy.name = layer_name + "_registered" + copy.visible = False + for source in copy.source: + # TODO might need mapping + source.transform = None + s.layers[copy.name] = copy + s.layout.children[0].layers.append(copy.name) + s.layers[self.annotations_name].tool = "annotatePoint" + s.selected_layer.layer = self.annotations_name + s.selected_layer.visible = True + s.layout.children[0].layers.append(self.annotations_name) + s.layout.children[1].layers.append(self.annotations_name) + self.setup_panel_coordinates(s) + self.ready_state = ReadyState.READY def setup_panel_coordinates(self, s: neuroglancer.ViewerState): dimensions = s.dimensions.names @@ -410,8 +411,8 @@ def setup_panel_coordinates(self, s: neuroglancer.ViewerState): def save_coord_space_info(self, info_future): result = info_future.result() self.moving_name = self.moving_layer_names[self._moving_idx] - self.stored_moving_dims[self.moving_name] = result.dimensions - done = len(self.stored_moving_dims) == len(self.moving_layer_names) + self.stored_map_moving_name_to_coords[self.moving_name] = result.dimensions + done = len(self.stored_map_moving_name_to_coords) == len(self.moving_layer_names) if not done: self._moving_idx += 1 self.setup_second_coord_space() @@ -419,8 +420,8 @@ def save_coord_space_info(self, info_future): # If we get here we have all the coord spaces ready and can update viewer with self.viewer.txn() as s: for layer_name in self.moving_layer_names: - input_dims = self.stored_moving_dims[layer_name] - output_dims = change_coord_names(input_dims, "2") + input_dims = self.stored_map_moving_name_to_coords[layer_name] + output_dims = new_coord_space_names(input_dims, "2") self.input_dim_names = input_dims.names self.output_dim_names = output_dims.names self.num_dims = len(input_dims.names) * 2 @@ -432,8 +433,10 @@ def save_coord_space_info(self, info_future): source.transform = new_coord_space def toggle_registered_visibility(self, _): - if not self.ready: - self.setup_viewer() + if self.ready_state == ReadyState.NOT_READY: + self.setup_viewer_after_user_ready() + return + elif self.ready_state == ReadyState.COORDS_READY: return with self.viewer.txn() as s: for layer_name in self.moving_layer_names: @@ -453,22 +456,11 @@ def setup_viewer_actions(self): def on_state_changed(self): self.viewer.defer_callback(self.update) - @debounce(0.25) - def automatically_group_markers_and_update(self): - with self.viewer.txn() as s: - self.automatically_group_markers(s) - @debounce(1.5) def update_affine(self): with self.viewer.txn() as s: self.estimate_affine(s) - def create_registered_image(self): - with self.viewer.txn() as s: - layer = deepcopy(s.layers[self.moving_name]) - layer.name = "registered" - return layer - def split_points_into_pairs(self, annotations, dim_names): if len(annotations) == 0: return np.zeros((0, 0)), np.zeros((0, 0)) @@ -501,31 +493,17 @@ def split_points_into_pairs(self, annotations, dim_names): return np.array(fixed_points), np.array(moving_points) - def automatically_group_markers(self, s: neuroglancer.ViewerState): - dimensions = s.dimensions.names - if self.two_coord_spaces: - return False - annotations = s.layers[self.annotations_name].annotations - if len(annotations) == self.stored_group_number: - return False - self.stored_group_number = len(annotations) - if len(annotations) < 2: - return False - for i, a in enumerate(s.layers[self.annotations_name].annotations): - a.props = [i // 2, i % 2] - return True - def update_registered_layers(self, s: neuroglancer.ViewerState): if self.affine is not None: transform = self.affine.tolist() # TODO handle layer being renamed - for k, v in self.stored_moving_dims.items(): + for k, v in self.stored_map_moving_name_to_coords.items(): # TODO not sure if need to handle local channels here # keeping code below just in case for source in s.layers[k].source: source.transform = neuroglancer.CoordinateSpaceTransform( input_dimensions=v, - output_dimensions=change_coord_names(v, "2"), + output_dimensions=new_coord_space_names(v, "2"), matrix=transform, ) for source in s.layers[k + "_registered"].source: @@ -538,8 +516,8 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): s.layers[self.annotations_name].source[ 0 ].transform = neuroglancer.CoordinateSpaceTransform( - input_dimensions=create_dimensions(s.dimensions), - output_dimensions=create_dimensions(s.dimensions), + input_dimensions=create_coord_space_matching_global_dims(s.dimensions), + output_dimensions=create_coord_space_matching_global_dims(s.dimensions), matrix=self.combine_affine_across_dims(s, self.affine).tolist(), ) From 94e52700a12de00a36fce2a0e1f2ab4869fc40bc Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 6 Oct 2025 17:31:12 +0200 Subject: [PATCH 26/65] feat: save progress of various fixes --- .../examples/example_linear_registration.py | 113 +++++++++++++----- 1 file changed, 86 insertions(+), 27 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index b03a57c28..17c4aa7de 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -11,7 +11,7 @@ import scipy.ndimage MESSAGE_DURATION = 5 # seconds -NUM_DEMO_DIMS = 2 # Currently can be 2D or 3D +NUM_DEMO_DIMS = 3 # Currently can be 2D or 3D AFFINE_NUM_DECIMALS = 4 MARKERS_SHADER = """ @@ -218,8 +218,12 @@ def _create_demo_moving_image(): def new_coord_space_names(dims: neuroglancer.CoordinateSpace, name_suffix): + def change_name(n): + if n.endswith(("'", "^", "#")): + return n + return n + name_suffix return neuroglancer.CoordinateSpace( - names=[n + name_suffix for n in dims.names], + names=[change_name(n) for n in dims.names], units=dims.units, scales=dims.scales, ) @@ -240,6 +244,7 @@ class ReadyState(Enum): NOT_READY = 0 COORDS_READY = 1 READY = 2 + ERROR = 3 class LinearRegistrationWorkflow: def __init__(self, args): @@ -248,14 +253,17 @@ def __init__(self, args): self.annotations_name = args.annotations_name self.status_timers = {} self.stored_points = [[], []] - self.stored_map_moving_name_to_coords = {} + self.stored_map_moving_name_to_data_coords = {} + self.stored_map_moving_name_to_viewer_coords = {} self.moving_layer_names = [] self.input_dim_names = [] self.output_dim_names = [] self.affine = None self.co_ords_ready = False + self.stored_last_grouped_points_num = -1 self.ready_state = ReadyState.NOT_READY self.last_updated_print_time = -1 + self.num_dims = 0 self.viewer = neuroglancer.Viewer() self.viewer.shared_state.add_changed_callback( lambda: self.viewer.defer_callback(self.on_state_changed) @@ -281,17 +289,49 @@ def update(self): self.last_updated_print_time = current_time if self.ready_state == ReadyState.COORDS_READY: self.setup_registration_layers() + if self.ready_state == ReadyState.ERROR: + # TODO put in right place + self._set_status_message( + "help", + "Please manually enter second coordinate space information for the moving layers." + ) elif self.ready_state == ReadyState.READY: + if not self.two_coord_spaces: + self.update_based_on_annotations_debounced() self.update_affine() self._clear_status_messages() + @debounce(0.25) + def update_based_on_annotations_debounced(self): + with self.viewer.txn() as s: + self.update_based_on_annotations(s) + + def update_based_on_annotations(self, s: neuroglancer.ViewerState): + annotations = s.layers[self.annotations_name].annotations + if len(annotations) > 2 and self.stored_last_grouped_points_num != len(annotations): + # TODO check the annotations for being modified + for i, a in enumerate(s.layers[self.annotations_name].annotations): + a.props = [i // 2, i % 2] + # Check if we are at one of the annotations in the left hand panel + # or right hand panel, if so we can move to that in the other panel + right_hand_position = s.layout.children[1].position.value + left_hand_position = s.position + + def setup_viewer_after_user_ready(self): - self.setup_second_coord_space() + if not self.moving_layer_names: + moving_layers = self.get_state().layout.children[1].layers + self.moving_layer_names = moving_layers + print(self.moving_layer_names) + self._moving_idx = 0 + if self.two_coord_spaces: + self.setup_second_coord_space() + else: + self.setup_registration_layers() self._set_status_message( "help", "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle visiblity of the registered layer.", ) - self.ready_state = ReadyState.COORDS_READY def setup_initial_two_panel_layout(self): with self.viewer.txn() as s: @@ -309,7 +349,7 @@ def setup_initial_two_panel_layout(self): neuroglancer.LayerGroupViewer(layers=group2_names, layout="xy-3d"), ] ) - if not self.two_coord_spaces + if not self.two_coord_spaces: s.layout.children[1].position.link = "unlinked" s.layout.children[1].crossSectionOrientation.link = "unlinked" s.layout.children[1].projectionOrientation.link = "unlinked" @@ -319,10 +359,6 @@ def setup_initial_two_panel_layout(self): # s.layout.children[1].projectionScale.link = "unlinked" def setup_second_coord_space(self): - if not self.moving_layer_names: - moving_layers = self.get_state().layout.children[1].layers - self.moving_layer_names = moving_layers - self._moving_idx = 0 layer_name = self.moving_layer_names[self._moving_idx] info_future = self.viewer.volume_info(layer_name) info_future.add_done_callback(lambda f: self.save_coord_space_info(f)) @@ -353,7 +389,7 @@ def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): def setup_registration_layers(self): with self.viewer.txn() as s: dimensions = s.dimensions - if len(dimensions.names) != self.num_dims # loading: + if not self.ready_state == ReadyState.ERROR or (self.two_coord_spaces and (len(dimensions.names) != self.num_dims)): # loading return # Make the annotation layer if needed @@ -388,9 +424,10 @@ def setup_registration_layers(self): copy = deepcopy(s.layers[layer_name]) copy.name = layer_name + "_registered" copy.visible = False - for source in copy.source: - # TODO might need mapping - source.transform = None + if self.two_coord_spaces: + for source in copy.source: + # TODO might need mapping + source.transform = None s.layers[copy.name] = copy s.layout.children[0].layers.append(copy.name) s.layers[self.annotations_name].tool = "annotatePoint" @@ -409,28 +446,48 @@ def setup_panel_coordinates(self, s: neuroglancer.ViewerState): s.layout.children[0].displayDimensions.value = self.input_dim_names[:3] def save_coord_space_info(self, info_future): - result = info_future.result() self.moving_name = self.moving_layer_names[self._moving_idx] - self.stored_map_moving_name_to_coords[self.moving_name] = result.dimensions - done = len(self.stored_map_moving_name_to_coords) == len(self.moving_layer_names) + try: + result = info_future.result() + except Exception as e: + print(f"ERROR: Could not parse volume info for {self.moving_name}: {e} {info_future}") + print("Please manually enter the coordinate space information as a second co-ordinate space.") + self.ready_state = ReadyState.ERROR + self._moving_idx += 1 + if self._moving_idx < len(self.moving_layer_names): + self.setup_second_coord_space() + return + # TODO not sure how to recover from this, maybe ask user to manually + # input the coord space info? + # TODO clean up + done = len(self.stored_map_moving_name_to_data_coords) == len(self.moving_layer_names) if not done: self._moving_idx += 1 self.setup_second_coord_space() return + self.stored_map_moving_name_to_data_coords[self.moving_name] = result.dimensions # If we get here we have all the coord spaces ready and can update viewer with self.viewer.txn() as s: for layer_name in self.moving_layer_names: - input_dims = self.stored_map_moving_name_to_coords[layer_name] - output_dims = new_coord_space_names(input_dims, "2") + input_dims = self.stored_map_moving_name_to_data_coords[layer_name] + print(layer_name, input_dims) self.input_dim_names = input_dims.names - self.output_dim_names = output_dims.names - self.num_dims = len(input_dims.names) * 2 - new_coord_space = neuroglancer.CoordinateSpaceTransform( - input_dimensions=input_dims, - output_dimensions=output_dims, - ) + self.stored_map_moving_name_to_viewer_coords[layer_name] = [] for source in s.layers[layer_name].source: + if source.transform is None: + output_dims = new_coord_space_names(input_dims, "2") + else: + output_dims = new_coord_space_names(source.transform.output_dimensions, "2") + self.output_dim_names = output_dims.names + new_coord_space = neuroglancer.CoordinateSpaceTransform( + input_dimensions=input_dims, + output_dimensions=output_dims, + ) + self.num_dims = max(self.num_dims, 2 * len([n for n in new_coord_space.output_dimensions.names if not n.endswith(("'", "^", "#"))])) + self.stored_map_moving_name_to_viewer_coords[layer_name].append(new_coord_space) source.transform = new_coord_space + if not self.ready_state == ReadyState.ERROR: + self.ready_state = ReadyState.COORDS_READY def toggle_registered_visibility(self, _): if self.ready_state == ReadyState.NOT_READY: @@ -438,6 +495,8 @@ def toggle_registered_visibility(self, _): return elif self.ready_state == ReadyState.COORDS_READY: return + elif self.ready_state == ReadyState.ERROR: + self.setup_registration_layers() with self.viewer.txn() as s: for layer_name in self.moving_layer_names: registered_name = layer_name + "_registered" @@ -497,7 +556,7 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): if self.affine is not None: transform = self.affine.tolist() # TODO handle layer being renamed - for k, v in self.stored_map_moving_name_to_coords.items(): + for k, v in self.stored_map_moving_name_to_data_coords.items(): # TODO not sure if need to handle local channels here # keeping code below just in case for source in s.layers[k].source: @@ -553,7 +612,7 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): def estimate_affine(self, s: neuroglancer.ViewerState): annotations = s.layers[self.annotations_name].annotations - if len(annotations) == 0: + if len(annotations) == 0 or (not self.two_coord_spaces and len(annotations) < 2): return False dim_names = s.dimensions.names From 409c744b6ed13ad0eaf5cbea1f151d237aecb547 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Oct 2025 16:17:54 +0200 Subject: [PATCH 27/65] refactor: remove second co-ord space --- .../examples/example_linear_registration.py | 159 +++++++----------- 1 file changed, 59 insertions(+), 100 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 17c4aa7de..3ff9c14c8 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -2,8 +2,8 @@ import threading import webbrowser from copy import deepcopy -from time import ctime, time from enum import Enum +from time import ctime, time import neuroglancer import neuroglancer.cli @@ -222,6 +222,7 @@ def change_name(n): if n.endswith(("'", "^", "#")): return n return n + name_suffix + return neuroglancer.CoordinateSpace( names=[change_name(n) for n in dims.names], units=dims.units, @@ -229,7 +230,9 @@ def change_name(n): ) -def create_coord_space_matching_global_dims(viewer_dims: neuroglancer.CoordinateSpace, indices=None): +def create_coord_space_matching_global_dims( + viewer_dims: neuroglancer.CoordinateSpace, indices=None +): names = viewer_dims.names units = viewer_dims.units scales = viewer_dims.scales @@ -240,16 +243,17 @@ def create_coord_space_matching_global_dims(viewer_dims: neuroglancer.Coordinate return neuroglancer.CoordinateSpace(names=names, units=units, scales=scales) + class ReadyState(Enum): NOT_READY = 0 COORDS_READY = 1 READY = 2 ERROR = 3 + class LinearRegistrationWorkflow: def __init__(self, args): starting_state = args.state - self.two_coord_spaces = not args.single_coord_space self.annotations_name = args.annotations_name self.status_timers = {} self.stored_points = [[], []] @@ -293,41 +297,19 @@ def update(self): # TODO put in right place self._set_status_message( "help", - "Please manually enter second coordinate space information for the moving layers." + "Please manually enter second coordinate space information for the moving layers.", ) elif self.ready_state == ReadyState.READY: - if not self.two_coord_spaces: - self.update_based_on_annotations_debounced() self.update_affine() self._clear_status_messages() - @debounce(0.25) - def update_based_on_annotations_debounced(self): - with self.viewer.txn() as s: - self.update_based_on_annotations(s) - - def update_based_on_annotations(self, s: neuroglancer.ViewerState): - annotations = s.layers[self.annotations_name].annotations - if len(annotations) > 2 and self.stored_last_grouped_points_num != len(annotations): - # TODO check the annotations for being modified - for i, a in enumerate(s.layers[self.annotations_name].annotations): - a.props = [i // 2, i % 2] - # Check if we are at one of the annotations in the left hand panel - # or right hand panel, if so we can move to that in the other panel - right_hand_position = s.layout.children[1].position.value - left_hand_position = s.position - - def setup_viewer_after_user_ready(self): if not self.moving_layer_names: moving_layers = self.get_state().layout.children[1].layers self.moving_layer_names = moving_layers print(self.moving_layer_names) self._moving_idx = 0 - if self.two_coord_spaces: - self.setup_second_coord_space() - else: - self.setup_registration_layers() + self.setup_second_coord_space() self._set_status_message( "help", "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle visiblity of the registered layer.", @@ -349,8 +331,6 @@ def setup_initial_two_panel_layout(self): neuroglancer.LayerGroupViewer(layers=group2_names, layout="xy-3d"), ] ) - if not self.two_coord_spaces: - s.layout.children[1].position.link = "unlinked" s.layout.children[1].crossSectionOrientation.link = "unlinked" s.layout.children[1].projectionOrientation.link = "unlinked" @@ -389,34 +369,16 @@ def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): def setup_registration_layers(self): with self.viewer.txn() as s: dimensions = s.dimensions - if not self.ready_state == ReadyState.ERROR or (self.two_coord_spaces and (len(dimensions.names) != self.num_dims)): # loading + if not self.ready_state == ReadyState.ERROR or ( + len(dimensions.names) != self.num_dims + ): return # Make the annotation layer if needed if s.layers.index(self.annotations_name) == -1: - if self.two_coord_spaces: - s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( - dimensions=create_coord_space_matching_global_dims(s.dimensions) - ) - else: - s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( - dimensions=create_coord_space_matching_global_dims(s.dimensions), - annotation_properties=[ - neuroglancer.AnnotationPropertySpec( - id="label", - type="uint32", - default=0, - ), - neuroglancer.AnnotationPropertySpec( - id="group", - type="uint8", - default=0, - enum_labels=["fixed", "moving"], - enum_values=[0, 1], - ), - ], - shader=MARKERS_SHADER, - ) + s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( + dimensions=create_coord_space_matching_global_dims(s.dimensions) + ) # Make a copy of all the moving layers but in original coord space # and as part of the left hand panel @@ -424,10 +386,9 @@ def setup_registration_layers(self): copy = deepcopy(s.layers[layer_name]) copy.name = layer_name + "_registered" copy.visible = False - if self.two_coord_spaces: - for source in copy.source: - # TODO might need mapping - source.transform = None + for source in copy.source: + # TODO might need mapping, probably better is to copy before making the second coord space + source.transform = None s.layers[copy.name] = copy s.layout.children[0].layers.append(copy.name) s.layers[self.annotations_name].tool = "annotatePoint" @@ -450,8 +411,12 @@ def save_coord_space_info(self, info_future): try: result = info_future.result() except Exception as e: - print(f"ERROR: Could not parse volume info for {self.moving_name}: {e} {info_future}") - print("Please manually enter the coordinate space information as a second co-ordinate space.") + print( + f"ERROR: Could not parse volume info for {self.moving_name}: {e} {info_future}" + ) + print( + "Please manually enter the coordinate space information as a second co-ordinate space." + ) self.ready_state = ReadyState.ERROR self._moving_idx += 1 if self._moving_idx < len(self.moving_layer_names): @@ -460,7 +425,9 @@ def save_coord_space_info(self, info_future): # TODO not sure how to recover from this, maybe ask user to manually # input the coord space info? # TODO clean up - done = len(self.stored_map_moving_name_to_data_coords) == len(self.moving_layer_names) + done = len(self.stored_map_moving_name_to_data_coords) == len( + self.moving_layer_names + ) if not done: self._moving_idx += 1 self.setup_second_coord_space() @@ -477,14 +444,28 @@ def save_coord_space_info(self, info_future): if source.transform is None: output_dims = new_coord_space_names(input_dims, "2") else: - output_dims = new_coord_space_names(source.transform.output_dimensions, "2") + output_dims = new_coord_space_names( + source.transform.output_dimensions, "2" + ) self.output_dim_names = output_dims.names new_coord_space = neuroglancer.CoordinateSpaceTransform( input_dimensions=input_dims, output_dimensions=output_dims, ) - self.num_dims = max(self.num_dims, 2 * len([n for n in new_coord_space.output_dimensions.names if not n.endswith(("'", "^", "#"))])) - self.stored_map_moving_name_to_viewer_coords[layer_name].append(new_coord_space) + self.num_dims = max( + self.num_dims, + 2 + * len( + [ + n + for n in new_coord_space.output_dimensions.names + if not n.endswith(("'", "^", "#")) + ] + ), + ) + self.stored_map_moving_name_to_viewer_coords[layer_name].append( + new_coord_space + ) source.transform = new_coord_space if not self.ready_state == ReadyState.ERROR: self.ready_state = ReadyState.COORDS_READY @@ -523,34 +504,20 @@ def update_affine(self): def split_points_into_pairs(self, annotations, dim_names): if len(annotations) == 0: return np.zeros((0, 0)), np.zeros((0, 0)) - if self.two_coord_spaces: - first_name = dim_names[0] - real_dims_last = first_name not in self.input_dim_names - num_points = len(annotations) - num_dims = len(annotations[0].point) // 2 - fixed_points = np.zeros((num_points, num_dims)) - moving_points = np.zeros((num_points, num_dims)) - for i, a in enumerate(annotations): - for j in range(num_dims): - fixed_index = j + num_dims if real_dims_last else j - moving_index = j if real_dims_last else j + num_dims - fixed_points[i, j] = a.point[fixed_index] - moving_points[i, j] = a.point[moving_index] - return np.array(fixed_points), np.array(moving_points) - else: - num_points = len(annotations) // 2 - annotations = annotations[: num_points * 2] - num_dims = len(annotations[0].point) - fixed_points = np.zeros((num_points, num_dims)) - moving_points = np.zeros((num_points, num_dims)) - for i, a in enumerate(annotations): - props = a.props - if props[1] == 0: - fixed_points[props[0]] = a.point - else: - moving_points[props[0]] = a.point - - return np.array(fixed_points), np.array(moving_points) + first_name = dim_names[0] + # TODO can this be avoided? + real_dims_last = first_name not in self.input_dim_names + num_points = len(annotations) + num_dims = len(annotations[0].point) // 2 + fixed_points = np.zeros((num_points, num_dims)) + moving_points = np.zeros((num_points, num_dims)) + for i, a in enumerate(annotations): + for j in range(num_dims): + fixed_index = j + num_dims if real_dims_last else j + moving_index = j if real_dims_last else j + num_dims + fixed_points[i, j] = a.point[fixed_index] + moving_points[i, j] = a.point[moving_index] + return np.array(fixed_points), np.array(moving_points) def update_registered_layers(self, s: neuroglancer.ViewerState): if self.affine is not None: @@ -612,7 +579,7 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): def estimate_affine(self, s: neuroglancer.ViewerState): annotations = s.layers[self.annotations_name].annotations - if len(annotations) == 0 or (not self.two_coord_spaces and len(annotations) < 2): + if len(annotations) == 0: return False dim_names = s.dimensions.names @@ -703,14 +670,6 @@ def add_mapping_args(ap: argparse.ArgumentParser): default="annotation", required=False, ) - ap.add_argument( - "--single-coord-space", - "-s", - action="store_true", - help="Use a single coordinate space for both fixed and moving layers (default is two coord spaces)", - default=False, - required=False, - ) def handle_args(): From b105a58d7baa4fe9408073f75c00101b2dc39ed4 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Oct 2025 16:49:08 +0200 Subject: [PATCH 28/65] refactor: continue to clarify with only one path --- .../examples/example_linear_registration.py | 104 ++++++++++-------- 1 file changed, 60 insertions(+), 44 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 3ff9c14c8..87e5d71d9 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -264,7 +264,6 @@ def __init__(self, args): self.output_dim_names = [] self.affine = None self.co_ords_ready = False - self.stored_last_grouped_points_num = -1 self.ready_state = ReadyState.NOT_READY self.last_updated_print_time = -1 self.num_dims = 0 @@ -293,8 +292,7 @@ def update(self): self.last_updated_print_time = current_time if self.ready_state == ReadyState.COORDS_READY: self.setup_registration_layers() - if self.ready_state == ReadyState.ERROR: - # TODO put in right place + elif self.ready_state == ReadyState.ERROR: self._set_status_message( "help", "Please manually enter second coordinate space information for the moving layers.", @@ -303,12 +301,23 @@ def update(self): self.update_affine() self._clear_status_messages() + def copy_moving_layers_to_left_panel(self): + """Make copies of the moving layers to show the registered result.""" + with self.viewer.txn() as s: + for layer_name in self.moving_layer_names: + copy = deepcopy(s.layers[layer_name]) + copy.name = layer_name + "_registered" + copy.visible = False + s.layers[copy.name] = copy + s.layout.children[0].layers.append(copy.name) + def setup_viewer_after_user_ready(self): + """Called when the user indicates they have placed layers in the two panels.""" if not self.moving_layer_names: moving_layers = self.get_state().layout.children[1].layers self.moving_layer_names = moving_layers - print(self.moving_layer_names) self._moving_idx = 0 + self.copy_moving_layers_to_left_panel() self.setup_second_coord_space() self._set_status_message( "help", @@ -316,6 +325,7 @@ def setup_viewer_after_user_ready(self): ) def setup_initial_two_panel_layout(self): + """Set up a two panel layout if not already present.""" with self.viewer.txn() as s: all_layer_names = [layer.name for layer in s.layers] if len(all_layer_names) >= 2: @@ -339,6 +349,7 @@ def setup_initial_two_panel_layout(self): # s.layout.children[1].projectionScale.link = "unlinked" def setup_second_coord_space(self): + """Set up the second coordinate space for the moving layers.""" layer_name = self.moving_layer_names[self._moving_idx] info_future = self.viewer.volume_info(layer_name) info_future.add_done_callback(lambda f: self.save_coord_space_info(f)) @@ -380,17 +391,6 @@ def setup_registration_layers(self): dimensions=create_coord_space_matching_global_dims(s.dimensions) ) - # Make a copy of all the moving layers but in original coord space - # and as part of the left hand panel - for layer_name in self.moving_layer_names: - copy = deepcopy(s.layers[layer_name]) - copy.name = layer_name + "_registered" - copy.visible = False - for source in copy.source: - # TODO might need mapping, probably better is to copy before making the second coord space - source.transform = None - s.layers[copy.name] = copy - s.layout.children[0].layers.append(copy.name) s.layers[self.annotations_name].tool = "annotatePoint" s.selected_layer.layer = self.annotations_name s.selected_layer.visible = True @@ -400,7 +400,6 @@ def setup_registration_layers(self): self.ready_state = ReadyState.READY def setup_panel_coordinates(self, s: neuroglancer.ViewerState): - dimensions = s.dimensions.names s.layout.children[1].displayDimensions.link = "unlinked" s.layout.children[1].displayDimensions.value = self.output_dim_names[:3] s.layout.children[0].displayDimensions.link = "unlinked" @@ -417,29 +416,30 @@ def save_coord_space_info(self, info_future): print( "Please manually enter the coordinate space information as a second co-ordinate space." ) - self.ready_state = ReadyState.ERROR - self._moving_idx += 1 - if self._moving_idx < len(self.moving_layer_names): - self.setup_second_coord_space() - return - # TODO not sure how to recover from this, maybe ask user to manually - # input the coord space info? - # TODO clean up - done = len(self.stored_map_moving_name_to_data_coords) == len( - self.moving_layer_names - ) - if not done: - self._moving_idx += 1 + else: + self.stored_map_moving_name_to_data_coords[self.moving_name] = ( + result.dimensions + ) + + self._moving_idx += 1 + if self._moving_idx < len(self.moving_layer_names): self.setup_second_coord_space() return - self.stored_map_moving_name_to_data_coords[self.moving_name] = result.dimensions + # If we get here we have all the coord spaces ready and can update viewer + self.ready_state = ReadyState.COORDS_READY with self.viewer.txn() as s: for layer_name in self.moving_layer_names: - input_dims = self.stored_map_moving_name_to_data_coords[layer_name] - print(layer_name, input_dims) + input_dims = self.stored_map_moving_name_to_data_coords.get( + layer_name, None + ) + if input_dims is None: + self.ready_state = ReadyState.ERROR + continue self.input_dim_names = input_dims.names self.stored_map_moving_name_to_viewer_coords[layer_name] = [] + # TODO this logic I think needs to be improved because volume info + # is actually the mapped dims not the input dims for source in s.layers[layer_name].source: if source.transform is None: output_dims = new_coord_space_names(input_dims, "2") @@ -467,10 +467,9 @@ def save_coord_space_info(self, info_future): new_coord_space ) source.transform = new_coord_space - if not self.ready_state == ReadyState.ERROR: - self.ready_state = ReadyState.COORDS_READY + return self.ready_state - def toggle_registered_visibility(self, _): + def continue_workflow(self, _): if self.ready_state == ReadyState.NOT_READY: self.setup_viewer_after_user_ready() return @@ -486,12 +485,11 @@ def toggle_registered_visibility(self, _): def setup_viewer_actions(self): viewer = self.viewer - viewer.actions.add( - "toggleRegisteredVisibility", self.toggle_registered_visibility - ) + name = "continueLinearRegistrationWorkflow" + viewer.actions.add(name, self.continue_workflow) with viewer.config_state.txn() as s: - s.input_event_bindings.viewer["keyt"] = "toggleRegisteredVisibility" + s.input_event_bindings.viewer["keyt"] = name def on_state_changed(self): self.viewer.defer_callback(self.update) @@ -501,12 +499,31 @@ def update_affine(self): with self.viewer.txn() as s: self.estimate_affine(s) + def get_moving_and_fixed_dims( + self, s: neuroglancer.ViewerState | None, dim_names=() + ): + if s is None: + dimensions = dim_names + else: + dimensions = s.dimensions.names + # The moving dims are the same as the input dims, but end with an extra number + # to indicate the second coord space + # e.g. x -> x2, y -> y2, z -> z2 + moving_dims = [] + fixed_dims = [] + for dim in dimensions: + if dim[:-1] in dimensions: + moving_dims.append(dim) + else: + fixed_dims.append(dim) + return fixed_dims, moving_dims + def split_points_into_pairs(self, annotations, dim_names): if len(annotations) == 0: return np.zeros((0, 0)), np.zeros((0, 0)) first_name = dim_names[0] - # TODO can this be avoided? - real_dims_last = first_name not in self.input_dim_names + fixed_dims, _ = self.get_moving_and_fixed_dims(dim_names) + real_dims_last = first_name not in fixed_dims num_points = len(annotations) num_dims = len(annotations[0].point) // 2 fixed_points = np.zeros((num_points, num_dims)) @@ -538,7 +555,6 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): output_dimensions=v, matrix=transform, ) - print(self.combine_affine_across_dims(s, self.affine).tolist()) s.layers[self.annotations_name].source[ 0 ].transform = neuroglancer.CoordinateSpaceTransform( @@ -611,10 +627,10 @@ def get_registration_info(self): fixed_points, moving_points = self.split_points_into_pairs( annotations, dim_names ) - transformed_points = self.transform_points_with_affine(moving_points) info["fixedPoints"] = fixed_points.tolist() info["movingPoints"] = moving_points.tolist() - if self.affine is not None and transformed_points is not None: + if self.affine is not None: + transformed_points = transform_points(self.affine, moving_points) info["transformedPoints"] = transformed_points.tolist() info["affineTransform"] = self.affine.tolist() return info From 363509bc2ef860baea6e20619d0f81dd8e492b30 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 8 Oct 2025 17:30:53 +0200 Subject: [PATCH 29/65] fix: address various assumptions to make pipeline more stable --- .../examples/example_linear_registration.py | 113 +++++++++--------- 1 file changed, 59 insertions(+), 54 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 87e5d71d9..0a6abe85b 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -4,6 +4,7 @@ from copy import deepcopy from enum import Enum from time import ctime, time +from typing import Union import neuroglancer import neuroglancer.cli @@ -47,6 +48,7 @@ def debounced(*args, **kwargs): return decorator +# TODO further test these fits, 2 point fit not right at the moment def fit_model(fixed_points: np.ndarray, moving_points: np.ndarray): """ Choose the appropriate model based on number of points and dimensions. @@ -164,7 +166,7 @@ def transform_points(affine: np.ndarray, points: np.ndarray): # Only used if no data provided -def _create_demo_data(size: int | tuple = 60, radius: float = 20): +def _create_demo_data(size: Union[int, tuple] = 60, radius: float = 20): data_size = (size,) * NUM_DEMO_DIMS if isinstance(size, int) else size data = np.zeros(data_size, dtype=np.uint8) if NUM_DEMO_DIMS == 2: @@ -255,23 +257,22 @@ class LinearRegistrationWorkflow: def __init__(self, args): starting_state = args.state self.annotations_name = args.annotations_name - self.status_timers = {} - self.stored_points = [[], []] + + self.stored_points = ([], []) self.stored_map_moving_name_to_data_coords = {} self.stored_map_moving_name_to_viewer_coords = {} - self.moving_layer_names = [] - self.input_dim_names = [] - self.output_dim_names = [] self.affine = None - self.co_ords_ready = False self.ready_state = ReadyState.NOT_READY - self.last_updated_print_time = -1 - self.num_dims = 0 self.viewer = neuroglancer.Viewer() self.viewer.shared_state.add_changed_callback( lambda: self.viewer.defer_callback(self.on_state_changed) ) + self._last_updated_print_time = -1 + self._status_timers = {} + self._current_moving_layer_idx = 0 + self._cached_moving_layer_names = [] + if starting_state is None: self._add_demo_data_to_viewer() else: @@ -287,9 +288,9 @@ def __init__(self, args): def update(self): """Primary update loop, called whenever the viewer state changes.""" current_time = time() - if current_time - self.last_updated_print_time > 5: + if current_time - self._last_updated_print_time > 5: print(f"Viewer states are successfully syncing at {ctime()}") - self.last_updated_print_time = current_time + self._last_updated_print_time = current_time if self.ready_state == ReadyState.COORDS_READY: self.setup_registration_layers() elif self.ready_state == ReadyState.ERROR: @@ -301,10 +302,17 @@ def update(self): self.update_affine() self._clear_status_messages() + def get_moving_layer_names(self, s: neuroglancer.ViewerState): + right_panel_layers = [ + n for n in s.layout.children[1].layers if n != self.annotations_name + ] + return right_panel_layers + def copy_moving_layers_to_left_panel(self): """Make copies of the moving layers to show the registered result.""" with self.viewer.txn() as s: - for layer_name in self.moving_layer_names: + self._cached_moving_layer_names = self.get_moving_layer_names(s) + for layer_name in self._cached_moving_layer_names: copy = deepcopy(s.layers[layer_name]) copy.name = layer_name + "_registered" copy.visible = False @@ -313,10 +321,6 @@ def copy_moving_layers_to_left_panel(self): def setup_viewer_after_user_ready(self): """Called when the user indicates they have placed layers in the two panels.""" - if not self.moving_layer_names: - moving_layers = self.get_state().layout.children[1].layers - self.moving_layer_names = moving_layers - self._moving_idx = 0 self.copy_moving_layers_to_left_panel() self.setup_second_coord_space() self._set_status_message( @@ -350,7 +354,7 @@ def setup_initial_two_panel_layout(self): def setup_second_coord_space(self): """Set up the second coordinate space for the moving layers.""" - layer_name = self.moving_layer_names[self._moving_idx] + layer_name = self._cached_moving_layer_names[self._current_moving_layer_idx] info_future = self.viewer.volume_info(layer_name) info_future.add_done_callback(lambda f: self.save_coord_space_info(f)) @@ -361,7 +365,7 @@ def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): applies to all dims so we need to create a larger matrix """ all_dims = s.dimensions.names - moving_dims = self.output_dim_names + moving_dims = self.get_fixed_and_moving_dims(None, all_dims) full_matrix = np.zeros((len(all_dims), len(all_dims) + 1)) for i, dim in enumerate(all_dims): @@ -377,12 +381,13 @@ def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): full_matrix[i, -1] = affine[moving_i, -1] return full_matrix + def has_two_coord_spaces(self, s: neuroglancer.ViewerState): + fixed_dims, moving_dims = self.get_fixed_and_moving_dims(s) + return len(fixed_dims) == len(moving_dims) + def setup_registration_layers(self): with self.viewer.txn() as s: - dimensions = s.dimensions - if not self.ready_state == ReadyState.ERROR or ( - len(dimensions.names) != self.num_dims - ): + if self.ready_state == ReadyState.ERROR or not self.has_two_coord_spaces(s): return # Make the annotation layer if needed @@ -400,13 +405,16 @@ def setup_registration_layers(self): self.ready_state = ReadyState.READY def setup_panel_coordinates(self, s: neuroglancer.ViewerState): + fixed_dims, moving_dims = self.get_fixed_and_moving_dims(s) s.layout.children[1].displayDimensions.link = "unlinked" - s.layout.children[1].displayDimensions.value = self.output_dim_names[:3] + s.layout.children[1].displayDimensions.value = moving_dims[:3] s.layout.children[0].displayDimensions.link = "unlinked" - s.layout.children[0].displayDimensions.value = self.input_dim_names[:3] + s.layout.children[0].displayDimensions.value = fixed_dims[:3] def save_coord_space_info(self, info_future): - self.moving_name = self.moving_layer_names[self._moving_idx] + self.moving_name = self._cached_moving_layer_names[ + self._current_moving_layer_idx + ] try: result = info_future.result() except Exception as e: @@ -421,48 +429,32 @@ def save_coord_space_info(self, info_future): result.dimensions ) - self._moving_idx += 1 - if self._moving_idx < len(self.moving_layer_names): + self._current_moving_layer_idx += 1 + if self._current_moving_layer_idx < len(self._cached_moving_layer_names): self.setup_second_coord_space() return # If we get here we have all the coord spaces ready and can update viewer self.ready_state = ReadyState.COORDS_READY with self.viewer.txn() as s: - for layer_name in self.moving_layer_names: - input_dims = self.stored_map_moving_name_to_data_coords.get( + for layer_name in self._cached_moving_layer_names: + output_dims = self.stored_map_moving_name_to_data_coords.get( layer_name, None ) - if input_dims is None: + if output_dims is None: self.ready_state = ReadyState.ERROR continue - self.input_dim_names = input_dims.names self.stored_map_moving_name_to_viewer_coords[layer_name] = [] - # TODO this logic I think needs to be improved because volume info - # is actually the mapped dims not the input dims for source in s.layers[layer_name].source: if source.transform is None: - output_dims = new_coord_space_names(input_dims, "2") + output_dims = new_coord_space_names(output_dims, "2") else: output_dims = new_coord_space_names( source.transform.output_dimensions, "2" ) - self.output_dim_names = output_dims.names new_coord_space = neuroglancer.CoordinateSpaceTransform( - input_dimensions=input_dims, output_dimensions=output_dims, ) - self.num_dims = max( - self.num_dims, - 2 - * len( - [ - n - for n in new_coord_space.output_dimensions.names - if not n.endswith(("'", "^", "#")) - ] - ), - ) self.stored_map_moving_name_to_viewer_coords[layer_name].append( new_coord_space ) @@ -478,7 +470,7 @@ def continue_workflow(self, _): elif self.ready_state == ReadyState.ERROR: self.setup_registration_layers() with self.viewer.txn() as s: - for layer_name in self.moving_layer_names: + for layer_name in self.get_moving_layer_names(s): registered_name = layer_name + "_registered" is_registered_visible = s.layers[registered_name].visible s.layers[registered_name].visible = not is_registered_visible @@ -499,8 +491,8 @@ def update_affine(self): with self.viewer.txn() as s: self.estimate_affine(s) - def get_moving_and_fixed_dims( - self, s: neuroglancer.ViewerState | None, dim_names=() + def get_fixed_and_moving_dims( + self, s: Union[neuroglancer.ViewerState, None], dim_names: list | tuple = () ): if s is None: dimensions = dim_names @@ -522,7 +514,7 @@ def split_points_into_pairs(self, annotations, dim_names): if len(annotations) == 0: return np.zeros((0, 0)), np.zeros((0, 0)) first_name = dim_names[0] - fixed_dims, _ = self.get_moving_and_fixed_dims(dim_names) + fixed_dims, _ = self.get_fixed_and_moving_dims(None, dim_names) real_dims_last = first_name not in fixed_dims num_points = len(annotations) num_dims = len(annotations[0].point) // 2 @@ -596,6 +588,18 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): def estimate_affine(self, s: neuroglancer.ViewerState): annotations = s.layers[self.annotations_name].annotations if len(annotations) == 0: + if len(self.stored_points[0]) > 0: + # Again not sure if need channels + _, moving_dims = self.get_fixed_and_moving_dims(s) + n_dims = len(moving_dims) + affine = np.zeros(shape=(n_dims, n_dims + 1)) + for i in range(n_dims): + affine[i][i] = 1 + print(affine) + self.affine = affine + self.update_registered_layers(s) + self.stored_points = ([], []) + return True return False dim_names = s.dimensions.names @@ -627,6 +631,7 @@ def get_registration_info(self): fixed_points, moving_points = self.split_points_into_pairs( annotations, dim_names ) + info["annotations"] = annotations.tolist() info["fixedPoints"] = fixed_points.tolist() info["movingPoints"] = moving_points.tolist() if self.affine is not None: @@ -651,18 +656,18 @@ def __str__(self): def _clear_status_messages(self): to_pop = [] - for k, v in self.status_timers.items(): + for k, v in self._status_timers.items(): if time() - v > MESSAGE_DURATION: to_pop.append(k) for k in to_pop: with self.viewer.config_state.txn() as s: s.status_messages.pop(k, None) - self.status_timers.pop(k) + self._status_timers.pop(k) def _set_status_message(self, key: str, message: str): with self.viewer.config_state.txn() as s: s.status_messages[key] = message - self.status_timers[key] = time() + self._status_timers[key] = time() def _transform_points_with_affine(self, points: np.ndarray): if self.affine is not None: From 1fa3d05e9f34df8610090e8a6023575e3a61b63c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 8 Oct 2025 17:51:17 +0200 Subject: [PATCH 30/65] feat: small updates --- .../examples/example_linear_registration.py | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 0a6abe85b..592291af4 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -257,12 +257,14 @@ class LinearRegistrationWorkflow: def __init__(self, args): starting_state = args.state self.annotations_name = args.annotations_name + self.ready_state = ( + ReadyState.READY if args.continue_workflow else ReadyState.NOT_READY + ) self.stored_points = ([], []) self.stored_map_moving_name_to_data_coords = {} self.stored_map_moving_name_to_viewer_coords = {} self.affine = None - self.ready_state = ReadyState.NOT_READY self.viewer = neuroglancer.Viewer() self.viewer.shared_state.add_changed_callback( lambda: self.viewer.defer_callback(self.on_state_changed) @@ -278,12 +280,13 @@ def __init__(self, args): else: self.viewer.set_state(starting_state) - self._set_status_message( - "help", - "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup.", - ) - self.setup_initial_two_panel_layout() self.setup_viewer_actions() + if self.ready_state != ReadyState.READY: + self._set_status_message( + "help", + "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup.", + ) + self.setup_initial_two_panel_layout() def update(self): """Primary update loop, called whenever the viewer state changes.""" @@ -300,7 +303,12 @@ def update(self): ) elif self.ready_state == ReadyState.READY: self.update_affine() - self._clear_status_messages() + # TODO consider allowing to dump to disk + self._set_status_message( + "help", + "Place points to inform registration by first placing the crosshair in the left/right panel to the correct place for the fixed/moving data, and then placing an actual annotation with ctrl+right click in the other panel. You can move the annotations afterwards if needed. Press 't' to toggle visibility of the registered layer.", + ) + # self._clear_status_messages() def get_moving_layer_names(self, s: neuroglancer.ViewerState): right_panel_layers = [ @@ -625,6 +633,7 @@ def estimate_affine(self, s: neuroglancer.ViewerState): def get_registration_info(self): info = {} + # TODO consider also dumping the full viewer state with self.viewer.txn() as s: annotations = s.layers[self.annotations_name].annotations dim_names = s.dimensions.names @@ -691,6 +700,12 @@ def add_mapping_args(ap: argparse.ArgumentParser): default="annotation", required=False, ) + ap.add_argument( + "--continue-workflow", + "-c", + action="store_true", + help="Indicates that we are continuing the workflow from a previously saved state. This will skip the inital setup steps and resume from the affine estimation step directly.", + ) def handle_args(): From 0bedda6437ba647d78fbf55e63131c62324b0861 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 9 Jan 2026 13:34:23 +0100 Subject: [PATCH 31/65] docs: add info on how to use lin reg scrtip --- .../examples/example_linear_registration.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 592291af4..9e4a6c3a4 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -1,3 +1,27 @@ +#!/usr/bin/env python + +"""Example of an interactive linear registration workflow using point annotations. + +General workflow: + 1. Start from a neuroglancer viewer with all the reference data and the data to register as layers. + 2. Pass this state to the script by either providing a url via --url or dumping the JSON state to a file and passing the file via --json. For example: + python -i example_linear_registration.py --url 'https://neuroglancer.demo.appspot/com/...' + 3. The default assumption is that the last layer in the viewer from step 2 is the moving data to be registered, and all other layers are fixed (reference) data. The script will launch with two layer groups side by side, left is fixed, right is moving. You can move layers between the groups such that all fixed layers are in the first group (left panel) and all moving layers are in the second group (right panel). Once you have done this, press 't' to continue. + 4. At this point, the viewer will: + a. Create a copy of each dimension in with a "2" suffix for the moving layers. E.g. x -> x2, y -> y2, z -> z2. This allows the moving layers to have a different coordinate space. + b. Create copies of the moving layers in the fixed panel with "_registered" suffixes. These layers will show the registered result. + c. Create a shared annotation layer between the two panels for placing registration points. Each point will be 2 * N dimensions, where the first N dimensions correspond to the fixed data, and the second N dimensions correspond to the moving data. + 5. You can now place point annotations to inform the registration. The workflow is to: + a. Move the center position in one of the panels to the desired location for the fixed or moving part of the point annotation. + b. Place a point annotation with ctrl+left click in the other panel. + c. This annotation will now have both fixed and moving coordinates, but be represented by a single point. + d. The fixed and moving coordinates can be adjusted later by moving the annotation as normal (alt + left click the point). This will only move the point in the panel you are currently focused on, so to adjust both fixed and moving coordinates you need to switch panels. + 6. As you add points, the estimated affine transform will be updated and applied to the moving layers. The registered layers can be toggled visible/invisible by pressing 't'. + 7. If an issue happens, the viewer state can go out of sync. To help with this, the python console will regularly print that viewer states are syncing with a timestamp. If you do not see this message for a while, consider continuing the workflow again from a saved state. + 8. To continue from a saved state, dump the viewer state to a file using either the viewer UI or the dump_info method in the python console, and then pass this file via --json when starting the script again. You should also pass --continue-workflow (or -c) to skip the initial setup steps. If you renamed the annotation layer containing the registration points, you should also pass --annotations-name (or -a) with the new name. For example: + python -i example_linear_registration.py --json saved_state.json -c -a registration_points +""" + import argparse import threading import webbrowser @@ -306,7 +330,7 @@ def update(self): # TODO consider allowing to dump to disk self._set_status_message( "help", - "Place points to inform registration by first placing the crosshair in the left/right panel to the correct place for the fixed/moving data, and then placing an actual annotation with ctrl+right click in the other panel. You can move the annotations afterwards if needed. Press 't' to toggle visibility of the registered layer.", + "Place points to inform registration by first placing the centre position in the left/right panel to the correct place for the fixed/moving data, and then placing a point annotation with ctrl+left click in the other panel. You can move the annotations afterwards if needed with alt+left click. Press 't' to toggle visibility of the registered layer.", ) # self._clear_status_messages() From 152ca1ab4302feb7adaa06cc006e219b158ee0aa Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 9 Jan 2026 14:28:48 +0100 Subject: [PATCH 32/65] fix: handle local channels in affine application --- .../examples/example_linear_registration.py | 78 ++++++++++--------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 9e4a6c3a4..2d47bad8d 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -25,7 +25,7 @@ import argparse import threading import webbrowser -from copy import deepcopy +from copy import deepcopy, copy from enum import Enum from time import ctime, time from typing import Union @@ -413,6 +413,31 @@ def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): full_matrix[i, -1] = affine[moving_i, -1] return full_matrix + def combine_local_channels_with_transform(self, existing_transform: neuroglancer.CoordinateSpaceTransform, transform: list): + local_channel_indices = [ + i + for i, name in enumerate(existing_transform.outputDimensions.names) + if name.endswith(("'", "^", "#")) + ] + if not local_channel_indices: + return transform + final_transform = [] + num_local_count = 0 + for i, name in enumerate(existing_transform.outputDimensions.names): + is_local = i in local_channel_indices + if is_local: + local_channel_row = [0 for _ in range(len(existing_transform.outputDimensions.names) + 1)] + local_channel_row[i] = 1 + final_transform.append(local_channel_row) + num_local_count += 1 + else: + row = copy(transform[i - num_local_count]) + # At the indices corresponding to local channels, insert 0s + for j in local_channel_indices: + row.insert(j, 0.0) + final_transform.append(row) + return final_transform + def has_two_coord_spaces(self, s: neuroglancer.ViewerState): fixed_dims, moving_dims = self.get_fixed_and_moving_dims(s) return len(fixed_dims) == len(moving_dims) @@ -565,20 +590,25 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): transform = self.affine.tolist() # TODO handle layer being renamed for k, v in self.stored_map_moving_name_to_data_coords.items(): - # TODO not sure if need to handle local channels here - # keeping code below just in case - for source in s.layers[k].source: - source.transform = neuroglancer.CoordinateSpaceTransform( + for i, source in enumerate(s.layers[k].source): + fixed_to_moving_transform_with_locals = self.combine_local_channels_with_transform( + source.transform, transform + ) + fixed_dims_to_moving_dims_transform = neuroglancer.CoordinateSpaceTransform( input_dimensions=v, output_dimensions=new_coord_space_names(v, "2"), - matrix=transform, + matrix=fixed_to_moving_transform_with_locals, ) - for source in s.layers[k + "_registered"].source: - source.transform = neuroglancer.CoordinateSpaceTransform( + source.transform = fixed_dims_to_moving_dims_transform + + registered_source = s.layers[k + "_registered"].source[i] + fixed_dims_to_fixed_dims_transform = neuroglancer.CoordinateSpaceTransform( input_dimensions=v, output_dimensions=v, - matrix=transform, + matrix=fixed_to_moving_transform_with_locals, ) + registered_source.transform = fixed_dims_to_fixed_dims_transform + breakpoint() s.layers[self.annotations_name].source[ 0 ].transform = neuroglancer.CoordinateSpaceTransform( @@ -587,35 +617,7 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): matrix=self.combine_affine_across_dims(s, self.affine).tolist(), ) - # print(s.layers["registered"].source[0].transform.matrix) - # TODO this is where that mapping needs to happen of affine dims - # overall this is a bit awkward right now, we need a lot of - # mapping info which we just don't have - # right now you can't input it from the command line - # if s.layers["registered"].source[0].transform is not None: - # final_transform = [] - # layer_transform = s.layers["registered"].source[0].transform - # local_channel_indices = [ - # i - # for i, name in enumerate(layer_transform.outputDimensions.names) - # if name.endswith(("'", "^", "#")) - # ] - # num_local_count = 0 - # for i, name in enumerate(layer_transform.outputDimensions.names): - # is_local = i in local_channel_indices - # if is_local: - # final_transform.append(layer_transform.matrix[i].tolist()) - # num_local_count += 1 - # else: - # row = transform[i - num_local_count] - # # At the indices corresponding to local channels, insert 0s - # for j in local_channel_indices: - # row.insert(j, 0) - # final_transform.append(row) - # else: - # final_transform = transform - print("Updated affine transform:", transform) - print(s.layers["registered"].source[0].transform) + print("Updated affine transform (without channel dimensions):", transform) def estimate_affine(self, s: neuroglancer.ViewerState): annotations = s.layers[self.annotations_name].annotations From 0cee9ef156e2f6b1023222a91e292554376eb776 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 12 Jan 2026 20:03:01 +0100 Subject: [PATCH 33/65] fix: correct estimation algo --- .../examples/example_linear_registration.py | 84 +++++++++++-------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 2d47bad8d..cfa8371c0 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -3,10 +3,10 @@ """Example of an interactive linear registration workflow using point annotations. General workflow: - 1. Start from a neuroglancer viewer with all the reference data and the data to register as layers. + 1. Start from a neuroglancer viewer with all the reference data and the data to register as layers. If the script is provided no data, it will create demo data for you to try. 2. Pass this state to the script by either providing a url via --url or dumping the JSON state to a file and passing the file via --json. For example: python -i example_linear_registration.py --url 'https://neuroglancer.demo.appspot/com/...' - 3. The default assumption is that the last layer in the viewer from step 2 is the moving data to be registered, and all other layers are fixed (reference) data. The script will launch with two layer groups side by side, left is fixed, right is moving. You can move layers between the groups such that all fixed layers are in the first group (left panel) and all moving layers are in the second group (right panel). Once you have done this, press 't' to continue. + 3. The default assumption is that the last layer in the viewer from step 2 is the moving data to be registered, and all other layers are fixed (reference) data. There must be at least two layers. The script will launch with two layer groups side by side, left is fixed, right is moving. You can move layers between the groups such that all fixed layers are in the first group (left panel) and all moving layers are in the second group (right panel). Once you have done this, press 't' to continue. 4. At this point, the viewer will: a. Create a copy of each dimension in with a "2" suffix for the moving layers. E.g. x -> x2, y -> y2, z -> z2. This allows the moving layers to have a different coordinate space. b. Create copies of the moving layers in the fixed panel with "_registered" suffixes. These layers will show the registered result. @@ -93,60 +93,64 @@ def fit_model(fixed_points: np.ndarray, moving_points: np.ndarray): return affine_fit(fixed_points, moving_points) +def translation_fit(fixed_points: np.ndarray, moving_points: np.ndarray): + N, D = fixed_points.shape + + estimated_translation = np.mean(moving_points - fixed_points, axis=0) + + affine = np.zeros((D, D + 1)) + affine[:, :D] = np.eye(D) + affine[:, -1] = estimated_translation + + affine = np.round(affine, decimals=AFFINE_NUM_DECIMALS) + return affine + # See https://en.wikipedia.org/wiki/Orthogonal_Procrustes_problem # and https://math.nist.gov/~JBernal/kujustf.pdf -def rigid_or_similarity_fit( - fixed_points: np.ndarray, moving_points: np.ndarray, rigid: bool = True -): - N, D = fixed_points.shape +# Follows the Kabsch algorithm https://en.wikipedia.org/wiki/Kabsch_algorithm +def rigid_or_similarity_fit(fixed_points, moving_points, rigid=True): + N, D = fixed_points.shape # N = number of points, D = number of dimensions - # Remove translation aspect to first determine rotation/scale - X = fixed_points - fixed_points.mean(axis=0) - Y = moving_points - moving_points.mean(axis=0) + # Find transform from Q to P + mu_q = moving_points.mean(axis=0) + mu_p = fixed_points.mean(axis=0) - # Cross-covariance - sigma = (Y.T @ X) / N + # Step 1, translate points so their origin is their centroids + Q = moving_points - mu_q + P = fixed_points - mu_p - # SVD - Unitary matrix, Diagonal, conjugate transpose of unitary matrix - U, S, Vt = np.linalg.svd(sigma) # Sigma ≈ U diag(S) V* + # Covariance matrix, D x D + H = (P.T @ Q) / N + # SVD of covariance matrix + U, Sigma, Vt = np.linalg.svd(H) + + # Record if the matrices contain a reflection d = np.ones(D) if np.linalg.det(U @ Vt) < 0: - d[-1] = -1 + d[-1] = -1.0 + # Rotation matrix R = U @ np.diag(d) @ Vt - # Scale + # Scale depending on rigid or similarity + # Extended from 2D to 3D from https://github.com/AllenInstitute/render-python/blob/master/renderapi/transform/leaf/affine_models.py if rigid: s = 1.0 else: - var_src = (X**2).sum() / N # sum of variances across dims - s = (S * d).sum() / var_src + var_x = (Q**2).sum() / N + s = (Sigma * d).sum() / var_x - # Translation - t = Y - s * (R @ X) + t = mu_p - s * (R @ mu_q) # Homogeneous (D+1)x(D+1) T = np.zeros((D, D + 1)) T[:D, :D] = s * R - T[:, -1] = -1 * np.diagonal(t) + T[:, -1] = t affine = np.round(T, decimals=AFFINE_NUM_DECIMALS) return affine -def translation_fit(fixed_points: np.ndarray, moving_points: np.ndarray): - N, D = fixed_points.shape - - estimated_translation = np.mean(moving_points - fixed_points, axis=0) - - affine = np.zeros((D, D + 1)) - affine[:, :D] = np.eye(D) - affine[:, -1] = estimated_translation - - affine = np.round(affine, decimals=AFFINE_NUM_DECIMALS) - return affine - - def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): N, D = fixed_points.shape @@ -284,6 +288,7 @@ def __init__(self, args): self.ready_state = ( ReadyState.READY if args.continue_workflow else ReadyState.NOT_READY ) + self.unlink_scales = args.unlink_scales self.stored_points = ([], []) self.stored_map_moving_name_to_data_coords = {} @@ -380,9 +385,9 @@ def setup_initial_two_panel_layout(self): s.layout.children[1].crossSectionOrientation.link = "unlinked" s.layout.children[1].projectionOrientation.link = "unlinked" - # Can also unlink scales if desired - # s.layout.children[1].crossSectionScale.link = "unlinked" - # s.layout.children[1].projectionScale.link = "unlinked" + if self.unlink_scales: + s.layout.children[1].crossSectionScale.link = "unlinked" + s.layout.children[1].projectionScale.link = "unlinked" def setup_second_coord_space(self): """Set up the second coordinate space for the moving layers.""" @@ -608,7 +613,6 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): matrix=fixed_to_moving_transform_with_locals, ) registered_source.transform = fixed_dims_to_fixed_dims_transform - breakpoint() s.layers[self.annotations_name].source[ 0 ].transform = neuroglancer.CoordinateSpaceTransform( @@ -732,6 +736,12 @@ def add_mapping_args(ap: argparse.ArgumentParser): action="store_true", help="Indicates that we are continuing the workflow from a previously saved state. This will skip the inital setup steps and resume from the affine estimation step directly.", ) + ap.add_argument( + "--unlink-scales", + "-us", + action="store_true", + help="If set, the scales of the two panels will be unlinked when setting up the initial two panel layout.", + ) def handle_args(): From dd663a3db909af0d1ec39fe91ea2221b71ddc1d8 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 12 Jan 2026 20:33:01 +0100 Subject: [PATCH 34/65] fix: correct translation --- python/examples/example_linear_registration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index cfa8371c0..f215e6850 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -96,7 +96,7 @@ def fit_model(fixed_points: np.ndarray, moving_points: np.ndarray): def translation_fit(fixed_points: np.ndarray, moving_points: np.ndarray): N, D = fixed_points.shape - estimated_translation = np.mean(moving_points - fixed_points, axis=0) + estimated_translation = np.mean(fixed_points - moving_points, axis=0) affine = np.zeros((D, D + 1)) affine[:, :D] = np.eye(D) From e40fc8af1638467778fb75cff384bab38f6b68da Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 12 Jan 2026 21:20:20 +0100 Subject: [PATCH 35/65] test: adding tests to lin reg --- .../examples/example_linear_registration.py | 135 +++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index f215e6850..935c3cb33 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -119,7 +119,7 @@ def rigid_or_similarity_fit(fixed_points, moving_points, rigid=True): Q = moving_points - mu_q P = fixed_points - mu_p - # Covariance matrix, D x D + # Cross covariance matrix, D x D H = (P.T @ Q) / N # SVD of covariance matrix @@ -742,6 +742,12 @@ def add_mapping_args(ap: argparse.ArgumentParser): action="store_true", help="If set, the scales of the two panels will be unlinked when setting up the initial two panel layout.", ) + ap.add_argument( + "--test", + "-t", + action="store_true", + help="If set, run the tests and exit.", + ) def handle_args(): @@ -753,10 +759,137 @@ def handle_args(): neuroglancer.cli.handle_server_arguments(args) return args +### Some testing code ### +class TestTransforms: + def test_translation_fit(self): + # Simple 2D translation, +4 in y, +1 in x + fixed = np.array([[1, 4], [2, 5], [3, 6]]) + moving = np.array([[0, 0], [1, 1], [2, 2]]) + affine = translation_fit(fixed, moving) + expected = np.array([[1, 0, 1], [0, 1, 4]]) + assert np.allclose(affine, expected) + + def test_rigid_fit_2d(self): + # Simple 90 degree rotation + fixed = np.array([[0, 0], [1, 0], [0, 1], [-1, 0], [0, -1]]) + moving = np.array([[0, 0], [0, 1], [-1, 0], [0, -1], [1, 0]]) + affine = rigid_or_similarity_fit(fixed, moving, rigid=True) + expected = np.array([[0, 1, 0], [-1, 0, 0]]) + assert np.allclose(affine, expected) + + def test_rigid_fit_3d(self): + # Test 1: Simple 90-degree rotation around Z-axis + fixed = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [-1, 0, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]) + moving = np.array([[0, 0, 0], [0, 1, 0], [-1, 0, 0], [0, -1, 0], [1, 0, 0], [0, 0, 1], [0, 0, -1]]) + affine = rigid_or_similarity_fit(fixed, moving, rigid=True) + expected = np.array([[0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 1, 0]]) + assert np.allclose(affine, expected) + + + def test_2d_dipper(self): + big = np.array([ + [ 0.0, 0.0], + [ 1.0, 0.2], + [ 1.2, -0.8], + [ 0.2, -1.0], + [-0.5, -1.2], + [-1.1, -1.6], + [-1.8, -2.1], + ], dtype=float) + + s = 1.7 + R = np.array([ + [ 0.866, -0.500], + [ 0.354, 0.612], + ]) + t = np.array([3.2, 1.4]) + + little = (big @ R.T) * s + t + + affine = rigid_or_similarity_fit(big, little, rigid=False) + + # Optional plot to visualize + import matplotlib.pyplot as plt + fig, ax = plt.subplots() + ax.plot(big[:,0], big[:,1], 'o', label='big') + ax.plot(little[:,0], little[:,1], 'o', label='little') + ax.plot( + transform_points(affine, little)[:,0], + transform_points(affine, little)[:,1], + 'x', label='transformed little' + ) + ax.legend() + fig.savefig("dipper.png", dpi=200) + + transformed_points = transform_points(affine, little) + assert np.allclose(transformed_points, big, atol=0.3) + + # While the transform is really a similarity transform, + # we can also try an affine fit here + affine2 = affine_fit(big, little) + transformed_points2 = transform_points(affine2, little) + assert np.allclose(transformed_points2, big, atol=1e-2) + + def test_similarity_3d_dipper(self): + big = np.array([ + [ 0.0, 0.0, 0.0], + [ 1.0, 0.2, 0.1], + [ 1.2, -0.8, 0.3], + [ 0.2, -1.0, 0.2], + [-0.5, -1.2, 0.0], + [-1.1, -1.6, -0.2], + [-1.8, -2.1, -0.4], + ], dtype=float) + + s = 1.7 + R = np.array([ + [ 0.866, -0.500, 0.000], + [ 0.354, 0.612, -0.707], + [ 0.354, 0.612, 0.707], + ]) + t = np.array([3.2, 1.4, 2.0]) + + little = (big @ R.T) * s + t + + affine = rigid_or_similarity_fit(big, little, rigid=False) + + # Optional plot to visualize + import matplotlib.pyplot as plt + fig = plt.figure() + ax = fig.add_subplot(111, projection="3d") + ax.scatter(big[:,0], big[:,1], big[:,2], label="big", marker="o") + ax.scatter(little[:,0], little[:,1], little[:,2], label="little", marker="o") + tl = transform_points(affine, little) + ax.scatter(tl[:,0], tl[:,1], tl[:,2], label="transformed little", marker="x") + ax.legend() + fig.savefig("dipper_3d.png", dpi=200) + + transformed_points = transform_points(affine, little) + assert np.allclose(transformed_points, big, atol=1e-2) + + # While the transform is really a similarity transform, + # we can also try an affine fit here + affine2 = affine_fit(big, little) + transformed_points2 = transform_points(affine2, little) + assert np.allclose(transformed_points2, big, atol=1e-2) + + def test_affine_fit_2d(self): + fixed = np.array([[0, 0], [1, 0], [0, 1]]) + moving = np.array([[1, 1], [2, 1], [1, 2]]) + affine = affine_fit(fixed, moving) + expected = np.array([[1, 0, -1], [0, 1, -1]]) + assert np.allclose(affine, expected) + if __name__ == "__main__": args = handle_args() + if args.test: + import pytest + + pytest.main([__file__]) + exit(0) + demo = LinearRegistrationWorkflow(args) webbrowser.open_new(demo.viewer.get_viewer_url()) From c18a24e02b053945c8b1362893b7052cec1ee330 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 13 Jan 2026 18:54:02 +0100 Subject: [PATCH 36/65] fix: correct pulling moving dims --- .../examples/example_linear_registration.py | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 935c3cb33..4c0712403 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -23,6 +23,7 @@ """ import argparse +import logging import threading import webbrowser from copy import deepcopy, copy @@ -35,9 +36,16 @@ import numpy as np import scipy.ndimage +# Debug flag to enable detailed logging of registration process +# Set to True to log fixed points, moving points, transform, and transformed points +DEBUG = True + +# Configure logging for debug output +logging.basicConfig(level=logging.INFO, format='%(message)s') + MESSAGE_DURATION = 5 # seconds -NUM_DEMO_DIMS = 3 # Currently can be 2D or 3D -AFFINE_NUM_DECIMALS = 4 +NUM_DEMO_DIMS = 2 # Currently can be 2D or 3D +AFFINE_NUM_DECIMALS = 6 MARKERS_SHADER = """ #uicontrol vec3 fixedPointColor color(default="#00FF00") @@ -402,7 +410,7 @@ def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): applies to all dims so we need to create a larger matrix """ all_dims = s.dimensions.names - moving_dims = self.get_fixed_and_moving_dims(None, all_dims) + _, moving_dims = self.get_fixed_and_moving_dims(None, all_dims) full_matrix = np.zeros((len(all_dims), len(all_dims) + 1)) for i, dim in enumerate(all_dims): @@ -593,7 +601,6 @@ def split_points_into_pairs(self, annotations, dim_names): def update_registered_layers(self, s: neuroglancer.ViewerState): if self.affine is not None: transform = self.affine.tolist() - # TODO handle layer being renamed for k, v in self.stored_map_moving_name_to_data_coords.items(): for i, source in enumerate(s.layers[k].source): fixed_to_moving_transform_with_locals = self.combine_local_channels_with_transform( @@ -613,13 +620,12 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): matrix=fixed_to_moving_transform_with_locals, ) registered_source.transform = fixed_dims_to_fixed_dims_transform - s.layers[self.annotations_name].source[ - 0 - ].transform = neuroglancer.CoordinateSpaceTransform( + annotation_transform = neuroglancer.CoordinateSpaceTransform( input_dimensions=create_coord_space_matching_global_dims(s.dimensions), output_dimensions=create_coord_space_matching_global_dims(s.dimensions), matrix=self.combine_affine_across_dims(s, self.affine).tolist(), ) + s.layers[self.annotations_name].source[0].transform = annotation_transform print("Updated affine transform (without channel dimensions):", transform) @@ -652,6 +658,25 @@ def estimate_affine(self, s: neuroglancer.ViewerState): ): return False self.affine = fit_model(fixed_points, moving_points) + + # Debug logging for registration process + if DEBUG: + print("\n=== DEBUG: Registration Transform Details ===") + print(f"Fixed points:\n{fixed_points}") + print(f"Moving points:\n{moving_points}") + print(f"Computed transform:\n{self.affine}") + + # Apply transform to moving points and log results + transformed_points = transform_points(self.affine, moving_points) + print(f"Transformed moving points:\n{transformed_points}") + + print("\nPoint-by-point comparison:") + for i in range(len(moving_points)): + print(f"{i+1}. Moving point ({', '.join(f'{x:.3f}' for x in moving_points[i])}), " + f"Fixed point ({', '.join(f'{x:.3f}' for x in fixed_points[i])}), " + f"Transformed point ({', '.join(f'{x:.3f}' for x in transformed_points[i])})") + print("=" * 50) + self.update_registered_layers(s) self._set_status_message( From a90b3f8e414a1c4a78d4f73bd7a46cdbdecc1c9e Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 13 Jan 2026 19:05:48 +0100 Subject: [PATCH 37/65] feat: add output affine --- python/examples/example_linear_registration.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 4c0712403..d1d3b14e4 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -297,6 +297,7 @@ def __init__(self, args): ReadyState.READY if args.continue_workflow else ReadyState.NOT_READY ) self.unlink_scales = args.unlink_scales + self.output_name = args.output_name self.stored_points = ([], []) self.stored_map_moving_name_to_data_coords = {} @@ -627,7 +628,10 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): ) s.layers[self.annotations_name].source[0].transform = annotation_transform - print("Updated affine transform (without channel dimensions):", transform) + print(f"Updated affine transform (without channel dimensions): {transform}, written to {self.output_name}") + + # Save affine matrix to file + np.savetxt(self.output_name, self.affine, fmt='%.6f') def estimate_affine(self, s: neuroglancer.ViewerState): annotations = s.layers[self.annotations_name].annotations @@ -767,6 +771,14 @@ def add_mapping_args(ap: argparse.ArgumentParser): action="store_true", help="If set, the scales of the two panels will be unlinked when setting up the initial two panel layout.", ) + ap.add_argument( + "--output-name", + "-o", + type=str, + help="Output filename for the affine matrix (default is affine.txt)", + default="affine.txt", + required=False, + ) ap.add_argument( "--test", "-t", From 5b330a640852124177a26b4364d2785ca38893b3 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 13 Jan 2026 19:50:25 +0100 Subject: [PATCH 38/65] feat: add dump --- .../examples/example_linear_registration.py | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index d1d3b14e4..9f91bf23a 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -29,6 +29,7 @@ from copy import deepcopy, copy from enum import Enum from time import ctime, time +from datetime import datetime from typing import Union import neuroglancer @@ -341,10 +342,9 @@ def update(self): ) elif self.ready_state == ReadyState.READY: self.update_affine() - # TODO consider allowing to dump to disk self._set_status_message( "help", - "Place points to inform registration by first placing the centre position in the left/right panel to the correct place for the fixed/moving data, and then placing a point annotation with ctrl+left click in the other panel. You can move the annotations afterwards if needed with alt+left click. Press 't' to toggle visibility of the registered layer.", + "Place points to inform registration by first placing the centre position in the left/right panel to the correct place for the fixed/moving data, and then placing a point annotation with ctrl+left click in the other panel. You can move the annotations afterwards if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'd' to dump current state for later resumption.", ) # self._clear_status_messages() @@ -369,10 +369,7 @@ def setup_viewer_after_user_ready(self): """Called when the user indicates they have placed layers in the two panels.""" self.copy_moving_layers_to_left_panel() self.setup_second_coord_space() - self._set_status_message( - "help", - "Place markers in pairs, starting with the fixed, and then the moving. The registered layer will automatically update as you add markers. Press 't' to toggle visiblity of the registered layer.", - ) + def setup_initial_two_panel_layout(self): """Set up a two panel layout if not already present.""" @@ -551,8 +548,12 @@ def setup_viewer_actions(self): name = "continueLinearRegistrationWorkflow" viewer.actions.add(name, self.continue_workflow) + dump_name = "dumpCurrentState" + viewer.actions.add(dump_name, self.dump_current_state) + with viewer.config_state.txn() as s: s.input_event_bindings.viewer["keyt"] = name + s.input_event_bindings.viewer["keyd"] = dump_name def on_state_changed(self): self.viewer.defer_callback(self.update) @@ -715,6 +716,22 @@ def dump_info(self, path: str): with open(path, "w") as f: json.dump(info, f, indent=4) + def dump_current_state(self, _): + import json + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"neuroglancer_state_{timestamp}.json" + + state_dict = self.get_state().to_json() + + with open(filename, "w") as f: + json.dump(state_dict, f, indent=4) + + print(f"Current state dumped to: {filename}") + self._set_status_message("dump", f"State saved to {filename}") + + return filename + def get_state(self): with self.viewer.txn() as s: return s From d5d2fa0f15cfe0c1ef507f7355909f6df0e2addc Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 15 Jan 2026 17:31:16 +0100 Subject: [PATCH 39/65] feat: allow annotation non display dims to not clip --- src/annotation/renderlayer.ts | 122 +++++++++++++++++++++++++++++++++- src/data_panel_layout.ts | 3 + src/layer_group_viewer.ts | 5 ++ src/layer_groups_layout.ts | 1 + src/viewer.ts | 80 ++++++++++++++++++++++ 5 files changed, 208 insertions(+), 3 deletions(-) diff --git a/src/annotation/renderlayer.ts b/src/annotation/renderlayer.ts index a79ef4834..ab5da235a 100644 --- a/src/annotation/renderlayer.ts +++ b/src/annotation/renderlayer.ts @@ -401,6 +401,8 @@ interface AttachmentState { chunkTransform: ValueOrError; displayDimensionRenderInfo: DisplayDimensionRenderInfo; chunkRenderParameters: AnnotationChunkRenderParameters | undefined; + // Map from layer name to map of dimension name to clip weight + clipDimensionsWeight: Map>; } type TransformedAnnotationSource = FrontendTransformedSource< @@ -414,20 +416,43 @@ interface SpatiallyIndexedValidAttachmentState extends AttachmentState { function getAnnotationProjectionParameters( chunkDisplayTransform: ChunkDisplayTransformParameters, + layerName: string | undefined, + clipDimensionsWeight: Map>, ) { const { chunkTransform } = chunkDisplayTransform; - const { unpaddedRank } = chunkTransform.modelTransform; + const { unpaddedRank, layerDimensionNames } = chunkTransform.modelTransform; const modelClipBounds = new Float32Array(unpaddedRank * 2); const renderSubspaceTransform = new Float32Array(unpaddedRank * 3); renderSubspaceTransform.fill(0); modelClipBounds.fill(1, unpaddedRank); const { numChunkDisplayDims, chunkDisplayDimensionIndices } = chunkDisplayTransform; + + // Set display dimensions to not clip (multiplier = 0) for (let i = 0; i < numChunkDisplayDims; ++i) { const chunkDim = chunkDisplayDimensionIndices[i]; modelClipBounds[unpaddedRank + chunkDim] = 0; renderSubspaceTransform[chunkDim * 3 + i] = 1; } + + // Apply custom clip dimension weights to non display dims + if (layerName !== undefined) { + const dimWeights = clipDimensionsWeight.get(layerName); + if (dimWeights !== undefined && dimWeights.size > 0) { + for (const [dimName, weight] of dimWeights) { + // TODO not sure if can directly use layer names + // TODO ensure not in chunk display dims + const dimIndex = layerDimensionNames.indexOf(dimName); + if (dimIndex !== -1 && dimIndex < unpaddedRank) { + // Set the multiplier to the specified weight for this dimension + // Weight of 0.0 = no clipping, 1.0 = full clipping + const newIndex = unpaddedRank + dimIndex; + modelClipBounds[newIndex] = weight; + } + } + } + } + return { modelClipBounds, renderSubspaceTransform }; } @@ -435,6 +460,8 @@ function getChunkRenderParameters( chunkTransform: ValueOrError, displayDimensionRenderInfo: DisplayDimensionRenderInfo, messages: MessageList, + layerName: string | undefined, + clipDimensionsWeight: Map>, ): AnnotationChunkRenderParameters | undefined { messages.clearMessages(); const returnError = (message: string) => { @@ -458,7 +485,11 @@ function getChunkRenderParameters( return returnError((e as Error).message); } const { modelClipBounds, renderSubspaceTransform } = - getAnnotationProjectionParameters(chunkDisplayTransform); + getAnnotationProjectionParameters( + chunkDisplayTransform, + layerName, + clipDimensionsWeight, + ); return { chunkTransform, chunkDisplayTransform, @@ -540,13 +571,43 @@ function AnnotationRenderLayer< const { chunkTransform } = this; const displayDimensionRenderInfo = attachment.view.displayDimensionRenderInfo.value; + + // Get clip dimensions weight from the viewer + const clipDimensionsWeight = new Map>(); + const viewer = (attachment.view as any).viewer; + const trackableClipDimensionsWeight = viewer?.clipDimensionsWeight; + if (trackableClipDimensionsWeight?.value) { + for (const [ + layerName, + dimWeights, + ] of trackableClipDimensionsWeight.value) { + clipDimensionsWeight.set(layerName, new Map(dimWeights)); + } + } + + // Listen for changes to clipDimensionsWeight + if (trackableClipDimensionsWeight) { + attachment.registerDisposer( + trackableClipDimensionsWeight.changed.add(() => { + this.updateAttachmentState(attachment); + this.redrawNeeded.dispatch(); + }), + ); + } + + // Get the layer name + const layerName = this.base.state?.dataSource?.layer?.managedLayer?.name; + attachment.state = { chunkTransform, displayDimensionRenderInfo, + clipDimensionsWeight, chunkRenderParameters: getChunkRenderParameters( chunkTransform, displayDimensionRenderInfo, attachment.messages, + layerName, + clipDimensionsWeight, ), }; } @@ -559,20 +620,61 @@ function AnnotationRenderLayer< const { chunkTransform } = this; const displayDimensionRenderInfo = attachment.view.displayDimensionRenderInfo.value; + + // Get clip dimensions weight from the viewer + const clipDimensionsWeight = new Map>(); + const viewer = (attachment.view as any).viewer; + const trackableClipDimensionsWeight = viewer?.clipDimensionsWeight; + if (trackableClipDimensionsWeight?.value) { + for (const [ + layerName, + dimWeights, + ] of trackableClipDimensionsWeight.value) { + clipDimensionsWeight.set(layerName, new Map(dimWeights)); + } + } + + // Check if clipDimensionsWeight has changed + let clipWeightsChanged = + state.clipDimensionsWeight.size !== clipDimensionsWeight.size; + if (!clipWeightsChanged) { + for (const [layerName, dimWeights] of clipDimensionsWeight) { + const oldDimWeights = state.clipDimensionsWeight.get(layerName); + if (!oldDimWeights || oldDimWeights.size !== dimWeights.size) { + clipWeightsChanged = true; + break; + } + for (const [dimName, weight] of dimWeights) { + if (oldDimWeights.get(dimName) !== weight) { + clipWeightsChanged = true; + break; + } + } + if (clipWeightsChanged) break; + } + } + + // Get the layer name + const layerName = this.base.state?.dataSource?.layer?.managedLayer?.name; + if ( state !== undefined && state.chunkTransform === chunkTransform && - state.displayDimensionRenderInfo === displayDimensionRenderInfo + state.displayDimensionRenderInfo === displayDimensionRenderInfo && + !clipWeightsChanged ) { return state.chunkRenderParameters; } state.chunkTransform = chunkTransform; state.displayDimensionRenderInfo = displayDimensionRenderInfo; + state.clipDimensionsWeight = clipDimensionsWeight; const chunkRenderParameters = (state.chunkRenderParameters = getChunkRenderParameters( chunkTransform, displayDimensionRenderInfo, attachment.messages, + layerName, + clipDimensionsWeight, )); return chunkRenderParameters; } @@ -1009,12 +1111,23 @@ const SpatiallyIndexedAnnotationLayer = < >, ) { super.attach(attachment); + + // Get clip dimensions weight from the viewer + const viewer = (attachment.view as any).viewer; + const trackableClipDimensionsWeight = viewer?.clipDimensionsWeight || { + value: new Map(), + }; + + // Get the layer name + const layerName = this.base.state?.dataSource?.layer?.managedLayer?.name; + attachment.state!.sources = attachment.registerDisposer( registerNested( ( context: RefCounted, transform: RenderLayerTransformOrError, displayDimensionRenderInfo: DisplayDimensionRenderInfo, + clipDimensionsWeightValue: Map>, ) => { const transformedSources = getVolumetricTransformedSources( displayDimensionRenderInfo, @@ -1033,6 +1146,8 @@ const SpatiallyIndexedAnnotationLayer = < tsource, getAnnotationProjectionParameters( tsource.chunkDisplayTransform, + layerName, + clipDimensionsWeightValue, ), ); } @@ -1052,6 +1167,7 @@ const SpatiallyIndexedAnnotationLayer = < }, this.base.state.transform, attachment.view.displayDimensionRenderInfo, + trackableClipDimensionsWeight, ), ); } diff --git a/src/data_panel_layout.ts b/src/data_panel_layout.ts index 0fbf698f1..c18fdc56d 100644 --- a/src/data_panel_layout.ts +++ b/src/data_panel_layout.ts @@ -66,6 +66,7 @@ import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; import { optionallyRestoreFromJsonMember } from "#src/util/trackable.js"; import { WatchableMap } from "#src/util/watchable_map.js"; +import type { TrackableClipDimensionsWeight } from "#src/viewer.js"; import type { VisibilityPrioritySpecification } from "#src/viewer_state.js"; import { DisplayDimensionsWidget } from "#src/widget/display_dimensions_widget.js"; import type { ScaleBarOptions } from "#src/widget/scale_bar.js"; @@ -101,6 +102,7 @@ export interface ViewerUIState crossSectionBackgroundColor: TrackableRGB; perspectiveViewBackgroundColor: TrackableRGB; hideCrossSectionBackground3D: TrackableBoolean; + clipDimensionsWeight: TrackableClipDimensionsWeight; } export interface DataDisplayLayout extends RefCounted { @@ -179,6 +181,7 @@ export function getCommonViewerState(viewer: ViewerUIState) { wireFrame: viewer.wireFrame, enableAdaptiveDownsampling: viewer.enableAdaptiveDownsampling, visibleLayerRoles: viewer.visibleLayerRoles, + clipDimensionsWeight: viewer.clipDimensionsWeight, selectedLayer: viewer.selectedLayer, visibility: viewer.visibility, scaleBarOptions: viewer.scaleBarOptions, diff --git a/src/layer_group_viewer.ts b/src/layer_group_viewer.ts index 007661a16..ab43592f9 100644 --- a/src/layer_group_viewer.ts +++ b/src/layer_group_viewer.ts @@ -77,6 +77,7 @@ import { CompoundTrackable, optionallyRestoreFromJsonMember, } from "#src/util/trackable.js"; +import type { TrackableClipDimensionsWeight } from "#src/viewer.js"; import type { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; import { EnumSelectWidget } from "#src/widget/enum_widget.js"; import type { TrackableScaleBarOptions } from "#src/widget/scale_bar.js"; @@ -103,6 +104,7 @@ export interface LayerGroupViewerState { crossSectionBackgroundColor: TrackableRGB; perspectiveViewBackgroundColor: TrackableRGB; hideCrossSectionBackground3D: TrackableBoolean; + clipDimensionsWeight: TrackableClipDimensionsWeight; } export interface LayerGroupViewerOptions { @@ -385,6 +387,9 @@ export class LayerGroupViewer extends RefCounted { get scaleBarOptions() { return this.viewerState.scaleBarOptions; } + get clipDimensionsWeight() { + return this.viewerState.clipDimensionsWeight; + } layerPanel: LayerBar | undefined; layout: DataPanelLayoutContainer; toolBinder: LocalToolBinder; diff --git a/src/layer_groups_layout.ts b/src/layer_groups_layout.ts index 4944ef1c3..6aae32c74 100644 --- a/src/layer_groups_layout.ts +++ b/src/layer_groups_layout.ts @@ -421,6 +421,7 @@ function getCommonViewerState(viewer: Viewer) { crossSectionBackgroundColor: viewer.crossSectionBackgroundColor, perspectiveViewBackgroundColor: viewer.perspectiveViewBackgroundColor, hideCrossSectionBackground3D: viewer.hideCrossSectionBackground3D, + clipDimensionsWeight: viewer.clipDimensionsWeight, }; } diff --git a/src/viewer.ts b/src/viewer.ts index 4ade91a50..27f46a540 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -116,8 +116,10 @@ import { parseFixedLengthArray, verifyFinitePositiveFloat, verifyObject, + verifyObjectAsMap, verifyOptionalObjectProperty, verifyString, + verifyFloat, } from "#src/util/json.js"; import { EventActionMap, @@ -125,6 +127,7 @@ import { } from "#src/util/keyboard_bindings.js"; import { ScreenshotManager } from "#src/util/screenshot_manager.js"; import { NullarySignal } from "#src/util/signal.js"; +import type { Trackable } from "#src/util/trackable.js"; import { CompoundTrackable, optionallyRestoreFromJsonMember, @@ -232,6 +235,81 @@ const defaultViewerOptions = resetStateWhenEmpty: true, }; +/** + * Trackable state for annotation clipping dimension weights per layer. + * Maps layer names to maps of dimension names to weight values (0.0 to 1.0). + * A weight of 0.0 means no clipping for that dimension, 1.0 means full clipping. + */ +export class TrackableClipDimensionsWeight implements Trackable { + changed = new NullarySignal(); + + // Map from layer name to map of dimension name to weight + private value_ = new Map>(); + + get value() { + return this.value_; + } + + reset() { + if (this.value_.size === 0) return; + this.value_.clear(); + this.changed.dispatch(); + } + + restoreState(obj: any) { + if (obj === undefined) { + this.reset(); + return; + } + verifyObject(obj); + const newValue = verifyObjectAsMap(obj, (layerObj) => + verifyObjectAsMap(layerObj, verifyFloat), + ); + + // Check if the value has actually changed + let changed = this.value_.size !== newValue.size; + if (!changed) { + for (const [layerName, dimWeights] of newValue) { + const oldDimWeights = this.value_.get(layerName); + if (!oldDimWeights || oldDimWeights.size !== dimWeights.size) { + changed = true; + break; + } + for (const [dimName, weight] of dimWeights) { + if (oldDimWeights.get(dimName) !== weight) { + changed = true; + break; + } + } + if (changed) break; + } + } + + if (changed) { + this.value_.clear(); + for (const [layerName, dimWeights] of newValue) { + this.value_.set(layerName, new Map(dimWeights)); + } + this.changed.dispatch(); + } + } + + toJSON() { + if (this.value_.size === 0) return undefined; + const obj: any = {}; + for (const [layerName, dimWeights] of this.value_) { + if (dimWeights.size > 0) { + const layerObj: any = {}; + for (const [dimName, weight] of dimWeights) { + layerObj[dimName] = weight; + } + obj[layerName] = layerObj; + } + } + return Object.keys(obj).length > 0 ? obj : undefined; + } +} + class TrackableViewerState extends CompoundTrackable { constructor(public viewer: Borrowed) { super(); @@ -253,6 +331,7 @@ class TrackableViewerState extends CompoundTrackable { this.add("enableAdaptiveDownsampling", viewer.enableAdaptiveDownsampling); this.add("showScaleBar", viewer.showScaleBar); this.add("showDefaultAnnotations", viewer.showDefaultAnnotations); + this.add("clipDimensionsWeight", viewer.clipDimensionsWeight); this.add("showSlices", viewer.showPerspectiveSliceViews); this.add( @@ -436,6 +515,7 @@ export class Viewer extends RefCounted implements ViewerState { perspectiveViewBackgroundColor = new TrackableRGB(vec3.fromValues(0, 0, 0)); scaleBarOptions = new TrackableScaleBarOptions(); partialViewport = new TrackableWindowedViewport(); + clipDimensionsWeight = new TrackableClipDimensionsWeight(); statisticsDisplayState = new StatisticsDisplayState(); helpPanelState = new HelpPanelState(); settingsPanelState = new ViewerSettingsPanelState(); From 1dc57b9327e38e31baf6ba919454e22785fa93ec Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Jan 2026 11:18:47 +0100 Subject: [PATCH 40/65] feat: add clip dims to python state --- python/examples/example_linear_registration.py | 15 ++++++++++++--- python/neuroglancer/viewer_state.py | 4 ++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 9f91bf23a..a12d0efb6 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -81,7 +81,6 @@ def debounced(*args, **kwargs): return decorator -# TODO further test these fits, 2 point fit not right at the moment def fit_model(fixed_points: np.ndarray, moving_points: np.ndarray): """ Choose the appropriate model based on number of points and dimensions. @@ -346,7 +345,7 @@ def update(self): "help", "Place points to inform registration by first placing the centre position in the left/right panel to the correct place for the fixed/moving data, and then placing a point annotation with ctrl+left click in the other panel. You can move the annotations afterwards if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'd' to dump current state for later resumption.", ) - # self._clear_status_messages() + self._clear_status_messages() def get_moving_layer_names(self, s: neuroglancer.ViewerState): right_panel_layers = [ @@ -505,6 +504,7 @@ def save_coord_space_info(self, info_future): # If we get here we have all the coord spaces ready and can update viewer self.ready_state = ReadyState.COORDS_READY with self.viewer.txn() as s: + self._ignore_non_display_dims() for layer_name in self._cached_moving_layer_names: output_dims = self.stored_map_moving_name_to_data_coords.get( layer_name, None @@ -693,7 +693,6 @@ def estimate_affine(self, s: neuroglancer.ViewerState): def get_registration_info(self): info = {} - # TODO consider also dumping the full viewer state with self.viewer.txn() as s: annotations = s.layers[self.annotations_name].annotations dim_names = s.dimensions.names @@ -766,6 +765,16 @@ def _add_demo_data_to_viewer(self): s.layers["fixed"] = fixed_layer s.layers["moving"] = moving_layer + def _ignore_non_display_dims(self): + with self.viewer.txn() as s: + dim_names = s.dimensions.names + dim_map = {k: 0 for k in dim_names if k not in ["t", "time", "t1"]} + layer_map = { + self.annotations_name: dim_map + } + s.clip_dimensions_weight = layer_map + + def add_mapping_args(ap: argparse.ArgumentParser): ap.add_argument( diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index 7f384b011..18ab2286b 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -1879,6 +1879,10 @@ class ViewerState(JsonObjectWrapper): tool_palettes = toolPalettes = wrapped_property( "toolPalettes", typed_map(key_type=str, value_type=ToolPalette) ) + clip_dimensions_weight = clipDimensionsWeight = wrapped_property( + "clipDimensionsWeight", + optional(typed_map(key_type=str, value_type=typed_map(key_type=str, value_type=float))) + ) @staticmethod def interpolate(a, b, t): From 749a1290498d41c0a730897df4274787b2583524 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Jan 2026 14:32:47 +0100 Subject: [PATCH 41/65] refactor: largely clean up the linear reg workflow --- .../examples/example_linear_registration.py | 807 ++++++++++-------- 1 file changed, 465 insertions(+), 342 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index a12d0efb6..10ce058e4 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -18,7 +18,7 @@ d. The fixed and moving coordinates can be adjusted later by moving the annotation as normal (alt + left click the point). This will only move the point in the panel you are currently focused on, so to adjust both fixed and moving coordinates you need to switch panels. 6. As you add points, the estimated affine transform will be updated and applied to the moving layers. The registered layers can be toggled visible/invisible by pressing 't'. 7. If an issue happens, the viewer state can go out of sync. To help with this, the python console will regularly print that viewer states are syncing with a timestamp. If you do not see this message for a while, consider continuing the workflow again from a saved state. - 8. To continue from a saved state, dump the viewer state to a file using either the viewer UI or the dump_info method in the python console, and then pass this file via --json when starting the script again. You should also pass --continue-workflow (or -c) to skip the initial setup steps. If you renamed the annotation layer containing the registration points, you should also pass --annotations-name (or -a) with the new name. For example: + 8. To continue from a saved state, dump the viewer state to a file using either the viewer UI or the dump_current_state method in the python console, and then pass this file via --json when starting the script again. You should also pass --continue-workflow (or -c) to skip the initial setup steps. If you renamed the annotation layer containing the registration points, you should also pass --annotations-name (or -a) with the new name. For example: python -i example_linear_registration.py --json saved_state.json -c -a registration_points """ @@ -26,6 +26,8 @@ import logging import threading import webbrowser +import json +from pprint import pprint from copy import deepcopy, copy from enum import Enum from time import ctime, time @@ -37,58 +39,37 @@ import numpy as np import scipy.ndimage -# Debug flag to enable detailed logging of registration process -# Set to True to log fixed points, moving points, transform, and transformed points -DEBUG = True - -# Configure logging for debug output -logging.basicConfig(level=logging.INFO, format='%(message)s') - +DEBUG = True # print debug info during execution MESSAGE_DURATION = 5 # seconds -NUM_DEMO_DIMS = 2 # Currently can be 2D or 3D +NUM_DEMO_DIMS = 3 # Currently can be 2D or 3D AFFINE_NUM_DECIMALS = 6 -MARKERS_SHADER = """ -#uicontrol vec3 fixedPointColor color(default="#00FF00") -#uicontrol vec3 movingPointColor color(default="#0000FF") -#uicontrol float pointSize slider(min=1, max=16, default=6) -void main() { - if (int(prop_group()) == 0) { - setColor(fixedPointColor); - } else { - setColor(movingPointColor); - } - setPointMarkerSize(pointSize); -} -""" - - -def debounce(wait: float): - def decorator(fn): - timer = None - - def debounced(*args, **kwargs): - nonlocal timer - - if timer is not None: - timer.cancel() - - timer = threading.Timer(wait, lambda: fn(*args, **kwargs)) - timer.start() - - return debounced +logging.basicConfig(level=logging.INFO, format="%(message)s") - return decorator - -def fit_model(fixed_points: np.ndarray, moving_points: np.ndarray): +def estimate_transform(fixed_points: np.ndarray, moving_points: np.ndarray): """ Choose the appropriate model based on number of points and dimensions. Inspired by https://github.com/AllenInstitute/render-python/blob/master/renderapi/transform/leaf/affine_models.py + That link contains 2D code, and so not everything here was used as an exact + generalisation to ND, but many of the ideas and maths did translate. + + Parameters + ---------- + fixed_points: np.ndarray + The points to try and map the moving_points to. + moving_points: np.ndarray + The points apply the transformation on. + + Returns + ------- + np.ndarray + The estimated affine transformation matrix. + """ assert fixed_points.shape == moving_points.shape - N, D = fixed_points.shape + N, D = fixed_points.shape # N = number of points, D = number of dimensions if N == 1: return translation_fit(fixed_points, moving_points) @@ -102,6 +83,7 @@ def fit_model(fixed_points: np.ndarray, moving_points: np.ndarray): def translation_fit(fixed_points: np.ndarray, moving_points: np.ndarray): + """Fit translation only between the points""" N, D = fixed_points.shape estimated_translation = np.mean(fixed_points - moving_points, axis=0) @@ -113,31 +95,42 @@ def translation_fit(fixed_points: np.ndarray, moving_points: np.ndarray): affine = np.round(affine, decimals=AFFINE_NUM_DECIMALS) return affine + # See https://en.wikipedia.org/wiki/Orthogonal_Procrustes_problem # and https://math.nist.gov/~JBernal/kujustf.pdf # Follows the Kabsch algorithm https://en.wikipedia.org/wiki/Kabsch_algorithm -def rigid_or_similarity_fit(fixed_points, moving_points, rigid=True): - N, D = fixed_points.shape # N = number of points, D = number of dimensions +def rigid_or_similarity_fit( + fixed_points: np.ndarray, moving_points: np.ndarray, rigid=True +): + """Fit rigid or similar between the points using the Kabsch algorithm + + See https://en.wikipedia.org/wiki/Kabsch_algorithm + https://en.wikipedia.org/wiki/Orthogonal_Procrustes_problem + and https://math.nist.gov/~JBernal/kujustf.pdf + + If rigid is True, do not perform scaling. + """ - # Find transform from Q to P + # Find transform from Q to P in the below code + N, D = fixed_points.shape mu_q = moving_points.mean(axis=0) mu_p = fixed_points.mean(axis=0) - # Step 1, translate points so their origin is their centroids + # Translate points so their origin is the centroid of the points Q = moving_points - mu_q - P = fixed_points - mu_p + P = fixed_points - mu_p - # Cross covariance matrix, D x D + # Find cross covariance matrix, D x D H = (P.T @ Q) / N - # SVD of covariance matrix + # Compute SVD of covariance matrix U, Sigma, Vt = np.linalg.svd(H) # Record if the matrices contain a reflection d = np.ones(D) if np.linalg.det(U @ Vt) < 0: d[-1] = -1.0 - # Rotation matrix + # Compute optimal rotation matrix to apply to Q R = U @ np.diag(d) @ Vt # Scale depending on rigid or similarity @@ -148,9 +141,10 @@ def rigid_or_similarity_fit(fixed_points, moving_points, rigid=True): var_x = (Q**2).sum() / N s = (Sigma * d).sum() / var_x + # Compute optimal translation t = mu_p - s * (R @ mu_q) - # Homogeneous (D+1)x(D+1) + # Fill the D x (D + 1) matrix for neuroglancer T = np.zeros((D, D + 1)) T[:D, :D] = s * R T[:, -1] = t @@ -160,24 +154,28 @@ def rigid_or_similarity_fit(fixed_points, moving_points, rigid=True): def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): + # Find mapping from Q to P + # Target values (P) is a D * N array + # Input values (Q) is a D * N, (D * (D + 1)) array + # Output estimation is a (D * (D + 1)) array N, D = fixed_points.shape - # Target values (B) is a D * N array - # Input values (A) is a D * N, (D * (D + 1)) array - # Output estimation is a (D * (D + 1)) array - A = np.zeros(((D * N), D * (D + 1))) + # We essentially setup multiple copies of the moving points + # so that solving Q * x = P solves multiplication by the affine + # with linear least squares + Q = np.zeros(((D * N), D * (D + 1))) for i in range(N): for j in range(D): start_index = j * D end_index = (j + 1) * D - A[D * i + j, start_index:end_index] = moving_points[i] - A[D * i + j, D * D + j] = 1 - B = fixed_points.flatten() + Q[D * i + j, start_index:end_index] = moving_points[i] + Q[D * i + j, D * D + j] = 1 + P = fixed_points.flatten() # The estimated affine transform params will be flattened # and there will be D * (D + 1) of them # Format is x1, x2, ..., b1, b2, ... - tvec, res, rank, sd = np.linalg.lstsq(A, B) + tvec, res, rank, sd = np.linalg.lstsq(Q, P) # Put the flattened version back into the matrix affine = np.zeros((D, D + 1)) @@ -201,8 +199,28 @@ def transform_points(affine: np.ndarray, points: np.ndarray): return transformed -# Only used if no data provided +def debounce(wait: float): + """Wrap function in debounce""" + + def decorator(fn): + timer = None + + def debounced(*args, **kwargs): + nonlocal timer + + if timer is not None: + timer.cancel() + + timer = threading.Timer(wait, lambda: fn(*args, **kwargs)) + timer.start() + + return debounced + + return decorator + + def _create_demo_data(size: Union[int, tuple] = 60, radius: float = 20): + """Only used if no data is provided to the script""" data_size = (size,) * NUM_DEMO_DIMS if isinstance(size, int) else size data = np.zeros(data_size, dtype=np.uint8) if NUM_DEMO_DIMS == 2: @@ -220,8 +238,8 @@ def _create_demo_data(size: Union[int, tuple] = 60, radius: float = 20): return data -# Only used if no data provided def _create_demo_fixed_image(): + """Only used if no data is provided to the script""" return neuroglancer.ImageLayer( source=[ neuroglancer.LayerDataSource(neuroglancer.LocalVolume(_create_demo_data())) @@ -229,8 +247,8 @@ def _create_demo_fixed_image(): ) -# Only used if no data provided def _create_demo_moving_image(): + """Only used if no data is provided to the script""" if NUM_DEMO_DIMS == 2: desired_output_matrix_homogenous = [ [0.8, 0, 0], @@ -249,22 +267,24 @@ def _create_demo_moving_image(): _create_demo_data(), matrix=inverse_matrix, ) - print("target demo affine", inverse_matrix) + print("Target demo affine, can be compared to estimated", inverse_matrix) return neuroglancer.ImageLayer( source=[neuroglancer.LayerDataSource(neuroglancer.LocalVolume(transformed))] ) -def new_coord_space_names(dims: neuroglancer.CoordinateSpace, name_suffix): +def copy_coord_space(space: neuroglancer.CoordinateSpace, name_suffix): + """Create a copy of a coord space and returns a space with new names""" + def change_name(n): if n.endswith(("'", "^", "#")): return n return n + name_suffix return neuroglancer.CoordinateSpace( - names=[change_name(n) for n in dims.names], - units=dims.units, - scales=dims.scales, + names=[change_name(n) for n in space.names], + units=space.units, + scales=space.scales, # type: ignore ) @@ -279,10 +299,16 @@ def create_coord_space_matching_global_dims( units = [units[i] for i in indices] scales = [scales[i] for i in indices] - return neuroglancer.CoordinateSpace(names=names, units=units, scales=scales) + return neuroglancer.CoordinateSpace( + names=names, + units=units, + scales=scales, # type: ignore + ) + +class PipelineState(Enum): + """The pipeline goes through multiple states that alter behaviour.""" -class ReadyState(Enum): NOT_READY = 0 COORDS_READY = 1 READY = 2 @@ -291,10 +317,10 @@ class ReadyState(Enum): class LinearRegistrationWorkflow: def __init__(self, args): - starting_state = args.state + starting_ng_state = args.state self.annotations_name = args.annotations_name self.ready_state = ( - ReadyState.READY if args.continue_workflow else ReadyState.NOT_READY + PipelineState.READY if args.continue_workflow else PipelineState.NOT_READY ) self.unlink_scales = args.unlink_scales self.output_name = args.output_name @@ -305,25 +331,22 @@ def __init__(self, args): self.affine = None self.viewer = neuroglancer.Viewer() self.viewer.shared_state.add_changed_callback( - lambda: self.viewer.defer_callback(self.on_state_changed) - ) + lambda: self.viewer.defer_callback(self.update) + ) # handle custom functionality for this pipeline on general state changes self._last_updated_print_time = -1 self._status_timers = {} self._current_moving_layer_idx = 0 self._cached_moving_layer_names = [] - if starting_state is None: + if starting_ng_state is None: self._add_demo_data_to_viewer() else: - self.viewer.set_state(starting_state) + self.viewer.set_state(starting_ng_state) - self.setup_viewer_actions() - if self.ready_state != ReadyState.READY: - self._set_status_message( - "help", - "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup.", - ) + self._setup_viewer_actions() + self._show_help_message() + if self.ready_state != PipelineState.READY: self.setup_initial_two_panel_layout() def update(self): @@ -332,44 +355,17 @@ def update(self): if current_time - self._last_updated_print_time > 5: print(f"Viewer states are successfully syncing at {ctime()}") self._last_updated_print_time = current_time - if self.ready_state == ReadyState.COORDS_READY: - self.setup_registration_layers() - elif self.ready_state == ReadyState.ERROR: + if self.ready_state == PipelineState.COORDS_READY: + self.setup_registration_point_layer() + elif self.ready_state == PipelineState.ERROR: self._set_status_message( "help", "Please manually enter second coordinate space information for the moving layers.", ) - elif self.ready_state == ReadyState.READY: + elif self.ready_state == PipelineState.READY: self.update_affine() - self._set_status_message( - "help", - "Place points to inform registration by first placing the centre position in the left/right panel to the correct place for the fixed/moving data, and then placing a point annotation with ctrl+left click in the other panel. You can move the annotations afterwards if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'd' to dump current state for later resumption.", - ) self._clear_status_messages() - def get_moving_layer_names(self, s: neuroglancer.ViewerState): - right_panel_layers = [ - n for n in s.layout.children[1].layers if n != self.annotations_name - ] - return right_panel_layers - - def copy_moving_layers_to_left_panel(self): - """Make copies of the moving layers to show the registered result.""" - with self.viewer.txn() as s: - self._cached_moving_layer_names = self.get_moving_layer_names(s) - for layer_name in self._cached_moving_layer_names: - copy = deepcopy(s.layers[layer_name]) - copy.name = layer_name + "_registered" - copy.visible = False - s.layers[copy.name] = copy - s.layout.children[0].layers.append(copy.name) - - def setup_viewer_after_user_ready(self): - """Called when the user indicates they have placed layers in the two panels.""" - self.copy_moving_layers_to_left_panel() - self.setup_second_coord_space() - - def setup_initial_two_panel_layout(self): """Set up a two panel layout if not already present.""" with self.viewer.txn() as s: @@ -394,69 +390,32 @@ def setup_initial_two_panel_layout(self): s.layout.children[1].crossSectionScale.link = "unlinked" s.layout.children[1].projectionScale.link = "unlinked" + def setup_viewer_after_user_ready(self): + """Called when the user indicates they have placed layers in the two panels.""" + self._copy_moving_layers_to_left_panel() + self.setup_second_coord_space() + def setup_second_coord_space(self): - """Set up the second coordinate space for the moving layers.""" - layer_name = self._cached_moving_layer_names[self._current_moving_layer_idx] - info_future = self.viewer.volume_info(layer_name) - info_future.add_done_callback(lambda f: self.save_coord_space_info(f)) + """Set up the second coordinate space for the moving layers. - def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): + The info for each layer is requested, then stored in a cache once ready. + When each layer info is ready (or failed) proceeds to the final setup. """ - The affine matrix only applies to the moving dims - but the annotation layer in the two coord space case - applies to all dims so we need to create a larger matrix - """ - all_dims = s.dimensions.names - _, moving_dims = self.get_fixed_and_moving_dims(None, all_dims) - full_matrix = np.zeros((len(all_dims), len(all_dims) + 1)) - - for i, dim in enumerate(all_dims): - for j, dim2 in enumerate(all_dims): - if dim in moving_dims and dim2 in moving_dims: - moving_i = moving_dims.index(dim) - moving_j = moving_dims.index(dim2) - full_matrix[i, j] = affine[moving_i, moving_j] - elif dim == dim2: - full_matrix[i, j] = 1 - if dim in moving_dims: - moving_i = moving_dims.index(dim) - full_matrix[i, -1] = affine[moving_i, -1] - return full_matrix - - def combine_local_channels_with_transform(self, existing_transform: neuroglancer.CoordinateSpaceTransform, transform: list): - local_channel_indices = [ - i - for i, name in enumerate(existing_transform.outputDimensions.names) - if name.endswith(("'", "^", "#")) - ] - if not local_channel_indices: - return transform - final_transform = [] - num_local_count = 0 - for i, name in enumerate(existing_transform.outputDimensions.names): - is_local = i in local_channel_indices - if is_local: - local_channel_row = [0 for _ in range(len(existing_transform.outputDimensions.names) + 1)] - local_channel_row[i] = 1 - final_transform.append(local_channel_row) - num_local_count += 1 - else: - row = copy(transform[i - num_local_count]) - # At the indices corresponding to local channels, insert 0s - for j in local_channel_indices: - row.insert(j, 0.0) - final_transform.append(row) - return final_transform - - def has_two_coord_spaces(self, s: neuroglancer.ViewerState): - fixed_dims, moving_dims = self.get_fixed_and_moving_dims(s) - return len(fixed_dims) == len(moving_dims) + layer_name = self._cached_moving_layer_names[self._current_moving_layer_idx] + info_future = self.viewer.volume_info(layer_name) + info_future.add_done_callback(lambda f: self._update_coord_space_info_cache(f)) - def setup_registration_layers(self): + def setup_registration_point_layer(self): + """Establish information to store affine transform updates and place registration points.""" with self.viewer.txn() as s: - if self.ready_state == ReadyState.ERROR or not self.has_two_coord_spaces(s): + if self.ready_state == PipelineState.ERROR or not self.has_two_coord_spaces( + s + ): return + # Also setup the new layer to clip differently on non display dims + self._ignore_non_display_dims(s) + # Make the annotation layer if needed if s.layers.index(self.annotations_name) == -1: s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( @@ -468,17 +427,27 @@ def setup_registration_layers(self): s.selected_layer.visible = True s.layout.children[0].layers.append(self.annotations_name) s.layout.children[1].layers.append(self.annotations_name) - self.setup_panel_coordinates(s) - self.ready_state = ReadyState.READY + self.setup_panel_display_dims(s) + self.ready_state = PipelineState.READY + self._show_help_message() - def setup_panel_coordinates(self, s: neuroglancer.ViewerState): + def setup_panel_display_dims(self, s: neuroglancer.ViewerState): + """Make the left and right panel show different display dimensions""" fixed_dims, moving_dims = self.get_fixed_and_moving_dims(s) s.layout.children[1].displayDimensions.link = "unlinked" s.layout.children[1].displayDimensions.value = moving_dims[:3] s.layout.children[0].displayDimensions.link = "unlinked" s.layout.children[0].displayDimensions.value = fixed_dims[:3] - def save_coord_space_info(self, info_future): + def _update_coord_space_info_cache(self, info_future): + """Respond to a request about a moving layer's information. + + Caches the info to avoid future requests. When all moving + layers info have been cached, marks the co-ordinate space + as ready (or error on failure) and setups up the second + coord space based on the available information about the moving + layers. + """ self.moving_name = self._cached_moving_layer_names[ self._current_moving_layer_idx ] @@ -499,25 +468,26 @@ def save_coord_space_info(self, info_future): self._current_moving_layer_idx += 1 if self._current_moving_layer_idx < len(self._cached_moving_layer_names): self.setup_second_coord_space() - return + else: + # All of the layers info has been cached, can proceed to setup + return self._create_second_coord_space() - # If we get here we have all the coord spaces ready and can update viewer - self.ready_state = ReadyState.COORDS_READY + def _create_second_coord_space(self): + self.ready_state = PipelineState.COORDS_READY with self.viewer.txn() as s: - self._ignore_non_display_dims() for layer_name in self._cached_moving_layer_names: output_dims = self.stored_map_moving_name_to_data_coords.get( layer_name, None ) if output_dims is None: - self.ready_state = ReadyState.ERROR + self.ready_state = PipelineState.ERROR continue self.stored_map_moving_name_to_viewer_coords[layer_name] = [] for source in s.layers[layer_name].source: if source.transform is None: - output_dims = new_coord_space_names(output_dims, "2") + output_dims = copy_coord_space(output_dims, "2") else: - output_dims = new_coord_space_names( + output_dims = copy_coord_space( source.transform.output_dimensions, "2" ) new_coord_space = neuroglancer.CoordinateSpaceTransform( @@ -530,42 +500,162 @@ def save_coord_space_info(self, info_future): return self.ready_state def continue_workflow(self, _): - if self.ready_state == ReadyState.NOT_READY: + """When the user presses to continue, respond according to the state.""" + if self.ready_state == PipelineState.NOT_READY: self.setup_viewer_after_user_ready() return - elif self.ready_state == ReadyState.COORDS_READY: + elif self.ready_state == PipelineState.COORDS_READY: return - elif self.ready_state == ReadyState.ERROR: - self.setup_registration_layers() + elif self.ready_state == PipelineState.ERROR: + self.setup_registration_point_layer() with self.viewer.txn() as s: for layer_name in self.get_moving_layer_names(s): registered_name = layer_name + "_registered" is_registered_visible = s.layers[registered_name].visible s.layers[registered_name].visible = not is_registered_visible - def setup_viewer_actions(self): + def _show_help_message(self): + in_prog_message = "Place registration points by moving the centre position of one panel and then putting an annotation with ctrl+left click in the other panel. Annotations can be adjusted if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'd' to dump current state for later resumption. Press 'y' to show or hide this help message." + setup_message = "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup. Press 'y' to show/hide this message." + error_message = f"There was an error in setup. Please try again. {setup_message}" + waiting_message = "Please wait while setup is completed" + + help_message = "" + if self.ready_state == PipelineState.READY: + help_message = in_prog_message + elif self.ready_state == PipelineState.NOT_READY: + help_message = setup_message + elif self.ready_state == PipelineState.ERROR: + help_message = error_message + elif self.ready_state == PipelineState.COORDS_READY: + help_message = waiting_message + self._set_status_message("help", help_message) + + def toggle_help_message(self, _): + help_shown = "help" in self._status_timers + if help_shown: + with self.viewer.config_state.txn() as s: + self._clear_status_message("help", s) + else: + self._show_help_message() + + def _setup_viewer_actions(self): viewer = self.viewer - name = "continueLinearRegistrationWorkflow" - viewer.actions.add(name, self.continue_workflow) + continue_name = "continueLinearRegistrationWorkflow" + viewer.actions.add(continue_name, self.continue_workflow) dump_name = "dumpCurrentState" viewer.actions.add(dump_name, self.dump_current_state) + toggle_help_name = "toggleHelpMessage" + viewer.actions.add(toggle_help_name, self.toggle_help_message) + with viewer.config_state.txn() as s: - s.input_event_bindings.viewer["keyt"] = name + s.input_event_bindings.viewer["keyt"] = continue_name s.input_event_bindings.viewer["keyd"] = dump_name + s.input_event_bindings.viewer["keyy"] = toggle_help_name + + def get_moving_layer_names(self, s: neuroglancer.ViewerState): + """Get all layers in right panel that are not the registration point annotation""" + right_panel_layers = [ + n for n in s.layout.children[1].layers if n != self.annotations_name + ] + return right_panel_layers + + def _copy_moving_layers_to_left_panel(self): + """Make copies of the moving layers to show the registered result.""" + with self.viewer.txn() as s: + self._cached_moving_layer_names = self.get_moving_layer_names(s) + for layer_name in self._cached_moving_layer_names: + copy = deepcopy(s.layers[layer_name]) + copy.name = layer_name + "_registered" + copy.visible = False + s.layers[copy.name] = copy + s.layout.children[0].layers.append(copy.name) + + def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): + """ + The affine matrix only applies to the moving dims + but the annotation layer in the two coord space case + applies to all dims so we need to create a larger matrix + """ + all_dims = s.dimensions.names + _, moving_dims = self.get_fixed_and_moving_dims(None, all_dims) + full_matrix = np.zeros((len(all_dims), len(all_dims) + 1)) - def on_state_changed(self): - self.viewer.defer_callback(self.update) + for i, dim in enumerate(all_dims): + for j, dim2 in enumerate(all_dims): + if dim in moving_dims and dim2 in moving_dims: + moving_i = moving_dims.index(dim) + moving_j = moving_dims.index(dim2) + full_matrix[i, j] = affine[moving_i, moving_j] + elif dim == dim2: + full_matrix[i, j] = 1 + if dim in moving_dims: + moving_i = moving_dims.index(dim) + full_matrix[i, -1] = affine[moving_i, -1] + return full_matrix + + def combine_local_channels_with_transform( + self, existing_transform: neuroglancer.CoordinateSpaceTransform, transform: list + ): + """The affine transform estimation does not account for local channel dimensions. + But neuroglancer requires these dimensions to be included in the layer transform. + This function inserts essentially padding in the correct locations in the matrix + for local channels. + """ + local_channel_indices = [ + i + for i, name in enumerate(existing_transform.outputDimensions.names) + if name.endswith(("'", "^", "#")) + ] + if not local_channel_indices: + return transform + final_transform = [] + num_local_count = 0 + for i, name in enumerate(existing_transform.outputDimensions.names): + is_local = i in local_channel_indices + if is_local: + local_channel_row = [ + 0 for _ in range(len(existing_transform.outputDimensions.names) + 1) + ] + local_channel_row[i] = 1 + final_transform.append(local_channel_row) + num_local_count += 1 + else: + row = copy(transform[i - num_local_count]) + # At the indices corresponding to local channels, insert 0s + for j in local_channel_indices: + row.insert(j, 0.0) + final_transform.append(row) + return final_transform + + # TODO ensure this works ok with t + def has_two_coord_spaces(self, s: neuroglancer.ViewerState): + """Check if the two coord space setup is complete""" + fixed_dims, moving_dims = self.get_fixed_and_moving_dims(s) + return len(fixed_dims) == len(moving_dims) @debounce(1.5) def update_affine(self): + """Estimate affine, with debouncing in case of rapid state updates""" with self.viewer.txn() as s: - self.estimate_affine(s) + updated = self.estimate_affine(s) + if updated: + num_point_pairs = len(self.stored_points[0]) + self.update_registered_layers(s) + self._set_status_message( + "info", + f"Estimated affine transform with {num_point_pairs} point pairs", + ) + if DEBUG: + pprint(self.get_registration_info(s)) + # TODO ensure works with t def get_fixed_and_moving_dims( self, s: Union[neuroglancer.ViewerState, None], dim_names: list | tuple = () ): + """Extract the fixed and moving dim names from the state""" if s is None: dimensions = dim_names else: @@ -583,6 +673,9 @@ def get_fixed_and_moving_dims( return fixed_dims, moving_dims def split_points_into_pairs(self, annotations, dim_names): + """In the simple case, each point contains fixed dim coords then moving dim coords + but in case the coords are interleaved more complicated, we pull out the + relevant info here.""" if len(annotations) == 0: return np.zeros((0, 0)), np.zeros((0, 0)) first_name = dim_names[0] @@ -601,25 +694,38 @@ def split_points_into_pairs(self, annotations, dim_names): return np.array(fixed_points), np.array(moving_points) def update_registered_layers(self, s: neuroglancer.ViewerState): + """When the affine updates, update the relevant transform in all layers + which depend upon the affine. + + These are the moving layers, registered layers, and the point registration layer. + Each moving layer has a corresponding registered layer and the transform + is the same across both, but the coord space is different. + """ if self.affine is not None: transform = self.affine.tolist() for k, v in self.stored_map_moving_name_to_data_coords.items(): for i, source in enumerate(s.layers[k].source): - fixed_to_moving_transform_with_locals = self.combine_local_channels_with_transform( - source.transform, transform + fixed_to_moving_transform_with_locals = ( + self.combine_local_channels_with_transform( + source.transform, transform + ) ) - fixed_dims_to_moving_dims_transform = neuroglancer.CoordinateSpaceTransform( - input_dimensions=v, - output_dimensions=new_coord_space_names(v, "2"), - matrix=fixed_to_moving_transform_with_locals, + fixed_dims_to_moving_dims_transform = ( + neuroglancer.CoordinateSpaceTransform( + input_dimensions=v, + output_dimensions=copy_coord_space(v, "2"), + matrix=fixed_to_moving_transform_with_locals, + ) ) source.transform = fixed_dims_to_moving_dims_transform registered_source = s.layers[k + "_registered"].source[i] - fixed_dims_to_fixed_dims_transform = neuroglancer.CoordinateSpaceTransform( - input_dimensions=v, - output_dimensions=v, - matrix=fixed_to_moving_transform_with_locals, + fixed_dims_to_fixed_dims_transform = ( + neuroglancer.CoordinateSpaceTransform( + input_dimensions=v, + output_dimensions=v, + matrix=fixed_to_moving_transform_with_locals, + ) ) registered_source.transform = fixed_dims_to_fixed_dims_transform annotation_transform = neuroglancer.CoordinateSpaceTransform( @@ -629,24 +735,25 @@ def update_registered_layers(self, s: neuroglancer.ViewerState): ) s.layers[self.annotations_name].source[0].transform = annotation_transform - print(f"Updated affine transform (without channel dimensions): {transform}, written to {self.output_name}") - - # Save affine matrix to file - np.savetxt(self.output_name, self.affine, fmt='%.6f') + print( + f"Updated affine transform (without channel dimensions): {transform}, written to {self.output_name}" + ) + np.savetxt(self.output_name, self.affine, fmt="%.6f") def estimate_affine(self, s: neuroglancer.ViewerState): + """Estimate the affine, return True if updated, False otherwise""" annotations = s.layers[self.annotations_name].annotations + + # If there are no annotations, either nothing happened yet + # or the user deleted all the annotations and we need to reset if len(annotations) == 0: if len(self.stored_points[0]) > 0: - # Again not sure if need channels _, moving_dims = self.get_fixed_and_moving_dims(s) n_dims = len(moving_dims) affine = np.zeros(shape=(n_dims, n_dims + 1)) for i in range(n_dims): affine[i][i] = 1 - print(affine) self.affine = affine - self.update_registered_layers(s) self.stored_points = ([], []) return True return False @@ -655,6 +762,8 @@ def estimate_affine(self, s: neuroglancer.ViewerState): fixed_points, moving_points = self.split_points_into_pairs( annotations, dim_names ) + + # Cached last points estimated with, if similar to current, don't estimate if len(self.stored_points[0]) == len(fixed_points) and len( self.stored_points[1] ) == len(moving_points): @@ -662,62 +771,29 @@ def estimate_affine(self, s: neuroglancer.ViewerState): np.isclose(self.stored_points[1], moving_points) ): return False - self.affine = fit_model(fixed_points, moving_points) - - # Debug logging for registration process - if DEBUG: - print("\n=== DEBUG: Registration Transform Details ===") - print(f"Fixed points:\n{fixed_points}") - print(f"Moving points:\n{moving_points}") - print(f"Computed transform:\n{self.affine}") - - # Apply transform to moving points and log results - transformed_points = transform_points(self.affine, moving_points) - print(f"Transformed moving points:\n{transformed_points}") - - print("\nPoint-by-point comparison:") - for i in range(len(moving_points)): - print(f"{i+1}. Moving point ({', '.join(f'{x:.3f}' for x in moving_points[i])}), " - f"Fixed point ({', '.join(f'{x:.3f}' for x in fixed_points[i])}), " - f"Transformed point ({', '.join(f'{x:.3f}' for x in transformed_points[i])})") - print("=" * 50) - - self.update_registered_layers(s) - - self._set_status_message( - "info", - f"Estimated affine transform with {len(moving_points)} point pairs", - ) + self.affine = estimate_transform(fixed_points, moving_points) self.stored_points = [fixed_points, moving_points] + return True - def get_registration_info(self): + def get_registration_info(self, state: neuroglancer.ViewerState): + """Return dict of fixed points, moving points, affine, and transformed points.""" info = {} - with self.viewer.txn() as s: - annotations = s.layers[self.annotations_name].annotations - dim_names = s.dimensions.names - fixed_points, moving_points = self.split_points_into_pairs( - annotations, dim_names - ) - info["annotations"] = annotations.tolist() - info["fixedPoints"] = fixed_points.tolist() - info["movingPoints"] = moving_points.tolist() - if self.affine is not None: - transformed_points = transform_points(self.affine, moving_points) - info["transformedPoints"] = transformed_points.tolist() - info["affineTransform"] = self.affine.tolist() + annotations = state.layers[self.annotations_name].annotations + dim_names = state.dimensions.names + fixed_points, moving_points = self.split_points_into_pairs( + annotations, dim_names + ) + info["annotations"] = annotations + info["fixedPoints"] = fixed_points.tolist() + info["movingPoints"] = moving_points.tolist() + if self.affine is not None: + transformed_points = transform_points(self.affine, moving_points) + info["transformedPoints"] = transformed_points.tolist() + info["affineTransform"] = self.affine.tolist() return info - def dump_info(self, path: str): - import json - - info = self.get_registration_info() - with open(path, "w") as f: - json.dump(info, f, indent=4) - def dump_current_state(self, _): - import json - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"neuroglancer_state_{timestamp}.json" @@ -726,8 +802,18 @@ def dump_current_state(self, _): with open(filename, "w") as f: json.dump(state_dict, f, indent=4) - print(f"Current state dumped to: {filename}") - self._set_status_message("dump", f"State saved to {filename}") + registration_log_filename = f"registration_log_{timestamp}.json" + + with open(registration_log_filename, "w") as f: + json.dump(self.get_registration_info(), f, indent=4) + + print( + f"Current state dumped to: {filename}, registration log saved to {registration_log_filename}" + ) + self._set_status_message( + "dump", + f"State saved to {filename}, registration log saved to {registration_log_filename}", + ) return filename @@ -741,12 +827,17 @@ def __str__(self): def _clear_status_messages(self): to_pop = [] for k, v in self._status_timers.items(): + if k == "help": # "help" is manually cleared + continue if time() - v > MESSAGE_DURATION: to_pop.append(k) - for k in to_pop: - with self.viewer.config_state.txn() as s: - s.status_messages.pop(k, None) - self._status_timers.pop(k) + with self.viewer.config_state.txn() as s: + for k in to_pop: + self._clear_status_message(k, s) + + def _clear_status_message(self, key: str, config): + config.status_messages.pop(key, None) + return self._status_timers.pop(key, None) def _set_status_message(self, key: str, message: str): with self.viewer.config_state.txn() as s: @@ -765,15 +856,13 @@ def _add_demo_data_to_viewer(self): s.layers["fixed"] = fixed_layer s.layers["moving"] = moving_layer - def _ignore_non_display_dims(self): - with self.viewer.txn() as s: - dim_names = s.dimensions.names - dim_map = {k: 0 for k in dim_names if k not in ["t", "time", "t1"]} - layer_map = { - self.annotations_name: dim_map - } - s.clip_dimensions_weight = layer_map - + def _ignore_non_display_dims(self, state: neuroglancer.ViewerState): + """With two coord spaces, we need to set annotations not to clip on certain + non-displayed dimensions""" + dim_names = state.dimensions.names + dim_map = {k: 0 for k in dim_names if k not in ["t", "time", "t1"]} + layer_map = {self.annotations_name: dim_map} + state.clip_dimensions_weight = layer_map def add_mapping_args(ap: argparse.ArgumentParser): @@ -822,7 +911,8 @@ def handle_args(): neuroglancer.cli.handle_server_arguments(args) return args -### Some testing code ### + +### Some testing code for transform fitting ### class TestTransforms: def test_translation_fit(self): # Simple 2D translation, +4 in y, +1 in x @@ -841,100 +931,133 @@ def test_rigid_fit_2d(self): assert np.allclose(affine, expected) def test_rigid_fit_3d(self): - # Test 1: Simple 90-degree rotation around Z-axis - fixed = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [-1, 0, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]) - moving = np.array([[0, 0, 0], [0, 1, 0], [-1, 0, 0], [0, -1, 0], [1, 0, 0], [0, 0, 1], [0, 0, -1]]) + # Simple 90-degree rotation around Z-axis + fixed = np.array( + [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + [-1, 0, 0], + [0, -1, 0], + [0, 0, 1], + [0, 0, -1], + ] + ) + moving = np.array( + [ + [0, 0, 0], + [0, 1, 0], + [-1, 0, 0], + [0, -1, 0], + [1, 0, 0], + [0, 0, 1], + [0, 0, -1], + ] + ) affine = rigid_or_similarity_fit(fixed, moving, rigid=True) expected = np.array([[0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 1, 0]]) assert np.allclose(affine, expected) - - def test_2d_dipper(self): - big = np.array([ - [ 0.0, 0.0], - [ 1.0, 0.2], - [ 1.2, -0.8], - [ 0.2, -1.0], - [-0.5, -1.2], - [-1.1, -1.6], - [-1.8, -2.1], - ], dtype=float) + def test_2d_transform_fit(self): + # Based on the idea of mapping the big and little dipper together + # In reality any points would do here, but having a kind of known layout + # helps + little = np.array( + [ + [0.0, 0.0], + [1.0, 0.2], + [1.2, -0.8], + [0.2, -1.0], + [-0.5, -1.2], + [-1.1, -1.6], + [-1.8, -2.1], + ], + dtype=float, + ) s = 1.7 - R = np.array([ - [ 0.866, -0.500], - [ 0.354, 0.612], - ]) + R = np.array( + [ + [0.866, -0.500], + [0.354, 0.612], + ] + ) t = np.array([3.2, 1.4]) - little = (big @ R.T) * s + t + big = (little @ R.T) * s + t - affine = rigid_or_similarity_fit(big, little, rigid=False) + affine = rigid_or_similarity_fit(little, big, rigid=False) # Optional plot to visualize - import matplotlib.pyplot as plt - fig, ax = plt.subplots() - ax.plot(big[:,0], big[:,1], 'o', label='big') - ax.plot(little[:,0], little[:,1], 'o', label='little') - ax.plot( - transform_points(affine, little)[:,0], - transform_points(affine, little)[:,1], - 'x', label='transformed little' - ) - ax.legend() - fig.savefig("dipper.png", dpi=200) - - transformed_points = transform_points(affine, little) - assert np.allclose(transformed_points, big, atol=0.3) + # import matplotlib.pyplot as plt + # fig, ax = plt.subplots() + # ax.plot(little[:, 0], little[:, 1], "o", label="big") + # ax.plot(big[:, 0], big[:, 1], "o", label="little") + # ax.plot( + # transform_points(affine, big)[:, 0], + # transform_points(affine, big)[:, 1], + # "x", + # label="transformed little", + # ) + # ax.legend() + # fig.savefig("dipper.png", dpi=200) + + transformed_points = transform_points(affine, big) + assert np.allclose(transformed_points, little, atol=0.3) # While the transform is really a similarity transform, # we can also try an affine fit here - affine2 = affine_fit(big, little) - transformed_points2 = transform_points(affine2, little) - assert np.allclose(transformed_points2, big, atol=1e-2) - - def test_similarity_3d_dipper(self): - big = np.array([ - [ 0.0, 0.0, 0.0], - [ 1.0, 0.2, 0.1], - [ 1.2, -0.8, 0.3], - [ 0.2, -1.0, 0.2], - [-0.5, -1.2, 0.0], - [-1.1, -1.6, -0.2], - [-1.8, -2.1, -0.4], - ], dtype=float) + affine2 = affine_fit(little, big) + transformed_points2 = transform_points(affine2, big) + assert np.allclose(transformed_points2, little, atol=1e-2) + + def test_3d_transform_fit(self): + little = np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.2, 0.1], + [1.2, -0.8, 0.3], + [0.2, -1.0, 0.2], + [-0.5, -1.2, 0.0], + [-1.1, -1.6, -0.2], + [-1.8, -2.1, -0.4], + ], + dtype=float, + ) s = 1.7 - R = np.array([ - [ 0.866, -0.500, 0.000], - [ 0.354, 0.612, -0.707], - [ 0.354, 0.612, 0.707], - ]) + R = np.array( + [ + [0.866, -0.500, 0.000], + [0.354, 0.612, -0.707], + [0.354, 0.612, 0.707], + ] + ) t = np.array([3.2, 1.4, 2.0]) - little = (big @ R.T) * s + t + big = (little @ R.T) * s + t - affine = rigid_or_similarity_fit(big, little, rigid=False) + affine = rigid_or_similarity_fit(little, big, rigid=False) # Optional plot to visualize - import matplotlib.pyplot as plt - fig = plt.figure() - ax = fig.add_subplot(111, projection="3d") - ax.scatter(big[:,0], big[:,1], big[:,2], label="big", marker="o") - ax.scatter(little[:,0], little[:,1], little[:,2], label="little", marker="o") - tl = transform_points(affine, little) - ax.scatter(tl[:,0], tl[:,1], tl[:,2], label="transformed little", marker="x") - ax.legend() - fig.savefig("dipper_3d.png", dpi=200) - - transformed_points = transform_points(affine, little) - assert np.allclose(transformed_points, big, atol=1e-2) + # import matplotlib.pyplot as plt + # fig = plt.figure() + # ax = fig.add_subplot(111, projection="3d") + # ax.scatter(little[:, 0], little[:, 1], little[:, 2], label="big", marker="o") + # ax.scatter(big[:, 0], big[:, 1], big[:, 2], label="little", marker="o") + # tl = transform_points(affine, big) + # ax.scatter(tl[:, 0], tl[:, 1], tl[:, 2], label="transformed little", marker="x") + # ax.legend() + # fig.savefig("dipper_3d.png", dpi=200) + + transformed_points = transform_points(affine, big) + assert np.allclose(transformed_points, little, atol=1e-2) # While the transform is really a similarity transform, # we can also try an affine fit here - affine2 = affine_fit(big, little) - transformed_points2 = transform_points(affine2, little) - assert np.allclose(transformed_points2, big, atol=1e-2) + affine2 = affine_fit(little, big) + transformed_points2 = transform_points(affine2, big) + assert np.allclose(transformed_points2, little, atol=1e-2) def test_affine_fit_2d(self): fixed = np.array([[0, 0], [1, 0], [0, 1]]) From 1c15819dea31f89e0f054dac1c69a9d4dae0561f Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Jan 2026 14:55:49 +0100 Subject: [PATCH 42/65] feat: allow to force non-affine. We also try detect it from rank --- .../examples/example_linear_registration.py | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 10ce058e4..1015467bb 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -47,7 +47,7 @@ logging.basicConfig(level=logging.INFO, format="%(message)s") -def estimate_transform(fixed_points: np.ndarray, moving_points: np.ndarray): +def estimate_transform(fixed_points: np.ndarray, moving_points: np.ndarray, force_non_affine=False): """ Choose the appropriate model based on number of points and dimensions. @@ -61,6 +61,8 @@ def estimate_transform(fixed_points: np.ndarray, moving_points: np.ndarray): The points to try and map the moving_points to. moving_points: np.ndarray The points apply the transformation on. + force_non_affine: bool + Force max of similarity transform. Returns ------- @@ -73,11 +75,11 @@ def estimate_transform(fixed_points: np.ndarray, moving_points: np.ndarray): if N == 1: return translation_fit(fixed_points, moving_points) - if N == 2: + elif N == 2: return rigid_or_similarity_fit(fixed_points, moving_points, rigid=True) - if N == 3 and D == 2: + elif N == 3 and D == 2: return affine_fit(fixed_points, moving_points) - if N == 3 and D > 2: + elif (N == 3 and D > 2) or force_non_affine: return rigid_or_similarity_fit(fixed_points, moving_points, rigid=False) return affine_fit(fixed_points, moving_points) @@ -177,6 +179,11 @@ def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): # Format is x1, x2, ..., b1, b2, ... tvec, res, rank, sd = np.linalg.lstsq(Q, P) + print(rank) + if rank < D*(D+1): + # planar/degenerate -> fall back + return rigid_or_similarity_fit(fixed_points, moving_points, rigid=False) + # Put the flattened version back into the matrix affine = np.zeros((D, D + 1)) for i in range(D): @@ -325,7 +332,7 @@ def __init__(self, args): self.unlink_scales = args.unlink_scales self.output_name = args.output_name - self.stored_points = ([], []) + self.stored_points = ([], [], False) self.stored_map_moving_name_to_data_coords = {} self.stored_map_moving_name_to_viewer_coords = {} self.affine = None @@ -338,6 +345,7 @@ def __init__(self, args): self._status_timers = {} self._current_moving_layer_idx = 0 self._cached_moving_layer_names = [] + self._force_non_affine = False if starting_ng_state is None: self._add_demo_data_to_viewer() @@ -515,7 +523,7 @@ def continue_workflow(self, _): s.layers[registered_name].visible = not is_registered_visible def _show_help_message(self): - in_prog_message = "Place registration points by moving the centre position of one panel and then putting an annotation with ctrl+left click in the other panel. Annotations can be adjusted if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'd' to dump current state for later resumption. Press 'y' to show or hide this help message." + in_prog_message = "Place registration points by moving the centre position of one panel and then putting an annotation with ctrl+left click in the other panel. Annotations can be adjusted if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'f' to force at most a similarity transform estimation. Press 'd' to dump current state for later resumption. Press 'y' to show or hide this help message." setup_message = "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup. Press 'y' to show/hide this message." error_message = f"There was an error in setup. Please try again. {setup_message}" waiting_message = "Please wait while setup is completed" @@ -539,6 +547,10 @@ def toggle_help_message(self, _): else: self._show_help_message() + def toggle_force_non_affine(self, _): + self._force_non_affine = not self._force_non_affine + self.update_affine() + def _setup_viewer_actions(self): viewer = self.viewer continue_name = "continueLinearRegistrationWorkflow" @@ -550,10 +562,14 @@ def _setup_viewer_actions(self): toggle_help_name = "toggleHelpMessage" viewer.actions.add(toggle_help_name, self.toggle_help_message) + force_name = "forceNonAffine" + viewer.actions.add(force_name, self.toggle_force_non_affine) + with viewer.config_state.txn() as s: s.input_event_bindings.viewer["keyt"] = continue_name s.input_event_bindings.viewer["keyd"] = dump_name s.input_event_bindings.viewer["keyy"] = toggle_help_name + s.input_event_bindings.viewer["keyf"] = force_name def get_moving_layer_names(self, s: neuroglancer.ViewerState): """Get all layers in right panel that are not the registration point annotation""" @@ -754,7 +770,7 @@ def estimate_affine(self, s: neuroglancer.ViewerState): for i in range(n_dims): affine[i][i] = 1 self.affine = affine - self.stored_points = ([], []) + self.stored_points = ([], [], False) return True return False @@ -766,13 +782,13 @@ def estimate_affine(self, s: neuroglancer.ViewerState): # Cached last points estimated with, if similar to current, don't estimate if len(self.stored_points[0]) == len(fixed_points) and len( self.stored_points[1] - ) == len(moving_points): + ) == len(moving_points) and self.stored_points[-1] == self._force_non_affine: if np.all(np.isclose(self.stored_points[0], fixed_points)) and np.all( np.isclose(self.stored_points[1], moving_points) ): return False - self.affine = estimate_transform(fixed_points, moving_points) - self.stored_points = [fixed_points, moving_points] + self.affine = estimate_transform(fixed_points, moving_points, self._force_non_affine) + self.stored_points = [fixed_points, moving_points, self._force_non_affine] return True @@ -797,7 +813,8 @@ def dump_current_state(self, _): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"neuroglancer_state_{timestamp}.json" - state_dict = self.get_state().to_json() + state = self.get_state() + state_dict = state.to_json() with open(filename, "w") as f: json.dump(state_dict, f, indent=4) @@ -805,7 +822,7 @@ def dump_current_state(self, _): registration_log_filename = f"registration_log_{timestamp}.json" with open(registration_log_filename, "w") as f: - json.dump(self.get_registration_info(), f, indent=4) + json.dump(self.get_registration_info(state), f, indent=4) print( f"Current state dumped to: {filename}, registration log saved to {registration_log_filename}" From f7c52e197f61514a250a1f9fb0253efb3113e70b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Jan 2026 15:47:20 +0100 Subject: [PATCH 43/65] feat: add local affine estimate --- .../examples/example_linear_registration.py | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 1015467bb..22d3edec5 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -40,9 +40,10 @@ import scipy.ndimage DEBUG = True # print debug info during execution -MESSAGE_DURATION = 5 # seconds +MESSAGE_DURATION = 4 # seconds NUM_DEMO_DIMS = 3 # Currently can be 2D or 3D AFFINE_NUM_DECIMALS = 6 +NUM_NEAREST_POINTS = 4 logging.basicConfig(level=logging.INFO, format="%(message)s") @@ -179,6 +180,7 @@ def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): # Format is x1, x2, ..., b1, b2, ... tvec, res, rank, sd = np.linalg.lstsq(Q, P) + # TODO check if rank works print(rank) if rank < D*(D+1): # planar/degenerate -> fall back @@ -321,6 +323,11 @@ class PipelineState(Enum): READY = 2 ERROR = 3 +class PointFilter(Enum): + """How to filter annotation points.""" + + NONE = 0 + NEAREST = 1 class LinearRegistrationWorkflow: def __init__(self, args): @@ -346,6 +353,7 @@ def __init__(self, args): self._current_moving_layer_idx = 0 self._cached_moving_layer_names = [] self._force_non_affine = False + self._annotation_filter_method = PointFilter.NONE if starting_ng_state is None: self._add_demo_data_to_viewer() @@ -523,7 +531,7 @@ def continue_workflow(self, _): s.layers[registered_name].visible = not is_registered_visible def _show_help_message(self): - in_prog_message = "Place registration points by moving the centre position of one panel and then putting an annotation with ctrl+left click in the other panel. Annotations can be adjusted if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'f' to force at most a similarity transform estimation. Press 'd' to dump current state for later resumption. Press 'y' to show or hide this help message." + in_prog_message = "Place registration points by moving the centre position of one panel and then putting an annotation with ctrl+left click in the other panel. Annotations can be adjusted if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'f' to toggle forcing at most a similarity transform estimation. Press 'g' to toggle between a local affine estimation and a global one. Press 'd' to dump current state for later resumption. Press 'y' to show or hide this help message." setup_message = "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup. Press 'y' to show/hide this message." error_message = f"There was an error in setup. Please try again. {setup_message}" waiting_message = "Please wait while setup is completed" @@ -549,6 +557,17 @@ def toggle_help_message(self, _): def toggle_force_non_affine(self, _): self._force_non_affine = not self._force_non_affine + message = "Estimating max of similarity transformation" if self._force_non_affine else "Estimating most appropriate transformation" + self._set_status_message("transform", message) + self.update_affine() + + def toggle_global_estimate(self, _): + if self._annotation_filter_method == PointFilter.NONE: + self._annotation_filter_method = PointFilter.NEAREST + self._set_status_message("global", f"Using nearest {NUM_NEAREST_POINTS} points in transform estimation") + elif self._annotation_filter_method == PointFilter.NEAREST: + self._annotation_filter_method = PointFilter.NONE + self._set_status_message("global", "Using all points in transform estimation") self.update_affine() def _setup_viewer_actions(self): @@ -565,11 +584,15 @@ def _setup_viewer_actions(self): force_name = "forceNonAffine" viewer.actions.add(force_name, self.toggle_force_non_affine) + global_name = "toggleGlobalEstimate" + viewer.actions.add(global_name, self.toggle_global_estimate) + with viewer.config_state.txn() as s: s.input_event_bindings.viewer["keyt"] = continue_name s.input_event_bindings.viewer["keyd"] = dump_name s.input_event_bindings.viewer["keyy"] = toggle_help_name s.input_event_bindings.viewer["keyf"] = force_name + s.input_event_bindings.viewer["keyg"] = global_name def get_moving_layer_names(self, s: neuroglancer.ViewerState): """Get all layers in right panel that are not the registration point annotation""" @@ -688,12 +711,12 @@ def get_fixed_and_moving_dims( fixed_dims.append(dim) return fixed_dims, moving_dims - def split_points_into_pairs(self, annotations, dim_names): + def split_points_into_pairs(self, annotations, dim_names, current_position = None): """In the simple case, each point contains fixed dim coords then moving dim coords - but in case the coords are interleaved more complicated, we pull out the - relevant info here.""" + but in case that is the other way around, we handle that here. + Right now we can't handle interleaved co-ordinate spaces.""" if len(annotations) == 0: - return np.zeros((0, 0)), np.zeros((0, 0)) + return np.zeros((0, 0)), np.zeros((0, 0)), None first_name = dim_names[0] fixed_dims, _ = self.get_fixed_and_moving_dims(None, dim_names) real_dims_last = first_name not in fixed_dims @@ -707,7 +730,11 @@ def split_points_into_pairs(self, annotations, dim_names): moving_index = j if real_dims_last else j + num_dims fixed_points[i, j] = a.point[fixed_index] moving_points[i, j] = a.point[moving_index] - return np.array(fixed_points), np.array(moving_points) + if current_position is not None: + dim_add = num_dims if real_dims_last else 0 + fixed_position_indices = [i + dim_add for i in range(num_dims)] + return np.array(fixed_points), np.array(moving_points), current_position[fixed_position_indices] + return np.array(fixed_points), np.array(moving_points), current_position def update_registered_layers(self, s: neuroglancer.ViewerState): """When the affine updates, update the relevant transform in all layers @@ -775,9 +802,10 @@ def estimate_affine(self, s: neuroglancer.ViewerState): return False dim_names = s.dimensions.names - fixed_points, moving_points = self.split_points_into_pairs( - annotations, dim_names + fixed_points, moving_points, current_position = self.split_points_into_pairs( + annotations, dim_names, s.position ) + fixed_points, moving_points = self._filter_annotations(fixed_points, moving_points, current_position) # Cached last points estimated with, if similar to current, don't estimate if len(self.stored_points[0]) == len(fixed_points) and len( @@ -792,12 +820,28 @@ def estimate_affine(self, s: neuroglancer.ViewerState): return True + def _filter_annotations(self, fixed_points: np.ndarray, moving_points: np.ndarray, position): + """To allow local estimations e.g. from the nearest points""" + if self._annotation_filter_method == PointFilter.NONE: + return fixed_points, moving_points + elif self._annotation_filter_method == PointFilter.NEAREST: + # if less than desired points, return them all + if len(fixed_points) <= NUM_NEAREST_POINTS: + return fixed_points, moving_points + # Find the X nearest fixed point indices + nearest_indices = [] + diff = fixed_points - np.asarray(position) + d2 = np.sum(diff * diff, axis=1) + nearest_indices = np.argpartition(d2, NUM_NEAREST_POINTS-1)[ :NUM_NEAREST_POINTS] + return fixed_points[nearest_indices], moving_points[nearest_indices] + return fixed_points, moving_points + def get_registration_info(self, state: neuroglancer.ViewerState): """Return dict of fixed points, moving points, affine, and transformed points.""" info = {} annotations = state.layers[self.annotations_name].annotations dim_names = state.dimensions.names - fixed_points, moving_points = self.split_points_into_pairs( + fixed_points, moving_points, _ = self.split_points_into_pairs( annotations, dim_names ) info["annotations"] = annotations From 515027ab5c6ab6ac666c3baaf6f4be2c2317ac5a Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Jan 2026 18:14:15 +0100 Subject: [PATCH 44/65] fix: correct for t/time --- .../examples/example_linear_registration.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 22d3edec5..6ec328483 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -45,6 +45,10 @@ AFFINE_NUM_DECIMALS = 6 NUM_NEAREST_POINTS = 4 +# We make a copy of all the physical dimensions, but to avoid +# expecting a copy of dimensions like t, or time, they are listed here +NON_PHYSICAL_DIM_NAMES = ["t", "time"] + logging.basicConfig(level=logging.INFO, format="%(message)s") @@ -374,10 +378,7 @@ def update(self): if self.ready_state == PipelineState.COORDS_READY: self.setup_registration_point_layer() elif self.ready_state == PipelineState.ERROR: - self._set_status_message( - "help", - "Please manually enter second coordinate space information for the moving layers.", - ) + return elif self.ready_state == PipelineState.READY: self.update_affine() self._clear_status_messages() @@ -427,6 +428,7 @@ def setup_registration_point_layer(self): if self.ready_state == PipelineState.ERROR or not self.has_two_coord_spaces( s ): + self._show_help_message() return # Also setup the new layer to clip differently on non display dims @@ -474,8 +476,9 @@ def _update_coord_space_info_cache(self, info_future): f"ERROR: Could not parse volume info for {self.moving_name}: {e} {info_future}" ) print( - "Please manually enter the coordinate space information as a second co-ordinate space." + "Try matching the global dimensions to the moving dimension units." ) + exit(-1) else: self.stored_map_moving_name_to_data_coords[self.moving_name] = ( result.dimensions @@ -496,6 +499,7 @@ def _create_second_coord_space(self): layer_name, None ) if output_dims is None: + print(f"ERROR: could not get output dims for a moving layer {layer_name}") self.ready_state = PipelineState.ERROR continue self.stored_map_moving_name_to_viewer_coords[layer_name] = [] @@ -534,7 +538,7 @@ def _show_help_message(self): in_prog_message = "Place registration points by moving the centre position of one panel and then putting an annotation with ctrl+left click in the other panel. Annotations can be adjusted if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'f' to toggle forcing at most a similarity transform estimation. Press 'g' to toggle between a local affine estimation and a global one. Press 'd' to dump current state for later resumption. Press 'y' to show or hide this help message." setup_message = "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup. Press 'y' to show/hide this message." error_message = f"There was an error in setup. Please try again. {setup_message}" - waiting_message = "Please wait while setup is completed" + waiting_message = "Please wait while setup is completed. In case it seems to be stuck, try pressing 't' again." help_message = "" if self.ready_state == PipelineState.READY: @@ -694,7 +698,7 @@ def update_affine(self): def get_fixed_and_moving_dims( self, s: Union[neuroglancer.ViewerState, None], dim_names: list | tuple = () ): - """Extract the fixed and moving dim names from the state""" + """Extract the fixed and moving dim names from the state or list of names""" if s is None: dimensions = dim_names else: @@ -705,6 +709,8 @@ def get_fixed_and_moving_dims( moving_dims = [] fixed_dims = [] for dim in dimensions: + if dim in NON_PHYSICAL_DIM_NAMES: + continue if dim[:-1] in dimensions: moving_dims.append(dim) else: From 939b81d91b3f7d9f9c9e1465ba09c8ac5c885f0e Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Jan 2026 18:31:29 +0100 Subject: [PATCH 45/65] fix: correct state dump --- .../examples/example_linear_registration.py | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 6ec328483..49ea766fd 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -20,6 +20,11 @@ 7. If an issue happens, the viewer state can go out of sync. To help with this, the python console will regularly print that viewer states are syncing with a timestamp. If you do not see this message for a while, consider continuing the workflow again from a saved state. 8. To continue from a saved state, dump the viewer state to a file using either the viewer UI or the dump_current_state method in the python console, and then pass this file via --json when starting the script again. You should also pass --continue-workflow (or -c) to skip the initial setup steps. If you renamed the annotation layer containing the registration points, you should also pass --annotations-name (or -a) with the new name. For example: python -i example_linear_registration.py --json saved_state.json -c -a registration_points + +Known issues: + 1. Channel dimensions that are store as c' get switched to c^ and then need to have + their shaders updated. + 2. If the layer info fails to be parsed from Python the workflow can't launch past the setup step. """ import argparse @@ -184,8 +189,6 @@ def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): # Format is x1, x2, ..., b1, b2, ... tvec, res, rank, sd = np.linalg.lstsq(Q, P) - # TODO check if rank works - print(rank) if rank < D*(D+1): # planar/degenerate -> fall back return rigid_or_similarity_fit(fixed_points, moving_points, rigid=False) @@ -478,6 +481,8 @@ def _update_coord_space_info_cache(self, info_future): print( "Try matching the global dimensions to the moving dimension units." ) + # TODO allow recovery from this failure by allowing the user + # to enter particular layer name co-ordinate spaces manually exit(-1) else: self.stored_map_moving_name_to_data_coords[self.moving_name] = ( @@ -673,7 +678,6 @@ def combine_local_channels_with_transform( final_transform.append(row) return final_transform - # TODO ensure this works ok with t def has_two_coord_spaces(self, s: neuroglancer.ViewerState): """Check if the two coord space setup is complete""" fixed_dims, moving_dims = self.get_fixed_and_moving_dims(s) @@ -694,7 +698,6 @@ def update_affine(self): if DEBUG: pprint(self.get_registration_info(s)) - # TODO ensure works with t def get_fixed_and_moving_dims( self, s: Union[neuroglancer.ViewerState, None], dim_names: list | tuple = () ): @@ -871,15 +874,22 @@ def dump_current_state(self, _): registration_log_filename = f"registration_log_{timestamp}.json" - with open(registration_log_filename, "w") as f: - json.dump(self.get_registration_info(state), f, indent=4) + reg_log_message = f", registration log saved to {registration_log_filename}" + try: + with open(registration_log_filename, "w") as f: + info = self.get_registration_info(state) + info.pop("annotations", None) + json.dump(info, f, indent=4) + except: + reg_log_message = "" + print("Error saving registration log") print( - f"Current state dumped to: {filename}, registration log saved to {registration_log_filename}" + f"Current state dumped to: {filename}{reg_log_message}" ) self._set_status_message( "dump", - f"State saved to {filename}, registration log saved to {registration_log_filename}", + f"State saved to {filename}{reg_log_message}", ) return filename From 265890f32c13e55a7842b275233acac2e98a3b62 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Jan 2026 18:59:32 +0100 Subject: [PATCH 46/65] fix: correct picking up from a saved point --- .../examples/example_linear_registration.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 49ea766fd..779538574 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -348,6 +348,7 @@ def __init__(self, args): self.stored_points = ([], [], False) self.stored_map_moving_name_to_data_coords = {} + # currently unused, keeping to parallel the above map self.stored_map_moving_name_to_viewer_coords = {} self.affine = None self.viewer = neuroglancer.Viewer() @@ -369,8 +370,15 @@ def __init__(self, args): self._setup_viewer_actions() self._show_help_message() - if self.ready_state != PipelineState.READY: + + if self.ready_state == PipelineState.NOT_READY: self.setup_initial_two_panel_layout() + elif args.continue_workflow: + self._cached_moving_layer_names = self.get_moving_layer_names(self.get_state()) + with open(args.reg_path, "r") as f: + info = json.load(f) + self.stored_map_moving_name_to_data_coords = {k: neuroglancer.CoordinateSpace(json=v) for k, v in info["layer_cache"].items()} + self.stored_map_moving_name_to_viewer_coords = {k: neuroglancer.CoordinateSpace(json=v) for k, v in info["viewer_layer_cache"].items()} def update(self): """Primary update loop, called whenever the viewer state changes.""" @@ -879,6 +887,8 @@ def dump_current_state(self, _): with open(registration_log_filename, "w") as f: info = self.get_registration_info(state) info.pop("annotations", None) + info["layer_cache"] = {k: v.to_json() for k, v in self.stored_map_moving_name_to_data_coords.items()} + info["viewer_layer_cache"] = {k: v.to_json() for k, v in self.stored_map_moving_name_to_viewer_coords.items()} json.dump(info, f, indent=4) except: reg_log_message = "" @@ -955,7 +965,7 @@ def add_mapping_args(ap: argparse.ArgumentParser): "--continue-workflow", "-c", action="store_true", - help="Indicates that we are continuing the workflow from a previously saved state. This will skip the inital setup steps and resume from the affine estimation step directly.", + help="Indicates that we are continuing the workflow from a previously saved state. This will skip the inital setup steps and resume from the affine estimation step directly. You must provide both a state (--url or --json) and the registration info dumped at the same time by this script (-r)", ) ap.add_argument( "--unlink-scales", @@ -977,6 +987,12 @@ def add_mapping_args(ap: argparse.ArgumentParser): action="store_true", help="If set, run the tests and exit.", ) + ap.add_argument( + "--reg-path", + "-r", + type=str, + help="Path to a JSON dump of registration info for continuing from. Required with the -c flag." + ) def handle_args(): @@ -1146,6 +1162,8 @@ def test_affine_fit_2d(self): if __name__ == "__main__": args = handle_args() + if args.continue_workflow and not args.reg_path: + raise ValueError("The continue flag requires a registration dump.") if args.test: import pytest From 4d20cbd6674589bf7d283703392636859c76f23c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Jan 2026 19:00:43 +0100 Subject: [PATCH 47/65] chore: update docs --- python/examples/example_linear_registration.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 779538574..d2dfdb272 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -22,8 +22,9 @@ python -i example_linear_registration.py --json saved_state.json -c -a registration_points Known issues: - 1. Channel dimensions that are store as c' get switched to c^ and then need to have - their shaders updated. + 1. Channel dimensions that are stored as c' get switched to c^ and then need to have + their shaders updated. Once the update is done though they will stay as c^ so this + is a one time setup. 2. If the layer info fails to be parsed from Python the workflow can't launch past the setup step. """ From 4dd9b90ed3e427c19abb5e2a3d78db65482377e2 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 16 Jan 2026 19:02:07 +0100 Subject: [PATCH 48/65] refactor: rename config state --- .../examples/example_linear_registration.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index d2dfdb272..88f8c0f91 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -568,8 +568,8 @@ def _show_help_message(self): def toggle_help_message(self, _): help_shown = "help" in self._status_timers if help_shown: - with self.viewer.config_state.txn() as s: - self._clear_status_message("help", s) + with self.viewer.config_state.txn() as cs: + self._clear_status_message("help", cs) else: self._show_help_message() @@ -605,12 +605,12 @@ def _setup_viewer_actions(self): global_name = "toggleGlobalEstimate" viewer.actions.add(global_name, self.toggle_global_estimate) - with viewer.config_state.txn() as s: - s.input_event_bindings.viewer["keyt"] = continue_name - s.input_event_bindings.viewer["keyd"] = dump_name - s.input_event_bindings.viewer["keyy"] = toggle_help_name - s.input_event_bindings.viewer["keyf"] = force_name - s.input_event_bindings.viewer["keyg"] = global_name + with viewer.config_state.txn() as cs: + cs.input_event_bindings.viewer["keyt"] = continue_name + cs.input_event_bindings.viewer["keyd"] = dump_name + cs.input_event_bindings.viewer["keyy"] = toggle_help_name + cs.input_event_bindings.viewer["keyf"] = force_name + cs.input_event_bindings.viewer["keyg"] = global_name def get_moving_layer_names(self, s: neuroglancer.ViewerState): """Get all layers in right panel that are not the registration point annotation""" @@ -919,17 +919,17 @@ def _clear_status_messages(self): continue if time() - v > MESSAGE_DURATION: to_pop.append(k) - with self.viewer.config_state.txn() as s: + with self.viewer.config_state.txn() as cs: for k in to_pop: - self._clear_status_message(k, s) + self._clear_status_message(k, cs) def _clear_status_message(self, key: str, config): config.status_messages.pop(key, None) return self._status_timers.pop(key, None) def _set_status_message(self, key: str, message: str): - with self.viewer.config_state.txn() as s: - s.status_messages[key] = message + with self.viewer.config_state.txn() as cs: + cs.status_messages[key] = message self._status_timers[key] = time() def _transform_points_with_affine(self, points: np.ndarray): From 800eea5b5cc1628ee2a25ff4bae2060703954034 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 22 Jan 2026 12:16:56 +0100 Subject: [PATCH 49/65] fix: correct dim weight and trackable watch --- src/annotation/renderlayer.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/annotation/renderlayer.ts b/src/annotation/renderlayer.ts index ab5da235a..23808afcc 100644 --- a/src/annotation/renderlayer.ts +++ b/src/annotation/renderlayer.ts @@ -440,12 +440,12 @@ function getAnnotationProjectionParameters( const dimWeights = clipDimensionsWeight.get(layerName); if (dimWeights !== undefined && dimWeights.size > 0) { for (const [dimName, weight] of dimWeights) { - // TODO not sure if can directly use layer names - // TODO ensure not in chunk display dims const dimIndex = layerDimensionNames.indexOf(dimName); - if (dimIndex !== -1 && dimIndex < unpaddedRank) { - // Set the multiplier to the specified weight for this dimension - // Weight of 0.0 = no clipping, 1.0 = full clipping + if ( + dimIndex !== -1 && + dimIndex < unpaddedRank && + !chunkDisplayDimensionIndices.includes(dimIndex) + ) { const newIndex = unpaddedRank + dimIndex; modelClipBounds[newIndex] = weight; } @@ -585,16 +585,6 @@ function AnnotationRenderLayer< } } - // Listen for changes to clipDimensionsWeight - if (trackableClipDimensionsWeight) { - attachment.registerDisposer( - trackableClipDimensionsWeight.changed.add(() => { - this.updateAttachmentState(attachment); - this.redrawNeeded.dispatch(); - }), - ); - } - // Get the layer name const layerName = this.base.state?.dataSource?.layer?.managedLayer?.name; From 3cf4e8618f880752846dc0fe377d3fdeba56e3a4 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 23 Jan 2026 14:14:13 +0100 Subject: [PATCH 50/65] refactor: better rep of clip dimensions --- .../examples/example_linear_registration.py | 8 +- python/neuroglancer/viewer_state.py | 8 +- src/annotation/annotation_layer_state.ts | 62 ++++++++++ src/annotation/renderlayer.ts | 115 ++++-------------- src/layer/annotation/index.ts | 9 ++ src/viewer.ts | 80 ------------ 6 files changed, 104 insertions(+), 178 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 88f8c0f91..85bc383ab 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -443,14 +443,13 @@ def setup_registration_point_layer(self): self._show_help_message() return - # Also setup the new layer to clip differently on non display dims - self._ignore_non_display_dims(s) - # Make the annotation layer if needed if s.layers.index(self.annotations_name) == -1: s.layers[self.annotations_name] = neuroglancer.LocalAnnotationLayer( dimensions=create_coord_space_matching_global_dims(s.dimensions) ) + # Also setup the new layer to clip differently on non display dims + self._ignore_non_display_dims(s) s.layers[self.annotations_name].tool = "annotatePoint" s.selected_layer.layer = self.annotations_name @@ -949,8 +948,7 @@ def _ignore_non_display_dims(self, state: neuroglancer.ViewerState): non-displayed dimensions""" dim_names = state.dimensions.names dim_map = {k: 0 for k in dim_names if k not in ["t", "time", "t1"]} - layer_map = {self.annotations_name: dim_map} - state.clip_dimensions_weight = layer_map + state.layers[self.annotations_name].clip_dimensions_weight = dim_map def add_mapping_args(ap: argparse.ArgumentParser): diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index bc9b33d53..a7f4c7e3d 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -1179,6 +1179,9 @@ def __init__(self, *args, **kwargs): ignore_null_segment_filter = ignoreNullSegmentFilter = wrapped_property( "ignoreNullSegmentFilter", optional(bool, True) ) + clip_dimensions_weight = clipDimensionsWeight = wrapped_property( + "clipDimensionsWeight", optional(typed_map(key_type=str, value_type=float)) + ) shader = wrapped_property("shader", str) shader_controls = shaderControls = wrapped_property( "shaderControls", ShaderControls @@ -1974,10 +1977,7 @@ class ViewerState(JsonObjectWrapper): tool_palettes = toolPalettes = wrapped_property( "toolPalettes", typed_map(key_type=str, value_type=ToolPalette) ) - clip_dimensions_weight = clipDimensionsWeight = wrapped_property( - "clipDimensionsWeight", - optional(typed_map(key_type=str, value_type=typed_map(key_type=str, value_type=float))) - ) + selection = wrapped_property("selection", DataSelectionState) @staticmethod diff --git a/src/annotation/annotation_layer_state.ts b/src/annotation/annotation_layer_state.ts index c9da60d6b..8ba594f8d 100644 --- a/src/annotation/annotation_layer_state.ts +++ b/src/annotation/annotation_layer_state.ts @@ -42,6 +42,13 @@ import { RefCounted } from "#src/util/disposable.js"; import type { ValueOrError } from "#src/util/error.js"; import { makeValueOrError, valueOrThrow } from "#src/util/error.js"; import { vec3 } from "#src/util/geom.js"; +import { + verifyFloat, + verifyObject, + verifyObjectAsMap, +} from "#src/util/json.js"; +import { NullarySignal } from "#src/util/signal.js"; +import type { Trackable } from "#src/util/trackable.js"; import { WatchableMap } from "#src/util/watchable_map.js"; import { makeTrackableFragmentMain, @@ -159,6 +166,7 @@ export class AnnotationDisplayState extends RefCounted { new WatchableAnnotationRelationshipStates(), ); ignoreNullSegmentFilter = new TrackableBoolean(true); + clipDimensionsWeight = new TrackableClipDimensionsWeight(); disablePicking = new WatchableValue(false); displayUnfiltered = makeCachedLazyDerivedWatchableValue( (map, ignoreNullSegmentFilter) => { @@ -242,3 +250,57 @@ export class AnnotationLayerState extends RefCounted { return dataSource.layer.dataSources.indexOf(dataSource); } } + +export class TrackableClipDimensionsWeight implements Trackable { + changed = new NullarySignal(); + + // Map of dimension name to weight + private value_ = new Map(); + + get value() { + return this.value_; + } + + reset() { + if (this.value_.size === 0) return; + this.value_.clear(); + this.changed.dispatch(); + } + + restoreState(obj: any) { + if (obj === undefined) { + this.reset(); + return; + } + verifyObject(obj); + const newValue = verifyObjectAsMap(obj, verifyFloat); + + // Check if the value has actually changed + let changed = this.value_.size !== newValue.size; + if (!changed) { + for (const [dimName, weight] of newValue) { + if (this.value_.get(dimName) !== weight) { + changed = true; + break; + } + } + } + + if (changed) { + this.value_.clear(); + for (const [dimName, weight] of newValue) { + this.value_.set(dimName, weight); + } + this.changed.dispatch(); + } + } + + toJSON() { + if (this.value_.size === 0) return undefined; + const obj: any = {}; + for (const [dimName, weight] of this.value_) { + obj[dimName] = weight; + } + return obj; + } +} diff --git a/src/annotation/renderlayer.ts b/src/annotation/renderlayer.ts index 23808afcc..9cdef1c6a 100644 --- a/src/annotation/renderlayer.ts +++ b/src/annotation/renderlayer.ts @@ -401,8 +401,6 @@ interface AttachmentState { chunkTransform: ValueOrError; displayDimensionRenderInfo: DisplayDimensionRenderInfo; chunkRenderParameters: AnnotationChunkRenderParameters | undefined; - // Map from layer name to map of dimension name to clip weight - clipDimensionsWeight: Map>; } type TransformedAnnotationSource = FrontendTransformedSource< @@ -416,8 +414,7 @@ interface SpatiallyIndexedValidAttachmentState extends AttachmentState { function getAnnotationProjectionParameters( chunkDisplayTransform: ChunkDisplayTransformParameters, - layerName: string | undefined, - clipDimensionsWeight: Map>, + clipDimensionsWeight: ReadonlyMap, ) { const { chunkTransform } = chunkDisplayTransform; const { unpaddedRank, layerDimensionNames } = chunkTransform.modelTransform; @@ -436,19 +433,16 @@ function getAnnotationProjectionParameters( } // Apply custom clip dimension weights to non display dims - if (layerName !== undefined) { - const dimWeights = clipDimensionsWeight.get(layerName); - if (dimWeights !== undefined && dimWeights.size > 0) { - for (const [dimName, weight] of dimWeights) { - const dimIndex = layerDimensionNames.indexOf(dimName); - if ( - dimIndex !== -1 && - dimIndex < unpaddedRank && - !chunkDisplayDimensionIndices.includes(dimIndex) - ) { - const newIndex = unpaddedRank + dimIndex; - modelClipBounds[newIndex] = weight; - } + if (clipDimensionsWeight.size > 0) { + for (const [dimName, weight] of clipDimensionsWeight) { + const dimIndex = layerDimensionNames.indexOf(dimName); + if ( + dimIndex !== -1 && + dimIndex < unpaddedRank && + !chunkDisplayDimensionIndices.includes(dimIndex) + ) { + const newIndex = unpaddedRank + dimIndex; + modelClipBounds[newIndex] = weight; } } } @@ -460,8 +454,7 @@ function getChunkRenderParameters( chunkTransform: ValueOrError, displayDimensionRenderInfo: DisplayDimensionRenderInfo, messages: MessageList, - layerName: string | undefined, - clipDimensionsWeight: Map>, + clipDimensionsWeight: ReadonlyMap, ): AnnotationChunkRenderParameters | undefined { messages.clearMessages(); const returnError = (message: string) => { @@ -487,7 +480,6 @@ function getChunkRenderParameters( const { modelClipBounds, renderSubspaceTransform } = getAnnotationProjectionParameters( chunkDisplayTransform, - layerName, clipDimensionsWeight, ); return { @@ -572,31 +564,17 @@ function AnnotationRenderLayer< const displayDimensionRenderInfo = attachment.view.displayDimensionRenderInfo.value; - // Get clip dimensions weight from the viewer - const clipDimensionsWeight = new Map>(); - const viewer = (attachment.view as any).viewer; - const trackableClipDimensionsWeight = viewer?.clipDimensionsWeight; - if (trackableClipDimensionsWeight?.value) { - for (const [ - layerName, - dimWeights, - ] of trackableClipDimensionsWeight.value) { - clipDimensionsWeight.set(layerName, new Map(dimWeights)); - } - } - - // Get the layer name - const layerName = this.base.state?.dataSource?.layer?.managedLayer?.name; + // Get clip dimensions weight from display state + const clipDimensionsWeight = + this.base.state.displayState.clipDimensionsWeight.value; attachment.state = { chunkTransform, displayDimensionRenderInfo, - clipDimensionsWeight, chunkRenderParameters: getChunkRenderParameters( chunkTransform, displayDimensionRenderInfo, attachment.messages, - layerName, clipDimensionsWeight, ), }; @@ -611,59 +589,24 @@ function AnnotationRenderLayer< const displayDimensionRenderInfo = attachment.view.displayDimensionRenderInfo.value; - // Get clip dimensions weight from the viewer - const clipDimensionsWeight = new Map>(); - const viewer = (attachment.view as any).viewer; - const trackableClipDimensionsWeight = viewer?.clipDimensionsWeight; - if (trackableClipDimensionsWeight?.value) { - for (const [ - layerName, - dimWeights, - ] of trackableClipDimensionsWeight.value) { - clipDimensionsWeight.set(layerName, new Map(dimWeights)); - } - } - - // Check if clipDimensionsWeight has changed - let clipWeightsChanged = - state.clipDimensionsWeight.size !== clipDimensionsWeight.size; - if (!clipWeightsChanged) { - for (const [layerName, dimWeights] of clipDimensionsWeight) { - const oldDimWeights = state.clipDimensionsWeight.get(layerName); - if (!oldDimWeights || oldDimWeights.size !== dimWeights.size) { - clipWeightsChanged = true; - break; - } - for (const [dimName, weight] of dimWeights) { - if (oldDimWeights.get(dimName) !== weight) { - clipWeightsChanged = true; - break; - } - } - if (clipWeightsChanged) break; - } - } - - // Get the layer name - const layerName = this.base.state?.dataSource?.layer?.managedLayer?.name; - if ( - state !== undefined && state.chunkTransform === chunkTransform && - state.displayDimensionRenderInfo === displayDimensionRenderInfo && - !clipWeightsChanged + state.displayDimensionRenderInfo === displayDimensionRenderInfo ) { return state.chunkRenderParameters; } + + // Get clip dimensions weight from display state + const clipDimensionsWeight = + this.base.state.displayState.clipDimensionsWeight.value; + state.chunkTransform = chunkTransform; state.displayDimensionRenderInfo = displayDimensionRenderInfo; - state.clipDimensionsWeight = clipDimensionsWeight; const chunkRenderParameters = (state.chunkRenderParameters = getChunkRenderParameters( chunkTransform, displayDimensionRenderInfo, attachment.messages, - layerName, clipDimensionsWeight, )); return chunkRenderParameters; @@ -1102,14 +1045,9 @@ const SpatiallyIndexedAnnotationLayer = < ) { super.attach(attachment); - // Get clip dimensions weight from the viewer - const viewer = (attachment.view as any).viewer; - const trackableClipDimensionsWeight = viewer?.clipDimensionsWeight || { - value: new Map(), - }; - - // Get the layer name - const layerName = this.base.state?.dataSource?.layer?.managedLayer?.name; + // Get clip dimensions weight from display state + const clipDimensionsWeight = + this.base.state.displayState.clipDimensionsWeight; attachment.state!.sources = attachment.registerDisposer( registerNested( @@ -1117,7 +1055,7 @@ const SpatiallyIndexedAnnotationLayer = < context: RefCounted, transform: RenderLayerTransformOrError, displayDimensionRenderInfo: DisplayDimensionRenderInfo, - clipDimensionsWeightValue: Map>, + clipDimensionsWeightValue: ReadonlyMap, ) => { const transformedSources = getVolumetricTransformedSources( displayDimensionRenderInfo, @@ -1136,7 +1074,6 @@ const SpatiallyIndexedAnnotationLayer = < tsource, getAnnotationProjectionParameters( tsource.chunkDisplayTransform, - layerName, clipDimensionsWeightValue, ), ); @@ -1157,7 +1094,7 @@ const SpatiallyIndexedAnnotationLayer = < }, this.base.state.transform, attachment.view.displayDimensionRenderInfo, - trackableClipDimensionsWeight, + clipDimensionsWeight, ), ); } diff --git a/src/layer/annotation/index.ts b/src/layer/annotation/index.ts index 600b61aac..03641dae7 100644 --- a/src/layer/annotation/index.ts +++ b/src/layer/annotation/index.ts @@ -153,6 +153,7 @@ interface LinkedSegmentationLayer { const LINKED_SEGMENTATION_LAYER_JSON_KEY = "linkedSegmentationLayer"; const FILTER_BY_SEGMENTATION_JSON_KEY = "filterBySegmentation"; const IGNORE_NULL_SEGMENT_FILTER_JSON_KEY = "ignoreNullSegmentFilter"; +const CLIP_DIMENSIONS_WEIGHT_JSON_KEY = "clipDimensionsWeight"; const CODE_VISIBLE_KEY = "codeVisible"; class LinkedSegmentationLayers extends RefCounted { @@ -430,6 +431,9 @@ export class AnnotationUserLayer extends Base { this.annotationDisplayState.ignoreNullSegmentFilter.changed.add( this.specificationChanged.dispatch, ); + this.annotationDisplayState.clipDimensionsWeight.changed.add( + this.specificationChanged.dispatch, + ); this.annotationCrossSectionRenderScaleTarget.changed.add( this.specificationChanged.dispatch, ); @@ -470,6 +474,9 @@ export class AnnotationUserLayer extends Base { this.annotationDisplayState.ignoreNullSegmentFilter.restoreState( specification[IGNORE_NULL_SEGMENT_FILTER_JSON_KEY], ); + this.annotationDisplayState.clipDimensionsWeight.restoreState( + specification[CLIP_DIMENSIONS_WEIGHT_JSON_KEY], + ); this.annotationDisplayState.shader.restoreState( specification[SHADER_JSON_KEY], ); @@ -728,6 +735,8 @@ export class AnnotationUserLayer extends Base { : localAnnotationRelationships; x[IGNORE_NULL_SEGMENT_FILTER_JSON_KEY] = this.annotationDisplayState.ignoreNullSegmentFilter.toJSON(); + x[CLIP_DIMENSIONS_WEIGHT_JSON_KEY] = + this.annotationDisplayState.clipDimensionsWeight.toJSON(); x[SHADER_JSON_KEY] = this.annotationDisplayState.shader.toJSON(); x[SHADER_CONTROLS_JSON_KEY] = this.annotationDisplayState.shaderControls.toJSON(); diff --git a/src/viewer.ts b/src/viewer.ts index 30a2cdd51..4923c217f 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -117,10 +117,8 @@ import { verifyFinitePositiveFloat, verifyNonnegativeInt, verifyObject, - verifyObjectAsMap, verifyOptionalObjectProperty, verifyString, - verifyFloat, } from "#src/util/json.js"; import { EventActionMap, @@ -128,7 +126,6 @@ import { } from "#src/util/keyboard_bindings.js"; import { ScreenshotManager } from "#src/util/screenshot_manager.js"; import { NullarySignal } from "#src/util/signal.js"; -import type { Trackable } from "#src/util/trackable.js"; import { CompoundTrackable, optionallyRestoreFromJsonMember, @@ -245,81 +242,6 @@ const defaultViewerOptions = resetStateWhenEmpty: true, }; -/** - * Trackable state for annotation clipping dimension weights per layer. - * Maps layer names to maps of dimension names to weight values (0.0 to 1.0). - * A weight of 0.0 means no clipping for that dimension, 1.0 means full clipping. - */ -export class TrackableClipDimensionsWeight implements Trackable { - changed = new NullarySignal(); - - // Map from layer name to map of dimension name to weight - private value_ = new Map>(); - - get value() { - return this.value_; - } - - reset() { - if (this.value_.size === 0) return; - this.value_.clear(); - this.changed.dispatch(); - } - - restoreState(obj: any) { - if (obj === undefined) { - this.reset(); - return; - } - verifyObject(obj); - const newValue = verifyObjectAsMap(obj, (layerObj) => - verifyObjectAsMap(layerObj, verifyFloat), - ); - - // Check if the value has actually changed - let changed = this.value_.size !== newValue.size; - if (!changed) { - for (const [layerName, dimWeights] of newValue) { - const oldDimWeights = this.value_.get(layerName); - if (!oldDimWeights || oldDimWeights.size !== dimWeights.size) { - changed = true; - break; - } - for (const [dimName, weight] of dimWeights) { - if (oldDimWeights.get(dimName) !== weight) { - changed = true; - break; - } - } - if (changed) break; - } - } - - if (changed) { - this.value_.clear(); - for (const [layerName, dimWeights] of newValue) { - this.value_.set(layerName, new Map(dimWeights)); - } - this.changed.dispatch(); - } - } - - toJSON() { - if (this.value_.size === 0) return undefined; - const obj: any = {}; - for (const [layerName, dimWeights] of this.value_) { - if (dimWeights.size > 0) { - const layerObj: any = {}; - for (const [dimName, weight] of dimWeights) { - layerObj[dimName] = weight; - } - obj[layerName] = layerObj; - } - } - return Object.keys(obj).length > 0 ? obj : undefined; - } -} - class TrackableViewerState extends CompoundTrackable { constructor(public viewer: Borrowed) { super(); @@ -341,7 +263,6 @@ class TrackableViewerState extends CompoundTrackable { this.add("enableAdaptiveDownsampling", viewer.enableAdaptiveDownsampling); this.add("showScaleBar", viewer.showScaleBar); this.add("showDefaultAnnotations", viewer.showDefaultAnnotations); - this.add("clipDimensionsWeight", viewer.clipDimensionsWeight); this.add("showSlices", viewer.showPerspectiveSliceViews); this.add( @@ -525,7 +446,6 @@ export class Viewer extends RefCounted implements ViewerState { perspectiveViewBackgroundColor = new TrackableRGB(vec3.fromValues(0, 0, 0)); scaleBarOptions = new TrackableScaleBarOptions(); partialViewport = new TrackableWindowedViewport(); - clipDimensionsWeight = new TrackableClipDimensionsWeight(); statisticsDisplayState = new StatisticsDisplayState(); helpPanelState = new HelpPanelState(); settingsPanelState = new ViewerSettingsPanelState(); From 3bc4ea7b74c9544e640288ac24886f427f805cc5 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 29 Jan 2026 12:02:32 +0100 Subject: [PATCH 51/65] fix, refactor: correct small pieces of the lin reg and clean up --- .../examples/example_linear_registration.py | 255 +++++++++++------- 1 file changed, 160 insertions(+), 95 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 85bc383ab..d14cd04a5 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -5,7 +5,7 @@ General workflow: 1. Start from a neuroglancer viewer with all the reference data and the data to register as layers. If the script is provided no data, it will create demo data for you to try. 2. Pass this state to the script by either providing a url via --url or dumping the JSON state to a file and passing the file via --json. For example: - python -i example_linear_registration.py --url 'https://neuroglancer.demo.appspot/com/...' + python -i example_linear_registration.py --url 'https://neuroglancer.demo.appspot.com/...' 3. The default assumption is that the last layer in the viewer from step 2 is the moving data to be registered, and all other layers are fixed (reference) data. There must be at least two layers. The script will launch with two layer groups side by side, left is fixed, right is moving. You can move layers between the groups such that all fixed layers are in the first group (left panel) and all moving layers are in the second group (right panel). Once you have done this, press 't' to continue. 4. At this point, the viewer will: a. Create a copy of each dimension in with a "2" suffix for the moving layers. E.g. x -> x2, y -> y2, z -> z2. This allows the moving layers to have a different coordinate space. @@ -45,20 +45,23 @@ import numpy as np import scipy.ndimage -DEBUG = True # print debug info during execution -MESSAGE_DURATION = 4 # seconds -NUM_DEMO_DIMS = 3 # Currently can be 2D or 3D -AFFINE_NUM_DECIMALS = 6 -NUM_NEAREST_POINTS = 4 +DEBUG = True # Print debug info during execution +MESSAGE_DURATION = 4 # How long to show help messages in seconds +NUM_DEMO_DIMS = 3 # Only used if no data give, can be 2D or 3D +NUM_NEAREST_POINTS = 4 # Number of nearest points to use in local estimation +AFFINE_NUM_DECIMALS = 6 # Number of decimals to round affine matrix to # We make a copy of all the physical dimensions, but to avoid # expecting a copy of dimensions like t, or time, they are listed here +# channel dimensions are already handled separately and don't need to be listed here NON_PHYSICAL_DIM_NAMES = ["t", "time"] logging.basicConfig(level=logging.INFO, format="%(message)s") -def estimate_transform(fixed_points: np.ndarray, moving_points: np.ndarray, force_non_affine=False): +def estimate_transform( + fixed_points: np.ndarray, moving_points: np.ndarray, force_non_affine=False +): """ Choose the appropriate model based on number of points and dimensions. @@ -109,9 +112,6 @@ def translation_fit(fixed_points: np.ndarray, moving_points: np.ndarray): return affine -# See https://en.wikipedia.org/wiki/Orthogonal_Procrustes_problem -# and https://math.nist.gov/~JBernal/kujustf.pdf -# Follows the Kabsch algorithm https://en.wikipedia.org/wiki/Kabsch_algorithm def rigid_or_similarity_fit( fixed_points: np.ndarray, moving_points: np.ndarray, rigid=True ): @@ -187,10 +187,9 @@ def affine_fit(fixed_points: np.ndarray, moving_points: np.ndarray): # The estimated affine transform params will be flattened # and there will be D * (D + 1) of them - # Format is x1, x2, ..., b1, b2, ... tvec, res, rank, sd = np.linalg.lstsq(Q, P) - if rank < D*(D+1): + if rank < D * (D + 1): # planar/degenerate -> fall back return rigid_or_similarity_fit(fixed_points, moving_points, rigid=False) @@ -331,21 +330,25 @@ class PipelineState(Enum): READY = 2 ERROR = 3 + class PointFilter(Enum): """How to filter annotation points.""" NONE = 0 NEAREST = 1 + class LinearRegistrationWorkflow: - def __init__(self, args): - starting_ng_state = args.state - self.annotations_name = args.annotations_name - self.ready_state = ( - PipelineState.READY if args.continue_workflow else PipelineState.NOT_READY + def __init__(self, parsed_args): + starting_ng_state = parsed_args.state + self.annotations_name = parsed_args.annotations_name + self.pipeline_state = ( + PipelineState.READY + if parsed_args.continue_workflow + else PipelineState.NOT_READY ) - self.unlink_scales = args.unlink_scales - self.output_name = args.output_name + self.unlink_scales = parsed_args.unlink_scales + self.output_name = parsed_args.output_name self.stored_points = ([], [], False) self.stored_map_moving_name_to_data_coords = {} @@ -364,22 +367,26 @@ def __init__(self, args): self._force_non_affine = False self._annotation_filter_method = PointFilter.NONE + linear_reg_pipeline_info = None if starting_ng_state is None: self._add_demo_data_to_viewer() else: + linear_reg_pipeline_info = starting_ng_state.get( + "linear_reg_pipeline_info", None + ) self.viewer.set_state(starting_ng_state) self._setup_viewer_actions() self._show_help_message() - if self.ready_state == PipelineState.NOT_READY: + if self.pipeline_state == PipelineState.NOT_READY: self.setup_initial_two_panel_layout() - elif args.continue_workflow: - self._cached_moving_layer_names = self.get_moving_layer_names(self.get_state()) - with open(args.reg_path, "r") as f: - info = json.load(f) - self.stored_map_moving_name_to_data_coords = {k: neuroglancer.CoordinateSpace(json=v) for k, v in info["layer_cache"].items()} - self.stored_map_moving_name_to_viewer_coords = {k: neuroglancer.CoordinateSpace(json=v) for k, v in info["viewer_layer_cache"].items()} + elif parsed_args.continue_workflow: + if linear_reg_pipeline_info is None: + raise ValueError( + "To continue workflow from saved state, the state must contain linear_reg_pipeline_info" + ) + self._restore_coord_maps(linear_reg_pipeline_info) def update(self): """Primary update loop, called whenever the viewer state changes.""" @@ -387,11 +394,11 @@ def update(self): if current_time - self._last_updated_print_time > 5: print(f"Viewer states are successfully syncing at {ctime()}") self._last_updated_print_time = current_time - if self.ready_state == PipelineState.COORDS_READY: + if self.pipeline_state == PipelineState.COORDS_READY: self.setup_registration_point_layer() - elif self.ready_state == PipelineState.ERROR: + elif self.pipeline_state == PipelineState.ERROR: return - elif self.ready_state == PipelineState.READY: + elif self.pipeline_state == PipelineState.READY: self.update_affine() self._clear_status_messages() @@ -437,8 +444,9 @@ def setup_second_coord_space(self): def setup_registration_point_layer(self): """Establish information to store affine transform updates and place registration points.""" with self.viewer.txn() as s: - if self.ready_state == PipelineState.ERROR or not self.has_two_coord_spaces( - s + if ( + self.pipeline_state == PipelineState.ERROR + or not self.has_two_coord_spaces(s) ): self._show_help_message() return @@ -457,7 +465,7 @@ def setup_registration_point_layer(self): s.layout.children[0].layers.append(self.annotations_name) s.layout.children[1].layers.append(self.annotations_name) self.setup_panel_display_dims(s) - self.ready_state = PipelineState.READY + self.pipeline_state = PipelineState.READY self._show_help_message() def setup_panel_display_dims(self, s: neuroglancer.ViewerState): @@ -486,9 +494,7 @@ def _update_coord_space_info_cache(self, info_future): print( f"ERROR: Could not parse volume info for {self.moving_name}: {e} {info_future}" ) - print( - "Try matching the global dimensions to the moving dimension units." - ) + print("Try matching the global dimensions to the moving dimension units.") # TODO allow recovery from this failure by allowing the user # to enter particular layer name co-ordinate spaces manually exit(-1) @@ -505,15 +511,17 @@ def _update_coord_space_info_cache(self, info_future): return self._create_second_coord_space() def _create_second_coord_space(self): - self.ready_state = PipelineState.COORDS_READY + self.pipeline_state = PipelineState.COORDS_READY with self.viewer.txn() as s: for layer_name in self._cached_moving_layer_names: output_dims = self.stored_map_moving_name_to_data_coords.get( layer_name, None ) if output_dims is None: - print(f"ERROR: could not get output dims for a moving layer {layer_name}") - self.ready_state = PipelineState.ERROR + print( + f"ERROR: could not get output dims for a moving layer {layer_name}" + ) + self.pipeline_state = PipelineState.ERROR continue self.stored_map_moving_name_to_viewer_coords[layer_name] = [] for source in s.layers[layer_name].source: @@ -530,37 +538,40 @@ def _create_second_coord_space(self): new_coord_space ) source.transform = new_coord_space - return self.ready_state + return self.pipeline_state def continue_workflow(self, _): """When the user presses to continue, respond according to the state.""" - if self.ready_state == PipelineState.NOT_READY: + if self.pipeline_state == PipelineState.NOT_READY: self.setup_viewer_after_user_ready() return - elif self.ready_state == PipelineState.COORDS_READY: - return - elif self.ready_state == PipelineState.ERROR: + elif self.pipeline_state == PipelineState.ERROR: self.setup_registration_point_layer() - with self.viewer.txn() as s: - for layer_name in self.get_moving_layer_names(s): - registered_name = layer_name + "_registered" - is_registered_visible = s.layers[registered_name].visible - s.layers[registered_name].visible = not is_registered_visible + elif self.pipeline_state == PipelineState.COORDS_READY: + return + elif self.pipeline_state == PipelineState.READY: + with self.viewer.txn() as s: + for layer_name in self.get_moving_layer_names(s): + registered_name = layer_name + "_registered" + is_registered_visible = s.layers[registered_name].visible + s.layers[registered_name].visible = not is_registered_visible def _show_help_message(self): in_prog_message = "Place registration points by moving the centre position of one panel and then putting an annotation with ctrl+left click in the other panel. Annotations can be adjusted if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'f' to toggle forcing at most a similarity transform estimation. Press 'g' to toggle between a local affine estimation and a global one. Press 'd' to dump current state for later resumption. Press 'y' to show or hide this help message." setup_message = "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup. Press 'y' to show/hide this message." - error_message = f"There was an error in setup. Please try again. {setup_message}" + error_message = ( + f"There was an error in setup. Please try again. {setup_message}" + ) waiting_message = "Please wait while setup is completed. In case it seems to be stuck, try pressing 't' again." help_message = "" - if self.ready_state == PipelineState.READY: + if self.pipeline_state == PipelineState.READY: help_message = in_prog_message - elif self.ready_state == PipelineState.NOT_READY: + elif self.pipeline_state == PipelineState.NOT_READY: help_message = setup_message - elif self.ready_state == PipelineState.ERROR: + elif self.pipeline_state == PipelineState.ERROR: help_message = error_message - elif self.ready_state == PipelineState.COORDS_READY: + elif self.pipeline_state == PipelineState.COORDS_READY: help_message = waiting_message self._set_status_message("help", help_message) @@ -574,17 +585,26 @@ def toggle_help_message(self, _): def toggle_force_non_affine(self, _): self._force_non_affine = not self._force_non_affine - message = "Estimating max of similarity transformation" if self._force_non_affine else "Estimating most appropriate transformation" + message = ( + "Estimating max of similarity transformation" + if self._force_non_affine + else "Estimating most appropriate transformation" + ) self._set_status_message("transform", message) self.update_affine() def toggle_global_estimate(self, _): if self._annotation_filter_method == PointFilter.NONE: self._annotation_filter_method = PointFilter.NEAREST - self._set_status_message("global", f"Using nearest {NUM_NEAREST_POINTS} points in transform estimation") + self._set_status_message( + "global", + f"Using nearest {NUM_NEAREST_POINTS} points in transform estimation", + ) elif self._annotation_filter_method == PointFilter.NEAREST: self._annotation_filter_method = PointFilter.NONE - self._set_status_message("global", "Using all points in transform estimation") + self._set_status_message( + "global", "Using all points in transform estimation" + ) self.update_affine() def _setup_viewer_actions(self): @@ -629,6 +649,44 @@ def _copy_moving_layers_to_left_panel(self): s.layers[copy.name] = copy s.layout.children[0].layers.append(copy.name) + def _restore_coord_maps(self, reg_info): + """Restore the coord space transforms from the stored maps. + + This is used when continuing from a saved state. + """ + self._cached_moving_layer_names = self.get_moving_layer_names(self.get_state()) + self.stored_map_moving_name_to_data_coords = { + k: neuroglancer.CoordinateSpace(json=v) + for k, v in reg_info["layer_cache"].items() + } + self.stored_map_moving_name_to_viewer_coords = { + k: [neuroglancer.CoordinateSpaceTransform(json_data=t) for t in v] + for k, v in reg_info["viewer_layer_cache"].items() + } + + def _handle_layer_names_changed(self, s: neuroglancer.ViewerState): + current_names = set(s.layers.keys()) + cached_names = set(self.stored_map_moving_name_to_data_coords.keys()) + if current_names == cached_names: + return + # The common case is that a layer was renamed + if len(current_names) == len(cached_names): + for old_name in cached_names: + if old_name not in current_names: + new_name = list(current_names - cached_names)[0] + self.stored_map_moving_name_to_data_coords[new_name] = ( + self.stored_map_moving_name_to_data_coords.pop(old_name) + ) + self.stored_map_moving_name_to_viewer_coords[new_name] = ( + self.stored_map_moving_name_to_viewer_coords.pop(old_name) + ) + break + else: + self._set_status_message( + "error", + "Layers have been added or removed, this may cause unexpected behaviour.", + ) + def combine_affine_across_dims(self, s: neuroglancer.ViewerState, affine): """ The affine matrix only applies to the moving dims @@ -695,6 +753,8 @@ def has_two_coord_spaces(self, s: neuroglancer.ViewerState): def update_affine(self): """Estimate affine, with debouncing in case of rapid state updates""" with self.viewer.txn() as s: + # Need to check if layer names changed first + self._handle_layer_names_changed(s) updated = self.estimate_affine(s) if updated: num_point_pairs = len(self.stored_points[0]) @@ -728,7 +788,7 @@ def get_fixed_and_moving_dims( fixed_dims.append(dim) return fixed_dims, moving_dims - def split_points_into_pairs(self, annotations, dim_names, current_position = None): + def split_points_into_pairs(self, annotations, dim_names, current_position=None): """In the simple case, each point contains fixed dim coords then moving dim coords but in case that is the other way around, we handle that here. Right now we can't handle interleaved co-ordinate spaces.""" @@ -750,7 +810,11 @@ def split_points_into_pairs(self, annotations, dim_names, current_position = Non if current_position is not None: dim_add = num_dims if real_dims_last else 0 fixed_position_indices = [i + dim_add for i in range(num_dims)] - return np.array(fixed_points), np.array(moving_points), current_position[fixed_position_indices] + return ( + np.array(fixed_points), + np.array(moving_points), + current_position[fixed_position_indices], + ) return np.array(fixed_points), np.array(moving_points), current_position def update_registered_layers(self, s: neuroglancer.ViewerState): @@ -822,22 +886,30 @@ def estimate_affine(self, s: neuroglancer.ViewerState): fixed_points, moving_points, current_position = self.split_points_into_pairs( annotations, dim_names, s.position ) - fixed_points, moving_points = self._filter_annotations(fixed_points, moving_points, current_position) + fixed_points, moving_points = self._filter_annotations( + fixed_points, moving_points, current_position + ) # Cached last points estimated with, if similar to current, don't estimate - if len(self.stored_points[0]) == len(fixed_points) and len( - self.stored_points[1] - ) == len(moving_points) and self.stored_points[-1] == self._force_non_affine: + if ( + len(self.stored_points[0]) == len(fixed_points) + and len(self.stored_points[1]) == len(moving_points) + and self.stored_points[-1] == self._force_non_affine + ): if np.all(np.isclose(self.stored_points[0], fixed_points)) and np.all( np.isclose(self.stored_points[1], moving_points) ): return False - self.affine = estimate_transform(fixed_points, moving_points, self._force_non_affine) + self.affine = estimate_transform( + fixed_points, moving_points, self._force_non_affine + ) self.stored_points = [fixed_points, moving_points, self._force_non_affine] return True - def _filter_annotations(self, fixed_points: np.ndarray, moving_points: np.ndarray, position): + def _filter_annotations( + self, fixed_points: np.ndarray, moving_points: np.ndarray, position + ): """To allow local estimations e.g. from the nearest points""" if self._annotation_filter_method == PointFilter.NONE: return fixed_points, moving_points @@ -849,7 +921,9 @@ def _filter_annotations(self, fixed_points: np.ndarray, moving_points: np.ndarra nearest_indices = [] diff = fixed_points - np.asarray(position) d2 = np.sum(diff * diff, axis=1) - nearest_indices = np.argpartition(d2, NUM_NEAREST_POINTS-1)[ :NUM_NEAREST_POINTS] + nearest_indices = np.argpartition(d2, NUM_NEAREST_POINTS - 1)[ + :NUM_NEAREST_POINTS + ] return fixed_points[nearest_indices], moving_points[nearest_indices] return fixed_points, moving_points @@ -877,31 +951,28 @@ def dump_current_state(self, _): state = self.get_state() state_dict = state.to_json() - with open(filename, "w") as f: - json.dump(state_dict, f, indent=4) - - registration_log_filename = f"registration_log_{timestamp}.json" - - reg_log_message = f", registration log saved to {registration_log_filename}" try: - with open(registration_log_filename, "w") as f: - info = self.get_registration_info(state) - info.pop("annotations", None) - info["layer_cache"] = {k: v.to_json() for k, v in self.stored_map_moving_name_to_data_coords.items()} - info["viewer_layer_cache"] = {k: v.to_json() for k, v in self.stored_map_moving_name_to_viewer_coords.items()} - json.dump(info, f, indent=4) - except: - reg_log_message = "" + info = self.get_registration_info(state) + info.pop("annotations", None) # annotations are already in the state dump + info["layer_cache"] = { + k: v.to_json() + for k, v in self.stored_map_moving_name_to_data_coords.items() + } + info["viewer_layer_cache"] = { + k: [t.to_json() for t in v] + for k, v in self.stored_map_moving_name_to_viewer_coords.items() + } + info["timestamp"] = timestamp + state_dict["linear_reg_pipeline_info"] = info + except Exception: print("Error saving registration log") - print( - f"Current state dumped to: {filename}{reg_log_message}" - ) + with open(filename, "w") as f: + json.dump(state_dict, f, indent=4) + self._set_status_message( - "dump", - f"State saved to {filename}{reg_log_message}", + "dump", f"State saved to {filename} and can be used to continue later." ) - return filename def get_state(self): @@ -914,7 +985,7 @@ def __str__(self): def _clear_status_messages(self): to_pop = [] for k, v in self._status_timers.items(): - if k == "help": # "help" is manually cleared + if k == "help": # "help" is manually cleared continue if time() - v > MESSAGE_DURATION: to_pop.append(k) @@ -986,12 +1057,6 @@ def add_mapping_args(ap: argparse.ArgumentParser): action="store_true", help="If set, run the tests and exit.", ) - ap.add_argument( - "--reg-path", - "-r", - type=str, - help="Path to a JSON dump of registration info for continuing from. Required with the -c flag." - ) def handle_args(): @@ -1053,7 +1118,7 @@ def test_rigid_fit_3d(self): def test_2d_transform_fit(self): # Based on the idea of mapping the big and little dipper together # In reality any points would do here, but having a kind of known layout - # helps + # helps visuallize the result if needed little = np.array( [ [0.0, 0.0], @@ -1161,8 +1226,8 @@ def test_affine_fit_2d(self): if __name__ == "__main__": args = handle_args() - if args.continue_workflow and not args.reg_path: - raise ValueError("The continue flag requires a registration dump.") + if args.continue_workflow and not args.json: + raise ValueError("The continue flag requires a registration state dump.") if args.test: import pytest From 4e2e523785c36818da1d2e8002e4a650e1ba3026 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 29 Jan 2026 12:15:40 +0100 Subject: [PATCH 52/65] fix: correct example tests --- python/examples/example_linear_registration.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index d14cd04a5..6819ee90f 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -1159,15 +1159,28 @@ def test_2d_transform_fit(self): # ax.legend() # fig.savefig("dipper.png", dpi=200) + # In this case there is a little bit of shear in the fit + # so a simiarity transform won't be perfect, but should be close transformed_points = transform_points(affine, big) assert np.allclose(transformed_points, little, atol=0.3) - # While the transform is really a similarity transform, - # we can also try an affine fit here + # The affine fit should be very accurate affine2 = affine_fit(little, big) transformed_points2 = transform_points(affine2, big) assert np.allclose(transformed_points2, little, atol=1e-2) + # If we change R to have determinant 1, the similarity fit should be very accurate too + R_det1 = np.array( + [ + [0.866, -0.500], + [0.500, 0.866], + ] + ) + big2 = (little @ R_det1.T) * s + t + affine3 = rigid_or_similarity_fit(little, big2, rigid=False) + transformed_points3 = transform_points(affine3, big2) + assert np.allclose(transformed_points3, little, atol=1e-2) + def test_3d_transform_fit(self): little = np.array( [ @@ -1183,6 +1196,7 @@ def test_3d_transform_fit(self): ) s = 1.7 + # Determinant is close to 1 R = np.array( [ [0.866, -0.500, 0.000], From 6c11758f7fefacb58f162ef45f31f6fc073cafcb Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 29 Jan 2026 12:34:49 +0100 Subject: [PATCH 53/65] fix: remove old viewer clip dims --- src/data_panel_layout.ts | 2 -- src/layer_group_viewer.ts | 5 ----- src/layer_groups_layout.ts | 1 - 3 files changed, 8 deletions(-) diff --git a/src/data_panel_layout.ts b/src/data_panel_layout.ts index 1d90cb9cb..441cb1d94 100644 --- a/src/data_panel_layout.ts +++ b/src/data_panel_layout.ts @@ -66,7 +66,6 @@ import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; import { optionallyRestoreFromJsonMember } from "#src/util/trackable.js"; import { WatchableMap } from "#src/util/watchable_map.js"; -import type { TrackableClipDimensionsWeight } from "#src/viewer.js"; import type { VisibilityPrioritySpecification } from "#src/viewer_state.js"; import { DisplayDimensionsWidget } from "#src/widget/display_dimensions_widget.js"; import type { ScaleBarOptions } from "#src/widget/scale_bar.js"; @@ -102,7 +101,6 @@ export interface ViewerUIState crossSectionBackgroundColor: TrackableRGB; perspectiveViewBackgroundColor: TrackableRGB; hideCrossSectionBackground3D: TrackableBoolean; - clipDimensionsWeight: TrackableClipDimensionsWeight; pickRadius: TrackableValue; } diff --git a/src/layer_group_viewer.ts b/src/layer_group_viewer.ts index 2155c7829..f80819f48 100644 --- a/src/layer_group_viewer.ts +++ b/src/layer_group_viewer.ts @@ -78,7 +78,6 @@ import { CompoundTrackable, optionallyRestoreFromJsonMember, } from "#src/util/trackable.js"; -import type { TrackableClipDimensionsWeight } from "#src/viewer.js"; import type { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; import { EnumSelectWidget } from "#src/widget/enum_widget.js"; import type { TrackableScaleBarOptions } from "#src/widget/scale_bar.js"; @@ -105,7 +104,6 @@ export interface LayerGroupViewerState { crossSectionBackgroundColor: TrackableRGB; perspectiveViewBackgroundColor: TrackableRGB; hideCrossSectionBackground3D: TrackableBoolean; - clipDimensionsWeight: TrackableClipDimensionsWeight; pickRadius: TrackableValue; } @@ -392,9 +390,6 @@ export class LayerGroupViewer extends RefCounted { get scaleBarOptions() { return this.viewerState.scaleBarOptions; } - get clipDimensionsWeight() { - return this.viewerState.clipDimensionsWeight; - } layerPanel: LayerBar | undefined; layout: DataPanelLayoutContainer; toolBinder: LocalToolBinder; diff --git a/src/layer_groups_layout.ts b/src/layer_groups_layout.ts index 490631465..bf06b2b31 100644 --- a/src/layer_groups_layout.ts +++ b/src/layer_groups_layout.ts @@ -421,7 +421,6 @@ function getCommonViewerState(viewer: Viewer) { crossSectionBackgroundColor: viewer.crossSectionBackgroundColor, perspectiveViewBackgroundColor: viewer.perspectiveViewBackgroundColor, hideCrossSectionBackground3D: viewer.hideCrossSectionBackground3D, - clipDimensionsWeight: viewer.clipDimensionsWeight, pickRadius: viewer.uiConfiguration.pickRadius, }; } From dc0afeecf3a84d575c939877e466cca6dad013e7 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 29 Jan 2026 12:35:05 +0100 Subject: [PATCH 54/65] refactor: clarify comments and code --- python/neuroglancer/viewer_state.py | 1 - src/annotation/annotation_layer_state.ts | 32 +++++++++++------------- src/annotation/renderlayer.ts | 13 ++-------- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index a7f4c7e3d..b7b56000d 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -1977,7 +1977,6 @@ class ViewerState(JsonObjectWrapper): tool_palettes = toolPalettes = wrapped_property( "toolPalettes", typed_map(key_type=str, value_type=ToolPalette) ) - selection = wrapped_property("selection", DataSelectionState) @staticmethod diff --git a/src/annotation/annotation_layer_state.ts b/src/annotation/annotation_layer_state.ts index 8ba594f8d..6e19c1f3e 100644 --- a/src/annotation/annotation_layer_state.ts +++ b/src/annotation/annotation_layer_state.ts @@ -253,29 +253,13 @@ export class AnnotationLayerState extends RefCounted { export class TrackableClipDimensionsWeight implements Trackable { changed = new NullarySignal(); - - // Map of dimension name to weight private value_ = new Map(); get value() { return this.value_; } - reset() { - if (this.value_.size === 0) return; - this.value_.clear(); - this.changed.dispatch(); - } - - restoreState(obj: any) { - if (obj === undefined) { - this.reset(); - return; - } - verifyObject(obj); - const newValue = verifyObjectAsMap(obj, verifyFloat); - - // Check if the value has actually changed + set value(newValue: Map) { let changed = this.value_.size !== newValue.size; if (!changed) { for (const [dimName, weight] of newValue) { @@ -295,6 +279,20 @@ export class TrackableClipDimensionsWeight implements Trackable { } } + reset() { + if (this.value_.size === 0) return; + this.value_.clear(); + this.changed.dispatch(); + } + + restoreState(obj: any) { + if (obj === undefined) { + this.reset(); + return; + } + this.value = verifyObjectAsMap(obj, verifyFloat); + } + toJSON() { if (this.value_.size === 0) return undefined; const obj: any = {}; diff --git a/src/annotation/renderlayer.ts b/src/annotation/renderlayer.ts index 9cdef1c6a..4b6b465c9 100644 --- a/src/annotation/renderlayer.ts +++ b/src/annotation/renderlayer.ts @@ -425,7 +425,7 @@ function getAnnotationProjectionParameters( const { numChunkDisplayDims, chunkDisplayDimensionIndices } = chunkDisplayTransform; - // Set display dimensions to not clip (multiplier = 0) + // Set display dimensions to not clip by default (multiplier = 0) for (let i = 0; i < numChunkDisplayDims; ++i) { const chunkDim = chunkDisplayDimensionIndices[i]; modelClipBounds[unpaddedRank + chunkDim] = 0; @@ -563,8 +563,6 @@ function AnnotationRenderLayer< const { chunkTransform } = this; const displayDimensionRenderInfo = attachment.view.displayDimensionRenderInfo.value; - - // Get clip dimensions weight from display state const clipDimensionsWeight = this.base.state.displayState.clipDimensionsWeight.value; @@ -596,10 +594,8 @@ function AnnotationRenderLayer< return state.chunkRenderParameters; } - // Get clip dimensions weight from display state const clipDimensionsWeight = this.base.state.displayState.clipDimensionsWeight.value; - state.chunkTransform = chunkTransform; state.displayDimensionRenderInfo = displayDimensionRenderInfo; const chunkRenderParameters = (state.chunkRenderParameters = @@ -1044,11 +1040,6 @@ const SpatiallyIndexedAnnotationLayer = < >, ) { super.attach(attachment); - - // Get clip dimensions weight from display state - const clipDimensionsWeight = - this.base.state.displayState.clipDimensionsWeight; - attachment.state!.sources = attachment.registerDisposer( registerNested( ( @@ -1094,7 +1085,7 @@ const SpatiallyIndexedAnnotationLayer = < }, this.base.state.transform, attachment.view.displayDimensionRenderInfo, - clipDimensionsWeight, + this.base.state.displayState.clipDimensionsWeight, ), ); } From e9f62d56dc1e289ab90c55b7f3631b4d7037fda6 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 29 Jan 2026 12:36:42 +0100 Subject: [PATCH 55/65] chore: fix unused import --- src/annotation/annotation_layer_state.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/annotation/annotation_layer_state.ts b/src/annotation/annotation_layer_state.ts index 6e19c1f3e..c5c74f6c8 100644 --- a/src/annotation/annotation_layer_state.ts +++ b/src/annotation/annotation_layer_state.ts @@ -42,11 +42,7 @@ import { RefCounted } from "#src/util/disposable.js"; import type { ValueOrError } from "#src/util/error.js"; import { makeValueOrError, valueOrThrow } from "#src/util/error.js"; import { vec3 } from "#src/util/geom.js"; -import { - verifyFloat, - verifyObject, - verifyObjectAsMap, -} from "#src/util/json.js"; +import { verifyFloat, verifyObjectAsMap } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; import type { Trackable } from "#src/util/trackable.js"; import { WatchableMap } from "#src/util/watchable_map.js"; From 2cf0551f7cc2a7b21dea3ac34e8d7a15f95881e9 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 29 Jan 2026 12:38:54 +0100 Subject: [PATCH 56/65] chore(python): format --- python/neuroglancer/viewer_state.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index b7b56000d..6362e204f 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -1516,15 +1516,20 @@ class Linked(LinkedType): return Linked + if typing.TYPE_CHECKING or _BUILDING_DOCS: _LinkedDisplayDimensionsBase = LinkedType[list[str]] else: - _LinkedDisplayDimensionsBase = make_linked_navigation_type(typed_list(str), lambda x: x) + _LinkedDisplayDimensionsBase = make_linked_navigation_type( + typed_list(str), lambda x: x + ) + @export class LinkedDisplayDimensions(_LinkedDisplayDimensionsBase): __slots__ = () + if typing.TYPE_CHECKING or _BUILDING_DOCS: _LinkedPositionBase = LinkedType[np.typing.NDArray[np.float32]] else: From f24c289fe888f1d52bce92d700bb3de3ff51d2c5 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 29 Jan 2026 12:46:45 +0100 Subject: [PATCH 57/65] fix(python): typing --- .../examples/example_linear_registration.py | 20 ++++++++----------- python/neuroglancer/coordinate_space.py | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 6819ee90f..13dd08eab 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -38,7 +38,6 @@ from enum import Enum from time import ctime, time from datetime import datetime -from typing import Union import neuroglancer import neuroglancer.cli @@ -235,7 +234,7 @@ def debounced(*args, **kwargs): return decorator -def _create_demo_data(size: Union[int, tuple] = 60, radius: float = 20): +def _create_demo_data(size: int | tuple = 60, radius: float = 20): """Only used if no data is provided to the script""" data_size = (size,) * NUM_DEMO_DIMS if isinstance(size, int) else size data = np.zeros(data_size, dtype=np.uint8) @@ -311,15 +310,13 @@ def create_coord_space_matching_global_dims( units = viewer_dims.units scales = viewer_dims.scales if indices is not None: - names = [names[i] for i in indices] - units = [units[i] for i in indices] - scales = [scales[i] for i in indices] + return neuroglancer.CoordinateSpace( + names=[names[i] for i in indices], + units=[units[i] for i in indices], + scales=np.array([scales[i] for i in indices]), + ) - return neuroglancer.CoordinateSpace( - names=names, - units=units, - scales=scales, # type: ignore - ) + return neuroglancer.CoordinateSpace(names=names, units=units, scales=scales) class PipelineState(Enum): @@ -767,7 +764,7 @@ def update_affine(self): pprint(self.get_registration_info(s)) def get_fixed_and_moving_dims( - self, s: Union[neuroglancer.ViewerState, None], dim_names: list | tuple = () + self, s: neuroglancer.ViewerState | None, dim_names: list | tuple = () ): """Extract the fixed and moving dim names from the state or list of names""" if s is None: @@ -918,7 +915,6 @@ def _filter_annotations( if len(fixed_points) <= NUM_NEAREST_POINTS: return fixed_points, moving_points # Find the X nearest fixed point indices - nearest_indices = [] diff = fixed_points - np.asarray(position) d2 = np.sum(diff * diff, axis=1) nearest_indices = np.argpartition(d2, NUM_NEAREST_POINTS - 1)[ diff --git a/python/neuroglancer/coordinate_space.py b/python/neuroglancer/coordinate_space.py index 30d9a00ec..f59d94abe 100644 --- a/python/neuroglancer/coordinate_space.py +++ b/python/neuroglancer/coordinate_space.py @@ -218,7 +218,7 @@ def __init__( self, json: Any = None, names: Sequence[str] | None = None, - scales: Sequence[float] | None = None, + scales: np.typing.NDArray[np.float64] | Sequence[float] | None = None, units: str | Sequence[str] | None = None, coordinate_arrays: Sequence[CoordinateArray | None] | None = None, ): From b1f094c2fdab6b3069b16e3c13ac19e8761fdeb1 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 29 Jan 2026 13:31:10 +0100 Subject: [PATCH 58/65] fix: correct various parts of pipeline --- .../examples/example_linear_registration.py | 73 +++++++++++-------- 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 13dd08eab..5dca56ab3 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -18,8 +18,8 @@ d. The fixed and moving coordinates can be adjusted later by moving the annotation as normal (alt + left click the point). This will only move the point in the panel you are currently focused on, so to adjust both fixed and moving coordinates you need to switch panels. 6. As you add points, the estimated affine transform will be updated and applied to the moving layers. The registered layers can be toggled visible/invisible by pressing 't'. 7. If an issue happens, the viewer state can go out of sync. To help with this, the python console will regularly print that viewer states are syncing with a timestamp. If you do not see this message for a while, consider continuing the workflow again from a saved state. - 8. To continue from a saved state, dump the viewer state to a file using either the viewer UI or the dump_current_state method in the python console, and then pass this file via --json when starting the script again. You should also pass --continue-workflow (or -c) to skip the initial setup steps. If you renamed the annotation layer containing the registration points, you should also pass --annotations-name (or -a) with the new name. For example: - python -i example_linear_registration.py --json saved_state.json -c -a registration_points + 8. To continue from a saved state, dump the viewer state to a file using either the 'd' key. Then pass this json in the --json command line argument to skip the initial setup steps and use existing annotations. If you renamed the annotation layer containing the registration points, you should also pass --annotations-name (or -a) with the new name. For example: + python -i example_linear_registration.py --json saved_state.json -a registration_points Known issues: 1. Channel dimensions that are stored as c' get switched to c^ and then need to have @@ -339,11 +339,7 @@ class LinearRegistrationWorkflow: def __init__(self, parsed_args): starting_ng_state = parsed_args.state self.annotations_name = parsed_args.annotations_name - self.pipeline_state = ( - PipelineState.READY - if parsed_args.continue_workflow - else PipelineState.NOT_READY - ) + self.pipeline_state = PipelineState.NOT_READY self.unlink_scales = parsed_args.unlink_scales self.output_name = parsed_args.output_name @@ -368,7 +364,7 @@ def __init__(self, parsed_args): if starting_ng_state is None: self._add_demo_data_to_viewer() else: - linear_reg_pipeline_info = starting_ng_state.get( + linear_reg_pipeline_info = starting_ng_state.to_json().get( "linear_reg_pipeline_info", None ) self.viewer.set_state(starting_ng_state) @@ -376,14 +372,13 @@ def __init__(self, parsed_args): self._setup_viewer_actions() self._show_help_message() + if linear_reg_pipeline_info is not None: + breakpoint() + self._restore_coord_maps(linear_reg_pipeline_info) + self.pipeline_state = PipelineState.READY + if self.pipeline_state == PipelineState.NOT_READY: self.setup_initial_two_panel_layout() - elif parsed_args.continue_workflow: - if linear_reg_pipeline_info is None: - raise ValueError( - "To continue workflow from saved state, the state must contain linear_reg_pipeline_info" - ) - self._restore_coord_maps(linear_reg_pipeline_info) def update(self): """Primary update loop, called whenever the viewer state changes.""" @@ -399,14 +394,20 @@ def update(self): self.update_affine() self._clear_status_messages() + def _reset(self): + self._cached_moving_layer_names = [] + self._current_moving_layer_idx = 0 + self.stored_map_moving_name_to_data_coords = {} + self.stored_map_moving_name_to_viewer_coords = {} + def setup_initial_two_panel_layout(self): """Set up a two panel layout if not already present.""" with self.viewer.txn() as s: all_layer_names = [layer.name for layer in s.layers] if len(all_layer_names) >= 2: - half_point = len(all_layer_names) // 2 - group1_names = all_layer_names[:half_point] - group2_names = all_layer_names[half_point:] + last_layer_name = all_layer_names[-1] + group1_names = all_layer_names[:-1] + group2_names = [last_layer_name] else: group1_names = all_layer_names group2_names = all_layer_names @@ -492,9 +493,11 @@ def _update_coord_space_info_cache(self, info_future): f"ERROR: Could not parse volume info for {self.moving_name}: {e} {info_future}" ) print("Try matching the global dimensions to the moving dimension units.") + self.pipeline_state = PipelineState.ERROR + self._reset() + self._show_help_message() # TODO allow recovery from this failure by allowing the user # to enter particular layer name co-ordinate spaces manually - exit(-1) else: self.stored_map_moving_name_to_data_coords[self.moving_name] = ( result.dimensions @@ -508,6 +511,8 @@ def _update_coord_space_info_cache(self, info_future): return self._create_second_coord_space() def _create_second_coord_space(self): + if self.pipeline_state == PipelineState.ERROR: + return self.pipeline_state self.pipeline_state = PipelineState.COORDS_READY with self.viewer.txn() as s: for layer_name in self._cached_moving_layer_names: @@ -540,10 +545,13 @@ def _create_second_coord_space(self): def continue_workflow(self, _): """When the user presses to continue, respond according to the state.""" if self.pipeline_state == PipelineState.NOT_READY: + all_compatible = self._check_all_moving_layers_are_image_or_seg(self.get_state()) + if not all_compatible: + return self.setup_viewer_after_user_ready() return elif self.pipeline_state == PipelineState.ERROR: - self.setup_registration_point_layer() + self.setup_viewer_after_user_ready() elif self.pipeline_state == PipelineState.COORDS_READY: return elif self.pipeline_state == PipelineState.READY: @@ -553,6 +561,21 @@ def continue_workflow(self, _): is_registered_visible = s.layers[registered_name].visible s.layers[registered_name].visible = not is_registered_visible + def _check_all_moving_layers_are_image_or_seg(self, s: neuroglancer.ViewerState): + all_images = True + for layer_name in self.get_moving_layer_names(s): + layer = s.layers[layer_name] + if not (layer.type == "image" or layer.type == "segmentation"): + all_images = False + break + if not all_images: + self._set_status_message( + "error", + "All moving layers must be image layers or seg layers for registration to work. Please correct this and try again.", + ) + self._show_help_message() + return all_images + def _show_help_message(self): in_prog_message = "Place registration points by moving the centre position of one panel and then putting an annotation with ctrl+left click in the other panel. Annotations can be adjusted if needed with alt+left click. Press 't' to toggle visibility of the registered layer. Press 'f' to toggle forcing at most a similarity transform estimation. Press 'g' to toggle between a local affine estimation and a global one. Press 'd' to dump current state for later resumption. Press 'y' to show or hide this help message." setup_message = "Place fixed (reference) layers in the left hand panel, and moving layers (to be registered) in the right hand panel. Then press 't' once you have completed this setup. Press 'y' to show/hide this message." @@ -662,7 +685,7 @@ def _restore_coord_maps(self, reg_info): } def _handle_layer_names_changed(self, s: neuroglancer.ViewerState): - current_names = set(s.layers.keys()) + current_names = set(self.get_moving_layer_names(s)) cached_names = set(self.stored_map_moving_name_to_data_coords.keys()) if current_names == cached_names: return @@ -1023,16 +1046,10 @@ def add_mapping_args(ap: argparse.ArgumentParser): "--annotations-name", "-a", type=str, - help="Name of the annotation layer (default is annotations)", + help="Name of the annotation layer (default is annotations). This is relevant when passing a --json file with saved state to continue from.", default="annotation", required=False, ) - ap.add_argument( - "--continue-workflow", - "-c", - action="store_true", - help="Indicates that we are continuing the workflow from a previously saved state. This will skip the inital setup steps and resume from the affine estimation step directly. You must provide both a state (--url or --json) and the registration info dumped at the same time by this script (-r)", - ) ap.add_argument( "--unlink-scales", "-us", @@ -1236,8 +1253,6 @@ def test_affine_fit_2d(self): if __name__ == "__main__": args = handle_args() - if args.continue_workflow and not args.json: - raise ValueError("The continue flag requires a registration state dump.") if args.test: import pytest From 9aab1ee6c801a4538ad308a321c5b5726a6f71d6 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 29 Jan 2026 13:31:48 +0100 Subject: [PATCH 59/65] chore(python): format --- python/examples/example_linear_registration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 5dca56ab3..dfe49343a 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -545,7 +545,9 @@ def _create_second_coord_space(self): def continue_workflow(self, _): """When the user presses to continue, respond according to the state.""" if self.pipeline_state == PipelineState.NOT_READY: - all_compatible = self._check_all_moving_layers_are_image_or_seg(self.get_state()) + all_compatible = self._check_all_moving_layers_are_image_or_seg( + self.get_state() + ) if not all_compatible: return self.setup_viewer_after_user_ready() From acde09bfdb495905ac6a6151f6d300a512fbae48 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 5 Feb 2026 11:55:57 +0100 Subject: [PATCH 60/65] refactor: clarify test --- .../examples/example_linear_registration.py | 97 ++++++++++++------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index dfe49343a..8f110ba9c 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -1086,6 +1086,43 @@ def handle_args(): ### Some testing code for transform fitting ### class TestTransforms: + should_plot = False + + @staticmethod + def plot_points( + fixed, moving, transformed, dims="2d", filename="test_transform.png" + ): + import matplotlib.pyplot as plt + + if dims == "2d": + fig, ax = plt.subplots() + ax.plot(fixed[:, 0], fixed[:, 1], "o", label="fixed") + ax.plot(moving[:, 0], moving[:, 1], "o", label="moving") + ax.plot( + transformed[:, 0], + transformed[:, 1], + "x", + label="transformed moving", + ) + elif dims == "3d": + fig = plt.figure() + ax = fig.add_subplot(111, projection="3d") + ax.scatter(fixed[:, 0], fixed[:, 1], fixed[:, 2], label="big", marker="o") + ax.scatter( + moving[:, 0], moving[:, 1], moving[:, 2], label="little", marker="o" + ) + ax.scatter( + transformed[:, 0], + transformed[:, 1], + transformed[:, 2], + label="transformed little", + marker="x", + ) + else: + raise ValueError("dims must be '2d' or '3d'") + ax.legend() + fig.savefig(filename, dpi=200) + def test_translation_fit(self): # Simple 2D translation, +4 in y, +1 in x fixed = np.array([[1, 4], [2, 5], [3, 6]]) @@ -1156,33 +1193,19 @@ def test_2d_transform_fit(self): ) t = np.array([3.2, 1.4]) - big = (little @ R.T) * s + t - - affine = rigid_or_similarity_fit(little, big, rigid=False) + big_with_shear = (little @ R.T) * s + t - # Optional plot to visualize - # import matplotlib.pyplot as plt - # fig, ax = plt.subplots() - # ax.plot(little[:, 0], little[:, 1], "o", label="big") - # ax.plot(big[:, 0], big[:, 1], "o", label="little") - # ax.plot( - # transform_points(affine, big)[:, 0], - # transform_points(affine, big)[:, 1], - # "x", - # label="transformed little", - # ) - # ax.legend() - # fig.savefig("dipper.png", dpi=200) + similarity = rigid_or_similarity_fit(little, big_with_shear, rigid=False) # In this case there is a little bit of shear in the fit # so a simiarity transform won't be perfect, but should be close - transformed_points = transform_points(affine, big) + transformed_points = transform_points(similarity, big_with_shear) assert np.allclose(transformed_points, little, atol=0.3) # The affine fit should be very accurate - affine2 = affine_fit(little, big) - transformed_points2 = transform_points(affine2, big) - assert np.allclose(transformed_points2, little, atol=1e-2) + affine = affine_fit(little, big_with_shear) + transformed_points_affine = transform_points(affine, big_with_shear) + assert np.allclose(transformed_points_affine, little, atol=1e-2) # If we change R to have determinant 1, the similarity fit should be very accurate too R_det1 = np.array( @@ -1191,10 +1214,24 @@ def test_2d_transform_fit(self): [0.500, 0.866], ] ) - big2 = (little @ R_det1.T) * s + t - affine3 = rigid_or_similarity_fit(little, big2, rigid=False) - transformed_points3 = transform_points(affine3, big2) - assert np.allclose(transformed_points3, little, atol=1e-2) + big = (little @ R_det1.T) * s + t + similarity = rigid_or_similarity_fit(little, big, rigid=False) + transformed_points_no_shear = transform_points(similarity, big) + assert np.allclose(transformed_points_no_shear, little, atol=1e-2) + + if self.should_plot: + self.plot_points( + little, + big_with_shear, + transformed_points, + filename="dipper_2d_shear.png", + ) + self.plot_points( + little, + big, + transformed_points_no_shear, + filename="dipper_2d_noshear.png", + ) def test_3d_transform_fit(self): little = np.array( @@ -1224,18 +1261,6 @@ def test_3d_transform_fit(self): big = (little @ R.T) * s + t affine = rigid_or_similarity_fit(little, big, rigid=False) - - # Optional plot to visualize - # import matplotlib.pyplot as plt - # fig = plt.figure() - # ax = fig.add_subplot(111, projection="3d") - # ax.scatter(little[:, 0], little[:, 1], little[:, 2], label="big", marker="o") - # ax.scatter(big[:, 0], big[:, 1], big[:, 2], label="little", marker="o") - # tl = transform_points(affine, big) - # ax.scatter(tl[:, 0], tl[:, 1], tl[:, 2], label="transformed little", marker="x") - # ax.legend() - # fig.savefig("dipper_3d.png", dpi=200) - transformed_points = transform_points(affine, big) assert np.allclose(transformed_points, little, atol=1e-2) From 86f62585c657b806ac3ab1c43696effc3d5679b7 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 5 Feb 2026 11:56:45 +0100 Subject: [PATCH 61/65] fix: remove unused var --- src/data_panel_layout.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/data_panel_layout.ts b/src/data_panel_layout.ts index 441cb1d94..f64f989f0 100644 --- a/src/data_panel_layout.ts +++ b/src/data_panel_layout.ts @@ -180,7 +180,6 @@ export function getCommonViewerState(viewer: ViewerUIState) { wireFrame: viewer.wireFrame, enableAdaptiveDownsampling: viewer.enableAdaptiveDownsampling, visibleLayerRoles: viewer.visibleLayerRoles, - clipDimensionsWeight: viewer.clipDimensionsWeight, selectedLayer: viewer.selectedLayer, visibility: viewer.visibility, scaleBarOptions: viewer.scaleBarOptions, From b9410af24e3cedd2c0fd8bff92aeb00f3aec453c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 5 Feb 2026 12:08:25 +0100 Subject: [PATCH 62/65] chore: update docs --- python/examples/example_linear_registration.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 8f110ba9c..b98afb9e9 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -6,11 +6,11 @@ 1. Start from a neuroglancer viewer with all the reference data and the data to register as layers. If the script is provided no data, it will create demo data for you to try. 2. Pass this state to the script by either providing a url via --url or dumping the JSON state to a file and passing the file via --json. For example: python -i example_linear_registration.py --url 'https://neuroglancer.demo.appspot.com/...' - 3. The default assumption is that the last layer in the viewer from step 2 is the moving data to be registered, and all other layers are fixed (reference) data. There must be at least two layers. The script will launch with two layer groups side by side, left is fixed, right is moving. You can move layers between the groups such that all fixed layers are in the first group (left panel) and all moving layers are in the second group (right panel). Once you have done this, press 't' to continue. + 3. The default assumption is that the last layer in the viewer from step 2 is the moving data to be registered, and all other layers are fixed (reference) data. The script will launch with two layer groups side by side, left is fixed, right is moving. You can move layers between the groups such that all fixed layers are in the first group (left panel) and all moving layers are in the second group (right panel). There must be at least two layers. Once you have done this, press 't' to continue. 4. At this point, the viewer will: a. Create a copy of each dimension in with a "2" suffix for the moving layers. E.g. x -> x2, y -> y2, z -> z2. This allows the moving layers to have a different coordinate space. b. Create copies of the moving layers in the fixed panel with "_registered" suffixes. These layers will show the registered result. - c. Create a shared annotation layer between the two panels for placing registration points. Each point will be 2 * N dimensions, where the first N dimensions correspond to the fixed data, and the second N dimensions correspond to the moving data. + c. Create a shared annotation layer between the two panels for placing registration points. Each point will have 2 * N dimensions, where the first N dimensions correspond to the fixed data, and the second N dimensions correspond to the moving data. 5. You can now place point annotations to inform the registration. The workflow is to: a. Move the center position in one of the panels to the desired location for the fixed or moving part of the point annotation. b. Place a point annotation with ctrl+left click in the other panel. @@ -18,14 +18,14 @@ d. The fixed and moving coordinates can be adjusted later by moving the annotation as normal (alt + left click the point). This will only move the point in the panel you are currently focused on, so to adjust both fixed and moving coordinates you need to switch panels. 6. As you add points, the estimated affine transform will be updated and applied to the moving layers. The registered layers can be toggled visible/invisible by pressing 't'. 7. If an issue happens, the viewer state can go out of sync. To help with this, the python console will regularly print that viewer states are syncing with a timestamp. If you do not see this message for a while, consider continuing the workflow again from a saved state. - 8. To continue from a saved state, dump the viewer state to a file using either the 'd' key. Then pass this json in the --json command line argument to skip the initial setup steps and use existing annotations. If you renamed the annotation layer containing the registration points, you should also pass --annotations-name (or -a) with the new name. For example: + 8. To continue from a saved state, dump the viewer state to a file using the 'd' key. Then pass this json in the --json command line argument to skip the initial setup steps and use existing annotations. If you renamed the annotation layer containing the registration points, you should also pass --annotations-name (or -a) with the new name. For example: python -i example_linear_registration.py --json saved_state.json -a registration_points Known issues: 1. Channel dimensions that are stored as c' get switched to c^ and then need to have their shaders updated. Once the update is done though they will stay as c^ so this is a one time setup. - 2. If the layer info fails to be parsed from Python the workflow can't launch past the setup step. + 2. If the layer info fails to be parsed from Python the workflow can't launch past the setup step. This can be worked around by setting up the viewer in full as laid out above and the extra information required in the json file manually and then passing that in, but it does require some effort to do so. This essentially simulates dumping the state after setup in step 8 and then continuing from that state. """ import argparse @@ -44,9 +44,9 @@ import numpy as np import scipy.ndimage -DEBUG = True # Print debug info during execution +DEBUG = False # Print debug info during execution MESSAGE_DURATION = 4 # How long to show help messages in seconds -NUM_DEMO_DIMS = 3 # Only used if no data give, can be 2D or 3D +NUM_DEMO_DIMS = 3 # Only used if no data given, can be 2D or 3D NUM_NEAREST_POINTS = 4 # Number of nearest points to use in local estimation AFFINE_NUM_DECIMALS = 6 # Number of decimals to round affine matrix to From c7a954f24bde9727e03a5727ecc9b53a98304cce Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 9 Feb 2026 17:39:47 +0100 Subject: [PATCH 63/65] fix: remove accidentally included breakpoint --- python/examples/example_linear_registration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index b98afb9e9..ad2427289 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -373,7 +373,6 @@ def __init__(self, parsed_args): self._show_help_message() if linear_reg_pipeline_info is not None: - breakpoint() self._restore_coord_maps(linear_reg_pipeline_info) self.pipeline_state = PipelineState.READY From 7765cc0799b36a05ad30949b8e45b899686dcb76 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 9 Feb 2026 18:09:59 +0100 Subject: [PATCH 64/65] docs: note missing dep for lin reg script --- python/examples/example_linear_registration.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index ad2427289..4f58d09c8 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -2,6 +2,9 @@ """Example of an interactive linear registration workflow using point annotations. +Requires scipy as an additional dependency in addition to the core neuroglancer python package. +python -m pip install scipy + General workflow: 1. Start from a neuroglancer viewer with all the reference data and the data to register as layers. If the script is provided no data, it will create demo data for you to try. 2. Pass this state to the script by either providing a url via --url or dumping the JSON state to a file and passing the file via --json. For example: From 5696eddd2517f791dd7f98103167ce1caf564e72 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 16 Feb 2026 16:10:17 +0100 Subject: [PATCH 65/65] fix: remove non-default package from example script --- .../examples/example_linear_registration.py | 61 ++----------------- 1 file changed, 5 insertions(+), 56 deletions(-) diff --git a/python/examples/example_linear_registration.py b/python/examples/example_linear_registration.py index 4f58d09c8..f938e25cf 100644 --- a/python/examples/example_linear_registration.py +++ b/python/examples/example_linear_registration.py @@ -32,22 +32,22 @@ """ import argparse +import json import logging import threading import webbrowser -import json -from pprint import pprint -from copy import deepcopy, copy +from copy import copy, deepcopy +from datetime import datetime from enum import Enum +from pprint import pprint from time import ctime, time -from datetime import datetime import neuroglancer import neuroglancer.cli import numpy as np import scipy.ndimage -DEBUG = False # Print debug info during execution +DEBUG = False # Print debug info during execution MESSAGE_DURATION = 4 # How long to show help messages in seconds NUM_DEMO_DIMS = 3 # Only used if no data given, can be 2D or 3D NUM_NEAREST_POINTS = 4 # Number of nearest points to use in local estimation @@ -1088,43 +1088,6 @@ def handle_args(): ### Some testing code for transform fitting ### class TestTransforms: - should_plot = False - - @staticmethod - def plot_points( - fixed, moving, transformed, dims="2d", filename="test_transform.png" - ): - import matplotlib.pyplot as plt - - if dims == "2d": - fig, ax = plt.subplots() - ax.plot(fixed[:, 0], fixed[:, 1], "o", label="fixed") - ax.plot(moving[:, 0], moving[:, 1], "o", label="moving") - ax.plot( - transformed[:, 0], - transformed[:, 1], - "x", - label="transformed moving", - ) - elif dims == "3d": - fig = plt.figure() - ax = fig.add_subplot(111, projection="3d") - ax.scatter(fixed[:, 0], fixed[:, 1], fixed[:, 2], label="big", marker="o") - ax.scatter( - moving[:, 0], moving[:, 1], moving[:, 2], label="little", marker="o" - ) - ax.scatter( - transformed[:, 0], - transformed[:, 1], - transformed[:, 2], - label="transformed little", - marker="x", - ) - else: - raise ValueError("dims must be '2d' or '3d'") - ax.legend() - fig.savefig(filename, dpi=200) - def test_translation_fit(self): # Simple 2D translation, +4 in y, +1 in x fixed = np.array([[1, 4], [2, 5], [3, 6]]) @@ -1221,20 +1184,6 @@ def test_2d_transform_fit(self): transformed_points_no_shear = transform_points(similarity, big) assert np.allclose(transformed_points_no_shear, little, atol=1e-2) - if self.should_plot: - self.plot_points( - little, - big_with_shear, - transformed_points, - filename="dipper_2d_shear.png", - ) - self.plot_points( - little, - big, - transformed_points_no_shear, - filename="dipper_2d_noshear.png", - ) - def test_3d_transform_fit(self): little = np.array( [