dicom-microscopy-viewer
Version:
Interactive web-based viewer for DICOM Microscopy Images
538 lines (468 loc) • 14.3 kB
JavaScript
import PointGeometry from 'ol/geom/Point'
import PolygonGeometry from 'ol/geom/Polygon'
import LineStringGeometry from 'ol/geom/LineString'
import CircleGeometry from 'ol/geom/Circle.js'
import Feature from 'ol/Feature'
import { getTopLeft, getBottomRight } from 'ol/extent'
import { _getCoordinates, _getPoint } from '../annotation'
import {
_scoord3dCoordinates2geometryCoordinates,
_geometryCoordinates2scoord3dCoordinates,
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 && 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(Math.pow(majorAxisEnd[0] - majorAxisStart[0], 2) + Math.pow(majorAxisEnd[1] - majorAxisStart[1], 2)) / 2
const semiMinorAxis = Math.sqrt(Math.pow(minorAxisEnd[0] - minorAxisStart[0], 2) + Math.pow(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
}