diff --git a/.gitignore b/.gitignore index 26c6b31da..cee44d2c4 100644 --- a/.gitignore +++ b/.gitignore @@ -131,5 +131,6 @@ src/**/*.png # ignore images in test scripts folder scripts/**/**.png scripts/**/**.json +scripts/**/**.jpg private diff --git a/scripts/alignStack/testAlignStack.ts b/scripts/alignStack/testAlignStack.ts new file mode 100644 index 000000000..709038f97 --- /dev/null +++ b/scripts/alignStack/testAlignStack.ts @@ -0,0 +1,38 @@ +import { readdirSync } from 'node:fs'; +import { + alignStack, + createStackFromPaths, + cropCommonArea, +} from '../../src/stack/index'; +import { findCommonArea } from '../../src/stack/align/utils/findCommonArea'; +import { crop, writeSync } from '../../src'; + +const folder = `${__dirname}/BloodMoon`; +let imageNames = readdirSync(folder); + +console.log(imageNames); + +const paths = imageNames.map((name) => `${folder}/${name}`); + +console.log('Number of images in stack:', paths.length); + +const stack = createStackFromPaths(paths); + +console.log( + `Images dimensions: ${stack.getImage(0).width}x${stack.getImage(0).height}`, +); + +console.log('Compute absolute translations'); +const translations = alignStack(stack, { + debug: true, + scalingFactor: 20, +}); + +console.log('Cropping all images'); +const crops = cropCommonArea(stack, translations.absolute); + +console.log('Writing all images'); +for (let i = 0; i < crops.size; i++) { + const image = crops.getImage(i); + writeSync(`${__dirname}/result/${imageNames[i]}`, image); +} diff --git a/src/Stack.ts b/src/Stack.ts index e56f190c4..d4c05f8fc 100644 --- a/src/Stack.ts +++ b/src/Stack.ts @@ -74,6 +74,7 @@ export class Stack { return new Stack(this.images.map((image) => image.clone())); } + // GET AND SET /** * Get the images of the stack. Mainly for debugging purposes. * @returns The images. @@ -123,6 +124,8 @@ export class Stack { return this.images[stackIndex].getValueByIndex(index, channel); } + // COMPUTE + /** * Return the image containing the minimum values of all the images in the stack for * each pixel. All the images must have the same dimensions. diff --git a/src/__tests__/Stack.test.ts b/src/__tests__/Stack.test.ts index 8e5c7bf7c..ed759d60a 100644 --- a/src/__tests__/Stack.test.ts +++ b/src/__tests__/Stack.test.ts @@ -103,3 +103,14 @@ test('remove images too dark using filter', () => { expect(result.size).toBe(1); expect(result.getImage(0)).toMatchImageData([[100, 100, 100, 100]]); }); + +test('set translations, wrong length', () => { + const image1 = testUtils.createGreyImage([[1, 2, 3, 4]]); + const image2 = testUtils.createGreyImage([[100, 100, 100, 100]]); + const stack = new Stack([image1, image2]); + expect(() => { + stack.setTranslations([{ row: 0, column: 0 }]); + }).toThrow( + 'The number of translations must be equal to the number of images', + ); +}); diff --git a/src/align/__tests__/__image_snapshots__/align-test-ts-id-crops-1-snap.png b/src/align/__tests__/__image_snapshots__/align-test-ts-id-crops-1-snap.png new file mode 100644 index 000000000..c9f5657b4 Binary files /dev/null and b/src/align/__tests__/__image_snapshots__/align-test-ts-id-crops-1-snap.png differ diff --git a/src/align/__tests__/__image_snapshots__/align-test-ts-other-id-crops-1-snap.png b/src/align/__tests__/__image_snapshots__/align-test-ts-other-id-crops-1-snap.png new file mode 100644 index 000000000..1ba83fab9 Binary files /dev/null and b/src/align/__tests__/__image_snapshots__/align-test-ts-other-id-crops-1-snap.png differ diff --git a/src/align/__tests__/align.test.ts b/src/align/__tests__/align.test.ts new file mode 100644 index 000000000..94971feb2 --- /dev/null +++ b/src/align/__tests__/align.test.ts @@ -0,0 +1,115 @@ +import { overlapImages } from '../../featureMatching'; +import { align } from '../align'; + +test('1 pixel source', () => { + const source = testUtils.createGreyImage([ + [255, 0], + [0, 0], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0], + [0, 0, 0], + [0, 255, 0], + [0, 0, 0], + ]); + + const result = align(source, destination); + expect(result).toStrictEqual({ row: 2, column: 1 }); +}); + +test('4 pixels source', () => { + const source = testUtils.createGreyImage([ + [0, 80], + [150, 200], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0], + [0, 0, 100], + [0, 255, 255], + ]); + + const result = align(source, destination, { blurKernelSize: 1 }); + expect(result).toStrictEqual({ + row: 1, + column: 1, + }); +}); + +test('twice same image', () => { + const source = testUtils.load('opencv/test.png').grey(); + + const destination = testUtils.load('opencv/test.png').grey(); + + const result = align(source, destination); + expect(result).toStrictEqual({ row: 0, column: 0 }); +}); + +test('larger image and crop', () => { + const side = 100; + const origin = { row: 30, column: 30 }; + const destination = testUtils.load('ssim/ssim-original.png'); + const source = destination.crop({ origin, width: side, height: side }); + + const result = align(source, destination); + + expect(result).toStrictEqual(origin); +}); + +test('id crops', () => { + const destination = testUtils.load('align/cropped.png').grey(); + const source = testUtils.load('align/croppedRef.png').grey(); + + const result = align(source, destination); + + const overlap = overlapImages(source, destination, { origin: result }); + + expect(overlap).toMatchImageSnapshot(); +}); + +test('other id crops', () => { + const destination = testUtils.load('align/cropped1.png').grey(); + const source = testUtils.load('align/croppedRef1.png').grey(); + const result = align(source, destination, { level: 'uniform' }); + + const overlap = overlapImages(source, destination, { origin: result }); + expect(overlap).toMatchImageSnapshot(); +}); + +test('same size source and destination', () => { + const source = testUtils.createGreyImage([ + [0, 0, 0], + [0, 0, 80], + [0, 150, 200], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0], + [0, 0, 100], + [0, 255, 255], + ]); + + const result = align(source, destination, { blurKernelSize: 1 }); + expect(result).toStrictEqual({ + row: 0, + column: 0, + }); +}); + +test('expect wrong behavior', () => { + const source = testUtils.createGreyImage([ + [0, 100, 0], + [255, 255, 0], + [0, 0, 0], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0], + [0, 0, 100], + [0, 255, 255], + ]); + + const result = align(source, destination, { blurKernelSize: 1 }); + expect(result).toStrictEqual({ + row: 0, + column: 0, + }); + // this algorithm doen't work when one image is partly out of the other +}); diff --git a/src/align/align.ts b/src/align/align.ts new file mode 100644 index 000000000..563428b75 --- /dev/null +++ b/src/align/align.ts @@ -0,0 +1,164 @@ +import { + Point, + Image, + alignMinDifference, + ImageColorModel, + ThresholdAlgorithm, + Mask, +} from '..'; + +export type LevelingAlgorithm = 'none' | 'minMax' | 'uniform'; + +export interface AlignOptions { + /** + * Factor by which to scale down the images for the rough alignment phase. + * @default 4 + */ + scalingFactor?: number; + /** + * Kernel size for the blur applied to the images before the rough alignment phase. + * @default 3 + */ + blurKernelSize?: number; + /** + * Factor by which to multiply the scaling factor to get the margin for the precise alignment phase. + * @default 1.5 + */ + precisionFactor?: number; + /** + * Whether to auto level the images before the precise alignment phase. You can chose between `minMax` + * leveling (span all channels from 0 to max value) or `uniform`(keep the color balance). + * @default 'minMax' + */ + level?: LevelingAlgorithm; + /** + * Threshold algorithm to use for the alignment masks. + * @default 'otsu' + */ + thresholdAlgoritm?: ThresholdAlgorithm; +} + +/** + * Align a source image on a destination image. The source must fit entirely inside the destination image. + * A rough alignment is first done on the images scaled down and a precise alignment is then + * applied on the real size images. Only part of the pixels are used for the comparison. + * The pixel to considered are defined by a mask. The algorithm used to create the mask + * is defined by the `thresholdAlgorithm` option. + * @param source - The source image. Must be smaller than the destination image. + * @param destination - The destination image. + * @param options - Aligning options. + * @returns The coordinates of the source image relative to the top-left corner + * of the destination image for an optimal alignment. + */ +export function align( + source: Image, + destination: Image, + options: AlignOptions = {}, +): Point { + const { + scalingFactor = 4, + precisionFactor = 1.5, + thresholdAlgoritm = 'otsu', + } = options; + + // console.log({ level: options.level }); + + // rough alignment + const small = prepareForAlign(destination, options); + const smallRef = prepareForAlign(source, options); + + const smallMask = getAlignMask(smallRef, thresholdAlgoritm); + + const roughAlign = alignMinDifference(smallRef, small, { + mask: smallMask, + startStep: 1, + }); + + // precise alignment + + // number of pixels to add around the rough roi crop for the precise alignment + const margin = scalingFactor * precisionFactor; + const originColumn = Math.max(0, roughAlign.column * scalingFactor - margin); + const originRow = Math.max(0, roughAlign.row * scalingFactor - margin); + + const roughCrop = destination.crop({ + origin: { + column: originColumn, + row: originRow, + }, + width: Math.min( + destination.width - originColumn, + source.width + 2 * margin, + ), + height: Math.min( + destination.height - originRow, + source.height + 2 * margin, + ), + }); + + const preciseCrop = prepareForAlign(roughCrop, { + ...options, + scalingFactor: 1, + }); + const preciseRef = prepareForAlign(source, { + ...options, + scalingFactor: 1, + }); + const refMask = getAlignMask(preciseRef, thresholdAlgoritm); + + const preciseAlign = alignMinDifference(preciseRef, preciseCrop, { + startStep: 1, + mask: refMask, + }); + + return { + column: originColumn + preciseAlign.column, + row: originRow + preciseAlign.row, + }; +} + +/** + * Prepare an image to align it with another image. + * @param image - Crop to align. + * @param options - Prepare for align options. + * @returns The prepared image + */ +export function prepareForAlign( + image: Image, + options: AlignOptions = {}, +): Image { + const { scalingFactor = 4, blurKernelSize = 3, level = 'minMax' } = options; + + const blurred = image.blur({ width: blurKernelSize, height: blurKernelSize }); + if (level === 'minMax') { + blurred.increaseContrast({ out: blurred }); + } else if (level === 'uniform') { + blurred.increaseContrast({ uniform: true, out: blurred }); + } + const scaled = blurred.resize({ + xFactor: 1 / scalingFactor, + yFactor: 1 / scalingFactor, + }); + return scaled; +} + +/** + * Create a mask from an image to specify which pixels to use for the alignment. + * @param image - Image to create the mask from. + * @param algorithm - Threshold algorithm to use. + * @returns The mask. + */ +export function getAlignMask( + image: Image, + algorithm: ThresholdAlgorithm, +): Mask { + if (image.colorModel !== ImageColorModel.GREY) { + image = image.grey(); + } + let mask = image.threshold({ algorithm }); + + if (mask.getNbNonZeroPixels() > image.size / 2) { + mask = mask.invert(); + } + return mask.dilate({ iterations: 2 }); +} diff --git a/src/align/alignMinDifference.ts b/src/align/alignMinDifference.ts index 2bff66db0..96a0462df 100644 --- a/src/align/alignMinDifference.ts +++ b/src/align/alignMinDifference.ts @@ -12,7 +12,7 @@ export interface AlignMinDifferenceOptions { /** * Aligns two images by finding the translation that minimizes the mean difference of all channels. - * between them. The source image should fit entirely in the destination image. + * The source image should fit entirely in the destination image. * @param source - Image to align. * @param destination - Image to align to. * @param options - Align images min difference options. diff --git a/src/featureMatching/visualize/overlapImages.ts b/src/featureMatching/visualize/overlapImages.ts index 28b1fa616..f7f3ff3e4 100644 --- a/src/featureMatching/visualize/overlapImages.ts +++ b/src/featureMatching/visualize/overlapImages.ts @@ -2,19 +2,19 @@ import { Image, ImageColorModel, ImageCoordinates, Point, merge } from '../..'; export interface OverlapImageOptions { /** - * Origin of the second image relatively to top-left corner of first image. + * Origin of the first image relatively to top-left corner of second image. * @default `{row: 0, column: 0}` */ origin?: Point; /** - * Desired rotation of image 2 in degrees around its top-left corner. + * Desired rotation of image1 in degrees around its top-left corner. * @default `0` */ angle?: number; /** - * Factor by which to scale the second image. + * Factor by which to scale the first image. * @default `1` */ scale?: number; diff --git a/src/operations/paintMaskOnImage.ts b/src/operations/paintMaskOnImage.ts index 216bd769e..0154d46c8 100644 --- a/src/operations/paintMaskOnImage.ts +++ b/src/operations/paintMaskOnImage.ts @@ -29,7 +29,7 @@ export interface PaintMaskOnImageOptions { } /** - * Paint a mask onto an image and the given position and with the given color. + * Paint a mask onto an image at the given position and with the given color. * @param image - Image on which to paint the mask. * @param mask - Mask to paint on the image. * @param options - Paint mask options. diff --git a/src/stack/align/__tests__/__image_snapshots__/align-different-size-test-ts-different-image-sizes-1-snap.png b/src/stack/align/__tests__/__image_snapshots__/align-different-size-test-ts-different-image-sizes-1-snap.png new file mode 100644 index 000000000..0f151d496 Binary files /dev/null and b/src/stack/align/__tests__/__image_snapshots__/align-different-size-test-ts-different-image-sizes-1-snap.png differ diff --git a/src/stack/align/__tests__/__image_snapshots__/align-different-size-test-ts-id-crops-1-snap.png b/src/stack/align/__tests__/__image_snapshots__/align-different-size-test-ts-id-crops-1-snap.png new file mode 100644 index 000000000..c9f5657b4 Binary files /dev/null and b/src/stack/align/__tests__/__image_snapshots__/align-different-size-test-ts-id-crops-1-snap.png differ diff --git a/src/stack/align/__tests__/alignDifferentSize.test.ts b/src/stack/align/__tests__/alignDifferentSize.test.ts new file mode 100644 index 000000000..23b4b67bd --- /dev/null +++ b/src/stack/align/__tests__/alignDifferentSize.test.ts @@ -0,0 +1,69 @@ +import { overlapImages } from '../../../featureMatching'; +import { writeSync } from '../../../save'; +import { alignDifferentSize } from '../alignDifferentSize'; + +test('id crops', () => { + const destination = testUtils.load('align/cropped.png').grey(); + const source = testUtils.load('align/croppedRef.png').grey(); + + const result = alignDifferentSize(source, destination, { + maxNbOperations: 1e8, + }); + + const overlap = overlapImages(source, destination, { origin: result }); + + expect(overlap).toMatchImageSnapshot(); +}); + +test('different image sizes, rounding problem', () => { + const einstein = testUtils.load('ssim/ssim-original.png'); + + const origin = { row: 50, column: 50 }; + const destination = einstein.crop({ + width: 200, + height: 200, + }); + const source = einstein.crop({ + width: 200, + height: 100, + origin, + }); + + const result = alignDifferentSize(source, destination, { + maxNbOperations: 1e8, + xFactor: 0.5, + yFactor: 0.5, + }); + + const overlap = overlapImages(source, destination, { origin: result }); + writeSync(`${__dirname}/overlap.png`, overlap); + + expect(result).toStrictEqual(origin); +}); + +test('different image sizes, no rounding problem', () => { + const einstein = testUtils.load('ssim/ssim-original.png'); + + const origin = { row: 20, column: 24 }; + const destination = einstein.crop({ + width: 150, + height: 200, + }); + const source = einstein.crop({ + width: 160, + height: 120, + origin, + }); + + const result = alignDifferentSize(source, destination, { + maxNbOperations: 1e8, + xFactor: 0.5, + yFactor: 0.5, + debug: true, + }); + + const overlap = overlapImages(source, destination, { origin: result }); + writeSync(`${__dirname}/overlap.png`, overlap); + + expect(result).toStrictEqual(origin); +}); diff --git a/src/stack/align/alignDifferentSize.ts b/src/stack/align/alignDifferentSize.ts new file mode 100644 index 000000000..c795f4f22 --- /dev/null +++ b/src/stack/align/alignDifferentSize.ts @@ -0,0 +1,282 @@ +import { + Image, + ImageColorModel, + Point, + ThresholdAlgorithm, + overlapImages, + writeSync, +} from '../..'; +import { + LevelingAlgorithm, + getAlignMask, + prepareForAlign, +} from '../../align/align'; +import { max } from '../../operations/greyAlgorithms'; + +import { + ComputeXYMarginsOptions, + computeNbOperations, + computeXYMargins, +} from './utils/computeNbOperations'; +import { findOverlap } from './utils/findOverlap'; +import { getMinDiffTranslation } from './utils/getMinDiffTranslation'; + +export interface AlignDifferentSizeOptions extends ComputeXYMarginsOptions { + /** + * Maximal number of operations that the algorithm can perform. + * This number is used to rescale the images if they are too big so that + * the algorithm takes roughly always the same time to compute. + * You can use scalingFactor instead to specify the scaling factor to apply, + * in that case, maxNbOperations should be undefined. + * @default 1e8 + */ + maxNbOperations?: number; + /** + * Scaling factor to apply to the images before the rough alignment phase. + * Can only be used if maxnbOperations is undefined. + * @default undefined + */ + scalingFactor?: number; + /** + * Threshold algorithm to use for the alignment masks. + * @default 'otsu' + */ + thresholdAlgoritm?: ThresholdAlgorithm; + /** + * Factor by which to multiply the scaling factor to get the margin for the precise alignment phase. + * @default 1.5 + */ + precisionFactor?: number; + /** + * Whether to auto level the images before the precise alignment phase. You can chose between `minMax` + * leveling (span all channels from 0 to max value) or `uniform`(keep the color balance). + * @default 'minMax' + */ + level?: LevelingAlgorithm; + /** + * Kernel size for the blur applied to the images before the rough alignment phase. + * @default 3 + */ + blurKernelSize?: number; + /** + * Minimal fraction of the pixels of the source that have to overlap to apply the algorithm. + * @default 0.1 + */ + minFractionPixels?: number; + /** + * Minimal number of overlapping pixels to apply the algorithm. + * @default undefined + */ + minNbPixels?: number; + /** + * Display debug information? + * @default false + */ + debug?: boolean; +} + +/** + * Align two different size images by finding the position which minimises the difference. + * @param source - Source image. + * @param destination - Destination image. + * @param options - Align different size options. + * @returns The translation to apply to the source image to align it with the destination image. + */ +export function alignDifferentSize( + source: Image, + destination: Image, + options: AlignDifferentSizeOptions = {}, +): Point { + const { + xFactor = 0.5, + yFactor = 0.5, + precisionFactor = 1.5, + thresholdAlgoritm = 'otsu', + level = 'minMax', + blurKernelSize, + minFractionPixels, + debug = false, + } = options; + + let maxNbOperations; + let scalingFactor = 1; + if ( + options.maxNbOperations === undefined && + options.scalingFactor === undefined + ) { + maxNbOperations = 1e8; + } else if ( + options.maxNbOperations !== undefined && + options.scalingFactor === undefined + ) { + maxNbOperations = options.maxNbOperations; + } else if ( + options.maxNbOperations === undefined && + options.scalingFactor !== undefined + ) { + scalingFactor = options.scalingFactor; + } else { + throw new Error('You cannot define both maxNbOperations and scalingFactor'); + } + + const margins = computeXYMargins(source, destination, { xFactor, yFactor }); + + if (maxNbOperations !== undefined) { + const initialSrcMask = getAlignMask(source, thresholdAlgoritm); + const initialDstMask = getAlignMask(destination, thresholdAlgoritm); + const nbOperations = computeNbOperations(source, destination, { + sourceMask: initialSrcMask, + destinationMask: initialDstMask, + margins, + }); + if (debug) { + console.log({ nbOperations }); + } + scalingFactor = 1; + if (nbOperations > maxNbOperations) { + scalingFactor = Math.sqrt(nbOperations / maxNbOperations); + } + } + if (debug) { + console.log({ scalingFactor }); + } + + // Rough alignment + const smallSource = prepareForAlign(source, { + scalingFactor, + level, + blurKernelSize, + }); + const smallSrcMask = getAlignMask(smallSource, thresholdAlgoritm); + const smallDestination = prepareForAlign(destination, { + scalingFactor, + level, + blurKernelSize, + }); + const smallDstMask = getAlignMask(smallDestination, thresholdAlgoritm); + + const smallNbPixels = Math.round( + (smallSrcMask.getNbNonZeroPixels() + smallDstMask.getNbNonZeroPixels()) / 2, + ); + + if (debug) { + writeSync(`${__dirname}/smallSource.png`, smallSource); + writeSync(`${__dirname}/smallDestination.png`, smallDestination); + writeSync(`${__dirname}/smallSrcMask.png`, smallSrcMask); + writeSync(`${__dirname}/smallDstMask.png`, smallDstMask); + } + + const smallMargins = computeXYMargins(smallSource, smallDestination, { + xFactor, + yFactor, + }); + + if (debug) { + console.log({ smallMargins, smallNbPixels }); + } + const roughTranslation = getMinDiffTranslation( + smallSource, + smallDestination, + { + sourceMask: smallSrcMask, + destinationMask: smallDstMask, + leftRightMargin: smallMargins.xMargin, + topBottomMargin: smallMargins.yMargin, + minNbPixels: smallNbPixels, + }, + ); + + if (debug) { + console.log({ roughTranslation }); + const overlap = overlapImages(smallSource, smallDestination, { + origin: roughTranslation, + }); + writeSync(`${__dirname}/roughOverlap.png`, overlap); + const maskOverlap = overlapImages( + smallSrcMask.convertColor(ImageColorModel.GREY), + smallDstMask.convertColor(ImageColorModel.GREY), + { + origin: roughTranslation, + }, + ); + writeSync(`${__dirname}/roughMaskOverlap.png`, maskOverlap); + } + + // Find overlapping surface and source and destination origins + if (debug) { + console.log('Find overlap'); + } + const scaledTranslation = { + column: Math.round(roughTranslation.column * scalingFactor), + row: Math.round(roughTranslation.row * scalingFactor), + }; + + const { + width: overlapWidth, + height: overlapHeight, + sourceOrigin, + destinationOrigin, + } = findOverlap({ + source, + destination, + sourceTranslation: scaledTranslation, + }); + + // Precise alignment + const sourceCrop = source.crop({ + origin: sourceOrigin, + width: overlapWidth, + height: overlapHeight, + }); + const destinationCrop = destination.crop({ + origin: destinationOrigin, + width: overlapWidth, + height: overlapHeight, + }); + + const preciseSource = prepareForAlign(sourceCrop, { + level, + blurKernelSize, + scalingFactor: 1, + }); + const srcMask = getAlignMask(preciseSource, thresholdAlgoritm); + const preciseDestination = prepareForAlign(destinationCrop, { + level, + blurKernelSize, + scalingFactor: 1, + }); + const dstMask = getAlignMask(preciseDestination, thresholdAlgoritm); + if (debug) { + writeSync(`${__dirname}/preciseSource.png`, preciseSource); + writeSync(`${__dirname}/preciseDestination.png`, preciseDestination); + writeSync(`${__dirname}/srcMask.png`, srcMask); + writeSync(`${__dirname}/dstMask.png`, dstMask); + } + + const nbPixels = Math.round( + (srcMask.getNbNonZeroPixels() + dstMask.getNbNonZeroPixels()) / 2, + ); + + const preciseMargins = Math.round(precisionFactor * scalingFactor); + + if (debug) { + console.log('Compute precise translation'); + } + const preciseTranslation = getMinDiffTranslation( + preciseSource, + preciseDestination, + { + leftRightMargin: preciseMargins, + topBottomMargin: preciseMargins, + minFractionPixels, + sourceMask: srcMask, + destinationMask: dstMask, + minNbPixels: nbPixels, + }, + ); + + return { + column: scaledTranslation.column + preciseTranslation.column, + row: scaledTranslation.row + preciseTranslation.row, + }; +} diff --git a/src/stack/align/alignStack.ts b/src/stack/align/alignStack.ts new file mode 100644 index 000000000..b282dfb03 --- /dev/null +++ b/src/stack/align/alignStack.ts @@ -0,0 +1,43 @@ +import { Stack } from '../../Stack'; +import { Point, sum } from '../../utils/geometry/points'; + +import { + AlignDifferentSizeOptions, + alignDifferentSize, +} from './alignDifferentSize'; + +export interface Translations { + absolute: Point[]; + relative: Point[]; +} +/** + * Computes the relative and absolute translations property + * to apply on each image to align it on the first image of the + * stack (absolute translation) or on the previous image of the stack (relative translation). + * The first image has a translation of {column: 0, row: 0}. + * Internally, the images are aligned between two consecutive images + * (we imagine that there is less variation in consecutive images). + * @param stack - Stack to align. + * @param options - Options to align different size images. + * @returns The relative and absolute translations. + */ +export function alignStack( + stack: Stack, + options: AlignDifferentSizeOptions = {}, +): Translations { + const relativeTranslations: Point[] = [{ column: 0, row: 0 }]; + const absoluteTranslations: Point[] = [{ column: 0, row: 0 }]; + for (let i = 1; i < stack.size; i++) { + console.log(`Aligning image ${i}`); + const currentRelativeTranslation = alignDifferentSize( + stack.getImage(i), + stack.getImage(i - 1), + options, + ); + relativeTranslations.push(currentRelativeTranslation); + absoluteTranslations.push( + sum(absoluteTranslations[i - 1], currentRelativeTranslation), + ); + } + return { absolute: absoluteTranslations, relative: relativeTranslations }; +} diff --git a/src/stack/align/cropCommonArea.ts b/src/stack/align/cropCommonArea.ts new file mode 100644 index 000000000..f19e701a7 --- /dev/null +++ b/src/stack/align/cropCommonArea.ts @@ -0,0 +1,36 @@ +import { Image } from '../../Image'; +import { Stack } from '../../Stack'; +import { Point } from '../../utils/geometry/points'; + +import { computeCropOrigins } from './utils/computeCropOrigins'; +import { findCommonArea } from './utils/findCommonArea'; + +/** + * Crops the common area of all the images of the stack, as defined by the absolute translations. + * @param stack - Stack to crop. + * @param absoluteTranslations - Absolute translations of the images of the stack (relative to the first image). + * @returns A new stack containing the cropped images. + */ +export function cropCommonArea( + stack: Stack, + absoluteTranslations: Point[], +): Stack { + const commonArea = findCommonArea(stack, absoluteTranslations); + const cropOrigins = computeCropOrigins( + stack, + commonArea, + absoluteTranslations, + ); + const croppedImages: Image[] = []; + for (let i = 0; i < stack.size; i++) { + croppedImages.push( + stack.getImage(i).crop({ + origin: cropOrigins[i], + width: commonArea.width, + height: commonArea.height, + }), + ); + } + + return new Stack(croppedImages); +} diff --git a/src/stack/align/utils/__tests__/__image_snapshots__/get-min-diff-translation-test-ts-larger-images-1-snap.png b/src/stack/align/utils/__tests__/__image_snapshots__/get-min-diff-translation-test-ts-larger-images-1-snap.png new file mode 100644 index 000000000..fe26d8064 Binary files /dev/null and b/src/stack/align/utils/__tests__/__image_snapshots__/get-min-diff-translation-test-ts-larger-images-1-snap.png differ diff --git a/src/stack/align/utils/__tests__/__image_snapshots__/get-min-diff-translation-test-ts-small-images-with-mask-1-snap.png b/src/stack/align/utils/__tests__/__image_snapshots__/get-min-diff-translation-test-ts-small-images-with-mask-1-snap.png new file mode 100644 index 000000000..97cfbe117 Binary files /dev/null and b/src/stack/align/utils/__tests__/__image_snapshots__/get-min-diff-translation-test-ts-small-images-with-mask-1-snap.png differ diff --git a/src/stack/align/utils/__tests__/computeCropOrigins.test.ts b/src/stack/align/utils/__tests__/computeCropOrigins.test.ts new file mode 100644 index 000000000..5def49408 --- /dev/null +++ b/src/stack/align/utils/__tests__/computeCropOrigins.test.ts @@ -0,0 +1,59 @@ +import { Image } from '../../../../Image'; +import { Stack } from '../../../../Stack'; +import { computeCropOrigins } from '../computeCropOrigins'; +import { findCommonArea } from '../findCommonArea'; + +const image1 = new Image(4, 4); +const image2 = new Image(3, 4); +const image3 = new Image(6, 5); +const image4 = new Image(4, 2); +const absoluteTranslations = [ + { column: 0, row: 0 }, + { column: 2, row: -2 }, + { column: 1, row: 1 }, + { column: -1, row: 1 }, +]; +test('stack of 2 images', () => { + const stack = new Stack([image1, image2]); + const absTranslations = absoluteTranslations.slice(0, 2); + + const commonArea = findCommonArea(stack, absTranslations); + + const result = computeCropOrigins(stack, commonArea, absTranslations); + + expect(result).toStrictEqual([ + { column: 2, row: 0 }, + { column: 0, row: 2 }, + ]); +}); + +test('stack of 3 images', () => { + const stack = new Stack([image1, image2, image3]); + const absTranslations = absoluteTranslations.slice(0, 3); + + const commonArea = findCommonArea(stack, absTranslations); + + const result = computeCropOrigins(stack, commonArea, absTranslations); + + expect(result).toStrictEqual([ + { column: 2, row: 1 }, + { column: 0, row: 3 }, + { column: 1, row: 0 }, + ]); +}); + +test('stack of 4 images', () => { + const stack = new Stack([image1, image2, image3, image4]); + const absTranslations = absoluteTranslations.slice(0, 4); + + const commonArea = findCommonArea(stack, absTranslations); + + const result = computeCropOrigins(stack, commonArea, absTranslations); + + expect(result).toStrictEqual([ + { column: 2, row: 1 }, + { column: 0, row: 3 }, + { column: 1, row: 0 }, + { column: 3, row: 0 }, + ]); +}); diff --git a/src/stack/align/utils/__tests__/computeNbOperations.test.ts b/src/stack/align/utils/__tests__/computeNbOperations.test.ts new file mode 100644 index 000000000..cc0177489 --- /dev/null +++ b/src/stack/align/utils/__tests__/computeNbOperations.test.ts @@ -0,0 +1,80 @@ +import { Image, Mask } from '../../../..'; +import { + computeNbOperations, + computeNbTranslations, +} from '../computeNbOperations'; + +test('number translations small images', () => { + const source = new Image(3, 3); + const destination = new Image(3, 5); + + expect( + computeNbTranslations(source, destination, { + xMargin: 1, + yMargin: 1, + }), + ).toBe(15); +}); + +test('number operations small images', () => { + const source = new Image(3, 3); + const sourceMask = new Mask(3, 3).fill(1); + const destination = new Image(3, 5); + + const trueNbOperations = 3 * 9 + 4 * 4 + 2 * 6 + 6 * 6; + + const result = computeNbOperations(source, destination, { + sourceMask, + margins: { + xMargin: 1, + yMargin: 1, + }, + }); + expect(result > trueNbOperations).toBe(true); + expect(result).toBe(15 * 9); +}); + +test('larger images, no margin', () => { + const source = new Image(2, 2); + const sourceMask = new Mask(2, 2).fill(1); + const destination = new Image(1000, 1000); + + const translations = computeNbTranslations(source, destination); + + expect(translations).toBe(999 ** 2); + + const operations = computeNbOperations(source, destination, { sourceMask }); + + expect(operations).toBe(3992004); +}); + +test('id crops, no margin', () => { + const source = testUtils.load('align/croppedRef.png'); + const sourceMask = new Mask(source.width, source.height).fill(1); + const destination = testUtils.load('align/cropped.png'); + + const translations = computeNbTranslations(source, destination); + + expect(translations).toBe( + (destination.width - source.width + 1) * + (destination.height - source.height + 1), + ); + + const operations = computeNbOperations(source, destination, { sourceMask }); + + expect(operations).toBe(11049161922); +}); + +test('larger images, empty mask', () => { + const source = new Image(2, 2); + const sourceMask = new Mask(2, 2); + const destination = new Image(1000, 1000); + + const translations = computeNbTranslations(source, destination); + + expect(translations).toBe(999 ** 2); + + const operations = computeNbOperations(source, destination, { sourceMask }); + + expect(operations).toBe(0); +}); diff --git a/src/stack/align/utils/__tests__/findCommonArea.test.ts b/src/stack/align/utils/__tests__/findCommonArea.test.ts new file mode 100644 index 000000000..34405d616 --- /dev/null +++ b/src/stack/align/utils/__tests__/findCommonArea.test.ts @@ -0,0 +1,60 @@ +import { Image } from '../../../../Image'; +import { Stack } from '../../../../Stack'; +import { findCommonArea } from '../findCommonArea'; + +const image1 = new Image(4, 4); +const image2 = new Image(3, 4); +const image3 = new Image(6, 5); +const image4 = new Image(4, 2); +const absoluteTranslations = [ + { column: 0, row: 0 }, + { column: 2, row: -2 }, + { column: 1, row: 1 }, + { column: -1, row: 1 }, +]; +test('stack of 2 images', () => { + const stack = new Stack([image1, image2]); + const absTranslations = absoluteTranslations.slice(0, 2); + + const result = findCommonArea(stack, absTranslations); + + expect(result).toStrictEqual({ + origin: { column: 2, row: 0 }, + width: 2, + height: 2, + }); +}); + +test('stack of 3 images', () => { + const stack = new Stack([image1, image2, image3]); + const absTranslations = absoluteTranslations.slice(0, 3); + + const result = findCommonArea(stack, absTranslations); + + expect(result).toStrictEqual({ + origin: { column: 2, row: 1 }, + width: 2, + height: 1, + }); +}); + +test('stack of 4 images', () => { + const stack = new Stack([image1, image2, image3, image4]); + const absTranslations = absoluteTranslations.slice(0, 4); + + const result = findCommonArea(stack, absTranslations); + + expect(result).toStrictEqual({ + origin: { column: 2, row: 1 }, + width: 1, + height: 1, + }); +}); + +test('stack of 1 images error', () => { + const stack = new Stack([image1]); + + expect(() => findCommonArea(stack, [{ row: 0, column: 0 }])).toThrow( + 'Stack must contain at least 2 images', + ); +}); diff --git a/src/stack/align/utils/__tests__/findOverlap.test.ts b/src/stack/align/utils/__tests__/findOverlap.test.ts new file mode 100644 index 000000000..f1c8734e8 --- /dev/null +++ b/src/stack/align/utils/__tests__/findOverlap.test.ts @@ -0,0 +1,32 @@ +import { Image } from '../../../..'; +import { findOverlap } from '../findOverlap'; + +test('source entirely in destination', () => { + const source = new Image(2, 2); + const destination = new Image(5, 4); + const sourceTranslation = { column: 2, row: 1 }; + + const result = findOverlap({ source, destination, sourceTranslation }); + + expect(result).toStrictEqual({ + sourceOrigin: { column: 0, row: 0 }, + destinationOrigin: { column: 2, row: 1 }, + width: 2, + height: 2, + }); +}); + +test('source partly out of destination', () => { + const source = new Image(2, 2); + const destination = new Image(5, 4); + const sourceTranslation = { column: -1, row: 3 }; + + const result = findOverlap({ source, destination, sourceTranslation }); + + expect(result).toStrictEqual({ + sourceOrigin: { column: 1, row: 0 }, + destinationOrigin: { column: 0, row: 3 }, + width: 1, + height: 1, + }); +}); diff --git a/src/stack/align/utils/__tests__/getMinDiffTranslation.test.ts b/src/stack/align/utils/__tests__/getMinDiffTranslation.test.ts new file mode 100644 index 000000000..2bba5d086 --- /dev/null +++ b/src/stack/align/utils/__tests__/getMinDiffTranslation.test.ts @@ -0,0 +1,146 @@ +import { overlapImages } from '../../../../featureMatching'; +import { readSync } from '../../../../load'; +import { writeSync } from '../../../../save'; +import { ImageColorModel } from '../../../../utils/constants/colorModels'; +import { getMinDiffTranslation } from '../getMinDiffTranslation'; + +test('no margins on sides', () => { + const source = testUtils.createGreyImage([ + [1, 2, 3, 4], + [0, 0, 0, 0], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [1, 1, 1, 1], + ]); + expect(getMinDiffTranslation(source, destination)).toBeDeepCloseTo({ + column: 0, + row: 0, + }); +}); + +test('margins of 1 pixels', () => { + const source = testUtils.createGreyImage([ + [1, 2, 3, 4], + [0, 45, 0, 0], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [1, 2, 3, 4], + ]); + + const result = getMinDiffTranslation(source, destination, { + topBottomMargin: 1, + leftRightMargin: 1, + }); + + expect(result).toBeDeepCloseTo({ + column: 0, + row: 1, + }); +}); + +test('larger images', () => { + const factor = 0.2; + const source = testUtils + .load('align/croppedRef.png') + .resize({ xFactor: factor, yFactor: factor }); + const destination = testUtils + .load('align/cropped.png') + .resize({ xFactor: factor, yFactor: factor }); + + const result = getMinDiffTranslation(source, destination); + + const overlap = overlapImages(source, destination, { origin: result }); + expect(overlap).toMatchImageSnapshot(); +}); + +test('small images with mask', () => { + const destination = readSync(`${__dirname}/dest.png`).grey(); + const source = readSync(`${__dirname}/src.png`).grey(); + const srcMask = readSync(`${__dirname}/srcMask.png`).threshold(); + const dstMask = readSync(`${__dirname}/dstMask.png`).threshold(); + + const result = getMinDiffTranslation(source, destination, { + sourceMask: srcMask, + destinationMask: dstMask, + minNbPixels: 40, + leftRightMargin: 15, + topBottomMargin: 15, + }); + + const baseOverlap = overlapImages(source, destination); + writeSync(`${__dirname}/baseOverlap.png`, baseOverlap); + + const maskOverlap = overlapImages( + srcMask.convertColor(ImageColorModel.GREY), + dstMask.convertColor(ImageColorModel.GREY), + { origin: result }, + ); + writeSync(`${__dirname}/maskOverlap.png`, maskOverlap); + console.log({ result }); + const overlap = overlapImages(source, destination, { origin: result }); + + expect(overlap).toMatchImageSnapshot(); +}); + +test('small source', () => { + const source = testUtils.createGreyImage([ + [1, 1], + [1, 1], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + + const result = getMinDiffTranslation(source, destination); + expect(result).toBeDeepCloseTo({ column: 2, row: 1 }); +}); + +test('same size', () => { + const source = testUtils.createGreyImage([ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 0, 0], + [1, 1, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const sourceMask = testUtils.createMask([ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 0, 0], + [1, 1, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const destinationMask = testUtils.createMask([ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = getMinDiffTranslation(source, destination, { + leftRightMargin: 3, + topBottomMargin: 3, + sourceMask, + destinationMask, + }); + + expect(result).toBeDeepCloseTo({ column: 2, row: -1 }); +}); diff --git a/src/stack/align/utils/__tests__/getNormalisedDifference.test.ts b/src/stack/align/utils/__tests__/getNormalisedDifference.test.ts new file mode 100644 index 000000000..c2e3518ac --- /dev/null +++ b/src/stack/align/utils/__tests__/getNormalisedDifference.test.ts @@ -0,0 +1,229 @@ +import { Mask } from '../../../../Mask'; +import { getNormalisedDifference } from '../getNormalisedDifference'; + +test.each([ + { + translation: { column: 0, row: 0 }, + expected: 2.5, + }, + { + translation: { column: 1, row: 0 }, + expected: 2, + }, + { + translation: { column: 0, row: 1 }, + expected: 1.5, + }, + { + translation: { column: 1, row: 1 }, + expected: 1, + }, +])('two small grey images ($translation)', (data) => { + const source = testUtils.createGreyImage([[1, 2, 3, 4]]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [1, 1, 1, 1], + ]); + expect( + getNormalisedDifference(source, destination, data.translation, { + minNbPixels: 1, + }), + ).toBe(data.expected); +}); + +test.each([ + { + translation: { column: -1, row: 0 }, + expected: 2, + }, + { + translation: { column: 0, row: -1 }, + expected: 0, + }, +])('negative offsets ($translation)', (data) => { + const source = testUtils.createGreyImage([ + [1, 2, 3, 4], + [0, 0, 0, 0], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [1, 1, 1, 1], + ]); + expect( + getNormalisedDifference(source, destination, data.translation, { + minNbPixels: 1, + }), + ).toBe(data.expected); +}); + +test('not enough overlapping points', () => { + const source = testUtils.createGreyImage([[1, 2, 3, 4]]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [1, 1, 1, 1], + ]); + expect(() => + getNormalisedDifference( + source, + destination, + { row: -1, column: 0 }, + { + minNbPixels: 1, + }, + ), + ).toThrow(`The number of pixels compared is too low (less than 1)`); +}); + +test('options error', () => { + const source = testUtils.createGreyImage([[1, 2, 3, 4]]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [1, 1, 1, 1], + ]); + expect(() => + getNormalisedDifference( + source, + destination, + { row: -1, column: 0 }, + { + minNbPixels: 1, + minFractionPixels: 0.1, + }, + ), + ).toThrow(/You cannot specify both minNbPixels and minFractionPixels/); +}); + +test.each([ + { + translation: { column: -1, row: 0 }, + expected: 3, + }, + { + translation: { column: 1, row: 0 }, + expected: 2, + }, +])('use source mask ($translation)', (data) => { + const source = testUtils.createGreyImage([ + [1, 2, 3, 4], + [0, 0, 0, 0], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [1, 1, 1, 1], + ]); + const mask = testUtils.createMask([ + [1, 1, 1, 1], + [0, 0, 0, 0], + ]); + expect( + getNormalisedDifference(source, destination, data.translation, { + minNbPixels: 1, + sourceMask: mask, + destinationMask: new Mask(destination.width, destination.height), + }), + ).toBe(data.expected); +}); + +test('small source', () => { + const source = testUtils.createGreyImage([ + [1, 1], + [1, 1], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + + expect( + getNormalisedDifference( + source, + destination, + { row: 1, column: 2 }, + { + minNbPixels: 1, + }, + ), + ).toBe(0); + + expect( + getNormalisedDifference( + source, + destination, + { row: 0, column: 0 }, + { + minNbPixels: 1, + }, + ), + ).toBe(1); + + expect( + getNormalisedDifference( + source, + destination, + { row: 0, column: 3 }, + { + minNbPixels: 1, + }, + ), + ).toBe(1 / 2); + + expect( + getNormalisedDifference( + source, + destination, + { row: 0, column: 3 }, + { + minNbPixels: 1, + }, + ), + ).toBe(1 / 2); +}); + +test('same size', () => { + const source = testUtils.createGreyImage([ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 0, 0], + [1, 1, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const sourceMask = testUtils.createMask([ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 0, 0], + [1, 1, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const destination = testUtils.createGreyImage([ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + + const destinationMask = testUtils.createMask([ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + + const translation = { row: -3, column: -3 }; + + const result = getNormalisedDifference(source, destination, translation, { + sourceMask, + destinationMask, + }); + + expect(result).toBe(255); +}); diff --git a/src/stack/align/utils/computeCropOrigins.ts b/src/stack/align/utils/computeCropOrigins.ts new file mode 100644 index 000000000..e9c1bd8d3 --- /dev/null +++ b/src/stack/align/utils/computeCropOrigins.ts @@ -0,0 +1,29 @@ +import { Stack } from '../../../Stack'; +import { Point } from '../../../geometry'; +import { difference } from '../../../utils/geometry/points'; + +import { CommonArea } from './findCommonArea'; + +/** + * Compute the origins of the crops relative to the top left corner of each image in order + * to crop the common area of each of the images of an aligned stack. + * @param stack - Stack to process. + * @param commonArea - The data of the common area of the stack. + * @param absoluteTranslations - The absolute translations of the images of the stack (relative to the first image). + * @returns The origins of the crops. + */ +export function computeCropOrigins( + stack: Stack, + commonArea: CommonArea, + absoluteTranslations: Point[], +): Point[] { + const cropOrigins: Point[] = []; + let currentCropOrigin = commonArea.origin; + cropOrigins.push(currentCropOrigin); + for (let i = 1; i < stack.size; i++) { + currentCropOrigin = difference(commonArea.origin, absoluteTranslations[i]); + cropOrigins.push(currentCropOrigin); + } + + return cropOrigins; +} diff --git a/src/stack/align/utils/computeNbOperations.ts b/src/stack/align/utils/computeNbOperations.ts new file mode 100644 index 000000000..53ad325bc --- /dev/null +++ b/src/stack/align/utils/computeNbOperations.ts @@ -0,0 +1,128 @@ +import { Image, Mask } from '../../..'; + +export interface ComputeXYMarginsOptions { + /** + * Horizontal fraction of the images that can not be overlapping. + * This fraction is applied on the image with the smallest width. + * @default 0 + */ + xFactor?: number; + /** + * Vertical fraction of the images that can not be overlapping. + * This fraction is applied on the image with the smallest height. + * @default 0 + */ + yFactor?: number; +} + +export interface Margins { + /** + * Horizontal margin in pixels. + */ + xMargin: number; + /** + * Vertical margin in pixels. + */ + yMargin: number; +} + +/** + * Compute the margins that around the destination image in which the source can be translated. + * @param source - Source image. + * @param destination - Destination image. + * @param options - Options. + * @returns The x and y margins. + */ +export function computeXYMargins( + source: Image, + destination: Image, + options: ComputeXYMarginsOptions = {}, +): Margins { + const { xFactor = 0, yFactor = 0 } = options; + let xMargin = 0; + let yMargin = 0; + + // compute x and y margins + if (source.width < destination.width) { + xMargin = Math.round(xFactor * source.width); + } else { + xMargin = Math.round(source.width - destination.width * (1 - xFactor)); + } + + if (source.height < destination.height) { + yMargin = Math.round(yFactor * source.height); + } else { + yMargin = Math.round(source.height - destination.height * (1 - yFactor)); + } + return { xMargin, yMargin }; +} + +const defaultMargins = { xMargin: 0, yMargin: 0 }; + +/** + * Compute the number of translations that will be performed for the alignment. + * @param source - Source image. + * @param destination - Destination image. + * @param margins - The margins around the destination image in which the source can be translated. + * @returns The number of translations that will be performed for the alignment. + */ +export function computeNbTranslations( + source: Image, + destination: Image, + margins: Margins = defaultMargins, +): number { + const minRow = -margins.yMargin; + const minColumn = -margins.xMargin; + const maxRow = destination.height - source.height + margins.yMargin; + const maxColumn = destination.width - source.width + margins.xMargin; + const nbTranslations = (maxRow - minRow + 1) * (maxColumn - minColumn + 1); + return nbTranslations; +} + +export interface ComputeNbOperationsOptions { + /** + * The mask of the source image. + * @default new Mask(source.width, source.height).fill(1) + */ + sourceMask?: Mask; + /** + * The mask of the destination image. + * @default new Mask(destination.width, destination.height).fill(1) + */ + destinationMask?: Mask; + /** + * The margins around the destination image in which the source can be translated. + * @default { xMargin: 0, yMargin: 0 } + */ + margins?: Margins; +} +/** + * Approximate the number of operations that will be performed for the alignment. This is an overestimate!! + * The bigger the margins the more we overestimate. For margins that are zero, the value is exact. + * @param source - Source image. + * @param destination - Destination image. + * @param options - Options. + * @returns The number of operations that will be performed for the alignment. + */ +export function computeNbOperations( + source: Image, + destination: Image, + options: ComputeNbOperationsOptions, +): number { + const { + sourceMask = new Mask(source.width, source.height).fill(1), + destinationMask = new Mask(destination.width, destination.height).fill(1), + margins = defaultMargins, + } = options; + const nbTranslations = computeNbTranslations(source, destination, margins); + const minHeight = Math.min(source.height, destination.height); + const minWidth = Math.min(source.width, destination.width); + // take mask into account + const fractionWhitePixels = + (sourceMask.getNbNonZeroPixels() / source.size + + destinationMask.getNbNonZeroPixels() / destination.size) / + 2; + console.log({ fractionWhitePixels }); + + return nbTranslations * minHeight * minWidth * fractionWhitePixels; +} diff --git a/src/stack/align/utils/findCommonArea.ts b/src/stack/align/utils/findCommonArea.ts new file mode 100644 index 000000000..60a73fb03 --- /dev/null +++ b/src/stack/align/utils/findCommonArea.ts @@ -0,0 +1,49 @@ +import { Stack } from '../../../Stack'; +import { Point } from '../../../geometry'; + +import { findOverlap } from './findOverlap'; + +export interface CommonArea { + origin: Point; + width: number; + height: number; +} + +/** + * Find the common area of all images of a stack. + * @param stack - Stack to process. + * @param absoluteTranslations - Absolute translations of the images of the stack (relative to the first image). + * @returns The common area origin relative to the top-left corner of first image of the stack, the width and the height of the common area. + */ +export function findCommonArea( + stack: Stack, + absoluteTranslations: Point[], +): CommonArea { + if (stack.size < 2) { + throw new RangeError('Stack must contain at least 2 images'); + } + + let commonAreaOrigin = { column: 0, row: 0 }; + let width = stack.getImage(0).width; + let height = stack.getImage(0).height; + + for (let i = 1; i < stack.size; i++) { + const overlap = findOverlap({ + source: stack.getImage(i), + destination: stack.getImage(0), + sourceTranslation: absoluteTranslations[i], + commonAreaOrigin, + previousHeight: height, + previousWidth: width, + }); + + commonAreaOrigin = { + column: Math.max(commonAreaOrigin.column, absoluteTranslations[i].column), + row: Math.max(commonAreaOrigin.row, absoluteTranslations[i].row), + }; + width = overlap.width; + height = overlap.height; + } + + return { origin: commonAreaOrigin, width, height }; +} diff --git a/src/stack/align/utils/findOverlap.ts b/src/stack/align/utils/findOverlap.ts new file mode 100644 index 000000000..aefd1a132 --- /dev/null +++ b/src/stack/align/utils/findOverlap.ts @@ -0,0 +1,98 @@ +import { Image } from '../../..'; +import { Point } from '../../../geometry'; + +export interface FindOverlapParameters { + /** + * Source image. + */ + source: Image; + /** + * Destination image. + */ + destination: Image; + /** + * Translation of the source image relative to top-left corner of destination image. + * @default { column: 0, row: 0 } + */ + sourceTranslation?: Point; + /** + * Theoretical origin of the common area in the source image. + * @default { column: 0, row: 0 } + */ + commonAreaOrigin?: Point; + /** + * Theoretical width of the common area on the iteration before. + * @default destination.width + */ + previousWidth?: number; + /** + * Theoretical height of the common area on the iteration before. + * @default destination.height + */ + previousHeight?: number; +} +export interface Overlap { + /** + * Origin of the overlapping area in the source image. + */ + sourceOrigin: Point; + /** + * Origin of the overlapping area in the destination image. + */ + destinationOrigin: Point; + /** + * Width of the overlapping area. + */ + width: number; + /** + * Height of the overlapping area. + */ + height: number; +} + +/** + * Find the overlapping area between two images. + * @param findOverlapParameters - Parameters. + * @returns Overlapping area width, height and origin in source and destination images. + */ +export function findOverlap( + findOverlapParameters: FindOverlapParameters, +): Overlap { + const { + source, + destination, + sourceTranslation = { column: 0, row: 0 }, + commonAreaOrigin = { column: 0, row: 0 }, + previousWidth = destination.width, + previousHeight = destination.height, + } = findOverlapParameters; + + const minX = Math.max(commonAreaOrigin.column, sourceTranslation.column); + const maxX = Math.min( + commonAreaOrigin.column + previousWidth, + sourceTranslation.column + source.width, + ); + const minY = Math.max(commonAreaOrigin.row, sourceTranslation.row); + const maxY = Math.min( + commonAreaOrigin.row + previousHeight, + sourceTranslation.row + source.height, + ); + + const width = maxX - minX; + const height = maxY - minY; + + const sourceOrigin = { + column: Math.max(0, -sourceTranslation.column), + row: Math.max(0, -sourceTranslation.row), + }; + const destinationOrigin = { + column: Math.max(0, sourceTranslation.column), + row: Math.max(0, sourceTranslation.row), + }; + return { + sourceOrigin, + destinationOrigin, + width, + height, + }; +} diff --git a/src/stack/align/utils/getMinDiffTranslation.ts b/src/stack/align/utils/getMinDiffTranslation.ts new file mode 100644 index 000000000..6f65e0711 --- /dev/null +++ b/src/stack/align/utils/getMinDiffTranslation.ts @@ -0,0 +1,66 @@ +import { Image, Point } from '../../..'; + +import { + AlignDifferentSizeOptions, + getNormalisedDifference, +} from './getNormalisedDifference'; + +export interface GetMinDiffTranslationOptions + extends AlignDifferentSizeOptions { + /** + * Maximal number of pixels that can not be overlapping on left and right sides. + * @default 0 + */ + leftRightMargin?: number; + /** + * Maximal number of pixels that can not be overlapping on left and right sides. + * @default 0 + */ + topBottomMargin?: number; +} + +/** + * Compute the translation to apply on the source that minimises the difference between two images. + * The images don't have to overlap entirely. + * @param source - Source image. + * @param destination - Destination image. + * @param options - Options. + * @returns The translation of the source that minimises the difference. + */ +export function getMinDiffTranslation( + source: Image, + destination: Image, + options: GetMinDiffTranslationOptions = {}, +): Point { + const { leftRightMargin = 0, topBottomMargin = 0 } = options; + + const maxRow = destination.height - source.height + topBottomMargin; + const maxColumn = destination.width - source.width + leftRightMargin; + const nbTranslations = + (maxRow + 2 * topBottomMargin) * (maxColumn + 2 * leftRightMargin); + console.log({ nbTranslations }); + + let minDiff = Number.MAX_SAFE_INTEGER; + let minDiffTranslation: Point = { row: 0, column: 0 }; + for (let row = -topBottomMargin; row <= maxRow; row++) { + for (let column = -leftRightMargin; column <= maxColumn; column++) { + const translation = { row, column }; + const diff = getNormalisedDifference( + source, + destination, + translation, + options, + ); + if (diff < minDiff) { + minDiff = diff; + minDiffTranslation = translation; + } + console.log( + `translation ${ + row * (maxRow + topBottomMargin) + column + } / ${nbTranslations}`, + ); + } + } + return minDiffTranslation; +} diff --git a/src/stack/align/utils/getNormalisedDifference.ts b/src/stack/align/utils/getNormalisedDifference.ts new file mode 100644 index 000000000..2a98313ec --- /dev/null +++ b/src/stack/align/utils/getNormalisedDifference.ts @@ -0,0 +1,142 @@ +import { Image, Mask, Point } from '../../..'; + +export interface AlignDifferentSizeOptions { + /** + * Minimal number of overlapping pixels to apply the algorithm. + * @default undefined + */ + minNbPixels?: number; + /** + * Minimal fraction of the pixels of the source that have to overlap to apply the algorithm. + * @default 0.1 + */ + minFractionPixels?: number; + /** + * Mask of the source image, which specifies the pixels to consider for the calculation. + * @default Mask with the dimensions of source and all pixels set to 1. + */ + sourceMask?: Mask; + /** + * Mask of the destination image, which specifies the pixels to consider for the calculation. + * @default Mask with the dimensions of destination and all pixels set to 1. + */ + destinationMask?: Mask; +} + +/** + * Compute the difference between two images that are not entirely overlapping and normalise it using the nb of pixels overlapping. + * Only the pixels present in both images are taken into account. + * @param source - Source image. + * @param destination - Destination image. + * @param sourceTranslation - Translation to apply on the source image relative to the top-left corner of the destination image before computing the difference. + * @param options - Options. + * @returns The normalised difference. + */ +export function getNormalisedDifference( + source: Image, + destination: Image, + sourceTranslation: Point, + options: AlignDifferentSizeOptions = {}, +): number { + const { + minFractionPixels, + sourceMask = new Mask(source.width, source.height).fill(1), + destinationMask = new Mask(destination.width, destination.height).fill(1), + } = options; + let { minNbPixels } = options; + + if (minNbPixels === undefined && minFractionPixels === undefined) { + minNbPixels = Math.ceil(0.1 * source.size); + } else if (minNbPixels !== undefined && minFractionPixels !== undefined) { + throw new Error( + 'You cannot specify both minNbPixels and minFractionPixels', + ); + } else if (minNbPixels === undefined && minFractionPixels !== undefined) { + minNbPixels = Math.ceil(minFractionPixels * source.size); + } else if (minNbPixels === undefined) { + throw new Error('minNbPixels cannot be undefined'); + } + + let difference = 0; + + let sourceXOffet = 0; + let sourceYOffset = 0; + let destinationXOffset = 0; + let destinationYOffset = 0; + + if (sourceTranslation.column < 0) { + sourceXOffet = -sourceTranslation.column; + } else { + destinationXOffset = sourceTranslation.column; + } + + if (sourceTranslation.row < 0) { + sourceYOffset = -sourceTranslation.row; + } else { + destinationYOffset = sourceTranslation.row; + } + + const maxX = Math.min( + destination.width, + source.width + sourceTranslation.column, + ); + const minX = Math.max(0, sourceTranslation.column); + const maxY = Math.min( + destination.height, + source.height + sourceTranslation.row, + ); + const minY = Math.max(0, sourceTranslation.row); + + const width = maxX - minX; + const height = maxY - minY; + + let nbPixels = 0; + + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + let pixelCounted = false; + for (let channel = 0; channel < source.channels; channel++) { + if ( + sourceMask.getValue(column + sourceXOffet, row + sourceYOffset, 0) || + destinationMask.getValue( + column + destinationXOffset, + row + destinationYOffset, + 0, + ) + ) { + pixelCounted = true; + + const sourceValue = source.getValue( + column + sourceXOffet, + row + sourceYOffset, + channel, + ); + const destinationValue = destination.getValue( + column + destinationXOffset, + row + destinationYOffset, + channel, + ); + + const currentDifference = sourceValue - destinationValue; + if (currentDifference < 0) { + difference -= currentDifference; + } else { + difference += currentDifference; + } + } else { + pixelCounted = false; + } + } + if (pixelCounted) { + nbPixels++; + } + } + } + + if (nbPixels < minNbPixels) { + // If there are not enough pixels overlapping, we consider the difference to be maximal + return 2 ** source.bitDepth - 1; + } + + return difference / nbPixels; +} diff --git a/src/stack/index.ts b/src/stack/index.ts index 1288e638d..13a1e87d6 100644 --- a/src/stack/index.ts +++ b/src/stack/index.ts @@ -3,5 +3,9 @@ export * from './compute/maxImage'; export * from './compute/meanImage'; export * from './compute/medianImage'; export * from './load/decodeStack'; +export * from './load/createStackFromPaths'; export * from './compute/minImage'; export * from './compute/sum'; +export * from './align/alignStack'; +export * from './align/alignDifferentSize'; +export * from './align/cropCommonArea'; diff --git a/src/stack/load/__tests__/createStackFromPaths.test.ts b/src/stack/load/__tests__/createStackFromPaths.test.ts new file mode 100644 index 000000000..570b0114e --- /dev/null +++ b/src/stack/load/__tests__/createStackFromPaths.test.ts @@ -0,0 +1,10 @@ +import { readdirSync } from 'node:fs'; + +import { createStackFromPaths } from '../createStackFromPaths'; + +test('read all images from a folder', () => { + const folder = `${__dirname}/../../../../test/img/ssim`; + const paths = readdirSync(folder).map((name) => `${folder}/${name}`); + const stack = createStackFromPaths(paths); + expect(stack.size).toBe(paths.length); +}); diff --git a/src/stack/load/createStackFromPaths.ts b/src/stack/load/createStackFromPaths.ts new file mode 100644 index 000000000..0aba97f41 --- /dev/null +++ b/src/stack/load/createStackFromPaths.ts @@ -0,0 +1,14 @@ +import { Stack } from '../../Stack'; +import { readSync } from '../../load'; + +/** + * Create a new stack from an array of paths to the images. + * @param paths - Array of paths to the images from which to create the stack. + * @returns A new stack. + */ +export function createStackFromPaths(paths: string[]): Stack { + const images = paths.map((path) => { + return readSync(path); + }); + return new Stack(images); +}