From cf554fdbfde67741dcc096b180c9cc32b9c1a019 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 10 Jun 2021 15:08:57 -0400 Subject: [PATCH] Ability to load potree measurements --- app/api/potree.py | 11 + app/api/urls.py | 3 + app/migrations/0031_auto_20210610_1850.py | 410 ++++++++++++++++++ app/models/task.py | 1 + app/static/app/js/ModelView.jsx | 12 +- nodeodm/migrations/0009_auto_20210610_1850.py | 73 ++++ package.json | 2 +- 7 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 app/api/potree.py create mode 100644 app/migrations/0031_auto_20210610_1850.py create mode 100644 nodeodm/migrations/0009_auto_20210610_1850.py diff --git a/app/api/potree.py b/app/api/potree.py new file mode 100644 index 00000000..0cfebb27 --- /dev/null +++ b/app/api/potree.py @@ -0,0 +1,11 @@ +from .tasks import TaskNestedView +from rest_framework.response import Response + +class Scene(TaskNestedView): + def get(self, request, pk=None, project_pk=None): + """ + Retrieve Potree scene information + """ + task = self.get_and_check_task(request, pk) + + return Response(task.potree_scene) \ No newline at end of file diff --git a/app/api/urls.py b/app/api/urls.py index fdaf50a3..5141c7da 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -10,6 +10,7 @@ from .admin import UserViewSet, GroupViewSet from rest_framework_nested import routers from rest_framework_jwt.views import obtain_jwt_token from .tiler import TileJson, Bounds, Metadata, Tiles, Export +from .potree import Scene from .workers import CheckTask, GetTaskResult router = routers.DefaultRouter() @@ -45,6 +46,8 @@ urlpatterns = [ url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/images/thumbnail/(?P.+)$', Thumbnail.as_view()), url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/images/download/(?P.+)$', ImageDownload.as_view()), + url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/3d/scene$', Scene.as_view()), + url(r'workers/check/(?P.+)', CheckTask.as_view()), url(r'workers/get/(?P.+)', GetTaskResult.as_view()), diff --git a/app/migrations/0031_auto_20210610_1850.py b/app/migrations/0031_auto_20210610_1850.py new file mode 100644 index 00000000..4d351f81 --- /dev/null +++ b/app/migrations/0031_auto_20210610_1850.py @@ -0,0 +1,410 @@ +# Generated by Django 2.1.15 on 2021-06-10 18:50 + +import app.models.image_upload +import app.models.task +import colorfield.fields +from django.conf import settings +import django.contrib.gis.db.models.fields +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0030_assure_cogeo'), + ] + + operations = [ + migrations.AlterModelOptions( + name='imageupload', + options={'verbose_name': 'Image Upload', 'verbose_name_plural': 'Image Uploads'}, + ), + migrations.AlterModelOptions( + name='plugin', + options={'verbose_name': 'Plugin', 'verbose_name_plural': 'Plugins'}, + ), + migrations.AlterModelOptions( + name='plugindatum', + options={'verbose_name': 'Plugin Datum', 'verbose_name_plural': 'Plugin Datum'}, + ), + migrations.AlterModelOptions( + name='preset', + options={'verbose_name': 'Preset', 'verbose_name_plural': 'Presets'}, + ), + migrations.AlterModelOptions( + name='project', + options={'verbose_name': 'Project', 'verbose_name_plural': 'Projects'}, + ), + migrations.AlterModelOptions( + name='setting', + options={'verbose_name': 'Settings', 'verbose_name_plural': 'Settings'}, + ), + migrations.AlterModelOptions( + name='task', + options={'verbose_name': 'Task', 'verbose_name_plural': 'Tasks'}, + ), + migrations.AlterModelOptions( + name='theme', + options={'verbose_name': 'Theme', 'verbose_name_plural': 'Theme'}, + ), + migrations.AddField( + model_name='task', + name='potree_scene', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict, help_text='Serialized potree scene information used to save/load measurements and camera view angle', verbose_name='Potree Scene'), + ), + migrations.AlterField( + model_name='imageupload', + name='image', + field=models.ImageField(help_text='File uploaded by a user', max_length=512, upload_to=app.models.image_upload.image_directory_path, verbose_name='Image'), + ), + migrations.AlterField( + model_name='imageupload', + name='task', + field=models.ForeignKey(help_text='Task this image belongs to', on_delete=django.db.models.deletion.CASCADE, to='app.Task', verbose_name='Task'), + ), + migrations.AlterField( + model_name='plugin', + name='enabled', + field=models.BooleanField(db_index=True, default=True, help_text='Whether this plugin is turned on.', verbose_name='Enabled'), + ), + migrations.AlterField( + model_name='plugin', + name='name', + field=models.CharField(help_text='Plugin name', max_length=255, primary_key=True, serialize=False, verbose_name='Name'), + ), + migrations.AlterField( + model_name='plugindatum', + name='bool_value', + field=models.NullBooleanField(default=None, verbose_name='Bool value'), + ), + migrations.AlterField( + model_name='plugindatum', + name='float_value', + field=models.FloatField(blank=True, default=None, null=True, verbose_name='Float value'), + ), + migrations.AlterField( + model_name='plugindatum', + name='int_value', + field=models.IntegerField(blank=True, default=None, null=True, verbose_name='Integer value'), + ), + migrations.AlterField( + model_name='plugindatum', + name='json_value', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=None, null=True, verbose_name='JSON value'), + ), + migrations.AlterField( + model_name='plugindatum', + name='key', + field=models.CharField(db_index=True, help_text='Setting key', max_length=255, verbose_name='Key'), + ), + migrations.AlterField( + model_name='plugindatum', + name='string_value', + field=models.TextField(blank=True, default=None, null=True, verbose_name='String value'), + ), + migrations.AlterField( + model_name='plugindatum', + name='user', + field=models.ForeignKey(default=None, help_text='The user this setting belongs to. If NULL, the setting is global.', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + migrations.AlterField( + model_name='preset', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Creation date', verbose_name='Created at'), + ), + migrations.AlterField( + model_name='preset', + name='name', + field=models.CharField(help_text='A label used to describe the preset', max_length=255, verbose_name='Name'), + ), + migrations.AlterField( + model_name='preset', + name='options', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text="Options that define this preset (same format as in a Task's options).", validators=[app.models.task.validate_task_options], verbose_name='Options'), + ), + migrations.AlterField( + model_name='preset', + name='owner', + field=models.ForeignKey(blank=True, help_text='The person who owns this preset', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Owner'), + ), + migrations.AlterField( + model_name='preset', + name='system', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this preset is available to every user in the system or just to its owner.', verbose_name='System'), + ), + migrations.AlterField( + model_name='project', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Creation date', verbose_name='Created at'), + ), + migrations.AlterField( + model_name='project', + name='deleting', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this project has been marked for deletion. Projects that have running tasks need to wait for tasks to be properly cleaned up before they can be deleted.', verbose_name='Deleting'), + ), + migrations.AlterField( + model_name='project', + name='description', + field=models.TextField(blank=True, default='', help_text='More in-depth description of the project', verbose_name='Description'), + ), + migrations.AlterField( + model_name='project', + name='name', + field=models.CharField(help_text='A label used to describe the project', max_length=255, verbose_name='Name'), + ), + migrations.AlterField( + model_name='project', + name='owner', + field=models.ForeignKey(help_text='The person who created the project', on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Owner'), + ), + migrations.AlterField( + model_name='setting', + name='app_logo', + field=models.ImageField(help_text='A 512x512 logo of your application (.png or .jpeg)', upload_to='settings/', verbose_name='App logo'), + ), + migrations.AlterField( + model_name='setting', + name='app_name', + field=models.CharField(help_text='The name of your application', max_length=255, verbose_name='App name'), + ), + migrations.AlterField( + model_name='setting', + name='organization_name', + field=models.CharField(blank=True, default='WebODM', help_text='The name of your organization', max_length=255, null=True, verbose_name='Organization name'), + ), + migrations.AlterField( + model_name='setting', + name='organization_website', + field=models.URLField(blank=True, default='https://github.com/OpenDroneMap/WebODM/', help_text='The website URL of your organization', max_length=255, null=True, verbose_name='Organization website'), + ), + migrations.AlterField( + model_name='setting', + name='theme', + field=models.ForeignKey(help_text='Active theme', on_delete=django.db.models.deletion.DO_NOTHING, to='app.Theme', verbose_name='Theme'), + ), + migrations.AlterField( + model_name='task', + name='auto_processing_node', + field=models.BooleanField(default=True, help_text='A flag indicating whether this task should be automatically assigned a processing node', verbose_name='Auto Processing Node'), + ), + migrations.AlterField( + model_name='task', + name='available_assets', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=80), blank=True, default=list, help_text='List of available assets to download', size=None, verbose_name='Available Assets'), + ), + migrations.AlterField( + model_name='task', + name='console_output', + field=models.TextField(blank=True, default='', help_text='Console output of the processing node', verbose_name='Console Output'), + ), + migrations.AlterField( + model_name='task', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Creation date', verbose_name='Created at'), + ), + migrations.AlterField( + model_name='task', + name='dsm_extent', + field=django.contrib.gis.db.models.fields.GeometryField(blank=True, help_text='Extent of the DSM', null=True, srid=4326, verbose_name='DSM Extent'), + ), + migrations.AlterField( + model_name='task', + name='dtm_extent', + field=django.contrib.gis.db.models.fields.GeometryField(blank=True, help_text='Extent of the DTM', null=True, srid=4326, verbose_name='DTM Extent'), + ), + migrations.AlterField( + model_name='task', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='Id'), + ), + migrations.AlterField( + model_name='task', + name='images_count', + field=models.IntegerField(blank=True, default=0, help_text='Number of images associated with this task', verbose_name='Images Count'), + ), + migrations.AlterField( + model_name='task', + name='import_url', + field=models.TextField(blank=True, default='', help_text='URL this task is imported from (only for imported tasks)', verbose_name='Import URL'), + ), + migrations.AlterField( + model_name='task', + name='last_error', + field=models.TextField(blank=True, help_text='The last processing error received', null=True, verbose_name='Last Error'), + ), + migrations.AlterField( + model_name='task', + name='name', + field=models.CharField(blank=True, help_text='A label for the task', max_length=255, null=True, verbose_name='Name'), + ), + migrations.AlterField( + model_name='task', + name='options', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict, help_text='Options that are being used to process this task', validators=[app.models.task.validate_task_options], verbose_name='Options'), + ), + migrations.AlterField( + model_name='task', + name='orthophoto_extent', + field=django.contrib.gis.db.models.fields.GeometryField(blank=True, help_text='Extent of the orthophoto', null=True, srid=4326, verbose_name='Orthophoto Extent'), + ), + migrations.AlterField( + model_name='task', + name='partial', + field=models.BooleanField(default=False, help_text='A flag indicating whether this task is currently waiting for information or files to be uploaded before being considered for processing.', verbose_name='Partial'), + ), + migrations.AlterField( + model_name='task', + name='pending_action', + field=models.IntegerField(blank=True, choices=[(1, 'CANCEL'), (2, 'REMOVE'), (3, 'RESTART'), (4, 'RESIZE'), (5, 'IMPORT')], db_index=True, help_text='A requested action to be performed on the task. The selected action will be performed by the worker at the next iteration.', null=True, verbose_name='Pending Action'), + ), + migrations.AlterField( + model_name='task', + name='processing_node', + field=models.ForeignKey(blank=True, help_text='Processing node assigned to this task (or null if this task has not been associated yet)', null=True, on_delete=django.db.models.deletion.SET_NULL, to='nodeodm.ProcessingNode', verbose_name='Processing Node'), + ), + migrations.AlterField( + model_name='task', + name='processing_time', + field=models.IntegerField(default=-1, help_text='Number of milliseconds that elapsed since the beginning of this task (-1 indicates that no information is available)', verbose_name='Processing Time'), + ), + migrations.AlterField( + model_name='task', + name='project', + field=models.ForeignKey(help_text='Project that this task belongs to', on_delete=django.db.models.deletion.CASCADE, to='app.Project', verbose_name='Project'), + ), + migrations.AlterField( + model_name='task', + name='public', + field=models.BooleanField(default=False, help_text='A flag indicating whether this task is available to the public', verbose_name='Public'), + ), + migrations.AlterField( + model_name='task', + name='resize_progress', + field=models.FloatField(blank=True, default=0.0, help_text="Value between 0 and 1 indicating the resize progress of this task's images", verbose_name='Resize Progress'), + ), + migrations.AlterField( + model_name='task', + name='resize_to', + field=models.IntegerField(default=-1, help_text='When set to a value different than -1, indicates that the images for this task have been / will be resized to the size specified here before processing.', verbose_name='Resize To'), + ), + migrations.AlterField( + model_name='task', + name='running_progress', + field=models.FloatField(blank=True, default=0.0, help_text='Value between 0 and 1 indicating the running progress (estimated) of this task', verbose_name='Running Progress'), + ), + migrations.AlterField( + model_name='task', + name='status', + field=models.IntegerField(blank=True, choices=[(10, 'QUEUED'), (20, 'RUNNING'), (30, 'FAILED'), (40, 'COMPLETED'), (50, 'CANCELED')], db_index=True, help_text='Current status of the task', null=True, verbose_name='Status'), + ), + migrations.AlterField( + model_name='task', + name='upload_progress', + field=models.FloatField(blank=True, default=0.0, help_text="Value between 0 and 1 indicating the upload progress of this task's files to the processing node", verbose_name='Upload Progress'), + ), + migrations.AlterField( + model_name='task', + name='uuid', + field=models.CharField(blank=True, db_index=True, default='', help_text='Identifier of the task (as returned by NodeODM API)', max_length=255, verbose_name='UUID'), + ), + migrations.AlterField( + model_name='theme', + name='border', + field=colorfield.fields.ColorField(default='#e7e7e7', help_text='The color of most borders.', max_length=18, verbose_name='Border'), + ), + migrations.AlterField( + model_name='theme', + name='button_danger', + field=colorfield.fields.ColorField(default='#e74c3c', help_text='Delete button color.', max_length=18, verbose_name='Button Danger'), + ), + migrations.AlterField( + model_name='theme', + name='button_default', + field=colorfield.fields.ColorField(default='#95a5a6', help_text='Default button color.', max_length=18, verbose_name='Button Default'), + ), + migrations.AlterField( + model_name='theme', + name='button_primary', + field=colorfield.fields.ColorField(default='#2c3e50', help_text='Primary button color.', max_length=18, verbose_name='Button Primary'), + ), + migrations.AlterField( + model_name='theme', + name='css', + field=models.TextField(blank=True, default='', verbose_name='CSS'), + ), + migrations.AlterField( + model_name='theme', + name='dialog_warning', + field=colorfield.fields.ColorField(default='#f39c12', help_text='The border color of warning dialogs.', max_length=18, verbose_name='Dialog Warning'), + ), + migrations.AlterField( + model_name='theme', + name='failed', + field=colorfield.fields.ColorField(default='#ffcbcb', help_text='The background color of failed notifications.', max_length=18, verbose_name='Failed'), + ), + migrations.AlterField( + model_name='theme', + name='header_background', + field=colorfield.fields.ColorField(default='#3498db', help_text="Background color of the site's header.", max_length=18, verbose_name='Header Background'), + ), + migrations.AlterField( + model_name='theme', + name='header_primary', + field=colorfield.fields.ColorField(default='#ffffff', help_text="Text and icons in the site's header.", max_length=18, verbose_name='Header Primary'), + ), + migrations.AlterField( + model_name='theme', + name='highlight', + field=colorfield.fields.ColorField(default='#f7f7f7', help_text='The background color of panels and some borders.', max_length=18, verbose_name='Highlight'), + ), + migrations.AlterField( + model_name='theme', + name='html_after_body', + field=models.TextField(blank=True, default='', verbose_name='HTML (after body)'), + ), + migrations.AlterField( + model_name='theme', + name='html_after_header', + field=models.TextField(blank=True, default='', verbose_name='HTML (after header)'), + ), + migrations.AlterField( + model_name='theme', + name='html_before_header', + field=models.TextField(blank=True, default='', verbose_name='HTML (before header)'), + ), + migrations.AlterField( + model_name='theme', + name='html_footer', + field=models.TextField(blank=True, default='', verbose_name='HTML (footer)'), + ), + migrations.AlterField( + model_name='theme', + name='name', + field=models.CharField(help_text='Name of theme', max_length=255, verbose_name='Name'), + ), + migrations.AlterField( + model_name='theme', + name='primary', + field=colorfield.fields.ColorField(default='#2c3e50', help_text='Most text, icons, and borders.', max_length=18, verbose_name='Primary'), + ), + migrations.AlterField( + model_name='theme', + name='secondary', + field=colorfield.fields.ColorField(default='#ffffff', help_text='The main background color, and text color of some buttons.', max_length=18, verbose_name='Secondary'), + ), + migrations.AlterField( + model_name='theme', + name='success', + field=colorfield.fields.ColorField(default='#cbffcd', help_text='The background color of success notifications.', max_length=18, verbose_name='Success'), + ), + migrations.AlterField( + model_name='theme', + name='tertiary', + field=colorfield.fields.ColorField(default='#3498db', help_text='Navigation links.', max_length=18, verbose_name='Tertiary'), + ), + ] diff --git a/app/models/task.py b/app/models/task.py index 93e6f5b8..d6410779 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -255,6 +255,7 @@ class Task(models.Model): import_url = models.TextField(null=False, default="", blank=True, help_text=_("URL this task is imported from (only for imported tasks)"), verbose_name=_("Import URL")) images_count = models.IntegerField(null=False, blank=True, default=0, help_text=_("Number of images associated with this task"), verbose_name=_("Images Count")) partial = models.BooleanField(default=False, help_text=_("A flag indicating whether this task is currently waiting for information or files to be uploaded before being considered for processing."), verbose_name=_("Partial")) + potree_scene = fields.JSONField(default=dict, blank=True, help_text=_("Serialized potree scene information used to save/load measurements and camera view angle"), verbose_name=_("Potree Scene")) class Meta: verbose_name = _("Task") diff --git a/app/static/app/js/ModelView.jsx b/app/static/app/js/ModelView.jsx index 709135e8..d9120f2b 100644 --- a/app/static/app/js/ModelView.jsx +++ b/app/static/app/js/ModelView.jsx @@ -274,7 +274,17 @@ class ModelView extends React.Component { material.size = 1; viewer.fitToScreen(); - }); + + // Load saved scene (if any) + $.ajax({ + type: "GET", + url: `/api/projects/${this.props.task.project}/tasks/${this.props.task.id}/3d/scene` + }).done(sceneData => { + Potree.loadProject(viewer, sceneData); + }).fail(e => { + console.error("Cannot load 3D scene information", e); + }); + }); }); viewer.renderer.domElement.addEventListener( 'mousedown', this.handleRenderMouseClick ); diff --git a/nodeodm/migrations/0009_auto_20210610_1850.py b/nodeodm/migrations/0009_auto_20210610_1850.py new file mode 100644 index 00000000..dc92348c --- /dev/null +++ b/nodeodm/migrations/0009_auto_20210610_1850.py @@ -0,0 +1,73 @@ +# Generated by Django 2.1.15 on 2021-06-10 18:50 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nodeodm', '0008_rename_default_odm_node'), + ] + + operations = [ + migrations.AlterModelOptions( + name='processingnode', + options={'verbose_name': 'Processing Node', 'verbose_name_plural': 'Processing Nodes'}, + ), + migrations.AlterField( + model_name='processingnode', + name='api_version', + field=models.CharField(help_text='API version used by the node', max_length=32, null=True, verbose_name='API Version'), + ), + migrations.AlterField( + model_name='processingnode', + name='available_options', + field=django.contrib.postgres.fields.jsonb.JSONField(default=dict, help_text='Description of the options that can be used for processing', verbose_name='Available Options'), + ), + migrations.AlterField( + model_name='processingnode', + name='engine', + field=models.CharField(help_text='Engine used by the node.', max_length=255, null=True, verbose_name='Engine'), + ), + migrations.AlterField( + model_name='processingnode', + name='engine_version', + field=models.CharField(help_text='Engine version used by the node.', max_length=32, null=True, verbose_name='Engine Version'), + ), + migrations.AlterField( + model_name='processingnode', + name='hostname', + field=models.CharField(help_text='Hostname or IP address where the node is located (can be an internal hostname as well). If you are using Docker, this is never 127.0.0.1 or localhost. Find the IP address of your host machine by running ifconfig on Linux or by checking your network settings.', max_length=255, verbose_name='Hostname'), + ), + migrations.AlterField( + model_name='processingnode', + name='label', + field=models.CharField(blank=True, default='', help_text='Optional label for this node. When set, this label will be shown instead of the hostname:port name.', max_length=255, verbose_name='Label'), + ), + migrations.AlterField( + model_name='processingnode', + name='last_refreshed', + field=models.DateTimeField(help_text='When was the information about this node last retrieved?', null=True, verbose_name='Last Refreshed'), + ), + migrations.AlterField( + model_name='processingnode', + name='max_images', + field=models.PositiveIntegerField(blank=True, help_text='Maximum number of images accepted by this node.', null=True, verbose_name='Max Images'), + ), + migrations.AlterField( + model_name='processingnode', + name='port', + field=models.PositiveIntegerField(help_text="Port that connects to the node's API", verbose_name='Port'), + ), + migrations.AlterField( + model_name='processingnode', + name='queue_count', + field=models.PositiveIntegerField(default=0, help_text='Number of tasks currently being processed by this node (as reported by the node itself)', verbose_name='Queue Count'), + ), + migrations.AlterField( + model_name='processingnode', + name='token', + field=models.CharField(blank=True, default='', help_text="Token to use for authentication. If the node doesn't have authentication, you can leave this field blank.", max_length=1024, verbose_name='Token'), + ), + ] diff --git a/package.json b/package.json index a0f8e292..6ac9f179 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WebODM", - "version": "1.9.1", + "version": "1.9.2", "description": "User-friendly, extendable application and API for processing aerial imagery.", "main": "index.js", "scripts": {