Skip to content

Commit 3dc6281

Browse files
authored
feat(geotiff): Support decoding JPEG and WebP-compressed COGs (#229)
1 parent 078b39e commit 3dc6281

File tree

5 files changed

+68
-28
lines changed

5 files changed

+68
-28
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { DecodedPixels, DecoderMetadata } from "../decode/api.js";
2+
3+
// TODO: in the future, have an API that returns an ImageBitmap directly from
4+
// the decoder, to avoid copying pixel data from GPU -> CPU memory
5+
// Then deck.gl could use the ImageBitmap directly as a texture source without
6+
// copying again from CPU -> GPU memory
7+
// https://github.com/developmentseed/deck.gl-raster/issues/228
8+
export async function decode(
9+
bytes: ArrayBuffer,
10+
metadata: DecoderMetadata,
11+
): Promise<DecodedPixels> {
12+
const blob = new Blob([bytes]);
13+
const imageBitmap = await createImageBitmap(blob);
14+
15+
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
16+
const ctx = canvas.getContext("2d")!;
17+
ctx.drawImage(imageBitmap, 0, 0);
18+
imageBitmap.close();
19+
20+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
21+
const rgba = imageData.data;
22+
23+
const samplesPerPixel = metadata.samplesPerPixel;
24+
if (samplesPerPixel === 4) {
25+
return { layout: "pixel-interleaved", data: rgba };
26+
}
27+
28+
if (samplesPerPixel === 3) {
29+
const pixelCount = imageBitmap.width * imageBitmap.height;
30+
const rgb = new Uint8ClampedArray(pixelCount * 3);
31+
for (let i = 0, j = 0; i < rgb.length; i += 3, j += 4) {
32+
rgb[i] = rgba[j]!;
33+
rgb[i + 1] = rgba[j + 1]!;
34+
rgb[i + 2] = rgba[j + 2]!;
35+
}
36+
return { layout: "pixel-interleaved", data: rgb };
37+
}
38+
39+
throw new Error(`Unsupported SamplesPerPixel for JPEG: ${samplesPerPixel}`);
40+
}

packages/geotiff/src/decode/api.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
import { Compression, SampleFormat } from "@cogeotiff/core";
22
import type { RasterTypedArray } from "../array.js";
3+
import { decode as decodeViaCanvas } from "../codecs/canvas.js";
34

45
/** The result of a decoding process */
56
export type DecodedPixels =
67
| { layout: "pixel-interleaved"; data: RasterTypedArray }
78
| { layout: "band-separate"; bands: RasterTypedArray[] };
89

10+
/** Metadata from the TIFF IFD, passed to decoders that need it. */
11+
export type DecoderMetadata = {
12+
sampleFormat: SampleFormat;
13+
bitsPerSample: number;
14+
samplesPerPixel: number;
15+
};
16+
917
/**
1018
* A decoder returns either:
1119
* - An ArrayBuffer of raw decompressed bytes (byte-level codecs like deflate, zstd)
1220
* - A DecodedPixels with typed pixel data (image codecs like LERC, JPEG)
1321
*/
1422
export type Decoder = (
1523
bytes: ArrayBuffer,
24+
metadata: DecoderMetadata,
1625
) => Promise<ArrayBuffer | DecodedPixels>;
1726

1827
async function decodeUncompressed(bytes: ArrayBuffer): Promise<ArrayBuffer> {
@@ -34,18 +43,12 @@ registry.set(Compression.DeflateOther, () =>
3443
// registry.set(Compression.Lzma, () =>
3544
// import("../codecs/lzma.js").then((m) => m.decode),
3645
// );
37-
// registry.set(Compression.Webp, () =>
38-
// import("../codecs/webp.js").then((m) => m.decode),
39-
// );
4046
// registry.set(Compression.Jp2000, () =>
4147
// import("../codecs/jp2000.js").then((m) => m.decode),
4248
// );
43-
// registry.set(Compression.Jpeg, () =>
44-
// import("../codecs/jpeg.js").then((m) => m.decode),
45-
// );
46-
// registry.set(Compression.Jpeg6, () =>
47-
// import("../codecs/jpeg.js").then((m) => m.decode),
48-
// );
49+
registry.set(Compression.Jpeg, () => Promise.resolve(decodeViaCanvas));
50+
registry.set(Compression.Jpeg6, () => Promise.resolve(decodeViaCanvas));
51+
registry.set(Compression.Webp, () => Promise.resolve(decodeViaCanvas));
4952
registry.set(Compression.Lerc, () =>
5053
import("../codecs/lerc.js").then((m) => m.decode),
5154
);
@@ -56,26 +59,20 @@ registry.set(Compression.Lerc, () =>
5659
export async function decode(
5760
bytes: ArrayBuffer,
5861
compression: Compression,
59-
{
60-
sampleFormat,
61-
bitsPerSample,
62-
}: {
63-
sampleFormat: SampleFormat;
64-
bitsPerSample: number;
65-
},
62+
metadata: DecoderMetadata,
6663
): Promise<DecodedPixels> {
6764
const loader = registry.get(compression);
6865
if (!loader) {
6966
throw new Error(`Unsupported compression: ${compression}`);
7067
}
7168

7269
const decoder = await loader();
73-
const result = await decoder(bytes);
70+
const result = await decoder(bytes, metadata);
7471

7572
if (result instanceof ArrayBuffer) {
7673
return {
7774
layout: "pixel-interleaved",
78-
data: toTypedArray(result, sampleFormat, bitsPerSample),
75+
data: toTypedArray(result, metadata),
7976
};
8077
}
8178

@@ -89,9 +86,9 @@ export async function decode(
8986
*/
9087
function toTypedArray(
9188
buffer: ArrayBuffer,
92-
sampleFormat: SampleFormat,
93-
bitsPerSample: number,
89+
metadata: Pick<DecoderMetadata, "sampleFormat" | "bitsPerSample">,
9490
): RasterTypedArray {
91+
const { sampleFormat, bitsPerSample } = metadata;
9592
switch (sampleFormat) {
9693
case SampleFormat.Uint:
9794
switch (bitsPerSample) {

packages/geotiff/src/fetch.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,19 @@ export async function fetchTile(
5555
translation(x * self.tileWidth, y * self.tileHeight),
5656
);
5757

58+
// https://github.com/blacha/cogeotiff/pull/1394
59+
const samplesPerPixel =
60+
(self.image.value(TiffTag.SamplesPerPixel) as number) ?? 1;
61+
5862
const decodedPixels = await decode(bytes, compression, {
5963
sampleFormat,
6064
bitsPerSample,
65+
samplesPerPixel,
6166
});
6267

63-
if (decodedPixels.layout === "band-separate") {
64-
}
65-
6668
const array = {
6769
...decodedPixels,
68-
// https://github.com/blacha/cogeotiff/pull/1394
69-
count: self.image.value(TiffTag.SamplesPerPixel) as number,
70+
count: samplesPerPixel,
7071
height: self.tileHeight,
7172
width: self.tileWidth,
7273
mask: null,

packages/geotiff/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export type { RasterArray } from "./array.js";
2-
export type { DecodedPixels, Decoder } from "./decode/api.js";
2+
export type { DecodedPixels, Decoder, DecoderMetadata } from "./decode/api.js";
33
export { decode, registry } from "./decode/api.js";
44
export { GeoTIFF } from "./geotiff.js";
55
export { Overview } from "./overview.js";

packages/geotiff/tests/decode.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@ describe("decode", () => {
1414
const sampleFormat =
1515
(image.value(TiffTag.SampleFormat) as SampleFormat[] | null)?.[0] ??
1616
SampleFormat.Uint;
17+
const samplesPerPixel = image.value(TiffTag.SamplesPerPixel) as number;
1718

1819
const result = await decode(tile!.bytes, tile!.compression, {
1920
sampleFormat,
2021
bitsPerSample,
22+
samplesPerPixel,
2123
});
2224

2325
const { width, height } = image.tileSize;
24-
const samplesPerPixel = image.value(TiffTag.SamplesPerPixel) as number;
2526
const bytesPerSample = bitsPerSample / 8;
2627
const expectedBytes = width * height * samplesPerPixel * bytesPerSample;
2728

@@ -42,14 +43,15 @@ describe("decode", () => {
4243
const sampleFormat =
4344
(image.value(TiffTag.SampleFormat) as SampleFormat[] | null)?.[0] ??
4445
SampleFormat.Uint;
46+
const samplesPerPixel = image.value(TiffTag.SamplesPerPixel) as number;
4547

4648
const result = await decode(tile!.bytes, tile!.compression, {
4749
sampleFormat,
4850
bitsPerSample,
51+
samplesPerPixel,
4952
});
5053

5154
const { width, height } = image.tileSize;
52-
const samplesPerPixel = image.value(TiffTag.SamplesPerPixel) as number;
5355
const bytesPerSample = bitsPerSample / 8;
5456
const expectedBytesPerBand = width * height * bytesPerSample;
5557

0 commit comments

Comments
 (0)