Skip to content
This repository was archived by the owner on Jul 26, 2025. It is now read-only.

Commit 0c347c8

Browse files
authored
feat: start implementing stacks (#416)
* feat: basic Stack API * feat(Stack): implement constructor Closes: #413 * feat(Stack): implement functions to get values * feat(Stack): implement minImage * feat(Stack): implement maxImage * feat(Stack): implement iterator * test(Stack): add min and max image tests * feat(Stack): implement getStackFromFolder for debug * fix(Stack): enhance Stack properties * feat(Stack): implement map and filter * feat(Stack): implement meanImage Closes: #414 * feat(Stack): implement medianImage * test: enhance stack coverage * fix: rename size to dimensions * refactor: change remaining size to dimensions
1 parent 70e18b0 commit 0c347c8

20 files changed

+763
-1
lines changed

src/Stack.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { BitDepth } from 'fast-png';
2+
3+
import { Image } from './Image';
4+
import { maxImage } from './stack/maxImage';
5+
import { meanImage } from './stack/meanImage';
6+
import { medianImage } from './stack/medianImage';
7+
import { minImage } from './stack/minImage';
8+
import {
9+
checkImagesValid,
10+
verifySameDimensions,
11+
} from './stack/utils/checkImagesValid';
12+
import { ImageColorModel } from './utils/constants/colorModels';
13+
14+
export class Stack {
15+
/**
16+
* The array of images.
17+
*/
18+
private readonly images: Image[];
19+
/**
20+
* The stack size.
21+
*/
22+
public readonly size: number;
23+
/**
24+
* Do the images have an alpha channel?
25+
*/
26+
public readonly alpha: boolean;
27+
/**
28+
* The color model of the images.
29+
*/
30+
public readonly colorModel: ImageColorModel;
31+
/**
32+
* The bit depth of the images.
33+
*/
34+
public readonly bitDepth: BitDepth;
35+
/**
36+
* Whether all the images of the stack have the same dimensions.
37+
*/
38+
public readonly sameDimensions: boolean;
39+
/**
40+
* The number of channels of the images.
41+
*/
42+
public readonly channels: number;
43+
44+
/**
45+
* Create a new stack from an array of images.
46+
* The images must have the same bit depth and color model.
47+
* @param images - Array of images from which to create the stack.
48+
*/
49+
public constructor(images: Image[]) {
50+
checkImagesValid(images);
51+
this.images = images;
52+
this.size = images.length;
53+
this.alpha = images[0].alpha;
54+
this.colorModel = images[0].colorModel;
55+
this.channels = images[0].channels;
56+
this.bitDepth = images[0].bitDepth;
57+
this.sameDimensions = verifySameDimensions(images);
58+
}
59+
60+
*[Symbol.iterator](): IterableIterator<Image> {
61+
for (const image of this.images) {
62+
yield image;
63+
}
64+
}
65+
66+
/**
67+
* Clone a stack.
68+
* @returns A new stack with the same images.
69+
*/
70+
public clone(): Stack {
71+
return new Stack(this.images.map((image) => image.clone()));
72+
}
73+
74+
/**
75+
* Get the images of the stack. Mainly for debugging purposes.
76+
* @returns The images.
77+
*/
78+
public getImages(): Image[] {
79+
return this.images;
80+
}
81+
82+
/**
83+
* Get the image at the given index.
84+
* @param index - The index of the image.
85+
* @returns The image.
86+
*/
87+
public getImage(index: number): Image {
88+
return this.images[index];
89+
}
90+
91+
/**
92+
* Get a value from an image of the stack.
93+
* @param stackIndex - Index of the image in the stack.
94+
* @param row - Row index of the pixel.
95+
* @param column - Column index of the pixel.
96+
* @param channel - The channel to retrieve.
97+
* @returns The value at the given position.
98+
*/
99+
public getValue(
100+
stackIndex: number,
101+
row: number,
102+
column: number,
103+
channel: number,
104+
): number {
105+
return this.images[stackIndex].getValue(row, column, channel);
106+
}
107+
108+
/**
109+
* Get a value from an image of the stack. Specify the pixel position using its index.
110+
* @param stackIndex - Index of the image in the stack.
111+
* @param index - The index of the pixel.
112+
* @param channel - The channel to retrieve.
113+
* @returns The value at the given position.
114+
*/
115+
public getValueByIndex(
116+
stackIndex: number,
117+
index: number,
118+
channel: number,
119+
): number {
120+
return this.images[stackIndex].getValueByIndex(index, channel);
121+
}
122+
123+
/**
124+
* Return the image containing the minimum values of all the images in the stack for
125+
* each pixel. All the images must have the same dimensions.
126+
* @returns The minimum image.
127+
*/
128+
public minImage(): Image {
129+
return minImage(this);
130+
}
131+
132+
/**
133+
* Return the image containing the maximum values of all the images in the stack for
134+
* each pixel. All the images must have the same dimensions.
135+
* @returns The maximum image.
136+
*/
137+
public maxImage(): Image {
138+
return maxImage(this);
139+
}
140+
141+
/**
142+
* Return the image containing the median values of all the images in the stack for
143+
* each pixel. All the images must have the same dimensions.
144+
* @returns The median image.
145+
*/
146+
public medianImage(): Image {
147+
return medianImage(this);
148+
}
149+
150+
/**
151+
* Return the image containing the average values of all the images in the stack for
152+
* each pixel. All the images must have the same dimensions.
153+
* @returns The mean image.
154+
*/
155+
public meanImage(): Image {
156+
return meanImage(this);
157+
}
158+
159+
/**
160+
* Get the global histogram of the stack.
161+
*/
162+
// public getHistogram(): Uint32Array {}
163+
164+
/**
165+
* Align all the images of the stack on the image at the given index.
166+
* @param refIndex - The index of the reference image.
167+
*/
168+
// public alignImages(refIndex: number): Stack {}
169+
170+
/**
171+
* Map a function on all the images of the stack.
172+
* @param callback - Function to apply on each image.
173+
* @returns New stack with the modified images.
174+
*/
175+
public map(callback: (image: Image) => Image): Stack {
176+
return new Stack(this.images.map(callback));
177+
}
178+
179+
/**
180+
* Filter the images in the stack.
181+
* @param callback - Function to decide which images to keep.
182+
* @returns New filtered stack.
183+
*/
184+
public filter(callback: (image: Image) => boolean): Stack {
185+
return new Stack(this.images.filter(callback));
186+
}
187+
}

src/__tests__/Stack.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Image } from '../Image';
2+
import { Stack } from '../Stack';
3+
4+
describe('Stack constructor', () => {
5+
it('create a stack containing one image', () => {
6+
const image = testUtils.createGreyImage([[1, 2, 3, 4]]);
7+
const stack = new Stack([image]);
8+
9+
const images = stack.getImages();
10+
expect(stack).toBeInstanceOf(Stack);
11+
expect(images).toHaveLength(1);
12+
expect(images[0]).toBeInstanceOf(Image);
13+
expect(images[0]).toBe(image);
14+
expect(stack.size).toBe(1);
15+
expect(stack.alpha).toBe(false);
16+
expect(stack.colorModel).toBe('GREY');
17+
expect(stack.channels).toBe(1);
18+
expect(stack.bitDepth).toBe(8);
19+
expect(stack.sameDimensions).toBe(true);
20+
});
21+
22+
it('should throw if color model is different', () => {
23+
const image1 = testUtils.createGreyImage([[1, 2, 3, 4]]);
24+
const image2 = testUtils.createRgbaImage([[1, 2, 3, 4]]);
25+
expect(() => {
26+
return new Stack([image1, image2]);
27+
}).toThrow('images must all have the same bit depth and color model');
28+
});
29+
30+
it('should throw if bit depths different', () => {
31+
const image1 = testUtils.createGreyImage([[1, 2, 3, 4]], { bitDepth: 8 });
32+
const image2 = testUtils.createGreyImage([[1, 2, 3, 4]], { bitDepth: 16 });
33+
expect(() => {
34+
return new Stack([image1, image2]);
35+
}).toThrow('images must all have the same bit depth and color model');
36+
});
37+
});
38+
39+
test('iterator', () => {
40+
expect.assertions(2);
41+
const image = testUtils.createGreyImage([[1, 2, 3, 4]]);
42+
const stack = new Stack([image, image]);
43+
44+
for (const image of stack) {
45+
expect(image).toBeInstanceOf(Image);
46+
}
47+
});
48+
49+
test('clone', () => {
50+
const image = testUtils.createGreyImage([[1, 2, 3, 4]]);
51+
const stack = new Stack([image]);
52+
const clone = stack.clone();
53+
expect(clone).toBeInstanceOf(Stack);
54+
expect(clone).not.toBe(stack);
55+
expect(clone.getImages()[0]).toBeInstanceOf(Image);
56+
expect(clone.getImages()[0]).not.toBe(image);
57+
expect(clone.getImages()[0]).toEqual(image);
58+
});
59+
60+
test('getImage', () => {
61+
const image = testUtils.createGreyImage([[1, 2, 3, 4]]);
62+
const stack = new Stack([image]);
63+
expect(stack.getImage(0)).toBe(image);
64+
});
65+
66+
describe('get values from stack', () => {
67+
it('getValue on grey image', () => {
68+
const image = testUtils.createGreyImage([[1, 2, 3, 4]]);
69+
const stack = new Stack([image]);
70+
expect(stack.getValue(0, 0, 0, 0)).toBe(1);
71+
});
72+
73+
it('getValue on RGB image', () => {
74+
const image1 = testUtils.createRgbImage([[1, 2, 3]]);
75+
const image2 = testUtils.createRgbImage([[4, 5, 6]]);
76+
const stack = new Stack([image1, image2]);
77+
expect(stack.getValue(1, 0, 0, 1)).toBe(5);
78+
});
79+
80+
it('getValueByIndex', () => {
81+
const image = testUtils.createGreyImage([[1, 2, 3, 4]]);
82+
const stack = new Stack([image]);
83+
expect(stack.getValueByIndex(0, 1, 0)).toBe(2);
84+
});
85+
});
86+
87+
test('level the images with map', () => {
88+
const image1 = testUtils.createGreyImage([[1, 2, 3, 4]]);
89+
const image2 = testUtils.createGreyImage([[4, 5, 6, 7]]);
90+
const stack = new Stack([image1, image2]);
91+
const result = stack.map((image) => image.level());
92+
expect(result).not.toBe(stack);
93+
expect(result.getImage(0)).toMatchImageData([[0, 85, 170, 255]]);
94+
expect(result.getImage(1)).toMatchImageData([[0, 85, 170, 255]]);
95+
});
96+
97+
test('remove images too dark using filter', () => {
98+
const image1 = testUtils.createGreyImage([[1, 2, 3, 4]]);
99+
const image2 = testUtils.createGreyImage([[100, 100, 100, 100]]);
100+
const stack = new Stack([image1, image2]);
101+
const result = stack.filter((image) => image.mean()[0] > 10);
102+
expect(result).not.toBe(stack);
103+
expect(result.size).toBe(1);
104+
expect(result.getImage(0)).toMatchImageData([[100, 100, 100, 100]]);
105+
});
86 Bytes
Loading
151 Bytes
Loading
141 Bytes
Loading
79 Bytes
Loading

