Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from app.security import path_traversal_check
from django.utils.translation import gettext_lazy as _
from .fields import PolygonGeometryField
from app.geoutils import geom_transform_wkt_bbox, get_srs_name_units_from_epsg
from app.geoutils import geom_transform_wkt_bbox, get_srs_name_units_from_epsg_or_wkt
from webodm import settings

def flatten_files(request_files):
Expand Down Expand Up @@ -93,7 +93,7 @@ def get_extent(self, obj):
return obj.get_extent()

def get_srs(self, obj):
return get_srs_name_units_from_epsg(obj.epsg)
return get_srs_name_units_from_epsg_or_wkt(obj.epsg, obj.wkt)

class Meta:
model = models.Task
Expand Down
5 changes: 3 additions & 2 deletions app/api/tiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ def get(self, request, pk=None, project_pk=None, tile_type=""):
info['maxzoom'] = info['minzoom']
info['maxzoom'] += ZOOM_EXTRA_LEVELS
info['minzoom'] -= ZOOM_EXTRA_LEVELS
info['bounds'] = {'value': bounds if bounds is not None else src.bounds, 'crs': {'init': str(task.epsg)}}
info['bounds'] = {'value': bounds if bounds is not None else src.bounds,
'crs': f"EPSG:{task.epsg}" if task.epsg is not None else task.wkt}

return Response(info)

Expand Down Expand Up @@ -689,7 +690,7 @@ def post(self, request, pk=None, project_pk=None, asset_type=None):
if not os.path.isfile(url):
raise exceptions.NotFound()

if epsg is not None and task.epsg is None:
if epsg is not None and (task.epsg is None and task.wkt is None):
raise exceptions.ValidationError(_("Cannot use epsg on non-georeferenced dataset"))

# Strip unsafe chars, append suffix
Expand Down
32 changes: 26 additions & 6 deletions app/geoutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,20 @@ def geom_transform_wkt_bbox(geom, dataset, bbox_crs="geographic", wkt_crs="raste
if close_ds:
dataset.close()

def geom_transform(geom, epsg):
def geom_transform(geom, target_srs):
if not geom.srid:
raise ValueError("Geometry must have an SRID")

coords = geom.tuple
if len(coords) == 1:
xs, ys = zip(*coords[0])
tx, ty = rasterio.warp.transform(CRS.from_epsg(geom.srid), CRS.from_epsg(epsg), xs, ys)

if isinstance(target_srs, int):
srs = CRS.from_epsg(target_srs)
elif isinstance(target_srs, str):
srs = CRS.from_wkt(target_srs)

tx, ty = rasterio.warp.transform(CRS.from_epsg(geom.srid), srs, xs, ys)
return list(zip(tx, ty))
else:
raise ValueError("Cannot transform complex geometries to WKT")
Expand Down Expand Up @@ -127,13 +133,19 @@ def get_raster_bounds_wkt(raster_path, target_srs="EPSG:4326"):
return wkt

@lru_cache(maxsize=1000)
def get_srs_name_units_from_epsg(epsg):
if epsg is None:
def get_srs_name_units_from_epsg_or_wkt(epsg, wkt):
if epsg is None and wkt is None:
return {'name': '', 'units': 'm'}

srs = osr.SpatialReference()
if srs.ImportFromEPSG(epsg) != 0:
return {'name': '', 'units': 'm'}

if epsg is not None:
if srs.ImportFromEPSG(epsg) != 0:
return {'name': '', 'units': 'm'}

if wkt is not None:
if srs.ImportFromWkt(wkt) != 0:
return {'name': '', 'units': 'm'}
Comment on lines +142 to +148
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When both epsg and wkt are provided, the function imports epsg first but then overwrites the srs object with wkt. This can lead to incorrect results if epsg import succeeds but wkt import fails or vice versa. The logic should use elif for the wkt check, or handle the case where both are provided differently.

Copilot uses AI. Check for mistakes.

name = srs.GetAttrValue("PROJCS")
if name is None:
Expand All @@ -142,6 +154,14 @@ def get_srs_name_units_from_epsg(epsg):
if name is None:
return {'name': '', 'units': 'm'}

if name == "unknown" and wkt is not None:
try:
proj = srs.ExportToProj4()
if proj is not None and proj != "":
name = proj
except:
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bare except clause catches all exceptions including system exits and keyboard interrupts. Specify the exception type(s) to catch, such as except Exception: or more specific exception types.

Suggested change
except:
except Exception:

Copilot uses AI. Check for mistakes.
pass

units = srs.GetAttrValue('UNIT')
if units is None:
units = 'm' # Default to meters
Expand Down
18 changes: 18 additions & 0 deletions app/migrations/0047_task_wkt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.27 on 2025-12-23 22:28

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('app', '0046_redirect'),
]

