diff --git a/coreplugins/objdetect/__init__.py b/coreplugins/objdetect/__init__.py new file mode 100644 index 00000000..48aad58e --- /dev/null +++ b/coreplugins/objdetect/__init__.py @@ -0,0 +1 @@ +from .plugin import * diff --git a/coreplugins/objdetect/api.py b/coreplugins/objdetect/api.py new file mode 100644 index 00000000..026eab61 --- /dev/null +++ b/coreplugins/objdetect/api.py @@ -0,0 +1,122 @@ +import os + +from rest_framework import status +from rest_framework.response import Response +from app.plugins.views import TaskView, CheckTask, GetTaskResult +from app.plugins.worker import run_function_async +from django.utils.translation import gettext_lazy as _ + +class ContoursException(Exception): + pass + +def calc_contours(dem, epsg, interval, output_format, simplify, zfactor = 1): + import os + import subprocess + import tempfile + import shutil + import glob + from webodm import settings + + ext = "" + if output_format == "GeoJSON": + ext = "json" + elif output_format == "GPKG": + ext = "gpkg" + elif output_format == "DXF": + ext = "dxf" + elif output_format == "ESRI Shapefile": + ext = "shp" + MIN_CONTOUR_LENGTH = 10 + + tmpdir = os.path.join(settings.MEDIA_TMP, os.path.basename(tempfile.mkdtemp('_contours', dir=settings.MEDIA_TMP))) + gdal_contour_bin = shutil.which("gdal_contour") + ogr2ogr_bin = shutil.which("ogr2ogr") + + if gdal_contour_bin is None: + return {'error': 'Cannot find gdal_contour'} + if ogr2ogr_bin is None: + return {'error': 'Cannot find ogr2ogr'} + + contours_file = f"contours.gpkg" + p = subprocess.Popen([gdal_contour_bin, "-q", "-a", "level", "-3d", "-f", "GPKG", "-i", str(interval), dem, contours_file], cwd=tmpdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = p.communicate() + + out = out.decode('utf-8').strip() + err = err.decode('utf-8').strip() + success = p.returncode == 0 + + if not success: + return {'error', f'Error calling gdal_contour: {str(err)}'} + + outfile = os.path.join(tmpdir, f"output.{ext}") + p = subprocess.Popen([ogr2ogr_bin, outfile, contours_file, "-simplify", str(simplify), "-f", output_format, "-t_srs", f"EPSG:{epsg}", "-nln", "contours", + "-dialect", "sqlite", "-sql", f"SELECT ID, ROUND(level * {zfactor}, 5) AS level, GeomFromGML(AsGML(ATM_Transform(GEOM, ATM_Scale(ATM_Create(), 1, 1, {zfactor})), 10)) as GEOM FROM contour WHERE ST_Length(GEOM) >= {MIN_CONTOUR_LENGTH}"], cwd=tmpdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = p.communicate() + + out = out.decode('utf-8').strip() + err = err.decode('utf-8').strip() + success = p.returncode == 0 + + if not success: + return {'error', f'Error calling ogr2ogr: {str(err)}'} + + if not os.path.isfile(outfile): + return {'error': f'Cannot find output file: {outfile}'} + + if output_format == "ESRI Shapefile": + ext="zip" + shp_dir = os.path.join(tmpdir, "contours") + os.makedirs(shp_dir) + contour_files = glob.glob(os.path.join(tmpdir, "output.*")) + for cf in contour_files: + shutil.move(cf, shp_dir) + + shutil.make_archive(os.path.join(tmpdir, 'output'), 'zip', shp_dir) + outfile = os.path.join(tmpdir, f"output.{ext}") + + return {'file': outfile} + + +class TaskContoursGenerate(TaskView): + def post(self, request, pk=None): + task = self.get_and_check_task(request, pk) + + layer = request.data.get('layer', None) + if layer == 'DSM' and task.dsm_extent is None: + return Response({'error': _('No DSM layer is available.')}) + elif layer == 'DTM' and task.dtm_extent is None: + return Response({'error': _('No DTM layer is available.')}) + + try: + if layer == 'DSM': + dem = os.path.abspath(task.get_asset_download_path("dsm.tif")) + elif layer == 'DTM': + dem = os.path.abspath(task.get_asset_download_path("dtm.tif")) + else: + raise ContoursException('{} is not a valid layer.'.format(layer)) + + epsg = int(request.data.get('epsg', '3857')) + interval = float(request.data.get('interval', 1)) + format = request.data.get('format', 'GPKG') + supported_formats = ['GPKG', 'ESRI Shapefile', 'DXF', 'GeoJSON'] + if not format in supported_formats: + raise ContoursException("Invalid format {} (must be one of: {})".format(format, ",".join(supported_formats))) + simplify = float(request.data.get('simplify', 0.01)) + zfactor = float(request.data.get('zfactor', 1)) + + celery_task_id = run_function_async(calc_contours, dem, epsg, interval, format, simplify, zfactor).task_id + return Response({'celery_task_id': celery_task_id}, status=status.HTTP_200_OK) + except ContoursException as e: + return Response({'error': str(e)}, status=status.HTTP_200_OK) + +class TaskContoursCheck(CheckTask): + def on_error(self, result): + pass + + def error_check(self, result): + contours_file = result.get('file') + if not contours_file or not os.path.exists(contours_file): + return _('Could not generate contour file. This might be a bug.') + +class TaskContoursDownload(GetTaskResult): + pass diff --git a/coreplugins/objdetect/manifest.json b/coreplugins/objdetect/manifest.json new file mode 100644 index 00000000..92839d5f --- /dev/null +++ b/coreplugins/objdetect/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Object Detect", + "webodmMinVersion": "2.5.8", + "description": "Detect objects using AI in orthophotos", + "version": "1.0.0", + "author": "Piero Toffanin", + "email": "pt@uav4geo.com", + "repository": "https://github.com/OpenDroneMap/WebODM", + "tags": ["object", "detect", "ai"], + "homepage": "https://github.com/OpenDroneMap/WebODM", + "experimental": false, + "deprecated": false +} \ No newline at end of file diff --git a/coreplugins/objdetect/plugin.py b/coreplugins/objdetect/plugin.py new file mode 100644 index 00000000..1dbf7e2e --- /dev/null +++ b/coreplugins/objdetect/plugin.py @@ -0,0 +1,20 @@ +from app.plugins import PluginBase +from app.plugins import MountPoint +# from .api import TaskContoursGenerate +# from .api import TaskContoursCheck +# from .api import TaskContoursDownload + + +class Plugin(PluginBase): + def include_js_files(self): + return ['main.js'] + + def build_jsx_components(self): + return ['ObjDetect.jsx'] + + def api_mount_points(self): + return [ + # MountPoint('task/(?P[^/.]+)/contours/generate', TaskContoursGenerate.as_view()), + # MountPoint('task/[^/.]+/contours/check/(?P.+)', TaskContoursCheck.as_view()), + # MountPoint('task/[^/.]+/contours/download/(?P.+)', TaskContoursDownload.as_view()), + ] \ No newline at end of file diff --git a/coreplugins/objdetect/public/ObjDetect.jsx b/coreplugins/objdetect/public/ObjDetect.jsx new file mode 100644 index 00000000..e494479e --- /dev/null +++ b/coreplugins/objdetect/public/ObjDetect.jsx @@ -0,0 +1,55 @@ +import L from 'leaflet'; +import ReactDOM from 'ReactDOM'; +import React from 'React'; +import PropTypes from 'prop-types'; +import './ObjDetect.scss'; +import ObjDetectPanel from './ObjDetectPanel'; + +class ObjDetectButton extends React.Component { + static propTypes = { + tasks: PropTypes.object.isRequired, + map: PropTypes.object.isRequired + } + + constructor(props){ + super(props); + + this.state = { + showPanel: false + }; + } + + handleOpen = () => { + this.setState({showPanel: true}); + } + + handleClose = () => { + this.setState({showPanel: false}); + } + + render(){ + const { showPanel } = this.state; + + return (
+ + +
); + } +} + +export default L.Control.extend({ + options: { + position: 'topright' + }, + + onAdd: function (map) { + var container = L.DomUtil.create('div', 'leaflet-control-objdetect leaflet-bar leaflet-control'); + L.DomEvent.disableClickPropagation(container); + ReactDOM.render(, container); + + return container; + } +}); + diff --git a/coreplugins/objdetect/public/ObjDetect.scss b/coreplugins/objdetect/public/ObjDetect.scss new file mode 100644 index 00000000..c23b5be5 --- /dev/null +++ b/coreplugins/objdetect/public/ObjDetect.scss @@ -0,0 +1,24 @@ +.leaflet-control-objdetect{ + z-index: 999 !important; + + a.leaflet-control-objdetect-button{ + background: url(icon.svg) no-repeat 0 0; + background-size: 26px 26px; + border-radius: 2px; + } + + div.objdetect-panel{ display: none; } + + .open{ + a.leaflet-control-objdetect-button{ + display: none; + } + + div.objdetect-panel{ + display: block; + } + } +} +.leaflet-touch .leaflet-control-objdetect a { + background-position: 2px 2px; +} diff --git a/coreplugins/objdetect/public/ObjDetectPanel.jsx b/coreplugins/objdetect/public/ObjDetectPanel.jsx new file mode 100644 index 00000000..946b44f3 --- /dev/null +++ b/coreplugins/objdetect/public/ObjDetectPanel.jsx @@ -0,0 +1,205 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Storage from 'webodm/classes/Storage'; +import L from 'leaflet'; +import './ObjDetectPanel.scss'; +import ErrorMessage from 'webodm/components/ErrorMessage'; +import Workers from 'webodm/classes/Workers'; +import { _ } from 'webodm/classes/gettext'; + +export default class ObjDetectPanel extends React.Component { + static defaultProps = { + }; + static propTypes = { + onClose: PropTypes.func.isRequired, + tasks: PropTypes.object.isRequired, + isShowed: PropTypes.bool.isRequired, + map: PropTypes.object.isRequired + } + + constructor(props){ + super(props); + + this.state = { + error: "", + permanentError: "", + model: Storage.getItem("last_objdetect_model") || "cars", + loading: true, + task: props.tasks[0] || null, + detecting: false, + objLayer: null, + }; + } + + componentDidMount(){ + } + + componentDidUpdate(){ + if (this.props.isShowed && this.state.loading){ + const {id, project} = this.state.task; + + this.loadingReq = $.getJSON(`/api/projects/${project}/tasks/${id}/`) + .done(res => { + const { available_assets } = res; + if (available_assets.indexOf("orthophoto.tif") === -1){ + this.setState({permanentError: _("No orthophoto is available. To use object detection you need an orthophoto.")}); + } + }) + .fail(() => { + this.setState({permanentError: _("Cannot retrieve information for task. Are you are connected to the internet?")}) + }) + .always(() => { + this.setState({loading: false}); + this.loadingReq = null; + }); + } + } + + componentWillUnmount(){ + if (this.loadingReq){ + this.loadingReq.abort(); + this.loadingReq = null; + } + if (this.detectReq){ + this.detectReq.abort(); + this.detectReq = null; + } + } + + handleSelectModel = e => { + this.setState({model: e.target.value}); + } + + getFormValues = () => { + const { model } = this.state; + + return { + model + }; + } + + addGeoJSONFromURL = (url, cb) => { + const { map } = this.props; + + $.getJSON(url) + .done((geojson) => { + try{ + this.handleRemoveObjLayer(); + + this.setState({objLayer: L.geoJSON(geojson, { + onEachFeature: (feature, layer) => { + if (feature.properties && feature.properties.level !== undefined) { + layer.bindPopup(`
+ ${_("Class:")} ${feature.properties.class}
+ ${_("Score:")} ${feature.properties.score}
+
+ `); + } + }, + style: feature => { + // TODO: different colors for different elevations? + return {color: "yellow"}; + } + })}); + this.state.objLayer.addTo(map); + + cb(); + }catch(e){ + cb(e.message); + } + }) + .fail(cb); + } + + handleRemoveObjLayer = () => { + const { map } = this.props; + + if (this.state.objLayer){ + map.removeLayer(this.state.objLayer); + this.setState({objLayer: null}); + } + } + + saveInputValues = () => { + // Save settings + Storage.setItem("last_objdetect_model", this.state.model); + } + + handleDetect = () => { + this.setState({detecting: true, error: ""}); + const taskId = this.state.task.id; + this.saveInputValues(); + + this.detectReq = $.ajax({ + type: 'POST', + url: `/api/plugins/objdetect/task/${taskId}/detect`, + data: this.getFormValues() + }).done(result => { + if (result.celery_task_id){ + Workers.waitForCompletion(result.celery_task_id, error => { + if (error) this.setState({detecting: false, error}); + else{ + const fileUrl = `/api/plugins/objdetect/task/${taskId}/download/${result.celery_task_id}`; + + this.addGeoJSONFromURL(fileUrl, e => { + if (e) this.setState({error: JSON.stringify(e)}); + this.setState({detecting: false}); + }); + } + }, `/api/plugins/objdetect/task/${taskId}/check/`); + }else if (result.error){ + this.setState({detecting: false, error: result.error}); + }else{ + this.setState({detecting: false, error: "Invalid response: " + result}); + } + }).fail(error => { + this.setState({detecting: false, error: JSON.stringify(error)}); + }); + } + + render(){ + const { loading, permanentError, objLayer, detecting, model } = this.state; + const models = [ + {label: _('Cars'), value: 'cars'}, + {label: _('Trees'), value: 'trees'}, + ] + + let content = ""; + if (loading) content = ( {_("Loading…")}); + else if (permanentError) content = (
{permanentError}
); + else{ + content = (
+ +
+ +
+ +
+
+ +
+
+ {objLayer ? + + : ""} +
+
+ +
+
+
); + } + + return (
+ +
{_("Object Detection")}
+
+ {content} +
); + } +} diff --git a/coreplugins/objdetect/public/ObjDetectPanel.scss b/coreplugins/objdetect/public/ObjDetectPanel.scss new file mode 100644 index 00000000..47f8929d --- /dev/null +++ b/coreplugins/objdetect/public/ObjDetectPanel.scss @@ -0,0 +1,72 @@ +.leaflet-control-objdetect .objdetect-panel{ + padding: 6px 10px 6px 6px; + background: #fff; + min-width: 250px; + max-width: 300px; + + .close-button{ + display: inline-block; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAAAkUlEQVR4AZWRxQGDUBAFJ9pMflNIP/iVSkIb2wgccXd7g7O+3JXCQUgqBAfFSl8CMooJGQHfuUlEwZpoahZQ7ODTSXWJQkxyioock7BL2tXmdF4moJNX6IDZfbUBQNrX7qfeXfPuqwBAQjEz60w64htGJ+luFH48gt+NYe6v5b/cnr9asM+HlRQ2Qlwh2CjuqQQ9vKsKTwhQ1wAAAABJRU5ErkJggg==); + height: 18px; + width: 18px; + margin-right: 0; + float: right; + vertical-align: middle; + text-align: right; + margin-top: 0px; + margin-left: 16px; + position: relative; + left: 2px; + + &:hover{ + opacity: 0.7; + cursor: pointer; + } + } + + .title{ + font-size: 120%; + margin-right: 60px; + } + + hr{ + clear: both; + margin: 6px 0px; + border-color: #ddd; + } + + label{ + padding-top: 5px; + } + + select, input{ + height: auto; + padding: 4px; + } + + *{ + font-size: 12px; + } + + .row.form-group.form-inline{ + margin-bottom: 8px; + } + + .dropdown-menu{ + a{ + width: 100%; + text-align: left; + display: block; + padding-top: 0; + padding-bottom: 0; + } + } + + .btn-detect{ + margin-right: 8px; + } + + .action-buttons{ + margin-top: 12px; + } +} diff --git a/coreplugins/objdetect/public/icon.svg b/coreplugins/objdetect/public/icon.svg new file mode 100644 index 00000000..67497f59 --- /dev/null +++ b/coreplugins/objdetect/public/icon.svg @@ -0,0 +1,60 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/coreplugins/objdetect/public/main.js b/coreplugins/objdetect/public/main.js new file mode 100644 index 00000000..250fd49e --- /dev/null +++ b/coreplugins/objdetect/public/main.js @@ -0,0 +1,14 @@ +PluginsAPI.Map.willAddControls([ + 'objdetect/build/ObjDetect.js', + 'objdetect/build/ObjDetect.css' + ], function(args, ObjDetect){ + var tasks = []; + for (var i = 0; i < args.tiles.length; i++){ + tasks.push(args.tiles[i].meta.task); + } + + // TODO: add support for map view where multiple tasks are available? + if (tasks.length === 1){ + args.map.addControl(new ObjDetect({map: args.map, tasks: tasks})); + } +}); diff --git a/package.json b/package.json index 6edfe846..f53ba4c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WebODM", - "version": "2.5.7", + "version": "2.5.8", "description": "User-friendly, extendable application and API for processing aerial imagery.", "main": "index.js", "scripts": { diff --git a/requirements.txt b/requirements.txt index 945db922..5dbf8bc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,7 @@ drf-nested-routers==0.11.1 funcsigs==1.0.2 futures==3.1.1 gunicorn==19.8.0 +geodeep==0.9.4 itypes==1.1.0 kombu==4.6.7 Markdown==3.3.4