10x volume calculations, remove grass dependency

pull/1435/head
Piero Toffanin 2023-11-14 16:10:16 -05:00
rodzic 5a94579a8e
commit c28d00f0b0
44 zmienionych plików z 234 dodań i 2728 usunięć

Wyświetl plik

@ -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" && \

Wyświetl plik

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

Wyświetl plik

@ -1,7 +1,5 @@
import inspect
from worker.celery import app
# noinspection PyUnresolvedReferences
from worker.tasks import execute_grass_script
task = app.task

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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()),
]

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,3 +0,0 @@
geojson==2.4.1
opencv-python-headless==4.4.0.46

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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()),
]

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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/>&#8226; Insert your custom elevation intervals, in the form: 10-15,20-30. <br/>&#8226; 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>);
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,2 +0,0 @@
geojson==2.4.1
opencv-python-headless==4.4.0.46

Wyświetl plik

@ -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
try:
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)

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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": {

Wyświetl plik

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

Wyświetl plik

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