diff --git a/label_studio/annotation_templates/computer-vision/dicom-annotation/config.yml b/label_studio/annotation_templates/computer-vision/dicom-annotation/config.yml
new file mode 100644
index 000000000000..5137486db7d3
--- /dev/null
+++ b/label_studio/annotation_templates/computer-vision/dicom-annotation/config.yml
@@ -0,0 +1,39 @@
+title: DICOM Medical Image Annotation
+type: community
+group: Computer Vision
+image: /static/templates/dicom-annotation.png
+details: |
+
Annotate DICOM medical images with multi-frame support
+
+ - Industry Applications
+ - radiology, pathology, oncology, diagnostic imaging, medical diagnosis, clinical decision support, medical AI, healthcare AI, CT scan, MRI, X-ray, ultrasound, mammography, PET scan, nuclear medicine, interventional radiology, cardiac imaging, neuroimaging, musculoskeletal imaging
+ - Domain Terminology
+ - DICOM, PACS, RIS, windowing, window level, window width, Hounsfield units, slice thickness, multi-planar reconstruction, segmentation, contouring, ROI, region of interest, lesion detection, tumor segmentation
+ - Regulatory
+ - FDA approval, medical device, clinical validation, HIPAA compliance, patient privacy, de-identification, anonymization
+
+config: |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/label_studio/annotation_templates/computer-vision/dicom-segmentation/config.yml b/label_studio/annotation_templates/computer-vision/dicom-segmentation/config.yml
new file mode 100644
index 000000000000..1465f20f44d0
--- /dev/null
+++ b/label_studio/annotation_templates/computer-vision/dicom-segmentation/config.yml
@@ -0,0 +1,37 @@
+title: DICOM Multi-Frame Segmentation
+type: community
+group: Computer Vision
+image: /static/templates/dicom-segmentation.png
+details: |
+ Segment DICOM medical images across multiple frames/slices
+
+ - Industry Applications
+ - radiology, radiation oncology, surgical planning, organ segmentation, tumor volumetrics, 3D reconstruction, treatment planning, dose calculation, CT colonography, cardiac CT, brain MRI, liver segmentation, lung nodule analysis
+ - Domain Terminology
+ - volumetric segmentation, contour propagation, slice-by-slice annotation, organ delineation, gross tumor volume, clinical target volume, planning target volume, isodose curves
+ - Regulatory
+ - FDA 510(k), CE marking, medical device software, clinical validation, HIPAA compliance, patient data protection
+
+config: |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/label_studio/core/settings/base.py b/label_studio/core/settings/base.py
index 5e890b6133b2..b510054938d0 100644
--- a/label_studio/core/settings/base.py
+++ b/label_studio/core/settings/base.py
@@ -486,6 +486,8 @@
[
'.bmp',
'.csv',
+ '.dcm',
+ '.dicom',
'.flac',
'.gif',
'.htm',
diff --git a/web/libs/editor/package.json b/web/libs/editor/package.json
index d3ba1b2a6050..c20f7483e49f 100644
--- a/web/libs/editor/package.json
+++ b/web/libs/editor/package.json
@@ -13,6 +13,8 @@
"colormap": "^2.3.2",
"d3": "^5.16.0",
"date-fns": "^2.20.1",
+ "dwv": "^0.33.0",
+ "dwv-react": "^0.15.0",
"emoji-regex": "^7.0.3",
"insert-after": "^0.1.4",
"keymaster": "^1.6.2",
@@ -35,7 +37,6 @@
"react-virtualized-auto-sizer": "^1.0.20",
"react-window": "^1.8.9",
"sanitize-html": "^2.12.1",
-
"webfft": "^1.0.3",
"xpath-range": "^1.1.1"
},
@@ -43,4 +44,4 @@
"devDependencies": {
"babel-plugin-istanbul": "^7.0.0"
}
-}
+}
\ No newline at end of file
diff --git a/web/libs/editor/src/regions/DicomBrushRegion.js b/web/libs/editor/src/regions/DicomBrushRegion.js
new file mode 100644
index 000000000000..d50883bcd032
--- /dev/null
+++ b/web/libs/editor/src/regions/DicomBrushRegion.js
@@ -0,0 +1,105 @@
+import { types } from "mobx-state-tree";
+
+import NormalizationMixin from "../mixins/Normalization";
+import RegionsMixin from "../mixins/Regions";
+import Registry from "../core/Registry";
+import { AreaMixin } from "../mixins/AreaMixin";
+import { DicomRegion, onlyProps } from "./DicomRegion";
+
+/**
+ * DicomBrushRegion - Brush/segmentation region with DICOM frame support
+ * Stores brush strokes per frame
+ */
+const Model = types
+ .model("DicomBrushRegionModel", {
+ type: "dicombrushregion",
+
+ // Brush data (RLE encoded or raw)
+ rle: types.maybeNull(types.frozen()),
+ touches: types.maybeNull(types.frozen()),
+
+ // Brush properties
+ strokeWidth: types.optional(types.number, 15),
+ opacity: types.optional(types.number, 1),
+ })
+ .volatile(() => ({
+ props: ["rle", "touches", "strokeWidth", "opacity"],
+ // Canvas for brush rendering
+ canvas: null,
+ imageData: null,
+ }))
+ .views((self) => ({
+ get bboxCoords() {
+ // For brush regions, we need to calculate from the mask
+ if (!self.rle && !self.touches) return null;
+ // This would be calculated from the actual brush data
+ return null;
+ },
+
+ /**
+ * Check if this brush region has any data
+ */
+ get hasData() {
+ return !!(self.rle || self.touches);
+ },
+ }))
+ .actions((self) => ({
+ setCanvas(canvas) {
+ self.canvas = canvas;
+ },
+
+ setRle(rle) {
+ self.rle = rle;
+
+ if (self.isMultiFrame) {
+ self.updateShape({ rle }, self.currentFrame);
+ }
+ },
+
+ setTouches(touches) {
+ self.touches = touches;
+
+ if (self.isMultiFrame) {
+ self.updateShape({ touches }, self.currentFrame);
+ }
+ },
+
+ setStrokeWidth(width) {
+ self.strokeWidth = width;
+ },
+
+ setOpacity(opacity) {
+ self.opacity = opacity;
+ },
+
+ /**
+ * Add a brush stroke point
+ */
+ addStrokePoint(point) {
+ const touches = self.touches ? [...self.touches] : [];
+ touches.push(point);
+ self.setTouches(touches);
+ },
+
+ /**
+ * Clear the brush data
+ */
+ clear() {
+ self.rle = null;
+ self.touches = null;
+ self.imageData = null;
+ },
+ }));
+
+const DicomBrushRegionModel = types.compose(
+ "DicomBrushRegionModel",
+ RegionsMixin,
+ DicomRegion,
+ AreaMixin,
+ NormalizationMixin,
+ Model,
+);
+
+Registry.addRegionType(DicomBrushRegionModel, "dicom");
+
+export { DicomBrushRegionModel };
diff --git a/web/libs/editor/src/regions/DicomPolygonRegion.js b/web/libs/editor/src/regions/DicomPolygonRegion.js
new file mode 100644
index 000000000000..8261058fb513
--- /dev/null
+++ b/web/libs/editor/src/regions/DicomPolygonRegion.js
@@ -0,0 +1,125 @@
+import { types } from "mobx-state-tree";
+
+import NormalizationMixin from "../mixins/Normalization";
+import RegionsMixin from "../mixins/Regions";
+import Registry from "../core/Registry";
+import { AreaMixin } from "../mixins/AreaMixin";
+import { DicomRegion, onlyProps } from "./DicomRegion";
+
+/**
+ * DicomPolygonRegion - Polygon region with DICOM frame support
+ * Stores polygon points per frame for multi-frame interpolation
+ */
+const Model = types
+ .model("DicomPolygonRegionModel", {
+ type: "dicompolygonregion",
+
+ // Polygon points as array of {x, y}
+ points: types.optional(types.frozen([]), []),
+
+ // Whether the polygon is closed
+ closed: types.optional(types.boolean, true),
+ })
+ .volatile(() => ({
+ props: ["points", "closed"],
+ }))
+ .views((self) => ({
+ get bboxCoords() {
+ const pts = self.isMultiFrame
+ ? self.getShape(self.currentFrame)?.points
+ : self.points;
+
+ if (!pts || pts.length === 0) return null;
+
+ let minX = Infinity,
+ minY = Infinity;
+ let maxX = -Infinity,
+ maxY = -Infinity;
+
+ for (const pt of pts) {
+ minX = Math.min(minX, pt.x);
+ minY = Math.min(minY, pt.y);
+ maxX = Math.max(maxX, pt.x);
+ maxY = Math.max(maxY, pt.y);
+ }
+
+ return {
+ left: minX,
+ top: minY,
+ right: maxX,
+ bottom: maxY,
+ };
+ },
+
+ get pointsArray() {
+ const pts = self.isMultiFrame
+ ? self.getShape(self.currentFrame)?.points
+ : self.points;
+ return pts || [];
+ },
+
+ get flatPoints() {
+ const pts = self.pointsArray;
+ const flat = [];
+ for (const pt of pts) {
+ flat.push(pt.x, pt.y);
+ }
+ return flat;
+ },
+ }))
+ .actions((self) => ({
+ setPoints(points) {
+ self.points = points;
+
+ if (self.isMultiFrame) {
+ self.updateShape({ points }, self.currentFrame);
+ }
+ },
+
+ addPoint(point) {
+ const points = [...self.points, point];
+ self.setPoints(points);
+ },
+
+ removePoint(index) {
+ const points = self.points.filter((_, i) => i !== index);
+ self.setPoints(points);
+ },
+
+ updatePoint(index, point) {
+ const points = self.points.map((p, i) => (i === index ? point : p));
+ self.setPoints(points);
+ },
+
+ setClosed(closed) {
+ self.closed = closed;
+
+ if (self.isMultiFrame) {
+ self.updateShape({ closed }, self.currentFrame);
+ }
+ },
+
+ /**
+ * Move the entire polygon by offset
+ */
+ moveBy(dx, dy) {
+ const points = self.points.map((pt) => ({
+ x: pt.x + dx,
+ y: pt.y + dy,
+ }));
+ self.setPoints(points);
+ },
+ }));
+
+const DicomPolygonRegionModel = types.compose(
+ "DicomPolygonRegionModel",
+ RegionsMixin,
+ DicomRegion,
+ AreaMixin,
+ NormalizationMixin,
+ Model,
+);
+
+Registry.addRegionType(DicomPolygonRegionModel, "dicom");
+
+export { DicomPolygonRegionModel };
diff --git a/web/libs/editor/src/regions/DicomRectangleRegion.js b/web/libs/editor/src/regions/DicomRectangleRegion.js
new file mode 100644
index 000000000000..43f1c8e61deb
--- /dev/null
+++ b/web/libs/editor/src/regions/DicomRectangleRegion.js
@@ -0,0 +1,77 @@
+import { types } from "mobx-state-tree";
+
+import NormalizationMixin from "../mixins/Normalization";
+import RegionsMixin from "../mixins/Regions";
+import Registry from "../core/Registry";
+import { AreaMixin } from "../mixins/AreaMixin";
+import { DicomRegion, onlyProps } from "./DicomRegion";
+
+/**
+ * DicomRectangleRegion - Rectangle region with DICOM frame support
+ * Extends DicomRegion with rectangle-specific properties
+ */
+const Model = types
+ .model("DicomRectangleRegionModel", {
+ type: "dicomrectangleregion",
+
+ // Rectangle properties
+ x: types.optional(types.number, 0),
+ y: types.optional(types.number, 0),
+ width: types.optional(types.number, 0),
+ height: types.optional(types.number, 0),
+ rotation: types.optional(types.number, 0),
+ })
+ .volatile(() => ({
+ props: ["x", "y", "width", "height", "rotation"],
+ }))
+ .views((self) => ({
+ get bboxCoords() {
+ const shape = self.isMultiFrame ? self.getShape(self.currentFrame) : self;
+ if (!shape) return null;
+
+ return {
+ left: shape.x,
+ top: shape.y,
+ right: shape.x + shape.width,
+ bottom: shape.y + shape.height,
+ };
+ },
+ }))
+ .actions((self) => ({
+ setPosition(x, y, width, height, rotation) {
+ self.x = x;
+ self.y = y;
+ self.width = width;
+ self.height = height;
+ self.rotation = (rotation + 360) % 360;
+
+ if (self.isMultiFrame) {
+ self.updateShape(
+ { x, y, width, height, rotation: self.rotation },
+ self.currentFrame,
+ );
+ }
+ },
+
+ setSize(width, height) {
+ self.width = width;
+ self.height = height;
+
+ if (self.isMultiFrame) {
+ self.updateShape({ width, height }, self.currentFrame);
+ }
+ },
+ }));
+
+const DicomRectangleRegionModel = types.compose(
+ "DicomRectangleRegionModel",
+ RegionsMixin,
+ DicomRegion,
+ AreaMixin,
+ NormalizationMixin,
+ Model,
+);
+
+Registry.addRegionType(DicomRectangleRegionModel, "dicom");
+
+export { DicomRectangleRegionModel };
diff --git a/web/libs/editor/src/regions/DicomRegion.js b/web/libs/editor/src/regions/DicomRegion.js
new file mode 100644
index 000000000000..400de2e90c3c
--- /dev/null
+++ b/web/libs/editor/src/regions/DicomRegion.js
@@ -0,0 +1,371 @@
+import { types } from "mobx-state-tree";
+
+import { guidGenerator } from "../core/Helpers";
+import { AreaMixin } from "../mixins/AreaMixin";
+import { AnnotationMixin } from "../mixins/AnnotationMixin";
+import NormalizationMixin from "../mixins/Normalization";
+import RegionsMixin from "../mixins/Regions";
+import { DicomModel } from "../tags/object/Dicom/Dicom";
+
+export const onlyProps = (props, obj) => {
+ return Object.fromEntries(props.map((prop) => [prop, obj[prop]]));
+};
+
+/**
+ * DicomRegion mixin for frame-aware annotation support
+ * Each region can have a sequence of keyframes with interpolation between them
+ * Default behavior: annotations stay on single frame
+ */
+const Model = types
+ .model("DicomRegionModel", {
+ id: types.optional(types.identifier, guidGenerator),
+ pid: types.optional(types.string, guidGenerator),
+ object: types.late(() => types.reference(DicomModel)),
+
+ /**
+ * Frame number where this region was created (1-based)
+ * For single-frame annotations, this is the only frame where the region appears
+ */
+ frame: types.optional(types.number, 1),
+
+ /**
+ * Sequence of keyframes for multi-frame regions
+ * Each keyframe contains: { frame, ...shapeProps, enabled }
+ * Empty by default - regions are single-frame unless explicitly extended
+ */
+ sequence: types.frozen([]),
+
+ /**
+ * Whether this region spans multiple frames
+ */
+ isMultiFrame: types.optional(types.boolean, false),
+ })
+ .preProcessSnapshot((snapshot) => {
+ // Handle legacy data or sequence-based data
+ if (snapshot.sequence?.length > 0) {
+ return { ...snapshot, isMultiFrame: true };
+ }
+ // Set frame from value if present
+ if (snapshot.value?.frame !== undefined) {
+ return { ...snapshot, frame: snapshot.value.frame };
+ }
+ return snapshot;
+ })
+ .volatile(() => ({
+ hideable: true,
+ }))
+ .views((self) => ({
+ get parent() {
+ return self.object;
+ },
+
+ /**
+ * Current frame from the parent DICOM object
+ */
+ get currentFrame() {
+ return self.object?.frame ?? 1;
+ },
+
+ /**
+ * Check if this region should be visible on the current frame
+ */
+ get isVisibleOnCurrentFrame() {
+ if (!self.isMultiFrame) {
+ // Single-frame region: only visible on its frame
+ return self.frame === self.currentFrame;
+ }
+ // Multi-frame region: check lifespan
+ return self.isInLifespan(self.currentFrame);
+ },
+
+ /**
+ * Get shape properties for a specific frame
+ * For single-frame regions, returns current props if on correct frame
+ * For multi-frame regions, interpolates between keyframes
+ */
+ getShape(frame) {
+ if (!self.isMultiFrame) {
+ // Single-frame: return null if not on this frame
+ if (frame !== self.frame) return null;
+ return onlyProps(self.props, self);
+ }
+
+ // Multi-frame: find and interpolate
+ let prev;
+ let next;
+
+ for (const item of self.sequence) {
+ if (item.frame === frame) {
+ return onlyProps(self.props, item);
+ }
+
+ if (item.frame > frame) {
+ next = item;
+ break;
+ }
+ prev = item;
+ }
+
+ if (!prev) return null;
+ if (!next) return onlyProps(self.props, prev);
+
+ // Interpolate between prev and next
+ return self.interpolateShape(prev, next, frame);
+ },
+
+ /**
+ * Linear interpolation between two keyframes
+ */
+ interpolateShape(prev, next, frame) {
+ const t = (frame - prev.frame) / (next.frame - prev.frame);
+ const result = {};
+
+ for (const prop of self.props) {
+ if (typeof prev[prop] === "number" && typeof next[prop] === "number") {
+ // Handle rotation specially (shortest path)
+ if (prop === "rotation") {
+ result[prop] = self.interpolateRotation(prev[prop], next[prop], t);
+ } else {
+ result[prop] = prev[prop] + (next[prop] - prev[prop]) * t;
+ }
+ } else {
+ result[prop] = prev[prop];
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Interpolate rotation taking the shortest path
+ */
+ interpolateRotation(from, to, t) {
+ let diff = to - from;
+ if (diff > 180) diff -= 360;
+ if (diff < -180) diff += 360;
+ return (from + diff * t + 360) % 360;
+ },
+
+ getVisibility() {
+ return self.isVisibleOnCurrentFrame;
+ },
+ }))
+ .actions((self) => ({
+ /**
+ * Update shape for a specific frame
+ * For single-frame regions, updates the region props
+ * For multi-frame regions, creates/updates a keyframe
+ */
+ updateShape(data, frame) {
+ if (!self.isMultiFrame) {
+ // Single-frame: just update props if on correct frame
+ if (frame === self.frame) {
+ Object.assign(self, data);
+ }
+ return;
+ }
+
+ // Multi-frame: update or create keyframe
+ const newItem = {
+ ...data,
+ frame,
+ enabled: true,
+ };
+
+ const kp = self.closestKeypoint(frame);
+ const index = self.sequence.findIndex((item) => item.frame >= frame);
+
+ if (index < 0) {
+ self.sequence = [...self.sequence, newItem];
+ } else {
+ const keypoint = {
+ ...(self.sequence[index] ?? {}),
+ ...data,
+ enabled: kp?.enabled ?? true,
+ frame,
+ };
+
+ self.sequence = [
+ ...self.sequence.slice(0, index),
+ keypoint,
+ ...self.sequence.slice(
+ index + (self.sequence[index].frame === frame ? 1 : 0),
+ ),
+ ];
+ }
+ },
+
+ /**
+ * Navigate to this region's frame in the viewer
+ */
+ onSelectInOutliner() {
+ if (self.isMultiFrame && self.sequence.length > 0) {
+ self.object.setFrame(self.sequence[0].frame);
+ } else {
+ self.object.setFrame(self.frame);
+ }
+ },
+
+ /**
+ * Serialize region with frame information
+ */
+ serialize() {
+ const { frameCount } = self.object;
+
+ const value = {
+ frame: self.frame,
+ framesCount: frameCount,
+ };
+
+ if (self.isMultiFrame) {
+ value.sequence = self.sequence;
+ }
+
+ return { value };
+ },
+
+ /**
+ * Toggle visibility at a specific frame (for multi-frame regions)
+ */
+ toggleLifespan(frame) {
+ if (!self.isMultiFrame) return;
+
+ const keypoint = self.closestKeypoint(frame, true);
+
+ if (keypoint) {
+ const index = self.sequence.indexOf(keypoint);
+
+ self.sequence = [
+ ...self.sequence.slice(0, index),
+ { ...keypoint, enabled: !keypoint.enabled },
+ ...self.sequence.slice(index + 1),
+ ];
+ }
+ },
+
+ /**
+ * Add a keyframe at the specified frame
+ */
+ addKeypoint(frame) {
+ if (!self.isMultiFrame) {
+ // Convert to multi-frame region
+ self.convertToMultiFrame();
+ }
+
+ const sequence = Array.from(self.sequence);
+ const closestKeypoint = self.closestKeypoint(frame);
+ const newKeypoint = {
+ ...(self.getShape(frame) ??
+ closestKeypoint ?? {
+ x: self.x || 0,
+ y: self.y || 0,
+ }),
+ enabled: closestKeypoint?.enabled ?? true,
+ frame,
+ };
+
+ sequence.push(newKeypoint);
+ sequence.sort((a, b) => a.frame - b.frame);
+
+ self.sequence = sequence;
+ },
+
+ /**
+ * Remove a keyframe at the specified frame
+ */
+ removeKeypoint(frame) {
+ if (!self.isMultiFrame) return;
+ self.sequence = self.sequence.filter((kp) => kp.frame !== frame);
+ },
+
+ /**
+ * Check if region is visible at the target frame
+ */
+ isInLifespan(targetFrame) {
+ if (!self.isMultiFrame) {
+ return self.frame === targetFrame;
+ }
+
+ const closestKeypoint = self.closestKeypoint(targetFrame);
+
+ if (closestKeypoint) {
+ const { enabled, frame } = closestKeypoint;
+ if (frame === targetFrame && !enabled) return true;
+ return enabled;
+ }
+ return false;
+ },
+
+ /**
+ * Find the closest keypoint at or before the target frame
+ */
+ closestKeypoint(targetFrame, onlyPrevious = false) {
+ if (!self.isMultiFrame) return null;
+
+ const seq = self.sequence;
+ let result;
+
+ const keypoints = seq.filter(({ frame }) => frame <= targetFrame);
+ result = keypoints[keypoints.length - 1];
+
+ if (!result && onlyPrevious !== true) {
+ result = seq.find(({ frame }) => frame >= targetFrame);
+ }
+
+ return result;
+ },
+
+ /**
+ * Convert a single-frame region to multi-frame
+ */
+ convertToMultiFrame() {
+ if (self.isMultiFrame) return;
+
+ const initialKeypoint = {
+ ...onlyProps(self.props, self),
+ frame: self.frame,
+ enabled: true,
+ };
+
+ self.sequence = [initialKeypoint];
+ self.isMultiFrame = true;
+ },
+
+ /**
+ * Copy this region to a range of frames
+ * Creates independent single-frame copies
+ */
+ copyToFrames(startFrame, endFrame) {
+ const results = [];
+ const shapeProps = onlyProps(self.props, self);
+
+ for (let frame = startFrame; frame <= endFrame; frame++) {
+ if (frame !== self.frame) {
+ // This will be implemented by the parent annotation store
+ // as it needs access to createResult
+ }
+ }
+
+ return results;
+ },
+
+ /**
+ * Set the frame for single-frame regions
+ */
+ setFrame(frame) {
+ if (!self.isMultiFrame) {
+ self.frame = frame;
+ }
+ },
+ }));
+
+const DicomRegion = types.compose(
+ "DicomRegionModel",
+ RegionsMixin,
+ AreaMixin,
+ AnnotationMixin,
+ NormalizationMixin,
+ Model,
+);
+
+export { DicomRegion };
diff --git a/web/libs/editor/src/regions/index.js b/web/libs/editor/src/regions/index.js
index 01fb771ef6e2..db2829810c2c 100644
--- a/web/libs/editor/src/regions/index.js
+++ b/web/libs/editor/src/regions/index.js
@@ -17,6 +17,12 @@ import { TimelineRegionModel } from "./TimelineRegion";
import { VideoRectangleRegionModel } from "./VideoRectangleRegion";
import { CustomRegionModel } from "./CustomRegion";
+// DICOM region models
+import { DicomRegion } from "./DicomRegion";
+import { DicomBrushRegionModel } from "./DicomBrushRegion";
+import { DicomPolygonRegionModel } from "./DicomPolygonRegion";
+import { DicomRectangleRegionModel } from "./DicomRectangleRegion";
+
const AllRegionsType = types.union(
AudioRegionModel,
BrushRegionModel,
@@ -34,6 +40,10 @@ const AllRegionsType = types.union(
ParagraphsRegionModel,
VideoRectangleRegionModel,
CustomRegionModel,
+ // DICOM regions
+ DicomBrushRegionModel,
+ DicomPolygonRegionModel,
+ DicomRectangleRegionModel,
...Registry.customTags.map((t) => t.region).filter(Boolean),
);
@@ -63,4 +73,9 @@ export {
TimelineRegionModel,
VideoRectangleRegionModel,
CustomRegionModel,
+ // DICOM regions
+ DicomRegion,
+ DicomBrushRegionModel,
+ DicomPolygonRegionModel,
+ DicomRectangleRegionModel,
};
diff --git a/web/libs/editor/src/tags/control/DicomBrushLabels.jsx b/web/libs/editor/src/tags/control/DicomBrushLabels.jsx
new file mode 100644
index 000000000000..d2891262873f
--- /dev/null
+++ b/web/libs/editor/src/tags/control/DicomBrushLabels.jsx
@@ -0,0 +1,64 @@
+import { observer } from "mobx-react";
+import { types } from "mobx-state-tree";
+
+import LabelMixin from "../../mixins/LabelMixin";
+import Registry from "../../core/Registry";
+import SelectedModelMixin from "../../mixins/SelectedModel";
+import Types from "../../core/Types";
+import { BrushModel } from "./Brush";
+import { HtxLabels, LabelsModel } from "./Labels/Labels";
+import ControlBase from "./Base";
+
+/**
+ * The `DicomBrushLabels` tag creates brush/segmentation labels for DICOM medical images.
+ * Enables pixel-level segmentation with per-frame annotation support for multi-slice imaging.
+ *
+ * Use with the following data types: DICOM images
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @name DicomBrushLabels
+ * @regions DicomBrushRegion
+ * @meta_title DICOM Brush Labels for Medical Image Segmentation
+ * @meta_description Create pixel-level segmentation masks on DICOM medical images for radiology and pathology annotation tasks.
+ * @param {string} name - Name of the element
+ * @param {string} toName - Name of the DICOM element to label
+ * @param {single|multiple=} [choice=single] - Configure whether the labeler can select one or multiple labels
+ * @param {number} [maxUsages] - Maximum number of times a label can be used per task
+ * @param {boolean} [showInline=true] - Show labels in the same visual line
+ */
+
+const Validation = types.model({
+ controlledTags: Types.unionTag(["Dicom"]),
+});
+
+const ModelAttrs = types.model("DicomBrushLabelsModel", {
+ type: "dicombrushlabels",
+ children: Types.unionArray(["label", "header", "view", "hypertext"]),
+});
+
+const DicomBrushLabelsModel = types.compose(
+ "DicomBrushLabelsModel",
+ ControlBase,
+ LabelsModel,
+ ModelAttrs,
+ BrushModel,
+ Validation,
+ LabelMixin,
+ SelectedModelMixin.props({ _child: "LabelModel" }),
+);
+
+const HtxDicomBrushLabels = observer(({ item }) => {
+ return ;
+});
+
+Registry.addTag("dicombrushlabels", DicomBrushLabelsModel, HtxDicomBrushLabels);
+
+export { HtxDicomBrushLabels, DicomBrushLabelsModel };
diff --git a/web/libs/editor/src/tags/control/DicomPolygonLabels.jsx b/web/libs/editor/src/tags/control/DicomPolygonLabels.jsx
new file mode 100644
index 000000000000..e914ab8c2610
--- /dev/null
+++ b/web/libs/editor/src/tags/control/DicomPolygonLabels.jsx
@@ -0,0 +1,70 @@
+import { observer } from "mobx-react";
+import { types } from "mobx-state-tree";
+
+import LabelMixin from "../../mixins/LabelMixin";
+import Registry from "../../core/Registry";
+import SelectedModelMixin from "../../mixins/SelectedModel";
+import Types from "../../core/Types";
+import { PolygonModel } from "./Polygon";
+import { HtxLabels, LabelsModel } from "./Labels/Labels";
+import ControlBase from "./Base";
+
+/**
+ * The `DicomPolygonLabels` tag creates labeled polygon regions on DICOM medical images.
+ * Ideal for outlining irregular structures like organs, tumors, and anatomical features.
+ *
+ * Use with the following data types: DICOM images
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @name DicomPolygonLabels
+ * @regions DicomPolygonRegion
+ * @meta_title DICOM Polygon Labels for Medical Image Annotation
+ * @meta_description Create labeled polygon regions on DICOM medical images for detailed anatomical structure annotation.
+ * @param {string} name - Name of the element
+ * @param {string} toName - Name of the DICOM element to label
+ * @param {single|multiple=} [choice=single] - Configure whether the labeler can select one or multiple labels
+ * @param {number} [maxUsages] - Maximum number of times a label can be used per task
+ * @param {boolean} [showInline=true] - Show labels in the same visual line
+ * @param {number} [pointSize=8] - Size of polygon points
+ * @param {string} [pointStyle=rectangle] - Style of polygon points (rectangle or circle)
+ */
+
+const Validation = types.model({
+ controlledTags: Types.unionTag(["Dicom"]),
+});
+
+const ModelAttrs = types.model("DicomPolygonLabelsModel", {
+ type: "dicompolygonlabels",
+ children: Types.unionArray(["label", "header", "view", "hypertext"]),
+});
+
+const DicomPolygonLabelsModel = types.compose(
+ "DicomPolygonLabelsModel",
+ ControlBase,
+ LabelsModel,
+ ModelAttrs,
+ PolygonModel,
+ Validation,
+ LabelMixin,
+ SelectedModelMixin.props({ _child: "LabelModel" }),
+);
+
+const HtxDicomPolygonLabels = observer(({ item }) => {
+ return ;
+});
+
+Registry.addTag(
+ "dicompolygonlabels",
+ DicomPolygonLabelsModel,
+ HtxDicomPolygonLabels,
+);
+
+export { HtxDicomPolygonLabels, DicomPolygonLabelsModel };
diff --git a/web/libs/editor/src/tags/control/DicomRectangleLabels.jsx b/web/libs/editor/src/tags/control/DicomRectangleLabels.jsx
new file mode 100644
index 000000000000..f75af79441ec
--- /dev/null
+++ b/web/libs/editor/src/tags/control/DicomRectangleLabels.jsx
@@ -0,0 +1,68 @@
+import { observer } from "mobx-react";
+import { types } from "mobx-state-tree";
+
+import LabelMixin from "../../mixins/LabelMixin";
+import Registry from "../../core/Registry";
+import SelectedModelMixin from "../../mixins/SelectedModel";
+import Types from "../../core/Types";
+import { RectangleModel } from "./Rectangle";
+import { HtxLabels, LabelsModel } from "./Labels/Labels";
+import ControlBase from "./Base";
+
+/**
+ * The `DicomRectangleLabels` tag creates labeled rectangle regions on DICOM medical images.
+ * Supports multi-frame DICOM files with per-frame annotations.
+ *
+ * Use with the following data types: DICOM images
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @name DicomRectangleLabels
+ * @regions DicomRectangleRegion
+ * @meta_title DICOM Rectangle Labels for Medical Image Annotation
+ * @meta_description Create labeled rectangle regions on DICOM medical images for radiology and medical imaging annotation tasks.
+ * @param {string} name - Name of the element
+ * @param {string} toName - Name of the DICOM element to label
+ * @param {single|multiple=} [choice=single] - Configure whether the labeler can select one or multiple labels
+ * @param {number} [maxUsages] - Maximum number of times a label can be used per task
+ * @param {boolean} [showInline=true] - Show labels in the same visual line
+ */
+
+const Validation = types.model({
+ controlledTags: Types.unionTag(["Dicom"]),
+});
+
+const ModelAttrs = types.model("DicomRectangleLabelsModel", {
+ type: "dicomrectanglelabels",
+ children: Types.unionArray(["label", "header", "view", "hypertext"]),
+});
+
+const DicomRectangleLabelsModel = types.compose(
+ "DicomRectangleLabelsModel",
+ ControlBase,
+ LabelsModel,
+ ModelAttrs,
+ RectangleModel,
+ Validation,
+ LabelMixin,
+ SelectedModelMixin.props({ _child: "LabelModel" }),
+);
+
+const HtxDicomRectangleLabels = observer(({ item }) => {
+ return ;
+});
+
+Registry.addTag(
+ "dicomrectanglelabels",
+ DicomRectangleLabelsModel,
+ HtxDicomRectangleLabels,
+);
+
+export { HtxDicomRectangleLabels, DicomRectangleLabelsModel };
diff --git a/web/libs/editor/src/tags/control/index.js b/web/libs/editor/src/tags/control/index.js
index 9461c6f23c48..637a1baf0462 100644
--- a/web/libs/editor/src/tags/control/index.js
+++ b/web/libs/editor/src/tags/control/index.js
@@ -34,6 +34,11 @@ import { RectangleModel } from "./Rectangle";
import { RelationsModel } from "./Relations";
import { RelationModel } from "./Relation";
+// DICOM-specific control tags
+import { DicomBrushLabelsModel } from "./DicomBrushLabels";
+import { DicomPolygonLabelsModel } from "./DicomPolygonLabels";
+import { DicomRectangleLabelsModel } from "./DicomRectangleLabels";
+
export {
ChoicesModel,
DateTimeModel,
@@ -67,4 +72,8 @@ export {
RectangleModel,
RelationsModel,
RelationModel,
+ // DICOM control tags
+ DicomBrushLabelsModel,
+ DicomPolygonLabelsModel,
+ DicomRectangleLabelsModel,
};
diff --git a/web/libs/editor/src/tags/object/Dicom/Dicom.js b/web/libs/editor/src/tags/object/Dicom/Dicom.js
new file mode 100644
index 000000000000..27c5056a2a86
--- /dev/null
+++ b/web/libs/editor/src/tags/object/Dicom/Dicom.js
@@ -0,0 +1,660 @@
+import { getRoot, getType, types } from "mobx-state-tree";
+import React from "react";
+
+import Registry from "../../../core/Registry";
+import { AnnotationMixin } from "../../../mixins/AnnotationMixin";
+import IsReadyMixin from "../../../mixins/IsReadyMixin";
+import { BrushRegionModel } from "../../../regions/BrushRegion";
+import { RectRegionModel } from "../../../regions/RectRegion";
+import { PolygonRegionModel } from "../../../regions/PolygonRegion";
+import { EllipseRegionModel } from "../../../regions/EllipseRegion";
+import { KeyPointRegionModel } from "../../../regions/KeyPointRegion";
+import * as Tools from "../../../tools";
+import ToolsManager from "../../../tools/Manager";
+import { parseValue } from "../../../utils/data";
+import { guidGenerator } from "../../../utils/unique";
+import { clamp, isDefined } from "../../../utils/utilities";
+import ObjectBase from "../Base";
+import { DicomEntityMixin } from "./DicomEntityMixin";
+
+// Constants for DICOM viewer
+const RELATIVE_STAGE_WIDTH = 100;
+const RELATIVE_STAGE_HEIGHT = 100;
+
+/**
+ * The `Dicom` tag displays DICOM medical images on the labeling interface.
+ * Supports multi-frame DICOM files (CT, MRI slices) with frame navigation.
+ * Zoom and pan are disabled by default to preserve annotation coordinate accuracy.
+ *
+ * Use with the following data types: DICOM images (.dcm, .dicom)
+ *
+ * Annotations are saved as percentages of the original image size (0-100) with frame information.
+ *
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @name Dicom
+ * @meta_title DICOM Tag for Medical Image Labeling
+ * @meta_description Customize Label Studio with the DICOM tag for medical image annotation including CT, MRI, X-ray and other DICOM formats.
+ * @param {string} name - Name of the element
+ * @param {string} value - Data field containing a path or URL to the DICOM file
+ * @param {string} [valueList] - References a variable that holds a list of DICOM file URLs for series
+ * @param {string=} [width=100%] - Viewer width
+ * @param {string=} [maxWidth=750px] - Maximum viewer width
+ * @param {boolean=} [zoom=false] - Enable zooming (disabled by default for coordinate accuracy)
+ * @param {boolean} [zoomControl=false] - Show zoom controls in toolbar (disabled for DICOM)
+ * @param {boolean} [brightnessControl=true] - Show brightness/window level control in toolbar
+ * @param {boolean} [contrastControl=true] - Show contrast/window width control in toolbar
+ * @param {boolean} [crosshair=false] - Show crosshair cursor
+ * @param {number} [defaultWindowCenter=40] - Default DICOM window center value
+ * @param {number} [defaultWindowWidth=400] - Default DICOM window width value
+ */
+const TagAttrs = types.model({
+ value: types.maybeNull(types.string),
+ valuelist: types.maybeNull(types.string),
+ width: types.optional(types.string, "100%"),
+ maxwidth: types.optional(types.string, "100%"),
+ maxheight: types.optional(types.string, "calc(100vh - 194px)"),
+
+ // Zoom/pan disabled by default for coordinate accuracy
+ zoom: types.optional(types.boolean, false),
+ zoomcontrol: types.optional(types.boolean, false),
+
+ // Windowing controls enabled by default for DICOM
+ brightnesscontrol: types.optional(types.boolean, true),
+ contrastcontrol: types.optional(types.boolean, true),
+
+ crosshair: types.optional(types.boolean, false),
+ selectioncontrol: types.optional(types.boolean, true),
+
+ // Default DICOM windowing values
+ defaultwindowcenter: types.optional(types.string, "40"),
+ defaultwindowwidth: types.optional(types.string, "400"),
+
+ // Alignment
+ horizontalalignment: types.optional(
+ types.enumeration(["left", "center", "right"]),
+ "center",
+ ),
+ verticalalignment: types.optional(
+ types.enumeration(["top", "center", "bottom"]),
+ "center",
+ ),
+});
+
+const DICOM_CONSTANTS = {
+ rectangleModel: "RectangleModel",
+ rectangleLabelsModel: "RectangleLabelsModel",
+ brushLabelsModel: "BrushLabelsModel",
+ rectanglelabels: "rectanglelabels",
+ polygonlabels: "polygonlabels",
+ brushlabels: "brushlabels",
+ ellipselabels: "ellipselabels",
+ keypointlabels: "keypointlabels",
+};
+
+const Model = types
+ .model({
+ type: "dicom",
+
+ sizeUpdated: types.optional(types.boolean, false),
+
+ /**
+ * Cursor coordinates
+ */
+ cursorPositionX: types.optional(types.number, 0),
+ cursorPositionY: types.optional(types.number, 0),
+
+ brushControl: types.optional(types.string, "brush"),
+ brushStrokeWidth: types.optional(types.number, 15),
+
+ /**
+ * Mode: drawing, viewing, brush, eraser
+ */
+ mode: types.optional(
+ types.enumeration(["drawing", "viewing", "brush", "eraser"]),
+ "viewing",
+ ),
+
+ /**
+ * Regions on this DICOM image
+ * Each region includes frame information for multi-frame support
+ */
+ regions: types.array(
+ types.union(
+ BrushRegionModel,
+ RectRegionModel,
+ EllipseRegionModel,
+ PolygonRegionModel,
+ KeyPointRegionModel,
+ ),
+ [],
+ ),
+ })
+ .volatile(() => ({
+ currentDicom: 0,
+ supportSuggestions: true,
+ // Zoom is fixed at 1 for DICOM to preserve coordinates
+ zoomScale: 1,
+ zoomingPositionX: 0,
+ zoomingPositionY: 0,
+ stageZoom: 1,
+ stageZoomX: 1,
+ stageZoomY: 1,
+ currentZoom: 1,
+ // Store reference to dwv app
+ dwvAppRef: null,
+ // Container refs
+ containerRef: null,
+ stageRef: null,
+ }))
+ .views((self) => ({
+ get store() {
+ return getRoot(self);
+ },
+
+ /**
+ * Current frame number (1-based)
+ */
+ get frame() {
+ return self.currentDicomEntity?.currentFrame ?? 1;
+ },
+
+ /**
+ * Total frame count
+ */
+ get length() {
+ return self.currentDicomEntity?.frameCount ?? 1;
+ },
+
+ get multiDicom() {
+ return !!self.isMultiItem;
+ },
+
+ get currentItemIndex() {
+ return self.currentDicom;
+ },
+
+ get parsedValue() {
+ return parseValue(self.value, self.store.task.dataObj);
+ },
+
+ get parsedValueList() {
+ return parseValue(self.valuelist, self.store.task.dataObj);
+ },
+
+ get currentSrc() {
+ return self.currentDicomEntity?.src;
+ },
+
+ get usedValue() {
+ return self.multiDicom ? self.valuelist : self.value;
+ },
+
+ get dicoms() {
+ const value = self.parsedValue;
+ if (!value) return [];
+ if (Array.isArray(value)) return value;
+ return [value];
+ },
+
+ get hasStates() {
+ const states = self.states();
+ return states && states.length > 0;
+ },
+
+ /**
+ * Get regions visible on the current frame
+ */
+ get visibleRegions() {
+ return self.regs.filter((region) => {
+ // If region has frame info, check if it matches current frame
+ if (region.frame !== undefined) {
+ return region.frame === self.frame;
+ }
+ // Legacy regions without frame info are always visible
+ return true;
+ });
+ },
+
+ get selectedRegions() {
+ return self.regs.filter((region) => region.inSelection);
+ },
+
+ get selectedShape() {
+ return self.regs.find((r) => r.selected);
+ },
+
+ get suggestions() {
+ return (
+ self.annotation?.regionStore.suggestions.filter(
+ (r) => r.object === self,
+ ) || []
+ );
+ },
+
+ /**
+ * States/labels attached to this DICOM
+ */
+ states() {
+ return self.annotation.toNames.get(self.name);
+ },
+
+ activeStates() {
+ const states = self.states();
+ return (
+ states &&
+ states.filter((s) => s.isSelected && s.type.includes("labels"))
+ );
+ },
+
+ controlButton() {
+ const names = self.states();
+ if (!names || names.length === 0) return;
+
+ let returnedControl = names[0];
+ names.forEach((item) => {
+ if (
+ item.type === DICOM_CONSTANTS.rectanglelabels ||
+ item.type === DICOM_CONSTANTS.brushlabels ||
+ item.type === DICOM_CONSTANTS.ellipselabels
+ ) {
+ returnedControl = item;
+ }
+ });
+
+ return returnedControl;
+ },
+
+ get controlButtonType() {
+ const name = self.controlButton();
+ return getType(name).name;
+ },
+
+ get stageComponentSize() {
+ return {
+ width: self.stageWidth,
+ height: self.stageHeight,
+ };
+ },
+
+ get canvasSize() {
+ return {
+ width: self.naturalWidth * self.stageZoomX,
+ height: self.naturalHeight * self.stageZoomY,
+ };
+ },
+
+ get alignmentOffset() {
+ const offset = { x: 0, y: 0 };
+
+ switch (self.horizontalalignment) {
+ case "center": {
+ offset.x = (self.containerWidth - self.canvasSize.width) / 2;
+ break;
+ }
+ case "right": {
+ offset.x = self.containerWidth - self.canvasSize.width;
+ break;
+ }
+ }
+ switch (self.verticalalignment) {
+ case "center": {
+ offset.y = (self.containerHeight - self.canvasSize.height) / 2;
+ break;
+ }
+ case "bottom": {
+ offset.y = self.containerHeight - self.canvasSize.height;
+ break;
+ }
+ }
+
+ return offset;
+ },
+
+ get maxScale() {
+ return Math.min(
+ self.containerWidth / self.naturalWidth,
+ self.containerHeight / self.naturalHeight,
+ );
+ },
+
+ /**
+ * Layer position for Konva
+ */
+ get layerZoomScalePosition() {
+ return {
+ scaleX: self.zoomScale,
+ scaleY: self.zoomScale,
+ x: self.zoomingPositionX + self.alignmentOffset.x,
+ y: self.zoomingPositionY + self.alignmentOffset.y,
+ };
+ },
+
+ get hasTools() {
+ return !!self.getToolsManager().allTools()?.length;
+ },
+
+ /**
+ * Frame range for timeline
+ */
+ get fullFrameRange() {
+ return { start: 1, end: self.length };
+ },
+ }))
+ .volatile(() => ({
+ manager: null,
+ }))
+ .actions((self) => {
+ const manager = ToolsManager.getInstance({ name: self.name });
+ const env = { manager, control: self, object: self };
+
+ function createDicomEntities() {
+ if (!self.store.task) return;
+
+ self.dicomEntities.clear();
+
+ const parsedValue = self.multiDicom
+ ? self.parsedValueList
+ : self.parsedValue;
+ const idPostfix = self.annotation ? `@${self.annotation.id}` : "";
+
+ if (Array.isArray(parsedValue)) {
+ parsedValue.forEach((src, index) => {
+ self.dicomEntities.push({
+ id: `${self.name}#${index}${idPostfix}`,
+ src,
+ index,
+ windowCenter: Number(self.defaultwindowcenter) || 40,
+ windowWidth: Number(self.defaultwindowwidth) || 400,
+ });
+ });
+ } else if (parsedValue) {
+ self.dicomEntities.push({
+ id: `${self.name}#0${idPostfix}`,
+ src: parsedValue,
+ index: 0,
+ windowCenter: Number(self.defaultwindowcenter) || 40,
+ windowWidth: Number(self.defaultwindowwidth) || 400,
+ });
+ }
+
+ self.setCurrentDicomEntity(0);
+ }
+
+ function afterAttach() {
+ if (!self.annotation) return;
+
+ if (self.selectioncontrol) {
+ manager.addTool(
+ "MoveTool",
+ Tools.Selection.create({}, env),
+ "MoveTool",
+ );
+ }
+
+ // Only add zoom tool if explicitly enabled
+ if (self.zoomcontrol && self.zoom) {
+ manager.addTool(
+ "ZoomPanTool",
+ Tools.Zoom.create({}, env),
+ "ZoomPanTool",
+ );
+ }
+
+ if (self.brightnesscontrol) {
+ manager.addTool(
+ "BrightnessTool",
+ Tools.Brightness.create({}, env),
+ "BrightnessTool",
+ );
+ }
+
+ if (self.contrastcontrol) {
+ manager.addTool(
+ "ContrastTool",
+ Tools.Contrast.create({}, env),
+ "ContrastTool",
+ );
+ }
+
+ createDicomEntities();
+ }
+
+ function getToolsManager() {
+ return manager;
+ }
+
+ return {
+ afterAttach,
+ getToolsManager,
+ };
+ })
+ .actions((self) => ({
+ setCurrentItem(index = 0) {
+ self.setCurrentDicom(index);
+ },
+
+ setCurrentDicom(index = 0) {
+ index = index ?? 0;
+ if (index === self.currentDicom) return;
+
+ self.currentDicom = index;
+ self.currentDicomEntity = self.findDicomEntity(index);
+ },
+
+ /**
+ * Set frame number (1-based)
+ */
+ setFrame(frame) {
+ self.currentDicomEntity?.setCurrentFrame(frame);
+ },
+
+ /**
+ * Navigate to next frame
+ */
+ nextFrame() {
+ self.currentDicomEntity?.nextFrame();
+ },
+
+ /**
+ * Navigate to previous frame
+ */
+ prevFrame() {
+ self.currentDicomEntity?.prevFrame();
+ },
+
+ setPointerPosition({ x, y }) {
+ self.freezeHistory();
+ self.cursorPositionX = x;
+ self.cursorPositionY = y;
+ },
+
+ setBrightnessGrade(value) {
+ self.currentDicomEntity?.setBrightnessGrade(value);
+ },
+
+ setContrastGrade(value) {
+ self.currentDicomEntity?.setContrastGrade(value);
+ },
+
+ setWindowCenter(value) {
+ self.currentDicomEntity?.setWindowCenter(value);
+ },
+
+ setWindowWidth(value) {
+ self.currentDicomEntity?.setWindowWidth(value);
+ },
+
+ applyWindowPreset(preset) {
+ self.currentDicomEntity?.applyWindowPreset(preset);
+ },
+
+ updateBrushControl(arg) {
+ self.brushControl = arg;
+ },
+
+ updateBrushStrokeWidth(arg) {
+ self.brushStrokeWidth = arg;
+ },
+
+ setMode(mode) {
+ self.mode = mode;
+ },
+
+ setContainerRef(ref) {
+ self.containerRef = ref;
+ },
+
+ setStageRef(ref) {
+ self.stageRef = ref;
+ const currentTool = self.getToolsManager().findSelectedTool();
+ currentTool?.updateCursor?.();
+ },
+
+ setDwvAppRef(ref) {
+ self.dwvAppRef = ref;
+ },
+
+ /**
+ * Called when DICOM is loaded to set dimensions
+ */
+ onDicomLoad({ width, height, frameCount, metadata }) {
+ const entity = self.currentDicomEntity;
+ if (!entity) return;
+
+ entity.setNaturalWidth(width);
+ entity.setNaturalHeight(height);
+ entity.setFrameCount(frameCount);
+ entity.setMetadata(metadata);
+ entity.setDicomLoaded(true);
+ entity.setDownloaded(true);
+
+ self._recalculateDicomParams();
+ },
+
+ _recalculateDicomParams() {
+ self.stageWidth = self.naturalWidth * self.stageZoom;
+ self.stageHeight = self.naturalHeight * self.stageZoom;
+ },
+
+ _updateDicomSize({ width, height }) {
+ if (self.naturalWidth === undefined) return;
+
+ if (width > 1 && height > 1) {
+ self.containerWidth = width;
+ self.containerHeight = height;
+
+ const scale = self.maxScale;
+ self.stageZoom = scale;
+ self.stageZoomX = scale;
+ self.stageZoomY = scale;
+
+ self._recalculateDicomParams();
+ }
+
+ self.sizeUpdated = true;
+ },
+
+ /**
+ * Create region with frame information
+ */
+ createRegionWithFrame(regionData) {
+ return {
+ ...regionData,
+ frame: self.frame,
+ };
+ },
+
+ /**
+ * Copy region to adjacent frames
+ */
+ copyRegionToFrames(region, startFrame, endFrame) {
+ const results = [];
+ for (let frame = startFrame; frame <= endFrame; frame++) {
+ if (frame !== region.frame) {
+ const newRegion = self.annotation.createResult(
+ { ...region.serialize().value, frame },
+ region.labeling,
+ region.from_name,
+ self,
+ );
+ results.push(newRegion);
+ }
+ }
+ return results;
+ },
+ }));
+
+// Coordinate calculations for DICOM (fixed 1:1 mapping, no zoom transforms)
+const DicomCoordsCalculations = types.model({}).views((self) => ({
+ canvasToInternalX(n) {
+ const { naturalWidth, stageWidth } = self;
+ return (n / stageWidth) * RELATIVE_STAGE_WIDTH;
+ },
+
+ canvasToInternalY(n) {
+ const { naturalHeight, stageHeight } = self;
+ return (n / stageHeight) * RELATIVE_STAGE_HEIGHT;
+ },
+
+ internalToCanvasX(n) {
+ const { stageWidth } = self;
+ return (n / RELATIVE_STAGE_WIDTH) * stageWidth;
+ },
+
+ internalToCanvasY(n) {
+ const { stageHeight } = self;
+ return (n / RELATIVE_STAGE_HEIGHT) * stageHeight;
+ },
+
+ internalToImageX(n) {
+ const { naturalWidth } = self;
+ return (n / RELATIVE_STAGE_WIDTH) * naturalWidth;
+ },
+
+ internalToImageY(n) {
+ const { naturalHeight } = self;
+ return (n / RELATIVE_STAGE_HEIGHT) * naturalHeight;
+ },
+
+ imageToInternalX(n) {
+ const { naturalWidth } = self;
+ return (n / naturalWidth) * RELATIVE_STAGE_WIDTH;
+ },
+
+ imageToInternalY(n) {
+ const { naturalHeight } = self;
+ return (n / naturalHeight) * RELATIVE_STAGE_HEIGHT;
+ },
+}));
+
+const DicomModel = types.compose(
+ "DicomModel",
+ TagAttrs,
+ ObjectBase,
+ AnnotationMixin,
+ DicomEntityMixin,
+ Model,
+ DicomCoordsCalculations,
+ IsReadyMixin,
+);
+
+export { DicomModel, RELATIVE_STAGE_WIDTH, RELATIVE_STAGE_HEIGHT };
diff --git a/web/libs/editor/src/tags/object/Dicom/Dicom.scss b/web/libs/editor/src/tags/object/Dicom/Dicom.scss
new file mode 100644
index 000000000000..9d884b0fe758
--- /dev/null
+++ b/web/libs/editor/src/tags/object/Dicom/Dicom.scss
@@ -0,0 +1,213 @@
+.dicom-viewer {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+
+ &__controls {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 8px 12px;
+ background: #f5f5f5;
+ border-bottom: 1px solid #e0e0e0;
+ flex-wrap: wrap;
+ }
+
+ &__frame-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ &__frame-btn {
+ padding: 4px 8px;
+ border: 1px solid #d9d9d9;
+ background: white;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+
+ &:hover:not(:disabled) {
+ background: #f0f0f0;
+ border-color: #1890ff;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ &__frame-info {
+ font-size: 13px;
+ color: #333;
+ }
+
+ &__frame-input {
+ width: 60px;
+ padding: 2px 4px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ text-align: center;
+ font-size: 13px;
+
+ &:focus {
+ outline: none;
+ border-color: #1890ff;
+ }
+ }
+
+ &__window-presets {
+ select {
+ padding: 4px 8px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ background: white;
+ font-size: 13px;
+ cursor: pointer;
+
+ &:hover {
+ border-color: #1890ff;
+ }
+
+ &:focus {
+ outline: none;
+ border-color: #1890ff;
+ }
+ }
+ }
+
+ &__metadata {
+ display: flex;
+ gap: 16px;
+ font-size: 12px;
+ color: #666;
+
+ span {
+ padding: 2px 8px;
+ background: #e8e8e8;
+ border-radius: 4px;
+ }
+ }
+
+ &__container {
+ position: relative;
+ background: #000;
+ overflow: hidden;
+ min-height: 400px;
+ }
+
+ &__loading {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ color: white;
+ font-size: 14px;
+
+ .anticon {
+ font-size: 32px;
+ }
+ }
+
+ &__timeline {
+ padding: 12px;
+ background: #f5f5f5;
+ border-top: 1px solid #e0e0e0;
+ }
+
+ &__timeline-slider {
+ width: 100%;
+ height: 8px;
+ cursor: pointer;
+ appearance: none;
+ background: #d9d9d9;
+ border-radius: 4px;
+ outline: none;
+
+ &::-webkit-slider-thumb {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ background: #1890ff;
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid white;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ }
+
+ &::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ background: #1890ff;
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid white;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ }
+
+ &:hover {
+ &::-webkit-slider-thumb {
+ background: #40a9ff;
+ }
+ &::-moz-range-thumb {
+ background: #40a9ff;
+ }
+ }
+ }
+}
+
+/* Dark mode support */
+.lsf-dark {
+ .dicom-viewer {
+ &__controls {
+ background: #2d2d2d;
+ border-color: #404040;
+ }
+
+ &__frame-btn {
+ background: #3d3d3d;
+ border-color: #555;
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: #4d4d4d;
+ }
+ }
+
+ &__frame-info {
+ color: #ddd;
+ }
+
+ &__frame-input {
+ background: #3d3d3d;
+ border-color: #555;
+ color: #fff;
+ }
+
+ &__window-presets select {
+ background: #3d3d3d;
+ border-color: #555;
+ color: #fff;
+ }
+
+ &__metadata span {
+ background: #3d3d3d;
+ color: #aaa;
+ }
+
+ &__timeline {
+ background: #2d2d2d;
+ border-color: #404040;
+ }
+
+ &__timeline-slider {
+ background: #555;
+ }
+ }
+}
diff --git a/web/libs/editor/src/tags/object/Dicom/DicomEntity.js b/web/libs/editor/src/tags/object/Dicom/DicomEntity.js
new file mode 100644
index 000000000000..5276497094d3
--- /dev/null
+++ b/web/libs/editor/src/tags/object/Dicom/DicomEntity.js
@@ -0,0 +1,237 @@
+import { types, getParent } from "mobx-state-tree";
+
+/**
+ * DicomEntity represents a single DICOM file or series with multiple frames
+ * Handles frame-based navigation and DICOM-specific metadata
+ */
+export const DicomEntity = types
+ .model("DicomEntity", {
+ id: types.identifier,
+ src: types.string,
+ index: types.number,
+
+ /**
+ * Natural sizes of DICOM image
+ */
+ naturalWidth: types.optional(types.integer, 512),
+ naturalHeight: types.optional(types.integer, 512),
+
+ stageWidth: types.optional(types.number, 512),
+ stageHeight: types.optional(types.number, 512),
+
+ /**
+ * Current frame index (1-based for consistency with Video tag)
+ */
+ currentFrame: types.optional(types.number, 1),
+
+ /**
+ * Total number of frames in the DICOM file/series
+ */
+ frameCount: types.optional(types.number, 1),
+
+ /**
+ * DICOM Window Center (for windowing/contrast)
+ */
+ windowCenter: types.optional(types.number, 40),
+
+ /**
+ * DICOM Window Width (for windowing/brightness)
+ */
+ windowWidth: types.optional(types.number, 400),
+
+ /**
+ * Brightness grade (0-200, 100 is default)
+ */
+ brightnessGrade: types.optional(types.number, 100),
+
+ /**
+ * Contrast grade (0-200, 100 is default)
+ */
+ contrastGrade: types.optional(types.number, 100),
+ })
+ .volatile(() => ({
+ stageRatio: 1,
+ containerWidth: 1,
+ containerHeight: 1,
+
+ /** Is DICOM file downloaded */
+ downloaded: false,
+ /** Is DICOM file being downloaded */
+ downloading: false,
+ /** If error happened during download */
+ error: false,
+ /** Download progress 0..1 */
+ progress: 0,
+ /** Is DICOM loaded and parsed */
+ dicomLoaded: false,
+
+ /** DICOM metadata extracted from tags */
+ metadata: {},
+
+ /** dwv App instance reference */
+ dwvApp: null,
+
+ /** Pixel data for current frame (for Konva rendering) */
+ frameImageData: null,
+ }))
+ .views((self) => ({
+ get parent() {
+ return getParent(self, 2);
+ },
+
+ get isMultiFrame() {
+ return self.frameCount > 1;
+ },
+
+ /**
+ * Get DICOM metadata value by tag name
+ */
+ getMetadata(tagName) {
+ return self.metadata[tagName] ?? null;
+ },
+
+ get patientId() {
+ return self.metadata.PatientID ?? "";
+ },
+
+ get patientName() {
+ return self.metadata.PatientName ?? "";
+ },
+
+ get studyDate() {
+ return self.metadata.StudyDate ?? "";
+ },
+
+ get modality() {
+ return self.metadata.Modality ?? "";
+ },
+
+ get seriesDescription() {
+ return self.metadata.SeriesDescription ?? "";
+ },
+ }))
+ .actions((self) => ({
+ setNaturalWidth(value) {
+ self.naturalWidth = value;
+ },
+
+ setNaturalHeight(value) {
+ self.naturalHeight = value;
+ },
+
+ setStageWidth(value) {
+ self.stageWidth = value;
+ },
+
+ setStageHeight(value) {
+ self.stageHeight = value;
+ },
+
+ setStageRatio(value) {
+ self.stageRatio = value;
+ },
+
+ setContainerWidth(value) {
+ self.containerWidth = value;
+ },
+
+ setContainerHeight(value) {
+ self.containerHeight = value;
+ },
+
+ setDownloaded(value) {
+ self.downloaded = value;
+ },
+
+ setDownloading(value) {
+ self.downloading = value;
+ },
+
+ setError(value) {
+ self.error = value;
+ },
+
+ setProgress(value) {
+ self.progress = value;
+ },
+
+ setDicomLoaded(value) {
+ self.dicomLoaded = value;
+ },
+
+ setMetadata(metadata) {
+ self.metadata = metadata;
+ },
+
+ setDwvApp(app) {
+ self.dwvApp = app;
+ },
+
+ setFrameCount(count) {
+ self.frameCount = count;
+ },
+
+ setCurrentFrame(frame) {
+ const clampedFrame = Math.max(1, Math.min(frame, self.frameCount));
+ self.currentFrame = clampedFrame;
+ },
+
+ setWindowCenter(value) {
+ self.windowCenter = value;
+ },
+
+ setWindowWidth(value) {
+ self.windowWidth = value;
+ },
+
+ setBrightnessGrade(value) {
+ self.brightnessGrade = value;
+ },
+
+ setContrastGrade(value) {
+ self.contrastGrade = value;
+ },
+
+ setFrameImageData(imageData) {
+ self.frameImageData = imageData;
+ },
+
+ /**
+ * Navigate to next frame
+ */
+ nextFrame() {
+ if (self.currentFrame < self.frameCount) {
+ self.setCurrentFrame(self.currentFrame + 1);
+ }
+ },
+
+ /**
+ * Navigate to previous frame
+ */
+ prevFrame() {
+ if (self.currentFrame > 1) {
+ self.setCurrentFrame(self.currentFrame - 1);
+ }
+ },
+
+ /**
+ * Apply windowing preset
+ */
+ applyWindowPreset(preset) {
+ const presets = {
+ // Common CT presets
+ bone: { center: 300, width: 1500 },
+ lung: { center: -600, width: 1500 },
+ abdomen: { center: 40, width: 400 },
+ brain: { center: 40, width: 80 },
+ // Common MRI presets
+ t1: { center: 500, width: 1000 },
+ t2: { center: 400, width: 800 },
+ };
+
+ if (presets[preset]) {
+ self.setWindowCenter(presets[preset].center);
+ self.setWindowWidth(presets[preset].width);
+ }
+ },
+ }));
diff --git a/web/libs/editor/src/tags/object/Dicom/DicomEntityMixin.js b/web/libs/editor/src/tags/object/Dicom/DicomEntityMixin.js
new file mode 100644
index 000000000000..790c06062da3
--- /dev/null
+++ b/web/libs/editor/src/tags/object/Dicom/DicomEntityMixin.js
@@ -0,0 +1,172 @@
+import { isAlive, types } from "mobx-state-tree";
+import { DicomEntity } from "./DicomEntity";
+
+/**
+ * Mixin that provides DicomEntity management for the Dicom tag
+ * Handles multiple DICOM files (series) and entity lifecycle
+ */
+export const DicomEntityMixin = types
+ .model({
+ currentDicomEntity: types.maybeNull(types.reference(DicomEntity)),
+ dicomEntities: types.optional(types.array(DicomEntity), []),
+ })
+ .actions((self) => ({
+ beforeDestroy() {
+ self.currentDicomEntity = null;
+ },
+ }))
+ .views((self) => ({
+ get maxItemIndex() {
+ return self.dicomEntities.length - 1;
+ },
+
+ get dicomIsLoaded() {
+ const entity = self.currentDicomEntity;
+ return (
+ !entity?.downloading &&
+ !entity?.error &&
+ entity?.downloaded &&
+ entity?.dicomLoaded
+ );
+ },
+
+ get naturalWidth() {
+ return self.currentDicomEntity?.naturalWidth;
+ },
+ set naturalWidth(value) {
+ self.currentDicomEntity?.setNaturalWidth(value);
+ },
+
+ get naturalHeight() {
+ return self.currentDicomEntity?.naturalHeight;
+ },
+ set naturalHeight(value) {
+ self.currentDicomEntity?.setNaturalHeight(value);
+ },
+
+ get stageWidth() {
+ return self.currentDicomEntity?.stageWidth;
+ },
+ set stageWidth(value) {
+ self.currentDicomEntity?.setStageWidth(value);
+ },
+
+ get stageHeight() {
+ return self.currentDicomEntity?.stageHeight;
+ },
+ set stageHeight(value) {
+ self.currentDicomEntity?.setStageHeight(value);
+ },
+
+ get stageRatio() {
+ return self.currentDicomEntity?.stageRatio;
+ },
+ set stageRatio(value) {
+ self.currentDicomEntity?.setStageRatio(value);
+ },
+
+ get containerWidth() {
+ return self.currentDicomEntity?.containerWidth;
+ },
+ set containerWidth(value) {
+ self.currentDicomEntity?.setContainerWidth(value);
+ },
+
+ get containerHeight() {
+ return self.currentDicomEntity?.containerHeight;
+ },
+ set containerHeight(value) {
+ self.currentDicomEntity?.setContainerHeight(value);
+ },
+
+ get currentFrame() {
+ return self.currentDicomEntity?.currentFrame ?? 1;
+ },
+
+ get frameCount() {
+ return self.currentDicomEntity?.frameCount ?? 1;
+ },
+
+ get isMultiFrame() {
+ return self.frameCount > 1;
+ },
+
+ get windowCenter() {
+ return self.currentDicomEntity?.windowCenter ?? 40;
+ },
+
+ get windowWidth() {
+ return self.currentDicomEntity?.windowWidth ?? 400;
+ },
+
+ get brightnessGrade() {
+ return self.currentDicomEntity?.brightnessGrade ?? 100;
+ },
+
+ get contrastGrade() {
+ return self.currentDicomEntity?.contrastGrade ?? 100;
+ },
+
+ get metadata() {
+ return self.currentDicomEntity?.metadata ?? {};
+ },
+
+ /**
+ * Get frame-specific data for export to task data
+ */
+ get dicomDataForTask() {
+ if (!isAlive(self) || !self.currentDicomEntity) return {};
+
+ const entity = self.currentDicomEntity;
+ return {
+ dicom_patient_id: entity.patientId,
+ dicom_patient_name: entity.patientName,
+ dicom_study_date: entity.studyDate,
+ dicom_modality: entity.modality,
+ dicom_series_description: entity.seriesDescription,
+ dicom_frame_count: entity.frameCount,
+ dicom_current_frame: entity.currentFrame,
+ };
+ },
+ }))
+ .actions((self) => ({
+ findDicomEntity(index) {
+ return self.dicomEntities.find((entity) => entity.index === index);
+ },
+
+ setCurrentDicomEntity(index) {
+ self.currentDicomEntity = self.findDicomEntity(index);
+ },
+
+ setFrame(frame) {
+ self.currentDicomEntity?.setCurrentFrame(frame);
+ },
+
+ nextFrame() {
+ self.currentDicomEntity?.nextFrame();
+ },
+
+ prevFrame() {
+ self.currentDicomEntity?.prevFrame();
+ },
+
+ setWindowCenter(value) {
+ self.currentDicomEntity?.setWindowCenter(value);
+ },
+
+ setWindowWidth(value) {
+ self.currentDicomEntity?.setWindowWidth(value);
+ },
+
+ setBrightnessGrade(value) {
+ self.currentDicomEntity?.setBrightnessGrade(value);
+ },
+
+ setContrastGrade(value) {
+ self.currentDicomEntity?.setContrastGrade(value);
+ },
+
+ applyWindowPreset(preset) {
+ self.currentDicomEntity?.applyWindowPreset(preset);
+ },
+ }));
diff --git a/web/libs/editor/src/tags/object/Dicom/HtxDicom.jsx b/web/libs/editor/src/tags/object/Dicom/HtxDicom.jsx
new file mode 100644
index 000000000000..380b33b36806
--- /dev/null
+++ b/web/libs/editor/src/tags/object/Dicom/HtxDicom.jsx
@@ -0,0 +1,447 @@
+import { observer } from "mobx-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Layer, Stage, Rect } from "react-konva";
+
+import { Block, Elem } from "../../../utils/bem";
+import ObjectTag from "../../../components/Tags/Object";
+import { ErrorMessage } from "../../../components/ErrorMessage/ErrorMessage";
+import { Toolbar } from "../../../components/Toolbar/Toolbar";
+import ResizeObserver from "../../../utils/resize-observer";
+import { debounce } from "../../../utils/debounce";
+import Tree from "../../../core/Tree";
+import { LoadingOutlined } from "@ant-design/icons";
+
+import "./Dicom.scss";
+
+// Frame navigation controls component
+const FrameControls = observer(({ item }) => {
+ const handlePrevFrame = useCallback(() => {
+ item.prevFrame();
+ }, [item]);
+
+ const handleNextFrame = useCallback(() => {
+ item.nextFrame();
+ }, [item]);
+
+ const handleFrameChange = useCallback(
+ (e) => {
+ const frame = parseInt(e.target.value, 10);
+ if (!isNaN(frame)) {
+ item.setFrame(frame);
+ }
+ },
+ [item],
+ );
+
+ if (!item.isMultiFrame) return null;
+
+ return (
+
+
+
+ Frame{" "}
+
+ {" / "}
+ {item.length}
+
+
+
+ );
+});
+
+// Window presets dropdown
+const WindowPresets = observer(({ item }) => {
+ const handlePresetChange = useCallback(
+ (e) => {
+ const preset = e.target.value;
+ if (preset) {
+ item.applyWindowPreset(preset);
+ }
+ },
+ [item],
+ );
+
+ return (
+
+
+
+ );
+});
+
+// DICOM metadata display
+const DicomMetadata = observer(({ item }) => {
+ const metadata = item.metadata || {};
+
+ if (Object.keys(metadata).length === 0) return null;
+
+ return (
+
+ {metadata.PatientID && Patient: {metadata.PatientID}}
+ {metadata.Modality && Modality: {metadata.Modality}}
+ {metadata.StudyDate && Study: {metadata.StudyDate}}
+
+ );
+});
+
+// Region rendering for current frame
+const DicomRegions = observer(({ item, width, height }) => {
+ const regions = item.visibleRegions;
+
+ return (
+ <>
+ {regions.map((region) => (
+
+ ))}
+ >
+ );
+});
+
+// Main DICOM viewer component
+export const HtxDicomView = observer(({ item, store }) => {
+ const containerRef = useRef(null);
+ const canvasRef = useRef(null);
+ const stageRef = useRef(null);
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [dicomImage, setDicomImage] = useState(null);
+
+ // Initialize dwv app
+ const dwvAppRef = useRef(null);
+
+ // Handle container resize
+ const handleResize = useMemo(() => {
+ return debounce((entries) => {
+ const entry = entries[0];
+ if (entry) {
+ const { width, height } = entry.contentRect;
+ setContainerSize({ width, height });
+ item._updateDicomSize({ width, height });
+ }
+ }, 100);
+ }, [item]);
+
+ // Set up resize observer
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const observer = new ResizeObserver(handleResize);
+ observer.observe(container);
+
+ // Initial size
+ const rect = container.getBoundingClientRect();
+ setContainerSize({ width: rect.width, height: rect.height });
+ item._updateDicomSize({ width: rect.width, height: rect.height });
+
+ return () => observer.disconnect();
+ }, [handleResize, item]);
+
+ // Load DICOM file using dwv
+ useEffect(() => {
+ const loadDicom = async () => {
+ const src = item.currentSrc;
+ if (!src) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ // Dynamic import of dwv to handle potential loading issues
+ const dwv = await import("dwv");
+
+ // Initialize dwv application
+ const app = new dwv.App();
+ dwvAppRef.current = app;
+ item.setDwvAppRef(app);
+
+ // Configure dwv - disable tools we don't want
+ app.init({
+ dataViewConfigs: { "*": [{ divId: `dwv-container-${item.name}` }] },
+ tools: {
+ // Only enable draw tool for annotations, disable zoom/pan
+ Draw: {},
+ },
+ });
+
+ // Handle load events
+ app.addEventListener("loadstart", () => {
+ setLoading(true);
+ });
+
+ app.addEventListener("loadend", () => {
+ setLoading(false);
+
+ // Get image dimensions and metadata
+ const image = app.getImage(0);
+ if (image) {
+ const geometry = image.getGeometry();
+ const size = geometry.getSize();
+ const width = size.get(0);
+ const height = size.get(1);
+ const frameCount = size.get(2) || 1;
+
+ // Extract DICOM metadata
+ const metaData = app.getMetaData(0);
+ const metadata = {
+ PatientID: metaData["00100020"]?.value?.[0] || "",
+ PatientName: metaData["00100010"]?.value?.[0] || "",
+ StudyDate: metaData["00080020"]?.value?.[0] || "",
+ Modality: metaData["00080060"]?.value?.[0] || "",
+ SeriesDescription: metaData["0008103E"]?.value?.[0] || "",
+ };
+
+ item.onDicomLoad({ width, height, frameCount, metadata });
+
+ // Get rendered image data for Konva overlay
+ updateCanvasImage(app);
+ }
+ });
+
+ app.addEventListener("error", (event) => {
+ setError(event.error || "Failed to load DICOM file");
+ setLoading(false);
+ });
+
+ // Load the DICOM file
+ app.loadURLs([src]);
+ } catch (err) {
+ console.error("Failed to load dwv:", err);
+ setError("Failed to initialize DICOM viewer");
+ setLoading(false);
+ }
+ };
+
+ loadDicom();
+
+ return () => {
+ if (dwvAppRef.current) {
+ dwvAppRef.current.reset();
+ dwvAppRef.current = null;
+ }
+ };
+ }, [item.currentSrc, item.name]);
+
+ // Update canvas when frame changes
+ useEffect(() => {
+ if (dwvAppRef.current && item.dicomIsLoaded) {
+ const app = dwvAppRef.current;
+ // Set the frame in dwv
+ if (item.isMultiFrame) {
+ app.setFrameIndex?.(item.frame - 1); // dwv uses 0-based index
+ }
+ updateCanvasImage(app);
+ }
+ }, [item.frame, item.dicomIsLoaded]);
+
+ // Update canvas when windowing changes
+ useEffect(() => {
+ if (dwvAppRef.current && item.dicomIsLoaded) {
+ const app = dwvAppRef.current;
+ // Apply windowing
+ const wc = item.windowCenter;
+ const ww = item.windowWidth;
+ app.setWindowLevelPreset?.({ center: wc, width: ww });
+ updateCanvasImage(app);
+ }
+ }, [item.windowCenter, item.windowWidth, item.dicomIsLoaded]);
+
+ // Extract rendered image from dwv canvas
+ const updateCanvasImage = useCallback(
+ (app) => {
+ try {
+ const layerGroup = app.getLayerGroupByDivId?.(
+ `dwv-container-${item.name}`,
+ );
+ if (layerGroup) {
+ const viewLayer = layerGroup.getActiveViewLayer?.();
+ if (viewLayer) {
+ const canvas = viewLayer.getCanvas?.();
+ if (canvas) {
+ // Create image from canvas for Konva
+ const imageObj = new Image();
+ imageObj.src = canvas.toDataURL();
+ imageObj.onload = () => {
+ setDicomImage(imageObj);
+ };
+ }
+ }
+ }
+ } catch (err) {
+ console.warn("Failed to extract DICOM image:", err);
+ }
+ },
+ [item.name],
+ );
+
+ // Keyboard shortcuts for frame navigation
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (!item.isMultiFrame) return;
+
+ switch (e.key) {
+ case "ArrowLeft":
+ e.preventDefault();
+ item.prevFrame();
+ break;
+ case "ArrowRight":
+ e.preventDefault();
+ item.nextFrame();
+ break;
+ case "Home":
+ e.preventDefault();
+ item.setFrame(1);
+ break;
+ case "End":
+ e.preventDefault();
+ item.setFrame(item.length);
+ break;
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [item]);
+
+ // Set refs
+ useEffect(() => {
+ item.setContainerRef(containerRef.current);
+ if (stageRef.current) {
+ item.setStageRef(stageRef.current);
+ }
+ }, [item]);
+
+ if (!item.currentSrc) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ const { width, height } = containerSize;
+ const stageWidth = item.stageWidth || width;
+ const stageHeight = item.stageHeight || height;
+
+ return (
+
+
+ {/* Controls bar */}
+
+
+
+
+
+
+ {/* Main viewer container */}
+
+ {loading && (
+
+
+ Loading DICOM...
+
+ )}
+
+ {/* Hidden dwv container for DICOM parsing */}
+
+
+ {/* Konva stage for annotations */}
+ {!loading && item.dicomIsLoaded && (
+
+ {/* Background layer with DICOM image */}
+
+ {dicomImage && (
+
+ )}
+
+
+ {/* Regions layer */}
+
+
+
+
+ )}
+
+ {/* Toolbar */}
+ {item.hasTools && }
+
+
+ {/* Frame timeline for multi-frame DICOM */}
+ {item.isMultiFrame && (
+
+ item.setFrame(parseInt(e.target.value, 10))}
+ className="dicom-viewer__timeline-slider"
+ />
+
+ )}
+
+
+ );
+});
diff --git a/web/libs/editor/src/tags/object/Dicom/index.js b/web/libs/editor/src/tags/object/Dicom/index.js
new file mode 100644
index 000000000000..ebed5438a32e
--- /dev/null
+++ b/web/libs/editor/src/tags/object/Dicom/index.js
@@ -0,0 +1,12 @@
+import { inject, observer } from "mobx-react";
+import Registry from "../../../core/Registry";
+
+import { HtxDicomView } from "./HtxDicom";
+import { DicomModel } from "./Dicom";
+
+const HtxDicom = inject("store")(observer(HtxDicomView));
+
+Registry.addTag("dicom", DicomModel, HtxDicom);
+Registry.addObjectType(DicomModel);
+
+export { DicomModel, HtxDicom };
diff --git a/web/libs/editor/src/tags/object/index.js b/web/libs/editor/src/tags/object/index.js
index 5993eac066e6..98caaf00249f 100644
--- a/web/libs/editor/src/tags/object/index.js
+++ b/web/libs/editor/src/tags/object/index.js
@@ -1,4 +1,5 @@
import { AudioModel } from "./Audio";
+import { DicomModel } from "./Dicom";
import { ImageModel } from "./Image";
import { ParagraphsModel } from "./Paragraphs";
import { PdfModel } from "./Pdf";
@@ -15,6 +16,7 @@ import "./Text";
export {
AudioModel,
+ DicomModel,
ImageModel,
ParagraphsModel,
TimeSeriesModel,