Skip to content

Commit f0b246a

Browse files
authored
Adding a Seam Carving algorithm with Dynamic Programming implementation. (trekhleb#693)
* Adding a Seam Carving algorithm with Dynamic Programming implementation. * Adding a Seam Carving algorithm with Dynamic Programming implementation. * Adding a Seam Carving algorithm with Dynamic Programming implementation. * Testing Husky integration. * Testing Husky integration.
1 parent 028ffa6 commit f0b246a

13 files changed

+2838
-1193
lines changed

.husky/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
_

.husky/pre-commit

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/sh
2+
. "$(dirname "$0")/_/husky.sh"
3+
4+
# npm run lint
5+
# npm run test

.huskyrc.json

-5
This file was deleted.

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ a set of rules that precisely define a sequence of operations.
152152
* `B` [NanoNeuron](https://github.com/trekhleb/nano-neuron) - 7 simple JS functions that illustrate how machines can actually learn (forward/backward propagation)
153153
* `B` [k-NN](src/algorithms/ml/knn) - k-nearest neighbors classification algorithm
154154
* `B` [k-Means](src/algorithms/ml/k-means) - k-Means clustering algorithm
155+
* **Image Processing**
156+
* `B` [Seam Carving](src/algorithms/image-processing/seam-carving) - content-aware image resizing algorithm
155157
* **Uncategorized**
156158
* `B` [Tower of Hanoi](src/algorithms/uncategorized/hanoi-tower)
157159
* `B` [Square Matrix Rotation](src/algorithms/uncategorized/square-matrix-rotation) - in-place algorithm
@@ -203,6 +205,7 @@ algorithm is an abstraction higher than a computer program.
203205
* `B` [Unique Paths](src/algorithms/uncategorized/unique-paths)
204206
* `B` [Rain Terraces](src/algorithms/uncategorized/rain-terraces) - trapping rain water problem
205207
* `B` [Recursive Staircase](src/algorithms/uncategorized/recursive-staircase) - count the number of ways to reach to the top
208+
* `B` [Seam Carving](src/algorithms/image-processing/seam-carving) - content-aware image resizing algorithm
206209
* `A` [Levenshtein Distance](src/algorithms/string/levenshtein-distance) - minimum edit distance between two sequences
207210
* `A` [Longest Common Subsequence](src/algorithms/sets/longest-common-subsequence) (LCS)
208211
* `A` [Longest Common Substring](src/algorithms/string/longest-common-substring)

package-lock.json

+1,464-1,186
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"lint": "eslint ./src/**",
88
"test": "jest",
99
"coverage": "npm run test -- --coverage",
10-
"ci": "npm run lint && npm run coverage"
10+
"ci": "npm run lint && npm run coverage",
11+
"prepare": "husky install"
1112
},
1213
"repository": {
1314
"type": "git",
@@ -37,13 +38,14 @@
3738
"@babel/cli": "7.12.10",
3839
"@babel/preset-env": "7.12.11",
3940
"@types/jest": "26.0.19",
41+
"canvas": "^2.7.0",
4042
"eslint": "7.16.0",
4143
"eslint-config-airbnb": "18.2.1",
4244
"eslint-plugin-import": "2.22.1",
4345
"eslint-plugin-jest": "24.1.3",
4446
"eslint-plugin-jsx-a11y": "6.4.1",
4547
"eslint-plugin-react": "7.21.5",
46-
"husky": "4.3.6",
48+
"husky": "6.0.0",
4749
"jest": "26.6.3"
4850
},
4951
"dependencies": {}

src/algorithms/image-processing/seam-carving/README.md

+509
Large diffs are not rendered by default.

src/algorithms/image-processing/seam-carving/README.ru-RU.md

+509
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createCanvas, loadImage } from 'canvas';
2+
import resizeImageWidth from '../resizeImageWidth';
3+
4+
const testImageBeforePath = './src/algorithms/image-processing/seam-carving/__tests__/test-image-before.jpg';
5+
const testImageAfterPath = './src/algorithms/image-processing/seam-carving/__tests__/test-image-after.jpg';
6+
7+
describe('resizeImageWidth', () => {
8+
it('should perform content-aware image width reduction', () => {
9+
// @see: https://jestjs.io/docs/asynchronous
10+
return Promise.all([
11+
loadImage(testImageBeforePath),
12+
loadImage(testImageAfterPath),
13+
]).then(([imgBefore, imgAfter]) => {
14+
// Original image.
15+
const canvasBefore = createCanvas(imgBefore.width, imgBefore.height);
16+
const ctxBefore = canvasBefore.getContext('2d');
17+
ctxBefore.drawImage(imgBefore, 0, 0, imgBefore.width, imgBefore.height);
18+
const imgDataBefore = ctxBefore.getImageData(0, 0, imgBefore.width, imgBefore.height);
19+
20+
// Resized image saved.
21+
const canvasAfter = createCanvas(imgAfter.width, imgAfter.height);
22+
const ctxAfter = canvasAfter.getContext('2d');
23+
ctxAfter.drawImage(imgAfter, 0, 0, imgAfter.width, imgAfter.height);
24+
25+
const toWidth = Math.floor(imgBefore.width / 2);
26+
27+
const {
28+
img: resizedImg,
29+
size: resizedSize,
30+
} = resizeImageWidth({ img: imgDataBefore, toWidth });
31+
32+
expect(resizedImg).toBeDefined();
33+
expect(resizedSize).toBeDefined();
34+
35+
// Resized image generated.
36+
const canvasTest = createCanvas(resizedSize.w, resizedSize.h);
37+
const ctxTest = canvasTest.getContext('2d');
38+
ctxTest.putImageData(resizedImg, 0, 0, 0, 0, resizedSize.w, resizedSize.h);
39+
const imgDataTest = ctxTest.getImageData(0, 0, resizedSize.w, resizedSize.h);
40+
41+
expect(resizedSize).toEqual({ w: toWidth, h: imgBefore.height });
42+
expect(imgDataTest.width).toBe(toWidth);
43+
expect(imgDataTest.height).toBe(imgBefore.height);
44+
expect(imgDataTest.width).toBe(imgAfter.width);
45+
expect(imgDataTest.height).toBe(imgAfter.height);
46+
47+
// @TODO: Check that images are identical.
48+
// expect(canvasTest.toDataURL()).toEqual(canvasAfter.toDataURL());
49+
});
50+
});
51+
});
Loading
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { getPixel, setPixel } from '../utils/imageData';
2+
3+
/**
4+
* The seam is a sequence of pixels (coordinates).
5+
* @typedef {PixelCoordinate[]} Seam
6+
*/
7+
8+
/**
9+
* Energy map is a 2D array that has the same width and height
10+
* as the image the map is being calculated for.
11+
* @typedef {number[][]} EnergyMap
12+
*/
13+
14+
/**
15+
* The metadata for the pixels in the seam.
16+
* @typedef {Object} SeamPixelMeta
17+
* @property {number} energy - the energy of the pixel.
18+
* @property {PixelCoordinate} coordinate - the coordinate of the pixel.
19+
* @property {?PixelCoordinate} previous - the previous pixel in a seam.
20+
*/
21+
22+
/**
23+
* Type that describes the image size (width and height)
24+
* @typedef {Object} ImageSize
25+
* @property {number} w - image width.
26+
* @property {number} h - image height.
27+
*/
28+
29+
/**
30+
* @typedef {Object} ResizeImageWidthArgs
31+
* @property {ImageData} img - image data we want to resize.
32+
* @property {number} toWidth - final image width we want the image to shrink to.
33+
*/
34+
35+
/**
36+
* @typedef {Object} ResizeImageWidthResult
37+
* @property {ImageData} img - resized image data.
38+
* @property {ImageSize} size - resized image size.
39+
*/
40+
41+
/**
42+
* Helper function that creates a matrix (2D array) of specific
43+
* size (w x h) and fills it with specified value.
44+
* @param {number} w
45+
* @param {number} h
46+
* @param {?(number | SeamPixelMeta)} filler
47+
* @returns {?(number | SeamPixelMeta)[][]}
48+
*/
49+
const matrix = (w, h, filler) => {
50+
return new Array(h)
51+
.fill(null)
52+
.map(() => {
53+
return new Array(w).fill(filler);
54+
});
55+
};
56+
57+
/**
58+
* Calculates the energy of a pixel.
59+
* @param {?PixelColor} left
60+
* @param {PixelColor} middle
61+
* @param {?PixelColor} right
62+
* @returns {number}
63+
*/
64+
const getPixelEnergy = (left, middle, right) => {
65+
// Middle pixel is the pixel we're calculating the energy for.
66+
const [mR, mG, mB] = middle;
67+
68+
// Energy from the left pixel (if it exists).
69+
let lEnergy = 0;
70+
if (left) {
71+
const [lR, lG, lB] = left;
72+
lEnergy = (lR - mR) ** 2 + (lG - mG) ** 2 + (lB - mB) ** 2;
73+
}
74+
75+
// Energy from the right pixel (if it exists).
76+
let rEnergy = 0;
77+
if (right) {
78+
const [rR, rG, rB] = right;
79+
rEnergy = (rR - mR) ** 2 + (rG - mG) ** 2 + (rB - mB) ** 2;
80+
}
81+
82+
// Resulting pixel energy.
83+
return Math.sqrt(lEnergy + rEnergy);
84+
};
85+
86+
/**
87+
* Calculates the energy of each pixel of the image.
88+
* @param {ImageData} img
89+
* @param {ImageSize} size
90+
* @returns {EnergyMap}
91+
*/
92+
const calculateEnergyMap = (img, { w, h }) => {
93+
// Create an empty energy map where each pixel has infinitely high energy.
94+
// We will update the energy of each pixel.
95+
const energyMap = matrix(w, h, Infinity);
96+
for (let y = 0; y < h; y += 1) {
97+
for (let x = 0; x < w; x += 1) {
98+
// Left pixel might not exist if we're on the very left edge of the image.
99+
const left = (x - 1) >= 0 ? getPixel(img, { x: x - 1, y }) : null;
100+
// The color of the middle pixel that we're calculating the energy for.
101+
const middle = getPixel(img, { x, y });
102+
// Right pixel might not exist if we're on the very right edge of the image.
103+
const right = (x + 1) < w ? getPixel(img, { x: x + 1, y }) : null;
104+
energyMap[y][x] = getPixelEnergy(left, middle, right);
105+
}
106+
}
107+
return energyMap;
108+
};
109+
110+
/**
111+
* Finds the seam (the sequence of pixels from top to bottom) that has the
112+
* lowest resulting energy using the Dynamic Programming approach.
113+
* @param {EnergyMap} energyMap
114+
* @param {ImageSize} size
115+
* @returns {Seam}
116+
*/
117+
const findLowEnergySeam = (energyMap, { w, h }) => {
118+
// The 2D array of the size of w and h, where each pixel contains the
119+
// seam metadata (pixel energy, pixel coordinate and previous pixel from
120+
// the lowest energy seam at this point).
121+
const seamPixelsMap = matrix(w, h, null);
122+
123+
// Populate the first row of the map by just copying the energies
124+
// from the energy map.
125+
for (let x = 0; x < w; x += 1) {
126+
const y = 0;
127+
seamPixelsMap[y][x] = {
128+
energy: energyMap[y][x],
129+
coordinate: { x, y },
130+
previous: null,
131+
};
132+
}
133+
134+
// Populate the rest of the rows.
135+
for (let y = 1; y < h; y += 1) {
136+
for (let x = 0; x < w; x += 1) {
137+
// Find the top adjacent cell with minimum energy.
138+
// This cell would be the tail of a seam with lowest energy at this point.
139+
// It doesn't mean that this seam (path) has lowest energy globally.
140+
// Instead, it means that we found a path with the lowest energy that may lead
141+
// us to the current pixel with the coordinates x and y.
142+
let minPrevEnergy = Infinity;
143+
let minPrevX = x;
144+
for (let i = (x - 1); i <= (x + 1); i += 1) {
145+
if (i >= 0 && i < w && seamPixelsMap[y - 1][i].energy < minPrevEnergy) {
146+
minPrevEnergy = seamPixelsMap[y - 1][i].energy;
147+
minPrevX = i;
148+
}
149+
}
150+
151+
// Update the current cell.
152+
seamPixelsMap[y][x] = {
153+
energy: minPrevEnergy + energyMap[y][x],
154+
coordinate: { x, y },
155+
previous: { x: minPrevX, y: y - 1 },
156+
};
157+
}
158+
}
159+
160+
// Find where the minimum energy seam ends.
161+
// We need to find the tail of the lowest energy seam to start
162+
// traversing it from its tail to its head (from the bottom to the top).
163+
let lastMinCoordinate = null;
164+
let minSeamEnergy = Infinity;
165+
for (let x = 0; x < w; x += 1) {
166+
const y = h - 1;
167+
if (seamPixelsMap[y][x].energy < minSeamEnergy) {
168+
minSeamEnergy = seamPixelsMap[y][x].energy;
169+
lastMinCoordinate = { x, y };
170+
}
171+
}
172+
173+
// Find the lowest energy energy seam.
174+
// Once we know where the tail is we may traverse and assemble the lowest
175+
// energy seam based on the "previous" value of the seam pixel metadata.
176+
const seam = [];
177+
178+
const { x: lastMinX, y: lastMinY } = lastMinCoordinate;
179+
180+
// Adding new pixel to the seam path one by one until we reach the top.
181+
let currentSeam = seamPixelsMap[lastMinY][lastMinX];
182+
while (currentSeam) {
183+
seam.push(currentSeam.coordinate);
184+
const prevMinCoordinates = currentSeam.previous;
185+
if (!prevMinCoordinates) {
186+
currentSeam = null;
187+
} else {
188+
const { x: prevMinX, y: prevMinY } = prevMinCoordinates;
189+
currentSeam = seamPixelsMap[prevMinY][prevMinX];
190+
}
191+
}
192+
193+
return seam;
194+
};
195+
196+
/**
197+
* Deletes the seam from the image data.
198+
* We delete the pixel in each row and then shift the rest of the row pixels to the left.
199+
* @param {ImageData} img
200+
* @param {Seam} seam
201+
* @param {ImageSize} size
202+
*/
203+
const deleteSeam = (img, seam, { w }) => {
204+
seam.forEach(({ x: seamX, y: seamY }) => {
205+
for (let x = seamX; x < (w - 1); x += 1) {
206+
const nextPixel = getPixel(img, { x: x + 1, y: seamY });
207+
setPixel(img, { x, y: seamY }, nextPixel);
208+
}
209+
});
210+
};
211+
212+
/**
213+
* Performs the content-aware image width resizing using the seam carving method.
214+
* @param {ResizeImageWidthArgs} args
215+
* @returns {ResizeImageWidthResult}
216+
*/
217+
const resizeImageWidth = ({ img, toWidth }) => {
218+
/**
219+
* For performance reasons we want to avoid changing the img data array size.
220+
* Instead we'll just keep the record of the resized image width and height separately.
221+
* @type {ImageSize}
222+
*/
223+
const size = { w: img.width, h: img.height };
224+
225+
// Calculating the number of pixels to remove.
226+
const pxToRemove = img.width - toWidth;
227+
228+
let energyMap = null;
229+
let seam = null;
230+
231+
// Removing the lowest energy seams one by one.
232+
for (let i = 0; i < pxToRemove; i += 1) {
233+
// 1. Calculate the energy map for the current version of the image.
234+
energyMap = calculateEnergyMap(img, size);
235+
236+
// 2. Find the seam with the lowest energy based on the energy map.
237+
seam = findLowEnergySeam(energyMap, size);
238+
239+
// 3. Delete the seam with the lowest energy seam from the image.
240+
deleteSeam(img, seam, size);
241+
242+
// Reduce the image width, and continue iterations.
243+
size.w -= 1;
244+
}
245+
246+
// Returning the resized image and its final size.
247+
// The img is actually a reference to the ImageData, so technically
248+
// the caller of the function already has this pointer. But let's
249+
// still return it for better code readability.
250+
return { img, size };
251+
};
252+
253+
export default resizeImageWidth;

0 commit comments

Comments
 (0)