Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SR): SCOORD3D point annotations support for stack viewport #3857

Open
wants to merge 42 commits into
base: master
Choose a base branch
from

Conversation

igoroctaviano
Copy link
Contributor

@igoroctaviano igoroctaviano commented Dec 15, 2023

Screenshot 2023-12-15 at 16 48 04

Context

Former PRs:

#planar

Changes & Results

Testing

Checklist

PR

  • [] My Pull Request title is descriptive, accurate and follows the
    semantic-release format and guidelines.

Code

  • [] My code has been well-documented (function documentation, inline comments,
    etc.)

Public Documentation Updates

  • [] The documentation page has been updated as necessary for any public API
    additions or removals.

Tested Environment

  • [] OS:
  • [] Node version:
  • [] Browser:

Copy link

netlify bot commented Dec 15, 2023

Deploy Preview for ohif-platform-docs canceled.

Name Link
🔨 Latest commit 6033d3e
🔍 Latest deploy log https://app.netlify.com/sites/ohif-platform-docs/deploys/665607c036d4c1000849ebcd

Copy link

netlify bot commented Dec 15, 2023

Deploy Preview for ohif-dev canceled.

Name Link
🔨 Latest commit 6033d3e
🔍 Latest deploy log https://app.netlify.com/sites/ohif-dev/deploys/665607c0dba79500081c0bf1

Copy link

codecov bot commented Dec 15, 2023

Codecov Report

All modified and coverable lines are covered by tests ✅

Comparison is base (a2a0090) 44.44% compared to head (4d83134) 44.44%.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##           master    #3857   +/-   ##
=======================================
  Coverage   44.44%   44.44%           
=======================================
  Files          80       80           
  Lines        1332     1332           
  Branches      327      327           
=======================================
  Hits          592      592           
  Misses        587      587           
  Partials      153      153           

Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update a2a0090...4d83134. Read the comment docs.

@igoroctaviano igoroctaviano changed the title WIP: SCOORD3D point annotations support SCOORD3D point annotations support Dec 15, 2023
@igoroctaviano
Copy link
Contributor Author

@sedghi can you take a look at these changes and let me know what you think? This allows the rendering of scoord3d points by FOR. There's another PR here that is related: cornerstonejs/cornerstone3D#950
I tried to keep the changes to a minimum without impacting existent functioanlity.

Copy link

cypress bot commented Jan 8, 2024

Passing run #4004 ↗︎

0 43 0 0 Flakiness 0

Details:

CR Update
Project: Viewers Commit: 6033d3eda8
Status: Passed Duration: 05:03 💡
Started: May 28, 2024 4:46 PM Ended: May 28, 2024 4:51 PM

Review all test suite changes for PR #3857 ↗︎

Copy link
Member

@sedghi sedghi left a comment

Choose a reason for hiding this comment

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

Thanks for the PR, this is not in the right direction I think. Here are my thoughts.

Based on the requirement, it seems like SCCORD3D should be rendered in any viewport that are in the same FOR and looking at the nearby slice that the annotation is drawn on.

I believe that is the definition of our volume viewports. You can look at the tmtv which has volume viewports. https://viewer-dev.ohif.org/tmtv?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463

CleanShot 2024-02-21 at 08 48 02

As seen the drawn annotation on the CT is rendering on PT and fusion because of the design of volume viewports (to show annotations if they are nearby, as opposed to stack viewports which needs referencedImageIds).

So I believe the cornerstony way of implementing this is to

  1. Import SCCORD3D like other regular SCCORDs that we have support
  2. Convert the referenced series into a volume viewport
  3. Force all other viewports that are in the same FOR and share the same normal into volume viewport if mounted
  4. Done

@wayfarer3130 what do you think

@fedorov
Copy link
Member

fedorov commented Mar 8, 2024

Convert the referenced series into a volume viewport

@sedghi I think it might be quite beneficial to support these annotations in the stack viewport. If you have a point that is not on the slice plane, you will have to interpolate to the point position. In the acquisitions that are used for prostate MRI you will be dealing with very anisotropic voxel sizes (0.5x0.5x4mm), so if you interpolate, you can get artifacts. And also I think it is very important for clinical workflow to see the point on the original slices.

I think there is significant value in being able to project the point to the closest slice and show it in the stack viewport, with a disclaimer to the user that the point was projected.

What do you think?

@sedghi
Copy link
Member

sedghi commented Mar 11, 2024