src/stack/__tests__/maxImage.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { join } from 'node:path';
2+
3+
import { Image } from '../../Image';
4+
import { Stack } from '../../Stack';
5+
import { getStackFromFolder } from '../utils/getStackFromFolder';
6+
7+
test('2 grey images', () => {
8+
const image1 = testUtils.createGreyImage([[1, 2, 3, 4]]);
9+
const image2 = testUtils.createGreyImage([[4, 3, 2, 1]]);
10+
const stack = new Stack([image1, image2]);
11+
const maxImage = stack.maxImage();
12+
13+
expect(maxImage).toBeInstanceOf(Image);
14+
expect(maxImage.width).toBe(4);
15+
expect(maxImage.height).toBe(1);
16+
expect(maxImage.channels).toBe(1);
17+
expect(maxImage).toMatchImageData([[4, 3, 3, 4]]);
18+
});
19+
20+
test('more complex stack', () => {
21+
const folder = join(__dirname, '../../../test/img/correctColor');
22+
const stack = getStackFromFolder(folder);
23+
24+
expect(stack.maxImage()).toMatchImageSnapshot();
25+
});
26+
27+
test('2 RGB images', () => {
28+
const image1 = testUtils.createRgbImage([[1, 2, 10]]);
29+
const image2 = testUtils.createRgbImage([[5, 6, 7]]);
30+
const stack = new Stack([image1, image2]);
31+
const maxImage = stack.maxImage();
32+
33+
expect(maxImage).toBeInstanceOf(Image);
34+
expect(maxImage.width).toBe(1);
35+
expect(maxImage.height).toBe(1);
36+
expect(maxImage.channels).toBe(3);
37+
expect(maxImage).toMatchImageData([[5, 6, 10]]);
38+
});

