From ce441df60ad9a48b5bb9b5715cd6de1f339ff7d1 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Sun, 1 Feb 2026 15:30:47 -0500 Subject: [PATCH 1/9] initial large image desktop testing --- client/dive-common/apispec.ts | 2 + client/package.json | 2 + .../platform/desktop/backend/native/common.ts | 48 ++- client/platform/desktop/backend/server.ts | 85 +++++ .../desktop/backend/tiles/geotiffTiles.ts | 351 ++++++++++++++++++ client/platform/desktop/constants.ts | 3 + client/platform/desktop/frontend/api.ts | 33 +- .../desktop/frontend/components/Recent.vue | 10 + .../web-girder/api/largeImage.service.ts | 2 +- client/src/@types/pngjs.d.ts | 11 + client/yarn.lock | 59 +++ 11 files changed, 599 insertions(+), 7 deletions(-) create mode 100644 client/platform/desktop/backend/tiles/geotiffTiles.ts create mode 100644 client/src/@types/pngjs.d.ts diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index 3fff296a1..f7ba84d2c 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -85,6 +85,8 @@ interface SaveAttributeTrackFilterArgs { interface FrameImage { url: string; filename: string; + /** Required for large-image (tiled) datasets; used as itemId for getTiles/getTileURL */ + id?: string; } export interface MultiCamImportFolderArgs { diff --git a/client/package.json b/client/package.json index f5c18e2d3..3db048dc0 100644 --- a/client/package.json +++ b/client/package.json @@ -46,6 +46,8 @@ "csv-stringify": "^5.6.0", "d3": "^5.12.0", "geojs": "~1.6.2", + "geotiff": "^3.0.0", + "pngjs": "^7.0.0", "glob-to-regexp": "^0.4.1", "lodash": "^4.17.19", "moment": "^2.29.1", diff --git a/client/platform/desktop/backend/native/common.ts b/client/platform/desktop/backend/native/common.ts index 7f4e1759f..3d4342100 100644 --- a/client/platform/desktop/backend/native/common.ts +++ b/client/platform/desktop/backend/native/common.ts @@ -32,7 +32,8 @@ import kpf from 'platform/desktop/backend/serializers/kpf'; // eslint-disable-next-line import/no-cycle import { checkMedia } from 'platform/desktop/backend/native/mediaJobs'; import { - websafeImageTypes, websafeVideoTypes, otherImageTypes, otherVideoTypes, MultiType, JsonMetaRegEx, + websafeImageTypes, websafeVideoTypes, otherImageTypes, otherVideoTypes, + MultiType, JsonMetaRegEx, largeImageTypes, } from 'dive-common/constants'; import { JsonMeta, Settings, JsonMetaCurrentVersion, DesktopMetadata, @@ -300,6 +301,13 @@ async function loadMetadata( }; }); } + } else if (projectMetaData.type === 'large-image' && projectMetaData.originalLargeImageFile) { + const tiffPath = npath.join(projectMetaData.originalBasePath, projectMetaData.originalLargeImageFile); + imageData = [{ + url: makeMediaUrl(tiffPath), + id: datasetId, + filename: npath.basename(tiffPath), + }]; } else { throw new Error(`unexpected project type for id="${datasetId}" type="${projectMetaData.type}"`); } @@ -930,6 +938,8 @@ async function beginMediaImport(path: string): Promise { + try { + const projectDirData = await getValidatedProjectDir(settings, datasetId); + const meta = await loadJsonMetadata(projectDirData.metaFileAbsPath); + if (meta.type !== 'large-image' || !meta.originalLargeImageFile) { + console.warn( + `[tiles] getLargeImagePath: no path for dataset "${datasetId}" (meta.type=${meta.type}, hasOriginalLargeImageFile=${!!meta.originalLargeImageFile})`, + ); + return null; + } + const path = npath.join(meta.originalBasePath, meta.originalLargeImageFile); + console.log(`[tiles] getLargeImagePath: dataset "${datasetId}" -> ${path}`); + return path; + } catch (err) { + console.warn(`[tiles] getLargeImagePath: error for dataset "${datasetId}":`, err); + return null; + } +} + async function openLink(url: string) { shell.openExternal(url); } @@ -1320,6 +1359,7 @@ export { getTrainingConfigs, getProjectDir, getValidatedProjectDir, + getLargeImagePath, loadMetadata, loadJsonMetadata, loadAnnotationFile, diff --git a/client/platform/desktop/backend/server.ts b/client/platform/desktop/backend/server.ts index 81822bb98..774c77413 100644 --- a/client/platform/desktop/backend/server.ts +++ b/client/platform/desktop/backend/server.ts @@ -12,6 +12,7 @@ import { SaveAttributeArgs, SaveAttributeTrackFilterArgs, SaveDetectionsArgs } f import settings from './state/settings'; import * as common from './native/common'; +import * as geotiffTiles from './tiles/geotiffTiles'; const app = express(); app.use(express.json({ limit: '250MB' })); @@ -123,6 +124,90 @@ apirouter.post('/dataset/:id/:camera?/detections', async (req, res, next) => { return null; }); +/* Large image (GeoTIFF) tiles - compatible with LargeImageAnnotator getTiles/getTileURL */ +apirouter.get('/dataset/:id/:camera?/tiles/:level/:x/:y', async (req, res, next) => { + try { + const datasetId = req.params.camera + ? `${req.params.id}/${req.params.camera}` + : req.params.id; + const level = parseInt(req.params.level, 10); + const x = parseInt(req.params.x, 10); + const y = parseInt(req.params.y, 10); + console.log(`[tiles] GET tile request: datasetId=${datasetId} level=${level} x=${x} y=${y}`); + if (Number.isNaN(level) || Number.isNaN(x) || Number.isNaN(y)) { + return next({ status: 400, statusMessage: 'Invalid level, x, or y' }); + } + const png = await geotiffTiles.getTilePng(settings.get(), datasetId, level, x, y); + if (!png) { + console.warn(`[tiles] GET tile 404: datasetId=${datasetId} level=${level} x=${x} y=${y} (see tile layer logs for reason)`); + return next({ status: 404, statusMessage: 'Tile not found or dataset is not a large image' }); + } + console.log(`[tiles] GET tile 200: datasetId=${datasetId} level=${level} x=${x} y=${y} size=${png.length}`); + res.setHeader('Content-Type', 'image/png'); + res.send(png); + } catch (err) { + console.error('[tiles] GET tile error:', err); + (err as { status?: number }).status = 500; + next(err); + } + return null; +}); + +apirouter.get('/dataset/:id/:camera?/tiles', async (req, res, next) => { + try { + const datasetId = req.params.camera + ? `${req.params.id}/${req.params.camera}` + : req.params.id; + console.log(`[tiles] GET tiles metadata request: datasetId=${datasetId}`); + const meta = await geotiffTiles.getTilesMetadata(settings.get(), datasetId); + if (!meta) { + console.warn(`[tiles] GET tiles metadata 404: datasetId=${datasetId} (see tile layer logs for reason)`); + return next({ status: 404, statusMessage: 'Dataset not found or is not a large image' }); + } + console.log(`[tiles] GET tiles metadata 200: datasetId=${datasetId} sizeX=${meta.sizeX} sizeY=${meta.sizeY} levels=${meta.levels}`); + res.json(meta); + } catch (err) { + console.error('[tiles] GET tiles metadata error:', err); + (err as { status?: number }).status = 500; + next(err); + } + return null; +}); + +/* List valid tile z/x/y values for a dataset (for debugging / tooling) */ +apirouter.get('/dataset/:id/:camera?/tiles/list', async (req, res, next) => { + try { + const datasetId = req.params.camera + ? `${req.params.id}/${req.params.camera}` + : req.params.id; + const limit = Math.min( + Math.max(0, parseInt(String(req.query.limit), 10) || 10000), + 50000, + ); + const meta = await geotiffTiles.getTilesMetadata(settings.get(), datasetId); + if (!meta) { + return next({ status: 404, statusMessage: 'Dataset not found or is not a large image' }); + } + const tiles = geotiffTiles.getValidTileList( + meta.sizeX, + meta.sizeY, + meta.levels, + limit, + ); + res.json({ + tiles, + total: tiles.length, + limit, + tileRanges: meta.tileRanges, + }); + } catch (err) { + console.error('[tiles] GET tiles list error:', err); + (err as { status?: number }).status = 500; + next(err); + } + return null; +}); + /* STREAM media */ apirouter.get('/media', (req, res, next) => { let { path } = req.query; diff --git a/client/platform/desktop/backend/tiles/geotiffTiles.ts b/client/platform/desktop/backend/tiles/geotiffTiles.ts new file mode 100644 index 000000000..1a8cfc321 --- /dev/null +++ b/client/platform/desktop/backend/tiles/geotiffTiles.ts @@ -0,0 +1,351 @@ +/** + * Serve large image (GeoTIFF) tiles using geotiff.js. + * Compatible with LargeImageAnnotator's getTiles/getTileURL expectations. + * + * Inspired by https://github.com/rowanwins/geotiff-server: + * - Uses image-space (pixel) window for readRasters, not bbox. + * - Normalizes 16-bit/float data to 0-255 for display (min-max or percentile-style). + * - Handles single-band (grayscale) by replicating to R=G=B. + * + * Geospatial metadata in the file is not used for tiling. + */ + +import fs from 'fs-extra'; +import { fromArrayBuffer } from 'geotiff'; +import { PNG } from 'pngjs'; +import type { Settings } from 'platform/desktop/constants'; +import { getLargeImagePath } from '../native/common'; + +const TILE_SIZE = 256; + +export interface TileRange { + level: number; + maxX: number; + maxY: number; +} + +export interface TilesMetadata { + sizeX: number; + sizeY: number; + tileWidth: number; + tileHeight: number; + levels: number; + /** Valid tile indices per level: for each level, x in [0, maxX], y in [0, maxY]. */ + tileRanges: TileRange[]; + /** List of valid { z, x, y } tile coordinates (capped by default limit). */ + validTileList: { z: number; x: number; y: number }[]; +} + +/** + * Compute valid tile index ranges from image dimensions and level count. + * Uses geoJS convention: level 0 = overview (scale 2^maxLevel), level maxLevel = full res (scale 1). + * At geoJS level L, scale = 2^(maxLevel - L); valid x in [0, maxX], y in [0, maxY]. + */ +export function getValidTileRanges( + sizeX: number, + sizeY: number, + levels: number, +): TileRange[] { + const ranges: TileRange[] = []; + const maxLevel = Math.ceil(Math.log2(Math.max(sizeX / TILE_SIZE, sizeY / TILE_SIZE))); + for (let level = 0; level <= maxLevel; level += 1) { + const scale = 2 ** (maxLevel - level); + const maxX = Math.max(0, Math.ceil(sizeX / (TILE_SIZE * scale)) - 1); + const maxY = Math.max(0, Math.ceil(sizeY / (TILE_SIZE * scale)) - 1); + ranges.push({ level, maxX, maxY }); + } + return ranges; +} + +/** + * Build a list of valid { z, x, y } tile coordinates, optionally capped. + * @param limit max number of tiles to return (default 10000); omit for no cap + */ +export function getValidTileList( + sizeX: number, + sizeY: number, + levels: number, + limit = 10000, +): { z: number; x: number; y: number }[] { + const ranges = getValidTileRanges(sizeX, sizeY, levels); + const tiles: { z: number; x: number; y: number }[] = []; + for (const { level, maxX, maxY } of ranges) { + for (let x = 0; x <= maxX && tiles.length < limit; x += 1) { + for (let y = 0; y <= maxY && tiles.length < limit; y += 1) { + tiles.push({ z: level, x, y }); + } + } + } + return tiles; +} + +const LOG_PREFIX = '[tiles]'; + +/** If set (e.g. DEBUG_TILE_PATH=/path/to/lake.tiff), use this path for all tile requests (for debugging). */ +function getEffectiveTiffPath( + settings: Settings, + datasetId: string, +): Promise { + const debugPath = process.env.DEBUG_TILE_PATH?.trim(); + if (debugPath) { + return fs.pathExists(debugPath).then((exists) => { + if (exists) { + console.log(`${LOG_PREFIX} using DEBUG_TILE_PATH: ${debugPath} (datasetId=${datasetId})`); + return debugPath; + } + console.warn(`${LOG_PREFIX} DEBUG_TILE_PATH set but file does not exist: ${debugPath}`); + return null; + }); + } + return getLargeImagePath(settings, datasetId); +} + +/** + * Get tile metadata for a large-image dataset (compatible with Girder large_image response). + * Returns null if the file is missing or invalid (e.g. LZW decode issues). + */ +export async function getTilesMetadata( + settings: Settings, + datasetId: string, +): Promise { + console.log(`${LOG_PREFIX} getTilesMetadata: datasetId="${datasetId}"`); + const tiffPath = await getEffectiveTiffPath(settings, datasetId); + if (!tiffPath) { + console.warn(`${LOG_PREFIX} getTilesMetadata: no large-image path for dataset "${datasetId}" (not large-image or missing originalLargeImageFile in meta)`); + return null; + } + if (!(await fs.pathExists(tiffPath))) { + console.warn(`${LOG_PREFIX} getTilesMetadata: file does not exist for dataset "${datasetId}": ${tiffPath}`); + return null; + } + try { + console.log(`${LOG_PREFIX} getTilesMetadata: reading file (${tiffPath.length} chars path)`); + const buffer = await fs.readFile(tiffPath); + console.log(`${LOG_PREFIX} getTilesMetadata: file size ${buffer.length} bytes`); + const arrayBuffer = buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ); + const tiff = await fromArrayBuffer(arrayBuffer); + const image = await tiff.getImage(0); + const width = image.getWidth(); + const height = image.getHeight(); + // geoJS pixelCoordinateParams uses maxLevel = ceil(log2(max(w/256, h/256))); level 0 = overview, maxLevel = full res. + const maxLevel = Math.max(0, Math.ceil(Math.log2(Math.max(width / TILE_SIZE, height / TILE_SIZE)))); + const tileRanges = getValidTileRanges(width, height, maxLevel); + const validTileList = getValidTileList(width, height, maxLevel, 10000); + console.log(`${LOG_PREFIX} getTilesMetadata: success width=${width} height=${height} maxLevel=${maxLevel} validTileList.length=${validTileList.length}`); + return { + sizeX: width, + sizeY: height, + tileWidth: TILE_SIZE, + tileHeight: TILE_SIZE, + levels: maxLevel, + tileRanges, + validTileList, + }; + } catch (err) { + console.error(`${LOG_PREFIX} getTilesMetadata: failed to read/parse GeoTIFF for dataset "${datasetId}" (${tiffPath}):`, err); + return null; + } +} + +/** + * Get a single tile as PNG buffer for level, x, y. + * Returns null on decode errors (e.g. LZW "ran off the end of the buffer" in + * geotiff.js for truncated or malformed GeoTIFF strips). + */ +export async function getTilePng( + settings: Settings, + datasetId: string, + level: number, + x: number, + y: number, +): Promise { + const tiffPath = await getEffectiveTiffPath(settings, datasetId); + if (!tiffPath) { + console.warn(`${LOG_PREFIX} getTilePng: no large-image path for dataset "${datasetId}" (level=${level}, x=${x}, y=${y})`); + return null; + } + if (!(await fs.pathExists(tiffPath))) { + console.warn(`${LOG_PREFIX} getTilePng: file does not exist for dataset "${datasetId}" (level=${level}, x=${x}, y=${y}): ${tiffPath}`); + return null; + } + try { + const buffer = await fs.readFile(tiffPath); + const arrayBuffer = buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ); + const tiff = await fromArrayBuffer(arrayBuffer); + const image = await tiff.getImage(0); + const width = image.getWidth(); + const height = image.getHeight(); + // geoJS uses level 0 = overview (fewest tiles), maxLevel = full res (most tiles). + // tilesAtZoom(level): scale = 2^(maxLevel - level). Match that here. + const maxLevel = Math.ceil(Math.log2(Math.max(width / TILE_SIZE, height / TILE_SIZE))); + const levelClamped = Math.max(0, Math.min(level, maxLevel)); + const scale = 2 ** (maxLevel - levelClamped); + const left = Math.min(x * TILE_SIZE * scale, width); + const top = Math.min(y * TILE_SIZE * scale, height); + const right = Math.min((x + 1) * TILE_SIZE * scale, width); + const bottom = Math.min((y + 1) * TILE_SIZE * scale, height); + if (left >= right || top >= bottom) { + console.warn(`${LOG_PREFIX} getTilePng: tile window out of bounds for dataset "${datasetId}" level=${level} x=${x} y=${y} (image ${width}x${height}, window [${left},${top},${right},${bottom}])`); + return null; + } + // Image-space pixel window [left, top, right, bottom]; not bbox (geospatial). + const window = [left, top, right, bottom] as [number, number, number, number]; + if (process.env.DEBUG_TILE_PATH) { + console.log(`${LOG_PREFIX} getTilePng: level=${level} maxLevel=${maxLevel} scale=${scale} x=${x} y=${y} window=[${left},${top},${right},${bottom}] image=${width}x${height}`); + } + const opts = { + window, + width: TILE_SIZE, + height: TILE_SIZE, + resampleMethod: 'bilinear' as const, + }; + const expectedPixels = TILE_SIZE * TILE_SIZE; + const expected = expectedPixels * 3; + const data = new Uint8Array(expected); + + const sampleCount = image.getSamplesPerPixel(); + type RasterArray = Uint8Array | Uint16Array | Float32Array | Float64Array; + try { + if (sampleCount >= 3) { + const rasters = await image.readRasters({ + ...opts, + samples: [0, 1, 2], + interleave: false, + }) as RasterArray[]; + const r = (rasters[0] ?? new Uint8Array(0)) as RasterArray; + const g = (rasters[1] ?? r) as RasterArray; + const b = (rasters[2] ?? r) as RasterArray; + const r8 = new Uint8Array(expectedPixels); + const g8 = new Uint8Array(expectedPixels); + const b8 = new Uint8Array(expectedPixels); + normalizeToU8(r, r8); + normalizeToU8(g, g8); + normalizeToU8(b, b8); + for (let i = 0; i < expectedPixels; i += 1) { + data[i * 3] = r8[i]; + data[i * 3 + 1] = g8[i]; + data[i * 3 + 2] = b8[i]; + } + } else { + const rasters = await image.readRasters({ + ...opts, + samples: [0], + interleave: false, + }) as RasterArray[]; + const gray = (rasters[0] ?? new Uint8Array(0)) as RasterArray; + const g8 = new Uint8Array(expectedPixels); + normalizeToU8(gray, g8); + for (let i = 0; i < expectedPixels; i += 1) { + const v = g8[i]; + data[i * 3] = v; + data[i * 3 + 1] = v; + data[i * 3 + 2] = v; + } + } + if (process.env.DEBUG_TILE_PATH) { + console.log(`${LOG_PREFIX} getTilePng: readRasters samples=${sampleCount} data length=${data.length}`); + } + } catch (readErr) { + if (process.env.DEBUG_TILE_PATH) { + console.warn(`${LOG_PREFIX} getTilePng: readRasters failed, trying readRGB:`, readErr); + } + try { + const rgb = await image.readRGB(opts); + const rgbBands = rgb as unknown as { [0]: RasterArray; [1]: RasterArray; [2]: RasterArray }; + if (rgb && rgbBands[0] !== undefined && rgbBands[1] !== undefined && rgbBands[2] !== undefined) { + const r8 = new Uint8Array(expectedPixels); + const g8 = new Uint8Array(expectedPixels); + const b8 = new Uint8Array(expectedPixels); + normalizeToU8(rgbBands[0], r8); + normalizeToU8(rgbBands[1], g8); + normalizeToU8(rgbBands[2], b8); + for (let i = 0; i < expectedPixels; i += 1) { + data[i * 3] = r8[i]; + data[i * 3 + 1] = g8[i]; + data[i * 3 + 2] = b8[i]; + } + } else { + const raw = (rgb as unknown as Uint8Array); + if (raw && raw.length >= expected) { + data.set(raw.subarray(0, expected)); + } else if (raw && raw.length > 0) { + const g8 = new Uint8Array(expectedPixels); + normalizeToU8(raw as RasterArray, g8); + for (let i = 0; i < expectedPixels; i += 1) { + const v = g8[i]; + data[i * 3] = v; + data[i * 3 + 1] = v; + data[i * 3 + 2] = v; + } + } + } + } catch { + throw readErr; + } + } + const pngBuffer = encodePng(TILE_SIZE, TILE_SIZE, data); + if (process.env.DEBUG_TILE_PATH) { + console.log(`${LOG_PREFIX} getTilePng: encodePng done, buffer length=${pngBuffer.length}`); + } + return pngBuffer; + } catch (err) { + // LZW/geotiff decode errors (e.g. "ran off the end of the buffer before + // finding EOI_CODE") or other tile read failures: treat as missing tile. + console.error(`${LOG_PREFIX} getTilePng: failed to read tile for dataset "${datasetId}" level=${level} x=${x} y=${y} (${tiffPath}):`, err); + return null; + } +} + +/** + * Normalize raw raster values to 0-255 for display. + * Handles 16-bit, float, or narrow 8-bit range (like geotiff-server pMin/pMax). + */ +function normalizeToU8( + raw: Uint8Array | Uint16Array | Float32Array | Float64Array, + out: Uint8Array, + offset = 0, + stride = 1, +): void { + const n = Math.min(raw.length, Math.floor((out.length - offset) / stride)); + if (n === 0) return; + let min = raw[0]; + let max = raw[0]; + for (let i = 1; i < n; i += 1) { + const v = raw[i]; + if (v < min) min = v; + if (v > max) max = v; + } + const range = max - min || 1; + for (let i = 0; i < n; i += 1) { + const v = (Number(raw[i]) - min) / range; + out[offset + i * stride] = Math.max(0, Math.min(255, Math.round(v * 255))); + } +} + +function interleaveRgb(r: Uint8Array, g: Uint8Array, b: Uint8Array): Uint8Array { + const n = r.length; + const out = new Uint8Array(n * 3); + for (let i = 0; i < n; i += 1) { + out[i * 3] = r[i]; + out[i * 3 + 1] = g[i]; + out[i * 3 + 2] = b[i]; + } + return out; +} + +function encodePng(width: number, height: number, rgb: Uint8Array): Buffer { + const png = new PNG({ width, height }); + for (let i = 0; i < width * height; i += 1) { + png.data[i * 4] = rgb[i * 3]; + png.data[i * 4 + 1] = rgb[i * 3 + 1]; + png.data[i * 4 + 2] = rgb[i * 3 + 2]; + png.data[i * 4 + 3] = 255; + } + return PNG.sync.write(png); +} diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index 33ea8694e..7d47ace1a 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -88,6 +88,9 @@ export interface JsonMeta extends DatasetMetaMutable { // relative to originalBasePath originalVideoFile: string; + // large image (e.g. GeoTIFF) file path, relative to originalBasePath + originalLargeImageFile?: string; + // output of web safe transcoding // relative to project path transcodedVideoFile: string; diff --git a/client/platform/desktop/frontend/api.ts b/client/platform/desktop/frontend/api.ts index 195c5c31f..a27ed9971 100644 --- a/client/platform/desktop/frontend/api.ts +++ b/client/platform/desktop/frontend/api.ts @@ -29,6 +29,8 @@ import { gpuJobQueue, cpuJobQueue } from './store/jobs'; * Native functions that run entirely in the renderer */ +const largeImageFileExtensions = ['tif', 'tiff', 'nitf', 'ntf']; + async function openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | 'annotation' | 'text', directory = false) { let filters: FileFilter[] = []; const allFiles = { name: 'All Files', extensions: ['*'] }; @@ -38,6 +40,12 @@ async function openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | allFiles, ]; } + if (datasetType === 'large-image') { + filters = [ + { name: 'GeoTIFF / TIFF', extensions: largeImageFileExtensions }, + allFiles, + ]; + } if (datasetType === 'calibration') { filters = [ { name: 'calibration', extensions: calibrationFileTypes }, @@ -214,15 +222,34 @@ async function cancelJob(job: DesktopJob): Promise { * address details fetched from backend over ipc */ let _axiosClient: AxiosInstance; // do not use elsewhere +let _baseURL: string | null = null; + async function getClient(): Promise { if (_axiosClient === undefined) { const addr = await ipcRenderer.invoke('server-info'); - const baseURL = `http://${addr.address}:${addr.port}/api`; - _axiosClient = axios.create({ baseURL }); + _baseURL = `http://${addr.address}:${addr.port}/api`; + _axiosClient = axios.create({ baseURL: _baseURL }); } return _axiosClient; } +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- projection kept for API compatibility +async function getTiles(itemId: string, _projection?: string) { + const client = await getClient(); // ensures _baseURL is set for getTileURL + const { data } = await client.get(`dataset/${itemId}/tiles`); + return data; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getTileURL(itemId: string, x: number, y: number, level: number, query: Record): string { + if (!_baseURL) { + throw new Error('API not initialized: getTileURL called before any REST request'); + } + const params = new URLSearchParams(query || {}).toString(); + const suffix = params ? `?${params}` : ''; + return `${_baseURL}/dataset/${itemId}/tiles/${level}/${x}/${y}${suffix}`; +} + async function loadMetadata(id: string) { const client = await getClient(); const { data } = await client.get(`dataset/${id}/meta`); @@ -271,6 +298,8 @@ export { saveAttributes, saveAttributeTrackFilters, openFromDisk, + getTiles, + getTileURL, /* Nonstandard APIs */ exportDataset, exportConfiguration, diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue index 04d355811..fb43e91fd 100644 --- a/client/platform/desktop/frontend/components/Recent.vue +++ b/client/platform/desktop/frontend/components/Recent.vue @@ -174,6 +174,9 @@ export default defineComponent({ if (recent.type === 'video') { return 'mdi-file-video'; } + if (recent.type === 'large-image') { + return 'mdi-map'; + } if (recent.imageListPath) { return 'mdi-view-list-outline'; } @@ -413,6 +416,13 @@ export default defineComponent({ @open="open($event)" @multi-cam="openMultiCamDialog" /> + diff --git a/client/platform/web-girder/api/largeImage.service.ts b/client/platform/web-girder/api/largeImage.service.ts index 385754c5e..bfe07f28e 100644 --- a/client/platform/web-girder/api/largeImage.service.ts +++ b/client/platform/web-girder/api/largeImage.service.ts @@ -14,7 +14,7 @@ async function getTiles(itemId: string, projection = '') { } // eslint-disable-next-line @typescript-eslint/no-explicit-any function getTileURL(itemId: string, x: number, y: number, level: number, query: Record) { - let url = `${girderRest.apiRoot}/item/${itemId}/tiles/zxy/${level}/${x}/${y}`; + let url = `${girderRest.apiRoot}/item/${itemId}/tiles/${level}/${x}/${y}`; if (query) { const params = Object.keys(query).map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(query[k])}`).join('&'); url += `?${params}`; diff --git a/client/src/@types/pngjs.d.ts b/client/src/@types/pngjs.d.ts new file mode 100644 index 000000000..ad2c8d82d --- /dev/null +++ b/client/src/@types/pngjs.d.ts @@ -0,0 +1,11 @@ +declare module 'pngjs' { + export class PNG { + width: number; + height: number; + data: Buffer; + constructor(options?: { width?: number; height?: number }); + static sync: { + write(png: PNG, options?: object): Buffer; + }; + } +} diff --git a/client/yarn.lock b/client/yarn.lock index aed760db7..219021981 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1696,6 +1696,11 @@ resolved "https://registry.yarnpkg.com/@oozcitak/util/-/util-8.3.8.tgz#10f65fe1891fd8cde4957360835e78fd1936bfdd" integrity sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ== +"@petamoriken/float16@^3.4.7": + version "3.9.3" + resolved "https://registry.yarnpkg.com/@petamoriken/float16/-/float16-3.9.3.tgz#84acef4816db7e4c2fe1c4e8cf902bcbc0440ac3" + integrity sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -6838,6 +6843,20 @@ geojs@~1.6.2: hammerjs "^2.0.8" vtk.js "*" +geotiff@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/geotiff/-/geotiff-3.0.0.tgz#04468b02ed738dd333b34a1bcacd5814d83fb148" + integrity sha512-skoz9vd8ovTH1BUEt4Mzg2N0rcv3lQG+f57nokoPhIISg5bbmcX5okUe30avjfEuu0Yk24KZCr45nJvkUHBH1Q== + dependencies: + "@petamoriken/float16" "^3.4.7" + lerc "^3.0.0" + pako "^2.0.4" + parse-headers "^2.0.2" + quick-lru "^6.1.1" + web-worker "^1.5.0" + xml-utils "^1.10.2" + zstddec "^0.2.0" + get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" @@ -8704,6 +8723,11 @@ lazy-val@^1.0.4, lazy-val@^1.0.5: resolved "https://registry.yarnpkg.com/lazy-val/-/lazy-val-1.0.5.tgz#6cf3b9f5bc31cee7ee3e369c0832b7583dcd923d" integrity sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q== +lerc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lerc/-/lerc-3.0.0.tgz#36f36fbd4ba46f0abf4833799fff2e7d6865f5cb" + integrity sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww== + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -9612,6 +9636,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pako@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86" + integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug== + param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" @@ -9632,6 +9661,11 @@ parse-cache-control@^1.0.1: resolved "https://registry.yarnpkg.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz#8eeab3e54fa56920fe16ba38f77fa21aacc2d74e" integrity sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg== +parse-headers@^2.0.2: + version "2.0.6" + resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.6.tgz#7940f0abe5fe65df2dd25d4ce8800cb35b49d01c" + integrity sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A== + parse-json@^5.0.0, parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -9841,6 +9875,11 @@ plist@^3.0.1, plist@^3.0.4: base64-js "^1.5.1" xmlbuilder "^15.1.1" +pngjs@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-7.0.0.tgz#a8b7446020ebbc6ac739db6c5415a65d17090e26" + integrity sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow== + portfinder@^1.0.26: version "1.0.32" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81" @@ -10308,6 +10347,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quick-lru@^6.1.1: + version "6.1.2" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-6.1.2.tgz#e9a90524108629be35287d0b864e7ad6ceb3659e" + integrity sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ== + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -12403,6 +12447,11 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-worker@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.5.0.tgz#71b2b0fbcc4293e8f0aa4f6b8a3ffebff733dcc5" + integrity sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -12756,6 +12805,11 @@ xml-name-validator@^4.0.0: resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== +xml-utils@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/xml-utils/-/xml-utils-1.10.2.tgz#436b39ccc25a663ce367ea21abb717afdea5d6b1" + integrity sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA== + xmlbuilder2@3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz#fc499688b35a916f269e7b459c2fa02bb5c0822a" @@ -12865,3 +12919,8 @@ yorkie@^2.0.0: is-ci "^1.0.10" normalize-path "^1.0.0" strip-indent "^2.0.0" + +zstddec@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/zstddec/-/zstddec-0.2.0.tgz#91c8cde8f351ef5fe0bdfca66bb14a5fa0d16d71" + integrity sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA== From fd6d3fef99f8d8cfc8ebca4ff8ebf419504b244a Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 27 Mar 2026 13:02:55 -0400 Subject: [PATCH 2/9] faster loading --- .../desktop/backend/tiles/geotiffTiles.ts | 617 ++++++++++++++---- 1 file changed, 501 insertions(+), 116 deletions(-) diff --git a/client/platform/desktop/backend/tiles/geotiffTiles.ts b/client/platform/desktop/backend/tiles/geotiffTiles.ts index 1a8cfc321..ab39eab43 100644 --- a/client/platform/desktop/backend/tiles/geotiffTiles.ts +++ b/client/platform/desktop/backend/tiles/geotiffTiles.ts @@ -11,7 +11,7 @@ */ import fs from 'fs-extra'; -import { fromArrayBuffer } from 'geotiff'; +import { fromArrayBuffer, fromFile } from 'geotiff'; import { PNG } from 'pngjs'; import type { Settings } from 'platform/desktop/constants'; import { getLargeImagePath } from '../native/common'; @@ -47,7 +47,7 @@ export function getValidTileRanges( levels: number, ): TileRange[] { const ranges: TileRange[] = []; - const maxLevel = Math.ceil(Math.log2(Math.max(sizeX / TILE_SIZE, sizeY / TILE_SIZE))); + const maxLevel = Math.max(0, levels); for (let level = 0; level <= maxLevel; level += 1) { const scale = 2 ** (maxLevel - level); const maxX = Math.max(0, Math.ceil(sizeX / (TILE_SIZE * scale)) - 1); @@ -69,7 +69,8 @@ export function getValidTileList( ): { z: number; x: number; y: number }[] { const ranges = getValidTileRanges(sizeX, sizeY, levels); const tiles: { z: number; x: number; y: number }[] = []; - for (const { level, maxX, maxY } of ranges) { + for (let i = 0; i < ranges.length; i += 1) { + const { level, maxX, maxY } = ranges[i]; for (let x = 0; x <= maxX && tiles.length < limit; x += 1) { for (let y = 0; y <= maxY && tiles.length < limit; y += 1) { tiles.push({ z: level, x, y }); @@ -80,6 +81,328 @@ export function getValidTileList( } const LOG_PREFIX = '[tiles]'; +const MAX_TILE_CACHE_ENTRIES = 256; +const TIFF_STAT_REFRESH_MS = 1000; +const DEFAULT_MAX_IN_MEMORY_TIFF_MB = 5120; +const PATH_CACHE_TTL_MS = 5000; +type RasterArray = Uint8Array | Uint16Array | Float32Array | Float64Array; + +interface GeoTiffReadableImage { + getWidth(): number; + getHeight(): number; + getSamplesPerPixel(): number; + readRasters(options: Record): Promise; + readRGB(options: Record): Promise; +} + +interface GeoTiffReadable { + getImage(index: number): Promise; + getImageCount?: () => Promise | number; +} + +interface TiffImageSource { + index: number; + image: GeoTiffReadableImage; + width: number; + height: number; + overviewLevel: number; + scaleX: number; + scaleY: number; + scale: number; +} + +interface RawTiffImageEntry { + index: number; + image: GeoTiffReadableImage; + width: number; + height: number; +} + +interface TiffContext { + path: string; + size: number; + mtimeMs: number; + inMemory: boolean; + tiff: unknown; + image: GeoTiffReadableImage; + imageSources: TiffImageSource[]; + width: number; + height: number; + maxLevel: number; +} + +let tiffContext: TiffContext | null = null; +let tiffContextPromise: Promise | null = null; +let tiffContextStatCheckedAtMs = 0; +const datasetPathCache = new Map(); +const datasetPathPromiseCache = new Map>(); + +const tilePngCache = new Map(); + +function touchTileCacheEntry(key: string, value: Buffer): void { + if (tilePngCache.has(key)) { + tilePngCache.delete(key); + } + tilePngCache.set(key, value); + if (tilePngCache.size > MAX_TILE_CACHE_ENTRIES) { + const oldestKey = tilePngCache.keys().next().value as string | undefined; + if (oldestKey) { + tilePngCache.delete(oldestKey); + } + } +} + +function getTileCacheKey(path: string, level: number, x: number, y: number): string { + return `${path}::${level}/${x}/${y}`; +} + +function resetTileCacheForPath(path: string): void { + const prefix = `${path}::`; + const keys = Array.from(tilePngCache.keys()); + keys.forEach((key) => { + if (key.startsWith(prefix)) { + tilePngCache.delete(key); + } + }); +} + +function getCachedDatasetPath(datasetId: string): string | null | undefined { + const now = Date.now(); + const hit = datasetPathCache.get(datasetId); + if (!hit) return undefined; + if (hit.expiresAtMs <= now) { + datasetPathCache.delete(datasetId); + return undefined; + } + return hit.path; +} + +function setCachedDatasetPath(datasetId: string, path: string | null): void { + datasetPathCache.set(datasetId, { + path, + expiresAtMs: Date.now() + PATH_CACHE_TTL_MS, + }); +} + +function getMaxInMemoryTiffBytes(): number { + const raw = process.env.MAX_IN_MEMORY_TIFF_MB; + if (!raw || raw.trim() === '') { + return DEFAULT_MAX_IN_MEMORY_TIFF_MB * 1024 * 1024; + } + const mb = Number(raw); + if (!Number.isFinite(mb) || mb <= 0) { + return 0; + } + return Math.floor(mb * 1024 * 1024); +} + +function shouldLoadTiffInMemory(sizeBytes: number): boolean { + if (process.env.TILES_FORCE_FILE_BACKED?.trim() === '1') { + return false; + } + if (process.env.TILES_FORCE_IN_MEMORY?.trim() === '1') { + return true; + } + return sizeBytes <= getMaxInMemoryTiffBytes(); +} + +async function loadTiffContext(path: string): Promise { + const stat = await fs.stat(path); + const inMemory = shouldLoadTiffInMemory(stat.size); + const tiff = inMemory + ? await fs.readFile(path).then((buffer) => { + const arrayBuffer = buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ); + return fromArrayBuffer(arrayBuffer); + }) + // File-backed mode fetches bytes lazily and keeps process memory lower. + : await fromFile(path); + const tiffReadable = tiff as GeoTiffReadable; + const imageCountRaw = tiffReadable.getImageCount ? await tiffReadable.getImageCount() : 1; + const imageCount = Number.isFinite(imageCountRaw) ? Math.max(1, Math.floor(Number(imageCountRaw))) : 1; + const rawImages: RawTiffImageEntry[] = []; + for (let i = 0; i < imageCount; i += 1) { + // eslint-disable-next-line no-await-in-loop + const image = await tiffReadable.getImage(i); + rawImages.push({ + index: i, + image, + width: image.getWidth(), + height: image.getHeight(), + }); + } + // Use the largest IFD as full resolution; overviews are typically smaller. + let fullRes = rawImages[0]; + for (let i = 1; i < rawImages.length; i += 1) { + const candidate = rawImages[i]; + if ((candidate.width * candidate.height) > (fullRes.width * fullRes.height)) { + fullRes = candidate; + } + } + const { width, height } = fullRes; + const theoreticalMaxLevel = Math.max(0, Math.ceil(Math.log2(Math.max(width / TILE_SIZE, height / TILE_SIZE)))); + const imageSources = buildAlignedImageSources(rawImages, fullRes); + const maxAvailableScale = imageSources.length > 0 + ? imageSources[imageSources.length - 1].scale + : 1; + const availableMaxLevel = Math.max(0, Math.floor(Math.log2(Math.max(1, maxAvailableScale)))); + const maxLevel = Math.min(theoreticalMaxLevel, availableMaxLevel); + return { + path, + size: stat.size, + mtimeMs: stat.mtimeMs, + inMemory, + tiff, + image: fullRes.image, + imageSources, + width, + height, + maxLevel, + }; +} + +function buildAlignedImageSources(rawImages: RawTiffImageEntry[], fullRes: RawTiffImageEntry): TiffImageSource[] { + const fullResSamples = fullRes.image.getSamplesPerPixel(); + const aligned = rawImages.filter((entry) => { + if (entry.width <= 0 || entry.height <= 0) return false; + if (entry.width > fullRes.width || entry.height > fullRes.height) return false; + if (entry.image.getSamplesPerPixel() !== fullResSamples) return false; + const scaleX = fullRes.width / entry.width; + const scaleY = fullRes.height / entry.height; + const aspectDelta = Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY, 1); + return aspectDelta <= 0.03; + }); + // Always include full-res source as a safe fallback. + const withFallback = aligned.some((entry) => entry.index === fullRes.index) + ? aligned + : [...aligned, fullRes]; + const byOverviewLevel = new Map(); + withFallback.forEach((entry) => { + const rawScaleX = Math.max(1, fullRes.width / Math.max(1, entry.width)); + const rawScaleY = Math.max(1, fullRes.height / Math.max(1, entry.height)); + const rawScale = Math.max(rawScaleX, rawScaleY); + const overviewLevel = Math.max(0, Math.round(Math.log2(rawScale))); + const expectedScale = 2 ** overviewLevel; + const scaleError = Math.max( + Math.abs(rawScaleX - expectedScale) / expectedScale, + Math.abs(rawScaleY - expectedScale) / expectedScale, + ); + // Keep only pyramid-like levels; odd non-pyramid IFDs can cause visible tile seams. + if (entry.index !== fullRes.index && scaleError > 0.12) { + return; + } + const candidate: TiffImageSource = { + index: entry.index, + image: entry.image, + width: entry.width, + height: entry.height, + overviewLevel, + scaleX: rawScaleX, + scaleY: rawScaleY, + scale: expectedScale, + }; + const existing = byOverviewLevel.get(overviewLevel); + if (!existing) { + byOverviewLevel.set(overviewLevel, candidate); + return; + } + const existingError = Math.max( + Math.abs(existing.scaleX - expectedScale) / expectedScale, + Math.abs(existing.scaleY - expectedScale) / expectedScale, + ); + // Prefer the source whose dimensions are closer to the expected pyramid level. + if (scaleError < existingError) { + byOverviewLevel.set(overviewLevel, candidate); + } + }); + if (!byOverviewLevel.has(0)) { + byOverviewLevel.set(0, { + index: fullRes.index, + image: fullRes.image, + width: fullRes.width, + height: fullRes.height, + overviewLevel: 0, + scaleX: 1, + scaleY: 1, + scale: 1, + }); + } + return Array.from(byOverviewLevel.values()).sort((a, b) => a.overviewLevel - b.overviewLevel); +} + +function pickImageSourceForScale(imageSources: TiffImageSource[], requestedScale: number): TiffImageSource { + if (process.env.TILES_DISABLE_OVERVIEW_SELECTION?.trim() === '1') { + return imageSources[0]; + } + const target = Math.max(1, requestedScale); + const targetOverviewLevel = Math.max(0, Math.round(Math.log2(target))); + const exact = imageSources.find((source) => source.overviewLevel === targetOverviewLevel); + if (exact) { + return exact; + } + // Prefer finer-than-requested sources (avoid upscaling when no exact level exists). + let best = imageSources[0]; + let bestLevel = -1; + for (let i = 0; i < imageSources.length; i += 1) { + const source = imageSources[i]; + if (source.overviewLevel <= targetOverviewLevel && source.overviewLevel > bestLevel) { + best = source; + bestLevel = source.overviewLevel; + } + } + if (bestLevel >= 0) { + return best; + } + // Fallback: no finer level exists, use the nearest coarser one. + for (let i = 0; i < imageSources.length; i += 1) { + const source = imageSources[i]; + if (source.overviewLevel > targetOverviewLevel) { + return source; + } + } + return imageSources[imageSources.length - 1]; +} + +async function getCachedTiffContext(path: string): Promise { + const now = Date.now(); + if ( + tiffContext + && tiffContext.path === path + && now - tiffContextStatCheckedAtMs < TIFF_STAT_REFRESH_MS + ) { + return tiffContext; + } + const stat = await fs.stat(path); + tiffContextStatCheckedAtMs = now; + const cacheValid = tiffContext + && tiffContext.path === path + && tiffContext.size === stat.size + && tiffContext.mtimeMs === stat.mtimeMs; + + if (cacheValid && tiffContext) { + return tiffContext; + } + + if (!tiffContextPromise) { + tiffContextPromise = loadTiffContext(path).then((ctx) => { + const pathChanged = tiffContext && tiffContext.path !== ctx.path; + const fileChanged = tiffContext + && tiffContext.path === ctx.path + && (tiffContext.size !== ctx.size || tiffContext.mtimeMs !== ctx.mtimeMs); + if (pathChanged || fileChanged) { + resetTileCacheForPath(ctx.path); + } + tiffContext = ctx; + return ctx; + }).finally(() => { + tiffContextPromise = null; + }); + } + + return tiffContextPromise; +} /** If set (e.g. DEBUG_TILE_PATH=/path/to/lake.tiff), use this path for all tile requests (for debugging). */ function getEffectiveTiffPath( @@ -97,7 +420,24 @@ function getEffectiveTiffPath( return null; }); } - return getLargeImagePath(settings, datasetId); + const cachedPath = getCachedDatasetPath(datasetId); + if (cachedPath !== undefined) { + return Promise.resolve(cachedPath); + } + const inFlight = datasetPathPromiseCache.get(datasetId); + if (inFlight) { + return inFlight; + } + const lookupPromise = getLargeImagePath(settings, datasetId) + .then((path) => { + setCachedDatasetPath(datasetId, path); + return path; + }) + .finally(() => { + datasetPathPromiseCache.delete(datasetId); + }); + datasetPathPromiseCache.set(datasetId, lookupPromise); + return lookupPromise; } /** @@ -114,24 +454,10 @@ export async function getTilesMetadata( console.warn(`${LOG_PREFIX} getTilesMetadata: no large-image path for dataset "${datasetId}" (not large-image or missing originalLargeImageFile in meta)`); return null; } - if (!(await fs.pathExists(tiffPath))) { - console.warn(`${LOG_PREFIX} getTilesMetadata: file does not exist for dataset "${datasetId}": ${tiffPath}`); - return null; - } try { - console.log(`${LOG_PREFIX} getTilesMetadata: reading file (${tiffPath.length} chars path)`); - const buffer = await fs.readFile(tiffPath); - console.log(`${LOG_PREFIX} getTilesMetadata: file size ${buffer.length} bytes`); - const arrayBuffer = buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, - ); - const tiff = await fromArrayBuffer(arrayBuffer); - const image = await tiff.getImage(0); - const width = image.getWidth(); - const height = image.getHeight(); + const ctx = await getCachedTiffContext(tiffPath); + const { width, height, maxLevel } = ctx; // geoJS pixelCoordinateParams uses maxLevel = ceil(log2(max(w/256, h/256))); level 0 = overview, maxLevel = full res. - const maxLevel = Math.max(0, Math.ceil(Math.log2(Math.max(width / TILE_SIZE, height / TILE_SIZE)))); const tileRanges = getValidTileRanges(width, height, maxLevel); const validTileList = getValidTileList(width, height, maxLevel, 10000); console.log(`${LOG_PREFIX} getTilesMetadata: success width=${width} height=${height} maxLevel=${maxLevel} validTileList.length=${validTileList.length}`); @@ -145,6 +471,10 @@ export async function getTilesMetadata( validTileList, }; } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + console.warn(`${LOG_PREFIX} getTilesMetadata: file does not exist for dataset "${datasetId}": ${tiffPath}`); + return null; + } console.error(`${LOG_PREFIX} getTilesMetadata: failed to read/parse GeoTIFF for dataset "${datasetId}" (${tiffPath}):`, err); return null; } @@ -167,23 +497,22 @@ export async function getTilePng( console.warn(`${LOG_PREFIX} getTilePng: no large-image path for dataset "${datasetId}" (level=${level}, x=${x}, y=${y})`); return null; } - if (!(await fs.pathExists(tiffPath))) { - console.warn(`${LOG_PREFIX} getTilePng: file does not exist for dataset "${datasetId}" (level=${level}, x=${x}, y=${y}): ${tiffPath}`); - return null; - } try { - const buffer = await fs.readFile(tiffPath); - const arrayBuffer = buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, - ); - const tiff = await fromArrayBuffer(arrayBuffer); - const image = await tiff.getImage(0); - const width = image.getWidth(); - const height = image.getHeight(); + const ctx = await getCachedTiffContext(tiffPath); + const { + width, + height, + maxLevel, + imageSources, + } = ctx; + const tileCacheKey = getTileCacheKey(tiffPath, level, x, y); + const cached = tilePngCache.get(tileCacheKey); + if (cached) { + touchTileCacheEntry(tileCacheKey, cached); + return cached; + } // geoJS uses level 0 = overview (fewest tiles), maxLevel = full res (most tiles). // tilesAtZoom(level): scale = 2^(maxLevel - level). Match that here. - const maxLevel = Math.ceil(Math.log2(Math.max(width / TILE_SIZE, height / TILE_SIZE))); const levelClamped = Math.max(0, Math.min(level, maxLevel)); const scale = 2 ** (maxLevel - levelClamped); const left = Math.min(x * TILE_SIZE * scale, width); @@ -195,108 +524,73 @@ export async function getTilePng( return null; } // Image-space pixel window [left, top, right, bottom]; not bbox (geospatial). - const window = [left, top, right, bottom] as [number, number, number, number]; + const source = pickImageSourceForScale(imageSources, scale); + const sourceLeft = Math.min(source.width, Math.max(0, Math.floor(left / source.scaleX))); + const sourceTop = Math.min(source.height, Math.max(0, Math.floor(top / source.scaleY))); + const sourceRight = Math.min(source.width, Math.max(sourceLeft + 1, Math.ceil(right / source.scaleX))); + const sourceBottom = Math.min(source.height, Math.max(sourceTop + 1, Math.ceil(bottom / source.scaleY))); + const sourceWindow = [sourceLeft, sourceTop, sourceRight, sourceBottom] as [number, number, number, number]; + const sourceImage = source.image; if (process.env.DEBUG_TILE_PATH) { - console.log(`${LOG_PREFIX} getTilePng: level=${level} maxLevel=${maxLevel} scale=${scale} x=${x} y=${y} window=[${left},${top},${right},${bottom}] image=${width}x${height}`); + console.log(`${LOG_PREFIX} getTilePng: level=${level} maxLevel=${maxLevel} scale=${scale} x=${x} y=${y} window=[${left},${top},${right},${bottom}] sourceIFD=${source.index} sourceScale=${source.scale.toFixed(2)} sourceWindow=[${sourceLeft},${sourceTop},${sourceRight},${sourceBottom}] image=${width}x${height}`); } const opts = { - window, + window: sourceWindow, width: TILE_SIZE, height: TILE_SIZE, resampleMethod: 'bilinear' as const, }; const expectedPixels = TILE_SIZE * TILE_SIZE; - const expected = expectedPixels * 3; - const data = new Uint8Array(expected); + const rgba = new Uint8Array(expectedPixels * 4); + for (let i = 3; i < rgba.length; i += 4) { + rgba[i] = 255; + } - const sampleCount = image.getSamplesPerPixel(); - type RasterArray = Uint8Array | Uint16Array | Float32Array | Float64Array; + const sampleCount = sourceImage.getSamplesPerPixel(); try { if (sampleCount >= 3) { - const rasters = await image.readRasters({ + const rgbInterleaved = await sourceImage.readRasters({ ...opts, samples: [0, 1, 2], - interleave: false, - }) as RasterArray[]; - const r = (rasters[0] ?? new Uint8Array(0)) as RasterArray; - const g = (rasters[1] ?? r) as RasterArray; - const b = (rasters[2] ?? r) as RasterArray; - const r8 = new Uint8Array(expectedPixels); - const g8 = new Uint8Array(expectedPixels); - const b8 = new Uint8Array(expectedPixels); - normalizeToU8(r, r8); - normalizeToU8(g, g8); - normalizeToU8(b, b8); - for (let i = 0; i < expectedPixels; i += 1) { - data[i * 3] = r8[i]; - data[i * 3 + 1] = g8[i]; - data[i * 3 + 2] = b8[i]; - } + interleave: true, + } as Record) as RasterArray; + normalizeInterleavedRgbToRgba(rgbInterleaved, rgba, expectedPixels); } else { - const rasters = await image.readRasters({ + const rasters = await sourceImage.readRasters({ ...opts, samples: [0], interleave: false, - }) as RasterArray[]; + } as Record) as RasterArray[]; const gray = (rasters[0] ?? new Uint8Array(0)) as RasterArray; - const g8 = new Uint8Array(expectedPixels); - normalizeToU8(gray, g8); - for (let i = 0; i < expectedPixels; i += 1) { - const v = g8[i]; - data[i * 3] = v; - data[i * 3 + 1] = v; - data[i * 3 + 2] = v; - } + normalizeGrayToRgba(gray, rgba, expectedPixels); } if (process.env.DEBUG_TILE_PATH) { - console.log(`${LOG_PREFIX} getTilePng: readRasters samples=${sampleCount} data length=${data.length}`); + console.log(`${LOG_PREFIX} getTilePng: readRasters samples=${sampleCount} rgba length=${rgba.length}`); } } catch (readErr) { if (process.env.DEBUG_TILE_PATH) { console.warn(`${LOG_PREFIX} getTilePng: readRasters failed, trying readRGB:`, readErr); } try { - const rgb = await image.readRGB(opts); - const rgbBands = rgb as unknown as { [0]: RasterArray; [1]: RasterArray; [2]: RasterArray }; - if (rgb && rgbBands[0] !== undefined && rgbBands[1] !== undefined && rgbBands[2] !== undefined) { - const r8 = new Uint8Array(expectedPixels); - const g8 = new Uint8Array(expectedPixels); - const b8 = new Uint8Array(expectedPixels); - normalizeToU8(rgbBands[0], r8); - normalizeToU8(rgbBands[1], g8); - normalizeToU8(rgbBands[2], b8); - for (let i = 0; i < expectedPixels; i += 1) { - data[i * 3] = r8[i]; - data[i * 3 + 1] = g8[i]; - data[i * 3 + 2] = b8[i]; - } - } else { - const raw = (rgb as unknown as Uint8Array); - if (raw && raw.length >= expected) { - data.set(raw.subarray(0, expected)); - } else if (raw && raw.length > 0) { - const g8 = new Uint8Array(expectedPixels); - normalizeToU8(raw as RasterArray, g8); - for (let i = 0; i < expectedPixels; i += 1) { - const v = g8[i]; - data[i * 3] = v; - data[i * 3 + 1] = v; - data[i * 3 + 2] = v; - } - } - } + const rgb = await sourceImage.readRGB(opts as Record); + fillRgbaFromReadRgbResult(rgb, rgba, expectedPixels); } catch { throw readErr; } } - const pngBuffer = encodePng(TILE_SIZE, TILE_SIZE, data); + const pngBuffer = encodePngRgba(TILE_SIZE, TILE_SIZE, rgba); if (process.env.DEBUG_TILE_PATH) { console.log(`${LOG_PREFIX} getTilePng: encodePng done, buffer length=${pngBuffer.length}`); } + touchTileCacheEntry(tileCacheKey, pngBuffer); return pngBuffer; } catch (err) { // LZW/geotiff decode errors (e.g. "ran off the end of the buffer before // finding EOI_CODE") or other tile read failures: treat as missing tile. + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + console.warn(`${LOG_PREFIX} getTilePng: file does not exist for dataset "${datasetId}" (level=${level}, x=${x}, y=${y}): ${tiffPath}`); + return null; + } console.error(`${LOG_PREFIX} getTilePng: failed to read tile for dataset "${datasetId}" level=${level} x=${x} y=${y} (${tiffPath}):`, err); return null; } @@ -314,6 +608,11 @@ function normalizeToU8( ): void { const n = Math.min(raw.length, Math.floor((out.length - offset) / stride)); if (n === 0) return; + const outView = out; + if (raw instanceof Uint8Array && offset === 0 && stride === 1 && n === out.length) { + outView.set(raw.subarray(0, n)); + return; + } let min = raw[0]; let max = raw[0]; for (let i = 1; i < n; i += 1) { @@ -321,31 +620,117 @@ function normalizeToU8( if (v < min) min = v; if (v > max) max = v; } - const range = max - min || 1; + const range = max - min; + if (range <= 0 || !Number.isFinite(range)) { + for (let i = 0; i < n; i += 1) { + outView[offset + i * stride] = 0; + } + return; + } + const scale = 255 / range; for (let i = 0; i < n; i += 1) { - const v = (Number(raw[i]) - min) / range; - out[offset + i * stride] = Math.max(0, Math.min(255, Math.round(v * 255))); + outView[offset + i * stride] = Math.round((Number(raw[i]) - min) * scale); } } -function interleaveRgb(r: Uint8Array, g: Uint8Array, b: Uint8Array): Uint8Array { - const n = r.length; - const out = new Uint8Array(n * 3); +function normalizeInterleavedRgbToRgba( + raw: Uint8Array | Uint16Array | Float32Array | Float64Array, + outRgba: Uint8Array, + pixelCount: number, +): void { + if (pixelCount <= 0 || raw.length < 3) return; + const out = outRgba; + const n = Math.min(pixelCount, Math.floor(raw.length / 3)); + if (raw instanceof Uint8Array) { + for (let i = 0; i < n; i += 1) { + const src = i * 3; + const dst = i * 4; + out[dst] = raw[src]; + out[dst + 1] = raw[src + 1] ?? raw[src]; + out[dst + 2] = raw[src + 2] ?? raw[src]; + } + return; + } + let rMin = Number(raw[0]); + let rMax = rMin; + let gMin = Number(raw[1] ?? raw[0]); + let gMax = gMin; + let bMin = Number(raw[2] ?? raw[0]); + let bMax = bMin; + for (let i = 0; i < n; i += 1) { + const base = i * 3; + const r = Number(raw[base]); + const g = Number(raw[base + 1] ?? r); + const b = Number(raw[base + 2] ?? r); + if (r < rMin) rMin = r; + if (r > rMax) rMax = r; + if (g < gMin) gMin = g; + if (g > gMax) gMax = g; + if (b < bMin) bMin = b; + if (b > bMax) bMax = b; + } + const rScale = rMax > rMin ? 255 / (rMax - rMin) : 0; + const gScale = gMax > gMin ? 255 / (gMax - gMin) : 0; + const bScale = bMax > bMin ? 255 / (bMax - bMin) : 0; for (let i = 0; i < n; i += 1) { - out[i * 3] = r[i]; - out[i * 3 + 1] = g[i]; - out[i * 3 + 2] = b[i]; + const src = i * 3; + const dst = i * 4; + out[dst] = rScale ? Math.round((Number(raw[src]) - rMin) * rScale) : 0; + out[dst + 1] = gScale ? Math.round((Number(raw[src + 1] ?? raw[src]) - gMin) * gScale) : 0; + out[dst + 2] = bScale ? Math.round((Number(raw[src + 2] ?? raw[src]) - bMin) * bScale) : 0; } - return out; } -function encodePng(width: number, height: number, rgb: Uint8Array): Buffer { - const png = new PNG({ width, height }); - for (let i = 0; i < width * height; i += 1) { - png.data[i * 4] = rgb[i * 3]; - png.data[i * 4 + 1] = rgb[i * 3 + 1]; - png.data[i * 4 + 2] = rgb[i * 3 + 2]; - png.data[i * 4 + 3] = 255; +function normalizeGrayToRgba( + raw: Uint8Array | Uint16Array | Float32Array | Float64Array, + outRgba: Uint8Array, + pixelCount: number, +): void { + if (pixelCount <= 0 || raw.length === 0) return; + const out = outRgba; + const n = Math.min(pixelCount, raw.length); + if (raw instanceof Uint8Array) { + for (let i = 0; i < n; i += 1) { + const dst = i * 4; + const v = raw[i]; + out[dst] = v; + out[dst + 1] = v; + out[dst + 2] = v; + } + return; } + normalizeToU8(raw, outRgba, 0, 4); + for (let i = 0; i < pixelCount; i += 1) { + const dst = i * 4; + const v = out[dst]; + out[dst + 1] = v; + out[dst + 2] = v; + } +} + +function fillRgbaFromReadRgbResult( + rgb: unknown, + outRgba: Uint8Array, + pixelCount: number, +): void { + const rgbBands = rgb as { [0]?: RasterArray; [1]?: RasterArray; [2]?: RasterArray }; + if (rgb && rgbBands[0] !== undefined && rgbBands[1] !== undefined && rgbBands[2] !== undefined) { + normalizeToU8(rgbBands[0], outRgba, 0, 4); + normalizeToU8(rgbBands[1], outRgba, 1, 4); + normalizeToU8(rgbBands[2], outRgba, 2, 4); + return; + } + const raw = rgb as RasterArray | undefined; + if (!raw || raw.length === 0) return; + if (raw.length >= pixelCount * 3) { + normalizeInterleavedRgbToRgba(raw, outRgba, pixelCount); + } else { + normalizeGrayToRgba(raw, outRgba, pixelCount); + } +} + +function encodePngRgba(width: number, height: number, rgba: Uint8Array): Buffer { + const png = new PNG({ width, height }); + png.data.set(rgba); return PNG.sync.write(png); } From ae61a5f4e99acfec0c0f902e29018190562bca0f Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Sat, 28 Mar 2026 13:10:10 -0400 Subject: [PATCH 3/9] restructure logic --- client/platform/desktop/backend/server.ts | 4 ++ .../desktop/backend/tiles/geotiffTiles.ts | 35 +++++++++++-- .../web-girder/api/largeImage.service.ts | 2 +- .../annotators/LargeImageAnnotator.vue | 49 ++++++++++++++++--- 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/client/platform/desktop/backend/server.ts b/client/platform/desktop/backend/server.ts index 774c77413..42ebdb428 100644 --- a/client/platform/desktop/backend/server.ts +++ b/client/platform/desktop/backend/server.ts @@ -164,6 +164,10 @@ apirouter.get('/dataset/:id/:camera?/tiles', async (req, res, next) => { console.warn(`[tiles] GET tiles metadata 404: datasetId=${datasetId} (see tile layer logs for reason)`); return next({ status: 404, statusMessage: 'Dataset not found or is not a large image' }); } + if (meta.preconversionRequired && meta.error) { + console.warn(`[tiles] GET tiles metadata 422: datasetId=${datasetId} requires pre-conversion`); + return next({ status: 422, statusMessage: meta.error }); + } console.log(`[tiles] GET tiles metadata 200: datasetId=${datasetId} sizeX=${meta.sizeX} sizeY=${meta.sizeY} levels=${meta.levels}`); res.json(meta); } catch (err) { diff --git a/client/platform/desktop/backend/tiles/geotiffTiles.ts b/client/platform/desktop/backend/tiles/geotiffTiles.ts index ab39eab43..31dec52d8 100644 --- a/client/platform/desktop/backend/tiles/geotiffTiles.ts +++ b/client/platform/desktop/backend/tiles/geotiffTiles.ts @@ -27,9 +27,14 @@ export interface TileRange { export interface TilesMetadata { sizeX: number; sizeY: number; + sourceSizeX?: number; + sourceSizeY?: number; tileWidth: number; tileHeight: number; levels: number; + sourceLevels?: number; + preconversionRequired?: boolean; + error?: string; /** Valid tile indices per level: for each level, x in [0, maxX], y in [0, maxY]. */ tileRanges: TileRange[]; /** List of valid { z, x, y } tile coordinates (capped by default limit). */ @@ -128,7 +133,10 @@ interface TiffContext { imageSources: TiffImageSource[]; width: number; height: number; + sourceMaxLevel: number; maxLevel: number; + preconversionRequired: boolean; + preconversionError: string | null; } let tiffContext: TiffContext | null = null; @@ -249,6 +257,10 @@ async function loadTiffContext(path: string): Promise { : 1; const availableMaxLevel = Math.max(0, Math.floor(Math.log2(Math.max(1, maxAvailableScale)))); const maxLevel = Math.min(theoreticalMaxLevel, availableMaxLevel); + const preconversionRequired = theoreticalMaxLevel > 0 && availableMaxLevel === 0; + const preconversionError = preconversionRequired + ? 'This large image is missing internal overview levels and must be pre-converted before viewing tiles. Please convert with GDAL (for example to a tiled pyramidal COG) and re-import.' + : null; return { path, size: stat.size, @@ -259,7 +271,10 @@ async function loadTiffContext(path: string): Promise { imageSources, width, height, + sourceMaxLevel: theoreticalMaxLevel, maxLevel, + preconversionRequired, + preconversionError, }; } @@ -456,17 +471,31 @@ export async function getTilesMetadata( } try { const ctx = await getCachedTiffContext(tiffPath); - const { width, height, maxLevel } = ctx; + const { + width, + height, + maxLevel, + sourceMaxLevel, + preconversionRequired, + preconversionError, + } = ctx; + const levelCount = maxLevel + 1; + const sourceLevelCount = sourceMaxLevel + 1; // geoJS pixelCoordinateParams uses maxLevel = ceil(log2(max(w/256, h/256))); level 0 = overview, maxLevel = full res. const tileRanges = getValidTileRanges(width, height, maxLevel); const validTileList = getValidTileList(width, height, maxLevel, 10000); - console.log(`${LOG_PREFIX} getTilesMetadata: success width=${width} height=${height} maxLevel=${maxLevel} validTileList.length=${validTileList.length}`); + console.log(`${LOG_PREFIX} getTilesMetadata: success width=${width} height=${height} maxLevel=${maxLevel} sourceMaxLevel=${sourceMaxLevel} preconversionRequired=${preconversionRequired} validTileList.length=${validTileList.length}`); return { sizeX: width, sizeY: height, + sourceSizeX: width, + sourceSizeY: height, tileWidth: TILE_SIZE, tileHeight: TILE_SIZE, - levels: maxLevel, + levels: levelCount, + sourceLevels: sourceLevelCount, + preconversionRequired, + error: preconversionError ?? undefined, tileRanges, validTileList, }; diff --git a/client/platform/web-girder/api/largeImage.service.ts b/client/platform/web-girder/api/largeImage.service.ts index bfe07f28e..385754c5e 100644 --- a/client/platform/web-girder/api/largeImage.service.ts +++ b/client/platform/web-girder/api/largeImage.service.ts @@ -14,7 +14,7 @@ async function getTiles(itemId: string, projection = '') { } // eslint-disable-next-line @typescript-eslint/no-explicit-any function getTileURL(itemId: string, x: number, y: number, level: number, query: Record) { - let url = `${girderRest.apiRoot}/item/${itemId}/tiles/${level}/${x}/${y}`; + let url = `${girderRest.apiRoot}/item/${itemId}/tiles/zxy/${level}/${x}/${y}`; if (query) { const params = Object.keys(query).map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(query[k])}`).join('&'); url += `?${params}`; diff --git a/client/src/components/annotators/LargeImageAnnotator.vue b/client/src/components/annotators/LargeImageAnnotator.vue index ff1d96013..c0c098e79 100644 --- a/client/src/components/annotators/LargeImageAnnotator.vue +++ b/client/src/components/annotators/LargeImageAnnotator.vue @@ -63,9 +63,14 @@ export default defineComponent({ }, getTileURL: { type: Function as PropType< - (itemId: string, x: number, y: number, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - level: number, query: Record) => string>, + ( + itemId: string, + level: number, + x: number, + y: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + query: Record, + ) => string>, required: true, }, imageEnhancementOutputs: { @@ -85,6 +90,8 @@ export default defineComponent({ setup(props) { const loadingVideo = ref(false); const loadingImage = ref(true); + const tileLoadError = ref(''); + let hasShownTileErrorPrompt = false; const cameraInitializer = injectCameraInitializer(); // eslint-disable-next-line prefer-const let geoSpatial = false; @@ -155,7 +162,7 @@ export default defineComponent({ ); local.nextLayer._options.maxLevel = newParams.layer.maxLevel; local.nextLayer._options.tileWidth = newParams.layer.tileWidth; - local.nextLayer._options.tileHeight = newParams.layer.tileWidth; + local.nextLayer._options.tileHeight = newParams.layer.tileHeight; local.nextLayer._options.tilesAtZoom = newParams.layer.tilesAtZoom; local.nextLayer._options.tilesMaxBounds = newParams.layer.tilesMaxBounds; local.nextLayer.url(_getTileURL(props.imageData[frame].id)); @@ -211,7 +218,7 @@ export default defineComponent({ geoViewer.value.onIdle(() => { local.currentLayer._options.maxLevel = newParams.layer.maxLevel; local.currentLayer._options.tileWidth = newParams.layer.tileWidth; - local.currentLayer._options.tileHeight = newParams.layer.tileWidth; + local.currentLayer._options.tileHeight = newParams.layer.tileHeight; local.currentLayer._options.tilesAtZoom = newParams.layer.tilesAtZoom; local.currentLayer._options.tilesMaxBounds = newParams.layer.tilesMaxBounds; local.currentLayer.url(_getTileURL(props.imageData[newFrame].id)); @@ -266,6 +273,8 @@ export default defineComponent({ { deep: true }, ); async function init() { + tileLoadError.value = ''; + hasShownTileErrorPrompt = false; data.maxFrame = props.imageData.length - 1; // Below are configuration settings we can set until we decide on good numbers to utilize. local = { @@ -293,7 +302,24 @@ export default defineComponent({ //const baseData = await props.getTiles(props.imageData[data.frame].id); //geoSpatial = !(!baseData.geospatial || !baseData.bounds); projection = geoSpatial ? 'EPSG:3857' : undefined; - const resp = await props.getTiles(props.imageData[data.frame].id, projection); + let resp; + try { + resp = await props.getTiles(props.imageData[data.frame].id, projection); + } catch (err) { + const fallbackMessage = 'Unable to load large-image tiles. This file may need to be pre-converted before viewing.'; + const message = (err as { response?: { data?: { message?: string } }; message?: string })?.response?.data?.message + || (err as { message?: string })?.message + || fallbackMessage; + tileLoadError.value = message; + loadingVideo.value = false; + loadingImage.value = false; + data.ready = false; + if (!hasShownTileErrorPrompt) { + hasShownTileErrorPrompt = true; + window.alert(message); + } + return; + } local.levels = resp.levels; local.width = resp.sizeX; local.height = resp.sizeY; @@ -373,7 +399,7 @@ export default defineComponent({ local.nextLayer = geoViewer.value.createLayer('osm', { ...localParams, ...newParams.layer }); local.nextLayer._options.maxLevel = newParams.layer.maxLevel; local.nextLayer._options.tileWidth = newParams.layer.tileWidth; - local.nextLayer._options.tileHeight = newParams.layer.tileWidth; + local.nextLayer._options.tileHeight = newParams.layer.tileHeight; local.nextLayer._options.tilesAtZoom = newParams.layer.tilesAtZoom; local.nextLayer._options.tilesMaxBounds = newParams.layer.tilesMaxBounds; local.nextLayer.url(_getTileURL(props.imageData[data.frame + 1].id, projection)); @@ -408,6 +434,7 @@ export default defineComponent({ data, loadingVideo, loadingImage, + tileLoadError, imageCursorRef: imageCursor, containerRef: container, cursorHandler, @@ -493,6 +520,14 @@ export default defineComponent({ @mouseleave="cursorHandler.handleMouseLeave" @mouseover="cursorHandler.handleMouseEnter" > + + {{ tileLoadError }} +
Date: Sat, 28 Mar 2026 14:26:51 -0400 Subject: [PATCH 4/9] basic functionality --- client/platform/desktop/backend/server.ts | 38 -- .../desktop/backend/tiles/geotiffTiles.ts | 451 +++++++++++++----- .../annotators/LargeImageAnnotator.vue | 97 +++- docs/Large-Image-Support.md | 45 ++ mkdocs.yml | 1 + 5 files changed, 459 insertions(+), 173 deletions(-) create mode 100644 docs/Large-Image-Support.md diff --git a/client/platform/desktop/backend/server.ts b/client/platform/desktop/backend/server.ts index 42ebdb428..8ec867de4 100644 --- a/client/platform/desktop/backend/server.ts +++ b/client/platform/desktop/backend/server.ts @@ -133,7 +133,6 @@ apirouter.get('/dataset/:id/:camera?/tiles/:level/:x/:y', async (req, res, next) const level = parseInt(req.params.level, 10); const x = parseInt(req.params.x, 10); const y = parseInt(req.params.y, 10); - console.log(`[tiles] GET tile request: datasetId=${datasetId} level=${level} x=${x} y=${y}`); if (Number.isNaN(level) || Number.isNaN(x) || Number.isNaN(y)) { return next({ status: 400, statusMessage: 'Invalid level, x, or y' }); } @@ -142,7 +141,6 @@ apirouter.get('/dataset/:id/:camera?/tiles/:level/:x/:y', async (req, res, next) console.warn(`[tiles] GET tile 404: datasetId=${datasetId} level=${level} x=${x} y=${y} (see tile layer logs for reason)`); return next({ status: 404, statusMessage: 'Tile not found or dataset is not a large image' }); } - console.log(`[tiles] GET tile 200: datasetId=${datasetId} level=${level} x=${x} y=${y} size=${png.length}`); res.setHeader('Content-Type', 'image/png'); res.send(png); } catch (err) { @@ -158,7 +156,6 @@ apirouter.get('/dataset/:id/:camera?/tiles', async (req, res, next) => { const datasetId = req.params.camera ? `${req.params.id}/${req.params.camera}` : req.params.id; - console.log(`[tiles] GET tiles metadata request: datasetId=${datasetId}`); const meta = await geotiffTiles.getTilesMetadata(settings.get(), datasetId); if (!meta) { console.warn(`[tiles] GET tiles metadata 404: datasetId=${datasetId} (see tile layer logs for reason)`); @@ -168,7 +165,6 @@ apirouter.get('/dataset/:id/:camera?/tiles', async (req, res, next) => { console.warn(`[tiles] GET tiles metadata 422: datasetId=${datasetId} requires pre-conversion`); return next({ status: 422, statusMessage: meta.error }); } - console.log(`[tiles] GET tiles metadata 200: datasetId=${datasetId} sizeX=${meta.sizeX} sizeY=${meta.sizeY} levels=${meta.levels}`); res.json(meta); } catch (err) { console.error('[tiles] GET tiles metadata error:', err); @@ -178,40 +174,6 @@ apirouter.get('/dataset/:id/:camera?/tiles', async (req, res, next) => { return null; }); -/* List valid tile z/x/y values for a dataset (for debugging / tooling) */ -apirouter.get('/dataset/:id/:camera?/tiles/list', async (req, res, next) => { - try { - const datasetId = req.params.camera - ? `${req.params.id}/${req.params.camera}` - : req.params.id; - const limit = Math.min( - Math.max(0, parseInt(String(req.query.limit), 10) || 10000), - 50000, - ); - const meta = await geotiffTiles.getTilesMetadata(settings.get(), datasetId); - if (!meta) { - return next({ status: 404, statusMessage: 'Dataset not found or is not a large image' }); - } - const tiles = geotiffTiles.getValidTileList( - meta.sizeX, - meta.sizeY, - meta.levels, - limit, - ); - res.json({ - tiles, - total: tiles.length, - limit, - tileRanges: meta.tileRanges, - }); - } catch (err) { - console.error('[tiles] GET tiles list error:', err); - (err as { status?: number }).status = 500; - next(err); - } - return null; -}); - /* STREAM media */ apirouter.get('/media', (req, res, next) => { let { path } = req.query; diff --git a/client/platform/desktop/backend/tiles/geotiffTiles.ts b/client/platform/desktop/backend/tiles/geotiffTiles.ts index 31dec52d8..e3f585202 100644 --- a/client/platform/desktop/backend/tiles/geotiffTiles.ts +++ b/client/platform/desktop/backend/tiles/geotiffTiles.ts @@ -37,8 +37,6 @@ export interface TilesMetadata { error?: string; /** Valid tile indices per level: for each level, x in [0, maxX], y in [0, maxY]. */ tileRanges: TileRange[]; - /** List of valid { z, x, y } tile coordinates (capped by default limit). */ - validTileList: { z: number; x: number; y: number }[]; } /** @@ -62,42 +60,32 @@ export function getValidTileRanges( return ranges; } -/** - * Build a list of valid { z, x, y } tile coordinates, optionally capped. - * @param limit max number of tiles to return (default 10000); omit for no cap - */ -export function getValidTileList( - sizeX: number, - sizeY: number, - levels: number, - limit = 10000, -): { z: number; x: number; y: number }[] { - const ranges = getValidTileRanges(sizeX, sizeY, levels); - const tiles: { z: number; x: number; y: number }[] = []; - for (let i = 0; i < ranges.length; i += 1) { - const { level, maxX, maxY } = ranges[i]; - for (let x = 0; x <= maxX && tiles.length < limit; x += 1) { - for (let y = 0; y <= maxY && tiles.length < limit; y += 1) { - tiles.push({ z: level, x, y }); - } - } - } - return tiles; -} - const LOG_PREFIX = '[tiles]'; const MAX_TILE_CACHE_ENTRIES = 256; const TIFF_STAT_REFRESH_MS = 1000; const DEFAULT_MAX_IN_MEMORY_TIFF_MB = 5120; const PATH_CACHE_TTL_MS = 5000; +const PRECONVERSION_GUIDANCE = [ + 'This large image is missing internal overview levels and must be pre-converted before viewing tiles.', + 'Run `gdalinfo --stats ".tif"` and note each band\'s minimum and maximum values.', + 'Then run `gdal_translate ".tif" "_cog_scaled.tif" -of COG -ot Byte -scale 0 255 -co BLOCKSIZE=256 -co COMPRESS=DEFLATE -co PREDICTOR=2 -co BIGTIFF=IF_SAFER -co NUM_THREADS=ALL_CPUS -co OVERVIEWS=IGNORE_EXISTING -co RESAMPLING=LANCZOS -co OVERVIEW_RESAMPLING=AVERAGE`.', + 'Re-import the converted file.', +].join(' '); type RasterArray = Uint8Array | Uint16Array | Float32Array | Float64Array; +interface SampleRange { + min: number; + max: number; +} + interface GeoTiffReadableImage { getWidth(): number; getHeight(): number; getSamplesPerPixel(): number; readRasters(options: Record): Promise; readRGB(options: Record): Promise; + getFileDirectory?: () => unknown; + fileDirectory?: unknown; } interface GeoTiffReadable { @@ -123,6 +111,17 @@ interface RawTiffImageEntry { height: number; } +interface TileWindow { + levelClamped: number; + scale: number; + left: number; + top: number; + right: number; + bottom: number; + outputWidth: number; + outputHeight: number; +} + interface TiffContext { path: string; size: number; @@ -133,6 +132,7 @@ interface TiffContext { imageSources: TiffImageSource[]; width: number; height: number; + sampleRanges: SampleRange[] | null; sourceMaxLevel: number; maxLevel: number; preconversionRequired: boolean; @@ -141,6 +141,7 @@ interface TiffContext { let tiffContext: TiffContext | null = null; let tiffContextPromise: Promise | null = null; +let tiffContextPromisePath: string | null = null; let tiffContextStatCheckedAtMs = 0; const datasetPathCache = new Map(); const datasetPathPromiseCache = new Map>(); @@ -227,7 +228,7 @@ async function loadTiffContext(path: string): Promise { }) // File-backed mode fetches bytes lazily and keeps process memory lower. : await fromFile(path); - const tiffReadable = tiff as GeoTiffReadable; + const tiffReadable = tiff as unknown as GeoTiffReadable; const imageCountRaw = tiffReadable.getImageCount ? await tiffReadable.getImageCount() : 1; const imageCount = Number.isFinite(imageCountRaw) ? Math.max(1, Math.floor(Number(imageCountRaw))) : 1; const rawImages: RawTiffImageEntry[] = []; @@ -250,6 +251,17 @@ async function loadTiffContext(path: string): Promise { } } const { width, height } = fullRes; + const sampleCount = fullRes.image.getSamplesPerPixel(); + let sampleRanges = getSampleRangesFromMetadata(fullRes.image, sampleCount); + if (!sampleRanges) { + for (let i = 0; i < rawImages.length; i += 1) { + sampleRanges = getSampleRangesFromMetadata(rawImages[i].image, sampleCount); + if (sampleRanges) break; + } + } + if (!sampleRanges) { + sampleRanges = await estimateSampleRangesFromOverview(rawImages, sampleCount); + } const theoreticalMaxLevel = Math.max(0, Math.ceil(Math.log2(Math.max(width / TILE_SIZE, height / TILE_SIZE)))); const imageSources = buildAlignedImageSources(rawImages, fullRes); const maxAvailableScale = imageSources.length > 0 @@ -258,9 +270,7 @@ async function loadTiffContext(path: string): Promise { const availableMaxLevel = Math.max(0, Math.floor(Math.log2(Math.max(1, maxAvailableScale)))); const maxLevel = Math.min(theoreticalMaxLevel, availableMaxLevel); const preconversionRequired = theoreticalMaxLevel > 0 && availableMaxLevel === 0; - const preconversionError = preconversionRequired - ? 'This large image is missing internal overview levels and must be pre-converted before viewing tiles. Please convert with GDAL (for example to a tiled pyramidal COG) and re-import.' - : null; + const preconversionError = preconversionRequired ? PRECONVERSION_GUIDANCE : null; return { path, size: stat.size, @@ -271,6 +281,7 @@ async function loadTiffContext(path: string): Promise { imageSources, width, height, + sampleRanges, sourceMaxLevel: theoreticalMaxLevel, maxLevel, preconversionRequired, @@ -279,6 +290,15 @@ async function loadTiffContext(path: string): Promise { } function buildAlignedImageSources(rawImages: RawTiffImageEntry[], fullRes: RawTiffImageEntry): TiffImageSource[] { + /** + * Select an IFD pyramid that behaves predictably for tile rendering: + * 1) keep only dimensions/samples compatible with full resolution, + * 2) snap each source to the nearest power-of-two overview level, + * 3) keep the best candidate per level and always keep full-res fallback. + * + * This avoids stitching seams from non-pyramidal sidecar IFDs while still + * making use of valid internal overviews when present. + */ const fullResSamples = fullRes.image.getSamplesPerPixel(); const aligned = rawImages.filter((entry) => { if (entry.width <= 0 || entry.height <= 0) return false; @@ -347,6 +367,134 @@ function buildAlignedImageSources(rawImages: RawTiffImageEntry[], fullRes: RawTi return Array.from(byOverviewLevel.values()).sort((a, b) => a.overviewLevel - b.overviewLevel); } +function getFileDirectory(image: GeoTiffReadableImage): Record | null { + if (typeof image.getFileDirectory === 'function') { + const raw = image.getFileDirectory(); + if (raw && typeof raw === 'object') { + return raw as Record; + } + return null; + } + if (image.fileDirectory && typeof image.fileDirectory === 'object') { + return image.fileDirectory as Record; + } + return null; +} + +function parseSampleValues(value: unknown): number[] { + if (typeof value === 'number' && Number.isFinite(value)) { + return [value]; + } + if (Array.isArray(value)) { + return value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)); + } + if (ArrayBuffer.isView(value)) { + const numericView = value as unknown as ArrayLike; + const out: number[] = []; + for (let i = 0; i < numericView.length; i += 1) { + const n = numericView[i]; + if (typeof n === 'number' && Number.isFinite(n)) { + out.push(n); + } + } + return out; + } + return []; +} + +function expandSampleValues(values: number[], sampleCount: number): number[] { + if (sampleCount <= 0) return []; + if (values.length === 0) return []; + if (values.length >= sampleCount) { + return values.slice(0, sampleCount); + } + const first = values[0]; + return Array.from({ length: sampleCount }, (_, i) => values[i] ?? first); +} + +function getSampleRangesFromMetadata( + image: GeoTiffReadableImage, + sampleCount: number, +): SampleRange[] | null { + if (sampleCount <= 0) return null; + const fileDirectory = getFileDirectory(image); + if (!fileDirectory) return null; + const mins = expandSampleValues( + parseSampleValues(fileDirectory.TIFFTAG_MINSAMPLEVALUE ?? fileDirectory.MinSampleValue), + sampleCount, + ); + const maxs = expandSampleValues( + parseSampleValues(fileDirectory.TIFFTAG_MAXSAMPLEVALUE ?? fileDirectory.MaxSampleValue), + sampleCount, + ); + if (mins.length === 0 || maxs.length === 0) return null; + const ranges: SampleRange[] = []; + for (let i = 0; i < sampleCount; i += 1) { + const min = mins[i]; + const max = maxs[i]; + if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { + return null; + } + ranges.push(mapAutoRange(min, max)); + } + return ranges; +} + +function mapAutoRange(min: number, max: number): SampleRange { + // large_image-like "auto": keep full 8-bit when native values already fit there. + return min >= 0 && max <= 255 ? { min: 0, max: 255 } : { min, max }; +} + +function getFiniteMinMax(raw: RasterArray): SampleRange | null { + if (raw.length === 0) return null; + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (let i = 0; i < raw.length; i += 1) { + const v = Number(raw[i]); + if (Number.isFinite(v)) { + if (v < min) min = v; + if (v > max) max = v; + } + } + if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { + return null; + } + return { min, max }; +} + +async function estimateSampleRangesFromOverview( + rawImages: RawTiffImageEntry[], + sampleCount: number, +): Promise { + if (rawImages.length === 0 || sampleCount <= 0) return null; + let smallest = rawImages[0]; + for (let i = 1; i < rawImages.length; i += 1) { + const candidate = rawImages[i]; + if ((candidate.width * candidate.height) < (smallest.width * smallest.height)) { + smallest = candidate; + } + } + const sampleIndices = Array.from({ length: sampleCount }, (_, i) => i); + try { + const rasters = await smallest.image.readRasters({ + window: [0, 0, smallest.width, smallest.height], + samples: sampleIndices, + interleave: false, + } as Record) as RasterArray[]; + const ranges: SampleRange[] = []; + for (let i = 0; i < sampleCount; i += 1) { + const band = rasters[i]; + if (!band) return null; + const minMax = getFiniteMinMax(band); + if (!minMax) return null; + ranges.push(mapAutoRange(minMax.min, minMax.max)); + } + return ranges; + } catch { + return null; + } +} + function pickImageSourceForScale(imageSources: TiffImageSource[], requestedScale: number): TiffImageSource { if (process.env.TILES_DISABLE_OVERVIEW_SELECTION?.trim() === '1') { return imageSources[0]; @@ -381,6 +529,12 @@ function pickImageSourceForScale(imageSources: TiffImageSource[], requestedScale } async function getCachedTiffContext(path: string): Promise { + /** + * Cache policy: + * - Return hot context quickly within a short stat refresh interval. + * - Re-stat and reuse context when file size/mtime match. + * - Coalesce concurrent reloads through a single in-flight promise per path. + */ const now = Date.now(); if ( tiffContext @@ -400,7 +554,8 @@ async function getCachedTiffContext(path: string): Promise { return tiffContext; } - if (!tiffContextPromise) { + if (!tiffContextPromise || tiffContextPromisePath !== path) { + tiffContextPromisePath = path; tiffContextPromise = loadTiffContext(path).then((ctx) => { const pathChanged = tiffContext && tiffContext.path !== ctx.path; const fileChanged = tiffContext @@ -412,13 +567,83 @@ async function getCachedTiffContext(path: string): Promise { tiffContext = ctx; return ctx; }).finally(() => { - tiffContextPromise = null; + if (tiffContextPromisePath === path) { + tiffContextPromise = null; + tiffContextPromisePath = null; + } }); } return tiffContextPromise; } +function clampToByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function normalizeSampleToU8(value: number, range?: SampleRange): number { + if (!range) { + return clampToByte(value); + } + const span = range.max - range.min; + if (!Number.isFinite(span) || span <= 0) { + return 0; + } + return clampToByte((value - range.min) * (255 / span)); +} + +function createOpaqueRgba(pixelCount: number): Uint8Array { + const rgba = new Uint8Array(pixelCount * 4); + for (let i = 3; i < rgba.length; i += 4) { + rgba[i] = 255; + } + return rgba; +} + +function computeTileWindow( + width: number, + height: number, + maxLevel: number, + level: number, + x: number, + y: number, +): TileWindow | null { + const levelClamped = Math.max(0, Math.min(level, maxLevel)); + const scale = 2 ** (maxLevel - levelClamped); + const left = Math.min(x * TILE_SIZE * scale, width); + const top = Math.min(y * TILE_SIZE * scale, height); + const right = Math.min((x + 1) * TILE_SIZE * scale, width); + const bottom = Math.min((y + 1) * TILE_SIZE * scale, height); + if (left >= right || top >= bottom) return null; + return { + levelClamped, + scale, + left, + top, + right, + bottom, + outputWidth: Math.max(1, Math.min(TILE_SIZE, Math.ceil((right - left) / scale))), + outputHeight: Math.max(1, Math.min(TILE_SIZE, Math.ceil((bottom - top) / scale))), + }; +} + +function computeSourceWindow( + source: TiffImageSource, + tileWindow: TileWindow, +): [number, number, number, number] { + const sourceLeft = Math.min(source.width, Math.max(0, Math.floor(tileWindow.left / source.scaleX))); + const sourceTop = Math.min(source.height, Math.max(0, Math.floor(tileWindow.top / source.scaleY))); + const sourceRight = Math.min( + source.width, + Math.max(sourceLeft + 1, Math.ceil(tileWindow.right / source.scaleX)), + ); + const sourceBottom = Math.min( + source.height, + Math.max(sourceTop + 1, Math.ceil(tileWindow.bottom / source.scaleY)), + ); + return [sourceLeft, sourceTop, sourceRight, sourceBottom]; +} + /** If set (e.g. DEBUG_TILE_PATH=/path/to/lake.tiff), use this path for all tile requests (for debugging). */ function getEffectiveTiffPath( settings: Settings, @@ -428,10 +653,8 @@ function getEffectiveTiffPath( if (debugPath) { return fs.pathExists(debugPath).then((exists) => { if (exists) { - console.log(`${LOG_PREFIX} using DEBUG_TILE_PATH: ${debugPath} (datasetId=${datasetId})`); return debugPath; } - console.warn(`${LOG_PREFIX} DEBUG_TILE_PATH set but file does not exist: ${debugPath}`); return null; }); } @@ -463,7 +686,6 @@ export async function getTilesMetadata( settings: Settings, datasetId: string, ): Promise { - console.log(`${LOG_PREFIX} getTilesMetadata: datasetId="${datasetId}"`); const tiffPath = await getEffectiveTiffPath(settings, datasetId); if (!tiffPath) { console.warn(`${LOG_PREFIX} getTilesMetadata: no large-image path for dataset "${datasetId}" (not large-image or missing originalLargeImageFile in meta)`); @@ -483,8 +705,6 @@ export async function getTilesMetadata( const sourceLevelCount = sourceMaxLevel + 1; // geoJS pixelCoordinateParams uses maxLevel = ceil(log2(max(w/256, h/256))); level 0 = overview, maxLevel = full res. const tileRanges = getValidTileRanges(width, height, maxLevel); - const validTileList = getValidTileList(width, height, maxLevel, 10000); - console.log(`${LOG_PREFIX} getTilesMetadata: success width=${width} height=${height} maxLevel=${maxLevel} sourceMaxLevel=${sourceMaxLevel} preconversionRequired=${preconversionRequired} validTileList.length=${validTileList.length}`); return { sizeX: width, sizeY: height, @@ -497,7 +717,6 @@ export async function getTilesMetadata( preconversionRequired, error: preconversionError ?? undefined, tileRanges, - validTileList, }; } catch (err) { if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { @@ -533,6 +752,7 @@ export async function getTilePng( height, maxLevel, imageSources, + sampleRanges, } = ctx; const tileCacheKey = getTileCacheKey(tiffPath, level, x, y); const cached = tilePngCache.get(tileCacheKey); @@ -542,38 +762,31 @@ export async function getTilePng( } // geoJS uses level 0 = overview (fewest tiles), maxLevel = full res (most tiles). // tilesAtZoom(level): scale = 2^(maxLevel - level). Match that here. - const levelClamped = Math.max(0, Math.min(level, maxLevel)); - const scale = 2 ** (maxLevel - levelClamped); - const left = Math.min(x * TILE_SIZE * scale, width); - const top = Math.min(y * TILE_SIZE * scale, height); - const right = Math.min((x + 1) * TILE_SIZE * scale, width); - const bottom = Math.min((y + 1) * TILE_SIZE * scale, height); - if (left >= right || top >= bottom) { - console.warn(`${LOG_PREFIX} getTilePng: tile window out of bounds for dataset "${datasetId}" level=${level} x=${x} y=${y} (image ${width}x${height}, window [${left},${top},${right},${bottom}])`); + const tileWindow = computeTileWindow(width, height, maxLevel, level, x, y); + if (!tileWindow) { + const attemptedScale = 2 ** (maxLevel - Math.max(0, Math.min(level, maxLevel))); + const attemptedLeft = Math.min(x * TILE_SIZE * attemptedScale, width); + const attemptedTop = Math.min(y * TILE_SIZE * attemptedScale, height); + const attemptedRight = Math.min((x + 1) * TILE_SIZE * attemptedScale, width); + const attemptedBottom = Math.min((y + 1) * TILE_SIZE * attemptedScale, height); + console.warn(`${LOG_PREFIX} getTilePng: tile window out of bounds for dataset "${datasetId}" level=${level} x=${x} y=${y} (image ${width}x${height}, window [${attemptedLeft},${attemptedTop},${attemptedRight},${attemptedBottom}])`); return null; } - // Image-space pixel window [left, top, right, bottom]; not bbox (geospatial). - const source = pickImageSourceForScale(imageSources, scale); - const sourceLeft = Math.min(source.width, Math.max(0, Math.floor(left / source.scaleX))); - const sourceTop = Math.min(source.height, Math.max(0, Math.floor(top / source.scaleY))); - const sourceRight = Math.min(source.width, Math.max(sourceLeft + 1, Math.ceil(right / source.scaleX))); - const sourceBottom = Math.min(source.height, Math.max(sourceTop + 1, Math.ceil(bottom / source.scaleY))); - const sourceWindow = [sourceLeft, sourceTop, sourceRight, sourceBottom] as [number, number, number, number]; + // Read window is in image-space pixels. We then remap the same window to the + // selected overview IFD so geotiff.js can resample to the requested tile size. + const source = pickImageSourceForScale(imageSources, tileWindow.scale); + const sourceWindow = computeSourceWindow(source, tileWindow); const sourceImage = source.image; if (process.env.DEBUG_TILE_PATH) { - console.log(`${LOG_PREFIX} getTilePng: level=${level} maxLevel=${maxLevel} scale=${scale} x=${x} y=${y} window=[${left},${top},${right},${bottom}] sourceIFD=${source.index} sourceScale=${source.scale.toFixed(2)} sourceWindow=[${sourceLeft},${sourceTop},${sourceRight},${sourceBottom}] image=${width}x${height}`); } const opts = { window: sourceWindow, - width: TILE_SIZE, - height: TILE_SIZE, + width: tileWindow.outputWidth, + height: tileWindow.outputHeight, resampleMethod: 'bilinear' as const, }; - const expectedPixels = TILE_SIZE * TILE_SIZE; - const rgba = new Uint8Array(expectedPixels * 4); - for (let i = 3; i < rgba.length; i += 4) { - rgba[i] = 255; - } + const expectedPixels = tileWindow.outputWidth * tileWindow.outputHeight; + const sampledRgba = createOpaqueRgba(expectedPixels); const sampleCount = sourceImage.getSamplesPerPixel(); try { @@ -583,7 +796,7 @@ export async function getTilePng( samples: [0, 1, 2], interleave: true, } as Record) as RasterArray; - normalizeInterleavedRgbToRgba(rgbInterleaved, rgba, expectedPixels); + normalizeInterleavedRgbToRgba(rgbInterleaved, sampledRgba, expectedPixels, sampleRanges); } else { const rasters = await sourceImage.readRasters({ ...opts, @@ -591,10 +804,9 @@ export async function getTilePng( interleave: false, } as Record) as RasterArray[]; const gray = (rasters[0] ?? new Uint8Array(0)) as RasterArray; - normalizeGrayToRgba(gray, rgba, expectedPixels); + normalizeGrayToRgba(gray, sampledRgba, expectedPixels, sampleRanges?.[0] ?? null); } if (process.env.DEBUG_TILE_PATH) { - console.log(`${LOG_PREFIX} getTilePng: readRasters samples=${sampleCount} rgba length=${rgba.length}`); } } catch (readErr) { if (process.env.DEBUG_TILE_PATH) { @@ -602,14 +814,15 @@ export async function getTilePng( } try { const rgb = await sourceImage.readRGB(opts as Record); - fillRgbaFromReadRgbResult(rgb, rgba, expectedPixels); + fillRgbaFromReadRgbResult(rgb, sampledRgba, expectedPixels, sampleRanges); } catch { throw readErr; } } + const rgba = new Uint8Array(TILE_SIZE * TILE_SIZE * 4); + blitRgba(sampledRgba, tileWindow.outputWidth, tileWindow.outputHeight, rgba, TILE_SIZE, TILE_SIZE); const pngBuffer = encodePngRgba(TILE_SIZE, TILE_SIZE, rgba); if (process.env.DEBUG_TILE_PATH) { - console.log(`${LOG_PREFIX} getTilePng: encodePng done, buffer length=${pngBuffer.length}`); } touchTileCacheEntry(tileCacheKey, pngBuffer); return pngBuffer; @@ -632,33 +845,15 @@ export async function getTilePng( function normalizeToU8( raw: Uint8Array | Uint16Array | Float32Array | Float64Array, out: Uint8Array, + range?: SampleRange, offset = 0, stride = 1, ): void { const n = Math.min(raw.length, Math.floor((out.length - offset) / stride)); if (n === 0) return; const outView = out; - if (raw instanceof Uint8Array && offset === 0 && stride === 1 && n === out.length) { - outView.set(raw.subarray(0, n)); - return; - } - let min = raw[0]; - let max = raw[0]; - for (let i = 1; i < n; i += 1) { - const v = raw[i]; - if (v < min) min = v; - if (v > max) max = v; - } - const range = max - min; - if (range <= 0 || !Number.isFinite(range)) { - for (let i = 0; i < n; i += 1) { - outView[offset + i * stride] = 0; - } - return; - } - const scale = 255 / range; for (let i = 0; i < n; i += 1) { - outView[offset + i * stride] = Math.round((Number(raw[i]) - min) * scale); + outView[offset + i * stride] = normalizeSampleToU8(Number(raw[i]), range); } } @@ -666,47 +861,23 @@ function normalizeInterleavedRgbToRgba( raw: Uint8Array | Uint16Array | Float32Array | Float64Array, outRgba: Uint8Array, pixelCount: number, + ranges: SampleRange[] | null = null, ): void { if (pixelCount <= 0 || raw.length < 3) return; const out = outRgba; const n = Math.min(pixelCount, Math.floor(raw.length / 3)); - if (raw instanceof Uint8Array) { - for (let i = 0; i < n; i += 1) { - const src = i * 3; - const dst = i * 4; - out[dst] = raw[src]; - out[dst + 1] = raw[src + 1] ?? raw[src]; - out[dst + 2] = raw[src + 2] ?? raw[src]; - } - return; - } - let rMin = Number(raw[0]); - let rMax = rMin; - let gMin = Number(raw[1] ?? raw[0]); - let gMax = gMin; - let bMin = Number(raw[2] ?? raw[0]); - let bMax = bMin; - for (let i = 0; i < n; i += 1) { - const base = i * 3; - const r = Number(raw[base]); - const g = Number(raw[base + 1] ?? r); - const b = Number(raw[base + 2] ?? r); - if (r < rMin) rMin = r; - if (r > rMax) rMax = r; - if (g < gMin) gMin = g; - if (g > gMax) gMax = g; - if (b < bMin) bMin = b; - if (b > bMax) bMax = b; - } - const rScale = rMax > rMin ? 255 / (rMax - rMin) : 0; - const gScale = gMax > gMin ? 255 / (gMax - gMin) : 0; - const bScale = bMax > bMin ? 255 / (bMax - bMin) : 0; + const rRange = ranges?.[0]; + const gRange = ranges?.[1] ?? rRange; + const bRange = ranges?.[2] ?? rRange; for (let i = 0; i < n; i += 1) { const src = i * 3; const dst = i * 4; - out[dst] = rScale ? Math.round((Number(raw[src]) - rMin) * rScale) : 0; - out[dst + 1] = gScale ? Math.round((Number(raw[src + 1] ?? raw[src]) - gMin) * gScale) : 0; - out[dst + 2] = bScale ? Math.round((Number(raw[src + 2] ?? raw[src]) - bMin) * bScale) : 0; + const r = Number(raw[src]); + const g = Number(raw[src + 1] ?? raw[src]); + const b = Number(raw[src + 2] ?? raw[src]); + out[dst] = normalizeSampleToU8(r, rRange); + out[dst + 1] = normalizeSampleToU8(g, gRange); + out[dst + 2] = normalizeSampleToU8(b, bRange); } } @@ -714,11 +885,12 @@ function normalizeGrayToRgba( raw: Uint8Array | Uint16Array | Float32Array | Float64Array, outRgba: Uint8Array, pixelCount: number, + range: SampleRange | null = null, ): void { if (pixelCount <= 0 || raw.length === 0) return; const out = outRgba; const n = Math.min(pixelCount, raw.length); - if (raw instanceof Uint8Array) { + if (!range && raw instanceof Uint8Array) { for (let i = 0; i < n; i += 1) { const dst = i * 4; const v = raw[i]; @@ -728,7 +900,7 @@ function normalizeGrayToRgba( } return; } - normalizeToU8(raw, outRgba, 0, 4); + normalizeToU8(raw, outRgba, range ?? undefined, 0, 4); for (let i = 0; i < pixelCount; i += 1) { const dst = i * 4; const v = out[dst]; @@ -741,20 +913,47 @@ function fillRgbaFromReadRgbResult( rgb: unknown, outRgba: Uint8Array, pixelCount: number, + ranges: SampleRange[] | null = null, ): void { const rgbBands = rgb as { [0]?: RasterArray; [1]?: RasterArray; [2]?: RasterArray }; if (rgb && rgbBands[0] !== undefined && rgbBands[1] !== undefined && rgbBands[2] !== undefined) { - normalizeToU8(rgbBands[0], outRgba, 0, 4); - normalizeToU8(rgbBands[1], outRgba, 1, 4); - normalizeToU8(rgbBands[2], outRgba, 2, 4); + normalizeToU8(rgbBands[0], outRgba, ranges?.[0], 0, 4); + normalizeToU8(rgbBands[1], outRgba, ranges?.[1] ?? ranges?.[0], 1, 4); + normalizeToU8(rgbBands[2], outRgba, ranges?.[2] ?? ranges?.[0], 2, 4); return; } const raw = rgb as RasterArray | undefined; if (!raw || raw.length === 0) return; if (raw.length >= pixelCount * 3) { - normalizeInterleavedRgbToRgba(raw, outRgba, pixelCount); + normalizeInterleavedRgbToRgba(raw, outRgba, pixelCount, ranges); } else { - normalizeGrayToRgba(raw, outRgba, pixelCount); + normalizeGrayToRgba(raw, outRgba, pixelCount, ranges?.[0] ?? null); + } +} + +function blitRgba( + sourceRgba: Uint8Array, + sourceWidth: number, + sourceHeight: number, + targetRgba: Uint8Array, + targetWidth: number, + targetHeight: number, +): void { + if ( + sourceWidth <= 0 + || sourceHeight <= 0 + || targetWidth <= 0 + || targetHeight <= 0 + ) { + return; + } + const copyWidth = Math.min(sourceWidth, targetWidth); + const copyHeight = Math.min(sourceHeight, targetHeight); + for (let row = 0; row < copyHeight; row += 1) { + const srcOffset = row * sourceWidth * 4; + const dstOffset = row * targetWidth * 4; + const rowLength = copyWidth * 4; + targetRgba.set(sourceRgba.subarray(srcOffset, srcOffset + rowLength), dstOffset); } } diff --git a/client/src/components/annotators/LargeImageAnnotator.vue b/client/src/components/annotators/LargeImageAnnotator.vue index c0c098e79..e5bab69a5 100644 --- a/client/src/components/annotators/LargeImageAnnotator.vue +++ b/client/src/components/annotators/LargeImageAnnotator.vue @@ -1,6 +1,6 @@