Merge pull request #1589 from pierotofy/objdec

AI Object Detection
pull/1593/head
Piero Toffanin 2025-01-24 16:42:37 -05:00 zatwierdzone przez GitHub
commit ea4cb436e6
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
23 zmienionych plików z 579 dodań i 25 usunięć

Wyświetl plik

@ -17,7 +17,14 @@ class CheckTask(APIView):
res = TestSafeAsyncResult(celery_task_id)
if not res.ready():
return Response({'ready': False}, status=status.HTTP_200_OK)
out = {'ready': False}
# Copy progress meta
if res.state == "PROGRESS" and res.info is not None:
for k in res.info:
out[k] = res.info[k]
return Response(out, status=status.HTTP_200_OK)
else:
result = res.get()
@ -29,6 +36,9 @@ class CheckTask(APIView):
msg = self.on_error(result)
return Response({'ready': True, 'error': msg})
if isinstance(result.get('file'), str) and not os.path.isfile(result.get('file')):
return Response({'ready': True, 'error': "Cannot generate file"})
return Response({'ready': True})
def on_error(self, result):

Wyświetl plik

@ -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

Wyświetl plik

@ -15,8 +15,8 @@ def run_function_async(func, *args, **kwargs):
return eval_async.delay(source, func.__name__, *args, **kwargs)
@app.task
def eval_async(source, funcname, *args, **kwargs):
@app.task(bind=True)
def eval_async(self, source, funcname, *args, **kwargs):
"""
Run Python code asynchronously using Celery.
It's recommended to use run_function_async instead.
@ -24,4 +24,11 @@ def eval_async(source, funcname, *args, **kwargs):
ns = {}
code = compile(source, 'file', 'exec')
eval(code, ns, ns)
if kwargs.get("with_progress"):
def progress_callback(status, perc):
self.update_state(state="PROGRESS", meta={"status": status, "progress": perc})
kwargs['progress_callback'] = progress_callback
del kwargs['with_progress']
return ns[funcname](*args, **kwargs)

Wyświetl plik

@ -1,7 +1,8 @@
import $ from 'jquery';
export default {
waitForCompletion: (celery_task_id, cb, checkUrl = "/api/workers/check/") => {
waitForCompletion: (celery_task_id, cb, progress_cb) => {
const checkUrl = "/api/workers/check/";
let errorCount = 0;
let url = checkUrl + celery_task_id;
@ -15,6 +16,9 @@ export default {
}else if (result.ready){
cb();
}else{
if (typeof progress_cb === "function" && result.progress !== undefined && result.status !== undefined){
progress_cb(result.status, result.progress);
}
// Retry
setTimeout(() => check(), 2000);
}

Wyświetl plik

@ -109,14 +109,6 @@ class TaskContoursGenerate(TaskView):
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

Wyświetl plik

@ -1,7 +1,6 @@
from app.plugins import PluginBase
from app.plugins import MountPoint
from .api import TaskContoursGenerate
from .api import TaskContoursCheck
from .api import TaskContoursDownload
@ -15,6 +14,5 @@ class Plugin(PluginBase):
def api_mount_points(self):
return [
MountPoint('task/(?P<pk>[^/.]+)/contours/generate', TaskContoursGenerate.as_view()),
MountPoint('task/[^/.]+/contours/check/(?P<celery_task_id>.+)', TaskContoursCheck.as_view()),
MountPoint('task/[^/.]+/contours/download/(?P<celery_task_id>.+)', TaskContoursDownload.as_view()),
]

Wyświetl plik

@ -241,7 +241,7 @@ export default class ContoursPanel extends React.Component {
this.setState({[loadingProp]: false});
}
}
}, `/api/plugins/contours/task/${taskId}/contours/check/`);
});
}else if (result.error){
this.setState({[loadingProp]: false, error: result.error});
}else{

Wyświetl plik

@ -2,7 +2,7 @@ import os
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
from app.api.workers import GetTaskResult, TaskResultOutputError, CheckTask
from app.api.workers import GetTaskResult, TaskResultOutputError
from app.models import Task
from app.plugins.views import TaskView
from django.utils.translation import gettext_lazy as _
@ -34,9 +34,6 @@ class TaskVolume(TaskView):
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_200_OK)
class TaskVolumeCheck(CheckTask):
pass
class TaskVolumeResult(GetTaskResult):
def get(self, request, pk=None, celery_task_id=None):
task = Task.objects.only('dsm_extent').get(pk=pk)

Wyświetl plik

@ -1,6 +1,6 @@
from app.plugins import MountPoint
from app.plugins import PluginBase
from .api import TaskVolume, TaskVolumeCheck, TaskVolumeResult
from .api import TaskVolume, TaskVolumeResult
class Plugin(PluginBase):
def include_js_files(self):
@ -12,6 +12,5 @@ class Plugin(PluginBase):
def api_mount_points(self):
return [
MountPoint('task/(?P<pk>[^/.]+)/volume$', TaskVolume.as_view()),
MountPoint('task/[^/.]+/volume/check/(?P<celery_task_id>.+)$', TaskVolumeCheck.as_view()),
MountPoint('task/(?P<pk>[^/.]+)/volume/get/(?P<celery_task_id>.+)$', TaskVolumeResult.as_view()),
]

Wyświetl plik

@ -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"
@ -139,7 +139,7 @@ export default class MeasurePopup extends React.Component {
else this.setState({volume: parseFloat(volume)});
}, `/api/plugins/measure/task/${task.id}/volume/get/`);
}
}, `/api/plugins/measure/task/${task.id}/volume/check/`);
});
}else if (result.error){
this.setState({error: result.error});
}else{

Wyświetl plik

@ -0,0 +1 @@
from .plugin import *

Wyświetl plik

@ -0,0 +1,46 @@
import os
import json
from rest_framework import status
from rest_framework.response import Response
from app.plugins.views import TaskView, GetTaskResult, TaskResultOutputError
from app.plugins.worker import run_function_async
from django.utils.translation import gettext_lazy as _
def detect(orthophoto, model, progress_callback=None):
import os
from webodm import settings
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"}
try:
return {'output': gdetect(orthophoto, model, output_type='geojson', progress_callback=progress_callback)}
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)
if task.orthophoto_extent is None:
return Response({'error': _('No orthophoto is available.')})
orthophoto = os.path.abspath(task.get_asset_download_path("orthophoto.tif"))
model = request.data.get('model', 'cars')
if not model in ['cars', 'trees']:
return Response({'error': 'Invalid model'}, status=status.HTTP_200_OK)
celery_task_id = run_function_async(detect, orthophoto, model, with_progress=True).task_id
return Response({'celery_task_id': celery_task_id}, status=status.HTTP_200_OK)
class TaskObjDownload(GetTaskResult):
def handle_output(self, output, result, **kwargs):
try:
return json.loads(output)
except:
raise TaskResultOutputError("Invalid GeoJSON")

Wyświetl plik

@ -0,0 +1,13 @@
{
"name": "Object Detect",
"webodmMinVersion": "2.6.0",
"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
}

Wyświetl plik

@ -0,0 +1,18 @@
from app.plugins import PluginBase
from app.plugins import MountPoint
from .api import TaskObjDetect
from .api import TaskObjDownload
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<pk>[^/.]+)/detect', TaskObjDetect.as_view()),
MountPoint('task/[^/.]+/download/(?P<celery_task_id>.+)', TaskObjDownload.as_view()),
]

Wyświetl plik

@ -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 (<div className={showPanel ? "open" : ""}>
<a href="javascript:void(0);"
onClick={this.handleOpen}
className="leaflet-control-objdetect-button leaflet-bar-part theme-secondary"></a>
<ObjDetectPanel map={this.props.map} isShowed={showPanel} tasks={this.props.tasks} onClose={this.handleClose} />
</div>);
}
}
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(<ObjDetectButton map={this.options.map} tasks={this.options.tasks} />, container);
return container;
}
});

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -0,0 +1,222 @@
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 Utils from 'webodm/classes/Utils';
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,
progress: null,
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
};
}
addGeoJSON = (geojson, cb) => {
const { map } = this.props;
try{
this.handleRemoveObjLayer();
this.setState({objLayer: L.geoJSON(geojson, {
onEachFeature: (feature, layer) => {
if (feature.properties && feature.properties['class'] !== undefined) {
layer.bindPopup(`<div style="margin-right: 32px;">
<b>${_("Label:")}</b> ${feature.properties['class']}<br/>
<b>${_("Confidence:")}</b> ${feature.properties.score.toFixed(3)}<br/>
</div>
`);
}
},
style: feature => {
// TODO: different colors for different elevations?
return {color: "red"};
}
})});
this.state.objLayer.addTo(map);
this.state.objLayer.label = this.state.model;
cb();
}catch(e){
cb(e.message);
}
}
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: "", progress: null});
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{
Workers.getOutput(result.celery_task_id, (error, geojson) => {
try{
geojson = JSON.parse(geojson);
}catch(e){
error = "Invalid GeoJSON";
}
if (error) this.setState({detecting: false, error});
else{
this.addGeoJSON(geojson, e => {
if (e) this.setState({error: JSON.stringify(e)});
this.setState({detecting: false});
});
}
});
}
}, (_, progress) => {
this.setState({progress});
});
}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)});
});
}
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, progress } = this.state;
const models = [
{label: _('Cars'), value: 'cars'},
{label: _('Trees'), value: 'trees'},
]
let content = "";
if (loading) content = (<span><i className="fa fa-circle-notch fa-spin"></i> {_("Loading…")}</span>);
else if (permanentError) content = (<div className="alert alert-warning">{permanentError}</div>);
else{
const featCount = objLayer ? objLayer.getLayers().length : 0;
content = (<div>
<ErrorMessage bind={[this, "error"]} />
<div className="row model-selector">
<select className="form-control" value={model} onChange={this.handleSelectModel}>
{models.map(m => <option value={m.value}>{m.label}</option>)}
</select>
<button onClick={this.handleDetect}
disabled={detecting} type="button" className="btn btn-sm btn-primary btn-detect">
{detecting ? <i className="fa fa-spin fa-circle-notch"/> : <i className="fa fa-search fa-fw"/>} {_("Detect")} {detecting && progress !== null ? ` (${progress.toFixed(0)}%)` : ""}
</button>
</div>
{objLayer ? <div className="detect-action-buttons">
<span><strong>{_("Count:")}</strong> {featCount}</span>
<div>
{featCount > 0 ? <button onClick={this.handleDownload}
type="button" className="btn btn-sm btn-primary btn-download">
<i className="fa fa-download fa-fw"/> {_("Download")}
</button> : ""}
<button onClick={this.handleRemoveObjLayer}
type="button" className="btn btn-sm btn-default">
<i className="fa fa-trash fa-fw"/>
</button>
</div>
</div> : ""}
</div>);
}
return (<div className="objdetect-panel">
<span className="close-button" onClick={this.props.onClose}/>
<div className="title">{_("Object Detection")}</div>
<hr/>
{content}
</div>);
}
}

Wyświetl plik

@ -0,0 +1,90 @@
.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;
&.no-pad{
padding-top: 0;
}
}
select, input{
height: auto;
padding: 4px;
}
*{
font-size: 12px;
}
.row.form-group.form-inline{
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%;
text-align: left;
display: block;
padding-top: 0;
padding-bottom: 0;
}
}
.action-buttons{
margin-top: 12px;
}
}

Wyświetl plik

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="52"
height="52"
viewBox="0 0 13.758333 13.758333"
version="1.1"
id="svg8976"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs8970" />
<metadata
id="metadata8973">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
transform="translate(0,-283.24167)">
<g
id="g9543"
transform="translate(0.76834737,-0.40970963)"
style="stroke:#000000;stroke-opacity:1;stroke-width:0.52916667;stroke-miterlimit:4;stroke-dasharray:none;stroke-linejoin:round;stroke-linecap:round">
<g
id="g9551"
transform="matrix(0.83317796,0,0,0.83296021,1.0098488,48.512648)"
style="stroke-width:0.63520145">
<g
id="g9575"
transform="matrix(1.1918583,0,0,1.1960416,-1.0686423,-56.918136)"
style="stroke-width:0.53201765">
<g
id="g831"
transform="translate(0.45113764,-0.11484945)">
<path
id="rect1"
style="fill:none;stroke:#000000;stroke-width:0.798026;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-dashoffset:3.61774;stroke-opacity:1;paint-order:markers fill stroke"
d="m 10.999825,293.33128 v 2.70001 H 8.2910396 m -5.4175705,0 H 0.16468392 v -2.70001 m 0,-5.40004 v -2.70001 H 2.8734691 m 5.4175705,0 h 2.7087854 v 2.70001" />
<path
fill-rule="nonzero"
d="m 2.3768541,288.69119 c -2.424e-4,-0.0594 0.036216,-0.11541 0.095329,-0.13736 l 3.0647714,-1.14407 a 0.145889,0.14543457 0 0 1 0.1031982,3.8e-4 l 3.0538864,1.16746 c 0.063373,0.0303 0.094163,0.085 0.094163,0.14812 l -0.00331,3.92377 c 0,0.0638 -0.041033,0.11797 -0.098218,0.13784 l -3.0262749,1.06095 c -0.059785,0.0211 -0.1004864,0.0187 -0.1514519,-8.1e-4 l -3.0367925,-1.13465 c -0.058911,-0.0219 -0.095329,-0.0776 -0.095329,-0.13679 H 2.376311 v -3.87215 z m 6.1183923,0.23619 -2.7602334,1.06342 v 3.52169 l 2.7572169,-0.96652 z m -2.9083779,-1.22456 -2.6684903,0.99613 2.680465,1.03123 2.6348849,-1.01512 z m -0.1457624,5.80716 v -3.52748 l -2.7708647,-1.06599 v 3.55817 z"
id="path1"
style="stroke-width:0.39901321;stroke-dasharray:none;fill:#000000;fill-opacity:1" />
</g>
</g>
</g>
</g>
</g>
</svg>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.7 KiB

Wyświetl plik

@ -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}));
}
});

Wyświetl plik

@ -1,6 +1,6 @@
{
"name": "WebODM",
"version": "2.5.7",
"version": "2.6.0",
"description": "User-friendly, extendable application and API for processing aerial imagery.",
"main": "index.js",
"scripts": {

Wyświetl plik

@ -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

Wyświetl plik

@ -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