From e7d58d568899af3be410dc9689a4107f2c15de19 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Mon, 14 Oct 2024 16:04:17 +0200 Subject: [PATCH] feat(HDRReader): add vtkHDRReader Fixes #3145 --- Sources/Common/Core/Math/index.d.ts | 5 + Sources/Common/Core/Math/index.js | 11 + Sources/IO/Image/HDRReader/Utils.js | 42 ++ Sources/IO/Image/HDRReader/example/index.js | 122 ++++++ Sources/IO/Image/HDRReader/index.d.ts | 121 ++++++ Sources/IO/Image/HDRReader/index.js | 448 ++++++++++++++++++++ Sources/IO/Image/index.js | 5 + 7 files changed, 754 insertions(+) create mode 100644 Sources/IO/Image/HDRReader/Utils.js create mode 100644 Sources/IO/Image/HDRReader/example/index.js create mode 100644 Sources/IO/Image/HDRReader/index.d.ts create mode 100644 Sources/IO/Image/HDRReader/index.js create mode 100644 Sources/IO/Image/index.js diff --git a/Sources/Common/Core/Math/index.d.ts b/Sources/Common/Core/Math/index.d.ts index d54f888a6a5..f597f2dffba 100755 --- a/Sources/Common/Core/Math/index.d.ts +++ b/Sources/Common/Core/Math/index.d.ts @@ -51,6 +51,11 @@ export function swapColumnsMatrix_nxn( */ export function Pi(): number; +/** + * Calculates x times (2 to the power of exponent). + */ +export function ldexp(x: number, exponent: number): number; + /** * Convert degrees to radians. * @param {Number} deg The value in degrees. diff --git a/Sources/Common/Core/Math/index.js b/Sources/Common/Core/Math/index.js index f7f5997223a..3ba8a25c0ed 100644 --- a/Sources/Common/Core/Math/index.js +++ b/Sources/Common/Core/Math/index.js @@ -57,6 +57,16 @@ export function createArray(size = 3) { export const Pi = () => Math.PI; +export function ldexp(x, exponent) { + if (exponent > 1023) { + return x * 2 ** 1023 * 2 ** (exponent - 1023); + } + if (exponent < -1074) { + return x * 2 ** -1074 * 2 ** (exponent + 1074); + } + return x * 2 ** exponent; +} + export function radiansFromDegrees(deg) { return (deg / 180) * Math.PI; } @@ -2227,6 +2237,7 @@ export function float2CssRGBA(rgbArray) { export default { Pi, + ldexp, radiansFromDegrees, degreesFromRadians, round, diff --git a/Sources/IO/Image/HDRReader/Utils.js b/Sources/IO/Image/HDRReader/Utils.js new file mode 100644 index 00000000000..d1f41e637b7 --- /dev/null +++ b/Sources/IO/Image/HDRReader/Utils.js @@ -0,0 +1,42 @@ +import * as vtkMath from '@kitware/vtk.js/Common/Core/Math'; + +/** + * Read a line from a Uint8Array + * @param {Uint8Array} uint8array + * @param {number} startIndex + * @returns string + */ +function readLine(uint8array, startIndex) { + let line = ''; + let character = ''; + for (let i = startIndex; i < uint8array.length - startIndex; i++) { + character = String.fromCharCode(uint8array[i]); + if (character === '\n') { + break; + } + line += character; + } + return line; +} + +/** + * Convert rgbe to float + * @param {Array} rgbe The rgbe array + * @param {Array} floats The output array + * @param {number} exposure The exposure value + */ +function rgbe2float(rgbe, floats, exposure) { + if (rgbe[3] > 0) { + /* nonzero pixel */ + const f = vtkMath.ldexp(1.0, rgbe[3] - (128 + 8)) / exposure; + floats[0] = rgbe[0] * f; + floats[1] = rgbe[1] * f; + floats[2] = rgbe[2] * f; + } else { + floats[0] = 0; + floats[1] = 0; + floats[2] = 0; + } +} + +export { readLine, rgbe2float }; diff --git a/Sources/IO/Image/HDRReader/example/index.js b/Sources/IO/Image/HDRReader/example/index.js new file mode 100644 index 00000000000..8c802521a8b --- /dev/null +++ b/Sources/IO/Image/HDRReader/example/index.js @@ -0,0 +1,122 @@ +import '@kitware/vtk.js/favicon'; + +// Load the rendering pieces we want to use (for both WebGL and WebGPU) +import '@kitware/vtk.js/Rendering/Profiles/Geometry'; + +import vtkFullScreenRenderWindow from '@kitware/vtk.js/Rendering/Misc/FullScreenRenderWindow'; +import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; +import vtkPlaneSource from '@kitware/vtk.js/Filters/Sources/PlaneSource'; +import vtkHDRReader from '@kitware/vtk.js/IO/Image/HDRReader'; +import vtkTexture from '@kitware/vtk.js/Rendering/Core/Texture'; +import vtkURLExtract from '@kitware/vtk.js/Common/Core/URLExtract'; + +// ---------------------------------------------------------------------------- +// Example code +// ---------------------------------------------------------------------------- +const userParams = vtkURLExtract.extractURLParameters(); + +const reader = vtkHDRReader.newInstance(); +const texture = vtkTexture.newInstance(); +const planeSource = vtkPlaneSource.newInstance(); +const mapper = vtkMapper.newInstance(); +const actor = vtkActor.newInstance(); +mapper.setInputConnection(planeSource.getOutputPort()); +actor.setMapper(mapper); + +// ---------------------------------------------------------------------------- +// Use a file reader to load a local file +// ---------------------------------------------------------------------------- + +const myContainer = document.querySelector('body'); +const fileContainer = document.createElement('div'); +fileContainer.innerHTML = + '
Select a hdr file.
'; +myContainer.appendChild(fileContainer); + +const fileInput = fileContainer.querySelector('input'); + +function zoomCameraToFitPlane(camera, planeWidth, planeHeight) { + const fov = 60; // Field of view in degrees + + // Calculate the distance needed to fit the plane in view + const distance = + Math.max(planeWidth, planeHeight) / + (2 * Math.tan((fov * Math.PI) / 180 / 2)); + + // Set camera position + camera.setPosition(planeWidth / 2, planeHeight / 2, distance); + camera.setFocalPoint(planeWidth / 2, planeHeight / 2, 0); + camera.setViewUp(0, 1, 0); + + // Set parallel scale for orthographic projection + camera.setParallelScale(planeHeight / 2); +} + +function update() { + // Get the vtkImageData from the reader + const imageData = reader.getOutputData(); + + // Set the vtkImageData as the texture input + texture.setInputData(imageData); + + // Get the image's extent and spacing + const [xMin, xMax, yMin, yMax] = imageData.getExtent(); + const [spacingX, spacingY] = imageData.getSpacing(); + + // Calculate the plane's width and height based on the image's dimensions + const planeWidth = (xMax - xMin + 1) * spacingX; + const planeHeight = (yMax - yMin + 1) * spacingY; + + // Set the plane's origin and corners based on calculated width and height + planeSource.setOrigin(0, 0, 0); + planeSource.setPoint1(planeWidth, 0, 0); // Horizontal edge + planeSource.setPoint2(0, planeHeight, 0); // Vertical edge + + actor.addTexture(texture); + + const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance(); + const renderer = fullScreenRenderer.getRenderer(); + const renderWindow = fullScreenRenderer.getRenderWindow(); + const camera = renderer.getActiveCamera(); + const interactor = renderWindow.getInteractor(); + + // Disable default interactor style + interactor.setInteractorStyle(null); + + renderer.addActor(actor); + + // Adjust the camera to fit the plane in the view + zoomCameraToFitPlane(camera, planeWidth, planeHeight); + renderer.resetCameraClippingRange(); + + renderWindow.render(); +} + +function handleFile(event) { + event.preventDefault(); + const dataTransfer = event.dataTransfer; + const files = event.target.files || dataTransfer.files; + if (files.length === 1) { + const file = files[0]; + const fileReader = new FileReader(); + fileReader.onload = () => { + reader.parse(fileReader.result); + update(); + }; + fileReader.readAsArrayBuffer(file); + } +} + +fileInput.addEventListener('change', handleFile); + +// ---------------------------------------------------------------------------- +// Use the reader to download a file +// ---------------------------------------------------------------------------- +if (userParams.fileURL) { + reader.setUrl(userParams.fileURL).then(() => { + reader.loadData().then(() => { + update(); + }); + }); +} diff --git a/Sources/IO/Image/HDRReader/index.d.ts b/Sources/IO/Image/HDRReader/index.d.ts new file mode 100644 index 00000000000..a6db1f92d09 --- /dev/null +++ b/Sources/IO/Image/HDRReader/index.d.ts @@ -0,0 +1,121 @@ +import { vtkAlgorithm, vtkObject } from '../../../interfaces'; +import HtmlDataAccessHelper from '../../Core/DataAccessHelper/HtmlDataAccessHelper'; +import HttpDataAccessHelper from '../../Core/DataAccessHelper/HttpDataAccessHelper'; +import JSZipDataAccessHelper from '../../Core/DataAccessHelper/JSZipDataAccessHelper'; +import LiteHttpDataAccessHelper from '../../Core/DataAccessHelper/LiteHttpDataAccessHelper'; + +interface IHDRReaderOptions { + compression?: string; + progressCallback?: any; +} + +/** + * + */ +export interface IHDRReaderInitialValues {} + +type vtkHDRReaderBase = vtkObject & + Omit< + vtkAlgorithm, + | 'getInputData' + | 'setInputData' + | 'setInputConnection' + | 'getInputConnection' + | 'addInputConnection' + | 'addInputData' + >; + +export interface vtkHDRReader extends vtkHDRReaderBase { + /** + * Get the base url. + */ + getBaseURL(): string; + + /** + * Get the dataAccess helper. + */ + getDataAccessHelper(): + | HtmlDataAccessHelper + | HttpDataAccessHelper + | JSZipDataAccessHelper + | LiteHttpDataAccessHelper; + + /** + * Get the url of the object to load. + */ + getUrl(): string; + + /** + * Load the object data. + * @param {IHDRReaderOptions} [options] + */ + loadData(options?: IHDRReaderOptions): Promise; + + /** + * Parse data. + * @param {ArrayBuffer} content The content to parse. + */ + parse(content: ArrayBuffer): void; + + /** + * Parse data as ArrayBuffer. + * @param {ArrayBuffer} content The content to parse. + */ + parseAsArrayBuffer(content: ArrayBuffer): void; + + /** + * + * @param inData + * @param outData + */ + requestData(inData: any, outData: any): void; + + /** + * + * @param dataAccessHelper + */ + setDataAccessHelper( + dataAccessHelper: + | HtmlDataAccessHelper + | HttpDataAccessHelper + | JSZipDataAccessHelper + | LiteHttpDataAccessHelper + ): boolean; + + /** + * Set the url of the object to load. + * @param {String} url the url of the object to load. + * @param {IHDRReaderOptions} [option] The PLY reader options. + */ + setUrl(url: string, option?: IHDRReaderOptions): Promise; +} + +/** + * Method used to decorate a given object (publicAPI+model) with vtkHDRReader characteristics. + * + * @param publicAPI object on which methods will be bounds (public) + * @param model object on which data structure will be bounds (protected) + * @param {IHDRReaderInitialValues} [initialValues] (default: {}) + */ +export function extend( + publicAPI: object, + model: object, + initialValues?: IHDRReaderInitialValues +): void; + +/** + * Method used to create a new instance of vtkHDRReader + * @param {IHDRReaderInitialValues} [initialValues] for pre-setting some of its content + */ +export function newInstance( + initialValues?: IHDRReaderInitialValues +): vtkHDRReader; + +/** + * vtkHDRReader is a source object that reads Radiance HDR files. + */ +export declare const vtkHDRReader: { + newInstance: typeof newInstance; + extend: typeof extend; +}; +export default vtkHDRReader; diff --git a/Sources/IO/Image/HDRReader/index.js b/Sources/IO/Image/HDRReader/index.js new file mode 100644 index 00000000000..fd2c5af74e1 --- /dev/null +++ b/Sources/IO/Image/HDRReader/index.js @@ -0,0 +1,448 @@ +/* eslint-disable no-bitwise */ + +// Enable data soure for DataAccessHelper +import 'vtk.js/Sources/IO/Core/DataAccessHelper/LiteHttpDataAccessHelper'; // Just need HTTP +// import 'vtk.js/Sources/IO/Core/DataAccessHelper/HttpDataAccessHelper'; // HTTP + zip +// import 'vtk.js/Sources/IO/Core/DataAccessHelper/HtmlDataAccessHelper'; // html + base64 + zip +// import 'vtk.js/Sources/IO/Core/DataAccessHelper/JSZipDataAccessHelper'; // zip + +import macro from 'vtk.js/Sources/macros'; +import DataAccessHelper from 'vtk.js/Sources/IO/Core/DataAccessHelper'; +import * as vtkMath from '@kitware/vtk.js/Common/Core/Math'; +import vtkImageData from 'vtk.js/Sources/Common/DataModel/ImageData'; +import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray'; +import { readLine, rgbe2float } from 'vtk.js/Sources/IO/Image/HDRReader/Utils'; + +const { vtkErrorMacro } = macro; + +const FormatType = { + FORMAT_32BIT_RLE_RGBE: 0, + FORMAT_32BIT_RLE_XYZE: 1, +}; + +const Patterns = { + magicToken: /^#\?(\S+)/, + gamma: /^\s*GAMMA\s*=\s*(\d+(\.\d+)?)\s*$/, + exposure: /^\s*EXPOSURE\s*=\s*(\d+(\.\d+)?)\s*$/, + format: /^\s*FORMAT=(\S+)\s*$/, + dimensions: /^\s*-Y\s+(\d+)\s+\+X\s+(\d+)\s*$/, +}; + +// ---------------------------------------------------------------------------- +// vtkHDRReader methods +// ---------------------------------------------------------------------------- + +function vtkHDRReader(publicAPI, model) { + // Set our className + model.classHierarchy.push('vtkHDRReader'); + + /** + * Reads header information from an RGBE texture stored in a native array. + * More information on this format are available here: + * https://en.wikipedia.org/wiki/RGBE_image_format + * + * @param {Uint8Array} uint8array The binary file stored in native array. + * @returns The header information. + */ + function readHeader(uint8array) { + // RGBE format header + const header = { + valid: 0, + string: '', + comments: '', + programtype: 'RGBE', + format: '', + gamma: 1.0, + exposure: 1.0, + pixelAspect: 1.0, + width: 0, + height: 0, + dataIndex: 0, + }; + + const RGBE_VALID_PROGRAMTYPE = 1; + const RGBE_VALID_FORMAT = 2; + const RGBE_VALID_DIMENSIONS = 3; + + let match; + let line = readLine(uint8array, 0); + + match = line.match(Patterns.magicToken); + if (!match) { + throw new Error('Bad HDR Format.'); + } + + header.valid |= RGBE_VALID_PROGRAMTYPE; + header.programtype = match[1]; + header.string += `${line}\n`; + + let lineIndex = 0; + + do { + lineIndex += line.length + 1; + line = readLine(uint8array, lineIndex); + header.string += `${line}\n`; + + if (line.charAt(0) === '#') { + header.comments += `${line}\n`; + // eslint-disable-next-line no-continue + continue; // comment line + } + + match = line.match(Patterns.gamma); + if (match) { + header.gamma = parseFloat(match[1]); + } + + match = line.match(Patterns.exposure); + if (match) { + header.exposure = parseFloat(match[1]); + } + + match = line.match(Patterns.format); + if (match) { + header.valid |= RGBE_VALID_FORMAT; + header.format = match[1]; + } + } while (line.length !== 0); + + if (!header.format === '') { + vtkErrorMacro('HDR Bad header format, unsupported FORMAT'); + } + + lineIndex += line.length + 1; + line = readLine(uint8array, lineIndex); + + match = line.match(Patterns.dimensions); + if (match) { + header.valid |= RGBE_VALID_DIMENSIONS; + header.height = parseInt(match[1], 10); + header.width = parseInt(match[2], 10); + } + + if (header.width < 8 || header.width > 0x7fff) { + vtkErrorMacro('HDR Bad header format, unsupported size'); + } + + lineIndex += line.length + 1; + header.dataIndex = lineIndex; + return header; + } + + /** + * + * @param {Uint8Array} uint8array + * @param {*} header + * @returns Float32Array + */ + function readRGBEPixelsNotRLE(uint8array, header) { + // this file is not run length encoded + // read values sequentially + + let numScanlines = header.height; + const scanlineWidth = header.width; + + let a; + let b; + let c; + let d; + let i; + let dataIndex = header.dataIndex; + + // 3 channels of 4 bytes per pixel in float. + const resultBuffer = new ArrayBuffer(header.width * header.height * 4 * 3); + const resultArray = new Float32Array(resultBuffer); + + // read in each successive scanline + while (numScanlines > 0) { + for (i = 0; i < header.width; i++) { + a = uint8array[dataIndex++]; + b = uint8array[dataIndex++]; + c = uint8array[dataIndex++]; + d = uint8array[dataIndex++]; + + const offset = (numScanlines - 1) * scanlineWidth * 3 + i * 3; + const output = []; + const input = [a, b, c, d]; + if (model.format === FormatType.FORMAT_32BIT_RLE_XYZE) { + // convert from XYZE to RGBE + vtkMath.xyz2rgb(input, output); + } else { + rgbe2float(input, output, model.exposure); + } + resultArray[offset] = output[0]; + resultArray[offset + 1] = output[1]; + resultArray[offset + 2] = output[2]; + } + + numScanlines--; + } + + return resultArray; + } + + /** + * + * @param {Uint8Array} uint8array + * @param {*} header + * @returns Float32Array + */ + function readRGBEPixelsRLE(uint8array, header) { + let numScanlines = header.height; + const scanlineWidth = header.width; + + let a; + let b; + let c; + let d; + let count; + let dataIndex = header.dataIndex; + let index = 0; + let endIndex = 0; + let i = 0; + + const scanLineArrayBuffer = new ArrayBuffer(scanlineWidth * 4); // four channel R G B E + const scanLineArray = new Uint8Array(scanLineArrayBuffer); + + // 3 channels of 4 bytes per pixel in float. + const resultBuffer = new ArrayBuffer(header.width * header.height * 4 * 3); + const resultArray = new Float32Array(resultBuffer); + + // read in each successive scanline + while (numScanlines > 0) { + a = uint8array[dataIndex++]; + b = uint8array[dataIndex++]; + c = uint8array[dataIndex++]; + d = uint8array[dataIndex++]; + + if ( + a !== 2 || + b !== 2 || + c & 0x80 || + header.width < 8 || + header.width > 32767 + ) { + return readRGBEPixelsNotRLE(uint8array, header); + } + + if (((c << 8) | d) !== scanlineWidth) { + vtkErrorMacro('HDR Bad header format, wrong scan line width'); + } + + index = 0; + + // read each of the four channels for the scanline into the buffer + for (i = 0; i < 4; i++) { + endIndex = (i + 1) * scanlineWidth; + + while (index < endIndex) { + a = uint8array[dataIndex++]; + b = uint8array[dataIndex++]; + + if (a > 128) { + // A run of the same value + count = a - 128; + if (count === 0 || count > endIndex - index) { + vtkErrorMacro('HDR Bad Format, bad scanline data (run)'); + } + + while (count-- > 0) { + scanLineArray[index++] = b; + } + } else { + // A non run + count = a; + if (count === 0 || count > endIndex - index) { + vtkErrorMacro('HDR Bad Format, bad scanline data (non-run)'); + } + + scanLineArray[index++] = b; + if (--count > 0) { + for (let j = 0; j < count; j++) { + scanLineArray[index++] = uint8array[dataIndex++]; + } + } + } + } + } + + // now convert data from buffer into floats + for (i = 0; i < scanlineWidth; i++) { + a = scanLineArray[i]; + b = scanLineArray[i + scanlineWidth]; + c = scanLineArray[i + 2 * scanlineWidth]; + d = scanLineArray[i + 3 * scanlineWidth]; + + const offset = (numScanlines - 1) * scanlineWidth * 3 + i * 3; + const output = []; + const input = [a, b, c, d]; + if (model.format === FormatType.FORMAT_32BIT_RLE_XYZE) { + // convert from XYZE to RGBE + vtkMath.xyz2rgb(input, output); + } else { + rgbe2float(input, output, model.exposure); + } + resultArray[offset] = output[0]; + resultArray[offset + 1] = output[1]; + resultArray[offset + 2] = output[2]; + } + + numScanlines--; + } + + return resultArray; + } + + // Create default dataAccessHelper if not available + if (!model.dataAccessHelper) { + model.dataAccessHelper = DataAccessHelper.get('http'); + } + + // Internal method to fetch Array + function fetchData(url, option = {}) { + const { compression, progressCallback } = model; + return model.dataAccessHelper.fetchBinary(url, { + compression, + progressCallback, + }); + } + + // Set DataSet url + publicAPI.setUrl = (url, option = { binary: true }) => { + model.url = url; + + // Remove the file in the URL + const path = url.split('/'); + path.pop(); + model.baseURL = path.join('/'); + + model.compression = option.compression; + + // Fetch metadata + return publicAPI.loadData({ + progressCallback: option.progressCallback, + }); + }; + + // Fetch the actual data arrays + publicAPI.loadData = (option = {}) => { + const promise = fetchData(model.url, option); + promise.then(publicAPI.parse); + return promise; + }; + + publicAPI.parse = (content) => { + publicAPI.parseAsArrayBuffer(content); + }; + + publicAPI.parseAsArrayBuffer = (content) => { + if (!content) { + return; + } + if (content !== model.parseData) { + publicAPI.modified(); + } else { + return; + } + + model.parseData = content; + + const data = new Uint8Array(model.parseData); + const header = readHeader(data); + + if (header.format === '32-bit_rle_rgbe') { + model.format = FormatType.FORMAT_32BIT_RLE_RGBE; + } else if (header.format === '32-bit_rle_xyze') { + model.format = FormatType.FORMAT_32BIT_RLE_XYZE; + } + + const output = readRGBEPixelsRLE(data, header); + + model.dataExtent[0] = 0; + model.dataExtent[1] = header.width - 1; + model.dataExtent[2] = 0; + model.dataExtent[3] = header.height - 1; + + model.dataSpacing[0] = 1; + model.dataSpacing[1] = header.pixelAspect; + model.dataSpacing[1] = 1; + model.dataSpacing[2] = 1; + + const imageData = vtkImageData.newInstance(); + imageData.setDimensions(header.width, header.height, 1); + imageData.setExtent(model.dataExtent); + imageData.setSpacing(model.dataSpacing); + imageData.setOrigin(0, 0, 0); + + const dataArray = vtkDataArray.newInstance({ + name: 'HDRImage', + numberOfComponents: 3, + values: output, + }); + + imageData.getPointData().setScalars(dataArray); + model.output[0] = imageData; + }; + + publicAPI.requestData = (inData, outData) => { + publicAPI.parse(model.parseData); + }; + + publicAPI.getWidth = () => model.dataExtent[1] - model.dataExtent[0] + 1; + publicAPI.getHeight = () => model.dataExtent[3] - model.dataExtent[2] + 1; +} + +// ---------------------------------------------------------------------------- +// Object factory +// ---------------------------------------------------------------------------- + +const DEFAULT_VALUES = { + gamma: 1.0, + exposure: 1.0, + pixelAspect: 1.0, + format: FormatType.FORMAT_32BIT_RLE_RGBE, + dataExtent: [0, 0, 0, 0, 0, 0], + dataSpacing: [1, 1, 1], +}; + +// ---------------------------------------------------------------------------- + +export function extend(publicAPI, model, initialValues = {}) { + Object.assign(model, DEFAULT_VALUES, initialValues); + + // Make this a VTK object + macro.obj(publicAPI, model); + + // Also make it an algorithm with one input and one output + macro.algo(publicAPI, model, 0, 1); + + macro.get(publicAPI, model, [ + 'gamma', + 'exposure', + 'pixelAspect', + 'dataExtent', + 'dataSpacing', + 'url', + 'baseURL', + ]); + macro.setGet(publicAPI, model, ['dataAccessHelper']); + + // Object specific methods + vtkHDRReader(publicAPI, model); + + // To support destructuring + if (!model.compression) { + model.compression = null; + } + if (!model.progressCallback) { + model.progressCallback = null; + } +} + +// ---------------------------------------------------------------------------- + +export const newInstance = macro.newInstance(extend, 'vtkHDRReader'); + +// ---------------------------------------------------------------------------- + +export default { newInstance, extend }; diff --git a/Sources/IO/Image/index.js b/Sources/IO/Image/index.js new file mode 100644 index 00000000000..0fa73fd4be6 --- /dev/null +++ b/Sources/IO/Image/index.js @@ -0,0 +1,5 @@ +import vtkHDRReader from './HDRReader'; + +export default { + vtkHDRReader, +};