From 7f44e62ac425dade1cd2743df841946b98a00733 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 23 Jan 2025 15:09:58 -0500 Subject: [PATCH 1/8] Object detection plugin mock --- coreplugins/objdetect/__init__.py | 1 + coreplugins/objdetect/api.py | 122 +++++++++++ coreplugins/objdetect/manifest.json | 13 ++ coreplugins/objdetect/plugin.py | 20 ++ coreplugins/objdetect/public/ObjDetect.jsx | 55 +++++ coreplugins/objdetect/public/ObjDetect.scss | 24 ++ .../objdetect/public/ObjDetectPanel.jsx | 205 ++++++++++++++++++ .../objdetect/public/ObjDetectPanel.scss | 72 ++++++ coreplugins/objdetect/public/icon.svg | 60 +++++ coreplugins/objdetect/public/main.js | 14 ++ package.json | 2 +- requirements.txt | 1 + 12 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 coreplugins/objdetect/__init__.py create mode 100644 coreplugins/objdetect/api.py create mode 100644 coreplugins/objdetect/manifest.json create mode 100644 coreplugins/objdetect/plugin.py create mode 100644 coreplugins/objdetect/public/ObjDetect.jsx create mode 100644 coreplugins/objdetect/public/ObjDetect.scss create mode 100644 coreplugins/objdetect/public/ObjDetectPanel.jsx create mode 100644 coreplugins/objdetect/public/ObjDetectPanel.scss create mode 100644 coreplugins/objdetect/public/icon.svg create mode 100644 coreplugins/objdetect/public/main.js 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 From 27074d71075343a746a9d47740c39c9e3fec0002 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 24 Jan 2025 11:43:19 -0500 Subject: [PATCH 2/8] PoC object detection working --- app/plugins/views.py | 1 + coreplugins/objdetect/api.py | 134 ++++-------------- coreplugins/objdetect/plugin.py | 12 +- .../objdetect/public/ObjDetectPanel.jsx | 67 +++++---- .../objdetect/public/ObjDetectPanel.scss | 4 - 5 files changed, 73 insertions(+), 145 deletions(-) diff --git a/app/plugins/views.py b/app/plugins/views.py index 8944a0a9..c3d16a7e 100644 --- a/app/plugins/views.py +++ b/app/plugins/views.py @@ -3,6 +3,7 @@ import os from app.api.tasks import TaskNestedView as TaskView from app.api.workers import CheckTask as CheckTask from app.api.workers import GetTaskResult as GetTaskResult +from app.api.workers import TaskResultOutputError from django.http import HttpResponse, Http404 from .functions import get_plugin_by_name, get_active_plugins diff --git a/coreplugins/objdetect/api.py b/coreplugins/objdetect/api.py index 026eab61..4131930a 100644 --- a/coreplugins/objdetect/api.py +++ b/coreplugins/objdetect/api.py @@ -1,122 +1,48 @@ import os - +import json from rest_framework import status from rest_framework.response import Response -from app.plugins.views import TaskView, CheckTask, GetTaskResult +from app.plugins.views import TaskView, CheckTask, GetTaskResult, TaskResultOutputError 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): +def detect(orthophoto, model): 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 + try: + from geodeep import detect as gdetect, models + models.cache_dir = os.path.join(settings.MEDIA_ROOT, "CACHE", "detection_models") + except ImportError: + return {'error': "GeoDeep library is missing"} - 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): + try: + return {'output': gdetect(orthophoto, model, output_type='geojson')} + except Exception as e: + return {'error': str(e)} + +class TaskObjDetect(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.')}) + if task.orthophoto_extent is None: + return Response({'error': _('No orthophoto 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)) + orthophoto = os.path.abspath(task.get_asset_download_path("orthophoto.tif")) + model = request.data.get('model', 'cars') - 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)) + if not model in ['cars', 'trees']: + return Response({'error': 'Invalid model'}, status=status.HTTP_200_OK) - 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) + celery_task_id = run_function_async(detect, orthophoto, model).task_id + return Response({'celery_task_id': celery_task_id}, 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): +class TaskObjCheck(CheckTask): pass + +class TaskObjDownload(GetTaskResult): + def handle_output(self, output, result, **kwargs): + try: + return json.loads(output) + except: + raise TaskResultOutputError("Invalid GeoJSON") diff --git a/coreplugins/objdetect/plugin.py b/coreplugins/objdetect/plugin.py index 1dbf7e2e..adc097df 100644 --- a/coreplugins/objdetect/plugin.py +++ b/coreplugins/objdetect/plugin.py @@ -1,8 +1,8 @@ from app.plugins import PluginBase from app.plugins import MountPoint -# from .api import TaskContoursGenerate -# from .api import TaskContoursCheck -# from .api import TaskContoursDownload +from .api import TaskObjDetect +from .api import TaskObjCheck +from .api import TaskObjDownload class Plugin(PluginBase): @@ -14,7 +14,7 @@ class Plugin(PluginBase): 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()), + MountPoint('task/(?P[^/.]+)/detect', TaskObjDetect.as_view()), + MountPoint('task/[^/.]+/check/(?P.+)', TaskObjCheck.as_view()), + MountPoint('task/[^/.]+/download/(?P.+)', TaskObjDownload.as_view()), ] \ No newline at end of file diff --git a/coreplugins/objdetect/public/ObjDetectPanel.jsx b/coreplugins/objdetect/public/ObjDetectPanel.jsx index 946b44f3..154e9b13 100644 --- a/coreplugins/objdetect/public/ObjDetectPanel.jsx +++ b/coreplugins/objdetect/public/ObjDetectPanel.jsx @@ -78,37 +78,33 @@ export default class ObjDetectPanel extends React.Component { }; } - addGeoJSONFromURL = (url, cb) => { + addGeoJSON = (geojson, cb) => { const { map } = this.props; - $.getJSON(url) - .done((geojson) => { - try{ - this.handleRemoveObjLayer(); + 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); + 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: "red"}; + } + })}); + this.state.objLayer.addTo(map); - cb(); - }catch(e){ - cb(e.message); - } - }) - .fail(cb); + cb(); + }catch(e){ + cb(e.message); + } } handleRemoveObjLayer = () => { @@ -139,11 +135,20 @@ export default class ObjDetectPanel extends React.Component { 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}`; + Workers.getOutput(result.celery_task_id, (error, geojson) => { + try{ + geojson = JSON.parse(geojson); + }catch(e){ + error = "Invalid GeoJSON"; + } - this.addGeoJSONFromURL(fileUrl, e => { - if (e) this.setState({error: JSON.stringify(e)}); - this.setState({detecting: false}); + if (error) this.setState({detecting: false, error}); + else{ + this.addGeoJSON(geojson, e => { + if (e) this.setState({error: JSON.stringify(e)}); + this.setState({detecting: false}); + }); + } }); } }, `/api/plugins/objdetect/task/${taskId}/check/`); diff --git a/coreplugins/objdetect/public/ObjDetectPanel.scss b/coreplugins/objdetect/public/ObjDetectPanel.scss index 47f8929d..1cfe68b7 100644 --- a/coreplugins/objdetect/public/ObjDetectPanel.scss +++ b/coreplugins/objdetect/public/ObjDetectPanel.scss @@ -62,10 +62,6 @@ } } - .btn-detect{ - margin-right: 8px; - } - .action-buttons{ margin-top: 12px; } From 30c9d274a16c075e51b1508f6964a9c2cfe3df9b Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 24 Jan 2025 12:20:53 -0500 Subject: [PATCH 3/8] UI improvements --- coreplugins/measure/public/MeasurePopup.jsx | 4 +- .../objdetect/public/ObjDetectPanel.jsx | 41 +++++++++++-------- .../objdetect/public/ObjDetectPanel.scss | 22 ++++++++++ 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/coreplugins/measure/public/MeasurePopup.jsx b/coreplugins/measure/public/MeasurePopup.jsx index 235b5f93..93679644 100644 --- a/coreplugins/measure/public/MeasurePopup.jsx +++ b/coreplugins/measure/public/MeasurePopup.jsx @@ -68,7 +68,7 @@ export default class MeasurePopup extends React.Component { } getGeoJSON(){ - const geoJSON = this.props.resultFeature.toGeoJSON(); + const geoJSON = this.props.resultFeature.toGeoJSON(14); geoJSON.properties = this.getProperties(); return geoJSON; } @@ -125,7 +125,7 @@ export default class MeasurePopup extends React.Component { type: 'POST', url: `/api/plugins/measure/task/${task.id}/volume`, data: JSON.stringify({ - area: this.props.resultFeature.toGeoJSON(), + area: this.props.resultFeature.toGeoJSON(14), method: baseMethod }), contentType: "application/json" diff --git a/coreplugins/objdetect/public/ObjDetectPanel.jsx b/coreplugins/objdetect/public/ObjDetectPanel.jsx index 154e9b13..fcd9743c 100644 --- a/coreplugins/objdetect/public/ObjDetectPanel.jsx +++ b/coreplugins/objdetect/public/ObjDetectPanel.jsx @@ -5,6 +5,7 @@ import L from 'leaflet'; import './ObjDetectPanel.scss'; import ErrorMessage from 'webodm/components/ErrorMessage'; import Workers from 'webodm/classes/Workers'; +import Utils from 'webodm/classes/Utils'; import { _ } from 'webodm/classes/gettext'; export default class ObjDetectPanel extends React.Component { @@ -86,10 +87,10 @@ export default class ObjDetectPanel extends React.Component { this.setState({objLayer: L.geoJSON(geojson, { onEachFeature: (feature, layer) => { - if (feature.properties && feature.properties.level !== undefined) { + if (feature.properties && feature.properties['class'] !== undefined) { layer.bindPopup(`
- ${_("Class:")} ${feature.properties.class}
- ${_("Score:")} ${feature.properties.score}
+ ${_("Label:")} ${feature.properties['class']}
+ ${_("Confidence:")} ${feature.properties.score.toFixed(3)}
`); } @@ -100,6 +101,7 @@ export default class ObjDetectPanel extends React.Component { } })}); this.state.objLayer.addTo(map); + this.state.objLayer.label = this.state.model; cb(); }catch(e){ @@ -162,6 +164,10 @@ export default class ObjDetectPanel extends React.Component { }); } + handleDownload = () => { + Utils.saveAs(JSON.stringify(this.state.objLayer.toGeoJSON(14), null, 4), `${this.state.objLayer.label || "objects"}.geojson`); + } + render(){ const { loading, permanentError, objLayer, detecting, model } = this.state; const models = [ @@ -175,28 +181,29 @@ export default class ObjDetectPanel extends React.Component { else{ content = (
-
- -
+
-
-
- -
-
- {objLayer ? - - : ""} -
-
-
+ + {objLayer ?
+ {_("Count:")} {objLayer.getLayers().length} +
+ + +
+
: ""}
); } diff --git a/coreplugins/objdetect/public/ObjDetectPanel.scss b/coreplugins/objdetect/public/ObjDetectPanel.scss index 1cfe68b7..8a008a95 100644 --- a/coreplugins/objdetect/public/ObjDetectPanel.scss +++ b/coreplugins/objdetect/public/ObjDetectPanel.scss @@ -37,6 +37,9 @@ label{ padding-top: 5px; + &.no-pad{ + padding-top: 0; + } } select, input{ @@ -52,6 +55,25 @@ margin-bottom: 8px; } + .model-selector{ + display: flex; + padding-left: 15px; + padding-right: 15px; + select{ + margin-right: 8px; + } + } + + .detect-action-buttons{ + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + .btn-download{ + margin-right: 6px; + } + } + .dropdown-menu{ a{ width: 100%; From 85c9df12650b14bb121016ef9d919e28c161db30 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 24 Jan 2025 12:26:06 -0500 Subject: [PATCH 4/8] Fix count 0 cases --- coreplugins/objdetect/public/ObjDetectPanel.jsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/coreplugins/objdetect/public/ObjDetectPanel.jsx b/coreplugins/objdetect/public/ObjDetectPanel.jsx index fcd9743c..07d1f004 100644 --- a/coreplugins/objdetect/public/ObjDetectPanel.jsx +++ b/coreplugins/objdetect/public/ObjDetectPanel.jsx @@ -174,11 +174,13 @@ export default class ObjDetectPanel extends React.Component { {label: _('Cars'), value: 'cars'}, {label: _('Trees'), value: 'trees'}, ] - + let content = ""; if (loading) content = ( {_("Loading…")}); else if (permanentError) content = (
{permanentError}
); else{ + const featCount = objLayer ? objLayer.getLayers().length : 0; + content = (
@@ -192,12 +194,12 @@ export default class ObjDetectPanel extends React.Component {
{objLayer ?
- {_("Count:")} {objLayer.getLayers().length} + {_("Count:")} {featCount}
- + : ""}
From 766d516e1952bfaf9a7db6a15729f8f181d34133 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 24 Jan 2025 14:52:12 -0500 Subject: [PATCH 6/8] Bump version --- coreplugins/objdetect/manifest.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/coreplugins/objdetect/manifest.json b/coreplugins/objdetect/manifest.json index 92839d5f..81e1932c 100644 --- a/coreplugins/objdetect/manifest.json +++ b/coreplugins/objdetect/manifest.json @@ -1,6 +1,6 @@ { "name": "Object Detect", - "webodmMinVersion": "2.5.8", + "webodmMinVersion": "2.6.0", "description": "Detect objects using AI in orthophotos", "version": "1.0.0", "author": "Piero Toffanin", diff --git a/package.json b/package.json index f53ba4c9..83713b6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WebODM", - "version": "2.5.8", + "version": "2.6.0", "description": "User-friendly, extendable application and API for processing aerial imagery.", "main": "index.js", "scripts": { From c3c2dfb784633734c87217e4e2f1d180bba3f99f Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 24 Jan 2025 15:41:05 -0500 Subject: [PATCH 7/8] Fix test --- worker/celery.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/worker/celery.py b/worker/celery.py index 7903f2b5..a604a58c 100644 --- a/worker/celery.py +++ b/worker/celery.py @@ -66,6 +66,8 @@ app.conf.beat_schedule = { class MockAsyncResult: def __init__(self, celery_task_id, result = None): self.celery_task_id = celery_task_id + self.state = "PENDING" + if result is None: if celery_task_id == 'bogus': self.result = None From 320bbafb0beeaa0b4c1324766ba72185dfcc22fa Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 24 Jan 2025 16:05:57 -0500 Subject: [PATCH 8/8] Add none check --- app/api/workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/workers.py b/app/api/workers.py index 60094475..308c30d0 100644 --- a/app/api/workers.py +++ b/app/api/workers.py @@ -20,7 +20,7 @@ class CheckTask(APIView): out = {'ready': False} # Copy progress meta - if res.state == "PROGRESS": + if res.state == "PROGRESS" and res.info is not None: for k in res.info: out[k] = res.info[k]