diff --git a/Dockerfile b/Dockerfile index 240e6563..978c2254 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" && \ diff --git a/app/plugins/grass_engine.py b/app/plugins/grass_engine.py deleted file mode 100644 index cb3c7ff9..00000000 --- a/app/plugins/grass_engine.py +++ /dev/null @@ -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() diff --git a/app/plugins/worker.py b/app/plugins/worker.py index 3c43b735..7c9e4789 100644 --- a/app/plugins/worker.py +++ b/app/plugins/worker.py @@ -1,7 +1,5 @@ import inspect from worker.celery import app -# noinspection PyUnresolvedReferences -from worker.tasks import execute_grass_script task = app.task diff --git a/app/tests/grass_scripts/simple_test.py b/app/tests/grass_scripts/simple_test.py deleted file mode 100644 index ed7d3e29..00000000 --- a/app/tests/grass_scripts/simple_test.py +++ /dev/null @@ -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()) - diff --git a/app/tests/test_plugins.py b/app/tests/test_plugins.py index dcb8b9d1..0dab3144 100644 --- a/app/tests/test_plugins.py +++ b/app/tests/test_plugins.py @@ -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") diff --git a/coreplugins/changedetection/__init__.py b/coreplugins/changedetection/__init__.py deleted file mode 100644 index 48aad58e..00000000 --- a/coreplugins/changedetection/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .plugin import * diff --git a/coreplugins/changedetection/api.py b/coreplugins/changedetection/api.py deleted file mode 100644 index 101dc8fe..00000000 --- a/coreplugins/changedetection/api.py +++ /dev/null @@ -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'}) diff --git a/coreplugins/changedetection/changedetection.py b/coreplugins/changedetection/changedetection.py deleted file mode 100644 index 0b315151..00000000 --- a/coreplugins/changedetection/changedetection.py +++ /dev/null @@ -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) diff --git a/coreplugins/changedetection/compare.py b/coreplugins/changedetection/compare.py deleted file mode 100644 index 3e113186..00000000 --- a/coreplugins/changedetection/compare.py +++ /dev/null @@ -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] diff --git a/coreplugins/changedetection/disabled b/coreplugins/changedetection/disabled deleted file mode 100644 index e69de29b..00000000 diff --git a/coreplugins/changedetection/manifest.json b/coreplugins/changedetection/manifest.json deleted file mode 100644 index 81375af4..00000000 --- a/coreplugins/changedetection/manifest.json +++ /dev/null @@ -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 -} diff --git a/coreplugins/changedetection/plugin.py b/coreplugins/changedetection/plugin.py deleted file mode 100644 index 1e83d134..00000000 --- a/coreplugins/changedetection/plugin.py +++ /dev/null @@ -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[^/.]+)/changedetection/generate', TaskChangeMapGenerate.as_view()), - MountPoint('task/(?P[^/.]+)/changedetection/check/(?P.+)', TaskChangeMapCheck.as_view()), - MountPoint('task/(?P[^/.]+)/changedetection/download/(?P.+)', TaskChangeMapDownload.as_view()), - ] diff --git a/coreplugins/changedetection/public/ChangeDetection.jsx b/coreplugins/changedetection/public/ChangeDetection.jsx deleted file mode 100644 index 7ad4cb82..00000000 --- a/coreplugins/changedetection/public/ChangeDetection.jsx +++ /dev/null @@ -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 (
- - -
); - } -} - -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(, container); - - return container; - } -}); diff --git a/coreplugins/changedetection/public/ChangeDetection.scss b/coreplugins/changedetection/public/ChangeDetection.scss deleted file mode 100644 index 0e52b6d9..00000000 --- a/coreplugins/changedetection/public/ChangeDetection.scss +++ /dev/null @@ -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; -} diff --git a/coreplugins/changedetection/public/ChangeDetectionPanel.jsx b/coreplugins/changedetection/public/ChangeDetectionPanel.jsx deleted file mode 100644 index b0b2e5ec..00000000 --- a/coreplugins/changedetection/public/ChangeDetectionPanel.jsx +++ /dev/null @@ -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
Min: ${min}m
Max: ${max}m
Avg: ${avg}m
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 = ( Loading...); - else if (permanentError) content = (
{permanentError}
); - else{ - content = (
- - -
- -
- -

-

-
- -
- -
- - {this.props.alignSupported ? -

- : -

- } -

-
- - {this.props.alignSupported ? -
- -
- -

-

-
- : ""} - - -
- -
- -

-

-
- -
- -
- meters/pixel -

-

-
- -
- -
- meters -

-

-
- -
- -
- sq meters -

- -

-
- -
- -
- -
-
- {epsg === "custom" ? -
- -
- -
-
- : ""} - - {previewLayer ? -
- -
- -

- -

-
- : ""} - -
-
- - -
- - - -
-
-
- -
); - } - - return (
- -
Change Detection
-
- {content} -
); - } -} diff --git a/coreplugins/changedetection/public/ChangeDetectionPanel.scss b/coreplugins/changedetection/public/ChangeDetectionPanel.scss deleted file mode 100644 index 5c966488..00000000 --- a/coreplugins/changedetection/public/ChangeDetectionPanel.scss +++ /dev/null @@ -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; - } -} diff --git a/coreplugins/changedetection/public/icon.png b/coreplugins/changedetection/public/icon.png deleted file mode 100644 index 9c2a8593..00000000 Binary files a/coreplugins/changedetection/public/icon.png and /dev/null differ diff --git a/coreplugins/changedetection/public/main.js b/coreplugins/changedetection/public/main.js deleted file mode 100644 index d14baf68..00000000 --- a/coreplugins/changedetection/public/main.js +++ /dev/null @@ -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})); - } -}); diff --git a/coreplugins/changedetection/public/package.json b/coreplugins/changedetection/public/package.json deleted file mode 100644 index b1e1f91c..00000000 --- a/coreplugins/changedetection/public/package.json +++ /dev/null @@ -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" - } -} diff --git a/coreplugins/changedetection/rasteralign.py b/coreplugins/changedetection/rasteralign.py deleted file mode 100644 index 8ce2e253..00000000 --- a/coreplugins/changedetection/rasteralign.py +++ /dev/null @@ -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) diff --git a/coreplugins/changedetection/requirements.txt b/coreplugins/changedetection/requirements.txt deleted file mode 100644 index 7197cf6f..00000000 --- a/coreplugins/changedetection/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -geojson==2.4.1 -opencv-python-headless==4.4.0.46 - diff --git a/coreplugins/elevationmap/ToDo.txt b/coreplugins/elevationmap/ToDo.txt deleted file mode 100644 index 55216291..00000000 --- a/coreplugins/elevationmap/ToDo.txt +++ /dev/null @@ -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. \ No newline at end of file diff --git a/coreplugins/elevationmap/__init__.py b/coreplugins/elevationmap/__init__.py deleted file mode 100644 index 48aad58e..00000000 --- a/coreplugins/elevationmap/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .plugin import * diff --git a/coreplugins/elevationmap/api.py b/coreplugins/elevationmap/api.py deleted file mode 100644 index 72fd1610..00000000 --- a/coreplugins/elevationmap/api.py +++ /dev/null @@ -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'}) diff --git a/coreplugins/elevationmap/disabled b/coreplugins/elevationmap/disabled deleted file mode 100644 index e69de29b..00000000 diff --git a/coreplugins/elevationmap/elevationmap.py b/coreplugins/elevationmap/elevationmap.py deleted file mode 100755 index 7f222daa..00000000 --- a/coreplugins/elevationmap/elevationmap.py +++ /dev/null @@ -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()) diff --git a/coreplugins/elevationmap/manifest.json b/coreplugins/elevationmap/manifest.json deleted file mode 100644 index ad883599..00000000 --- a/coreplugins/elevationmap/manifest.json +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/coreplugins/elevationmap/plugin.py b/coreplugins/elevationmap/plugin.py deleted file mode 100644 index 6f760abd..00000000 --- a/coreplugins/elevationmap/plugin.py +++ /dev/null @@ -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[^/.]+)/elevationmap/generate', TaskElevationMapGenerate.as_view()), - MountPoint('task/(?P[^/.]+)/elevationmap/check/(?P.+)', TaskElevationMapCheck.as_view()), - MountPoint('task/(?P[^/.]+)/elevationmap/download/(?P.+)', TaskElevationMapDownload.as_view()), - ] \ No newline at end of file diff --git a/coreplugins/elevationmap/public/ElevationMap.jsx b/coreplugins/elevationmap/public/ElevationMap.jsx deleted file mode 100644 index 0b9a9c23..00000000 --- a/coreplugins/elevationmap/public/ElevationMap.jsx +++ /dev/null @@ -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 (
- - -
); - } -} - -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(, container); - - return container; - } -}); - diff --git a/coreplugins/elevationmap/public/ElevationMap.scss b/coreplugins/elevationmap/public/ElevationMap.scss deleted file mode 100644 index 115deb6b..00000000 --- a/coreplugins/elevationmap/public/ElevationMap.scss +++ /dev/null @@ -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; -} diff --git a/coreplugins/elevationmap/public/ElevationMapPanel.jsx b/coreplugins/elevationmap/public/ElevationMapPanel.jsx deleted file mode 100644 index 0f06ef06..00000000 --- a/coreplugins/elevationmap/public/ElevationMapPanel.jsx +++ /dev/null @@ -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
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 = ( Loading...); - else if (permanentError) content = (
{permanentError}
); - else{ - content = (
- -
- -
- -

-

-
- -
- -
- -

-

-
- -
- -
- -

-

-
- {noiseFilterSize === "custom" ? -
- -
- meter -
-
- : ""} - -
- -
- -
-
- {epsg === "custom" ? -
- -
- -
-
- : ""} - - {previewLayer ? -
- -
- -

- -

-
- : ""} - -
-
- - -
- - - -
-
-
- -
); - } - - return (
- -
Elevation Map
-
- {content} -
); - } -} \ No newline at end of file diff --git a/coreplugins/elevationmap/public/ElevationMapPanel.scss b/coreplugins/elevationmap/public/ElevationMapPanel.scss deleted file mode 100644 index f7288d3a..00000000 --- a/coreplugins/elevationmap/public/ElevationMapPanel.scss +++ /dev/null @@ -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; - } -} diff --git a/coreplugins/elevationmap/public/icon.png b/coreplugins/elevationmap/public/icon.png deleted file mode 100644 index 7ab827e0..00000000 Binary files a/coreplugins/elevationmap/public/icon.png and /dev/null differ diff --git a/coreplugins/elevationmap/public/main.js b/coreplugins/elevationmap/public/main.js deleted file mode 100644 index 464a8d42..00000000 --- a/coreplugins/elevationmap/public/main.js +++ /dev/null @@ -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})); - } -}); diff --git a/coreplugins/elevationmap/public/package.json b/coreplugins/elevationmap/public/package.json deleted file mode 100644 index 591190cd..00000000 --- a/coreplugins/elevationmap/public/package.json +++ /dev/null @@ -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" - } -} diff --git a/coreplugins/elevationmap/requirements.txt b/coreplugins/elevationmap/requirements.txt deleted file mode 100644 index b24a8f23..00000000 --- a/coreplugins/elevationmap/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -geojson==2.4.1 -opencv-python-headless==4.4.0.46 diff --git a/coreplugins/measure/api.py b/coreplugins/measure/api.py index acfd4161..1dc437ea 100644 --- a/coreplugins/measure/api.py +++ b/coreplugins/measure/api.py @@ -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) diff --git a/coreplugins/measure/calc_volume.py b/coreplugins/measure/calc_volume.py deleted file mode 100755 index df69760c..00000000 --- a/coreplugins/measure/calc_volume.py +++ /dev/null @@ -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()) diff --git a/coreplugins/measure/public/MeasurePopup.jsx b/coreplugins/measure/public/MeasurePopup.jsx index 14e61805..afbb8905 100644 --- a/coreplugins/measure/public/MeasurePopup.jsx +++ b/coreplugins/measure/public/MeasurePopup.jsx @@ -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 (

