Volume measurements! Processing done in celery, updated README

pull/418/head
Piero Toffanin 2018-03-29 17:28:15 -04:00
rodzic e3c36b1526
commit ae0a0cbf67
13 zmienionych plików z 89 dodań i 38 usunięć

Wyświetl plik

@ -189,7 +189,7 @@ Developer, I'm looking to build an app that will stay behind a firewall and just
- [X] 2D Map Display - [X] 2D Map Display
- [X] 3D Model Display - [X] 3D Model Display
- [ ] NDVI display - [ ] NDVI display
- [ ] Volumetric Measurements - [X] Volumetric Measurements
- [X] Cluster management and setup. - [X] Cluster management and setup.
- [ ] Mission Planner - [ ] Mission Planner
- [X] Plugins/Webhooks System - [X] Plugins/Webhooks System

Wyświetl plik

@ -35,6 +35,10 @@ def boot():
if settings.DEBUG: if settings.DEBUG:
logger.warning("Debug mode is ON (for development this is OK)") logger.warning("Debug mode is ON (for development this is OK)")
# Make sure our app/media/tmp folder exists
if not os.path.exists(settings.MEDIA_TMP):
os.mkdir(settings.MEDIA_TMP)
# Check default group # Check default group
try: try:
default_group, created = Group.objects.get_or_create(name='Default') default_group, created = Group.objects.get_or_create(name='Default')

Wyświetl plik

