diff --git a/.circleci/config.yml b/.circleci/config.yml index 91bfabdbc84..3c3438acc6e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -431,6 +431,14 @@ jobs: command: export DISPLAY=:99 - cypress/install: install-command: pnpm install + - run: + # Build OHIF against the CS3D ref in .cs3d-ref (a branch is cloned, + # built with build:esm, and symlinked into node_modules; a version is + # pinned + reinstalled). No-op when .cs3d-ref is absent, so master and + # non-integration PRs are unaffected. Runs after the install above so + # the link lands on the freshly-installed node_modules. + name: 'CS3D integration: build & link ref from .cs3d-ref' + command: bash .scripts/ci/setup-cs3d.sh - cypress/run-tests: # CI runs headless under Xvfb with no GPU, so Electron uses software # WebGL. Newer Chromium deprecated that implicit fallback (canvas diff --git a/.cs3d-ref b/.cs3d-ref new file mode 100644 index 00000000000..22878ff282f --- /dev/null +++ b/.cs3d-ref @@ -0,0 +1,6 @@ +# Cornerstone3D ref to build OHIF against in CI (read by .scripts/ci/setup-cs3d.sh). +# A version (e.g. 5.0.2, 4.19+) builds against the published packages; anything +# else is treated as a CS3D branch (optionally "owner:branch" for a fork) that is +# cloned + built + linked. Must mirror the PR-body "CS3D_REF:" line; set back to a +# published version (or remove this file) before merging to master. +fix/grow-cut-suv-pt diff --git a/.scripts/ci/free-ohif-e2e-port.mjs b/.scripts/ci/free-ohif-e2e-port.mjs new file mode 100644 index 00000000000..8586f3dde8b --- /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/.scripts/ci/setup-cs3d.sh b/.scripts/ci/setup-cs3d.sh new file mode 100644 index 00000000000..5c3cb1ebcdc --- /dev/null +++ b/.scripts/ci/setup-cs3d.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Portable CS3D integration setup — no GitHub token required. +# +# Resolves which Cornerstone3D to build OHIF against from, in priority: +# 1. $CS3D_REF environment variable (explicit override) +# 2. the committed .cs3d-ref file at the repo root +# 3. nothing -> leave the published @cornerstonejs/* from the lockfile in place +# +# A ref that looks like a version (e.g. 5.0.2, 4.19+, 4.x, 4.19.0-beta.1) takes +# the "version" path; anything else is treated as a CS3D branch ("branch" path, +# optionally "owner:branch" to pull from a fork). +# +# branch -> clone + install + build:esm into libs/@cornerstonejs, then symlink +# the built packages into OHIF's node_modules +# version -> rewrite @cornerstonejs/* versions across the workspace + reinstall +# +# Intended to run AFTER OHIF's own `pnpm install` (the branch build is independent +# of OHIF's dependency install; the link/reinstall steps assume node_modules exist). +# +# This mirrors what .github/workflows/playwright.yml does inline, but works in any +# CI (CircleCI, Netlify) because it never calls `gh` or reads $GITHUB_OUTPUT. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +log() { echo "[cs3d] $*"; } + +# ── 1. Resolve the ref ─────────────────────────────────────────────────────── +REF="${CS3D_REF:-}" +if [[ -z "$REF" && -f "$ROOT/.cs3d-ref" ]]; then + REF="$(grep -vE '^[[:space:]]*(#|$)' "$ROOT/.cs3d-ref" | head -1 | tr -d '[:space:]' || true)" +fi + +if [[ -z "$REF" ]]; then + log "no ref configured (no \$CS3D_REF and no .cs3d-ref) — using published @cornerstonejs/* from the lockfile" + exit 0 +fi + +# ── 2. Classify: version vs branch (same pattern as the Playwright workflow) ── +if [[ "$REF" =~ ^[0-9]+\.[0-9x]+\+?(\.[0-9x]+)?(-[a-zA-Z0-9._]+)?$ ]]; then + TYPE=version +else + TYPE=branch +fi +log "ref=$REF type=$TYPE" + +# ── 3. Apply ───────────────────────────────────────────────────────────────── +if [[ "$TYPE" == "version" ]]; then + log "pinning @cornerstonejs/* to $REF and reinstalling" + node .scripts/cs3d-set-version.mjs "$REF" + pnpm install --no-frozen-lockfile + log "done (version $REF)" + exit 0 +fi + +# branch path +if [[ "$REF" == *:* ]]; then + REPO="https://github.com/${REF%%:*}/cornerstone3D.git" + BRANCH="${REF#*:}" +else + REPO="https://github.com/cornerstonejs/cornerstone3D.git" + BRANCH="$REF" +fi + +# Never delete an existing checkout (e.g. a local git worktree at libs/@cornerstonejs). +# On CI the dir is absent (libs/ is gitignored) so we clone; locally we build in place. +if [[ -e libs/@cornerstonejs/.git ]]; then + log "libs/@cornerstonejs already present — building in place (skipping clone)" +else + log "cloning $REPO @ $BRANCH" + git clone --depth 1 --branch "$BRANCH" "$REPO" libs/@cornerstonejs +fi + +log "installing + building CS3D (build:esm)" +( cd libs/@cornerstonejs && pnpm install --frozen-lockfile && pnpm run build:esm ) + +log "linking built @cornerstonejs/* packages into OHIF node_modules" +node libs/@cornerstonejs/scripts/link-ohif-cornerstone-node-modules.mjs "$ROOT" + +log "done (branch $BRANCH)" diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 98e3d495b99..a417282e82b 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -195,6 +195,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 (isStackViewportType(viewport)) { + maxDeltaK = Math.max(1, viewport.getImageIds()?.length ?? 1); + } else if (isVolumeViewportType(viewport)) { + 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. @@ -2027,6 +2071,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(); @@ -2110,6 +2185,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'); }, @@ -2798,12 +2937,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 d5fbde16385..b0829a9adac 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} OHIF_OPEN=false nyc pnpm --filter @ohif/app exec rspack serve --config .webpack/webpack.pwa.js`; +const webServerCommand = process.env.CI + ? `node .scripts/ci/free-ohif-e2e-port.mjs && ${webServerStart}` + : webServerStart; + export default defineConfig({ testDir: './tests', globalSetup: './tests/globalSetup.ts', @@ -7,22 +18,23 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, maxFailures: process.env.CI ? 10 : undefined, - workers: process.env.CI ? 18 : undefined, + workers: process.env.CI ? 6 : undefined, snapshotPathTemplate: './tests/screenshots{/projectName}/{testFilePath}/{arg}{ext}', outputDir: './tests/test-results', - reporter: [['html', { outputFolder: './tests/playwright-report' }]], + reporter: [ + ['html', { outputFolder: './tests/playwright-report' }], + ], globalTimeout: 800_000, timeout: 800_000, use: { - baseURL: 'http://localhost:3335', + baseURL: E2E_BASE_URL, trace: 'on-first-retry', - video: 'retain-on-failure', + video: 'on-first-retry', testIdAttribute: 'data-cy', actionTimeout: 10_000, launchOptions: { // do not hide the scrollbars so that we can assert their look-and-feel ignoreDefaultArgs: ['--hide-scrollbars'], - args: ['--use-gl=egl'], }, }, @@ -45,9 +57,8 @@ export default defineConfig({ //}, ], webServer: { - command: - 'cross-env APP_CONFIG=config/e2e.js COVERAGE=true OHIF_PORT=3335 OHIF_OPEN=false nyc pnpm --filter @ohif/app exec rspack serve --config .webpack/webpack.pwa.js', - url: 'http://localhost:3335', + command: webServerCommand, + url: E2E_BASE_URL, reuseExistingServer: !process.env.CI, timeout: 360_000, },