diff --git a/deploy/csp/index.js b/deploy/csp/index.js index b31a3f9f1a68f85ef3643b1aeb1da53aa9c3889a..d745b8d58f0a70422b9d48cb481cf533f01401eb 100644 --- a/deploy/csp/index.js +++ b/deploy/csp/index.js @@ -104,7 +104,7 @@ module.exports = { 'unpkg.com/kg-dataset-previewer@1.2.0/', // preview component 'cdnjs.cloudflare.com/ajax/libs/mathjax/', // math jax 'https://unpkg.com/three-surfer@0.0.10/dist/bundle.js', // for threeSurfer (freesurfer support in browser) - 'https://unpkg.com/ng-layer-tune@0.0.1/dist/ng-layer-tune/ng-layer-tune.esm.js', // needed for ng layer control + 'https://unpkg.com/ng-layer-tune@0.0.2/dist/ng-layer-tune/ng-layer-tune.esm.js', // needed for ng layer control (req, res) => res.locals.nonce ? `'nonce-${res.locals.nonce}'` : null, ...SCRIPT_SRC, ...WHITE_LIST_SRC, diff --git a/src/atlasViewer/atlasViewer.workerService.service.ts b/src/atlasViewer/atlasViewer.workerService.service.ts index 227f5f2aa1f9a7ee255ccb9dba0e0d2b57642338..62395463088ed3939c419ec61329c18e68865993 100644 --- a/src/atlasViewer/atlasViewer.workerService.service.ts +++ b/src/atlasViewer/atlasViewer.workerService.service.ts @@ -7,6 +7,7 @@ import { getUuid } from "src/util/fn"; import '!!file-loader?name=worker.js!worker/worker.js' import '!!file-loader?name=worker-plotly.js!worker/worker-plotly.js' +import '!!file-loader?name=worker-nifti.js!worker/worker-nifti.js' /** * export the worker, so that services that does not require dependency injection can import the worker @@ -16,6 +17,7 @@ export const worker = new Worker('worker.js') interface IWorkerMessage { method: string param: any + transfers?: any[] } @Injectable({ @@ -25,20 +27,24 @@ interface IWorkerMessage { export class AtlasWorkerService { public worker = worker - async sendMessage(data: IWorkerMessage){ + async sendMessage(_data: IWorkerMessage){ + const { transfers = [], ...data } = _data const newUuid = getUuid() this.worker.postMessage({ id: newUuid, ...data - }) + }, transfers) const message = await fromEvent(this.worker, 'message').pipe( filter((message: MessageEvent) => message.data.id && message.data.id === newUuid), take(1) ).toPromise() const { data: returnData } = message as MessageEvent - const { id, ...rest } = returnData + const { id, error, ...rest } = returnData + if (error) { + throw new Error(error.message || error) + } return rest } } diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index 5bf60d5906fe324de2ece7d04c17db1f15e559a2..caa678cc44df957b127679abf2ac3421f88f1f60 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -4,7 +4,7 @@ export const environment = { PRODUCTION: true, BACKEND_URL: null, DATASET_PREVIEW_URL: 'https://hbp-kg-dataset-previewer.apps.hbp.eu/v2', - BS_REST_URL: 'https://siibra-api-latest.apps-dev.hbp.eu/v1_0', + BS_REST_URL: 'https://siibra-api-edge.apps-dev.hbp.eu/v1_0', SPATIAL_TRANSFORM_BACKEND: 'https://hbp-spatial-backend.apps.hbp.eu', MATOMO_URL: null, MATOMO_ID: null, diff --git a/src/index.html b/src/index.html index c313b64c0f931b7c6bca5d3ca051ed2f220dfb72..5701618243131618751f45ef96644368a9baf333 100644 --- a/src/index.html +++ b/src/index.html @@ -15,7 +15,7 @@ <script src="https://unpkg.com/kg-dataset-previewer@1.2.0/dist/kg-dataset-previewer/kg-dataset-previewer.js" defer> </script> <script src="https://unpkg.com/three-surfer@0.0.10/dist/bundle.js" defer></script> - <script type="module" src="https://unpkg.com/ng-layer-tune@0.0.1/dist/ng-layer-tune/ng-layer-tune.esm.js"></script> + <script type="module" src="https://unpkg.com/ng-layer-tune@0.0.2/dist/ng-layer-tune/ng-layer-tune.esm.js"></script> <title>Interactive Atlas Viewer</title> </head> diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts index 30e0f9c0df3886f6c13c63acf11a5f80e84c7a29..6256f6b7e8f02b90e4043e35ee6d0c714a510126 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts @@ -23,13 +23,14 @@ import { MouseHoverDirective } from "src/mouseoverModule"; import { NehubaMeshService } from "../mesh.service"; import { IQuickTourData } from "src/ui/quickTour/constrants"; import { NehubaLayerControlService, IColorMap, SET_COLORMAP_OBS, SET_LAYER_VISIBILITY } from "../layerCtrl.service"; -import { getUuid, switchMapWaitFor } from "src/util/fn"; +import { getExportNehuba, getUuid, switchMapWaitFor } from "src/util/fn"; import { INavObj } from "../navigation.service"; import { NG_LAYER_CONTROL, SET_SEGMENT_VISIBILITY } from "../layerCtrl.service/layerCtrl.util"; import { MatSnackBar } from "@angular/material/snack-bar"; import { getShader } from "src/util/constants"; import { EnumColorMapName } from "src/util/colorMaps"; import { MatDialog } from "@angular/material/dialog"; +import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; export const INVALID_FILE_INPUT = `Exactly one (1) nifti file is required!` @@ -311,6 +312,7 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnChanges, OnDestroy, A private log: LoggingService, private snackbar: MatSnackBar, private dialog: MatDialog, + private worker: AtlasWorkerService, @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, @Optional() @Inject(API_SERVICE_SET_VIEWER_HANDLE_TOKEN) setViewerHandle: TSetViewerHandle, @Optional() private layerCtrlService: NehubaLayerControlService, @@ -718,7 +720,7 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnChanges, OnDestroy, A URL.revokeObjectURL(resourceUrl) } } - public handleFileDrop(files: File[]){ + public async handleFileDrop(files: File[]){ if (files.length !== 1) { this.snackbar.open(INVALID_FILE_INPUT, 'Dismiss', { duration: 5000 @@ -731,45 +733,77 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnChanges, OnDestroy, A /** * TODO check extension? */ - + this.dismissAllAddedLayers() - const url = URL.createObjectURL(file) - this.droppedLayerNames.push({ - layerName: randomUuid, - resourceUrl: url - }) - this.layerCtrlService.addNgLayer([{ - name: randomUuid, - mixability: 'mixable', - source: `nifti://${url}`, - shader: getShader({ - colormap: EnumColorMapName.MAGMA - }) - }]) + // Get file, try to inflate, if files, use original array buffer + const buf = await file.arrayBuffer() + let outbuf + try { + outbuf = getExportNehuba().pako.inflate(buf).buffer + } catch (e) { + console.log('unpack error', e) + outbuf = buf + } - this.dialog.open( - this.layerCtrlTmpl, - { - data: { - layerName: randomUuid, - filename: file.name, - moreInfoFlag: false - }, - hasBackdrop: false, - disableClose: true, - position: { - top: '0em' + try { + const { result, ...other } = await this.worker.sendMessage({ + method: 'PROCESS_NIFTI', + param: { + nifti: outbuf }, - autoFocus: false, - panelClass: [ - 'no-padding-dialog', - 'w-100' - ] - } - ).afterClosed().subscribe( - () => this.dismissAllAddedLayers() - ) + transfers: [ outbuf ] + }) + + const { meta, buffer } = result + + const url = URL.createObjectURL(new Blob([ buffer ])) + this.droppedLayerNames.push({ + layerName: randomUuid, + resourceUrl: url + }) + this.layerCtrlService.addNgLayer([{ + name: randomUuid, + mixability: 'mixable', + source: `nifti://${url}`, + shader: getShader({ + colormap: EnumColorMapName.MAGMA, + lowThreshold: meta.min || 0, + highThreshold: meta.max || 1 + }) + }]) + + this.dialog.open( + this.layerCtrlTmpl, + { + data: { + layerName: randomUuid, + filename: file.name, + moreInfoFlag: false, + min: meta.min || 0, + max: meta.max || 1, + warning: meta.warning || [] + }, + hasBackdrop: false, + disableClose: true, + position: { + top: '0em' + }, + autoFocus: false, + panelClass: [ + 'no-padding-dialog', + 'w-100' + ] + } + ).afterClosed().subscribe( + () => this.dismissAllAddedLayers() + ) + } catch (e) { + console.error(e) + this.snackbar.open(`Error loading nifti: ${e.toString()}`, 'Dismiss', { + duration: 5000 + }) + } } diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html index 3cda701b7cebafcae2dc9569a3a38c0ed0ad58d7..ba41f41c2cc6d2fc711a0ccb60cee8c8f512ae3f 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html @@ -217,8 +217,14 @@ <div *ngIf="data.moreInfoFlag" class="iv-custom-comp darker-bg overflow-hidden grid-wide-3"> - <ng-layer-tune [ngLayerName]="data.layerName"> + <ng-layer-tune + [ngLayerName]="data.layerName" + [thresholdMin]="data.min" + [thresholdMax]="data.max"> </ng-layer-tune> + <ul> + <li *ngFor="let warn of data.warning">{{ warn }}</li> + </ul> </div> </div> diff --git a/worker/worker-nifti.js b/worker/worker-nifti.js new file mode 100644 index 0000000000000000000000000000000000000000..56f0aabaef84833182d543db3b0f08a56f147f42 --- /dev/null +++ b/worker/worker-nifti.js @@ -0,0 +1,267 @@ +(function(exports){ + const DTYPE = { + SHORT: 0, + BYTE: 1, + FLOAT: 2, + DOUBLE: 3, + INT: 4, + LONG: 5, + } + + const NIFTI_CONST = { + TYPE_FLOAT64: 64, + SPATIAL_UNITS_MASK: 0x07, + TEMPORAL_UNITS_MASK: 0x38, + NIFTI1_HDR_SIZE: 348, + NIFTI2_HDR_SIZE: 540 + } + + const nifti1 = { + datatype: { + offset: 70, + type: DTYPE.SHORT + }, + dim0: { + offset: 40, + type: DTYPE.SHORT + }, + dim1: { + offset: 42, + type: DTYPE.SHORT + }, + dim2: { + offset: 44, + type: DTYPE.SHORT + }, + dim3: { + offset: 46, + type: DTYPE.SHORT + }, + dim4: { + offset: 48, + type: DTYPE.SHORT + }, + dim5: { + offset: 50, + type: DTYPE.SHORT + }, + xyztUnits: { + offset: 123, + type: DTYPE.BYTE + }, + voxOffset: { + offset: 108, + type: DTYPE.FLOAT + }, + numBitsPerVoxel: { + offset: 72, + type: DTYPE.SHORT + } + } + + const nifti2 = { + datatype: { + offset: 12, + type: DTYPE.SHORT + }, + dim0: { + offset: 16, + type: DTYPE.LONG + }, + dim1: { + offset: 24, + type: DTYPE.LONG + }, + dim2: { + offset: 32, + type: DTYPE.LONG + }, + dim3: { + offset: 40, + type: DTYPE.LONG + }, + dim4: { + offset: 48, + type: DTYPE.LONG + }, + dim5: { + offset: 56, + type: DTYPE.LONG + }, + xyztUnits: { + offset: 500, + type: DTYPE.INT + }, + voxOffset: { + offset: 168, + type: DTYPE.LONG + }, + numBitsPerVoxel: { + offset: 14, + type: DTYPE.SHORT + } + } + + const isNifti1 = data => { + if (data.byteLength < 348) return false + const buf = new DataView(data) + return buf.getUint8(344) === 0x6E + && buf.getUint8(345) === 0x2B + && buf.getUint8(346) === 0x31 + } + + const isNifti2 = data => { + if (data.byteLength < 348) return false + const buf = new DataView(data) + return buf.getUint8(4) === 0x6E + && buf.getUint8(5) === 0x69 + && buf.getUint8(6) === 0x31 + } + + const readData = (buf, spec, le = false) => { + const { offset, type } = spec + if (type === DTYPE.SHORT) return buf.getInt16(offset, le) + if (type === DTYPE.INT) return buf.getInt32(offset, le) + if (type === DTYPE.FLOAT) return buf.getFloat32(offset, le) + if (type === DTYPE.DOUBLE) return buf.getFloat64(offset, le) + if (type === DTYPE.BYTE) return buf.getInt8(offset, le) + if (type === DTYPE.LONG) { + const ints = [] + let final = 0 + for (const i = 0; i < 8; i++) { + ints.push( + readData(buf, { + offset: offset + i, + type: DTYPE.INT + }, le) + ) + } + for (const i = 0; i < 8; i++) { + const counter = le ? i : (7 - i) + final += ints[counter] * 256 + } + return final + } + throw new Error(`Unknown type ${type}`) + } + + const setData = (buf, spec, value, le = false) => { + const { offset, type } = spec + if (type === DTYPE.SHORT) return buf.setInt16(offset, value, le) + if (type === DTYPE.INT) return buf.setInt32(offset, value, le) + if (type === DTYPE.FLOAT) return buf.setFloat32(offset, value, le) + if (type === DTYPE.DOUBLE) return buf.setFloat64(offset, value, le) + if (type === DTYPE.BYTE) return buf.setInt8(offset, value, le) + if (type === DTYPE.LONG) { + throw new Error(`Writing to LONG not currently supported`) + } + throw new Error(`Unknown type ${type}`) + } + + exports.nifti = { + convert: buf => { + + const is1 = isNifti1(buf) + const is2 = isNifti2(buf) + if (!is1 && !is2) { + throw new Error(`The file is not a valid nifti file`) + } + const warning = [] + + const dict = is1 + ? nifti1 + : nifti2 + const dataView = new DataView(buf) + + let le = false + + // determine the endianness + const expectedHdrSize = is1 ? NIFTI_CONST.NIFTI1_HDR_SIZE : NIFTI_CONST.NIFTI2_HDR_SIZE + const hdrSize = readData(dataView, { + type: DTYPE.INT, + offset: 0 + }, le) + if (hdrSize !== expectedHdrSize) le = !le + + // check datatypes + const datatype = readData(dataView, dict.datatype, le) + if (datatype == NIFTI_CONST.TYPE_FLOAT64) { + throw new Error(`Float64 not currently supported.`) + } + + // check... other headers + + const dim0 = readData(dataView, dict.dim0, le) + const dim1 = readData(dataView, dict.dim1, le) + const dim2 = readData(dataView, dict.dim2, le) + const dim3 = readData(dataView, dict.dim3, le) + const dim4 = readData(dataView, dict.dim4, le) + const dim5 = readData(dataView, dict.dim5, le) + if (dim4 === 0) { + warning.push(`dim[4] was 0, set to 1 instead`) + setData(dataView, dict.dim4, 1, le) + } + if (dim4 > 1) { + throw new Error(`Cannot parse time series`) + } + if (dim5 === 0) { + warning.push(`dim[5] was 0, set to 1 instead`) + setData(dataView, dict.dim5, 1, le) + } + if (dim5 > 1) { + throw new Error(`cannot show nifti with dim[5] > 1`) + } + const xyztUnits = readData(dataView, dict.xyztUnits, le) + const xyzUnit = xyztUnits & NIFTI_CONST.SPATIAL_UNITS_MASK + if (xyzUnit === 0) { + warning.push(`xyzt spatial unit not defined. Forcing to be mm.`) + setData(dataView, dict.xyztUnits, xyztUnits + 2, le) + } + const voxOffset = readData(dataView, dict.voxOffset, le) + const numBitsPerVoxel = readData(dataView, dict.numBitsPerVoxel, le) + + let type, increment = 0, min = null, max = null + // INT8 + if (datatype === 256) type = DTYPE.BYTE, increment = 1 + // INT16 + if (datatype === 4) type = DTYPE.SHORT, increment = 2 + // INT32 + if (datatype === 8) type = DTYPE.INT, increment = 4 + // FLOAT32 + if (datatype === 16) type = DTYPE.FLOAT, increment = 4 + if (type) { + + const pointer = { + offset: voxOffset, + type + } + + while (true) { + try { + const val = readData(dataView, pointer, le) + if (min === null) min = val + if (max === null) max = val + if (val < min) min = val + if (val > max) max = val + } catch (e) { + console.error(`error in while true block`) + break + } + pointer.offset += increment + } + } + + return { + meta: { + min, max, + warning + }, + buffer: dataView.buffer + } + } + } +})( + typeof exports === 'undefined' + ? self + : exports +) \ No newline at end of file diff --git a/worker/worker.js b/worker/worker.js index ef24f040c13260bc6fd9183a8376e9b326f52539..51156b1b95fe6ac2dd8d064156c0bf36ca6d56e9 100644 --- a/worker/worker.js +++ b/worker/worker.js @@ -8,6 +8,7 @@ globalThis.constants = { } if (typeof self.importScripts === 'function') self.importScripts('./worker-plotly.js') +if (typeof self.importScripts === 'function') self.importScripts('./worker-nifti.js') /** * TODO migrate processing functionalities to other scripts @@ -21,11 +22,13 @@ const validTypes = [ ] const VALID_METHOD = { - PROCESS_PLOTLY: `PROCESS_PLOTLY` + PROCESS_PLOTLY: `PROCESS_PLOTLY`, + PROCESS_NIFTI: 'PROCESS_NIFTI', } const VALID_METHODS = [ - VALID_METHOD.PROCESS_PLOTLY + VALID_METHOD.PROCESS_PLOTLY, + VALID_METHOD.PROCESS_NIFTI, ] const validOutType = [ @@ -262,6 +265,32 @@ onmessage = (message) => { }) } } + + if (message.data.method === VALID_METHOD.PROCESS_NIFTI) { + try { + const { nifti } = message.data.param + const { + meta, + buffer + } = self.nifti.convert(nifti) + + postMessage({ + id, + result: { + meta, + buffer + } + }, [ buffer ]) + } catch (e) { + postMessage({ + id, + error: { + code: 401, + message: `nifti error: ${e.toString()}` + } + }) + } + } postMessage({ id, error: {