Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[VIEWER-167] Heatmap Viewer 스펙 변경 #422

Merged
merged 10 commits into from
Oct 31, 2024
13 changes: 0 additions & 13 deletions apps/insight-viewer-dev/containers/Basic/DicomImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,6 @@ export default function DicomImageViewer(): JSX.Element {
const { ImageSelect, selected } = useImageSelect()
const { loadingState, image } = useImage({
wadouri: selected,
loaderOptions: {
webWorkerManagerOptions: {
webWorkerTaskPaths: [
`${window.location.origin}/workers/610.bundle.min.worker.js`,
`${window.location.origin}/workers/888.bundle.min.worker.js`,
],
taskConfiguration: {
decodeTask: {
initializeCodecsOnStartup: false,
},
},
},
},
})

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { useRef } from 'react'
import { Box, Stack, Switch, Text } from '@chakra-ui/react'
import { Resizable } from 're-resizable'
import InsightViewer, { useImage, useInteraction, HeatmapViewer } from '@lunit/insight-viewer'
import InsightViewer, { useImage, useInteraction, HeatmapViewer, CXR4HeatmapViewer } from '@lunit/insight-viewer'
import { useViewport } from '@lunit/insight-viewer/viewport'
import { IMAGES } from '@insight-viewer-library/fixtures'
import OverlayLayer from '../../../components/OverlayLayer'
Expand Down Expand Up @@ -71,25 +71,47 @@ function HeatmapContainer(): JSX.Element {
</Box>
</Stack>
</Box>
<Box data-cy-loaded={loadingState}>
<Resizable
style={style}
defaultSize={{
width: 500,
height: 500,
}}
>
<InsightViewer
viewerRef={viewerRef}
image={image}
viewport={viewport}
onViewportChange={setViewport}
interaction={interaction}
<Box style={{ display: 'flex', gap: 12, width: '100%', height: '100%' }}>
<Box data-cy-loaded={loadingState}>
<Resizable
style={style}
defaultSize={{
width: 500,
height: 500,
}}
>
{loadingState === 'success' && <HeatmapViewer posMap={posMap} threshold={0.15} />}
<OverlayLayer viewport={viewport} />
</InsightViewer>
</Resizable>
<InsightViewer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각 뷰어 위에다 작게 이름(HeatmapViewer) 써주면 좋겠네요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀해주신 것처럼 각 Heatmap 의 title 을 추가했습니다.

c5634c7
스크린샷 2024-10-31 오전 11 05 14

viewerRef={viewerRef}
image={image}
viewport={viewport}
onViewportChange={setViewport}
interaction={interaction}
>
{loadingState === 'success' && <HeatmapViewer posMap={posMap} threshold={0.15} />}
<OverlayLayer viewport={viewport} />
</InsightViewer>
</Resizable>
</Box>
<Box data-cy-loaded={loadingState}>
<Resizable
style={style}
defaultSize={{
width: 500,
height: 500,
}}
>
<InsightViewer
viewerRef={viewerRef}
image={image}
viewport={viewport}
onViewportChange={setViewport}
interaction={interaction}
>
{loadingState === 'success' && <CXR4HeatmapViewer posMap={posMap} threshold={0.15} />}
<OverlayLayer viewport={viewport} />
</InsightViewer>
</Resizable>
</Box>
</Box>
</>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { CSSProperties } from 'react'

export const style: CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
filter: 'blur(3.5px)',
} as const
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { OverlayContext } from '../../contexts'

export interface HeatmapViewerProps {
/** Heatmap data format resulting from AI */
posMap: number[][]

/** Threshold value (CXR = 0.15, MMG = 0.1) */
threshold: number
}

export interface HeatmapDrawProps extends Pick<OverlayContext, 'setToPixelCoordinateSystem' | 'enabledElement'> {
baseCanvas: HTMLCanvasElement | null
heatmapData: ImageData | undefined
}
12 changes: 12 additions & 0 deletions libs/insight-viewer/src/Viewer/CXR4HeatmapViewer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React, { ReactElement } from 'react'

import useHeatmapDrawing from './useHeatmapDrawing'
import { style } from './HeatmapViewer.styles'

import type { HeatmapViewerProps } from './HeatmapViewer.types'

export function CXR4HeatmapViewer(props: HeatmapViewerProps): ReactElement<HTMLCanvasElement> {
const [canvasRef] = useHeatmapDrawing(props)

return <canvas data-cy-name="heatmap-canvas" ref={canvasRef} style={style} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* eslint-disable no-param-reassign */
import { useEffect, useMemo, useRef, RefObject } from 'react'

import { HeatmapViewerProps } from './HeatmapViewer.types'
import { useOverlayContext } from '../../contexts'

import drawHeatmap from '../../utils/CXR4HeatmapViewer/drawHeatmap'
import getHeatmapImageData from '../../utils/CXR4HeatmapViewer/getHeatmapImageData'

function useHeatmapDrawing({ posMap, threshold }: HeatmapViewerProps): [RefObject<HTMLCanvasElement>] {
const canvasRef = useRef<HTMLCanvasElement>(null)
const baseCanvas = canvasRef?.current
const { enabledElement, setToPixelCoordinateSystem } = useOverlayContext()
const { heatmapData, heatmapCanvas } = useMemo(
() =>
getHeatmapImageData({
posMap,
threshold,
canvas: baseCanvas,
}),
[posMap, threshold, baseCanvas]
)

useEffect(() => {
drawHeatmap({
baseCanvas,
heatmapData,
heatmapCanvas,
enabledElement,
setToPixelCoordinateSystem,
})
}, [baseCanvas, heatmapCanvas, heatmapData, setToPixelCoordinateSystem, enabledElement])

return [canvasRef]
}

export default useHeatmapDrawing
1 change: 1 addition & 0 deletions libs/insight-viewer/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './const'
export { InsightViewer as default } from './Viewer'
export { CXR4HeatmapViewer } from './Viewer/CXR4HeatmapViewer'
export { HeatmapViewer } from './Viewer/HeatmapViewer'
export { useMultipleImages } from './hooks/useMultipleImages'
export { useInteraction } from './hooks/useInteraction'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
interface HeatmapClearProps {
canvas: HTMLCanvasElement
context: CanvasRenderingContext2D
}

export default function clearHeatmap({ canvas, context }: HeatmapClearProps): void {
context.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight)
}
55 changes: 55 additions & 0 deletions libs/insight-viewer/src/utils/CXR4HeatmapViewer/drawHeatmap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/* eslint-disable no-param-reassign */

import { OverlayContext } from '../../contexts'
import clearHeatmap from './clearHeatmap'

interface DrawHeatmapProps extends Pick<OverlayContext, 'setToPixelCoordinateSystem' | 'enabledElement'> {
baseCanvas: HTMLCanvasElement | null
heatmapData: ImageData | null
heatmapCanvas: HTMLCanvasElement | null
}

function drawHeatmap({
baseCanvas,
heatmapData,
heatmapCanvas,
enabledElement,
setToPixelCoordinateSystem,
}: DrawHeatmapProps): void {
if (!heatmapData || !baseCanvas || !heatmapCanvas || !enabledElement) return

const baseCanvasContext = baseCanvas.getContext('2d')
const heatmapCanvasContext = heatmapCanvas.getContext('2d')

if (!baseCanvasContext || !heatmapCanvasContext) return

clearHeatmap({ canvas: baseCanvas, context: baseCanvasContext })

const { offsetWidth, offsetHeight } = baseCanvas

baseCanvas.width = offsetWidth
baseCanvas.height = offsetHeight
heatmapCanvas.width = heatmapData.width
heatmapCanvas.height = heatmapData.height

baseCanvasContext.save()
heatmapCanvasContext.putImageData(heatmapData, 0, 0)

setToPixelCoordinateSystem(baseCanvasContext)

baseCanvasContext.drawImage(
heatmapCanvas,
0,
0,
heatmapCanvas.width,
heatmapCanvas.height,
0,
0,
enabledElement.image?.width ?? 0,
enabledElement.image?.height ?? 0
)

baseCanvasContext.restore()
}

export default drawHeatmap
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { getRGBArray } from './getRGBArray'

interface GetHeatmapImageDataProps {
canvas: HTMLCanvasElement | null
posMap: number[][]
threshold: number
}

interface GetHeatmapImageDataReturn {
heatmapData: ImageData | null
heatmapCanvas: HTMLCanvasElement | null
}

const MAX_ALPHA = 255
const HEATMAP_OPACITY = 0.35

export default function getHeatmapImageData({
canvas,
posMap,
threshold,
}: GetHeatmapImageDataProps): GetHeatmapImageDataReturn {
if (!canvas) {
return { heatmapData: null, heatmapCanvas: null }
}

const heatmapWidth = posMap[0].length ?? 0
const heatmapHeight = posMap.length ?? 0

canvas.width = heatmapWidth
canvas.height = heatmapHeight

const heatmapCanvas = document.createElement('canvas')
const heatmapImageData = canvas.getContext('2d')?.createImageData(heatmapWidth, heatmapHeight)
const pixels = heatmapImageData?.data

if (!heatmapImageData || !pixels) {
return { heatmapData: null, heatmapCanvas: null }
}

// convert prob_map into a heatmap on the canvas
for (let i = 0; i < heatmapHeight; i += 1) {
for (let j = 0; j < heatmapWidth; j += 1) {
const offset = (i * heatmapWidth + j) * 4
const posProb = posMap[i][j]

const thPosProb = (posProb - threshold) / (1 - threshold)
if (posProb < threshold) {
pixels[offset + 3] = 0
} else {
const pixVal = getRGBArray(thPosProb)

pixels[offset] = pixVal[0]
pixels[offset + 1] = pixVal[1]
pixels[offset + 2] = pixVal[2]
pixels[offset + 3] = Math.round(MAX_ALPHA * HEATMAP_OPACITY)
}
}
}

return { heatmapData: heatmapImageData, heatmapCanvas }
}
29 changes: 29 additions & 0 deletions libs/insight-viewer/src/utils/CXR4HeatmapViewer/getRGBArray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* A function that converts probability values
* (0~1) of a specific area into [r, g, b] arrays corresponding to JET colormap colors
*/
export function getRGBArray(value: number): number[] {
const v = Math.max(Math.min(value, 1), 0)
let r, g, b

if (v < 0.25) {
// Between 0 and 0.25: (74,230,255) fixed
r = 74
g = 230
b = 255
} else if (v < 0.5) {
// Between 0.25 and 0.5: linearly convert from (74,230,255) to (221,255,0)
const t = (v - 0.25) / 0.25
r = Math.round(74 + t * (221 - 74))
g = Math.round(230 + t * (255 - 230))
b = Math.round(255 + t * (0 - 255))
} else {
// 0.5 to 1 interval: linearly convert from (221,255,0) to (255,0,92)
const t = (v - 0.5) / 0.5
r = Math.round(221 + t * (255 - 221))
g = Math.round(255 + t * (0 - 255))
b = Math.round(0 + t * (92 - 0))
}

return [r, g, b]
}
Loading