feat: Ultrasound Pleura B-line Annotation Mode & Extension (#5061)

This commit is contained in:
rodrigobasilio2022 authored and GitHub committed 2025-06-24 12:50:48 -04:00
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;
+5
View File
@@ -0,0 +1,5 @@
import packageJson from '../package.json';
const id = packageJson.name;
export { id };
+90
View File
@@ -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 };
+8
View File
@@ -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;