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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions client/dive-common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,18 @@ const fileVideoTypes = [
const largeImageTypes = [
'image/geotiff',
'image/tiff',
'image/tif',
'image/x-tiff',
'image/nitf',
'image/ntf',
];

const largeImageDesktopTypes = [
'geotiff',
'tiff',
'tif',
];

const websafeImageTypes = [
// 'image/apng',
// 'image/bmp',
Expand Down Expand Up @@ -149,6 +156,7 @@ export {
websafeVideoTypes,
inputAnnotationTypes,
largeImageTypes,
largeImageDesktopTypes,
inputAnnotationFileTypes,
listFileTypes,
zipFileTypes,
Expand Down
2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 43 additions & 4 deletions client/platform/desktop/backend/native/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}"`);
}
Expand Down Expand Up @@ -943,6 +951,8 @@ async function beginMediaImport(path: string): Promise<DesktopMediaImportRespons
const mimetype = mime.lookup(path);
if (mimetype && mimetype === 'text/plain') {
datasetType = 'image-sequence';
} else if (mimetype && largeImageTypes.includes(mimetype)) {
datasetType = 'large-image';
} else {
datasetType = 'video';
}
Expand Down Expand Up @@ -977,14 +987,20 @@ async function beginMediaImport(path: string): Promise<DesktopMediaImportRespons
// get parent folder, since videos reference a file directly
jsonMeta.originalBasePath = npath.dirname(path);
}
if (datasetType === 'large-image') {
jsonMeta.originalBasePath = npath.dirname(path);
jsonMeta.originalLargeImageFile = npath.basename(path);
}

/* Path to search for other related data like annotations */
let relatedDataSearchPath = jsonMeta.originalBasePath;

/* mediaConvertList is a list of absolute paths of media to convert */
let mediaConvertList: string[] = [];
/* Extract and validate media from import path */
if (jsonMeta.type === 'video') {
if (jsonMeta.type === 'large-image') {
// No conversion for large images; tile serving is done on demand via geotiff
} else if (jsonMeta.type === 'video') {
jsonMeta.originalVideoFile = npath.basename(path);
const mimetype = mime.lookup(path);
if (mimetype) {
Expand Down Expand Up @@ -1022,8 +1038,8 @@ async function beginMediaImport(path: string): Promise<DesktopMediaImportRespons
relatedDataSearchPath = npath.dirname(path);
}
mediaConvertList = found.mediaConvertList;
} else {
throw new Error('only video and image-sequence types are supported');
} else if (datasetType !== 'large-image') {
throw new Error('only video, image-sequence, and large-image types are supported');
}

const { trackFileAbsPath, metaFileAbsPath } = await
Expand Down Expand Up @@ -1222,6 +1238,28 @@ async function finalizeMediaImport(
return conversionJobArgs;
}

/**
* Get the absolute path to the large image (e.g. GeoTIFF) file for a dataset.
* Returns null if the dataset is not type 'large-image' or path is missing.
*/
async function getLargeImagePath(settings: Settings, datasetId: string): Promise<string | null> {
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);
return path;
} catch (err) {
console.warn(`[tiles] getLargeImagePath: error for dataset "${datasetId}":`, err);
return null;
}
}

async function openLink(url: string) {
shell.openExternal(url);
}
Expand Down Expand Up @@ -1333,6 +1371,7 @@ export {
getTrainingConfigs,
getProjectDir,
getValidatedProjectDir,
getLargeImagePath,
loadMetadata,
loadJsonMetadata,
loadAnnotationFile,
Expand Down
51 changes: 51 additions & 0 deletions client/platform/desktop/backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down Expand Up @@ -123,6 +124,56 @@ 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);
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' });
}
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;
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' });
}
if (meta.preconversionRequired && meta.error) {
console.warn(`[tiles] GET tiles metadata 422: datasetId=${datasetId} requires pre-conversion`);
return next({ status: 422, statusMessage: meta.error });
}
res.json(meta);
} catch (err) {
console.error('[tiles] GET tiles metadata error:', err);
(err as { status?: number }).status = 500;
next(err);
}
return null;
});

/* STREAM media */
apirouter.get('/media', (req, res, next) => {
let { path } = req.query;
Expand Down
Loading
Loading