import type { WadoRsFrame } from './healthImagingToWadoRs.ts'; // Translates Cornerstone measurement annotations between the volatile image reference // (`imageId`, built from a HealthImaging imageSetId + frameId - both re-minted on every // import) and the permanent DICOM SOPInstanceUID. // // TWO URL-BEARING FIELDS, NOT ONE. Cornerstone's annotation metadata carries both // `referencedImageId` and `referencedImageURI` (verified in the installed package, // @cornerstonejs/core/dist/esm/types/IViewport.d.ts:37-38 - ViewReference). Both embed the // imageSetId/frameId pair. Stripping only `referencedImageId` before persisting leaves a // dangling URL in `referencedImageURI` that points at frames a re-import has already // invalidated. Every place that touches one of these fields touches both. // // Pure module: no I/O, no React, no Cornerstone runtime import - types only. export interface StoredAnnotation { sopInstanceUid: string; annotation: Record; // Cornerstone's annotation, minus the image references } export interface StoredAnnotationState { version: 1; annotations: StoredAnnotation[]; } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); // Cornerstone's convention for referencedImageURI is the imageId with its loader scheme // (everything up to and including the first colon, e.g. `wadors:`) stripped. const toReferencedImageURI = (imageId: string): string => { const colonIndex = imageId.indexOf(':'); return colonIndex === -1 ? imageId : imageId.slice(colonIndex + 1); }; const referencedImageId = (annotation: Record): string | undefined => { const metadata = annotation.metadata; if (!isRecord(metadata)) return undefined; const value = metadata.referencedImageId; return typeof value === 'string' ? value : undefined; }; // Cornerstone keys an annotation's measured value under data.cachedStats by a targetId derived // from the currently-displayed imageId, and that imageId embeds identifiers HealthImaging mints // fresh on every import. A stored document must not carry that volatile identity in the key -- // the sopInstanceUid alongside it is already the annotation's stable identity -- so the entry is // rewritten onto this fixed key on the way out and rewritten onto the current targetId implied by // the frame it is restored against on the way back in. const CACHED_STATS_STORAGE_KEY = 'measurement'; // A stack viewport's targetId is the literal string "imageId:" followed by the imageId itself // (StackViewport.getViewReferenceId, @cornerstonejs/core). Cornerstone's measurement tools use // exactly this value as the key under data.cachedStats. const toTargetId = (imageId: string): string => `imageId:${imageId}`; // Moves a live annotation's sole cachedStats entry onto the fixed storage key, leaving the value // object itself untouched. An annotation with no measured stats yet is left as-is: there is // nothing to normalise. One with more than one entry is also left as-is, key and all: that shape // does not arise from normal drawing (Cornerstone produces exactly one entry per annotation), and // with no single entry to prefer, rewriting one arbitrarily would be inventing data rather than // storing what is there. const normaliseCachedStatsForStorage = (annotation: Record): void => { const data = annotation.data; if (!isRecord(data)) return; const cachedStats = data.cachedStats; if (!isRecord(cachedStats)) return; const entries = Object.entries(cachedStats); if (entries.length !== 1) return; data.cachedStats = { [CACHED_STATS_STORAGE_KEY]: entries[0][1] }; }; // Moves the fixed storage key back onto the targetId implied by the frame the annotation is being // restored against, so Cornerstone finds a cachedStats entry for the image it is actually showing // and does not recompute -- or worse, add a second entry alongside a stale one. const restoreCachedStatsTargetId = (annotation: Record, imageId: string): void => { const data = annotation.data; if (!isRecord(data)) return; const cachedStats = data.cachedStats; if (!isRecord(cachedStats) || !(CACHED_STATS_STORAGE_KEY in cachedStats)) return; const rewritten: Record = { ...cachedStats }; rewritten[toTargetId(imageId)] = rewritten[CACHED_STATS_STORAGE_KEY]; delete rewritten[CACHED_STATS_STORAGE_KEY]; data.cachedStats = rewritten; }; // Converts live Cornerstone annotations into the document that gets persisted. An annotation // whose referenced image is not among the currently-loaded frames - a series that failed to // import while the rest of the study succeeded - cannot be stored. That drop is counted, // symmetrically with fromStored: a measurement that silently fails to save is clinical scratch // work lost with nothing said, and a drop at SAVE time is worse than one at load time because // at load time the data still exists somewhere and could in principle be recovered. // // `unresolved` MUST BE MERGED IN, NOT JUST ACCEPTED. It carries whatever fromStored could not // restore into Cornerstone on THIS load (SOPInstanceUID absent from the current frames - the // same "a series failed to import" case toStored's own drop-counting exists for). Cornerstone // never held those annotations, so getAllAnnotations() can never hand them back - without // merging them in here verbatim, the very next autosave would persist a document that silently // omits them, destroying data a *load*-time drop was supposed to leave recoverable. They pass // through completely untouched: no image-reference stripping (they never had current ones to // strip) and no involvement in the `dropped` count below (they are not being dropped by this // call, only carried forward). export const toStored = ( annotations: unknown[], frames: WadoRsFrame[], unresolved: StoredAnnotation[] = [] ): { state: StoredAnnotationState; dropped: number } => { const sopByImageId = new Map(); for (const frame of frames) sopByImageId.set(frame.imageId, frame.sopInstanceUid); const stored: StoredAnnotation[] = [...unresolved]; let dropped = 0; for (const raw of annotations) { if (!isRecord(raw)) { dropped++; continue; } const imageId = referencedImageId(raw); const sopInstanceUid = imageId !== undefined ? sopByImageId.get(imageId) : undefined; if (sopInstanceUid === undefined) { dropped++; continue; } // Never mutate the live annotation the rendering engine is using - work on a deep copy. const annotationCopy = structuredClone(raw); const metadata = annotationCopy.metadata; if (isRecord(metadata)) { delete metadata.referencedImageId; delete metadata.referencedImageURI; } normaliseCachedStatsForStorage(annotationCopy); stored.push({ sopInstanceUid, annotation: annotationCopy }); } return { state: { version: 1, annotations: stored }, dropped }; }; // Restores a persisted document against the CURRENT frames (post re-import, imageSetId and // frameId may both have changed). Never throws: a corrupt, truncated or wrong-version document // costs the practitioner their annotations, not their viewer. Any annotation whose // SOPInstanceUID is absent from the loaded study - that series did not come back - is dropped // and counted rather than silently discarded. // // `unresolved` IS THE OTHER HALF OF THAT DROP, NOT JUST A COUNT. A dropped annotation is not // discarded here - it is handed back verbatim (same sopInstanceUid, same annotation, untouched) // so the caller can carry it forward into every subsequent toStored call for the life of this // session. Without this, the annotation exists nowhere Cornerstone can ever reproduce it (it was // never added, so getAllAnnotations() never returns it), and the first autosave after this load // permanently erases it from the persisted document - even though, at load time, the data still // exists and the missing series could in principle come back. Malformed entries (not a record, // missing sopInstanceUid, missing annotation) are NOT included here: they are not a "this series // didn't come back" case, they are corrupt data with nothing recoverable to carry forward. export const fromStored = ( state: unknown, frames: WadoRsFrame[] ): { annotations: Record[]; dropped: number; unresolved: StoredAnnotation[] } => { const empty = { annotations: [] as Record[], dropped: 0, unresolved: [] as StoredAnnotation[] }; if (!isRecord(state)) return empty; if (state.version !== 1) return empty; if (!Array.isArray(state.annotations)) return empty; const imageIdBySop = new Map(); for (const frame of frames) imageIdBySop.set(frame.sopInstanceUid, frame.imageId); const annotations: Record[] = []; const unresolved: StoredAnnotation[] = []; let dropped = 0; for (const entry of state.annotations) { if (!isRecord(entry) || typeof entry.sopInstanceUid !== 'string' || !isRecord(entry.annotation)) { dropped++; continue; } const imageId = imageIdBySop.get(entry.sopInstanceUid); if (imageId === undefined) { dropped++; unresolved.push(structuredClone({ sopInstanceUid: entry.sopInstanceUid, annotation: entry.annotation })); continue; } const annotationCopy = structuredClone(entry.annotation); const metadata = isRecord(annotationCopy.metadata) ? annotationCopy.metadata : {}; metadata.referencedImageId = imageId; metadata.referencedImageURI = toReferencedImageURI(imageId); annotationCopy.metadata = metadata; restoreCachedStatsTargetId(annotationCopy, imageId); annotations.push(annotationCopy); } return { annotations, dropped, unresolved }; };