Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions packages/geotiff/src/colorinterp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Photometric } from "@cogeotiff/core";
import { ExtraSample } from "./ifd.js";

export enum ColorInterp {
UNDEFINED = "undefined",
GRAY = "gray",
RED = "red",
GREEN = "green",
BLUE = "blue",
ALPHA = "alpha",
PALETTE = "palette",
CYAN = "cyan",
MAGENTA = "magenta",
YELLOW = "yellow",
BLACK = "black",
Y = "Y",
Cb = "Cb",
Cr = "Cr",
}

export function inferColorInterpretation({
count,
photometric,
extraSamples,
}: {
count: number;
photometric: Photometric | null;
extraSamples: ExtraSample[] | null;
}): ColorInterp[] {
switch (photometric) {
case null:
return Array<ColorInterp>(count).fill(ColorInterp.UNDEFINED);

case Photometric.MinIsWhite:
case Photometric.MinIsBlack:
return [ColorInterp.GRAY, ...trailingBands(extraSamples, count - 1)];

case Photometric.Rgb: {
if (count < 3) {
throw new Error(
"RGB photometric interpretation with fewer than 3 bands is not supported.",
);
}
return [
ColorInterp.RED,
ColorInterp.GREEN,
ColorInterp.BLUE,
...trailingBands(extraSamples, count - 3),
];
}

case Photometric.Palette:
return [ColorInterp.PALETTE];

case Photometric.Separated:
return [
ColorInterp.CYAN,
ColorInterp.MAGENTA,
ColorInterp.YELLOW,
ColorInterp.BLACK,
];

case Photometric.Ycbcr:
return [ColorInterp.Y, ColorInterp.Cb, ColorInterp.Cr];

default:
throw new Error(
`Color interpretation not implemented for photometric: ${photometric}`,
);
}
}

/**
* Color interpretation for the `count` trailing (non-primary) bands.
*
* A band is ALPHA if its extra sample is unassociated alpha, otherwise
* UNDEFINED.
*
* Bands without a corresponding extra sample (e.g. a multi-band
* grayscale/multispectral stack) are UNDEFINED, keeping the result one entry
* per band.
*/
function trailingBands(
extraSamples: ExtraSample[] | null,
count: number,
): ColorInterp[] {
return Array.from({ length: count }, (_, i) =>
extraSamples?.[i] === ExtraSample.UnassociatedAlpha
? ColorInterp.ALPHA
: ColorInterp.UNDEFINED,
);
}
11 changes: 11 additions & 0 deletions packages/geotiff/src/geotiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { Source, TiffImage, TiffImageTileCount } from "@cogeotiff/core";
import { Photometric, SubFileType, Tiff, TiffTag } from "@cogeotiff/core";
import type { Affine } from "@developmentseed/affine";
import type { ProjJson } from "@developmentseed/proj";
import type { ColorInterp } from "./colorinterp.js";
import { inferColorInterpretation } from "./colorinterp.js";
import { crsFromGeoKeys } from "./crs.js";
import { fetchTile, fetchTiles } from "./fetch.js";
import type { BandStatistics, GDALMetadata } from "./gdal-metadata.js";
Expand Down Expand Up @@ -447,6 +449,15 @@ export class GeoTIFF {
return this.image.value(TiffTag.SamplesPerPixel) ?? 1;
}

/** The color interpretation of each band in index order. */
get colorInterp(): ColorInterp[] {
return inferColorInterpretation({
count: this.count,
photometric: this.cachedTags.photometric,
extraSamples: this.cachedTags.extraSamples,
});
}

