feat: Ultrasound Pleura B-line Annotation Mode & Extension (#5061)
This commit is contained in:
1 parent
9b8eef9474
commit
226f9b0773
46 files changed
+7123
-3660
No files matched your search
@@ -0,0 +1,10 @@
|
||||
// Global state to control whether to show the percentage in the overlay
|
||||
export let showPercentage = true;
|
||||
|
||||
/**
|
||||
* Sets whether to show the pleura percentage in the viewport overlay
|
||||
* @param value - Boolean indicating whether to show the percentage
|
||||
*/
|
||||
export function setShowPercentage(value) {
|
||||
showPercentage = value;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { UltrasoundPleuraBLineTool, Enums as csToolsEnums } from '@cornerstonejs/tools';
|
||||
import { Types as OhifTypes } from '@ohif/core';
|
||||
import { eventTarget, triggerEvent, utilities } from '@cornerstonejs/core';
|
||||
import getInstanceByImageId from './getInstanceByImageId';
|
||||
import { setShowPercentage } from './PleuraBlinePercentage';
|
||||
|
||||
const { transformWorldToIndex } = utilities;
|
||||
|
||||
/**
|
||||
* Creates and returns the commands module for ultrasound annotation
|
||||
* @param params - Extension parameters including servicesManager and commandsManager
|
||||
* @returns The commands module with actions and definitions
|
||||
*/
|
||||
function commandsModule({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
}: OhifTypes.Extensions.ExtensionParams): OhifTypes.Extensions.CommandsModule {
|
||||
const { viewportGridService, toolGroupService, cornerstoneViewportService } =
|
||||
servicesManager.services as AppTypes.Services;
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Switches the active ultrasound annotation type
|
||||
* @param options - Object containing the annotationType to switch to
|
||||
*/
|
||||
switchUSPleuraBLineAnnotation: ({ annotationType }) => {
|
||||
const activeViewportId = viewportGridService.getActiveViewportId();
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(activeViewportId);
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
const usAnnotation = toolGroup.getToolInstance(UltrasoundPleuraBLineTool.toolName);
|
||||
if (usAnnotation) {
|
||||
usAnnotation.setActiveAnnotationType(annotationType);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Convenience method to switch to pleura line annotation type
|
||||
*/
|
||||
switchUSPleuraBLineAnnotationToPleuraLine: () => {
|
||||
actions.switchUSPleuraBLineAnnotation({
|
||||
annotationType: UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.PLEURA,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Convenience method to switch to B-line annotation type
|
||||
*/
|
||||
switchUSPleuraBLineAnnotationToBLine: () => {
|
||||
actions.switchUSPleuraBLineAnnotation({
|
||||
annotationType: UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Deletes the last annotation of the specified type
|
||||
* @param options - Object containing the annotationType to delete
|
||||
*/
|
||||
deleteLastUSPleuraBLineAnnotation: ({ annotationType }) => {
|
||||
const activeViewportId = viewportGridService.getActiveViewportId();
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(activeViewportId);
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
const usAnnotation = toolGroup.getToolInstance(UltrasoundPleuraBLineTool.toolName);
|
||||
if (usAnnotation) {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId);
|
||||
usAnnotation.deleteLastAnnotationType(viewport.element, annotationType);
|
||||
viewport.render();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Convenience method to delete the last pleura line annotation
|
||||
*/
|
||||
deleteLastPleuraAnnotation: () => {
|
||||
actions.deleteLastUSPleuraBLineAnnotation({
|
||||
annotationType: UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.PLEURA,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Convenience method to delete the last B-line annotation
|
||||
*/
|
||||
deleteLastBLineAnnotation: () => {
|
||||
actions.deleteLastUSPleuraBLineAnnotation({
|
||||
annotationType: UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Toggles a boolean attribute of the ultrasound annotation tool
|
||||
* @param options - Object containing the attribute name to toggle
|
||||
*/
|
||||
toggleUSToolAttribute: ({ attribute }) => {
|
||||
const activeViewportId = viewportGridService.getActiveViewportId();
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(activeViewportId);
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
const configuration = toolGroup.getToolConfiguration(UltrasoundPleuraBLineTool.toolName);
|
||||
if (!configuration) {
|
||||
return;
|
||||
}
|
||||
toolGroup.setToolConfiguration(UltrasoundPleuraBLineTool.toolName, {
|
||||
[attribute]: !configuration[attribute],
|
||||
});
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId);
|
||||
viewport.render();
|
||||
},
|
||||
/**
|
||||
* Sets a specific attribute of the ultrasound annotation tool to a given value
|
||||
* @param options - Object containing the attribute name and value to set
|
||||
*/
|
||||
setUSToolAttribute: ({ attribute, value }) => {
|
||||
const activeViewportId = viewportGridService.getActiveViewportId();
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(activeViewportId);
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
const configuration = toolGroup.getToolConfiguration(UltrasoundPleuraBLineTool.toolName);
|
||||
if (!configuration) {
|
||||
return;
|
||||
}
|
||||
toolGroup.setToolConfiguration(UltrasoundPleuraBLineTool.toolName, {
|
||||
[attribute]: value,
|
||||
});
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId);
|
||||
viewport.render();
|
||||
},
|
||||
/**
|
||||
* Toggles the display of fan annotations
|
||||
*/
|
||||
toggleDisplayFanAnnotation: () => {
|
||||
actions.toggleUSToolAttribute({
|
||||
attribute: 'showFanAnnotations',
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Toggles the display of the depth guide
|
||||
*/
|
||||
toggleDepthGuide: () => {
|
||||
actions.toggleUSToolAttribute({
|
||||
attribute: 'drawDepthGuide',
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Sets the depth guide display state
|
||||
* @param options - Object containing the boolean value to set
|
||||
*/
|
||||
setDepthGuide: ({ value }) => {
|
||||
actions.setUSToolAttribute({
|
||||
attribute: 'drawDepthGuide',
|
||||
value,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Sets the fan annotation display state
|
||||
* @param options - Object containing the boolean value to set
|
||||
*/
|
||||
setDisplayFanAnnotation: ({ value }) => {
|
||||
actions.setUSToolAttribute({
|
||||
attribute: 'showFanAnnotations',
|
||||
value,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Sets whether to show the pleura percentage in the viewport overlay
|
||||
* @param options - Object containing the boolean value to set
|
||||
*/
|
||||
setShowPleuraPercentage: ({ value }) => {
|
||||
setShowPercentage(value);
|
||||
// Trigger ANNOTATION_MODIFIED event to update the overlay
|
||||
triggerEvent(eventTarget, csToolsEnums.Events.ANNOTATION_MODIFIED, {
|
||||
annotation: {
|
||||
metadata: {
|
||||
toolName: UltrasoundPleuraBLineTool.toolName,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Generates a JSON representation of the ultrasound annotations
|
||||
* @param labels - Array of annotation labels
|
||||
* @param imageIds - Array of image IDs to include in the JSON
|
||||
* @returns A JSON object containing the annotations data or undefined if generation fails
|
||||
*/
|
||||
generateUSPleuraBLineAnnotationsJSON: (labels: string[] = [], imageIds: string[] = []) => {
|
||||
const activeViewportId = viewportGridService.getActiveViewportId();
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId);
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const { imageData } = viewport.getImageData() || {};
|
||||
if (!imageData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(activeViewportId);
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
const usAnnotation = toolGroup.getToolInstance(UltrasoundPleuraBLineTool.toolName);
|
||||
if (usAnnotation) {
|
||||
const configuration = toolGroup.getToolConfiguration(UltrasoundPleuraBLineTool.toolName);
|
||||
const imageId = viewport.getCurrentImageId();
|
||||
const filterImageIds = (imageId: string) => {
|
||||
if (imageIds.length === 0) {
|
||||
return true;
|
||||
} else {
|
||||
return imageIds.includes(imageId);
|
||||
}
|
||||
};
|
||||
const annotations = UltrasoundPleuraBLineTool.filterAnnotations(
|
||||
viewport.element,
|
||||
filterImageIds
|
||||
);
|
||||
const frame_annotations = {};
|
||||
const viewportImageIds = viewport.getImageIds();
|
||||
annotations.forEach(annotation => {
|
||||
const imageId = annotation.metadata.referencedImageId;
|
||||
const { annotationType } = annotation.data;
|
||||
const [point1, point2] = annotation.data.handles.points;
|
||||
const p1 = transformWorldToIndex(imageData, point1);
|
||||
const p2 = transformWorldToIndex(imageData, point2);
|
||||
const imageIdIndex = viewportImageIds.indexOf(imageId);
|
||||
if (frame_annotations[imageIdIndex] === undefined) {
|
||||
frame_annotations[imageIdIndex] = {
|
||||
pleura_lines: [],
|
||||
b_lines: [],
|
||||
};
|
||||
}
|
||||
if (annotationType === UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.PLEURA) {
|
||||
frame_annotations[imageIdIndex].pleura_lines.push([
|
||||
[p1[0], p1[1], 0],
|
||||
[p2[0], p2[1], 0],
|
||||
]);
|
||||
} else if (
|
||||
annotationType === UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE
|
||||
) {
|
||||
frame_annotations[imageIdIndex].b_lines.push([
|
||||
[p1[0], p1[1], 0],
|
||||
[p2[0], p2[1], 0],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const instance = getInstanceByImageId(servicesManager.services, imageId);
|
||||
const json = {
|
||||
SOPInstanceUID: instance.SOPInstanceUID,
|
||||
GrayscaleConversion: false,
|
||||
mask_type: 'fan',
|
||||
angle1: configuration.startAngle,
|
||||
angle2: configuration.endAngle,
|
||||
center_rows_px: configuration.center[0],
|
||||
center_cols_px: configuration.center[1],
|
||||
radius1: configuration.innerRadius,
|
||||
radius2: configuration.outerRadius,
|
||||
image_size_rows: instance.rows,
|
||||
image_size_cols: instance.columns,
|
||||
AnnotationLabels: labels,
|
||||
frame_annotations,
|
||||
};
|
||||
return json;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Downloads the ultrasound annotations as a JSON file
|
||||
* @param options - Object containing labels and imageIds arrays
|
||||
*/
|
||||
downloadUSPleuraBLineAnnotationsJSON({ labels = [], imageIds = [] }) {
|
||||
const json = actions.generateUSPleuraBLineAnnotationsJSON(labels, imageIds);
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert JSON object to a string
|
||||
const jsonString = JSON.stringify(json, null, 2);
|
||||
|
||||
// Create a blob with the JSON data
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
|
||||
// Create a URL for the blob
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Create an anchor element
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ultrasound_annotations_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
// Append to the document, click to download, and remove
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Clean up by revoking the URL
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
switchUSAnnotation: {
|
||||
commandFn: actions.switchUSPleuraBLineAnnotation,
|
||||
},
|
||||
deleteLastAnnotation: {
|
||||
commandFn: actions.deleteLastUSPleuraBLineAnnotation,
|
||||
},
|
||||
toggleDepthGuide: {
|
||||
commandFn: actions.toggleDepthGuide,
|
||||
},
|
||||
setDepthGuide: {
|
||||
commandFn: actions.setDepthGuide,
|
||||
},
|
||||
setShowPleuraPercentage: {
|
||||
commandFn: actions.setShowPleuraPercentage,
|
||||
},
|
||||
toggleUSToolAttribute: {
|
||||
commandFn: actions.toggleUSToolAttribute,
|
||||
},
|
||||
setUSToolAttribute: {
|
||||
commandFn: actions.setUSToolAttribute,
|
||||
},
|
||||
toggleDisplayFanAnnotation: {
|
||||
commandFn: actions.toggleDisplayFanAnnotation,
|
||||
},
|
||||
setDisplayFanAnnotation: {
|
||||
commandFn: actions.setDisplayFanAnnotation,
|
||||
},
|
||||
generateJSON: {
|
||||
commandFn: actions.generateUSPleuraBLineAnnotationsJSON,
|
||||
},
|
||||
downloadJSON: {
|
||||
commandFn: actions.downloadUSPleuraBLineAnnotationsJSON,
|
||||
},
|
||||
switchUSAnnotationToPleuraLine: {
|
||||
commandFn: actions.switchUSPleuraBLineAnnotationToPleuraLine,
|
||||
},
|
||||
switchUSAnnotationToBLine: {
|
||||
commandFn: actions.switchUSPleuraBLineAnnotationToBLine,
|
||||
},
|
||||
deleteLastPleuraAnnotation: {
|
||||
commandFn: actions.deleteLastPleuraAnnotation,
|
||||
},
|
||||
deleteLastBLineAnnotation: {
|
||||
commandFn: actions.deleteLastBLineAnnotation,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
actions,
|
||||
definitions,
|
||||
defaultContext: 'CORNERSTONE',
|
||||
};
|
||||
}
|
||||
|
||||
export default commandsModule;
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Retrieves the DICOM instance associated with a specific imageId
|
||||
* @param services - The OHIF services object
|
||||
* @param imageId - The image ID to find the instance for
|
||||
* @returns The DICOM instance object or undefined if not found
|
||||
*/
|
||||
export default function getInstanceByImageId(services: AppTypes.Services, imageId: string) {
|
||||
const activeDisplaySets = services.displaySetService.getActiveDisplaySets();
|
||||
const displaySet = activeDisplaySets.find(displaySet => displaySet?.imageIds?.includes(imageId));
|
||||
return displaySet?.instance;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
import USAnnotationPanel from './panels/USAnnotationPanel';
|
||||
|
||||
/**
|
||||
* Creates and returns the panel module for ultrasound annotation
|
||||
* @param params - Object containing commandsManager, servicesManager, and extensionManager
|
||||
* @returns Array of panel configurations
|
||||
*/
|
||||
const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: withAppTypes) => {
|
||||
/**
|
||||
* Wrapper component for the USAnnotationPanel that injects the required props
|
||||
* @param props - Component props including configuration
|
||||
* @returns The wrapped USAnnotationPanel component
|
||||
*/
|
||||
const wrappedUSAnnotationPanel = ({ configuration }) => {
|
||||
return <USAnnotationPanel />;
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'USAnnotationPanel',
|
||||
iconName: 'tab-linear',
|
||||
iconLabel: 'US Annotation',
|
||||
label: 'USAnnotation',
|
||||
component: wrappedUSAnnotationPanel,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default getPanelModule;
|
||||
@@ -0,0 +1,5 @@
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const id = packageJson.name;
|
||||
|
||||
export { id };
|
||||
@@ -0,0 +1,90 @@
|
||||
import { id } from './id';
|
||||
import getPanelModule from './getPanelModule';
|
||||
import getCommandsModule from './getCommandsModule';
|
||||
import { showPercentage, setShowPercentage } from './PleuraBlinePercentage';
|
||||
/**
|
||||
* You can remove any of the following modules if you don't need them.
|
||||
*/
|
||||
export default {
|
||||
/**
|
||||
* Only required property. Should be a unique value across all extensions.
|
||||
* You ID can be anything you want, but it should be unique.
|
||||
*/
|
||||
id,
|
||||
|
||||
/**
|
||||
* Perform any pre-registration tasks here. This is called before the extension
|
||||
* is registered. Usually we run tasks such as: configuring the libraries
|
||||
* (e.g. cornerstone, cornerstoneTools, ...) or registering any services that
|
||||
* this extension is providing.
|
||||
*/
|
||||
preRegistration: () => {},
|
||||
/**
|
||||
* PanelModule should provide a list of panels that will be available in OHIF
|
||||
* for Modes to consume and render. Each panel is defined by a {name,
|
||||
* iconName, iconLabel, label, component} object. Example of a panel module
|
||||
* is the StudyBrowserPanel that is provided by the default extension in OHIF.
|
||||
*/
|
||||
getPanelModule,
|
||||
/**
|
||||
* ViewportModule should provide a list of viewports that will be available in OHIF
|
||||
* for Modes to consume and use in the viewports. Each viewport is defined by
|
||||
* {name, component} object. Example of a viewport module is the CornerstoneViewport
|
||||
* that is provided by the Cornerstone extension in OHIF.
|
||||
*/
|
||||
getViewportModule: ({ servicesManager, commandsManager, extensionManager }) => {},
|
||||
/**
|
||||
* ToolbarModule should provide a list of tool buttons that will be available in OHIF
|
||||
* for Modes to consume and use in the toolbar. Each tool button is defined by
|
||||
* {name, defaultComponent, clickHandler }. Examples include radioGroupIcons and
|
||||
* splitButton toolButton that the default extension is providing.
|
||||
*/
|
||||
getToolbarModule: ({ servicesManager, commandsManager, extensionManager }) => {},
|
||||
/**
|
||||
* LayoutTemplateMOdule should provide a list of layout templates that will be
|
||||
* available in OHIF for Modes to consume and use to layout the viewer.
|
||||
* Each layout template is defined by a { name, id, component}. Examples include
|
||||
* the default layout template provided by the default extension which renders
|
||||
* a Header, left and right sidebars, and a viewport section in the middle
|
||||
* of the viewer.
|
||||
*/
|
||||
getLayoutTemplateModule: ({ servicesManager, commandsManager, extensionManager }) => {},
|
||||
/**
|
||||
* SopClassHandlerModule should provide a list of sop class handlers that will be
|
||||
* available in OHIF for Modes to consume and use to create displaySets from Series.
|
||||
* Each sop class handler is defined by a { name, sopClassUids, getDisplaySetsFromSeries}.
|
||||
* Examples include the default sop class handler provided by the default extension
|
||||
*/
|
||||
getSopClassHandlerModule: ({ servicesManager, commandsManager, extensionManager }) => {},
|
||||
/**
|
||||
* HangingProtocolModule should provide a list of hanging protocols that will be
|
||||
* available in OHIF for Modes to use to decide on the structure of the viewports
|
||||
* and also the series that hung in the viewports. Each hanging protocol is defined by
|
||||
* { name, protocols}. Examples include the default hanging protocol provided by
|
||||
* the default extension that shows 2x2 viewports.
|
||||
*/
|
||||
getHangingProtocolModule: ({ servicesManager, commandsManager, extensionManager }) => {},
|
||||
/**
|
||||
* CommandsModule should provide a list of commands that will be available in OHIF
|
||||
* for Modes to consume and use in the viewports. Each command is defined by
|
||||
* an object of { actions, definitions, defaultContext } where actions is an
|
||||
* object of functions, definitions is an object of available commands, their
|
||||
* options, and defaultContext is the default context for the command to run against.
|
||||
*/
|
||||
getCommandsModule,
|
||||
/**
|
||||
* ContextModule should provide a list of context that will be available in OHIF
|
||||
* and will be provided to the Modes. A context is a state that is shared OHIF.
|
||||
* Context is defined by an object of { name, context, provider }. Examples include
|
||||
* the measurementTracking context provided by the measurementTracking extension.
|
||||
*/
|
||||
getContextModule: ({ servicesManager, commandsManager, extensionManager }) => {},
|
||||
/**
|
||||
* DataSourceModule should provide a list of data sources to be used in OHIF.
|
||||
* DataSources can be used to map the external data formats to the OHIF's
|
||||
* native format. DataSources are defined by an object of { name, type, createDataSource }.
|
||||
*/
|
||||
getDataSourcesModule: ({ servicesManager, commandsManager, extensionManager }) => {},
|
||||
};
|
||||
|
||||
export { showPercentage, setShowPercentage };
|
||||
@@ -0,0 +1,8 @@
|
||||
import { metaData } from '@cornerstonejs/core';
|
||||
|
||||
import FanShapeGeometryProvider from '../providers/FanShapeGeometryProvider';
|
||||
|
||||
export default function init({ servicesManager }) {
|
||||
const fanShapeGeometryProvider = new FanShapeGeometryProvider(servicesManager.services);
|
||||
metaData.addProvider(fanShapeGeometryProvider.get.bind(fanShapeGeometryProvider), 9999);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Input } from '@ohif/ui-next';
|
||||
|
||||
/**
|
||||
* Props for the MultiLabelInput component
|
||||
*/
|
||||
interface MultiLabelInputProps {
|
||||
/** Placeholder text for the input field */
|
||||
placeholder?: string;
|
||||
/** Additional CSS class name for the input field */
|
||||
className?: string;
|
||||
/** Array of labels to display */
|
||||
labels?: string[];
|
||||
/** Callback function called when labels are added or removed */
|
||||
onLabelsChange?: (labels: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that allows users to input and manage multiple text labels
|
||||
* @param props - Component props
|
||||
* @returns The MultiLabelInput component
|
||||
*/
|
||||
const MultiLabelInput: React.FC<MultiLabelInputProps> = ({
|
||||
placeholder = 'Enter a label and press Enter',
|
||||
className,
|
||||
labels = [],
|
||||
onLabelsChange,
|
||||
}) => {
|
||||
// State for the current input value
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
// Internal state for labels that syncs with the external labels prop
|
||||
const [internalLabels, setInternalLabels] = useState<string[]>(labels);
|
||||
|
||||
// Update internal labels when external labels change
|
||||
useEffect(() => {
|
||||
setInternalLabels(labels);
|
||||
}, [labels]);
|
||||
|
||||
/**
|
||||
* Handles changes to the input field
|
||||
* @param e - The change event
|
||||
*/
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds the current input value as a new label if it's not empty and not a duplicate
|
||||
*/
|
||||
const handleAddLabel = () => {
|
||||
if (inputValue.trim() && !internalLabels.includes(inputValue.trim())) {
|
||||
const newLabels = [...internalLabels, inputValue.trim()];
|
||||
setInternalLabels(newLabels);
|
||||
if (onLabelsChange) {
|
||||
onLabelsChange(newLabels);
|
||||
}
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a label from the list
|
||||
* @param label - The label to remove
|
||||
*/
|
||||
const handleRemoveLabel = (label: string) => {
|
||||
const newLabels = internalLabels.filter(l => l !== label);
|
||||
setInternalLabels(newLabels);
|
||||
if (onLabelsChange) {
|
||||
onLabelsChange(newLabels);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles key press events in the input field
|
||||
* @param e - The keyboard event
|
||||
*/
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddLabel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
/>
|
||||
<div className="flex flex-wrap">
|
||||
{internalLabels.map((label, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="m-1 inline-flex items-center rounded-full bg-[#2a2a4a] px-4 py-1 text-white"
|
||||
>
|
||||
{label}
|
||||
<button
|
||||
className="ml-2.5 cursor-pointer rounded-full bg-blue-800 px-2 py-0.5 text-xs text-white transition-colors hover:bg-blue-600"
|
||||
onClick={() => handleRemoveLabel(label)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiLabelInput;
|
||||
@@ -0,0 +1,421 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Enums as csToolsEnums, UltrasoundPleuraBLineTool } from '@cornerstonejs/tools';
|
||||
import { eventTarget, utilities } from '@cornerstonejs/core';
|
||||
import { useSystem } from '@ohif/core';
|
||||
|
||||
import {
|
||||
/* Layout */
|
||||
PanelSection,
|
||||
ScrollArea,
|
||||
/* Controls */
|
||||
Label,
|
||||
Button,
|
||||
Icons,
|
||||
Switch,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Separator,
|
||||
} from '@ohif/ui-next';
|
||||
|
||||
/**
|
||||
* A side panel that drives the ultrasound annotation workflow.
|
||||
* It provides controls for managing annotations, toggling display options,
|
||||
* and downloading annotations as JSON.
|
||||
* @returns The USAnnotationPanel component
|
||||
*/
|
||||
export default function USAnnotationPanel() {
|
||||
const { servicesManager, commandsManager } = useSystem();
|
||||
|
||||
/** ──────────────────────────────────────────────────────
|
||||
* Local state – purely UI related (no business logic). */
|
||||
|
||||
const { viewportGridService, cornerstoneViewportService, measurementService } =
|
||||
servicesManager.services as AppTypes.Services;
|
||||
|
||||
// UI state variables
|
||||
const [depthGuide, setDepthGuide] = useState(true);
|
||||
const [autoAdd, setAutoAdd] = useState(true);
|
||||
const [showPleuraPct, setShowPleuraPct] = useState(true);
|
||||
const [showOverlay, setShowOverlay] = useState(true);
|
||||
|
||||
// Data state variables
|
||||
const [annotatedFrames, setAnnotatedFrames] = useState<any[]>([]);
|
||||
const [imageIdsToObserve, setImageIdsToObserve] = useState<string[]>([]);
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
|
||||
/** ──────────────────────────────────────────────────────
|
||||
* Helper – commands bridging back to OHIF services. */
|
||||
|
||||
/**
|
||||
* Switches the active annotation type (pleura or B-line)
|
||||
* @param type - The annotation type to switch to
|
||||
*/
|
||||
const switchAnnotation = (type: string) => {
|
||||
commandsManager.runCommand('setToolActive', { toolName: UltrasoundPleuraBLineTool.toolName });
|
||||
commandsManager.runCommand('switchUSAnnotation', { annotationType: type });
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes the last annotation of the specified type
|
||||
* @param type - The annotation type to delete
|
||||
*/
|
||||
const deleteLast = (type: string) => {
|
||||
commandsManager.runCommand('deleteLastAnnotation', { annotationType: type });
|
||||
updateAnnotatedFrames();
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the depth guide display state
|
||||
* @param value - Boolean indicating whether to show the depth guide
|
||||
*/
|
||||
const setDepthGuideCommand = (value: boolean) => {
|
||||
commandsManager.runCommand('setDepthGuide', { value });
|
||||
setDepthGuide(value);
|
||||
};
|
||||
/**
|
||||
* Sets the auto-add annotations state
|
||||
* When enabled, all frames are monitored for annotations
|
||||
* When disabled, only manually added frames are monitored
|
||||
* @param value - Boolean indicating whether to auto-add annotations
|
||||
*/
|
||||
const setAutoAddCommand = (value: boolean) => {
|
||||
if (value) {
|
||||
setImageIdsToObserve([]);
|
||||
} else {
|
||||
const imageIds = annotatedFrames.map(item => item.imageId);
|
||||
if (imageIds.length > 0) {
|
||||
setImageIdsToObserve(imageIds);
|
||||
} else {
|
||||
setImageIdsToObserve(['Manual']);
|
||||
}
|
||||
}
|
||||
setAutoAdd(value);
|
||||
};
|
||||
/**
|
||||
* Sets whether to show the pleura percentage in the viewport overlay
|
||||
* @param value - Boolean indicating whether to show the percentage
|
||||
*/
|
||||
const setShowPleuraPercentageCommand = (value: boolean) => {
|
||||
commandsManager.runCommand('setShowPleuraPercentage', { value });
|
||||
setShowPleuraPct(value);
|
||||
};
|
||||
/**
|
||||
* Sets whether to show the fan overlay in the viewport
|
||||
* @param value - Boolean indicating whether to show the overlay
|
||||
*/
|
||||
const setShowOverlayCommand = (value: boolean) => {
|
||||
commandsManager.runCommand('setDisplayFanAnnotation', { value });
|
||||
setShowOverlay(value);
|
||||
};
|
||||
/**
|
||||
* Downloads the annotations as a JSON file
|
||||
* Uses the labels and imageIdsToObserve state variables
|
||||
*/
|
||||
const downloadJSON = () => {
|
||||
commandsManager.runCommand('downloadJSON', { labels, imageIds: imageIdsToObserve });
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds the current image ID to the list of monitored image IDs
|
||||
* Only works when auto-add is disabled
|
||||
*/
|
||||
const addCurrentImageId = () => {
|
||||
if (!autoAdd) {
|
||||
const activeViewportId = viewportGridService.getActiveViewportId();
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId);
|
||||
const currentImageId = viewport.getCurrentImageId();
|
||||
const imageIds = [...imageIdsToObserve];
|
||||
if (!imageIds.includes(currentImageId)) {
|
||||
imageIds.push(currentImageId);
|
||||
}
|
||||
setImageIdsToObserve(imageIds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles clicking on a row in the annotated frames table
|
||||
* Scrolls the viewport to the selected frame
|
||||
* @param item - The annotated frame item that was clicked
|
||||
*/
|
||||
const handleRowClick = item => {
|
||||
const activeViewportId = viewportGridService.getActiveViewportId();
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId);
|
||||
utilities.scroll(viewport, { delta: item.frame - viewport.getCurrentImageIdIndex() });
|
||||
};
|
||||
|
||||
/**
|
||||
* Render helpers so the JSX doesn’t become spaghetti. */
|
||||
const renderWorkflowToggles = () => (
|
||||
<PanelSection.Content>
|
||||
<div className="space-y-3 p-2 text-sm text-white">
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
id="depth-guide-switch"
|
||||
className="mr-3"
|
||||
checked={depthGuide}
|
||||
onCheckedChange={() => setDepthGuideCommand(!depthGuide)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="depth-guide-switch"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDepthGuideCommand(!depthGuide)}
|
||||
>
|
||||
Depth guide toggle
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex items-center">
|
||||
<Switch
|
||||
id="auto-add-switch"
|
||||
className="mr-3"
|
||||
checked={autoAdd}
|
||||
onCheckedChange={() => setAutoAddCommand(!autoAdd)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="auto-add-switch"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setAutoAddCommand(!autoAdd)}
|
||||
>
|
||||
Auto-add annotations
|
||||
</label>
|
||||
</div> */}
|
||||
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
id="pleura-percentage-switch"
|
||||
className="mr-3"
|
||||
checked={showPleuraPct}
|
||||
onCheckedChange={() => setShowPleuraPercentageCommand(!showPleuraPct)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="pleura-percentage-switch"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowPleuraPercentageCommand(!showPleuraPct)}
|
||||
>
|
||||
Show pleura percentage
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection.Content>
|
||||
);
|
||||
|
||||
const renderSectorAnnotations = () => (
|
||||
<PanelSection.Content>
|
||||
<div className="flex flex-col gap-4 p-2">
|
||||
<Label>Sector Annotations</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs
|
||||
defaultValue={UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE}
|
||||
onValueChange={newValue => switchAnnotation(newValue)}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value={UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.PLEURA}>
|
||||
<Icons.Plus /> Pleura line
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE}>
|
||||
<Icons.Plus /> B-line
|
||||
</TabsTrigger>
|
||||
<Separator orientation="vertical" />
|
||||
<Separator orientation="vertical" />
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="ml-auto">
|
||||
<Icons.More />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
deleteLast(UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.BLINE)
|
||||
}
|
||||
>
|
||||
<Icons.Delete className="text-foreground" />
|
||||
<span className="pl-2">B-line annotation</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
deleteLast(UltrasoundPleuraBLineTool.USPleuraBLineAnnotationType.PLEURA)
|
||||
}
|
||||
>
|
||||
<Icons.Delete className="text-foreground" />
|
||||
<span className="pl-2">Pleura annotation</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
id="show-overlay-switch"
|
||||
checked={showOverlay}
|
||||
onCheckedChange={() => setShowOverlayCommand(!showOverlay)}
|
||||
className="data-[state=checked]:bg-blue-500"
|
||||
/>
|
||||
<label htmlFor="show-overlay-switch" className="cursor-pointer text-blue-300">
|
||||
Show Overlay
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-t border-gray-800" />
|
||||
</div>
|
||||
</PanelSection.Content>
|
||||
);
|
||||
|
||||
const renderAnnotatedFrames = () => (
|
||||
<ScrollArea className="h-full">
|
||||
<PanelSection.Content>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-blue-300"
|
||||
disabled={autoAdd}
|
||||
onClick={addCurrentImageId}
|
||||
>
|
||||
<Icons.Plus className="mr-2" /> Add current frame
|
||||
</Button> */}
|
||||
<Button variant="ghost" onClick={() => downloadJSON()}>
|
||||
<Icons.Download className="h-5 w-5" />
|
||||
<span>JSON</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setShowOverlayCommand(!showOverlay)}>
|
||||
{showOverlay ? <Icons.Hide className="h-5 w-5" /> : <Icons.Show className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full overflow-hidden">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-blue-900 text-blue-300">
|
||||
<th></th>
|
||||
<th className="py-2 px-3 text-left">Frame</th>
|
||||
<th className="py-2 px-3 text-center">Pleura lines</th>
|
||||
<th className="py-2 px-3 text-center">B-lines</th>
|
||||
<th className="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{annotatedFrames.map(item => (
|
||||
<tr
|
||||
key={item.frame}
|
||||
className={`border-b border-blue-900 ${
|
||||
item.frame === 5 ? 'bg-cyan-800 bg-opacity-30' : ''
|
||||
}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td className="py-3 px-3">{item.index}</td>
|
||||
<td className="py-3 px-3">{item.frame + 1}</td>
|
||||
<td className="py-3 px-3 text-center">{item.pleura}</td>
|
||||
<td className="py-3 px-3 text-center">{item.bLine}</td>
|
||||
<td className="py-3 px-3 text-right">
|
||||
{item.frame === 5 && (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="ghost" className="p-0 text-blue-300">
|
||||
<Icons.EyeVisible />
|
||||
</Button>
|
||||
<Button variant="ghost" className="ml-2 p-0 text-blue-300">
|
||||
<Icons.More />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PanelSection.Content>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
const updateAnnotatedFrames = () => {
|
||||
const activeViewportId = viewportGridService.getActiveViewportId();
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(activeViewportId);
|
||||
// copying to avoid mutating the original array
|
||||
const imageIdsMonitored = [...imageIdsToObserve];
|
||||
const imageIdFilter = (imageId: string) => {
|
||||
if (imageIdsMonitored.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return imageIdsMonitored.includes(imageId);
|
||||
};
|
||||
const mapping = UltrasoundPleuraBLineTool.countAnnotations(viewport.element, imageIdFilter);
|
||||
if (!mapping) {
|
||||
setAnnotatedFrames([]);
|
||||
return;
|
||||
}
|
||||
const keys = Array.from(mapping.keys());
|
||||
const updatedFrames = keys.map((key, index) => {
|
||||
const { pleura, bLine, frame } = mapping.get(key) || { pleura: 0, bLine: 0, frame: 0 };
|
||||
return { imageId: key, index: index + 1, frame, pleura, bLine };
|
||||
});
|
||||
setAnnotatedFrames(updatedFrames);
|
||||
};
|
||||
/**
|
||||
* Callback function that is called when an annotation is modified
|
||||
* Updates the annotatedFrames state with the latest annotation data
|
||||
*/
|
||||
const annotationModified = React.useCallback(
|
||||
event => {
|
||||
if (event.detail.annotation.metadata.toolName === UltrasoundPleuraBLineTool.toolName) {
|
||||
updateAnnotatedFrames();
|
||||
}
|
||||
},
|
||||
[viewportGridService, cornerstoneViewportService, imageIdsToObserve]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
eventTarget.addEventListener(csToolsEnums.Events.ANNOTATION_MODIFIED, annotationModified);
|
||||
const { unsubscribe } = measurementService.subscribe(
|
||||
measurementService.EVENTS.MEASUREMENT_REMOVED,
|
||||
() => {
|
||||
updateAnnotatedFrames();
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
eventTarget.removeEventListener(csToolsEnums.Events.ANNOTATION_MODIFIED, annotationModified);
|
||||
unsubscribe();
|
||||
};
|
||||
}, [annotationModified, measurementService]);
|
||||
|
||||
/**
|
||||
* ──────────────────────────────────────────────────────
|
||||
* 🖼 Final Render */
|
||||
return (
|
||||
<div className="h-full w-64 bg-black text-white">
|
||||
{/* Workflow */}
|
||||
<PanelSection>
|
||||
<PanelSection.Header>Workflow</PanelSection.Header>
|
||||
{renderWorkflowToggles()}
|
||||
</PanelSection>
|
||||
|
||||
{/* Progress
|
||||
<PanelSection>
|
||||
<SectionHeader title="Workflow Progress" actionLabel="Source Folder" />
|
||||
{renderWorkflowProgress()}
|
||||
</PanelSection> */}
|
||||
|
||||
{/* Annotations */}
|
||||
<PanelSection>
|
||||
<PanelSection.Header> Annotations </PanelSection.Header>
|
||||
{renderSectorAnnotations()}
|
||||
</PanelSection>
|
||||
|
||||
{/* Annotated frames */}
|
||||
<PanelSection className="flex-1">
|
||||
<PanelSection.Header> Annotated Frames </PanelSection.Header>
|
||||
{renderAnnotatedFrames()}
|
||||
</PanelSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import getInstanceByImageId from '../getInstanceByImageId';
|
||||
|
||||
class FanShapeGeometryProvider {
|
||||
services;
|
||||
constructor(services) {
|
||||
this.services = services;
|
||||
}
|
||||
get(query, imageId) {
|
||||
if (query !== 'ultrasoundFanShapeGeometry') {
|
||||
return null;
|
||||
}
|
||||
const instance = getInstanceByImageId(this.services, imageId);
|
||||
// here you can add your logic to retrieve the fan shape geometry
|
||||
// based on the instance or imageId
|
||||
// The value returned should be an object with the following structure:
|
||||
// return {
|
||||
// center: Types.Point2; // The center of the fan shape in pixel coordinates (e.g. [-70, 80])
|
||||
// startAngle: number; // The starting angle of the fan shape in degrees (e.g. 60)
|
||||
// endAngle: number; // The ending angle of the fan shape in degrees (e.g. 120)
|
||||
// innerRadius: number; // The inner radius of the fan shape (e.g. 300)
|
||||
// outerRadius: number; // The outer radius of the fan shape (e.g. 650)
|
||||
// };
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default FanShapeGeometryProvider;
|
||||
Reference in new issue
Block a user