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

Commit f3ca64c

Browse files
feat: add background correction (#449)
1 parent 625bc6e commit f3ca64c

13 files changed

+571
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"ml-matrix": "^6.11.0",
5959
"ml-ransac": "^1.0.0",
6060
"ml-regression-multivariate-linear": "^2.0.4",
61+
"ml-regression-polynomial-2d": "^0.2.0",
6162
"ml-spectra-processing": "^14.3.0",
6263
"robust-point-in-polygon": "^1.0.3",
6364
"ssim.js": "^3.5.0",
223 KB
Loading
108 KB
Loading
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { Point } from '../../geometry';
2+
import { sampleBackgroundPoints } from '../../utils/sampleBackgroundPoints';
3+
import { correctBackground } from '../correctBackground';
4+
import { getMaskFromCannyEdge } from '../getMaskFromCannyEdge';
5+
6+
test('basic test', () => {
7+
const image = testUtils.createGreyImage([
8+
[1, 2, 3, 4, 5],
9+
[1, 2, 3, 4, 5],
10+
[1, 2, 3, 4, 5],
11+
[1, 2, 3, 4, 5],
12+
[1, 2, 3, 4, 5],
13+
]);
14+
const mask = getMaskFromCannyEdge(image);
15+
const points = sampleBackgroundPoints(image, {
16+
mask,
17+
gridWidth: 3,
18+
gridHeight: 3,
19+
});
20+
const newImage = correctBackground(image, {
21+
background: points,
22+
order: 2,
23+
backgroundKind: 'dark',
24+
});
25+
const result = testUtils.createGreyImage([
26+
[0, 0, 0, 0, 0],
27+
[0, 0, 0, 0, 0],
28+
[0, 0, 0, 0, 0],
29+
[0, 0, 0, 0, 0],
30+
[0, 0, 0, 0, 0],
31+
]);
32+
expect(newImage).toEqual(result);
33+
});
34+
35+
test('test with object 8x8 and manually picked points', () => {
36+
const image = testUtils.createGreyImage([
37+
[1, 2, 3, 4, 5, 6, 7, 8],
38+
[1, 2, 3, 4, 5, 6, 7, 8],
39+
[1, 2, 250, 250, 250, 250, 7, 8],
40+
[1, 2, 250, 4, 5, 250, 7, 8],
41+
[1, 2, 250, 4, 5, 250, 7, 8],
42+
[1, 2, 250, 250, 250, 250, 7, 8],
43+
[1, 2, 3, 4, 5, 6, 7, 8],
44+
[1, 2, 3, 4, 5, 6, 7, 8],
45+
]);
46+
const points: Point[] = [
47+
{ column: 0, row: 0 },
48+
{ column: 1, row: 6 },
49+
{ column: 2, row: 1 },
50+
{ column: 3, row: 1 },
51+
{ column: 4, row: 6 },
52+
{ column: 3, row: 7 },
53+
{ column: 4, row: 7 },
54+
{ column: 5, row: 7 },
55+
];
56+
57+
const newImage = correctBackground(image, {
58+
background: points,
59+
backgroundKind: 'dark',
60+
});
61+
const result = testUtils.createGreyImage([
62+
[0, 0, 0, 0, 0, 0, 0, 0],
63+
[0, 0, 0, 0, 0, 0, 0, 0],
64+
[0, 0, 247, 246, 245, 244, 0, 0],
65+
[0, 0, 247, 0, 0, 244, 0, 0],
66+
[0, 0, 247, 0, 0, 244, 0, 0],
67+
[0, 0, 247, 246, 245, 244, 0, 0],
68+
[0, 0, 0, 0, 0, 0, 0, 0],
69+
[0, 0, 0, 0, 0, 0, 0, 0],
70+
]);
71+
expect(newImage).toEqual(result);
72+
});
73+
74+
test('test with object 8x8 and sampled points', () => {
75+
const image = testUtils.createGreyImage([
76+
[1, 2, 3, 4, 5, 6, 7, 8],
77+
[1, 2, 3, 4, 5, 6, 7, 8],
78+
[1, 2, 250, 250, 250, 250, 7, 8],
79+
[1, 2, 250, 4, 5, 250, 7, 8],
80+
[1, 2, 250, 4, 5, 250, 7, 8],
81+
[1, 2, 250, 250, 250, 250, 7, 8],
82+
[1, 2, 3, 4, 5, 6, 7, 8],
83+
[1, 2, 3, 4, 5, 6, 7, 8],
84+
]);
85+
const mask = getMaskFromCannyEdge(image, { iterations: 0 });
86+
87+
const points = sampleBackgroundPoints(image, {
88+
mask,
89+
gridWidth: 5,
90+
gridHeight: 5,
91+
});
92+
const newImage = correctBackground(image, {
93+
background: points,
94+
order: 3,
95+
backgroundKind: 'dark',
96+
});
97+
const result = testUtils.createGreyImage([
98+
[0, 0, 0, 0, 0, 0, 0, 0],
99+
[0, 0, 0, 0, 0, 0, 0, 0],
100+
[0, 0, 247, 246, 245, 244, 0, 0],
101+
[0, 0, 247, 0, 0, 244, 0, 0],
102+
[0, 0, 247, 0, 0, 244, 0, 0],
103+
[0, 0, 247, 246, 245, 244, 0, 0],
104+
[0, 0, 0, 0, 0, 0, 0, 0],
105+
[0, 0, 0, 0, 0, 0, 0, 0],
106+
]);
107+
expect(newImage).toEqual(result);
108+
});
109+
110+
test('basic screws image test', () => {
111+
const image = testUtils.load('various/screws.png').grey();
112+
const mask = getMaskFromCannyEdge(image);
113+
const points = sampleBackgroundPoints(image, {
114+
mask,
115+
gridWidth: 15,
116+
gridHeight: 15,
117+
});
118+
const newImage = correctBackground(image, {
119+
background: points,
120+
order: 2,
121+
backgroundKind: 'light',
122+
});
123+
expect(newImage).toMatchImageSnapshot();
124+
});
125+
126+
test('basic sudoku image test', () => {
127+
const image = testUtils.load('various/sudoku.jpg').grey();
128+
const mask = getMaskFromCannyEdge(image, { iterations: 0 });
129+
const points = sampleBackgroundPoints(image, {
130+
mask,
131+
gridWidth: 15,
132+
gridHeight: 15,
133+
});
134+
const newImage = correctBackground(image, { background: points });
135+
expect(newImage).toMatchImageSnapshot();
136+
});
137+
test('throw if insufficient number of points', () => {
138+
const image = testUtils.createGreyImage([
139+
[1, 2, 3, 4, 5],
140+
[1, 2, 3, 4, 5],
141+
[1, 2, 3, 4, 5],
142+
[1, 2, 3, 4, 5],
143+
[1, 2, 3, 4, 5],
144+
]);
145+
const mask = getMaskFromCannyEdge(image);
146+
const points = sampleBackgroundPoints(image, {
147+
mask,
148+
gridWidth: 2,
149+
gridHeight: 2,
150+
});
151+
152+
expect(() => {
153+
correctBackground(image, {
154+
background: points,
155+
order: 2,
156+
backgroundKind: 'dark',
157+
});
158+
}).toThrow('Insufficient number of points to create regression model.');
159+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { getMaskFromCannyEdge } from '../getMaskFromCannyEdge';
2+
3+
test('basic test', () => {
4+
const image = testUtils.createGreyImage([
5+
[1, 2, 3, 4, 5],
6+
[1, 50, 50, 50, 5],
7+
[1, 50, 3, 50, 5],
8+
[1, 50, 50, 50, 5],
9+
[1, 2, 3, 4, 5],
10+
]);
11+
const mask = testUtils.createMask([
12+
[0, 0, 0, 0, 0],
13+
[0, 1, 1, 1, 0],
14+
[0, 1, 1, 1, 0],
15+
[0, 1, 1, 1, 0],
16+
[0, 0, 0, 0, 0],
17+
]);
18+
const fromCannyMask = getMaskFromCannyEdge(image, { iterations: 0 });
19+
expect(fromCannyMask).toEqual(mask);
20+
});
21+
22+
test('testing 10x10 with dilation', () => {
23+
const image = testUtils.createGreyImage([
24+
[40, 40, 40, 4, 5, 6, 7, 8, 9, 10],
25+
[40, 2, 40, 0, 5, 6, 7, 8, 9, 10],
26+
[40, 2, 3, 40, 5, 6, 7, 8, 9, 10],
27+
[40, 0, 0, 0, 5, 6, 7, 8, 9, 10],
28+
[1, 1, 3, 4, 5, 6, 7, 8, 9, 10],
29+
[1, 2, 3, 4, 5, 5, 5, 70, 70, 10],
30+
[1, 2, 3, 4, 5, 6, 7, 60, 9, 70],
31+
[1, 2, 3, 4, 5, 40, 40, 8, 9, 70],
32+
[1, 2, 3, 4, 5, 6, 7, 70, 9, 70],
33+
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
34+
]);
35+
const mask = testUtils.createMask([
36+
[1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
37+
[1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
38+
[1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
39+
[1, 1, 1, 1, 0, 0, 1, 1, 1, 1],
40+
[0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
41+
[0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
42+
[0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
43+
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1],
44+
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1],
45+
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1],
46+
]);
47+
const fromCannyMask = getMaskFromCannyEdge(image);
48+
expect(fromCannyMask).toEqual(mask);
49+
});
50+
51+
test('testing 7x7 without dilation', () => {
52+
const image = testUtils.createGreyImage([
53+
[50, 50, 50, 50, 50, 50, 50],
54+
[50, 0, 0, 0, 0, 0, 0],
55+
[50, 0, 0, 0, 0, 0, 0],
56+
[50, 0, 0, 0, 50, 0, 0],
57+
[50, 0, 0, 0, 50, 0, 0],
58+
[50, 0, 0, 0, 50, 0, 0],
59+
[50, 50, 50, 50, 50, 0, 0],
60+
]);
61+
const mask = testUtils.createMask([
62+
[0, 0, 0, 0, 0, 0, 0],
63+
[0, 1, 1, 1, 1, 1, 0],
64+
[0, 1, 0, 0, 0, 0, 0],
65+
[0, 1, 0, 1, 0, 1, 0],
66+
[0, 1, 0, 1, 0, 1, 0],
67+
[0, 1, 1, 1, 0, 1, 0],
68+
[0, 0, 0, 0, 0, 0, 0],
69+
]);
70+
const fromCannyMask = getMaskFromCannyEdge(image, { iterations: 0 });
71+
expect(fromCannyMask).toEqual(mask);
72+
});
73+
74+
test('testing 7x7 with dilation', () => {
75+
const image = testUtils.createGreyImage([
76+
[50, 50, 50, 50, 50, 50, 50],
77+
[50, 0, 0, 0, 0, 0, 0],
78+
[50, 0, 0, 0, 0, 0, 0],
79+
[50, 0, 0, 0, 50, 0, 0],
80+
[50, 0, 0, 0, 50, 0, 0],
81+
[50, 0, 0, 0, 50, 0, 0],
82+
[50, 50, 50, 50, 50, 0, 0],
83+
]);
84+
const mask = testUtils.createMask([
85+
[1, 1, 1, 1, 1, 1, 1],
86+
[1, 1, 1, 1, 1, 1, 1],
87+
[1, 1, 1, 1, 1, 1, 1],
88+
[1, 1, 1, 1, 1, 1, 1],
89+
[1, 1, 1, 1, 1, 1, 1],
90+
[1, 1, 1, 1, 1, 1, 1],
91+
[1, 1, 1, 1, 1, 1, 1],
92+
]);
93+
const fromCannyMask = getMaskFromCannyEdge(image);
94+
expect(fromCannyMask).toEqual(mask);
95+
});

src/operations/correctBackground.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { PolynomialRegression2D } from 'ml-regression-polynomial-2d';
2+
3+
import { Image } from '../Image';
4+
import { Point } from '../geometry';
5+
import checkProcessable from '../utils/validators/checkProcessable';
6+
7+
export interface CorrectBackgroundOptions {
8+
/**
9+
* @param background - Points that are considered the background of an image.
10+
*/
11+
background: Point[];
12+
/**
13+
* @param order - Order of regression function.
14+
* @default `2`
15+
*/
16+
order?: number;
17+
/**
18+
* Checks if the image background is light or dark. If the background is
19+
* light, the output image will be inverted.
20+
* @default `'light'`
21+
*/
22+
backgroundKind?: 'dark' | 'light';
23+
}
24+
25+
/**
26+
* Corrects background from an image for baseline correction.
27+
* @param image - Image to subtract background from.
28+
* @param options - CorrectBackgroundOptions.
29+
* @returns Image with corrected baseline.
30+
*/
31+
export function correctBackground(
32+
image: Image,
33+
options: CorrectBackgroundOptions,
34+
) {
35+
const { background, order = 2, backgroundKind = 'light' } = options;
36+
checkProcessable(image, { colorModel: ['GREY'] });
37+
const columns = new Array<number>();
38+
const rows = new Array<number>();
39+
const values = new Array<number>();
40+
for (const point of background) {
41+
columns.push(point.column);
42+
rows.push(point.row);
43+
values.push(image.getValueByPoint(point, 0));
44+
}
45+
46+
const model = new PolynomialRegression2D({ x: columns, y: rows }, values, {
47+
order,
48+
});
49+
const points: { x: number[]; y: number[] } = { x: [], y: [] };
50+
51+
for (let row = 0; row < image.height; row++) {
52+
for (let column = 0; column < image.width; column++) {
53+
points.x.push(column);
54+
points.y.push(row);
55+
}
56+
}
57+
const Y = model.predict(points);
58+
for (let row = 0; row < image.height; row++) {
59+
for (let column = 0; column < image.width; column++) {
60+
const value = Math.abs(
61+
image.getValue(column, row, 0) - Y[row * image.width + column],
62+
);
63+
image.setValue(column, row, 0, value);
64+
}
65+
}
66+
if (backgroundKind === 'light') {
67+
return image.invert();
68+
} else {
69+
return image;
70+
}
71+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Image } from '../Image';
2+
import { DilateOptions } from '../morphology';
3+
import { fromMask } from '../roi';
4+
5+
/**
6+
* Creates a mask with ROI shapes with CannyEdge filter. Then these shapes
7+
* get "filled" through internalIds.
8+
* @param image - Image to get the mask with.
9+
* @param options - GetMaskFromCannyEdge options.
10+
* @returns Mask
11+
*/
12+
export function getMaskFromCannyEdge(image: Image, options?: DilateOptions) {
13+
const kernel = options?.kernel ?? [
14+
[1, 1, 1],
15+
[1, 1, 1],
16+
[1, 1, 1],
17+
];
18+
const iterations = options?.iterations ?? 1;
19+
20+
let mask = image.cannyEdgeDetector();
21+
mask = mask.dilate({ iterations, kernel });
22+
23+
const roiMap = fromMask(mask);
24+
const rois = roiMap.getRois({ kind: 'white' });
25+
for (const roi of rois) {
26+
const ids = new Set(
27+
roi.internalIDs.filter((value) => {
28+
return value < 0;
29+
}),
30+
);
31+
for (let i = roi.origin.row; i < roi.origin.row + roi.height; i++) {
32+
for (let j = roi.origin.column; j < roi.origin.column + roi.width; j++) {
33+
const value = roi.getMapValue(j, i);
34+
if (ids.has(value)) {
35+
mask.setBit(j, i, 1);
36+
}
37+
}
38+
}
39+
}
40+
return mask;
41+
}

src/operations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './cropRectangle';
1212
export * from './operations.types';
1313
export * from './paintMaskOnImage';
1414
export * from './paintMaskOnMask';
15+
export * from './correctBackground';

0 commit comments

Comments
 (0)