@fedorov To see the point on the original slices we should use Stack Viewport, but the issue/complexity here is that that annotation needs to be viewed in other series in the same FOR and in the same Slice I guess (no?), how do you imagine handling those without some projection?

I don't think we interpolate for volume viewports the logic is: if the annotation is within the same slice thickness of any viewport, that viewport will show it

import { vec3 } from 'gl-matrix';
import { CONSTANTS, metaData } from '@cornerstonejs/core';
import type { Types } from '@cornerstonejs/core';
import { Annotations, Annotation } from '../../types';

const { EPSILON } = CONSTANTS;

const PARALLEL_THRESHOLD = 1 - EPSILON;

/**
 * given some `Annotations`, and the slice defined by the camera's normal
 * direction and the spacing in the normal, filter the `Annotations` which
 * is within the slice.
 *
 * @param annotations - Annotations
 * @param camera - The camera
 * @param spacingInNormalDirection - The spacing in the normal direction
 * @returns The filtered `Annotations`.
 */
export default function filterAnnotationsWithinSlice(
  annotations: Annotations,
  camera: Types.ICamera,
  spacingInNormalDirection: number
): Annotations {
  const { viewPlaneNormal } = camera;

  // The reason we use parallel normals instead of actual orientation is that
  // flipped action is done through camera API, so we can't rely on the
  // orientation (viewplaneNormal and viewUp) since even the same image and
  // same slice if flipped will have different orientation, but still rendering
  // the same slice. Instead, we choose to use the parallel normals to filter
  // the annotations and later we fine tune it with the annotation within slice
  // logic down below.
  const annotationsWithParallelNormals = annotations.filter(
    (td: Annotation) => {
      let annotationViewPlaneNormal = td.metadata.viewPlaneNormal;

      if (!annotationViewPlaneNormal) {
        // This code is run to set the annotation view plane normal
        // for historical data which was saved without the normal.
        const { referencedImageId } = td.metadata;
        const { imageOrientationPatient } = metaData.get(
          'imagePlaneModule',
          referencedImageId
        );
        const rowCosineVec = vec3.fromValues(
          imageOrientationPatient[0],
          imageOrientationPatient[1],
          imageOrientationPatient[2]
        );

        const colCosineVec = vec3.fromValues(
          imageOrientationPatient[3],
          imageOrientationPatient[4],
          imageOrientationPatient[5]
        );

        annotationViewPlaneNormal = vec3.create() as Types.Point3;

        vec3.cross(annotationViewPlaneNormal, rowCosineVec, colCosineVec);
        td.metadata.viewPlaneNormal = annotationViewPlaneNormal;
      }
      const isParallel =
        Math.abs(vec3.dot(viewPlaneNormal, annotationViewPlaneNormal)) >
        PARALLEL_THRESHOLD;

      return annotationViewPlaneNormal && isParallel;
    }
  );

  // No in plane annotations.
  if (!annotationsWithParallelNormals.length) {
    return [];
  }

  // Annotation should be within the slice, which means that it should be between
  // camera's focalPoint +/- spacingInNormalDirection.

  const halfSpacingInNormalDirection = spacingInNormalDirection / 2;
  const { focalPoint } = camera;

  const annotationsWithinSlice = [];

  for (const annotation of annotationsWithParallelNormals) {
    const data = annotation.data;
    const point = data.handles.points[0];

    if (!annotation.isVisible) {
      continue;
    }
    // A = point
    // B = focal point
    // P = normal

    // B-A dot P  => Distance in the view direction.
    // this should be less than half the slice distance.

    const dir = vec3.create();

    vec3.sub(dir, focalPoint, point);

    const dot = vec3.dot(dir, viewPlaneNormal);

    if (Math.abs(dot) < halfSpacingInNormalDirection) {
      annotationsWithinSlice.push(annotation);
    }
  }

  return annotationsWithinSlice;
}

@fedorov
Copy link
Member

fedorov commented Mar 11, 2024

To see the point on the original slices we should use Stack Viewport, but the issue/complexity here is that that annotation needs to be viewed in other series in the same FOR and in the same Slice I guess (no?), how do you imagine handling those without some projection?

The idea is to project, and indicate to the user that the point is not the original one, but has been projected.

I don't think we interpolate for volume viewports the logic is: if the annotation is within the same slice thickness of any viewport, that viewport will show it

I do not understand the above. "If the annotation is within the same slice thickness of any viewport" - what does this mean?

@sedghi
Copy link
Member

sedghi commented Mar 12, 2024

maybe we can meet and discuss?