{_("Area:")} {this.props.model.areaDisplay}

{_("Perimeter:")} {this.props.model.lengthDisplay}

{volume === null && !error &&

{_("Volume:")} {_("computing…")}

} - {typeof volume === "number" &&

{_("Volume:")} {volume.toFixed("2")} {_("Cubic Meters")} ({(volume * 35.3147).toFixed(2)} {_("Cubic Feet")})

} + {typeof volume === "number" ? + [ +

{_("Volume:")} {volume.toFixed("2")} {_("Cubic Meters")} ({(volume * 35.3147).toFixed(2)} {_("Cubic Feet")})

, +

{_("Base surface:")} + +

+ ] + : ""} {error &&

{_("Volume:")} 200 ? 'long' : '')}>{error}

} {_("Export to GeoJSON")}
); diff --git a/coreplugins/measure/public/MeasurePopup.scss b/coreplugins/measure/public/MeasurePopup.scss index eb23b92d..69e2210f 100644 --- a/coreplugins/measure/public/MeasurePopup.scss +++ b/coreplugins/measure/public/MeasurePopup.scss @@ -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; + } + } } \ No newline at end of file diff --git a/coreplugins/measure/volume.py b/coreplugins/measure/volume.py new file mode 100644 index 00000000..2fc78bdc --- /dev/null +++ b/coreplugins/measure/volume.py @@ -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) \ No newline at end of file diff --git a/package.json b/package.json index d18eabf8..6380d825 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/requirements.txt b/requirements.txt index 07a92e66..e53a1051 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/worker/tasks.py b/worker/tasks.py index 5ca53841..bb3acad5 100644 --- a/worker/tasks.py +++ b/worker/tasks.py @@ -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: