diff --git a/helpers/odmOptionsToJson.py b/helpers/odmOptionsToJson.py new file mode 100644 index 0000000..578d3ce --- /dev/null +++ b/helpers/odmOptionsToJson.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +''' +Node-OpenDroneMap Node.js App and REST API to access OpenDroneMap. +Copyright (C) 2016 Node-OpenDroneMap Contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +''' + +import sys +import imp +import argparse +import json + +imp.load_source('context', sys.argv[2] + '/opendm/context.py') +odm = imp.load_source('config', sys.argv[2] + '/opendm/config.py') + +options = {} +class ArgumentParserStub(argparse.ArgumentParser): + def add_argument(self, *args, **kwargs): + argparse.ArgumentParser.add_argument(self, *args, **kwargs) + options[args[0]] = {} + for name, value in kwargs.items(): + options[args[0]][str(name)] = str(value) + +odm.parser = ArgumentParserStub() +odm.config() +print json.dumps(options) diff --git a/index.js b/index.js index 1cede3f..97e7bfd 100644 --- a/index.js +++ b/index.js @@ -64,6 +64,7 @@ let winstonStream = { let TaskManager = require('./libs/taskManager'); let Task = require('./libs/Task'); +let odmOptions = require('./libs/odmOptions'); app.use(morgan('combined', { stream : winstonStream })); app.use(bodyParser.urlencoded({extended: true})); @@ -93,8 +94,18 @@ let upload = multer({ app.post('/task/new', addRequestId, upload.array('images'), (req, res) => { if (req.files.length === 0) res.json({error: "Need at least 1 file."}); else{ - // Move to data async.series([ + cb => { + odmOptions.filterOptions(req.body.options, (err, options) => { + if (err) cb(err); + else{ + req.body.options = options; + cb(null); + } + }); + }, + + // Move uploads to data dir cb => { fs.stat(`data/${req.id}`, (err, stat) => { if (err && err.code === 'ENOENT') cb(); @@ -104,19 +115,21 @@ app.post('/task/new', addRequestId, upload.array('images'), (req, res) => { cb => { fs.mkdir(`data/${req.id}`, undefined, cb); }, cb => { fs.rename(`tmp/${req.id}`, `data/${req.id}/images`, err => { - if (!err){ - new Task(req.id, req.body.name, (err, task) => { - if (err) cb(err); - else{ - taskManager.addNew(task); - res.json({uuid: req.id, success: true}); - cb(); - } - }); - }else{ - cb(new Error("Could not move images folder.")) - } + if (!err) cb(); + else cb(new Error("Could not move images folder.")) }); + }, + + // Create task + cb => { + new Task(req.id, req.body.name, (err, task) => { + if (err) cb(err); + else{ + taskManager.addNew(task); + res.json({uuid: req.id, success: true}); + cb(); + } + }, req.body.options); } ], err => { if (err) res.json({error: err.message}) @@ -172,9 +185,16 @@ app.post('/task/restart', uuidCheck, (req, res) => { taskManager.restart(req.body.uuid, successHandler(res)); }); +app.get('/getOptions', (req, res) => { + odmOptions.getOptions((err, options) => { + if (err) res.json({error: err.message}); + else res.json(options); + }); +}); + let gracefulShutdown = done => { async.series([ - cb => { taskManager.dumpTaskList(cb) }, + cb => taskManager.dumpTaskList(cb), cb => { logger.info("Closing server"); server.close(); diff --git a/libs/Task.js b/libs/Task.js index e30a6ba..8d9fd58 100644 --- a/libs/Task.js +++ b/libs/Task.js @@ -25,7 +25,7 @@ let archiver = require('archiver'); let statusCodes = require('./statusCodes'); module.exports = class Task{ - constructor(uuid, name, done){ + constructor(uuid, name, done, options = []){ assert(uuid !== undefined, "uuid must be set"); assert(done !== undefined, "ready must be set"); @@ -34,10 +34,12 @@ module.exports = class Task{ this.dateCreated = new Date().getTime(); this.processingTime = -1; this.setStatus(statusCodes.QUEUED); - this.options = {}; + this.options = options; this.output = []; this.runnerProcess = null; + this.options.forEach(option => { console.log(option); }); + // Read images info fs.readdir(this.getImagesFolderPath(), (err, files) => { if (err) done(err); @@ -64,7 +66,7 @@ module.exports = class Task{ } done(null, task); } - }) + }, taskJson.options); } // Get path where images are stored for this task @@ -137,7 +139,7 @@ module.exports = class Task{ this.setStatus(statusCodes.CANCELED); if (wasRunning && this.runnerProcess){ - // TODO: this does guarantee that + // TODO: this does NOT guarantee that // the process will immediately terminate. // In fact, often times ODM will continue running for a while // This might need to be fixed on ODM's end. diff --git a/libs/TaskManager.js b/libs/TaskManager.js index 80c6fda..caa5918 100644 --- a/libs/TaskManager.js +++ b/libs/TaskManager.js @@ -34,8 +34,8 @@ module.exports = class TaskManager{ this.runningQueue = []; async.series([ - cb => { this.restoreTaskListFromDump(cb); }, - cb => { this.removeOldTasks(cb); }, + cb => this.restoreTaskListFromDump(cb), + cb => this.removeOldTasks(cb), cb => { this.processNextTask(); cb(); @@ -136,10 +136,7 @@ module.exports = class TaskManager{ removeFromRunningQueue(task){ assert(task.constructor.name === "Task", "Must be a Task object"); - - this.runningQueue = this.runningQueue.filter(t => { - return t !== task; - }); + this.runningQueue = this.runningQueue.filter(t => t !== task); } addNew(task){ diff --git a/libs/odmOptions.js b/libs/odmOptions.js new file mode 100644 index 0000000..b8d346b --- /dev/null +++ b/libs/odmOptions.js @@ -0,0 +1,210 @@ +/* +Node-OpenDroneMap Node.js App and REST API to access OpenDroneMap. +Copyright (C) 2016 Node-OpenDroneMap Contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +"use strict"; +let odmRunner = require('./odmRunner'); +let assert = require('assert'); + +let odmOptions = null; + +module.exports = { + getOptions: function(done){ + if (odmOptions){ + done(null, odmOptions); + return; + } + + odmRunner.getJsonOptions((err, json) => { + if (err) done(err); + else{ + odmOptions = []; + for (let option in json){ + // Not all options are useful to the end user + // (num cores can be set programmatically, so can gcpFile, etc.) + if (["-h", "--project-path", + "--zip-results", "--pmvs-num-cores", "--odm_georeferencing-useGcp", + "--start-with", "--odm_georeferencing-gcpFile", "--end-with"].indexOf(option) !== -1) continue; + + let values = json[option]; + + let name = option.replace(/^--/, ""); + let type = ""; + let value = ""; + let help = values.help || ""; + let domain = values.metavar !== undefined ? + values.metavar.replace(/^[<>]/g, "") + .replace(/[<>]$/g, "") + .trim() : + ""; + + switch((values.type || "").trim()){ + case "": + type = "int"; + value = values['default'] !== undefined ? + parseInt(values['default']) : + 0; + break; + case "": + type = "float"; + value = values['default'] !== undefined ? + parseFloat(values['default']) : + 0.0; + break; + default: + type = "string"; + value = values['default'] !== undefined ? + values['default'].trim() : + ""; + } + + if (values['default'] === "True"){ + type = "bool"; + value = true; + }else if (values['default'] === "False"){ + type = "bool"; + value = false; + } + + help = help.replace(/\%\(default\)s/g, value); + + odmOptions.push({ + name, type, value, domain, help + }); + } + done(null, odmOptions); + } + }); + }, + + // Checks that the options (as received from the rest endpoint) + // Are valid and within proper ranges. + // The result of filtering is passed back via callback + // @param options[] + filterOptions: function(options, done){ + assert(odmOptions !== null, "odmOptions is not set. Have you initialized odmOptions properly?"); + + try{ + if (typeof options === "string") options = JSON.parse(options); + + let result = []; + let errors = []; + function addError(opt, descr){ + errors.push({ + name: opt.name, + error: descr + }); + } + + let typeConversion = { + 'float': Number.parseFloat, + 'int': Number.parseInt, + 'bool': function(value){ + if (value === 'true') return true; + else if (value === 'false') return false; + else if (typeof value === 'boolean') return value; + else throw new Error(`Cannot convert ${value} to boolean`); + } + }; + + let domainChecks = [ + { + regex: /^(positive |negative )?(integer|float)$/, + validate: function(matches, value){ + if (matches[1] === 'positive ') return value >= 0; + else if (matches[1] === 'negative ') return value <= 0; + + else if (matches[2] === 'integer') return Number.isInteger(value); + else if (matches[2] === 'float') return Number.isFinite(value); + } + }, + { + regex: /^percent$/, + validate: function(matches, value){ + return value >= 0 && value <= 100; + } + }, + { + regex: /^(float): ([\-\+\.\d]+) <= x <= ([\-\+\.\d]+)$/, + validate: function(matches, value){ + let [str, type, lower, upper] = matches; + lower = parseFloat(lower); + upper = parseFloat(upper); + return value >= lower && value <= upper; + } + }, + { + regex: /^(float) (>=|>|<|<=) ([\-\+\.\d]+)$/, + validate: function(matches, value){ + let [str, type, oper, bound] = matches; + bound = parseFloat(bound); + switch(oper){ + case '>=': + return value >= bound; + case '>': + return value > bound; + case '<=': + return value <= bound; + case '<': + return value < bound; + default: + return false; + } + } + } + ]; + + function checkDomain(domain, value){ + let dc, matches; + + if (dc = domainChecks.find(dc => matches = domain.match(dc.regex))){ + if (!dc.validate(matches, value)) throw new Error(`Invalid value ${value} (out of range)`); + }else{ + throw new Error(`Domain value cannot be handled: '${domain}' : '${value}'`); + } + } + + // Scan through all possible options + for (let odmOption of odmOptions){ + // Was this option selected by the user? + let opt; + if (opt = options.find(o => o.name === odmOption.name)){ + try{ + // Convert to proper data type + let value = typeConversion[odmOption.type](opt.value); + + // Domain check + if (odmOption.domain){ + checkDomain(odmOption.domain, value); + } + + result.push({ + name: odmOption.name, + value: value + }); + }catch(e){ + addError(opt, e.message); + } + } + } + + if (errors.length > 0) done(new Error(JSON.stringify(errors))); + else done(null, result); + }catch(e){ + done(e); + } + } +}; \ No newline at end of file diff --git a/libs/odmRunner.js b/libs/odmRunner.js index 6cd41f7..7299c7f 100644 --- a/libs/odmRunner.js +++ b/libs/odmRunner.js @@ -30,19 +30,36 @@ module.exports = { "--project-path", options.projectPath ], {cwd: ODM_PATH}); + childProcess + .on('exit', (code, signal) => done(null, code, signal)) + .on('error', done); + + childProcess.stdout.on('data', chunk => outputReceived(chunk.toString())); + childProcess.stderr.on('data', chunk => outputReceived(chunk.toString())); + + return childProcess; + }, + + getJsonOptions: function(done){ + // Launch + let childProcess = spawn("python", [`${__dirname}/../helpers/odmOptionsToJson.py`, + "--project-path", ODM_PATH]); + let output = []; + childProcess .on('exit', (code, signal) => { - done(null, code, signal); + try{ + let json = JSON.parse(output.join("")); + done(null, json); + }catch(err){ + done(err); + } }) .on('error', done); - childProcess.stdout.on('data', chunk => { - outputReceived(chunk.toString()); - }); - childProcess.stderr.on('data', chunk => { - outputReceived(chunk.toString()); - }); + let processOutput = chunk => output.push(chunk.toString()); - return childProcess; + childProcess.stdout.on('data', processOutput); + childProcess.stderr.on('data', processOutput); } }; diff --git a/public/css/main.css b/public/css/main.css index 8b5d502..0da13d7 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -55,4 +55,12 @@ height: 200px; font-family: monospace; font-size: 90%; +} + +.selectric-items li{ + background: #fff; +} + +#options .checkbox{ + margin-right: 143px; } \ No newline at end of file diff --git a/public/index.html b/public/index.html index 6b970e5..21a2565 100644 --- a/public/index.html +++ b/public/index.html @@ -15,7 +15,6 @@ } - @@ -41,20 +40,42 @@
-
- -
-
-
- + +
+
+
+
+
+ + + +
+
+
+ + + + +
+
+ + + -
- +

+
+
+
+
+ -
+

Current Tasks ()

No running tasks.

@@ -113,6 +134,7 @@ + diff --git a/public/js/main.js b/public/js/main.js index 37c5a74..310efcc 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -149,8 +149,8 @@ $(function(){ }) .always(function(){ self.loading(false); }); }; - Task.prototype.consoleMouseOver = function(){ this.autoScrollOutput = false; } - Task.prototype.consoleMouseOut = function(){ this.autoScrollOutput = true; } + Task.prototype.consoleMouseOver = function(){ this.autoScrollOutput = false; }; + Task.prototype.consoleMouseOut = function(){ this.autoScrollOutput = true; }; Task.prototype.resetOutput = function(){ this.viewOutputLine = 0; this.autoScrollOutput = true; @@ -170,7 +170,7 @@ $(function(){ self.viewOutputLine += output.length; if (self.autoScrollOutput){ var $console = $("#console_" + self.uuid); - $console.scrollTop($console[0].scrollHeight - $console.height()) + $console.scrollTop($console[0].scrollHeight - $console.height()); } } }) @@ -243,8 +243,8 @@ $(function(){ self.info({error: url + " is unreachable."}); self.stopRefreshingInfo(); }); - } - }; + }; + } Task.prototype.cancel = genApiCall("/task/cancel"); Task.prototype.restart = genApiCall("/task/restart", function(task){ task.resetOutput(); @@ -254,7 +254,7 @@ $(function(){ }; var taskList = new TaskList(); - ko.applyBindings(taskList); + ko.applyBindings(taskList, document.getElementById('taskList')); // Handle uploads $("#images").fileinput({ @@ -266,7 +266,8 @@ $(function(){ uploadAsync: false, uploadExtraData: function(){ return { - name: $("#taskName").val() + name: $("#taskName").val(), + options: JSON.stringify(optionsModel.getUserOptions()) }; } }); @@ -294,4 +295,55 @@ $(function(){ }) .on('filebatchuploaderror', function(e, data, msg){ }); + + // Load options + function Option(properties){ + this.properties = properties; + this.value = ko.observable(); + } + Option.prototype.resetToDefault = function(){ + this.value(undefined); + }; + + function OptionsModel(){ + var self = this; + + this.options = ko.observableArray(); + this.options.subscribe(function(){ + setTimeout(function(){ + $('#options [data-toggle="tooltip"]').tooltip(); + }, 100); + }); + this.showOptions = ko.observable(false); + this.error = ko.observable(); + + $.get("/getOptions") + .done(function(json){ + if (json.error) self.error(json.error); + else{ + for (var i in json){ + self.options.push(new Option(json[i])); + } + } + }) + .fail(function(){ + self.error("options are not available."); + }); + } + OptionsModel.prototype.getUserOptions = function(){ + var result = []; + for (var i = 0; i < this.options().length; i++){ + var opt = this.options()[i]; + if (opt.value() !== undefined){ + result.push({ + name: opt.properties.name, + value: opt.value() + }); + } + } + return result; + }; + + var optionsModel = new OptionsModel(); + ko.applyBindings(optionsModel, document.getElementById("options")); });