Adding a Seam Carving algorithm with Dynamic Programming implementation. (#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.
This commit is contained in:
Oleksii Trekhleb 2021-04-20 17:47:33 +02:00 committed by GitHub
parent 028ffa6e9a
commit f0b246a521
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 2827 additions and 1182 deletions

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

5
.husky/pre-commit Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# npm run lint
# npm run test

View File

@ -1,5 +0,0 @@
{
"hooks": {
"pre-commit": "npm run lint && npm run test"
}
}

View File

@ -152,6 +152,8 @@ a set of rules that precisely define a sequence of operations.
* `B` [NanoNeuron](https://github.com/trekhleb/nano-neuron) - 7 simple JS functions that illustrate how machines can actually learn (forward/backward propagation)
* `B` [k-NN](src/algorithms/ml/knn) - k-nearest neighbors classification algorithm
* `B` [k-Means](src/algorithms/ml/k-means) - k-Means clustering algorithm
* **Image Processing**
* `B` [Seam Carving](src/algorithms/image-processing/seam-carving) - content-aware image resizing algorithm
* **Uncategorized**
* `B` [Tower of Hanoi](src/algorithms/uncategorized/hanoi-tower)
* `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.
* `B` [Unique Paths](src/algorithms/uncategorized/unique-paths)
* `B` [Rain Terraces](src/algorithms/uncategorized/rain-terraces) - trapping rain water problem
* `B` [Recursive Staircase](src/algorithms/uncategorized/recursive-staircase) - count the number of ways to reach to the top
* `B` [Seam Carving](src/algorithms/image-processing/seam-carving) - content-aware image resizing algorithm
* `A` [Levenshtein Distance](src/algorithms/string/levenshtein-distance) - minimum edit distance between two sequences
* `A` [Longest Common Subsequence](src/algorithms/sets/longest-common-subsequence) (LCS)
* `A` [Longest Common Substring](src/algorithms/string/longest-common-substring)

2628
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,8 @@
"lint": "eslint ./src/**",
"test": "jest",
"coverage": "npm run test -- --coverage",
"ci": "npm run lint && npm run coverage"
"ci": "npm run lint && npm run coverage",
"prepare": "husky install"
},
"repository": {
"type": "git",
@ -37,13 +38,14 @@
"@babel/cli": "7.12.10",
"@babel/preset-env": "7.12.11",
"@types/jest": "26.0.19",
"canvas": "^2.7.0",
"eslint": "7.16.0",
"eslint-config-airbnb": "18.2.1",
"eslint-plugin-import": "2.22.1",
"eslint-plugin-jest": "24.1.3",
"eslint-plugin-jsx-a11y": "6.4.1",
"eslint-plugin-react": "7.21.5",
"husky": "4.3.6",
"husky": "6.0.0",
"jest": "26.6.3"
},
"dependencies": {}

View File

@ -0,0 +1,509 @@
# Content-aware image resizing in JavaScript
![Content-aware image resizing in JavaScript](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/01-cover-02.png)
> There is an [interactive version of this post](https://trekhleb.dev/blog/2021/content-aware-image-resizing-in-javascript/) available where you can upload and resize your custom images.
## TL;DR
There are many great articles written about the *Seam Carving algorithm* already, but I couldn't resist the temptation to explore this elegant, powerful, and *yet simple* algorithm on my own, and to write about my personal experience with it. Another point that drew my attention (as a creator of [javascript-algorithms](https://github.com/trekhleb/javascript-algorithms) repo) was the fact that *Dynamic Programming (DP)* approach might be smoothly applied to solve it. And, if you're like me and still on your "learning algorithms" journey, this algorithmic solution may enrich your personal DP arsenal.
So, with this article I want to do three things:
1. Provide you with an interactive **content-aware resizer** so that you could play around with resizing your own images
2. Explain the idea behind the **Seam Carving algorithm**
3. Explain the **dynamic programming approach** to implement the algorithm (we'll be using TypeScript for it)
### Content-aware image resizing
*Content-aware image resizing* might be applied when it comes to changing the image proportions (i.e. reducing the width while keeping the height) and when losing some parts of the image is not desirable. Doing the straightforward image scaling in this case would distort the objects in it. To preserve the proportions of the objects while changing the image proportions we may use the [Seam Carving algorithm](https://perso.crans.org/frenoy/matlab2012/seamcarving.pdf) that was introduced by *Shai Avidan* and *Ariel Shamir*.
The example below shows how the original image width was reduced by 50% using *content-aware resizing* (left image) and *straightforward scaling* (right image). In this particular case, the left image looks more natural since the proportions of the balloons were preserved.
![Content-aware image resizing](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/01-resizing-options.png)
The Seam Carving algorithm's idea is to find the *seam* (continuous sequence of pixels) with the lowest contribution to the image content and then *carve* (remove) it. This process repeats over and over again until we get the required image width or height. In the example below you may see that the hot air balloon pixels contribute more to the content of the image than the sky pixels. Thus, the sky pixels are being removed first.
![JS IMAGE CARVER DEMO](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/10-demo-01.gif)
Finding the seam with the lowest energy is a computationally expensive task (especially for large images). To make the seam search faster the *dynamic programming* approach might be applied (we will go through the implementation details below).
### Objects removal
The importance of each pixel (so-called pixel's energy) is being calculated based on its color (`R`, `G`, `B`, `A`) difference between two neighbor pixels. Now, if we set the pixel energy to some really low level artificially (i.e. by drawing a mask on top of them), the Seam Carving algorithm would perform an **object removal** for us for free.
![JS IMAGE CARVER OBJECT REMOVAL DEMO](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/10-demo-02.gif)
### JS IMAGE CARVER demo
I've created the [JS IMAGE CARVER](https://trekhleb.dev/js-image-carver/) web-app (and also [open-sourced it on GitHub](https://github.com/trekhleb/js-image-carver)) that you may use to play around with resizing of your custom images.
### More examples
Here are some more examples of how the algorithm copes with more complex backgrounds.
Mountains on the background are being shrunk smoothly without visible seams.
![Resizing demo with more complex backgrounds](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/11-demo-01.png)
The same goes for the ocean waves. The algorithm preserved the wave structure without distorting the surfers.
![Resizing demo with more complex backgrounds](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/11-demo-02.png)
We need to keep in mind that the Seam Carving algorithm is not a silver bullet, and it may fail to resize the images where *most of the pixels are edges* (look important to the algorithm). In this case, it starts distorting even the important parts of the image. In the example below the content-aware image resizing looks pretty similar to a straightforward scaling since for the algorithm all the pixels look important, and it is hard for it to distinguish Van Gogh's face from the background.
![Example when the algorithm does not work as expected](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/12-demo-01.png)
## How Seam Carving algorithms works
Imagine we have a `1000 x 500 px` picture, and we want to change its size to `500 x 500 px` to make it square (let's say the square ratio would better fit the Instagram feed). We might want to set up several **requirements to the resizing process** in this case:
- *Preserve the important parts of the image* (i.e. if there were 5 trees before the resizing we want to have 5 trees after resizing as well).
- *Preserve the proportions* of the important parts of the image (i.e. circle car wheels should not be squeezed to the ellipse wheels)
To avoid changing the important parts of the image we may find the **continuous sequence of pixels (the seam)**, that goes from top to bottom and has *the lowest contribution to the content* of the image (avoids important parts) and then remove it. The seam removal will shrink the image by 1 pixel. We will then repeat this step until the image will get the desired width.
The question is how to define *the importance of the pixel* and its contribution to the content (in the original paper the authors are using the term **energy of the pixel**). One of the ways to do it is to treat all the pixels that form the edges as important ones. In case if a pixel is a part of the edge its color would have a greater difference between the neighbors (left and right pixels) than the pixel that isn't a part of the edge.
![Pixels color difference](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/30-pixel-energy-comparison.png)
Assuming that the color of a pixel is represented by *4* numbers (`R` - red, `G` - green, `B` - blue, `A` - alpha) we may use the following formula to calculate the color difference (the pixel energy):
![Pixel energy formula](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/20-energy-formula.png)
Where:
- `mEnergy` - *Energy* (importance) of the *middle* pixel (`[0..626]` if rounded)
- `lR` - *Red* channel value for the *left* pixel (`[0..255]`)
- `mR` - *Red* channel value for the *middle* pixel (`[0..255]`)
- `rR` - *Red* channel value for the *right* pixel (`[0..255]`)
- `lG` - *Green* channel value for the *left* pixel (`[0..255]`)
- and so on...
In the formula above we're omitting the alpha (transparency) channel, for now, assuming that there are no transparent pixels in the image. Later we will use the alpha channel for masking and for object removal.
![Example of pixel energy calculation](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/30-pixel-energy-calculation-example.png)
Now, since we know how to find the energy of one pixel, we can calculate, so-called, **energy map** which will contain the energies of each pixel of the image. On each resizing step the energy map should be re-calculated (at least partially, more about it below) and would have the same size as the image.
For example, on the 1st resizing step we will have a `1000 x 500` image and a `1000 x 500` energy map. On the 2nd resizing step we will remove the seam from the image and re-calculate the energy map based on the new shrunk image. Thus, we will get a `999 x 500` image and a `999 x 500` energy map.
The higher the energy of the pixel the more likely it is a part of an edge, and it is important for the image content and the less likely that we need to remove it.
To visualize the energy map we may assign a brighter color to the pixels with the higher energy and darker colors to the pixels with the lower energy. Here is an artificial example of how the random part of the energy map might look like. You may see the bright line which represents the edge and which we want to preserve during the resizing.
![Energy map sketch](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/30-energy-map-padding.png)
Here is a real example of the energy map for the demo image you saw above (with hot air balloons).
![Energy map example](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/40-energy-map.png)
You may play around with your custom images and see how the energy map would look like in the [interactive version of the post](https://trekhleb.dev/blog/2021/content-aware-image-resizing-in-javascript/).
We may use the energy map to find the seams (one after another) with the lowest energy and by doing this to decide which pixels should be ultimately deleted.
![Searching the seam](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/41-seam-search.png)
Finding the seam with the lowest energy is not a trivial task and requires exploring many possible pixel combinations before making the decision. We will apply the dynamic programming approach to speed it up.
In the example below, you may see the energy map with the first lowest energy seam that was found for it.
![Energy map example with seam](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/40-energy-map-with-seam.png)
In the examples above we were reducing the width of the image. A similar approach may be taken to reduce the image height. We need to "rotate" the approach though:
- start using *top* and *bottom* pixel neighbors (instead of *left* and *right* ones) to calculate the pixel energy
- when searching for a seam we need to move from *left* to *right* (instead of from *up* to *bottom*)
## Implementation in TypeScript
> You may find the source code, and the functions mentioned below in the [js-image-carver](https://github.com/trekhleb/js-image-carver) repository.
To implement the algorithm we will be using TypeScript. If you want a JavaScript version, you may ignore (remove) type definitions and their usages.
For simplicity reasons let's implement the seam carving algorithm only for the image *width* reduction.
### Content-aware width resizing (the entry function)
First, let's define some common types that we're going to use while implementing the algorithm.
```typescript
// Type that describes the image size (width and height).
type ImageSize = { w: number, h: number };
// The coordinate of the pixel.
type Coordinate = { x: number, y: number };
// The seam is a sequence of pixels (coordinates).
type Seam = Coordinate[];
// Energy map is a 2D array that has the same width and height
// as the image the map is being calculated for.
type EnergyMap = number[][];
// Type that describes the image pixel's RGBA color.
type Color = [
r: number, // Red
g: number, // Green
b: number, // Blue
a: number, // Alpha (transparency)
] | Uint8ClampedArray;
```
On the high level the algorithm consists of the following steps:
1. Calculate the **energy map** for the current version of the image.
2. Find the **seam** with the lowest energy based on the energy map (this is where we will apply Dynamic Programming).
3. **Delete the seam** with the lowest energy seam from the image.
4. **Repeat** until the image width is reduced to the desired value.
```typescript
type ResizeImageWidthArgs = {
img: ImageData, // Image data we want to resize.
toWidth: number, // Final image width we want the image to shrink to.
};
type ResizeImageWidthResult = {
img: ImageData, // Resized image data.
size: ImageSize, // Resized image size (w x h).
};
// Performs the content-aware image width resizing using the seam carving method.
export const resizeImageWidth = (
{ img, toWidth }: ResizeImageWidthArgs,
): ResizeImageWidthResult => {
// For performance reasons we want to avoid changing the img data array size.
// Instead we'll just keep the record of the resized image width and height separately.
const size: ImageSize = { w: img.width, h: img.height };
// Calculating the number of pixels to remove.
const pxToRemove = img.width - toWidth;
if (pxToRemove < 0) {
throw new Error('Upsizing is not supported for now');
}
let energyMap: EnergyMap | null = null;
let seam: Seam | null = null;
// Removing the lowest energy seams one by one.
for (let i = 0; i < pxToRemove; i += 1) {
// 1. Calculate the energy map for the current version of the image.
energyMap = calculateEnergyMap(img, size);
// 2. Find the seam with the lowest energy based on the energy map.
seam = findLowEnergySeam(energyMap, size);
// 3. Delete the seam with the lowest energy seam from the image.
deleteSeam(img, seam, size);
// Reduce the image width, and continue iterations.
size.w -= 1;
}
// Returning the resized image and its final size.
// The img is actually a reference to the ImageData, so technically
// the caller of the function already has this pointer. But let's
// still return it for better code readability.
return { img, size };
};
```
The image that needs to be resized is being passed to the function in [ImageData](https://developer.mozilla.org/en-US/docs/Web/API/ImageData) format. You may draw the image on the canvas and then extract the ImageData from the canvas like this:
```javascript
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, imgWidth, imgHeight);
```
> The way of uploading and drawing images in JavaScript is out of scope for this article, but you may find the complete source code of how it may be done using React in [js-image-carver](https://github.com/trekhleb/js-image-carver) repo.
Let's break down each step ony be one and implement the `calculateEnergyMap()`, `findLowEnergySeam()` and `deleteSeam()` functions.
### Calculating the pixel's energy
Here we apply the color difference formula described above. For the left and right borders (when there are no left or right neighbors), we ignore the neighbors and don't take them into account during the energy calculation.
```typescript
// Calculates the energy of a pixel.
const getPixelEnergy = (left: Color | null, middle: Color, right: Color | null): number => {
// Middle pixel is the pixel we're calculating the energy for.
const [mR, mG, mB] = middle;
// Energy from the left pixel (if it exists).
let lEnergy = 0;
if (left) {
const [lR, lG, lB] = left;
lEnergy = (lR - mR) ** 2 + (lG - mG) ** 2 + (lB - mB) ** 2;
}
// Energy from the right pixel (if it exists).
let rEnergy = 0;
if (right) {
const [rR, rG, rB] = right;
rEnergy = (rR - mR) ** 2 + (rG - mG) ** 2 + (rB - mB) ** 2;
}
// Resulting pixel energy.
return Math.sqrt(lEnergy + rEnergy);
};
```
### Calculating the energy map
The image we're working with has the [ImageData](https://developer.mozilla.org/en-US/docs/Web/API/ImageData) format. It means that all the pixels (and their colors) are stored in a flat (*1D*) [Uint8ClampedArray](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8ClampedArray) array. For readability purposes let's introduce the couple of helper functions that will allow us to work with the Uint8ClampedArray array as with a *2D* matrix instead.
```typescript
// Helper function that returns the color of the pixel.
const getPixel = (img: ImageData, { x, y }: Coordinate): Color => {
// The ImageData data array is a flat 1D array.
// Thus we need to convert x and y coordinates to the linear index.
const i = y * img.width + x;
const cellsPerColor = 4; // RGBA
// For better efficiency, instead of creating a new sub-array we return
// a pointer to the part of the ImageData array.
return img.data.subarray(i * cellsPerColor, i * cellsPerColor + cellsPerColor);
};
// Helper function that sets the color of the pixel.
const setPixel = (img: ImageData, { x, y }: Coordinate, color: Color): void => {
// The ImageData data array is a flat 1D array.
// Thus we need to convert x and y coordinates to the linear index.
const i = y * img.width + x;
const cellsPerColor = 4; // RGBA
img.data.set(color, i * cellsPerColor);
};
```
To calculate the energy map we go through each image pixel and call the previously described `getPixelEnergy()` function against it.
```typescript
// Helper function that creates a matrix (2D array) of specific
// size (w x h) and fills it with specified value.
const matrix = <T>(w: number, h: number, filler: T): T[][] => {
return new Array(h)
.fill(null)
.map(() => {
return new Array(w).fill(filler);
});
};
// Calculates the energy of each pixel of the image.
const calculateEnergyMap = (img: ImageData, { w, h }: ImageSize): EnergyMap => {
// Create an empty energy map where each pixel has infinitely high energy.
// We will update the energy of each pixel.
const energyMap: number[][] = matrix<number>(w, h, Infinity);
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
// Left pixel might not exist if we're on the very left edge of the image.
const left = (x - 1) >= 0 ? getPixel(img, { x: x - 1, y }) : null;
// The color of the middle pixel that we're calculating the energy for.
const middle = getPixel(img, { x, y });
// Right pixel might not exist if we're on the very right edge of the image.
const right = (x + 1) < w ? getPixel(img, { x: x + 1, y }) : null;
energyMap[y][x] = getPixelEnergy(left, middle, right);
}
}
return energyMap;
};
```
> The energy map is going to be recalculated on every resize iteration. It means that it will be recalculated, let's say, 500 times if we need to shrink the image by 500 pixels which is not optimal. To speed up the energy map calculation on the 2nd, 3rd, and further steps, we may re-calculate the energy only for those pixels that are placed around the seam that is going to be removed. For simplicity reasons this optimization is omitted here, but you may find the example source-code in [js-image-carver](https://github.com/trekhleb/js-image-carver) repo.
### Finding the seam with the lowest energy (Dynamic Programming approach)
> I've described some Dynamic Programming basics in [Dynamic Programming vs Divide-and-Conquer](https://trekhleb.dev/blog/2018/dynamic-programming-vs-divide-and-conquer/) article before. There is a DP example based on the minimum edit distance problem. You might want to check it out to get some more context.
The issue we need to solve now is to find the path (the seam) on the energy map that goes from top to bottom and has the minimum sum of pixel energies.
#### The naive approach
The naive approach would be to check all possible paths one after another.
![The naive approach](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/50-naive-approach.png)
Going from top to bottom, for each pixel, we have 3 options (↙︎ go down-left, ↓ go down, ↘︎ go down-right). This gives us the time complexity of `O(w * 3^h)` or simply `O(3^h)`, where `w` and `h` are the width and the height of the image. This approach looks slow.
#### The greedy approach
We may also try to choose the next pixel as a pixel with the lowest energy, hoping that the resulting seam energy will be the smallest one.
![The greedy approach](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/51-greedy-approach.png)
This approach gives not the worst solution, but it cannot guarantee that we will find the best available solution. On the image above you may see how the greedy approach chose `5` instead of `10` at first and missed the chain of optimal pixels.
The good part about this approach is that it is fast, and it has a time complexity of `O(w + h)`, where `w` and `h` are the width and the height of the image. In this case, the cost of the speed is the low quality of resizing. We need to find a minimum value in the first row (traversing `w` cells) and then we explore only 3 neighbor pixels for each row (traversing `h` rows).
#### The dynamic programming approach
You might have noticed that in the naive approach we summed up the same pixel energies over and over again while calculating the resulting seams' energy.
![Repeated problems](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/52-dp-repeated-problems.png)
In the example above you see that for the first two seams we are re-using the energy of the shorter seam (which has the energy of `235`). Instead of doing just one operation `235 + 70` to calculate the energy of the 2nd seam we're doing four operations `(5 + 0 + 80 + 150) + 70`.
> This fact that we're re-using the energy of the previous seam to calculate the current seam's energy might be applied recursively to all the shorter seams up to the very top 1st row seam. When we have such overlapping sub-problems, [it is a sign](https://trekhleb.dev/blog/2018/dynamic-programming-vs-divide-and-conquer/) that the general problem *might* be optimized by dynamic programming approach.
So, we may **save the energy of the current seam** at the particular pixel in an additional `seamsEnergies` table to make it re-usable for calculating the next seams faster (the `seamsEnergies` table will have the same size as the energy map and the image itself).
Let's also keep in mind that for one particular pixel on the image (i.e. the bottom left one) we may have *several* values of the previous seams energies.
![What seam to choose](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/53-dp-what-to-choose.png)
Since we're looking for a seam with the lowest resulting energy it would make sense to pick the previous seam with the lowest resulting energy as well.
![Seams energies example](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/56-dp-seams-energies-example.png)
In general, we have three possible previous seems to choose from:
![Three options to choose from](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/55-dp-three-options.png)
You may think about it this way:
- The cell `[1][x]`: contains the lowest possible energy of the seam that starts somewhere on the row `[0][?]` and ends up at cell `[1][x]`
- **The current cell** `[2][3]`: contains the lowest possible energy of the seam that starts somewhere on the row `[0][?]` and ends up at cell `[2][3]`. To calculate it we need to sum up the energy of the current pixel `[2][3]` (from the energy map) with the `min(seam_energy_1_2, seam_energy_1_3, seam_energy_1_4)`
If we fill the `seamsEnergies` table completely, then the minimum number in the lowest row would be the lowest possible seam energy.
Let's try to fill several cells of this table to see how it works.
![Seams energies map traversal](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/57-dp-seams-energies-traversal.png)
After filling out the `seamsEnergies` table we may see that the lowest energy pixel has an energy of `50`. For convenience, during the `seamsEnergies` generation for each pixel, we may save not only the energy of the seam but also the coordinates of the previous lowest energy seam. This will give us the possibility to reconstruct the seam path from the bottom to the top easily.
The time complexity of DP approach would be `O(w * h)`, where `w` and `h` are the width and the height of the image. We need to calculate energies for *every* pixel of the image.
Here is an example of how this logic might be implemented:
```typescript
// The metadata for the pixels in the seam.
type SeamPixelMeta = {
energy: number, // The energy of the pixel.
coordinate: Coordinate, // The coordinate of the pixel.
previous: Coordinate | null, // The previous pixel in a seam.
};
// Finds the seam (the sequence of pixels from top to bottom) that has the
// lowest resulting energy using the Dynamic Programming approach.
const findLowEnergySeam = (energyMap: EnergyMap, { w, h }: ImageSize): Seam => {
// The 2D array of the size of w and h, where each pixel contains the
// seam metadata (pixel energy, pixel coordinate and previous pixel from
// the lowest energy seam at this point).
const seamsEnergies: (SeamPixelMeta | null)[][] = matrix<SeamPixelMeta | null>(w, h, null);
// Populate the first row of the map by just copying the energies
// from the energy map.
for (let x = 0; x < w; x += 1) {
const y = 0;
seamsEnergies[y][x] = {
energy: energyMap[y][x],
coordinate: { x, y },
previous: null,
};
}
// Populate the rest of the rows.
for (let y = 1; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
// Find the top adjacent cell with minimum energy.
// This cell would be the tail of a seam with lowest energy at this point.
// It doesn't mean that this seam (path) has lowest energy globally.
// Instead, it means that we found a path with the lowest energy that may lead
// us to the current pixel with the coordinates x and y.
let minPrevEnergy = Infinity;
let minPrevX: number = x;
for (let i = (x - 1); i <= (x + 1); i += 1) {
if (i >= 0 && i < w && seamsEnergies[y - 1][i].energy < minPrevEnergy) {
minPrevEnergy = seamsEnergies[y - 1][i].energy;
minPrevX = i;
}
}
// Update the current cell.
seamsEnergies[y][x] = {
energy: minPrevEnergy + energyMap[y][x],
coordinate: { x, y },
previous: { x: minPrevX, y: y - 1 },
};
}
}
// Find where the minimum energy seam ends.
// We need to find the tail of the lowest energy seam to start
// traversing it from its tail to its head (from the bottom to the top).
let lastMinCoordinate: Coordinate | null = null;
let minSeamEnergy = Infinity;
for (let x = 0; x < w; x += 1) {
const y = h - 1;
if (seamsEnergies[y][x].energy < minSeamEnergy) {
minSeamEnergy = seamsEnergies[y][x].energy;
lastMinCoordinate = { x, y };
}
}
// Find the lowest energy energy seam.
// Once we know where the tail is we may traverse and assemble the lowest
// energy seam based on the "previous" value of the seam pixel metadata.
const seam: Seam = [];
if (!lastMinCoordinate) {
return seam;
}
const { x: lastMinX, y: lastMinY } = lastMinCoordinate;
// Adding new pixel to the seam path one by one until we reach the top.
let currentSeam = seamsEnergies[lastMinY][lastMinX];
while (currentSeam) {
seam.push(currentSeam.coordinate);
const prevMinCoordinates = currentSeam.previous;
if (!prevMinCoordinates) {
currentSeam = null;
} else {
const { x: prevMinX, y: prevMinY } = prevMinCoordinates;
currentSeam = seamsEnergies[prevMinY][prevMinX];
}
}
return seam;
};
```
### Removing the seam with the lowest energy
Once we found the lowest energy seam, we need to remove (to carve) the pixels that form it from the image. The removal is happening by shifting the pixels to the right of the seam by `1px` to the left. For performance reasons, we don't actually delete the last columns. Instead, the rendering component will just ignore the part of the image that lays beyond the resized image width.
![Deleting the seam](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/60-deleting-example.png)
```typescript
// Deletes the seam from the image data.
// We delete the pixel in each row and then shift the rest of the row pixels to the left.
const deleteSeam = (img: ImageData, seam: Seam, { w }: ImageSize): void => {
seam.forEach(({ x: seamX, y: seamY }: Coordinate) => {
for (let x = seamX; x < (w - 1); x += 1) {
const nextPixel = getPixel(img, { x: x + 1, y: seamY });
setPixel(img, { x, y: seamY }, nextPixel);
}
});
};
```
## Objects removal
The Seam Carving algorithm tries to remove the seams which consist of low energy pixels first. We could leverage this fact and by assigning low energy to some pixels manually (i.e. by drawing on the image and masking out some areas of it) we could make the Seam Carving algorithm to do *objects removal* for us for free.
Currently, in `getPixelEnergy()` function we were using only the `R`, `G`, `B` color channels to calculate the pixel's energy. But there is also the `A` (alpha, transparency) parameter of the color that we didn't use yet. We may use the transparency channel to tell the algorithm that transparent pixels are the pixels we want to remove. You may check the [source-code of the energy function](https://github.com/trekhleb/js-image-carver/blob/main/src/utils/contentAwareResizer.ts#L54) that takes transparency into account.
Here is how the algorithm works for object removal.
![JS IMAGE CARVER OBJECT REMOVAL DEMO](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/10-demo-02.gif)
## Issues and what's next
The [JS IMAGE CARVER](https://github.com/trekhleb/js-image-carver) web app is far from being a production ready resizer of course. Its main purpose was to experiment with the Seam Carving algorithm interactively. So the plan for the future is to continue experimentation.
The [original paper](https://perso.crans.org/frenoy/matlab2012/seamcarving.pdf) describes how the Seam Carving algorithm might be used not only for the downscaling but also for the **upscaling of the images**. The upscaling, in turn, might be used to **upscale the image back to its original width after the objects' removal**.
Another interesting area of experimentation might be to make the algorithm work in a **real-time**.
> Those are the plans for the future, but for now, I hope that the example with image downsizing was interesting and useful for you. I also hope that you've got the idea of using dynamic programming to implement it.
>
> So, good luck with your own experiments!

View File

@ -0,0 +1,509 @@
# Изменение размеров изображения с учетом его содержимого в JavaScript
![Content-aware image resizing in JavaScript](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/01-cover-02.png)
> Доступна [английская интерактивная версия этой статьи](https://trekhleb.dev/blog/2021/content-aware-image-resizing-in-javascript/) в которой вы можете загрузить свои собственные изображения и посмотреть, как алгоритм "справляется" с ними.
## TL;DR
Написано много замечательных статей об алгоритме *Seam Carving* ("Вырезание швов"), но я не смог устоять перед соблазном самостоятельно исследовать этот элегантный, мощный и в то же время простой алгоритм и написать о своем личном опыте работы с ним. Мое внимание также привлек тот факт, что для его имплементации мы можем применить *динамическое программирование (DP)*. И, если вы, как и я, все еще находитесь на пути изучения алгоритмов, то это решение может обогатить ваш личный арсенал DP.
Итак, в этой статье я хочу сделать три вещи:
1. Предоставить вам возможность "поиграться" с алгоритмом самостоятельно при помощи **интерактивного ресайзера**.
2. Объяснить **идею алгоритма Seam Carving**.
3. Объяснить как можно **применить динамическое программирование** для имплементации алгоритма (мы будем писать на TypeScript).
### Изменение размеров изображений с учетом их содержимого
*Изменение размера изображения с учетом содержимого* (content-aware image resizing) может быть применено, когда дело доходит до изменения пропорций изображения (например, уменьшения ширины при сохранении высоты), а также когда потеря некоторых частей изображения нежелательна. Простое масштабирование изображения в этом случае исказит находящиеся в нем объекты. Для сохранения пропорций объектов при изменении пропорций изображения можно использовать [алгоритм Seam Carving](https://perso.crans.org/frenoy/matlab2012/seamcarving.pdf), который был описан *Shai Avidan* и *Ariel Shamir*.
В приведенном ниже примере показано, как ширина исходного изображения была уменьшена на 50% *с учетом содержимого изображения* (слева) и *без учета содержимого изображения* (справа, простой скейлинг). В данном случае левое изображение выглядит более естественным, так как пропорции воздушных шаров в нем были сохранены.
![Content-aware image resizing](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/01-resizing-options.png)
Идея алгоритма Seam Carving заключается в том, чтобы найти *шов* (seam, непрерывную последовательность пикселей) с наименьшим влиянием на содержание изображения, а затем его *вырезать* (carve). Этот процесс повторяется снова и снова, пока мы не получим требуемую ширину или высоту изображения. В примере ниже интуитивно видно, что пиксели воздушных шаров вносят больший "вклад" в содержание и смысл изображения, чем пиксели неба. Таким образом, сначала удаляются пиксели неба.
![JS IMAGE CARVER DEMO](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/10-demo-01.gif)
Поиск шва с наименьшей энергией (с наименьшим вкладом в содержимое изображения) является вычислительно дорогостоящей операцией (особенно для больших изображений). Для ускорения поиска шва может быть применено *динамическое программирование* (мы рассмотрим детали реализации ниже).
### Удаление объектов
Важность каждого пикселя (так называемая энергия пикселя) вычисляется исходя из его цветовой разницы (`R`, `G`, `B`, `A`) между двумя соседними пикселями. Если же мы вручную зададим некоторым пикселям низкий уровень энергии (например нарисовав маску поверх них), то алгоритм Seam Carving выполнит для нас **удаление помеченного объекта**, как говорится, "забесплатно".
![JS IMAGE CARVER OBJECT REMOVAL DEMO](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/10-demo-02.gif)
### Интерактивный ресзайзер
Для этой статьи я создал приложение [JS IMAGE CARVER](https://trekhleb.dev/js-image-carver/) (доступен также и [исходный код на GitHub](https://github.com/trekhleb/js-image-carver)), которым вы можете воспользоваться для ресайза своих изображений и увидеть в реальном времени, как работает алгоритм.
### Другие примеры ресайза
Вот еще несколько примеров того, как алгоритм справляется с более сложным фоном.
Горы на заднем плане плавно сжимаются, без видимых швов.
![Resizing demo with more complex backgrounds](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/11-demo-01.png)
То же самое и с океанскими волнами. Алгоритм сохранил волновую структуру, не искажая серферов.
![Resizing demo with more complex backgrounds](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/11-demo-02.png)
Нужно помнить, что алгоритм Seam Carving не является "волшебной таблеткой", и может не сохранить пропорции важных частей изображения в том случае, когда *большая часть пикселей выглядят как края, ребра или границы* (почти все пиксели выглядят одинаково важными с точки зрения алгоритма). В приведенном ниже примере изменение размера изображения с учетом содержимого похоже на простое масштабирование, т.к. для алгоритма все пиксели выглядят важными, и ему трудно отличить лицо Ван Гога от фона.
![Example when the algorithm does not work as expected](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/12-demo-01.png)
## Как работает алгоритм Seam Carving
Представим, что у нас есть картинка размером `1000 x 500 px`, и мы хотим уменьшить ее до `500 x 500 px` (допустим, квадратное изображение больше подходит для Instagram). В этом случае мы, возможно, захотим задать несколько **требований к процессу изменения размера**:
- *Важные части изображения должны быть сохранены* (если до ресайза на фото было 5 деревьев, то и после ресайза мы хотим увидеть все те же 5 деревьев).
- *Пропорции важных частей изображения должны быть сохранены* (круглые колеса автомобиля не должны стать овальными после ресайза).
Чтобы избежать изменения важных частей изображения можно найти **непрерывную последовательность пикселей (шов)**, которая будет идти от верхней границы к нижней и иметь *наименьший вклад в содержимое* изображения (шов, который не проходит через важные части изображения), а затем удалить его. Удаление шва сожмет изображение на один пиксель. Далее надо повторять этот шаг до тех пор, пока изображение не станет нужной ширины.
Вопрос в том, как определить *важность пикселя* и его вклад в содержание изображения (в оригинальной статье авторы используют термин **энергия пикселя**). Один из способов это сделать — рассматривать все пиксели, образующие края (границы, ребра), как важные. В случае, если пиксель является частью ребра, его цвет будет отличаться от соседей (левого и правого пикселей).
![Pixels color difference](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/30-pixel-energy-comparison.png)
Предполагая, что цвет пикселя представлен *4-мя* числами (`R` - красный, `G` - зеленый, `B` - синий, `A` - альфа, прозрачность), мы можем использовать следующую формулу для вычисления разницы в цвете (энергии пикселя):
![Pixel energy formula](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/20-energy-formula.png)
Где:
- `mEnergy` - *Энергия* (важность) *среднего* пикселя (`[0..626]` если округлить)
- `lR` - *Красный* цветовой канал *левого* пикселя (`[0..255]`)
- `mR` - *Красный* цветовой канал *среднего* пикселя (`[0..255]`)
- `rR` - *Красный* цветовой канал *правого* пикселя (`[0..255]`)
- `lG` - *Зеленый* цветовой канал *левого* пикселя (`[0..255]`)
- и так далее...
В приведенной выше формуле мы пока не используем альфа-канал (прозрачность), предполагая, что изображение не содержит прозрачные пиксели. Позже мы будем использовать альфа-канал для маскировки и удаления объектов.
![Example of pixel energy calculation](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/30-pixel-energy-calculation-example.png)
Поскольку мы знаем, как найти энергию одного пикселя, мы можем вычислить так называемую **энергетическую карту**, которая будет содержать энергии каждого пикселя изображения. На каждом шаге изменения размера изображения карту энергий необходимо пересчитывать (по крайней мере частично, подробнее об этом ниже), и она будет иметь тот же размер, что и изображение.
Например, на 1-м шаге у нас будет изображение размером `1000 x 500` и энергетическая карта размером `1000 x 500`. На 2-м шаге изменения размера мы удалим шов с изображения и пересчитаем карту энергий на основе нового уменьшенного изображения. Таким образом, мы получим изображение размером `999 x 500` и карту энергий размером `999 x 500`.
Чем выше энергия пикселя, тем больше вероятность того, что он является частью ребра, важен для содержимого изображения и тем меньше вероятность того, что нам потребуется его удалить.
Для визуализации карты энергий мы можем присвоить более яркий цвет пикселям с большей энергией и более темные цвета пикселям с меньшей энергией. Вот как может выглядеть часть карты энергий. Вы можете увидеть светлую линию, которая представляет край и которую мы хотим сохранить при изменении размера.
![Energy map sketch](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/30-energy-map-padding.png)
Вот реальный пример энергетической карты для изображения, которое вы видели выше (с воздушными шарами).
![Energy map example](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/40-energy-map.png)
Вы можете загрузить свое изображение и посмотреть, как будет выглядеть энергетическая карта в [интерактивной версии статьи](https://trekhleb.dev/blog/2021/content-aware-image-resizing-in-javascript/).
Мы можем использовать энергетическую карту, чтобы найти швы (один за другим) с наименьшей энергией и тем самым решить, какие пиксели в конечном итоге должны быть удалены.
![Searching the seam](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/41-seam-search.png)
Поиск шва с наименьшими затратами энергии не является тривиальной задачей и требует перебора множества возможных комбинаций пикселей. Мы применим динамическое программирование для оптимизации поиска шва.
В примере ниже вы можете увидеть карту энергий с первым найденным для нее швом с наименьшей энергией.
![Energy map example with seam](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/40-energy-map-with-seam.png)
В приведенных выше примерах мы уменьшали ширину изображения. Аналогичный подход может быть использован для уменьшения высоты изображения. Для этого нам нужно:
- начать использовать соседей *сверху* и *снизу*, а не *слева* и *справа*, для вычисления энергии пикселя
- при поиске шва нам нужно двигаться *слева* *направо*, а не *сверху* *вниз*.
## Реализация алгоритма на TypeScript
> Исходный код и функции, упомянутые ниже, можно найти в репозитории [js-image-carver](https://github.com/trekhleb/js-image-carver).
Для реализации алгоритма мы будем использовать TypeScript. Если вам нужна версия на JavaScript, вы можете игнорировать (удалить) определения типов и их использование.
Для простоты примеров мы напишем код только для уменьшения *ширины* изображения.
### Уменьшение ширины с учетом содержимого изображения (исходная функция)
Для начала определим некоторые общие типы, которые мы будем использовать при реализации алгоритма.
```typescript
// Type that describes the image size (width and height).
type ImageSize = { w: number, h: number };
// The coordinate of the pixel.
type Coordinate = { x: number, y: number };
// The seam is a sequence of pixels (coordinates).
type Seam = Coordinate[];
// Energy map is a 2D array that has the same width and height
// as the image the map is being calculated for.
type EnergyMap = number[][];
// Type that describes the image pixel's RGBA color.
type Color = [
r: number, // Red
g: number, // Green
b: number, // Blue
a: number, // Alpha (transparency)
] | Uint8ClampedArray;
```
Для имплементации алгоритма нам необходимо выполнить следующие шаги:
1. Рассчитать **карту энергии** для текущей версии изображения.
2. Найти **шов** с наименьшей энергией на основе карты энергий (здесь мы применим динамическое программирование).
3. **Удалить шов** с наименьшей энергией из изображения.
4. **Повторять** до тех пор, пока ширина изображения не будет уменьшена до нужного значения.
```typescript
type ResizeImageWidthArgs = {
img: ImageData, // Image data we want to resize.
toWidth: number, // Final image width we want the image to shrink to.
};
type ResizeImageWidthResult = {
img: ImageData, // Resized image data.
size: ImageSize, // Resized image size (w x h).
};
// Performs the content-aware image width resizing using the seam carving method.
export const resizeImageWidth = (
{ img, toWidth }: ResizeImageWidthArgs,
): ResizeImageWidthResult => {
// For performance reasons we want to avoid changing the img data array size.
// Instead we'll just keep the record of the resized image width and height separately.
const size: ImageSize = { w: img.width, h: img.height };
// Calculating the number of pixels to remove.
const pxToRemove = img.width - toWidth;
if (pxToRemove < 0) {
throw new Error('Upsizing is not supported for now');
}
let energyMap: EnergyMap | null = null;
let seam: Seam | null = null;
// Removing the lowest energy seams one by one.
for (let i = 0; i < pxToRemove; i += 1) {
// 1. Calculate the energy map for the current version of the image.
energyMap = calculateEnergyMap(img, size);
// 2. Find the seam with the lowest energy based on the energy map.
seam = findLowEnergySeam(energyMap, size);
// 3. Delete the seam with the lowest energy seam from the image.
deleteSeam(img, seam, size);
// Reduce the image width, and continue iterations.
size.w -= 1;
}
// Returning the resized image and its final size.
// The img is actually a reference to the ImageData, so technically
// the caller of the function already has this pointer. But let's
// still return it for better code readability.
return { img, size };
};
```
Изображение, которому необходимо изменить размер, передается в функцию в формате [ImageData](https://developer.mozilla.org/en-US/docs/Web/API/ImageData). Вы можете отобразить изображение на canvas-е, а затем извлечь ImageData из того же canvas-а следующим образом:
```javascript
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, imgWidth, imgHeight);
```
> Загрузка и отрисовка изображений в JavaScript выходит за рамки данной статьи, но вы можете найти полный исходный код того, как это можно сделать с помощью React в репозитории [js-image-carver](https://github.com/trekhleb/js-image-carver).
Теперь, пошагово реализуем функции `calculateEnergyMap()`, `findLowEnergySeam()` и `deleteSeam()`.
### Расчет энергии пикселя
Для расчета воспользуемся формулой разницы цветов, описанной выше. Для левой и правой краев изображения (когда нет левого или правого соседей) мы игнорируем соседей и не учитываем их при расчете энергии.
```typescript
// Calculates the energy of a pixel.
const getPixelEnergy = (left: Color | null, middle: Color, right: Color | null): number => {
// Middle pixel is the pixel we're calculating the energy for.
const [mR, mG, mB] = middle;
// Energy from the left pixel (if it exists).
let lEnergy = 0;
if (left) {
const [lR, lG, lB] = left;
lEnergy = (lR - mR) ** 2 + (lG - mG) ** 2 + (lB - mB) ** 2;
}
// Energy from the right pixel (if it exists).
let rEnergy = 0;
if (right) {
const [rR, rG, rB] = right;
rEnergy = (rR - mR) ** 2 + (rG - mG) ** 2 + (rB - mB) ** 2;
}
// Resulting pixel energy.
return Math.sqrt(lEnergy + rEnergy);
};
```
### Расчет энергетической карты
Изображение, с которым мы работаем, имеет формат [ImageData](https://developer.mozilla.org/en-US/docs/Web/API/ImageData). Это означает, что все пиксели (и их цвета) хранятся в одномерном массиве [Uint8ClampedArray](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8ClampedArray). Для удобства чтения введем пару вспомогательных функций, которые позволят работать с массивом Uint8ClampedArray как с *2D* матрицей.
```typescript
// Helper function that returns the color of the pixel.
const getPixel = (img: ImageData, { x, y }: Coordinate): Color => {
// The ImageData data array is a flat 1D array.
// Thus we need to convert x and y coordinates to the linear index.
const i = y * img.width + x;
const cellsPerColor = 4; // RGBA
// For better efficiency, instead of creating a new sub-array we return
// a pointer to the part of the ImageData array.
return img.data.subarray(i * cellsPerColor, i * cellsPerColor + cellsPerColor);
};
// Helper function that sets the color of the pixel.
const setPixel = (img: ImageData, { x, y }: Coordinate, color: Color): void => {
// The ImageData data array is a flat 1D array.
// Thus we need to convert x and y coordinates to the linear index.
const i = y * img.width + x;
const cellsPerColor = 4; // RGBA
img.data.set(color, i * cellsPerColor);
};
```
Для вычисления карты энергии мы проходим через каждый пиксель изображения и вызываем для него описанную ранее функцию `getPixelEnergy()`.
```typescript
// Helper function that creates a matrix (2D array) of specific
// size (w x h) and fills it with specified value.
const matrix = <T>(w: number, h: number, filler: T): T[][] => {
return new Array(h)
.fill(null)
.map(() => {
return new Array(w).fill(filler);
});
};
// Calculates the energy of each pixel of the image.
const calculateEnergyMap = (img: ImageData, { w, h }: ImageSize): EnergyMap => {
// Create an empty energy map where each pixel has infinitely high energy.
// We will update the energy of each pixel.
const energyMap: number[][] = matrix<number>(w, h, Infinity);
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
// Left pixel might not exist if we're on the very left edge of the image.
const left = (x - 1) >= 0 ? getPixel(img, { x: x - 1, y }) : null;
// The color of the middle pixel that we're calculating the energy for.
const middle = getPixel(img, { x, y });
// Right pixel might not exist if we're on the very right edge of the image.
const right = (x + 1) < w ? getPixel(img, { x: x + 1, y }) : null;
energyMap[y][x] = getPixelEnergy(left, middle, right);
}
}
return energyMap;
};
```
> Карта энергии будет пересчитываться при каждой итерации изменения размера. Это значит, что она будет пересчитываться, скажем, 500 раз, если нам нужно будет уменьшить изображение на 500 пикселей, что выглядит неоптимально. Чтобы ускорить вычисление карты энергии на 2-м, 3-м и последующих этапах, мы можем пересчитать энергию только для тех пикселей, которые расположены вокруг шва, который будет удален. Для простоты эта оптимизация здесь пропущена, но пример с исходным кодом можно найти в репозитории [js-image-carver](https://github.com/trekhleb/js-image-carver).
### Нахождение шва с минимальной энергией (применяем динамическое программирование)
> В статье [Dynamic Programming vs Divide-and-Conquer](https://trekhleb.dev/blog/2018/dynamic-programming-vs-divide-and-conquer/) я описывал некоторые аспекты динамического программирования на примере нахождения "расстояния Левенштейна" (преобразование одной строки в другую). Возможно она будет полезна для ознакомления.
Проблема, которую нам необходимо решить заключается в нахождении пути (шва) на энергетической карте, который идет от верхней границы изображения к нижней и имеет минимальную энергию (сумма энергий пикселей, составляющих шов должна быть минимальной).
#### "Наивный" подход (naive)
Прямолинейный ("наивный") подход — перебрать все возможные пути один за другим.
![The naive approach](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/50-naive-approach.png)
Двигаясь сверху вниз, для каждого пикселя у нас есть 3 варианта (↙︎ идти вниз-влево, ↓ вниз, ↘︎ идти вниз-вправо). Это дает нам временную сложность `O (w * 3 ^ h)` или просто `O (3 ^ h)`, где `w` и` h` - ширина и высота изображения. Такой подход выглядит неоптимальным.
#### "Жадный" подход (greedy)
Жадный подход — выбирать следующий пиксель как пиксель с наименьшей энергией, надеясь, что результирующая энергия шва будет наименьшей.
![The greedy approach](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/51-greedy-approach.png)
Жадный подход приведет нас к не самому худшему решению, но он не сможет гарантировать, что мы найдем наилучшее доступное решение. На картинке выше видно, как мы выбрали `5` вместо `10` и пропустили цепочку оптимальных пикселей.
Плюс этого подхода в том, что он быстрый и имеет временную сложность `O(w + h)`, где `w` и `h` - это ширина и высота изображения. В этом случае плата за скорость — низкое качество ресайза (много искажений). Временная сложность обусловлена тем, что нужно найти минимальное значение в первом ряду (обход `w` ячеек), а затем исследовать только 3 соседних пикселя для каждого ряда (обход `h` рядов).
#### Используем динамическое программирование
Вы, наверное, заметили, что в наивном подходе мы снова и снова суммировали одни и те же энергии пикселей, вычисляя энергию образовавшихся швов.
![Repeated problems](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/52-dp-repeated-problems.png)
В примере выше видно, что для первых двух швов мы повторно используем энергию более короткого шва (который имеет энергию `235`). Вместо одной операции `235 + 70` для вычисления энергии 2-го шва мы делаем четыре операции `(5 + 0 + 80 + 150) + 70`.
> Тот факт, что мы повторно используем энергию предыдущего шва для вычисления энергии текущего шва, может быть применен рекурсивно ко всем более коротким швам до самого верхнего 1-го ряда. Когда у нас есть такие перекрывающиеся под-проблемы, [это признак](https://trekhleb.dev/blog/2018/dynamic-programming-vs-divide-and-conquer/), что общая задача *может* быть оптимизирована с использованием динамического программирования.
Таким образом, мы можем **сохранить энергию текущего шва** для конкретного пикселя в дополнительной таблице `samsEnergies`, чтобы повторно использовать ее при расчете энергии следующих швов (таблица `samsEnergies` будет иметь тот же размер, что и энергетическая карта и само изображение).
Обратите также внимание, что для большинства пикселей в изображении (например, для левого нижнего) мы можем иметь *несколько* значений энергий предыдущих швов.
![What seam to choose](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/53-dp-what-to-choose.png)
Так как мы ищем шов с наименьшей результирующей энергией, имеет смысл выбирать и предыдущий шов с наименьшей результирующей энергией.
![Seams energies example](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/56-dp-seams-energies-example.png)
Как правило, у нас есть три возможных предыдущих шва, которые текущий пиксель продолжает:
![Three options to choose from](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/55-dp-three-options.png)
Можем посмотреть на это с такой стороны:
- Ячейка `[1][x]`: содержит наименьшую возможную энергию шва, который начинается где-то в ряду `[0][?] ` и заканчивается в ячейке `[1][x]`.
- **Текущая ячейка** `[2][3]`: содержит наименьшую возможную энергию шва, который начинается где-то в ряду `[0][?] ` и заканчивается в ячейке `[2][3]`. Для вычисления нужно суммировать энергию текущего пикселя `[2][3]` (из энергетической карты) с `min(seam_energy_1_2, seam_energy_1_3, seam_energy_1_4)`.
Если мы заполним таблицу `ShesamsEnergies` полностью, то минимальное число в нижнем ряду будет наименьшей возможной энергией шва.
Попробуем заполнить несколько ячеек этой таблицы, чтобы посмотреть, как это работает.
![Seams energies map traversal](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/57-dp-seams-energies-traversal.png)
После заполнения таблицы `ShesamsEnergies` видно, что в нижнем ряду пиксель с самой низкой энергией имеет значение `50`. Для удобства во время генерации `samsEnergies` для каждого пикселя мы можем сохранить не только энергию шва, но и координаты предыдущего шва с наименьшей энергией. Это даст нам возможность легко восстанавливать траекторию шва снизу вверх.
Временная сложность DP подхода составит `O(w * h)`, где `w` и `h` - это ширина и высота изображения. Обусловлена она тем, что нужно вычислить энергии для *всех* пикселей изображения.
Вот пример того, как эта логика может быть реализована:
```typescript
// The metadata for the pixels in the seam.
type SeamPixelMeta = {
energy: number, // The energy of the pixel.
coordinate: Coordinate, // The coordinate of the pixel.
previous: Coordinate | null, // The previous pixel in a seam.
};
// Finds the seam (the sequence of pixels from top to bottom) that has the
// lowest resulting energy using the Dynamic Programming approach.
const findLowEnergySeam = (energyMap: EnergyMap, { w, h }: ImageSize): Seam => {
// The 2D array of the size of w and h, where each pixel contains the
// seam metadata (pixel energy, pixel coordinate and previous pixel from
// the lowest energy seam at this point).
const seamsEnergies: (SeamPixelMeta | null)[][] = matrix<SeamPixelMeta | null>(w, h, null);
// Populate the first row of the map by just copying the energies
// from the energy map.
for (let x = 0; x < w; x += 1) {
const y = 0;
seamsEnergies[y][x] = {
energy: energyMap[y][x],
coordinate: { x, y },
previous: null,
};
}
// Populate the rest of the rows.
for (let y = 1; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
// Find the top adjacent cell with minimum energy.
// This cell would be the tail of a seam with lowest energy at this point.
// It doesn't mean that this seam (path) has lowest energy globally.
// Instead, it means that we found a path with the lowest energy that may lead
// us to the current pixel with the coordinates x and y.
let minPrevEnergy = Infinity;
let minPrevX: number = x;
for (let i = (x - 1); i <= (x + 1); i += 1) {
if (i >= 0 && i < w && seamsEnergies[y - 1][i].energy < minPrevEnergy) {
minPrevEnergy = seamsEnergies[y - 1][i].energy;
minPrevX = i;
}
}
// Update the current cell.
seamsEnergies[y][x] = {
energy: minPrevEnergy + energyMap[y][x],
coordinate: { x, y },
previous: { x: minPrevX, y: y - 1 },
};
}
}
// Find where the minimum energy seam ends.
// We need to find the tail of the lowest energy seam to start
// traversing it from its tail to its head (from the bottom to the top).
let lastMinCoordinate: Coordinate | null = null;
let minSeamEnergy = Infinity;
for (let x = 0; x < w; x += 1) {
const y = h - 1;
if (seamsEnergies[y][x].energy < minSeamEnergy) {
minSeamEnergy = seamsEnergies[y][x].energy;
lastMinCoordinate = { x, y };
}
}
// Find the lowest energy energy seam.
// Once we know where the tail is we may traverse and assemble the lowest
// energy seam based on the "previous" value of the seam pixel metadata.
const seam: Seam = [];
if (!lastMinCoordinate) {
return seam;
}
const { x: lastMinX, y: lastMinY } = lastMinCoordinate;
// Adding new pixel to the seam path one by one until we reach the top.
let currentSeam = seamsEnergies[lastMinY][lastMinX];
while (currentSeam) {
seam.push(currentSeam.coordinate);
const prevMinCoordinates = currentSeam.previous;
if (!prevMinCoordinates) {
currentSeam = null;
} else {
const { x: prevMinX, y: prevMinY } = prevMinCoordinates;
currentSeam = seamsEnergies[prevMinY][prevMinX];
}
}
return seam;
};
```
### Удаление шва с минимальной энергией
Как только мы нашли шов с наименьшей суммарной энергией, нам нужно удалить (вырезать) пиксели, которые образуют его из изображения. Удаление происходит путем смещения пикселей справа от шва на `1px` влево. Из соображений производительности мы не будем удалять крайний столбик пикселей. Вместо этого, компонент, отвечающий за отрисовку уменьшенного изображения просто проигнорирует ту часть изображения, которая лежит за пределами обрезанной ширины.
![Deleting the seam](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/60-deleting-example.png)
```typescript
// Deletes the seam from the image data.
// We delete the pixel in each row and then shift the rest of the row pixels to the left.
const deleteSeam = (img: ImageData, seam: Seam, { w }: ImageSize): void => {
seam.forEach(({ x: seamX, y: seamY }: Coordinate) => {
for (let x = seamX; x < (w - 1); x += 1) {
const nextPixel = getPixel(img, { x: x + 1, y: seamY });
setPixel(img, { x, y: seamY }, nextPixel);
}
});
};
```
## Удаление объектов с изображения
Seam Carving алгоритм пытается сначала удалить швы, состоящие из низкоэнергетических пикселей. Мы могли бы использовать этот факт и, присвоив низкую энергию некоторым пикселям вручную (например, нарисовав на изображении маску), мы могли бы заставить алгоритм удалить отмеченные пиксели (*объекты*).
В настоящее время в функции `getPixelEnergy()` мы используем только каналы цветов `R`, `G`, `B` для вычисления энергии пикселей. Но есть еще и параметр `A` (альфа, прозрачность), который мы не использовали. Мы можем использовать канал прозрачности, чтобы "сказать" алгоритму, что прозрачные пиксели — это те пиксели, которые мы хотим удалить. Вы можете ознакомиться с [исходным кодом функции getPixelEnergy()](https://github.com/trekhleb/js-image-carver/blob/main/src/utils/contentAwareResizer.ts#L54), которая учитывает прозрачность.
Вот как при этом будет выглядеть удаление объектов:
![JS IMAGE CARVER OBJECT REMOVAL DEMO](https://raw.githubusercontent.com/trekhleb/trekhleb.github.io/master/src/posts/2021/content-aware-image-resizing-in-javascript/assets/10-demo-02.gif)
## Проблемы алгоритма и дальнейшие планы
Приложение [JS IMAGE CARVER](https://github.com/trekhleb/js-image-carver) далеко от идеала и не является приложением production-ready качества. Основной его целью была возможность интерактивного экспериментирования с алгоритмом. Поэтому в дальнейших планах — использовать его именно для экспериментов.
В [оригинальной статье](https://perso.crans.org/frenoy/matlab2012/seamcarving.pdf) описывается, как алгоритм может быть использован не только для уменьшения, но и для **увеличения изображения**. Увеличение (расширение) изображения, в свою очередь, может быть использовано для **автоматического расширения изображения до его исходной ширины после удаления объектов**.
Еще одной интересной областью экспериментов может быть попытка ускорить алгоритм, чтобы он работал в режиме **реального времени**.
> Таковы планы на будущее, но пока, надеюсь, пример с уменьшением изображения был интересен и полезен для вас. Также надеюсь, что вам было интересно увидеть применение динамического программирования в задачах, приближенных к реальности.
>
> Удачи с вашими собственными экспериментами!

View File

@ -0,0 +1,51 @@
import { createCanvas, loadImage } from 'canvas';
import resizeImageWidth from '../resizeImageWidth';
const testImageBeforePath = './src/algorithms/image-processing/seam-carving/__tests__/test-image-before.jpg';
const testImageAfterPath = './src/algorithms/image-processing/seam-carving/__tests__/test-image-after.jpg';
describe('resizeImageWidth', () => {
it('should perform content-aware image width reduction', () => {
// @see: https://jestjs.io/docs/asynchronous
return Promise.all([
loadImage(testImageBeforePath),
loadImage(testImageAfterPath),
]).then(([imgBefore, imgAfter]) => {
// Original image.
const canvasBefore = createCanvas(imgBefore.width, imgBefore.height);
const ctxBefore = canvasBefore.getContext('2d');
ctxBefore.drawImage(imgBefore, 0, 0, imgBefore.width, imgBefore.height);
const imgDataBefore = ctxBefore.getImageData(0, 0, imgBefore.width, imgBefore.height);
// Resized image saved.
const canvasAfter = createCanvas(imgAfter.width, imgAfter.height);
const ctxAfter = canvasAfter.getContext('2d');
ctxAfter.drawImage(imgAfter, 0, 0, imgAfter.width, imgAfter.height);
const toWidth = Math.floor(imgBefore.width / 2);
const {
img: resizedImg,
size: resizedSize,
} = resizeImageWidth({ img: imgDataBefore, toWidth });
expect(resizedImg).toBeDefined();
expect(resizedSize).toBeDefined();
// Resized image generated.
const canvasTest = createCanvas(resizedSize.w, resizedSize.h);
const ctxTest = canvasTest.getContext('2d');
ctxTest.putImageData(resizedImg, 0, 0, 0, 0, resizedSize.w, resizedSize.h);
const imgDataTest = ctxTest.getImageData(0, 0, resizedSize.w, resizedSize.h);
expect(resizedSize).toEqual({ w: toWidth, h: imgBefore.height });
expect(imgDataTest.width).toBe(toWidth);
expect(imgDataTest.height).toBe(imgBefore.height);
expect(imgDataTest.width).toBe(imgAfter.width);
expect(imgDataTest.height).toBe(imgAfter.height);
// @TODO: Check that images are identical.
// expect(canvasTest.toDataURL()).toEqual(canvasAfter.toDataURL());
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,253 @@
import { getPixel, setPixel } from '../utils/imageData';
/**
* The seam is a sequence of pixels (coordinates).
* @typedef {PixelCoordinate[]} Seam
*/
/**
* Energy map is a 2D array that has the same width and height
* as the image the map is being calculated for.
* @typedef {number[][]} EnergyMap
*/
/**
* The metadata for the pixels in the seam.
* @typedef {Object} SeamPixelMeta
* @property {number} energy - the energy of the pixel.
* @property {PixelCoordinate} coordinate - the coordinate of the pixel.
* @property {?PixelCoordinate} previous - the previous pixel in a seam.
*/
/**
* Type that describes the image size (width and height)
* @typedef {Object} ImageSize
* @property {number} w - image width.
* @property {number} h - image height.
*/
/**
* @typedef {Object} ResizeImageWidthArgs
* @property {ImageData} img - image data we want to resize.
* @property {number} toWidth - final image width we want the image to shrink to.
*/
/**
* @typedef {Object} ResizeImageWidthResult
* @property {ImageData} img - resized image data.
* @property {ImageSize} size - resized image size.
*/
/**
* Helper function that creates a matrix (2D array) of specific
* size (w x h) and fills it with specified value.
* @param {number} w
* @param {number} h
* @param {?(number | SeamPixelMeta)} filler
* @returns {?(number | SeamPixelMeta)[][]}
*/
const matrix = (w, h, filler) => {
return new Array(h)
.fill(null)
.map(() => {
return new Array(w).fill(filler);
});
};
/**
* Calculates the energy of a pixel.
* @param {?PixelColor} left
* @param {PixelColor} middle
* @param {?PixelColor} right
* @returns {number}
*/
const getPixelEnergy = (left, middle, right) => {
// Middle pixel is the pixel we're calculating the energy for.
const [mR, mG, mB] = middle;
// Energy from the left pixel (if it exists).
let lEnergy = 0;
if (left) {
const [lR, lG, lB] = left;
lEnergy = (lR - mR) ** 2 + (lG - mG) ** 2 + (lB - mB) ** 2;
}
// Energy from the right pixel (if it exists).
let rEnergy = 0;
if (right) {
const [rR, rG, rB] = right;
rEnergy = (rR - mR) ** 2 + (rG - mG) ** 2 + (rB - mB) ** 2;
}
// Resulting pixel energy.
return Math.sqrt(lEnergy + rEnergy);
};
/**
* Calculates the energy of each pixel of the image.
* @param {ImageData} img
* @param {ImageSize} size
* @returns {EnergyMap}
*/
const calculateEnergyMap = (img, { w, h }) => {
// Create an empty energy map where each pixel has infinitely high energy.
// We will update the energy of each pixel.
const energyMap = matrix(w, h, Infinity);
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
// Left pixel might not exist if we're on the very left edge of the image.
const left = (x - 1) >= 0 ? getPixel(img, { x: x - 1, y }) : null;
// The color of the middle pixel that we're calculating the energy for.
const middle = getPixel(img, { x, y });
// Right pixel might not exist if we're on the very right edge of the image.
const right = (x + 1) < w ? getPixel(img, { x: x + 1, y }) : null;
energyMap[y][x] = getPixelEnergy(left, middle, right);
}
}
return energyMap;
};
/**
* Finds the seam (the sequence of pixels from top to bottom) that has the
* lowest resulting energy using the Dynamic Programming approach.
* @param {EnergyMap} energyMap
* @param {ImageSize} size
* @returns {Seam}
*/
const findLowEnergySeam = (energyMap, { w, h }) => {
// The 2D array of the size of w and h, where each pixel contains the
// seam metadata (pixel energy, pixel coordinate and previous pixel from
// the lowest energy seam at this point).
const seamPixelsMap = matrix(w, h, null);
// Populate the first row of the map by just copying the energies
// from the energy map.
for (let x = 0; x < w; x += 1) {
const y = 0;
seamPixelsMap[y][x] = {
energy: energyMap[y][x],
coordinate: { x, y },
previous: null,
};
}
// Populate the rest of the rows.
for (let y = 1; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
// Find the top adjacent cell with minimum energy.
// This cell would be the tail of a seam with lowest energy at this point.
// It doesn't mean that this seam (path) has lowest energy globally.
// Instead, it means that we found a path with the lowest energy that may lead
// us to the current pixel with the coordinates x and y.
let minPrevEnergy = Infinity;
let minPrevX = x;
for (let i = (x - 1); i <= (x + 1); i += 1) {
if (i >= 0 && i < w && seamPixelsMap[y - 1][i].energy < minPrevEnergy) {
minPrevEnergy = seamPixelsMap[y - 1][i].energy;
minPrevX = i;
}
}
// Update the current cell.
seamPixelsMap[y][x] = {
energy: minPrevEnergy + energyMap[y][x],
coordinate: { x, y },
previous: { x: minPrevX, y: y - 1 },
};
}
}
// Find where the minimum energy seam ends.
// We need to find the tail of the lowest energy seam to start
// traversing it from its tail to its head (from the bottom to the top).
let lastMinCoordinate = null;
let minSeamEnergy = Infinity;
for (let x = 0; x < w; x += 1) {
const y = h - 1;
if (seamPixelsMap[y][x].energy < minSeamEnergy) {
minSeamEnergy = seamPixelsMap[y][x].energy;
lastMinCoordinate = { x, y };
}
}
// Find the lowest energy energy seam.
// Once we know where the tail is we may traverse and assemble the lowest
// energy seam based on the "previous" value of the seam pixel metadata.
const seam = [];
const { x: lastMinX, y: lastMinY } = lastMinCoordinate;
// Adding new pixel to the seam path one by one until we reach the top.
let currentSeam = seamPixelsMap[lastMinY][lastMinX];
while (currentSeam) {
seam.push(currentSeam.coordinate);
const prevMinCoordinates = currentSeam.previous;
if (!prevMinCoordinates) {
currentSeam = null;
} else {
const { x: prevMinX, y: prevMinY } = prevMinCoordinates;
currentSeam = seamPixelsMap[prevMinY][prevMinX];
}
}
return seam;
};
/**
* Deletes the seam from the image data.
* We delete the pixel in each row and then shift the rest of the row pixels to the left.
* @param {ImageData} img
* @param {Seam} seam
* @param {ImageSize} size
*/
const deleteSeam = (img, seam, { w }) => {
seam.forEach(({ x: seamX, y: seamY }) => {
for (let x = seamX; x < (w - 1); x += 1) {
const nextPixel = getPixel(img, { x: x + 1, y: seamY });
setPixel(img, { x, y: seamY }, nextPixel);
}
});
};
/**
* Performs the content-aware image width resizing using the seam carving method.
* @param {ResizeImageWidthArgs} args
* @returns {ResizeImageWidthResult}
*/
const resizeImageWidth = ({ img, toWidth }) => {
/**
* For performance reasons we want to avoid changing the img data array size.
* Instead we'll just keep the record of the resized image width and height separately.
* @type {ImageSize}
*/
const size = { w: img.width, h: img.height };
// Calculating the number of pixels to remove.
const pxToRemove = img.width - toWidth;
let energyMap = null;
let seam = null;
// Removing the lowest energy seams one by one.
for (let i = 0; i < pxToRemove; i += 1) {
// 1. Calculate the energy map for the current version of the image.
energyMap = calculateEnergyMap(img, size);
// 2. Find the seam with the lowest energy based on the energy map.
seam = findLowEnergySeam(energyMap, size);
// 3. Delete the seam with the lowest energy seam from the image.
deleteSeam(img, seam, size);
// Reduce the image width, and continue iterations.
size.w -= 1;
}
// Returning the resized image and its final size.
// The img is actually a reference to the ImageData, so technically
// the caller of the function already has this pointer. But let's
// still return it for better code readability.
return { img, size };
};
export default resizeImageWidth;

View File

@ -0,0 +1,39 @@
/**
* @typedef {ArrayLike<number> | Uint8ClampedArray} PixelColor
*/
/**
* @typedef {Object} PixelCoordinate
* @property {number} x - horizontal coordinate.
* @property {number} y - vertical coordinate.
*/
/**
* Helper function that returns the color of the pixel.
* @param {ImageData} img
* @param {PixelCoordinate} coordinate
* @returns {PixelColor}
*/
export const getPixel = (img, { x, y }) => {
// The ImageData data array is a flat 1D array.
// Thus we need to convert x and y coordinates to the linear index.
const i = y * img.width + x;
const cellsPerColor = 4; // RGBA
// For better efficiency, instead of creating a new sub-array we return
// a pointer to the part of the ImageData array.
return img.data.subarray(i * cellsPerColor, i * cellsPerColor + cellsPerColor);
};
/**
* Helper function that sets the color of the pixel.
* @param {ImageData} img
* @param {PixelCoordinate} coordinate
* @param {PixelColor} color
*/
export const setPixel = (img, { x, y }, color) => {
// The ImageData data array is a flat 1D array.
// Thus we need to convert x and y coordinates to the linear index.
const i = y * img.width + x;
const cellsPerColor = 4; // RGBA
img.data.set(color, i * cellsPerColor);
};