@igoroctaviano igoroctaviano changed the title SCOORD3D point annotations support SCOORD3D point annotations support for stack viewport Apr 17, 2024
@igoroctaviano igoroctaviano changed the title SCOORD3D point annotations support for stack viewport feat(SR): SCOORD3D point annotations support for stack viewport Apr 17, 2024
@igoroctaviano igoroctaviano requested a review from sedghi May 28, 2024 12:56
@@ -209,10 +209,17 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) {
onElementEnabled(evt);
}}
initialImageIndex={initialImageIndex}
isJumpToMeasurementDisabled={true}
isJumpToMeasurementDisabled={false}
Copy link
Member

Choose a reason for hiding this comment

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

Can you please add a comment explaining why this should be false? Why don't we want to enable jump in the SR viewport?

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 jump was disabled. I just enabled it.


const EPSILON = 1e-4;

function getRenderableData(GraphicType, GraphicData, ValueType, imageId) {
Copy link
Member

Choose a reason for hiding this comment

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

is this just a refactor or should i review this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactor + adding scoord3d conditional

@@ -0,0 +1,18 @@
import { metaData } from '@cornerstonejs/core';

export default function getSOPInstanceAttributes(imageId) {
Copy link
Member

Choose a reason for hiding this comment

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

Can you export this from the @ohif/cornerstone extension and then import it here? This seems to be a duplication.

Comment on lines +1 to +39
import { adaptersSR } from '@cornerstonejs/adapters';

const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;

export const CodeNameCodeSequenceValues = {
ImagingMeasurementReport: '126000',
ImageLibrary: '111028',
ImagingMeasurements: '126010',
MeasurementGroup: '125007',
ImageLibraryGroup: '126200',
TrackingUniqueIdentifier: '112040',
TrackingIdentifier: '112039',
Finding: '121071',
FindingSite: 'G-C0E3', // SRT
FindingSiteSCT: '363698007', // SCT
CornerstoneFreeText: Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT,
};

export const CodingSchemeDesignators = {
SRT: 'SRT',
SCT: 'SCT',
CornerstoneCodeSchemes: [Cornerstone3DCodeScheme.CodingSchemeDesignator, 'CST4'],
};

export const RELATIONSHIP_TYPE = {
INFERRED_FROM: 'INFERRED FROM',
CONTAINS: 'CONTAINS',
};

export const CORNERSTONE_FREETEXT_CODE_VALUE = 'CORNERSTONEFREETEXT';

const enums = {
CodeNameCodeSequenceValues,
CodingSchemeDesignators,
RELATIONSHIP_TYPE,
CORNERSTONE_FREETEXT_CODE_VALUE,
};

export default enums;
Copy link
Member

Choose a reason for hiding this comment

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

Does it make sense to move these into adapters long-term?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These are SR values so I think they could live in the SR ext.

Comment on lines +47 to +58
measurementService.addMapping(
source,
toolNames.DICOMSRDisplay,
[
{
valueType: MeasurementService.VALUE_TYPES.POINT,
points: 1,
},
],
DICOMSRDisplayPoint.toAnnotation,
csToolsAnnotation => DICOMSRDisplayPoint.toMeasurement(csToolsAnnotation, displaySetService)
);
Copy link
Member

Choose a reason for hiding this comment

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

So it seems like you are adding the DICOMSRDisplay to be inserted in the measurementService so that we can map to it? Am I understanding this correctly? So that in the Cornerstone viewport, we can use it to display the point?

I'm trying to understand, for instance, for the length tool that is coming from SR, what would happen after hydration?

I mean, can't we just keep the DICOMSRDisplay tool for the SR viewport and then hydrate the measurement, and you create an SCCORD3DPoint tool to display those in the OHIFCornerstoneViewport?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Any reason not to use DICOMSRDisplay tool since it knows how to display all value types?

*/
const findImageIdIndexFromMeasurementByFOR = (imageIds, measurement) => {
let imageIdIndex = -1;
measurement.metadata.coords.forEach(coord => {
Copy link
Member

Choose a reason for hiding this comment

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

This seems to be a special measurement since it has coordinates. Can we call it SRMeasurement then?

const addReferencedSOPSequenceByFOR = (measurements, displaySet) => {
if (displaySet instanceof ImageSet) {
measurements.forEach(measurement => {
measurement.coords.forEach(coord => {
Copy link
Member

Choose a reason for hiding this comment

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

This is a duplication of code in the other file, right?

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.

None yet

4 participants