diff --git a/build-and-run.sh b/build-and-run.sh new file mode 100755 index 000000000..f23a29e72 --- /dev/null +++ b/build-and-run.sh @@ -0,0 +1,8 @@ +mvn install -pl weasis-dicom/weasis-dicom-viewer2d -am -DskipTests -q +cd weasis-distributions && mvn clean package -DskipTests -q +pkill -f "AppLauncher" || true; sleep 1 +rm -rf /tmp/weasis-build; mkdir -p /tmp/weasis-build +unzip -o target/native-dist/weasis-native.zip -d /tmp/weasis-build/ > /dev/null +export JAVA_HOME=$(brew --prefix openjdk)/libexec/openjdk.jdk/Contents/Home +cd /tmp/weasis-build/bin-dist/weasis +$JAVA_HOME/bin/java -cp weasis-launcher.jar:felix.jar org.weasis.launcher.AppLauncher >weasis-stdout.log 2>weasis-stderr.log & diff --git a/docs/CURVED_MPR_IMPLEMENTATION_SPEC.md b/docs/CURVED_MPR_IMPLEMENTATION_SPEC.md new file mode 100644 index 000000000..6fa2b8826 --- /dev/null +++ b/docs/CURVED_MPR_IMPLEMENTATION_SPEC.md @@ -0,0 +1,166 @@ +# Curved Multi-Planar Reconstruction (cMPR) Feature Specification + +## Overview + +The Curved Multi-Planar Reconstruction (cMPR) feature enables users to draw a curved path through a 3D medical volume and generate a straightened panoramic view. This is particularly useful for visualizing curved anatomical structures such as blood vessels, airways, or bone surfaces in a single, easy-to-analyze 2D image. + +### Key Capabilities + +- **Curve Drawing**: Draw arbitrary curves in any MPR plane (axial, coronal, sagittal, or oblique) +- **3D Path Definition**: Automatically converts 2D curve points to 3D volume coordinates +- **Panoramic Generation**: Creates a straightened 2D view by sampling the volume along the curved path +- **Configurable Parameters**: Adjust the width and sampling resolution of the panoramic view +- **Real-time Updates**: Modify parameters and see updated panoramic views immediately +- **Standard Image Tools**: All standard viewing tools (zoom, pan, window/level, measurements) work on the panoramic view + +## User Workflow + +### Creating a Curved MPR View + +1. **Open an MPR View**: Start with any standard MPR view of a 3D volume +2. **Activate Curve Drawing**: Select the curved MPR drawing tool from the toolbar +3. **Draw the Curve**: Click to place points along the desired anatomical path +4. **Complete the Curve**: Double-click or right-click to finish drawing +5. **Generate Panoramic View**: The curved MPR view automatically opens, showing the straightened panoramic view + +### Using the Panoramic View + +The panoramic view displays the volume data sampled perpendicular to the drawn curve: + +- **Horizontal Axis**: Represents distance along the curve path +- **Vertical Axis**: Represents the width perpendicular to the curve +- **Default Width**: 40mm (adjustable) +- **Default Sampling**: Uses the volume's native resolution + +### Adjusting Parameters + +Users can modify the following parameters: + +- **Width**: Controls how far perpendicular to the curve the view extends (e.g., 20mm to 100mm) +- **Sampling Step**: Controls the resolution along the curve (smaller steps = higher resolution) +- Changes to these parameters regenerate the panoramic view in real-time + +## Feature Components + +### Curve Drawing Tool + +A specialized drawing tool that: +- Allows freehand polyline drawing in any 2D MPR plane +- Stores 2D screen coordinates that are converted to 3D volume coordinates +- Provides context menu options for generating the curved MPR view +- Validates that curves have sufficient points (minimum 2) + +### Path Definition + +The curve defines a 3D path through the volume: +- Points are converted from 2D screen coordinates to 3D voxel coordinates +- The path is stored in volume coordinate space for consistency +- The source plane's orientation is preserved for proper perpendicular sampling + +### Panoramic Image Generation + +The panoramic view is generated by: + +1. **Curve Resampling**: The drawn curve is resampled at uniform intervals along its arc length +2. **Perpendicular Sampling**: At each point along the curve, the volume is sampled perpendicular to the curve direction +3. **Image Construction**: Samples are assembled into a 2D panoramic image +4. **Metadata Assignment**: DICOM metadata is assigned for proper display and measurements + +### Sampling Algorithm + +The generation process uses the following approach: + +- **Arc-Length Parameterization**: Ensures uniform sampling along curved paths +- **Tangent Computation**: Calculates the direction of the curve at each sample point +- **Perpendicular Direction**: Computes a direction perpendicular to both the curve tangent and the original plane normal +- **Volume Interpolation**: Uses trilinear interpolation for smooth sampling +- **Boundary Handling**: Returns background values for samples outside the volume + +### Viewer Integration + +The curved MPR viewer integrates with the existing viewing system: + +- **Standard 2D View**: Uses the same 2D viewer infrastructure as other views +- **Image Tools**: Supports zoom, pan, window/level adjustments +- **Measurements**: Distance and angle measurements can be performed on the panoramic view +- **Layout Options**: Opens in a separate viewer window or panel + +## Technical Characteristics + +### Coordinate Systems + +- **Input Coordinates**: 2D screen coordinates in the source MPR plane +- **Working Coordinates**: 3D voxel coordinates in volume space +- **Output Coordinates**: 2D image coordinates in the panoramic view + +### Image Properties + +- **Image Type**: Synthetic DICOM-like image +- **Width**: Determined by curve length and sampling step +- **Height**: Determined by the configurable width parameter +- **Pixel Spacing**: Matches the volume's native resolution +- **Intensity Range**: Matches the source volume's intensity range + +### Performance Considerations + +- **Default Parameters**: Chosen for good performance on typical volumes +- **Resolution Limits**: Width and step parameters can be adjusted to balance quality and speed +- **Caching**: Generated images are cached to avoid unnecessary regeneration +- **Background Sampling**: Out-of-bounds samples are handled efficiently + +## Use Cases + +### Vessel Analysis + +- Visualize curved blood vessels in a single straightened view +- Measure vessel diameter along its length +- Assess stenosis or aneurysms along tortuous paths + +### Airway Evaluation + +- Examine bronchial tubes or other tubular structures +- Follow airway paths through branching regions +- Assess wall thickness or abnormalities + +### Bone Surface Analysis + +- Follow curved bone surfaces +- Analyze cortical thickness along curved paths +- Visualize fracture lines or deformities + +### General Path Following + +- Any scenario where a curved anatomical structure needs to be visualized linearly +- Reduces cognitive load by straightening complex 3D paths +- Facilitates comparison of different regions along the same structure + +## Limitations and Constraints + +### Curve Requirements + +- Minimum 2 points required +- Curves cannot self-intersect (may produce unexpected results) +- Very tight curves may produce distorted panoramic views + +### Volume Boundaries + +- Sampling outside the volume returns background values +- Curves extending beyond volume edges will have truncated regions +- Best results when curve stays within volume boundaries + +### Performance + +- Large widths and small sampling steps increase generation time +- Very long curves may produce large panoramic images +- Real-time updates may be delayed on slower systems for complex curves + +## Optional Enhancements + +Potential future improvements: + +- **Spline Smoothing**: Use Catmull-Rom or other spline interpolation for smoother curves +- **Slab Thickness**: Add maximum/minimum/average intensity projection options +- **Live Synchronization**: Update panoramic view when source MPR changes +- **Curve Editing**: Modify existing curves with live preview +- **Multi-threading**: Parallelize image generation for better performance +- **Advanced Measurements**: Enable specialized measurements for curved structures diff --git a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/MprView.java b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/MprView.java index 0a70d7a8a..26ca11109 100755 --- a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/MprView.java +++ b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/MprView.java @@ -47,6 +47,7 @@ import org.joml.Matrix4d; import org.joml.Quaterniond; import org.joml.Vector3d; +import org.joml.Vector3i; import org.joml.Vector4d; import org.opencv.core.Point3; import org.slf4j.Logger; @@ -63,12 +64,14 @@ import org.weasis.core.api.util.ResourceUtil; import org.weasis.core.api.util.ResourceUtil.OtherIcon; import org.weasis.core.ui.editor.image.ImageViewerEventManager; +import org.weasis.core.ui.util.MouseEventDouble; import org.weasis.core.ui.editor.image.SynchCineEvent; import org.weasis.core.ui.editor.image.SynchEvent; import org.weasis.core.ui.editor.image.ViewButton; import org.weasis.core.ui.editor.image.ViewCanvas; import org.weasis.core.ui.model.AbstractGraphicModel; import org.weasis.core.ui.model.graphic.Graphic; +import org.weasis.core.ui.model.graphic.imp.line.PolylineGraphic; import org.weasis.core.ui.model.layer.GraphicLayer; import org.weasis.core.ui.model.layer.LayerItem; import org.weasis.core.ui.model.layer.LayerType; @@ -85,6 +88,8 @@ import org.weasis.dicom.viewer2d.Messages; import org.weasis.dicom.viewer2d.View2d; import org.weasis.dicom.viewer2d.mpr.MprController.ControlPoints; +import org.weasis.dicom.viewer2d.mpr.cmpr.CurvedMprFactory; +import org.weasis.dicom.viewer2d.mpr.cmpr.CurvedMprImageIO; public class MprView extends View2d implements SliceCanvas { private static final Logger LOGGER = LoggerFactory.getLogger(MprView.class); @@ -222,6 +227,66 @@ public Vector3d getVolumeCoordinatesFromMouse(int x, int y, Vector3d crossHair) return getVolumeCoordinates(new Vector3d(pt.getX(), pt.getY(), crossHair.z)); } + /** + * Convert image coordinates to volume coordinates. + * Use this when you have coordinates from a graphic (which are in image space), + * not mouse/screen coordinates. + * + * Note: The MPR uses a cubic texture (sliceSize³) for rendering, but the actual + * volume may be non-cubic (e.g., 640x640x218). This method returns coordinates + * in actual voxel space. + */ + public Point3 getVolumeCoordinatesFromImage(double imgX, double imgY) { + if (mprController.getVolume() == null) { + return null; + } + Volume volume = mprController.getVolume(); + int sliceSize = volume.getSliceSize(); + Vector3i volSize = volume.getSize(); + Vector3d voxelRatio = volume.getVoxelRatio(); + + // The volume is centered in the sliceSize×sliceSize image. + // The rendered extent along each axis is volSize * voxelRatio. + // The centering offset is (sliceSize - volSize*voxelRatio) / 2. + double offsetX = (sliceSize - volSize.x * voxelRatio.x) / 2.0; + double offsetY = (sliceSize - volSize.y * voxelRatio.y) / 2.0; + double offsetZ = (sliceSize - volSize.z * voxelRatio.z) / 2.0; + + // Get the raw center position (not canvas-transformed) for the depth axis + Vector3d rawCenter = mprController.getAxesControl().getCenter(); + + // Convert image pixel coordinate to raw voxel index: + // voxelIndex = (imgCoord - offset) / voxelRatio + double voxelX, voxelY, voxelZ; + switch (plane) { + case AXIAL -> { + // Axial: image X->volume X, image Y->volume Y, depth->volume Z + voxelX = (imgX - offsetX) / voxelRatio.x; + voxelY = (imgY - offsetY) / voxelRatio.y; + voxelZ = rawCenter.z * volSize.z; + } + case CORONAL -> { + // Coronal: image X->volume X, image Y->volume Z, depth->volume Y + voxelX = (imgX - offsetX) / voxelRatio.x; + voxelZ = (imgY - offsetZ) / voxelRatio.z; + voxelY = rawCenter.y * volSize.y; + } + case SAGITTAL -> { + // Sagittal: image X->volume Y, image Y->volume Z, depth->volume X + voxelY = (imgX - offsetY) / voxelRatio.y; + voxelZ = (imgY - offsetZ) / voxelRatio.z; + voxelX = rawCenter.x * volSize.x; + } + default -> { + voxelX = (imgX - offsetX) / voxelRatio.x; + voxelY = (imgY - offsetY) / voxelRatio.y; + voxelZ = rawCenter.z * volSize.z; + } + } + + return new Point3(voxelX, voxelY, voxelZ); + } + public Vector3d getVolumeCoordinates(Vector3d planePosition) { Vector3d p = new Vector3d(planePosition); transform(getDisplayPointToTexturePointMatrix(), p); @@ -278,17 +343,96 @@ public void reset() { @Override public JPopupMenu buildContextMenu(final MouseEvent evt) { + LOGGER.info("MprView.buildContextMenu called at ({}, {})", evt.getX(), evt.getY()); ComboItemListener> action = eventManager.getAction(ActionW.SORT_STACK).orElse(null); + JPopupMenu ctx; if (action != null && action.isActionEnabled()) { // Force to disable sort stack menu action.enableAction(false); - JPopupMenu ctx = super.buildContextMenu(evt); + ctx = super.buildContextMenu(evt); action.enableAction(true); - return ctx; + } else { + ctx = super.buildContextMenu(evt); + } + + // Add "Generate Curved MPR" option if a polyline is under the cursor + addCurvedMprMenuItem(ctx, evt); + return ctx; + } + + private void addCurvedMprMenuItem(JPopupMenu popupMenu, MouseEvent evt) { + LOGGER.info("addCurvedMprMenuItem called"); + if (popupMenu == null) { + LOGGER.warn("popupMenu is null"); + return; + } + if (mprController == null) { + LOGGER.warn("mprController is null"); + return; + } + if (mprController.getVolume() == null) { + LOGGER.warn("volume is null"); + return; + } + + // Log all graphics in the manager + List allGraphics = getGraphicManager().getAllGraphics(); + LOGGER.info("Total graphics in manager: {}", allGraphics.size()); + for (Graphic g : allGraphics) { + LOGGER.info(" Graphic: {} complete={} class={}", g, g.isGraphicComplete(), g.getClass().getSimpleName()); + } + + // Try to find graphic under the cursor (like "Remove this point" does) + PolylineGraphic polyline = null; + Point2D imageCoords = getImageCoordinatesFromMouse(evt.getX(), evt.getY()); + LOGGER.info("Mouse at screen ({}, {}), image coords: {}", evt.getX(), evt.getY(), imageCoords); + + MouseEventDouble mouseEvt = new MouseEventDouble( + this, MouseEvent.MOUSE_RELEASED, evt.getWhen(), 16, 0, 0, 0, 0, 1, true, 1); + mouseEvt.setSource(this); + mouseEvt.setImageCoordinates(imageCoords); + + java.util.Optional graphicUnderCursor = + getGraphicManager().getFirstGraphicIntersecting(mouseEvt); + LOGGER.info("Graphic under cursor: {}", graphicUnderCursor.orElse(null)); + + if (graphicUnderCursor.isPresent()) { + Graphic g = graphicUnderCursor.get(); + LOGGER.info("Found graphic: {} isPolyline={} isComplete={}", + g.getClass().getSimpleName(), + g instanceof PolylineGraphic, + g.isGraphicComplete()); + if (g instanceof PolylineGraphic pl && pl.isGraphicComplete()) { + polyline = pl; + } } - return super.buildContextMenu(evt); + if (polyline != null) { + LOGGER.info("Adding Curved MPR menu item for polyline with {} points", polyline.getHandlePointList().size()); + final PolylineGraphic finalPolyline = polyline; + popupMenu.addSeparator(); + JMenuItem curvedMprItem = new JMenuItem("Generate Curved MPR"); + curvedMprItem.addActionListener(e -> { + List pts = finalPolyline.getHandlePointList(); + if (pts.size() >= 2) { + java.util.List points3D = new java.util.ArrayList<>(); + for (Point2D pt : pts) { + if (pt != null) { + // Use getVolumeCoordinatesFromImage since polyline points are in image coords + Point3 volCoord = getVolumeCoordinatesFromImage(pt.getX(), pt.getY()); + if (volCoord != null) { + points3D.add(new org.joml.Vector3d(volCoord.x, volCoord.y, volCoord.z)); + } + } + } + if (points3D.size() >= 2) { + CurvedMprFactory.openCurvedMpr(this, points3D); + } + } + }); + popupMenu.add(curvedMprItem); + } } @Override @@ -345,6 +489,111 @@ protected void drawOnTop(Graphics2D g2d) { super.drawOnTop(g2d); drawProgressBar(g2d, progressBar); + + // Draw debug visualization for curved MPR + drawCurvedMprDebug(g2d); + } + + /** + * Draw debug visualization for the curved MPR curve and sampling directions. + * Shows: original points (red), smoothed curve (green), perpendicular directions (cyan). + */ + private void drawCurvedMprDebug(Graphics2D g2d) { + CurvedMprImageIO.DebugCurveData debug = CurvedMprImageIO.getLastDebugData(); + if (debug == null || plane != Plane.AXIAL) { + return; // Only draw on axial view where the curve was drawn + } + + Volume vol = mprController.getVolume(); + if (vol == null) return; + + int sliceSize = vol.getSliceSize(); + Vector3i volSize = vol.getSize(); + Vector3d voxelRatio = vol.getVoxelRatio(); + + // Centering offsets: the volume is centered in the sliceSize×sliceSize image + double offsetX = (sliceSize - volSize.x * voxelRatio.x) / 2.0; + double offsetY = (sliceSize - volSize.y * voxelRatio.y) / 2.0; + + java.awt.Stroke oldStroke = g2d.getStroke(); + java.awt.Color oldColor = g2d.getColor(); + + // Draw original user points (large red circles) + g2d.setColor(Color.RED); + g2d.setStroke(new java.awt.BasicStroke(2f)); + for (Vector3d pt : debug.originalPoints) { + double imgX = pt.x * voxelRatio.x + offsetX; + double imgY = pt.y * voxelRatio.y + offsetY; + Point2D screenPt = getImageCoordinatesToScreen(imgX, imgY); + int r = 6; + g2d.drawOval((int)screenPt.getX() - r, (int)screenPt.getY() - r, r*2, r*2); + } + + // Draw smoothed curve (green line) + g2d.setColor(Color.GREEN); + g2d.setStroke(new java.awt.BasicStroke(1.5f)); + Point2D prevPt = null; + for (Vector3d pt : debug.smoothedPoints) { + double imgX = pt.x * voxelRatio.x + offsetX; + double imgY = pt.y * voxelRatio.y + offsetY; + Point2D screenPt = getImageCoordinatesToScreen(imgX, imgY); + if (prevPt != null) { + g2d.drawLine((int)prevPt.getX(), (int)prevPt.getY(), + (int)screenPt.getX(), (int)screenPt.getY()); + } + prevPt = screenPt; + } + + // Draw perpendicular directions at sampled points (cyan lines showing slab extent) + g2d.setColor(Color.CYAN); + g2d.setStroke(new java.awt.BasicStroke(1f)); + // Convert slab thickness from mm to voxels for correct visualization + double pixelMm = vol.getMinPixelRatio(); + double halfSlabVoxels = (debug.slabThicknessMm / 2.0) / pixelMm; + + // Sample every Nth point to avoid clutter + int step = Math.max(1, debug.sampledPoints.size() / 30); + for (int i = 0; i < debug.sampledPoints.size(); i += step) { + Vector3d pt = debug.sampledPoints.get(i); + Vector3d dir = debug.perpDirections.get(i); + + // Compute endpoints of perpendicular line (in voxel coords) + Vector3d p1 = new Vector3d(pt).add(new Vector3d(dir).mul(halfSlabVoxels)); + Vector3d p2 = new Vector3d(pt).sub(new Vector3d(dir).mul(halfSlabVoxels)); + + // Convert to image coordinates + double img1X = p1.x * voxelRatio.x + offsetX; + double img1Y = p1.y * voxelRatio.y + offsetY; + double img2X = p2.x * voxelRatio.x + offsetX; + double img2Y = p2.y * voxelRatio.y + offsetY; + + Point2D screen1 = getImageCoordinatesToScreen(img1X, img1Y); + Point2D screen2 = getImageCoordinatesToScreen(img2X, img2Y); + + g2d.drawLine((int)screen1.getX(), (int)screen1.getY(), + (int)screen2.getX(), (int)screen2.getY()); + } + + // Draw info text + g2d.setColor(Color.YELLOW); + g2d.drawString("Debug: orig=" + debug.originalPoints.size() + + " smooth=" + debug.smoothedPoints.size() + + " sampled=" + debug.sampledPoints.size() + + " slab=" + debug.slabThicknessMm + "mm", 10, 50); + + g2d.setStroke(oldStroke); + g2d.setColor(oldColor); + } + + private Point2D getImageCoordinatesToScreen(double imgX, double imgY) { + java.awt.geom.AffineTransform transform = getAffineTransform(); + if (transform == null) { + return new Point2D.Double(imgX, imgY); + } + Point2D.Double src = new Point2D.Double(imgX, imgY); + Point2D.Double dst = new Point2D.Double(); + transform.transform(src, dst); + return dst; } public ControlPoints getControlPoints(Line2D line, Point2D center) { @@ -856,4 +1105,42 @@ protected void setRotation(double rotation) { } repaint(); } + + @Override + public JPopupMenu buildGraphicContextMenu(final MouseEvent evt, final List selected) { + LOGGER.info("MprView.buildGraphicContextMenu called, selected={}", selected != null ? selected.size() : 0); + JPopupMenu popupMenu = super.buildGraphicContextMenu(evt, selected); + if (popupMenu != null && selected != null && selected.size() == 1) { + Graphic graphic = selected.getFirst(); + LOGGER.info("Selected graphic: {} isPolyline={} isComplete={}", + graphic.getClass().getSimpleName(), + graphic instanceof PolylineGraphic, + graphic.isGraphicComplete()); + if (graphic instanceof PolylineGraphic polyline && graphic.isGraphicComplete()) { + LOGGER.info("Adding Curved MPR menu item in buildGraphicContextMenu"); + popupMenu.addSeparator(); + JMenuItem curvedMprItem = new JMenuItem("Generate Curved MPR"); + curvedMprItem.addActionListener(e -> { + List pts = polyline.getHandlePointList(); + if (pts.size() >= 2) { + java.util.List points3D = new java.util.ArrayList<>(); + for (Point2D pt : pts) { + if (pt != null) { + // Use getVolumeCoordinatesFromImage since polyline points are in image coords + Point3 volCoord = getVolumeCoordinatesFromImage(pt.getX(), pt.getY()); + if (volCoord != null) { + points3D.add(new org.joml.Vector3d(volCoord.x, volCoord.y, volCoord.z)); + } + } + } + if (points3D.size() >= 2) { + CurvedMprFactory.openCurvedMpr(this, points3D); + } + } + }); + popupMenu.add(curvedMprItem); + } + } + return popupMenu; + } } diff --git a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/Volume.java b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/Volume.java index 481d2024e..c1e313987 100644 --- a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/Volume.java +++ b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/Volume.java @@ -722,7 +722,7 @@ private Vector3i[] calculateTransformedBounds(Matrix4d transform) { return new Vector3i[] {min, max}; } - protected T getInterpolatedValueFromSource(double x, double y, double z) { + public T getInterpolatedValueFromSource(double x, double y, double z) { // Check bounds in the ORIGINAL volume (this) if (x < 0 || x >= this.size.x - 1 diff --git a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprAxis.java b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprAxis.java new file mode 100644 index 000000000..807092cf6 --- /dev/null +++ b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprAxis.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2024 Weasis Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.weasis.dicom.viewer2d.mpr.cmpr; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.joml.Vector3d; +import org.weasis.dicom.codec.DicomImageElement; +import org.weasis.dicom.viewer2d.mpr.Volume; + +/** + * Holds curve state and parameters for panoramic (curved MPR) generation. + * + *

