Integration TrizRis OHIF: key images, annotations, layouts, access control and reconstruction
Build and Deploy Docs / build-and-deploy-docs (push) Canceled after 0s

This commit is contained in:
Kenza committed 2026-10-11 10:34:47 +02:00
1 parent bbe37ff388
commit f00379a672
28 files changed
+1898 -105

No files matched your search

@@ -4,6 +4,7 @@ import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core';
import { MeasurementService, useViewportRef } from '@ohif/core';
import { useViewportDialog } from '@ohif/ui-next';
import type { Types as csTypes } from '@cornerstonejs/core';
import { deleteKeyImageFromTrizRis } from '../utils/keyImages/deleteKeyImageFromTrizRis';
import { setEnabledElement } from '../state';
@@ -17,6 +18,13 @@ import { getViewportPresentations } from '../utils/presentations/getViewportPres
import { useSynchronizersStore } from '../stores/useSynchronizersStore';
import ActiveViewportBehavior from '../utils/ActiveViewportBehavior';
import { WITH_NAVIGATION } from '../services/ViewportService/CornerstoneViewportService';
import { useKeyImagesStore } from '../stores/useKeyImagesStore';
import { loadKeyImagesFromTrizRis } from '../utils/keyImages/loadKeyImagesFromTrizRis';
import { useSavedKeyImages } from '../hooks/useSavedKeyImages';
import {
saveImageViewState,
getImageViewState,
} from '../../../default/src/services/TrizRisImageViewStateApi';
const STACK = 'stack';
@@ -80,7 +88,13 @@ const OHIFCornerstoneViewport = React.memo(
const [scrollbarHeight, setScrollbarHeight] = useState('100px');
const [enabledVPElement, setEnabledVPElement] = useState(null);
const [currentImageId, setCurrentImageId] = useState<string | null>(null);
const { savedKeyImageSOPs, removeSavedKeyImage, addSavedKeyImage } = useSavedKeyImages();
const keyImages = useKeyImagesStore(state => state.keyImages);
const addKeyImage = useKeyImagesStore(state => state.addKeyImage);
const removeKeyImage = useKeyImagesStore(state => state.removeKeyImage);
const elementRef = useRef() as React.MutableRefObject<HTMLDivElement>;
const viewportRef = useViewportRef(viewportId);
const {
@@ -142,6 +156,201 @@ const OHIFCornerstoneViewport = React.memo(
};
}, [onResize]);
// AJOUTER ICI
useEffect(() => {
const element = enabledVPElement;
let isRestoringViewState = true;
let lastRestoredImageId: string | null = null;
const lastSavedViewState = new Map<string, { zoom: number; rotation: number }>();
if (!element) {
return;
}
const updateCurrentImage = () => {
try {
const enabledElement = getEnabledElement(element);
const viewport = enabledElement?.viewport;
const imageId = viewport?.getCurrentImageId?.();
if (imageId) {
setCurrentImageId(imageId);
if (lastRestoredImageId === imageId) {
return;
}
lastRestoredImageId = imageId;
isRestoringViewState = true;
const match = imageId.match(/studies\/([^/]+)\/series\/([^/]+)\/instances\/([^/]+)/);
if (match) {
const studyInstanceUID = match[1];
const seriesInstanceUID = match[2];
const sopInstanceUID = match[3];
getImageViewState(studyInstanceUID, seriesInstanceUID, sopInstanceUID)
.then(state => {
console.log('### VIEW STATE RECUPERE ###');
console.log(state);
if (state) {
const imageKey = `${state.studyInstanceUID}|${state.seriesInstanceUID}|${state.sopInstanceUID}`;
lastSavedViewState.set(imageKey, {
zoom: state.zoom,
rotation: state.rotation,
});
const enabledElement = getEnabledElement(element);
const viewport = enabledElement?.viewport;
if (!viewport) {
// ===== CORRECTION 1 =====
isRestoringViewState = false;
return;
}
const currentImageId = viewport.getCurrentImageId?.();
if (currentImageId !== imageId) {
// ===== CORRECTION 2 =====
isRestoringViewState = false;
return;
}
viewport.setZoom(state.zoom);
viewport.setViewPresentation({
rotation: state.rotation,
});
viewport.render();
console.log('### VIEW STATE APPLIQUE ###');
console.log('zoom =', state.zoom);
console.log('rotation =', state.rotation);
}
// ===== CORRECTION 3 =====
// Important : même si state == null,
// on réactive la sauvegarde
isRestoringViewState = false;
})
.catch(error => {
console.error('Erreur récupération zoom/rotation :', error);
// ===== CORRECTION 4 =====
// Si le GET échoue, on ne reste pas bloqué
isRestoringViewState = false;
});
} else {
// ===== CORRECTION 5 =====
// Si l'imageId ne correspond pas au regex,
// on réactive quand même la sauvegarde
isRestoringViewState = false;
}
console.log('### IMAGE COURANTE VIEWPORT', viewportId, '###');
console.log(imageId);
}
} catch (error) {
console.error('Erreur récupération image courante :', error);
// ===== CORRECTION 6 =====
isRestoringViewState = false;
}
};
updateCurrentImage();
let saveViewStateTimeout: ReturnType<typeof setTimeout>;
const handleCameraModified = () => {
clearTimeout(saveViewStateTimeout);
saveViewStateTimeout = setTimeout(() => {
if (isRestoringViewState) {
console.log('### SAUVEGARDE IGNOREE - RESTAURATION EN COURS ###');
return;
}
try {
const enabledElement = getEnabledElement(element);
const viewport = enabledElement?.viewport;
if (!viewport) {
return;
}
const imageId = viewport.getCurrentImageId?.();
if (!imageId) {
return;
}
const zoom = viewport.getZoom();
const rotation = viewport.getRotation();
console.log('### CAMERA MODIFIED TERMINE ###');
console.log('zoom =', zoom);
console.log('rotation =', rotation);
const match = imageId.match(/studies\/([^/]+)\/series\/([^/]+)\/instances\/([^/]+)/);
if (!match) {
return;
}
const studyInstanceUID = match[1];
const seriesInstanceUID = match[2];
const sopInstanceUID = match[3];
const imageKey = `${studyInstanceUID}|${seriesInstanceUID}|${sopInstanceUID}`;
const previousState = lastSavedViewState.get(imageKey);
if (
previousState &&
Math.abs(previousState.zoom - zoom) < 0.000001 &&
previousState.rotation === rotation
) {
console.log('### VIEW STATE INCHANGE -> PAS DE SAUVEGARDE ###');
return;
}
console.log('### SAUVEGARDE VIEW STATE ###');
console.log('studyInstanceUID =', studyInstanceUID);
console.log('seriesInstanceUID =', seriesInstanceUID);
console.log('sopInstanceUID =', sopInstanceUID);
saveImageViewState({
studyInstanceUID,
seriesInstanceUID,
sopInstanceUID,
zoom,
rotation,
})
.then(() => {
lastSavedViewState.set(imageKey, {
zoom,
rotation,
});
})
.catch(error => {
console.error('Erreur sauvegarde zoom/rotation :', error);
});
} catch (error) {
console.error('Erreur récupération zoom/rotation :', error);
}
}, 400);
};
element.addEventListener(Enums.Events.IMAGE_RENDERED, updateCurrentImage);
element.addEventListener(Enums.Events.CAMERA_MODIFIED, handleCameraModified);
return () => {
clearTimeout(saveViewStateTimeout);
element.removeEventListener(Enums.Events.IMAGE_RENDERED, updateCurrentImage);
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, handleCameraModified);
};
}, [enabledVPElement, viewportId]);
const cleanUpServices = useCallback(
viewportInfo => {
const renderingEngineId = viewportInfo.getRenderingEngineId();
@@ -304,11 +513,104 @@ const OHIFCornerstoneViewport = React.memo(
loadViewportData();
}, [viewportOptions, displaySets, dataSource]);
const getSopInstanceUIDFromImageId = (imageId: string | null) => {
if (!imageId) {
return null;
}
const match = imageId.match(/\/instances\/([^/]+)/i);
return match ? decodeURIComponent(match[1]) : null;
};
const currentSopInstanceUID = getSopInstanceUIDFromImageId(currentImageId);
const isSavedKeyImage = !!(
currentSopInstanceUID && savedKeyImageSOPs.includes(currentSopInstanceUID)
);
const isCurrentKeyImage = !!(currentImageId && (keyImages[currentImageId] || isSavedKeyImage));
const toggleCurrentKeyImage = async () => {
if (!currentImageId) {
console.log('Aucune image actuellement affichée');
return;
}
const sopInstanceUID = getSopInstanceUIDFromImageId(currentImageId);
if (!sopInstanceUID) {
console.error('SOP Instance UID introuvable');
return;
}
/*
* L'image est actuellement Key Image
* soit parce qu'elle est dans le store local,
* soit parce qu'elle vient de la base TrizRis.
*/
if (isCurrentKeyImage) {
console.log('### DESACTIVATION KEY IMAGE ###');
/*
* Si elle était sauvegardée dans TrizRis,
* supprimer fichier + base.
*/
if (isSavedKeyImage) {
await deleteKeyImageFromTrizRis(currentImageId);
removeSavedKeyImage(sopInstanceUID);
}
/*
* Si elle existe aussi dans le store local,
* on la retire explicitement.
*/
if (keyImages[currentImageId]) {
removeKeyImage(currentImageId);
}
console.log('### KEY IMAGE DESACTIVE ###');
} else {
/*
* Elle n'était pas active :
* on l'ajoute explicitement au store.
*/
addKeyImage(currentImageId, viewportId);
console.log('### KEY IMAGE ACTIVE ###');
}
};
const Notification = customizationService.getCustomization('ui.notificationComponent');
return (
<React.Fragment>
<div className="viewport-wrapper">
<button
type="button"
onClick={event => {
event.stopPropagation();
toggleCurrentKeyImage();
}}
style={{
position: 'absolute',
top: '35px',
right: '10px',
zIndex: 1000,
width: '36px',
height: '36px',
borderRadius: '50%',
border: '1px solid #666',
background: 'rgba(0, 0, 0, 0.65)',
color: currentImageId && isCurrentKeyImage ? '#FFD700' : '#FFFFFF',
fontSize: '22px',
cursor: 'pointer',
}}
title={
currentImageId && isCurrentKeyImage ? 'Désactiver Key Image' : 'Activer Key Image'
}
>
{currentImageId && isCurrentKeyImage ? '★' : '☆'}
</button>
<div
className="cornerstone-viewport-element"
style={{ height: '100%', width: '100%' }}
@@ -37,6 +37,8 @@ import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/u
import { getViewportEnabledElement } from './utils/getViewportEnabledElement';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import toggleVOISliceSync from './utils/toggleVOISliceSync';
import { useKeyImagesStore } from './stores/useKeyImagesStore';
import { exportKeyImageToTrizRis } from './utils/keyImages/exportKeyImagesToTrizRis';
import {
isStackViewportType,
isOrthographicViewportType,
@@ -231,7 +233,31 @@ function commandsModule({
}
};
};
const extractDicomInfoFromImageId = (imageId: string) => {
if (!imageId) {
return null;
}
const imageIdWithoutPrefix = imageId.replace(/^wadors:/, '').replace(/^dicomweb:/, '');
const match = imageIdWithoutPrefix.match(
/\/studies\/([^/]+)\/series\/([^/]+)\/instances\/([^/]+)(?:\/frames\/(\d+))?/i
);
if (!match) {
console.error('Impossible d extraire les UID depuis imageId :', imageId);
return null;
}
return {
imageId,
studyInstanceUID: decodeURIComponent(match[1]),
seriesInstanceUID: decodeURIComponent(match[2]),
sopInstanceUID: decodeURIComponent(match[3]),
frameNumber: match[4] || null,
};
};
const actions = {
jumpToMeasurementViewport: ({ annotationUID, measurement }) => {
cornerstoneTools.annotation.selection.setAnnotationSelected(annotationUID, true);
@@ -1136,6 +1162,34 @@ function commandsModule({
bindings,
});
},
//key image button
toggleKeyImageMode: async () => {
console.log('### KEY IMAGES BUTTON CLICKED ###');
const keyImages = useKeyImagesStore.getState().keyImages;
const liste = Object.values(keyImages);
console.log('Nombre de Key Images =', liste.length);
if (liste.length === 0) {
console.log('Aucune Key Image sélectionnée');
return;
}
console.log('### EXPORT DE TOUTES LES KEY IMAGES ###');
for (let i = 0; i < liste.length; i++) {
const keyImage = liste[i];
console.log(`Export ${i + 1} / ${liste.length}`);
await exportKeyImageToTrizRis(keyImage.imageId, keyImage.viewportId);
}
console.log('### EXPORT KEY IMAGES TERMINE ###');
},
// capture viewport
showDownloadViewportModal: () => {
const { activeViewportId } = viewportGridService.getState();
@@ -2700,6 +2754,9 @@ function commandsModule({
deleteActiveAnnotation: {
commandFn: actions.deleteActiveAnnotation,
},
toggleKeyImageMode: {
commandFn: actions.toggleKeyImageMode,
},
setDisplaySetsForViewports: actions.setDisplaySetsForViewports,
undo: actions.undo,
redo: actions.redo,
@@ -38,6 +38,13 @@ const toolbarButtons: Button[] = [
buttonSection: true,
},
},
{
id: 'Reconstruction',
uiType: 'ohif.toolButtonList',
props: {
buttonSection: true,
},
},
{
id: 'AdvancedRenderingControls',
uiType: 'ohif.advancedRenderingControls',
@@ -633,6 +640,86 @@ const toolbarButtons: Button[] = [
],
},
},
{
id: 'MPR',
uiType: 'ohif.toolButton',
props: {
icon: 'layout-advanced-mpr',
label: 'MPR',
tooltip: 'Reconstruction multiplanaire',
commands: {
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: 'mpr',
},
},
evaluate: 'evaluate.action',
},
},
{
id: 'MPR3D',
uiType: 'ohif.toolButton',
props: {
icon: 'layout-advanced-mpr',
label: 'MPR + 3D',
tooltip: 'Reconstruction multiplanaire avec rendu 3D',
commands: {
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: 'mprAnd3DVolumeViewport',
},
},
evaluate: 'evaluate.action',
},
},
{
id: 'MIP',
uiType: 'ohif.toolButton',
props: {
icon: 'layout-advanced-mpr',
label: 'MIP',
tooltip: 'Maximum Intensity Projection',
commands: {
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: 'mip',
},
},
evaluate: 'evaluate.action',
},
},
{
id: 'KeyImages',
uiType: 'ohif.toolButton',
props: {
icon: 'Export',
label: 'Key Images',
commands: 'toggleKeyImageMode',
evaluate: [
'evaluate.action',
{
name: 'evaluate.viewport.supported',
unsupportedViewportTypes: ['video', 'wholeSlide'],
},
],
},
},
{
id: 'Only3D',
uiType: 'ohif.toolButton',
props: {
icon: 'layout-advanced-mpr',
label: '3D',
tooltip: 'Rendu volumique 3D',
commands: {
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: 'only3D',
},
},
evaluate: 'evaluate.action',
},
},
{
id: 'Layout',
uiType: 'ohif.layoutSelector',
@@ -723,11 +810,13 @@ export const toolbarSections = {
'TrackballRotate',
'WindowLevel',
'Capture',
'KeyImages',
'Layout',
'Crosshairs',
'MoreTools',
'Reconstruction',
],
Reconstruction: ['MPR', 'MPR3D', 'MIP', 'Only3D'],
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
@@ -6,9 +6,14 @@ import { only3D } from './hps/only3D';
import { primary3D } from './hps/primary3D';
import { primaryAxial } from './hps/primaryAxial';
import { frameView } from './hps/frameView';
import { mip } from './hps/mip';
function getHangingProtocolModule() {
return [
{
name: mip.id,
protocol: mip,
},
{
name: mpr.id,
protocol: mpr,
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { loadKeyImagesFromTrizRis } from '../utils/keyImages/loadKeyImagesFromTrizRis';
export const useSavedKeyImages = () => {
const [savedKeyImageSOPs, setSavedKeyImageSOPs] = useState<string[]>([]);
useEffect(() => {
const chargerKeyImages = async () => {
const sopInstanceUIDs = await loadKeyImagesFromTrizRis();
setSavedKeyImageSOPs(sopInstanceUIDs);
};
chargerKeyImages();
}, []);
const removeSavedKeyImage = (sopInstanceUID: string) => {
setSavedKeyImageSOPs(prev => prev.filter(sop => sop !== sopInstanceUID));
};
const addSavedKeyImage = (sopInstanceUID: string) => {
setSavedKeyImageSOPs(prev => {
if (prev.includes(sopInstanceUID)) {
return prev;
}
return [...prev, sopInstanceUID];
});
};
return {
savedKeyImageSOPs,
removeSavedKeyImage,
addSavedKeyImage,
};
};
+75
View File
@@ -0,0 +1,75 @@
import { HYDRATE_SEG_SYNC_GROUP, VOI_SYNC_GROUP } from './mpr';
export const mip = {
id: 'mip',
locked: true,
name: 'MIP',
createdDate: '2026-10-08T00:00:00.000Z',
modifiedDate: '2026-10-08T00:00:00.000Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
imageLoadStrategy: 'interleaveCenter',
displaySetSelectors: {
activeDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
],
},
},
stages: [
{
id: 'mipStage',
name: 'MIP',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: {
viewportId: 'mip-axial',
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'axial',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP],
},
displaySets: [
{
id: 'activeDisplaySet',
options: {
blendMode: 'MIP',
},
},
],
},
],
},
],
};
@@ -0,0 +1,74 @@
import { create } from 'zustand';
type KeyImageInfo = {
imageId: string;
viewportId?: string;
};
type KeyImagesState = {
keyImages: Record<string, KeyImageInfo>;
addKeyImage: (imageId: string, viewportId?: string) => void;
removeKeyImage: (imageId: string) => void;
toggleKeyImage: (imageId: string, viewportId?: string) => void;
isKeyImage: (imageId: string) => boolean;
clearKeyImages: () => void;
};
export const useKeyImagesStore = create<KeyImagesState>((set, get) => ({
keyImages: {},
addKeyImage: (imageId, viewportId) => {
set(state => ({
keyImages: {
...state.keyImages,
[imageId]: {
imageId,
viewportId,
},
},
}));
console.log('### KEY IMAGE AJOUTEE ###', imageId);
},
removeKeyImage: imageId => {
set(state => {
const newKeyImages = {
...state.keyImages,
};
delete newKeyImages[imageId];
return {
keyImages: newKeyImages,
};
});
console.log('### KEY IMAGE RETIREE ###', imageId);
},
toggleKeyImage: (imageId, viewportId) => {
const existe = !!get().keyImages[imageId];
if (existe) {
get().removeKeyImage(imageId);
} else {
get().addKeyImage(imageId, viewportId);
}
},
isKeyImage: imageId => {
return !!get().keyImages[imageId];
},
clearKeyImages: () => {
set({
keyImages: {},
});
},
}));
@@ -0,0 +1,32 @@
import { extractDicomInfoFromImageId } from './extractDicomInfoFromImageId';
import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig';
export const deleteKeyImageFromTrizRis = async (imageId: string) => {
const dicomInfo = extractDicomInfoFromImageId(imageId);
if (!dicomInfo) {
console.error('Informations DICOM introuvables');
return;
}
const response = await fetch(`${TrizRisConfig.apiBaseUrl}/dicom/key-image`, {
method: 'DELETE',
headers: {
'X-SOP-Instance-UID': dicomInfo.sopInstanceUID,
...(dicomInfo.frameNumber
? {
'X-Frame-Number': dicomInfo.frameNumber,
}
: {}),
},
});
const resultat = await response.text();
console.log('### SUPPRESSION KEY IMAGE TRIZRIS ###');
console.log('HTTP =', response.status);
console.log('Résultat =', resultat);
};
@@ -0,0 +1,322 @@
import html2canvas from 'html2canvas';
import { Enums, getEnabledElement, getRenderingEngine } from '@cornerstonejs/core';
import { getEnabledElement as OHIFgetEnabledElement } from '../../state';
import { extractDicomInfoFromImageId } from './extractDicomInfoFromImageId';
import { ToolGroupManager } from '@cornerstonejs/tools';
import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig';
const attendreRenduImage = (element: HTMLElement) => {
return new Promise<void>(resolve => {
let termine = false;
const terminer = () => {
if (termine) {
return;
}
termine = true;
element.removeEventListener(Enums.Events.IMAGE_RENDERED, handler as EventListener);
resolve();
};
const handler = () => {
terminer();
};
element.addEventListener(Enums.Events.IMAGE_RENDERED, handler as EventListener);
// Sécurité si jamais l'événement ne remonte pas
setTimeout(() => {
terminer();
}, 1500);
});
};
export const exportKeyImageToTrizRis = async (imageId: string, viewportId: string) => {
console.log('====================================');
console.log('### DEBUT EXPORT KEY IMAGE ###');
console.log('ImageId demandé =', imageId);
console.log('Viewport source =', viewportId);
const dicomInfo = extractDicomInfoFromImageId(imageId);
if (!dicomInfo) {
console.error('Informations DICOM introuvables');
return;
}
/*
* ------------------------------------------------
* 1. Retrouver le viewport OHIF visible
* ------------------------------------------------
*
* On l'utilise uniquement pour retrouver
* le RenderingEngine et le type du viewport.
*
* ON NE MODIFIE PLUS SON IMAGE.
*/
const enabledElementOHIF = OHIFgetEnabledElement(viewportId);
if (!enabledElementOHIF?.element) {
console.error('Impossible de trouver le viewport OHIF :', viewportId);
return;
}
const sourceElement = enabledElementOHIF.element as HTMLElement;
const sourceEnabledElement = getEnabledElement(sourceElement);
if (!sourceEnabledElement) {
console.error('EnabledElement Cornerstone source introuvable');
return;
}
const sourceViewport = sourceEnabledElement.viewport;
const renderingEngineId = sourceEnabledElement.renderingEngineId;
console.log('RenderingEngine ID =', renderingEngineId);
console.log('Image visible utilisateur =', sourceViewport.getCurrentImageId?.());
const sourceToolGroup = ToolGroupManager.getToolGroupForViewport(
sourceViewport.id,
renderingEngineId
);
if (!sourceToolGroup) {
console.warn('Aucun ToolGroup trouvé pour le viewport source');
}
/*
* ------------------------------------------------
* 2. Retrouver le RenderingEngine
* ------------------------------------------------
*/
const renderingEngine = getRenderingEngine(renderingEngineId);
if (!renderingEngine) {
console.error('RenderingEngine introuvable');
return;
}
/*
* ------------------------------------------------
* 3. Créer un viewport caché hors écran
* ------------------------------------------------
*/
const hiddenViewportId = `key-image-export-${Date.now()}-${Math.random()
.toString(36)
.substring(2)}`;
const hiddenElement = document.createElement('div');
hiddenElement.setAttribute('data-key-image-viewport', hiddenViewportId);
hiddenElement.style.position = 'fixed';
/*
* IMPORTANT :
* ne pas utiliser display:none.
*
* Cornerstone doit réellement pouvoir rendre
* dans le div.
*/
hiddenElement.style.left = '-10000px';
hiddenElement.style.top = '0';
hiddenElement.style.width = '512px';
hiddenElement.style.height = '512px';
hiddenElement.style.pointerEvents = 'none';
hiddenElement.style.background = 'black';
document.body.appendChild(hiddenElement);
try {
/*
* ------------------------------------------------
* 4. Activer le viewport caché Cornerstone
* ------------------------------------------------
*/
console.log('### CREATION VIEWPORT CACHE ###');
renderingEngine.enableElement({
viewportId: hiddenViewportId,
element: hiddenElement,
/*
* On utilise le même type de viewport
* que le viewport source.
*/
type: sourceViewport.type,
defaultOptions: {
background: sourceViewport.defaultOptions?.background,
orientation: sourceViewport.defaultOptions?.orientation,
},
});
if (sourceToolGroup) {
sourceToolGroup.addViewport(hiddenViewportId, renderingEngineId);
}
const captureViewport = renderingEngine.getViewport(hiddenViewportId);
if (!captureViewport) {
console.error('Viewport caché introuvable');
return;
}
/*
* ------------------------------------------------
* 5. Charger LA Key Image dans le viewport caché
* ------------------------------------------------
*/
console.log('### CHARGEMENT KEY IMAGE DANS VIEWPORT CACHE ###');
const renderPromise = attendreRenduImage(hiddenElement);
await captureViewport.setStack([imageId], 0);
captureViewport.render();
await renderPromise;
const imageChargee = captureViewport.getCurrentImageId?.();
console.log('Image chargée dans viewport caché =', imageChargee);
/*
* Sécurité :
* vérifier qu'on capture bien la bonne image.
*/
if (imageChargee !== imageId) {
console.error('ERREUR : le viewport caché n affiche pas la Key Image demandée');
console.error('Demandée =', imageId);
console.error('Affichée =', imageChargee);
return;
}
/*
* ------------------------------------------------
* 6. Transformer le viewport caché en PNG
* ------------------------------------------------
*/
console.log('### CREATION PNG ###');
const canvas = await html2canvas(hiddenElement);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Impossible de créer le PNG'));
}
},
'image/png',
1.0
);
});
console.log('### PNG KEY IMAGE CREE ###');
console.log('StudyInstanceUID =', dicomInfo.studyInstanceUID);
console.log('SeriesInstanceUID =', dicomInfo.seriesInstanceUID);
console.log('SOPInstanceUID =', dicomInfo.sopInstanceUID);
console.log('FrameNumber =', dicomInfo.frameNumber);
console.log('Taille PNG =', blob.size);
/*
* ------------------------------------------------
* 7. Envoyer vers TrizRis
* ------------------------------------------------
*/
console.log('### ENVOI VERS TRIZRIS ###');
const response = await fetch(`${TrizRisConfig.apiBaseUrl}/dicom/save-image`, {
method: 'POST',
headers: {
'Content-Type': 'image/png',
'X-Study-Instance-UID': dicomInfo.studyInstanceUID,
'X-Series-Instance-UID': dicomInfo.seriesInstanceUID,
'X-SOP-Instance-UID': dicomInfo.sopInstanceUID,
...(dicomInfo.frameNumber
? {
'X-Frame-Number': dicomInfo.frameNumber,
}
: {}),
},
body: blob,
});
const resultat = await response.text();
console.log('### REPONSE TRIZRIS ###');
console.log('HTTP =', response.status);
console.log('Résultat =', resultat);
} catch (error) {
console.error('Erreur export Key Image :', error);
} finally {
/*
* ------------------------------------------------
* 8. Détruire le viewport caché
* ------------------------------------------------
*/
console.log('### SUPPRESSION VIEWPORT CACHE ###');
if (sourceToolGroup) {
try {
sourceToolGroup.removeViewports(renderingEngineId, hiddenViewportId);
} catch (error) {
console.warn('Erreur suppression viewport caché du ToolGroup', error);
}
}
try {
renderingEngine.disableElement(hiddenViewportId);
} catch (error) {
console.warn('Erreur lors de la désactivation du viewport caché :', error);
}
if (hiddenElement.parentNode) {
hiddenElement.parentNode.removeChild(hiddenElement);
}
console.log('### FIN EXPORT KEY IMAGE ###');
console.log('====================================');
}
};
@@ -0,0 +1,25 @@
export const extractDicomInfoFromImageId = (imageId: string) => {
if (!imageId) {
return null;
}
const imageIdWithoutPrefix = imageId.replace(/^wadors:/, '').replace(/^dicomweb:/, '');
const match = imageIdWithoutPrefix.match(
/\/studies\/([^/]+)\/series\/([^/]+)\/instances\/([^/]+)(?:\/frames\/(\d+))?/i
);
if (!match) {
console.error('Impossible d extraire les UID depuis imageId :', imageId);
return null;
}
return {
imageId,
studyInstanceUID: decodeURIComponent(match[1]),
seriesInstanceUID: decodeURIComponent(match[2]),
sopInstanceUID: decodeURIComponent(match[3]),
frameNumber: match[4] || null,
};
};
@@ -0,0 +1,41 @@
import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig';
export const loadKeyImagesFromTrizRis = async (): Promise<string[]> => {
try {
const response = await fetch(`${TrizRisConfig.apiBaseUrl}/dicom/key-images`);
if (!response.ok) {
console.error('Erreur chargement Key Images :', response.status);
return [];
}
const text = await response.text();
/*
* Ton endpoint renvoie actuellement du XML :
*
* <List>
* <item>...</item>
* </List>
*/
const parser = new DOMParser();
const xml = parser.parseFromString(text, 'application/xml');
const items = Array.from(xml.getElementsByTagName('item'));
const sopInstanceUIDs = items.map(item => item.textContent?.trim()).filter(Boolean) as string[];
console.log('### KEY IMAGES CHARGEES DEPUIS TRIZRIS ###');
console.log(sopInstanceUIDs);
return sopInstanceUIDs;
} catch (error) {
console.error('Erreur récupération Key Images :', error);
return [];
}
};
+58 -10
View File
@@ -28,6 +28,7 @@ import { useViewportsByPositionStore } from './stores/useViewportsByPositionStor
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy';
import promptSaveReport from './utils/promptSaveReport';
import { saveLayoutPreference } from './services/TrizRisLayoutApi';
export type HangingProtocolParams = {
protocolId?: string;
@@ -498,12 +499,21 @@ const commandsModule = ({
/**
* Changes the viewport grid layout in terms of the MxN layout.
*/
setViewportGridLayout: ({ numRows, numCols, isHangingProtocolLayout = false }) => {
const { protocol } = hangingProtocolService.getActiveProtocol();
const onLayoutChange = protocol.callbacks?.onLayoutChange;
if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) {
// Don't apply the layout if the run command returns false
return;
setViewportGridLayout: ({
numRows,
numCols,
isHangingProtocolLayout = false,
savePreference = true,
}) => {
const activeProtocol = hangingProtocolService.getActiveProtocol();
const protocol = activeProtocol?.protocol;
const onLayoutChange = protocol?.callbacks?.onLayoutChange;
if (onLayoutChange) {
if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) {
return;
}
}
const completeLayout = () => {
@@ -525,9 +535,49 @@ const commandsModule = ({
findOrCreateViewport,
isHangingProtocolLayout,
});
console.log('### LAYOUT CHOISI ###');
console.log('numRows =', numRows);
console.log('numCols =', numCols);
console.log('isHangingProtocolLayout =', isHangingProtocolLayout);
if (!isHangingProtocolLayout && savePreference) {
const hangingProtocolState = hangingProtocolService.getState();
const studyInstanceUID = hangingProtocolState.activeStudyUID;
console.log('### STUDY UID POUR LAYOUT ###', studyInstanceUID);
if (!studyInstanceUID) {
console.error('StudyInstanceUID introuvable dans URL');
return;
}
saveLayoutPreference(numRows, numCols, studyInstanceUID)
.then(() => {
console.log('### LAYOUT SAUVEGARDE AVEC SUCCES ###');
})
.catch(error => {
console.error('### ERREUR SAUVEGARDE LAYOUT ###', error);
});
}
};
// Need to finish any work in the callback
window.setTimeout(completeLayout, 0);
const applyLayoutWhenReady = () => {
const hpState = hangingProtocolService.getState();
if (!hpState) {
console.log('### HANGING PROTOCOL PAS ENCORE PRET -> ATTENTE ###');
window.setTimeout(applyLayoutWhenReady, 100);
return;
}
console.log('### HANGING PROTOCOL PRET -> APPLICATION LAYOUT ###');
completeLayout();
};
applyLayoutWhenReady();
},
toggleOneUp() {
@@ -805,9 +855,7 @@ const commandsModule = ({
}
const reportBlob = datasetToDicomBlob(instances[0]);
const type = defaultContentType || 'application/dicom';
await navigator.clipboard.write([
new ClipboardItem({ [type]: reportBlob }),
]);
await navigator.clipboard.write([new ClipboardItem({ [type]: reportBlob })]);
};
}
@@ -0,0 +1,29 @@
import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig';
const ACCESS_URL = `${TrizRisConfig.apiBaseUrl}/ohif/access`;
export async function checkTrizRisAccess(): Promise<boolean> {
try {
const response = await fetch(ACCESS_URL, {
method: 'GET',
headers: {
Accept: 'application/json',
},
credentials: 'include',
});
if (!response.ok) {
return false;
}
const result = await response.json();
return result.authorized === true;
} catch (error) {
console.error('### ERREUR VERIFICATION ACCES TRIZRIS ###', error);
return false;
}
}
@@ -0,0 +1,61 @@
import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig';
const BASE_URL = `${TrizRisConfig.apiBaseUrl}/image-view-state/current`;
export type ImageViewState = {
zoom: number;
rotation: number;
studyInstanceUID: string;
seriesInstanceUID: string;
sopInstanceUID: string;
};
export async function saveImageViewState(state: ImageViewState): Promise<void> {
const response = await fetch(BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
credentials: 'include',
body: JSON.stringify(state),
});
if (!response.ok) {
throw new Error(`Erreur sauvegarde image view state : ${response.status}`);
}
}
export async function getImageViewState(
studyInstanceUID: string,
seriesInstanceUID: string,
sopInstanceUID: string
): Promise<ImageViewState | null> {
const url =
`${BASE_URL}` +
`?studyInstanceUID=${encodeURIComponent(studyInstanceUID)}` +
`&seriesInstanceUID=${encodeURIComponent(seriesInstanceUID)}` +
`&sopInstanceUID=${encodeURIComponent(sopInstanceUID)}`;
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
},
credentials: 'include',
});
if (response.status === 204) {
return null;
}
if (!response.ok) {
throw new Error(`Erreur récupération image view state : ${response.status}`);
}
return response.json();
}
@@ -0,0 +1,59 @@
import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig';
const BASE_URL = `${TrizRisConfig.apiBaseUrl}/layout/current`;
export type SavedLayout = {
numRows: number;
numCols: number;
};
export async function saveLayoutPreference(
numRows: number,
numCols: number,
studyInstanceUID: string
): Promise<void> {
const response = await fetch(BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
credentials: 'include',
body: JSON.stringify({
numRows,
numCols,
studyInstanceUID,
}),
});
if (!response.ok) {
throw new Error(`Erreur sauvegarde layout TrizRis : ${response.status}`);
}
}
export async function getLayoutPreference(studyInstanceUID: string): Promise<SavedLayout | null> {
const response = await fetch(
`${BASE_URL}?studyInstanceUID=${encodeURIComponent(studyInstanceUID)}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
},
credentials: 'include',
}
);
if (response.status === 204) {
return null;
}
if (!response.ok) {
throw new Error(`Erreur récupération layout TrizRis : ${response.status}`);
}
return response.json();
}
+58 -5
View File
@@ -1,7 +1,8 @@
import update from 'immutability-helper';
import { utils } from '@ohif/core';
import { getLayoutPreference } from '../../../extensions/default/src/services/TrizRisLayoutApi';
import initToolGroups from './initToolGroups';
import { checkTrizRisAccess } from '../../../extensions/default/src/services/TrizRisAccessApi';
import {
addActivatePanelTriggers,
applyToolGroupAdditions,
@@ -182,9 +183,19 @@ export const defaultActivatePanelTriggers = [
},
];
export function onModeEnter({ servicesManager, extensionManager, commandsManager }: withAppTypes) {
const { measurementService, toolbarService, toolGroupService, customizationService } =
servicesManager.services;
export function onModeEnter({
servicesManager,
extensionManager,
commandsManager,
studyInstanceUIDs,
}: withAppTypes) {
const {
measurementService,
toolbarService,
toolGroupService,
customizationService,
hangingProtocolService,
} = servicesManager.services;
measurementService.clearMeasurements();
@@ -220,7 +231,7 @@ export function onModeEnter({ servicesManager, extensionManager, commandsManager
);
// Note: the mode's `modeCustomizations` are NOT applied here — the mode
// route applies them right after the mode scope is reset, before the app
// route applies them right after the mode scope is reset, before the appf
// config / URL `mode` phase blocks, so the final value of every key is
// decided purely by customization scope precedence and application order.
@@ -230,6 +241,47 @@ export function onModeEnter({ servicesManager, extensionManager, commandsManager
this._unsubscriptions.push(
...addActivatePanelTriggers({ servicesManager }, this.activatePanelTriggers)
);
const restoreLayoutTrizRis = async (attempt = 0) => {
try {
const studyInstanceUID = studyInstanceUIDs?.[0];
if (!studyInstanceUID) {
console.log('### STUDY UID PAS ENCORE DISPONIBLE ###');
return;
}
console.log('### STUDY UID DIRECT POUR LAYOUT ###', studyInstanceUID);
console.log('### STUDY UID POUR LAYOUT ###', studyInstanceUID);
const layout = await getLayoutPreference(studyInstanceUID);
console.log('### LAYOUT RECUPERE DEPUIS TRIZRIS ###', layout);
if (!layout) {
console.log('### AUCUNE PREFERENCE LAYOUT -> OHIF / HANGING PROTOCOL CONSERVE ###');
return;
}
console.log('### PREFERENCE LAYOUT TROUVEE -> PRIORITE TRIZRIS ###');
commandsManager.runCommand('setViewportGridLayout', {
numRows: layout.numRows,
numCols: layout.numCols,
isHangingProtocolLayout: false,
savePreference: false,
});
console.log('### LAYOUT TRIZRIS APPLIQUE ###', layout.numRows, 'x', layout.numCols);
console.log('### LAYOUT TRIZRIS APPLIQUE ###', layout.numRows, 'x', layout.numCols);
} catch (error) {
console.error('### ERREUR RESTAURATION LAYOUT TRIZRIS ###', error);
}
};
restoreLayoutTrizRis();
}
export function onModeExit({ servicesManager }: withAppTypes) {
@@ -240,6 +292,7 @@ export function onModeExit({ servicesManager }: withAppTypes) {
cornerstoneViewportService,
uiDialogService,
uiModalService,
hangingProtocolService,
} = servicesManager.services;
this._unsubscriptions?.forEach(unsubscribe => unsubscribe());
+5 -1
View File
@@ -83,11 +83,15 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage
{ toolName: toolNames.LivewireContour },
{ toolName: toolNames.WindowLevelRegion },
],
enabled: [{ toolName: toolNames.ImageOverlayViewer }, { toolName: toolNames.ReferenceLines }],
enabled: [{ toolName: toolNames.ImageOverlayViewer }],
disabled: [
{
toolName: toolNames.AdvancedMagnify,
},
{
toolName: toolNames.ReferenceLines,
},
],
};
+9
View File
@@ -209,6 +209,15 @@ module.exports = (env, argv) => {
context: ['/dicomweb'],
target: 'http://localhost:5000',
},
{
context: ['/orthanc'],
target: 'http://localhost:8042',
changeOrigin: true,
pathRewrite: {
'^/orthanc': '',
},
},
],
static: [
{
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
TRIZRIS_BASE_URL: 'http://localhost:9090/TrizRis',
ORTHANC_BASE_URL: 'http://localhost:8042',
OHIF_BASE_URL: 'http://localhost:3000',
};
+67 -19
View File
@@ -16,12 +16,18 @@
// your own DICOMweb server. For a fully-featured setup with every data source
// and customization loading enabled, see config/dev.js (local development) and
// config/netlify.js (the public demo deploy).
window.config = {
name: 'config/default.js',
routerBasename: null,
// whiteLabeling: {},
extensions: [],
modes: [],
customizationService: {},
// --- URL-driven customizations (?customization=) ----------------------------
@@ -44,68 +50,112 @@ window.config = {
// ("next") API instead of the legacy Stack/Volume viewport classes.
genericViewports: {
enabled: false,
// Render backend selection: 'cpu' | 'webgl' | 'auto' | a backend id
// registered via cornerstone's registerRenderBackend (e.g. a webgpu
// backend), or a map with per-viewport-type overrides, e.g.
// { default: 'webgl', orthographic: 'cpu' }. The matching URL params take
// precedence per-session: ?viewportRendering=cpu and
// ?orthographic.viewportRendering=cpu.
// viewportRendering: 'auto',
},
// ----------------------------------------------------------------------------
showStudyList: true,
// some windows systems have issues with more than 3 web workers
maxNumberOfWebWorkers: 3,
// below flag is for performance reasons, but it might not work for all servers
showWarningMessageForCrossOrigin: true,
showCPUFallbackMessage: true,
showLoadingIndicator: true,
experimentalStudyBrowserSort: false,
strictZSpacingForVolumeViewport: true,
groupEnabledModesFirst: true,
allowMultiSelectExport: false,
maxNumRequests: {
interaction: 100,
thumbnail: 5,
// Prefetch number is dependent on the http protocol. For http 2 or
// above, the number of requests can be go a lot higher.
prefetch: 25,
},
showErrorDetails: 'always', // 'always', 'dev', 'production'
showErrorDetails: 'always',
// `dangerouslyUseDynamicConfig` (load configuration from a `configUrl` query
// parameter) is intentionally left OFF in the secure default build. See
// config/dev.js for the documented shape.
defaultDataSourceName: 'ohif',
// ✅ MODIFIÉ : on indique que la source par défaut est Orthanc
defaultDataSourceName: 'orthanc',
dataSources: [
{
// Read-only public demo server. Replace with your own DICOMweb server.
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'ohif',
// ✅ MODIFIÉ : nom logique de la datasource
sourceName: 'orthanc',
configuration: {
friendlyName: 'AWS S3 Static wado server',
name: 'aws',
wadoUriRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoSupportsIncludeField: false,
// ✅ MODIFIÉ
friendlyName: 'Orthanc local',
// ✅ MODIFIÉ
name: 'Orthanc',
// ✅ Orthanc WADO-URI
wadoUriRoot: '/orthanc/wado',
// ✅ Orthanc QIDO-RS
qidoRoot: '/orthanc/dicom-web',
// ✅ Orthanc WADO-RS
wadoRoot: '/orthanc/dicom-web',
qidoSupportsIncludeField: true,
imageRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRendering: 'wadors',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: true,
supportsFuzzyMatching: false,
supportsWildcard: true,
staticWado: true,
// ✅ MODIFIÉ
// Avant tu avais true car la config venait d'un serveur WADO statique.
// Avec Orthanc DICOMweb, mets false.
staticWado: false,
// Multiframe SEG loads fetch the whole instance as a single Part 10
// object by default and wait for it: the per-frame endpoint is
// efficient, but SEG frames are so small and numerous that one bulk
// fetch beats hundreds of tiny requests. Per-frame loading is the
// exception — set loadMultiframeAsPart10: false here to force it.
// fetch beats hundreds of tiny requests.
singlepart: 'bulkdata,video',
bulkDataURI: {
enabled: true,
relativeResolution: 'studies',
transform: url => url.replace('/pixeldata.mp4', '/rendered'),
},
omitQuotationForMultipartRequest: true,
},
},
@@ -114,15 +164,13 @@ window.config = {
// default because they broaden the attack surface of a default deployment.
// Enable them only in a deployment you control (see config/dev.js):
// - dicomlocal: loads DICOM files from the user's machine.
// - dicomjson: loads metadata from an arbitrary `?url=` (gate with
// `dangerouslyAllowedOriginsForAuthenticatedEnvironments`).
// - dicomjson: loads metadata from an arbitrary `?url=`.
// - dicomwebproxy: delegating proxy driven by `?url=`.
],
httpErrorHandler: error => {
// This is 429 when rejected from the public idc sandbox too often.
console.warn(error.status);
// Could use services manager here to bring up a dialog/modal if needed.
console.warn('test, navigate to https://ohif.org/');
},
};
+234 -50
View File
@@ -10,52 +10,64 @@
// customizations, can be exercised locally exactly as it runs on the demo.
// The locked-down config/default.js is what a plain production build emits
// instead.
window.config = {
name: 'config/dev.js',
routerBasename: null,
// whiteLabeling: {},
extensions: [],
modes: [],
customizationService: ['@ohif/extension-default.customizationModule.theme'],
// URL-driven customizations (?customization=). The `default` prefix (no
// slashes) is used for values without a leading slash; every other prefix
// must start AND end with a slash and matches the leading `/segment/` of the
// value. Files are fetched and parsed as JSONC data — never executed.
// e.g. ?customization=tools/ctPresets -> ./customizations/tools/ctPresets.jsonc
customizationUrlPrefixes: {
default: './customizations/',
},
showStudyList: true,
// some windows systems have issues with more than 3 web workers
maxNumberOfWebWorkers: 3,
// below flag is for performance reasons, but it might not work for all servers
showWarningMessageForCrossOrigin: true,
showCPUFallbackMessage: true,
showLoadingIndicator: true,
experimentalStudyBrowserSort: false,
strictZSpacingForVolumeViewport: true,
groupEnabledModesFirst: true,
allowMultiSelectExport: false,
maxNumRequests: {
interaction: 100,
thumbnail: 5,
// Prefetch number is dependent on the http protocol. For http 2 or
// above, the number of requests can be go a lot higher.
prefetch: 25,
},
showErrorDetails: 'always', // 'always', 'dev', 'production'
showErrorDetails: 'always',
// filterQueryParam: false,
// Defines multi-monitor layouts
multimonitor: [
{
id: 'split',
test: ({ multimonitor }) => multimonitor === 'split',
screens: [
{
id: 'ohif0',
screen: null,
location: {
screen: 0,
width: 0.5,
@@ -63,17 +75,22 @@ window.config = {
left: 0,
top: 0,
},
options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
},
{
id: 'ohif1',
screen: null,
location: {
width: 0.5,
height: 1,
left: 0.5,
top: 0,
},
options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
},
],
@@ -81,195 +98,362 @@ window.config = {
{
id: '2',
test: ({ multimonitor }) => multimonitor === '2',
screens: [
{
id: 'ohif0',
screen: 0,
location: {
width: 1,
height: 1,
left: 0,
top: 0,
},
options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
},
{
id: 'ohif1',
screen: 1,
location: {
width: 1,
height: 1,
left: 0,
top: 0,
},
options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no',
},
],
},
],
defaultDataSourceName: 'ohif',
/* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */
// dangerouslyUseDynamicConfig: {
// enabled: true,
// regex: /.*/,
// },
// ✅ MODIFIÉ
// Avant :
// defaultDataSourceName: 'ohif',
//
// Maintenant OHIF utilise Orthanc par défaut.
defaultDataSourceName: 'orthanc',
dataSources: [
// =========================================================
// AWS source 1
// =========================================================
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'ohif',
configuration: {
friendlyName: 'AWS S3 Static wado server',
name: 'aws',
wadoUriRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoSupportsIncludeField: false,
imageRendering: 'wadors',
thumbnailRendering: 'thumbnail',
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: true,
supportsWildcard: true,
staticWado: true,
singlepart: 'bulkdata,video',
bulkDataURI: {
enabled: true,
relativeResolution: 'studies',
transform: url => url.replace('/pixeldata.mp4', '/rendered'),
},
omitQuotationForMultipartRequest: true,
},
},
// =========================================================
// AWS source 2
// =========================================================
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'ohif2',
configuration: {
friendlyName: 'AWS S3 Static wado secondary server',
name: 'aws',
wadoUriRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb',
wadoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb',
qidoSupportsIncludeField: false,
supportsReject: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
enableStudyLazyLoad: true,
supportsFuzzyMatching: false,
supportsWildcard: true,
staticWado: true,
singlepart: 'bulkdata,video',
bulkDataURI: {
enabled: true,
relativeResolution: 'studies',
},
omitQuotationForMultipartRequest: true,
},
},
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'ohif3',
configuration: {
friendlyName: 'AWS S3 Static wado secondary server',
name: 'aws',
wadoUriRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
qidoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
wadoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
qidoSupportsIncludeField: false,
supportsReject: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
enableStudyLazyLoad: true,
supportsFuzzyMatching: false,
supportsWildcard: true,
staticWado: true,
singlepart: 'bulkdata,video',
bulkDataURI: {
enabled: true,
relativeResolution: 'studies',
},
omitQuotationForMultipartRequest: true,
},
},
// =========================================================
// AWS source 3
// =========================================================
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'ohif3',
configuration: {
friendlyName: 'AWS S3 Static wado secondary server',
name: 'aws',
wadoUriRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
qidoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
wadoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb',
qidoSupportsIncludeField: false,
supportsReject: false,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
enableStudyLazyLoad: true,
supportsFuzzyMatching: false,
supportsWildcard: true,
staticWado: true,
singlepart: 'bulkdata,video',
bulkDataURI: {
enabled: true,
relativeResolution: 'studies',
},
omitQuotationForMultipartRequest: true,
},
},
// =========================================================
// local5000
// =========================================================
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'local5000',
configuration: {
friendlyName: 'Static WADO Local Data',
name: 'DCM4CHEE',
qidoRoot: 'http://localhost:5000/dicomweb',
wadoRoot: 'http://localhost:5000/dicomweb',
qidoSupportsIncludeField: false,
supportsReject: true,
supportsStow: true,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
enableStudyLazyLoad: true,
supportsFuzzyMatching: false,
supportsWildcard: true,
staticWado: true,
singlepart: 'video',
bulkDataURI: {
enabled: true,
relativeResolution: 'studies',
},
},
},
// =========================================================
// ✅ ORTHANC LOCAL VIA PROXY OHIF
// =========================================================
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'orthanc',
configuration: {
friendlyName: 'local Orthanc DICOMWeb Server',
name: 'DCM4CHEE',
wadoUriRoot: 'http://localhost/pacs/dicom-web',
qidoRoot: 'http://localhost/pacs/dicom-web',
wadoRoot: 'http://localhost/pacs/dicom-web',
// ✅ MODIFIÉ
friendlyName: 'Orthanc local',
// ✅ MODIFIÉ
name: 'Orthanc',
/*
* ✅ MODIFIÉ IMPORTANT
*
* AVANT :
* http://localhost:8042/wado
*
* MAINTENANT :
* on passe par le serveur OHIF lui-même.
*/
wadoUriRoot: '/orthanc/wado',
/*
* ✅ MODIFIÉ IMPORTANT
*
* AVANT :
* http://localhost:8042/dicom-web
*/
qidoRoot: '/orthanc/dicom-web',
/*
* ✅ MODIFIÉ IMPORTANT
*
* AVANT :
* http://localhost:8042/dicom-web
*/
wadoRoot: '/orthanc/dicom-web',
qidoSupportsIncludeField: true,
supportsReject: true,
dicomUploadEnabled: true,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
// ✅ AJOUTÉ
thumbnailRequestStrategy: 'fetch',
enableStudyLazyLoad: true,
supportsFuzzyMatching: true,
// ✅ MODIFIÉ
supportsFuzzyMatching: false,
supportsWildcard: true,
// ✅ AJOUTÉ
staticWado: false,
// ✅ AJOUTÉ
singlepart: 'bulkdata,video',
omitQuotationForMultipartRequest: true,
bulkDataURI: {
enabled: true,
// ✅ AJOUTÉ
relativeResolution: 'studies',
},
},
},
// =========================================================
// dicomweb proxy
// =========================================================
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomwebproxy',
sourceName: 'dicomwebproxy',
configuration: {
friendlyName: 'dicomweb delegating proxy',
name: 'dicomwebproxy',
},
},
// =========================================================
// dicomjson
// =========================================================
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomjson',
sourceName: 'dicomjson',
configuration: {
friendlyName: 'dicom json',
name: 'json',
},
},
// =========================================================
// dicomlocal
// =========================================================
{
namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal',
sourceName: 'dicomlocal',
configuration: {
friendlyName: 'dicom local',
},
},
],
httpErrorHandler: error => {
// This is 429 when rejected from the public idc sandbox too often.
console.warn(error.status);
// Could use services manager here to bring up a dialog/modal if needed.
console.warn('test, navigate to https://ohif.org/');
},
};
+2
View File
@@ -317,6 +317,8 @@ export default function ModeRoute({
extensionManager,
commandsManager,
appConfig,
// ===== TRIZRIS =====
studyInstanceUIDs,
});
// Move hotkeys setup here, after onModeEnter
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
export default function NoAccess() {
return (
<div
style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
background: '#0b0f14',
color: 'white',
textAlign: 'center',
padding: '20px',
}}
>
<h1>Accès refusé</h1>
<p>Vous devez être connecté à TrizRis avec un compte médecin pour accéder au viewer.</p>
</div>
);
}
@@ -0,0 +1,72 @@
import React, { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { checkTrizRisAccess } from '../../../../extensions/default/src/services/TrizRisAccessApi';
type Props = {
children: React.ReactNode;
};
export default function TrizRisProtectedRoute({ children }: Props) {
const [checking, setChecking] = useState(true);
const [authorized, setAuthorized] = useState(false);
useEffect(() => {
let mounted = true;
const checkAccess = async () => {
try {
const result = await checkTrizRisAccess();
if (!mounted) {
return;
}
console.log('### VERIFICATION ACCES TRIZRIS ###', result);
setAuthorized(result);
} catch (error) {
console.error('### ERREUR VERIFICATION ACCES TRIZRIS ###', error);
if (mounted) {
setAuthorized(false);
}
} finally {
if (mounted) {
setChecking(false);
}
}
};
checkAccess();
return () => {
mounted = false;
};
}, []);
// Pendant la vérification :
// on n'affiche PAS encore le viewer.
if (checking) {
return (
<div
style={{
width: '100vw',
height: '100vh',
background: '#0b0f14',
}}
/>
);
}
if (!authorized) {
return (
<Navigate
to="/no-access"
replace
/>
);
}
return <>{children}</>;
}
+30 -16
View File
@@ -18,6 +18,8 @@ import PropTypes from 'prop-types';
import { routerBasename } from '../utils/publicUrl';
import { useAppConfig } from '@state';
import { history } from '../utils/history';
import TrizRisProtectedRoute from './TrizRisProtectedRoute';
import NoAccess from './NoAccess';
const NotFoundServer = ({
message = 'Unable to query for studies at this time. Check your data source configuration or network connection',
@@ -71,6 +73,10 @@ NotFoundStudy.propTypes = {
// TODO: Include "routes" debug route if dev build
const bakedInRoutes = [
{
path: `/no-access`,
children: NoAccess,
},
{
path: `/notfoundserver`,
children: NotFoundServer,
@@ -105,7 +111,7 @@ const createRoutes = ({
hotkeysManager,
showStudyList,
}: withAppTypes) => {
const routes =
const routes = (
buildModeRoutes({
modes,
dataSources,
@@ -113,7 +119,11 @@ const createRoutes = ({
servicesManager,
commandsManager,
hotkeysManager,
}) || [];
}) || []
).map(route => ({
...route,
trizRisProtected: true,
}));
const { customizationService } = servicesManager.services;
@@ -176,23 +186,27 @@ const createRoutes = ({
return (
<Routes>
{allRoutes.map((route, i) => {
return route.private === true ? (
let element = <RouteWithErrorBoundary route={route} />;
if (route.trizRisProtected === true) {
element = <TrizRisProtectedRoute>{element}</TrizRisProtectedRoute>;
}
if (route.private === true) {
element = (
<PrivateRoute
handleUnauthenticated={() => userAuthenticationService.handleUnauthenticated()}
>
{element}
</PrivateRoute>
);
}
return (
<Route
key={i}
path={route.path}
element={
<PrivateRoute
handleUnauthenticated={() => userAuthenticationService.handleUnauthenticated()}
>
<RouteWithErrorBoundary route={route} />
</PrivateRoute>
}
></Route>
) : (
<Route
key={i}
path={route.path}
element={<RouteWithErrorBoundary route={route} />}
element={element}
/>
);
})}
+13
View File
@@ -0,0 +1,13 @@
const TRIZRIS_BASE_URL = 'http://localhost:9090/TrizRis';
const ORTHANC_BASE_URL = 'http://localhost:8042';
export const TrizRisConfig = {
apiBaseUrl: `${TRIZRIS_BASE_URL}/api`,
accessUrl: `${TRIZRIS_BASE_URL}/ohif/access`,
orthancBaseUrl: ORTHANC_BASE_URL,
orthancDicomWebUrl: `${ORTHANC_BASE_URL}/dicom-web`,
orthancWadoUrl: `${ORTHANC_BASE_URL}/wado`,
ohifBaseUrl: 'http://localhost:3000',
};
@@ -1,5 +1,6 @@
import { annotation } from '@cornerstonejs/tools';
import LocalStorageAnnotationPersistenceBackend from './LocalStorageAnnotationPersistenceBackend';
import TrizRisAnnotationPersistenceBackend from './TrizRisAnnotationPersistenceBackend';
export interface AnnotationPersistenceBackend {
save(record: Record<string, any>): Promise<void> | void;
@@ -28,7 +29,8 @@ export default class AnnotationPersistenceService {
name: 'annotationPersistenceService',
altName: 'AnnotationPersistenceService',
create: ({ servicesManager }: { servicesManager: any }) => {
const backend = new LocalStorageAnnotationPersistenceBackend();
// = new LocalStorageAnnotationPersistenceBackend();
const backend = new TrizRisAnnotationPersistenceBackend();
return new AnnotationPersistenceService(backend, {
services: {
measurementService: servicesManager.services.measurementService,
@@ -207,8 +209,7 @@ export default class AnnotationPersistenceService {
const resolvedSource =
(persistedSource?.name && persistedSource?.version
? measurementService.getSource(persistedSource.name, persistedSource.version)
: null) ||
measurementService.getSource('Cornerstone3DTools', '0.1');
: null) || measurementService.getSource('Cornerstone3DTools', '0.1');
if (!resolvedSource) {
console.warn('Annotation persistence: no registered source found, skipping restore');
@@ -232,7 +233,20 @@ export default class AnnotationPersistenceService {
const normalizedAnnotation = this._normalizePersistedAnnotation(record.annotation);
const rebuiltAnnotation = this._buildRestoredAnnotation(measurement, normalizedAnnotation);
console.log('====================================');
console.log('### RESTAURATION MEASUREMENT ###');
console.log('referencedImageId =', rebuiltAnnotation?.metadata?.referencedImageId);
console.log('FrameOfReferenceUID =', rebuiltAnnotation?.metadata?.FrameOfReferenceUID);
console.log('points JSON =', JSON.stringify(rebuiltAnnotation?.data?.handles?.points));
console.log('cachedStats JSON =', JSON.stringify(rebuiltAnnotation?.data?.cachedStats));
console.log('measurement JSON =', JSON.stringify(measurement));
console.log('====================================');
measurementService.addOrUpdateMeasurement?.(measurement, {
annotation: rebuiltAnnotation,
});
@@ -0,0 +1,96 @@
export interface AnnotationPersistenceBackend {
save(record: Record<string, any>): Promise<void> | void;
update(record: Record<string, any>): Promise<void> | void;
delete(uid: string): Promise<void> | void;
load(studyInstanceUID: string): Promise<any[]> | any[];
}
const BASE_URL = 'http://localhost:9090/TrizRis/api/annotation';
export default class TrizRisAnnotationPersistenceBackend implements AnnotationPersistenceBackend {
public async save(record: Record<string, any>): Promise<void> {
console.log('### SAUVEGARDE ANNOTATION TRIZRIS ###');
console.log(record);
const response = await fetch(BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(record),
});
if (!response.ok) {
throw new Error(`Erreur sauvegarde annotation : ${response.status}`);
}
const resultat = await response.text();
console.log('Réponse TrizRis =', resultat);
}
public async update(record: Record<string, any>): Promise<void> {
console.log('### MODIFICATION ANNOTATION TRIZRIS ###');
console.log(record);
const response = await fetch(BASE_URL, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(record),
});
if (!response.ok) {
throw new Error(`Erreur modification annotation : ${response.status}`);
}
const resultat = await response.text();
console.log('Réponse TrizRis =', resultat);
}
public async delete(uid: string): Promise<void> {
console.log('### SUPPRESSION ANNOTATION TRIZRIS ###');
console.log('UID =', uid);
const response = await fetch(`${BASE_URL}/${encodeURIComponent(uid)}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Erreur suppression annotation : ${response.status}`);
}
const resultat = await response.text();
console.log('Réponse TrizRis =', resultat);
}
public async load(studyInstanceUID: string): Promise<any[]> {
console.log('### CHARGEMENT ANNOTATIONS TRIZRIS ###');
console.log('StudyInstanceUID =', studyInstanceUID);
const response = await fetch(
`${BASE_URL}?studyInstanceUID=${encodeURIComponent(studyInstanceUID)}`
);
if (!response.ok) {
throw new Error(`Erreur chargement annotations : ${response.status}`);
}
const records = await response.json();
console.log('Annotations reçues =', records);
return Array.isArray(records) ? records : [];
}
}