/** Bounding box [minX, minY, maxX, maxY] in the CRS. */
get bbox(): [number, number, number, number] {
return this.image.bbox;
Expand Down
27 changes: 27 additions & 0 deletions packages/geotiff/src/ifd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,28 @@ import {
TiffTagGeo,
} from "@cogeotiff/core";

/**
* Description of extra components.
*
* Specifies that each pixel has N extra components whose interpretation is
* defined by one of the values listed below. When this field is used, the
* SamplesPerPixel field has a value greater than the PhotometricInterpretation
* field suggests.
*
* @see https://web.archive.org/web/20240329145321/https://www.awaresystems.be/imaging/tiff/tifftags/extrasamples.html
*/
export enum ExtraSample {
Unspecified = 0,
AssociatedAlpha = 1,
UnassociatedAlpha = 2,
}

/** Subset of TIFF tags that we pre-fetch for easier visualization. */
export interface CachedTags {
bitsPerSample: Uint16Array;
colorMap?: Uint16Array; // TiffTagType[TiffTag.ColorMap];
compression: TiffTagType[TiffTag.Compression];
extraSamples: ExtraSample[] | null;
gdalMetadata: TiffTagType[TiffTag.GdalMetadata] | null;
lercParameters: TiffTagType[TiffTag.LercParameters] | null;
modelTiepoint: TiffTagType[TiffTag.ModelTiePoint] | null;
Expand Down Expand Up @@ -41,6 +58,7 @@ export async function prefetchTags(
const [
bitsPerSample,
colorMap,
extraSamples,
gdalNoData,
gdalMetadata,
lercParameters,
Expand All @@ -55,6 +73,7 @@ export async function prefetchTags(
] = await Promise.all([
image.fetch(TiffTag.BitsPerSample, { signal }),
image.fetch(TiffTag.ColorMap, { signal }),
image.fetch(TiffTag.ExtraSamples, { signal }),
image.fetch(TiffTag.GdalNoData, { signal }),
image.fetch(TiffTag.GdalMetadata, { signal }),
image.fetch(TiffTag.LercParameters, { signal }),
Expand Down Expand Up @@ -88,6 +107,14 @@ export async function prefetchTags(
bitsPerSample: new Uint16Array(bitsPerSample),
colorMap: colorMap ? new Uint16Array(colorMap as number[]) : undefined,
compression,
// Coerce extraSamples to an array. Can be updated once merged:
// https://github.com/blacha/cogeotiff/pull/1478
extraSamples:
extraSamples == null
? null
: ((Array.isArray(extraSamples)
? extraSamples
: [extraSamples]) as ExtraSample[]),
gdalMetadata,
lercParameters,
modelTiepoint,
Expand Down
165 changes: 165 additions & 0 deletions packages/geotiff/tests/colorinterp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { Photometric } from "@cogeotiff/core";
import { describe, expect, it } from "vitest";
import { ColorInterp, inferColorInterpretation } from "../src/colorinterp.js";
import { ExtraSample } from "../src/ifd.js";

describe("inferColorInterpretation", () => {
it("returns UNDEFINED per band when photometric is null", () => {
expect(
inferColorInterpretation({
count: 3,
photometric: null,
extraSamples: null,
}),
).toEqual([
ColorInterp.UNDEFINED,
ColorInterp.UNDEFINED,
ColorInterp.UNDEFINED,
]);
});

it("maps a single-band MinIsBlack image to GRAY", () => {
expect(
inferColorInterpretation({
count: 1,
photometric: Photometric.MinIsBlack,
extraSamples: null,
}),
).toEqual([ColorInterp.GRAY]);
});

it("maps a single-band MinIsWhite image to GRAY", () => {
// GDAL reports GCI_GrayIndex for both black-is-zero and white-is-zero; the
// black/white polarity is not a color-interpretation concern.
expect(
inferColorInterpretation({
count: 1,
photometric: Photometric.MinIsWhite,
extraSamples: null,
}),
).toEqual([ColorInterp.GRAY]);
});

it("labels the alpha band of a gray+alpha image, not a second GRAY", () => {
expect(
inferColorInterpretation({
count: 2,
photometric: Photometric.MinIsBlack,
extraSamples: [ExtraSample.UnassociatedAlpha],
}),
).toEqual([ColorInterp.GRAY, ColorInterp.ALPHA]);
});

it("handles gray+alpha for MinIsWhite too", () => {
expect(
inferColorInterpretation({
count: 2,
photometric: Photometric.MinIsWhite,
extraSamples: [ExtraSample.UnassociatedAlpha],
}),
).toEqual([ColorInterp.GRAY, ColorInterp.ALPHA]);
});

it("pads a multi-band grayscale/multispectral stack to one entry per band", () => {
expect(
inferColorInterpretation({
count: 6,
photometric: Photometric.MinIsBlack,
extraSamples: null,
}),
).toEqual([
ColorInterp.GRAY,
ColorInterp.UNDEFINED,
ColorInterp.UNDEFINED,
ColorInterp.UNDEFINED,
ColorInterp.UNDEFINED,
ColorInterp.UNDEFINED,
]);
});

it("maps a 3-band RGB image to RED/GREEN/BLUE", () => {
expect(
inferColorInterpretation({
count: 3,
photometric: Photometric.Rgb,
extraSamples: null,
}),
).toEqual([ColorInterp.RED, ColorInterp.GREEN, ColorInterp.BLUE]);
});

it("maps a 4-band RGBA image to RED/GREEN/BLUE/ALPHA", () => {
expect(
inferColorInterpretation({
count: 4,
photometric: Photometric.Rgb,
extraSamples: [ExtraSample.UnassociatedAlpha],
}),
).toEqual([
ColorInterp.RED,
ColorInterp.GREEN,
ColorInterp.BLUE,
ColorInterp.ALPHA,
]);
});

it("pads RGB with a trailing non-alpha band to one entry per band", () => {
expect(
inferColorInterpretation({
count: 5,
photometric: Photometric.Rgb,
extraSamples: [ExtraSample.UnassociatedAlpha],
}),
).toEqual([
ColorInterp.RED,
ColorInterp.GREEN,
ColorInterp.BLUE,
ColorInterp.ALPHA,
ColorInterp.UNDEFINED,
]);
});

it("throws for RGB with fewer than 3 bands", () => {
expect(() =>
inferColorInterpretation({
count: 2,
photometric: Photometric.Rgb,
extraSamples: null,
}),
).toThrow();
});

it("maps a palette image to a single PALETTE band", () => {
expect(
inferColorInterpretation({
count: 1,
photometric: Photometric.Palette,
extraSamples: null,
}),
).toEqual([ColorInterp.PALETTE]);
});

it("maps a Separated image to CMYK", () => {
expect(
inferColorInterpretation({
count: 4,
photometric: Photometric.Separated,
extraSamples: null,
}),
).toEqual([
ColorInterp.CYAN,
ColorInterp.MAGENTA,
ColorInterp.YELLOW,
ColorInterp.BLACK,
]);
});

it("maps a YCbCr image to Y/Cb/Cr", () => {
expect(
inferColorInterpretation({
count: 3,
photometric: Photometric.Ycbcr,
extraSamples: null,
}),
).toEqual([ColorInterp.Y, ColorInterp.Cb, ColorInterp.Cr]);
});
});
Loading