From ae51bb4877e9b52f6da5268bf0f51c11cf0b6456 Mon Sep 17 00:00:00 2001 From: Brandon Davis Date: Wed, 4 Nov 2020 09:30:16 -0500 Subject: [PATCH 1/7] Revert "remove settings page" This reverts commit 23aa62b1a5fd3f44bdb1930611426bbb18432276. --- client/platform/desktop/api/main.ts | 20 ++- client/platform/desktop/backend/ipcService.ts | 18 +++ .../desktop/backend/platforms/common.ts | 77 +++++++++++ .../desktop/backend/platforms/linux.ts | 112 ++++++++++++++++ .../desktop/backend/platforms/windows.ts | 3 + .../platform/desktop/components/Settings.vue | 121 ++++++++++++++++++ client/platform/desktop/router.ts | 6 + 7 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 client/platform/desktop/backend/platforms/linux.ts create mode 100644 client/platform/desktop/backend/platforms/windows.ts create mode 100644 client/platform/desktop/components/Settings.vue diff --git a/client/platform/desktop/api/main.ts b/client/platform/desktop/api/main.ts index adce8d98f..5b2a5812d 100644 --- a/client/platform/desktop/api/main.ts +++ b/client/platform/desktop/api/main.ts @@ -15,6 +15,8 @@ import { Pipelines, TrainingConfigs, } from 'viame-web-common/apispec'; +import { Settings, getSettings } from 'platform/desktop/store/settings'; + // TODO: disable node integration in renderer // https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html import { loadDetections, saveDetections } from './nativeServices'; @@ -42,14 +44,26 @@ export interface DesktopDataset { meta: DatasetMeta; } +function getDefaultSettings(): Promise { + return ipcRenderer.invoke('default-settings'); +} + 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); } +function validateSettings(settings: Settings): Promise { + return ipcRenderer.invoke('validate-settings', settings); +} + async function openFromDisk(datasetType: DatasetType) { let filters: FileFilter[] = []; if (datasetType === 'video') { @@ -69,7 +83,8 @@ async function getAttributes() { } async function getPipelineList(): Promise { - return Promise.resolve({}); + const defaultSettings = await getDefaultSettings(); + return ipcRenderer.invoke('get-pipeline-list', getSettings(defaultSettings)); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -161,4 +176,7 @@ export { /* Nonstandard APIs */ openFromDisk, openLink, + nvidiaSmi, + validateSettings, + getDefaultSettings, }; diff --git a/client/platform/desktop/backend/ipcService.ts b/client/platform/desktop/backend/ipcService.ts index 9ce1bae30..f924ed5b1 100644 --- a/client/platform/desktop/backend/ipcService.ts +++ b/client/platform/desktop/backend/ipcService.ts @@ -3,6 +3,7 @@ import { ipcMain } from 'electron'; import { Settings } from '../store/settings'; 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,17 @@ 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; + }); } diff --git a/client/platform/desktop/backend/platforms/common.ts b/client/platform/desktop/backend/platforms/common.ts index 1655dd631..03f059604 100644 --- a/client/platform/desktop/backend/platforms/common.ts +++ b/client/platform/desktop/backend/platforms/common.ts @@ -4,18 +4,47 @@ import npath from 'path'; // eslint-disable-next-line import/no-extraneous-dependencies import fs from 'fs-extra'; +import { spawn } from 'child_process'; // eslint-disable-next-line import/no-extraneous-dependencies import { shell } from 'electron'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { xml2json } from 'xml-js'; +import moment from 'moment'; import { DatasetType, Pipelines } from 'viame-web-common/apispec'; import { Settings } from 'platform/desktop/store/settings'; 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$/; +export interface JobCreatedReply { + // The name of the pipe or job being run + pipeline: string; + // A unique identifier for the job + jobName: string; + // The pid of the process spawned + pid: number; + // The working directory of the job's output + workingDir: string; + // If exitCode is set, the job exited already + exitCode?: number; +} + +export interface JobUpdateReply { + // Matches JobCreated identifier + jobName: string; + // Originating pid + pid: number; + // Update Body + body: string; + // If exitCode is set, the job exited already + exitCode?: number; +} + async function getDatasetBase(datasetId: string): Promise<{ datasetType: DatasetType; basePath: string; @@ -107,13 +136,61 @@ 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); + const safeDatasetName = datasetName.replaceAll(/[\.\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', (code) => { + let jsonStr = 'null'; // parses to null + if (code === 0) { + jsonStr = xml2json(result, { compact: true }); + } + resolve({ + output: JSON.parse(jsonStr), + code, + error: result, + }); + }); + smi.on('error', (err) => { + resolve({ + output: null, + code: -1, + error: err, + }); + }); + }); +} + async function openLink(url: string) { shell.openExternal(url); } export default { + nvidiaSmi, openLink, getAuxFolder, + createKwiverRunWorkingDir, getDatasetBase, getPipelineList, }; diff --git a/client/platform/desktop/backend/platforms/linux.ts b/client/platform/desktop/backend/platforms/linux.ts new file mode 100644 index 000000000..9f34b198e --- /dev/null +++ b/client/platform/desktop/backend/platforms/linux.ts @@ -0,0 +1,112 @@ +/** + * VIAME process manager for linux platform + */ +import path from 'path'; +import { spawn } from 'child_process'; +// eslint-disable-next-line import/no-extraneous-dependencies +import fs from 'fs-extra'; + +import { Settings, SettingsCurrentVersion } from 'platform/desktop/store/settings'; +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 = path.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'); + } + }); + }); +} + +// command = [ +// f"cd {conf.viame_install_path} &&", +// ". ./setup_viame.sh &&", +// "kwiver runner", +// "-s input:video_reader:type=vidl_ffmpeg", +// f"-p {pipeline_path}", +// f"-s input:video_filename={input_file}", +// f"-s detector_writer:file_name={detector_output_path}", +// f"-s track_writer:file_name={track_output_path}", +// ] +// elif input_type == 'image-sequence': +// with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as temp2: +// temp2.writelines( +// ( +// (os.path.join(input_path, file_name) + "\n").encode() +// for file_name in sorted(filtered_directory_files) +// ) +// ) +// image_list_file = temp2.name +// command = [ +// f"cd {conf.viame_install_path} &&", +// ". ./setup_viame.sh &&", +// "kwiver runner", +// f"-p {pipeline_path}", +// f"-s input:video_filename={image_list_file}", +// f"-s detector_writer:file_name={detector_output_path}", +// f"-s track_writer:file_name={track_output_path}", +// ] + +/** + * Fashioned as a node.js implementation of viame_tasks.tasks.run_pipeline + * + * @param p dataset path absolute + * @param pipeline pipeline file basename + * @param settings global settings + */ +async function runPipeline(p: string, pipeline: string, settings: Settings) { + const isValid = await validateViamePath(settings); + if (isValid !== true) { + throw new Error(isValid); + } + const setupScriptPath = path.join(settings.viamePath, 'setup_viame.sh'); + const pipelinePath = path.join(settings.viamePath, 'configs/pipelines', pipeline); + const datasetInfo = await common.getDatasetBase(p); + + 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=${p}`, + '-s detector_writer:file_name', + ]; + } else if (datasetInfo.datasetType === 'image-sequence') { + // command = [ + // `source ${setupScriptPath} &&`, + // 'kwiver runner', + // `-p ${pipelinePath}`, + // ] + } + return Promise.resolve(command); +} + +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/components/Settings.vue b/client/platform/desktop/components/Settings.vue new file mode 100644 index 000000000..16c753e4a --- /dev/null +++ b/client/platform/desktop/components/Settings.vue @@ -0,0 +1,121 @@ + + + diff --git a/client/platform/desktop/router.ts b/client/platform/desktop/router.ts index 3395036a3..07db77bf5 100644 --- a/client/platform/desktop/router.ts +++ b/client/platform/desktop/router.ts @@ -2,6 +2,7 @@ import Vue from 'vue'; import Router from 'vue-router'; import Recent from './components/Recent.vue'; +import Settings from './components/Settings.vue'; import ViewerLoader from './components/ViewerLoader.vue'; Vue.use(Router); @@ -13,6 +14,11 @@ export default new Router({ name: 'recent', component: Recent, }, + { + path: '/settings', + name: 'settings', + component: Settings, + }, { path: '/viewer/:path', name: 'viewer', From 63cf8821798e8a9b77fac0c2b17ce1ffe60acd1c Mon Sep 17 00:00:00 2001 From: Brandon Davis Date: Wed, 4 Nov 2020 10:19:48 -0500 Subject: [PATCH 2/7] Add types for settings --- client/platform/desktop/api/main.ts | 5 +- .../desktop/backend/platforms/common.ts | 24 ++++++- .../desktop/backend/platforms/linux.ts | 2 +- .../desktop/components/BrowserLink.vue | 8 ++- .../platform/desktop/components/Settings.vue | 63 ++++++++++++++----- 5 files changed, 79 insertions(+), 23 deletions(-) diff --git a/client/platform/desktop/api/main.ts b/client/platform/desktop/api/main.ts index 5b2a5812d..2807233a4 100644 --- a/client/platform/desktop/api/main.ts +++ b/client/platform/desktop/api/main.ts @@ -15,11 +15,12 @@ import { Pipelines, TrainingConfigs, } from 'viame-web-common/apispec'; -import { Settings, getSettings } from 'platform/desktop/store/settings'; +import { Settings, getSettings } from '../store/settings'; // TODO: disable node integration in renderer // https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html import { loadDetections, saveDetections } from './nativeServices'; +import { NvidiaSmiReply } from '../backend/platforms/common'; const websafeVideoTypes = [ 'video/mp4', @@ -52,7 +53,7 @@ function mediaServerInfo(): Promise { return ipcRenderer.invoke('info'); } -function nvidiaSmi(): Promise> { +function nvidiaSmi(): Promise { return ipcRenderer.invoke('nvidia-smi'); } diff --git a/client/platform/desktop/backend/platforms/common.ts b/client/platform/desktop/backend/platforms/common.ts index 03f059604..c836a9385 100644 --- a/client/platform/desktop/backend/platforms/common.ts +++ b/client/platform/desktop/backend/platforms/common.ts @@ -21,6 +21,22 @@ const JobFolderName = 'job_runs'; // result.json const JsonFileName = /^result(_.*)?\.json$/; +interface NvidiaSmiTextRecord { + _text: string; +} + +export interface NvidiaSmiReply { + output: { + nvidia_smi_log: { + driver_version: NvidiaSmiTextRecord; + cuda_version: NvidiaSmiTextRecord; + attached_gpus: NvidiaSmiTextRecord; + }; + } | null; + code: number; + error: string; +} + export interface JobCreatedReply { // The name of the pipe or job being run pipeline: string; @@ -143,7 +159,9 @@ async function getAuxFolder(baseDir: string): Promise { */ async function createKwiverRunWorkingDir(datasetName: string, baseDir: string, pipeline: string) { const jobFolderPath = npath.join(baseDir, JobFolderName); - const safeDatasetName = datasetName.replaceAll(/[\.\s/]+/g, '_'); + // 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)) { @@ -154,7 +172,7 @@ async function createKwiverRunWorkingDir(datasetName: string, baseDir: string, p } // Based on https://github.com/chrisallenlane/node-nvidia-smi -async function nvidiaSmi(): Promise { +async function nvidiaSmi(): Promise { return new Promise((resolve) => { const smi = spawn('nvidia-smi', ['-q', '-x']); let result = ''; @@ -176,7 +194,7 @@ async function nvidiaSmi(): Promise { resolve({ output: null, code: -1, - error: err, + error: err.message, }); }); }); diff --git a/client/platform/desktop/backend/platforms/linux.ts b/client/platform/desktop/backend/platforms/linux.ts index 9f34b198e..1c4ec3e0f 100644 --- a/client/platform/desktop/backend/platforms/linux.ts +++ b/client/platform/desktop/backend/platforms/linux.ts @@ -6,7 +6,7 @@ import { spawn } from 'child_process'; // eslint-disable-next-line import/no-extraneous-dependencies import fs from 'fs-extra'; -import { Settings, SettingsCurrentVersion } from 'platform/desktop/store/settings'; +import { Settings, SettingsCurrentVersion } from '../../store/settings'; import common from './common'; const DefaultSettings: Settings = { 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/api/main.ts b/client/platform/desktop/api/main.ts index 2807233a4..575dff95d 100644 --- a/client/platform/desktop/api/main.ts +++ b/client/platform/desktop/api/main.ts @@ -9,45 +9,20 @@ import fs from 'fs-extra'; import mime from 'mime-types'; import { - Attribute, DatasetMeta, + Attribute, DatasetMetaMutable, DatasetType, FrameImage, Pipelines, TrainingConfigs, } from 'viame-web-common/apispec'; -import { Settings, getSettings } from '../store/settings'; - +import { + DesktopJob, NvidiaSmiReply, RunPipeline, + websafeImageTypes, websafeVideoTypes, + DesktopDataset, Settings, +} from '../constants'; // TODO: disable node integration in renderer // https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html import { loadDetections, saveDetections } from './nativeServices'; -import { NvidiaSmiReply } from '../backend/platforms/common'; - -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; -} - -function getDefaultSettings(): Promise { - return ipcRenderer.invoke('default-settings'); -} function mediaServerInfo(): Promise { return ipcRenderer.invoke('info'); @@ -61,10 +36,6 @@ function openLink(url: string): Promise { return ipcRenderer.invoke('open-link-in-browser', url); } -function validateSettings(settings: Settings): Promise { - return ipcRenderer.invoke('validate-settings', settings); -} - async function openFromDisk(datasetType: DatasetType) { let filters: FileFilter[] = []; if (datasetType === 'video') { @@ -83,14 +54,18 @@ async function getAttributes() { return Promise.resolve([] as Attribute[]); } -async function getPipelineList(): Promise { - const defaultSettings = await getDefaultSettings(); - return ipcRenderer.invoke('get-pipeline-list', getSettings(defaultSettings)); +async function getPipelineList(settings: Settings): Promise { + return ipcRenderer.invoke('get-pipeline-list', settings); } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function runPipeline(itemId: string, pipeline: string) { - 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; } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -109,6 +84,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(); @@ -118,6 +94,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)) { @@ -146,8 +123,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, @@ -178,6 +154,4 @@ export { openFromDisk, openLink, nvidiaSmi, - validateSettings, - getDefaultSettings, }; diff --git a/client/platform/desktop/backend/ipcService.ts b/client/platform/desktop/backend/ipcService.ts index f924ed5b1..266233959 100644 --- a/client/platform/desktop/backend/ipcService.ts +++ b/client/platform/desktop/backend/ipcService.ts @@ -1,7 +1,8 @@ // 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'; @@ -40,4 +41,10 @@ export default function register() { 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 c836a9385..c243cc321 100644 --- a/client/platform/desktop/backend/platforms/common.ts +++ b/client/platform/desktop/backend/platforms/common.ts @@ -8,10 +8,13 @@ import { spawn } from 'child_process'; // eslint-disable-next-line import/no-extraneous-dependencies import { shell } from 'electron'; // eslint-disable-next-line import/no-extraneous-dependencies +import mime from 'mime-types'; +// eslint-disable-next-line import/no-extraneous-dependencies import { xml2json } from 'xml-js'; import moment from 'moment'; import { DatasetType, Pipelines } from 'viame-web-common/apispec'; -import { Settings } from 'platform/desktop/store/settings'; + +import { Settings, NvidiaSmiReply, websafeImageTypes } from '../../constants'; const AuxFolderName = 'auxiliary'; const JobFolderName = 'job_runs'; @@ -21,51 +24,12 @@ const JobFolderName = 'job_runs'; // result.json const JsonFileName = /^result(_.*)?\.json$/; -interface NvidiaSmiTextRecord { - _text: string; -} - -export interface NvidiaSmiReply { - output: { - nvidia_smi_log: { - driver_version: NvidiaSmiTextRecord; - cuda_version: NvidiaSmiTextRecord; - attached_gpus: NvidiaSmiTextRecord; - }; - } | null; - code: number; - error: string; -} - -export interface JobCreatedReply { - // The name of the pipe or job being run - pipeline: string; - // A unique identifier for the job - jobName: string; - // The pid of the process spawned - pid: number; - // The working directory of the job's output - workingDir: string; - // If exitCode is set, the job exited already - exitCode?: number; -} - -export interface JobUpdateReply { - // Matches JobCreated identifier - jobName: string; - // Originating pid - pid: number; - // Update Body - body: string; - // If exitCode is set, the job exited already - exitCode?: number; -} - async function getDatasetBase(datasetId: string): Promise<{ datasetType: DatasetType; basePath: string; name: string; jsonFile: string | null; + imageFiles: string[]; directoryContents: string[]; }> { let datasetType: DatasetType = 'image-sequence'; @@ -93,6 +57,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) { @@ -103,6 +76,7 @@ async function getDatasetBase(datasetId: string): Promise<{ datasetType, basePath: datasetFolderPath, jsonFile, + imageFiles, name: npath.parse(datasetId).name, directoryContents: contents, }; @@ -179,21 +153,21 @@ async function nvidiaSmi(): Promise { smi.stdout.on('data', (chunk) => { result = result.concat(chunk.toString('utf-8')); }); - smi.on('close', (code) => { + smi.on('close', (exitCode) => { let jsonStr = 'null'; // parses to null - if (code === 0) { + if (exitCode === 0) { jsonStr = xml2json(result, { compact: true }); } resolve({ output: JSON.parse(jsonStr), - code, + exitCode, error: result, }); }); smi.on('error', (err) => { resolve({ output: null, - code: -1, + exitCode: -1, error: err.message, }); }); diff --git a/client/platform/desktop/backend/platforms/linux.ts b/client/platform/desktop/backend/platforms/linux.ts index 1c4ec3e0f..21b658400 100644 --- a/client/platform/desktop/backend/platforms/linux.ts +++ b/client/platform/desktop/backend/platforms/linux.ts @@ -1,12 +1,16 @@ /** * VIAME process manager for linux platform */ -import path from 'path'; +import npath from 'path'; import { spawn } from 'child_process'; // eslint-disable-next-line import/no-extraneous-dependencies import fs from 'fs-extra'; -import { Settings, SettingsCurrentVersion } from '../../store/settings'; +import { + Settings, SettingsCurrentVersion, + DesktopJob, DesktopJobUpdate, RunPipeline, +} from '../../constants'; + import common from './common'; const DefaultSettings: Settings = { @@ -19,7 +23,7 @@ const DefaultSettings: Settings = { }; async function validateViamePath(settings: Settings): Promise { - const setupScriptPath = path.join(settings.viamePath, 'setup_viame.sh'); + const setupScriptPath = npath.join(settings.viamePath, 'setup_viame.sh'); const setupExists = await fs.pathExists(setupScriptPath); if (!setupExists) { return `${setupScriptPath} does not exist`; @@ -40,69 +44,117 @@ async function validateViamePath(settings: Settings): Promise { }); } -// command = [ -// f"cd {conf.viame_install_path} &&", -// ". ./setup_viame.sh &&", -// "kwiver runner", -// "-s input:video_reader:type=vidl_ffmpeg", -// f"-p {pipeline_path}", -// f"-s input:video_filename={input_file}", -// f"-s detector_writer:file_name={detector_output_path}", -// f"-s track_writer:file_name={track_output_path}", -// ] -// elif input_type == 'image-sequence': -// with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as temp2: -// temp2.writelines( -// ( -// (os.path.join(input_path, file_name) + "\n").encode() -// for file_name in sorted(filtered_directory_files) -// ) -// ) -// image_list_file = temp2.name -// command = [ -// f"cd {conf.viame_install_path} &&", -// ". ./setup_viame.sh &&", -// "kwiver runner", -// f"-p {pipeline_path}", -// f"-s input:video_filename={image_list_file}", -// f"-s detector_writer:file_name={detector_output_path}", -// f"-s track_writer:file_name={track_output_path}", -// ] - /** * Fashioned as a node.js implementation of viame_tasks.tasks.run_pipeline * - * @param p dataset path absolute + * @param datasetIdPath dataset path absolute * @param pipeline pipeline file basename * @param settings global settings */ -async function runPipeline(p: string, pipeline: string, settings: 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 = path.join(settings.viamePath, 'setup_viame.sh'); - const pipelinePath = path.join(settings.viamePath, 'configs/pipelines', pipeline); - const datasetInfo = await common.getDatasetBase(p); + + 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', + '-s input:video_reader:type=vidl_ffmpeg', `-p ${pipelinePath}`, - `-s input:video_filename=${p}`, - '-s detector_writer:file_name', + `-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') { - // command = [ - // `source ${setupScriptPath} &&`, - // 'kwiver runner', - // `-p ${pipelinePath}`, - // ] + // 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}"`, + ]; } - return Promise.resolve(command); + + 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', (code) => { + // eslint-disable-next-line no-console + console.log('exited', code); + updater({ + ...jobBase, + body: [''], + exitCode: code, + endTime: new Date(), + }); + }); + + return jobBase; } export default { diff --git a/client/platform/desktop/backend/serializers/cli.ts b/client/platform/desktop/backend/serializers/cli.ts new file mode 100755 index 000000000..1d520b868 --- /dev/null +++ b/client/platform/desktop/backend/serializers/cli.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env ts-node-script + +// eslint-disable-next-line import/no-extraneous-dependencies +import yargs from 'yargs'; +import { parseFile } from './viame'; + +/** + * Command line entrypoints to run these serializers + * directly from console + */ + +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')) { + console.log(argv); + parseFile(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..831203289 --- /dev/null +++ b/client/platform/desktop/backend/serializers/viame.ts @@ -0,0 +1,98 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import csvparser from 'csv-parse'; +// eslint-disable-next-line import/no-extraneous-dependencies +import fs from 'fs-extra'; +import { pipeline } from 'stream'; + +import { cloneDeep } from 'lodash'; +import { + TrackData, Feature, StringKeyObject, ConfidencePair, +} from 'vue-media-annotator/track'; + +function _rowInfo(row: string[]) { + if (row.length < 9) { + throw new Error('malformed row: too few columns'); + } + 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 _createGeoJSONFeauture( + feature: Feature, type: string, coords: number[], key = '', +) { + const f = cloneDeep(feature); + const geoFeature = {}; + if (!f.geometry) { + f.geometry = { type: 'FeatureCollection', features: [] }; + } + return f; +} + +function _parseRow(row: string[]): { + feature: Feature; + attributes: StringKeyObject; + trackAttributes: StringKeyObject; + confidencePairs: ConfidencePair[]; +} { + const info = _rowInfo(row); + const feature: Feature = { + frame: info.frame, + bounds: info.bounds, + fishLength: info.fishLength || undefined, + }; + return { + feature, + attributes: {}, + trackAttributes: {}, + 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: Record = {}; + const dataList: TrackData[] = []; + + return new Promise((resolve, reject) => { + pipeline(input, parser, (err) => { + reject(err); + }); + parser.on('readable', () => { + let record; + // eslint-disable-next-line no-cond-assign + while (record = parser.read()) { + console.log(record); + } + }); + parser.on('error', reject); + parser.on('end', () => resolve(dataList)); + }); +} + +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/background.ts b/client/platform/desktop/background.ts index b5b30ee81..944eac285 100644 --- a/client/platform/desktop/background.ts +++ b/client/platform/desktop/background.ts @@ -33,6 +33,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 diff --git a/client/platform/desktop/components/JobTab.vue b/client/platform/desktop/components/JobTab.vue new file mode 100644 index 000000000..377e17f30 --- /dev/null +++ b/client/platform/desktop/components/JobTab.vue @@ -0,0 +1,5 @@ + diff --git a/client/platform/desktop/components/Jobs.vue b/client/platform/desktop/components/Jobs.vue new file mode 100644 index 000000000..39aef54bc --- /dev/null +++ b/client/platform/desktop/components/Jobs.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/client/platform/desktop/components/NavigationBar.vue b/client/platform/desktop/components/NavigationBar.vue index 74f8b7ae0..793c76f13 100644 --- a/client/platform/desktop/components/NavigationBar.vue +++ b/client/platform/desktop/components/NavigationBar.vue @@ -2,9 +2,10 @@ 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 }, });