operations = [
migrations.AddField(
model_name='task',
name='wkt',
field=models.TextField(blank=True, default=None, help_text='WKT definition of the dataset (if georeferenced and EPSG code is not available)', null=True, verbose_name='WKT'),
),
]
28 changes: 19 additions & 9 deletions app/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from app.pointcloud_utils import is_pointcloud_georeferenced
from app.testwatch import testWatch
from app.security import path_traversal_check
from app.geoutils import geom_transform, epsg_from_wkt, get_raster_bounds_wkt, get_srs_name_units_from_epsg
from app.geoutils import geom_transform, epsg_from_wkt, get_raster_bounds_wkt, get_srs_name_units_from_epsg_or_wkt
from nodeodm import status_codes
from nodeodm.models import ProcessingNode
from pyodm.exceptions import NodeResponseError, NodeConnectionError, NodeServerError, OdmError
Expand Down Expand Up @@ -287,6 +287,7 @@ class Task(models.Model):
partial = models.BooleanField(default=False, help_text=_("A flag indicating whether this task is currently waiting for information or files to be uploaded before being considered for processing."), verbose_name=_("Partial"))
potree_scene = fields.JSONField(default=dict, blank=True, help_text=_("Serialized potree scene information used to save/load measurements and camera view angle"), verbose_name=_("Potree Scene"))
epsg = models.IntegerField(null=True, default=None, blank=True, help_text=_("EPSG code of the dataset (if georeferenced)"), verbose_name="EPSG")
wkt = models.TextField(null=True, default=None, blank=True, help_text=_("WKT definition of the dataset (if georeferenced and EPSG code is not available)"), verbose_name="WKT")
tags = models.TextField(db_index=True, default="", blank=True, help_text=_("Task tags"), verbose_name=_("Tags"))
orthophoto_bands = fields.JSONField(default=list, blank=True, help_text=_("List of orthophoto bands"), verbose_name=_("Orthophoto Bands"))
size = models.FloatField(default=0.0, blank=True, help_text=_("Size of the task on disk in megabytes"), verbose_name=_("Size"))
Expand Down Expand Up @@ -1025,7 +1026,7 @@ def extract_assets_and_complete(self):

self.check_ept()
self.update_available_assets_field()
self.update_epsg_field()
self.update_georef_fields()
self.update_orthophoto_bands_field()
self.update_size()
self.clear_task_assets_cache()
Expand Down Expand Up @@ -1144,7 +1145,8 @@ def get_map_items(self):
'camera_shots': camera_shots,
'ground_control_points': ground_control_points,
'epsg': self.epsg,
'srs': get_srs_name_units_from_epsg(self.epsg),
'wkt': self.wkt,
'srs': get_srs_name_units_from_epsg_or_wkt(self.epsg, self.wkt),
'orthophoto_bands': self.orthophoto_bands,
'crop': self.crop is not None,
'extent': self.get_extent(),
Expand All @@ -1153,10 +1155,10 @@ def get_map_items(self):
}

def get_projected_crop(self):
if self.crop is None or self.epsg is None:
if self.crop is None or (self.epsg is None and self.wkt is None):
return None

return geom_transform(self.crop, self.epsg)
return geom_transform(self.crop, self.epsg if self.epsg is not None else self.wkt)

