From 25e38fd90690e6cb8cfe46309d307feedb82c15f Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 5 Mar 2026 17:38:46 -0500 Subject: [PATCH 1/6] defined extrasamples --- packages/geotiff/src/ifd.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/geotiff/src/ifd.ts b/packages/geotiff/src/ifd.ts index c572f775..d835f0c2 100644 --- a/packages/geotiff/src/ifd.ts +++ b/packages/geotiff/src/ifd.ts @@ -1,11 +1,28 @@ import type { TiffImage, TiffTagGeoType, TiffTagType } from "@cogeotiff/core"; import { Predictor, SampleFormat, TiffTag, 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 + */ +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; @@ -33,6 +50,7 @@ export async function prefetchTags(image: TiffImage): Promise { const [ bitsPerSample, colorMap, + extraSamples, gdalNoData, gdalMetadata, lercParameters, @@ -49,6 +67,7 @@ export async function prefetchTags(image: TiffImage): Promise { ] = await Promise.all([ image.fetch(TiffTag.BitsPerSample), image.fetch(TiffTag.ColorMap), + image.fetch(TiffTag.ExtraSamples), image.fetch(TiffTag.GdalNoData), image.fetch(TiffTag.GdalMetadata), image.fetch(TiffTag.LercParameters), @@ -91,6 +110,7 @@ export async function prefetchTags(image: TiffImage): Promise { bitsPerSample: new Uint16Array(bitsPerSample), colorMap: colorMap ? new Uint16Array(colorMap as number[]) : undefined, compression, + extraSamples: (extraSamples as [ExtraSample] | null) ?? null, gdalMetadata, lercParameters, modelTiepoint, From 486eab9a62c2561e3da0461de02fcc2f2ce77d48 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 5 Mar 2026 17:45:44 -0500 Subject: [PATCH 2/6] add colorinterp --- packages/geotiff/src/colorinterp.ts | 67 +++++++++++++++++++++++++++++ packages/geotiff/src/ifd.ts | 2 +- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 packages/geotiff/src/colorinterp.ts diff --git a/packages/geotiff/src/colorinterp.ts b/packages/geotiff/src/colorinterp.ts new file mode 100644 index 00000000..b0c5b87a --- /dev/null +++ b/packages/geotiff/src/colorinterp.ts @@ -0,0 +1,67 @@ +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.MinIsBlack: + return Array(count).fill(ColorInterp.GRAY); + + case Photometric.Rgb: { + if (count < 3) { + throw new Error( + "RGB photometric interpretation with fewer than 3 bands is not supported.", + ); + } + if (count === 3) { + return [ColorInterp.RED, ColorInterp.GREEN, ColorInterp.BLUE]; + } + // count >= 4: map extra samples + const extras = (extraSamples ?? []).map((sample) => + sample === ExtraSample.UnassociatedAlpha ? ColorInterp.ALPHA : ColorInterp.UNDEFINED, + ); + return [ColorInterp.RED, ColorInterp.GREEN, ColorInterp.BLUE, ...extras]; + } + + 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}`, + ); + } +} diff --git a/packages/geotiff/src/ifd.ts b/packages/geotiff/src/ifd.ts index d835f0c2..d1a8b49c 100644 --- a/packages/geotiff/src/ifd.ts +++ b/packages/geotiff/src/ifd.ts @@ -11,7 +11,7 @@ import { Predictor, SampleFormat, TiffTag, TiffTagGeo } from "@cogeotiff/core"; * * @see https://web.archive.org/web/20240329145321/https://www.awaresystems.be/imaging/tiff/tifftags/extrasamples.html */ -enum ExtraSample { +export enum ExtraSample { Unspecified = 0, AssociatedAlpha = 1, UnassociatedAlpha = 2, From 084829f7dd4b06fd8e453c0526276088e02c3b2e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 5 Mar 2026 17:47:14 -0500 Subject: [PATCH 3/6] Expose colorinterp property --- packages/geotiff/src/geotiff.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index 96d4725c..70ff2d35 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -10,6 +10,8 @@ import { crsFromGeoKeys } from "./crs.js"; import { fetchTile } from "./fetch.js"; import type { BandStatistics, GDALMetadata } from "./gdal-metadata.js"; import { parseGDALMetadata } from "./gdal-metadata.js"; +import type { ColorInterp } from "./colorinterp.js"; +import { inferColorInterpretation } from "./colorinterp.js"; import type { CachedTags, GeoKeyDirectory } from "./ifd.js"; import { extractGeoKeyDirectory, prefetchTags } from "./ifd.js"; import { Overview } from "./overview.js"; @@ -330,6 +332,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; From 9501e82fe0ef9e4356dcf05c07812b364217f144 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 10 Jul 2026 14:21:06 -0400 Subject: [PATCH 4/6] coerce to array --- packages/geotiff/src/ifd.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/geotiff/src/ifd.ts b/packages/geotiff/src/ifd.ts index 2c6cd346..a92f2c2f 100644 --- a/packages/geotiff/src/ifd.ts +++ b/packages/geotiff/src/ifd.ts @@ -28,7 +28,7 @@ export interface CachedTags { bitsPerSample: Uint16Array; colorMap?: Uint16Array; // TiffTagType[TiffTag.ColorMap]; compression: TiffTagType[TiffTag.Compression]; - extraSamples: [ExtraSample] | null; + extraSamples: ExtraSample[] | null; gdalMetadata: TiffTagType[TiffTag.GdalMetadata] | null; lercParameters: TiffTagType[TiffTag.LercParameters] | null; modelTiepoint: TiffTagType[TiffTag.ModelTiePoint] | null; @@ -107,7 +107,14 @@ export async function prefetchTags( bitsPerSample: new Uint16Array(bitsPerSample), colorMap: colorMap ? new Uint16Array(colorMap as number[]) : undefined, compression, - extraSamples: (extraSamples as [ExtraSample] | null) ?? null, + // Coerce extraSamples to an array + // https://github.com/blacha/cogeotiff/pull/1478 + extraSamples: + extraSamples == null + ? null + : ((Array.isArray(extraSamples) + ? extraSamples + : [extraSamples]) as ExtraSample[]), gdalMetadata, lercParameters, modelTiepoint, From fc161efdbaf211428c9481b82e95dc7eb57bcf52 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 10 Jul 2026 14:21:56 -0400 Subject: [PATCH 5/6] comment --- packages/geotiff/src/ifd.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/geotiff/src/ifd.ts b/packages/geotiff/src/ifd.ts index a92f2c2f..ddd562f3 100644 --- a/packages/geotiff/src/ifd.ts +++ b/packages/geotiff/src/ifd.ts @@ -107,7 +107,7 @@ export async function prefetchTags( bitsPerSample: new Uint16Array(bitsPerSample), colorMap: colorMap ? new Uint16Array(colorMap as number[]) : undefined, compression, - // Coerce extraSamples to an array + // Coerce extraSamples to an array. Can be updated once merged: // https://github.com/blacha/cogeotiff/pull/1478 extraSamples: extraSamples == null From efa9e39b22c679d394f84ca80d065fe3e7884d29 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 10 Jul 2026 14:31:52 -0400 Subject: [PATCH 6/6] cleanup --- packages/geotiff/src/colorinterp.ts | 40 +++-- packages/geotiff/tests/colorinterp.test.ts | 165 +++++++++++++++++++++ 2 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 packages/geotiff/tests/colorinterp.test.ts diff --git a/packages/geotiff/src/colorinterp.ts b/packages/geotiff/src/colorinterp.ts index 13d5381f..e090cc7d 100644 --- a/packages/geotiff/src/colorinterp.ts +++ b/packages/geotiff/src/colorinterp.ts @@ -31,8 +31,9 @@ export function inferColorInterpretation({ case null: return Array(count).fill(ColorInterp.UNDEFINED); + case Photometric.MinIsWhite: case Photometric.MinIsBlack: - return Array(count).fill(ColorInterp.GRAY); + return [ColorInterp.GRAY, ...trailingBands(extraSamples, count - 1)]; case Photometric.Rgb: { if (count < 3) { @@ -40,16 +41,12 @@ export function inferColorInterpretation({ "RGB photometric interpretation with fewer than 3 bands is not supported.", ); } - if (count === 3) { - return [ColorInterp.RED, ColorInterp.GREEN, ColorInterp.BLUE]; - } - // count >= 4: map extra samples - const extras = (extraSamples ?? []).map((sample) => - sample === ExtraSample.UnassociatedAlpha - ? ColorInterp.ALPHA - : ColorInterp.UNDEFINED, - ); - return [ColorInterp.RED, ColorInterp.GREEN, ColorInterp.BLUE, ...extras]; + return [ + ColorInterp.RED, + ColorInterp.GREEN, + ColorInterp.BLUE, + ...trailingBands(extraSamples, count - 3), + ]; } case Photometric.Palette: @@ -72,3 +69,24 @@ export function inferColorInterpretation({ ); } } + +/** + * 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/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]); + }); +});