This class stores the 3D curve points defined by the user, the source plane normal, + * and parameters controlling the panoramic image generation (width, sampling step). + */ +public class CurvedMprAxis { + + private final Volume volume; + private final List curvePoints3D; + private final Vector3d planeNormal; + private double widthMm; + private double stepMm; + private CurvedMprImageIO io; + private DicomImageElement imageElement; + private CurvedMprView view; + + /** + * Create a new CurvedMprAxis. + * + * @param volume the 3D volume to sample + * @param curvePoints3D the polyline control points in voxel coordinates + * @param planeNormal the source plane normal (effective after rotation) + */ + public CurvedMprAxis(Volume volume, List curvePoints3D, Vector3d planeNormal) { + this.volume = Objects.requireNonNull(volume); + this.curvePoints3D = new ArrayList<>(curvePoints3D); + this.planeNormal = new Vector3d(planeNormal).normalize(); + this.widthMm = 40.0; + this.stepMm = volume.getMinPixelRatio(); + } + + public Volume getVolume() { + return volume; + } + + public List getCurvePoints3D() { + return Collections.unmodifiableList(curvePoints3D); + } + + public Vector3d getPlaneNormal() { + return new Vector3d(planeNormal); + } + + public double getWidthMm() { + return widthMm; + } + + public void setWidthMm(double widthMm) { + if (widthMm > 0 && this.widthMm != widthMm) { + this.widthMm = widthMm; + updateImage(); + } + } + + public double getStepMm() { + return stepMm; + } + + public void setStepMm(double stepMm) { + if (stepMm > 0 && this.stepMm != stepMm) { + this.stepMm = stepMm; + updateImage(); + } + } + + public CurvedMprImageIO getIo() { + return io; + } + + public void setIo(CurvedMprImageIO io) { + this.io = io; + } + + public DicomImageElement getImageElement() { + return imageElement; + } + + public void setImageElement(DicomImageElement imageElement) { + this.imageElement = imageElement; + } + + public CurvedMprView getView() { + return view; + } + + public void setView(CurvedMprView view) { + this.view = view; + } + + /** + * Update the panoramic image in the associated view. + */ + public void updateImage() { + if (view != null && imageElement != null) { + view.getImageLayer().setImage(null, null); + imageElement.removeImageFromCache(); + view.setImage(imageElement); + view.repaint(); + } + } + + /** + * Calculate the total arc length of the curve in millimeters. + * + * @return total arc length in mm + */ + public double getTotalArcLengthMm() { + double pixelMm = volume.getMinPixelRatio(); + double totalLength = 0; + for (int i = 1; i < curvePoints3D.size(); i++) { + totalLength += curvePoints3D.get(i).distance(curvePoints3D.get(i - 1)) * pixelMm; + } + return totalLength; + } + + /** + * Dispose resources associated with this axis. + */ + public void dispose() { + if (imageElement != null) { + imageElement.removeImageFromCache(); + imageElement.dispose(); + } + } +} diff --git a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprContainer.java b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprContainer.java new file mode 100644 index 000000000..942ba226c --- /dev/null +++ b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprContainer.java @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2024 Weasis Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.weasis.dicom.viewer2d.mpr.cmpr; + +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import javax.swing.Action; +import javax.swing.JComponent; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JSeparator; +import javax.swing.SwingUtilities; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.weasis.core.api.explorer.ObservableEvent; +import org.weasis.core.api.gui.util.GuiUtils; +import org.weasis.core.api.image.GridBagLayoutModel; +import org.weasis.core.api.media.data.MediaSeries; +import org.weasis.core.api.util.ResourceUtil; +import org.weasis.core.api.util.ResourceUtil.ActionIcon; +import org.weasis.core.api.util.ResourceUtil.OtherIcon; +import org.weasis.core.ui.editor.SeriesViewerUI; +import org.weasis.core.ui.editor.image.DefaultView2d; +import org.weasis.core.ui.editor.image.MeasureToolBar; +import org.weasis.core.ui.editor.image.RotationToolBar; +import org.weasis.core.ui.editor.image.SynchView; +import org.weasis.core.ui.editor.image.ViewCanvas; +import org.weasis.core.ui.editor.image.ViewerToolBar; +import org.weasis.core.ui.editor.image.ZoomToolBar; +import org.weasis.core.ui.util.ColorLayerUI; +import org.weasis.core.ui.util.DefaultAction; +import org.weasis.core.ui.util.PrintDialog; +import org.weasis.core.ui.util.Toolbar; +import org.weasis.dicom.codec.DicomImageElement; +import org.weasis.dicom.codec.DicomSeries; +import org.weasis.dicom.explorer.DicomViewerPlugin; +import org.weasis.dicom.explorer.ExportToolBar; +import org.weasis.dicom.explorer.ImportToolBar; +import org.weasis.dicom.viewer2d.DcmHeaderToolBar; +import org.weasis.dicom.viewer2d.EventManager; +import org.weasis.dicom.viewer2d.LutToolBar; +import org.weasis.dicom.viewer2d.Messages; +import org.weasis.dicom.viewer2d.View2dContainer; + +/** + * Viewer plugin container for curved MPR panoramic views. + */ +public class CurvedMprContainer extends DicomViewerPlugin implements PropertyChangeListener { + private static final Logger LOGGER = LoggerFactory.getLogger(CurvedMprContainer.class); + + public static final String NAME = "Curved MPR"; + + public static final SeriesViewerUI UI = + new SeriesViewerUI(CurvedMprContainer.class, null, View2dContainer.UI.tools, null); + + private CurvedMprAxis curvedMprAxis; + + public CurvedMprContainer() { + this(VIEWS_1x1, null); + } + + public CurvedMprContainer(GridBagLayoutModel layoutModel, String uid) { + super( + EventManager.getInstance(), + layoutModel, + uid, + NAME, + ResourceUtil.getIcon(OtherIcon.VIEW_3D), + null); + setSynchView(SynchView.NONE); + if (!UI.init.getAndSet(true)) { + initToolBars(); + } + } + + private void initToolBars() { + List toolBars = UI.toolBars; + EventManager evtMg = EventManager.getInstance(); + + Optional importBar = + View2dContainer.UI.toolBars.stream().filter(ImportToolBar.class::isInstance).findFirst(); + importBar.ifPresent(toolBars::add); + Optional exportBar = + View2dContainer.UI.toolBars.stream().filter(ExportToolBar.class::isInstance).findFirst(); + exportBar.ifPresent(toolBars::add); + Optional viewBar = + View2dContainer.UI.toolBars.stream().filter(ViewerToolBar.class::isInstance).findFirst(); + viewBar.ifPresent(toolBars::add); + toolBars.add(new MeasureToolBar(evtMg, 11)); + toolBars.add(new ZoomToolBar(evtMg, 20, true)); + toolBars.add(new RotationToolBar(evtMg, 30)); + toolBars.add(new LutToolBar(evtMg, 40)); + toolBars.add(new DcmHeaderToolBar(evtMg, 50)); + } + + @Override + public void propertyChange(PropertyChangeEvent evt) { + if (evt instanceof ObservableEvent event) { + ObservableEvent.BasicAction action = event.getActionCommand(); + Object newVal = event.getNewValue(); + if (ObservableEvent.BasicAction.REMOVE.equals(action)) { + if (newVal instanceof MediaSeries) { + close(); + } + } + } + } + + @Override + public SeriesViewerUI getSeriesViewerUI() { + return UI; + } + + @Override + public void close() { + CurvedMprFactory.closeSeriesViewer(this); + super.close(); + if (curvedMprAxis != null) { + curvedMprAxis.dispose(); + } + } + + @Override + public int getViewTypeNumber(GridBagLayoutModel layout, Class defaultClass) { + return 1; + } + + @Override + public boolean isViewType(Class defaultClass, String type) { + if (defaultClass != null) { + try { + Class clazz = Class.forName(type); + return defaultClass.isAssignableFrom(clazz); + } catch (Exception e) { + LOGGER.error("Checking view type", e); + } + } + return false; + } + + @Override + public DefaultView2d createDefaultView(String classType) { + return new CurvedMprView(eventManager); + } + + @Override + public JComponent createComponent(String clazz) { + if (isViewType(DefaultView2d.class, clazz)) { + return createDefaultView(clazz); + } + try { + return buildInstance(Class.forName(clazz)); + } catch (Exception e) { + LOGGER.error("Cannot create {}", clazz, e); + } + return null; + } + + @Override + public Class getSeriesViewerClass() { + return CurvedMprView.class; + } + + @Override + public GridBagLayoutModel getDefaultLayoutModel() { + return VIEWS_1x1; + } + + @Override + public List getExportActions() { + return selectedImagePane == null + ? super.getExportActions() + : selectedImagePane.getExportActions(); + } + + @Override + public List getPrintActions() { + ArrayList actions = new ArrayList<>(1); + final String title = Messages.getString("View2dContainer.print_layout"); + DefaultAction printStd = + new DefaultAction( + title, + ResourceUtil.getIcon(ActionIcon.PRINT), + event -> { + ColorLayerUI layer = ColorLayerUI.createTransparentLayerUI(CurvedMprContainer.this); + PrintDialog dialog = + new PrintDialog<>( + SwingUtilities.getWindowAncestor(CurvedMprContainer.this), title, eventManager); + ColorLayerUI.showCenterScreen(dialog, layer); + }); + actions.add(printStd); + return actions; + } + + @Override + public List getSynchList() { + return List.of(SynchView.NONE); + } + + @Override + public List getLayoutList() { + return List.of(VIEWS_1x1); + } + + public CurvedMprAxis getCurvedMprAxis() { + return curvedMprAxis; + } + + public void setCurvedMprAxis(CurvedMprAxis axis) { + LOGGER.info("setCurvedMprAxis called, axis={}", axis != null ? "not null" : "null"); + this.curvedMprAxis = axis; + CurvedMprView view = getSelectedCurvedMprView(); + LOGGER.info("getSelectedCurvedMprView returned: {}", view != null ? view.getClass().getName() : "null"); + if (view != null && axis != null) { + view.setCurvedMprAxis(axis); + DicomImageElement img = axis.getImageElement(); + LOGGER.info("ImageElement from axis: {}", img != null ? "not null" : "null"); + if (img != null) { + // Create a series containing the image - required by InfoLayer + String uid = "curved-mpr-" + System.currentTimeMillis(); + DicomSeries series = new DicomSeries(uid); + series.addMedia(img); + LOGGER.info("Created DicomSeries with uid: {}", uid); + + // Set the series first, then the image + view.setSeries(series, img); + LOGGER.info("Called view.setSeries()"); + } + } else { + LOGGER.warn("Cannot set image: view={}, axis={}", view, axis); + } + } + + public CurvedMprView getSelectedCurvedMprView() { + ViewCanvas selected = getSelectedImagePane(); + if (selected instanceof CurvedMprView curvedMprView) { + return curvedMprView; + } + for (ViewCanvas v : view2ds) { + if (v instanceof CurvedMprView curvedMprView) { + return curvedMprView; + } + } + return null; + } + + @Override + public void addSeries(MediaSeries sequence) { + // Curved MPR is created programmatically, not from a series + } + + @Override + public void addSeriesList( + List> seriesList, boolean removeOldSeries) { + // Not used for curved MPR + } + + @Override + public void selectLayoutPositionForAddingSeries(List> seriesList) { + // Not used for curved MPR + } + + public JMenu createCurvedMprMenu() { + JMenu menu = new JMenu("Curved MPR"); + + JMenuItem widthItem = new JMenuItem("Adjust Width..."); + widthItem.addActionListener(e -> { + CurvedMprView view = getSelectedCurvedMprView(); + if (view != null) { + view.showWidthDialog(); + } + }); + menu.add(widthItem); + + JMenuItem stepItem = new JMenuItem("Adjust Sampling Step..."); + stepItem.addActionListener(e -> { + CurvedMprView view = getSelectedCurvedMprView(); + if (view != null) { + view.showStepDialog(); + } + }); + menu.add(stepItem); + + return menu; + } + + @Override + public JMenu fillSelectedPluginMenu(JMenu menuRoot) { + if (menuRoot != null) { + menuRoot.removeAll(); + + if (eventManager instanceof EventManager manager) { + int count = menuRoot.getItemCount(); + + GuiUtils.addItemToMenu(menuRoot, manager.getPresetMenu("weasis.pluginMenu.presets")); + GuiUtils.addItemToMenu(menuRoot, manager.getLutShapeMenu("weasis.pluginMenu.lutShape")); + GuiUtils.addItemToMenu(menuRoot, manager.getLutMenu("weasis.pluginMenu.lut")); + GuiUtils.addItemToMenu(menuRoot, manager.getLutInverseMenu("weasis.pluginMenu.invertLut")); + GuiUtils.addItemToMenu(menuRoot, manager.getFilterMenu("weasis.pluginMenu.filter")); + + if (count < menuRoot.getItemCount()) { + menuRoot.add(new JSeparator()); + count = menuRoot.getItemCount(); + } + + GuiUtils.addItemToMenu(menuRoot, manager.getZoomMenu("weasis.pluginMenu.zoom")); + GuiUtils.addItemToMenu( + menuRoot, manager.getOrientationMenu("weasis.pluginMenu.orientation")); + + if (count < menuRoot.getItemCount()) { + menuRoot.add(new JSeparator()); + count = menuRoot.getItemCount(); + } + + menuRoot.add(createCurvedMprMenu()); + + if (count < menuRoot.getItemCount()) { + menuRoot.add(new JSeparator()); + } + + menuRoot.add(manager.getResetMenu("weasis.pluginMenu.reset")); + } + } + return menuRoot; + } +} diff --git a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprFactory.java b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprFactory.java new file mode 100644 index 000000000..e5eaf35f4 --- /dev/null +++ b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprFactory.java @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2024 Weasis Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.weasis.dicom.viewer2d.mpr.cmpr; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.swing.Icon; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.Tag; +import org.joml.Quaterniond; +import org.joml.Vector3d; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.weasis.core.api.explorer.DataExplorerView; +import org.weasis.core.api.gui.util.GuiExecutor; +import org.weasis.core.api.gui.util.GuiUtils; +import org.weasis.core.api.image.GridBagLayoutModel; +import org.weasis.core.api.media.data.MediaElement; +import org.weasis.core.api.media.data.MediaSeries; +import org.weasis.core.api.util.ResourceUtil; +import org.weasis.core.api.util.ResourceUtil.OtherIcon; +import org.weasis.core.ui.editor.SeriesViewer; +import org.weasis.core.ui.editor.SeriesViewerFactory; +import org.weasis.core.ui.editor.ViewerPluginBuilder; +import org.weasis.core.ui.editor.image.ImageViewerPlugin; +import org.weasis.core.ui.editor.image.ImageViewerPlugin.LayoutModel; +import org.weasis.core.ui.editor.image.ViewerPlugin; +import org.weasis.dicom.codec.DicomImageElement; +import org.weasis.dicom.codec.TagD; +import org.weasis.dicom.explorer.DicomExplorer; +import org.weasis.dicom.viewer2d.mpr.MprController; +import org.weasis.dicom.viewer2d.mpr.MprView; +import org.weasis.dicom.viewer2d.mpr.MprView.Plane; +import org.weasis.dicom.viewer2d.mpr.Volume; + +/** + * Factory for creating Curved MPR viewer containers. + * + *

This factory is registered as an OSGi component and provides the ability to create + * CurvedMprContainer instances programmatically from an MprView. + */ +@org.osgi.service.component.annotations.Component(service = SeriesViewerFactory.class) +public class CurvedMprFactory implements SeriesViewerFactory { + + private static final Logger LOGGER = LoggerFactory.getLogger(CurvedMprFactory.class); + + public static final String NAME = "Curved MPR"; + + @Override + public Icon getIcon() { + return ResourceUtil.getIcon(OtherIcon.VIEW_3D); + } + + @Override + public String getUIName() { + return NAME; + } + + @Override + public String getDescription() { + return "Curved Multi-Planar Reconstruction viewer"; + } + + @Override + public SeriesViewer createSeriesViewer(Map properties) { + LOGGER.info("createSeriesViewer called"); + LayoutModel layout = + ImageViewerPlugin.getLayoutModel(properties, ImageViewerPlugin.VIEWS_1x1, null); + CurvedMprContainer instance = new CurvedMprContainer(layout.model(), layout.uid()); + LOGGER.info("Created CurvedMprContainer instance"); + + // Retrieve the axis from properties if available + if (properties != null) { + Object axisObj = properties.get("curvedMprAxis"); + LOGGER.info("curvedMprAxis in properties: {}", axisObj != null ? axisObj.getClass().getName() : "null"); + if (axisObj instanceof CurvedMprAxis axis) { + LOGGER.info("Setting CurvedMprAxis on container"); + instance.setCurvedMprAxis(axis); + } + } + + ImageViewerPlugin.registerInDataExplorerModel(properties, instance); + LOGGER.info("Registered container, returning instance"); + return instance; + } + + @Override + public boolean canReadMimeType(String mimeType) { + return false; + } + + @Override + public boolean isViewerCreatedByThisFactory(SeriesViewer viewer) { + return viewer instanceof CurvedMprContainer; + } + + @Override + public int getLevel() { + return 16; + } + + @Override + public boolean canAddSeries() { + return false; + } + + @Override + public boolean canExternalizeSeries() { + return true; + } + + @Override + public boolean canReadSeries(MediaSeries series) { + return false; + } + + public static void closeSeriesViewer(CurvedMprContainer container) { + DataExplorerView dicomView = GuiUtils.getUICore().getExplorerPlugin(DicomExplorer.NAME); + if (dicomView != null) { + dicomView.getDataExplorerModel().removePropertyChangeListener(container); + } + } + + /** + * Open a Curved MPR viewer from an MprView with the given curve points. + * + * @param sourceView the source MprView + * @param curvePoints3D the 3D curve points in voxel coordinates + */ + public static void openCurvedMpr(MprView sourceView, List curvePoints3D) { + LOGGER.info("openCurvedMpr called with {} points", curvePoints3D != null ? curvePoints3D.size() : 0); + + if (sourceView == null || curvePoints3D == null || curvePoints3D.size() < 2) { + LOGGER.warn("Cannot open curved MPR: invalid source view or curve points"); + return; + } + + MprController controller = sourceView.getMprController(); + if (controller == null) { + LOGGER.warn("Cannot open curved MPR: no controller available"); + return; + } + + Volume volume = controller.getVolume(); + if (volume == null) { + LOGGER.warn("Cannot open curved MPR: no volume available"); + return; + } + LOGGER.info("Volume available: {}", volume.getClass().getSimpleName()); + + Plane plane = sourceView.getPlane(); + Vector3d planeNormal = plane.getDirection(); + LOGGER.info("Plane: {}, normal: {}", plane, planeNormal); + + Quaterniond rotation = controller.getRotation(plane); + if (rotation != null) { + rotation.transform(planeNormal); + } + LOGGER.info("Transformed normal: {}", planeNormal); + + CurvedMprAxis axis = new CurvedMprAxis(volume, curvePoints3D, planeNormal); + LOGGER.info("Created CurvedMprAxis with arc length: {} mm", axis.getTotalArcLengthMm()); + + CurvedMprImageIO io = new CurvedMprImageIO(axis); + axis.setIo(io); + LOGGER.info("Created CurvedMprImageIO"); + + DicomImageElement refImg = sourceView.getImage(); + if (refImg != null) { + copyBaseTags(io, refImg); + Attributes attrs = getBaseAttributes(refImg); + io.setBaseAttributes(attrs); + LOGGER.info("Copied tags from reference image"); + } + + DicomImageElement imageElement = new DicomImageElement(io, 0); + axis.setImageElement(imageElement); + LOGGER.info("Created DicomImageElement"); + + LOGGER.info("Scheduling openCurvedMprViewer on GUI thread"); + GuiExecutor.execute(() -> openCurvedMprViewer(axis)); + } + + private static void copyBaseTags(CurvedMprImageIO io, DicomImageElement refImg) { + io.copyTags(TagD.getTagFromIDs( + Tag.PatientID, + Tag.PatientName, + Tag.PatientBirthDate, + Tag.PatientSex, + Tag.StudyInstanceUID, + Tag.StudyID, + Tag.StudyDate, + Tag.StudyTime, + Tag.AccessionNumber, + Tag.ReferringPhysicianName, + Tag.Modality, + Tag.BodyPartExamined, + Tag.PhotometricInterpretation, + Tag.SamplesPerPixel, + Tag.BitsAllocated, + Tag.BitsStored, + Tag.HighBit, + Tag.PixelRepresentation, + Tag.RescaleSlope, + Tag.RescaleIntercept, + Tag.RescaleType, + Tag.WindowCenter, + Tag.WindowWidth, + Tag.WindowCenterWidthExplanation, + Tag.VOILUTFunction + ), refImg, false); + } + + private static Attributes getBaseAttributes(DicomImageElement refImg) { + Attributes attrs = new Attributes(); + Object val = refImg.getTagValue(TagD.get(Tag.PatientID)); + if (val != null) attrs.setString(Tag.PatientID, org.dcm4che3.data.VR.LO, val.toString()); + val = refImg.getTagValue(TagD.get(Tag.PatientName)); + if (val != null) attrs.setString(Tag.PatientName, org.dcm4che3.data.VR.PN, val.toString()); + val = refImg.getTagValue(TagD.get(Tag.StudyInstanceUID)); + if (val != null) attrs.setString(Tag.StudyInstanceUID, org.dcm4che3.data.VR.UI, val.toString()); + val = refImg.getTagValue(TagD.get(Tag.Modality)); + if (val != null) attrs.setString(Tag.Modality, org.dcm4che3.data.VR.CS, val.toString()); + return attrs; + } + + private static void openCurvedMprViewer(CurvedMprAxis axis) { + LOGGER.info("openCurvedMprViewer called"); + + SeriesViewerFactory factory = GuiUtils.getUICore().getViewerFactory(CurvedMprFactory.class); + if (factory == null) { + LOGGER.error("CurvedMprFactory not found in registered factories!"); + for (SeriesViewerFactory f : GuiUtils.getUICore().getSeriesViewerFactories()) { + LOGGER.info(" Available factory: {}", f.getClass().getName()); + } + return; + } + LOGGER.info("Found CurvedMprFactory: {}", factory.getClass().getName()); + + // Create the viewer directly instead of using ViewerPluginBuilder + // since we don't have a traditional series + Map props = Collections.synchronizedMap(new HashMap<>()); + props.put("curvedMprAxis", axis); + + SeriesViewer viewer = factory.createSeriesViewer(props); + if (viewer instanceof ViewerPlugin plugin) { + LOGGER.info("Created viewer plugin, registering..."); + GuiUtils.getUICore().getViewerPlugins().add(plugin); + plugin.showDockable(); + plugin.setSelectedAndGetFocus(); + LOGGER.info("Viewer registered and shown"); + } else { + LOGGER.error("Created viewer is not a ViewerPlugin: {}", + viewer != null ? viewer.getClass().getName() : "null"); + } + } +} diff --git a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprGraphic.java b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprGraphic.java new file mode 100644 index 000000000..94478bb6f --- /dev/null +++ b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprGraphic.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2024 Weasis Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.weasis.dicom.viewer2d.mpr.cmpr; + +import jakarta.xml.bind.annotation.XmlRootElement; +import jakarta.xml.bind.annotation.XmlType; +import java.awt.geom.Point2D; +import java.util.ArrayList; +import java.util.List; +import javax.swing.Icon; +import org.joml.Vector3d; +import org.opencv.core.Point3; +import org.weasis.core.api.util.ResourceUtil; +import org.weasis.core.api.util.ResourceUtil.ActionIcon; +import org.weasis.core.ui.model.graphic.imp.line.PolylineGraphic; +import org.weasis.dicom.viewer2d.mpr.MprView; + +/** + * Specialized polyline graphic for drawing curved MPR paths. + * + *

This graphic extends PolylineGraphic to allow users to draw a curve in an MPR plane. + * The 2D points are converted to 3D volume coordinates for panoramic image generation. + */ +@XmlType(name = "curvedMpr") +@XmlRootElement(name = "curvedMpr") +public class CurvedMprGraphic extends PolylineGraphic { + + public static final Icon ICON = ResourceUtil.getIcon(ActionIcon.DRAW_POLYLINE); + + public CurvedMprGraphic() { + super(); + } + + public CurvedMprGraphic(CurvedMprGraphic graphic) { + super(graphic); + } + + @Override + public CurvedMprGraphic copy() { + return new CurvedMprGraphic(this); + } + + @Override + public Icon getIcon() { + return ICON; + } + + @Override + public String getUIName() { + return "Curved MPR Path"; + } + + /** + * Convert the 2D polyline points to 3D volume coordinates. + * + * @param mprView the MprView containing the volume and coordinate transformation methods + * @return list of 3D points in volume coordinates + */ + public List get3DPoints(MprView mprView) { + List points3D = new ArrayList<>(); + if (mprView == null) { + return points3D; + } + + for (Point2D pt : pts) { + if (pt != null) { + // Use getVolumeCoordinatesFromImage since graphic points are in image space, + // not mouse/screen coordinates. This returns actual voxel coordinates. + Point3 volCoord = mprView.getVolumeCoordinatesFromImage(pt.getX(), pt.getY()); + if (volCoord != null) { + points3D.add(new Vector3d(volCoord.x, volCoord.y, volCoord.z)); + } + } + } + return points3D; + } + + /** + * Generate a Curved MPR view from this graphic. + * + * @param mprView the source MprView + */ + public void generateCurvedMpr(MprView mprView) { + List points3D = get3DPoints(mprView); + if (points3D.size() >= 2) { + CurvedMprFactory.openCurvedMpr(mprView, points3D); + } + } + + /** + * Check if the graphic has enough points to generate a curved MPR. + * + * @return true if there are at least 2 valid points + */ + public boolean canGenerateCurvedMpr() { + int validPoints = 0; + for (Point2D pt : pts) { + if (pt != null) { + validPoints++; + } + } + return validPoints >= 2; + } +} diff --git a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprImageIO.java b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprImageIO.java new file mode 100644 index 000000000..3962504bd --- /dev/null +++ b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprImageIO.java @@ -0,0 +1,769 @@ +/* + * Copyright (c) 2024 Weasis Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.weasis.dicom.viewer2d.mpr.cmpr; + +import java.io.File; +import java.lang.ref.Reference; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import org.dcm4che3.data.Attributes; +import org.dcm4che3.data.SpecificCharacterSet; +import org.dcm4che3.data.Tag; +import org.dcm4che3.data.UID; +import org.dcm4che3.img.DicomMetaData; +import org.dcm4che3.util.UIDUtils; +import org.joml.Vector3d; +import org.opencv.core.CvType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.weasis.core.api.explorer.model.DataExplorerModel; +import org.weasis.core.api.media.data.Codec; +import org.weasis.core.api.media.data.FileCache; +import org.weasis.core.api.media.data.MediaElement; +import org.weasis.core.api.media.data.MediaSeriesGroup; +import org.weasis.core.api.media.data.TagW; +import org.weasis.core.util.SoftHashMap; +import org.weasis.dicom.codec.DcmMediaReader; +import org.weasis.dicom.codec.DicomImageElement; +import org.weasis.dicom.codec.DicomSeries; +import org.weasis.dicom.codec.TagD; +import org.weasis.dicom.codec.utils.DicomMediaUtils; +import org.weasis.dicom.viewer2d.mpr.Volume; +import org.weasis.opencv.data.ImageCV; +import org.weasis.opencv.data.PlanarImage; + +/** + * Image I/O handler for curved MPR panoramic images. + * + *

This class generates the "straightened" panoramic view by sampling the volume along + * the curved path defined in CurvedMprAxis. + */ +public class CurvedMprImageIO implements DcmMediaReader { + private static final Logger LOGGER = LoggerFactory.getLogger(CurvedMprImageIO.class); + + private static final String MIME_TYPE = "image/cmpr"; + + // Debug visualization data - stores the last computed curve info for overlay drawing + private static volatile DebugCurveData lastDebugData = null; + + /** + * Debug data for visualizing the computed curve and perpendicular directions. + */ + public static class DebugCurveData { + public final List originalPoints; + public final List smoothedPoints; + public final List sampledPoints; + public final List perpDirections; + public final double slabThicknessMm; + + public DebugCurveData(List original, List smoothed, + List sampled, List perps, double slabMm) { + this.originalPoints = new ArrayList<>(original); + this.smoothedPoints = new ArrayList<>(smoothed); + this.sampledPoints = new ArrayList<>(sampled); + this.perpDirections = new ArrayList<>(perps); + this.slabThicknessMm = slabMm; + } + } + + public static DebugCurveData getLastDebugData() { + return lastDebugData; + } + private static final SoftHashMap HEADER_CACHE = + new SoftHashMap<>() { + @Override + public void removeElement(Reference soft) { + CurvedMprImageIO key = reverseLookup.remove(soft); + if (key != null) { + hash.remove(key); + } + } + }; + + private final FileCache fileCache; + private final HashMap tags; + private final URI uri; + private final CurvedMprAxis axis; + private final Volume volume; + private Attributes attributes; + + public CurvedMprImageIO(CurvedMprAxis axis) { + this.axis = Objects.requireNonNull(axis); + this.volume = axis.getVolume(); + this.fileCache = new FileCache(this); + this.tags = new HashMap<>(); + try { + this.uri = new URI("data:" + MIME_TYPE); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + + public void setBaseAttributes(Attributes attributes) { + this.attributes = attributes; + } + + @Override + public PlanarImage getImageFragment(MediaElement media) throws Exception { + return generatePanoramicImage(); + } + + /** + * Generate the panoramic image using Curved Planar Reformation (CPR). + * + *

For a dental arch curve drawn on the axial plane (XY plane), this uses a + * straightforward approach: + *

    + *
  • The curve lies in the XY plane at a fixed Z (the axial slice level)
  • + *
  • At each curve point, the tangent is computed in XY
  • + *
  • The in-plane perpendicular (Z × tangent) is used for MIP slab (depth)
  • + *
  • The vertical (Z) direction is used for height sampling
  • + *
  • Output X = arc-length position along the curve
  • + *
  • Output Y = vertical (Z) position
  • + *
+ */ + private PlanarImage generatePanoramicImage() { + List curvePoints = axis.getCurvePoints3D(); + if (curvePoints.size() < 2) { + return null; + } + + double stepMm = axis.getStepMm(); + double widthMm = axis.getWidthMm(); + double pixelMm = volume.getMinPixelRatio(); + Vector3d voxelRatio = volume.getVoxelRatio(); + + LOGGER.info("=== generatePanoramicImage CPR ==="); + LOGGER.info("curvePoints: {}, stepMm: {}, widthMm: {}, pixelMm: {}", + curvePoints.size(), stepMm, widthMm, pixelMm); + LOGGER.info("volume size: {}x{}x{}, voxelRatio: ({},{},{})", + volume.getSize().x, volume.getSize().y, volume.getSize().z, + voxelRatio.x, voxelRatio.y, voxelRatio.z); + + // Smooth the curve using Catmull-Rom spline interpolation + List smoothedPoints = smoothCurveWithSpline(curvePoints); + + // Resample to uniform arc-length spacing along the curve + List sampledPoints = resampleCurve(smoothedPoints, stepMm, pixelMm); + if (sampledPoints.isEmpty()) { + return null; + } + + // Reverse for dentist view (patient's right on viewer's left) + Collections.reverse(sampledPoints); + + // Compute in-plane perpendicular directions for MIP slab + // For a curve in the XY plane: perp = Z × tangent (gives in-plane perpendicular) + List perpDirs = computePerpendicularDirections( + sampledPoints, axis.getPlaneNormal()); + + // Slab thickness (in mm) for MIP along the perpendicular direction + double slabThicknessMm = 10.0; + int slabSamples = (int) Math.max(1, Math.round(slabThicknessMm / pixelMm)); + + // Vertical extent: height in Z direction (in mm) + double sliceSizeMm = widthMm; + int heightPx = (int) Math.round(sliceSizeMm / pixelMm); + if (heightPx < 1) heightPx = 1; + + int widthPx = sampledPoints.size(); + + // Store debug data for visualization + lastDebugData = new DebugCurveData(curvePoints, smoothedPoints, sampledPoints, perpDirs, slabThicknessMm); + + LOGGER.info("Output: {}x{} px, slab={}mm ({} samples), height={}mm", + widthPx, heightPx, slabThicknessMm, slabSamples, sliceSizeMm); + + int cvType = volume.getCVType(); + ImageCV dst = new ImageCV(heightPx, widthPx, cvType); + + // The Z coordinate of the curve (axial slice level) + double curveZ = sampledPoints.get(0).z; + + // Diagnostic logging + int midI = widthPx / 2; + Vector3d midPt = sampledPoints.get(midI); + Vector3d midPerp = perpDirs.get(midI); + LOGGER.info("Middle curve point[{}]: ({},{},{})", midI, + String.format("%.1f", midPt.x), String.format("%.1f", midPt.y), String.format("%.1f", midPt.z)); + LOGGER.info("Middle perp: ({},{},{})", + String.format("%.3f", midPerp.x), String.format("%.3f", midPerp.y), String.format("%.3f", midPerp.z)); + + // For each point along the curve (horizontal axis of panoramic) + for (int i = 0; i < widthPx; i++) { + Vector3d curvePoint = sampledPoints.get(i); + Vector3d perp = perpDirs.get(i); // in-plane perpendicular for MIP slab + + // For each pixel in the vertical direction (Z axis) + for (int j = 0; j < heightPx; j++) { + // Vertical offset in voxels along Z, centered on the curve's Z level + // Account for voxel ratio: convert pixel offset to voxel offset + double zOffsetVoxels = (j - heightPx / 2.0) / voxelRatio.z; + double sampleZ = curveZ + zOffsetVoxels; + + // MIP along the in-plane perpendicular direction (slab thickness) + double maxValue = Double.NEGATIVE_INFINITY; + for (int k = 0; k < slabSamples; k++) { + double perpOffset = (k - slabSamples / 2.0); + double sampleX = curvePoint.x + perp.x * perpOffset; + double sampleY = curvePoint.y + perp.y * perpOffset; + + Number value = volume.getInterpolatedValueFromSource(sampleX, sampleY, sampleZ); + if (value != null && value.doubleValue() > maxValue) { + maxValue = value.doubleValue(); + } + } + + if (maxValue > Double.NEGATIVE_INFINITY) { + setPixelValue(dst, j, i, maxValue, cvType); + } + } + } + + LOGGER.info("Generated CPR panoramic image"); + + setDicomTags(widthPx, heightPx, pixelMm, stepMm); + return dst; + } + + /** + * Compute a parallel transport frame along the curve. + * + *

This creates a consistent coordinate system at each curve point: + *

    + *
  • Tangent: direction along the curve
  • + *
  • Normal: perpendicular to tangent, smoothly transported along curve
  • + *
  • Binormal: perpendicular to both tangent and normal
  • + *
+ * + *

The parallel transport frame avoids the twisting that occurs with + * Frenet-Serret frames at inflection points. + */ + private void computeParallelTransportFrame( + List points, + List tangents, + List normals, + List binormals) { + + int n = points.size(); + if (n < 2) return; + + // Compute tangent vectors + for (int i = 0; i < n; i++) { + Vector3d tangent; + if (i == 0) { + tangent = new Vector3d(points.get(1)).sub(points.get(0)); + } else if (i == n - 1) { + tangent = new Vector3d(points.get(n - 1)).sub(points.get(n - 2)); + } else { + tangent = new Vector3d(points.get(i + 1)).sub(points.get(i - 1)); + } + tangent.normalize(); + tangents.add(tangent); + } + + // Initialize the first normal using a reference direction + // For dental (curve in XY plane), use Z as the initial binormal reference + Vector3d refUp = new Vector3d(0, 0, 1); + Vector3d firstTangent = tangents.get(0); + + // First normal = refUp × tangent (gives a vector in XY plane, perpendicular to tangent) + Vector3d firstNormal = new Vector3d(refUp).cross(firstTangent); + if (firstNormal.lengthSquared() < 1e-10) { + // Tangent is parallel to Z, use X as reference + firstNormal = new Vector3d(1, 0, 0).cross(firstTangent); + } + firstNormal.normalize(); + + // First binormal = tangent × normal + Vector3d firstBinormal = new Vector3d(firstTangent).cross(firstNormal); + firstBinormal.normalize(); + + normals.add(firstNormal); + binormals.add(firstBinormal); + + // Propagate the frame along the curve using parallel transport + for (int i = 1; i < n; i++) { + Vector3d prevNormal = normals.get(i - 1); + Vector3d prevBinormal = binormals.get(i - 1); + Vector3d prevTangent = tangents.get(i - 1); + Vector3d currTangent = tangents.get(i); + + // Compute the rotation axis and angle between consecutive tangents + Vector3d rotAxis = new Vector3d(prevTangent).cross(currTangent); + double sinAngle = rotAxis.length(); + double cosAngle = prevTangent.dot(currTangent); + + Vector3d newNormal, newBinormal; + + if (sinAngle > 1e-10) { + // Rotate the previous normal and binormal to align with new tangent + rotAxis.normalize(); + double angle = Math.atan2(sinAngle, cosAngle); + + // Rodrigues' rotation formula + newNormal = rotateVector(prevNormal, rotAxis, angle); + newBinormal = rotateVector(prevBinormal, rotAxis, angle); + } else { + // Tangents are parallel, just copy + newNormal = new Vector3d(prevNormal); + newBinormal = new Vector3d(prevBinormal); + } + + // Re-orthogonalize to prevent drift + newBinormal = new Vector3d(currTangent).cross(newNormal); + newBinormal.normalize(); + newNormal = new Vector3d(newBinormal).cross(currTangent); + newNormal.normalize(); + + normals.add(newNormal); + binormals.add(newBinormal); + } + + // Ensure normals point outward from the dental arch + // Check if the middle normal points toward or away from the curve centroid + Vector3d centroid = new Vector3d(0, 0, 0); + for (Vector3d p : points) { + centroid.add(p); + } + centroid.div(n); + + int midIdx = n / 2; + Vector3d toMid = new Vector3d(points.get(midIdx)).sub(centroid); + if (normals.get(midIdx).dot(toMid) < 0) { + // Flip all normals to point outward + for (int i = 0; i < n; i++) { + normals.get(i).negate(); + binormals.get(i).negate(); + } + } + + LOGGER.info("Computed parallel transport frame for {} points", n); + // Log some sample frame values for debugging + int[] sampleIdxs = {0, n/2, n-1}; + for (int idx : sampleIdxs) { + Vector3d t = tangents.get(idx); + Vector3d nn = normals.get(idx); + Vector3d b = binormals.get(idx); + LOGGER.info("Frame[{}]: T=({},{},{}), N=({},{},{}), B=({},{},{})", + idx, + String.format("%.2f", t.x), String.format("%.2f", t.y), String.format("%.2f", t.z), + String.format("%.2f", nn.x), String.format("%.2f", nn.y), String.format("%.2f", nn.z), + String.format("%.2f", b.x), String.format("%.2f", b.y), String.format("%.2f", b.z)); + } + } + + /** + * Rotate a vector around an axis using Rodrigues' rotation formula. + */ + private Vector3d rotateVector(Vector3d v, Vector3d axis, double angle) { + double cos = Math.cos(angle); + double sin = Math.sin(angle); + + // v_rot = v*cos + (axis × v)*sin + axis*(axis·v)*(1-cos) + Vector3d cross = new Vector3d(axis).cross(v); + double dot = axis.dot(v); + + return new Vector3d(v).mul(cos) + .add(new Vector3d(cross).mul(sin)) + .add(new Vector3d(axis).mul(dot * (1 - cos))); + } + + /** + * Compute the perpendicular direction to the curve tangent at each point. + * The perpendicular is computed in the plane defined by planeNormal (typically XY plane). + * This direction is used for the slab thickness in MIP. + * + *

Perpendicular directions are kept consistent along the curve by ensuring + * each direction doesn't flip relative to its predecessor. This prevents + * sudden direction changes that would cause sampling artifacts. + * + * @param sampledPoints the resampled curve points + * @param planeNormal the normal of the source plane (e.g., Z for axial) + * @return list of unit perpendicular directions at each point + */ + private List computePerpendicularDirections( + List sampledPoints, Vector3d planeNormal) { + List perpDirs = new ArrayList<>(); + int n = sampledPoints.size(); + + Vector3d prevPerp = null; + + for (int i = 0; i < n; i++) { + Vector3d tangent; + if (i == 0) { + tangent = new Vector3d(sampledPoints.get(1)).sub(sampledPoints.get(0)); + } else if (i == n - 1) { + tangent = new Vector3d(sampledPoints.get(n - 1)).sub(sampledPoints.get(n - 2)); + } else { + tangent = new Vector3d(sampledPoints.get(i + 1)).sub(sampledPoints.get(i - 1)); + } + + // Compute perpendicular in the plane: perp = planeNormal × tangent + Vector3d perp = new Vector3d(planeNormal).cross(tangent); + + if (perp.lengthSquared() > 1e-10) { + perp.normalize(); + } else { + perp = (prevPerp != null) ? new Vector3d(prevPerp) : new Vector3d(1, 0, 0); + } + + // Ensure consistency with previous perpendicular (no sudden flips) + if (prevPerp != null && perp.dot(prevPerp) < 0) { + perp.negate(); + } + + perpDirs.add(perp); + prevPerp = perp; + } + + // Now determine if we need to flip ALL directions to point "outward" + // Use the curve's overall shape: for a dental arch, the middle of the curve + // should have perpendiculars pointing "forward" (away from the throat) + // We check by looking at the middle point's perpendicular relative to curve center + if (n >= 3) { + Vector3d centroid = new Vector3d(0, 0, 0); + for (Vector3d p : sampledPoints) { + centroid.add(p); + } + centroid.div(n); + + int midIdx = n / 2; + Vector3d midPoint = sampledPoints.get(midIdx); + Vector3d midPerp = perpDirs.get(midIdx); + Vector3d toMid = new Vector3d(midPoint).sub(centroid); + + // If middle perpendicular points inward (toward centroid), flip all + if (midPerp.dot(toMid) < 0) { + LOGGER.info("Flipping all perpendiculars to point outward"); + for (Vector3d p : perpDirs) { + p.negate(); + } + } + } + + return perpDirs; + } + + /** + * Smooth the curve using Catmull-Rom spline interpolation. + * This converts rough user-drawn polylines into smooth curves. + * + *

The number of samples per segment is proportional to the segment length + * to ensure uniform sampling density along the entire curve. + * + * @param points the original control points + * @return smoothed curve with many more points + */ + private List smoothCurveWithSpline(List points) { + if (points.size() < 2) return new ArrayList<>(points); + if (points.size() == 2) return new ArrayList<>(points); + + List result = new ArrayList<>(); + + // Calculate segment lengths to determine proportional sampling + double[] segmentLengths = new double[points.size() - 1]; + double totalLength = 0; + for (int i = 0; i < points.size() - 1; i++) { + segmentLengths[i] = points.get(i).distance(points.get(i + 1)); + totalLength += segmentLengths[i]; + } + + // Target: approximately 1 sample per voxel along the curve + // Use a base density that gives good smoothing + double samplesPerVoxel = 2.0; + + for (int i = 0; i < points.size() - 1; i++) { + // Get 4 control points for Catmull-Rom (with clamping at endpoints) + Vector3d p0 = points.get(Math.max(0, i - 1)); + Vector3d p1 = points.get(i); + Vector3d p2 = points.get(i + 1); + Vector3d p3 = points.get(Math.min(points.size() - 1, i + 2)); + + // Number of samples proportional to segment length + int segmentSamples = Math.max(2, (int) Math.round(segmentLengths[i] * samplesPerVoxel)); + + // Generate points along this segment + for (int j = 0; j < segmentSamples; j++) { + double t = (double) j / segmentSamples; + Vector3d interpolated = catmullRom(p0, p1, p2, p3, t); + result.add(interpolated); + } + } + + // Add the last point + result.add(new Vector3d(points.get(points.size() - 1))); + + LOGGER.info("Smoothed curve: {} input points -> {} output points (total length: {} voxels)", + points.size(), result.size(), totalLength); + + return result; + } + + /** + * Catmull-Rom spline interpolation between p1 and p2. + * + * @param p0 control point before p1 + * @param p1 start point of segment + * @param p2 end point of segment + * @param p3 control point after p2 + * @param t interpolation parameter [0, 1] + * @return interpolated point + */ + private Vector3d catmullRom(Vector3d p0, Vector3d p1, Vector3d p2, Vector3d p3, double t) { + double t2 = t * t; + double t3 = t2 * t; + + // Catmull-Rom basis functions + double b0 = -0.5 * t3 + t2 - 0.5 * t; + double b1 = 1.5 * t3 - 2.5 * t2 + 1.0; + double b2 = -1.5 * t3 + 2.0 * t2 + 0.5 * t; + double b3 = 0.5 * t3 - 0.5 * t2; + + return new Vector3d( + b0 * p0.x + b1 * p1.x + b2 * p2.x + b3 * p3.x, + b0 * p0.y + b1 * p1.y + b2 * p2.y + b3 * p3.y, + b0 * p0.z + b1 * p1.z + b2 * p2.z + b3 * p3.z + ); + } + + /** + * Resample the curve to have evenly-spaced points. + * Points are in voxel coordinates. We resample at 1-voxel intervals. + */ + private List resampleCurve(List points, double stepMm, double pixelMm) { + List result = new ArrayList<>(); + if (points.size() < 2) return result; + + // Calculate total length in voxels + double totalLengthVoxels = 0; + for (int i = 1; i < points.size(); i++) { + totalLengthVoxels += points.get(i).distance(points.get(i - 1)); + } + + if (totalLengthVoxels <= 0) return result; + + // Resample at 1-voxel step intervals for smooth output + double stepVoxels = 1.0; + int numSamples = (int) Math.ceil(totalLengthVoxels / stepVoxels); + + LOGGER.info("Resampling: totalLength={} voxels, numSamples={}", totalLengthVoxels, numSamples); + + for (int i = 0; i <= numSamples; i++) { + double targetDist = i * stepVoxels; + Vector3d point = interpolateAlongCurve(points, targetDist); + if (point != null) { + result.add(point); + } + } + return result; + } + + /** + * Interpolate along the curve to find the point at a given distance (in voxels). + */ + private Vector3d interpolateAlongCurve(List points, double targetDistVoxels) { + double accumulated = 0; + for (int i = 1; i < points.size(); i++) { + Vector3d p0 = points.get(i - 1); + Vector3d p1 = points.get(i); + double segmentLength = p0.distance(p1); + if (accumulated + segmentLength >= targetDistVoxels) { + double remaining = targetDistVoxels - accumulated; + double t = segmentLength > 0 ? remaining / segmentLength : 0; + return new Vector3d(p0).lerp(p1, t); + } + accumulated += segmentLength; + } + return points.isEmpty() ? null : new Vector3d(points.get(points.size() - 1)); + } + + private void setPixelValue(ImageCV dst, int row, int col, Number value, int cvType) { + int depth = CvType.depth(cvType); + switch (depth) { + case CvType.CV_8U, CvType.CV_8S -> dst.put(row, col, value.byteValue()); + case CvType.CV_16U, CvType.CV_16S -> dst.put(row, col, value.shortValue()); + case CvType.CV_32S -> dst.put(row, col, value.intValue()); + case CvType.CV_32F -> dst.put(row, col, value.floatValue()); + case CvType.CV_64F -> dst.put(row, col, value.doubleValue()); + } + } + + private void setDicomTags(int widthPx, int heightPx, double pixelMm, double stepMm) { + HEADER_CACHE.remove(this); + tags.put(TagD.get(Tag.Columns), widthPx); + tags.put(TagD.get(Tag.Rows), heightPx); + tags.put(TagD.get(Tag.SliceThickness), pixelMm); + tags.put(TagD.get(Tag.PixelSpacing), new double[]{pixelMm, stepMm}); + tags.put(TagD.get(Tag.SOPInstanceUID), UIDUtils.createUID()); + tags.put(TagD.get(Tag.InstanceNumber), 1); + } + + @Override + public URI getUri() { + return uri; + } + + @Override + public MediaElement getPreview() { + return null; + } + + @Override + public boolean delegate(DataExplorerModel explorerModel) { + return false; + } + + @Override + public DicomImageElement[] getMediaElement() { + return null; + } + + @Override + public DicomSeries getMediaSeries() { + return null; + } + + @Override + public int getMediaElementNumber() { + return 1; + } + + @Override + public String getMediaFragmentMimeType() { + return MIME_TYPE; + } + + @Override + public Map getMediaFragmentTags(Object key) { + return tags; + } + + @Override + public void close() { + HEADER_CACHE.remove(this); + } + + @Override + public Codec getCodec() { + return null; + } + + @Override + public String[] getReaderDescription() { + return new String[]{"Curved MPR Image Decoder"}; + } + + @Override + public Object getTagValue(TagW tag) { + return tag == null ? null : tags.get(tag); + } + + @Override + public void setTag(TagW tag, Object value) { + DicomMediaUtils.setTag(tags, tag, value); + } + + @Override + public void setTagNoNull(TagW tag, Object value) { + if (value != null) { + setTag(tag, value); + } + } + + @Override + public boolean containTagKey(TagW tag) { + return tags.containsKey(tag); + } + + @Override + public Iterator> getTagEntrySetIterator() { + return tags.entrySet().iterator(); + } + + public void copyTags(TagW[] tagList, MediaElement media, boolean allowNullValue) { + if (tagList != null && media != null) { + for (TagW tag : tagList) { + Object value = media.getTagValue(tag); + if (allowNullValue || value != null) { + tags.put(tag, value); + } + } + } + } + + @Override + public void replaceURI(URI uri) { + throw new UnsupportedOperationException(); + } + + @Override + public Attributes getDicomObject() { + Attributes dcm = new Attributes(tags.size() + (attributes != null ? attributes.size() : 0)); + if (attributes != null) { + SpecificCharacterSet cs = attributes.getSpecificCharacterSet(); + dcm.setSpecificCharacterSet(cs.toCodes()); + dcm.addAll(attributes); + } + DicomMediaUtils.fillAttributes(tags, dcm); + return dcm; + } + + @Override + public FileCache getFileCache() { + return fileCache; + } + + @Override + public boolean buildFile(File output) { + return false; + } + + @Override + public DicomMetaData getDicomMetaData() { + return readMetaData(); + } + + @Override + public boolean isEditableDicom() { + return false; + } + + @Override + public void writeMetaData(MediaSeriesGroup group) { + DcmMediaReader.super.writeMetaData(group); + } + + private synchronized DicomMetaData readMetaData() { + DicomMetaData header = HEADER_CACHE.get(this); + if (header != null) { + return header; + } + Attributes dcm = getDicomObject(); + header = new DicomMetaData(dcm, UID.ImplicitVRLittleEndian); + org.dcm4che3.img.stream.ImageDescriptor desc = header.getImageDescriptor(); + if (desc != null) { + org.opencv.core.Core.MinMaxLocResult minMax = new org.opencv.core.Core.MinMaxLocResult(); + minMax.minVal = volume.getMinimum(); + minMax.maxVal = volume.getMaximum(); + desc.setMinMaxPixelValue(0, minMax); + } + HEADER_CACHE.put(this, header); + return header; + } +} diff --git a/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprView.java b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprView.java new file mode 100644 index 000000000..b30740b6d --- /dev/null +++ b/weasis-dicom/weasis-dicom-viewer2d/src/main/java/org/weasis/dicom/viewer2d/mpr/cmpr/CurvedMprView.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2024 Weasis Team and other contributors. + * + * This program and the accompanying materials are made available under the terms of the Eclipse + * Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0, or the Apache + * License, Version 2.0 which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package org.weasis.dicom.viewer2d.mpr.cmpr; + +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.FontMetrics; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.weasis.core.api.gui.util.DecFormatter; +import org.weasis.core.api.gui.util.GuiUtils; +import org.weasis.core.ui.editor.image.ImageViewerEventManager; +import org.weasis.core.util.StringUtil; +import org.weasis.dicom.codec.DicomImageElement; +import org.weasis.dicom.viewer2d.View2d; + +/** + * 2D view for displaying curved MPR panoramic images. + * + *

This view extends View2d and provides the standard WL/LUT/zoom/pan controls + * for the panoramic image generated from a curved MPR path. + */ +public class CurvedMprView extends View2d { + private static final Logger LOGGER = LoggerFactory.getLogger(CurvedMprView.class); + + private CurvedMprAxis curvedMprAxis; + + public CurvedMprView(ImageViewerEventManager eventManager) { + super(eventManager); + LOGGER.info("CurvedMprView constructor called"); + getViewButtons().clear(); + } + + @Override + protected void initActionWState() { + super.initActionWState(); + } + + public CurvedMprAxis getCurvedMprAxis() { + return curvedMprAxis; + } + + public void setCurvedMprAxis(CurvedMprAxis axis) { + this.curvedMprAxis = axis; + if (axis != null) { + axis.setView(this); + } + } + + @Override + protected void setImage(DicomImageElement img) { + LOGGER.info("CurvedMprView.setImage called, img={}", img != null ? "not null" : "null"); + super.setImage(img); + LOGGER.info("super.setImage completed"); + } + + /** + * Show a dialog to adjust the panoramic width parameter. + */ + public void showWidthDialog() { + if (curvedMprAxis == null) return; + + double currentWidth = curvedMprAxis.getWidthMm(); + double maxWidth = 200.0; + double minPixelRatio = curvedMprAxis.getVolume().getMinPixelRatio(); + + JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEADING)); + SpinnerNumberModel spinnerModel = + new SpinnerNumberModel(currentWidth, 1.0, maxWidth, minPixelRatio); + JSpinner widthSpinner = new JSpinner(spinnerModel); + JLabel conversionLabel = new JLabel(); + + widthSpinner.addChangeListener( + e -> { + double value = (Double) widthSpinner.getValue(); + int pixels = (int) Math.round(value / minPixelRatio); + conversionLabel.setText( + " %.1f mm = %d pixels".formatted(value, pixels)); + }); + + panel.add(new JLabel("Width (mm)" + StringUtil.COLON)); + panel.add(widthSpinner); + panel.add(conversionLabel); + + FontMetrics metrics = conversionLabel.getFontMetrics(conversionLabel.getFont()); + String maxExpectedLabel = " %.1f mm = %d pixels".formatted(maxWidth, (int) (maxWidth / minPixelRatio)); + conversionLabel.setPreferredSize( + new Dimension(metrics.stringWidth(maxExpectedLabel), metrics.getHeight())); + + widthSpinner.setValue(currentWidth); + + int result = + JOptionPane.showConfirmDialog( + GuiUtils.getUICore().getApplicationWindow(), + panel, + "Adjust Panoramic Width", + JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE); + + if (result == JOptionPane.OK_OPTION) { + double newWidth = (Double) widthSpinner.getValue(); + curvedMprAxis.setWidthMm(newWidth); + } + } + + /** + * Show a dialog to adjust the sampling step parameter. + */ + public void showStepDialog() { + if (curvedMprAxis == null) return; + + double currentStep = curvedMprAxis.getStepMm(); + double minPixelRatio = curvedMprAxis.getVolume().getMinPixelRatio(); + double maxStep = 10.0; + + JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEADING)); + SpinnerNumberModel spinnerModel = + new SpinnerNumberModel(currentStep, minPixelRatio / 2, maxStep, minPixelRatio / 4); + JSpinner stepSpinner = new JSpinner(spinnerModel); + JLabel infoLabel = new JLabel(); + + stepSpinner.addChangeListener( + e -> { + double value = (Double) stepSpinner.getValue(); + double totalArcLength = curvedMprAxis.getTotalArcLengthMm(); + int samples = (int) Math.ceil(totalArcLength / value); + infoLabel.setText(" Step: %s mm (%d samples)" + .formatted(DecFormatter.allNumber(value), samples)); + }); + + panel.add(new JLabel("Step (mm)" + StringUtil.COLON)); + panel.add(stepSpinner); + panel.add(infoLabel); + + FontMetrics metrics = infoLabel.getFontMetrics(infoLabel.getFont()); + String maxExpectedLabel = " Step: %s mm (%d samples)" + .formatted(DecFormatter.allNumber(maxStep), 9999); + infoLabel.setPreferredSize( + new Dimension(metrics.stringWidth(maxExpectedLabel), metrics.getHeight())); + + stepSpinner.setValue(currentStep); + + int result = + JOptionPane.showConfirmDialog( + GuiUtils.getUICore().getApplicationWindow(), + panel, + "Adjust Sampling Step", + JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE); + + if (result == JOptionPane.OK_OPTION) { + double newStep = (Double) stepSpinner.getValue(); + curvedMprAxis.setStepMm(newStep); + } + } + +}