Skip to content

Commit

Permalink
feat(loaders): Add Multifile OME-TIFF support to loadOmeTiff (#740)
Browse files Browse the repository at this point in the history
  • Loading branch information
manzt authored Nov 16, 2023
1 parent afca265 commit d87f4e5
Show file tree
Hide file tree
Showing 22 changed files with 467 additions and 366 deletions.
5 changes: 5 additions & 0 deletions .changeset/tidy-geckos-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vivjs/loaders': patch
---

Support multifile OME-TIFF in `loadOmeTiff`
19 changes: 12 additions & 7 deletions packages/loaders/src/omexml.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ensureArray, intToRgba, parseXML } from './utils';
import { intToRgba, parseXML } from './utils';
import * as z from 'zod';

export type OmeXml = ReturnType<typeof fromString>;
Expand All @@ -15,6 +15,10 @@ function flattenAttributes<T extends { attr: Record<string, unknown> }>({
return { ...attr, ...rest };
}

function ensureArray<T>(x: T | T[]) {
return Array.isArray(x) ? x : [x];
}

export type DimensionOrder = z.infer<typeof DimensionOrderSchema>;
const DimensionOrderSchema = z.enum([
'XYZCT',
Expand All @@ -39,8 +43,8 @@ const PixelTypeSchema = z.enum([
'double-complex'
]);

export type UnitsLength = z.infer<typeof UnitsLengthSchema>;
const UnitsLengthSchema = z.enum([
export type PhysicalUnit = z.infer<typeof PhysicalUnitSchema>;
const PhysicalUnitSchema = z.enum([
'Ym',
'Zm',
'Em',
Expand Down Expand Up @@ -130,9 +134,9 @@ const PixelsSchema = z
PhysicalSizeY: z.coerce.number().optional(),
PhysicalSizeZ: z.coerce.number().optional(),
SignificantBits: z.coerce.number().optional(),
PhysicalSizeXUnit: UnitsLengthSchema.optional().default('µm'),
PhysicalSizeYUnit: UnitsLengthSchema.optional().default('µm'),
PhysicalSizeZUnit: UnitsLengthSchema.optional().default('µm'),
PhysicalSizeXUnit: PhysicalUnitSchema.optional().default('µm'),
PhysicalSizeYUnit: PhysicalUnitSchema.optional().default('µm'),
PhysicalSizeZUnit: PhysicalUnitSchema.optional().default('µm'),
BigEndian: z
.string()
.transform(v => v.toLowerCase() === 'true')
Expand Down Expand Up @@ -176,7 +180,8 @@ const OmeSchema = z

export function fromString(str: string) {
const raw = parseXML(str);
return OmeSchema.parse(raw).Image.map(img => {
const omeXml = OmeSchema.parse(raw);
return omeXml['Image'].map(img => {
return {
...img,
format() {
Expand Down
87 changes: 32 additions & 55 deletions packages/loaders/src/tiff/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { fromUrl, fromBlob, fromFile, addDecoder } from 'geotiff';
import type { GeoTIFF, Pool } from 'geotiff';
import { fromBlob, addDecoder } from 'geotiff';
import type { Pool } from 'geotiff';

import { createOffsetsProxy, checkProxies } from './lib/proxies';
import LZWDecoder from './lib/lzw-decoder';
import { parseFilename, type OmeTiffSelection } from './lib/utils';

import { load as loadOme } from './ome-tiff';
import {
parseFilename,
type OmeTiffSelection,
createGeoTiff,
type OmeTiffDims
} from './lib/utils';

import { loadSingleFileOmeTiff } from './singlefile-ome-tiff';
import { loadMultifileOmeTiff } from './multifile-ome-tiff';
import { load as loadMulti, type MultiTiffImage } from './multi-tiff';
import type TiffPixelSource from './pixel-source';
import type { OmeXml } from '../omexml';

addDecoder(5, () => LZWDecoder);

interface TiffOptions {
headers?: Headers | Record<string, string>;
offsets?: number[];
pool?: Pool;
images?: 'first' | 'all';
}

interface OmeTiffOptions extends TiffOptions {
Expand All @@ -28,29 +34,32 @@ interface MultiTiffOptions {
headers?: Headers | Record<string, string>;
}

type MultiImage = Awaited<ReturnType<typeof loadOme>>; // get return-type from `load`
type OmeTiffImage = {
data: TiffPixelSource<OmeTiffDims>[];
metadata: OmeXml[number];
};

export const FILE_PREFIX = 'file://';
function isSupportedCompanionOmeTiffFile(source: string | File) {
return typeof source === 'string' && source.endsWith('.companion.ome');
}

/** @ignore */
export async function loadOmeTiff(
source: string | File,
opts: TiffOptions & { images: 'all' }
): Promise<MultiImage>;
): Promise<OmeTiffImage[]>;
/** @ignore */
export async function loadOmeTiff(
source: string | File,
opts: TiffOptions & { images: 'first' }
): Promise<MultiImage[0]>;
): Promise<OmeTiffImage>;
/** @ignore */
export async function loadOmeTiff(
source: string | File,
opts: TiffOptions
): Promise<MultiImage[0]>;
): Promise<OmeTiffImage>;
/** @ignore */
export async function loadOmeTiff(
source: string | File
): Promise<MultiImage[0]>;
export async function loadOmeTiff(source: string | File): Promise<OmeTiffImage>;
/**
* Opens an OME-TIFF via URL and returns data source and associated metadata for first or all images in files.
*
Expand All @@ -68,40 +77,11 @@ export async function loadOmeTiff(
source: string | File,
opts: OmeTiffOptions = {}
) {
const { headers = {}, offsets, pool, images = 'first' } = opts;

let tiff: GeoTIFF;

// Create tiff source
if (typeof source === 'string') {
if (source.startsWith(FILE_PREFIX)) {
tiff = await fromFile(source.slice(FILE_PREFIX.length));
} else {
// https://github.com/ilan-gold/geotiff.js/tree/viv#abortcontroller-support
// https://www.npmjs.com/package/lru-cache#options
// Cache size needs to be infinite due to consistency issues.
tiff = await fromUrl(source, { headers, cacheSize: Infinity });
}
} else {
tiff = await fromBlob(source);
}

if (offsets) {
/*
* Performance enhancement. If offsets are provided, we
* create a proxy that intercepts calls to `tiff.getImage`
* and injects the pre-computed offsets.
*/
tiff = createOffsetsProxy(tiff, offsets);
}
/*
* Inspect tiff source for our performance enhancing proxies.
* Prints warnings to console if `offsets` or `pool` are missing.
*/
checkProxies(tiff);

const loaders = await loadOme(tiff, pool);
return images === 'all' ? loaders : loaders[0];
const load = isSupportedCompanionOmeTiffFile(source)
? loadMultifileOmeTiff
: loadSingleFileOmeTiff;
const loaders = await load(source, opts);
return opts.images === 'all' ? loaders : loaders[0];
}

function getImageSelectionName(
Expand Down Expand Up @@ -161,12 +141,9 @@ export async function loadMultiTiff(
if (extension === 'tif' || extension === 'tiff') {
const tiffImageName = parsedFilename.name;
if (tiffImageName) {
let curImage: GeoTIFF;
if (file.startsWith(FILE_PREFIX)) {
curImage = await fromFile(file.slice(FILE_PREFIX.length));
} else {
curImage = await fromUrl(file, { headers, cacheSize: Infinity });
}
const curImage = await createGeoTiff(file, {
headers
});
for (let i = 0; i < imageSelections.length; i++) {
const curSelection = imageSelections[i];

Expand Down
137 changes: 105 additions & 32 deletions packages/loaders/src/tiff/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,61 @@
import type { GeoTIFFImage } from 'geotiff';
import { getDims, getLabels, DTYPE_LOOKUP } from '../../utils';
import type { OmeXml, UnitsLength, DimensionOrder } from '../../omexml';
import {
type GeoTIFF,
type GeoTIFFImage,
fromFile,
fromUrl,
fromBlob
} from 'geotiff';
import { getLabels, DTYPE_LOOKUP } from '../../utils';
import type { OmeXml, PhysicalUnit, DimensionOrder } from '../../omexml';
import type { MultiTiffImage } from '../multi-tiff';
import { createOffsetsProxy } from './proxies';

// TODO: Remove the fancy label stuff
export type OmeTiffDims =
| ['t', 'c', 'z']
| ['z', 't', 'c']
| ['t', 'z', 'c']
| ['c', 'z', 't']
| ['z', 'c', 't']
| ['c', 't', 'z'];

export interface OmeTiffSelection {
t: number;
c: number;
z: number;
}

function findPhysicalSizes({
PhysicalSizeX,
PhysicalSizeY,
PhysicalSizeZ,
PhysicalSizeXUnit,
PhysicalSizeYUnit,
PhysicalSizeZUnit
}: OmeXml[number]['Pixels']):
| undefined
| Record<string, { size: number; unit: UnitsLength }> {
type PhysicalSize = {
size: number;
unit: PhysicalUnit;
};

type PhysicalSizes = {
x: PhysicalSize;
y: PhysicalSize;
z?: PhysicalSize;
};

function extractPhysicalSizesfromOmeXml(
d: OmeXml[number]['Pixels']
): undefined | PhysicalSizes {
if (
!PhysicalSizeX ||
!PhysicalSizeY ||
!PhysicalSizeXUnit ||
!PhysicalSizeYUnit
!d['PhysicalSizeX'] ||
!d['PhysicalSizeY'] ||
!d['PhysicalSizeXUnit'] ||
!d['PhysicalSizeYUnit']
) {
return undefined;
}
const physicalSizes: Record<string, { size: number; unit: UnitsLength }> = {
x: { size: PhysicalSizeX, unit: PhysicalSizeXUnit },
y: { size: PhysicalSizeY, unit: PhysicalSizeYUnit }
const physicalSizes: PhysicalSizes = {
x: { size: d['PhysicalSizeX'], unit: d['PhysicalSizeXUnit'] },
y: { size: d['PhysicalSizeY'], unit: d['PhysicalSizeYUnit'] }
};
if (PhysicalSizeZ && PhysicalSizeZUnit) {
physicalSizes.z = { size: PhysicalSizeZ, unit: PhysicalSizeZUnit };
if (d['PhysicalSizeZ'] && d['PhysicalSizeZUnit']) {
physicalSizes.z = {
size: d['PhysicalSizeZ'],
unit: d['PhysicalSizeZUnit']
};
}
return physicalSizes;
}
Expand All @@ -42,11 +65,10 @@ export function getOmePixelSourceMeta({ Pixels }: OmeXml[0]) {
const labels = getLabels(Pixels.DimensionOrder);

// Compute "shape" of image
const dims = getDims(labels);
const shape: number[] = Array(labels.length).fill(0);
shape[dims('t')] = Pixels.SizeT;
shape[dims('c')] = Pixels.SizeC;
shape[dims('z')] = Pixels.SizeZ;
shape[labels.indexOf('t')] = Pixels.SizeT;
shape[labels.indexOf('c')] = Pixels.SizeC;
shape[labels.indexOf('z')] = Pixels.SizeZ;

// Push extra dimension if data are interleaved.
if (Pixels.Interleaved) {
Expand All @@ -57,10 +79,10 @@ export function getOmePixelSourceMeta({ Pixels }: OmeXml[0]) {

// Creates a new shape for different level of pyramid.
// Assumes factor-of-two downsampling.
const getShape = (level: number) => {
const getShape = (level: number = 0) => {
const s = [...shape];
s[dims('x')] = Pixels.SizeX >> level;
s[dims('y')] = Pixels.SizeY >> level;
s[labels.indexOf('x')] = Pixels.SizeX >> level;
s[labels.indexOf('y')] = Pixels.SizeY >> level;
return s;
};

Expand All @@ -69,9 +91,9 @@ export function getOmePixelSourceMeta({ Pixels }: OmeXml[0]) {
}

const dtype = DTYPE_LOOKUP[Pixels.Type as keyof typeof DTYPE_LOOKUP];
const physicalSizes = findPhysicalSizes(Pixels);
if (physicalSizes) {
return { labels, getShape, dtype, physicalSizes };
const maybePhysicalSizes = extractPhysicalSizesfromOmeXml(Pixels);
if (maybePhysicalSizes) {
return { labels, getShape, dtype, physicalSizes: maybePhysicalSizes };
}
return { labels, getShape, dtype };
}
Expand Down Expand Up @@ -277,3 +299,54 @@ export function parseFilename(path: string) {
}
return parsedFilename;
}

/**
* Creates a GeoTIFF object from a URL, File, or Blob.
*
* @param source - URL, File, or Blob
* @param options
* @param options.headers - HTTP headers to use when fetching a URL
*/
function createGeoTiffObject(
source: string | URL | File,
{ headers }: { headers?: Headers | Record<string, string> }
): Promise<GeoTIFF> {
if (source instanceof Blob) {
return fromBlob(source);
}
const url = typeof source === 'string' ? new URL(source) : source;
if (url.protocol === 'file:') {
return fromFile(url.pathname);
}
// https://github.com/ilan-gold/geotiff.js/tree/viv#abortcontroller-support
// https://www.npmjs.com/package/lru-cache#options
// Cache size needs to be infinite due to consistency issues.
return fromUrl(url.href, { headers, cacheSize: Infinity });
}

/**
* Creates a GeoTIFF object from a URL, File, or Blob.
*
* If `offsets` are provided, a proxy is returned that
* intercepts calls to `tiff.getImage` and injects the
* pre-computed offsets. This is a performance enhancement.
*
* @param source - URL, File, or Blob
* @param options
* @param options.headers - HTTP headers to use when fetching a URL
*/
export async function createGeoTiff(
source: string | URL | File,
options: {
headers?: Headers | Record<string, string>;
offsets?: number[];
} = {}
): Promise<GeoTIFF> {
const tiff = await createGeoTiffObject(source, options);
/*
* Performance enhancement. If offsets are provided, we
* create a proxy that intercepts calls to `tiff.getImage`
* and injects the pre-computed offsets.
*/
return options.offsets ? createOffsetsProxy(tiff, options.offsets) : tiff;
}
Loading

0 comments on commit d87f4e5

Please sign in to comment.