def get_model_display_params(self):
"""
Expand All @@ -1169,7 +1171,7 @@ def get_model_display_params(self):
'public': self.public,
'public_edit': self.public_edit,
'epsg': self.epsg,
'srs': get_srs_name_units_from_epsg(self.epsg),
'srs': get_srs_name_units_from_epsg_or_wkt(self.epsg, self.wkt),
'crop_projected': self.get_projected_crop()
}

Expand Down Expand Up @@ -1201,12 +1203,14 @@ def update_available_assets_field(self, commit=False):
if commit: self.save()


def update_epsg_field(self, commit=False):
def update_georef_fields(self, commit=False):
"""
Updates the epsg field with the correct value
Updates the epsg and wkt field with the correct values
:param commit: when True also saves the model, otherwise the user should manually call save()
"""
epsg = None
wkt = None

for asset in ['orthophoto.tif', 'dsm.tif', 'dtm.tif']:
asset_path = self.assets_path(self.ASSETS_MAP[asset])
if os.path.isfile(asset_path):
Expand All @@ -1231,12 +1235,18 @@ def update_epsg_field(self, commit=False):
# If point cloud is not georeferenced, dataset is not georeferenced
# (2D assets might be using pseudo-georeferencing)
point_cloud = self.assets_path(self.ASSETS_MAP['georeferenced_model.laz'])
if epsg is not None and os.path.isfile(point_cloud):
if (epsg is not None or wkt is not None) and os.path.isfile(point_cloud):
if not is_pointcloud_georeferenced(point_cloud):
logger.info("{} is not georeferenced".format(self))
epsg = None
wkt = None

self.epsg = epsg
if epsg is None:
self.wkt = wkt
else:
self.wkt = None # Only save one or the other

if commit: self.save()


Expand Down
22 changes: 18 additions & 4 deletions app/static/app/js/components/ExportAssetPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default class ExportAssetPanel extends React.Component {
this.state = {
error: "",
format: props.exportFormats[0],
epsg: this.props.task.epsg || null,
epsg: this.props.task.epsg || "",
customEpsg: Storage.getItem("last_export_custom_epsg") || "3857",
customProj: Storage.getItem("last_export_custom_proj") || "",
resample: 0,
Expand Down Expand Up @@ -205,6 +205,8 @@ export default class ExportAssetPanel extends React.Component {
const {epsg, customEpsg, customProj, exporting, format, resample, progress } = this.state;
const { exportFormats } = this.props;
const projEPSG = this.props.task.epsg;
const projWKT = this.props.task.wkt;
const georeferenced = this.props.task.epsg || this.props.task.wkt;
let projSrsName = this.props.task.srs?.name;
if (!projSrsName && projEPSG) projSrsName = `EPSG:${projEPSG}`;
else if (projSrsName && projEPSG) projSrsName = `${projSrsName} (EPSG:${projEPSG})`;
Expand All @@ -216,11 +218,23 @@ export default class ExportAssetPanel extends React.Component {
(epsg === "proj" && (!customProj || (typeof customProj === "string" && !customProj.toLowerCase().startsWith("+proj")))) ||
exporting;

let projection = projEPSG ? (<div><div className="row form-group form-inline">
let firstOpt = "";
let title = "";

if (projEPSG){
firstOpt = (<option value={projEPSG}>{projSrsName}</option>);
}else if (projWKT){
firstOpt = (<option value={""}>{projSrsName}</option>);
}

if (epsg == projEPSG) title = projSrsName;
else if (epsg == "" && projWKT) title = projWKT;
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use strict equality operators (===) instead of loose equality (==) for comparisons. This is especially important when comparing with empty strings.

Suggested change
else if (epsg == "" && projWKT) title = projWKT;
else if (epsg === "" && projWKT) title = projWKT;

Copilot uses AI. Check for mistakes.

let projection = georeferenced ? (<div><div className="row form-group form-inline">
<label className="col-sm-3 control-label">{_("CRS:")}</label>
<div className="col-sm-9 ">
<select className="form-control crs" value={epsg} onChange={this.handleSelectEpsg} title={epsg == projEPSG ? projSrsName : ""}>
{projEPSG ? <option value={projEPSG}>{projSrsName}</option> : ""}
<select className="form-control crs" value={epsg} onChange={this.handleSelectEpsg} title={title}>
{firstOpt}
<option value="4326">{_("Lat/Lon")} (EPSG:4326)</option>
<option value="3857">{_("Web Mercator")} (EPSG:3857)</option>
<option value="custom">{_("Custom")} EPSG</option>
Expand Down
36 changes: 27 additions & 9 deletions app/static/app/js/components/TaskListItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ class TaskListItem extends React.Component {
showMoveDialog: false,
actionLoading: false,
thumbLoadFailed: false,
displayPdf: false
displayPdf: false,
copiedToClipboard: false,
}

for (let k in props.data){
Expand Down Expand Up @@ -272,6 +273,18 @@ class TaskListItem extends React.Component {
this.setState({editing: false});
}

copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
this.setState({copiedToClipboard: true});
if (this._clipboardTimeout){
clearTimeout(this._clipboardTimeout);
this._clipboardTimeout = null;
}
setTimeout(() => {
this.setState({copiedToClipboard: false});
}, 2000);
}

checkForCommonErrors(lines){
for (let line of lines){
if (line.indexOf("Killed") !== -1 ||
Expand Down Expand Up @@ -639,14 +652,18 @@ class TaskListItem extends React.Component {
<div className="col-md-9 col-sm-10 no-padding">
<table className="table table-condensed info-table">
<tbody>
<tr>
<td><strong>{_("Task ID:")}</strong></td>
<td><a title={_("Copy to clipboard")} onClick={() => this.copyToClipboard(task.id)} href="javascript:void(0)" className="task-id-link">{task.id} <i className={"clipboard " + (this.state.copiedToClipboard ? "fa fa-check visible" : "far fa-clipboard")}></i></a></td>
</tr>
<tr>
<td><strong>{_("Created on:")}</strong></td>
<td>{(new Date(task.created_at)).toLocaleString()}</td>
</tr>
<tr>
{task.status !== statusCodes.COMPLETED && <tr>
<td><strong>{_("Processing Node:")}</strong></td>
<td>{task.processing_node_name || "-"} ({task.auto_processing_node ? _("auto") : _("manual")})</td>
</tr>
</tr>}
{Array.isArray(task.options) &&
<tr>
<td><strong>{_("Options:")}</strong></td>
Expand All @@ -664,23 +681,24 @@ class TaskListItem extends React.Component {
</tr>}
{stats && stats.pointcloud && stats.pointcloud.points &&
<tr>
<td><strong>{_("Reconstructed Points:")}</strong></td>
<td><strong>{_("Points:")}</strong></td>
<td>{stats.pointcloud.points.toLocaleString()}</td>
</tr>}
{stats && stats.spatial_refs && stats.spatial_refs.length &&
<tr>
<td><strong>{_("Spatial Reference:")}</strong></td>
<td><strong>{_("Georeferencing:")}</strong></td>
<td>{this.spatialRefsToHuman(stats.spatial_refs)}</td>
</tr>}
{task.srs && task.srs.name &&
<tr>
<td><strong>{_("CRS:")}</strong></td>
<td>{task.srs.name}</td>
</tr>}
{task.size > 0 &&
<tr>
<td><strong>{_("Disk Usage:")}</strong></td>
<td>{Utils.bytesToSize(task.size * 1024 * 1024)}</td>
</tr>}
<tr>
<td><strong>{_("Task ID:")}</strong></td>
<td>{task.id}</td>
</tr>
<tr>
<td><strong>{_("Task Output:")}</strong></td>
<td><div className="btn-group btn-toggle">
Expand Down
17 changes: 17 additions & 0 deletions app/static/app/js/css/TaskListItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,21 @@
opacity: 0.9;
}
}

.task-id-link{
text-decoration: none !important;
color: inherit;
i{
margin-left: 4px;
display: none;
}
i.visible{
display: inline-block;
}
&:hover{
i{
display: inline-block;
}
}
}
}
Loading