From 5c26ff0742ed9f178a5526ed28264acb3aeb86eb Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 18 Feb 2025 18:52:43 +0100 Subject: [PATCH] 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 } /> : ""}