Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
160 changes: 160 additions & 0 deletions .scripts/ci/free-ohif-e2e-port.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
141 changes: 141 additions & 0 deletions extensions/cornerstone/src/commandsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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.
Expand Down Expand Up @@ -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();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
},
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();
Expand Down Expand Up @@ -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);

Comment thread
greptile-apps[bot] marked this conversation as resolved.
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');
},
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions extensions/cornerstone/src/initCornerstoneTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
OrientationMarkerTool,
WindowLevelRegionTool,
SegmentSelectTool,
RegionSegmentPlusTool,
RegionSegmentPlusFloodFillTool,
SegmentLabelTool,
LivewireContourSegmentationTool,
SculptorTool,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions modes/segmentation/src/initToolGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading