image-js
Version:
Image processing and manipulation in JavaScript
562 lines • 22.1 kB
JavaScript
import { getConvexHull } from '../maskAnalysis/getConvexHull.js';
import { getFeret } from '../maskAnalysis/getFeret.js';
import { getMbr } from '../maskAnalysis/getMbr.js';
import { getBorderPoints } from './getBorderPoints.js';
import { getExternalContour } from "./getExternalContour.js";
import { getMask } from './getMask.js';
import { getEllipse } from './properties/getEllipse.js';
export class Roi {
/**
* Original map with all the ROI IDs.
*/
map;
/**
* ID of the ROI. Positive for white ROIs and negative for black ones.
*/
id;
/**
* Origin of the ROI. The top-left corner of the rectangle around
* the ROI relative to the original image.
*/
origin;
/**
* Width of the ROI.
*/
width;
/**
* Height of the ROI.
*/
height;
/**
* Surface of the ROI.
*/
surface;
/**
* Cached values of properties to improve performance.
*/
#computed;
constructor(map, id, width, height, origin, surface) {
this.map = map;
this.id = id;
this.origin = origin;
this.width = width;
this.height = height;
this.surface = surface;
this.#computed = {};
}
/**
* Return the value at the given coordinates in an ROI map.
* @param column - Column of the value.
* @param row - Row of the value.
* @returns The value at the given coordinates.
*/
getMapValue(column, row) {
return this.map.data[this.map.width * row + column];
}
/**
* Returns the ratio between the width and the height of the bounding rectangle of the ROI.
* @returns The width by height ratio.
*/
getRatio() {
return this.width / this.height;
}
/**
* Generates a mask of an ROI. You can specify the kind of mask you want using the `kind` option.
* @param options - Get Mask options.
* @returns The ROI mask.
*/
getMask(options) {
return getMask(this, options);
}
/**
* Computes the diameter of a circle that has the same perimeter as the particle image.
* @returns Ped value in pixels.
*/
get ped() {
return this.perimeter / Math.PI;
}
/**
* Return an array with the coordinates of the pixels that are on the border of the ROI.
* The points are defined as [column, row].
* @param options - Get border points options.
* @returns The array of border pixels.
*/
getBorderPoints(options) {
return getBorderPoints(this, options);
}
/**
* Finds external contour of the roi from its mask.
* @param options - GetExternalContourOptions.
* @returns Array of contour points.
*/
getExternalContour(options) {
return getExternalContour(this, options);
}
/**
* Returns an array of ROIs IDs that are included in the current ROI.
* This will be useful to know if there are some holes in the ROI.
* @returns InternalIDs.
*/
get internalIDs() {
return this.#getComputed('internalIDs', () => {
const internal = [this.id];
const roiMap = this.map;
const data = roiMap.data;
if (this.height > 2) {
for (let column = 0; column < this.width; column++) {
const target = this.#computeIndex(0, column);
if (internal.includes(data[target])) {
const id = data[target + roiMap.width];
if (!internal.includes(id) && !this.boxIDs.includes(id)) {
internal.push(id);
}
}
}
}
const array = new Array(4);
for (let column = 1; column < this.width - 1; column++) {
for (let row = 1; row < this.height - 1; row++) {
const target = this.#computeIndex(row, column);
if (internal.includes(data[target])) {
// We check if one of the neighbor is not yet in.
array[0] = data[target - 1];
array[1] = data[target + 1];
array[2] = data[target - roiMap.width];
array[3] = data[target + roiMap.width];
for (let i = 0; i < 4; i++) {
const id = array[i];
if (!internal.includes(id) && !this.boxIDs.includes(id)) {
internal.push(id);
}
}
}
}
}
return internal;
});
}
/**
* Returns an array of ROIs IDs that touch the current ROI.
* @returns The array of Borders.
*/
get externalBorders() {
return this.#getComputed('externalBorders', () => {
// Takes all the borders and removes the internal one ...
const borders = this.borders;
const externalBorders = [];
const externalIDs = [];
const internals = this.internalIDs;
for (const border of borders) {
if (!internals.includes(border.connectedID)) {
const element = {
connectedID: border.connectedID,
length: border.length,
};
externalIDs.push(element.connectedID);
externalBorders.push(element);
}
}
return externalBorders;
});
}
/**
* Calculates and caches the number of sides by which each pixel is touched externally.
* @returns An object which tells how many pixels are exposed externally to how many sides.
*/
get perimeterInfo() {
return this.#getComputed('perimeterInfo', () => {
const roiMap = this.map;
const data = roiMap.data;
let one = 0;
let two = 0;
let three = 0;
let four = 0;
const externalIDs = new Set(this.externalBorders.map((element) => element.connectedID));
for (let column = 0; column < this.width; column++) {
for (let row = 0; row < this.height; row++) {
const target = this.#computeIndex(row, column);
if (data[target] === this.id) {
let nbAround = 0;
if (column === 0) {
nbAround++;
}
else if (externalIDs.has(data[target - 1])) {
nbAround++;
}
if (column === roiMap.width - 1) {
nbAround++;
}
else if (externalIDs.has(data[target + 1])) {
nbAround++;
}
if (row === 0) {
nbAround++;
}
else if (externalIDs.has(data[target - roiMap.width])) {
nbAround++;
}
if (row === roiMap.height - 1) {
nbAround++;
}
else if (externalIDs.has(data[target + roiMap.width])) {
nbAround++;
}
switch (nbAround) {
case 1:
one++;
break;
case 2:
two++;
break;
case 3:
three++;
break;
case 4:
four++;
break;
default:
}
}
}
}
return { one, two, three, four };
});
}
/**
* Perimeter of the ROI.
* The perimeter is calculated using the sum of all the external borders of the ROI to which we subtract:
* (2 - √2) * the number of pixels that have 2 external borders
* 2 * (2 - √2) * the number of pixels that have 3 external borders
* @returns Perimeter value in pixels.
*/
get perimeter() {
const info = this.perimeterInfo;
const delta = 2 - Math.sqrt(2);
return (info.one +
info.two * 2 +
info.three * 3 +
info.four * 4 -
delta * (info.two + info.three * 2 + info.four));
}
/**
* Computes ROI points relative to ROIs point of `origin`.
* @returns Array of points with relative ROI coordinates.
*/
get relativePoints() {
return this.#getComputed(`relativePoints`, () => {
const points = Array.from(this.points(false));
return points;
});
}
/**
* Computes ROI points relative to Image's/Mask's point of `origin`.
* @returns Array of points with absolute ROI coordinates.
*/
get absolutePoints() {
return this.#getComputed(`absolutePoints`, () => {
const points = Array.from(this.points(true));
return points;
});
}
get boxIDs() {
return this.#getComputed('boxIDs', () => {
const surroundingIDs = new Set(); // Allows to get a unique list without indexOf.
const roiMap = this.map;
const data = roiMap.data;
// We check the first line and the last line.
for (const row of [0, this.height - 1]) {
for (let column = 0; column < this.width; column++) {
const target = this.#computeIndex(row, column);
if (column - this.origin.column > 0 &&
data[target] === this.id &&
data[target - 1] !== this.id) {
const value = data[target - 1];
surroundingIDs.add(value);
}
if (roiMap.width - column - this.origin.column > 1 &&
data[target] === this.id &&
data[target + 1] !== this.id) {
const value = data[target + 1];
surroundingIDs.add(value);
}
}
}
// We check the first column and the last column.
for (const column of [0, this.width - 1]) {
for (let row = 0; row < this.height; row++) {
const target = this.#computeIndex(row, column);
if (row - this.origin.row > 0 &&
data[target] === this.id &&
data[target - roiMap.width] !== this.id) {
const value = data[target - roiMap.width];
surroundingIDs.add(value);
}
if (roiMap.height - row - this.origin.row > 1 &&
data[target] === this.id &&
data[target + roiMap.width] !== this.id) {
const value = data[target + roiMap.width];
surroundingIDs.add(value);
}
}
}
return Array.from(surroundingIDs); // The selection takes the whole rectangle.
});
}
/**
* Computes the diameter of a circle of equal projection area (EQPC).
* It is a diameter of a circle that has the same surface as the ROI.
* @returns `eqpc` value in pixels.
*/
get eqpc() {
return 2 * Math.sqrt(this.surface / Math.PI);
}
/**
* Computes ellipse of ROI. It is the smallest ellipse that fits the ROI.
* @returns Ellipse
*/
get ellipse() {
return this.#getComputed('ellipse', () => {
return getEllipse(this);
});
}
/**
* Number of holes in the ROI and their total surface.
* Used to calculate fillRatio.
* @returns The surface of holes in ROI in pixels.
*/
get holesInfo() {
return this.#getComputed('holesInfo', () => {
let surface = 0;
const data = this.map.data;
for (let column = 1; column < this.width - 1; column++) {
for (let row = 1; row < this.height - 1; row++) {
const target = this.#computeIndex(row, column);
if (this.internalIDs.includes(data[target]) &&
data[target] !== this.id) {
surface++;
}
}
}
return {
number: this.internalIDs.length - 1,
surface,
};
});
}
/**
* Calculates and caches border's length and their IDs.
* @returns Borders' length and their IDs.
*/
get borders() {
return this.#getComputed('borders', () => {
const roiMap = this.map;
const data = roiMap.data;
const surroudingIDs = new Set();
const surroundingBorders = new Map();
const visitedData = new Set();
const dx = [1, 0, -1, 0];
const dy = [0, 1, 0, -1];
for (let column = this.origin.column; column <= this.origin.column + this.width; column++) {
for (let row = this.origin.row; row <= this.origin.row + this.height; row++) {
const target = column + row * roiMap.width;
if (data[target] === this.id) {
for (let dir = 0; dir < 4; dir++) {
const newX = column + dx[dir];
const newY = row + dy[dir];
if (newX >= 0 &&
newY >= 0 &&
newX < roiMap.width &&
newY < roiMap.height) {
const neighbour = newX + newY * roiMap.width;
if (data[neighbour] !== this.id &&
!visitedData.has(neighbour)) {
visitedData.add(neighbour);
surroudingIDs.add(data[neighbour]);
let surroundingBorder = surroundingBorders.get(data[neighbour]);
if (!surroundingBorder) {
surroundingBorders.set(data[neighbour], 1);
}
else {
surroundingBorders.set(data[neighbour], ++surroundingBorder);
}
}
}
}
}
}
}
const id = Array.from(surroudingIDs);
return id.map((id) => {
return {
connectedID: id,
length: surroundingBorders.get(id),
};
});
});
}
/**
* Computes fill ratio of the ROI. It is calculated by dividing ROI's actual surface over the surface combined with holes, to see how holes affect its surface.
* @returns Fill ratio value.
*/
get fillRatio() {
return this.surface / (this.surface + this.holesInfo.surface);
}
/**
* Computes sphericity of the ROI.
* Sphericity is a measure of the degree to which a particle approximates the shape of a sphere, and is independent of its size. The value is always between 0 and 1. The less spheric the ROI is the smaller is the number.
* @returns Sphericity value.
*/
get sphericity() {
return (2 * Math.sqrt(this.surface * Math.PI)) / this.perimeter;
}
/**
* Computes the surface of the ROI, including the surface of the holes.
* @returns Surface including holes measured in pixels.
*/
get filledSurface() {
return this.surface + this.holesInfo.surface;
}
/**
* The solidity describes the extent to which a shape is convex or concave.
* The solidity of a completely convex shape is 1, the farther the it deviates from 1, the greater the extent of concavity in the shape of the ROI.
* @returns Solidity value.
*/
get solidity() {
return this.surface / this.convexHull.surface;
}
//TODO Should be refactored to not need creating a new Mask.
/**
*Computes convex hull. It is the smallest convex set that contains it.
* @see https://en.wikipedia.org/wiki/Convex_hull
* @returns Convex hull.
*/
get convexHull() {
return this.#getComputed('convexHull', () => {
return getConvexHull(this.getMask());
});
}
/**
* Computes the minimum bounding rectangle.
* In digital image processing, the bounding box is merely the coordinates of the rectangular border that fully encloses a digital image when it is placed over a page, a canvas, a screen or other similar bidimensional background.
* @returns The minimum bounding rectangle.
*/
get mbr() {
return this.#getComputed('mbr', () => {
return getMbr(this.getMask());
});
}
/*
* Computes roundness of ROI.
* Roundness is the measure of how closely the shape of an object approaches that of a mathematically perfect circle.
(See slide 24 https://static.horiba.com/fileadmin/Horiba/Products/Scientific/Particle_Characterization/Webinars/Slides/TE011.pdf) */
get roundness() {
return (4 * this.surface) / (Math.PI * this.feret.maxDiameter.length ** 2);
}
/**
* This is not a diameter in its actual sense but the common basis of a group of diameters derived from the distance of two tangents to the contour of the particle in a well-defined orientation.
* In simpler words, the method corresponds to the measurement by a slide gauge (slide gauge principle).
* In general it is defined as the distance between two parallel tangents of the particle at an arbitrary angle. The minimum Feret diameter is often used as the diameter equivalent to a sieve analysis.
* @returns The maximum and minimum Feret Diameters.
*/
get feret() {
return this.#getComputed('feret', () => {
return getFeret(this.getMask());
});
}
/**
* A JSON object with all the data about ROI.
* @returns All current ROI properties as one object.
*/
toJSON() {
return {
id: this.id,
origin: this.origin,
height: this.height,
width: this.width,
surface: this.surface,
eqpc: this.eqpc,
ped: this.ped,
feret: this.feret,
fillRatio: this.fillRatio,
sphericity: this.sphericity,
roundness: this.roundness,
solidity: this.solidity,
perimeter: this.perimeter,
convexHull: this.convexHull,
mbr: this.mbr,
filledSurface: this.filledSurface,
centroid: this.centroid,
};
}
/**
* Computes a center of mass of the current ROI.
* @returns point
*/
get centroid() {
return this.#getComputed('centroid', () => {
const roiMap = this.map;
const data = roiMap.data;
let sumColumn = 0;
let sumRow = 0;
for (let column = 0; column < this.width; column++) {
for (let row = 0; row < this.height; row++) {
const target = this.#computeIndex(row, column);
if (data[target] === this.id) {
sumColumn += column;
sumRow += row;
}
}
}
return {
column: sumColumn / this.surface + this.origin.column,
row: sumRow / this.surface + this.origin.row,
};
});
}
// A helper function to cache already calculated properties.
#getComputed(property, callback) {
if (this.#computed[property] === undefined) {
const result = callback();
this.#computed[property] = result;
return result;
}
return this.#computed[property];
}
//TODO Make this private.
/**
* Calculates the correct index on the map of ROI.
* @param y - Map row
* @param x - Map column
* @returns Index within the ROI map.
*/
#computeIndex(y, x) {
const roiMap = this.map;
return (y + this.origin.row) * roiMap.width + x + this.origin.column;
}
/**
* Generator function to calculate point's coordinates.
* @param absolute - controls whether coordinates should be relative to ROI's point of `origin` (relative), or relative to ROI's position on the Image/Mask (absolute).
* @yields Coordinates of each point of ROI.
*/
*points(absolute) {
for (let row = 0; row < this.height; row++) {
for (let column = 0; column < this.width; column++) {
const target = (row + this.origin.row) * this.map.width +
column +
this.origin.column;
if (this.map.data[target] === this.id) {
if (absolute) {
yield {
column: this.origin.column + column,
row: this.origin.row + row,
};
}
else {
yield { column, row };
}
}
}
}
}
}
//# sourceMappingURL=Roi.js.map