diff --git a/packages/geotiff/src/colorinterp.ts b/packages/geotiff/src/colorinterp.ts new file mode 100644 index 00000000..e090cc7d --- /dev/null +++ b/packages/geotiff/src/colorinterp.ts @@ -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(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, + ); +} diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index 62687c66..5d20d8e3 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -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"; @@ -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; diff --git a/packages/geotiff/src/ifd.ts b/packages/geotiff/src/ifd.ts index 5ac5b1d0..ddd562f3 100644 --- a/packages/geotiff/src/ifd.ts +++ b/packages/geotiff/src/ifd.ts @@ -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; @@ -41,6 +58,7 @@ export async function prefetchTags( const [ bitsPerSample, colorMap, + extraSamples, gdalNoData, gdalMetadata, lercParameters, @@ -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 }), @@ -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, diff --git a/packages/geotiff/tests/colorinterp.test.ts b/packages/geotiff/tests/colorinterp.test.ts new file mode 100644 index 00000000..11eb7436 --- /dev/null +++ b/packages/geotiff/tests/colorinterp.test.ts @@ -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]); + }); +});