kopia lustrzana https://github.com/OpenDroneMap/WebODM
10x volume calculations, remove grass dependency
rodzic
5a94579a8e
commit
c28d00f0b0
|
@ -18,7 +18,7 @@ RUN apt-get -qq update && apt-get -qq install -y --no-install-recommends wget cu
|
|||
wget --no-check-certificate https://deb.nodesource.com/setup_14.x -O /tmp/node.sh && bash /tmp/node.sh && \
|
||||
apt-get -qq update && apt-get -qq install -y nodejs && \
|
||||
# Install Python3, GDAL, PDAL, nginx, letsencrypt, psql
|
||||
apt-get -qq update && apt-get -qq install -y --no-install-recommends python3 python3-pip python3-setuptools python3-wheel git g++ python3-dev python2.7-dev libpq-dev binutils libproj-dev gdal-bin pdal libgdal-dev python3-gdal nginx certbot grass-core gettext-base cron postgresql-client-13 gettext tzdata && \
|
||||
apt-get -qq update && apt-get -qq install -y --no-install-recommends python3 python3-pip python3-setuptools python3-wheel git g++ python3-dev python2.7-dev libpq-dev binutils libproj-dev gdal-bin pdal libgdal-dev python3-gdal nginx certbot gettext-base cron postgresql-client-13 gettext tzdata && \
|
||||
update-alternatives --install /usr/bin/python python /usr/bin/python2.7 1 && update-alternatives --install /usr/bin/python python /usr/bin/python3.9 2 && \
|
||||
# Install pip reqs
|
||||
pip install -U pip && pip install -r requirements.txt "boto3==1.14.14" && \
|
||||
|
|
|
@ -1,152 +0,0 @@
|
|||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
import platform
|
||||
|
||||
from webodm import settings
|
||||
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
||||
class GrassEngine:
|
||||
def __init__(self):
|
||||
self.grass_binary = shutil.which('grass7') or \
|
||||
shutil.which('grass7.bat') or \
|
||||
shutil.which('grass72') or \
|
||||
shutil.which('grass72.bat') or \
|
||||
shutil.which('grass74') or \
|
||||
shutil.which('grass74.bat') or \
|
||||
shutil.which('grass76') or \
|
||||
shutil.which('grass76.bat') or \
|
||||
shutil.which('grass78') or \
|
||||
shutil.which('grass78.bat') or \
|
||||
shutil.which('grass80') or \
|
||||
shutil.which('grass80.bat')
|
||||
|
||||
if self.grass_binary is None:
|
||||
logger.warning("Could not find a GRASS 7 executable. GRASS scripts will not work.")
|
||||
|
||||
def create_context(self, serialized_context = {}):
|
||||
if self.grass_binary is None: raise GrassEngineException("GRASS engine is unavailable")
|
||||
return GrassContext(self.grass_binary, **serialized_context)
|
||||
|
||||
|
||||
class GrassContext:
|
||||
def __init__(self, grass_binary, tmpdir = None, script_opts = {}, location = None, auto_cleanup=True, python_path=None):
|
||||
self.grass_binary = grass_binary
|
||||
if tmpdir is None:
|
||||
tmpdir = os.path.basename(tempfile.mkdtemp('_grass_engine', dir=settings.MEDIA_TMP))
|
||||
self.tmpdir = tmpdir
|
||||
self.script_opts = script_opts.copy()
|
||||
self.location = location
|
||||
self.auto_cleanup = auto_cleanup
|
||||
self.python_path = python_path
|
||||
|
||||
def get_cwd(self):
|
||||
return os.path.join(settings.MEDIA_TMP, self.tmpdir)
|
||||
|
||||
def add_file(self, filename, source, use_as_location=False):
|
||||
param = os.path.splitext(filename)[0] # filename without extension
|
||||
|
||||
dst_path = os.path.abspath(os.path.join(self.get_cwd(), filename))
|
||||
with open(dst_path, 'w') as f:
|
||||
f.write(source)
|
||||
self.script_opts[param] = dst_path
|
||||
|
||||
if use_as_location:
|
||||
self.set_location(self.script_opts[param])
|
||||
|
||||
return dst_path
|
||||
|
||||
def add_param(self, param, value):
|
||||
self.script_opts[param] = value
|
||||
|
||||
def set_location(self, location):
|
||||
"""
|
||||
:param location: either a "epsg:XXXXX" string or a path to a geospatial file defining the location
|
||||
"""
|
||||
if not location.lower().startswith('epsg:'):
|
||||
location = os.path.abspath(location)
|
||||
self.location = location
|
||||
|
||||
def execute(self, script):
|
||||
"""
|
||||
:param script: path to .grass script
|
||||
:return: script output
|
||||
"""
|
||||
if self.location is None: raise GrassEngineException("Location is not set")
|
||||
|
||||
script = os.path.abspath(script)
|
||||
|
||||
# Make sure working directory exists
|
||||
if not os.path.exists(self.get_cwd()):
|
||||
os.mkdir(self.get_cwd())
|
||||
|
||||
# Create param list
|
||||
params = ["{}={}".format(opt,value) for opt,value in self.script_opts.items()]
|
||||
|
||||
# Track success, output
|
||||
success = False
|
||||
out = ""
|
||||
err = ""
|
||||
|
||||
# Setup env
|
||||
env = os.environ.copy()
|
||||
env["LC_ALL"] = "C.UTF-8"
|
||||
|
||||
if self.python_path:
|
||||
sep = ";" if platform.system() == "Windows" else ":"
|
||||
env["PYTHONPATH"] = "%s%s%s" % (self.python_path, sep, env.get("PYTHONPATH", ""))
|
||||
|
||||
# Execute it
|
||||
logger.info("Executing grass script from {}: {} -c {} location --exec python3 {} {}".format(self.get_cwd(), self.grass_binary, self.location, script, " ".join(params)))
|
||||
|
||||
command = [self.grass_binary, '-c', self.location, 'location', '--exec', 'python3', script] + params
|
||||
if platform.system() == "Windows":
|
||||
# communicate() hangs on Windows so we use check_output instead
|
||||
try:
|
||||
out = subprocess.check_output(command, cwd=self.get_cwd(), env=env).decode('utf-8').strip()
|
||||
success = True
|
||||
except:
|
||||
success = False
|
||||
err = out
|
||||
else:
|
||||
p = subprocess.Popen(command, cwd=self.get_cwd(), env=env, 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 success:
|
||||
return out
|
||||
else:
|
||||
raise GrassEngineException("Could not execute GRASS script {} from {}: {}".format(script, self.get_cwd(), err))
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'tmpdir': self.tmpdir,
|
||||
'script_opts': self.script_opts,
|
||||
'location': self.location,
|
||||
'auto_cleanup': self.auto_cleanup,
|
||||
'python_path': self.python_path,
|
||||
}
|
||||
|
||||
def cleanup(self):
|
||||
if os.path.exists(self.get_cwd()):
|
||||
shutil.rmtree(self.get_cwd())
|
||||
|
||||
def __del__(self):
|
||||
if self.auto_cleanup:
|
||||
self.cleanup()
|
||||
|
||||
class GrassEngineException(Exception):
|
||||
pass
|
||||
|
||||
def cleanup_grass_context(serialized_context):
|
||||
ctx = grass.create_context(serialized_context)
|
||||
ctx.cleanup()
|
||||
|
||||
grass = GrassEngine()
|
|
@ -1,7 +1,5 @@
|
|||
import inspect
|
||||
from worker.celery import app
|
||||
# noinspection PyUnresolvedReferences
|
||||
from worker.tasks import execute_grass_script
|
||||
|
||||
task = app.task
|
||||
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
#%module
|
||||
#% description: greets the user and prints the information of a spatial file
|
||||
#%end
|
||||
#%option
|
||||
#% key: test
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Geospatial test file
|
||||
#%end
|
||||
|
||||
import sys
|
||||
from grass.pygrass.modules import Module
|
||||
import grass.script as grass
|
||||
|
||||
def main():
|
||||
# Import raster and vector
|
||||
Module("v.in.ogr", input=opts['test'], layer="test", output="test", overwrite=True)
|
||||
info = grass.vector_info("test")
|
||||
print("Number of points: %s" % info['points'])
|
||||
|
||||
if __name__ == "__main__":
|
||||
opts, _ = grass.parser()
|
||||
sys.exit(main())
|
||||
|
|
@ -16,9 +16,7 @@ from app.plugins import sync_plugin_db, get_plugins_persistent_path
|
|||
from app.plugins.data_store import InvalidDataStoreValue
|
||||
from app.plugins.pyutils import parse_requirements, compute_file_md5, requirements_installed
|
||||
from .classes import BootTestCase
|
||||
from app.plugins.grass_engine import grass, GrassEngineException
|
||||
|
||||
from worker.tasks import execute_grass_script
|
||||
|
||||
class TestPlugins(BootTestCase):
|
||||
def setUp(self):
|
||||
|
@ -140,71 +138,6 @@ class TestPlugins(BootTestCase):
|
|||
self.assertEqual(test_plugin.get_current_plugin_test(), test_plugin)
|
||||
|
||||
|
||||
|
||||
def test_grass_engine(self):
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
grass_scripts_dir = os.path.join(cwd, "grass_scripts")
|
||||
|
||||
ctx = grass.create_context()
|
||||
points = """{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
13.770675659179686,
|
||||
45.655328041141374
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}"""
|
||||
ctx.add_file('test.geojson', points)
|
||||
ctx.set_location("EPSG:4326")
|
||||
|
||||
result = execute_grass_script.delay(
|
||||
os.path.join(grass_scripts_dir, "simple_test.py"),
|
||||
ctx.serialize()
|
||||
).get()
|
||||
|
||||
self.assertEqual("Number of points: 1", result.get('output'))
|
||||
|
||||
self.assertTrue(result.get('context') == ctx.serialize())
|
||||
|
||||
# Context dir has been cleaned up automatically
|
||||
self.assertFalse(os.path.exists(ctx.get_cwd()))
|
||||
|
||||
error = execute_grass_script.delay(
|
||||
os.path.join(grass_scripts_dir, "nonexistant_script.py"),
|
||||
ctx.serialize()
|
||||
).get()
|
||||
self.assertIsInstance(error, dict)
|
||||
self.assertIsInstance(error['error'], str)
|
||||
|
||||
with self.assertRaises(GrassEngineException):
|
||||
ctx.execute(os.path.join(grass_scripts_dir, "nonexistant_script.py"))
|
||||
|
||||
ctx = grass.create_context({"auto_cleanup": False})
|
||||
ctx.add_file('test.geojson', points)
|
||||
ctx.set_location("EPSG:4326")
|
||||
|
||||
result = execute_grass_script.delay(
|
||||
os.path.join(grass_scripts_dir, "simple_test.py"),
|
||||
ctx.serialize()
|
||||
).get()
|
||||
self.assertEqual("Number of points: 1", result.get('output'))
|
||||
|
||||
# Path still there
|
||||
self.assertTrue(os.path.exists(ctx.get_cwd()))
|
||||
|
||||
ctx.cleanup()
|
||||
|
||||
# Cleanup worked
|
||||
self.assertFalse(os.path.exists(ctx.get_cwd()))
|
||||
|
||||
def test_plugin_datastore(self):
|
||||
enable_plugin("test")
|
||||
test_plugin = get_plugin_by_name("test")
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
from .plugin import *
|
|
@ -1,126 +0,0 @@
|
|||
import mimetypes
|
||||
import os
|
||||
|
||||
from django.http import FileResponse
|
||||
from django.http import HttpResponse
|
||||
from wsgiref.util import FileWrapper
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from app.plugins.views import TaskView
|
||||
from worker.tasks import execute_grass_script
|
||||
from app.plugins.grass_engine import grass, GrassEngineException, cleanup_grass_context
|
||||
from worker.celery import app as celery
|
||||
from app.plugins import get_current_plugin
|
||||
|
||||
class TaskChangeMapGenerate(TaskView):
|
||||
def post(self, request, pk=None):
|
||||
|
||||
role = request.data.get('role', 'reference')
|
||||
if role == 'reference':
|
||||
reference_pk = pk
|
||||
compare_task_pk = request.data.get('other_task', None)
|
||||
else:
|
||||
reference_pk = request.data.get('other_task', None)
|
||||
compare_task_pk = pk
|
||||
|
||||
reference_task = self.get_and_check_task(request, reference_pk)
|
||||
if compare_task_pk is None:
|
||||
return Response({'error': 'You must select a task to compare to.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
compare_task = self.get_and_check_task(request, compare_task_pk)
|
||||
|
||||
reference_pc = os.path.abspath(reference_task.get_asset_download_path("georeferenced_model.laz"))
|
||||
reference_dsm = os.path.abspath(reference_task.get_asset_download_path("dsm.tif"))
|
||||
reference_dtm = os.path.abspath(reference_task.get_asset_download_path("dtm.tif"))
|
||||
|
||||
compare_pc = os.path.abspath(compare_task.get_asset_download_path("georeferenced_model.laz"))
|
||||
compare_dsm = os.path.abspath(compare_task.get_asset_download_path("dsm.tif"))
|
||||
compare_dtm = os.path.abspath(compare_task.get_asset_download_path("dtm.tif"))
|
||||
|
||||
plugin = get_current_plugin()
|
||||
|
||||
# We store the aligned DEMs on the persistent folder, to avoid recalculating them in the future
|
||||
aligned_dsm = plugin.get_persistent_path("{}_{}_dsm.tif".format(pk, compare_task_pk))
|
||||
aligned_dtm = plugin.get_persistent_path("{}_{}_dtm.tif".format(pk, compare_task_pk))
|
||||
|
||||
try:
|
||||
context = grass.create_context({'auto_cleanup' : False, 'location': 'epsg:3857', 'python_path': plugin.get_python_packages_path()})
|
||||
format = request.data.get('format', 'GPKG')
|
||||
epsg = int(request.data.get('epsg', '3857'))
|
||||
supported_formats = ['GPKG', 'ESRI Shapefile', 'DXF', 'GeoJSON']
|
||||
if not format in supported_formats:
|
||||
raise GrassEngineException("Invalid format {} (must be one of: {})".format(format, ",".join(supported_formats)))
|
||||
min_area = float(request.data.get('min_area', 40))
|
||||
min_height = float(request.data.get('min_height', 5))
|
||||
resolution = float(request.data.get('resolution', 0.5))
|
||||
display_type = request.data.get('display_type', 'contour')
|
||||
can_align_and_rasterize = request.data.get('align', 'false')
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
context.add_param('reference_pc', reference_pc)
|
||||
context.add_param('compare_pc', compare_pc)
|
||||
context.add_param('reference_dsm', reference_dsm)
|
||||
context.add_param('reference_dtm', reference_dtm)
|
||||
context.add_param('compare_dsm', compare_dsm)
|
||||
context.add_param('compare_dtm', compare_dtm)
|
||||
context.add_param('aligned_dsm', aligned_dsm)
|
||||
context.add_param('aligned_dtm', aligned_dtm)
|
||||
context.add_param('format', format)
|
||||
context.add_param('epsg', epsg)
|
||||
context.add_param('display_type', display_type)
|
||||
context.add_param('resolution', resolution)
|
||||
context.add_param('min_area', min_area)
|
||||
context.add_param('min_height', min_height)
|
||||
context.add_param('can_align_and_rasterize', can_align_and_rasterize)
|
||||
|
||||
celery_task_id = execute_grass_script.delay(os.path.join(current_dir, "changedetection.py"), context.serialize()).task_id
|
||||
|
||||
return Response({'celery_task_id': celery_task_id}, status=status.HTTP_200_OK)
|
||||
except GrassEngineException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_200_OK)
|
||||
|
||||
class TaskChangeMapCheck(TaskView):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
res = celery.AsyncResult(celery_task_id)
|
||||
if not res.ready():
|
||||
return Response({'ready': False}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
result = res.get()
|
||||
if result.get('error', None) is not None:
|
||||
cleanup_grass_context(result['context'])
|
||||
return Response({'ready': True, 'error': result['error']})
|
||||
|
||||
output = result.get('output')
|
||||
if not output or not os.path.exists(output):
|
||||
cleanup_grass_context(result['context'])
|
||||
return Response({'ready': True, 'error': output})
|
||||
|
||||
request.session['change_detection_' + celery_task_id] = output
|
||||
return Response({'ready': True})
|
||||
|
||||
|
||||
class TaskChangeMapDownload(TaskView):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
change_detection_file = request.session.get('change_detection_' + celery_task_id, None)
|
||||
|
||||
if change_detection_file is not None:
|
||||
filename = os.path.basename(change_detection_file)
|
||||
filesize = os.stat(change_detection_file).st_size
|
||||
|
||||
f = open(change_detection_file, "rb")
|
||||
|
||||
# More than 100mb, normal http response, otherwise stream
|
||||
# Django docs say to avoid streaming when possible
|
||||
stream = filesize > 1e8
|
||||
if stream:
|
||||
response = FileResponse(f)
|
||||
else:
|
||||
response = HttpResponse(FileWrapper(f),
|
||||
content_type=(mimetypes.guess_type(filename)[0] or "application/zip"))
|
||||
|
||||
response['Content-Type'] = mimetypes.guess_type(filename)[0] or "application/zip"
|
||||
response['Content-Disposition'] = "attachment; filename={}".format(filename)
|
||||
response['Content-Length'] = filesize
|
||||
|
||||
return response
|
||||
else:
|
||||
return Response({'error': 'Invalid change_detecton download id'})
|
|
@ -1,224 +0,0 @@
|
|||
#%module
|
||||
#% description: This script detectes changes by comparing two different sets of DEMs.
|
||||
#%end
|
||||
#%option
|
||||
#% key: reference_pc
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the reference point cloud file
|
||||
#%end
|
||||
#%option
|
||||
#% key: reference_dsm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the reference dsm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: reference_dtm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the reference dtm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: compare_pc
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare point cloud file
|
||||
#%end
|
||||
#%option
|
||||
#% key: compare_dsm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare dsm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: compare_dtm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare dtm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: aligned_compare_dsm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare dtm file that should be aligned to the reference cloud
|
||||
#%end
|
||||
#%option
|
||||
#% key: aligned_compare_dtm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare dtm file that should be aligned to the reference cloud
|
||||
#%end
|
||||
#%option
|
||||
#% key: format
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: OGR output format
|
||||
#%end
|
||||
#%option
|
||||
#% key: epsg
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The epsg code that will be used for output
|
||||
#%end
|
||||
#%option
|
||||
#% key: display_type
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Whether to display a heatmap or contours
|
||||
#%end
|
||||
#%option
|
||||
#% key: resolution
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Target resolution in meters
|
||||
#%end
|
||||
#%option
|
||||
#% key: min_height
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Min height in meters for a difference to be considered change
|
||||
#%end
|
||||
#%option
|
||||
#% key: min_area
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Min area in meters for a difference to be considered change
|
||||
#%end
|
||||
#%option
|
||||
#% key: can_align_and_rasterize
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Whether the comparison should be done after aligning the reference and compare clouds
|
||||
#%end
|
||||
|
||||
from os import path, makedirs, getcwd
|
||||
from compare import compare
|
||||
import sys
|
||||
import subprocess
|
||||
import grass.script as grass
|
||||
|
||||
def main():
|
||||
# Read params
|
||||
reference_pc = opts['reference_pc']
|
||||
compare_pc = opts['compare_pc']
|
||||
reference_dsm = opts['reference_dsm']
|
||||
reference_dtm = opts['reference_dtm']
|
||||
compare_dsm = opts['compare_dsm']
|
||||
compare_dtm = opts['compare_dtm']
|
||||
aligned_compare_dsm = opts['aligned_compare_dsm']
|
||||
aligned_compare_dtm = opts['aligned_compare_dtm']
|
||||
epsg = opts['epsg']
|
||||
resolution = float(opts['resolution'])
|
||||
min_height = float(opts['min_height'])
|
||||
min_area = float(opts['min_area'])
|
||||
display_type = opts['display_type']
|
||||
format = opts['format']
|
||||
can_align_and_rasterize = opts['can_align_and_rasterize'] == 'true'
|
||||
|
||||
if can_align_and_rasterize:
|
||||
handle_if_should_align_align_and_rasterize(reference_pc, compare_pc, reference_dsm, reference_dtm, aligned_compare_dsm, aligned_compare_dtm)
|
||||
result_dump = compare(reference_dsm, reference_dtm, aligned_compare_dsm, aligned_compare_dtm, epsg, resolution, display_type, min_height, min_area)
|
||||
else:
|
||||
handle_if_shouldnt_align_and_rasterize(reference_dsm, reference_dtm, compare_dsm, compare_dtm)
|
||||
result_dump = compare(reference_dsm, reference_dtm, compare_dsm, compare_dtm, epsg, resolution, display_type, min_height, min_area)
|
||||
|
||||
# Write the geojson as the expected format file
|
||||
write_to_file(result_dump, format)
|
||||
|
||||
|
||||
def handle_if_shouldnt_align_and_rasterize(reference_dsm, reference_dtm, compare_dsm, compare_dtm):
|
||||
if not path.exists(reference_dsm) or not path.exists(reference_dtm) or not path.exists(compare_dsm) or not path.exists(compare_dtm):
|
||||
raise Exception('Failed to find all four required DEMs to detect changes.')
|
||||
|
||||
|
||||
def handle_if_should_align_align_and_rasterize(reference_pc, compare_pc, reference_dsm, reference_dtm, aligned_compare_dsm, aligned_compare_dtm):
|
||||
from align.align_and_rasterize import align, rasterize
|
||||
|
||||
if not path.exists(reference_pc) or not path.exists(compare_pc):
|
||||
raise Exception('Failed to find both the reference and compare point clouds')
|
||||
|
||||
# Create reference DSM if it does not exist
|
||||
if not path.exists(reference_dsm):
|
||||
make_dirs_if_necessary(reference_dsm)
|
||||
rasterize(reference_pc, 'dsm', reference_dsm)
|
||||
|
||||
# Create reference DTM if it does not exist
|
||||
if not path.exists(reference_dtm):
|
||||
make_dirs_if_necessary(reference_dtm)
|
||||
rasterize(reference_pc, 'dtm', reference_dtm)
|
||||
|
||||
if not path.exists(aligned_compare_dsm) or not path.exists(aligned_compare_dtm):
|
||||
aligned_compare_pc = 'aligned.laz'
|
||||
|
||||
# Run ICP and align the compare point cloud
|
||||
align(reference_pc, compare_pc, aligned_compare_pc)
|
||||
|
||||
# Create compare DSM if it does not exist
|
||||
if not path.exists(aligned_compare_dsm):
|
||||
make_dirs_if_necessary(aligned_compare_dsm)
|
||||
rasterize(aligned_compare_pc, 'dsm', aligned_compare_dsm)
|
||||
|
||||
# Create compare DTM if it does not exist
|
||||
if not path.exists(aligned_compare_dtm):
|
||||
make_dirs_if_necessary(aligned_compare_dtm)
|
||||
rasterize(aligned_compare_pc, 'dtm', aligned_compare_dtm)
|
||||
|
||||
|
||||
def make_dirs_if_necessary(file_path):
|
||||
dirname = path.dirname(file_path)
|
||||
makedirs(dirname, exist_ok = True)
|
||||
|
||||
|
||||
def write_to_file(result_dump, format):
|
||||
ext = ""
|
||||
if format == "GeoJSON":
|
||||
ext = "json"
|
||||
elif format == "GPKG":
|
||||
ext = "gpkg"
|
||||
elif format == "DXF":
|
||||
ext = "dxf"
|
||||
elif format == "ESRI Shapefile":
|
||||
ext = "shp"
|
||||
|
||||
with open("output.json", 'w+') as output:
|
||||
output.write(result_dump)
|
||||
|
||||
if ext != "json":
|
||||
subprocess.check_call(["ogr2ogr", "-f", format, "output.%s" % ext, "output.json"], stdout=subprocess.DEVNULL)
|
||||
|
||||
if path.isfile("output.%s" % ext):
|
||||
if format == "ESRI Shapefile":
|
||||
ext="zip"
|
||||
makedirs("changes")
|
||||
contour_files = glob.glob("output.*")
|
||||
for cf in contour_files:
|
||||
shutil.move(cf, path.join("changes", path.basename(cf)))
|
||||
|
||||
shutil.make_archive('output', 'zip', 'changes/')
|
||||
|
||||
print(path.join(getcwd(), "output.%s" % ext))
|
||||
else:
|
||||
print("error")
|
||||
|
||||
if __name__ == "__main__":
|
||||
opts, _ = grass.parser()
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e:
|
||||
print(e)
|
|
@ -1,146 +0,0 @@
|
|||
import rasterio as rio
|
||||
from rasterio import warp, transform
|
||||
import numpy as np
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from geojson import Feature, FeatureCollection, dumps, Polygon
|
||||
from rasteralign import align, align_altitudes
|
||||
|
||||
from webodm import settings
|
||||
|
||||
sys.path.insert(0, os.path.join(settings.MEDIA_ROOT, "plugins", "changedetection", "site-packages"))
|
||||
import cv2
|
||||
|
||||
KERNEL_10_10 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (10, 10))
|
||||
KERNEL_20_20 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (20, 20))
|
||||
|
||||
def compare(reference_dsm_path, reference_dtm_path, compare_dsm_path, compare_dtm_path, epsg, resolution, display_type, min_height, min_area):
|
||||
# Read DEMs and align them
|
||||
with rio.open(reference_dsm_path) as reference_dsm, \
|
||||
rio.open(reference_dtm_path) as reference_dtm, \
|
||||
rio.open(compare_dsm_path) as compare_dsm, \
|
||||
rio.open(compare_dtm_path) as compare_dtm:
|
||||
reference_dsm, reference_dtm, compare_dsm, compare_dtm = align(reference_dsm, reference_dtm, compare_dsm, compare_dtm, resolution=resolution)
|
||||
reference_dsm, reference_dtm, compare_dsm, compare_dtm = align_altitudes(reference_dsm, reference_dtm, compare_dsm, compare_dtm)
|
||||
|
||||
# Get arrays from DEMs
|
||||
reference_dsm_array = reference_dsm.read(1, masked=True)
|
||||
reference_dtm_array = reference_dtm.read(1, masked=True)
|
||||
compare_dsm_array = compare_dsm.read(1, masked=True)
|
||||
compare_dtm_array = compare_dtm.read(1, masked=True)
|
||||
|
||||
# Calculate CHMs
|
||||
chm_reference = reference_dsm_array - reference_dtm_array
|
||||
chm_compare = compare_dsm_array - compare_dtm_array
|
||||
|
||||
# Calculate diff between CHMs
|
||||
diff = chm_reference - chm_compare
|
||||
|
||||
# Add to the mask everything below the min height
|
||||
diff.mask = np.ma.mask_or(diff.mask, diff < min_height)
|
||||
|
||||
# Copy the diff, and set everything on the mask to 0
|
||||
process = np.copy(diff)
|
||||
process[diff.mask] = 0
|
||||
|
||||
# Apply open filter to filter out noise
|
||||
process = cv2.morphologyEx(process, cv2.MORPH_OPEN, KERNEL_10_10)
|
||||
|
||||
# Apply close filter to fill little areas
|
||||
process = cv2.morphologyEx(process, cv2.MORPH_CLOSE, KERNEL_20_20)
|
||||
|
||||
# Transform to uint8
|
||||
process = process.astype(np.uint8)
|
||||
|
||||
if display_type == 'contours':
|
||||
return calculate_contours(process, reference_dsm, epsg, min_height, min_area)
|
||||
else:
|
||||
return calculate_heatmap(process, diff.mask, reference_dsm, epsg, min_height)
|
||||
|
||||
def calculate_contours(diff, reference_dem, epsg, min_height, min_area):
|
||||
# Calculate contours
|
||||
contours, _ = cv2.findContours(diff, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
# Convert contours into features
|
||||
features = [map_contour_to_geojson_feature(contour, diff, epsg, reference_dem, min_height) for contour in contours]
|
||||
|
||||
# Keep features that meet the threshold
|
||||
features = [feature for feature in features if feature.properties['area'] >= min_area]
|
||||
|
||||
# Write the GeoJSON to a string
|
||||
return dumps(FeatureCollection(features))
|
||||
|
||||
def map_contour_to_geojson_feature(contour, diff_array, epsg, reference_dem, min_height):
|
||||
# Calculate how much area is inside a pixel
|
||||
pixel_area = reference_dem.res[0] * reference_dem.res[1]
|
||||
|
||||
# Calculate the area of the contour
|
||||
area = cv2.contourArea(contour) * pixel_area
|
||||
|
||||
# Calculate the indices of the values inside the contour
|
||||
cimg = np.zeros_like(diff_array)
|
||||
cv2.drawContours(cimg, [contour], 0, color=255, thickness=-1)
|
||||
indices = cimg == 255
|
||||
|
||||
# Calculate values inside the contour
|
||||
values = diff_array[indices]
|
||||
masked_values = np.ma.masked_array(values, values < min_height)
|
||||
|
||||
# Calculate properties regarding the difference values
|
||||
avg = float(masked_values.mean())
|
||||
min = float(masked_values.min())
|
||||
max = float(masked_values.max())
|
||||
std = float(masked_values.std())
|
||||
|
||||
# Map the contour to pixels
|
||||
pixels = to_pixel_format(contour)
|
||||
|
||||
rows = [row for (row, _) in pixels]
|
||||
cols = [col for (_, col) in pixels]
|
||||
|
||||
# Map from pixels to coordinates
|
||||
xs, ys = map_pixels_to_coordinates(reference_dem, epsg, rows, cols)
|
||||
coords = [(x, y) for x, y in zip(xs, ys)]
|
||||
|
||||
# Build polygon, based on the contour
|
||||
polygon = Polygon([coords])
|
||||
|
||||
# Build the feature
|
||||
feature = Feature(geometry = polygon, properties = { 'area': area, 'avg': avg, 'min': min, 'max': max, 'std': std })
|
||||
|
||||
return feature
|
||||
|
||||
|
||||
def calculate_heatmap(diff, mask, dem, epsg, min_height):
|
||||
# Calculate the pixels of valid values
|
||||
pixels = np.argwhere(~mask)
|
||||
xs = pixels[:, 0]
|
||||
ys = pixels[:, 1]
|
||||
|
||||
|
||||
# Map pixels to coordinates
|
||||
coords_xs, coords_ys = map_pixels_to_coordinates(dem, epsg, xs, ys)
|
||||
|
||||
# Calculate the actual values
|
||||
values = diff[~mask]
|
||||
|
||||
# Substract the min, so all values are between 0 and max
|
||||
values = values - np.min(values)
|
||||
|
||||
array = np.column_stack((coords_ys, coords_xs, values))
|
||||
return json.dumps({ 'values': array.tolist(), 'max': float(max(values)) })
|
||||
|
||||
|
||||
def map_pixels_to_coordinates(reference_tiff, dst_epsg, rows, cols):
|
||||
xs, ys = transform.xy(reference_tiff.transform, rows, cols)
|
||||
dst_crs = rio.crs.CRS.from_epsg(dst_epsg)
|
||||
return map_to_new_crs(reference_tiff.crs, dst_crs, xs, ys)
|
||||
|
||||
def map_to_new_crs(src_crs, target_crs, xs, ys):
|
||||
"""Map the given arrays from one crs to the other"""
|
||||
return warp.transform(src_crs, target_crs, xs, ys)
|
||||
|
||||
def to_pixel_format(contour):
|
||||
"""OpenCV contours have a weird format. We are converting them to (row, col)"""
|
||||
return [(pixel[0][1], pixel[0][0]) for pixel in contour]
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "ChangeDetection",
|
||||
"webodmMinVersion": "1.1.1",
|
||||
"description": "Detect changes between two different tasks in the same project.",
|
||||
"version": "1.0.1",
|
||||
"author": "Nicolas Chamo",
|
||||
"email": "nicolas@chamo.com.ar",
|
||||
"repository": "https://github.com/OpenDroneMap/WebODM",
|
||||
"tags": ["change", "detection", "dsm", "dem", "dtm"],
|
||||
"homepage": "https://github.com/OpenDroneMap/WebODM",
|
||||
"experimental": false,
|
||||
"deprecated": false
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
from app.plugins import PluginBase
|
||||
from app.plugins import MountPoint
|
||||
from .api import TaskChangeMapGenerate
|
||||
from .api import TaskChangeMapCheck
|
||||
from .api import TaskChangeMapDownload
|
||||
|
||||
class Plugin(PluginBase):
|
||||
def include_js_files(self):
|
||||
return ['main.js']
|
||||
|
||||
def build_jsx_components(self):
|
||||
return ['ChangeDetection.jsx']
|
||||
|
||||
def api_mount_points(self):
|
||||
return [
|
||||
MountPoint('task/(?P<pk>[^/.]+)/changedetection/generate', TaskChangeMapGenerate.as_view()),
|
||||
MountPoint('task/(?P<pk>[^/.]+)/changedetection/check/(?P<celery_task_id>.+)', TaskChangeMapCheck.as_view()),
|
||||
MountPoint('task/(?P<pk>[^/.]+)/changedetection/download/(?P<celery_task_id>.+)', TaskChangeMapDownload.as_view()),
|
||||
]
|
|
@ -1,56 +0,0 @@
|
|||
import L from 'leaflet';
|
||||
import ReactDOM from 'ReactDOM';
|
||||
import React from 'React';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ChangeDetection.scss';
|
||||
import ChangeDetectionPanel from './ChangeDetectionPanel';
|
||||
|
||||
class ChangeDetectionButton extends React.Component {
|
||||
static propTypes = {
|
||||
tasks: PropTypes.object.isRequired,
|
||||
map: PropTypes.object.isRequired,
|
||||
alignSupported: PropTypes.bool.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-changedetection-button leaflet-bar-part theme-secondary"></a>
|
||||
<ChangeDetectionPanel map={this.props.map} isShowed={showPanel} alignSupported={this.props.alignSupported} 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-changedetection leaflet-bar leaflet-control');
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
ReactDOM.render(<ChangeDetectionButton map={this.options.map} alignSupported={this.options.alignSupported} tasks={this.options.tasks} />, container);
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
|
@ -1,24 +0,0 @@
|
|||
.leaflet-control-changedetection{
|
||||
z-index: 999;
|
||||
|
||||
a.leaflet-control-changedetection-button{
|
||||
background: url(icon.png) no-repeat 0 0;
|
||||
background-size: 26px 26px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
div.changedetection-panel{ display: none; }
|
||||
|
||||
.open{
|
||||
a.leaflet-control-changedetection-button{
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.changedetection-panel{
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
.leaflet-touch .leaflet-control-changedetection a {
|
||||
background-position: 2px 2px;
|
||||
}
|
|
@ -1,476 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Storage from 'webodm/classes/Storage';
|
||||
import L from 'leaflet';
|
||||
require('leaflet.heat')
|
||||
import './ChangeDetectionPanel.scss';
|
||||
import ErrorMessage from 'webodm/components/ErrorMessage';
|
||||
import ReactTooltip from 'react-tooltip'
|
||||
|
||||
export default class ChangeDetectionPanel extends React.Component {
|
||||
static defaultProps = {
|
||||
};
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
tasks: PropTypes.object.isRequired,
|
||||
isShowed: PropTypes.bool.isRequired,
|
||||
map: PropTypes.object.isRequired,
|
||||
alignSupported: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: "",
|
||||
permanentError: "",
|
||||
epsg: Storage.getItem("last_changedetection_epsg") || "4326",
|
||||
customEpsg: Storage.getItem("last_changedetection_custom_epsg") || "4326",
|
||||
displayType: Storage.getItem("last_changedetection_display_type") || "contours",
|
||||
resolution: Storage.getItem("last_changedetection_resolution") || 0.2,
|
||||
minArea: Storage.getItem("last_changedetection_min_area") || 40,
|
||||
minHeight: Storage.getItem("last_changedetection_min_height") || 5,
|
||||
role: Storage.getItem("last_changedetection_role") || 'reference',
|
||||
align: this.props.alignSupported ? (Storage.getItem("last_changedetection_align") === 'true') : false,
|
||||
other: "",
|
||||
otherTasksInProject: new Map(),
|
||||
loading: true,
|
||||
task: props.tasks[0] || null,
|
||||
previewLoading: false,
|
||||
exportLoading: false,
|
||||
previewLayer: null,
|
||||
opacity: 100,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
componentDidUpdate(){
|
||||
if (this.props.isShowed && this.state.loading){
|
||||
const {id: taskId, project} = this.state.task;
|
||||
|
||||
this.loadingReq = $.getJSON(`/api/projects/${project}/tasks/`)
|
||||
.done(res => {
|
||||
|
||||
const otherTasksInProject = new Map()
|
||||
|
||||
if (!this.props.alignSupported) {
|
||||
const myTask = res.filter(({ id }) => id === taskId)[0]
|
||||
const { available_assets: myAssets } = myTask;
|
||||
const errors = []
|
||||
|
||||
if (myAssets.indexOf("dsm.tif") === -1)
|
||||
errors.push("No DSM is available. Make sure to process a task with either the --dsm option checked");
|
||||
if (myAssets.indexOf("dtm.tif") === -1)
|
||||
errors.push("No DTM is available. Make sure to process a task with either the --dtm option checked");
|
||||
|
||||
if (errors.length > 0) {
|
||||
this.setState({permanentError: errors.join('\n')});
|
||||
return
|
||||
}
|
||||
|
||||
const otherTasksWithDEMs = res.filter(({ id }) => id !== taskId)
|
||||
.filter(({ available_assets }) => available_assets.indexOf("dsm.tif") >= 0 && available_assets.indexOf("dtm.tif") >= 0)
|
||||
|
||||
if (otherTasksWithDEMs.length === 0) {
|
||||
this.setState({permanentError: "Couldn't find other tasks on the project. Please make sure there are other tasks on the project that have both a DTM and DSM."});
|
||||
return
|
||||
}
|
||||
otherTasksWithDEMs.forEach(({ id, name }) => otherTasksInProject.set(id, name))
|
||||
} else {
|
||||
res.filter(({ id }) => id !== taskId)
|
||||
.forEach(({ id, name }) => otherTasksInProject.set(id, name))
|
||||
}
|
||||
|
||||
if (otherTasksInProject.size === 0) {
|
||||
this.setState({permanentError: `Couldn't find other tasks on this project. This plugin must be used on projects with 2 or more tasks.`})
|
||||
} else {
|
||||
const firstOtherTask = Array.from(otherTasksInProject.entries())[0][0]
|
||||
this.setState({otherTasksInProject, other: firstOtherTask});
|
||||
}
|
||||
})
|
||||
.fail(() => {
|
||||
this.setState({permanentError: `Cannot retrieve information for the current project. 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.generateReq){
|
||||
this.generateReq.abort();
|
||||
this.generateReq = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectMinArea = e => {
|
||||
this.setState({minArea: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectResolution = e => {
|
||||
this.setState({resolution: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectMinHeight = e => {
|
||||
this.setState({minHeight: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectRole = e => {
|
||||
this.setState({role: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectOther = e => {
|
||||
this.setState({other: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectEpsg = e => {
|
||||
this.setState({epsg: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectDisplayType = e => {
|
||||
this.setState({displayType: e.target.value});
|
||||
}
|
||||
|
||||
handleChangeAlign = e => {
|
||||
this.setState({align: e.target.checked});
|
||||
}
|
||||
|
||||
handleChangeCustomEpsg = e => {
|
||||
this.setState({customEpsg: e.target.value});
|
||||
}
|
||||
|
||||
getFormValues = () => {
|
||||
const { epsg, customEpsg, displayType, align,
|
||||
resolution, minHeight, minArea, other, role } = this.state;
|
||||
return {
|
||||
display_type: displayType,
|
||||
resolution: resolution,
|
||||
min_height: minHeight,
|
||||
min_area: minArea,
|
||||
role: role,
|
||||
epsg: epsg !== "custom" ? epsg : customEpsg,
|
||||
other_task: other,
|
||||
align: align,
|
||||
};
|
||||
}
|
||||
|
||||
waitForCompletion = (taskId, celery_task_id, cb) => {
|
||||
let errorCount = 0;
|
||||
|
||||
const check = () => {
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: `/api/plugins/changedetection/task/${taskId}/changedetection/check/${celery_task_id}`
|
||||
}).done(result => {
|
||||
if (result.error){
|
||||
cb(result.error);
|
||||
}else if (result.ready){
|
||||
cb();
|
||||
}else{
|
||||
// Retry
|
||||
setTimeout(() => check(), 2000);
|
||||
}
|
||||
}).fail(error => {
|
||||
console.warn(error);
|
||||
if (errorCount++ < 10) setTimeout(() => check(), 2000);
|
||||
else cb(JSON.stringify(error));
|
||||
});
|
||||
};
|
||||
|
||||
check();
|
||||
}
|
||||
|
||||
addPreview = (url, cb) => {
|
||||
const { map } = this.props;
|
||||
|
||||
$.getJSON(url)
|
||||
.done((result) => {
|
||||
try{
|
||||
this.removePreview();
|
||||
|
||||
if (result.max) {
|
||||
const heatMap = L.heatLayer(result.values, { max: result.max, radius: 9, minOpacity: 0 })
|
||||
heatMap.setStyle = ({ opacity }) => heatMap.setOptions({ max: result.max / opacity } )
|
||||
this.setState({ previewLayer: heatMap });
|
||||
} else {
|
||||
let featureGroup = L.featureGroup();
|
||||
result.features.forEach(feature => {
|
||||
const area = feature.properties.area.toFixed(2);
|
||||
const min = feature.properties.min.toFixed(2);
|
||||
const max = feature.properties.max.toFixed(2);
|
||||
const avg = feature.properties.avg.toFixed(2);
|
||||
const std = feature.properties.std.toFixed(2);
|
||||
let geojsonForLevel = L.geoJSON(feature)
|
||||
.bindPopup(`Area: ${area}m2<BR/>Min: ${min}m<BR/>Max: ${max}m<BR/>Avg: ${avg}m<BR/>Std: ${std}m`)
|
||||
featureGroup.addLayer(geojsonForLevel);
|
||||
});
|
||||
featureGroup.geojson = result;
|
||||
this.setState({ previewLayer: featureGroup });
|
||||
}
|
||||
|
||||
this.state.previewLayer.addTo(map);
|
||||
|
||||
cb();
|
||||
}catch(e){
|
||||
throw e
|
||||
cb(e.message);
|
||||
}
|
||||
})
|
||||
.fail(cb);
|
||||
}
|
||||
|
||||
removePreview = () => {
|
||||
const { map } = this.props;
|
||||
|
||||
if (this.state.previewLayer){
|
||||
map.removeLayer(this.state.previewLayer);
|
||||
this.setState({previewLayer: null});
|
||||
}
|
||||
}
|
||||
|
||||
generateChangeMap = (data, loadingProp, isPreview) => {
|
||||
this.setState({[loadingProp]: true, error: ""});
|
||||
const taskId = this.state.task.id;
|
||||
|
||||
// Save settings for next time
|
||||
Storage.setItem("last_changedetection_display_type", this.state.displayType);
|
||||
Storage.setItem("last_changedetection_resolution", this.state.resolution);
|
||||
Storage.setItem("last_changedetection_min_height", this.state.minHeight);
|
||||
Storage.setItem("last_changedetection_min_area", this.state.minArea);
|
||||
Storage.setItem("last_changedetection_epsg", this.state.epsg);
|
||||
Storage.setItem("last_changedetection_custom_epsg", this.state.customEpsg);
|
||||
Storage.setItem("last_changedetection_role", this.state.role);
|
||||
Storage.setItem("last_changedetection_align", this.state.align);
|
||||
|
||||
|
||||
|
||||
this.generateReq = $.ajax({
|
||||
type: 'POST',
|
||||
url: `/api/plugins/changedetection/task/${taskId}/changedetection/generate`,
|
||||
data: data
|
||||
}).done(result => {
|
||||
if (result.celery_task_id){
|
||||
this.waitForCompletion(taskId, result.celery_task_id, error => {
|
||||
if (error) this.setState({[loadingProp]: false, 'error': error});
|
||||
else{
|
||||
const fileUrl = `/api/plugins/changedetection/task/${taskId}/changedetection/download/${result.celery_task_id}`;
|
||||
|
||||
// Preview
|
||||
if (isPreview){
|
||||
this.addPreview(fileUrl, e => {
|
||||
if (e) this.setState({error: JSON.stringify(e)});
|
||||
this.setState({[loadingProp]: false});
|
||||
});
|
||||
}else{
|
||||
// Download
|
||||
location.href = fileUrl;
|
||||
this.setState({[loadingProp]: false});
|
||||
}
|
||||
}
|
||||
});
|
||||
}else if (result.error){
|
||||
this.setState({[loadingProp]: false, error: result.error});
|
||||
}else{
|
||||
this.setState({[loadingProp]: false, error: "Invalid response: " + result});
|
||||
}
|
||||
}).fail(error => {
|
||||
this.setState({[loadingProp]: false, error: JSON.stringify(error)});
|
||||
});
|
||||
}
|
||||
|
||||
handleExport = (format) => {
|
||||
return () => {
|
||||
const data = this.getFormValues();
|
||||
data.format = format;
|
||||
data.display_type = 'contours'
|
||||
this.generateChangeMap(data, 'exportLoading', false);
|
||||
};
|
||||
}
|
||||
|
||||
handleShowPreview = () => {
|
||||
this.setState({previewLoading: true});
|
||||
|
||||
const data = this.getFormValues();
|
||||
data.epsg = 4326;
|
||||
data.format = "GeoJSON";
|
||||
this.generateChangeMap(data, 'previewLoading', true);
|
||||
}
|
||||
|
||||
handleChangeOpacity = (evt) => {
|
||||
const opacity = parseFloat(evt.target.value) / 100;
|
||||
this.setState({opacity: opacity});
|
||||
this.state.previewLayer.setStyle({ opacity: opacity });
|
||||
this.props.map.closePopup();
|
||||
}
|
||||
|
||||
render(){
|
||||
const { loading, task, otherTasksInProject, error, permanentError, other,
|
||||
epsg, customEpsg, exportLoading, minHeight, minArea, displayType,
|
||||
resolution, previewLoading, previewLayer, opacity, role, align } = this.state;
|
||||
|
||||
const disabled = (epsg === "custom" && !customEpsg) || !other;
|
||||
|
||||
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{
|
||||
content = (<div>
|
||||
<ErrorMessage bind={[this, "error"]} />
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Role:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={role} onChange={this.handleSelectRole}>
|
||||
<option value="reference">Reference</option>
|
||||
<option value="compare">Compare</option>
|
||||
</select>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="This plugin will take the reference task, and substract the compare task. Then, we will apply the filters<BR/>available below to determine if some difference is a valid change or not." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Other:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={other} onChange={this.handleSelectOther}>
|
||||
{Array.from(otherTasksInProject.entries()).map(([id, name]) => <option value={id} title={name}>{name.length > 20 ? name.substring(0, 19) + '...' : name}</option>)}
|
||||
</select>
|
||||
{this.props.alignSupported ?
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="Select the other task on the project to compare this task against." />
|
||||
:
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="Select the other task on the project to compare this task against.<BR/>Take into account that only tasks with both a DSM and DTM will be available here." />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.props.alignSupported ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Align:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="checkbox" className="form-control" checked={align} onChange={this.handleChangeAlign} />
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="It is possible to align the two tasks to detect changes more accurately.<BR/>But take into account that the processing can take longer if you do so." />
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Display mode:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={displayType} onChange={this.handleSelectDisplayType}>
|
||||
<option value="contours">Contours</option>
|
||||
<option value="heatmap">Heatmap</option>
|
||||
</select>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You can select to display a heatmap with all the substraction, or the contours of the filtered changes.<BR/>Export is only available for the 'Contours' mode." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Resolution:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={resolution} onChange={this.handleSelectResolution} /><span> meters/pixel</span>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You can indicate the resolution to use when detecting changes. The final resolution used will be: max(input, resolution(reference), resolution(compare)).<BR/>The higher the resolution, the faster the result will be calculated. You can set to 0 to use the DEMs resolutions." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Min Height:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={minHeight} onChange={this.handleSelectMinHeight} /><span> meters</span>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="When detecting change, there can be some noise. Please indicate the min height that change needs to have to consider it a valid change." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Min Area:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" disabled={displayType === 'heatmap'} className="form-control custom-interval" value={minArea} onChange={this.handleSelectMinArea} /><span> sq meters</span>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="When detecting change, there can be some noise. Please indicate the min area that change needs to have to consider it a valid change.<BR/>This option is only available with the 'Contours' display mode." />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Projection:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={epsg} onChange={this.handleSelectEpsg}>
|
||||
<option value="4326">WGS84 (EPSG:4326)</option>
|
||||
<option value="3857">Web Mercator (EPSG:3857)</option>
|
||||
<option value="custom">Custom EPSG</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{epsg === "custom" ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">EPSG:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={customEpsg} onChange={this.handleChangeCustomEpsg} />
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
{previewLayer ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Opacity:</label>
|
||||
<div className="col-sm-9">
|
||||
<input type="range" className="slider" step="1" value={opacity * 100} onChange={this.handleChangeOpacity} />
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="Control the opacity of the change map. You must generate a preview to be able to control the opacity." />
|
||||
<ReactTooltip place="left" effect="solid" html={true}/>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
<div className="row action-buttons">
|
||||
<div className="col-sm-9 text-right">
|
||||
<button onClick={this.handleShowPreview}
|
||||
disabled={disabled || previewLoading} type="button" className="btn btn-sm btn-primary btn-preview">
|
||||
{previewLoading ? <i className="fa fa-spin fa-circle-notch"/> : <i className="glyphicon glyphicon-eye-open"/>} Preview
|
||||
</button>
|
||||
|
||||
<div className="btn-group">
|
||||
<button disabled={disabled || exportLoading || displayType === 'heatmap'} title={displayType === 'heatmap' ? "Export is only available for the 'Contours' display mode" : ""} type="button" className="btn btn-sm btn-primary" data-toggle="dropdown">
|
||||
{exportLoading ? <i className="fa fa-spin fa-circle-notch"/> : <i className="glyphicon glyphicon-download" />} Export
|
||||
</button>
|
||||
<button disabled={disabled|| exportLoading || displayType === 'heatmap'} title={displayType === 'heatmap' ? "Export is only available for the 'Contours' display mode" : ""} type="button" className="btn btn-sm dropdown-toggle btn-primary" data-toggle="dropdown"><span className="caret"></span></button>
|
||||
<ul className="dropdown-menu pull-right">
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("GPKG")}>
|
||||
<i className="fa fa-globe fa-fw"></i> GeoPackage (.GPKG)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("DXF")}>
|
||||
<i className="fa fa-file fa-fw"></i> AutoCAD (.DXF)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("GeoJSON")}>
|
||||
<i className="fa fa-code fa-fw"></i> GeoJSON (.JSON)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("ESRI Shapefile")}>
|
||||
<i className="fa fa-file-archive fa-fw"></i> ShapeFile (.SHP)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReactTooltip place="left" effect="solid" html={true}/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (<div className="changedetection-panel">
|
||||
<span className="close-button" onClick={this.props.onClose}/>
|
||||
<div className="title">Change Detection</div>
|
||||
<hr/>
|
||||
{content}
|
||||
</div>);
|
||||
}
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
.leaflet-control-changedetection .changedetection-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;
|
||||
}
|
||||
|
||||
input.custom-interval{
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
*{
|
||||
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-preview{
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.action-buttons{
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.help {
|
||||
margin-left: 4px;
|
||||
top: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
padding: 0px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
Plik binarny nie jest wyświetlany.
Przed Szerokość: | Wysokość: | Rozmiar: 1.1 KiB |
|
@ -1,13 +0,0 @@
|
|||
PluginsAPI.Map.didAddControls([
|
||||
'changedetection/build/ChangeDetection.js',
|
||||
'changedetection/build/ChangeDetection.css'
|
||||
], function(args, ChangeDetection){
|
||||
var tasks = [];
|
||||
for (var i = 0; i < args.tiles.length; i++){
|
||||
tasks.push(args.tiles[i].meta.task);
|
||||
}
|
||||
|
||||
if (tasks.length === 1){
|
||||
args.map.addControl(new ChangeDetection({map: args.map, tasks, alignSupported: false}));
|
||||
}
|
||||
});
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"name": "changedetection",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"react-tooltip": "^3.10.0"
|
||||
}
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
from rasterio.io import MemoryFile
|
||||
from rasterio.transform import from_origin
|
||||
from rasterio.warp import aligned_target, reproject
|
||||
import rasterio as rio
|
||||
import numpy as np
|
||||
|
||||
def align(reference, other, *more_others, **kwargs):
|
||||
others = [other] + list(more_others)
|
||||
assert_same_crs(reference, others)
|
||||
reference, others = build_complex_rasters(reference, others)
|
||||
match_pixel_size(reference, others, kwargs)
|
||||
intersect_rasters(reference, others)
|
||||
return [reference.raster] + [other.raster for other in others]
|
||||
|
||||
def align_altitudes(reference, other, *more_others):
|
||||
others = [other] + list(more_others)
|
||||
reference, others = build_complex_rasters(reference, others)
|
||||
|
||||
reference.align_altitude_to_zero()
|
||||
for other in others:
|
||||
other.align_altitude_to_zero()
|
||||
|
||||
return [reference.raster] + [other.raster for other in others]
|
||||
|
||||
def assert_same_crs(reference, others):
|
||||
for other in others:
|
||||
assert reference.crs == other.crs, "All rasters should have the same CRS."
|
||||
|
||||
def build_complex_rasters(reference, others):
|
||||
"""Build Raster objects from the rasterio rasters"""
|
||||
return Raster(reference), [Raster(other) for other in others]
|
||||
|
||||
def match_pixel_size(reference, others, kwargs):
|
||||
"""Take two or more rasters and modify them so that they have the same pixel size"""
|
||||
rasters = [reference] + others
|
||||
max_xres = max([raster.xres for raster in rasters])
|
||||
max_yres = max([raster.yres for raster in rasters])
|
||||
|
||||
if 'resolution' in kwargs:
|
||||
max_xres = max(max_xres, kwargs['resolution'])
|
||||
max_yres = max(max_yres, kwargs['resolution'])
|
||||
|
||||
reference.match_pixel_size(max_xres, max_yres)
|
||||
for other in others:
|
||||
other.match_pixel_size(max_xres, max_yres)
|
||||
|
||||
def intersect_rasters(reference, others):
|
||||
"""Take two or more rasters with the same size per pixel, and calculate the areas where they intersect, based on their position. Then, we keep only those areas, discarding the other pixels."""
|
||||
final_bounds = reference.get_bounds()
|
||||
|
||||
for other in others:
|
||||
final_bounds = final_bounds.intersection(other.get_bounds())
|
||||
|
||||
reference.reduce_to_bounds(final_bounds)
|
||||
for other in others:
|
||||
other.reduce_to_bounds(final_bounds)
|
||||
|
||||
|
||||
class Raster:
|
||||
def __init__(self, raster):
|
||||
self.raster = raster
|
||||
self.xres, self.yres = raster.res
|
||||
|
||||
def get_bounds(self):
|
||||
(left, bottom, right, top) = self.raster.bounds
|
||||
return Bounds(left, bottom, right, top)
|
||||
|
||||
def get_window(self):
|
||||
print(self.raster.bounds)
|
||||
(left, bottom, right, top) = self.raster.bounds
|
||||
return self.raster.window(left, bottom, right, top)
|
||||
|
||||
def match_pixel_size(self, xres, yres):
|
||||
dst_transform, dst_width, dst_height = aligned_target(self.raster.transform, self.raster.width, self.raster.height, (xres, yres))
|
||||
with MemoryFile() as mem_file:
|
||||
aligned = mem_file.open(driver = 'GTiff', height = dst_height, width = dst_width, count = self.raster.count, dtype = self.raster.dtypes[0], crs = self.raster.crs, transform = dst_transform, nodata = self.raster.nodata)
|
||||
for band in range(1, self.raster.count + 1):
|
||||
reproject(rio.band(self.raster, band), rio.band(aligned, band))
|
||||
self.raster = aligned
|
||||
|
||||
def reduce_to_bounds(self, bounds):
|
||||
"""Take some bounds and remove the pixels outside of it"""
|
||||
(left, bottom, right, top) = bounds.as_tuple()
|
||||
window = self.raster.window(left, bottom, right, top)
|
||||
with MemoryFile() as mem_file:
|
||||
raster = mem_file.open(driver = 'GTiff', height = window.height, width = window.width, count = self.raster.count, dtype = self.raster.dtypes[0], crs = self.raster.crs, transform = self.raster.window_transform(window), nodata = self.raster.nodata)
|
||||
for band in range(1, self.raster.count + 1):
|
||||
band_array = self.raster.read(band, window = window)
|
||||
raster.write(band_array, band)
|
||||
self.raster = raster
|
||||
|
||||
def align_altitude_to_zero(self):
|
||||
with MemoryFile() as mem_file:
|
||||
raster = mem_file.open(driver = 'GTiff', height = self.raster.height, width = self.raster.width, count = self.raster.count, dtype = self.raster.dtypes[0], crs = self.raster.crs, transform = self.raster.transform, nodata = self.raster.nodata)
|
||||
for band in range(1, self.raster.count + 1):
|
||||
band_array = self.raster.read(band, masked = True)
|
||||
min = band_array.min()
|
||||
aligned = band_array - min
|
||||
raster.write(aligned, band)
|
||||
self.raster = raster
|
||||
|
||||
class Bounds:
|
||||
def __init__(self, left, bottom, right, top):
|
||||
self.left = left
|
||||
self.bottom = bottom
|
||||
self.right = right
|
||||
self.top = top
|
||||
|
||||
def intersection(self, other_bounds):
|
||||
max_left = max(self.left, other_bounds.left)
|
||||
max_bottom = max(self.bottom, other_bounds.bottom)
|
||||
min_right = min(self.right, other_bounds.right)
|
||||
min_top = min(self.top, other_bounds.top)
|
||||
return Bounds(max_left, max_bottom, min_right, min_top)
|
||||
|
||||
def as_tuple(self):
|
||||
return (self.left, self.bottom, self.right, self.top)
|
|
@ -1,3 +0,0 @@
|
|||
geojson==2.4.1
|
||||
opencv-python-headless==4.4.0.46
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
* Save the "ground" choice on the plugin panel
|
||||
* Consider fetching (or creating if it doesn't exist) a smaller version of the dsm/dtm. There is no need to work with a high resolution image in this case, and it should speed things up.
|
|
@ -1 +0,0 @@
|
|||
from .plugin import *
|
|
@ -1,102 +0,0 @@
|
|||
import mimetypes
|
||||
import os
|
||||
|
||||
from django.http import FileResponse
|
||||
from django.http import HttpResponse
|
||||
from wsgiref.util import FileWrapper
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from app.plugins.views import TaskView
|
||||
from worker.tasks import execute_grass_script
|
||||
from app.plugins.grass_engine import grass, GrassEngineException, cleanup_grass_context
|
||||
from worker.celery import app as celery
|
||||
from app.plugins import get_current_plugin
|
||||
|
||||
class TaskElevationMapGenerate(TaskView):
|
||||
def post(self, request, pk=None):
|
||||
task = self.get_and_check_task(request, pk)
|
||||
plugin = get_current_plugin()
|
||||
|
||||
if task.dsm_extent is None:
|
||||
return Response({'error': 'No DSM layer is available.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
reference = request.data.get('reference', 'global')
|
||||
if reference.lower() == 'ground' and task.dtm_extent is None:
|
||||
return Response({'error': 'No DTM layer is available. You need one to set the ground as reference.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
context = grass.create_context({'auto_cleanup' : False, 'location': 'epsg:3857', 'python_path': plugin.get_python_packages_path()})
|
||||
dsm = os.path.abspath(task.get_asset_download_path("dsm.tif"))
|
||||
dtm = os.path.abspath(task.get_asset_download_path("dtm.tif")) if reference.lower() == 'ground' else None
|
||||
epsg = int(request.data.get('epsg', '3857'))
|
||||
interval = request.data.get('interval', '5')
|
||||
format = request.data.get('format', 'GPKG')
|
||||
supported_formats = ['GPKG', 'ESRI Shapefile', 'DXF', 'GeoJSON']
|
||||
if not format in supported_formats:
|
||||
raise GrassEngineException("Invalid format {} (must be one of: {})".format(format, ",".join(supported_formats)))
|
||||
noise_filter_size = float(request.data.get('noise_filter_size', 2))
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
context.add_param('dsm', dsm)
|
||||
context.add_param('interval', interval)
|
||||
context.add_param('format', format)
|
||||
context.add_param('noise_filter_size', noise_filter_size)
|
||||
context.add_param('epsg', epsg)
|
||||
|
||||
if dtm != None:
|
||||
context.add_param('dtm', dtm)
|
||||
|
||||
context.set_location(dsm)
|
||||
|
||||
celery_task_id = execute_grass_script.delay(os.path.join(current_dir, "elevationmap.py"), context.serialize()).task_id
|
||||
|
||||
return Response({'celery_task_id': celery_task_id}, status=status.HTTP_200_OK)
|
||||
except GrassEngineException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_200_OK)
|
||||
|
||||
class TaskElevationMapCheck(TaskView):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
res = celery.AsyncResult(celery_task_id)
|
||||
if not res.ready():
|
||||
return Response({'ready': False}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
result = res.get()
|
||||
if result.get('error', None) is not None:
|
||||
cleanup_grass_context(result['context'])
|
||||
return Response({'ready': True, 'error': result['error']})
|
||||
|
||||
output = result.get('output')
|
||||
if not output or not os.path.exists(output):
|
||||
cleanup_grass_context(result['context'])
|
||||
return Response({'ready': True, 'error': output})
|
||||
|
||||
request.session['elevation_map_' + celery_task_id] = output
|
||||
return Response({'ready': True})
|
||||
|
||||
|
||||
class TaskElevationMapDownload(TaskView):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
elevation_map_file = request.session.get('elevation_map_' + celery_task_id, None)
|
||||
|
||||
if elevation_map_file is not None:
|
||||
filename = os.path.basename(elevation_map_file)
|
||||
filesize = os.stat(elevation_map_file).st_size
|
||||
|
||||
f = open(elevation_map_file, "rb")
|
||||
|
||||
# More than 100mb, normal http response, otherwise stream
|
||||
# Django docs say to avoid streaming when possible
|
||||
stream = filesize > 1e8
|
||||
if stream:
|
||||
response = FileResponse(f)
|
||||
else:
|
||||
response = HttpResponse(FileWrapper(f),
|
||||
content_type=(mimetypes.guess_type(filename)[0] or "application/zip"))
|
||||
|
||||
response['Content-Type'] = mimetypes.guess_type(filename)[0] or "application/zip"
|
||||
response['Content-Disposition'] = "attachment; filename={}".format(filename)
|
||||
response['Content-Length'] = filesize
|
||||
|
||||
return response
|
||||
else:
|
||||
return Response({'error': 'Invalid elevation_map download id'})
|
|
@ -1,240 +0,0 @@
|
|||
#%module
|
||||
#% description: This script takes a GeoTIFF file, calculates its heighmap, and outputs it as a GeoJSON
|
||||
#%end
|
||||
#%option
|
||||
#% key: dsm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the dsm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: intervals
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The intervals used to generate the diferent elevation levels
|
||||
#%end
|
||||
#%option
|
||||
#% key: format
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: OGR output format
|
||||
#%end
|
||||
#%option
|
||||
#% key: dtm
|
||||
#% type: string
|
||||
#% required: no
|
||||
#% multiple: no
|
||||
#% description: The path for the dtm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: epsg
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The epsg code that will be used for output
|
||||
#%end
|
||||
#%option
|
||||
#% key: noise_filter_size
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Area in meters where we will clean up noise in the contours
|
||||
#%end
|
||||
|
||||
|
||||
import math, argparse
|
||||
import numpy as np
|
||||
import rasterio as rio
|
||||
from rasterio import warp, transform
|
||||
from geojson import Feature, FeatureCollection, MultiPolygon, dumps
|
||||
import subprocess
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import sys
|
||||
import grass.script as grass
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||||
from webodm import settings
|
||||
sys.path.insert(0, os.path.join(settings.MEDIA_ROOT, "plugins", "elevationmap", "site-packages"))
|
||||
import cv2
|
||||
|
||||
def main():
|
||||
ext = ""
|
||||
if opts['format'] == "GeoJSON":
|
||||
ext = "json"
|
||||
elif opts['format'] == "GPKG":
|
||||
ext = "gpkg"
|
||||
elif opts['format'] == "DXF":
|
||||
ext = "dxf"
|
||||
elif opts['format'] == "ESRI Shapefile":
|
||||
ext = "shp"
|
||||
|
||||
# Open dsm
|
||||
dsm = rio.open(opts['dsm'])
|
||||
# Read the tiff as an numpy masked array
|
||||
dsm_array = dsm.read(1, masked = True)
|
||||
# Create a kernel based on the parameter 'noise_filter_size' and the tiff resolution
|
||||
kernel = get_kernel(float(opts['noise_filter_size']), dsm)
|
||||
|
||||
# Check if we want to use the dtm also
|
||||
if opts['dtm'] != '':
|
||||
# Open the dtm
|
||||
dtm = rio.open(opts['dtm'])
|
||||
# Assert that the dtm and dsm have the same bounds and resolution
|
||||
assert_same_bounds_and_resolution(dsm, dtm)
|
||||
# Calculate the different between the dsm and dtm
|
||||
array = calculate_difference(dsm_array, dtm)
|
||||
else:
|
||||
array = dsm_array
|
||||
|
||||
# Calculate the ranges based on the parameter 'intervals' and the elevation array
|
||||
ranges = calculate_ranges(opts['intervals'], array)
|
||||
|
||||
features = []
|
||||
|
||||
for bottom, top in ranges:
|
||||
# Binarize the image. Everything in [bottom, top) is white. Everything else is black
|
||||
surface_array = np.ma.where((bottom <= array) & (array < top), 255, 0).astype(np.uint8)
|
||||
# Apply kernel to reduce noise
|
||||
without_noise = cv2.morphologyEx(surface_array, cv2.MORPH_CLOSE, kernel) if kernel is not None else surface_array
|
||||
# Find contours
|
||||
contours, hierarchy = cv2.findContours(without_noise, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
|
||||
# Check if we found something
|
||||
if len(contours) > 0:
|
||||
# Transform contours from pixels to coordinates
|
||||
mapped_contours = [map_pixels_to_coordinates(dsm, opts['epsg'], to_pixel_format(contour)) for contour in contours]
|
||||
# Build the MultiPolygon for based on the contours and their hierarchy
|
||||
built_multi_polygon = LevelBuilder(bottom, top, mapped_contours, hierarchy[0]).build_multi_polygon()
|
||||
features.append(built_multi_polygon)
|
||||
|
||||
# Write the GeoJSON to a file
|
||||
dump = dumps(FeatureCollection(features))
|
||||
with open("output.json", 'w+') as output:
|
||||
output.write(dump)
|
||||
|
||||
if ext != "json":
|
||||
subprocess.check_call(["ogr2ogr", "-f", opts['format'], "output.%s" % ext, "output.json"], stdout=subprocess.DEVNULL)
|
||||
|
||||
if os.path.isfile("output.%s" % ext):
|
||||
if opts['format'] == "ESRI Shapefile":
|
||||
ext="zip"
|
||||
os.makedirs("contours")
|
||||
contour_files = glob.glob("output.*")
|
||||
for cf in contour_files:
|
||||
shutil.move(cf, os.path.join("contours", os.path.basename(cf)))
|
||||
|
||||
shutil.make_archive('output', 'zip', 'contours/')
|
||||
|
||||
print(os.path.join(os.getcwd(), "output.%s" % ext))
|
||||
else:
|
||||
print("error")
|
||||
|
||||
def get_kernel(noise_filter_size, dsm):
|
||||
"""Generate a kernel for noise filtering. Will return none if the noise_filter_size isn't positive"""
|
||||
if noise_filter_size <= 0:
|
||||
return None
|
||||
if dsm.crs.linear_units != 'metre':
|
||||
noise_filter_size *= 3.2808333333465 # Convert meter to feets
|
||||
return cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (int(round(noise_filter_size / dsm.res[0])), int(round(noise_filter_size / dsm.res[1]))))
|
||||
|
||||
def assert_same_bounds_and_resolution(dsm, dtm):
|
||||
if dtm.bounds != dsm.bounds or dtm.res != dsm.res:
|
||||
raise Exception("DTM and DSM have differenct bounds or resolution.")
|
||||
|
||||
def calculate_difference(dsm_array, dtm):
|
||||
"""Calculate the difference between the dsm and dtm"""
|
||||
dtm_array = dtm.read(1, masked = True)
|
||||
difference = dsm_array - dtm_array
|
||||
difference.data[difference < 0] = 0 # We set to 0 anything that might have been negative
|
||||
return difference
|
||||
|
||||
def calculate_ranges(interval_text, array):
|
||||
"""Calculate the ranges based on the provided 'interval_text'"""
|
||||
if is_number(interval_text):
|
||||
# If it is a number, then consider it the step
|
||||
min_elevation = math.floor(np.amin(array))
|
||||
max_elevation = math.ceil(np.amax(array))
|
||||
interval = float(interval_text)
|
||||
return [(bottom, bottom + interval) for bottom in np.arange(min_elevation, max_elevation, interval)]
|
||||
else:
|
||||
# If it is not a number, then we consider the text the intervals. We are going to validate them
|
||||
ranges = [validate_and_convert_to_range(range) for range in interval_text.split(',')]
|
||||
if len(ranges) == 0:
|
||||
raise Exception('Please add a range.')
|
||||
elif len(ranges) > 1:
|
||||
ranges.sort()
|
||||
for i in range(len(ranges) - 1):
|
||||
if ranges[i][1] > ranges[i + 1][0]:
|
||||
raise Exception('Please make sure that the ranges don\'t overlap.')
|
||||
return ranges
|
||||
|
||||
def to_pixel_format(contour):
|
||||
"""OpenCV contours have a weird format. We are converting them to (row, col)"""
|
||||
return [(pixel[0][1], pixel[0][0]) for pixel in contour]
|
||||
|
||||
def map_pixels_to_coordinates(reference_tiff, dst_epsg, pixels):
|
||||
"""We are assuming that the pixels are a list of tuples. For example: [(row1, col1), (row2, col2)]"""
|
||||
rows = [row for (row, _) in pixels]
|
||||
cols = [col for (_, col) in pixels]
|
||||
xs, ys = transform.xy(reference_tiff.transform, rows, cols)
|
||||
dst_crs = rio.crs.CRS.from_epsg(dst_epsg)
|
||||
return map_to_new_crs(reference_tiff.crs, dst_crs, xs, ys)
|
||||
|
||||
def map_to_new_crs(src_crs, target_crs, xs, ys):
|
||||
"""Map the given arrays from one crs to the other"""
|
||||
transformed = warp.transform(src_crs, target_crs, xs, ys)
|
||||
return [(x, y) for x, y in zip(transformed[0], transformed[1])]
|
||||
|
||||
def is_number(text):
|
||||
try:
|
||||
float(text)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def validate_and_convert_to_range(range):
|
||||
"""Validate the given range and return a tuple (start, end) if it is valid"""
|
||||
range = range.strip().split('-')
|
||||
if len(range) != 2:
|
||||
raise Exception('Ranges must have a beggining and an end.')
|
||||
if not is_number(range[0]) or not is_number(range[1]):
|
||||
raise Exception('Please make sure that both the beggining and end of the range are numeric.')
|
||||
range = (float(range[0]), float(range[1]))
|
||||
if (range[0] >= range[1]):
|
||||
raise Exception('The end of the range must be greater than the beggining.')
|
||||
return range
|
||||
|
||||
class LevelBuilder:
|
||||
def __init__(self, bottom, top, contours, hierarchy):
|
||||
self.bottom = bottom
|
||||
self.top = top
|
||||
self.contours = contours
|
||||
self.hierarchy = hierarchy
|
||||
|
||||
def build_polygon(self, idx):
|
||||
polygon_contours = [self.contours[idx]]
|
||||
[_, _, child, _] = self.hierarchy[idx]
|
||||
while child >= 0:
|
||||
polygon_contours.append(self.contours[child])
|
||||
next, _, _, _ = self.hierarchy[child]
|
||||
child = next
|
||||
return polygon_contours
|
||||
|
||||
def build_multi_polygon(self):
|
||||
polygons = []
|
||||
idx = 0
|
||||
while idx >= 0:
|
||||
polygons.append(self.build_polygon(idx))
|
||||
[next, _, _, _] = self.hierarchy[idx]
|
||||
idx = next
|
||||
multi_polygon = MultiPolygon(polygons)
|
||||
return Feature(geometry = multi_polygon, properties = { 'bottom': int(self.bottom), 'top': int(self.top) })
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
opts, _ = grass.parser()
|
||||
sys.exit(main())
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "ElevationMap",
|
||||
"webodmMinVersion": "1.1.1",
|
||||
"description": "Calculate and draw an elevation map based on a task's DEMs",
|
||||
"version": "1.0.0",
|
||||
"author": "Nicolas Chamo",
|
||||
"email": "nicolas@chamo.com.ar",
|
||||
"repository": "https://github.com/OpenDroneMap/WebODM",
|
||||
"tags": ["contours", "elevationmap", "dsm", "dem", "dtm"],
|
||||
"homepage": "https://github.com/OpenDroneMap/WebODM",
|
||||
"experimental": false,
|
||||
"deprecated": false
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
from app.plugins import PluginBase
|
||||
from app.plugins import MountPoint
|
||||
from .api import TaskElevationMapGenerate
|
||||
from .api import TaskElevationMapCheck
|
||||
from .api import TaskElevationMapDownload
|
||||
|
||||
class Plugin(PluginBase):
|
||||
def include_js_files(self):
|
||||
return ['main.js']
|
||||
|
||||
def build_jsx_components(self):
|
||||
return ['ElevationMap.jsx']
|
||||
|
||||
def api_mount_points(self):
|
||||
return [
|
||||
MountPoint('task/(?P<pk>[^/.]+)/elevationmap/generate', TaskElevationMapGenerate.as_view()),
|
||||
MountPoint('task/(?P<pk>[^/.]+)/elevationmap/check/(?P<celery_task_id>.+)', TaskElevationMapCheck.as_view()),
|
||||
MountPoint('task/(?P<pk>[^/.]+)/elevationmap/download/(?P<celery_task_id>.+)', TaskElevationMapDownload.as_view()),
|
||||
]
|
|
@ -1,56 +0,0 @@
|
|||
import L from 'leaflet';
|
||||
import ReactDOM from 'ReactDOM';
|
||||
import React from 'React';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ElevationMap.scss';
|
||||
import ElevationMapPanel from './ElevationMapPanel';
|
||||
|
||||
class ElevationMapButton extends React.Component {
|
||||
static propTypes = {
|
||||
tasks: PropTypes.object.isRequired,
|
||||
map: PropTypes.object.isRequired,
|
||||
layersControl: 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-elevationmap-button leaflet-bar-part theme-secondary"></a>
|
||||
<ElevationMapPanel map={this.props.map} layersControl={this.props.layersControl} 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-elevationmap leaflet-bar leaflet-control');
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
ReactDOM.render(<ElevationMapButton map={this.options.map} layersControl={this.options.layersControl} tasks={this.options.tasks} />, container);
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
.leaflet-control-elevationmap{
|
||||
z-index: 999;
|
||||
|
||||
a.leaflet-control-elevationmap-button{
|
||||
background: url(icon.png) no-repeat 0 0;
|
||||
background-size: 26px 26px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
div.elevationmap-panel{ display: none; }
|
||||
|
||||
.open{
|
||||
a.leaflet-control-elevationmap-button{
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.elevationmap-panel{
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
.leaflet-touch .leaflet-control-elevationmap a {
|
||||
background-position: 2px 2px;
|
||||
}
|
|
@ -1,431 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Storage from 'webodm/classes/Storage';
|
||||
import L from 'leaflet';
|
||||
import area from '@turf/area'
|
||||
import './ElevationMapPanel.scss';
|
||||
import ErrorMessage from 'webodm/components/ErrorMessage';
|
||||
import ReactTooltip from 'react-tooltip'
|
||||
|
||||
export default class ElevationMapPanel extends React.Component {
|
||||
static defaultProps = {
|
||||
};
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
tasks: PropTypes.object.isRequired,
|
||||
isShowed: PropTypes.bool.isRequired,
|
||||
map: PropTypes.object.isRequired,
|
||||
layersControl: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: "",
|
||||
permanentError: "",
|
||||
interval: Storage.getItem("last_elevationmap_interval") || "5",
|
||||
reference: "Sea",
|
||||
noiseFilterSize: Storage.getItem("last_elevationmap_noise_filter_size") || "3",
|
||||
customNoiseFilterSize: Storage.getItem("last_elevationmap_custom_noise_filter_size") || "3",
|
||||
epsg: Storage.getItem("last_elevationmap_epsg") || "4326",
|
||||
customEpsg: Storage.getItem("last_elevationmap_custom_epsg") || "4326",
|
||||
references: [],
|
||||
loading: true,
|
||||
task: props.tasks[0] || null,
|
||||
previewLoading: false,
|
||||
exportLoading: false,
|
||||
previewLayer: null,
|
||||
opacity: 100,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
let references = ['Sea'];
|
||||
|
||||
if (available_assets.indexOf("dsm.tif") === -1)
|
||||
this.setState({permanentError: "No DSM is available. Make sure to process a task with either the --dsm option checked"});
|
||||
if (available_assets.indexOf("dtm.tif") !== -1)
|
||||
references.push("Ground");
|
||||
this.setState({references, reference: references[0]});
|
||||
})
|
||||
.fail(() => {
|
||||
this.setState({permanentError: `Cannot retrieve information for task ${id}. 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.generateReq){
|
||||
this.generateReq.abort();
|
||||
this.generateReq = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectInterval = e => {
|
||||
this.setState({interval: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectNoiseFilterSize = e => {
|
||||
this.setState({noiseFilterSize: e.target.value});
|
||||
}
|
||||
|
||||
handleChangeCustomNoiseFilterSize = e => {
|
||||
this.setState({customNoiseFilterSize: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectReference = e => {
|
||||
this.setState({reference: e.target.value});
|
||||
}
|
||||
|
||||
handleChangeCustomInterval = e => {
|
||||
this.setState({customInterval: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectEpsg = e => {
|
||||
this.setState({epsg: e.target.value});
|
||||
}
|
||||
|
||||
handleChangeCustomEpsg = e => {
|
||||
this.setState({customEpsg: e.target.value});
|
||||
}
|
||||
|
||||
getFormValues = () => {
|
||||
const { interval, customInterval, epsg, customEpsg,
|
||||
noiseFilterSize, customNoiseFilterSize, reference } = this.state;
|
||||
return {
|
||||
interval: interval !== "custom" ? interval : customInterval,
|
||||
epsg: epsg !== "custom" ? epsg : customEpsg,
|
||||
noise_filter_size: noiseFilterSize !== "custom" ? noiseFilterSize : customNoiseFilterSize,
|
||||
reference
|
||||
};
|
||||
}
|
||||
|
||||
waitForCompletion = (taskId, celery_task_id, cb) => {
|
||||
let errorCount = 0;
|
||||
|
||||
const check = () => {
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: `/api/plugins/elevationmap/task/${taskId}/elevationmap/check/${celery_task_id}`
|
||||
}).done(result => {
|
||||
if (result.error){
|
||||
cb(result.error);
|
||||
}else if (result.ready){
|
||||
cb();
|
||||
}else{
|
||||
// Retry
|
||||
setTimeout(() => check(), 2000);
|
||||
}
|
||||
}).fail(error => {
|
||||
console.warn(error);
|
||||
if (errorCount++ < 10) setTimeout(() => check(), 2000);
|
||||
else cb(JSON.stringify(error));
|
||||
});
|
||||
};
|
||||
|
||||
check();
|
||||
}
|
||||
|
||||
heatmap_coloring = (value, lowest, highest) => {
|
||||
const ratio = (value - lowest) / (highest - lowest);
|
||||
const h = 315 * (1 - ratio) / 360;
|
||||
const s = 1;
|
||||
const l = 0.5;
|
||||
let r, g, b;
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
const toHex = x => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
addGeoJSONFromURL = (url, cb) => {
|
||||
const { map, layersControl } = this.props;
|
||||
|
||||
$.getJSON(url)
|
||||
.done((geojson) => {
|
||||
try{
|
||||
this.removePreview();
|
||||
|
||||
// Calculating all the elevation levels present
|
||||
const allLevels = geojson.features.map(feature => [feature.properties.bottom, feature.properties.top]).flat().sort((a, b) => a - b);
|
||||
const lowestLevel = allLevels[0];
|
||||
const highestLevel = allLevels[allLevels.length - 1];
|
||||
|
||||
let featureGroup = L.featureGroup();
|
||||
geojson.features.forEach(levelFeature => {
|
||||
const top = levelFeature.properties.top;
|
||||
const bottom = levelFeature.properties.bottom;
|
||||
const rgbHex = this.heatmap_coloring((bottom + top) / 2, lowestLevel, highestLevel);
|
||||
const areaInLevel = area(levelFeature).toFixed(2);
|
||||
let geojsonForLevel = L.geoJSON(levelFeature).setStyle({color: rgbHex, fill: true, fillColor: rgbHex, fillOpacity: 1})
|
||||
.bindPopup(`Altitude: Between ${bottom}m and ${top}m<BR>Area: ${areaInLevel}m2`)
|
||||
.on('popupopen', popup => {
|
||||
// Make all other layers transparent and highlight the clicked one
|
||||
featureGroup.getLayers().forEach(layer => layer.setStyle({ fillOpacity: 0.4 * this.state.opacity}));
|
||||
popup.propagatedFrom.setStyle({ color: "black", fillOpacity: this.state.opacity }).bringToFront()
|
||||
})
|
||||
.on('popupclose', popup => {
|
||||
// Reset all layers to their original state
|
||||
featureGroup.getLayers().forEach(layer => layer.bringToFront().setStyle({ fillOpacity: this.state.opacity }));
|
||||
popup.propagatedFrom.setStyle({ color: rgbHex });
|
||||
});
|
||||
featureGroup.addLayer(geojsonForLevel);
|
||||
});
|
||||
|
||||
featureGroup.geojson = geojson;
|
||||
|
||||
this.setState({ previewLayer: featureGroup });
|
||||
this.state.previewLayer.addTo(map);
|
||||
layersControl.addOverlay(this.state.previewLayer, "Elevation Map");
|
||||
|
||||
cb();
|
||||
}catch(e){
|
||||
cb(e.message);
|
||||
}
|
||||
})
|
||||
.fail(cb);
|
||||
}
|
||||
|
||||
removePreview = () => {
|
||||
const { map, layersControl } = this.props;
|
||||
|
||||
if (this.state.previewLayer){
|
||||
map.removeLayer(this.state.previewLayer);
|
||||
layersControl.removeLayer(this.state.previewLayer);
|
||||
this.setState({previewLayer: null});
|
||||
}
|
||||
}
|
||||
|
||||
generateElevationMap = (data, loadingProp, isPreview) => {
|
||||
this.setState({[loadingProp]: true, error: ""});
|
||||
const taskId = this.state.task.id;
|
||||
|
||||
// Save settings for next time
|
||||
Storage.setItem("last_elevationmap_interval", this.state.interval);
|
||||
Storage.setItem("last_elevationmap_custom_interval", this.state.customInterval);
|
||||
Storage.setItem("last_elevationmap_noise_filter_size", this.state.noiseFilterSize);
|
||||
Storage.setItem("last_elevationmap_custom_noise_filter_size", this.state.customNoiseFilterSize);
|
||||
Storage.setItem("last_elevationmap_epsg", this.state.epsg);
|
||||
Storage.setItem("last_elevationmap_custom_epsg", this.state.customEpsg);
|
||||
|
||||
this.generateReq = $.ajax({
|
||||
type: 'POST',
|
||||
url: `/api/plugins/elevationmap/task/${taskId}/elevationmap/generate`,
|
||||
data: data
|
||||
}).done(result => {
|
||||
if (result.celery_task_id){
|
||||
this.waitForCompletion(taskId, result.celery_task_id, error => {
|
||||
if (error) this.setState({[loadingProp]: false, 'error': error});
|
||||
else{
|
||||
const fileUrl = `/api/plugins/elevationmap/task/${taskId}/elevationmap/download/${result.celery_task_id}`;
|
||||
|
||||
// Preview
|
||||
if (isPreview){
|
||||
this.addGeoJSONFromURL(fileUrl, e => {
|
||||
if (e) this.setState({error: JSON.stringify(e)});
|
||||
this.setState({[loadingProp]: false});
|
||||
});
|
||||
}else{
|
||||
// Download
|
||||
location.href = fileUrl;
|
||||
this.setState({[loadingProp]: false});
|
||||
}
|
||||
}
|
||||
});
|
||||
}else if (result.error){
|
||||
this.setState({[loadingProp]: false, error: result.error});
|
||||
}else{
|
||||
this.setState({[loadingProp]: false, error: "Invalid response: " + result});
|
||||
}
|
||||
}).fail(error => {
|
||||
this.setState({[loadingProp]: false, error: JSON.stringify(error)});
|
||||
});
|
||||
}
|
||||
|
||||
handleExport = (format) => {
|
||||
return () => {
|
||||
const data = this.getFormValues();
|
||||
data.format = format;
|
||||
this.generateElevationMap(data, 'exportLoading', false);
|
||||
};
|
||||
}
|
||||
|
||||
handleShowPreview = () => {
|
||||
this.setState({previewLoading: true});
|
||||
|
||||
const data = this.getFormValues();
|
||||
data.epsg = 4326;
|
||||
data.format = "GeoJSON";
|
||||
this.generateElevationMap(data, 'previewLoading', true);
|
||||
}
|
||||
|
||||
handleChangeOpacity = (evt) => {
|
||||
const opacity = parseFloat(evt.target.value) / 100;
|
||||
this.setState({opacity: opacity});
|
||||
this.state.previewLayer.setStyle({ opacity: opacity, fillOpacity: opacity });
|
||||
this.props.map.closePopup();
|
||||
}
|
||||
|
||||
render(){
|
||||
const { loading, task, references, error, permanentError, interval, reference,
|
||||
epsg, customEpsg, exportLoading,
|
||||
noiseFilterSize, customNoiseFilterSize,
|
||||
previewLoading, previewLayer, opacity} = this.state;
|
||||
const noiseFilterSizeValues = [{label: 'Do not filter noise', value: 0},
|
||||
{label: 'Normal', value: 3},
|
||||
{label: 'Aggressive', value: 5}];
|
||||
|
||||
const disabled = (epsg === "custom" && !customEpsg) ||
|
||||
(noiseFilterSize === "custom" && !customNoiseFilterSize);
|
||||
|
||||
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{
|
||||
content = (<div>
|
||||
<ErrorMessage bind={[this, "error"]} />
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Interval:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="text" className="form-control" value={interval} onChange={this.handleSelectInterval} /><span></span>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You have two options:<br/>• Insert your custom elevation intervals, in the form: 10-15,20-30. <br/>• Insert a number (for example 5) and the intervals will be auto generated every 5 meters based on the elevation data." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Reference:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={reference} onChange={this.handleSelectReference}>
|
||||
{references.map(r => <option value={r}>{r}</option>)}
|
||||
</select>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You can determine if the intervals specified above will be based on the sea level, or on the ground.<br/>Take into account that in order to be able to select 'ground' you need to have run the task with the --dtm option." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Noise Filter:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={noiseFilterSize} onChange={this.handleSelectNoiseFilterSize}>
|
||||
{noiseFilterSizeValues.map(sv => <option value={sv.value}>{sv.label} ({sv.value} meter)</option>)}
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You can determine the diameter of the area used to filter noise." />
|
||||
</div>
|
||||
</div>
|
||||
{noiseFilterSize === "custom" ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Value:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={customNoiseFilterSize} onChange={this.handleChangeCustomNoiseFilterSize} /><span> meter</span>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Projection:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={epsg} onChange={this.handleSelectEpsg}>
|
||||
<option value="4326">WGS84 (EPSG:4326)</option>
|
||||
<option value="3857">Web Mercator (EPSG:3857)</option>
|
||||
<option value="custom">Custom EPSG</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{epsg === "custom" ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">EPSG:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={customEpsg} onChange={this.handleChangeCustomEpsg} />
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
{previewLayer ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Opacity:</label>
|
||||
<div className="col-sm-9">
|
||||
<input type="range" className="slider" step="1" value={opacity * 100} onChange={this.handleChangeOpacity} />
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="Control the opacity of the elevation map. You must generate a preview to be able to control the opacity." />
|
||||
<ReactTooltip place="left" effect="solid" html={true}/>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
<div className="row action-buttons">
|
||||
<div className="col-sm-9 text-right">
|
||||
<button onClick={this.handleShowPreview}
|
||||
disabled={disabled || previewLoading} type="button" className="btn btn-sm btn-primary btn-preview">
|
||||
{previewLoading ? <i className="fa fa-spin fa-circle-notch"/> : <i className="glyphicon glyphicon-eye-open"/>} Preview
|
||||
</button>
|
||||
|
||||
<div className="btn-group">
|
||||
<button disabled={disabled || exportLoading} type="button" className="btn btn-sm btn-primary" data-toggle="dropdown">
|
||||
{exportLoading ? <i className="fa fa-spin fa-circle-notch"/> : <i className="glyphicon glyphicon-download" />} Export
|
||||
</button>
|
||||
<button disabled={disabled|| exportLoading} type="button" className="btn btn-sm dropdown-toggle btn-primary" data-toggle="dropdown"><span className="caret"></span></button>
|
||||
<ul className="dropdown-menu pull-right">
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("GPKG")}>
|
||||
<i className="fa fa-globe fa-fw"></i> GeoPackage (.GPKG)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("DXF")}>
|
||||
<i className="fa fa-file fa-fw"></i> AutoCAD (.DXF)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("GeoJSON")}>
|
||||
<i className="fa fa-code fa-fw"></i> GeoJSON (.JSON)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("ESRI Shapefile")}>
|
||||
<i className="fa fa-file-archive fa-fw"></i> ShapeFile (.SHP)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReactTooltip place="left" effect="solid" html={true}/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (<div className="elevationmap-panel">
|
||||
<span className="close-button" onClick={this.props.onClose}/>
|
||||
<div className="title">Elevation Map</div>
|
||||
<hr/>
|
||||
{content}
|
||||
</div>);
|
||||
}
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
.leaflet-control-elevationmap .elevationmap-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;
|
||||
}
|
||||
|
||||
input.custom-interval{
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
*{
|
||||
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-preview{
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.action-buttons{
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.help {
|
||||
margin-left: 4px;
|
||||
top: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
padding: 0px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
Plik binarny nie jest wyświetlany.
Przed Szerokość: | Wysokość: | Rozmiar: 4.9 KiB |
|
@ -1,14 +0,0 @@
|
|||
PluginsAPI.Map.didAddControls([
|
||||
'elevationmap/build/ElevationMap.js',
|
||||
'elevationmap/build/ElevationMap.css'
|
||||
], function(args, ElevationMap){
|
||||
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 ElevationMap({map: args.map, layersControl: args.controls.autolayers, tasks: tasks}));
|
||||
}
|
||||
});
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"name": "elevationmap",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@turf/turf": "^5.1.6",
|
||||
"react-tooltip": "^3.10.0"
|
||||
}
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
geojson==2.4.1
|
||||
opencv-python-headless==4.4.0.46
|
|
@ -1,24 +1,18 @@
|
|||
import os
|
||||
import json
|
||||
import math
|
||||
from rest_framework import serializers
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
import rasterio
|
||||
|
||||
from app.api.workers import GetTaskResult, TaskResultOutputError, CheckTask
|
||||
from app.models import Task
|
||||
from app.plugins.views import TaskView
|
||||
|
||||
from worker.tasks import execute_grass_script
|
||||
|
||||
from app.plugins.grass_engine import grass, GrassEngineException, cleanup_grass_context
|
||||
from geojson import Feature, Point, FeatureCollection
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from app.plugins.worker import run_function_async
|
||||
|
||||
class GeoJSONSerializer(serializers.Serializer):
|
||||
area = serializers.JSONField(help_text="Polygon contour defining the volume area to compute")
|
||||
from .volume import calc_volume
|
||||
|
||||
class VolumeRequestSerializer(serializers.Serializer):
|
||||
area = serializers.JSONField(help_text="GeoJSON Polygon contour defining the volume area to compute")
|
||||
method = serializers.CharField(help_text="One of: [plane,triangulate,average,custom,highest,lowest]", default="triangulate", allow_blank=True)
|
||||
|
||||
class TaskVolume(TaskView):
|
||||
def post(self, request, pk=None):
|
||||
|
@ -26,56 +20,26 @@ class TaskVolume(TaskView):
|
|||
if task.dsm_extent is None:
|
||||
return Response({'error': _('No surface model available. From the Dashboard, select this task, press Edit, from the options make sure to check "dsm", then press Restart --> From DEM.')})
|
||||
|
||||
serializer = GeoJSONSerializer(data=request.data)
|
||||
serializer = VolumeRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
area = serializer['area'].value
|
||||
points = FeatureCollection([Feature(geometry=Point(coords)) for coords in area['geometry']['coordinates'][0]])
|
||||
method = serializer['method'].value
|
||||
points = [coord for coord in area['geometry']['coordinates'][0]]
|
||||
dsm = os.path.abspath(task.get_asset_download_path("dsm.tif"))
|
||||
|
||||
try:
|
||||
context = grass.create_context({'auto_cleanup': False})
|
||||
context.add_file('area_file.geojson', json.dumps(area))
|
||||
context.add_file('points_file.geojson', str(points))
|
||||
context.add_param('dsm_file', dsm)
|
||||
context.set_location(dsm)
|
||||
|
||||
celery_task_id = execute_grass_script.delay(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"calc_volume.py"
|
||||
), context.serialize()).task_id
|
||||
|
||||
celery_task_id = run_function_async(calc_volume, input_dem=dsm, pts=points, pts_epsg=4326, base_method=method).task_id
|
||||
return Response({'celery_task_id': celery_task_id}, status=status.HTTP_200_OK)
|
||||
except GrassEngineException as e:
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_200_OK)
|
||||
|
||||
class TaskVolumeCheck(CheckTask):
|
||||
def on_error(self, result):
|
||||
cleanup_grass_context(result['context'])
|
||||
pass
|
||||
|
||||
class TaskVolumeResult(GetTaskResult):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
task = Task.objects.only('dsm_extent').get(pk=pk)
|
||||
return super().get(request, celery_task_id, task=task)
|
||||
|
||||
def handle_output(self, output, result, task):
|
||||
cleanup_grass_context(result['context'])
|
||||
|
||||
cols = output.split(':')
|
||||
if len(cols) == 7:
|
||||
# Legacy: we had rasters in EPSG:3857 for a while
|
||||
# This could be removed at some point in the future
|
||||
# Correct scale measurement for web mercator
|
||||
# https://gis.stackexchange.com/questions/93332/calculating-distance-scale-factor-by-latitude-for-mercator#93335
|
||||
scale_factor = 1.0
|
||||
dsm = os.path.abspath(task.get_asset_download_path("dsm.tif"))
|
||||
with rasterio.open(dsm) as dst:
|
||||
if str(dst.crs) == 'EPSG:3857':
|
||||
latitude = task.dsm_extent.centroid[1]
|
||||
scale_factor = math.cos(math.radians(latitude)) ** 2
|
||||
|
||||
volume = abs(float(cols[6]) * scale_factor)
|
||||
return str(volume)
|
||||
else:
|
||||
raise TaskResultOutputError(output)
|
||||
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
#%module
|
||||
#% description: Calculate volume of area and prints the volume to stdout
|
||||
#%end
|
||||
#%option
|
||||
#% key: area_file
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Geospatial file containing the area to measure
|
||||
#%end
|
||||
#%option
|
||||
#% key: points_file
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Geospatial file containing the points defining the area
|
||||
#%end
|
||||
#%option
|
||||
#% key: dsm_file
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: GeoTIFF DEM containing the surface
|
||||
#%end
|
||||
|
||||
import sys
|
||||
from grass.pygrass.modules import Module
|
||||
import grass.script as grass
|
||||
|
||||
def main():
|
||||
# Import raster and vector
|
||||
Module("v.import", input=opts['area_file'], output="polygon_area", overwrite=True)
|
||||
Module("v.import", input=opts['points_file'], output="polygon_points", overwrite=True)
|
||||
Module("v.buffer", input="polygon_area", s=True, type="area", output="region", distance=1, minordistance=1, overwrite=True)
|
||||
Module("r.external", input=opts['dsm_file'], output="dsm", overwrite=True)
|
||||
|
||||
# Set Grass region and resolution to DSM
|
||||
Module("g.region", raster="dsm")
|
||||
|
||||
# Set Grass region to vector bbox
|
||||
Module("g.region", vector="region")
|
||||
|
||||
# Create a mask to speed up computation
|
||||
Module("r.mask", vector="region")
|
||||
|
||||
# Transfer dsm raster data to vector
|
||||
Module("v.what.rast", map="polygon_points", raster="dsm", column="height")
|
||||
|
||||
# Decimate DSM and generate interpolation of new terrain
|
||||
Module("v.surf.rst", input="polygon_points", zcolumn="height", elevation="dsm_below_pile", smooth=0, overwrite=True)
|
||||
|
||||
# Compute difference between dsm and new dsm
|
||||
Module("r.mapcalc", expression='pile_height_above_dsm=dsm-dsm_below_pile', overwrite=True)
|
||||
|
||||
# Update region and mask to polygon area to calculate volume
|
||||
Module("g.region", vector="polygon_area")
|
||||
Module("r.mask", vector="polygon_area", overwrite=True)
|
||||
|
||||
# Volume output from difference
|
||||
Module("r.volume", input="pile_height_above_dsm", f=True)
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
opts, _ = grass.parser()
|
||||
sys.exit(main())
|
|
@ -24,7 +24,9 @@ export default class MeasurePopup extends React.Component {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
volume: null, // to be calculated
|
||||
volume: null, // to be calculated,
|
||||
baseMethod: localStorage.getItem("measure_base_method") || "triangulate",
|
||||
task: null,
|
||||
error: ""
|
||||
};
|
||||
|
||||
|
@ -49,6 +51,7 @@ export default class MeasurePopup extends React.Component {
|
|||
};
|
||||
if (this.state.volume !== null && this.state.volume !== false){
|
||||
result.Volume = this.state.volume;
|
||||
result.BaseSurface = this.state.baseMethod;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
@ -69,6 +72,14 @@ export default class MeasurePopup extends React.Component {
|
|||
Utils.saveAs(JSON.stringify(geoJSON, null, 4), "measurement.geojson")
|
||||
}
|
||||
|
||||
handleBaseMethodChange = (e) => {
|
||||
this.setState({baseMethod: e.target.value});
|
||||
localStorage.setItem("measure_base_method", e.target.value);
|
||||
setTimeout(() => {
|
||||
this.recalculateVolume();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
calculateVolume(){
|
||||
const { lastCoord } = this.props.model;
|
||||
let layers = this.getLayersAtCoords(L.latLng(
|
||||
|
@ -81,32 +92,10 @@ export default class MeasurePopup extends React.Component {
|
|||
const layer = layers[layers.length - 1];
|
||||
const meta = layer[Symbol.for("meta")];
|
||||
if (meta){
|
||||
const task = meta.task;
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `/api/plugins/measure/task/${task.id}/volume`,
|
||||
data: JSON.stringify({'area': this.props.resultFeature.toGeoJSON()}),
|
||||
contentType: "application/json"
|
||||
}).done(result => {
|
||||
if (result.celery_task_id){
|
||||
Workers.waitForCompletion(result.celery_task_id, error => {
|
||||
if (error) this.setState({error});
|
||||
else{
|
||||
Workers.getOutput(result.celery_task_id, (error, volume) => {
|
||||
if (error) this.setState({error});
|
||||
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{
|
||||
this.setState({error: interpolate(_("Invalid response: %(error)s"), { error: result})});
|
||||
}
|
||||
}).fail(error => {
|
||||
this.setState({error});
|
||||
});
|
||||
this.setState({task: meta.task});
|
||||
setTimeout(() => {
|
||||
this.recalculateVolume();
|
||||
}, 0);
|
||||
}else{
|
||||
console.warn("Cannot find [meta] symbol for layer: ", layer);
|
||||
this.setState({volume: false});
|
||||
|
@ -116,6 +105,41 @@ export default class MeasurePopup extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
recalculateVolume = () => {
|
||||
const { task, baseMethod } = this.state;
|
||||
if (!task) return;
|
||||
|
||||
this.setState({volume: null, error: ""});
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `/api/plugins/measure/task/${task.id}/volume`,
|
||||
data: JSON.stringify({
|
||||
area: this.props.resultFeature.toGeoJSON(),
|
||||
method: baseMethod
|
||||
}),
|
||||
contentType: "application/json"
|
||||
}).done(result => {
|
||||
if (result.celery_task_id){
|
||||
Workers.waitForCompletion(result.celery_task_id, error => {
|
||||
if (error) this.setState({error});
|
||||
else{
|
||||
Workers.getOutput(result.celery_task_id, (error, volume) => {
|
||||
if (error) this.setState({error});
|
||||
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{
|
||||
this.setState({error: interpolate(_("Invalid response: %(error)s"), { error: result})});
|
||||
}
|
||||
}).fail(error => {
|
||||
this.setState({error});
|
||||
});
|
||||
}
|
||||
|
||||
// @return the layers in the map
|
||||
// at a specific lat/lon
|
||||
getLayersAtCoords(latlng){
|
||||
|
@ -137,12 +161,29 @@ export default class MeasurePopup extends React.Component {
|
|||
|
||||
render(){
|
||||
const { volume, error } = this.state;
|
||||
const baseMethods = [
|
||||
{label: _("Triangulate"), method: 'triangulate'},
|
||||
{label: _("Plane"), method: 'plane'},
|
||||
{label: _("Average"), method: 'average'},
|
||||
{label: _("Highest"), method: 'highest'},
|
||||
{label: _("Lowest"), method: 'lowest'}];
|
||||
|
||||
return (<div className="plugin-measure popup">
|
||||
<p>{_("Area:")} {this.props.model.areaDisplay}</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>}
|
||||
{typeof volume === "number" && <p>{_("Volume:")} {volume.toFixed("2")} {_("Cubic Meters")} ({(volume * 35.3147).toFixed(2)} {_("Cubic Feet")})</p>}
|
||||
{typeof volume === "number" ?
|
||||
[
|
||||
<p>{_("Volume:")} {volume.toFixed("2")} {_("Cubic Meters")} ({(volume * 35.3147).toFixed(2)} {_("Cubic Feet")})</p>,
|
||||
<p className="base-control">{_("Base surface:")}
|
||||
<select className="form-control" value={this.state.baseMethod} onChange={this.handleBaseMethodChange}>
|
||||
{baseMethods.map(bm =>
|
||||
<option key={bm.method}
|
||||
value={bm.method}>{bm.label}</option>)}
|
||||
</select>
|
||||
</p>
|
||||
]
|
||||
: ""}
|
||||
{error && <p>{_("Volume:")} <span className={"error theme-background-failed " + (error.length > 200 ? 'long' : '')}>{error}</span></p>}
|
||||
<a href="#" onClick={this.exportMeasurement} className="export-measurements"><i className="fa fa-download"></i> {_("Export to GeoJSON")}</a>
|
||||
</div>);
|
||||
|
|
|
@ -13,4 +13,17 @@
|
|||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.base-control{
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
select{
|
||||
font-size: 12px;
|
||||
height: auto;
|
||||
margin-left: 4px;
|
||||
padding: 4px;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
def calc_volume(input_dem, pts=None, pts_epsg=None, geojson_polygon=None, decimals=4,
|
||||
base_method="triangulate", custom_base_z=None):
|
||||
try:
|
||||
import os
|
||||
import rasterio
|
||||
import rasterio.mask
|
||||
from osgeo import osr
|
||||
from scipy.optimize import curve_fit
|
||||
from scipy.interpolate import griddata
|
||||
import numpy as np
|
||||
import json
|
||||
import warnings
|
||||
|
||||
osr.UseExceptions()
|
||||
warnings.filterwarnings("ignore", module='scipy.optimize')
|
||||
|
||||
if not os.path.isfile(input_dem):
|
||||
raise IOError(f"{input_dem} does not exist")
|
||||
|
||||
crs = None
|
||||
with rasterio.open(input_dem) as d:
|
||||
if d.crs is None:
|
||||
raise ValueError(f"{input_dem} does not have a CRS")
|
||||
crs = osr.SpatialReference()
|
||||
crs.ImportFromEPSG(d.crs.to_epsg())
|
||||
|
||||
if pts is None and pts_epsg is None and geojson_polygon is not None:
|
||||
# Read GeoJSON points
|
||||
pts = read_polygon(geojson_polygon)
|
||||
return calc_volume(input_dem, pts=pts, pts_epsg=4326, decimals=decimals, base_method=base_method, custom_base_z=custom_base_z)
|
||||
|
||||
# Convert to DEM crs
|
||||
src_crs = osr.SpatialReference()
|
||||
src_crs.ImportFromEPSG(pts_epsg)
|
||||
transformer = osr.CoordinateTransformation(src_crs, crs)
|
||||
|
||||
dem_pts = [list(transformer.TransformPoint(p[1], p[0]))[:2] for p in pts]
|
||||
|
||||
# Some checks
|
||||
if len(dem_pts) < 2:
|
||||
raise ValueError("Insufficient points to form a polygon")
|
||||
|
||||
# Close loop if needed
|
||||
if not np.array_equal(dem_pts[0], dem_pts[-1]):
|
||||
dem_pts.append(dem_pts[0])
|
||||
|
||||
polygon = {"coordinates": [dem_pts], "type": "Polygon"}
|
||||
dem_pts = np.array(dem_pts)
|
||||
|
||||
# Remove last point (loop close)
|
||||
dem_pts = dem_pts[:-1]
|
||||
|
||||
with rasterio.open(input_dem) as d:
|
||||
px_w = d.transform[0]
|
||||
px_h = d.transform[4]
|
||||
|
||||
# Area of a pixel in square units
|
||||
px_area = abs(px_w * px_h)
|
||||
|
||||
rast_dem, transform = rasterio.mask.mask(d, [polygon], crop=True, all_touched=True, indexes=1, nodata=np.nan)
|
||||
h, w = rast_dem.shape
|
||||
|
||||
# X/Y coordinates in transform coordinates
|
||||
ys, xs = np.array(rasterio.transform.rowcol(transform, dem_pts[:,0], dem_pts[:,1]))
|
||||
|
||||
if np.any(xs<0) or np.any(xs>=w) or np.any(ys<0) or np.any(ys>=h):
|
||||
raise ValueError("Points are out of bounds")
|
||||
|
||||
zs = rast_dem[ys,xs]
|
||||
|
||||
if base_method == "plane":
|
||||
# Create a grid for interpolation
|
||||
x_grid, y_grid = np.meshgrid(np.linspace(0, w - 1, w), np.linspace(0, h - 1, h))
|
||||
|
||||
# Perform curve fitting
|
||||
linear_func = lambda xy, m1, m2, b: m1 * xy[0] + m2 * xy[1] + b
|
||||
params, covariance = curve_fit(linear_func, np.vstack((xs, ys)), zs)
|
||||
|
||||
base = linear_func((x_grid, y_grid), *params)
|
||||
elif base_method == "triangulate":
|
||||
# Create a grid for interpolation
|
||||
x_grid, y_grid = np.meshgrid(np.linspace(0, w - 1, w), np.linspace(0, h - 1, h))
|
||||
|
||||
# Tessellate the input point set to N-D simplices, and interpolate linearly on each simplex.
|
||||
base = griddata(np.column_stack((xs, ys)), zs, (x_grid, y_grid), method='linear')
|
||||
elif base_method == "average":
|
||||
base = np.full((h, w), np.mean(zs))
|
||||
elif base_method == "custom":
|
||||
if custom_base_z is None:
|
||||
raise ValueError("Base method set to custom, but no custom base Z specified")
|
||||
base = np.full((h, w), float(custom_base_z))
|
||||
elif base_method == "highest":
|
||||
base = np.full((h, w), np.max(zs))
|
||||
elif base_method == "lowest":
|
||||
base = np.full((h, w), np.min(zs))
|
||||
else:
|
||||
raise ValueError(f"Invalid base method {base_method}")
|
||||
|
||||
base[np.isnan(rast_dem)] = np.nan
|
||||
|
||||
# Calculate volume
|
||||
diff = rast_dem - base
|
||||
volume = np.nansum(diff) * px_area
|
||||
|
||||
# import matplotlib.pyplot as plt
|
||||
# fig, ax = plt.subplots()
|
||||
# ax.imshow(base)
|
||||
# plt.scatter(xs, ys, c=zs, cmap='viridis', s=50, edgecolors='k')
|
||||
# plt.colorbar(label='Z values')
|
||||
# plt.title('Debug')
|
||||
# plt.show()
|
||||
|
||||
return {'output': np.abs(np.round(volume, decimals=decimals))}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
def read_polygon(file):
|
||||
with open(file, 'r', encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if data.get('type') == "FeatureCollection":
|
||||
features = data.get("features", [{}])
|
||||
else:
|
||||
features = [data]
|
||||
|
||||
for feature in features:
|
||||
if not 'geometry' in feature:
|
||||
continue
|
||||
|
||||
# Check if the feature geometry type is Polygon
|
||||
if feature['geometry']['type'] == 'Polygon':
|
||||
# Extract polygon coordinates
|
||||
coordinates = feature['geometry']['coordinates'][0] # Assuming exterior ring
|
||||
return coordinates
|
||||
|
||||
raise IOError("No polygons found in %s" % file)
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "WebODM",
|
||||
"version": "2.2.1",
|
||||
"version": "2.3.0",
|
||||
"description": "User-friendly, extendable application and API for processing aerial imagery.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -22,7 +22,6 @@ djangorestframework-guardian==0.3.0
|
|||
drf-nested-routers==0.11.1
|
||||
funcsigs==1.0.2
|
||||
futures==3.1.1
|
||||
geojson==2.3.0
|
||||
gunicorn==19.7.1
|
||||
itypes==1.1.0
|
||||
kombu==4.6.7
|
||||
|
@ -63,5 +62,6 @@ https://github.com/OpenDroneMap/WebODM/releases/download/v1.9.7/GDAL-3.3.3-cp39-
|
|||
Shapely==1.7.0 ; sys_platform == "win32"
|
||||
eventlet==0.32.0 ; sys_platform == "win32"
|
||||
pyopenssl==19.1.0 ; sys_platform == "win32"
|
||||
numpy==1.21.1
|
||||
numpy==1.26.2
|
||||
scipy==1.11.3
|
||||
drf-yasg==1.20.0
|
||||
|
|
|
@ -15,7 +15,6 @@ from app.models import Profile
|
|||
|
||||
from app.models import Project
|
||||
from app.models import Task
|
||||
from app.plugins.grass_engine import grass, GrassEngineException
|
||||
from nodeodm import status_codes
|
||||
from nodeodm.models import ProcessingNode
|
||||
from webodm import settings
|
||||
|
@ -178,15 +177,6 @@ def process_pending_tasks():
|
|||
process_task.delay(task.id)
|
||||
|
||||
|
||||
@app.task
|
||||
def execute_grass_script(script, serialized_context = {}, out_key='output'):
|
||||
try:
|
||||
ctx = grass.create_context(serialized_context)
|
||||
return {out_key: ctx.execute(script), 'context': ctx.serialize()}
|
||||
except GrassEngineException as e:
|
||||
logger.error(str(e))
|
||||
return {'error': str(e), 'context': ctx.serialize()}
|
||||
|
||||
@app.task(bind=True)
|
||||
def export_raster(self, input, **opts):
|
||||
try:
|
||||
|
|
Ładowanie…
Reference in New Issue