diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 461b4aeca..53ae23600 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: # Specifies the registry, this field is required! registry-url: https://registry.npmjs.org/ - run: yarn install --frozen-lockfile - # set up git since we will later push to the repo + # set up git because yarn publish creates a commit - run: git config --global user.name "GitHub Bot" - run: git config --global user.email "viame-web@kitware.com" # Run some sanity checks @@ -32,12 +32,9 @@ jobs: - run: yarn test # Build and publish - run: yarn build:lib + - run: yarn build:electron + - run: yarn build:cli - run: yarn publish --new-version ${{ github.event.release.tag_name }} env: # Use a token to publish to NPM. Must configure this! NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # push the version changes to GitHub - - run: git push - env: - # The secret is passed automatically. Nothing to configure. - github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index aed39e6ed..0ca8d6907 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ __pycache__/ build/ develop-eggs/ dist/ +dist_electron/ devel/ downloads/ eggs/ diff --git a/client/README.md b/client/README.md index 3034c26de..9c0c03b5e 100644 --- a/client/README.md +++ b/client/README.md @@ -29,6 +29,11 @@ yarn lint:templates # Local verification of all tests, linting, builds ./checkbuild.sh + +# Parser CLI tools +yarn serialize viame /path/to/viame.csv +# output to file, suppress yarn's stdout +yarn --silent serialize viame /path/to/viame.csv > tracks.json ``` See [this issue](https://github.com/vuejs/vue-cli/issues/3065) for details on why our `yarn serve` command is weird. diff --git a/client/package.json b/client/package.json index 760db1577..945f72f6a 100644 --- a/client/package.json +++ b/client/package.json @@ -7,16 +7,22 @@ "build:web": "vue-cli-service build platform/web-girder/main.ts", "build:electron": "vue-cli-service electron:build", "build:lib": "rollup -c", + "build:cli": "tsc -p tsconfig.cli.json", "lint": "vue-cli-service lint src/ viame-web-common/ platform/", "lint:templates": "vtc --workspace . --srcDir src/", - "test": "vue-cli-service test:unit src/ viame-web-common/" + "test": "vue-cli-service test:unit src/ viame-web-common/", + "serialize": "ts-node --project tsconfig.cli.json platform/desktop/backend/serializers/cli.ts" }, "resolutions": { "@types/jest": "^25.2.3" }, "files": [ + "/bin/", "/lib/" ], + "bin": { + "viamecli": "./bin/platform/desktop/backend/serializers/cli.js" + }, "main": "lib/index.js", "types": "lib/types/index.d.ts", "dependencies": { @@ -43,6 +49,7 @@ }, "devDependencies": { "@types/axios": "^0.14.0", + "@types/csv-parse": "^1.2.2", "@types/d3": "^5.7.2", "@types/electron-devtools-installer": "^2.2.0", "@types/geojson": "^7946.0.7", @@ -67,6 +74,7 @@ "babel-eslint": "^10.1.0", "babel-jest": "^26.0.1", "babel-register": "^6.26.0", + "csv-parse": "^4.13.1", "electron": "^10.1.3", "electron-devtools-installer": "^3.1.1", "eslint": "^6.7.2", @@ -89,6 +97,7 @@ "sass": "^1.26.3", "sass-loader": "^8.0.2", "ts-jest": "^26.0.0", + "ts-node": "^9.0.0", "typescript": "~3.8.3", "vue-cli-plugin-electron-builder": "^2.0.0-beta.5", "vue-cli-plugin-vuetify": "^2.0.5", @@ -97,7 +106,8 @@ "vue-type-check": "^1.0.0", "vuetify-loader": "^1.4.3", "xml-js": "^1.6.11", - "xml2json": "^0.12.0" + "xml2json": "^0.12.0", + "yargs": "^16.1.0" }, "jest": { "verbose": true, diff --git a/client/platform/desktop/.eslintrc b/client/platform/desktop/.eslintrc new file mode 100644 index 000000000..d9889afc0 --- /dev/null +++ b/client/platform/desktop/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "import/no-extraneous-dependencies": ["error", {"devDependencies": true}] + } +} diff --git a/client/platform/desktop/App.vue b/client/platform/desktop/App.vue index e9b6483b9..557983f29 100644 --- a/client/platform/desktop/App.vue +++ b/client/platform/desktop/App.vue @@ -7,13 +7,13 @@ diff --git a/client/platform/desktop/api/main.ts b/client/platform/desktop/api/main.ts index adce8d98f..86f880500 100644 --- a/client/platform/desktop/api/main.ts +++ b/client/platform/desktop/api/main.ts @@ -1,51 +1,34 @@ import { AddressInfo } from 'net'; import path from 'path'; -// eslint-disable-next-line import/no-extraneous-dependencies import { ipcRenderer, remote, FileFilter } from 'electron'; -// eslint-disable-next-line import/no-extraneous-dependencies import fs from 'fs-extra'; -// eslint-disable-next-line import/no-extraneous-dependencies import mime from 'mime-types'; import { - Attribute, DatasetMeta, + Attribute, DatasetMetaMutable, DatasetType, FrameImage, Pipelines, TrainingConfigs, } from 'viame-web-common/apispec'; -// TODO: disable node integration in renderer -// https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html -import { loadDetections, saveDetections } from './nativeServices'; - -const websafeVideoTypes = [ - 'video/mp4', - 'video/webm', -]; - -const websafeImageTypes = [ - 'image/apng', - 'image/bmp', - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/svg+xml', - 'image/webp', -]; - -export interface DesktopDataset { - name: string; - basePath: string; - root: string; - videoPath?: string; - meta: DatasetMeta; -} +import common from '../backend/platforms/common'; +import { + DesktopJob, NvidiaSmiReply, RunPipeline, + websafeImageTypes, websafeVideoTypes, + DesktopDataset, Settings, +} from '../constants'; + +const { loadDetections, saveDetections } = common; function mediaServerInfo(): Promise { return ipcRenderer.invoke('info'); } +function nvidiaSmi(): Promise { + return ipcRenderer.invoke('nvidia-smi'); +} + function openLink(url: string): Promise { return ipcRenderer.invoke('open-link-in-browser', url); } @@ -68,13 +51,8 @@ async function getAttributes() { return Promise.resolve([] as Attribute[]); } -async function getPipelineList(): Promise { - return Promise.resolve({}); -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function runPipeline(itemId: string, pipeline: string) { - return Promise.resolve(); +async function getPipelineList(settings: Settings): Promise { + return ipcRenderer.invoke('get-pipeline-list', settings); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -93,6 +71,7 @@ async function loadMetadata(datasetId: string): Promise { let datasetType = undefined as 'video' | 'image-sequence' | undefined; let videoUrl = ''; let videoPath = ''; + let basePath = datasetId; // default to image-sequence type basepath const imageData = [] as FrameImage[]; const serverInfo = await mediaServerInfo(); @@ -102,6 +81,7 @@ async function loadMetadata(datasetId: string): Promise { const mimetype = mime.lookup(abspath); if (mimetype && websafeVideoTypes.includes(mimetype)) { datasetType = 'video'; + basePath = path.dirname(datasetId); // parent directory of video; videoPath = abspath; videoUrl = abspathuri; } else if (mimetype && websafeImageTypes.includes(mimetype)) { @@ -130,8 +110,7 @@ async function loadMetadata(datasetId: string): Promise { return Promise.resolve({ name: path.basename(datasetId), - basePath: path.dirname(datasetId), - root: datasetId, + basePath, videoPath, meta: { type: datasetType, @@ -147,6 +126,16 @@ async function saveMetadata(datasetId: string, metadata: DatasetMetaMutable) { return Promise.resolve(); } +async function runPipeline(itemId: string, pipeline: string, settings: Settings) { + const args: RunPipeline = { + pipelineName: pipeline, + datasetId: itemId, + settings, + }; + const job: DesktopJob = await ipcRenderer.invoke('run-pipeline', args); + return job; +} + export { /* Standard common APIs */ getAttributes, @@ -161,4 +150,5 @@ export { /* Nonstandard APIs */ openFromDisk, openLink, + nvidiaSmi, }; diff --git a/client/platform/desktop/api/nativeServices.ts b/client/platform/desktop/api/nativeServices.ts deleted file mode 100644 index 548170237..000000000 --- a/client/platform/desktop/api/nativeServices.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Native services that directly interact with the OS. - * I'm not sure whether I want to call these from render or main thread. - * IPC module is not ideal for transferring such large amounts of data. - * It would be reasonable to use HTTP either with the embedded server - * or perhaps a custom `local-file` protocol - * - * https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/configuration.html#changing-the-file-loading-protocol - */ - -import npath from 'path'; -// eslint-disable-next-line import/no-extraneous-dependencies -import fs from 'fs-extra'; -import moment from 'moment'; - -import { TrackData } from 'vue-media-annotator/track'; -import { SaveDetectionsArgs } from 'viame-web-common/apispec'; - -import common from '../backend/platforms/common'; - -const CsvFileName = /^viame-annotations.csv$/; - -/** - * @param path a known, existing path - */ -async function loadJsonAnnotations(path: string): Promise> { - const rawBuffer = await fs.readFile(path, 'utf-8'); - const annotationData = JSON.parse(rawBuffer); - // TODO: validate json schema - return annotationData as Record; -} - -/** - * @param path a known, existing path - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function loadCSVAnnotations(path: string): Promise> { - // TODO: maybe come up with a way to invoke python csv -> json transcoding - // Otherwise we'll have to reimplement in Node.js - return {}; -} - -async function loadDetections(datasetId: string, ignoreCSV = false) { - const empty = Promise.resolve({} as { [key: string]: TrackData }); - const base = await common.getDatasetBase(datasetId); - - /* First, look for a JSON file */ - if (base.jsonFile) { - const annotations = loadJsonAnnotations(npath.join(base.basePath, base.jsonFile)); - return annotations; - } - - if (ignoreCSV) { - return empty; - } - - /* Then, look for a CSV */ - const csvFileCandidates = base.directoryContents.filter((v) => CsvFileName.test(v)); - if (csvFileCandidates.length === 1) { - const annotations = loadCSVAnnotations(npath.join(base.basePath, csvFileCandidates[0])); - return annotations; - } - - /* return empty by default */ - return empty; -} - -async function saveDetections(datasetId: string, args: SaveDetectionsArgs) { - const time = moment().format('MM-DD-YYYY_HH-MM-SS'); - const newFileName = `result_${time}.json`; - const base = await common.getDatasetBase(datasetId); - - // TODO: Validate SaveDetectionArgs - - /* Update existing track file */ - const existing = await loadDetections(datasetId, true); - args.delete.forEach((trackId) => delete existing[trackId.toString()]); - args.upsert.forEach((track, trackId) => { - existing[trackId.toString()] = track.serialize(); - }); - - const auxFolderPath = await common.getAuxFolder(base.basePath); - - /* Move old file if it exists */ - if (base.jsonFile) { - await fs.move( - npath.join(base.basePath, base.jsonFile), - npath.join(auxFolderPath, base.jsonFile), - ); - } - - const serialized = JSON.stringify(existing); - - /* Save new file */ - await fs.writeFile(npath.join(base.basePath, newFileName), serialized); -} - -export { - loadDetections, - saveDetections, -}; diff --git a/client/platform/desktop/backend/ipcService.ts b/client/platform/desktop/backend/ipcService.ts index 9ce1bae30..bc05a8c84 100644 --- a/client/platform/desktop/backend/ipcService.ts +++ b/client/platform/desktop/backend/ipcService.ts @@ -1,8 +1,9 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { ipcMain } from 'electron'; -import { Settings } from '../store/settings'; +import { DesktopJobUpdate, RunPipeline, Settings } from '../constants'; + import server from './server'; +import linux from './platforms/linux'; import common from './platforms/common'; export default function register() { @@ -15,6 +16,10 @@ export default function register() { * Platform-agnostic methods */ + ipcMain.handle('nvidia-smi', async () => { + const ret = await common.nvidiaSmi(); + return ret; + }); ipcMain.handle('get-pipeline-list', async (_, settings: Settings) => { const ret = await common.getPipelineList(settings); return ret; @@ -22,4 +27,23 @@ export default function register() { ipcMain.handle('open-link-in-browser', (_, url: string) => { common.openLink(url); }); + + /** + * TODO: replace linux defaults with some kind of platform switching logic + */ + + ipcMain.handle('default-settings', async () => { + const defaults = linux.DefaultSettings; + return defaults; + }); + ipcMain.handle('validate-settings', async (_, settings: Settings) => { + const ret = await linux.validateViamePath(settings); + return ret; + }); + ipcMain.handle('run-pipeline', async (event, args: RunPipeline) => { + const updater = (update: DesktopJobUpdate) => { + event.sender.send('job-update', update); + }; + return linux.runPipeline(args, updater); + }); } diff --git a/client/platform/desktop/backend/platforms/common.ts b/client/platform/desktop/backend/platforms/common.ts index 1655dd631..be843ac69 100644 --- a/client/platform/desktop/backend/platforms/common.ts +++ b/client/platform/desktop/backend/platforms/common.ts @@ -2,25 +2,33 @@ * Common native implementations */ import npath from 'path'; -// eslint-disable-next-line import/no-extraneous-dependencies import fs from 'fs-extra'; -// eslint-disable-next-line import/no-extraneous-dependencies +import { spawn } from 'child_process'; import { shell } from 'electron'; -import { DatasetType, Pipelines } from 'viame-web-common/apispec'; -import { Settings } from 'platform/desktop/store/settings'; +import mime from 'mime-types'; +import { xml2json } from 'xml-js'; +import moment from 'moment'; +import { TrackData } from 'vue-media-annotator/track'; +import { DatasetType, Pipelines, SaveDetectionsArgs } from 'viame-web-common/apispec'; + +import { Settings, NvidiaSmiReply, websafeImageTypes } from '../../constants'; +import * as viameSerializers from '../serializers/viame'; const AuxFolderName = 'auxiliary'; +const JobFolderName = 'job_runs'; // Match examples: // result_09-14-2020_14-49-05.json // result_.json // result.json const JsonFileName = /^result(_.*)?\.json$/; +const CsvFileName = /^.*\.csv$/; async function getDatasetBase(datasetId: string): Promise<{ datasetType: DatasetType; basePath: string; name: string; jsonFile: string | null; + imageFiles: string[]; directoryContents: string[]; }> { let datasetType: DatasetType = 'image-sequence'; @@ -48,6 +56,15 @@ async function getDatasetBase(datasetId: string): Promise<{ const jsonFileCandidates = contents.filter((v) => JsonFileName.test(v)); let jsonFile = null; + const imageFiles = contents.filter((filename) => { + const abspath = npath.join(datasetFolderPath, filename); + const mimetype = mime.lookup(abspath); + if (mimetype && websafeImageTypes.includes(mimetype)) { + return true; + } + return false; + }); + if (jsonFileCandidates.length > 1) { throw new Error('Too many matches for json annotation file!'); } else if (jsonFileCandidates.length === 1) { @@ -58,11 +75,61 @@ async function getDatasetBase(datasetId: string): Promise<{ datasetType, basePath: datasetFolderPath, jsonFile, + imageFiles, name: npath.parse(datasetId).name, directoryContents: contents, }; } +/** + * Load annotations from JSON + * @param path a known, existing path + */ +async function loadJsonAnnotations(path: string): Promise> { + const rawBuffer = await fs.readFile(path, 'utf-8'); + const annotationData = JSON.parse(rawBuffer); + // TODO: validate json schema + return annotationData as Record; +} + +/** + * Load detections from disk in priority order + * @param datasetId path + * @param ignoreCSV ignore CSV files if found + */ +async function loadDetections(datasetId: string, ignoreCSV = false): + Promise<{ [key: string]: TrackData }> { + const data = {} as { [key: string]: TrackData }; + const base = await getDatasetBase(datasetId); + + /* First, look for a JSON file */ + if (base.jsonFile) { + const annotations = loadJsonAnnotations(npath.join(base.basePath, base.jsonFile)); + return annotations; + } + + if (ignoreCSV) { + return Promise.resolve(data); + } + + /* Then, look for a CSV */ + const csvFileCandidates = base.directoryContents.filter((v) => CsvFileName.test(v)); + if (csvFileCandidates.length === 1) { + const tracks = await viameSerializers.parseFile( + npath.join(base.basePath, csvFileCandidates[0]), + ); + tracks.forEach((t) => { data[t.trackId.toString()] = t; }); + return data; + } + + /* return empty by default */ + return Promise.resolve(data); +} + +/** + * Get all runnable pipelines + * @param settings app settings + */ async function getPipelineList(settings: Settings): Promise { const pipelinePath = npath.join(settings.viamePath, 'configs/pipelines'); const allowedPatterns = /^detector_.+|^tracker_.+|^generate_.+/; @@ -107,13 +174,136 @@ async function getAuxFolder(baseDir: string): Promise { return auxFolderPath; } +/** + * Create `job_runs/{runfoldername}` folder, usually inside an aux folder + * @param baseDir parent + * @param pipeline name + */ +async function createKwiverRunWorkingDir(datasetName: string, baseDir: string, pipeline: string) { + const jobFolderPath = npath.join(baseDir, JobFolderName); + // eslint won't recognize \. as valid escape + // eslint-disable-next-line no-useless-escape + const safeDatasetName = datasetName.replace(/[\.\s/]+/g, '_'); + const runFolderName = moment().format(`[${safeDatasetName}_${pipeline}]_MM-DD-yy_hh-mm-ss`); + const runFolderPath = npath.join(jobFolderPath, runFolderName); + if (!fs.existsSync(jobFolderPath)) { + await fs.mkdir(jobFolderPath); + } + await fs.mkdir(runFolderPath); + return runFolderPath; +} + +// Based on https://github.com/chrisallenlane/node-nvidia-smi +async function nvidiaSmi(): Promise { + return new Promise((resolve) => { + const smi = spawn('nvidia-smi', ['-q', '-x']); + let result = ''; + smi.stdout.on('data', (chunk) => { + result = result.concat(chunk.toString('utf-8')); + }); + smi.on('close', (exitCode) => { + let jsonStr = 'null'; // parses to null + if (exitCode === 0) { + jsonStr = xml2json(result, { compact: true }); + } + resolve({ + output: JSON.parse(jsonStr), + exitCode, + error: result, + }); + }); + smi.on('error', (err) => { + resolve({ + output: null, + exitCode: -1, + error: err.message, + }); + }); + }); +} + +/** + * Save pre-serialized tracks to disk + * @param datasetId path + * @param trackData json serialized track object + */ +async function saveSerialized( + datasetId: string, + trackData: Record, +) { + const time = moment().format('MM-DD-YYYY_HH-MM-SS'); + const newFileName = `result_${time}.json`; + const base = await getDatasetBase(datasetId); + + const auxFolderPath = await getAuxFolder(base.basePath); + + /* Move old file if it exists */ + if (base.jsonFile) { + await fs.move( + npath.join(base.basePath, base.jsonFile), + npath.join(auxFolderPath, base.jsonFile), + ); + } + + const serialized = JSON.stringify(trackData); + + /* Save new file */ + await fs.writeFile(npath.join(base.basePath, newFileName), serialized); +} + +/** + * Save detections to json file in aux + * @param datasetId path + * @param args save args + */ +async function saveDetections(datasetId: string, args: SaveDetectionsArgs) { + /* Update existing track file */ + const existing = await loadDetections(datasetId, true); + args.delete.forEach((trackId) => delete existing[trackId.toString()]); + args.upsert.forEach((track, trackId) => { + existing[trackId.toString()] = track.serialize(); + }); + return saveSerialized(datasetId, existing); +} + +/** + * Postprocess possible annotation files + * @param paths paths to input annotation files in descending priority order. + * Only the first successful input will be loaded. + * @param datasetId dataset id path + */ +async function postprocess(paths: string[], datasetId: string) { + for (let i = 0; i < paths.length; i += 1) { + const path = paths[i]; + if (!fs.existsSync(path)) { + // eslint-disable-next-line no-continue + continue; + } + if (fs.statSync(path).size > 0) { + // Attempt to process the file + // eslint-disable-next-line no-await-in-loop + const tracks = await viameSerializers.parseFile(path); + const data = {} as Record; + tracks.forEach((t) => { data[t.trackId.toString()] = t; }); + // eslint-disable-next-line no-await-in-loop + await saveSerialized(datasetId, data); + break; // Exit on first successful detection load + } + } +} + async function openLink(url: string) { shell.openExternal(url); } export default { + nvidiaSmi, openLink, getAuxFolder, + createKwiverRunWorkingDir, getDatasetBase, getPipelineList, + loadDetections, + saveDetections, + postprocess, }; diff --git a/client/platform/desktop/backend/platforms/linux.ts b/client/platform/desktop/backend/platforms/linux.ts new file mode 100644 index 000000000..ede7af9e7 --- /dev/null +++ b/client/platform/desktop/backend/platforms/linux.ts @@ -0,0 +1,169 @@ +/** + * VIAME process manager for linux platform + */ +import npath from 'path'; +import { spawn } from 'child_process'; +import fs from 'fs-extra'; + +import { + Settings, SettingsCurrentVersion, + DesktopJob, DesktopJobUpdate, RunPipeline, +} from '../../constants'; + +import common from './common'; + +const DefaultSettings: Settings = { + // The current settings schema config + version: SettingsCurrentVersion, + // A path to the VIAME base install + viamePath: '/opt/noaa/viame', + // Path to a user data folder + dataPath: '~/viamedata', +}; + +async function validateViamePath(settings: Settings): Promise { + const setupScriptPath = npath.join(settings.viamePath, 'setup_viame.sh'); + const setupExists = await fs.pathExists(setupScriptPath); + if (!setupExists) { + return `${setupScriptPath} does not exist`; + } + + const kwiverExistsOnPath = spawn( + `source ${setupScriptPath} && which kwiver`, + { shell: '/bin/bash' }, + ); + return new Promise((resolve) => { + kwiverExistsOnPath.on('exit', (code) => { + if (code === 0) { + resolve(true); + } else { + resolve('kwiver failed to initialize'); + } + }); + }); +} + +/** + * Fashioned as a node.js implementation of viame_tasks.tasks.run_pipeline + * + * @param datasetIdPath dataset path absolute + * @param pipeline pipeline file basename + * @param settings global settings + */ +async function runPipeline( + runPipelineArgs: RunPipeline, + updater: (msg: DesktopJobUpdate) => void, +): Promise { + const { settings, datasetId, pipelineName } = runPipelineArgs; + const isValid = await validateViamePath(settings); + if (isValid !== true) { + throw new Error(isValid); + } + + const setupScriptPath = npath.join(settings.viamePath, 'setup_viame.sh'); + const pipelinePath = npath.join(settings.viamePath, 'configs/pipelines', pipelineName); + const datasetInfo = await common.getDatasetBase(datasetId); + const auxPath = await common.getAuxFolder(datasetInfo.basePath); + const jobWorkDir = await common.createKwiverRunWorkingDir( + datasetInfo.name, auxPath, pipelineName, + ); + + const detectorOutput = npath.join(jobWorkDir, 'detector_output.csv'); + const trackOutput = npath.join(jobWorkDir, 'track_output.csv'); + const joblog = npath.join(jobWorkDir, 'runlog.txt'); + + let command: string[] = []; + if (datasetInfo.datasetType === 'video') { + command = [ + `source ${setupScriptPath} &&`, + 'kwiver runner', + '-s input:video_reader:type=vidl_ffmpeg', + `-p ${pipelinePath}`, + `-s input:video_filename=${datasetId}`, + `-s detector_writer:file_name=${detectorOutput}`, + `-s track_writer:file_name=${trackOutput}`, + `| tee ${joblog}`, + ]; + } else if (datasetInfo.datasetType === 'image-sequence') { + // Create frame image manifest + const manifestFile = npath.join(jobWorkDir, 'image-manifest.txt'); + // map image file names to absolute paths + const fileData = datasetInfo.imageFiles + .map((f) => npath.join(datasetInfo.basePath, f)) + .join('\n'); + await fs.writeFile(manifestFile, fileData); + command = [ + `source ${setupScriptPath} &&`, + 'kwiver runner', + `-p "${pipelinePath}"`, + `-s input:video_filename="${manifestFile}"`, + `-s detector_writer:file_name="${detectorOutput}"`, + `-s track_writer:file_name="${trackOutput}"`, + `| tee "${joblog}"`, + ]; + } + + const job = spawn(command.join(' '), { + shell: '/bin/bash', + cwd: jobWorkDir, + }); + + const jobBase: DesktopJob = { + key: `pipeline_${job.pid}_${jobWorkDir}`, + jobType: 'pipeline', + pid: job.pid, + pipelineName, + workingDir: jobWorkDir, + datasetIds: [datasetId], + exitCode: job.exitCode, + startTime: new Date(), + }; + + const processChunk = (chunk: Buffer) => chunk + .toString('utf-8') + .split('\n') + .filter((a) => a); + + job.stdout.on('data', (chunk: Buffer) => { + // eslint-disable-next-line no-console + console.debug(chunk.toString('utf-8')); + updater({ + ...jobBase, + body: processChunk(chunk), + }); + }); + + job.stderr.on('data', (chunk: Buffer) => { + // eslint-disable-next-line no-console + console.log(chunk.toString('utf-8')); + updater({ + ...jobBase, + body: processChunk(chunk), + }); + }); + + job.on('exit', async (code) => { + // eslint-disable-next-line no-console + if (code === 0) { + try { + await common.postprocess([trackOutput, detectorOutput], datasetId); + } catch (err) { + console.error(err); + } + } + updater({ + ...jobBase, + body: [''], + exitCode: code, + endTime: new Date(), + }); + }); + + return jobBase; +} + +export default { + DefaultSettings, + validateViamePath, + runPipeline, +}; diff --git a/client/platform/desktop/backend/platforms/windows.ts b/client/platform/desktop/backend/platforms/windows.ts new file mode 100644 index 000000000..772bbd42a --- /dev/null +++ b/client/platform/desktop/backend/platforms/windows.ts @@ -0,0 +1,3 @@ +/** + * VIAME process manager for windows platform + */ diff --git a/client/platform/desktop/backend/serializers/cli.ts b/client/platform/desktop/backend/serializers/cli.ts new file mode 100755 index 000000000..0bc34345c --- /dev/null +++ b/client/platform/desktop/backend/serializers/cli.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/// + +/** + * Command-line entrypoints into serializers and other tooling. + * See README.md for usage + */ + +import yargs from 'yargs'; + +import { parseFile } from './viame'; + +async function parseViameFile(file: string) { + const tracks = await parseFile(file); + // eslint-disable-next-line no-console + console.log(JSON.stringify(tracks)); +} + +const { argv } = yargs + .command('viame [file]', 'Parse VIAME CSV', (y) => { + y.positional('file', { + description: 'The file to parse', + type: 'string', + }); + }) + .help(); + +if (argv._.includes('viame')) { + parseViameFile(argv.file as string); +} diff --git a/client/platform/desktop/backend/serializers/viame.ts b/client/platform/desktop/backend/serializers/viame.ts new file mode 100644 index 000000000..065391430 --- /dev/null +++ b/client/platform/desktop/backend/serializers/viame.ts @@ -0,0 +1,273 @@ +/** + * VIAME CSV parser/serializer copied logically from + * viame_server.serializers.viame python module + */ + +import csvparser from 'csv-parse'; +import fs from 'fs-extra'; +import { pipeline } from 'stream'; + +import { + TrackData, Feature, StringKeyObject, ConfidencePair, TrackSupportedFeature, +} from 'vue-media-annotator/track'; + +const CommentRegex = /^\s*#/g; +const HeadRegex = /^\(kp\) head ([0-9]+\.*[0-9]*) ([0-9]+\.*[0-9]*)/g; +const TailRegex = /^\(kp\) tail ([0-9]+\.*[0-9]*) ([0-9]+\.*[0-9]*)/g; +const AttrRegex = /^\(atr\) (.*?)\s(.+)/g; +const TrackAttrRegex = /^\(trk-atr\) (.*?)\s(.+)/g; +const PolyRegex = /^(\(poly\)) ((?:[0-9]+\.*[0-9]*\s*)+)/g; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/matchAll +function getCaptureGroups(regexp: RegExp, str: string) { + const array = [...str.matchAll(regexp)]; + if (array.length) { + return array[0]; + } + return null; +} + +function _rowInfo(row: string[]) { + if (row.length < 9) { + throw new Error('malformed row: too few columns'); + } + if (row[0].match(CommentRegex) !== null) { + throw new Error('malformed row: comment row'); + } + return { + trackId: parseInt(row[0], 10), + filename: row[1], + frame: parseInt(row[2], 10), + bounds: ( + row.slice(3, 7).map((v) => Math.round(parseFloat(v))) as + [number, number, number, number] + ), + fishLength: parseFloat(row[8]), + }; +} + +function _deduceType(value: string): boolean | number | string { + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + const float = parseFloat(value); + if (!Number.isNaN(float)) { + return float; + } + return value; +} + +/** + * Simplified from python variant. Does not handle duplicate type/key pairs + * within a single feature. + * + * @param type geojson feature type + * @param coords 2D float array + * @param key optional feature key string + */ +function _createGeoJsonFeature( + type: 'Point' | 'LineString' | 'Polygon', + coords: number[][], + key = '', +) { + const geoFeature: GeoJSON.Feature = { + type: 'Feature', + properties: { key }, + geometry: { + type, + coordinates: [], + }, + }; + if (type === 'Polygon') { + geoFeature.geometry.coordinates = [coords]; + } else if (type === 'Point') { + [geoFeature.geometry.coordinates] = coords; + } else { + geoFeature.geometry.coordinates = coords; + } + return geoFeature; +} + +function _parseRow(row: string[]) { + // Create empty feature collection + const geoFeatureCollection: + GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [], + }; + let attributes: StringKeyObject | undefined; + const trackAttributes: StringKeyObject = {}; + const cpStarti = 9; // Confidence pairs start at i=9 + const confidencePairs: ConfidencePair[] = row + .slice(cpStarti, row.length) + .map((_, j) => { + if (j % 2 !== 0) { + // Filter out ODDs + return ['', 0] as ConfidencePair; + } + const i = j + cpStarti; + if ((i + 1) < row.length && row[i] && row[i + 1] && !row[i].startsWith('(')) { + return [row[i], parseFloat(row[i + 1])] as ConfidencePair; + } + return ['', 0] as ConfidencePair; + }) + .filter((val) => val[0] !== ''); + const headTail: [number, number][] = []; + const start = 9 + (confidencePairs.length * 2); + row.slice(start).forEach((value) => { + /* Head */ + const head = getCaptureGroups(HeadRegex, value); + if (head !== null) { + headTail[0] = [parseFloat(head[1]), parseFloat(head[2])]; + geoFeatureCollection.features.push(_createGeoJsonFeature('Point', [headTail[0]], 'head')); + } + + /* Tail */ + const tail = getCaptureGroups(TailRegex, value); + if (tail !== null) { + headTail[1] = [parseFloat(tail[1]), parseFloat(tail[2])]; + geoFeatureCollection.features.push(_createGeoJsonFeature('Point', [headTail[1]], 'tail')); + } + + /* Detection Attribute */ + const attr = getCaptureGroups(AttrRegex, value); + if (attr !== null) { + if (attributes === undefined) attributes = {}; + attributes[attr[1]] = _deduceType(attr[2]); + } + + /* Track Attribute */ + const trackattr = getCaptureGroups(TrackAttrRegex, value); + if (trackattr !== null) { + trackAttributes[trackattr[1]] = _deduceType(trackattr[2]); + } + + /* Polygon */ + const poly = getCaptureGroups(PolyRegex, value); + if (poly !== null) { + const coords: number[][] = []; + const polyList = poly[2].split(' '); + polyList.forEach((coord, j) => { + if (j % 2 === 0) { + // Filter out ODDs + if (polyList[j + 1]) { + coords.push([parseFloat(coord), parseFloat(polyList[j + 1])]); + } + } + }); + geoFeatureCollection.features.push(_createGeoJsonFeature('Polygon', coords)); + } + }); + + if (headTail[0] !== undefined && headTail[1] !== undefined) { + geoFeatureCollection.features.push(_createGeoJsonFeature('LineString', headTail, 'HeadTails')); + } + + return { + attributes, trackAttributes, confidencePairs, geoFeatureCollection, + }; +} + +function _parseFeature(row: string[]) { + const rowInfo = _rowInfo(row); + const rowData = _parseRow(row); + const feature: Feature = { + frame: rowInfo.frame, + bounds: rowInfo.bounds, + fishLength: rowInfo.fishLength, + }; + if (rowData.attributes) { + feature.attributes = rowData.attributes; + } + if (rowData.geoFeatureCollection.features.length > 0) { + feature.geometry = rowData.geoFeatureCollection; + } + return { + rowInfo, + feature, + trackAttributes: rowData.trackAttributes, + confidencePairs: rowData.confidencePairs, + }; +} + +async function parse(input: fs.ReadStream): Promise { + const parser = csvparser({ + delimiter: ',', + // comment lines may not have the correct number of columns + relaxColumnCount: true, + }); + + const dataMap = new Map(); + + return new Promise((resolve, reject) => { + pipeline(input, parser, (err) => { + // undefined err indicates successful exit + if (err !== undefined) { + reject(err); + } + resolve(Array.from(dataMap.values())); + }); + parser.on('readable', () => { + let record: string[]; + // eslint-disable-next-line no-cond-assign + while (record = parser.read()) { + try { + const { + rowInfo, feature, trackAttributes, confidencePairs, + } = _parseFeature(record); + let track = dataMap.get(rowInfo.trackId); + if (track === undefined) { + // Create new track if not exists in map + track = { + begin: rowInfo.frame, + end: rowInfo.frame, + trackId: rowInfo.trackId, + meta: {}, + attributes: {}, + confidencePairs: [], + features: [], + }; + dataMap.set(rowInfo.trackId, track); + } + track.begin = Math.min(rowInfo.frame, track.begin); + track.end = Math.max(rowInfo.frame, track.end); + track.features.push(feature); + track.confidencePairs = confidencePairs; + Object.entries(trackAttributes).forEach(([key, val]) => { + // "track is possibly undefined" seems like a bug + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore + track.attributes[key] = val; + }); + } catch (err) { + // Allow malformed row errors + if (!err.toString().includes('malformed row')) { + throw err; + } + } + } + }); + parser.on('error', (err) => { + console.error(err); + reject(err); + }); + }); +} + +async function parseFile(path: string): Promise { + const stream = fs.createReadStream(path); + return parse(stream); +} + +// function serialize(data: TrackData[]): string[] { +// return []; +// } + +export { + parse, + parseFile, + // serialize, +}; diff --git a/client/platform/desktop/backend/server.ts b/client/platform/desktop/backend/server.ts index 644f793d1..f3cb622ef 100644 --- a/client/platform/desktop/backend/server.ts +++ b/client/platform/desktop/backend/server.ts @@ -1,8 +1,5 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import mime from 'mime-types'; -// eslint-disable-next-line import/no-extraneous-dependencies import pump from 'pump'; -// eslint-disable-next-line import/no-extraneous-dependencies import rangeParser from 'range-parser'; import http from 'http'; import fs from 'fs'; diff --git a/client/platform/desktop/background.ts b/client/platform/desktop/background.ts index b5b30ee81..fb4e0a268 100644 --- a/client/platform/desktop/background.ts +++ b/client/platform/desktop/background.ts @@ -1,8 +1,5 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { app, protocol, BrowserWindow } from 'electron'; -// eslint-disable-next-line import/no-extraneous-dependencies import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'; -// eslint-disable-next-line import/no-extraneous-dependencies import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; import server from './backend/server'; @@ -33,6 +30,7 @@ function createWindow() { win = new BrowserWindow({ width: 800, height: 600, + autoHideMenuBar: true, webPreferences: { // Use pluginOptions.nodeIntegration, leave this alone // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html @@ -47,8 +45,6 @@ function createWindow() { if (process.env.IS_ELECTRON) { // Load the url of the dev server if in development mode win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string); - // win.loadURL(`file:/${__dirname}/index.html`); - if (!process.env.IS_TEST) win.webContents.openDevTools(); } else { createProtocol('app'); diff --git a/client/platform/desktop/components/BrowserLink.vue b/client/platform/desktop/components/BrowserLink.vue index e5e29525a..3ba22ee5d 100644 --- a/client/platform/desktop/components/BrowserLink.vue +++ b/client/platform/desktop/components/BrowserLink.vue @@ -1,5 +1,5 @@ + + diff --git a/client/platform/desktop/components/Jobs.vue b/client/platform/desktop/components/Jobs.vue new file mode 100644 index 000000000..ac8a2a3fe --- /dev/null +++ b/client/platform/desktop/components/Jobs.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/client/platform/desktop/components/NavigationBar.vue b/client/platform/desktop/components/NavigationBar.vue index 74f8b7ae0..4d4b552cc 100644 --- a/client/platform/desktop/components/NavigationBar.vue +++ b/client/platform/desktop/components/NavigationBar.vue @@ -2,16 +2,19 @@ import { defineComponent } from '@vue/composition-api'; import NavigationTitle from 'viame-web-common/components/NavigationTitle.vue'; +import JobTab from './JobTab.vue'; export default defineComponent({ - components: { NavigationTitle }, + components: { NavigationTitle, JobTab }, }); +