@ -3,8 +3,12 @@ import shutil
import tempfile import tempfile
import subprocess import subprocess
import os import os
import geojson
from string import Template from string import Template
from webodm import settings
logger = logging.getLogger('app.logger') logger = logging.getLogger('app.logger')
class GrassEngine: class GrassEngine:
@ -18,24 +22,28 @@ class GrassEngine:
else: else:
logger.info("Initializing GRASS engine using {}".format(self.grass_binary)) logger.info("Initializing GRASS engine using {}".format(self.grass_binary))
def create_context(self): def create_context(self, serialized_context = {}):
if self.grass_binary is None: raise GrassEngineException("GRASS engine is unavailable") if self.grass_binary is None: raise GrassEngineException("GRASS engine is unavailable")
return GrassContext(self.grass_binary) return GrassContext(self.grass_binary, **serialized_context)
class GrassContext: class GrassContext:
def __init__(self, grass_binary): def __init__(self, grass_binary, tmpdir = None, template_args = {}, location = None):
self.grass_binary = grass_binary self.grass_binary = grass_binary
self.cwd = tempfile.mkdtemp('_webodm_grass') if tmpdir is None:
self.template_args = {} tmpdir = os.path.basename(tempfile.mkdtemp('_grass_engine', dir=settings.MEDIA_TMP))
self.location = None self.tmpdir = tmpdir
self.template_args = template_args
self.location = location
def get_cwd(self):
return os.path.join(settings.MEDIA_TMP, self.tmpdir)
def add_file(self, filename, source, use_as_location=False): def add_file(self, filename, source, use_as_location=False):
param = os.path.splitext(filename)[0] # filename without extension param = os.path.splitext(filename)[0] # filename without extension
dst_path = os.path.abspath(os.path.join(self.cwd, filename)) dst_path = os.path.abspath(os.path.join(self.get_cwd(), filename))
with open(dst_path) as f: with open(dst_path, 'w') as f:
f.write(source) f.write(source)
self.template_args[param] = dst_path self.template_args[param] = dst_path
@ -51,7 +59,7 @@ class GrassContext:
""" """
:param location: either a "epsg:XXXXX" string or a path to a geospatial file defining the location :param location: either a "epsg:XXXXX" string or a path to a geospatial file defining the location
""" """
if not location.startsWith('epsg:'): if not location.startswith('epsg:'):
location = os.path.abspath(location) location = os.path.abspath(location)
self.location = location self.location = location
@ -74,23 +82,33 @@ class GrassContext:
tmpl = Template(script_content) tmpl = Template(script_content)
# Write script to disk # Write script to disk
with open(os.path.join(self.cwd, 'script.sh')) as f: with open(os.path.join(self.get_cwd(), 'script.sh'), 'w') as f:
f.write(tmpl.substitute(self.template_args)) f.write(tmpl.substitute(self.template_args))
# Execute it # Execute it
p = subprocess.Popen([self.grass_binary, '-c', self.location, 'location', '--exec', 'sh', 'script.sh'], p = subprocess.Popen([self.grass_binary, '-c', self.location, 'location', '--exec', 'sh', 'script.sh'],
cwd=self.cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) cwd=self.get_cwd(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate() out, err = p.communicate()
out = out.decode('utf-8').strip()
err = err.decode('utf-8').strip()
if p.returncode == 0: if p.returncode == 0:
return out return out
else: else:
raise GrassEngineException("Could not execute GRASS script {} from {}: {}".format(script, self.cwd, err)) raise GrassEngineException("Could not execute GRASS script {} from {}: {}".format(script, self.get_cwd(), err))
def serialize(self):
return {
'tmpdir': self.tmpdir,
'template_args': self.template_args,
'location': self.location
}
def __del__(self): def __del__(self):
# Cleanup # Cleanup
if os.path.exists(self.cwd): if os.path.exists(self.get_cwd()):
shutil.rmtree(self.cwd) shutil.rmtree(self.get_cwd())
class GrassEngineException(Exception): class GrassEngineException(Exception):
pass pass

Wyświetl plik

@ -46,5 +46,4 @@ class TestPlugins(BootTestCase):
self.assertTrue(os.path.exists(test_plugin.get_path("public/node_modules"))) self.assertTrue(os.path.exists(test_plugin.get_path("public/node_modules")))
# TODO: # TODO:
# test API endpoints # test GRASS engine
# test python hooks

Wyświetl plik

@ -1,13 +1,16 @@
import os import os
import json
from rest_framework import serializers from rest_framework import serializers
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from app.api.tasks import TaskNestedView from app.api.tasks import TaskNestedView
from app.plugins.grass_engine import grass from worker.tasks import execute_grass_script
from app.plugins.grass_engine import grass, GrassEngineException
from geojson import Feature, Point, FeatureCollection
class GeoJSONSerializer(serializers.Serializer): class GeoJSONSerializer(serializers.Serializer):
area = serializers.JSONField(help_text="Polygon contour defining the volume area to compute") area = serializers.JSONField(help_text="Polygon contour defining the volume area to compute")
@ -22,18 +25,30 @@ class TaskVolume(TaskNestedView):
serializer = GeoJSONSerializer(data=request.data) serializer = GeoJSONSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
area = serializer['area'].value
points = FeatureCollection([Feature(geometry=Point(coords)) for coords in area['geometry']['coordinates'][0]])
dsm = os.path.abspath(task.get_asset_download_path("dsm.tif"))
# context = grass.create_context() try:
# context.add_file('area_file.geojson', serializer['area']) context = grass.create_context()
# context.add_file('points_file.geojson', 'aaa') context.add_file('area_file.geojson', json.dumps(area))
# context.add_param('dsm_file', os.path.abspath(task.get_asset_download_path("dsm.tif"))) context.add_file('points_file.geojson', str(points))
# context.execute(os.path.join( context.add_param('dsm_file', dsm)
# os.path.dirname(os.path.abspath(__file__), context.set_location(dsm)
# "calc_volume.grass"
# ))) output = execute_grass_script.delay(os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"calc_volume.grass"
), context.serialize()).get()
cols = output.split(':')
if len(cols) == 7:
return Response({'volume': str(abs(float(cols[6])))}, status=status.HTTP_200_OK)
else:
raise GrassEngineException("Invalid GRASS output: {}".format(output))
except GrassEngineException as e:
return Response({'error': str(e)}, status=status.HTTP_200_OK)
print(serializer['area'])
return Response(30, status=status.HTTP_200_OK)

Wyświetl plik

@ -7,9 +7,15 @@
v.import input=${area_file} output=polygon_area --overwrite v.import input=${area_file} output=polygon_area --overwrite
v.import input=${points_file} output=polygon_points --overwrite v.import input=${points_file} output=polygon_points --overwrite
v.buffer -s --overwrite input=polygon_area type=area output=region distance=3 minordistance=3 v.buffer -s --overwrite input=polygon_area type=area output=region distance=3 minordistance=3
r.external input=${dsm_file} output=dsm --overwrite
g.region rast=dsm
g.region vector=region g.region vector=region
r.import input=${dsm_file} output=dsm --overwrite # prevent : removing eventual existing mask
r.mask -r
r.mask vect=region
v.what.rast map=polygon_points raster=dsm column=height v.what.rast map=polygon_points raster=dsm column=height
v.to.rast input=polygon_area output=r_polygon_area use=val value=255 --overwrite v.to.rast input=polygon_area output=r_polygon_area use=val value=255 --overwrite

