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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user