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

Commit f81e45e

Browse files
feat: add bmp encoding (#462)
close: #460
1 parent ffca331 commit f81e45e

File tree

11 files changed

+141
-14
lines changed

11 files changed

+141
-14
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"dependencies": {
4848
"bresenham-zingl": "^0.1.1",
4949
"colord": "^2.9.3",
50+
"fast-bmp": "^2.0.1",
5051
"fast-jpeg": "^2.0.1",
5152
"fast-png": "^6.2.0",
5253
"image-type": "^4.1.0",

src/save/__tests__/encodeBmp.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { encodeBmp } from '../encodeBmp';
2+
3+
test('encode 5x5 mask', () => {
4+
const image = testUtils.createGreyImage([
5+
[0, 0, 0, 0, 0],
6+
[0, 255, 255, 255, 0],
7+
[0, 255, 0, 255, 0],
8+
[0, 255, 255, 255, 0],
9+
[255, 0, 255, 0, 255],
10+
]);
11+
const mask = image.threshold({ threshold: 0.5 });
12+
const result = testUtils.loadBuffer('formats/bmp/5x5.bmp');
13+
const buffer = encodeBmp(mask).buffer;
14+
expect(buffer).toEqual(result.buffer);
15+
});
16+
test('encode 6x4 mask', () => {
17+
const image = testUtils.createGreyImage([
18+
[255, 255, 255, 255, 255, 255],
19+
[0, 0, 0, 0, 0, 0],
20+
[255, 255, 255, 255, 255, 255],
21+
[0, 0, 0, 0, 0, 0],
22+
]);
23+
const mask = image.threshold({ threshold: 0.5 });
24+
const result = testUtils.loadBuffer('formats/bmp/6x4.bmp');
25+
const buffer = encodeBmp(mask).buffer;
26+
expect(buffer).toEqual(result.buffer);
27+
});
28+
test('encode 10x2 mask', () => {
29+
const image = testUtils.createGreyImage([
30+
[255, 255, 255, 0, 0, 255, 0, 255, 0, 255],
31+
[255, 0, 255, 0, 255, 0, 0, 255, 255, 255],
32+
]);
33+
const mask = image.threshold({ threshold: 0.5 });
34+
const result = testUtils.loadBuffer('formats/bmp/10x2.bmp');
35+
const buffer = encodeBmp(mask).buffer;
36+
expect(buffer).toEqual(result.buffer);
37+
});

src/save/encode.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { Image } from '../Image';
2+
import { Mask } from '../Mask';
23

4+
import { encodeBmp } from './encodeBmp';
35
import { encodeJpeg, EncodeJpegOptions } from './encodeJpeg';
46
import { encodePng, EncodePngOptions } from './encodePng';
57

68
export const ImageFormat = {
79
PNG: 'png',
810
JPG: 'jpg',
911
JPEG: 'jpeg',
12+
BMP: 'bmp',
1013
} as const;
1114
// eslint-disable-next-line @typescript-eslint/no-redeclare
1215
export type ImageFormat = (typeof ImageFormat)[keyof typeof ImageFormat];
@@ -19,7 +22,9 @@ export interface EncodeOptionsJpeg {
1922
format: 'jpg' | 'jpeg';
2023
encoderOptions?: EncodeJpegOptions;
2124
}
22-
25+
export interface EncodeOptionsBmp {
26+
format: 'bmp';
27+
}
2328
const defaultPng: EncodeOptionsPng = { format: 'png' };
2429

2530
/**
@@ -29,13 +34,15 @@ const defaultPng: EncodeOptionsPng = { format: 'png' };
2934
* @returns The encoded image.
3035
*/
3136
export function encode(
32-
image: Image,
33-
options: EncodeOptionsPng | EncodeOptionsJpeg = defaultPng,
37+
image: Image | Mask,
38+
options: EncodeOptionsBmp | EncodeOptionsPng | EncodeOptionsJpeg = defaultPng,
3439
): Uint8Array {
3540
if (options.format === 'png') {
3641
return encodePng(image, options.encoderOptions);
3742
} else if (options.format === 'jpg' || options.format === 'jpeg') {
3843
return encodeJpeg(image, options.encoderOptions);
44+
} else if (options.format === 'bmp') {
45+
return encodeBmp(image);
3946
} else {
4047
throw new RangeError(`invalid format: ${options.format}`);
4148
}

src/save/encodeBmp.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//@ts-expect-error ts package not ready yet
2+
import * as bmp from 'fast-bmp';
3+
4+
import { Image } from '../Image';
5+
import { Mask } from '../Mask';
6+
7+
/**
8+
* Creates a BMP buffer from a mask.
9+
* @param mask - The mask instance.
10+
* @returns The buffer.
11+
*/
12+
export function encodeBmp(mask: Mask | Image) {
13+
if (!(mask instanceof Mask)) {
14+
throw new TypeError('Image bmp encoding is not implemented.');
15+
}
16+
const compressedBitMask = new Uint8Array(Math.ceil(mask.size / 8));
17+
let destIndex = 0;
18+
for (let index = 0; index < mask.size; index++) {
19+
if (index % 8 === 0 && index !== 0) {
20+
destIndex++;
21+
}
22+
if (destIndex !== compressedBitMask.length - 1) {
23+
compressedBitMask[destIndex] <<= 1;
24+
compressedBitMask[destIndex] |= mask.getBitByIndex(index);
25+
} else {
26+
compressedBitMask[destIndex] |= mask.getBitByIndex(index);
27+
compressedBitMask[destIndex] <<= 7 - (index % 8);
28+
}
29+
}
30+
31+
return bmp.encode({
32+
width: mask.width,
33+
height: mask.height,
34+
components: 1,
35+
bitDepth: 1,
36+
channels: 1,
37+
data: compressedBitMask,
38+
});
39+
}

src/save/encodeJpeg.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { encode } from 'jpeg-js';
22

33
import { Image } from '../Image';
4+
import { Mask } from '../Mask';
45

56
export interface EncodeJpegOptions {
67
/**
@@ -17,11 +18,13 @@ export interface EncodeJpegOptions {
1718
* @returns The buffer.
1819
*/
1920
export function encodeJpeg(
20-
image: Image,
21+
image: Image | Mask,
2122
options: EncodeJpegOptions = {},
2223
): Uint8Array {
2324
const { quality = 50 } = options;
24-
25+
if (!(image instanceof Image)) {
26+
throw new TypeError('Mask JPG/JPEG encoding is not supported.');
27+
}
2528
if (image.colorModel !== 'RGBA') {
2629
image = image.convertColor('RGBA');
2730
}

src/save/encodePng.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { encode, PngEncoderOptions } from 'fast-png';
22

33
import { Image } from '../Image';
4+
import { Mask } from '../Mask';
45

56
export type EncodePngOptions = PngEncoderOptions;
67

@@ -11,9 +12,23 @@ export type EncodePngOptions = PngEncoderOptions;
1112
* @returns The buffer.
1213
*/
1314
export function encodePng(
14-
image: Image,
15+
image: Image | Mask,
1516
options?: EncodePngOptions,
1617
): Uint8Array {
18+
if (!(image instanceof Image)) {
19+
throw new TypeError('Mask PNG encoding is not supported.');
20+
}
21+
if (image.bitDepth !== 8 && image.bitDepth !== 16) {
22+
image = image.convertBitDepth(8);
23+
}
24+
if (
25+
image.colorModel !== 'RGB' &&
26+
image.colorModel !== 'RGBA' &&
27+
image.colorModel !== 'GREY' &&
28+
image.colorModel !== 'GREYA'
29+
) {
30+
image = image.convertColor('GREY');
31+
}
1732
const { bitDepth: depth, ...other } = image.getRawImage();
1833
return encode(
1934
{

src/save/write.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import url from 'node:url';
44

55
import { Image, Mask } from '..';
66

7-
import { encode, EncodeOptionsJpeg, EncodeOptionsPng } from './encode';
7+
import {
8+
encode,
9+
EncodeOptionsBmp,
10+
EncodeOptionsJpeg,
11+
EncodeOptionsPng,
12+
} from './encode';
813

914
export interface WriteOptions {
1015
/**
@@ -15,6 +20,7 @@ export interface WriteOptions {
1520

1621
export type WriteOptionsPng = WriteOptions & EncodeOptionsPng;
1722
export type WriteOptionsJpeg = WriteOptions & EncodeOptionsJpeg;
23+
export type WriteOptionsBmp = WriteOptions & EncodeOptionsBmp;
1824

1925
/**
2026
* Write an image to the disk.
@@ -56,6 +62,20 @@ export async function write(
5662
image: Image | Mask,
5763
options: WriteOptionsJpeg,
5864
): Promise<void>;
65+
66+
/**
67+
* Write an image to the disk as BMP.
68+
* When the `bmp` format is specified, the file's extension doesn't matter.
69+
* @param path - Path or file URL where the image should be written.
70+
* @param image - Image to save.
71+
* @param options - Encode options for bmp images.
72+
* @returns A promise that resolves when the image is written.
73+
*/
74+
export async function write(
75+
path: string | URL,
76+
image: Mask,
77+
options?: WriteOptionsBmp,
78+
): Promise<void>;
5979
/**
6080
* Asynchronously write an image to the disk.
6181
* @param path - Path where the image should be written.
@@ -65,7 +85,7 @@ export async function write(
6585
export async function write(
6686
path: string | URL,
6787
image: Image | Mask,
68-
options?: WriteOptionsPng | WriteOptionsJpeg | WriteOptions,
88+
options?: WriteOptionsBmp | WriteOptionsPng | WriteOptionsJpeg | WriteOptions,
6989
): Promise<void> {
7090
if (typeof path !== 'string') {
7191
path = url.fileURLToPath(path);
@@ -90,14 +110,11 @@ export async function write(
90110
export function writeSync(
91111
path: string | URL,
92112
image: Image | Mask,
93-
options?: WriteOptionsPng | WriteOptionsJpeg | WriteOptions,
113+
options?: WriteOptionsBmp | WriteOptionsPng | WriteOptionsJpeg | WriteOptions,
94114
): void {
95115
if (typeof path !== 'string') {
96116
path = url.fileURLToPath(path);
97117
}
98-
if (image instanceof Mask) {
99-
image = image.convertColor('GREY');
100-
}
101118
const toWrite = getDataToWrite(path, image, options);
102119
if (options?.recursive) {
103120
const dir = nodePath.dirname(path);
@@ -115,12 +132,17 @@ export function writeSync(
115132
*/
116133
function getDataToWrite(
117134
destinationPath: string,
118-
image: Image,
119-
options?: WriteOptionsPng | WriteOptionsJpeg | WriteOptions,
135+
image: Image | Mask,
136+
options?: WriteOptionsBmp | WriteOptionsPng | WriteOptionsJpeg | WriteOptions,
120137
): Uint8Array {
121138
if (!options || !('format' in options)) {
122139
const extension = nodePath.extname(destinationPath).slice(1).toLowerCase();
123140
if (extension === 'png' || extension === 'jpg' || extension === 'jpeg') {
141+
if (image instanceof Mask) {
142+
image = image.convertColor('GREY');
143+
}
144+
return encode(image, { ...options, format: extension });
145+
} else if (extension === 'bmp') {
124146
return encode(image, { ...options, format: extension });
125147
} else {
126148
throw new RangeError(

test/TestImagePath.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export type TestImagePath =
3434
| 'featureMatching/polygons/polygon2.png'
3535
| 'featureMatching/polygons/polygonRotated10degrees.png'
3636
| 'featureMatching/polygons/polygonRotated180degrees.png'
37+
| 'formats/bmp/10x2.bmp'
38+
| 'formats/bmp/6x4.bmp'
39+
| 'formats/bmp/5x5.bmp'
3740
| 'formats/grey6.jpg'
3841
| 'formats/grey8.png'
3942
| 'formats/grey12.jpg'

test/img/formats/bmp/10x2.bmp

154 Bytes
Binary file not shown.

test/img/formats/bmp/5x5.bmp

166 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)