Wyświetl plik

@ -18,10 +18,9 @@ module.exports = class MeasurePopup extends React.Component {
constructor(props){ constructor(props){
super(props); super(props);
console.log(props);
this.state = { this.state = {
volume: null, // to be calculated, volume: null, // to be calculated
error: "" error: ""
}; };
} }
@ -51,7 +50,7 @@ module.exports = class MeasurePopup extends React.Component {
contentType: "application/json" contentType: "application/json"
}).done(result => { }).done(result => {
if (result.volume){ if (result.volume){
this.setState({volume}); this.setState({volume: parseFloat(result.volume)});
}else if (result.error){ }else if (result.error){
this.setState({error: result.error}); this.setState({error: result.error});
}else{ }else{
@ -95,7 +94,7 @@ module.exports = class MeasurePopup extends React.Component {
<p>Area: {this.props.model.areaDisplay}</p> <p>Area: {this.props.model.areaDisplay}</p>
<p>Perimeter: {this.props.model.lengthDisplay}</p> <p>Perimeter: {this.props.model.lengthDisplay}</p>
{volume === null && !error && <p>Volume: <i>computing...</i> <i className="fa fa-cog fa-spin fa-fw" /></p>} {volume === null && !error && <p>Volume: <i>computing...</i> <i className="fa fa-cog fa-spin fa-fw" /></p>}
{typeof volume === "number" && <p>Volume: {volume.toFixed("3")} Cubic Meters</p>} {typeof volume === "number" && <p>Volume: {volume.toFixed("2")} Cubic Meters ({(volume * 35.3147).toFixed(2)} Cubic Feet)</p>}
{error && <p>Volume: <span className="error theme-background-failed">{error}</span></p>} {error && <p>Volume: <span className="error theme-background-failed">{error}</span></p>}
</div>); </div>);
} }

Wyświetl plik

@ -1,7 +1,7 @@
import L from 'leaflet'; import L from 'leaflet';
import './app.scss'; import './app.scss';
import './dist/leaflet-measure'; import 'leaflet-measure-ex/dist/leaflet-measure';
import './dist/leaflet-measure.css'; import 'leaflet-measure-ex/dist/leaflet-measure.css';
import MeasurePopup from './MeasurePopup'; import MeasurePopup from './MeasurePopup';
import ReactDOM from 'ReactDOM'; import ReactDOM from 'ReactDOM';
import React from 'react'; import React from 'react';

Wyświetl plik

@ -1 +0,0 @@
../../../../leaflet-measure-ex/dist/

Wyświetl plik

@ -8,5 +8,7 @@
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": {} "dependencies": {
"leaflet-measure-ex": "^3.0.4"
}
} }

Wyświetl plik

@ -21,6 +21,7 @@ djangorestframework-jwt==1.9.0
drf-nested-routers==0.11.1 drf-nested-routers==0.11.1
funcsigs==1.0.2 funcsigs==1.0.2
futures==3.0.5 futures==3.0.5
geojson==2.3.0
gunicorn==19.7.1 gunicorn==19.7.1
itypes==1.1.0 itypes==1.1.0
kombu==4.1.0 kombu==4.1.0

Wyświetl plik

@ -249,6 +249,7 @@ CORS_ORIGIN_ALLOW_ALL = True
# File uploads # File uploads
MEDIA_ROOT = os.path.join(BASE_DIR, 'app', 'media') MEDIA_ROOT = os.path.join(BASE_DIR, 'app', 'media')
MEDIA_TMP = os.path.join(MEDIA_ROOT, 'tmp')
# Store flash messages in cookies # Store flash messages in cookies
MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage' MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage'

Wyświetl plik

@ -8,6 +8,7 @@ from django.db.models import Q
from app.models import Project from app.models import Project
from app.models import Task from app.models import Task
from app.plugins.grass_engine import grass
from nodeodm import status_codes from nodeodm import status_codes
from nodeodm.models import ProcessingNode from nodeodm.models import ProcessingNode
from webodm import settings from webodm import settings
@ -77,3 +78,9 @@ def process_pending_tasks():
for task in tasks: for task in tasks:
process_task.delay(task.id) process_task.delay(task.id)
@app.task
def execute_grass_script(script, serialized_context = {}):
ctx = grass.create_context(serialized_context)
return ctx.execute(script)