From e2eafe050753ea2ef419d46c7db2243a725d71bc Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 18 Feb 2025 14:26:38 +0100 Subject: [PATCH 01/17] Add geolocation file download from map preview --- app/static/app/js/components/ExportAssetPanel.jsx | 2 +- app/static/app/js/components/MapPreview.jsx | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/static/app/js/components/ExportAssetPanel.jsx b/app/static/app/js/components/ExportAssetPanel.jsx index 0d66d3e5..2638b8ed 100644 --- a/app/static/app/js/components/ExportAssetPanel.jsx +++ b/app/static/app/js/components/ExportAssetPanel.jsx @@ -66,7 +66,7 @@ export default class ExportAssetPanel extends React.Component { }, 'csv': { label: "CSV", - icon: "fa fa-file-text" + icon: "fas fa-file-alt" } }; diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index cb601b51..8d238f67 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -511,6 +511,10 @@ _('Example:'), download = format => { let output = ""; let filename = `images.${format}`; + if (format === "geo"){ + filename = "geo.txt"; + } + const feats = { type: "FeatureCollection", features: this.exifData.map(ed => { @@ -538,6 +542,10 @@ _('Example:'), output = `Filename,Timestamp,Latitude,Longitude,Altitude\r\n${feats.features.map(feat => { return `${feat.properties.Filename},${feat.properties.Timestamp},${feat.geometry.coordinates[1]},${feat.geometry.coordinates[0]},${feat.geometry.coordinates[2]}` }).join("\r\n")}`; + }else if (format === 'geo'){ + output = `EPSG:4326\r\n${feats.features.map(feat => { + return `${feat.properties.Filename} ${feat.geometry.coordinates[0]} ${feat.geometry.coordinates[1]} ${feat.geometry.coordinates[2]}` + }).join("\r\n")}`; }else{ console.error("Invalid format"); } @@ -569,6 +577,7 @@ _('Example:'),
  • this.download('geojson')}> GeoJSON this.download('csv')}> CSV + this.download('geo')}> Geolocation File
  • : ""} From a1681c94d52e3e9182592f3a4348c98b86d9788c Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 18 Feb 2025 14:28:22 +0100 Subject: [PATCH 02/17] Localize strings --- app/static/app/js/components/MapPreview.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index 8d238f67..603af00a 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -575,9 +575,9 @@ _('Example:'), : ""} From 5c26ff0742ed9f178a5526ed28264acb3aeb86eb Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 18 Feb 2025 18:52:43 +0100 Subject: [PATCH 03/17] PoC alignment UI --- app/api/tasks.py | 18 ++++- app/models/task.py | 21 +++++- app/static/app/js/components/MapPreview.jsx | 22 ++++++- app/static/app/js/components/NewTaskPanel.jsx | 66 ++++++++++++++++++- .../app/js/components/ProjectListItem.jsx | 5 +- 5 files changed, 125 insertions(+), 7 deletions(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index b1869d61..90663bce 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -91,6 +91,7 @@ class TaskViewSet(viewsets.ViewSet): parser_classes = (parsers.MultiPartParser, parsers.JSONParser, parsers.FormParser, ) ordering_fields = '__all__' + filter_fields = ('status', ) # TODO: add filter fields def get_permissions(self): """ @@ -238,11 +239,25 @@ class TaskViewSet(viewsets.ViewSet): def create(self, request, project_pk=None): project = get_and_check_project(request, project_pk, ('change_project', )) + # Check if an alignment field is set to a valid task + # this means a user wants to align this task with another + align_to = request.data.get('align_to') + align_task = None + if align_to is not None and align_to != "auto" and align_to != "": + try: + align_task = models.Task.objects.get(pk=align_to) + get_and_check_project(request, align_task.project.id, ('view_project', )) + except ObjectDoesNotExist: + raise exceptions.ValidationError(detail=_("Cannot create task, alignment task is not valid")) + # If this is a partial task, we're going to upload images later # for now we just create a placeholder task. if request.data.get('partial'): task = models.Task.objects.create(project=project, pending_action=pending_actions.RESIZE if 'resize_to' in request.data else None) + if align_task is not None: + task.set_alignment_file_from(align_task) + serializer = TaskSerializer(task, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() @@ -255,7 +270,8 @@ class TaskViewSet(viewsets.ViewSet): with transaction.atomic(): task = models.Task.objects.create(project=project, pending_action=pending_actions.RESIZE if 'resize_to' in request.data else None) - + if align_task is not None: + task.set_alignment_file_from(align_task) task.handle_images_upload(files) task.images_count = len(task.scan_images()) diff --git a/app/models/task.py b/app/models/task.py index d05af610..86c5287f 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1241,7 +1241,26 @@ class Task(models.Model): def get_image_path(self, filename): p = self.task_path(filename) return path_traversal_check(p, self.task_path()) - + + def set_alignment_file_from(self, align_task): + tp = self.task_path() + if not os.path.exists(tp): + os.makedirs(tp, exist_ok=True) + + alignment_file = align_task.assets_path(self.ASSETS_MAP['georeferenced_model.laz']) + dst_file = self.task_path("align.laz") + + if os.path.exists(dst_file): + os.unlink(dst_file) + + if os.path.exists(alignment_file): + try: + os.link(alignment_file, dst_file) + except: + shutil.copy(alignment_file, dst_file) + else: + logger.warn("Cannot set alignment file for {}, {} does not exist".format(self, alignment_file)) + def handle_images_upload(self, files): uploaded = {} for file in files: diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index 603af00a..5244564c 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -24,12 +24,14 @@ const Colors = { class MapPreview extends React.Component { static defaultProps = { getFiles: null, - onPolygonChange: () => {} + onPolygonChange: () => {}, + onImagesBboxChanged: () => {} }; static propTypes = { getFiles: PropTypes.func.isRequired, - onPolygonChange: PropTypes.func + onPolygonChange: PropTypes.func, + onImagesBboxChanged: PropTypes.func }; constructor(props) { @@ -214,6 +216,8 @@ _('Example:'), this.map.fitBounds(this.imagesGroup.getBounds()); } + this.props.onImagesBboxChanged(this.computeBbox(this.exifData)); + this.setState({showLoading: false}); }).catch(e => { @@ -221,6 +225,20 @@ _('Example:'), }); } + computeBbox = exifData => { + // minx, maxx, miny, maxy + let bbox = [Infinity, -Infinity, Infinity, -Infinity]; + exifData.forEach(ed => { + if (ed.gps){ + bbox[0] = Math.min(bbox[0], ed.gps.longitude); + bbox[1] = Math.max(bbox[1], ed.gps.longitude); + bbox[2] = Math.min(bbox[2], ed.gps.latitude); + bbox[3] = Math.max(bbox[3], ed.gps.latitude); + } + }); + return bbox; + } + readExifData = () => { return new Promise((resolve, reject) => { const files = this.props.getFiles(); diff --git a/app/static/app/js/components/NewTaskPanel.jsx b/app/static/app/js/components/NewTaskPanel.jsx index 6d8e769c..fd0d8cfa 100644 --- a/app/static/app/js/components/NewTaskPanel.jsx +++ b/app/static/app/js/components/NewTaskPanel.jsx @@ -7,12 +7,15 @@ import ResizeModes from '../classes/ResizeModes'; import MapPreview from './MapPreview'; import update from 'immutability-helper'; import PluginsAPI from '../classes/plugins/API'; +import statusCodes from '../classes/StatusCodes'; import { _, interpolate } from '../classes/gettext'; class NewTaskPanel extends React.Component { static defaultProps = { filesCount: 0, - showResize: false + showResize: false, + showAlign: false, + projectId: null }; static propTypes = { @@ -20,7 +23,9 @@ class NewTaskPanel extends React.Component { onCancel: PropTypes.func, filesCount: PropTypes.number, showResize: PropTypes.bool, + showAlign: PropTypes.bool, getFiles: PropTypes.func, + projectId: PropTypes.number, suggestedTaskName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]) }; @@ -31,6 +36,9 @@ class NewTaskPanel extends React.Component { editTaskFormLoaded: false, resizeMode: Storage.getItem('resize_mode') === null ? ResizeModes.YES : ResizeModes.fromString(Storage.getItem('resize_mode')), resizeSize: parseInt(Storage.getItem('resize_size')) || 2048, + alignTo: "auto", + alignTasks: [], // loaded on mount if showAlign is true + loadingAlignTasks: false, items: [], // Coming from plugins, taskInfo: {}, inReview: false, @@ -63,6 +71,26 @@ class NewTaskPanel extends React.Component { }); } + componentWillUnmount(){ + if (this.alignTasksRequest) this.alignTasksRequest.abort(); + } + + loadAlignTasks = (bbox) => { + // TODO: filter by status on server + this.setState({alignTasks: [], alignTo: "auto", loadingAlignTasks: true}); + + this.alignTasksRequest = + $.getJSON(`/api/projects/${this.props.projectId}/tasks/?ordering=-created_at`, tasks => { + if (Array.isArray(tasks)){ + this.setState({loadingAlignTasks: false, alignTasks: tasks.filter(t => t.status === statusCodes.COMPLETED && t.available_assets.indexOf("georeferenced_model.laz") !== -1)}); + }else{ + this.setState({loadingAlignTasks: false}); + } + }).fail(() => { + this.setState({loadingAlignTasks: false}); + }); + } + save(e){ if (!this.state.inReview){ this.setState({inReview: true}); @@ -99,7 +127,8 @@ class NewTaskPanel extends React.Component { getTaskInfo(){ return Object.assign(this.taskForm.getTaskInfo(), { resizeSize: this.state.resizeSize, - resizeMode: this.state.resizeMode + resizeMode: this.state.resizeMode, + alignTo: this.state.alignTo }); } @@ -148,6 +177,21 @@ class NewTaskPanel extends React.Component { if (this.taskForm) this.taskForm.forceUpdate(); } + handleImagesBboxChange = (bbox) => { + if (this.props.showAlign){ + console.log("TODO! Load alignment tasks that fit within", bbox); + this.loadAlignTasks(bbox); + } + } + + handleAlignToChanged = e => { + this.setState({alignTo: e.target.value}); + + setTimeout(() => { + this.handleFormChanged(); + }, 0); + } + render() { let filesCountOk = true; if (this.taskForm && !this.taskForm.checkFilesCount(this.props.filesCount)) filesCountOk = false; @@ -176,6 +220,7 @@ class NewTaskPanel extends React.Component { {this.state.showMapPreview ? {this.mapPreview = domNode; }} /> : ""} @@ -189,6 +234,22 @@ class NewTaskPanel extends React.Component { ref={(domNode) => { if (domNode) this.taskForm = domNode; }} /> + {this.state.editTaskFormLoaded && this.props.showAlign && this.state.showMapPreview ? +
    +
    + +
    + +
    +
    +
    + : ""} + {this.state.editTaskFormLoaded && this.props.showResize ?
    @@ -228,6 +289,7 @@ class NewTaskPanel extends React.Component {
    )}
    : ""} + {this.state.editTaskFormLoaded ? diff --git a/app/static/app/js/components/ProjectListItem.jsx b/app/static/app/js/components/ProjectListItem.jsx index cd4bb20b..b5dcf72e 100644 --- a/app/static/app/js/components/ProjectListItem.jsx +++ b/app/static/app/js/components/ProjectListItem.jsx @@ -418,7 +418,8 @@ class ProjectListItem extends React.Component { options: taskInfo.options, processing_node: taskInfo.selectedNode.id, auto_processing_node: taskInfo.selectedNode.key == "auto", - partial: true + partial: true, + align_to: taskInfo.alignTo }; if (taskInfo.resizeMode === ResizeModes.YES){ @@ -780,6 +781,8 @@ class ProjectListItem extends React.Component { suggestedTaskName={this.handleTaskTitleHint} filesCount={this.state.upload.totalCount} showResize={true} + showAlign={numTasks > 0} + projectId={this.state.data.id} getFiles={() => this.state.upload.files } /> : ""} From 8570bf8e42f096c3a70b4c63e79111c5394ab71e Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 18 Feb 2025 20:00:19 +0100 Subject: [PATCH 04/17] PoC thumbnails --- app/api/tasks.py | 65 ++++++++++- app/api/urls.py | 3 +- app/models/task.py | 5 + app/static/app/js/components/TaskListItem.jsx | 108 ++++++++++-------- app/static/app/js/css/TaskListItem.scss | 4 + 5 files changed, 135 insertions(+), 50 deletions(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index 90663bce..c4118390 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -4,6 +4,10 @@ import shutil from wsgiref.util import FileWrapper import mimetypes +import rasterio +from rasterio.enums import ColorInterp +from PIL import Image +import io from shutil import copyfileobj, move from django.core.exceptions import ObjectDoesNotExist, SuspiciousFileOperation, ValidationError @@ -235,7 +239,7 @@ class TaskViewSet(viewsets.ViewSet): return Response({'success': True, 'task': TaskSerializer(new_task).data}, status=status.HTTP_200_OK) else: return Response({'error': _("Cannot duplicate task")}, status=status.HTTP_200_OK) - + def create(self, request, project_pk=None): project = get_and_check_project(request, project_pk, ('change_project', )) @@ -403,6 +407,65 @@ class TaskDownloads(TaskNestedView): else: return download_file_response(request, asset_fs, 'attachment', download_filename=download_filename) + +class TaskThumbnail(TaskNestedView): + def get(self, request, pk=None, project_pk=None): + """ + Generate a thumbnail on the fly for a particular task + """ + task = self.get_and_check_task(request, pk) + orthophoto_path = task.get_check_file_asset_path("orthophoto.tif") + if orthophoto_path is None: + raise exceptions.NotFound() + + try: + thumb_size = int(self.request.query_params.get('size', 512)) + if thumb_size < 1 or thumb_size > 2048: + raise ValueError() + + quality = int(self.request.query_params.get('quality', 75)) + if quality < 0 or quality > 100: + raise ValueError() + except ValueError: + raise exceptions.ValidationError("Invalid query parameters") + + with rasterio.open(orthophoto_path, "r") as raster: + ci = raster.colorinterp + indexes = (1, 2, 3,) + + # More than 4 bands? + if len(ci) > 4: + # Try to find RGBA band order + if ColorInterp.red in ci and \ + ColorInterp.green in ci and \ + ColorInterp.blue in ci: + indexes = (ci.index(ColorInterp.red) + 1, + ci.index(ColorInterp.green) + 1, + ci.index(ColorInterp.blue) + 1,) + elif len(ci) < 3: + raise exceptions.NotFound() + + if ColorInterp.alpha in ci: + indexes += (ci.index(ColorInterp.alpha) + 1, ) + + img = raster.read(indexes=indexes, boundless=True, fill_value=255, out_shape=( + len(indexes), + thumb_size, + thumb_size, + ), resampling=rasterio.enums.Resampling.nearest).transpose((1, 2, 0)) + + img = Image.fromarray(img) + output = io.BytesIO() + img.save(output, format='PNG', quality=quality) + + res = HttpResponse(content_type="image/png") + res['Content-Disposition'] = 'inline' + res.write(output.getvalue()) + output.close() + + return res + + """ Raw access to the task's asset folder resources Useful when accessing a textured 3d model, or the Potree point cloud data diff --git a/app/api/urls.py b/app/api/urls.py index 7cf862d4..dce1744c 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -3,7 +3,7 @@ from django.conf.urls import url, include from app.api.presets import PresetViewSet from app.plugins.views import api_view_handler from .projects import ProjectViewSet -from .tasks import TaskViewSet, TaskDownloads, TaskAssets, TaskBackup, TaskAssetsImport +from .tasks import TaskViewSet, TaskDownloads, TaskThumbnail, TaskAssets, TaskBackup, TaskAssetsImport from .imageuploads import Thumbnail, ImageDownload from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView from .admin import AdminUserViewSet, AdminGroupViewSet, AdminProfileViewSet @@ -46,6 +46,7 @@ urlpatterns = [ url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/download/(?P.+)$', TaskDownloads.as_view()), url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/assets/(?P.+)$', TaskAssets.as_view()), url(r'projects/(?P[^/.]+)/tasks/import$', TaskAssetsImport.as_view()), + url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/thumbnail$', TaskThumbnail.as_view()), url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/backup$', TaskBackup.as_view()), url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/images/thumbnail/(?P.+)$', Thumbnail.as_view()), url(r'projects/(?P[^/.]+)/tasks/(?P[^/.]+)/images/download/(?P.+)$', ImageDownload.as_view()), diff --git a/app/models/task.py b/app/models/task.py index 86c5287f..0fdd9b81 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1260,6 +1260,11 @@ class Task(models.Model): shutil.copy(alignment_file, dst_file) else: logger.warn("Cannot set alignment file for {}, {} does not exist".format(self, alignment_file)) + + def get_check_file_asset_path(self, asset): + file = self.assets_path(self.ASSETS_MAP[asset]) + if isinstance(file, str) and os.path.isfile(file): + return file def handle_images_upload(self, files): uploaded = {} diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 2be820c0..2397115a 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -171,6 +171,10 @@ class TaskListItem extends React.Component { return `/api/projects/${this.state.task.project}/tasks/${this.state.task.id}/output/?line=${line}`; } + thumbnailUrl = () => { + return `/api/projects/${this.state.task.project}/tasks/${this.state.task.id}/thumbnail?size=192`; + } + hoursMinutesSecs(t){ if (t === 0 || t === -1) return "-- : -- : --"; @@ -556,54 +560,62 @@ class TaskListItem extends React.Component {
    - - - - - - - - - - - {Array.isArray(task.options) && - - - - } - {stats && stats.gsd && - - - - } - {stats && stats.area && - - - - } - {stats && stats.pointcloud && stats.pointcloud.points && - - - - } - {task.size > 0 && - - - - } - - - - - - - - - -
    {_("Created on:")}{(new Date(task.created_at)).toLocaleString()}
    {_("Processing Node:")}{task.processing_node_name || "-"} ({task.auto_processing_node ? _("auto") : _("manual")})
    {_("Options:")}{this.optionsToList(task.options)}
    {_("Average GSD:")}{parseFloat(stats.gsd.toFixed(2)).toLocaleString()} cm
    {_("Area:")}{parseFloat(stats.area.toFixed(2)).toLocaleString()} m²
    {_("Reconstructed Points:")}{stats.pointcloud.points.toLocaleString()}
    {_("Disk Usage:")}{Utils.bytesToSize(task.size * 1024 * 1024)}
    {_("Task ID:")}{task.id}
    {_("Task Output:")}
    - - -
    +
    + + + + + + + + + + + {Array.isArray(task.options) && + + + + } + {stats && stats.gsd && + + + + } + {stats && stats.area && + + + + } + {stats && stats.pointcloud && stats.pointcloud.points && + + + + } + {task.size > 0 && + + + + } + + + + + + + + + +
    {_("Created on:")}{(new Date(task.created_at)).toLocaleString()}
    {_("Processing Node:")}{task.processing_node_name || "-"} ({task.auto_processing_node ? _("auto") : _("manual")})
    {_("Options:")}{this.optionsToList(task.options)}
    {_("Average GSD:")}{parseFloat(stats.gsd.toFixed(2)).toLocaleString()} cm
    {_("Area:")}{parseFloat(stats.area.toFixed(2)).toLocaleString()} m²
    {_("Reconstructed Points:")}{stats.pointcloud.points.toLocaleString()}
    {_("Disk Usage:")}{Utils.bytesToSize(task.size * 1024 * 1024)}
    {_("Task ID:")}{task.id}
    {_("Task Output:")}
    + + +
    +
    + {task.status === statusCodes.COMPLETED ? +
    + + {_("Thumbnail")}/ + +
    : ""} {this.state.view === 'console' ? Date: Tue, 18 Feb 2025 20:28:19 +0100 Subject: [PATCH 05/17] Handle thumb errors, normalize non-int8 --- app/api/tasks.py | 14 ++++++++++++++ app/static/app/js/components/TaskListItem.jsx | 9 +++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index c4118390..29bb6232 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -8,6 +8,7 @@ import rasterio from rasterio.enums import ColorInterp from PIL import Image import io +import numpy as np from shutil import copyfileobj, move from django.core.exceptions import ObjectDoesNotExist, SuspiciousFileOperation, ValidationError @@ -453,7 +454,20 @@ class TaskThumbnail(TaskNestedView): thumb_size, thumb_size, ), resampling=rasterio.enums.Resampling.nearest).transpose((1, 2, 0)) + + if img.dtype != np.uint8: + img = img.astype(np.float32) + # Ignore alpha values + minval = img[:,:,:3].min() + maxval = img[:,:,:3].max() + + if minval != maxval: + img[:,:,:3] -= minval + img[:,:,:3] *= (255.0/(maxval-minval)) + + img = img.astype(np.uint8) + img = Image.fromarray(img) output = io.BytesIO() img.save(output, format='PNG', quality=quality) diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 2397115a..fcbe477d 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -48,6 +48,7 @@ class TaskListItem extends React.Component { view: "basic", showMoveDialog: false, actionLoading: false, + thumbLoadFailed: false } for (let k in props.data){ @@ -429,6 +430,10 @@ class TaskListItem extends React.Component { } } + handleThumbError = e => { + this.setState({thumbLoadFailed: true}); + } + render() { const task = this.state.task; const name = task.name !== null ? task.name : interpolate(_("Task #%(number)s"), { number: task.id }); @@ -610,10 +615,10 @@ class TaskListItem extends React.Component {
    - {task.status === statusCodes.COMPLETED ? + {!this.state.thumbLoadFailed && task.status === statusCodes.COMPLETED ? : ""} From b7e1e56b01af76a53190618b56b696a254d0a5b9 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Feb 2025 01:00:49 +0100 Subject: [PATCH 06/17] Thumbnail hover --- app/static/app/js/css/TaskListItem.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/static/app/js/css/TaskListItem.scss b/app/static/app/js/css/TaskListItem.scss index 95145c09..bfaceeca 100644 --- a/app/static/app/js/css/TaskListItem.scss +++ b/app/static/app/js/css/TaskListItem.scss @@ -159,5 +159,8 @@ .task-thumbnail{ max-width: 100%; + &:hover{ + opacity: 0.9; + } } } From 041598979d4e74ad2971d415b4ac067c820c6bb0 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Feb 2025 01:45:03 +0100 Subject: [PATCH 07/17] Server side task filtering --- app/api/tasks.py | 24 ++++++++++++++++--- app/static/app/js/components/NewTaskPanel.jsx | 6 ++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index 29bb6232..17a60d97 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -96,8 +96,7 @@ class TaskViewSet(viewsets.ViewSet): parser_classes = (parsers.MultiPartParser, parsers.JSONParser, parsers.FormParser, ) ordering_fields = '__all__' - filter_fields = ('status', ) # TODO: add filter fields - + def get_permissions(self): """ Instantiates and returns the list of permissions that this view requires. @@ -159,7 +158,26 @@ class TaskViewSet(viewsets.ViewSet): def list(self, request, project_pk=None): get_and_check_project(request, project_pk) - tasks = self.queryset.filter(project=project_pk) + query = { + 'project': project_pk + } + + status = request.query_params.get('status') + if status is not None: + try: + query['status'] = int(status) + except ValueError: + raise exceptions.ValidationError("Invalid status parameter") + + available_assets = request.query_params.get('available_assets') + if available_assets is not None: + assets = [a.strip() for a in available_assets.split(",") if a.strip() != ""] + for a in assets: + query['available_assets__contains'] = "{" + a + "}" + + # TODO bounding box filtering + + tasks = self.queryset.filter(**query) tasks = filters.OrderingFilter().filter_queryset(self.request, tasks, self) serializer = TaskSerializer(tasks, many=True) return Response(serializer.data) diff --git a/app/static/app/js/components/NewTaskPanel.jsx b/app/static/app/js/components/NewTaskPanel.jsx index fd0d8cfa..f63becfd 100644 --- a/app/static/app/js/components/NewTaskPanel.jsx +++ b/app/static/app/js/components/NewTaskPanel.jsx @@ -76,13 +76,13 @@ class NewTaskPanel extends React.Component { } loadAlignTasks = (bbox) => { - // TODO: filter by status on server + // TODO: filter by bbox this.setState({alignTasks: [], alignTo: "auto", loadingAlignTasks: true}); this.alignTasksRequest = - $.getJSON(`/api/projects/${this.props.projectId}/tasks/?ordering=-created_at`, tasks => { + $.getJSON(`/api/projects/${this.props.projectId}/tasks/?ordering=-created_at&status=${statusCodes.COMPLETED}&available_assets=georeferenced_model.laz`, tasks => { if (Array.isArray(tasks)){ - this.setState({loadingAlignTasks: false, alignTasks: tasks.filter(t => t.status === statusCodes.COMPLETED && t.available_assets.indexOf("georeferenced_model.laz") !== -1)}); + this.setState({loadingAlignTasks: false, alignTasks: tasks}); }else{ this.setState({loadingAlignTasks: false}); } From 4793e6add7177918d41e3be1ae3ea9cefb10bc97 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Feb 2025 15:47:09 -0500 Subject: [PATCH 08/17] Fix thumb aspect ratio --- app/api/tasks.py | 9 ++++++++- app/static/app/js/components/TaskListItem.jsx | 8 ++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index 17a60d97..c13038d5 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -466,8 +466,15 @@ class TaskThumbnail(TaskNestedView): if ColorInterp.alpha in ci: indexes += (ci.index(ColorInterp.alpha) + 1, ) + + w = raster.width + h = raster.height + d = max(w, h) + dw = (d - w) // 2 + dh = (d - h) // 2 + win = rasterio.windows.Window(-dw, -dh, d, d) - img = raster.read(indexes=indexes, boundless=True, fill_value=255, out_shape=( + img = raster.read(indexes=indexes, window=win, boundless=True, fill_value=0, out_shape=( len(indexes), thumb_size, thumb_size, diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index fcbe477d..16ad3f60 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -173,7 +173,7 @@ class TaskListItem extends React.Component { } thumbnailUrl = () => { - return `/api/projects/${this.state.task.project}/tasks/${this.state.task.id}/thumbnail?size=192`; + return `/api/projects/${this.state.task.project}/tasks/${this.state.task.id}/thumbnail?size=156`; } hoursMinutesSecs(t){ @@ -434,6 +434,10 @@ class TaskListItem extends React.Component { this.setState({thumbLoadFailed: true}); } + handleThumbLoad = e => { + console.log("LOADED") + } + render() { const task = this.state.task; const name = task.name !== null ? task.name : interpolate(_("Task #%(number)s"), { number: task.id }); @@ -618,7 +622,7 @@ class TaskListItem extends React.Component { {!this.state.thumbLoadFailed && task.status === statusCodes.COMPLETED ? : ""} From 1b3be7eee24e12fec08bd6ed45867ddbfdc0b526 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Feb 2025 16:18:10 -0500 Subject: [PATCH 09/17] Progressive thumbs, webp --- app/api/imageuploads.py | 2 +- app/api/tasks.py | 20 +++++++++---------- app/static/app/js/components/TaskListItem.jsx | 6 +----- app/static/app/js/css/TaskListItem.scss | 4 ++++ 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/app/api/imageuploads.py b/app/api/imageuploads.py index 64efabd5..1e3eba59 100644 --- a/app/api/imageuploads.py +++ b/app/api/imageuploads.py @@ -125,7 +125,7 @@ class Thumbnail(TaskNestedView): img.thumbnail((thumb_size, thumb_size)) output = io.BytesIO() - img.save(output, format='JPEG', quality=quality) + img.save(output, format='JPEG', quality=quality, progressive=True) res = HttpResponse(content_type="image/jpeg") res['Content-Disposition'] = 'inline' diff --git a/app/api/tasks.py b/app/api/tasks.py index c13038d5..41625b8a 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -437,15 +437,8 @@ class TaskThumbnail(TaskNestedView): if orthophoto_path is None: raise exceptions.NotFound() - try: - thumb_size = int(self.request.query_params.get('size', 512)) - if thumb_size < 1 or thumb_size > 2048: - raise ValueError() - - quality = int(self.request.query_params.get('quality', 75)) - if quality < 0 or quality > 100: - raise ValueError() - except ValueError: + thumb_size = int(self.request.query_params.get('size', 512)) + if thumb_size < 1 or thumb_size > 2048: raise exceptions.ValidationError("Invalid query parameters") with rasterio.open(orthophoto_path, "r") as raster: @@ -495,9 +488,14 @@ class TaskThumbnail(TaskNestedView): img = Image.fromarray(img) output = io.BytesIO() - img.save(output, format='PNG', quality=quality) - res = HttpResponse(content_type="image/png") + if 'image/webp' in request.META.get('HTTP_ACCEPT', ''): + img.save(output, format='WEBP') + res = HttpResponse(content_type="image/webp") + else: + img.save(output, format='PNG') + res = HttpResponse(content_type="image/png") + res['Content-Disposition'] = 'inline' res.write(output.getvalue()) output.close() diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 16ad3f60..12733f33 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -434,10 +434,6 @@ class TaskListItem extends React.Component { this.setState({thumbLoadFailed: true}); } - handleThumbLoad = e => { - console.log("LOADED") - } - render() { const task = this.state.task; const name = task.name !== null ? task.name : interpolate(_("Task #%(number)s"), { number: task.id }); @@ -622,7 +618,7 @@ class TaskListItem extends React.Component { {!this.state.thumbLoadFailed && task.status === statusCodes.COMPLETED ? : ""} diff --git a/app/static/app/js/css/TaskListItem.scss b/app/static/app/js/css/TaskListItem.scss index bfaceeca..a76a8d54 100644 --- a/app/static/app/js/css/TaskListItem.scss +++ b/app/static/app/js/css/TaskListItem.scss @@ -158,7 +158,11 @@ } .task-thumbnail{ + width: 156px; + height: 156px; max-width: 100%; + max-height: 156px; + overflow: hidden; &:hover{ opacity: 0.9; } From 424fb7a2ccf37468512b4202a200625b60b45398 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Feb 2025 17:45:03 -0500 Subject: [PATCH 10/17] Bbox server side filtering --- app/api/tasks.py | 11 ++++++++++- app/static/app/js/components/MapPreview.jsx | 8 ++++---- app/static/app/js/components/NewTaskPanel.jsx | 4 +--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index 41625b8a..99b3ec57 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -17,6 +17,7 @@ from django.db import transaction from django.http import FileResponse from django.http import HttpResponse from django.http import StreamingHttpResponse +from django.contrib.gis.geos import Polygon from app.vendor import zipfly from rest_framework import status, serializers, viewsets, filters, exceptions, permissions, parsers from rest_framework.decorators import action @@ -175,7 +176,15 @@ class TaskViewSet(viewsets.ViewSet): for a in assets: query['available_assets__contains'] = "{" + a + "}" - # TODO bounding box filtering + bbox = request.query_params.get('bbox') + if bbox is not None: + try: + xmin, ymin, xmax, ymax = [float(v) for v in bbox.split(",")] + except: + raise exceptions.ValidationError("Invalid bbox parameter") + + geom = Polygon.from_bbox((xmin, ymin, xmax, ymax)) + query['orthophoto_extent__intersects'] = geom tasks = self.queryset.filter(**query) tasks = filters.OrderingFilter().filter_queryset(self.request, tasks, self) diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index 5244564c..dbfc9e8a 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -226,13 +226,13 @@ _('Example:'), } computeBbox = exifData => { - // minx, maxx, miny, maxy - let bbox = [Infinity, -Infinity, Infinity, -Infinity]; + // minx, miny, maxx, maxy + let bbox = [Infinity, Infinity, -Infinity, -Infinity]; exifData.forEach(ed => { if (ed.gps){ bbox[0] = Math.min(bbox[0], ed.gps.longitude); - bbox[1] = Math.max(bbox[1], ed.gps.longitude); - bbox[2] = Math.min(bbox[2], ed.gps.latitude); + bbox[1] = Math.min(bbox[1], ed.gps.latitude); + bbox[2] = Math.max(bbox[2], ed.gps.longitude); bbox[3] = Math.max(bbox[3], ed.gps.latitude); } }); diff --git a/app/static/app/js/components/NewTaskPanel.jsx b/app/static/app/js/components/NewTaskPanel.jsx index f63becfd..9ea64ed7 100644 --- a/app/static/app/js/components/NewTaskPanel.jsx +++ b/app/static/app/js/components/NewTaskPanel.jsx @@ -76,11 +76,10 @@ class NewTaskPanel extends React.Component { } loadAlignTasks = (bbox) => { - // TODO: filter by bbox this.setState({alignTasks: [], alignTo: "auto", loadingAlignTasks: true}); this.alignTasksRequest = - $.getJSON(`/api/projects/${this.props.projectId}/tasks/?ordering=-created_at&status=${statusCodes.COMPLETED}&available_assets=georeferenced_model.laz`, tasks => { + $.getJSON(`/api/projects/${this.props.projectId}/tasks/?ordering=-created_at&status=${statusCodes.COMPLETED}&available_assets=georeferenced_model.laz&bbox=${bbox.join(",")}`, tasks => { if (Array.isArray(tasks)){ this.setState({loadingAlignTasks: false, alignTasks: tasks}); }else{ @@ -179,7 +178,6 @@ class NewTaskPanel extends React.Component { handleImagesBboxChange = (bbox) => { if (this.props.showAlign){ - console.log("TODO! Load alignment tasks that fit within", bbox); this.loadAlignTasks(bbox); } } From 9d29a8922728154372954a04502c97a8eaceb1b7 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Feb 2025 18:06:44 -0500 Subject: [PATCH 11/17] Add task extent --- app/api/tasks.py | 13 ++++++++++++- app/static/app/js/components/MapPreview.jsx | 4 ++++ app/static/app/js/components/NewTaskPanel.jsx | 7 +++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index 99b3ec57..c4e12d75 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -52,6 +52,7 @@ class TaskSerializer(serializers.ModelSerializer): processing_node_name = serializers.SerializerMethodField() can_rerun_from = serializers.SerializerMethodField() statistics = serializers.SerializerMethodField() + extent = serializers.SerializerMethodField() tags = TagsField(required=False) def get_processing_node_name(self, obj): @@ -82,6 +83,16 @@ class TaskSerializer(serializers.ModelSerializer): return [] + def get_extent(self, obj): + if obj.orthophoto_extent is not None: + return obj.orthophoto_extent.extent + elif obj.dsm_extent is not None: + return obj.dsm_extent.extent + elif obj.dtm_extent is not None: + return obj.dsm_extent.extent + else: + return None + class Meta: model = models.Task exclude = ('orthophoto_extent', 'dsm_extent', 'dtm_extent', ) @@ -93,7 +104,7 @@ class TaskViewSet(viewsets.ViewSet): A task represents a set of images and other input to be sent to a processing node. Once a processing node completes processing, results are stored in the task. """ - queryset = models.Task.objects.all().defer('orthophoto_extent', 'dsm_extent', 'dtm_extent', ) + queryset = models.Task.objects.all().defer('dsm_extent', 'dtm_extent', ) parser_classes = (parsers.MultiPartParser, parsers.JSONParser, parsers.FormParser, ) ordering_fields = '__all__' diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index dbfc9e8a..ac65e58e 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -526,6 +526,10 @@ _('Example:'), })); } + setAlignmentPolygon = (task) => { + console.log(task.extent) + } + download = format => { let output = ""; let filename = `images.${format}`; diff --git a/app/static/app/js/components/NewTaskPanel.jsx b/app/static/app/js/components/NewTaskPanel.jsx index 9ea64ed7..0c9189fc 100644 --- a/app/static/app/js/components/NewTaskPanel.jsx +++ b/app/static/app/js/components/NewTaskPanel.jsx @@ -184,6 +184,13 @@ class NewTaskPanel extends React.Component { handleAlignToChanged = e => { this.setState({alignTo: e.target.value}); + if (this.mapPreview){ + if (e.target.value !== "auto"){ + this.mapPreview.setAlignmentPolygon(this.state.alignTasks.find(t => t.id === e.target.value)); + }else{ + this.mapPreview.setAlignmentPolygon(null); + } + } setTimeout(() => { this.handleFormChanged(); From 7c4bfb5c1653180e5fc8529b7e479607d1ce2d5d Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Feb 2025 18:30:34 -0500 Subject: [PATCH 12/17] Highlight alignment extent on align selection --- app/static/app/js/components/MapPreview.jsx | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index ac65e58e..36249f18 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -527,7 +527,35 @@ _('Example:'), } setAlignmentPolygon = (task) => { - console.log(task.extent) + if (this.alignPoly){ + this.map.removeLayer(this.alignPoly); + this.alignPoly = null; + } + + if (!task || !task.extent){ + if (this.imagesGroup) this.map.fitBounds(this.imagesGroup.getBounds()); + return; + } + + const [xmin, ymin, xmax, ymax] = task.extent; + + this.alignPoly = L.polygon([ + [ymin, xmin], + [ymax, xmin], + [ymax, xmax], + [ymin, xmax], + [ymin, xmin] + ], { + clickable: true, + weight: 3, + opacity: 0.9, + color: "#808f9b", + fillColor: "#808f9b", + fillOpacity: 0.2 + }).bindPopup(task.name).addTo(this.map); + + this.alignPoly.bringToBack(); + this.map.fitBounds(this.alignPoly.getBounds()); } download = format => { From 8174bb998731eb34e1b7b93532190facdc22a581 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Feb 2025 23:17:24 -0500 Subject: [PATCH 13/17] Add spatial reference info field --- app/models/task.py | 11 ++++++++++- app/static/app/js/components/TaskListItem.jsx | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/models/task.py b/app/models/task.py index 0fdd9b81..72529f65 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -411,7 +411,15 @@ class Task(models.Model): points = j.get('point_cloud_statistics', {}).get('stats', {}).get('statistic', [{}])[0].get('count') else: points = j.get('reconstruction_statistics', {}).get('reconstructed_points_count') - + + spatial_refs = [] + if j.get('reconstruction_statistics', {}).get('has_gps'): + spatial_refs.append("gps") + if j.get('reconstruction_statistics', {}).get('has_gcp') and 'average_error' in j.get('gcp_errors', {}): + spatial_refs.append("gcp") + if 'align' in j: + spatial_refs.append("alignment") + return { 'pointcloud':{ 'points': points, @@ -420,6 +428,7 @@ class Task(models.Model): 'area': j.get('processing_statistics', {}).get('area'), 'start_date': j.get('processing_statistics', {}).get('start_date'), 'end_date': j.get('processing_statistics', {}).get('end_date'), + 'spatial_refs': spatial_refs, } else: return {} diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 12733f33..e72bc144 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -434,6 +434,20 @@ class TaskListItem extends React.Component { this.setState({thumbLoadFailed: true}); } + spatialRefsToHuman = (refs) => { + if (refs.indexOf("alignment") !== -1) return _("Alignment"); + + let out = []; + if (refs.indexOf("gps") !== -1){ + out.push(_("GPS")); + } + if (refs.indexOf("gcp") !== -1){ + out.push(_("GCP")); + } + + return out.join("/"); + } + render() { const task = this.state.task; const name = task.name !== null ? task.name : interpolate(_("Task #%(number)s"), { number: task.id }); @@ -596,6 +610,11 @@ class TaskListItem extends React.Component { {_("Reconstructed Points:")} {stats.pointcloud.points.toLocaleString()} } + {stats && stats.spatial_refs && stats.spatial_refs.length && + + {_("Spatial Reference:")} + {this.spatialRefsToHuman(stats.spatial_refs)} + } {task.size > 0 && {_("Disk Usage:")} From c1c75e96a31baedd9103f4d6c0c7039f105041da Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 19 Feb 2025 23:20:25 -0500 Subject: [PATCH 14/17] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a26adbc8..13643e33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WebODM", - "version": "2.7.0", + "version": "2.7.1", "description": "User-friendly, extendable application and API for processing aerial imagery.", "main": "index.js", "scripts": { From 9ac864a6ab739dc97382c6eba138e06ab5f1e933 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 20 Feb 2025 11:51:05 -0500 Subject: [PATCH 15/17] Fixes, unit tests --- app/api/tasks.py | 25 ++-- app/static/app/js/components/TaskListItem.jsx | 2 +- app/static/app/js/css/TaskListItem.scss | 6 +- app/tests/test_api_task.py | 129 ++++++++++++++++++ 4 files changed, 147 insertions(+), 15 deletions(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index c4e12d75..a04e0389 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -24,6 +24,7 @@ from rest_framework.decorators import action from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView +from django.db.models import Q from app import models, pending_actions from nodeodm import status_codes @@ -104,7 +105,7 @@ class TaskViewSet(viewsets.ViewSet): A task represents a set of images and other input to be sent to a processing node. Once a processing node completes processing, results are stored in the task. """ - queryset = models.Task.objects.all().defer('dsm_extent', 'dtm_extent', ) + queryset = models.Task.objects.all() parser_classes = (parsers.MultiPartParser, parsers.JSONParser, parsers.FormParser, ) ordering_fields = '__all__' @@ -170,14 +171,12 @@ class TaskViewSet(viewsets.ViewSet): def list(self, request, project_pk=None): get_and_check_project(request, project_pk) - query = { - 'project': project_pk - } + query = Q(project=project_pk) status = request.query_params.get('status') if status is not None: try: - query['status'] = int(status) + query &= Q(status=int(status)) except ValueError: raise exceptions.ValidationError("Invalid status parameter") @@ -185,7 +184,7 @@ class TaskViewSet(viewsets.ViewSet): if available_assets is not None: assets = [a.strip() for a in available_assets.split(",") if a.strip() != ""] for a in assets: - query['available_assets__contains'] = "{" + a + "}" + query &= Q(available_assets__contains="{" + a + "}") bbox = request.query_params.get('bbox') if bbox is not None: @@ -195,9 +194,11 @@ class TaskViewSet(viewsets.ViewSet): raise exceptions.ValidationError("Invalid bbox parameter") geom = Polygon.from_bbox((xmin, ymin, xmax, ymax)) - query['orthophoto_extent__intersects'] = geom + query &= Q(orthophoto_extent__intersects=geom) | \ + Q(dsm_extent__intersects=geom) | \ + Q(dtm_extent__intersects=geom) - tasks = self.queryset.filter(**query) + tasks = self.queryset.filter(query) tasks = filters.OrderingFilter().filter_queryset(self.request, tasks, self) serializer = TaskSerializer(tasks, many=True) return Response(serializer.data) @@ -457,9 +458,11 @@ class TaskThumbnail(TaskNestedView): if orthophoto_path is None: raise exceptions.NotFound() - thumb_size = int(self.request.query_params.get('size', 512)) - if thumb_size < 1 or thumb_size > 2048: - raise exceptions.ValidationError("Invalid query parameters") + thumb_size = 256 + try: + thumb_size = max(1, min(1024, int(request.query_params.get('size', 256)))) + except ValueError: + pass with rasterio.open(orthophoto_path, "r") as raster: ci = raster.colorinterp diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index e72bc144..8740228c 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -173,7 +173,7 @@ class TaskListItem extends React.Component { } thumbnailUrl = () => { - return `/api/projects/${this.state.task.project}/tasks/${this.state.task.id}/thumbnail?size=156`; + return `/api/projects/${this.state.task.project}/tasks/${this.state.task.id}/thumbnail?size=164`; } hoursMinutesSecs(t){ diff --git a/app/static/app/js/css/TaskListItem.scss b/app/static/app/js/css/TaskListItem.scss index a76a8d54..cc949388 100644 --- a/app/static/app/js/css/TaskListItem.scss +++ b/app/static/app/js/css/TaskListItem.scss @@ -158,10 +158,10 @@ } .task-thumbnail{ - width: 156px; - height: 156px; + width: 164px; + height: 164px; max-width: 100%; - max-height: 156px; + max-height: 164px; overflow: hidden; &:hover{ opacity: 0.9; diff --git a/app/tests/test_api_task.py b/app/tests/test_api_task.py index c22dbf41..45ce9a66 100644 --- a/app/tests/test_api_task.py +++ b/app/tests/test_api_task.py @@ -14,6 +14,7 @@ from PIL import Image from django.contrib.auth.models import User from rest_framework import status from rest_framework.test import APIClient +from django.contrib.gis.geos import Polygon import worker from django.utils import timezone @@ -237,6 +238,9 @@ class TestApiTask(BootTransactionTestCase): # Can_rerun_from should be an empty list self.assertTrue(len(res.data['can_rerun_from']) == 0) + # Extent should be null + self.assertTrue(res.data['extent'] is None) + # processing_node_name should be null self.assertTrue(res.data['processing_node_name'] is None) @@ -294,10 +298,18 @@ class TestApiTask(BootTransactionTestCase): res = client.get("/api/projects/{}/tasks/{}/images/download/tiny_drone_image.jpg".format(other_project.id, other_task.id)) self.assertTrue(res.status_code == status.HTTP_404_NOT_FOUND) + # Cannot get thumbnail for task we have no access to + res = client.get("/api/projects/{}/tasks/{}/thumbnail".format(other_project.id, other_task.id)) + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + # Cannot duplicate a task we have no access to res = client.post("/api/projects/{}/tasks/{}/duplicate/".format(other_project.id, other_task.id)) self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + # Cannot get thumbnail for task that is not processed + res = client.get("/api/projects/{}/tasks/{}/thumbnail".format(project.id, task.id)) + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + # Cannot export orthophoto res = client.post("/api/projects/{}/tasks/{}/orthophoto/export".format(project.id, task.id), { 'formula': 'NDVI', @@ -362,6 +374,14 @@ class TestApiTask(BootTransactionTestCase): # processing_node_name should be the name of the pnode self.assertEqual(res.data['processing_node_name'], str(pnode)) + # extent should be populated + self.assertEqual(len(res.data['extent']), 4) + self.assertTrue(isinstance(res.data['extent'][0], float)) + + # Can get thumbnail + res = client.get("/api/projects/{}/tasks/{}/thumbnail".format(project.id, task.id)) + self.assertEqual(res.status_code, status.HTTP_200_OK) + # Can download assets for asset in list(task.ASSETS_MAP.keys()): res = client.get("/api/projects/{}/tasks/{}/download/{}".format(project.id, task.id, asset)) @@ -435,6 +455,36 @@ class TestApiTask(BootTransactionTestCase): self.assertEqual(i.width, 48) self.assertEqual(i.height, 36) + # Can access task thumbnails + res = client.get("/api/projects/{}/tasks/{}/thumbnail?size=128".format(project.id, task.id)) + self.assertEqual(res.status_code, status.HTTP_200_OK) + with Image.open(io.BytesIO(res.content)) as i: + # Thumbnail has requested size + self.assertEqual(i.width, 128) + self.assertEqual(i.height, 128) + + # Should be PNG + self.assertEqual(i.format, "PNG") + + # Can make a bad thumbnail size request + res = client.get("/api/projects/{}/tasks/{}/thumbnail?size=abc".format(project.id, task.id)) + self.assertEqual(res.status_code, status.HTTP_200_OK) + with Image.open(io.BytesIO(res.content)) as i: + # Thumbnail has default size + self.assertEqual(i.width, 256) + self.assertEqual(i.height, 256) + + # Can get webp thumbnails, use out of bounds size parameter + res = client.get("/api/projects/{}/tasks/{}/thumbnail?size=-5".format(project.id, task.id), HTTP_ACCEPT="image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + self.assertEqual(res.status_code, status.HTTP_200_OK) + with Image.open(io.BytesIO(res.content)) as i: + # Thumbnail has size 1 due to out of bounds value + self.assertEqual(i.width, 1) + self.assertEqual(i.height, 1) + + # Should be WEBP + self.assertEqual(i.format, "WEBP") + # Can download images res = client.get("/api/projects/{}/tasks/{}/images/download/tiny_drone_image.jpg".format(project.id, task.id)) self.assertTrue(res.status_code == status.HTTP_200_OK) @@ -976,6 +1026,9 @@ class TestApiTask(BootTransactionTestCase): self.assertFalse('orthophoto_tiles.zip' in res.data['available_assets']) self.assertTrue('textured_model.zip' in res.data['available_assets']) + # Extent should be set + self.assertTrue(len(res.data['extent']), 4) + # Can duplicate a task res = client.post("/api/projects/{}/tasks/{}/duplicate/".format(project.id, task.id)) self.assertTrue(res.status_code, status.HTTP_200_OK) @@ -992,6 +1045,29 @@ class TestApiTask(BootTransactionTestCase): # Directories have been created self.assertTrue(os.path.exists(new_task.task_path())) + # Can create task with align_to parameter + res = client.post("/api/projects/{}/tasks/".format(project.id), { + 'images': [image1, image2], + 'name': 'test_align_task', + 'processing_node': pnode.id, + 'align_to': new_task.id + }, format="multipart") + self.assertTrue(res.status_code == status.HTTP_201_CREATED) + align_task = Task.objects.latest('created_at') + + # Has alignment file + self.assertTrue(os.path.isfile(align_task.task_path("align.laz"))) + + # Alignment file is same as point cloud from align task + with open(align_task.task_path("align.laz"), "rb") as f1, open(new_task.assets_path(new_task.ASSETS_MAP['georeferenced_model.laz']), "rb") as f2: + self.assertEqual(f1.read(), f2.read()) + + # Images are 2 + 1 (alignment file) + self.assertEqual(align_task.images_count, 3) + + image1.seek(0) + image2.seek(0) + image1.close() image2.close() multispec_image.close() @@ -1190,3 +1266,56 @@ class TestApiTask(BootTransactionTestCase): image1.close() image2.close() + + + def test_task_list(self): + user = User.objects.get(username="testuser") + project = Project.objects.create(name="User Test Project", owner=user) + task_completed = Task.objects.create(project=project, name="Test Success", + status=status_codes.COMPLETED, + available_assets=["dsm.tif", "georeferenced_model.laz"], + dsm_extent=Polygon.from_bbox([-82.8325,27.9578,-82.8310,27.9593])) + task_fail = Task.objects.create(project=project, name="Test Fail", + status=status_codes.FAILED, + available_assets=["orthophoto.tif", "georeferenced_model.laz"], + orthophoto_extent=Polygon.from_bbox([-82.8325,27.9578,-82.8310,27.9593])) + client = APIClient() + client.login(username="testuser", password="test1234") + + other_client = APIClient() + other_client.login(username="testuser2", password="test1234") + + # Cannot list another user's tasks + res = other_client.get("/api/projects/{}/tasks/".format(project.id)) + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + + # Can list tasks + res = client.get("/api/projects/{}/tasks/".format(project.id)) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(len(res.data), 2) + + # Can filter tasks by status + res = client.get("/api/projects/{}/tasks/?status={}".format(project.id, status_codes.COMPLETED)) + self.assertEqual(len(res.data), 1) + self.assertEqual(res.data[0]['id'], str(task_completed.id)) + + # Can filter tasks by available_assets + res = client.get("/api/projects/{}/tasks/?available_assets=georeferenced_model.laz,dsm.tif".format(project.id)) + self.assertEqual(len(res.data), 1) + self.assertEqual(res.data[0]['id'], str(task_completed.id)) + + res = client.get("/api/projects/{}/tasks/?available_assets=orthophoto.tif".format(project.id)) + self.assertEqual(len(res.data), 1) + self.assertEqual(res.data[0]['id'], str(task_fail.id)) + + # Can filter by bounding box intersection + res = client.get("/api/projects/{}/tasks/?bbox=-82.8320,27.9588,-82.8310,27.9593".format(project.id)) + self.assertEqual(len(res.data), 2) + + res = client.get("/api/projects/{}/tasks/?bbox=-82.8420,27.9688,-82.8410,27.9693".format(project.id)) + self.assertEqual(len(res.data), 0) + + # Cannot filter with invalid bounding box format + res = client.get("/api/projects/{}/tasks/?bbox=bad".format(project.id)) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + From f181ddaa69c288994942bdf6b92a0a7ffe8f9c46 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 20 Feb 2025 13:20:07 -0500 Subject: [PATCH 16/17] Update locale --- .../app/js/translations/odm_autogenerated.js | 170 +++++++++--------- .../plugin_manifest_autogenerated.py | 1 + locale | 2 +- 3 files changed, 87 insertions(+), 86 deletions(-) diff --git a/app/static/app/js/translations/odm_autogenerated.js b/app/static/app/js/translations/odm_autogenerated.js index 7afdca65..d7bf0c56 100644 --- a/app/static/app/js/translations/odm_autogenerated.js +++ b/app/static/app/js/translations/odm_autogenerated.js @@ -1,93 +1,93 @@ // Auto-generated with extract_odm_strings.py, do not edit! -_("Filters the point cloud by removing points that deviate more than N standard deviations from the local mean. Set to 0 to disable filtering. Default: %(default)s"); -_("Automatically compute image masks using AI to remove the sky. Experimental. Default: %(default)s"); -_("Set the compression to use for orthophotos. Can be one of: %(choices)s. Default: %(default)s"); -_("Skip generation of the orthophoto. This can save time if you only need 3D results or DEMs. Default: %(default)s"); -_("Classify the point cloud outputs. You can control the behavior of this option by tweaking the --dem-* parameters. Default: %(default)s"); -_("Skip normalization of colors across all images. Useful when processing radiometric data. Default: %(default)s"); -_("End processing at this stage. Can be one of: %(choices)s. Default: %(default)s"); -_("Automatically crop image outputs by creating a smooth buffer around the dataset boundaries, shrunk by N meters. Use 0 to disable cropping. Default: %(default)s"); -_("Filters the point cloud by keeping only a single point around a radius N (in meters). This can be useful to limit the output resolution of the point cloud and remove duplicate points. Set to 0 to disable sampling. Default: %(default)s"); -_("Matcher algorithm, Fast Library for Approximate Nearest Neighbors or Bag of Words. FLANN is slower, but more stable. BOW is faster, but can sometimes miss valid matches. BRUTEFORCE is very slow but robust.Can be one of: %(choices)s. Default: %(default)s"); -_("Do not use GPU acceleration, even if it's available. Default: %(default)s"); -_("Skip generation of PDF report. This can save time if you don't need a report. Default: %(default)s"); -_("Perform image matching with the nearest images based on GPS exif data. Set to 0 to match by triangulation. Default: %(default)s"); -_("When processing multispectral datasets, ODM will automatically align the images for each band. If the images have been postprocessed and are already aligned, use this option. Default: %(default)s"); -_("Set a camera projection type. Manually setting a value can help improve geometric undistortion. By default the application tries to determine a lens type from the images metadata. Can be one of: %(choices)s. Default: %(default)s"); -_("Set a value in meters for the GPS Dilution of Precision (DOP) information for all images. If your images are tagged with high precision GPS information (RTK), this value will be automatically set accordingly. You can use this option to manually set it in case the reconstruction fails. Lowering this option can sometimes help control bowling-effects over large areas. Default: %(default)s"); -_("Run local bundle adjustment for every image added to the reconstruction and a global adjustment every 100 images. Speeds up reconstruction for very large datasets. Default: %(default)s"); -_("Skip generation of a full 3D model. This can save time if you only need 2D results such as orthophotos and DEMs. Default: %(default)s"); -_("Specify the distance between camera shot locations and the outer edge of the boundary when computing the boundary with --auto-boundary. Set to 0 to automatically choose a value. Default: %(default)s"); -_("Delete heavy intermediate files to optimize disk space usage. This affects the ability to restart the pipeline from an intermediate stage, but allows datasets to be processed on machines that don't have sufficient disk space available. Default: %(default)s"); -_("Use this tag if you have a GCP File but want to use the EXIF information for georeferencing instead. Default: %(default)s"); -_("Set feature extraction quality. Higher quality generates better features, but requires more memory and takes longer. Can be one of: %(choices)s. Default: %(default)s"); -_("Turn on rolling shutter correction. If the camera has a rolling shutter and the images were taken in motion, you can turn on this option to improve the accuracy of the results. See also --rolling-shutter-readout. Default: %(default)s"); -_("When processing multispectral datasets, you can specify the name of the primary band that will be used for reconstruction. It's recommended to choose a band which has sharp details and is in focus. Default: %(default)s"); -_("Use this tag to build a DSM (Digital Surface Model, ground + objects) using a progressive morphological filter. Check the --dem* parameters for finer tuning. Default: %(default)s"); -_("Orthophoto resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate.Default: %(default)s"); -_("Simple Morphological Filter slope parameter (rise over run). Default: %(default)s"); -_("Export the georeferenced point cloud in LAS format. Default: %(default)s"); -_("Create Cloud-Optimized GeoTIFFs instead of normal GeoTIFFs. Default: %(default)s"); -_("Perform ground rectification on the point cloud. This means that wrongly classified ground points will be re-classified and gaps will be filled. Useful for generating DTMs. Default: %(default)s"); -_("Perform image matching with the nearest N images based on image filename order. Can speed up processing of sequential images, such as those extracted from video. It is applied only on non-georeferenced datasets. Set to 0 to disable. Default: %(default)s"); -_("Path to a GeoTIFF DEM or a LAS/LAZ point cloud that the reconstruction outputs should be automatically aligned to. Experimental. Default: %(default)s"); -_("Set this parameter if you want to generate a PNG rendering of the orthophoto. Default: %(default)s"); -_("Geometric estimates improve the accuracy of the point cloud by computing geometrically consistent depthmaps but may not be usable in larger datasets. This flag disables geometric estimates. Default: %(default)s"); -_("Maximum number of frames to extract from video files for processing. Set to 0 for no limit. Default: %(default)s"); -_("Path to the image groups file that controls how images should be split into groups. The file needs to use the following format: image_name group_nameDefault: %(default)s"); -_("Radius of the overlap between submodels. After grouping images into clusters, images that are closer than this radius to a cluster are added to the cluster. This is done to ensure that neighboring submodels overlap. Default: %(default)s"); -_("Copy output results to this folder after processing."); -_("Octree depth used in the mesh reconstruction, increase to get more vertices, recommended values are 8-12. Default: %(default)s"); -_("Use the camera parameters computed from another dataset instead of calculating them. Can be specified either as path to a cameras.json file or as a JSON string representing the contents of a cameras.json file. Default: %(default)s"); -_("Override the rolling shutter readout time for your camera sensor (in milliseconds), instead of using the rolling shutter readout database. Note that not all cameras are present in the database. Set to 0 to use the database value. Default: %(default)s"); -_("Generate static tiles for orthophotos and DEMs that are suitable for viewers like Leaflet or OpenLayers. Default: %(default)s"); -_("Permanently delete all previous results and rerun the processing pipeline."); -_("Minimum number of features to extract per image. More features can be useful for finding more matches between images, potentially allowing the reconstruction of areas with little overlap or insufficient features. More features also slow down processing. Default: %(default)s"); -_("The maximum number of processes to use in various processes. Peak memory requirement is ~1GB per thread and 2 megapixel image resolution. Default: %(default)s"); -_("Name of dataset (i.e subfolder name within project folder). Default: %(default)s"); -_("Average number of images per submodel. When splitting a large dataset into smaller submodels, images are grouped into clusters. This value regulates the number of images that each cluster should have on average. Default: %(default)s"); -_("Displays version number and exits. "); -_("Automatically compute image masks using AI to remove the background. Experimental. Default: %(default)s"); -_("Use images' GPS exif data for reconstruction, even if there are GCPs present.This flag is useful if you have high precision GPS measurements. If there are no GCPs, this flag does nothing. Default: %(default)s"); -_("Export the georeferenced point cloud in CSV format. Default: %(default)s"); -_("Decimate the points before generating the DEM. 1 is no decimation (full quality). 100 decimates ~99%% of the points. Useful for speeding up generation of DEM results in very large datasets. Default: %(default)s"); -_("Export the georeferenced point cloud in Entwine Point Tile (EPT) format. Default: %(default)s"); -_("Use this tag to build a DTM (Digital Terrain Model, ground only) using a simple morphological filter. Check the --dem* and --smrf* parameters for finer tuning. Default: %(default)s"); -_("Path to the project folder. Your project folder should contain subfolders for each dataset. Each dataset should have an \"images\" folder."); -_("Turn off camera parameter optimization during bundle adjustment. This can be sometimes useful for improving results that exhibit doming/bowling or when images are taken with a rolling shutter camera. Default: %(default)s"); -_("The maximum output resolution of extracted video frames in pixels. Default: %(default)s"); -_("Generates a polygon around the cropping area that cuts the orthophoto around the edges of features. This polygon can be useful for stitching seamless mosaics with multiple overlapping orthophotos. Default: %(default)s"); -_("Choose the algorithm for extracting keypoints and computing descriptors. Can be one of: %(choices)s. Default: %(default)s"); -_("Set the radiometric calibration to perform on images. When processing multispectral and thermal images you should set this option to obtain reflectance/temperature values (otherwise you will get digital number values). [camera] applies black level, vignetting, row gradient gain/exposure compensation (if appropriate EXIF tags are found) and computes absolute temperature values. [camera+sun] is experimental, applies all the corrections of [camera], plus compensates for spectral radiance registered via a downwelling light sensor (DLS) taking in consideration the angle of the sun. Can be one of: %(choices)s. Default: %(default)s"); -_("Use a full 3D mesh to compute the orthophoto instead of a 2.5D mesh. This option is a bit faster and provides similar results in planar areas. Default: %(default)s"); -_("Skip alignment of submodels in split-merge. Useful if GPS is good enough on very large datasets. Default: %(default)s"); -_("Path to the file containing the ground control points used for georeferencing. The file needs to use the following format: EPSG: or <+proj definition>geo_x geo_y geo_z im_x im_y image_name [gcp_name] [extra1] [extra2]Default: %(default)s"); -_("Choose the structure from motion algorithm. For aerial datasets, if camera GPS positions and angles are available, triangulation can generate better results. For planar scenes captured at fixed altitude with nadir-only images, planar can be much faster. Can be one of: %(choices)s. Default: %(default)s"); -_("Skips dense reconstruction and 3D model generation. It generates an orthophoto directly from the sparse reconstruction. If you just need an orthophoto and do not need a full 3D model, turn on this option. Default: %(default)s"); -_("Simple Morphological Filter elevation scalar parameter. Default: %(default)s"); -_("Computes an euclidean raster map for each DEM. The map reports the distance from each cell to the nearest NODATA value (before any hole filling takes place). This can be useful to isolate the areas that have been filled. Default: %(default)s"); -_("Set point cloud quality. Higher quality generates better, denser point clouds, but requires more memory and takes longer. Each step up in quality increases processing time roughly by a factor of 4x.Can be one of: %(choices)s. Default: %(default)s"); -_("Set this parameter if you want to generate a Google Earth (KMZ) rendering of the orthophoto. Default: %(default)s"); -_("show this help message and exit"); -_("Number of steps used to fill areas with gaps. Set to 0 to disable gap filling. Starting with a radius equal to the output resolution, N different DEMs are generated with progressively bigger radius using the inverse distance weighted (IDW) algorithm and merged together. Remaining gaps are then merged using nearest neighbor interpolation. Default: %(default)s"); -_("Choose what to merge in the merge step in a split dataset. By default all available outputs are merged. Options: %(choices)s. Default: %(default)s"); -_("URL to a ClusterODM instance for distributing a split-merge workflow on multiple nodes in parallel. Default: %(default)s"); -_("Rerun processing from this stage. Can be one of: %(choices)s. Default: %(default)s"); _("Path to the image geolocation file containing the camera center coordinates used for georeferencing. If you don't have values for yaw/pitch/roll you can set them to 0. The file needs to use the following format: EPSG: or <+proj definition>image_name geo_x geo_y geo_z [yaw (degrees)] [pitch (degrees)] [roll (degrees)] [horz accuracy (meters)] [vert accuracy (meters)]Default: %(default)s"); -_("DSM/DTM resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate. Default: %(default)s"); -_("Ignore Ground Sampling Distance (GSD).A memory and processor hungry change relative to the default behavior if set to true. Ordinarily, GSD estimates are used to cap the maximum resolution of image outputs and resizes images when necessary, resulting in faster processing and lower memory usage. Since GSD is an estimate, sometimes ignoring it can result in slightly better image output quality. Never set --ignore-gsd to true unless you are positive you need it, and even then: do not use it. Default: %(default)s"); -_("Generate single file Binary glTF (GLB) textured models. Default: %(default)s"); -_("Build orthophoto overviews for faster display in programs such as QGIS. Default: %(default)s"); +_("The maximum output resolution of extracted video frames in pixels. Default: %(default)s"); +_("Set feature extraction quality. Higher quality generates better features, but requires more memory and takes longer. Can be one of: %(choices)s. Default: %(default)s"); _("Set this parameter if you want a striped GeoTIFF. Default: %(default)s"); -_("Save the georeferenced point cloud in Cloud Optimized Point Cloud (COPC) format. Default: %(default)s"); -_("Generate OGC 3D Tiles outputs. Default: %(default)s"); +_("Specify the distance between camera shot locations and the outer edge of the boundary when computing the boundary with --auto-boundary. Set to 0 to automatically choose a value. Default: %(default)s"); +_("Perform ground rectification on the point cloud. This means that wrongly classified ground points will be re-classified and gaps will be filled. Useful for generating DTMs. Default: %(default)s"); +_("Matcher algorithm, Fast Library for Approximate Nearest Neighbors or Bag of Words. FLANN is slower, but more stable. BOW is faster, but can sometimes miss valid matches. BRUTEFORCE is very slow but robust.Can be one of: %(choices)s. Default: %(default)s"); +_("Set this parameter if you want to generate a Google Earth (KMZ) rendering of the orthophoto. Default: %(default)s"); _("Simple Morphological Filter elevation threshold parameter (meters). Default: %(default)s"); +_("Classify the point cloud outputs. You can control the behavior of this option by tweaking the --dem-* parameters. Default: %(default)s"); +_("Override the rolling shutter readout time for your camera sensor (in milliseconds), instead of using the rolling shutter readout database. Note that not all cameras are present in the database. Set to 0 to use the database value. Default: %(default)s"); +_("GeoJSON polygon limiting the area of the reconstruction. Can be specified either as path to a GeoJSON file or as a JSON string representing the contents of a GeoJSON file. Default: %(default)s"); +_("Skips dense reconstruction and 3D model generation. It generates an orthophoto directly from the sparse reconstruction. If you just need an orthophoto and do not need a full 3D model, turn on this option. Default: %(default)s"); +_("Keep faces in the mesh that are not seen in any camera. Default: %(default)s"); +_("Do not use GPU acceleration, even if it's available. Default: %(default)s"); +_("Save the georeferenced point cloud in Cloud Optimized Point Cloud (COPC) format. Default: %(default)s"); +_("Average number of images per submodel. When splitting a large dataset into smaller submodels, images are grouped into clusters. This value regulates the number of images that each cluster should have on average. Default: %(default)s"); +_("Use images' GPS exif data for reconstruction, even if there are GCPs present.This flag is useful if you have high precision GPS measurements. If there are no GCPs, this flag does nothing. Default: %(default)s"); +_("Orthophoto resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate.Default: %(default)s"); +_("Number of steps used to fill areas with gaps. Set to 0 to disable gap filling. Starting with a radius equal to the output resolution, N different DEMs are generated with progressively bigger radius using the inverse distance weighted (IDW) algorithm and merged together. Remaining gaps are then merged using nearest neighbor interpolation. Default: %(default)s"); +_("Skip generation of PDF report. This can save time if you don't need a report. Default: %(default)s"); +_("Turn on rolling shutter correction. If the camera has a rolling shutter and the images were taken in motion, you can turn on this option to improve the accuracy of the results. See also --rolling-shutter-readout. Default: %(default)s"); +_("Set a value in meters for the GPS Dilution of Precision (DOP) information for all images. If your images are tagged with high precision GPS information (RTK), this value will be automatically set accordingly. You can use this option to manually set it in case the reconstruction fails. Lowering this option can sometimes help control bowling-effects over large areas. Default: %(default)s"); +_("Skip normalization of colors across all images. Useful when processing radiometric data. Default: %(default)s"); +_("Filters the point cloud by keeping only a single point around a radius N (in meters). This can be useful to limit the output resolution of the point cloud and remove duplicate points. Set to 0 to disable sampling. Default: %(default)s"); +_("Perform image matching with the nearest images based on GPS exif data. Set to 0 to match by triangulation. Default: %(default)s"); +_("Radius of the overlap between submodels. After grouping images into clusters, images that are closer than this radius to a cluster are added to the cluster. This is done to ensure that neighboring submodels overlap. Default: %(default)s"); +_("End processing at this stage. Can be one of: %(choices)s. Default: %(default)s"); +_("Rerun this stage only and stop. Can be one of: %(choices)s. Default: %(default)s"); +_("Set the radiometric calibration to perform on images. When processing multispectral and thermal images you should set this option to obtain reflectance/temperature values (otherwise you will get digital number values). [camera] applies black level, vignetting, row gradient gain/exposure compensation (if appropriate EXIF tags are found) and computes absolute temperature values. [camera+sun] is experimental, applies all the corrections of [camera], plus compensates for spectral radiance registered via a downwelling light sensor (DLS) taking in consideration the angle of the sun. Can be one of: %(choices)s. Default: %(default)s"); +_("Octree depth used in the mesh reconstruction, increase to get more vertices, recommended values are 8-12. Default: %(default)s"); +_("Export the georeferenced point cloud in LAS format. Default: %(default)s"); +_("When processing multispectral datasets, you can specify the name of the primary band that will be used for reconstruction. It's recommended to choose a band which has sharp details and is in focus. Default: %(default)s"); +_("Simple Morphological Filter slope parameter (rise over run). Default: %(default)s"); +_("Generates a polygon around the cropping area that cuts the orthophoto around the edges of features. This polygon can be useful for stitching seamless mosaics with multiple overlapping orthophotos. Default: %(default)s"); +_("Automatically crop image outputs by creating a smooth buffer around the dataset boundaries, shrunk by N meters. Use 0 to disable cropping. Default: %(default)s"); +_("Simple Morphological Filter elevation scalar parameter. Default: %(default)s"); +_("Generate single file Binary glTF (GLB) textured models. Default: %(default)s"); +_("Decimate the points before generating the DEM. 1 is no decimation (full quality). 100 decimates ~99%% of the points. Useful for speeding up generation of DEM results in very large datasets. Default: %(default)s"); +_("Export the georeferenced point cloud in CSV format. Default: %(default)s"); +_("Choose the algorithm for extracting keypoints and computing descriptors. Can be one of: %(choices)s. Default: %(default)s"); +_("Choose the structure from motion algorithm. For aerial datasets, if camera GPS positions and angles are available, triangulation can generate better results. For planar scenes captured at fixed altitude with nadir-only images, planar can be much faster. Can be one of: %(choices)s. Default: %(default)s"); +_("Turn off camera parameter optimization during bundle adjustment. This can be sometimes useful for improving results that exhibit doming/bowling or when images are taken with a rolling shutter camera. Default: %(default)s"); +_("Path to the project folder. Your project folder should contain subfolders for each dataset. Each dataset should have an \"images\" folder."); +_("Rerun processing from this stage. Can be one of: %(choices)s. Default: %(default)s"); +_("Skip alignment of submodels in split-merge. Useful if GPS is good enough on very large datasets. Default: %(default)s"); +_("Build orthophoto overviews for faster display in programs such as QGIS. Default: %(default)s"); +_("show this help message and exit"); +_("Name of dataset (i.e subfolder name within project folder). Default: %(default)s"); +_("Use a full 3D mesh to compute the orthophoto instead of a 2.5D mesh. This option is a bit faster and provides similar results in planar areas. Default: %(default)s"); +_("Use the camera parameters computed from another dataset instead of calculating them. Can be specified either as path to a cameras.json file or as a JSON string representing the contents of a cameras.json file. Default: %(default)s"); +_("Path to the file containing the ground control points used for georeferencing. The file needs to use the following format: EPSG: or <+proj definition>geo_x geo_y geo_z im_x im_y image_name [gcp_name] [extra1] [extra2]Default: %(default)s"); +_("Generate OGC 3D Tiles outputs. Default: %(default)s"); +_("Use this tag to build a DTM (Digital Terrain Model, ground only) using a simple morphological filter. Check the --dem* and --smrf* parameters for finer tuning. Default: %(default)s"); +_("Automatically compute image masks using AI to remove the background. Experimental. Default: %(default)s"); +_("Copy output results to this folder after processing."); +_("The maximum number of processes to use in various processes. Peak memory requirement is ~1GB per thread and 2 megapixel image resolution. Default: %(default)s"); +_("Perform image matching with the nearest N images based on image filename order. Can speed up processing of sequential images, such as those extracted from video. It is applied only on non-georeferenced datasets. Set to 0 to disable. Default: %(default)s"); +_("Automatically compute image masks using AI to remove the sky. Experimental. Default: %(default)s"); +_("Generate OBJs that have a single material and a single texture file instead of multiple ones. Default: %(default)s"); +_("DSM/DTM resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate. Default: %(default)s"); +_("When processing multispectral datasets, ODM will automatically align the images for each band. If the images have been postprocessed and are already aligned, use this option. Default: %(default)s"); +_("Ignore Ground Sampling Distance (GSD).A memory and processor hungry change relative to the default behavior if set to true. Ordinarily, GSD estimates are used to cap the maximum resolution of image outputs and resizes images when necessary, resulting in faster processing and lower memory usage. Since GSD is an estimate, sometimes ignoring it can result in slightly better image output quality. Never set --ignore-gsd to true unless you are positive you need it, and even then: do not use it. Default: %(default)s"); +_("Skip generation of a full 3D model. This can save time if you only need 2D results such as orthophotos and DEMs. Default: %(default)s"); +_("Do not attempt to merge partial reconstructions. This can happen when images do not have sufficient overlap or are isolated. Default: %(default)s"); +_("Path to a GeoTIFF DEM or a LAS/LAZ point cloud that the reconstruction outputs should be automatically aligned to. Experimental. Default: %(default)s"); +_("Delete heavy intermediate files to optimize disk space usage. This affects the ability to restart the pipeline from an intermediate stage, but allows datasets to be processed on machines that don't have sufficient disk space available. Default: %(default)s"); +_("Computes an euclidean raster map for each DEM. The map reports the distance from each cell to the nearest NODATA value (before any hole filling takes place). This can be useful to isolate the areas that have been filled. Default: %(default)s"); +_("Generate static tiles for orthophotos and DEMs that are suitable for viewers like Leaflet or OpenLayers. Default: %(default)s"); +_("Use this tag to build a DSM (Digital Surface Model, ground + objects) using a progressive morphological filter. Check the --dem* parameters for finer tuning. Default: %(default)s"); +_("Skip generation of the orthophoto. This can save time if you only need 3D results or DEMs. Default: %(default)s"); _("Automatically set a boundary using camera shot locations to limit the area of the reconstruction. This can help remove far away background artifacts (sky, background landscapes, etc.). See also --boundary. Default: %(default)s"); _("The maximum vertex count of the output mesh. Default: %(default)s"); -_("Do not attempt to merge partial reconstructions. This can happen when images do not have sufficient overlap or are isolated. Default: %(default)s"); -_("GeoJSON polygon limiting the area of the reconstruction. Can be specified either as path to a GeoJSON file or as a JSON string representing the contents of a GeoJSON file. Default: %(default)s"); -_("Keep faces in the mesh that are not seen in any camera. Default: %(default)s"); -_("Generate OBJs that have a single material and a single texture file instead of multiple ones. Default: %(default)s"); +_("Filters the point cloud by removing points that deviate more than N standard deviations from the local mean. Set to 0 to disable filtering. Default: %(default)s"); +_("Set this parameter if you want to generate a PNG rendering of the orthophoto. Default: %(default)s"); +_("Maximum number of frames to extract from video files for processing. Set to 0 for no limit. Default: %(default)s"); +_("URL to a ClusterODM instance for distributing a split-merge workflow on multiple nodes in parallel. Default: %(default)s"); +_("Set point cloud quality. Higher quality generates better, denser point clouds, but requires more memory and takes longer. Each step up in quality increases processing time roughly by a factor of 4x.Can be one of: %(choices)s. Default: %(default)s"); +_("Set the compression to use for orthophotos. Can be one of: %(choices)s. Default: %(default)s"); +_("Export the georeferenced point cloud in Entwine Point Tile (EPT) format. Default: %(default)s"); +_("Set a camera projection type. Manually setting a value can help improve geometric undistortion. By default the application tries to determine a lens type from the images metadata. Can be one of: %(choices)s. Default: %(default)s"); +_("Displays version number and exits. "); _("Simple Morphological Filter window radius parameter (meters). Default: %(default)s"); -_("Rerun this stage only and stop. Can be one of: %(choices)s. Default: %(default)s"); +_("Run local bundle adjustment for every image added to the reconstruction and a global adjustment every 100 images. Speeds up reconstruction for very large datasets. Default: %(default)s"); +_("Minimum number of features to extract per image. More features can be useful for finding more matches between images, potentially allowing the reconstruction of areas with little overlap or insufficient features. More features also slow down processing. Default: %(default)s"); +_("Choose what to merge in the merge step in a split dataset. By default all available outputs are merged. Options: %(choices)s. Default: %(default)s"); +_("Create Cloud-Optimized GeoTIFFs instead of normal GeoTIFFs. Default: %(default)s"); +_("Permanently delete all previous results and rerun the processing pipeline."); +_("Path to the image groups file that controls how images should be split into groups. The file needs to use the following format: image_name group_nameDefault: %(default)s"); +_("Use this tag if you have a GCP File but want to use the EXIF information for georeferencing instead. Default: %(default)s"); +_("Geometric estimates improve the accuracy of the point cloud by computing geometrically consistent depthmaps but may not be usable in larger datasets. This flag disables geometric estimates. Default: %(default)s"); diff --git a/app/translations/plugin_manifest_autogenerated.py b/app/translations/plugin_manifest_autogenerated.py index 9b7c5e7e..c58cacce 100644 --- a/app/translations/plugin_manifest_autogenerated.py +++ b/app/translations/plugin_manifest_autogenerated.py @@ -20,3 +20,4 @@ _("A plugin to create GCP files from images") _("Annotate and measure on 2D maps with ease") _("Add a GPS location button to the 2D map view") _("Plugin to get align from external service for WebODM") +_("Detect objects using AI in orthophotos") diff --git a/locale b/locale index 58b4dc1e..34142a65 160000 --- a/locale +++ b/locale @@ -1 +1 @@ -Subproject commit 58b4dc1e1a145c2ad892e4a3de4b4514a91fbff3 +Subproject commit 34142a65381f45226aa3bf323241946cc7d1c3c3 From 50e879fa9ce64d8fdb2fe10a5d201234dff89a09 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 20 Feb 2025 13:32:38 -0500 Subject: [PATCH 17/17] Update locales --- locale | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locale b/locale index 34142a65..820bc7a5 160000 --- a/locale +++ b/locale @@ -1 +1 @@ -Subproject commit 34142a65381f45226aa3bf323241946cc7d1c3c3 +Subproject commit 820bc7a599c768f3bd4d437180f192baf8bc077b