feature(segmentation): Add contour tools and utilities in a side panel (#5517)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
This commit is contained in:
Joe Boccanfuso 2025-10-29 14:15:52 -04:00 committed by GitHub
parent ab440f086a
commit 35bfc9b0cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 2770 additions and 435 deletions

View File

@ -3,7 +3,7 @@ import { classes, Types } from '@ohif/core';
import { cache, metaData } from '@cornerstonejs/core';
import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools';
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
import { createReportDialogPrompt } from '@ohif/extension-default';
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
import { DicomMetadataStore } from '@ohif/core';
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
@ -37,7 +37,7 @@ const commandsModule = ({
servicesManager,
extensionManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const { segmentationService, displaySetService, viewportGridService, toolGroupService } =
const { segmentationService, displaySetService, viewportGridService } =
servicesManager.services as AppTypes.Services;
const actions = {
@ -307,6 +307,17 @@ const commandsModule = ({
console.warn(e);
}
},
toggleActiveSegmentationUtility: ({ itemId: buttonId }) => {
const { uiState, setUIState } = useUIStateStore.getState();
const isButtonActive = uiState['activeSegmentationUtility'] === buttonId;
console.log('toggleActiveSegmentationUtility', isButtonActive, buttonId);
// if the button is active, clear the active segmentation utility
if (isButtonActive) {
setUIState('activeSegmentationUtility', null);
} else {
setUIState('activeSegmentationUtility', buttonId);
}
},
};
const definitions = {
@ -326,6 +337,9 @@ const commandsModule = ({
downloadRTSS: {
commandFn: actions.downloadRTSS,
},
toggleActiveSegmentationUtility: {
commandFn: actions.toggleActiveSegmentationUtility,
},
};
return {

View File

@ -0,0 +1,243 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useRunCommand, useSystem } from '@ohif/core';
import { useActiveViewportSegmentationRepresentations } from '@ohif/extension-cornerstone';
import {
Button,
cn,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Switch,
Tabs,
TabsList,
TabsTrigger,
} from '@ohif/ui-next';
import { Icons } from '@ohif/ui-next';
import { contourSegmentation } from '@cornerstonejs/tools/utilities';
import { Segment } from '@cornerstonejs/tools/types';
const { LogicalOperation } = contourSegmentation;
const options = [
{
value: 'merge',
logicalOperation: LogicalOperation.Union,
label: 'Merge',
icon: 'actions-combine-merge',
helperIcon: 'helper-combine-merge',
},
{
value: 'intersect',
logicalOperation: LogicalOperation.Intersect,
label: 'Intersect',
icon: 'actions-combine-intersect',
helperIcon: 'helper-combine-intersect',
},
{
value: 'subtract',
logicalOperation: LogicalOperation.Subtract,
label: 'Subtract',
icon: 'actions-combine-subtract',
helperIcon: 'helper-combine-subtract',
},
];
// Shared component for segment selection
function SegmentSelector({
label,
value,
onValueChange,
segments,
placeholder = 'Select a segment',
}: {
label: string;
value: string;
onValueChange: (value: string) => void;
segments: Segment[];
placeholder?: string;
}) {
return (
<div className="flex justify-between gap-6">
<div>{label}</div>
<Select
key={`select-segment-${label}`}
onValueChange={onValueChange}
value={value}
>
<SelectTrigger className="overflow-hidden">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{segments.map(segment => (
<SelectItem
key={segment.segmentIndex}
value={segment.segmentIndex.toString()}
>
{segment.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function LogicalContourOperationOptions() {
const { servicesManager } = useSystem();
const { segmentationService } = servicesManager.services;
const { segmentationsWithRepresentations } = useActiveViewportSegmentationRepresentations();
const activeRepresentation = segmentationsWithRepresentations?.find(
({ representation }) => representation?.active
);
const segments = activeRepresentation
? Object.values(activeRepresentation.segmentation.segments)
: [];
// Calculate the next available segment index
const nextSegmentIndex = activeRepresentation
? segmentationService.getNextAvailableSegmentIndex(
activeRepresentation.segmentation.segmentationId
)
: 1;
const activeSegment = segments.find(segment => segment.active);
const activeSegmentIndex = activeSegment?.segmentIndex || 0;
const [operation, setOperation] = useState(options[0]);
const [segmentA, setSegmentA] = useState<string>(activeSegmentIndex?.toString() || '');
const [segmentB, setSegmentB] = useState<string>('');
const [createNewSegment, setCreateNewSegment] = useState<boolean>(false);
const [newSegmentName, setNewSegmentName] = useState<string>('');
useEffect(() => {
setSegmentA(activeSegmentIndex?.toString() || null);
}, [activeSegmentIndex]);
useEffect(() => {
setNewSegmentName(`Segment ${nextSegmentIndex}`);
}, [nextSegmentIndex]);
const runCommand = useRunCommand();
const applyLogicalContourOperation = useCallback(() => {
let resultSegmentIndex = segmentA;
if (createNewSegment) {
resultSegmentIndex = nextSegmentIndex.toString();
runCommand('addSegment', {
segmentationId: activeRepresentation.segmentation.segmentationId,
config: {
label: newSegmentName,
segmentIndex: nextSegmentIndex,
},
});
}
runCommand('applyLogicalContourOperation', {
segmentAInfo: {
segmentationId: activeRepresentation.segmentation.segmentationId,
segmentIndex: parseInt(segmentA),
},
segmentBInfo: {
segmentationId: activeRepresentation.segmentation.segmentationId,
segmentIndex: parseInt(segmentB),
},
resultSegmentInfo: {
segmentationId: activeRepresentation.segmentation.segmentationId,
segmentIndex: parseInt(resultSegmentIndex),
},
logicalOperation: operation.logicalOperation,
});
}, [
activeRepresentation?.segmentation?.segmentationId,
createNewSegment,
newSegmentName,
nextSegmentIndex,
operation.logicalOperation,
runCommand,
segmentA,
segmentB,
]);
return (
<div className="flex w-[245px] flex-col gap-4">
<div className="flex items-start justify-between">
<div className="flex w-auto flex-col items-center gap-2 text-base font-normal leading-none">
<Tabs value={operation.value}>
<TabsList className="inline-flex space-x-1">
{options.map(option => {
const { value, icon } = option;
return (
<TabsTrigger
value={value}
key={`logical-contour-operation-${value}`}
onClick={() => setOperation(option)}
>
<Icons.ByName name={icon}></Icons.ByName>
</TabsTrigger>
);
})}
</TabsList>
</Tabs>
<div>{operation.label}</div>
</div>
<div className="bg-primary-dark flex h-[62px] w-[88px] items-center justify-center rounded-lg">
<Icons.ByName name={operation.helperIcon}></Icons.ByName>
</div>
</div>
<SegmentSelector
label="A"
value={segmentA}
onValueChange={setSegmentA}
segments={segments}
/>
<SegmentSelector
label="B"
value={segmentB}
onValueChange={setSegmentB}
segments={segments}
/>
<div className="flex justify-end pl-[34px]">
<Button
className="border-primary/60 grow border"
variant="ghost"
onClick={() => {
applyLogicalContourOperation();
}}
>
{operation.label}
</Button>
</div>
<Separator className="bg-input mt-2 h-[1px]" />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-start gap-2">
<Switch
id="logical-contour-operations-create-new-segment-switch"
onCheckedChange={setCreateNewSegment}
></Switch>
<Label htmlFor="logical-contour-operations-create-new-segment-switch">
Create a new segment
</Label>
</div>
<div className="pl-9">
<Input
className={cn(createNewSegment ? 'visible' : 'hidden')}
disabled={!createNewSegment}
id="logical-contour-operations-create-new-segment-input"
type="text"
placeholder="New segment name"
value={newSegmentName}
onChange={e => setNewSegmentName(e.target.value)}
/>
</div>
</div>
</div>
);
}
export default LogicalContourOperationOptions;

View File

@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { Button, Input, Label, Separator } from '@ohif/ui-next';
import { useRunCommand } from '@ohif/core';
function SimplifyContourOptions() {
const [areaThreshold, setAreaThreshold] = useState(10);
const runCommand = useRunCommand();
return (
<div className="flex w-auto w-[252px] flex-col gap-[8px] text-base font-normal leading-none">
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>Fill contour holes</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('removeContourHoles');
}}
>
Fill Holes
</Button>
<Separator className="bg-input mt-[20px] h-[1px]" />
</div>
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>Remove Small Contours</div>
<div className="flex items-center gap-2 self-end">
<Label
htmlFor="simplify-contour-options"
className="text-muted-foreground"
>
Area Threshold
</Label>
<Input
id="simplify-contour-options"
className="w-20"
type="number"
value={areaThreshold}
onChange={e => setAreaThreshold(Number(e.target.value))}
/>
</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('removeSmallContours', {
areaThreshold,
});
}}
>
Remove Small Contours
</Button>
<Separator className="bg-input mt-[20px] h-[1px]" />
</div>
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>Create New Segment from Holes</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('convertContourHoles');
}}
>
Create New Segment
</Button>
</div>
</div>
);
}
export default SimplifyContourOptions;

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Button, Separator } from '@ohif/ui-next';
import { useRunCommand } from '@ohif/core';
function SmoothContoursOptions() {
const runCommand = useRunCommand();
return (
<div className="flex w-auto w-[245px] flex-col gap-[8px] text-base font-normal leading-none">
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>Smooth all edges</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('smoothContours');
}}
>
Smooth Edges
</Button>
<Separator className="bg-input mt-[20px] h-[1px]" />
</div>
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
<div>Remove extra points</div>
<Button
className="border-primary/60 border"
variant="ghost"
onClick={() => {
runCommand('decimateContours');
}}
>
Remove Points
</Button>
</div>
</div>
);
}
export default SmoothContoursOptions;

View File

@ -1,6 +1,32 @@
import { useUIStateStore } from '@ohif/extension-default';
import LogicalContourOperationsOptions from './components/LogicalContourOperationsOptions';
import SimplifyContourOptions from './components/SimplifyContourOptions';
import SmoothContoursOptions from './components/SmoothContoursOptions';
export function getToolbarModule({ servicesManager }: withAppTypes) {
const { segmentationService, toolbarService, toolGroupService } = servicesManager.services;
return [
{
name: 'cornerstone.SimplifyContourOptions',
defaultComponent: SimplifyContourOptions,
},
{
name: 'cornerstone.LogicalContourOperationsOptions',
defaultComponent: LogicalContourOperationsOptions,
},
{
name: 'cornerstone.SmoothContoursOptions',
defaultComponent: SmoothContoursOptions,
},
{
name: 'cornerstone.isActiveSegmentationUtility',
evaluate: ({ button }) => {
const { uiState } = useUIStateStore.getState();
return {
isActive: uiState[`activeSegmentationUtility`] === button.id,
};
},
},
{
name: 'evaluate.cornerstone.hasSegmentation',
evaluate: ({ viewportId }) => {
@ -10,6 +36,30 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
};
},
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
evaluate: ({ viewportId, segmentationRepresentationType }) => {
const segmentations = segmentationService.getSegmentationRepresentations(viewportId);
if (!segmentations?.length) {
return {
disabled: true,
disabledText: 'No segmentations available',
};
}
if (
!segmentations.some(segmentation =>
Boolean(segmentation.type === segmentationRepresentationType)
)
) {
return {
disabled: true,
disabledText: `No ${segmentationRepresentationType} segmentations available`,
};
}
},
},
{
name: 'evaluate.cornerstone.segmentation',
evaluate: ({ viewportId, button, toolNames, disabledText }) => {

View File

@ -14,13 +14,18 @@ import {
utilities as cstUtils,
annotation,
Types as ToolTypes,
SplineContourSegmentationTool,
} from '@cornerstonejs/tools';
import {
SegmentInfo,
LogicalOperation,
OperatorOptions,
} from '@cornerstonejs/tools/utilities/contourSegmentation/logicalOperators';
import * as cornerstoneTools from '@cornerstonejs/tools';
import * as labelmapInterpolation from '@cornerstonejs/labelmap-interpolation';
import { ONNXSegmentationController } from '@cornerstonejs/ai';
import { Types as OhifTypes, utils } from '@ohif/core';
import i18n from '@ohif/i18n';
import {
callInputDialogAutoComplete,
createReportAsync,
@ -33,7 +38,11 @@ import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/u
import { getViewportEnabledElement } from './utils/getViewportEnabledElement';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import toggleVOISliceSync from './utils/toggleVOISliceSync';
import { usePositionPresentationStore, useSegmentationPresentationStore } from './stores';
import {
usePositionPresentationStore,
useSegmentationPresentationStore,
useSelectedSegmentationsForViewportStore,
} from './stores';
import { toolNames } from './initCornerstoneTools';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats';
@ -43,6 +52,11 @@ import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
import { isMeasurementWithinViewport } from './utils/isMeasurementWithinViewport';
import { getCenterExtent } from './utils/getCenterExtent';
import { EasingFunctionEnum } from './utils/transitions';
import { createSegmentationForViewport } from './utils/createSegmentationForViewport';
import { utilities as segmentationUtilities } from '@cornerstonejs/tools/segmentation';
import i18n from '@ohif/i18n';
const { add, intersect, subtract, copy } = cstUtils.contourSegmentation;
const { DefaultHistoryMemo } = csUtils.HistoryMemo;
const toggleSyncFunctions = {
@ -963,17 +977,21 @@ function commandsModule({
}
}
},
setToolActiveToolbar: ({ value, itemId, toolName, toolGroupIds = [] }) => {
setToolActiveToolbar: ({ value, itemId, toolName, toolGroupIds = [], bindings }) => {
// Sometimes it is passed as value (tools with options), sometimes as itemId (toolbar buttons)
toolName = toolName || itemId || value;
toolGroupIds = toolGroupIds.length ? toolGroupIds : toolGroupService.getToolGroupIds();
toolGroupIds.forEach(toolGroupId => {
actions.setToolActive({ toolName, toolGroupId });
actions.setToolActive({ toolName, toolGroupId, bindings });
});
},
setToolActive: ({ toolName, toolGroupId = null }) => {
setToolActive: ({
toolName,
toolGroupId = null,
bindings = [{ mouseButton: Enums.MouseBindings.Primary }],
}) => {
const { viewports } = viewportGridService.getState();
if (!viewports.size) {
@ -1001,11 +1019,7 @@ function commandsModule({
// Set the new toolName to be active
toolGroup.setToolActive(toolName, {
bindings: [
{
mouseButton: Enums.MouseBindings.Primary,
},
],
bindings,
});
},
// capture viewport
@ -1478,48 +1492,24 @@ function commandsModule({
* as a segmentation representation to the viewport.
*/
createLabelmapForViewport: async ({ viewportId, options = {} }) => {
const { viewportGridService, displaySetService, segmentationService } =
servicesManager.services;
const { viewports } = viewportGridService.getState();
const targetViewportId = viewportId;
const viewport = viewports.get(targetViewportId);
// Todo: add support for multiple display sets
const displaySetInstanceUID =
options.displaySetInstanceUID || viewport.displaySetInstanceUIDs[0];
const segs = segmentationService.getSegmentations();
const label = options.label || `${i18n.t('Tools:Segmentation')} ${segs.length + 1}`;
const segmentationId = options.segmentationId || `${csUtils.uuidv4()}`;
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
// This will create the segmentation and register it as a display set
const generatedSegmentationId = await segmentationService.createLabelmapForDisplaySet(
displaySet,
{
label,
segmentationId,
segments: options.createInitialSegment
? {
1: {
label: `${i18n.t('Tools:Segment')} 1`,
active: true,
},
}
: {},
}
);
// Also add the segmentation representation to the viewport
await segmentationService.addSegmentationRepresentation(viewportId, {
segmentationId,
type: Enums.SegmentationRepresentations.Labelmap,
return createSegmentationForViewport(servicesManager, {
viewportId,
options,
segmentationType: SegmentationRepresentations.Labelmap,
});
},
/**
* Creates a contour for the active viewport
*
* The created contour will be registered as a display set and also added
* as a segmentation representation to the viewport.
*/
createContourForViewport: async ({ viewportId, options = {} }) => {
return createSegmentationForViewport(servicesManager, {
viewportId,
options,
segmentationType: SegmentationRepresentations.Contour,
});
return generatedSegmentationId;
},
/**
@ -1538,9 +1528,9 @@ function commandsModule({
* Adds a new segment to a segmentation
* @param props.segmentationId - The ID of the segmentation to add the segment to
*/
addSegmentCommand: ({ segmentationId }) => {
addSegmentCommand: ({ segmentationId, config }) => {
const { segmentationService } = servicesManager.services;
segmentationService.addSegment(segmentationId);
segmentationService.addSegment(segmentationId, config);
},
/**
@ -2269,6 +2259,207 @@ function commandsModule({
deleting,
});
},
activateSelectedSegmentationOfType: ({ segmentationRepresentationType }) => {
const { segmentationService, viewportGridService } = servicesManager.services;
const activeViewportId = viewportGridService.getActiveViewportId();
const { selectedSegmentationsForViewport } =
useSelectedSegmentationsForViewportStore.getState();
const segmentationId = selectedSegmentationsForViewport[activeViewportId]?.get(
segmentationRepresentationType
);
if (!segmentationId) {
return;
}
segmentationService.setActiveSegmentation(activeViewportId, segmentationId);
},
setDynamicCursorSizeForSculptorTool: ({ value: isDynamicCursorSize }) => {
const viewportId = viewportGridService.getActiveViewportId();
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
const sculptorToolInstance = toolGroup.getToolInstance(toolNames.SculptorTool);
const oldConfiguration = sculptorToolInstance.configuration;
sculptorToolInstance.configuration = {
...oldConfiguration,
updateCursorSize: isDynamicCursorSize ? 'dynamic' : '',
};
},
setInterpolationToolConfiguration: ({ value: interpolateContours, toolNames }) => {
const viewportId = viewportGridService.getActiveViewportId();
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
// Set the interpolation configuration for the active tool.
const activeTool = toolGroupService.getActiveToolForViewport(viewportId);
const interpolationConfig = {
interpolation: {
enabled: interpolateContours,
showInterpolationPolyline: true,
},
};
toolGroup.setToolConfiguration(activeTool, interpolationConfig);
// Now set the interpolation configuration for the other tools specified.
if (toolNames) {
Object.values(toolGroup.getToolInstances()).forEach(toolInstance => {
if (toolNames?.includes(toolInstance.toolName)) {
toolGroup.setToolConfiguration(toolInstance.toolName, interpolationConfig);
}
});
}
},
setSimplifiedSplineForSplineContourSegmentationTool: ({ value: simplifiedSpline }) => {
const viewportId = viewportGridService.getActiveViewportId();
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
Object.values(toolGroup.getToolInstances()).forEach(toolInstance => {
if (toolInstance instanceof SplineContourSegmentationTool) {
const oldConfiguration = toolInstance.configuration;
toolInstance.configuration = {
...oldConfiguration,
simplifiedSpline,
};
}
});
},
removeSmallContours: ({ areaThreshold: threshold }) => {
const viewportId = viewportGridService.getActiveViewportId();
const activeSegmentation = segmentationService.getActiveSegmentation(viewportId);
const activeSegment = segmentationService.getActiveSegment(viewportId);
if (!activeSegmentation || !activeSegment) {
return;
}
const { removeContourIslands } = segmentationUtilities;
removeContourIslands(activeSegmentation.segmentationId, activeSegment.segmentIndex, {
threshold,
});
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
renderingEngine.render();
},
applyLogicalContourOperation: ({
segmentAInfo,
segmentBInfo,
resultSegmentInfo,
logicalOperation,
}: {
segmentAInfo: SegmentInfo;
segmentBInfo: SegmentInfo;
resultSegmentInfo: OperatorOptions;
logicalOperation: LogicalOperation;
}) => {
switch (logicalOperation) {
case LogicalOperation.Union:
add(segmentAInfo, segmentBInfo, resultSegmentInfo);
break;
case LogicalOperation.Intersect:
intersect(segmentAInfo, segmentBInfo, resultSegmentInfo);
break;
case LogicalOperation.Subtract:
subtract(segmentAInfo, segmentBInfo, resultSegmentInfo);
break;
default:
throw new Error('Unsupported logical operation');
break;
}
},
copyContourSegment: ({
sourceSegmentInfo,
targetSegmentInfo,
}: {
sourceSegmentInfo: SegmentInfo;
targetSegmentInfo?: SegmentInfo;
}) => {
if (!targetSegmentInfo) {
targetSegmentInfo = {
segmentationId: sourceSegmentInfo.segmentationId,
segmentIndex: segmentationService.getNextAvailableSegmentIndex(
sourceSegmentInfo.segmentationId
),
};
segmentationService.addSegment(targetSegmentInfo.segmentationId, {
segmentIndex: targetSegmentInfo.segmentIndex,
});
}
copy(sourceSegmentInfo, targetSegmentInfo);
},
smoothContours: () => {
const viewportId = viewportGridService.getActiveViewportId();
const activeSegmentation = segmentationService.getActiveSegmentation(viewportId);
const activeSegment = segmentationService.getActiveSegment(viewportId);
if (!activeSegmentation || !activeSegment) {
return;
}
const { smoothContours } = segmentationUtilities;
smoothContours(activeSegmentation.segmentationId, activeSegment.segmentIndex);
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
renderingEngine.render();
},
removeContourHoles: () => {
const viewportId = viewportGridService.getActiveViewportId();
const activeSegmentation = segmentationService.getActiveSegmentation(viewportId);
const activeSegment = segmentationService.getActiveSegment(viewportId);
if (!activeSegmentation || !activeSegment) {
return;
}
const { removeContourHoles } = segmentationUtilities;
removeContourHoles(activeSegmentation.segmentationId, activeSegment.segmentIndex);
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
renderingEngine.render();
},
decimateContours: () => {
const viewportId = viewportGridService.getActiveViewportId();
const activeSegmentation = segmentationService.getActiveSegmentation(viewportId);
const activeSegment = segmentationService.getActiveSegment(viewportId);
if (!activeSegmentation || !activeSegment) {
return;
}
const { decimateContours } = segmentationUtilities;
decimateContours(activeSegmentation.segmentationId, activeSegment.segmentIndex);
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
renderingEngine.render();
},
convertContourHoles: () => {
const viewportId = viewportGridService.getActiveViewportId();
const activeSegmentation = segmentationService.getActiveSegmentation(viewportId);
const activeSegment = segmentationService.getActiveSegment(viewportId);
if (!activeSegmentation || !activeSegment) {
return;
}
const targetSegmentInfo = {
segmentationId: activeSegmentation.segmentationId,
segmentIndex: segmentationService.getNextAvailableSegmentIndex(
activeSegmentation.segmentationId
),
};
segmentationService.addSegment(targetSegmentInfo.segmentationId, {
segmentIndex: targetSegmentInfo.segmentIndex,
});
const { convertContourHoles } = segmentationUtilities;
convertContourHoles(
activeSegmentation.segmentationId,
activeSegment.segmentIndex,
targetSegmentInfo.segmentationId,
targetSegmentInfo.segmentIndex
);
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
renderingEngine.render();
},
};
const definitions = {
@ -2467,6 +2658,9 @@ function commandsModule({
createLabelmapForViewport: {
commandFn: actions.createLabelmapForViewport,
},
createContourForViewport: {
commandFn: actions.createContourForViewport,
},
setActiveSegmentation: {
commandFn: actions.setActiveSegmentation,
},
@ -2568,6 +2762,18 @@ function commandsModule({
toggleSegmentLabel: actions.toggleSegmentLabel,
jumpToMeasurementViewport: actions.jumpToMeasurementViewport,
initializeSegmentLabelTool: actions.initializeSegmentLabelTool,
activateSelectedSegmentationOfType: actions.activateSelectedSegmentationOfType,
setDynamicCursorSizeForSculptorTool: actions.setDynamicCursorSizeForSculptorTool,
setSimplifiedSplineForSplineContourSegmentationTool:
actions.setSimplifiedSplineForSplineContourSegmentationTool,
removeSmallContours: actions.removeSmallContours,
applyLogicalContourOperation: actions.applyLogicalContourOperation,
copyContourSegment: actions.copyContourSegment,
smoothContours: actions.smoothContours,
removeContourHoles: actions.removeContourHoles,
decimateContours: actions.decimateContours,
convertContourHoles: actions.convertContourHoles,
setInterpolationToolConfiguration: actions.setInterpolationToolConfiguration,
};
return {

View File

@ -0,0 +1,111 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
Icons,
} from '@ohif/ui-next';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
interface ExportSegmentationSubMenuItemProps {
segmentationId: string;
segmentationRepresentationType: string;
allowExport: boolean;
actions: {
storeSegmentation: (segmentationId: string) => Promise<void>;
onSegmentationDownloadRTSS: (segmentationId: string) => void;
onSegmentationDownload: (segmentationId: string) => void;
downloadCSVSegmentationReport: (segmentationId: string) => void;
};
}
export const ExportSegmentationSubMenuItem: React.FC<ExportSegmentationSubMenuItemProps> = ({
segmentationId,
segmentationRepresentationType,
allowExport,
actions,
}) => {
const { t } = useTranslation('SegmentationTable');
return (
<>
{segmentationRepresentationType === SegmentationRepresentations.Labelmap && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="pl-1">
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Download & Export')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuLabel className="flex items-center pl-0">
<Icons.Download className="h-5 w-5" />
<span className="pl-1">{t('Download')}</span>
</DropdownMenuLabel>
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.downloadCSVSegmentationReport(segmentationId);
}}
disabled={!allowExport}
>
{t('CSV Report')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.onSegmentationDownload(segmentationId);
}}
disabled={!allowExport}
>
{t('DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.onSegmentationDownloadRTSS(segmentationId);
}}
disabled={!allowExport}
>
{t('DICOM RTSS')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center pl-0">
<Icons.Export className="h-5 w-5" />
<span className="pl-1 pt-1">{t('Export')}</span>
</DropdownMenuLabel>
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.storeSegmentation(segmentationId);
}}
disabled={!allowExport}
>
{t('DICOM SEG')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)}
{segmentationRepresentationType === SegmentationRepresentations.Contour && (
<>
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.onSegmentationDownloadRTSS(segmentationId);
}}
disabled={!allowExport}
>
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Download DICOM RTSS')}</span>
</DropdownMenuItem>
</>
)}
</>
);
};

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Switch } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
export default function SegmentationToolConfig() {
const { commandsManager } = useSystem();
// Get initial states based on current configuration
const [previewEdits, setPreviewEdits] = useState(false);
const [segmentLabelEnabled, setSegmentLabelEnabled] = useState(false);
const [toggleSegmentEnabled, setToggleSegmentEnabled] = useState(false);
const [useCenterAsSegmentIndex, setUseCenterAsSegmentIndex] = useState(false);
const handlePreviewEditsChange = checked => {
setPreviewEdits(checked);
commandsManager.run('toggleSegmentPreviewEdit', { toggle: checked });
};
const handleToggleSegmentEnabledChange = checked => {
setToggleSegmentEnabled(checked);
commandsManager.run('toggleSegmentSelect', { toggle: checked });
};
const handleUseCenterAsSegmentIndexChange = checked => {
setUseCenterAsSegmentIndex(checked);
commandsManager.run('toggleUseCenterSegmentIndex', { toggle: checked });
};
const handleSegmentLabelEnabledChange = checked => {
setSegmentLabelEnabled(checked);
commandsManager.run('toggleSegmentLabel', { enabled: checked });
};
return (
<div className="bg-muted flex flex-col gap-2 border-b border-b-[2px] border-black px-2 py-3">
<div className="flex items-center gap-2">
<Switch
checked={previewEdits}
onCheckedChange={handlePreviewEditsChange}
/>
<span className="text-foreground text-base">Preview edits before creating</span>
</div>
<div className="flex items-center gap-2">
<Switch
checked={useCenterAsSegmentIndex}
onCheckedChange={handleUseCenterAsSegmentIndexChange}
/>
<span className="text-foreground text-base">Use center as segment index</span>
</div>
<div className="flex items-center gap-2">
<Switch
checked={toggleSegmentEnabled}
onCheckedChange={handleToggleSegmentEnabledChange}
/>
<span className="text-foreground text-base">Hover on segment border to activate</span>
</div>
<div className="flex items-center gap-2">
<Switch
checked={segmentLabelEnabled}
onCheckedChange={handleSegmentLabelEnabledChange}
/>
<span className="text-foreground text-base">Show segment name on hover</span>
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
import React, { useCallback } from 'react';
import { cn, ToolButton } from '@ohif/ui-next';
import { useUIStateStore } from '@ohif/extension-default';
interface SegmentationUtilityButtonProps {
className?: string;
isActive?: boolean;
id: string;
}
/**
* A button that represents a segmentation utility.
* It is implicitly the PopoverTrigger for the options Popover panel that is
* opened in the PanelSegmentation component. It in essence is the PopoverTrigger
* because typically this button has the associated options to display in the Popover.
* Furthermore it also typically has a command for toggling the activeSegmentationUtility
* value in the UIStateStore that triggers the fetching of its options.
* @param props - The props for the button.
* @param props.className - The class name for the button.
* @param props.isActive - Whether the button is active
* @param props.id - The id of the button.
*/
function SegmentationUtilityButton(props: SegmentationUtilityButtonProps) {
const { className, isActive, id } = props;
const activeSegmentationUtility = useUIStateStore(
store => store.uiState.activeSegmentationUtility
);
const toolButtonClassName = cn(
'w-7 h-7 text-primary hover:text-primary hover:!bg-primary/30',
className,
isActive && 'bg-primary/30'
);
const handleMouseDownCapture = useCallback(
event => {
if (activeSegmentationUtility === id) {
// If this active button is clicked, prevent the default Popover
// behaviour of closing the Popover on a pointer/mouse down.
// Not doing this will cause the Popover to close and then reopen again.
// Why? Because propagating this event will cause PanelSegmentation to
// close the Popover by clearing the activeSegmentationUtility. Then
// this button will set the activeSegmentationUtility again and the
// Popover will reopen.
event.preventDefault();
event.stopPropagation();
}
},
[activeSegmentationUtility, id]
);
return (
<div onPointerDownCapture={handleMouseDownCapture}>
<ToolButton
{...props}
className={toolButtonClassName}
/>
</div>
);
}
export default SegmentationUtilityButton;

View File

@ -3,17 +3,14 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
Icons,
useSegmentationTableContext,
useSegmentationExpanded,
} from '@ohif/ui-next';
import { useTranslation } from 'react-i18next';
import { useSystem } from '@ohif/core/src';
import { ExportSegmentationSubMenuItem } from '../components/ExportSegmentationSubMenuItem';
/**
* Custom dropdown menu component for segmentation panel that uses context for data
@ -29,6 +26,8 @@ export const CustomDropdownMenuContent = () => {
exportOptions,
activeSegmentation,
activeSegmentationId,
segmentationRepresentationType,
disableEditing,
} = useSegmentationTableContext('CustomDropdownMenu');
// Try to get segmentation data from expanded context first, fall back to table context
@ -78,10 +77,14 @@ export const CustomDropdownMenuContent = () => {
return (
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onSegmentationAdd(segmentationId)}>
<Icons.Add className="text-foreground" />
<span className="pl-2">{t('Create New Segmentation')}</span>
</DropdownMenuItem>
{!disableEditing && (
<DropdownMenuItem
onClick={() => onSegmentationAdd({ segmentationId, segmentationRepresentationType })}
>
<Icons.Add className="text-foreground" />
<span className="pl-2">{t('Create New Segmentation')}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('Manage Current Segmentation')}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onSegmentationRemoveFromViewport(segmentationId)}>
@ -92,61 +95,12 @@ export const CustomDropdownMenuContent = () => {
<Icons.Rename className="text-foreground" />
<span className="pl-2">{t('Rename')}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="pl-1">
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Download & Export')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuLabel className="flex items-center pl-0">
<Icons.Download className="h-5 w-5" />
<span className="pl-1">{t('Download')}</span>
</DropdownMenuLabel>
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.downloadCSVSegmentationReport(segmentationId);
}}
disabled={!allowExport}
>
{t('CSV Report')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.onSegmentationDownload(segmentationId);
}}
disabled={!allowExport}
>
{t('DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.onSegmentationDownloadRTSS(segmentationId);
}}
disabled={!allowExport}
>
{t('DICOM RTSS')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center pl-0">
<Icons.Export className="h-5 w-5" />
<span className="pl-1 pt-1">{t('Export')}</span>
</DropdownMenuLabel>
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.storeSegmentation(segmentationId);
}}
disabled={!allowExport}
>
{t('DICOM SEG')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<ExportSegmentationSubMenuItem
segmentationId={segmentationId}
segmentationRepresentationType={segmentationRepresentationType}
allowExport={allowExport}
actions={actions}
/>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSegmentationDelete(segmentationId)}>
<Icons.Delete className="text-red-600" />

View File

@ -1,7 +1,9 @@
import { CustomDropdownMenuContent } from './CustomDropdownMenuContent';
import { CustomSegmentStatisticsHeader } from './CustomSegmentStatisticsHeader';
import React, { useState } from 'react';
import { Switch } from '@ohif/ui-next';
import SegmentationToolConfig from '../components/SegmentationToolConfig';
import React from 'react';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
import * as cornerstoneTools from '@cornerstonejs/tools';
export default function getSegmentationPanelCustomization({ commandsManager, servicesManager }) {
return {
@ -9,10 +11,25 @@ export default function getSegmentationPanelCustomization({ commandsManager, ser
'panelSegmentation.customSegmentStatisticsHeader': CustomSegmentStatisticsHeader,
'panelSegmentation.disableEditing': false,
'panelSegmentation.showAddSegment': true,
'panelSegmentation.onSegmentationAdd': () => {
'panelSegmentation.onSegmentationAdd': async ({
segmentationRepresentationType = SegmentationRepresentations.Labelmap,
}) => {
const { viewportGridService } = servicesManager.services;
const viewportId = viewportGridService.getState().activeViewportId;
commandsManager.run('createLabelmapForViewport', { viewportId });
if (segmentationRepresentationType === SegmentationRepresentations.Labelmap) {
commandsManager.run('createLabelmapForViewport', { viewportId });
} else if (segmentationRepresentationType === SegmentationRepresentations.Contour) {
const segmentationId = await commandsManager.run('createContourForViewport', {
viewportId,
});
cornerstoneTools.segmentation.config.style.setStyle(
{ segmentationId, type: SegmentationRepresentations.Contour },
{
fillAlpha: 0.5,
renderFill: true,
}
);
}
},
'panelSegmentation.tableMode': 'collapsed',
'panelSegmentation.readableText': {
@ -33,67 +50,11 @@ export default function getSegmentationPanelCustomization({ commandsManager, ser
lesionGlycolysis: 'Lesion Glycolysis',
center: 'Center',
},
'segmentationToolbox.config': () => {
// Get initial states based on current configuration
const [previewEdits, setPreviewEdits] = useState(false);
const [segmentLabelEnabled, setSegmentLabelEnabled] = useState(false);
const [toggleSegmentEnabled, setToggleSegmentEnabled] = useState(false);
const [useCenterAsSegmentIndex, setUseCenterAsSegmentIndex] = useState(false);
const handlePreviewEditsChange = checked => {
setPreviewEdits(checked);
commandsManager.run('toggleSegmentPreviewEdit', { toggle: checked });
};
const handleToggleSegmentEnabledChange = checked => {
setToggleSegmentEnabled(checked);
commandsManager.run('toggleSegmentSelect', { toggle: checked });
};
const handleUseCenterAsSegmentIndexChange = checked => {
setUseCenterAsSegmentIndex(checked);
commandsManager.run('toggleUseCenterSegmentIndex', { toggle: checked });
};
const handleSegmentLabelEnabledChange = checked => {
setSegmentLabelEnabled(checked);
commandsManager.run('toggleSegmentLabel', { enabled: checked });
};
return (
<div className="bg-muted flex flex-col gap-2 border-b border-b-[2px] border-black px-2 py-3">
<div className="flex items-center gap-2">
<Switch
checked={previewEdits}
onCheckedChange={handlePreviewEditsChange}
/>
<span className="text-foreground text-base">Preview edits before creating</span>
</div>
<div className="flex items-center gap-2">
<Switch
checked={useCenterAsSegmentIndex}
onCheckedChange={handleUseCenterAsSegmentIndexChange}
/>
<span className="text-foreground text-base">Use center as segment index</span>
</div>
<div className="flex items-center gap-2">
<Switch
checked={toggleSegmentEnabled}
onCheckedChange={handleToggleSegmentEnabledChange}
/>
<span className="text-foreground text-base">Hover on segment border to activate</span>
</div>
<div className="flex items-center gap-2">
<Switch
checked={segmentLabelEnabled}
onCheckedChange={handleSegmentLabelEnabledChange}
/>
<span className="text-foreground text-base">Show segment name on hover</span>
</div>
</div>
);
'labelMapSegmentationToolbox.config': () => {
return <SegmentationToolConfig />;
},
'contourSegmentationToolbox.config': () => {
return <SegmentationToolConfig />;
},
};
}

View File

@ -1,53 +1,67 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Toolbox } from '@ohif/extension-default';
import PanelSegmentation from './panels/PanelSegmentation';
import ActiveViewportWindowLevel from './components/ActiveViewportWindowLevel';
import PanelMeasurement from './panels/PanelMeasurement';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
import i18n from '@ohif/i18n';
const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: withAppTypes) => {
const wrappedPanelSegmentation = ({ configuration }) => {
const { toolbarService } = servicesManager.services;
const toolSectionMap = {
[SegmentationRepresentations.Labelmap]: toolbarService.sections.labelMapSegmentationToolbox,
[SegmentationRepresentations.Contour]: toolbarService.sections.contourSegmentationToolbox,
};
const wrappedPanelSegmentation = props => {
return (
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
...props?.configuration,
}}
segmentationRepresentationType={props?.segmentationRepresentationType}
/>
);
};
const wrappedPanelSegmentationNoHeader = ({ configuration }) => {
const wrappedPanelSegmentationNoHeader = props => {
return (
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
...props?.configuration,
}}
segmentationRepresentationType={props?.segmentationRepresentationType}
/>
);
};
const wrappedPanelSegmentationWithTools = ({ configuration }) => {
const { toolbarService } = servicesManager.services;
const wrappedPanelSegmentationWithTools = props => {
const { t } = useTranslation('SegmentationTable');
const tKey = `${props.segmentationRepresentationType ?? 'Segmentation'} tools`;
const tValue = t(tKey);
return (
<>
<Toolbox
buttonSectionId={toolbarService.sections.segmentationToolbox}
title="Segmentation Tools"
buttonSectionId={toolSectionMap[props.segmentationRepresentationType]}
title={tValue}
/>
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
...props?.configuration,
}}
segmentationRepresentationType={props?.segmentationRepresentationType}
/>
</>
);
@ -82,11 +96,26 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }:
component: wrappedPanelSegmentationNoHeader,
},
{
name: 'panelSegmentationWithTools',
name: 'panelSegmentationWithToolsLabelMap',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedPanelSegmentationWithTools,
label: i18n.t('SegmentationTable:Labelmap'),
component: props =>
wrappedPanelSegmentationWithTools({
...props,
segmentationRepresentationType: SegmentationRepresentations.Labelmap,
}),
},
{
name: 'panelSegmentationWithToolsContour',
iconName: 'tab-contours',
iconLabel: 'Segmentation',
label: i18n.t('SegmentationTable:Contour'),
component: props =>
wrappedPanelSegmentationWithTools({
...props,
segmentationRepresentationType: SegmentationRepresentations.Contour,
}),
},
];
};

View File

@ -44,6 +44,7 @@ import {
usePositionPresentationStore,
useSegmentationPresentationStore,
useSynchronizersStore,
useSelectedSegmentationsForViewportStore,
} from './stores';
import { useToggleOneUpViewportGridStore } from '@ohif/extension-default';
import { useActiveViewportSegmentationRepresentations } from './hooks/useActiveViewportSegmentationRepresentations';
@ -109,6 +110,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
toolbarService.registerEventForToolbarUpdate(segmentationService, [
segmentationService.EVENTS.SEGMENTATION_REMOVED,
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
segmentationService.EVENTS.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED,
]);
toolbarService.registerEventForToolbarUpdate(cornerstone.eventTarget, [
@ -153,6 +155,9 @@ const cornerstoneExtension: Types.Extensions.Extension = {
useSynchronizersStore.getState().clearSynchronizersStore();
useToggleOneUpViewportGridStore.getState().clearToggleOneUpViewportGridStore();
useSegmentationPresentationStore.getState().clearSegmentationPresentationStore();
useSelectedSegmentationsForViewportStore
.getState()
.clearSelectedSegmentationsForViewportStore();
segmentationService.removeAllSegmentations();
},
@ -256,6 +261,7 @@ export {
usePositionPresentationStore,
useSegmentationPresentationStore,
useSynchronizersStore,
useSelectedSegmentationsForViewportStore,
Enums,
useMeasurements,
useActiveViewportSegmentationRepresentations,

View File

@ -106,6 +106,12 @@ export default async function init({
colorbarService.EVENTS.STATE_CHANGED,
]);
toolbarService.registerEventForToolbarUpdate(segmentationService, [
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED,
segmentationService.EVENTS.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED,
]);
window.services = servicesManager.services;
window.extensionManager = extensionManager;
window.commandsManager = commandsManager;

View File

@ -41,6 +41,10 @@ import {
SegmentSelectTool,
RegionSegmentPlusTool,
SegmentLabelTool,
LivewireContourSegmentationTool,
SculptorTool,
SplineContourSegmentationTool,
LabelMapEditWithContourTool,
} from '@cornerstonejs/tools';
import { LabelmapSlicePropagationTool, MarkerLabelmapTool } from '@cornerstonejs/ai';
import * as polySeg from '@cornerstonejs/polymorphic-segmentation';
@ -109,6 +113,10 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(LabelmapSlicePropagationTool);
addTool(MarkerLabelmapTool);
addTool(RegionSegmentPlusTool);
addTool(LivewireContourSegmentationTool);
addTool(SculptorTool);
addTool(SplineContourSegmentationTool);
addTool(LabelMapEditWithContourTool);
// Modify annotation tools to use dashed lines on SR
const annotationStyle = {
textBoxFontSize: '15px',
@ -168,6 +176,10 @@ const toolNames = {
LabelmapSlicePropagation: LabelmapSlicePropagationTool.toolName,
MarkerLabelmap: MarkerLabelmapTool.toolName,
RegionSegmentPlus: RegionSegmentPlusTool.toolName,
LivewireContourSegmentation: LivewireContourSegmentationTool.toolName,
SculptorTool: SculptorTool.toolName,
SplineContourSegmentation: SplineContourSegmentationTool.toolName,
LabelMapEditWithContourTool: LabelMapEditWithContourTool.toolName,
};
export { toolNames };

View File

@ -1,16 +1,92 @@
import React from 'react';
import { SegmentationTable } from '@ohif/ui-next';
import React, { useCallback, useEffect } from 'react';
import {
IconPresentationProvider,
Popover,
PopoverAnchor,
PopoverContent,
SegmentationTable,
ToolSettings,
} from '@ohif/ui-next';
import { useActiveViewportSegmentationRepresentations } from '../hooks/useActiveViewportSegmentationRepresentations';
import { metaData, cache } from '@cornerstonejs/core';
import { useSystem } from '@ohif/core/src';
import { useActiveToolOptions, useSystem } from '@ohif/core/src';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
import { Toolbar, useUIStateStore } from '@ohif/extension-default';
import SegmentationUtilityButton from '../components/SegmentationUtilityButton';
import { useSelectedSegmentationsForViewportStore } from '../stores';
import {
hasExportableLabelMapData,
hasExportableContourData,
} from '../utils/segmentationExportUtils';
export default function PanelSegmentation({ children }: withAppTypes) {
type PanelSegmentationProps = {
children?: React.ReactNode;
// The representation type for this segmentation panel. Undefined means all types.
segmentationRepresentationType?: SegmentationRepresentations;
} & withAppTypes;
export default function PanelSegmentation({
children,
segmentationRepresentationType,
}: PanelSegmentationProps) {
const { commandsManager, servicesManager } = useSystem();
const { customizationService, displaySetService } = servicesManager.services;
const {
customizationService,
displaySetService,
viewportGridService,
toolbarService,
segmentationService,
} = servicesManager.services;
const { activeViewportId } = viewportGridService.getState();
const utilitiesSectionMap = {
[SegmentationRepresentations.Labelmap]: toolbarService.sections.labelMapSegmentationUtilities,
[SegmentationRepresentations.Contour]: toolbarService.sections.contourSegmentationUtilities,
};
const selectedSegmentationsForViewportMap = useSelectedSegmentationsForViewportStore(
store => store.selectedSegmentationsForViewport[activeViewportId]
);
const selectedSegmentationIdForType = segmentationRepresentationType
? selectedSegmentationsForViewportMap?.get(segmentationRepresentationType)
: segmentationService?.getActiveSegmentation(activeViewportId)?.segmentationId;
const buttonSection = utilitiesSectionMap[segmentationRepresentationType];
const { activeToolOptions: activeUtilityOptions } = useActiveToolOptions({
buttonSectionId: buttonSection,
});
const { segmentationsWithRepresentations, disabled } =
useActiveViewportSegmentationRepresentations();
const setUIState = useUIStateStore(store => store.setUIState);
// useEffect for handling clicks on any of the non-active viewports.
// The ViewportGrid stops the propagation of pointer/mouse events
// for non-active viewports so the Popover below
// is not closed when clicking on any of the non-active viewports.
useEffect(() => {
setUIState('activeSegmentationUtility', null);
toolbarService.refreshToolbarState({ viewportId: activeViewportId });
}, [activeViewportId, setUIState, toolbarService]);
// The callback for handling clicks outside of the Popover and, the SegmentationUtilityButton
// that triggered it to open. Clicks outside those components must close the Popover.
// The Popover is made visible whenever the options associated with the
// activeSegmentationUtility exist. Thus clearing the activeSegmentationUtility
// clears the associated options and will keep the Popover closed.
const handlePopoverOpenChange = useCallback(
(open: boolean) => {
if (!open) {
setUIState('activeSegmentationUtility', null);
toolbarService.refreshToolbarState({ viewportId: activeViewportId });
}
},
[activeViewportId, setUIState, toolbarService]
);
// Extract customization options
const segmentationTableMode = customizationService.getCustomization(
'panelSegmentation.tableMode'
@ -35,6 +111,7 @@ export default function PanelSegmentation({ children }: withAppTypes) {
},
onSegmentAdd: segmentationId => {
commandsManager.run('addSegment', { segmentationId });
commandsManager.run('setActiveSegmentation', { segmentationId });
},
onSegmentClick: (segmentationId, segmentIndex) => {
commandsManager.run('setActiveSegmentAndCenter', { segmentationId, segmentIndex });
@ -51,6 +128,14 @@ export default function PanelSegmentation({ children }: withAppTypes) {
onSegmentDelete: (segmentationId, segmentIndex) => {
commandsManager.run('deleteSegment', { segmentationId, segmentIndex });
},
onSegmentCopy:
segmentationRepresentationType === SegmentationRepresentations.Contour
? (segmentationId, segmentIndex) => {
commandsManager.run('copyContourSegment', {
sourceSegmentInfo: { segmentationId, segmentIndex },
});
}
: undefined,
onToggleSegmentVisibility: (segmentationId, segmentIndex, type) => {
commandsManager.run('toggleSegmentVisibility', { segmentationId, segmentIndex, type });
},
@ -96,52 +181,26 @@ export default function PanelSegmentation({ children }: withAppTypes) {
};
// Generate export options
// Map each segmentation to an export option for it.
// A segmentation is exportable if it has any labelmap or contour data.
const exportOptions = segmentationsWithRepresentations.map(({ segmentation }) => {
const { representationData, segmentationId } = segmentation;
const { Labelmap } = representationData;
const { Labelmap, Contour } = representationData;
if (!Labelmap) {
if (!Labelmap && !Contour) {
return { segmentationId, isExportable: true };
}
// Check if any segments have anything drawn in any of the viewports
const hasAnySegmentData = (() => {
const imageIds = Labelmap.imageIds;
if (!imageIds?.length) return false;
for (const imageId of imageIds) {
const pixelData = cache.getImage(imageId)?.getPixelData();
if (!pixelData) continue;
for (let i = 0; i < pixelData.length; i++) {
if (pixelData[i] !== 0) return true;
}
}
return false;
})();
if (!hasAnySegmentData) {
if (
!hasExportableLabelMapData(Labelmap, displaySetService) &&
!hasExportableContourData(Contour)
) {
return { segmentationId, isExportable: false };
}
const referencedImageIds = Labelmap.referencedImageIds;
const firstImageId = referencedImageIds[0];
const instance = metaData.get('instance', firstImageId);
if (!instance) {
return { segmentationId, isExportable: false };
}
const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID;
const SeriesInstanceUID = instance.SeriesInstanceUID;
const displaySet = displaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
return {
segmentationId,
isExportable: displaySet?.isReconstructable,
isExportable: true,
};
});
@ -150,15 +209,34 @@ export default function PanelSegmentation({ children }: withAppTypes) {
disabled,
data: segmentationsWithRepresentations,
mode: segmentationTableMode,
title: 'Segmentations',
title: `${segmentationRepresentationType ? `${segmentationRepresentationType} ` : ''}Segmentations`,
exportOptions,
disableEditing,
onSegmentationAdd,
showAddSegment,
renderInactiveSegmentations: handlers.getRenderInactiveSegmentations(),
segmentationRepresentationType,
selectedSegmentationIdForType,
...handlers,
};
const renderUtilitiesToolbar = () => {
if (!buttonSection) {
return null;
}
return (
<IconPresentationProvider
size="large"
IconContainer={SegmentationUtilityButton}
>
<div className="flex flex-wrap gap-[3px] bg-transparent pb-[2px] pl-[8px] pt-[6px]">
<Toolbar buttonSection={buttonSection} />
</div>
</IconPresentationProvider>
);
};
const renderSegments = () => {
return (
<SegmentationTable.Segments>
@ -175,6 +253,7 @@ export default function PanelSegmentation({ children }: withAppTypes) {
if (tableProps.mode === 'collapsed') {
return (
<SegmentationTable.Collapsed>
{renderUtilitiesToolbar()}
<SegmentationTable.Collapsed.Header>
<SegmentationTable.Collapsed.DropdownMenu>
<CustomDropdownMenuContent />
@ -193,6 +272,7 @@ export default function PanelSegmentation({ children }: withAppTypes) {
return (
<>
<SegmentationTable.Expanded>
{renderUtilitiesToolbar()}
<SegmentationTable.Expanded.Header>
<SegmentationTable.Expanded.DropdownMenu>
<CustomDropdownMenuContent />
@ -211,11 +291,27 @@ export default function PanelSegmentation({ children }: withAppTypes) {
};
return (
<SegmentationTable {...tableProps}>
{children}
<SegmentationTable.Config />
<SegmentationTable.AddSegmentationRow />
{renderModeContent()}
</SegmentationTable>
<Popover
open={!!activeUtilityOptions}
onOpenChange={handlePopoverOpenChange}
>
<PopoverAnchor>
<SegmentationTable {...tableProps}>
{children}
<SegmentationTable.Config />
<SegmentationTable.AddSegmentationRow />
{renderModeContent()}
</SegmentationTable>
</PopoverAnchor>
{activeUtilityOptions && (
<PopoverContent
side="left"
align="start"
className="w-auto"
>
<ToolSettings options={activeUtilityOptions} />
</PopoverContent>
)}
</Popover>
);
}

View File

@ -221,7 +221,7 @@ describe('SegmentationService', () => {
it('should add event listeners', () => {
service.onModeEnter();
expect(eventTarget.addEventListener).toHaveBeenCalledTimes(7);
expect(eventTarget.addEventListener).toHaveBeenCalledTimes(8);
expect(eventTarget.addEventListener).toHaveBeenCalledWith(
csToolsEnums.Events.SEGMENTATION_MODIFIED,
@ -251,6 +251,10 @@ describe('SegmentationService', () => {
csToolsEnums.Events.SEGMENTATION_ADDED,
expect.any(Function)
);
expect(eventTarget.addEventListener).toHaveBeenCalledWith(
csToolsEnums.Events.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED,
expect.any(Function)
);
});
});
@ -1999,7 +2003,7 @@ describe('SegmentationService', () => {
service.addSegment(segmentationId, config);
expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(1);
expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(2);
expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith(segmentationId);
expect(cstSegmentation.updateSegmentations).toHaveBeenCalledTimes(1);

View File

@ -81,6 +81,8 @@ const EVENTS = {
SEGMENT_LOADING_COMPLETE: 'event::segment_loading_complete',
// loading completed for all segments
SEGMENTATION_LOADING_COMPLETE: 'event::segmentation_loading_complete',
// fired when a contour annotation cut merge process is completed
ANNOTATION_CUT_MERGE_PROCESS_COMPLETED: 'event::annotation_cut_merge_process_completed',
};
const VALUE_TYPES = {};
@ -350,6 +352,39 @@ class SegmentationService extends PubSubService {
FrameOfReferenceUID?: string;
label?: string;
}
): Promise<string> {
return this._createSegmentationForDisplaySet(displaySet, LABELMAP, options);
}
public async createContourForDisplaySet(
displaySet: AppTypes.DisplaySet,
options?: {
segmentationId?: string;
segments?: { [segmentIndex: number]: Partial<cstTypes.Segment> };
FrameOfReferenceUID?: string;
label?: string;
}
): Promise<string> {
return this._createSegmentationForDisplaySet(displaySet, CONTOUR, options);
}
/**
* Private method to create segmentation for a display set with the specified type
*
* @param displaySet - The display set to create the segmentation for
* @param segmentationType - The type of segmentation (SegmentationRepresentations enum)
* @param options - Optional parameters for creating the segmentation
* @returns A promise that resolves to the created segmentation ID
*/
private async _createSegmentationForDisplaySet(
displaySet: AppTypes.DisplaySet,
segmentationType: SegmentationRepresentations,
options?: {
segmentationId?: string;
segments?: { [segmentIndex: number]: Partial<cstTypes.Segment> };
FrameOfReferenceUID?: string;
label?: string;
}
): Promise<string> {
// Todo: random does not makes sense, make this better, like
// labelmap 1, 2, 3 etc
@ -375,7 +410,7 @@ class SegmentationService extends PubSubService {
const segmentationPublicInput: cstTypes.SegmentationPublicInput = {
segmentationId,
representation: {
type: LABELMAP,
type: segmentationType,
data: {
imageIds: segImageIds,
// referencedVolumeId: this._getVolumeIdForDisplaySet(displaySet),
@ -808,6 +843,15 @@ class SegmentationService extends PubSubService {
cstSegmentation.config.style.resetToGlobalStyle();
};
public getNextAvailableSegmentIndex(segmentationId: string): number {
const csSegmentation = this.getCornerstoneSegmentation(segmentationId);
// grab the next available segment index based on the object keys,
// so basically get the highest segment index value + 1
const segmentKeys = Object.keys(csSegmentation.segments);
return segmentKeys.length === 0 ? 1 : Math.max(...segmentKeys.map(Number)) + 1;
}
/**
* Adds a new segment to the specified segmentation.
* @param segmentationId - The ID of the segmentation to add the segment to.
@ -841,10 +885,7 @@ class SegmentationService extends PubSubService {
let segmentIndex = config.segmentIndex;
if (!segmentIndex) {
// grab the next available segment index based on the object keys,
// so basically get the highest segment index value + 1
const segmentKeys = Object.keys(csSegmentation.segments);
segmentIndex = segmentKeys.length === 0 ? 1 : Math.max(...segmentKeys.map(Number)) + 1;
segmentIndex = this.getNextAvailableSegmentIndex(segmentationId);
}
// update the segmentation
@ -1627,6 +1668,11 @@ class SegmentationService extends PubSubService {
csToolsEnums.Events.SEGMENTATION_ADDED,
this._onSegmentationAddedFromSource
);
eventTarget.addEventListener(
csToolsEnums.Events.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED,
this._onAnnotationCutMergeProcessCompletedFromSource
);
}
private getCornerstoneSegmentation(segmentationId: string) {
@ -1879,6 +1925,13 @@ class SegmentationService extends PubSubService {
segmentationId,
});
};
private _onAnnotationCutMergeProcessCompletedFromSource = evt => {
const { segmentationId } = evt.detail;
this._broadcastEvent(this.EVENTS.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED, {
segmentationId,
});
};
}
export default SegmentationService;

View File

@ -2,3 +2,4 @@ export { useLutPresentationStore } from './useLutPresentationStore';
export { usePositionPresentationStore } from './usePositionPresentationStore';
export { useSegmentationPresentationStore } from './useSegmentationPresentationStore';
export { useSynchronizersStore } from './useSynchronizersStore';
export { useSelectedSegmentationsForViewportStore } from './useSelectedSegmentationsForViewportStore';

View File

@ -0,0 +1,85 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { SelectedSegmentationByTypeMap } from '../types/SelectedSegmentationByTypeMap';
const PRESENTATION_TYPE_ID = 'selectedSegmentationsForViewportId';
const DEBUG_STORE = false;
/**
* Represents the state and actions for managing selected segmentations for a viewport by representation type.
*/
type SelectedSegmentationsForViewportState = {
/**
* Stores a mapping from viewport id to a map of representation type to segmentation id.
*/
selectedSegmentationsForViewport: Record<string, SelectedSegmentationByTypeMap>;
/**
* Sets the selected segmentations for a given viewport id.
*
* @param key - The viewport id.
* @param value - The `SelectedSegmentationByTypeMap` to associate with the viewport id.
*/
setSelectedSegmentationsForViewport: (key: string, value: SelectedSegmentationByTypeMap) => void;
/**
* Clears all selected segmentations for all viewports.
*/
clearSelectedSegmentationsForViewportStore: () => void;
/**
* Type identifier for the store.
*/
type: string;
};
/**
* Creates the Selected Segmentations For Viewport store.
*
* @param set - The zustand set function.
* @returns The selected segmentations for viewport store state and actions.
*/
const createSelectedSegmentationsForViewportStore = (
set
): SelectedSegmentationsForViewportState => ({
selectedSegmentationsForViewport: {},
type: PRESENTATION_TYPE_ID,
/**
* Sets the selected segmentations for a given viewport id.
*/
setSelectedSegmentationsForViewport: (key, value) =>
set(
state => ({
selectedSegmentationsForViewport: {
...state.selectedSegmentationsForViewport,
[key]: value,
},
}),
false,
'setSelectedSegmentationsForViewport'
),
/**
* Clears all selected segmentations for all viewports.
*/
clearSelectedSegmentationsForViewportStore: () =>
set(
{ selectedSegmentationsForViewport: {} },
false,
'clearSelectedSegmentationsForViewportStore'
),
});
/**
* Zustand store for managing selected segmentations for a viewport by representation type.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useSelectedSegmentationsForViewportStore =
create<SelectedSegmentationsForViewportState>()(
DEBUG_STORE
? devtools(createSelectedSegmentationsForViewportStore, {
name: 'SelectedSegmentationsForViewportStore',
})
: createSelectedSegmentationsForViewportStore
);

View File

@ -0,0 +1,3 @@
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
export type SelectedSegmentationByTypeMap = Map<SegmentationRepresentations, string>;

View File

@ -0,0 +1,74 @@
import { utilities as csUtils } from '@cornerstonejs/core';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
import i18n from '@ohif/i18n';
import { ServicesManager } from '@ohif/core';
function _createDefaultSegments(createInitialSegment?: boolean) {
return createInitialSegment
? {
1: {
label: `${i18n.t('Tools:Segment')} 1`,
active: true,
},
}
: {};
}
type CreateSegmentationForViewportOptions = {
displaySetInstanceUID?: string;
label?: string;
segmentationId?: string;
createInitialSegment?: boolean;
};
type CreateSegmentationForViewportParams = {
viewportId: string;
options?: CreateSegmentationForViewportOptions;
segmentationType: SegmentationRepresentations;
};
/**
* Creates a segmentation for the active viewport
*
* The created segmentation will be registered as a display set and also added
* as a segmentation representation to the viewport.
*/
export async function createSegmentationForViewport(
servicesManager: ServicesManager,
{ viewportId, options = {}, segmentationType }: CreateSegmentationForViewportParams
): Promise<string> {
const { viewportGridService, displaySetService, segmentationService } = servicesManager.services;
const { viewports } = viewportGridService.getState();
const targetViewportId = viewportId;
const viewport = viewports.get(targetViewportId);
// Todo: add support for multiple display sets
const displaySetInstanceUID = options.displaySetInstanceUID || viewport.displaySetInstanceUIDs[0];
const segs = segmentationService.getSegmentations();
const label = options.label || `${i18n.t('Tools:Segmentation')} ${segs.length + 1}`;
const segmentationId = options.segmentationId || `${csUtils.uuidv4()}`;
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
const segmentationCreationOptions = {
label,
segmentationId,
segments: _createDefaultSegments(options.createInitialSegment),
};
// This will create the segmentation and register it as a display set
const generatedSegmentationId = await (segmentationType === SegmentationRepresentations.Labelmap
? segmentationService.createLabelmapForDisplaySet(displaySet, segmentationCreationOptions)
: segmentationService.createContourForDisplaySet(displaySet, segmentationCreationOptions));
// Also add the segmentation representation to the viewport
await segmentationService.addSegmentationRepresentation(viewportId, {
segmentationId,
type: segmentationType,
});
return generatedSegmentationId;
}

View File

@ -10,6 +10,7 @@ import promptHydrationDialog, {
HydrationSRResult,
} from './promptHydrationDialog';
import { getCenterExtent } from './getCenterExtent';
import { createSegmentationForViewport } from './createSegmentationForViewport';
const utils = {
handleSegmentChange,
@ -18,6 +19,7 @@ const utils = {
setupSegmentationModifiedHandler,
promptHydrationDialog,
getCenterExtent,
createSegmentationForViewport,
};
export type { HydrationDialogProps, HydrationCallback, HydrationSRResult };

View File

@ -0,0 +1,86 @@
import { cache, metaData } from '@cornerstonejs/core';
import { Types as cstTypes } from '@cornerstonejs/tools';
/**
* Checks if a labelmap segmentation has exportable data
* A label map is exportable if it has any pixel data and
* it is referenced by a display set that is reconstructable.
*
* @param labelmap - The labelmap representation data
* @param displaySetService - The display set service instance
* @returns boolean - Whether the labelmap has exportable data
*/
export const hasExportableLabelMapData = (
labelmap: cstTypes.LabelmapSegmentationData | undefined,
displaySetService: any
): boolean => {
if (!labelmap) {
return false;
}
const imageIds = labelmap?.imageIds;
if (!imageIds?.length) {
return false;
}
const hasPixelData = imageIds.some(imageId => {
const pixelData = cache.getImage(imageId)?.getPixelData();
if (!pixelData) {
return false;
}
for (let i = 0; i < pixelData.length; i++) {
if (pixelData[i] !== 0) {
return true;
}
}
});
if (!hasPixelData) {
return false;
}
const referencedImageIds = labelmap?.referencedImageIds;
if (!referencedImageIds) {
return false;
}
const firstImageId = referencedImageIds[0];
const instance = metaData.get('instance', firstImageId);
if (!instance) {
return false;
}
const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID;
const SeriesInstanceUID = instance.SeriesInstanceUID;
const displaySet = displaySetService.getDisplaySetForSOPInstanceUID(
SOPInstanceUID,
SeriesInstanceUID
);
return displaySet?.isReconstructable;
};
/**
* Checks if a contour segmentation has exportable data
* A contour is exportable if it has any contour annotation/geometry data.
*
* @param contour - The contour representation data
* @returns boolean - Whether the contour has exportable data
*/
export const hasExportableContourData = (
contour: cstTypes.ContourSegmentationData | undefined
): boolean => {
if (!contour) {
return false;
}
const contourAnnotationUIDsMap = contour?.annotationUIDsMap;
if (!contourAnnotationUIDsMap) {
return false;
}
return contourAnnotationUIDsMap.size > 0;
};

View File

@ -1,5 +1,7 @@
import * as cornerstoneTools from '@cornerstonejs/tools';
import { updateSegmentationStats } from './updateSegmentationStats';
import { useSelectedSegmentationsForViewportStore } from '../stores';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
/**
* Sets up the handler for segmentation data modification events
@ -110,3 +112,49 @@ export function setupSegmentationModifiedHandler({ segmentationService }) {
return { unsubscribe };
}
/**
* Sets up auto tab switching for when the first segmentation is added into the viewer.
*/
export function setUpSelectedSegmentationsForViewportHandler({ segmentationService }) {
const selectedSegmentationsForViewportEvents = [
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED,
];
const unsubscribeSelectedSegmentationsForViewportEvents = selectedSegmentationsForViewportEvents
.map(eventName =>
segmentationService.subscribe(eventName, event => {
const { viewportId } = event;
if (!viewportId) {
return;
}
const { selectedSegmentationsForViewport, setSelectedSegmentationsForViewport } =
useSelectedSegmentationsForViewportStore.getState();
const representations = segmentationService.getSegmentationRepresentations(viewportId);
const activeRepresentation = representations.find(representation => representation.active);
const typeToSegmentationIdMap =
selectedSegmentationsForViewport[viewportId] ??
new Map<SegmentationRepresentations, string>();
if (activeRepresentation) {
typeToSegmentationIdMap.set(
activeRepresentation.type,
activeRepresentation.segmentationId
);
} else {
typeToSegmentationIdMap.clear();
}
setSelectedSegmentationsForViewport(viewportId, typeToSegmentationIdMap);
})
)
.map(subscription => subscription.unsubscribe);
return { unsubscribeSelectedSegmentationsForViewportEvents };
}

View File

@ -2,11 +2,13 @@ import { setUpSegmentationEventHandlers } from './setUpSegmentationEventHandlers
import {
setupSegmentationDataModifiedHandler,
setupSegmentationModifiedHandler,
setUpSelectedSegmentationsForViewportHandler,
} from './segmentationHandlers';
jest.mock('./segmentationHandlers', () => ({
setupSegmentationDataModifiedHandler: jest.fn(),
setupSegmentationModifiedHandler: jest.fn(),
setUpSelectedSegmentationsForViewportHandler: jest.fn(),
}));
describe('setUpSegmentationEventHandlers', () => {
@ -38,6 +40,7 @@ describe('setUpSegmentationEventHandlers', () => {
const mockUnsubscribeDataModified = jest.fn();
const mockUnsubscribeModified = jest.fn();
const mockUnsubscribeCreated = jest.fn();
const mockUnsubscribeSelectedSegmentationsForViewportEvents = [jest.fn(), jest.fn()];
const defaultParameters = {
servicesManager: mockServicesManager as unknown as AppTypes.ServicesManager,
@ -52,6 +55,10 @@ describe('setUpSegmentationEventHandlers', () => {
(setupSegmentationModifiedHandler as jest.Mock).mockReturnValue({
unsubscribe: mockUnsubscribeModified,
});
(setUpSelectedSegmentationsForViewportHandler as jest.Mock).mockReturnValue({
unsubscribeSelectedSegmentationsForViewportEvents:
mockUnsubscribeSelectedSegmentationsForViewportEvents,
});
mockSegmentationService.subscribe.mockReturnValue({
unsubscribe: mockUnsubscribeCreated,
});
@ -92,6 +99,7 @@ describe('setUpSegmentationEventHandlers', () => {
mockUnsubscribeDataModified,
mockUnsubscribeModified,
mockUnsubscribeCreated,
...mockUnsubscribeSelectedSegmentationsForViewportEvents,
],
});
});

View File

@ -1,4 +1,5 @@
import {
setUpSelectedSegmentationsForViewportHandler,
setupSegmentationDataModifiedHandler,
setupSegmentationModifiedHandler,
} from './segmentationHandlers';
@ -28,7 +29,9 @@ export const setUpSegmentationEventHandlers = ({ servicesManager, commandsManage
const segmentation = segmentationService.getSegmentation(segmentationId);
const label = segmentation.cachedStats.info;
const imageIds = segmentation.representationData.Labelmap.imageIds;
const imageIds =
segmentation.representationData?.Labelmap?.imageIds ??
segmentation.representationData?.Contour?.imageIds;
// Create a display set for the segmentation
const segmentationDisplaySet = {
@ -50,10 +53,16 @@ export const setUpSegmentationEventHandlers = ({ servicesManager, commandsManage
}
);
const { unsubscribeSelectedSegmentationsForViewportEvents } =
setUpSelectedSegmentationsForViewportHandler({
segmentationService,
});
const unsubscriptions = [
unsubscribeSegmentationDataModifiedHandler,
unsubscribeSegmentationModifiedHandler,
unsubscribeSegmentationCreated,
...unsubscribeSelectedSegmentationsForViewportEvents,
];
return { unsubscriptions };

View File

@ -28,7 +28,7 @@ const SidePanelWithServices = ({
onClose,
...props
}: SidePanelWithServicesProps) => {
const panelService = servicesManager?.services?.panelService;
const { panelService, toolbarService, viewportGridService } = servicesManager.services;
// Tracks whether this SidePanel has been opened at least once since this SidePanel was inserted into the DOM.
// Thus going to the Study List page and back to the viewer resets this flag for a SidePanel.
@ -37,9 +37,15 @@ const SidePanelWithServices = ({
const [closedManually, setClosedManually] = useState(false);
const [tabs, setTabs] = useState(tabsProp ?? panelService.getPanels(side));
const handleActiveTabIndexChange = useCallback(({ activeTabIndex }) => {
setActiveTabIndex(activeTabIndex);
}, []);
const handleActiveTabIndexChange = useCallback(
({ activeTabIndex }) => {
const { activeViewportId: viewportId } = viewportGridService.getState();
toolbarService.refreshToolbarState({ viewportId });
setActiveTabIndex(activeTabIndex);
},
[toolbarService, viewportGridService]
);
const handleOpen = useCallback(() => {
setSidePanelExpanded(true);

View File

@ -1,9 +1,30 @@
import React from 'react';
import { useToolbar } from '@ohif/core';
/**
* Props for the Toolbar component that renders a collection of toolbar buttons and/or button sections.
*
* @interface ToolbarProps
*/
interface ToolbarProps {
/**
* The section of buttons to display in the toolbar.
* Common values include 'primary', 'secondary', 'tertiary', etc.
* Defaults to 'primary' if not specified.
*
* @default 'primary'
*/
buttonSection?: string;
/**
* The unique identifier of the viewport this toolbar is associated with.
*/
viewportId?: string;
/**
* The numeric position or location of the toolbar.
* Used for ordering and layout purposes in the UI.
*/
location?: number;
}
@ -60,7 +81,17 @@ export function Toolbar({ buttonSection = 'primary', viewportId, location }: Too
/>
);
return <div key={id}>{tool}</div>;
return (
<div
key={id}
// This wrapper div exists solely for React's key prop requirement during reconciliation.
// We use display:contents to make it transparent to the layout engine (children appear
// as direct children of the parent) while keeping it in the DOM for React's virtual DOM.
className="contents"
>
{tool}
</div>
);
})}
</>
);

View File

@ -10,6 +10,7 @@ import ToolButtonListWrapper from './Toolbar/ToolButtonListWrapper';
import ToolRowWrapper from './Toolbar/ToolRowWrapper';
import { ToolBoxButtonGroupWrapper, ToolBoxButtonWrapper } from './Toolbar/ToolBoxWrapper';
import { ToolButtonWrapper } from './Toolbar/ToolButtonWrapper';
import { Toolbar } from './Toolbar';
export default function getToolbarModule({ commandsManager, servicesManager }: withAppTypes) {
const { cineService } = servicesManager.services;
@ -45,6 +46,10 @@ export default function getToolbarModule({ commandsManager, servicesManager }: w
name: 'ohif.progressDropdown',
defaultComponent: ProgressDropdownWithService,
},
{
name: 'ohif.Toolbar',
defaultComponent: Toolbar,
},
{
name: 'evaluate.cine',
evaluate: () => {

View File

@ -1,12 +1,21 @@
import React, { useState } from 'react';
import { Icons, PanelSection, ToolSettings } from '@ohif/ui-next';
import { useSystem, useToolbar } from '@ohif/core';
import classnames from 'classnames';
import { useSystem, useToolbar, useActiveToolOptions } from '@ohif/core';
import { useTranslation } from 'react-i18next';
interface ButtonProps {
isActive?: boolean;
options?: unknown;
/**
* Props for the Toolbox component that renders a collection of toolbar button sections.
*/
interface ToolboxProps {
/**
* The unique identifier of the button section this toolbox represents.
*/
buttonSectionId: string;
/**
* The display title for the toolbox.
*/
title: string;
}
/**
@ -19,7 +28,7 @@ interface ButtonProps {
* role in enhancing the app with a toolbox by providing a way to integrate
* and display various tools and their corresponding options
*/
export function Toolbox({ buttonSectionId, title }: { buttonSectionId: string; title: string }) {
export function Toolbox({ buttonSectionId, title }: ToolboxProps) {
const { servicesManager } = useSystem();
const { t } = useTranslation();
@ -30,6 +39,8 @@ export function Toolbox({ buttonSectionId, title }: { buttonSectionId: string; t
buttonSection: buttonSectionId,
});
const { activeToolOptions } = useActiveToolOptions({ buttonSectionId });
if (!toolboxSections.length) {
return null;
}
@ -41,35 +52,6 @@ export function Toolbox({ buttonSectionId, title }: { buttonSectionId: string; t
);
}
// Helper to check a list of buttons for an active tool.
const findActiveOptions = (buttons: any[]): unknown => {
for (const tool of buttons) {
if (tool.componentProps.isActive) {
return tool.componentProps.options;
}
if (tool.componentProps.buttonSection) {
const nestedButtons = toolbarService.getButtonPropsInButtonSection(
tool.componentProps.buttonSection
) as ButtonProps[];
const activeNested = nestedButtons.find(nested => nested.isActive);
if (activeNested) {
return activeNested.options;
}
}
}
return null;
};
// Look for active tool options across all sections.
const activeToolOptions = toolboxSections.reduce((activeOptions, section) => {
if (activeOptions) {
return activeOptions;
}
const sectionId = section.componentProps.buttonSection;
const buttons = toolbarService.getButtonSection(sectionId);
return findActiveOptions(buttons);
}, null);
// Define the interaction handler once.
const handleInteraction = ({ itemId }: { itemId: string }) => {
onInteraction?.({ itemId });
@ -103,19 +85,20 @@ export function Toolbox({ buttonSectionId, title }: { buttonSectionId: string; t
return (
<div
key={sectionId}
className="bg-muted flex flex-wrap space-x-2 py-2 px-1"
className="bg-muted flex flex-wrap gap-2 py-2 px-1"
>
{buttons.map(tool => {
if (!tool) {
// Skip over tools that are not visible. The visible flag is typically set to
// false as a result of the evaluator function. The evaluator might explicitly
// set visible to false. Alternatively, the ToolbarService will set the visible flag to
// false when the evaluator sets disabled to true and the tool has the hideWhenDisabled flag set to true.
if (!tool || !tool.componentProps.visible) {
return null;
}
const { id, Component, componentProps } = tool;
return (
<div
key={id}
className={classnames('ml-1')}
>
<div key={id}>
<Component
{...componentProps}
id={id}

View File

@ -659,14 +659,6 @@ const toolbarButtons: Button[] = [
},
},
},
// Section containers for the nested toolbox
{
id: 'SegmentationUtilities',
uiType: 'ohif.toolBoxButton',
props: {
buttonSection: true,
},
},
{
id: 'SegmentLabelTool',
uiType: 'ohif.toolBoxButton',

View File

@ -71,11 +71,11 @@ const config = {
name: 'preset-default',
params: {
overrides: {
removeViewBox: false
removeViewBox: false,
},
},
},
]
],
},
prettier: false,
svgo: true,

View File

@ -1,6 +1,7 @@
import { id } from './id';
import toolbarButtons from './toolbarButtons';
import initToolGroups from './initToolGroups';
import setUpAutoTabSwitchHandler from './utils/setUpAutoTabSwitchHandler';
const ohif = {
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
@ -11,7 +12,10 @@ const ohif = {
const cornerstone = {
viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone',
panelTool: '@ohif/extension-cornerstone.panelModule.panelSegmentationWithTools',
labelMapSegmentationPanel:
'@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsLabelMap',
contourSegmentationPanel:
'@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsContour',
measurements: '@ohif/extension-cornerstone.panelModule.panelMeasurement',
};
@ -36,6 +40,7 @@ const extensionDependencies = {
};
function modeFactory({ modeConfiguration }) {
const _unsubscriptions = [];
return {
/**
* Mode ID, which should be unique among modes used by the viewer. This ID
@ -53,8 +58,14 @@ function modeFactory({ modeConfiguration }) {
* Services and other resources.
*/
onModeEnter: ({ servicesManager, extensionManager, commandsManager }: withAppTypes) => {
const { measurementService, toolbarService, toolGroupService, customizationService } =
servicesManager.services;
const {
measurementService,
toolbarService,
toolGroupService,
segmentationService,
viewportGridService,
panelService,
} = servicesManager.services;
measurementService.clearMeasurements();
@ -114,22 +125,54 @@ function modeFactory({ modeConfiguration }) {
'TagBrowser',
]);
toolbarService.updateSection(toolbarService.sections.segmentationToolbox, [
'SegmentationUtilities',
'SegmentationTools',
toolbarService.updateSection(toolbarService.sections.labelMapSegmentationToolbox, [
'LabelMapTools',
]);
toolbarService.updateSection('SegmentationUtilities', [
toolbarService.updateSection(toolbarService.sections.contourSegmentationToolbox, [
'ContourTools',
]);
toolbarService.updateSection('LabelMapTools', [
'LabelmapSlicePropagation',
'InterpolateLabelmap',
'SegmentBidirectional',
]);
toolbarService.updateSection('SegmentationTools', [
'BrushTools',
'MarkerLabelmap',
'RegionSegmentPlus',
'Shapes',
'LabelMapEditWithContour',
]);
toolbarService.updateSection('ContourTools', [
'PlanarFreehandContourSegmentationTool',
'SculptorTool',
'SplineContourSegmentationTool',
'LivewireContourSegmentationTool',
]);
toolbarService.updateSection(toolbarService.sections.labelMapSegmentationUtilities, [
'LabelMapUtilities',
]);
toolbarService.updateSection(toolbarService.sections.contourSegmentationUtilities, [
'ContourUtilities',
]);
toolbarService.updateSection('LabelMapUtilities', [
'InterpolateLabelmap',
'SegmentBidirectional',
]);
toolbarService.updateSection('ContourUtilities', [
'LogicalContourOperations',
'SimplifyContours',
'SmoothContours',
]);
toolbarService.updateSection('BrushTools', ['Brush', 'Eraser', 'Threshold']);
const { unsubscribeAutoTabSwitchEvents } = setUpAutoTabSwitchHandler({
segmentationService,
viewportGridService,
panelService,
});
_unsubscriptions.push(...unsubscribeAutoTabSwitchEvents);
},
onModeExit: ({ servicesManager }: withAppTypes) => {
const {
@ -141,6 +184,9 @@ function modeFactory({ modeConfiguration }) {
uiModalService,
} = servicesManager.services;
_unsubscriptions.forEach(unsubscribe => unsubscribe());
_unsubscriptions.length = 0;
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
@ -192,7 +238,10 @@ function modeFactory({ modeConfiguration }) {
props: {
leftPanels: [ohif.leftPanel],
leftPanelResizable: true,
rightPanels: [cornerstone.panelTool],
rightPanels: [
cornerstone.contourSegmentationPanel,
cornerstone.labelMapSegmentationPanel,
],
rightPanelResizable: true,
// leftPanelClosed: true,
viewports: [

View File

@ -106,6 +106,9 @@ function createTools({ utilityModule, commandsManager }) {
},
},
},
{
toolName: toolNames.LabelMapEditWithContourTool,
},
{ toolName: toolNames.CircleScissors },
{ toolName: toolNames.RectangleScissors },
{ toolName: toolNames.SphereScissors },
@ -114,6 +117,42 @@ function createTools({ utilityModule, commandsManager }) {
{ toolName: toolNames.WindowLevelRegion },
{ toolName: toolNames.UltrasoundDirectional },
{
toolName: toolNames.PlanarFreehandContourSegmentation,
},
{ toolName: toolNames.LivewireContourSegmentation },
{ toolName: toolNames.SculptorTool },
{ toolName: toolNames.PlanarFreehandROI },
{
toolName: 'CatmullRomSplineROI',
parentTool: toolNames.SplineContourSegmentation,
configuration: {
spline: {
type: 'CATMULLROM',
enableTwoPointPreview: true,
},
},
},
{
toolName: 'LinearSplineROI',
parentTool: toolNames.SplineContourSegmentation,
configuration: {
spline: {
type: 'LINEAR',
enableTwoPointPreview: true,
},
},
},
{
toolName: 'BSplineROI',
parentTool: toolNames.SplineContourSegmentation,
configuration: {
spline: {
type: 'BSPLINE',
enableTwoPointPreview: true,
},
},
},
],
disabled: [{ toolName: toolNames.ReferenceLines }, { toolName: toolNames.AdvancedMagnify }],
};

View File

@ -174,17 +174,31 @@ const toolbarButtons: Button[] = [
buttonSection: true,
},
},
// Section containers for the nested toolbox
// Section containers for the nested toolboxes and toolbars.
{
id: 'SegmentationUtilities',
uiType: 'ohif.toolBoxButton',
id: 'LabelMapUtilities',
uiType: 'ohif.Toolbar',
props: {
buttonSection: true,
},
},
{
id: 'SegmentationTools',
uiType: 'ohif.toolBoxButton',
id: 'ContourUtilities',
uiType: 'ohif.Toolbar',
props: {
buttonSection: true,
},
},
{
id: 'LabelMapTools',
uiType: 'ohif.toolBoxButtonGroup',
props: {
buttonSection: true,
},
},
{
id: 'ContourTools',
uiType: 'ohif.toolBoxButtonGroup',
props: {
buttonSection: true,
},
@ -399,17 +413,254 @@ const toolbarButtons: Button[] = [
commands: 'openDICOMTagViewer',
},
},
{
id: 'PlanarFreehandContourSegmentationTool',
uiType: 'ohif.toolBoxButton',
props: {
icon: 'icon-tool-freehand-roi',
label: 'Freehand Segmentation',
tooltip: 'Freehand Segmentation',
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['PlanarFreehandContourSegmentationTool'],
disabledText: 'Create new segmentation to enable this tool.',
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Contour',
},
],
commands: [
{
commandName: 'setToolActiveToolbar',
commandOptions: {
bindings: [
{
mouseButton: 1, // Left Click
},
{
mouseButton: 1, // Left Click+Shift to create a hole
modifierKey: 16, // Shift
},
],
},
},
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Contour',
},
},
],
options: [
{
name: 'Interpolate Contours',
type: 'switch',
id: 'planarFreehandInterpolateContours',
value: false,
commands: {
commandName: 'setInterpolationToolConfiguration',
},
},
],
},
},
{
id: 'LivewireContourSegmentationTool',
uiType: 'ohif.toolBoxButton',
props: {
icon: 'icon-tool-livewire',
label: 'Livewire Contour',
tooltip: 'Livewire Contour',
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['LivewireContourSegmentationTool'],
disabledText: 'Create new segmentation to enable this tool.',
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Contour',
},
],
commands: [
{
commandName: 'setToolActiveToolbar',
commandOptions: {
bindings: [
{
mouseButton: 1, // Left Click
},
{
mouseButton: 1, // Left Click+Shift to create a hole
modifierKey: 16, // Shift
},
],
},
},
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Contour',
},
},
],
options: [
{
name: 'Interpolate Contours',
type: 'switch',
id: 'livewireInterpolateContours',
value: false,
commands: {
commandName: 'setInterpolationToolConfiguration',
},
},
],
},
},
{
id: 'SplineContourSegmentationTool',
uiType: 'ohif.toolBoxButton',
props: {
icon: 'icon-tool-spline-roi',
label: 'Spline Contour Segmentation Tool',
tooltip: 'Spline Contour Segmentation Tool',
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CatmullRomSplineROI', 'LinearSplineROI', 'BSplineROI'],
disabledText: 'Create new segmentation to enable this tool.',
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Contour',
},
],
commands: [
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Contour',
},
},
],
options: [
{
name: 'Spline Type',
type: 'select',
id: 'splineTypeSelect',
value: 'CatmullRomSplineROI',
values: [
{
id: 'CatmullRomSplineROI',
value: 'CatmullRomSplineROI',
label: 'Catmull Rom Spline',
},
{ id: 'LinearSplineROI', value: 'LinearSplineROI', label: 'Linear Spline' },
{ id: 'BSplineROI', value: 'BSplineROI', label: 'B-Spline' },
],
commands: {
commandName: 'setToolActiveToolbar',
commandOptions: {
bindings: [
{
mouseButton: 1, // Left Click
},
{
mouseButton: 1, // Left Click+Shift to create a hole
modifierKey: 16, // Shift
},
],
},
},
},
{
name: 'Simplified Spline',
type: 'switch',
id: 'simplifiedSpline',
value: true,
commands: {
commandName: 'setSimplifiedSplineForSplineContourSegmentationTool',
},
},
{
name: 'Interpolate Contours',
type: 'switch',
id: 'splineInterpolateContours',
value: false,
commands: {
commandName: 'setInterpolationToolConfiguration',
commandOptions: {
toolNames: ['CatmullRomSplineROI', 'LinearSplineROI', 'BSplineROI'],
},
},
},
],
},
},
{
id: 'SculptorTool',
uiType: 'ohif.toolBoxButton',
props: {
icon: 'icon-tool-sculptor',
label: 'Sculptor Tool',
tooltip: 'Sculptor Tool',
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['SculptorTool'],
disabledText: 'Create new segmentation to enable this tool.',
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Contour',
},
],
commands: [
'setToolActiveToolbar',
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Contour',
},
},
],
options: [
{
name: 'Dynamic Cursor Size',
type: 'switch',
id: 'dynamicCursorSize',
value: true,
commands: {
commandName: 'setDynamicCursorSizeForSculptorTool',
},
},
],
},
},
{
id: 'Brush',
uiType: 'ohif.toolBoxButton',
props: {
icon: 'icon-tool-brush',
label: i18n.t('Buttons:Brush'),
evaluate: {
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CircularBrush', 'SphereBrush'],
disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'),
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CircularBrush', 'SphereBrush'],
disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'),
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
],
commands: {
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Labelmap',
},
},
options: [
{
@ -420,10 +671,12 @@ const toolbarButtons: Button[] = [
max: 99.5,
step: 0.5,
value: 25,
commands: {
commandName: 'setBrushSize',
commandOptions: { toolNames: ['CircularBrush', 'SphereBrush'] },
},
commands: [
{
commandName: 'setBrushSize',
commandOptions: { toolNames: ['CircularBrush', 'SphereBrush'] },
},
],
},
{
name: 'Shape',
@ -434,44 +687,72 @@ const toolbarButtons: Button[] = [
{ value: 'CircularBrush', label: 'Circle' },
{ value: 'SphereBrush', label: 'Sphere' },
],
commands: 'setToolActiveToolbar',
commands: ['setToolActiveToolbar'],
},
],
},
},
{
id: 'InterpolateLabelmap',
uiType: 'ohif.toolBoxButton',
uiType: 'ohif.toolButton',
props: {
icon: 'icon-tool-interpolation',
icon: 'actions-interpolate',
label: i18n.t('Buttons:Interpolate Labelmap'),
tooltip: i18n.t(
'Buttons:Automatically fill in missing slices between drawn segments. Use brush or threshold tools on at least two slices, then click to interpolate across slices. Works in any direction. Volume must be reconstructable.'
),
evaluate: [
'evaluate.cornerstone.segmentation',
{
name: 'evaluate.cornerstone.segmentation',
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
{
name: 'evaluate.displaySetIsReconstructable',
disabledText: i18n.t('Buttons:The current viewport cannot handle interpolation.'),
},
],
commands: 'interpolateLabelmap',
commands: [
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Labelmap',
},
},
'interpolateLabelmap',
],
},
},
{
id: 'SegmentBidirectional',
uiType: 'ohif.toolBoxButton',
uiType: 'ohif.toolButton',
props: {
icon: 'icon-tool-bidirectional-segment',
icon: 'actions-bidirectional',
label: i18n.t('Buttons:Segment Bidirectional'),
tooltip: i18n.t(
'Buttons:Automatically detects the largest length and width across slices for the selected segment and displays a bidirectional measurement.'
),
evaluate: {
name: 'evaluate.cornerstone.segmentation',
disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'),
},
commands: 'runSegmentBidirectional',
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'),
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
],
commands: [
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Labelmap',
},
},
'runSegmentBidirectional',
],
},
},
{
@ -483,12 +764,26 @@ const toolbarButtons: Button[] = [
tooltip: i18n.t(
'Buttons:Detects segmentable regions with one click. Hover for visual feedback—click when a plus sign appears to auto-segment the lesion.'
),
evaluate: {
name: 'evaluate.cornerstone.segmentation',
toolNames: ['RegionSegmentPlus'],
disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'),
},
commands: 'setToolActiveToolbar',
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['RegionSegmentPlus'],
disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'),
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
],
commands: [
'setToolActiveToolbar',
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Labelmap',
},
},
],
},
},
{
@ -503,7 +798,8 @@ const toolbarButtons: Button[] = [
evaluate: [
'evaluate.cornerstoneTool.toggle',
{
name: 'evaluate.cornerstone.hasSegmentation',
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
],
listeners: {
@ -512,7 +808,15 @@ const toolbarButtons: Button[] = [
),
[ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('LabelmapSlicePropagation'),
},
commands: 'toggleEnabledDisabledToolbar',
commands: [
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Labelmap',
},
},
'toggleEnabledDisabledToolbar',
],
},
},
{
@ -529,8 +833,20 @@ const toolbarButtons: Button[] = [
name: 'evaluate.cornerstone.segmentation',
toolNames: ['MarkerLabelmap', 'MarkerInclude', 'MarkerExclude'],
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
],
commands: [
'setToolActiveToolbar',
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Labelmap',
},
},
],
commands: 'setToolActiveToolbar',
listeners: {
[ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: callbacks('MarkerLabelmap'),
[ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('MarkerLabelmap'),
@ -573,10 +889,16 @@ const toolbarButtons: Button[] = [
props: {
icon: 'icon-tool-eraser',
label: i18n.t('Buttons:Eraser'),
evaluate: {
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CircularEraser', 'SphereEraser'],
},
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CircularEraser', 'SphereEraser'],
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
],
options: [
{
name: 'Radius (mm)',
@ -603,6 +925,12 @@ const toolbarButtons: Button[] = [
commands: 'setToolActiveToolbar',
},
],
commands: {
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Labelmap',
},
},
},
},
{
@ -611,15 +939,28 @@ const toolbarButtons: Button[] = [
props: {
icon: 'icon-tool-threshold',
label: 'Threshold Tool',
evaluate: {
name: 'evaluate.cornerstone.segmentation',
toolNames: [
'ThresholdCircularBrush',
'ThresholdSphereBrush',
'ThresholdCircularBrushDynamic',
'ThresholdSphereBrushDynamic',
],
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: [
'ThresholdCircularBrush',
'ThresholdSphereBrush',
'ThresholdCircularBrushDynamic',
'ThresholdSphereBrushDynamic',
],
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
],
commands: {
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Labelmap',
},
},
options: [
{
name: 'Radius (mm)',
@ -728,10 +1069,22 @@ const toolbarButtons: Button[] = [
props: {
icon: 'icon-tool-shape',
label: i18n.t('Buttons:Shapes'),
evaluate: {
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CircleScissor', 'SphereScissor', 'RectangleScissor'],
disabledText: i18n.t('Buttons:Create new segmentation to enable shapes tool.'),
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['CircleScissor', 'SphereScissor', 'RectangleScissor'],
disabledText: i18n.t('Buttons:Create new segmentation to enable shapes tool.'),
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
],
commands: {
commandName: 'activateSelectedSegmentationOfType',
commandOptions: {
segmentationRepresentationType: 'Labelmap',
},
},
options: [
{
@ -749,6 +1102,81 @@ const toolbarButtons: Button[] = [
],
},
},
{
id: 'SimplifyContours',
uiType: 'ohif.toolButton',
props: {
icon: 'actions-simplify',
label: 'Simplify Contours',
tooltip: 'Simplify Contours',
commands: ['toggleActiveSegmentationUtility'],
evaluate: [
{
name: 'cornerstone.isActiveSegmentationUtility',
},
],
options: 'cornerstone.SimplifyContourOptions',
},
},
{
id: 'SmoothContours',
uiType: 'ohif.toolButton',
props: {
icon: 'actions-smooth',
label: 'Smooth Contours',
tooltip: 'Smooth Contours',
commands: ['toggleActiveSegmentationUtility'],
evaluate: [
{
name: 'cornerstone.isActiveSegmentationUtility',
},
],
options: 'cornerstone.SmoothContoursOptions',
},
},
{
id: 'LogicalContourOperations',
uiType: 'ohif.toolButton',
props: {
icon: 'actions-combine',
label: 'Combine Contours',
tooltip: 'Combine Contours',
commands: ['toggleActiveSegmentationUtility'],
evaluate: [
{
name: 'cornerstone.isActiveSegmentationUtility',
},
],
options: 'cornerstone.LogicalContourOperationsOptions',
},
},
{
id: 'LabelMapEditWithContour',
uiType: 'ohif.toolBoxButton',
props: {
icon: 'tool-labelmap-edit-with-contour',
label: 'Labelmap Edit with Contour Tool',
tooltip: 'Labelmap Edit with Contour Tool',
commands: [
'setToolActiveToolbar',
{
commandName: 'activateSelectedSegmentationOfType',
commandOptions: { segmentationRepresentationType: 'Labelmap' },
},
],
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['LabelMapEditWithContour'],
disabledText: 'Create new segmentation to enable this tool.',
},
{
name: 'evaluate.cornerstone.hasSegmentationOfType',
segmentationRepresentationType: 'Labelmap',
},
],
},
},
];
export default toolbarButtons;

View File

@ -0,0 +1,56 @@
/**
* Sets up auto tab switching for when the first segmentation is added into the viewer.
*/
export default function setUpAutoTabSwitchHandler({
segmentationService,
viewportGridService,
panelService,
}) {
const autoTabSwitchEvents = [
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED,
];
// Initially there are no segmentations, so we should switch the tab whenever the first segmentation is added.
let shouldSwitchTab = true;
const unsubscribeAutoTabSwitchEvents = autoTabSwitchEvents
.map(eventName =>
segmentationService.subscribe(eventName, () => {
const segmentations = segmentationService.getSegmentations();
if (!segmentations.length) {
// If all the segmentations are removed, then the next time a segmentation is added, we should switch the tab.
shouldSwitchTab = true;
return;
}
const activeViewportId = viewportGridService.getActiveViewportId();
const activeRepresentation = segmentationService
.getSegmentationRepresentations(activeViewportId)
?.find(representation => representation.active);
if (activeRepresentation && shouldSwitchTab) {
shouldSwitchTab = false;
switch (activeRepresentation.type) {
case 'Labelmap':
panelService.activatePanel(
'@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsLabelMap',
true
);
break;
case 'Contour':
panelService.activatePanel(
'@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsContour',
true
);
break;
}
}
})
)
.map(subscription => subscription.unsubscribe);
return { unsubscribeAutoTabSwitchEvents };
}

View File

@ -3,3 +3,5 @@ export * from './types';
export * from './useViewportRef';
export * from './useViewportSize';
export * from './useViewportMousePosition';
export * from './useActiveToolOptions';
export * from './useRunCommand';

View File

@ -0,0 +1,53 @@
import { useSystem } from '../contextProviders/SystemProvider';
import { ButtonProps } from '../services/ToolBarService/types';
import { useToolbar } from './useToolbar';
type UseActiveToolOptionsProps = {
buttonSectionId: string;
};
export function useActiveToolOptions({ buttonSectionId }: UseActiveToolOptionsProps) {
const { servicesManager } = useSystem();
const { toolbarService } = servicesManager.services;
const { toolbarButtons } = useToolbar({
buttonSection: buttonSectionId,
});
// Helper to check a list of buttons for an active tool.
const findActiveOptions = (buttons: any[]): unknown => {
for (const tool of buttons) {
if (tool.componentProps.isActive) {
return {
activeToolOptions: tool.componentProps.options,
activeToolButtonId: tool.componentProps.id,
};
}
if (tool.componentProps.buttonSection) {
const nestedButtons = toolbarService.getButtonPropsInButtonSection(
tool.componentProps.buttonSection
) as ButtonProps[];
const activeNested = nestedButtons.find(nested => nested.isActive);
if (activeNested) {
return {
activeToolOptions: activeNested.options,
activeToolButtonId: activeNested.id,
};
}
}
}
return null;
};
// Look for active tool options across all sections.
const activeOptions = toolbarButtons.reduce((activeOptions, button) => {
if (activeOptions) {
return activeOptions;
}
const sectionId = button.componentProps.buttonSection;
const buttons = sectionId ? toolbarService.getButtonSection(sectionId) : [button];
return findActiveOptions(buttons);
}, null);
return activeOptions ?? {};
}

View File

@ -0,0 +1,19 @@
import { useCallback } from 'react';
import { useSystem } from '../contextProviders/SystemProvider';
/**
* Hook that provides a runCommand function for executing commands
* @returns A memoized runCommand function
*/
export function useRunCommand() {
const { commandsManager } = useSystem();
const runCommand = useCallback(
(commandName: string, commandOptions: Record<string, unknown> = {}) => {
return commandsManager.runCommand(commandName, commandOptions);
},
[commandsManager]
);
return runCommand;
}

View File

@ -38,7 +38,10 @@ export const TOOLBAR_SECTIONS = {
},
// mode specific
segmentationToolbox: 'segmentationToolbox',
labelMapSegmentationToolbox: 'labelMapSegmentationToolbox',
contourSegmentationToolbox: 'contourSegmentationToolbox',
labelMapSegmentationUtilities: 'labelMapSegmentationUtilities',
contourSegmentationUtilities: 'contourSegmentationUtilities',
dynamicToolbox: 'dynamic-toolbox',
roiThresholdToolbox: 'ROIThresholdToolbox',
};
@ -314,12 +317,22 @@ export default class ToolbarService extends PubSubService {
: undefined;
// Check hideWhenDisabled at both evaluateProps level and props level
const hideWhenDisabled = evaluateProps?.hideWhenDisabled || props.hideWhenDisabled;
// Visibility is first determined by the evaluate function. If it is not returned from there,
// then we check hideWhenDisabled and disabled to determine visibility.
const visible =
evaluated?.visible === undefined
? hideWhenDisabled && evaluated?.disabled
? false
: true
: evaluated?.visible;
const updatedProps = {
...props,
...evaluated,
disabled: evaluated?.disabled || false,
visible: hideWhenDisabled && evaluated?.disabled ? false : true,
className: evaluated?.className || '',
visible,
className: evaluated?.className || props?.className || '',
isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop
};
evaluationResults.set(button.id, updatedProps);
@ -570,7 +583,8 @@ export default class ToolbarService extends PubSubService {
btn.component = buttonType.defaultComponent;
}
if (!buttonType) {
if (!buttonType && !btn.component) {
console.warn(`Neither button type nor a component found for button: ${id}`);
return;
}

View File

@ -34,7 +34,7 @@ export type EvaluateObject = {
export type ButtonOptions = {
id: string;
type: 'range' | 'radio' | 'double-range' | 'custom';
type: 'range' | 'radio' | 'double-range' | 'custom' | 'checkbox' | 'select' | 'button';
name?: string;
min?: number;
max?: number;
@ -59,6 +59,7 @@ export type ButtonProps = {
listeners?: Record<string, RunCommand>;
options?: ButtonOptions[];
buttonSection?: string | boolean;
isActive?: boolean;
};
export type Button = {

View File

@ -117,6 +117,13 @@ Let's look at one of the evaluators (for `evaluate.cornerstoneTool`)
as you can see the job of this evaluator is to determine if the button should be disabled or not. It does so by checking the `toolGroup` and the `toolName` and then returns an object with `disabled` and `className` properties.
Various information can be returned by an evaluator. In particular...
- `disabled`: flag indicating if the tool should be disabled or not
- `disabledText`: the text or tooltip to show if the `disabled` flag above is set to `true`
- `visible`: flag indicating if the tool show be visible or not; this is a convenient way to force a tool to hide based on some custom (evaluator) logic
- `isActive`: flag to indicating where the tool is currently active or not
- `className`: custom CSS class names to add to the tool's component
The following evaluators are provided by us:
- `evaluate.cornerstoneTool`: If assigned to a button (see next), it will make the button react to the active viewport state based on its toolGroup.

View File

@ -34,6 +34,17 @@ createLabelmapForDisplaySet(
}
}
)
createContourForDisplaySet(
displaySet,
{
segmentationId?: string,
label: string,
segments?: {
[segmentIndex: number]: Partial<Segment>
}
}
)
```
### Segmentation Management
@ -85,17 +96,33 @@ interface Segmentation {
## Code Examples
### Creating a Segmentation
### Creating a Label Map Segmentation
```typescript
const displaySet = displaySetService.getDisplaySetByUID(displaySetUID);
const segmentationId = await segmentationService.createLabelmapForDisplaySet(
displaySet,
{
label: 'New Segmentation',
label: 'New Label Map Segmentation',
segments: {
1: {
label: 'First Segment',
label: 'First Label Map Segment',
active: true
}
}
}
);
### Creating a Label Map Segmentation
const displaySet = displaySetService.getDisplaySetByUID(displaySetUID);
const segmentationId = await segmentationService.createContourForDisplaySet(
displaySet,
{
label: 'New Contour Segmentation',
segments: {
1: {
label: 'First Contour Segment',
active: true
}
}

View File

@ -3,16 +3,25 @@
"Add new segmentation": "Add new segmentation",
"Add segment": "Add segment",
"Add segmentation": "Add segmentation",
"Contour": "Contour",
"Contour tools": "Contour tools",
"Contour Segmentations": "Contour segmentations",
"Delete": "Delete",
"Display inactive segmentations": "Display inactive segmentations",
"Download DICOM RTSS": "Download DICOM RTSS",
"Export DICOM SEG": "Export DICOM SEG",
"Download DICOM SEG": "Download DICOM SEG",
"Download DICOM RTSTRUCT": "Download DICOM RTSTRUCT",
"Fill": "Fill",
"Inactive segmentations": "Inactive segmentations",
"Labelmap": "Label map",
"Labelmap tools": "Label map tools",
"Labelmap Segmentations": "Label map segmentations",
"Opacity": "Opacity",
"Outline": "Outline",
"Rename": "Rename",
"Segmentation": "Segmentation",
"Segmentations": "Segmentations",
"Segmentation not supported": "Segmentation not supported",
"Size": "Size"
}

View File

@ -3,16 +3,24 @@
"Add new segmentation": "Test Add new segmentation",
"Add segment": "Test Add segment",
"Add segmentation": "Test Add segmentation",
"Contour": "Test Contour",
"Contour tools": "Test Contour tools",
"Contour Segmentations": "Contour segmentations",
"Delete": "Test Delete",
"Display inactive segmentations": "Test Display inactive segmentations",
"Download DICOM RTSS": "Test Download DICOM RTSS",
"Export DICOM SEG": "Test Export DICOM SEG",
"Download DICOM SEG": "Test Download DICOM SEG",
"Download DICOM RTSTRUCT": "Test Download DICOM RTSTRUCT",
"Fill": "Test Fill",
"Inactive segmentations": "Test Inactive segmentations",
"Labelmap": "Test Label map",
"Labelmap tools": "Test Label map tools",
"Labelmap Segmentations": "Test Label map segmentations",
"Opacity": "Test Opacity",
"Outline": "Test Outline",
"Rename": "Test Rename",
"Segmentation": "Test Segmentation",
"Segmentation not supported": "Test Segmentation not supported",
"Size": "Test Size"
}

View File

@ -87,7 +87,10 @@ interface DataRowProps {
description: string;
details?: { primary: string[]; secondary: string[] };
//
/** Primary selection: selected and in the active segmentation */
isSelected?: boolean;
/** Secondary selection: selected but in an inactive segmentation */
isSecondarySelected?: boolean;
onSelect?: (e) => void;
//
isVisible: boolean;
@ -103,6 +106,7 @@ interface DataRowProps {
//
colorHex?: string;
onColor: (e) => void;
onCopy?: (e) => void;
className?: string;
children?: React.ReactNode;
}
@ -121,7 +125,9 @@ const DataRowComponent = React.forwardRef<HTMLDivElement, DataRowProps>(
onRename,
onDelete,
onColor,
onCopy,
isSelected = false,
isSecondarySelected = false,
isVisible = true,
disableEditing = false,
className,
@ -146,6 +152,9 @@ const DataRowComponent = React.forwardRef<HTMLDivElement, DataRowProps>(
case 'Rename':
onRename(e);
break;
case 'Copy':
onCopy?.(e);
break;
case 'Lock':
onToggleLocked(e);
break;
@ -234,7 +243,11 @@ const DataRowComponent = React.forwardRef<HTMLDivElement, DataRowProps>(
onClick={onSelect}
data-cy="data-row"
>
{/* Hover Overlay */}
{/* Secondary Selection Tint (below hover, always visible when secondary-selected) */}
{isSecondarySelected && (
<div className="bg-primary/20 pointer-events-none absolute inset-0"></div>
)}
<div className="bg-primary/20 pointer-events-none absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100"></div>
{/* Number Box */}
@ -351,6 +364,17 @@ const DataRowComponent = React.forwardRef<HTMLDivElement, DataRowProps>(
Rename
</span>
</DropdownMenuItem>
{onCopy && (
<DropdownMenuItem onClick={e => handleAction('Copy', e)}>
<Icons.Copy className="text-foreground" />
<span
className="pl-2"
data-cy="Duplicate"
>
Duplicate
</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={e => handleAction('Delete', e)}>
<Icons.Delete className="text-foreground" />
<span

View File

@ -146,6 +146,8 @@ import {
ToolExpand,
ToolClickSegment,
ToolSegmentLabel,
ToolSculptor,
ToolLabelmapEditWithContour,
} from './Sources/Tools';
import ActionNewDialog from './Sources/ActionNewDialog';
import NotificationInfo from './Sources/NotificationInfo';
@ -228,6 +230,7 @@ import ArrowRight from './Sources/ArrowRight';
import ChevronLeft from './Sources/ChevronLeft';
import StatusAlert from './Sources/StatusAlert';
import Undo from './Sources/Undo';
import TabContours from './Sources/TabContours';
//
//
type IconProps = React.HTMLAttributes<SVGElement>;
@ -674,6 +677,8 @@ export const Icons = {
'status-tracked': (props: IconProps) => StatusTracking(props),
'status-untracked': (props: IconProps) => StatusUntracked(props),
'status-locked': (props: IconProps) => StatusLocked(props),
'tab-contours': (props: IconProps) => TabContours(props),
TabContours: (props: IconProps) => TabContours(props),
'tab-segmentation': (props: IconProps) => TabSegmentation(props),
'tab-studies': (props: IconProps) => TabStudies(props),
'tab-linear': (props: IconProps) => TabLinear(props),
@ -720,6 +725,7 @@ export const Icons = {
'tool-referenceLines': (props: IconProps) => ToolReferenceLines(props),
'tool-reset': (props: IconProps) => ToolReset(props),
'tool-rotate-right': (props: IconProps) => ToolRotateRight(props),
'icon-tool-sculptor': (props: IconProps) => ToolSculptor(props),
'tool-seg-brush': (props: IconProps) => ToolSegBrush(props),
'tool-seg-eraser': (props: IconProps) => ToolSegEraser(props),
'tool-seg-shape': (props: IconProps) => ToolSegShape(props),
@ -775,6 +781,7 @@ export const Icons = {
'old-trash': (props: IconProps) => Trash(props),
'tool-point': (props: IconProps) => ToolCircle(props),
'tool-freehand-line': (props: IconProps) => ToolFreehand(props),
'tool-labelmap-edit-with-contour': (props: IconProps) => ToolLabelmapEditWithContour(props),
'actions-smooth': (props: IconProps) => ActionsSmooth(props),
'actions-simplify': (props: IconProps) => ActionsSimplify(props),
'actions-combine': (props: IconProps) => ActionsCombine(props),

View File

@ -0,0 +1,63 @@
import React from 'react';
import type { IconProps } from '../types';
export const TabContours = (props: IconProps) => (
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
{...props}
>
<path
d="M11.9168 1.5H10.2502C10.02 1.5 9.8335 1.68655 9.8335 1.91667V3.58333C9.8335 3.81345 10.02 4 10.2502 4H11.9168C12.1469 4 12.3335 3.81345 12.3335 3.58333V1.91667C12.3335 1.68655 12.1469 1.5 11.9168 1.5Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11.9168 18.1667H10.2502C10.02 18.1667 9.8335 18.3532 9.8335 18.5833V20.25C9.8335 20.4801 10.02 20.6667 10.2502 20.6667H11.9168C12.1469 20.6667 12.3335 20.4801 12.3335 20.25V18.5833C12.3335 18.3532 12.1469 18.1667 11.9168 18.1667Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M20.2503 9.83333H18.5837C18.3535 9.83333 18.167 10.0199 18.167 10.25V11.9167C18.167 12.1468 18.3535 12.3333 18.5837 12.3333H20.2503C20.4804 12.3333 20.667 12.1468 20.667 11.9167V10.25C20.667 10.0199 20.4804 9.83333 20.2503 9.83333Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.58333 9.83333H1.91667C1.68655 9.83333 1.5 10.0199 1.5 10.25V11.9167C1.5 12.1468 1.68655 12.3333 1.91667 12.3333H3.58333C3.81345 12.3333 4 12.1468 4 11.9167V10.25C4 10.0199 3.81345 9.83333 3.58333 9.83333Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.84277 9.83332C3.11056 8.0751 3.93334 6.44851 5.19101 5.191C6.44867 3.93349 8.07536 3.1109 9.83361 2.84332"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.83361 19.3242C8.07524 19.0565 6.44846 18.2338 5.19078 16.9762C3.93311 15.7185 3.1104 14.0917 2.84277 12.3333"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19.3243 12.3333C19.0568 14.0916 18.2341 15.7184 16.9764 16.9759C15.7187 18.2335 14.0918 19.056 12.3335 19.3233"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.3335 2.84332C14.0917 3.1109 15.7184 3.93349 16.9761 5.191C18.2338 6.44851 19.0565 8.0751 19.3243 9.83332"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default TabContours;

View File

@ -2995,16 +2995,16 @@ export const ToolSegmentLabel = (props: IconProps) => (
<path
d="M2.6636 14.3578C2.58841 14.4105 2.53094 14.4847 2.49878 14.5707C2.46661 14.6567 2.46127 14.7504 2.48345 14.8395C2.50564 14.9286 2.5543 15.0088 2.62302 15.0697C2.69175 15.1305 2.77731 15.1691 2.86842 15.1804L7.42867 16.5722L8.82125 21.1316C8.83209 21.2229 8.87047 21.3088 8.93131 21.3779C8.99214 21.4469 9.07254 21.4958 9.16181 21.518C9.25109 21.5402 9.34502 21.5348 9.43111 21.5023C9.5172 21.4699 9.59139 21.412 9.6438 21.3364L20.4127 10.5683C20.7607 10.1801 20.9661 9.68484 20.9951 9.16423L21 3.82256C21 3.6044 20.9133 3.39518 20.7591 3.24092C20.6048 3.08666 20.3956 3 20.1774 3H14.8382C14.3176 3.02823 13.8223 3.2334 13.4341 3.58155L2.6636 14.3578Z"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.8882 8.75787C15.9796 8.75787 15.2431 8.02133 15.2431 7.11276C15.2431 6.20419 15.9796 5.46765 16.8882 5.46765C17.7968 5.46765 18.5333 6.20419 18.5333 7.11276C18.5333 8.02133 17.7968 8.75787 16.8882 8.75787Z"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
@ -3544,3 +3544,57 @@ export const ToolContract = (props: IconProps) => (
/>
</svg>
);
export const ToolSculptor = (props: IconProps) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M16.5 1.5C19.2614 1.5 21.5 3.73858 21.5 6.5C21.5 8.59844 20.2065 10.3931 18.374 11.1348C18.4555 11.5776 18.5 12.0336 18.5 12.5C18.5 16.6421 15.1421 20 11 20C10.6311 20 10.4428 19.5684 10.6035 19.2363C10.8575 18.7113 11 18.1224 11 17.5C11 15.2909 9.20914 13.5 7 13.5C6.07751 13.5 5.22759 13.8124 4.55078 14.3369C4.24235 14.5757 3.75506 14.4797 3.67188 14.0986C3.55945 13.5836 3.5 13.0488 3.5 12.5C3.5 8.35786 6.85786 5 11 5C11.2421 5 11.4815 5.01272 11.7178 5.03516C12.3439 2.98844 14.2482 1.5 16.5 1.5ZM16.4707 3.53125C16.1133 3.53125 15.8906 3.7832 15.8906 4.14648V6.07422H14.0684C13.7109 6.07422 13.4648 6.29688 13.4648 6.64844C13.4648 7 13.7109 7.2168 14.0684 7.2168H15.8906V9.13867C15.8906 9.50195 16.1133 9.75391 16.4707 9.75391C16.8223 9.75391 17.0449 9.50195 17.0449 9.13867V7.2168H18.8613C19.2188 7.2168 19.4707 7 19.4707 6.64844C19.4707 6.29688 19.2188 6.07422 18.8613 6.07422H17.0449V4.14648C17.0449 3.7832 16.8223 3.53125 16.4707 3.53125Z"
fill="currentColor"
/>
<path
d="M5.69531 18.1406C5.34375 18.1406 5.08594 17.9238 5.08594 17.5664C5.08594 17.2148 5.34375 16.998 5.69531 16.998H8.50195C8.85352 16.998 9.11719 17.2148 9.11719 17.5664C9.11719 17.9238 8.85352 18.1406 8.50195 18.1406H5.69531Z"
fill="currentColor"
/>
<circle
cx="7"
cy="17.5"
r="4.5"
stroke="currentColor"
strokeLinecap="round"
/>
</svg>
);
export const ToolLabelmapEditWithContour = (props: IconProps) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M10.3945 12.6113C10.043 12.6113 9.78516 12.3945 9.78516 12.0371C9.78516 11.6855 10.043 11.4688 10.3945 11.4688H13.2012C13.5527 11.4688 13.8164 11.6855 13.8164 12.0371C13.8164 12.3945 13.5527 12.6113 13.2012 12.6113H10.3945Z"
fill="currentColor"
/>
<path
opacity="0.5"
d="M18.312 6.84473C21.8473 8.88583 22.9225 13.6424 20.7134 17.4687C18.5042 21.2951 13.8473 22.7422 10.312 20.7011C9.51661 20.2419 8.84737 19.6439 8.31042 18.9512C11.3012 19.3598 14.4827 17.8251 16.1946 14.8598C17.9067 11.8945 17.6457 8.3712 15.7963 5.98538C16.6645 6.10409 17.5167 6.38556 18.312 6.84473Z"
fill="currentColor"
/>
<circle
cx="13.7852"
cy="13.4688"
r="8"
stroke="currentColor"
/>
<path
d="M4.50586 8.22266C4.14844 8.22266 3.92578 7.9707 3.92578 7.60742V5.68555H2.10352C1.74609 5.68555 1.5 5.46875 1.5 5.11719C1.5 4.76562 1.74609 4.54297 2.10352 4.54297H3.92578V2.61523C3.92578 2.25195 4.14844 2 4.50586 2C4.85742 2 5.08008 2.25195 5.08008 2.61523V4.54297H6.89648C7.25391 4.54297 7.50586 4.76562 7.50586 5.11719C7.50586 5.46875 7.25391 5.68555 6.89648 5.68555H5.08008V7.60742C5.08008 7.9707 4.85742 8.22266 4.50586 8.22266Z"
fill="currentColor"
/>
</svg>
);

View File

@ -1,8 +1,12 @@
import React from 'react';
import React, { useState } from 'react';
import RowInputRange from './RowInputRange';
import RowSegmentedControl from './RowSegmentedControl';
import RowDoubleRange from './RowDoubleRange';
import { Button } from '../Button';
import { Checkbox } from '../Checkbox/Checkbox';
import { Label } from '../Label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../Select';
import { Switch } from '../Switch';
const SETTING_TYPES = {
RANGE: 'range',
@ -10,6 +14,9 @@ const SETTING_TYPES = {
CUSTOM: 'custom',
DOUBLE_RANGE: 'double-range',
BUTTON: 'button',
CHECKBOX: 'checkbox',
SWITCH: 'switch',
SELECT: 'select',
};
function ToolSettings({ options }) {
@ -18,7 +25,8 @@ function ToolSettings({ options }) {
}
if (typeof options === 'function') {
return options();
const OptionsComponent = options;
return <OptionsComponent />;
}
return (
@ -39,6 +47,12 @@ function ToolSettings({ options }) {
return renderCustomSetting(option);
case SETTING_TYPES.BUTTON:
return renderButtonSetting(option);
case SETTING_TYPES.CHECKBOX:
return renderCheckboxSetting(option);
case SETTING_TYPES.SWITCH:
return renderSwitchSetting(option);
case SETTING_TYPES.SELECT:
return renderSelectSetting(option);
default:
return null;
}
@ -116,4 +130,74 @@ const renderButtonSetting = option => {
);
};
const renderCheckboxSetting = option => {
return (
<div
key={option.id}
className="flex items-center gap-2"
>
<Checkbox
id={option.id}
checked={option.value}
onCheckedChange={checked => {
option.onChange?.(checked);
}}
/>
<Label
htmlFor={option.id}
className="cursor-pointer"
>
{option.name}
</Label>
</div>
);
};
const renderSwitchSetting = option => {
return (
<div
key={option.id}
className="flex items-center gap-2"
>
<Switch
id={option.id}
checked={option.value}
onCheckedChange={checked => {
option.onChange?.(checked);
}}
/>
<Label
htmlFor={option.id}
className="cursor-pointer"
>
{option.name}
</Label>
</div>
);
};
const renderSelectSetting = option => {
return (
<Select
key={option.id}
onValueChange={value => option.onChange?.(value)}
value={option.value}
>
<SelectTrigger className="w-full overflow-hidden">
<SelectValue />
</SelectTrigger>
<SelectContent>
{option.values.map(value => (
<SelectItem
key={value.id}
value={value.id}
>
{value.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default ToolSettings;

View File

@ -8,12 +8,21 @@ export const AddSegmentationRow: React.FC<{ children?: React.ReactNode }> = ({
}) => {
const { t } = useTranslation('SegmentationTable');
const { onSegmentationAdd, data, disableEditing, mode, disabled } =
useSegmentationTableContext('AddSegmentationRow');
const {
onSegmentationAdd,
data,
disableEditing,
mode,
disabled,
segmentationRepresentationType,
} = useSegmentationTableContext('AddSegmentationRow');
const isEmpty = data.length === 0;
// Check if we have at least one segmentation of the representation type for the panel this component is contained in.
const hasRepresentationType =
(!segmentationRepresentationType && data.length > 0) ||
data.some(info => segmentationRepresentationType === info.representation?.type);
if (!isEmpty && mode === 'collapsed') {
if (hasRepresentationType && mode === 'collapsed') {
return null;
}
@ -25,7 +34,9 @@ export const AddSegmentationRow: React.FC<{ children?: React.ReactNode }> = ({
<div
data-cy="addSegmentation"
className={`group ${disabled ? 'pointer-events-none cursor-not-allowed opacity-70' : ''}`}
onClick={() => !disabled && onSegmentationAdd('')}
onClick={() =>
!disabled && onSegmentationAdd({ segmentationId: '', segmentationRepresentationType })
}
>
{children}
<div className="text-primary group-hover:bg-secondary-dark flex items-center rounded-[4px] pl-1 group-hover:cursor-pointer">
@ -33,7 +44,7 @@ export const AddSegmentationRow: React.FC<{ children?: React.ReactNode }> = ({
{disabled ? <Icons.Info /> : <Icons.Add />}
</div>
<span className="text-[13px]">
{t(`${disabled ? 'Segmentation Not Supported' : 'Add Segmentation'}`)}
{t(disabled ? 'Segmentation not supported' : 'Add segmentation')}
</span>
</div>
</div>

View File

@ -1,6 +1,10 @@
import React from 'react';
import { PanelSection } from '../PanelSection';
import { useSegmentationTableContext, SegmentationExpandedProvider } from './contexts';
import {
useSegmentationTableContext,
SegmentationExpandedProvider,
useSegmentationExpanded,
} from './contexts';
import { useTranslation } from 'react-i18next';
import {
Button,
@ -46,23 +50,31 @@ const SegmentationCollapsedDropdownMenu = ({ children }: { children: React.React
// Selector component - for the segmentation selection dropdown
const SegmentationCollapsedSelector = () => {
const { t } = useTranslation('SegmentationTable.HeaderCollapsed');
const { data, activeSegmentationId, onSegmentationClick } = useSegmentationTableContext(
const { data, onSegmentationClick, segmentationRepresentationType } = useSegmentationTableContext(
'SegmentationCollapsedSelector'
);
const { segmentation } = useSegmentationExpanded('SegmentationCollapsedSelector');
if (!data?.length) {
return null;
}
const segmentations = data.map(seg => ({
id: seg.segmentation.segmentationId,
label: seg.segmentation.label,
}));
const segmentations = data
// Only show segmentations of the representation type for this panel. Show all segmentations if no type is specified.
.filter(
seg =>
!segmentationRepresentationType ||
segmentationRepresentationType === seg.representation.type
)
.map(seg => ({
id: seg.segmentation.segmentationId,
label: seg.segmentation.label,
}));
return (
<Select
onValueChange={value => onSegmentationClick(value)}
value={activeSegmentationId}
value={segmentation?.segmentationId}
>
<SelectTrigger className="w-full overflow-hidden">
<SelectValue placeholder={t('Select a segmentation')} />
@ -120,28 +132,33 @@ const SegmentationCollapsedContent = ({ children }: { children: React.ReactNode
const SegmentationCollapsedRoot: React.FC<{ children?: React.ReactNode }> = ({
children = null,
}) => {
const { mode, data, activeSegmentationId } = useSegmentationTableContext('SegmentationCollapsed');
const { mode, data, segmentationRepresentationType, selectedSegmentationIdForType } =
useSegmentationTableContext('SegmentationCollapsed');
// Check if we should render based on mode
if (mode !== 'collapsed' || !data || data.length === 0) {
return null;
}
// Find active segmentation
const activeSegmentationInfo = data.find(
info => info.segmentation.segmentationId === activeSegmentationId
// Find the segmentations for the representation type for this collapsed view.
const segmentations = data.filter(
segmentation =>
!segmentationRepresentationType ||
segmentationRepresentationType === segmentation.representation?.type
);
if (!activeSegmentationInfo) {
// Check if we should render.
if (mode !== 'collapsed' || !data || data.length === 0 || segmentations.length === 0) {
return null;
}
// Find the selected segmentation info for the representation type, or default to the first one.
const selectedSegmentationInfo =
segmentations.find(
segmentation => segmentation.segmentation.segmentationId === selectedSegmentationIdForType
) ?? segmentations[0];
return (
<div className="space-y-0">
<PanelSection className="mb-0">
<SegmentationExpandedProvider
segmentation={activeSegmentationInfo.segmentation}
representation={activeSegmentationInfo.representation}
segmentation={selectedSegmentationInfo.segmentation}
representation={selectedSegmentationInfo.representation}
isActive={true}
onSegmentationClick={() => {}} // No-op since it's already the active one
>

View File

@ -93,7 +93,7 @@ const SegmentationExpandedContent = ({ children }: { children: React.ReactNode }
// Main compound component
const SegmentationExpandedRoot = ({ children }) => {
const { data, activeSegmentationId, onSegmentationClick, mode } =
const { data, activeSegmentationId, onSegmentationClick, mode, segmentationRepresentationType } =
useSegmentationTableContext('SegmentationExpanded');
const { ref: scrollableContainerRef, maxHeight } = useDynamicMaxHeight(data);
@ -113,25 +113,31 @@ const SegmentationExpandedRoot = ({ children }) => {
style={{ maxHeight: maxHeight }}
className={`space-y-0 pl-0.5`}
>
{data.map(segmentationInfo => {
const isActive = segmentationInfo.segmentation.segmentationId === activeSegmentationId;
{data
.filter(
segmentationInfo =>
!segmentationRepresentationType ||
segmentationInfo.representation.type === segmentationRepresentationType
)
.map(segmentationInfo => {
const isActive = segmentationInfo.segmentation.segmentationId === activeSegmentationId;
return (
<PanelSection
key={segmentationInfo.segmentation.segmentationId}
className=""
>
<SegmentationExpandedProvider
segmentation={segmentationInfo.segmentation}
representation={segmentationInfo.representation}
isActive={isActive}
onSegmentationClick={onSegmentationClick}
return (
<PanelSection
key={segmentationInfo.segmentation.segmentationId}
className=""
>
{children}
</SegmentationExpandedProvider>
</PanelSection>
);
})}
<SegmentationExpandedProvider
segmentation={segmentationInfo.segmentation}
representation={segmentationInfo.representation}
isActive={isActive}
onSegmentationClick={onSegmentationClick}
>
{children}
</SegmentationExpandedProvider>
</PanelSection>
);
})}
</div>
</ScrollArea>
);

View File

@ -15,6 +15,7 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea
onSegmentClick,
onSegmentEdit,
onSegmentDelete,
onSegmentCopy,
data,
showSegmentIndex = true,
} = useSegmentationTableContext('SegmentationSegments');
@ -116,6 +117,9 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea
const { locked, active, label, displayText } = segmentFromSegmentation;
const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`;
// Secondary selection: segment is active, but its parent segmentation is inactive
const isSecondarySelected = active && !isActiveSegmentation;
const hasStats = segmentFromSegmentation.cachedStats?.namedStats;
const segmentRowRef = (element: HTMLElement) => {
@ -139,7 +143,10 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea
// details={displayText}
description={displayText}
colorHex={cssColor}
isSelected={active}
// Primary selection only when part of the active segmentation
isSelected={active && isActiveSegmentation}
// Secondary selection tint when selected in an inactive segmentation
isSecondarySelected={isSecondarySelected}
isVisible={visible}
isLocked={locked}
disableEditing={disableEditing}
@ -158,6 +165,11 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea
onSelect={() => onSegmentClick(segmentation.segmentationId, segmentIndex)}
onRename={() => onSegmentEdit(segmentation.segmentationId, segmentIndex)}
onDelete={() => onSegmentDelete(segmentation.segmentationId, segmentIndex)}
onCopy={
onSegmentCopy
? () => onSegmentCopy(segmentation.segmentationId, segmentIndex)
: undefined
}
/>
);

View File

@ -65,6 +65,13 @@ export interface SegmentationTableContextType {
showSegmentIndex?: boolean;
renderInactiveSegmentations?: boolean;
// The type segmentations displayed/filtered in this table. If undefined, show all types.
segmentationRepresentationType?: string;
// The (last) selected segmentation ID for the representation type above.
// If the type above is undefined, then it will store the last active segmentation ID.
selectedSegmentationIdForType?: string;
// Function handlers
setShowConfig?: (show: boolean) => void;
setRenderFill?: ({ type }: { type: string }, value: boolean) => void;
@ -73,12 +80,19 @@ export interface SegmentationTableContextType {
setFillAlpha?: ({ type }: { type: string }, value: number) => void;
setFillAlphaInactive?: ({ type }: { type: string }, value: number) => void;
toggleRenderInactiveSegmentations?: () => void;
onSegmentationAdd?: (segmentationId: string) => void;
onSegmentationAdd?: ({
segmentationId,
segmentationRepresentationType,
}: {
segmentationId: string;
segmentationRepresentationType: string;
}) => void;
onSegmentationClick?: (segmentationId: string) => void;
onSegmentationDelete?: (segmentationId: string) => void;
onSegmentAdd?: (segmentationId: string) => void;
onSegmentClick?: (segmentationId: string, segmentIndex: number) => void;
onSegmentEdit?: (segmentationId: string, segmentIndex: number) => void;
onSegmentCopy?: (segmentationId: string, segmentIndex: number) => void;
onSegmentationEdit?: (segmentationId: string) => void;
onSegmentColorClick?: (segmentationId: string, segmentIndex: number) => void;
onSegmentDelete?: (segmentationId: string, segmentIndex: number) => void;

View File

@ -22,6 +22,8 @@ test('should prevent editing of label map segmentations when panelSegmentation.d
}
);
});
await page.getByTestId('panelSegmentationWithToolsLabelMap-btn').click();
await page.getByTestId('study-browser-thumbnail-no-image').dblclick();
// Wait for the segmentation to be loaded.
await page.waitForTimeout(5000);
@ -74,6 +76,9 @@ test('should allow editing of label map segmentations when panelSegmentation.dis
}
);
});
await page.getByTestId('panelSegmentationWithToolsLabelMap-btn').click();
await page.getByTestId('study-browser-thumbnail-no-image').dblclick();
// Wait for the segmentation to be loaded.
await page.waitForTimeout(5000);

View File

@ -13,11 +13,13 @@ test('should properly display MPR for MR', async ({ page }) => {
await page.getByTestId('yes-hydrate-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segHydrationThenMPR.segPostHydration);
await page.getByTestId('Layout').click();
await page.getByTestId('Axial Primary').click();
await page.waitForTimeout(5000);
await checkForScreenshot(
page,
page,

View File

@ -10,9 +10,12 @@ test.beforeEach(async ({ page }) => {
test('checks basic add, rename, delete segments from panel', async ({ page }) => {
// Segmentation Panel should already be open
const segmentationPanel = page.getByTestId('panelSegmentationWithTools-btn');
const segmentationPanel = page.getByTestId('panelSegmentationWithToolsLabelMap-btn');
await expect(segmentationPanel).toBeVisible();
// Switch to labelmap tab.
segmentationPanel.click();
// Add segmentation
const addSegmentationBtn = page.getByTestId('addSegmentation');
await addSegmentationBtn.click();
@ -72,7 +75,7 @@ test('checks saved segmentations loads and jumps to slices', async ({ page }) =>
await page.getByTestId('yes-hydrate-btn').click();
// Segmentation Panel should already be open
const segmentationPanel = page.getByTestId('panelSegmentationWithTools-btn');
const segmentationPanel = page.getByTestId('panelSegmentationWithToolsLabelMap-btn');
await expect(segmentationPanel).toBeVisible();
// Confirm spleen jumps to slice 17

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 420 KiB