diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e1a315f47db..0438e4c9753 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -111,6 +111,8 @@ jobs: # ── Common: run tests ─────────────────────────────────────────────── - name: Install Playwright browsers run: npx playwright install + - name: Free OHIF e2e port (stale self-hosted processes) + run: node .scripts/ci/free-ohif-e2e-port.mjs - name: Run Playwright tests run: | export NODE_OPTIONS="--max_old_space_size=10192" diff --git a/.scripts/ci/free-ohif-e2e-port.mjs b/.scripts/ci/free-ohif-e2e-port.mjs new file mode 100644 index 00000000000..aac78046b42 --- /dev/null +++ b/.scripts/ci/free-ohif-e2e-port.mjs @@ -0,0 +1,160 @@ +/** + * Frees the OHIF Playwright e2e dev-server port before CI runs. + * Self-hosted runners (macOS and Linux) often leave `yarn start` / nyc processes + * bound to 3335 after cancelled or failed jobs, which makes Playwright fail with + * "http://localhost:3335 is already used". + */ +import { execSync } from 'node:child_process'; + +const DEFAULT_E2E_PORT = 3335; + +export function getOhifE2ePort() { + const port = Number(process.env.OHIF_PORT || DEFAULT_E2E_PORT); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid OHIF_PORT: ${process.env.OHIF_PORT}`); + } + return port; +} + +function runQuiet(command) { + try { + return execSync(command, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +function parsePidList(output) { + return [...new Set(output.split(/\s+/).filter(Boolean))]; +} + +function parseSsListeningPids(output) { + const pids = []; + for (const match of output.matchAll(/pid=(\d+)/g)) { + pids.push(match[1]); + } + return [...new Set(pids)]; +} + +function killPids(pids, port, method) { + const killed = []; + + for (const pid of pids) { + try { + process.kill(Number(pid), 'SIGKILL'); + killed.push(pid); + } catch { + // Process may have already exited. + } + } + + if (killed.length > 0) { + console.log( + `[free-ohif-e2e-port] Freed port ${port} via ${method}: killed PID(s) ${killed.join(', ')}` + ); + } +} + +function getListeningPidsDarwin(port) { + // macOS: -sTCP:LISTEN is supported and avoids matching outbound connections. + const output = runQuiet(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`); + if (output) { + return { pids: parsePidList(output), method: 'lsof (darwin)' }; + } + + const fallback = runQuiet(`lsof -nP -i :${port} -t`); + return { pids: parsePidList(fallback), method: 'lsof (darwin, fallback)' }; +} + +function getListeningPidsLinux(port) { + // Prefer LISTEN filter when supported (util-linux / recent lsof). + let output = runQuiet(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`); + if (output) { + return { pids: parsePidList(output), method: 'lsof (linux)' }; + } + + // Broader match — some Linux images lack -sTCP:LISTEN. + output = runQuiet(`lsof -nP -i :${port} -t`); + if (output) { + return { pids: parsePidList(output), method: 'lsof (linux, fallback)' }; + } + + // iproute2 ss — common on minimal Linux runners without lsof. + output = runQuiet(`ss -H -lptn 'sport = :${port}'`); + const ssPids = parseSsListeningPids(output); + if (ssPids.length > 0) { + return { pids: ssPids, method: 'ss' }; + } + + return { pids: [], method: null }; +} + +function freePortLinuxWithFuser(port) { + try { + execSync(`fuser -k ${port}/tcp`, { stdio: 'ignore' }); + console.log(`[free-ohif-e2e-port] Freed port ${port} via fuser`); + return true; + } catch { + return false; + } +} + +function freeOhifE2ePortUnix(port) { + const { pids, method } = + process.platform === 'darwin' + ? getListeningPidsDarwin(port) + : getListeningPidsLinux(port); + + if (pids.length > 0) { + killPids(pids, port, method); + return; + } + + if (process.platform === 'linux') { + freePortLinuxWithFuser(port); + } +} + +function freeOhifE2ePortWindows(port) { + const output = runQuiet( + `netstat -ano | findstr :${port} | findstr LISTENING` + ); + + if (!output) { + return; + } + + const pids = [ + ...new Set( + output + .split(/\r?\n/) + .map(line => line.trim().split(/\s+/).pop()) + .filter(Boolean) + ), + ]; + + killPids(pids, port, 'netstat'); +} + +export function freeOhifE2ePort(port = getOhifE2ePort()) { + const { platform } = process; + + if (platform === 'darwin' || platform === 'linux') { + freeOhifE2ePortUnix(port); + return; + } + + if (platform === 'win32') { + freeOhifE2ePortWindows(port); + } +} + +const isDirectRun = + process.argv[1]?.replace(/\\/g, '/').endsWith('free-ohif-e2e-port.mjs') ?? false; + +if (isDirectRun) { + freeOhifE2ePort(); +} diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index c55c52c41e8..470d40d20a4 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -193,6 +193,50 @@ function commandsModule({ } } + function _clampToRange(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); + } + + function _getRegionSegmentPlusLimits() { + const enabledElement = _getActiveViewportEnabledElement(); + const viewport = enabledElement?.viewport; + + if (!viewport) { + return { + maxDeltaK: 25, + maxDeltaIJ: 100, + }; + } + + let maxDeltaK = 25; + let maxDeltaIJ = 100; + + if (viewport instanceof StackViewport) { + maxDeltaK = Math.max(1, viewport.getImageIds()?.length ?? 1); + } else if (viewport instanceof VolumeViewport) { + const sliceData = csUtils.getImageSliceDataForVolumeViewport(viewport); + maxDeltaK = Math.max(1, sliceData?.numberOfSlices ?? 1); + } + + const imageData = viewport.getImageData?.(); + const dimensions = + imageData?.imageData?.getDimensions?.() ?? + imageData?.dimensions ?? + (Array.isArray(imageData) ? imageData : undefined); + + if (Array.isArray(dimensions) && dimensions.length >= 2) { + maxDeltaIJ = Math.max(1, dimensions[0], dimensions[1]); + if (dimensions.length >= 3) { + maxDeltaK = Math.max(maxDeltaK, dimensions[2] || 1); + } + } + + return { + maxDeltaK, + maxDeltaIJ, + }; + } + /** * Creates a command function that sets a style property for segmentation types. * If type is provided, sets the property for that type only. @@ -2021,6 +2065,37 @@ function commandsModule({ }, rejectPreview: () => { actions._handlePreviewAction('reject'); + // ESC is commonly bound to rejectPreview in OHIF. + // Also cancel any in-flight tool operation so non-preview tools + // (e.g., one-click flood fill) can be interrupted consistently. + actions.cancelMeasurement(); + }, + cancelMeasurement: () => { + const enabledElement = _getActiveViewportEnabledElement(); + const element = enabledElement?.viewport?.element; + const viewportId = viewportGridService.getActiveViewportId(); + + let cancelled = false; + + if (element) { + const cancelledAnnotationUID = cornerstoneTools.cancelActiveManipulations(element); + cancelled = !!cancelledAnnotationUID; + } + + const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); + const activeToolName = toolGroupService.getActiveToolForViewport(viewportId); + const activeToolInstance = activeToolName + ? toolGroup?.getToolInstance(activeToolName) + : undefined; + + if (activeToolInstance && typeof activeToolInstance.cancelActiveOperation === 'function') { + cancelled = activeToolInstance.cancelActiveOperation() || cancelled; + } + + if (cancelled) { + const renderingEngine = cornerstoneViewportService.getRenderingEngine(); + renderingEngine.render(); + } }, clearMarkersForMarkerLabelmap: () => { const { viewport } = _getActiveViewportEnabledElement(); @@ -2104,6 +2179,70 @@ function commandsModule({ }); } }, + setRegionSegmentPlusFloodFillConfiguration: ({ value, id, options }) => { + const viewportId = viewportGridService.getActiveViewportId(); + const toolGroupId = toolGroupService.getToolGroupForViewport(viewportId)?.id; + const toolGroupIds = toolGroupId ? [toolGroupId] : toolGroupService.getToolGroupIds(); + + const maxDeltaKOptionId = 'region-segment-plus-max-delta-k'; + const maxDeltaIJOptionId = 'region-segment-plus-max-delta-ij'; + const toolButton = toolbarService.getButton('RegionSegmentPlus'); + const buttonOptions = toolButton?.props?.options; + const optionList = + (Array.isArray(options) && options.length ? options : buttonOptions) ?? []; + + const maxDeltaKOption = optionList.find(option => option.id === maxDeltaKOptionId); + const maxDeltaIJOption = optionList.find(option => option.id === maxDeltaIJOptionId); + const { maxDeltaK: maxAllowedK, maxDeltaIJ: maxAllowedIJ } = _getRegionSegmentPlusLimits(); + + if (maxDeltaKOption) { + maxDeltaKOption.max = maxAllowedK; + } + if (maxDeltaIJOption) { + maxDeltaIJOption.max = maxAllowedIJ; + } + + const incomingValue = Number(value); + const currentK = Number(maxDeltaKOption?.value ?? 25); + const currentIJ = Number(maxDeltaIJOption?.value ?? 100); + + const nextMaxDeltaK = _clampToRange( + id === maxDeltaKOptionId && Number.isFinite(incomingValue) ? incomingValue : currentK, + 1, + maxAllowedK + ); + const nextMaxDeltaIJ = _clampToRange( + id === maxDeltaIJOptionId && Number.isFinite(incomingValue) ? incomingValue : currentIJ, + 1, + maxAllowedIJ + ); + + if (maxDeltaKOption) { + maxDeltaKOption.value = nextMaxDeltaK; + } + if (maxDeltaIJOption) { + maxDeltaIJOption.value = nextMaxDeltaIJ; + } + + for (const tgId of toolGroupIds) { + const toolGroup = toolGroupService.getToolGroup(tgId); + if (!toolGroup?.hasTool(toolNames.RegionSegmentPlus)) { + continue; + } + + toolGroup.setToolConfiguration(toolNames.RegionSegmentPlus, { + hoverPrecheckEnabled: false, + intensityRangeStrategy: 'canvasDiskTriClassLarge', + maxDeltaK: nextMaxDeltaK, + maxDeltaIJ: nextMaxDeltaIJ, + preview: { + enabled: false, + }, + }); + } + + toolbarService.refreshToolbarState({ viewportId }); + }, increaseBrushSize: () => { _handleBrushSizeAction('increase'); }, @@ -2781,12 +2920,14 @@ function commandsModule({ toggleSegmentSelect: actions.toggleSegmentSelect, acceptPreview: actions.acceptPreview, rejectPreview: actions.rejectPreview, + cancelMeasurement: actions.cancelMeasurement, toggleUseCenterSegmentIndex: actions.toggleUseCenterSegmentIndex, toggleLabelmapAssist: actions.toggleLabelmapAssist, interpolateScrollForMarkerLabelmap: actions.interpolateScrollForMarkerLabelmap, clearMarkersForMarkerLabelmap: actions.clearMarkersForMarkerLabelmap, setBrushSize: actions.setBrushSize, setThresholdRange: actions.setThresholdRange, + setRegionSegmentPlusFloodFillConfiguration: actions.setRegionSegmentPlusFloodFillConfiguration, increaseBrushSize: actions.increaseBrushSize, decreaseBrushSize: actions.decreaseBrushSize, addNewSegment: actions.addNewSegment, diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index 27bbca70c95..a882dd0a2a7 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -39,7 +39,7 @@ import { OrientationMarkerTool, WindowLevelRegionTool, SegmentSelectTool, - RegionSegmentPlusTool, + RegionSegmentPlusFloodFillTool, SegmentLabelTool, LivewireContourSegmentationTool, SculptorTool, @@ -112,7 +112,7 @@ export default function initCornerstoneTools(configuration = {}) { addTool(SegmentLabelTool); addTool(LabelmapSlicePropagationTool); addTool(MarkerLabelmapTool); - addTool(RegionSegmentPlusTool); + addTool(RegionSegmentPlusFloodFillTool); addTool(LivewireContourSegmentationTool); addTool(SculptorTool); addTool(SplineContourSegmentationTool); @@ -175,7 +175,7 @@ const toolNames = { SegmentLabel: SegmentLabelTool.toolName, LabelmapSlicePropagation: LabelmapSlicePropagationTool.toolName, MarkerLabelmap: MarkerLabelmapTool.toolName, - RegionSegmentPlus: RegionSegmentPlusTool.toolName, + RegionSegmentPlus: RegionSegmentPlusFloodFillTool.toolName, LivewireContourSegmentation: LivewireContourSegmentationTool.toolName, SculptorTool: SculptorTool.toolName, SplineContourSegmentation: SplineContourSegmentationTool.toolName, diff --git a/modes/segmentation/src/initToolGroups.ts b/modes/segmentation/src/initToolGroups.ts index 23509a8c965..15177192bdd 100644 --- a/modes/segmentation/src/initToolGroups.ts +++ b/modes/segmentation/src/initToolGroups.ts @@ -46,6 +46,15 @@ function createTools({ utilityModule, commandsManager }) { }, { toolName: toolNames.RegionSegmentPlus, + configuration: { + hoverPrecheckEnabled: false, + intensityRangeStrategy: 'canvasDiskTriClassLarge', + maxDeltaK: 25, + maxDeltaIJ: 100, + preview: { + enabled: false, + }, + }, }, { toolName: 'CircularEraser', diff --git a/modes/segmentation/src/toolbarButtons.ts b/modes/segmentation/src/toolbarButtons.ts index ecf2a5b36b8..9a53de399d0 100644 --- a/modes/segmentation/src/toolbarButtons.ts +++ b/modes/segmentation/src/toolbarButtons.ts @@ -769,12 +769,12 @@ export const toolbarButtons: Button[] = [ icon: 'icon-tool-click-segment', label: i18n.t('Buttons:One Click Segment'), 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.' + 'Buttons:Segments a region with one click using intensity flood fill. Adjust Max Delta K/IJ to limit growth in slice and in-plane directions.' ), evaluate: [ { name: 'evaluate.cornerstone.segmentation', - toolNames: ['RegionSegmentPlus'], + toolNames: ['RegionSegmentPlusFloodFill'], disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'), }, { @@ -784,6 +784,7 @@ export const toolbarButtons: Button[] = [ ], commands: [ 'setToolActiveToolbar', + 'setRegionSegmentPlusFloodFillConfiguration', { commandName: 'activateSelectedSegmentationOfType', commandOptions: { @@ -791,6 +792,34 @@ export const toolbarButtons: Button[] = [ }, }, ], + options: [ + { + name: i18n.t('Buttons:Max Delta K'), + id: 'region-segment-plus-max-delta-k', + type: 'range', + explicitRunOnly: true, + min: 1, + max: 1000, + step: 1, + value: 25, + commands: { + commandName: 'setRegionSegmentPlusFloodFillConfiguration', + }, + }, + { + name: i18n.t('Buttons:Max Delta IJ'), + id: 'region-segment-plus-max-delta-ij', + type: 'range', + explicitRunOnly: true, + min: 1, + max: 4096, + step: 1, + value: 100, + commands: { + commandName: 'setRegionSegmentPlusFloodFillConfiguration', + }, + }, + ], }, }, { diff --git a/playwright.config.ts b/playwright.config.ts index cec0f4f71a2..b3fe4c16e77 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,16 @@ import { defineConfig, devices } from '@playwright/test'; +const E2E_PORT = Number(process.env.OHIF_PORT || 3335); +const E2E_BASE_URL = `http://localhost:${E2E_PORT}`; + +// Port cleanup must run before the dev server starts, not in globalSetup — Playwright +// starts webServer before globalSetup, so killing the port there would stop the server +// and cause net::ERR_CONNECTION_REFUSED in tests. +const webServerStart = `cross-env APP_CONFIG=config/e2e.js COVERAGE=true OHIF_PORT=${E2E_PORT} nyc yarn start`; +const webServerCommand = process.env.CI + ? `node .scripts/ci/free-ohif-e2e-port.mjs && ${webServerStart}` + : webServerStart; + export default defineConfig({ testDir: './tests', fullyParallel: !!process.env.CI, @@ -15,7 +26,7 @@ export default defineConfig({ globalTimeout: 800_000, timeout: 800_000, use: { - baseURL: 'http://localhost:3335', + baseURL: E2E_BASE_URL, trace: 'on-first-retry', video: 'on-first-retry', testIdAttribute: 'data-cy', @@ -45,8 +56,8 @@ export default defineConfig({ //}, ], webServer: { - command: 'cross-env APP_CONFIG=config/e2e.js COVERAGE=true OHIF_PORT=3335 nyc yarn start', - url: 'http://localhost:3335', + command: webServerCommand, + url: E2E_BASE_URL, reuseExistingServer: !process.env.CI, timeout: 360_000, },