src/stack/__tests__/meanImage.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { join } from 'node:path';
2+
3+
import { Image } from '../../Image';
4+
import { Stack } from '../../Stack';
5+
import { getStackFromFolder } from '../utils/getStackFromFolder';
6+
7+
test('2 grey images', () => {
8+
const image1 = testUtils.createGreyImage([[1, 2, 3, 4]]);
9+
const image2 = testUtils.createGreyImage([[4, 3, 2, 1]]);
10+
const stack = new Stack([image1, image2]);
11+
const meanImage = stack.meanImage();
12+
13+
expect(meanImage).toBeInstanceOf(Image);
14+
expect(meanImage.width).toBe(4);
15+
expect(meanImage.height).toBe(1);
16+
expect(meanImage.channels).toBe(1);
17+
expect(meanImage).toMatchImageData([[2, 2, 2, 2]]);
18+
});
19+
20+
test('2 RGB images', () => {
21+
const image1 = testUtils.createRgbImage([[1, 2, 3]]);
22+
const image2 = testUtils.createRgbImage([[5, 6, 7]]);
23+
const stack = new Stack([image1, image2]);
24+
const meanImage = stack.meanImage();
25+
26+
expect(meanImage).toBeInstanceOf(Image);
27+
expect(meanImage.width).toBe(1);
28+
expect(meanImage.height).toBe(1);
29+
expect(meanImage.channels).toBe(3);
30+
expect(meanImage).toMatchImageData([[3, 4, 5]]);
31+
});
32+
33+
test('more complex stack', () => {
34+
const folder = join(__dirname, '../../../test/img/correctColor');
35+
const stack = getStackFromFolder(folder);
36+
expect(stack.meanImage()).toMatchImageSnapshot();
37+
});
38+
39+
test('2 grey images 16 bits depth', () => {
40+
const data = new Uint16Array([1, 2, 3, 4]);
41+
const image1 = new Image(4, 1, { data, bitDepth: 16, colorModel: 'GREY' });
42+
const image2 = new Image(4, 1, { data, bitDepth: 16, colorModel: 'GREY' });
43+
const stack = new Stack([image1, image2]);
44+
const meanImage = stack.meanImage();
45+
46+
expect(meanImage).toBeInstanceOf(Image);
47+
expect(meanImage.bitDepth).toBe(16);
48+
expect(meanImage).toMatchImage(image1);
49+
});

0 commit comments

Comments
 (0)