dicom-microscopy-viewer
Version:
Interactive web-based viewer for DICOM Microscopy Images
573 lines (503 loc) • 14.4 kB
JavaScript
import { getBottomRight, getTopLeft } from 'ol/extent'
import Feature from 'ol/Feature'
import CircleGeometry from 'ol/geom/Circle.js'
import LineStringGeometry from 'ol/geom/LineString'
import PointGeometry from 'ol/geom/Point'
import PolygonGeometry from 'ol/geom/Polygon'
import { _getCoordinates, _getPoint } from '../annotation'
import {
_geometryCoordinates2scoord3dCoordinates,
_scoord3dCoordinates2geometryCoordinates,
getPixelSpacing,
} from '../scoord3dUtils'
import { buildTransform, mapPixelCoordToSlideCoord } from '../utils'
/**
* Get viewport bounding box
*
* @param {*} view
* @returns
*/
export const getViewportBoundingBox = ({ view, pyramid, affine }) => {
const visibleExtent = view.calculateExtent()
const topLeft = getTopLeft(visibleExtent)
const bottomRight = getBottomRight(visibleExtent)
const scoord3DCoords = _geometryCoordinates2scoord3dCoordinates(
[topLeft, bottomRight],
pyramid,
affine,
)
return {
topLeft: scoord3DCoords[0],
bottomRight: scoord3DCoords[1],
}
}
/**
* Check if coordinate is inside bounding box
*
* @param {*} coordinate
* @param {*} topLeft
* @param {*} bottomRight
* @returns
*/
export const isCoordinateInsideBoundingBox = (
coordinate,
topLeft,
bottomRight,
) => {
const minX = Math.min(topLeft[0], bottomRight[0])
const maxX = Math.max(topLeft[0], bottomRight[0])
const minY = Math.min(topLeft[1], bottomRight[1])
const maxY = Math.max(topLeft[1], bottomRight[1])
return (
coordinate[0] >= minX &&
coordinate[0] <= maxX &&
coordinate[1] >= minY &&
coordinate[1] <= maxY
)
}
/**
* Calculates the affine transformation matrix based on the pyramid level.
*
* @param {Object} options - The options object.
* @param {Object} options.affine - The initial affine transformation matrix.
* @param {Array} options.pyramid - The array of pyramid metadata.
* @param {Object} options.annotationGroup - The annotation group metadata.
* @returns {Array} - The affine transformation matrix.
*/
const getAffineBasedOnPyramidLevel = ({ affine, pyramid, annotationGroup }) => {
const { ReferencedSOPInstanceUID } =
annotationGroup.metadata.ReferencedImageSequence[0]
const currentPyramidMetadata = pyramid.find(
(p) => p.SOPInstanceUID === ReferencedSOPInstanceUID,
)
if (currentPyramidMetadata?.ImageOrientationSlide) {
const orientation = currentPyramidMetadata.ImageOrientationSlide
const spacing = getPixelSpacing(currentPyramidMetadata)
const origin = currentPyramidMetadata.TotalPixelMatrixOriginSequence[0]
const offset = [
Number(origin.XOffsetInSlideCoordinateSystem),
Number(origin.YOffsetInSlideCoordinateSystem),
]
return buildTransform({
offset,
orientation,
spacing,
})
}
return affine
}
/**
* Get circle feature from bulk data annotation data
*
* @param {*} param0
* @returns
*/
export const getCircleFeature = ({
graphicData,
annotationIndex,
pyramid,
affine,
affineInverse,
commonZCoordinate,
coordinateDimensionality,
annotationCoordinateType,
annotationGroup,
}) => {
const length = coordinateDimensionality * 4
const offset = annotationIndex * length
const coordinates = []
for (let j = offset; j < offset + length; j++) {
let coordinate = _getCoordinates(graphicData, j, commonZCoordinate)
if (annotationCoordinateType === '2D') {
coordinate = mapPixelCoordToSlideCoord({
point: [coordinate[0], coordinate[1]],
affine: getAffineBasedOnPyramidLevel({
affine,
pyramid,
annotationGroup,
}),
})
}
coordinate = _scoord3dCoordinates2geometryCoordinates(
coordinate,
affineInverse,
)
coordinates.push(coordinate)
j += coordinateDimensionality - 1
}
const majorAxisCoordinates = coordinates.slice(0, 2)
const point1 = majorAxisCoordinates[0]
const point2 = majorAxisCoordinates[1]
let coords = [
[
(point1[0] + point2[0]) / parseFloat(2),
(point1[1] + point2[1]) / parseFloat(2),
0,
],
point2,
]
/** To flat coordinates */
coords = [...coords[0].slice(0, 2), ...coords[1].slice(0, 2)]
/**
* flat coordinates in combination with opt_layout and no
* opt_radius are also accepted and internally it calculates the Radius
*/
return new Feature({
geometry: new CircleGeometry(coords, null, 'XY'),
})
}
/**
* Get ellipse feature from bulk data annotation data
*
* @param {*} param0
* @returns
*/
export const getEllipseFeature = ({
graphicData,
annotationIndex,
pyramid,
affine,
affineInverse,
commonZCoordinate,
coordinateDimensionality,
annotationCoordinateType,
annotationGroup,
}) => {
const length = coordinateDimensionality * 4
const offset = annotationIndex * length
const coordinates = []
for (let j = offset; j < offset + length; j++) {
let coordinate = _getCoordinates(graphicData, j, commonZCoordinate)
if (annotationCoordinateType === '2D') {
coordinate = mapPixelCoordToSlideCoord({
point: [coordinate[0], coordinate[1]],
affine: getAffineBasedOnPyramidLevel({
affine,
pyramid,
annotationGroup,
}),
})
}
coordinate = _scoord3dCoordinates2geometryCoordinates(
coordinate,
affineInverse,
)
coordinates.push(coordinate)
j += coordinateDimensionality - 1
}
function calculateEllipsePoints(
majorAxisStart,
majorAxisEnd,
minorAxisStart,
minorAxisEnd,
) {
// Calculate semi-major and semi-minor axis lengths
const semiMajorAxis =
Math.sqrt(
(majorAxisEnd[0] - majorAxisStart[0]) ** 2 +
(majorAxisEnd[1] - majorAxisStart[1]) ** 2,
) / 2
const semiMinorAxis =
Math.sqrt(
(minorAxisEnd[0] - minorAxisStart[0]) ** 2 +
(minorAxisEnd[1] - minorAxisStart[1]) ** 2,
) / 2
// Calculate rotation angle in radians
const rotationAngle = Math.atan2(
majorAxisEnd[1] - majorAxisStart[1],
majorAxisEnd[0] - majorAxisStart[0],
)
// Generate points on the ellipse
const ellipsePoints = []
for (let i = 0; i <= 360; i++) {
// Calculate the parameter 't' representing the angle
const angle = (2 * Math.PI * i) / 360
// Calculate coordinates based on parametric equations for an ellipse
const x = semiMajorAxis * Math.cos(angle)
const y = semiMinorAxis * Math.sin(angle)
// Rotate the points to match the orientation of the ellipse
const rotatedX = x * Math.cos(rotationAngle) - y * Math.sin(rotationAngle)
const rotatedY = x * Math.sin(rotationAngle) + y * Math.cos(rotationAngle)
// Translate the points to the center of the ellipse
const centerX = (majorAxisStart[0] + majorAxisEnd[0]) / 2
const centerY = (majorAxisStart[1] + majorAxisEnd[1]) / 2
// Add the rotated and translated points to the result array
ellipsePoints.push([centerX + rotatedX, centerY + rotatedY])
}
return ellipsePoints
}
const majorAxisFirstEndpoint = coordinates[0]
const majorAxisSecondEndpoint = coordinates[1]
const minorAxisFirstEndpoint = coordinates[2]
const minorAxisSecondEndpoint = coordinates[3]
const points = calculateEllipsePoints(
majorAxisFirstEndpoint,
majorAxisSecondEndpoint,
minorAxisFirstEndpoint,
minorAxisSecondEndpoint,
)
return new Feature({
geometry: new PolygonGeometry([points]),
})
}
/**
* Get rectangle feature from bulk data annotation data
*
* @param {*} param0
* @returns
*/
export const getRectangleFeature = ({
graphicData,
annotationIndex,
pyramid,
affine,
affineInverse,
commonZCoordinate,
coordinateDimensionality,
annotationCoordinateType,
annotationGroup,
}) => {
const length = coordinateDimensionality * 4
const offset = annotationIndex * length
const coordinates = []
for (let j = offset; j < offset + length; j++) {
let coordinate = _getCoordinates(graphicData, j, commonZCoordinate)
if (annotationCoordinateType === '2D') {
coordinate = mapPixelCoordToSlideCoord({
point: [coordinate[0], coordinate[1]],
affine: getAffineBasedOnPyramidLevel({
affine,
pyramid,
annotationGroup,
}),
})
}
coordinate = _scoord3dCoordinates2geometryCoordinates(
coordinate,
affineInverse,
)
coordinates.push(coordinate)
j += coordinateDimensionality - 1
}
return new Feature({
geometry: new PolygonGeometry([coordinates]),
})
}
/**
* Get polygon feature from bulk data annotation data
*
* @param {*} param0
* @returns
*/
export const getPolygonFeature = ({
graphicIndex,
graphicData,
graphicType,
numberOfAnnotations,
annotationIndex,
pyramid,
affine,
affineInverse,
commonZCoordinate,
coordinateDimensionality,
annotationGroup,
annotationCoordinateType,
}) => {
const offset = graphicIndex[annotationIndex] - 1
const Geometry =
graphicType === 'POLYLINE' ? LineStringGeometry : PolygonGeometry
let annotationLength
if (annotationIndex < numberOfAnnotations - 1) {
annotationLength = graphicIndex[annotationIndex + 1] - offset
} else {
annotationLength = graphicData.length
}
const polygonCoordinates = []
const roof = offset + annotationLength
for (let j = offset; j < roof; j++) {
let coordinate = _getCoordinates(
graphicData,
j === offset + annotationLength - 1 ? offset : j,
commonZCoordinate,
)
if (!coordinate || !coordinate[0] || !coordinate[1]) {
continue
}
if (annotationCoordinateType === '2D') {
coordinate = mapPixelCoordToSlideCoord({
point: [coordinate[0], coordinate[1]],
affine: getAffineBasedOnPyramidLevel({
affine,
pyramid,
annotationGroup,
}),
})
}
coordinate = _scoord3dCoordinates2geometryCoordinates(
coordinate,
affineInverse,
)
polygonCoordinates.push(coordinate)
/** Jump to the next point: (x, y) if 2 or (x, y, z) if 3 */
j += coordinateDimensionality - 1
}
return new Feature({
geometry: new Geometry(
graphicType === 'POLYLINE' ? polygonCoordinates : [polygonCoordinates],
),
})
}
/**
* Get point feature from bulk data annotation data
*
* @param {*} param0
* @returns
*/
export const getPointFeature = ({
graphicIndex,
graphicData,
numberOfAnnotations,
annotationIndex,
pyramid,
affine,
affineInverse,
commonZCoordinate,
coordinateDimensionality,
annotationGroup,
annotationCoordinateType,
}) => {
let coordinate
if (graphicIndex) {
const offset = graphicIndex[annotationIndex] - 1
coordinate = _getCoordinates(graphicData, offset, commonZCoordinate)
} else {
coordinate = _getPoint(
graphicData,
graphicIndex,
coordinateDimensionality,
commonZCoordinate,
annotationIndex,
numberOfAnnotations,
)
}
if (annotationCoordinateType === '2D') {
coordinate = mapPixelCoordToSlideCoord({
point: [coordinate[0], coordinate[1]],
affine: getAffineBasedOnPyramidLevel({
affine,
pyramid,
annotationGroup,
}),
})
}
coordinate = _scoord3dCoordinates2geometryCoordinates(
coordinate,
affineInverse,
)
return new Feature({
geometry: new PointGeometry(coordinate),
})
}
const HIGH_RES_GRAPHIC_TYPES = ['POLYLINE', 'POLYGON', 'ELLIPSE', 'RECTANGLE']
export const getFeaturesFromBulkAnnotations = ({
graphicType,
graphicData,
graphicIndex,
measurements,
commonZCoordinate,
coordinateDimensionality,
numberOfAnnotations,
annotationGroupUID,
annotationGroup,
pyramid,
affine,
affineInverse,
view,
featureFunction,
isHighResolution,
}) => {
const annotationCoordinateType =
annotationGroup.metadata.AnnotationCoordinateType
console.info('create features from bulk annotations')
console.info('coordinate dimensionality:', coordinateDimensionality)
const { topLeft, bottomRight } = getViewportBoundingBox({
view,
pyramid,
affine,
})
const cachedAffine = getAffineBasedOnPyramidLevel({
affine,
pyramid,
annotationGroup,
})
const features = []
for (
let annotationIndex = 0;
annotationIndex < numberOfAnnotations;
annotationIndex++
) {
if (isHighResolution && HIGH_RES_GRAPHIC_TYPES.includes(graphicType)) {
const length = coordinateDimensionality * 4
const offset = ['POLYGON', 'POLYLINE'].includes(graphicType)
? graphicIndex[annotationIndex] - 1
: annotationIndex * length
let firstCoordinate = _getCoordinates(
graphicData,
offset,
commonZCoordinate,
)
if (!firstCoordinate || !firstCoordinate[0] || !firstCoordinate[1]) {
continue
}
if (annotationCoordinateType === '2D') {
firstCoordinate = mapPixelCoordToSlideCoord({
point: [firstCoordinate[0], firstCoordinate[1]],
affine: cachedAffine,
})
}
if (
!isCoordinateInsideBoundingBox(firstCoordinate, topLeft, bottomRight)
) {
continue
}
}
const feature = featureFunction({
graphicType,
graphicIndex,
graphicData,
numberOfAnnotations,
annotationIndex,
annotationGroup,
pyramid,
affine,
affineInverse,
commonZCoordinate,
coordinateDimensionality,
annotationCoordinateType,
})
feature.setId(`${annotationGroupUID}-${annotationIndex}`)
feature.set('annotationGroupUID', annotationGroupUID, true)
measurements.forEach((measurement, measurementIndex) => {
const key = `measurementValue${measurementIndex}`
const value = measurement.values[annotationIndex]
/**
* Needed for the WebGL renderer. This is required for the point layer which uses webgl
* so it might not be required for other layers e.g. vector layer.
*/
feature.set(key, value, true)
})
features.push(feature)
}
console.debug('num of features:', features.length)
return features
}
export default {
getFeaturesFromBulkAnnotations,
getPointFeature,
getCircleFeature,
getEllipseFeature,
getRectangleFeature,
getPolygonFeature,
isCoordinateInsideBoundingBox,
getViewportBoundingBox,
}