Skip to content

Conversation

@ebrahimebrahim
Copy link
Contributor

@ebrahimebrahim ebrahimebrahim commented Feb 14, 2025

This updates the fake virtual fit placeholder to save virtual fit results into the slicer scene (as transform nodes with special attribues tagged on them) and to make it so that when you go to save a Session the virtual fit results are converted into openlifu format and written into the session.

This PR comes in parallel to OpenwaterHealth/OpenLIFU-python#212 which it depends on. This PR is needed before virtual fitting is fully flesh out in order to maintain compatibility of SlicerOpenLIFU with OpenLIFU-python

This is connected to #145 but we won't close that out until we bring in the actual virtual fitting algorithm.

TODO:

@ebrahimebrahim ebrahimebrahim force-pushed the update_vf_placeholder_to_work_with_P#179 branch 9 times, most recently from a130db5 to 9778e92 Compare February 23, 2025 05:30
@ebrahimebrahim ebrahimebrahim marked this pull request as ready for review February 23, 2025 05:31
@ebrahimebrahim
Copy link
Contributor Author

ebrahimebrahim commented Feb 23, 2025

@sadhana-r This is ready for review!

To test this you need to set the python-requirements OpenLIFU-python commit hash to 70e52c60f801f3be27e5488a348e1396ec9f4313, which is this branch (it's the changes coming for P#179 plus a cherry pick of the bugfix OpenwaterHealth/OpenLIFU-python#219 which is needed to work with the DVC example data)

  • The virtual fit algorithm is still a placeholder, but what this PR does is to represent virtual fit results in the slicer scene. Virtual fit results are represented in the scene as just transform nodes, but they are tagged with attributes that link them to a target and possibly to a session. They are also "ranked", so if you have several virtual fit results ordered from best to worst, then their place in the ranking is represented by a node attribute (rank "1" for best, "2" for the second best, etc.).
  • The way of interfacing with in-scene virtual fit results is totally encapsulated in OpenLIFULib.virtual_fit_results
  • A big change from previous logic is that everything has a representation in the scene, even approval. Virtual fit approval is now an attribute on a virtual fit result transform node. Previously virtual fit approval was only represented directly in the openlifu session. So now there is a need to sync virtual fit information between the "underlying openlifu session" and the slicer scene, just like we have to for everything else in the scene.
  • Things should work with or without a loaded session. When doing things without a loaded session, you end up creating "sessionless" virtual fit results. Everything works the same.
  • OpenLIFULib.transform_convestion was created as part of a refactor of existing code to make it easier to go back and forth between openlifu style transducer transforms and slicer transform nodes.

Here is a snippet I was using to test the individual things in OpenLIFULib.virtual_fit_results and OpenLIFULib.transform_convestion which may or may not be helpful:

import slicer
from OpenLIFULib.virtual_fit_results import *
from OpenLIFULib.transform_conversion import *
import numpy as np
from scipy.linalg import expm
from openlifu import Transducer
from openlifu.db.session import ArrayTransform

slicer.util.selectModule("OpenLIFUData")
dw = slicer.modules.OpenLIFUDataWidget
dw.onLoadDatabaseClicked(True) # or dw.ui.databaseLoadButton.click()
dl = dw.logic

def load_session():
    dl.load_session("example_subject", "example_session")

def load_session2():
    dl.load_session("mannequin", "demo_4_simulated")

def clear():
    dl.clear_session(clean_up_scene=True)
    slicer.mrmlScene.Clear()

def make_random_matrix() -> np.ndarray:
    rng = np.random.default_rng()
    affine = np.eye(4)
    affine[:3,:3] = expm((lambda A: (A - A.T)/2)(rng.normal(size=(3,3)))) # generate a random orthogonal matrix
    affine[:3,3] = rng.random(3) # generate a random origin
    return affine

def make_random_transform_node(node_name:str) -> vtkMRMLTransformNode:
    affine = make_random_matrix()
    node = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLTransformNode")
    node.SetName(node_name)
    node.SetMatrixTransformToParent(numpy_to_vtk_4x4(affine))
    return node
    

def test_virtual_fit_result_module():
    clear()
    test_transform_node = make_random_transform_node("Test Node to be turned into VF result")
    test_transform_node2 = make_random_transform_node("Test Node to be turned into VF result 2")
    test_transform_node3 = make_random_transform_node("Test Node to be cloned into VF result 3")
    test_transform_node4 = make_random_transform_node("Test Node to be turned into VF result 4")
    vf1 = add_virtual_fit_result(
        transform_node = test_transform_node,
        target_id = "target123",
        session_id = "session123",
        approval_status = True,
        clone_node = False,
    )
    vf2 = add_virtual_fit_result(
        transform_node = test_transform_node2,
        target_id = "target123",
        session_id = "session123",
        approval_status = False,
        rank = 2,
        clone_node = False,
    )
    vf3 = add_virtual_fit_result(
        transform_node = test_transform_node3,
        target_id = "a_different_target",
        session_id = "session123",
        clone_node = True,
    )
    vf4 = add_virtual_fit_result(
        transform_node = test_transform_node4,
        target_id = "target123",
        session_id = "a_different_session",
        approval_status = False,
        clone_node = False,
    )
    assert get_approval_from_virtual_fit_result_node(vf1)
    assert not get_approval_from_virtual_fit_result_node(vf3)
    assert not get_approval_from_virtual_fit_result_node(vf4)
    vfs = list(get_virtual_fit_result_nodes(target_id="target123"))
    assert len(vfs)==3
    assert vf3 not in vfs
    vfs = list(get_virtual_fit_result_nodes(session_id="session123"))
    assert len(vfs)==3
    assert vf4 not in vfs
    vfs = list(get_virtual_fit_result_nodes(target_id="target123", best_only=True))
    assert len(vfs)==2
    vf = get_best_virtual_fit_result_node(target_id="target123", session_id="session123")
    assert vf is vf1
    vf_results_openlifu = get_virtual_fit_results_in_openlifu_session_format(session_id="session123", units="m")
    assert len(vf_results_openlifu)==2
    assert len(vf_results_openlifu["a_different_target"][1])==1
    assert len(vf_results_openlifu["target123"][1])==2
    assert not vf_results_openlifu["a_different_target"][0]
    assert vf_results_openlifu["target123"][0]
    assert len(get_virtual_fit_result_nodes())==4
    clear_virtual_fit_results(target_id="target123", session_id="a_different_session")
    assert len(get_virtual_fit_result_nodes())==3
    dpn = dl.getParameterNode()
    transducer_openlifu = Transducer(units="m")
    clear()
    add_virtual_fit_results_from_openlifu_session_format(vf_results_openlifu,session_id="session1234",transducer=transducer_openlifu)
    assert len(get_virtual_fit_result_nodes())==3
    assert get_approved_target_ids(session_id="session1234") == ['target123']
    vf5 = add_virtual_fit_result(transform_node=make_random_transform_node("hello"),target_id="target123", approval_status=True)
    set_virtual_fit_approval_for_target(False, target_id="target123", session_id=None)
    assert get_virtual_fit_approval_for_target(session_id="session1234", target_id="target123")
    set_virtual_fit_approval_for_target(False, target_id="target123", session_id="session1234")
    assert not get_virtual_fit_approval_for_target(session_id="session1234", target_id="target123")

def test_transform_conversion_tools():
    clear()
    test_transform_node = make_random_transform_node("test transform node")
    array_transform = transform_node_to_openlifu(test_transform_node, "cm")
    reconstructed_transform_node = transform_node_from_openlifu(
        openlifu_transform_matrix = array_transform.matrix,
        transducer = Transducer(units="cm"),
        transform_units = array_transform.units,
    )
    reconstructed_transform_node.SetName("reconstruction")
    assert np.allclose(
        slicer.util.arrayFromTransformMatrix(reconstructed_transform_node),
        slicer.util.arrayFromTransformMatrix(test_transform_node)
    )

    clear()
    transducer_that_uses_cm = Transducer(units="cm")
    array_transform = ArrayTransform(matrix=make_random_matrix(), units="km")
    transform_node = transform_node_from_openlifu(array_transform.matrix, transducer_that_uses_cm, array_transform.units)
    reconstructed_array_transform = transform_node_to_openlifu(transform_node, "cm")
    original_array_transform_matrix_in_transducer_native_units = transducer_that_uses_cm.convert_transform(
        array_transform.matrix,
        array_transform.units,
    )
    assert reconstructed_array_transform.units == "cm" # Not necessary but happens to be the way it works; ensures the next comparison is valid
    assert np.allclose(
        original_array_transform_matrix_in_transducer_native_units,
        reconstructed_array_transform.matrix
    )

test_virtual_fit_result_module()
test_transform_conversion_tools()

What this doesn't test is all the integration into the general workflow. So besides this you'd want to check that you can use the "virtual fit" button to add a virtual fit result, that it can be saved to and loaded from a session, that approval can be toggled with the button, and that approval is revoked if the affiliated target or transform get modified.

Copy link
Contributor

@sadhana-r sadhana-r left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a lot of work! Great job - it is well structured/organized. It all seems to work well except for a few small things. With regards to saving the Slicer scene, I think I prefered having the scene directly update the database, or atleast on approval. Otherwise, its easy to lose track of what is and isn't saved when switching between modules. If we want to keep the slicer scene as its own state, we could prompt the user upon approval/revoking approval to save the session, or add some text to the virtual fit status message to indicate which results are saved/not saved?

A few other thoughts:

  • While VF is running, it may be a good idea to disable the target combo box. This is probably only relevant now for the manual place holder, but its easy to accidently switch the target when doing manual fitting if there are multiple, and then accidently save the virtual fit result to a different target than what was intended.
  • In the pre-planning module, it may be nice to add a button to apply a virtual fit result for a specific target if it already exists in the scene.

if data_logic.validate_session():
target_id = data_logic.get_virtual_fit_approval_state()
if target_id is None:
target_ids = data_logic.get_virtual_fit_approvals_in_session()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be getting confused, but is calling get_virtual_fit_approvals_in_session different from calling get_approved_target_ids and providing it the session id? This separate function may not be required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The difference is that the one in OpenLIFUDataLogic grabs the info from the underlying session, while the other one which is in the preplanning module grabs what is in the scene. Hopefully it's the same info. I do think they belong where they are, since the data module handles talking with the session and the preplanning module handles in-scene virtual fit related stuff. I felt that in the sonication planning module we should look to the session for the source of truth about approval, even though the information should hopefully match what's in the scene.

replace: Whether to replace any existing virtual fit results that have the
same session ID, target ID, and rank. If this is off, then an error is raised
in the event that there is already a matching virtual fit result in the scene.
clone_node: Whether to clone or to take the `transform_node`. If True, then the node is cloned
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I fully understand this. I think it would be helpful to add an example situations where clone_node would be True vs False. What does cloning do different to just taking the transform node? Is cloning a temporary functionality till the VF algorithm is in place?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not temporary functionality, it's just to make the function more useful. The idea is to set clone_node to False if you no longer need the original transform_node, and to set it to True if you want to preserve the integrity of the original transform_node. Setting it to False is almost equivalent to setting it to True and then deleting the original transform_node.

thanks for asking, I added a bit to clarify the docstring

Comment on lines +64 to +67
raise RuntimeError("There is already a virtual fit result node for this target+session+rank and replace is False")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't seem to be getting this error? When I re-run virtual fitting when there is already a virtual fit result, I expected to see an error since replace has a default value of False but it just overwrites the result.

Copy link
Contributor Author

@ebrahimebrahim ebrahimebrahim Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confused me too for a while haha. It does work but the reason it's not called is that there is a call to clear_virtual_fit_results before add_virtual_fit_result gets called when you re-run virtual fitting. So that's deliberate: when you click "virtual fit" it's making sure to clear out any other virtual fit stuff that may already be present.

(test that the error does work)
from OpenLIFULib.virtual_fit_results import add_virtual_fit_result

test_transform_node = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLTransformNode")
test_transform_node2 = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLTransformNode")
vf1 = add_virtual_fit_result(
    transform_node = test_transform_node,
    target_id = "target123",
    session_id = "session123",
    approval_status = False,
    clone_node = True,
)
vf2 = add_virtual_fit_result(
    transform_node = test_transform_node2,
    target_id = "target123",
    session_id = "session123",
    approval_status = False,
    clone_node = False,
) # This should raise a RuntimeError unless you set replace=True

@ebrahimebrahim
Copy link
Contributor Author

In the pre-planning module, it may be nice to add a button to apply a virtual fit result for a specific target if it already exists in the scene.

Agreed! Let me punt this improvement to #139, which is coming soon after the algorithm is implemented.

@ebrahimebrahim ebrahimebrahim force-pushed the update_vf_placeholder_to_work_with_P#179 branch from 6f3ea34 to 1cbc4da Compare February 28, 2025 03:38
@ebrahimebrahim
Copy link
Contributor Author

(Will rebase after it's approved, just added a few commits on top to make the re-review easier)

This function is useful for applying transform updates that come
from algorithms in openlifu-python, where the transform would be
in openlifu conventions. If they work how I expect, then this may
be useful in virtual fitting (#139) and transducer tracking (#149).
- Factor out code from OpenLIFULib.session that converted transforms
  between Slicer and openlifu, and put it into a new OpenLIFULib.transform_conversion
  module so that it can be used by other modules.
- Add more utitlies to OpenLIFULib.virtual_fit_results for the retrieval
  of virtual fit result information, including converting it all into
  the format that is used by an openlifu Session.
- Make it so that when the underlying openlifu Session is updated, the
  session recieves the virtual fit result info from the slicer scene
- Make the virtual fit approval button operate as a toggle that writes
  state not to the session and database but rather into the slicer scene.
- Revoke virtual fit approval when a virtual fit transform is modified,
  not when the transducer's "current transform" is modified.
This makes the session status view in the OpenLIFUData module show the
correct information regarding virtual fit approvals.
Virtual fit transform nodes need to be watched for modification so
that approval can be revoked if they are ever modified.
Virtual fit nodes were already getting watched when they are added via
the pre-planning module ("running" a virtual fit), but they were
not getting watched when they were loaded in from a session.
ebrahimebrahim and others added 4 commits February 28, 2025 10:40
This covers a rare situation where the user has some virtual fit results
that are affiliated with a session, and then invalidates the session and
doesn't clean up the virtual fit results. Later if they reload the
session, this now allows the dangling virtual fit results to get
replaced rather than giving an error about the conflict.
Co-authored-by: Sadhana Ravikumar <sadhana.ravikumar@kitware.com>
Co-authored-by: Sadhana Ravikumar <sadhana.ravikumar@kitware.com>
@ebrahimebrahim ebrahimebrahim force-pushed the update_vf_placeholder_to_work_with_P#179 branch from 1cbc4da to aa6a64f Compare February 28, 2025 15:40
Changes that work towards #145 need this
@ebrahimebrahim ebrahimebrahim force-pushed the update_vf_placeholder_to_work_with_P#179 branch from 8568e4b to 52ad950 Compare February 28, 2025 17:00
This updates SlicerOpenLIFU to work with the change introduced in
OpenwaterHealth/OpenLIFU-python@611ff30
which was part of the work for OpenwaterHealth/OpenLIFU-python#218
@ebrahimebrahim
Copy link
Contributor Author

@sadhana-r, after you approved I added one non-trivial commit 00014a0 to update things to work with the new OpenLIFU-python. It's because in OpenwaterHealth/OpenLIFU-python@611ff30 there was a small change to the way transducer absolute path info is represented. In your re-review you can just check this one commit. I already tested that the mannequin and example_subject examples can be loaded.

@sadhana-r
Copy link
Contributor

looks good to me!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants