UNPKG

image-js

Version:

Image processing and manipulation in JavaScript

504 lines (454 loc) 16 kB
import Qty from 'js-quantities'; import deepValue from '../../util/deepValue'; import Image from '../Image'; import RoiLayer from './RoiLayer'; import RoiMap from './RoiMap'; import fromMask from './creator/fromMask'; import fromMaskConnectedComponentLabelingAlgorithm from './creator/fromMaskConnectedComponentLabelingAlgorithm'; import fromMaxima from './creator/fromMaxima'; import fromPoints from './creator/fromPoints'; import fromWaterShed from './creator/fromWaterShed'; /** * A manager of Regions of Interest. A RoiManager is related to a specific Image * and may contain multiple layers. Each layer is characterized by a label whose is * name by default 'default' * @class RoiManager * @param {Image} image * @param {object} [options] */ export default class RoiManager { constructor(image, options = {}) { this._image = image; this._options = options; if (!this._options.label) { this._options.label = 'default'; } this._layers = {}; this._painted = null; } // docs is in the corresponding file fromMaxima(options = {}) { let opt = Object.assign({}, this._options, options); let roiMap = fromMaxima.call(this._image, options); this._layers[opt.label] = new RoiLayer(roiMap, opt); } // docs is in the corresponding file fromPoints(points, options = {}) { let opt = Object.assign({}, this._options, options); let roiMap = fromPoints.call(this._image, points, options); this._layers[opt.label] = new RoiLayer(roiMap, opt); return this; } /** * @param {number[]} map * @param {object} [options] * @return {this} */ putMap(map, options = {}) { let roiMap = new RoiMap(this._image, map); let opt = Object.assign({}, this._options, options); this._layers[opt.label] = new RoiLayer(roiMap, opt); return this; } // docs is in the corresponding file fromWaterShed(options = {}) { let opt = Object.assign({}, this._options, options); let roiMap = fromWaterShed.call(this._image, options); this._layers[opt.label] = new RoiLayer(roiMap, opt); } // docs is in the corresponding file fromMask(mask, options = {}) { let opt = Object.assign({}, this._options, options); let roiMap = fromMask.call(this._image, mask, options); this._layers[opt.label] = new RoiLayer(roiMap, opt); return this; } fromMaskConnectedComponentLabelingAlgorithm(mask, options = {}) { let opt = Object.assign({}, this._options, options); let roiMap = fromMaskConnectedComponentLabelingAlgorithm.call( this._image, mask, options, ); this._layers[opt.label] = new RoiLayer(roiMap, opt); return this; } /** * * @param {object} [options] * @return {RoiMap} */ getMap(options = {}) { let opt = Object.assign({}, this._options, options); this._assertLayerWithLabel(opt.label); return this._layers[opt.label].roiMap; } /** * Return statistics about rows * @param {object} [options] * @return {object[]} */ rowsInfo(options = {}) { return this.getMap(options).rowsInfo(); } /** * Return statistics about columns * @param {object} [options] * @return {object[]} */ colsInfo(options = {}) { return this.getMap(options).rowsInfo(); } /** * Return the IDs of the Regions Of Interest (Roi) as an array of number * @param {object} [options] * @return {number[]} */ getRoiIds(options = {}) { let rois = this.getRois(options); if (rois) { let ids = new Array(rois.length); for (let i = 0; i < rois.length; i++) { ids[i] = rois[i].id; } return ids; } throw new Error('ROIs not found'); } /** * Allows to select ROI based on size, label and sign. * @param {object} [options={}] * @param {string} [options.label='default'] Label of the layer containing the ROI * @param {boolean} [options.positive=true] Select the positive region of interest * @param {boolean} [options.negative=true] Select he negative region of interest * @param {number} [options.minSurface=0] * @param {number} [options.maxSurface=Number.POSITIVE_INFINITY] * @param {number} [options.minWidth=0] * @param {number} [options.minHeight=Number.POSITIVE_INFINITY] * @param {number} [options.maxWidth=0] * @param {number} [options.maxHeight=Number.POSITIVE_INFINITY] * @param {number} [options.minRatio=0] Ratio width / height * @param {number} [options.maxRatio=Number.POSITIVE_INFINITY] * @return {Roi[]} */ getRois(options = {}) { let { label = this._options.label, positive = true, negative = true, minSurface = 0, maxSurface = Number.POSITIVE_INFINITY, minWidth = 0, maxWidth = Number.POSITIVE_INFINITY, minHeight = 0, maxHeight = Number.POSITIVE_INFINITY, minRatio = 0, maxRatio = Number.POSITIVE_INFINITY, } = options; if (!this._layers[label]) { throw new Error(`this Roi layer (${label}) does not exist`); } const allRois = this._layers[label].roi; const rois = []; for (const roi of allRois) { if ( ((roi.id < 0 && negative) || (roi.id > 0 && positive)) && roi.surface >= minSurface && roi.surface <= maxSurface && roi.width >= minWidth && roi.width <= maxWidth && roi.height >= minHeight && roi.height <= maxHeight && roi.ratio >= minRatio && roi.ratio <= maxRatio ) { rois.push(roi); } } return rois; } /** * Get an ROI by its id. * @param {number} roiId * @param {object} [options={}] * @param {string} [options.label='default'] Label of the layer containing the ROI * @return {Roi} */ getRoi(roiId, options = {}) { const { label = this._options.label } = options; if (!this._layers[label]) { throw new Error(`this Roi layer (${label}) does not exist`); } const roi = this._layers[label].roi.find((roi) => roi.id === roiId); if (!roi) { throw new Error(`found no Roi with id ${roiId}`); } return roi; } /** * Returns an array of masks * See {@link Roi.getMask} for the options * @param {object} [options] * @return {Image[]} Retuns an array of masks (1 bit Image) */ getMasks(options = {}) { let rois = this.getRois(options); let masks = new Array(rois.length); for (let i = 0; i < rois.length; i++) { masks[i] = rois[i].getMask(options); } return masks; } /** * Returns an array of masks * See {@link Roi.getAnalysisMasks} for the options * @param {object} [options] * @return {Image[]} Retuns an array of masks (1 bit Image) */ getAnalysisMasks(options = {}) { const { analysisProperty } = options; let maskProperty = `${analysisProperty}Mask`; let rois = this.getRois(options); if (rois.length === 0 || !rois[0][maskProperty]) return []; return rois.map((roi) => roi[maskProperty]); } /** * * @param {object} [options] * @return {number[]} */ getData(options = {}) { let opt = Object.assign({}, this._options, options); this._assertLayerWithLabel(opt.label); return this._layers[opt.label].roiMap.data; } /** * Paint the ROI on a copy of the image and return this image. * For painting options {@link Image.paintMasks} * For ROI selection options, see {@link RoiManager.getMasks} * @param {object} [options] - all the options to select ROIs * @param {string} [options.labelProperty] - Paint a mask property on the image. * May be any property of the ROI like * for example id, surface, width, height, meanX, meanY. * @param {number} [options.pixelSize] Size of a pixel in SI * @param {string} [options.unit="pixel"] Unit in which to display the values * @return {Image} - The painted RGBA 8 bits image */ paint(options = {}) { let { labelProperty, analysisProperty } = options; if (!this._painted) { this._painted = this._image.rgba8(); } let masks = this.getMasks(options); if (labelProperty) { const rois = this.getRois(options); options.labels = rois.map((roi) => deepValue(roi, labelProperty)); const max = Math.max(...options.labels); let isSurface = false; let isDistance = false; if (labelProperty.includes('surface')) { isSurface = true; } else if ( /(?:perimeter|min|max|external|width|height|length)/.test(labelProperty) ) { isDistance = true; } if (isFinite(max)) { let unitLabel = ''; if ( options.unit !== 'pixel' && options.pixelSize && (isDistance || isSurface) ) { unitLabel = isSurface ? `${options.unit}^2` : options.unit; let siLabel = isSurface ? 'm^2' : 'm'; let factor = isSurface ? options.pixelSize ** 2 : options.pixelSize; const convert = Qty.swiftConverter(siLabel, unitLabel); options.labels = options.labels.map((value) => { return convert(factor * value); }); } if (max > 50) { options.labels = options.labels.map( (number) => Math.round(number) + unitLabel, ); } else if (max > 10) { options.labels = options.labels.map( (number) => number.toFixed(1) + unitLabel, ); } else { options.labels = options.labels.map( (number) => number.toFixed(2) + unitLabel, ); } } options.labelsPosition = rois.map((roi) => [roi.meanX, roi.meanY]); } this._painted.paintMasks(masks, options); if (analysisProperty) { let analysisMasks = this.getAnalysisMasks(options); this._painted.paintMasks(analysisMasks, { color: options.analysisColor, alpha: options.analysisAlpha, }); } return this._painted; } // return a mask corresponding to all the selected masks getMask(options = {}) { let mask = new Image(this._image.width, this._image.height, { kind: 'BINARY', }); let masks = this.getMasks(options); for (let i = 0; i < masks.length; i++) { let roi = masks[i]; // we need to find the parent image to calculate the relative position for (let x = 0; x < roi.width; x++) { for (let y = 0; y < roi.height; y++) { if (roi.getBitXY(x, y)) { mask.setBitXY(x + roi.position[0], y + roi.position[1]); } } } } return mask; } /** * Reset the changes to the current painted iamge to the image that was * used during the creation of the RoiManager except if a new image is * specified as parameter; * @param {object} [options] * @param {Image} [options.image] A new iamge that you would like to sue for painting over */ resetPainted(options = {}) { const { image } = options; if (image) { this._painted = this.image.rgba8(); } else { this._painted = this._image.rgba8(); } } /** * In place modification of the roiMap that joins regions of interest * @param {object} [options] * @param {string|function(object,number,number)} [options.algorithm='commonBorderLength'] algorithm used to decide which ROIs are merged. * Current implemented algorithms are 'commonBorderLength' that use the parameters * 'minCommonBorderLength' and 'maxCommonBorderLength' as well as 'commonBorderRatio' that uses * the parameters 'minCommonBorderRatio' and 'maxCommonBorderRatio'. * @param {number} [options.minCommonBorderLength=5] minimal common number of pixels for merging * @param {number} [options.maxCommonBorderLength=100] maximal common number of pixels for merging * @param {number} [options.minCommonBorderRatio=0.3] minimal common border ratio for merging * @param {number} [options.maxCommonBorderRatio=1] maximal common border ratio for merging * @return {this} */ mergeRoi(options = {}) { const roiMap = this.getMap(options); roiMap.mergeRoi(options); this.putMap(roiMap.data, options); return this; } /** * Merge multiple rois into one. * All rois in the provided array will be merged into the first one. * @param {Array<number>} roiIds - A list of Roi ids to merge * @param {object} [options] */ mergeRois(roiIds, options = {}) { if (!Array.isArray(roiIds) || roiIds.some((id) => !Number.isInteger(id))) { throw new Error('Roi ids must be an array of integers'); } if (roiIds.length < 2) { throw new Error('Roi ids must have at least two elements'); } if (new Set(roiIds).size !== roiIds.length) { throw new Error('Roi ids must be all different'); } // Throws if one of the ids is wrong roiIds.forEach((roiId) => this.getRoi(roiId)); const roiMap = this.getMap(options); roiMap.mergeRois(roiIds); this.putMap(roiMap.data, options); return this; } /** * Finds all corresponding ROIs for all ROIs in the manager * @param {number[]} roiMap * @param {object} [options] * @return {Array} array of objects returned in correspondingRoisInformation */ findCorrespondingRoi(roiMap, options = {}) { let allRois = this.getRois(options); let allRelated = []; for (let i = 0; i < allRois.length; i++) { let currentRoi = allRois[i]; let x = currentRoi.minX; let y = currentRoi.minY; let allPoints = currentRoi.points; let roiSign = Math.sign(currentRoi.id); let currentRelated = correspondingRoisInformation( x, y, allPoints, roiMap, roiSign, ); allRelated.push(currentRelated); } return allRelated; } _assertLayerWithLabel(label) { if (!this._layers[label]) { throw new Error(`no layer with label ${label}`); } } } /** * For a given ROI, find corresponding ROIs and properties in given ROIMap. * Returns an object containing the ID of ROIs, the surface shared by given and corresponding ROIs, * the percentage of given ROI surface covered by the corresponding ROI, the number of points with same and opposite signs, * the total number of points (same and opposite). * @param {number} x - minX value of ROI * @param {number} y - minY value of ROI * @param {Array<Array<number>>} points - points of ROI * @param {Array<number>} roiMap - roiMap from which we get the corresponding ROI * @param {number} roiSign - sign of ROI * @return {object} {{id: Array, surface: Array, roiSurfaceCovered: Array, same: number, opposite: number, total: number}} * @private */ function correspondingRoisInformation(x, y, points, roiMap, roiSign) { let correspondingRois = { id: [], surface: [], roiSurfaceCovered: [], same: 0, opposite: 0, total: 0, }; for (let i = 0; i < points.length; i++) { let currentPoint = points[i]; let currentX = currentPoint[0]; let currentY = currentPoint[1]; let correspondingRoiMapIndex = currentX + x + (currentY + y) * roiMap.width; let value = roiMap.data[correspondingRoiMapIndex]; if (value > 0 || value < 0) { if (correspondingRois.id.includes(value)) { correspondingRois.surface[correspondingRois.id.indexOf(value)] += 1; } else { correspondingRois.id.push(value); correspondingRois.surface.push(1); } } } for (let i = 0; i < correspondingRois.id.length; i++) { let currentSign = Math.sign(correspondingRois.id[i]); if (currentSign === roiSign) { correspondingRois.same += correspondingRois.surface[i]; } else { correspondingRois.opposite += correspondingRois.surface[i]; } correspondingRois.roiSurfaceCovered[i] = correspondingRois.surface[i] / points.length; } correspondingRois.total = correspondingRois.opposite + correspondingRois.same; return correspondingRois; }