Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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 @@ -114,6 +114,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`
);
Comment thread
wayfarer3130 marked this conversation as resolved.

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 @@ -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 (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,
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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 @@ -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();
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 @@ -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);

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 @@ -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,
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