Merge branch 'master' of https://github.com/OpenDroneMap/WebODM into align_plugin

pull/1538/head
Piero Toffanin 2024-08-12 23:50:34 -04:00
commit b592d48796
62 zmienionych plików z 3041 dodań i 140 usunięć

2
.github/FUNDING.yml vendored
Wyświetl plik

@ -2,5 +2,5 @@
github: pierotofy
custom:
- https://www.opendronemap.org/webodm/download/
- https://webodm.net
- https://odmbook.com

Wyświetl plik

@ -17,7 +17,7 @@ jobs:
swap-size-gb: 12
- name: Build and Test
run: |
docker-compose -f docker-compose.yml -f docker-compose.build.yml build --build-arg TEST_BUILD=ON
docker-compose -f docker-compose.yml -f docker-compose.build.yml up -d
docker compose -f docker-compose.yml -f docker-compose.build.yml build --build-arg TEST_BUILD=ON
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d
sleep 20
docker-compose exec -T webapp /webodm/webodm.sh test
docker compose exec -T webapp /webodm/webodm.sh test

Wyświetl plik

@ -11,6 +11,8 @@ from django.core.files.uploadedfile import InMemoryUploadedFile
from django.db import transaction
from django.http import FileResponse
from django.http import HttpResponse
from django.http import StreamingHttpResponse
from app.vendor import zipfly
from rest_framework import status, serializers, viewsets, filters, exceptions, permissions, parsers
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
@ -340,8 +342,13 @@ def download_file_response(request, filePath, content_disposition, download_file
def download_file_stream(request, stream, content_disposition, download_filename=None):
response = HttpResponse(FileWrapper(stream),
content_type=(mimetypes.guess_type(download_filename)[0] or "application/zip"))
if isinstance(stream, zipfly.ZipStream):
f = stream.generator()
else:
# This should never happen, but just in case..
raise exceptions.ValidationError("stream not a zipstream instance")
response = StreamingHttpResponse(f, content_type=(mimetypes.guess_type(download_filename)[0] or "application/zip"))
response['Content-Type'] = mimetypes.guess_type(download_filename)[0] or "application/zip"
response['Content-Disposition'] = "{}; filename={}".format(content_disposition, download_filename)

Wyświetl plik

@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
class PluginDatum(models.Model):
key = models.CharField(max_length=255, help_text=_("Setting key"), db_index=True, verbose_name=_("Key"))
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, default=None, on_delete=models.CASCADE, help_text=_("The user this setting belongs to. If NULL, the setting is global."), verbose_name=_("User"))
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, default=None, on_delete=models.CASCADE, help_text=_("The user this setting belongs to. If NULL, the setting is global."), verbose_name=_("User"))
int_value = models.IntegerField(blank=True, null=True, default=None, verbose_name=_("Integer value"))
float_value = models.FloatField(blank=True, null=True, default=None, verbose_name=_("Float value"))
bool_value = models.NullBooleanField(blank=True, null=True, default=None, verbose_name=_("Bool value"))

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.8 KiB

Wyświetl plik

@ -33,6 +33,7 @@ class MapView extends React.Component {
// and preference order (below)
if (props.selectedMapType === "auto"){
let preferredTypes = ['orthophoto', 'dsm', 'dtm'];
if (this.isThermalMap()) preferredTypes = ['plant'].concat(preferredTypes);
for (let i = 0; i < this.props.mapItems.length; i++){
let mapItem = this.props.mapItems[i];
@ -57,6 +58,21 @@ class MapView extends React.Component {
this.handleMapTypeButton = this.handleMapTypeButton.bind(this);
}
isThermalMap = () => {
let thermalCount = 0;
for (let item of this.props.mapItems){
if (item.meta && item.meta.task && item.meta.task.orthophoto_bands){
if (item.meta.task.orthophoto_bands.length === 2 && item.meta.task.orthophoto_bands &&
item.meta.task.orthophoto_bands[0] && typeof(item.meta.task.orthophoto_bands[0].description) === "string" &&
item.meta.task.orthophoto_bands[0].description.toLowerCase() === "lwir"){
thermalCount++;
}
}
}
return thermalCount === this.props.mapItems.length;
}
getTilesByMapType(type){
// Go through the list of map items and return
// only those that match a particular type (in tile format)
@ -85,6 +101,8 @@ class MapView extends React.Component {
}
render(){
const isThermal = this.isThermalMap();
let mapTypeButtons = [
{
label: _("Orthophoto"),
@ -92,9 +110,9 @@ class MapView extends React.Component {
icon: "far fa-image"
},
{
label: _("Plant Health"),
label: isThermal ? _("Thermal") : _("Plant Health"),
type: "plant",
icon: "fa fa-seedling"
icon: isThermal ? "fa fa-thermometer-half" : "fa fa-seedling"
},
{
label: _("Surface Model"),
@ -113,17 +131,17 @@ class MapView extends React.Component {
return (<div className="map-view">
<div className="map-view-header">
{this.props.title ?
{this.props.title ?
<h3 className="map-title" title={this.props.title}><i className="fa fa-globe"></i> {this.props.title}</h3>
: ""}
<div className="map-type-selector btn-group" role="group">
{mapTypeButtons.map(mapType =>
<button
<button
key={mapType.type}
onClick={this.handleMapTypeButton(mapType.type)}
title={mapType.label}
className={"btn btn-sm " + (mapType.type === this.state.selectedMapType ? "btn-primary" : "btn-default")}><i className={mapType.icon}></i><span className="hidden-sm hidden-xs"> {mapType.label}</span></button>
className={"btn btn-sm " + (mapType.type === this.state.selectedMapType ? "btn-primary" : "btn-default")}><i className={mapType.icon + " fa-fw"}></i><span className="hidden-sm hidden-xs"> {mapType.label}</span></button>
)}
</div>
</div>
@ -136,6 +154,7 @@ class MapView extends React.Component {
public={this.props.public}
shareButtons={this.props.shareButtons}
permissions={this.props.permissions}
thermal={isThermal}
/>
</div>
</div>);

Wyświetl plik

@ -20,10 +20,10 @@ export default [
},
{
attribution:
'&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 21,
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
minZoom: 0,
label: _("OSM Mapnik"),
url: "//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
label: _("OpenStreetMap"),
url: "//tile.openstreetmap.org/{z}/{x}/{y}.png"
}
];

Wyświetl plik

@ -3,7 +3,8 @@ import { _ } from './gettext';
const types = {
LENGTH: 1,
AREA: 2,
VOLUME: 3
VOLUME: 3,
TEMPERATURE: 4
};
const units = {
@ -139,6 +140,26 @@ const units = {
round: 4,
label: _("Cubic Yards"),
type: types.VOLUME
},
celsius:{
conversion:{
forward: celsius => celsius,
backward: celsius => celsius
},
abbr: '°C',
round: 1,
label: _("Celsius"),
type: types.TEMPERATURE
},
fahrenheit:{
conversion: {
forward: celsius => (9.0 / 5.0) * celsius + 32.0,
backward: fahrenheit => (fahrenheit - 32.0) * (5.0 / 9.0)
},
abbr: '°F',
round: 1,
label: _("Fahrenheit"),
type: types.TEMPERATURE
}
};
@ -175,6 +196,7 @@ class UnitSystem{
lengthUnit(meters, opts = {}){ throw new Error("Not implemented"); }
areaUnit(sqmeters, opts = {}){ throw new Error("Not implemented"); }
volumeUnit(cbmeters, opts = {}){ throw new Error("Not implemented"); }
temperatureUnit(celsius, opts = {}){ throw new Error("Not implemented"); }
getName(){ throw new Error("Not implemented"); }
getKey(){ throw new Error("Not implemented"); }
@ -209,6 +231,15 @@ class UnitSystem{
const val = unit.factor * cbmeters;
return new ValueUnit(val, unit);
}
temperature(celsius, opts = {}){
celsius = parseFloat(celsius);
if (isNaN(celsius)) return NanUnit();
const unit = this.temperatureUnit(celsius, opts);
const val = unit.conversion.forward(celsius);
return new ValueUnit(val, unit);
}
};
function toMetric(valueUnit, unit){
@ -221,13 +252,23 @@ function toMetric(valueUnit, unit){
}
if (isNaN(value)) return NanUnit();
const val = value / unit.factor;
let val;
if (unit.factor !== undefined){
val = value / unit.factor;
}else if (unit.conversion !== undefined){
val = unit.conversion.backward(value);
}else{
throw new Error(`No unit factor or conversion: ${unit.type}`);
}
if (unit.type === types.LENGTH){
return new ValueUnit(val, units.meters);
}else if (unit.type === types.AREA){
return new ValueUnit(val, unit.sqmeters);
}else if (unit.type === types.VOLUME){
return new ValueUnit(val, unit.cbmeters);
}else if (unit.type === types.TEMPERATURE){
return new ValueUnit(val, units.celsius);
}else{
throw new Error(`Unrecognized unit type: ${unit.type}`);
}
@ -261,6 +302,10 @@ class MetricSystem extends UnitSystem{
volumeUnit(cbmeters, opts = {}){
return units.cbmeters;
}
temperatureUnit(celsius, opts = {}){
return units.celsius;
}
}
class ImperialSystem extends UnitSystem{
@ -316,6 +361,10 @@ class ImperialSystem extends UnitSystem{
volumeUnit(cbmeters, opts = {}){
return this.cbyards();
}
temperatureUnit(celsius, opts = {}){
return units.fahrenheit;
}
}
class ImperialUSSystem extends ImperialSystem{

Wyświetl plik

@ -27,7 +27,8 @@ class EditTaskForm extends React.Component {
onFormChanged: PropTypes.func,
inReview: PropTypes.bool,
task: PropTypes.object,
suggestedTaskName: PropTypes.oneOfType([PropTypes.string, PropTypes.func])
suggestedTaskName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
getCropPolygon: PropTypes.func
};
constructor(props){
@ -350,14 +351,32 @@ class EditTaskForm extends React.Component {
// from a processing node)
getAvailableOptionsOnly(options, availableOptions){
const optionNames = {};
let optsCopy = Utils.clone(options);
availableOptions.forEach(opt => optionNames[opt.name] = true);
return options.filter(opt => optionNames[opt.name]);
// Override boundary and crop options (if they are available)
if (this.props.getCropPolygon){
const poly = this.props.getCropPolygon();
if (poly && optionNames['crop'] && optionNames['boundary']){
let cropOpt = optsCopy.find(opt => opt.name === 'crop');
if (!cropOpt) optsCopy.push({name: 'crop', value: "0"});
let boundaryOpt = optsCopy.find(opt => opt.name === 'boundary');
if (!boundaryOpt) optsCopy.push({name: 'boundary', value: JSON.stringify(poly)});
else boundaryOpt.value = JSON.stringify(poly);
}
}
return optsCopy.filter(opt => optionNames[opt.name]);
}
getAvailableOptionsOnlyText(options, availableOptions){
const opts = this.getAvailableOptionsOnly(options, availableOptions);
let res = opts.map(opt => `${opt.name}:${opt.value}`).join(", ");
let res = opts.map(opt => {
if (opt.name === "boundary") return `${opt.name}:geojson`;
else return `${opt.name}:${opt.value}`;
}).join(", ");
if (!res) res = _("Default");
return res;
}

Wyświetl plik

@ -36,7 +36,8 @@ class Map extends React.Component {
mapType: "orthophoto",
public: false,
shareButtons: true,
permissions: ["view"]
permissions: ["view"],
thermal: false
};
static propTypes = {
@ -45,7 +46,8 @@ class Map extends React.Component {
mapType: PropTypes.oneOf(['orthophoto', 'plant', 'dsm', 'dtm']),
public: PropTypes.bool,
shareButtons: PropTypes.bool,
permissions: PropTypes.array
permissions: PropTypes.array,
thermal: PropTypes.bool
};
constructor(props) {
@ -88,7 +90,7 @@ class Map extends React.Component {
case "orthophoto":
return _("Orthophoto");
case "plant":
return _("Plant Health");
return this.props.thermal ? _("Thermal") : _("Plant Health");
case "dsm":
return _("DSM");
case "dtm":
@ -102,13 +104,13 @@ class Map extends React.Component {
case "orthophoto":
return "far fa-image fa-fw"
case "plant":
return "fa fa-seedling fa-fw";
return this.props.thermal ? "fa fa-thermometer-half fa-fw" : "fa fa-seedling fa-fw";
case "dsm":
case "dtm":
return "fa fa-chart-area fa-fw";
}
return "";
}
}
hasBands = (bands, orthophoto_bands) => {
if (!orthophoto_bands) return false;

Wyświetl plik

@ -0,0 +1,568 @@
import React from 'react';
import ReactDOM from 'ReactDOM';
import '../css/MapPreview.scss';
import 'leaflet/dist/leaflet.css';
import Leaflet from 'leaflet';
import PropTypes from 'prop-types';
import $ from 'jquery';
import ErrorMessage from './ErrorMessage';
import Utils from '../classes/Utils';
import '../vendor/leaflet/Leaflet.Autolayers/css/leaflet.auto-layers.css';
import '../vendor/leaflet/Leaflet.Autolayers/leaflet-autolayers';
import Basemaps from '../classes/Basemaps';
import Standby from './Standby';
import exifr from '../vendor/exifr';
import '../vendor/leaflet/leaflet-markers-canvas';
import { _, interpolate } from '../classes/gettext';
const Colors = {
fill: '#fff',
stroke: '#1a1a1a'
};
class MapPreview extends React.Component {
static defaultProps = {
getFiles: null,
onPolygonChange: () => {}
};
static propTypes = {
getFiles: PropTypes.func.isRequired,
onPolygonChange: PropTypes.func
};
constructor(props) {
super(props);
this.state = {
showLoading: true,
error: "",
cropping: false
};
this.basemaps = {};
this.mapBounds = null;
this.exifData = [];
this.hasTimestamp = true;
this.MaxImagesPlot = 10000;
}
componentDidMount() {
this.map = Leaflet.map(this.container, {
scrollWheelZoom: true,
positionControl: false,
zoomControl: false,
minZoom: 0,
maxZoom: 24
});
this.group = L.layerGroup();
this.group.addTo(this.map);
// For some reason, in production this class is not added (but we need it)
// leaflet bug?
$(this.container).addClass("leaflet-touch");
//add zoom control with your options
Leaflet.control.zoom({
position:'bottomleft'
}).addTo(this.map);
this.basemaps = {};
Basemaps.forEach((src, idx) => {
const { url, ...props } = src;
const tileProps = Utils.clone(props);
tileProps.maxNativeZoom = tileProps.maxZoom;
tileProps.maxZoom = tileProps.maxZoom + 99;
const layer = L.tileLayer(url, tileProps);
if (idx === 2) {
layer.addTo(this.map);
}
this.basemaps[props.label] = layer;
});
const customLayer = L.layerGroup();
customLayer.on("add", a => {
const defaultCustomBm = window.localStorage.getItem('lastCustomBasemap') || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
let url = window.prompt([_('Enter a tile URL template. Valid coordinates are:'),
_('{z}, {x}, {y} for Z/X/Y tile scheme'),
_('{-y} for flipped TMS-style Y coordinates'),
'',
_('Example:'),
'https://tile.openstreetmap.org/{z}/{x}/{y}.png'].join("\n"), defaultCustomBm);
if (url){
customLayer.clearLayers();
const l = L.tileLayer(url, {
maxNativeZoom: 24,
maxZoom: 99,
minZoom: 0
});
customLayer.addLayer(l);
l.bringToBack();
window.localStorage.setItem('lastCustomBasemap', url);
}
});
this.basemaps[_("Custom")] = customLayer;
this.basemaps[_("None")] = L.layerGroup();
this.autolayers = Leaflet.control.autolayers({
overlays: {},
selectedOverlays: [],
baseLayers: this.basemaps
}).addTo(this.map);
this.map.fitBounds([
[13.772919746115805,
45.664640939831735],
[13.772825784981254,
45.664591558975154]]);
this.map.attributionControl.setPrefix("");
this.loadNewFiles();
}
sampled = (arr, N) => {
// Return a uniformly sampled array with max N elements
if (arr.length <= N) return arr;
else{
const res = [];
const step = arr.length / N;
for (let i = 0; i < N; i++){
res.push(arr[Math.floor(i * step)]);
}
return res;
}
};
loadNewFiles = () => {
this.setState({showLoading: true});
if (this.imagesGroup){
this.map.removeLayer(this.imagesGroup);
this.imagesGroup = null;
}
this.readExifData().then(() => {
let images = this.sampled(this.exifData, this.MaxImagesPlot).map(exif => {
let layer = L.circleMarker([exif.gps.latitude, exif.gps.longitude], {
radius: 8,
fillOpacity: 1,
color: "#fcfcff", //ff9e67
fillColor: "#4b96f3",
weight: 1.5,
}).bindPopup(exif.image.name);
layer.feature = layer.feature || {};
layer.feature.type = "Feature";
layer.feature.properties = layer.feature.properties || {};
layer.feature.properties["Filename"] = exif.image.name;
if (this.hasTimestamp) layer.feature.properties["Timestamp"] = exif.timestamp;
return layer;
});
if (this.capturePath){
this.map.removeLayer(this.capturePath);
this.capturePath = null;
}
// Only show line if we have reliable date/time info
if (this.hasTimestamp){
let coords = this.exifData.map(exif => [exif.gps.latitude, exif.gps.longitude]);
this.capturePath = L.polyline(coords, {
color: "#4b96f3",
weight: 3
});
this.capturePath.addTo(this.map);
}
if (images.length > 0){
this.imagesGroup = L.featureGroup(images).addTo(this.map);
this.map.fitBounds(this.imagesGroup.getBounds());
}
this.setState({showLoading: false});
}).catch(e => {
this.setState({showLoading: false, error: e.message});
});
}
readExifData = () => {
return new Promise((resolve, reject) => {
const files = this.props.getFiles();
const images = [];
// TODO: gcps? geo files?
for (let i = 0; i < files.length; i++){
const f = files[i];
if (f.type.indexOf("image") === 0) images.push(f);
}
// Parse EXIF
const options = {
ifd0: false,
exif: [0x9003],
gps: [0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006],
interop: false,
ifd1: false // thumbnail
};
const next = (i) => {
if (i < images.length - 1) parseImage(i+1);
else{
// Sort by date/time
if (this.hasTimestamp){
this.exifData.sort((a, b) => {
if (a.timestamp < b.timestamp) return -1;
else if (a.timestamp > b.timestamp) return 1;
else return 0;
});
}
resolve();
}
};
const parseImage = i => {
const img = images[i];
exifr.parse(img, options).then(exif => {
if (!exif.latitude || !exif.longitude){
// reject(new Error(interpolate(_("Cannot extract GPS data from %(file)s"), {file: img.name})));
next(i);
return;
}
let dateTime = exif.DateTimeOriginal;
let timestamp = null;
if (dateTime && dateTime.getTime) timestamp = dateTime.getTime();
if (!timestamp) this.hasTimestamp = false;
this.exifData.push({
image: img,
gps: {
latitude: exif.latitude,
longitude: exif.longitude,
altitude: exif.GPSAltitude !== undefined ? exif.GPSAltitude : null,
},
timestamp
});
next(i);
}).catch((e) => {
console.warn(e);
next(i);
});
};
if (images.length > 0) parseImage(0);
else resolve();
});
}
componentWillUnmount() {
this.map.remove();
}
getCropPolygon = () => {
if (!this.polygon) return null;
return this.polygon.toGeoJSON(14);
}
toggleCrop = () => {
const { cropping } = this.state;
let crop = !cropping;
if (!crop) {
if (this.captureMarker) {
this.captureMarker.off('click', this.handleMarkerClick);
this.captureMarker.off('dblclick', this.handleMarkerDblClick);
this.captureMarker.off('mousemove', this.handleMarkerMove);
this.captureMarker.off('contextmenu', this.handleMarkerContextMenu);
this.map.off('move', this.onMapMove);
this.map.off('resize', this.onMapResize);
this.group.removeLayer(this.captureMarker);
this.captureMarker = null;
}
if (this.acceptMarker) {
this.group.removeLayer(this.acceptMarker);
this.acceptMarker = null;
}
if (this.measureBoundary) {
this.group.removeLayer(this.measureBoundary);
this.measureBoundary = null;
}
if (this.measureArea) {
this.group.removeLayer(this.measureArea);
this.measureArea = null;
}
this.cropButton.blur();
}
else{
if (!this.captureMarker) {
this.captureMarker = L.marker(this.map.getCenter(), {
clickable: true,
zIndexOffset: 10001
}).setIcon(L.divIcon({
iconSize: this.map.getSize().multiplyBy(2),
className: "map-preview-marker-layer"
})).addTo(this.group);
this.captureMarker.on('click', this.handleMarkerClick);
this.captureMarker.on('dblclick', this.handleMarkerDblClick);
this.captureMarker.on('mousemove', this.handleMarkerMove);
this.captureMarker.on('contextmenu', this.handleMarkerContextMenu);
this.map.on('move', this.onMapMove);
this.map.on('resize', this.onMapResize);
}
if (this.polygon){
this.group.removeLayer(this.polygon);
this.polygon = null;
this.props.onPolygonChange();
}
// Reset latlngs
this.latlngs = [];
}
this.setState({cropping: !cropping});
}
handleMarkerClick = e => {
L.DomEvent.stop(e);
const latlng = this.map.mouseEventToLatLng(e.originalEvent);
this.uniqueLatLonPush(latlng);
if (this.latlngs.length >= 1) {
if (!this.measureBoundary) {
this.measureBoundary = L.polyline(this.latlngs.concat(latlng), {
clickable: false,
color: Colors.stroke,
weight: 2,
opacity: 0.9,
fill: false,
}).addTo(this.group);
} else {
this.measureBoundary.setLatLngs(this.latlngs.concat(latlng));
}
}
if (this.latlngs.length >= 2) {
if (!this.measureArea) {
this.measureArea = L.polygon(this.latlngs.concat(latlng), {
clickable: false,
stroke: false,
fillColor: Colors.fill,
fillOpacity: 0.2,
}).addTo(this.group);
} else {
this.measureArea.setLatLngs(this.latlngs.concat(latlng));
}
}
if (this.latlngs.length >= 3) {
if (this.acceptMarker) {
this.group.removeLayer(this.acceptMarker);
this.acceptMarker = null;
}
const onAccept = e => {
L.DomEvent.stop(e);
this.confirmPolygon();
return false;
};
let acceptLatlng = this.latlngs[0];
this.acceptMarker = L.marker(acceptLatlng, {
icon: L.icon({
iconUrl: `/static/app/img/accept.png`,
iconSize: [20, 20],
iconAnchor: [10, 10],
className: "map-preview-accept-button",
}),
zIndexOffset: 99999
}).addTo(this.group)
.on("click", onAccept)
.on("contextmenu", onAccept);
}
};
confirmPolygon = () => {
if (this.latlngs.length >= 3){
const popupContainer = L.DomUtil.create('div');
popupContainer.className = "map-preview-delete";
const deleteLink = L.DomUtil.create('a');
deleteLink.href = "javascript:void(0)";
deleteLink.innerHTML = `<i class="fa fa-trash"></i> ${_("Delete")}`;
deleteLink.onclick = (e) => {
L.DomEvent.stop(e);
if (this.polygon){
this.group.removeLayer(this.polygon);
this.polygon = null;
this.props.onPolygonChange();
}
};
popupContainer.appendChild(deleteLink);
this.polygon = L.polygon(this.latlngs, {
clickable: true,
weight: 3,
opacity: 0.9,
color: "#ffa716",
fillColor: "#ffa716",
fillOpacity: 0.2
}).bindPopup(popupContainer).addTo(this.group);
this.props.onPolygonChange();
}
this.toggleCrop();
}
uniqueLatLonPush = latlng => {
if (this.latlngs.length === 0) this.latlngs.push(latlng);
else{
const last = this.latlngs[this.latlngs.length - 1];
if (last.lat !== latlng.lat && last.lng !== latlng.lng) this.latlngs.push(latlng);
}
};
handleMarkerDblClick = e => {
if (this.latlngs.length >= 2){
const latlng = this.map.mouseEventToLatLng(e.originalEvent);
this.uniqueLatLonPush(latlng);
this.confirmPolygon();
}
}
handleMarkerMove = e => {
const latlng = this.map.mouseEventToLatLng(e.originalEvent);
let lls = this.latlngs.concat(latlng);
lls.push(lls[0]);
if (this.measureBoundary) {
this.measureBoundary.setLatLngs(lls);
}
if (this.measureArea) {
this.measureArea.setLatLngs(lls);
}
}
handleMarkerContextMenu = e => {
if (this.latlngs.length >= 2){
const latlng = this.map.mouseEventToLatLng(e.originalEvent);
this.uniqueLatLonPush(latlng);
this.confirmPolygon();
}
return false;
}
onMapMove = () => {
if (this.captureMarker) this.captureMarker.setLatLng(this.map.getCenter());
};
onMapResize = () => {
if (this.captureMarker) this.captureMarker.setIcon(L.divIcon({
iconSize: this._map.getSize().multiplyBy(2)
}));
}
download = format => {
let output = "";
let filename = `images.${format}`;
const feats = {
type: "FeatureCollection",
features: this.exifData.map(ed => {
return {
type: "Feature",
properties: {
Filename: ed.image.name,
Timestamp: ed.timestamp
},
geometry:{
type: "Point",
coordinates: [
ed.gps.longitude,
ed.gps.latitude,
ed.gps.altitude !== null ? ed.gps.altitude : 0
]
}
}
})
};
if (format === 'geojson'){
output = JSON.stringify(feats, null, 4);
}else if (format === 'csv'){
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{
console.error("Invalid format");
}
Utils.saveAs(output, filename);
}
render() {
return (
<div style={{height: "280px"}} className="map-preview">
<ErrorMessage bind={[this, 'error']} />
<Standby
message={_("Plotting GPS locations...")}
show={this.state.showLoading}
/>
{this.state.error === "" && this.exifData.length > this.MaxImagesPlot ?
<div className="plot-warning btn-warning" title={interpolate(_("For performance reasons, only %(num)s images are plotted"), {num: this.MaxImagesPlot})}>
<i className="fa fa-exclamation-triangle"></i>
</div>
: ""}
{this.state.error === "" ? <div className="download-control">
<button title={_("Download")} type="button" className="btn btn-sm btn-secondary dropdown-toggle" data-toggle="dropdown">
<i className="fa fa-download"></i>
</button>
<ul className="dropdown-menu">
<li>
<a href="javascript:void(0);" onClick={() => this.download('geojson')}><i className="fas fa-map fa-fw"></i> GeoJSON</a>
<a href="javascript:void(0);" onClick={() => this.download('csv')}><i className="fas fa-file-alt fa-fw"></i> CSV</a>
</li>
</ul>
</div> : ""}
{this.state.error === "" ?
<div className="crop-control">
<button ref={(domNode) => {this.cropButton = domNode; }} type="button" onClick={this.toggleCrop} className={"btn btn-sm " + (this.state.cropping ? "btn-default" : "btn-secondary")} title={_("Set Reconstruction Area (optional)")}>
<i className="fa fa-crop-alt"></i>
</button>
</div>
: ""}
<div
style={{height: "100%"}}
ref={(domNode) => (this.container = domNode)}
>
</div>
</div>
);
}
}
export default MapPreview;

Wyświetl plik

@ -4,6 +4,7 @@ import EditTaskForm from './EditTaskForm';
import PropTypes from 'prop-types';
import Storage from '../classes/Storage';
import ResizeModes from '../classes/ResizeModes';
import MapPreview from './MapPreview';
import update from 'immutability-helper';
import PluginsAPI from '../classes/plugins/API';
import { _, interpolate } from '../classes/gettext';
@ -34,6 +35,7 @@ class NewTaskPanel extends React.Component {
taskInfo: {},
inReview: false,
loading: false,
showMapPreview: false
};
this.save = this.save.bind(this);
@ -44,6 +46,12 @@ class NewTaskPanel extends React.Component {
this.handleFormChanged = this.handleFormChanged.bind(this);
}
componentDidUpdate(prevProps, prevState){
if (this.props.filesCount !== prevProps.filesCount && this.mapPreview){
this.mapPreview.loadNewFiles();
}
}
componentDidMount(){
PluginsAPI.Dashboard.triggerAddNewTaskPanelItem({}, (item) => {
if (!item) return;
@ -123,6 +131,22 @@ class NewTaskPanel extends React.Component {
this.setState({taskInfo: this.getTaskInfo()});
}
handleSuggestedTaskName = () => {
return this.props.suggestedTaskName(() => {
// Has GPS
this.setState({showMapPreview: true});
});
}
getCropPolygon = () => {
if (!this.mapPreview) return null;
return this.mapPreview.getCropPolygon();
};
handlePolygonChange = () => {
if (this.taskForm) this.taskForm.forceUpdate();
}
render() {
let filesCountOk = true;
if (this.taskForm && !this.taskForm.checkFilesCount(this.props.filesCount)) filesCountOk = false;
@ -142,12 +166,19 @@ class NewTaskPanel extends React.Component {
</div>
: ""}
{this.state.showMapPreview ? <MapPreview
getFiles={this.props.getFiles}
onPolygonChange={this.handlePolygonChange}
ref={(domNode) => {this.mapPreview = domNode; }}
/> : ""}
<EditTaskForm
selectedNode={Storage.getItem("last_processing_node") || "auto"}
onFormLoaded={this.handleFormTaskLoaded}
onFormChanged={this.handleFormChanged}
inReview={this.state.inReview}
suggestedTaskName={this.props.suggestedTaskName}
suggestedTaskName={this.handleSuggestedTaskName}
getCropPolygon={this.getCropPolygon}
ref={(domNode) => { if (domNode) this.taskForm = domNode; }}
/>

Wyświetl plik

@ -140,7 +140,7 @@ class ProjectListItem extends React.Component {
url : 'TO_BE_CHANGED',
parallelUploads: 6,
uploadMultiple: false,
acceptedFiles: "image/*,text/*,.las,.laz,video/*,.srt",
acceptedFiles: "image/*,text/plain,.las,.laz,video/*,.srt",
autoProcessQueue: false,
createImageThumbnails: false,
clickable: this.uploadButton,
@ -476,7 +476,7 @@ class ProjectListItem extends React.Component {
this.setState({importing: false});
}
handleTaskTitleHint = () => {
handleTaskTitleHint = (hasGPSCallback) => {
return new Promise((resolve, reject) => {
if (this.state.upload.files.length > 0){
@ -501,32 +501,27 @@ class ProjectListItem extends React.Component {
interop: false,
ifd1: false // thumbnail
};
exifr.parse(f, options).then(gps => {
if (!gps.latitude || !gps.longitude){
exifr.parse(f, options).then(exif => {
if (!exif.latitude || !exif.longitude){
reject();
return;
}
let dateTime = gps["36867"];
if (hasGPSCallback !== undefined) hasGPSCallback();
// Try to parse the date from EXIF to JS
const parts = dateTime.split(" ");
if (parts.length == 2){
let [ d, t ] = parts;
d = d.replace(/:/g, "-");
const tm = Date.parse(`${d} ${t}`);
if (!isNaN(tm)){
dateTime = new Date(tm).toLocaleDateString();
}
}
let dateTime = exif.DateTimeOriginal;
if (dateTime && dateTime.toLocaleDateString) dateTime = dateTime.toLocaleDateString();
// Fallback to file modified date if
// no exif info is available
if (!dateTime) dateTime = f.lastModifiedDate.toLocaleDateString();
if (!dateTime){
if (f.lastModifiedDate) dateTime = f.lastModifiedDate.toLocaleDateString();
else if (f.lastModified) dateTime = new Date(f.lastModified).toLocaleDateString();
}
// Query nominatim OSM
$.ajax({
url: `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${gps.latitude}&lon=${gps.longitude}`,
url: `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${exif.latitude}&lon=${exif.longitude}`,
contentType: 'application/json',
type: 'GET'
}).done(json => {

Wyświetl plik

@ -238,7 +238,10 @@ class TaskListItem extends React.Component {
if (!Array.isArray(options)) return "";
else if (options.length === 0) return "Default";
else {
return options.map(opt => `${opt.name}: ${opt.value}`).join(", ");
return options.map(opt => {
if (opt.name === "boundary") return `${opt.name}:geojson`;
else return `${opt.name}:${opt.value}`
}).join(", ");
}
}

Wyświetl plik

@ -0,0 +1,10 @@
import React from 'react';
import { mount } from 'enzyme';
import MapPreview from '../MapPreview';
describe('<MapPreview />', () => {
it('renders without exploding', () => {
const wrapper = mount(<MapPreview getFiles={() => []} />);
expect(wrapper.exists()).toBe(true);
})
});

Wyświetl plik

@ -50,6 +50,15 @@ describe('Metric system', () => {
});
expect(metric.area(11005.09, { fixedUnit: true }).toString({precision: 1})).toBe("11,005.1 m²");
const temperatures = [
[1, "1 °C"],
[100.255, "100.3 °C"]
];
temperatures.forEach(v => {
expect(metric.temperature(v[0]).toString()).toBe(v[1]);
});
})
});
@ -98,6 +107,15 @@ describe('Imperial systems', () => {
});
expect(imperial.area(9999, { fixedUnit: true }).toString({precision: 1})).toBe("107,628.3 ft²");
const temperatures = [
[1, "33.8 °F"],
[100.255, "212.5 °F"]
];
temperatures.forEach(v => {
expect(imperial.temperature(v[0]).toString()).toBe(v[1]);
});
});
});
@ -118,5 +136,13 @@ describe('Metric conversion', () => {
expect(toMetric(km).value).toBe(2000);
expect(toMetric(mi).value).toBe(3220);
const celsius = metric.temperature(50);
const fahrenheit = imperial.temperature(50);
expect(celsius.unit.abbr).toBe("°C");
expect(fahrenheit.unit.abbr).toBe("°F");
expect(toMetric(celsius).value).toBe(50);
expect(toMetric(fahrenheit).value).toBe(50);
});
});

Wyświetl plik

@ -0,0 +1,64 @@
.map-preview{
position: relative;
margin-bottom: 16px;
border-radius: 3px;
.leaflet-container, .standby .cover{
border-radius: 3px;
}
.standby{
z-index: 1001;
}
.download-control{
position: absolute;
left: 8px;
top: 8px;
z-index: 1000;
.btn:active, .btn:focus{
outline: none;
}
}
.crop-control{
position: absolute;
top: 50px;
left: 8px;
z-index: 999;
.btn:active, .btn:focus{
outline: none;
}
}
.leaflet-control-layers-expanded{
.leaflet-control-layers-base{
overflow: hidden;
}
height: 200px;
overflow: hidden;
}
.map-preview-marker-layer{
&:hover{
cursor: crosshair;
}
}
.map-preview-accept-button{
&:hover{
cursor: pointer;
}
}
.map-preview-delete{
min-width: 70px;
}
.plot-warning{
position: absolute;
z-index: 999;
left: 58px;
top: 8px;
padding: 8px;
border-radius: 4px;
}
}

Wyświetl plik

@ -1,93 +1,93 @@
// Auto-generated with extract_odm_strings.py, do not edit!
_("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");
_("Path to the file containing the ground control points used for georeferencing. The file needs to use the following format: EPSG:<code> or <+proj definition>geo_x geo_y geo_z im_x im_y image_name [gcp_name] [extra1] [extra2]Default: %(default)s");
_("Create Cloud-Optimized GeoTIFFs instead of normal GeoTIFFs. Default: %(default)s");
_("Skip normalization of colors across all images. Useful when processing radiometric data. 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");
_("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 what to merge in the merge step in a split dataset. By default all available outputs are merged. Options: %(choices)s. Default: %(default)s");
_("Rerun this stage only and stop. Can be one of: %(choices)s. 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 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");
_("Displays version number and exits. ");
_("Permanently delete all previous results and rerun the processing pipeline.");
_("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");
_("Choose the algorithm for extracting keypoints and computing descriptors. Can be one of: %(choices)s. 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");
_("Octree depth used in the mesh reconstruction, increase to get more vertices, recommended values are 8-12. 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");
_("Set this parameter if you want to generate a Google Earth (KMZ) rendering of the orthophoto. 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 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");
_("Export the georeferenced point cloud in CSV format. Default: %(default)s");
_("Simple Morphological Filter elevation threshold parameter (meters). 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");
_("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");
_("Generate OBJs that have a single material and a single texture file instead of multiple ones. Default: %(default)s");
_("Name of dataset (i.e subfolder name within project folder). Default: %(default)s");
_("End processing at this stage. Can be one of: %(choices)s. Default: %(default)s");
_("Rerun processing from this stage. Can be one of: %(choices)s. Default: %(default)s");
_("Automatically compute image masks using AI to remove the background. Experimental. Default: %(default)s");
_("Simple Morphological Filter slope parameter (rise over run). 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");
_("Generate single file Binary glTF (GLB) textured models. Default: %(default)s");
_("Save the georeferenced point cloud in Cloud Optimized Point Cloud (COPC) format. Default: %(default)s");
_("Skip generation of the orthophoto. This can save time if you only need 3D results or DEMs. 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");
_("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");
_("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");
_("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");
_("Maximum number of frames to extract from video files for processing. Set to 0 for no limit. 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");
_("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");
_("Rerun this stage only and stop. Can be one of: %(choices)s. 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");
_("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");
_("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");
_("Permanently delete all previous results and rerun the processing pipeline.");
_("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");
_("Automatically compute image masks using AI to remove the background. Experimental. 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");
_("Simple Morphological Filter slope parameter (rise over run). 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");
_("Classify the point cloud outputs. You can control the behavior of this option by tweaking the --dem-* parameters. 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");
_("Keep faces in the mesh that are not seen in any camera. 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, 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");
_("Build orthophoto overviews for faster display in programs such as QGIS. 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 the compression to use for orthophotos. Can be one of: %(choices)s. Default: %(default)s");
_("The maximum vertex count of the output mesh. 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:<code> 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");
_("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");
_("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");
_("Set this parameter if you want a striped GeoTIFF. 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");
_("Skip alignment of submodels in split-merge. Useful if GPS is good enough on very large datasets. Default: %(default)s");
_("Generate single file Binary glTF (GLB) textured models. Default: %(default)s");
_("Generate OBJs that have a single material and a single texture file instead of multiple ones. 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");
_("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");
_("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");
_("Skip normalization of colors across all images. Useful when processing radiometric data. Default: %(default)s");
_("Export the georeferenced point cloud in LAS format. 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");
_("Path to the file containing the ground control points used for georeferencing. The file needs to use the following format: EPSG:<code> or <+proj definition>geo_x geo_y geo_z im_x im_y image_name [gcp_name] [extra1] [extra2]Default: %(default)s");
_("Skip generation of the orthophoto. This can save time if you only need 3D results or DEMs. Default: %(default)s");
_("show this help message and exit");
_("Rerun processing from this stage. Can be one of: %(choices)s. Default: %(default)s");
_("Skip generation of PDF report. This can save time if you don't need a report. Default: %(default)s");
_("Simple Morphological Filter window radius parameter (meters). Default: %(default)s");
_("Path to the project folder. Your project folder should contain subfolders for each dataset. Each dataset should have an \"images\" folder.");
_("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");
_("Generate OGC 3D Tiles outputs. Default: %(default)s");
_("Name of dataset (i.e subfolder name within project folder). Default: %(default)s");
_("Export the georeferenced point cloud in Entwine Point Tile (EPT) format. Default: %(default)s");
_("Displays version number and exits. ");
_("Use this tag if you have a GCP File but want to use the EXIF information for georeferencing instead. 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");
_("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");
_("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");
_("URL to a ClusterODM instance for distributing a split-merge workflow on multiple nodes in parallel. 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");
_("Keep faces in the mesh that are not seen in any camera. Default: %(default)s");
_("Export the georeferenced point cloud in CSV format. 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");
_("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");
_("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");
_("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");
_("Maximum number of frames to extract from video files for processing. Set to 0 for no limit. 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");
_("Do not use GPU acceleration, even if it's available. Default: %(default)s");
_("Export the georeferenced point cloud in Entwine Point Tile (EPT) format. Default: %(default)s");
_("show this help message and exit");
_("Do not attempt to merge partial reconstructions. This can happen when images do not have sufficient overlap or are isolated. 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");
_("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");
_("Set this parameter if you want a striped GeoTIFF. 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 elevation threshold parameter (meters). 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");
_("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");
_("DSM/DTM resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate. Default: %(default)s");
_("The maximum output resolution of extracted video frames in pixels. 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:<code> 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");
_("Set this parameter if you want to generate a PNG rendering of the orthophoto. Default: %(default)s");
_("Simple Morphological Filter elevation scalar parameter. Default: %(default)s");
_("The maximum vertex count of the output mesh. 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");
_("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");
_("Automatically compute image masks using AI to remove the sky. Experimental. Default: %(default)s");
_("Export the georeferenced point cloud in LAS format. 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 generation of a full 3D model. This can save time if you only need 2D results such as orthophotos and DEMs. 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");
_("Path to a GeoTIFF DEM or a LAS/LAZ point cloud that the reconstruction outputs should be automatically aligned to. Experimental. Default: %(default)s");
_("Path to the project folder. Your project folder should contain subfolders for each dataset. Each dataset should have an \"images\" folder.");
_("Generate static tiles for orthophotos and DEMs that are suitable for viewers like Leaflet or OpenLayers. 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");
_("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 crop image outputs by creating a smooth buffer around the dataset boundaries, shrunk by N meters. Use 0 to disable cropping. Default: %(default)s");
_("Generate OGC 3D Tiles outputs. Default: %(default)s");
_("Orthophoto resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate.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");
_("Set this parameter if you want to generate a Google Earth (KMZ) rendering of the orthophoto. 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");
_("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");
_("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");
_("Set the compression to use for orthophotos. 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");
_("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");
_("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");
_("Simple Morphological Filter window radius parameter (meters). 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");
_("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");
_("Simple Morphological Filter elevation scalar parameter. 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");
_("The maximum output resolution of extracted video frames in pixels. 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");
_("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");
_("End processing at this stage. Can be one of: %(choices)s. Default: %(default)s");
_("Save the georeferenced point cloud in Cloud Optimized Point Cloud (COPC) format. 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");
_("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");
_("Perform image matching with the nearest images based on GPS exif data. Set to 0 to match by triangulation. Default: %(default)s");
_("Choose the algorithm for extracting keypoints and computing descriptors. Can be one of: %(choices)s. 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");
_("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");
_("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");
_("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");
_("Build orthophoto overviews for faster display in programs such as QGIS. 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");
_("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");
_("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");
_("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");
_("Automatically compute image masks using AI to remove the sky. Experimental. Default: %(default)s");
_("Create Cloud-Optimized GeoTIFFs instead of normal GeoTIFFs. 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");
_("Generate static tiles for orthophotos and DEMs that are suitable for viewers like Leaflet or OpenLayers. Default: %(default)s");
_("Do not use GPU acceleration, even if it's available. Default: %(default)s");
_("Octree depth used in the mesh reconstruction, increase to get more vertices, recommended values are 8-12. Default: %(default)s");
_("Set this parameter if you want to generate a PNG rendering of the orthophoto. Default: %(default)s");

Wyświetl plik

@ -1 +1 @@
module.exports = require('exifr/dist/mini.umd');
module.exports = require('exifr/dist/full.legacy.umd');

Wyświetl plik

@ -74,7 +74,7 @@ class TestApiTask(BootTransactionTestCase):
assets_path = os.path.join(settings.MEDIA_TMP, "all.zip")
with open(assets_path, 'wb') as f:
f.write(res.content)
f.write(b''.join(res.streaming_content))
remove_perm('change_project', user, project)
@ -272,7 +272,7 @@ class TestApiTask(BootTransactionTestCase):
assets_path = os.path.join(settings.MEDIA_TMP, "backup.zip")
with open(assets_path, 'wb') as f:
f.write(res.content)
f.write(b''.join(res.streaming_content))
assets_file = open(assets_path, 'rb')

13
app/vendor/zipfly.py vendored
Wyświetl plik

@ -46,7 +46,6 @@ class ZipflyStream(io.RawIOBase):
def size(self):
return self._size
class ZipFly:
def __init__(self,
@ -280,13 +279,17 @@ class ZipFly:
class ZipStream:
def __init__(self, paths):
self.paths = paths
self.generator = None
self._generator = None
def lazy_load(self, chunksize):
if self.generator is None:
if self._generator is None:
zfly = ZipFly(paths=self.paths, mode='w', chunksize=chunksize)
self.generator = zfly.generator()
self._generator = zfly.generator()
def read(self, count):
self.lazy_load(count)
return next(self.generator)
return next(self._generator)
def generator(self):
self.lazy_load(0x8000)
return self._generator

Wyświetl plik

@ -0,0 +1,61 @@
<p align="center">
<img src="https://github.com/AnalyticalGraphicsInc/Cesium/wiki/logos/Cesium_Logo_Color.jpg" width="50%" />
</p>
# Cesium Ion WebODM Plugin
## 1. Introduction
### Overview
The Cesium Ion WebODM plugin enables seamless integratIon to upload processed WebODM tasks to your Cesium Ion account.
Using the Cesium Ion ecosystem, multi-gigabit models can be streamed to any device using Cesium clients to load 3D tiles.
Learn more at https://Cesium.com
### Prerequisites
> - WebODM versIon 2.5.0 or later
> - [Cesium Ion](https://Cesium.com/ion/tokens) token with `assets:list, assets:read, assets:write` permissions
> - Internet connection
## 2. Initial Setup
### Enabling Plugin
1. Go to "AdministratIon -> Plugins" and enable Cesium ion.
2. Select the left Cesium Ion tab
3. Copy and paste your Cesium Ion token then `Set Token`.
## 3. Usage
### Basic Usage
Example:
1. Create a new project in the WebODM dashboard.
2. Upload your images.
3. Edit the WebODM task options and make sure to enable `texturing-single-material`.
4. Start the WebODM processing (this will take a while to complete).
3. Once finished, select the `Tile in CesiumIon` dropdown button for a list of available asset uploads.
4. Click on a dropdown item to show the popup dialogue where you can rename the asset, add a description/attribute, or enable an Cesium Ion option before uploading.
5. Submit to start the upload to your Cesium Ion assets account.
6. You can view the progress of the upload by clicking the `View Ion Tasks` button.
7. Once complete you can then click on the `View in Cesium` dropdown button to open a new browser tab to view your Cesium Ion assets
> **NOTE:** There are 2 phases to a Cesium task: **uploading** and **processing**. Uploading is the transfer of processed WebODM data to Cesium Ion. Processing is the tiling/rendering Cesium Ion does to generate streamable models.
## 4. New Feature: CesiumIon Plugin v1.3.0
### KVX 2.0
Cesium Ion upgraded their streaming pipeline to automatically use their `1.1` tileset version. The new standardize tileset version comes with [`KTX2`](https://www.khronos.org/ktx/), a texture format compression option to create a smaller tilset for better streaming performance.
## 5. Troubleshooting
### Common Issues
> - **Issue:** texture model uploads to cesium ion but fails to process/render it.
> - **Solution:** Ensure that you have enabled `texturing-single-material` before WebODM processing on a *new* project task as WebODM stores previously processed textured models in the same odm_textured data folder. (Cesium Ion only accepts single textured materials for a 3D_CAPTURE)
## 6. FAQ
### Frequently Asked Questions
> - **Q:** Can I use the plugin with older versions of WebODM?
> - **A:** No, the updated plugin is compatible only with WebODM versIon 2.5.0 or later.

Wyświetl plik

@ -0,0 +1,17 @@
[
{
"name": "formik",
"url": "https://github.com/jaredpalmer/formik",
"license": "MIT"
},
{
"name": "yup",
"url": "https://github.com/jquense/yup",
"license": "MIT"
},
{
"name": "boto3",
"url": "https://github.com/boto/boto3",
"license": "Apache-2.0"
}
]

Wyświetl plik

@ -0,0 +1 @@
from .plugin import *

Wyświetl plik

@ -0,0 +1,322 @@
import sys
import time
import logging
import requests
from os import path
from enum import Enum
from itertools import chain as iter_chain
from app.plugins.views import TaskView
from app.plugins.worker import run_function_async
from app.plugins.data_store import GlobalDataStore
from app.plugins import signals as plugin_signals
from worker.celery import app
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from rest_framework.fields import ChoiceField, CharField, JSONField
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework import serializers
from .globals import PROJECT_NAME, ION_API_URL
from .uploader import upload_to_ion
pluck = lambda dic, *keys: [dic[k] if k in dic else None for k in keys]
### ###
# API UTILS #
### ###
def get_key_for(task_id, key):
return "task_{}_{}".format(str(task_id), key)
def del_asset_info(task_id, asset_type, ds=None):
if ds is None:
ds = GlobalDataStore(PROJECT_NAME)
ds.del_key(get_key_for(task_id, asset_type.value))
def set_asset_info(task_id, asset_type, json, ds=None):
if ds is None:
ds = GlobalDataStore(PROJECT_NAME)
return ds.set_json(get_key_for(task_id, asset_type.value), json)
def get_asset_info(task_id, asset_type, default=None, ds=None):
if default is None:
default = {
"id": None,
"upload": {"progress": 0, "active": False},
"process": {"progress": 0, "active": False},
"error": "",
}
if ds is None:
ds = GlobalDataStore(PROJECT_NAME)
return ds.get_json(get_key_for(task_id, asset_type.value), default)
def is_asset_task(asset_meta):
is_error = len(asset_meta["error"]) > 0
return asset_meta["upload"]["active"] or asset_meta["process"]["active"] or is_error
def get_processing_assets(task_id):
ispc = app.control.inspect()
ion_tasks = set()
active = set()
from uuid import UUID
for wtask in iter_chain(*ispc.active().values(), *ispc.reserved().values()):
args = eval(wtask["args"])
if len(args) < 2:
continue
ion_tasks.add((str(args[0]), AssetType[args[1]]))
for asset_type in AssetType:
asset_info = get_asset_info(task_id, asset_type)
ion_task_id = (task_id, asset_type)
if not is_asset_task(asset_info) or ion_task_id in ion_tasks:
continue
active.add(asset_type)
return active
### ###
# MODEL CONFIG #
### ###
class AssetType(str, Enum):
ORTHOPHOTO = "ORTHOPHOTO"
TERRAIN_MODEL = "TERRAIN_MODEL"
SURFACE_MODEL = "SURFACE_MODEL"
POINTCLOUD = "POINTCLOUD"
TEXTURED_MODEL = "TEXTURED_MODEL"
class SourceType(str, Enum):
RASTER_IMAGERY = "RASTER_IMAGERY"
RASTER_TERRAIN = "RASTER_TERRAIN"
TERRAIN_DATABASE = "TERRAIN_DATABASE"
CITYGML = "CITYGML"
KML = "KML"
CAPTURE = "3D_CAPTURE"
MODEL = "3D_MODEL"
POINTCLOUD = "POINT_CLOUD"
class OutputType(str, Enum):
IMAGERY = "IMAGERY"
TILES = "3DTILES"
TERRAIN = "TERRAIN"
ASSET_TO_FILE = {
AssetType.ORTHOPHOTO: "orthophoto.tif",
AssetType.TERRAIN_MODEL: "dtm.tif",
AssetType.SURFACE_MODEL: "dsm.tif",
AssetType.POINTCLOUD: "georeferenced_model.laz",
AssetType.TEXTURED_MODEL: "textured_model.zip",
}
FILE_TO_ASSET = dict([reversed(i) for i in ASSET_TO_FILE.items()])
ASSET_TO_OUTPUT = {
AssetType.ORTHOPHOTO: OutputType.IMAGERY,
AssetType.TERRAIN_MODEL: OutputType.TERRAIN,
AssetType.SURFACE_MODEL: OutputType.TERRAIN,
AssetType.POINTCLOUD: OutputType.TILES,
AssetType.TEXTURED_MODEL: OutputType.TILES,
}
ASSET_TO_SOURCE = {
AssetType.ORTHOPHOTO: SourceType.RASTER_IMAGERY,
AssetType.TERRAIN_MODEL: SourceType.RASTER_TERRAIN,
AssetType.SURFACE_MODEL: SourceType.RASTER_TERRAIN,
AssetType.POINTCLOUD: SourceType.POINTCLOUD,
AssetType.TEXTURED_MODEL: SourceType.CAPTURE,
}
### ###
# RECIEVERS #
### ###
@receiver(plugin_signals.task_removed, dispatch_uid="oam_on_task_removed")
@receiver(plugin_signals.task_completed, dispatch_uid="oam_on_task_completed")
def oam_cleanup(sender, task_id, **kwargs):
# When a task is removed, simply remove clutter
# When a task is re-processed, make sure we can re-share it if we shared a task previously
for asset_type in AssetType:
del_asset_info(task_id, asset_type)
### ###
# API VIEWS #
### ###
class EnumField(ChoiceField):
default_error_messages = {"invalid": _("No matching enum type.")}
def __init__(self, **kwargs):
self.enum_type = kwargs.pop("enum_type")
choices = [enum_item.value for enum_item in self.enum_type]
self.choice_set = set(choices)
super().__init__(choices, **kwargs)
def to_internal_value(self, data):
if data in self.choice_set:
return self.enum_type[data]
self.fail("invalid")
def to_representation(self, value):
if not value:
return None
return value.value
class UploadSerializer(serializers.Serializer):
token = CharField()
name = CharField()
asset_type = EnumField(enum_type=AssetType)
description = CharField(default="", required=False, allow_blank=True)
attribution = CharField(default="", required=False, allow_blank=True)
options = JSONField(default={}, required=False)
class UpdateIonAssets(serializers.Serializer):
token = CharField()
class ShareTaskView(TaskView):
def get(self, request, pk=None):
task = self.get_and_check_task(request, pk)
assets = []
for file_name in task.available_assets:
if file_name not in FILE_TO_ASSET:
continue
asset_type = FILE_TO_ASSET[file_name]
asset_info = get_asset_info(task.id, asset_type)
ion_id = asset_info["id"]
is_error = len(asset_info["error"]) > 0
is_task = is_asset_task(asset_info)
is_exported = asset_info["id"] is not None and not is_task
assets.append(
{
"type": asset_type,
"isError": is_error,
"isTask": is_task,
"isExported": is_exported,
**asset_info,
}
)
return Response({"items": assets}, status=status.HTTP_200_OK)
def post(self, request, pk=None):
from app.plugins import logger
task = self.get_and_check_task(request, pk)
serializer = UploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
token, asset_type, name, description, attribution, options = pluck(
serializer.validated_data,
"token",
"asset_type",
"name",
"description",
"attribution",
"options",
)
asset_path = task.get_asset_download_path(ASSET_TO_FILE[asset_type])
# Skip already processing tasks
if asset_type not in get_processing_assets(task.id):
if asset_type == AssetType.TEXTURED_MODEL and "position" not in options:
value = task.ASSETS_MAP[ASSET_TO_FILE[asset_type]]
if isinstance(value, dict):
if 'deferred_compress_dir' in value:
odm_path = value['deferred_compress_dir']
asset_path = task.generate_deferred_asset(asset_path, odm_path, False)
logger.info(f"generate_deferred_asset at {asset_path}")
extent = None
if task.dsm_extent is not None:
extent = task.dsm_extent.extent
if task.dtm_extent is not None:
extent = task.dtm_extent.extent
if extent is None:
print(f"Unable to find task boundary: {task}")
else:
lng, lat = extent[0], extent[1]
# height is set to zero as model is already offset
options["position"] = [lng, lat, 0]
del_asset_info(task.id, asset_type)
asset_info = get_asset_info(task.id, asset_type)
asset_info["upload"]["active"] = True
set_asset_info(task.id, asset_type, asset_info)
run_function_async(upload_to_ion,
task.id,
asset_type,
token,
asset_path,
name,
description,
attribution,
options,
)
else:
print(f"Ignore running ion task {task.id} {str(asset_type)}")
return Response(status=status.HTTP_200_OK)
class RefreshIonTaskView(TaskView):
def post(self, request, pk=None):
serializer = UpdateIonAssets(data=request.data)
serializer.is_valid(raise_exception=True)
task = self.get_and_check_task(request, pk)
token = serializer.validated_data["token"]
headers = {"Authorization": f"Bearer {token}"}
is_updated = False
# ion cleanup check
for asset_type in AssetType:
asset_info = get_asset_info(task.id, asset_type)
ion_id = asset_info["id"]
if ion_id is None:
continue
res = requests.get(f"{ION_API_URL}/assets/{ion_id}", headers=headers)
if res.status_code != 200:
del_asset_info(task.id, asset_type)
is_updated = True
# dead task cleanup
for asset_type in get_processing_assets(task.id):
del_asset_info(task.id, asset_type)
is_updated = True
return Response({"updated": is_updated}, status=status.HTTP_200_OK)
class ClearErrorsTaskView(TaskView):
def post(self, request, pk=None):
task = self.get_and_check_task(request, pk)
for asset_type in AssetType:
asset_info = get_asset_info(task.id, asset_type)
if len(asset_info["error"]) <= 0:
continue
del_asset_info(task.id, asset_type)
return Response({"complete": True}, status=status.HTTP_200_OK)

Wyświetl plik

@ -0,0 +1,71 @@
import json
import requests
from django import forms
from django.contrib import messages
from django.http import HttpResponse
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from .globals import ION_API_URL
class TokenForm(forms.Form):
token = forms.CharField(
label="",
required=False,
max_length=1024,
widget=forms.TextInput(attrs={"placeholder": "Token"}),
)
def JsonResponse(dictionary):
return HttpResponse(json.dumps(dictionary), content_type="application/json")
def HomeView(plugin):
@login_required
def view(request):
ds = plugin.get_user_data_store(request.user)
# if this is a POST request we need to process the form data
if request.method == "POST":
form = TokenForm(request.POST)
if form.is_valid():
token = form.cleaned_data["token"].strip()
if len(token) > 0:
messages.success(request, "Updated Cesium ion Token!")
else:
messages.info(request, "Reset Cesium ion Token")
ds.set_string("token", token)
form = TokenForm(initial={"token": ds.get_string("token", default="")})
return render(
request,
plugin.template_path("app.html"),
{"title": "Cesium Ion", "form": form},
)
return view
def LoadButtonView(plugin):
@login_required
def view(request):
ds = plugin.get_user_data_store(request.user)
token = ds.get_string("token")
return render(
request,
plugin.template_path("load_buttons.js"),
{
"token": token,
"app_name": plugin.get_name(),
"api_url": plugin.public_url("").rstrip("/"),
"ion_url": ION_API_URL,
},
content_type="text/javascript",
)
return view

Wyświetl plik

@ -0,0 +1,2 @@
PROJECT_NAME = __name__.split(".")[-2]
ION_API_URL = "https://api.cesium.com/v1"

Wyświetl plik

@ -0,0 +1,13 @@
{
"name": "Cesium Ion",
"webodmMinVersion": "2.5.0",
"description": "Upload and tile ODM assets with Cesium ion.",
"version": "1.3.0",
"author": "Cesium GS, Inc",
"email": "hello@cesium.com",
"repository": " https://github.com/OpenDroneMap/WebODM",
"tags": ["cesium ion", "cesium"],
"homepage": "https://cesium.com",
"experimental": false,
"deprecated": false
}

Wyświetl plik

@ -0,0 +1,71 @@
from os import path, mkdir, walk, remove as removeFile
from zipfile import ZipFile, ZIP_DEFLATED
from shutil import rmtree
from tempfile import mkdtemp
DELETE_EXTENSIONS = (".conf", ".vec", ".spt")
OBJ_FILE_EXTENSION = ".obj"
MTL_FILE_EXTENSION = ".mtl"
class IonInvalidZip(Exception):
pass
def file_walk(directory):
for root, _, file_names in walk(directory):
for file_name in file_names:
yield path.join(root, file_name)
def zip_dir(zip_name, directory, destructive=False):
with ZipFile(zip_name, mode="w", compression=ZIP_DEFLATED) as zipfile:
for file_path in file_walk(directory):
relpath = path.relpath(file_path, directory)
zipfile.write(file_path, relpath)
if destructive:
removeFile(file_path)
def to_ion_texture_model(texture_model_path, dest_directory=None, minimize_space=True):
is_tmp = False
if dest_directory is None:
is_tmp = True
dest_directory = mkdtemp()
dest_file = path.join(dest_directory, path.basename(texture_model_path))
try:
unzip_dir = path.join(dest_directory, "_tmp")
mkdir(unzip_dir)
with ZipFile(texture_model_path) as zipfile:
zipfile.extractall(unzip_dir)
files_to_delete = set()
found_geo = False
for file_name in file_walk(unzip_dir):
if file_name.endswith(DELETE_EXTENSIONS):
files_to_delete.add(file_name)
elif file_name.endswith(".obj"):
if "_geo" in path.basename(file_name):
found_geo = True
else:
file_name = path.splitext(file_name)[0]
files_to_delete.add(file_name + OBJ_FILE_EXTENSION)
files_to_delete.add(file_name + MTL_FILE_EXTENSION)
if not found_geo:
raise IonInvalidZip("Unable to find geo file")
for file_name in files_to_delete:
if not path.isfile(file_name):
continue
removeFile(file_name)
zip_dir(dest_file, unzip_dir, destructive=minimize_space)
rmtree(unzip_dir)
except Exception as e:
if is_tmp:
rmtree(dest_directory)
raise e
return dest_file, dest_directory

Wyświetl plik

@ -0,0 +1,39 @@
import re
import json
from app.plugins import PluginBase, Menu, MountPoint, logger
from .globals import PROJECT_NAME
from .api_views import ShareTaskView, RefreshIonTaskView, ClearErrorsTaskView
from .app_views import HomeView, LoadButtonView
class Plugin(PluginBase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.name = PROJECT_NAME
def main_menu(self):
return [Menu("Cesium Ion", self.public_url(""), "fa-cesium fa fa-fw")]
def include_js_files(self):
return ["load_buttons.js"]
def include_css_files(self):
return ["font.css", "build/TaskView.css"]
def build_jsx_components(self):
return ["TaskView.jsx"]
def api_mount_points(self):
return [
MountPoint("task/(?P<pk>[^/.]+)/share", ShareTaskView.as_view()),
MountPoint("task/(?P<pk>[^/.]+)/refresh", RefreshIonTaskView.as_view()),
MountPoint("task/(?P<pk>[^/.]+)/clear", ClearErrorsTaskView.as_view()),
]
def app_mount_points(self):
return [
MountPoint("$", HomeView(self)),
MountPoint("load_buttons.js$", LoadButtonView(self)),
]

Wyświetl plik

@ -0,0 +1,291 @@
import React, { Component, Fragment } from "react";
import ErrorMessage from "webodm/components/ErrorMessage";
import IonAssetButton from "./components/IonAssetButton";
import UploadDialog from "./components/UploadDialog";
import TasksDialog from "./components/TasksDialog";
import AppContext from "./components/AppContext";
import {
ImplicitTaskFetcher as TaskFetcher,
APIFetcher
} from "./components/Fetcher";
import {AssetConfig, AssetStyles} from "./defaults";
import { fetchCancelable, getCookie } from "./utils";
export default class TaskView extends Component {
constructor(){
super();
this.onOpenUploadDialog = this.onOpenUploadDialog.bind(this);
this.showTaskDialog = this.showTaskDialog.bind(this);
}
state = {
error: "",
currentAsset: null,
isTasksDialog: false,
isUploadDialogLoading: false
};
cancelableFetch = null;
timeoutHandler = null;
refreshAssets = null;
componentWillUnmount() {
if (this.timeoutHandler !== null) {
clearTimeout(this.timeoutHandler);
this.timeoutHandler = null;
}
if (this.cancelableFetch !== null) {
try {
this.cancelableFetch.cancel();
this.cancelableFetch = null;
} catch (exception) {
console.error(exception);
}
}
this.refreshAssets = null;
}
onOpenUploadDialog(asset) {
this.setState({ currentAsset: asset });
if (this.uploadDialog != null)
{
this.uploadDialog.show();
}
}
onHideUploadDialog = () =>
this.setState({ currentAsset: null, isUploadDialogLoading: false });
showTaskDialog() {
this.setState({ isTasksDialog: true });
if (this.tasksDialog != null)
{
this.tasksDialog.show();
}
}
hideTaskDialog = () => this.setState({ isTasksDialog: false });
onUploadAsset = async data => {
const { task, token, apiURL } = this.props;
const { currentAsset } = this.state;
const payload = {...data};
if (currentAsset === null) {
return;
}
payload.token = token;
payload.asset_type = currentAsset;
this.setState({ isUploadDialogLoading: true });
this.cancelableFetch = await fetchCancelable(
`/api${apiURL}/task/${task.id}/share`,
{
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": getCookie("csrftoken"),
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
)
.promise.then(this.refreshAssets)
.finally(this.onHideUploadDialog);
};
onClearFailedAssets = () => {
const { task, apiURL } = this.props;
this.cancelableFetch = fetchCancelable(
`/api${apiURL}/task/${task.id}/clear`,
{
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": getCookie("csrftoken")
}
}
);
this.cancelableFetch.promise.then(this.refreshAssets);
};
onAssetsRefreshed = ({ items = [] }) => {
const { isTasksDialog } = this.state;
const hasTasks = items.some(item => item.isTask);
if (! hasTasks){
this.hideTaskDialog();
}
if (items.some(item => item.isTask && !item.isError)) {
const timeout = 4000 / (isTasksDialog ? 2 : 1);
this.timeoutHandler = setTimeout(this.refreshAssets, timeout);
}
};
onCleanStatus = ({ updated = false }) => {
if (! updated || this.refreshAssets == null){
return;
}
this.refreshAssets();
};
onErrorUploadDialog = msg => {
this.setState({ error: msg });
this.onHideUploadDialog();
};
handleAssetSelect = data => asset => {
const idMap = data.items
.filter(item => item.isExported)
.reduce((accum, item) => {
accum[item.type] = item.id;
return accum;
}, {});
if (idMap[asset] === undefined) {
console.warn('Asset not found.', asset);
}
window.open(`https://cesium.com/ion/assets/${idMap[asset] ?? ''}`);
};
render() {
const { task, token } = this.props;
const {
isTasksDialog,
isUploadDialogLoading,
currentAsset
} = this.state;
const isUploadDialog = currentAsset !== null;
const assetName = isUploadDialog ? AssetConfig[currentAsset].name : "";
return (
<AppContext.Provider value={this.props}>
<ErrorMessage bind={[this, "error"]} />
<div className={"ion-dropdowns"}>
<TaskFetcher
path={"share"}
onLoad={this.onAssetsRefreshed}
onBindRefresh={method => (this.refreshAssets = method)}
>
{({ isError, data = {} }) => {
// Asset Export and View Selector
const { items = [] } = data;
const available = items
.filter(
item => {
return (! item.isExported && ! item.isTask) || item.isError
}
)
.map(item => item.type);
const exported = items
.filter(item => item.isExported)
.map(item => item.type);
// Tasks Selector
const processing = items.filter(item => item.isTask);
const hasProcessingTasks = processing.length > 0;
const hasErrors = processing.some(item => item.isError);
return (
<Fragment>
{available.length > 0 && (
<IonAssetButton
assets={available}
onSelect={this.onOpenUploadDialog}
>
Tile in Cesium Ion
</IonAssetButton>
)}
{exported.length > 0 && (
<IonAssetButton
assets={exported}
onSelect={this.handleAssetSelect(
data
)}
>
View in Cesium Ion
</IonAssetButton>
)}
{items.length <= 0 && (
<button
className={"ion-btn btn btn-primary btn-sm"}
onClick={this.refreshAssets}
>
<i className={"fa fa-cesium"} />
Refresh Available ion Assets
</button>
)}
{hasProcessingTasks && (
<button
className={`ion-btn btn btn-sm ${hasErrors ? "btn-danger" : "btn-primary"}`}
onClick={this.showTaskDialog}
>
<i className={"fa fa-cesium"} />
View ion Tasks
</button>
)}
<TasksDialog
title={"Cesium ion Tasks"}
show={isTasksDialog}
tasks={processing}
onHide={this.hideTaskDialog}
onClearFailed={this.onClearFailedAssets}
ref={(domNode) => { this.tasksDialog = domNode; }}
/>
</Fragment>
);
}}
</TaskFetcher>
</div>
<APIFetcher path={"projects/"} params={{ id: task.project }}>
{({ isLoading, isError, data }) => {
const initialValues = {};
if (isLoading || assetName === "") {
return null;
}
if (!isLoading && !isError && data?.length > 0) {
const project = data[0];
initialValues.name = `${project.name} | ${task.name} ⁠— ${assetName}`;
initialValues.description = project.description;
}
return (
<UploadDialog
title={`Tile in Cesium ion — ${assetName}`}
initialValues={initialValues}
show={isUploadDialog}
loading={isUploadDialogLoading}
asset={currentAsset}
onHide={this.onHideUploadDialog}
onSubmit={this.onUploadAsset}
onError={this.onErrorUploadDialog}
ref={(domNode) => { this.uploadDialog = domNode; }}
/>
);
}}
</APIFetcher>
<TaskFetcher
method={"POST"}
path={"refresh"}
body={JSON.stringify({ token })}
onLoad={this.onCleanStatus}
/>
</AppContext.Provider>
);
}
}

Wyświetl plik

@ -0,0 +1,8 @@
const AppContext = React.createContext({
apiUrl: null,
ionURL: null,
token: null,
task: null
});
export default AppContext;

Wyświetl plik

@ -0,0 +1,140 @@
import React, { PureComponent } from "react";
import AppContext from "./AppContext";
import { fetchCancelable, getCookie } from "../utils";
export class Fetcher extends PureComponent {
static defaultProps = {
url: "",
path: "",
method: "GET",
onBindRefresh: () => {},
onError: () => {},
onLoad: () => {}
};
state = {
isLoading: true,
isError: false
};
cancelableFetch = null;
fetch = () => {
const {
url,
path,
onError,
onLoad,
refresh,
children,
params,
...options
} = this.props;
let queryURL = `${url}/${path}`;
if (params !== undefined) {
const serializedParams = `?${Object.keys(params)
.map(key =>
[key, params[key]].map(encodeURIComponent).join("=")
)
.join("&")}`;
queryURL = queryURL.replace(/[\/\?]+$/, "");
queryURL += serializedParams;
}
this.cancelableFetch = fetchCancelable(queryURL, options);
return this.cancelableFetch.promise
.then(res => {
if (res.status !== 200) throw new Error(res.status);
return res.json();
})
.then(data => {
this.setState({ data, isLoading: false });
onLoad(data);
})
.catch(out => {
if (out.isCanceled) return;
this.setState({ error: out, isLoading: false, isError: true });
onError(out);
})
.finally(() => (this.cancelableFetch = null));
};
componentDidMount() {
this.fetch();
this.props.onBindRefresh(this.fetch);
}
componentWillUnmount() {
this.props.onBindRefresh(null);
if (this.cancelableFetch === null) return;
this.cancelableFetch.cancel();
this.cancelableFetch = null;
}
render() {
const { children } = this.props;
if (children == null) return null;
if (typeof children !== "function")
return React.cloneElement(children, this.state);
else return children(this.state);
}
}
const ImplicitFetcher = ({
url,
getURL = null,
getOptions = null,
...options
}) => (
<AppContext.Consumer>
{context => (
<Fetcher
url={getURL !== null ? getURL(context, options) : url}
{...(getOptions !== null ? getOptions(context, options) : {})}
{...options}
/>
)}
</AppContext.Consumer>
);
const APIFetcher = props => (
<Fetcher
url={"/api"}
credentials={"same-origin"}
headers={{
"X-CSRFToken": getCookie("csrftoken"),
Accept: "application/json",
"Content-Type": "application/json"
}}
{...props}
/>
);
const ImplicitTaskFetcher = props => (
<ImplicitFetcher
getURL={({ apiURL, task }) => `/api${apiURL}/task/${task.id}`}
credentials={"same-origin"}
headers={{
"X-CSRFToken": getCookie("csrftoken"),
Accept: "application/json",
"Content-Type": "application/json"
}}
{...props}
/>
);
const ImplicitIonFetcher = props => (
<ImplicitFetcher
getURL={({ ionURL }) => ionURL}
getOptions={({ token }) => ({
headers: {
Authorization: `Bearer ${token}`
}
})}
{...props}
/>
);
export { APIFetcher, ImplicitTaskFetcher, ImplicitIonFetcher };

Wyświetl plik

@ -0,0 +1,35 @@
import React from "react";
import { connect } from "formik";
class FormikErrorFocus extends React.Component {
isObject(value) {
return (
value && typeof value === "object" && value.constructor === Object
);
}
getKeysRecursively = object => {
if (!this.isObject(object)) {
return "";
}
const currentKey = Object.keys(object)[0];
if (!this.getKeysRecursively(object[currentKey])) {
return currentKey;
}
return currentKey + "." + this.getKeysRecursively(object[currentKey]);
};
componentDidUpdate(prevProps) {
const { isSubmitting, isValidating, errors } = prevProps.formik;
const keys = Object.keys(errors);
if (keys.length > 0 && isSubmitting && !isValidating) {
const selectorKey = this.getKeysRecursively(errors);
const selector = `[id="${selectorKey}"], [name="${selectorKey}"] `;
const errorElement = document.querySelector(selector);
if (errorElement) errorElement.focus();
console.warn(errors);
}
}
render() {
return null;
}
}
export default connect(FormikErrorFocus);

Wyświetl plik

@ -0,0 +1,55 @@
import React, { PureComponent, Fragment } from "react";
import IonAssetLabel from "./IonAssetLabel";
import { AssetStyles } from "../defaults";
import "./IonAssetButton.scss";
export default class IonAssetButton extends PureComponent {
static defaultProps = {
assets: [],
assetComponent: IonAssetLabel,
onSelect: () => {}
};
handleClick = asset => () => this.props.onSelect(asset);
render() {
const {
assets,
onSelect,
children,
assetComponent: AssetComponent
} = this.props;
const menuItems = assets
.sort((a, b) => AssetStyles[a].name.localeCompare(AssetStyles[b].name))
.map(asset => (
<li>
<a key={asset} style={{cursor:'pointer'}} onClick={this.handleClick(asset)}>
<AssetComponent asset={asset} showIcon={true} />
</a>
</li>
));
const title = (
<Fragment>
<i className={"fa fa-cesium"} />
{children}
</Fragment>
);
return (
<div className={"btn-group"}>
<button type="button" className={"btn btn-sm btn-primary"} data-toggle="dropdown">
{title}
</button>
<button type="button" className={"btn btn-sm dropdown-toggle btn-primary"} data-toggle="dropdown">
<span className="caret"></span>
</button>
<ul className="dropdown-menu">
{menuItems}
</ul>
</div>
);
}
}

Wyświetl plik

@ -0,0 +1,14 @@
.ion-btn {
.fa-cesium {
margin-right: 0.5em;
}
.caret {
margin-left: 1em;
}
}
.ion-dropdowns .dropdown {
float: none;
margin-right: 4px;
}

Wyświetl plik

@ -0,0 +1,12 @@
import React, { PureComponent, Fragment } from "react";
import { AssetStyles } from "../defaults";
const IonAssetLabel = ({ asset, showIcon = false, ...options }) => (
<Fragment>
{showIcon && <i className={`${AssetStyles[asset].icon}`} />}
{" "}
{AssetStyles[asset].name}
</Fragment>
);
export default IonAssetLabel;

Wyświetl plik

@ -0,0 +1,123 @@
import React from 'react';
import PropTypes from 'prop-types';
const IonFieldComponent = ({
name,
value,
label,
help,
type = "text",
showIcon = true,
error,
touched,
onChange,
onBlur,
...props
}) => {
const isError = error && touched;
const isCheckbox = type === "checkbox";
const ControlComponent = isCheckbox ? "input" : (type === "textarea" || type === "select") ? type : "input";
return (
<div className={`form-group${isError ? ' has-error' : ''}`} style={{ marginLeft: 0, marginRight: 0 }}>
{label && !isCheckbox && <label htmlFor={name} className="control-label">{label}</label>}
<ControlComponent
id={name}
name={name}
className={isCheckbox ? "" : "form-control"}
type={type}
value={isCheckbox ? undefined : value}
checked={isCheckbox ? value : undefined}
onChange={onChange}
onBlur={onBlur}
{...props}
/>
{label && isCheckbox && <label htmlFor={name} className="control-label">{label}</label>}
{isError && <span className="help-block">{error}</span>}
{help && !isError && <span className="help-block">{help}</span>}
{isError && showIcon && <span className="glyphicon glyphicon-remove form-control-feedback"></span>}
</div>
);
};
IonFieldComponent.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.any,
label: PropTypes.string,
help: PropTypes.string,
type: PropTypes.string,
showIcon: PropTypes.bool,
error: PropTypes.string,
touched: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired
};
class IonField extends React.Component {
constructor(props) {
super(props);
this.state = {
value: props.type === "checkbox" ? props.checked : props.value || '',
touched: false,
error: ''
};
}
handleChange = (e) => {
const { type, checked, value } = e.target;
const newValue = type === "checkbox" ? checked : value;
this.setState({ value: newValue }, () => {
if (this.props.onChange) {
this.props.onChange(e);
}
});
};
handleBlur = (e) => {
this.setState({ touched: true }, () => {
if (this.props.onBlur) {
this.props.onBlur(e);
}
});
};
render() {
const { name, label, help, type, showIcon, validate, ...props } = this.props;
const { value, touched, error } = this.state;
let validationError = error;
if (validate) {
validationError = validate(value);
}
return (
<IonFieldComponent
name={name}
value={value}
label={label}
help={help}
type={type}
showIcon={showIcon}
error={validationError}
touched={touched}
onChange={this.handleChange}
onBlur={this.handleBlur}
{...props}
/>
);
}
}
IonField.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.string,
help: PropTypes.string,
type: PropTypes.string,
showIcon: PropTypes.bool,
validate: PropTypes.func,
onChange: PropTypes.func,
onBlur: PropTypes.func
};
export { IonFieldComponent };
export default IonField;

Wyświetl plik

@ -0,0 +1,19 @@
.ion-tasks .list-group .list-group-item {
border-right: 0;
border-left: 0;
border-radius: 0;
padding: 20px 0;
&:first-child {
border-top: 0;
}
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
.progress {
margin-bottom: 0;
}
}

Wyświetl plik

@ -0,0 +1,183 @@
import React from 'react';
import PropTypes from 'prop-types';
// import ErrorMessage from './ErrorMessage';
import IonAssetLabel from './IonAssetLabel';
import './TaskDialog.scss';
import $ from 'jquery';
const TaskStatusItem = ({
asset,
progress,
task,
helpText = '',
active = true,
bsStyle = 'primary'
}) => (
<div className="list-group-item">
<div className="row">
<div className="col-xs-6">
<p style={{ fontWeight: 'bold' }}>
<IonAssetLabel asset={asset} showIcon={true} />
</p>
</div>
<div className="col-xs-6">
<p className="pull-right">Status: {task}</p>
</div>
</div>
<progress value={progress} max="100" className={bsStyle}></progress>
{helpText && <small>{helpText}</small>}
</div>
);
export default class TaskDialog extends React.Component {
static defaultProps = {
tasks: [],
taskComponent: TaskStatusItem,
show: false
};
static propTypes = {
tasks: PropTypes.array.isRequired,
taskComponent: PropTypes.elementType,
onClearFailed: PropTypes.func.isRequired,
onHide: PropTypes.func.isRequired,
show: PropTypes.bool
};
constructor(props) {
super(props);
this.state = {
showModal: props.show,
error: ''
};
this.setModal = this.setModal.bind(this);
this.show = this.show.bind(this);
this.hide = this.hide.bind(this);
}
setModal(domNode) {
this.modal = domNode;
}
componentDidMount() {
this._mounted = true;
$(this.modal)
.on('hidden.bs.modal', () => {
this.hide();
})
.on('shown.bs.modal', () => {
if (this.props.onShow) this.props.onShow();
});
this.componentDidUpdate();
}
componentWillUnmount() {
this._mounted = false;
$(this.modal).off('hidden.bs.modal shown.bs.modal').modal('hide');
}
componentDidUpdate() {
if (this.state.showModal) {
$(this.modal).modal('show');
} else {
$(this.modal).modal('hide');
}
}
show() {
this.setState({ showModal: true, error: '' });
}
hide() {
this.setState({ showModal: false });
if (this.props.onHide) this.props.onHide();
}
renderTaskItems() {
const { tasks, taskComponent: TaskComponent } = this.props;
let hasErrors = false;
const taskItems = tasks.map(
({ type: asset, upload, process, error }) => {
let task,
style,
progress = 0;
if (upload.active) {
progress = upload.progress;
task = 'Uploading';
style = 'info';
} else if (process.active) {
progress = process.progress;
task = 'Processing';
style = 'success';
}
if (error.length > 0) {
task = 'Error';
style = 'danger';
console.error(error);
hasErrors = true;
}
return (
<TaskComponent
key={asset}
asset={asset}
progress={progress * 100}
task={task}
bsStyle={style}
helpText={error}
/>
);
}
);
return { taskItems, hasErrors };
}
render() {
const { onClearFailed } = this.props;
const { taskItems, hasErrors } = this.renderTaskItems();
return (
<div ref={this.setModal} className="modal task-dialog" tabIndex="-1" data-backdrop="static">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" onClick={this.hide}>
<span>&times;</span>
</button>
<h4 className="modal-title">
<i className="fa fa-cesium" /> Cesium Ion Tasks
</h4>
</div>
<div className="modal-body">
{/* <ErrorMessage bind={[this, "error"]} /> */}
<div className="list-group">{taskItems}</div>
{hasErrors && (
<button
className="center-block btn btn-danger btn-sm"
onClick={onClearFailed}
>
<i className="glyphicon glyphicon-trash"></i>
Remove Failed Tasks
</button>
)}
</div>
<div className="modal-footer">
<button className="btn btn-primary" onClick={this.hide}>
Close
</button>
</div>
</div>
</div>
</div>
);
}
}

Wyświetl plik

@ -0,0 +1,196 @@
import React, { Component, Fragment } from "react";
import FormDialog from "../../../../app/static/app/js/components/FormDialog";
import IonField from "./IonField";
import { ImplicitIonFetcher as IonFetcher } from "./Fetcher";
import { AssetType, SourceType } from "../defaults";
import "./UploadDialog.scss";
export default class UploadDialog extends Component {
static AssetSourceType = {
[AssetType.ORTHOPHOTO]: SourceType.RASTER_IMAGERY,
[AssetType.TERRAIN_MODEL]: SourceType.RASTER_TERRAIN,
[AssetType.SURFACE_MODEL]: SourceType.RASTER_TERRAIN,
[AssetType.POINTCLOUD]: SourceType.POINTCLOUD,
[AssetType.TEXTURED_MODEL]: SourceType.CAPTURE
};
static defaultProps = {
show: true,
asset: null,
loading: false,
initialValues: {
name: "",
description: "",
attribution: "",
options: {
baseTerrainId: "",
textureFormat: false
}
}
};
constructor(props) {
super(props);
this.mergedInitialValues = {
...UploadDialog.defaultProps.initialValues,
...this.props.initialValues
};
this.state = {
title : props.title,
...this.mergedInitialValues
}
}
show(){
this.dialog.show();
}
handleChange = (e) => {
const { value, name } = e.target;
if (name === "options.textureFormat")
{
let options = {...this.state.options};
options["textureFormat"] = value === "Yes";
this.setState({ options });
}
else if (name === "options.baseTerrainId")
{
let options = {...this.state.options};
options["baseTerrainId"] = value;
this.setState({ options });
}
else
{
this.setState({ [name]: value });
}
}
handleError = msg => error => {
this.props.onError(msg);
};
onSubmit = values => {
const { asset, onSubmit } = this.props;
values = {...this.state};
const { options = {} } = values;
switch (UploadDialog.AssetSourceType[asset]) {
case SourceType.RASTER_TERRAIN:
if (options.baseTerrainId === "")
delete options["baseTerrainId"];
else options.baseTerrainId = parseInt(options.baseTerrainId);
options.toMeters = 1;
options.heightReference = "WGS84";
options.waterMask = false;
break;
case SourceType.CAPTURE:
options.textureFormat = options.textureFormat ? "KTX2" : "AUTO";
break;
}
onSubmit(values);
};
getSourceFields() {
switch (UploadDialog.AssetSourceType[this.props.asset]) {
case SourceType.RASTER_TERRAIN:
let loadOptions = ({ isLoading, isError, data }) => {
if (isLoading || isError){
return <option disabled>LOADING...</option>;
}
let userItems = data.items
.filter(item => item.type === "TERRAIN")
.map(item => (
<option key={item.id} value={item.id}>
{item.name}
</option>
));
return [
<option key={"mean-sea-level"} value={""}>
Mean Sea Level
</option>,
...userItems
];
};
return (
<IonField
name={"options.baseTerrainId"}
label={"Base Terrain: "}
type={"select"}
value={this.state.options.baseTerrainId}
onChange={this.handleChange}
>
<IonFetcher
path="assets"
onError={this.handleError('Failed to load terrain options. Please check your token!')}
>
{loadOptions}
</IonFetcher>
</IonField>
);
case SourceType.CAPTURE:
return (
<IonField
name={"options.textureFormat"}
label={"Use KTX2 Compression"}
type={"select"}
value={this.state.options.textureFormat ? "Yes" : "No"}
help={'KTX v2.0 is an image container format that supports Basis Universal supercompression. Use KTX2 compression to create a smaller tileset with better streaming performance.'}
onChange={this.handleChange}
>
<option>No</option>
<option>Yes</option>
</IonField>
);
default:
return null;
}
}
render() {
return (
<FormDialog
title={this.state.title}
show={this.props.show}
onHide={this.props.onHide}
handleSaveFunction={this.onSubmit}
saveLabel={this.state.loading ? "Submitting..." : "Submit"}
savingLabel="Submitting..."
saveIcon={this.state.loading ? "fa fa-sync fa-spin" : "fa fa-upload"}
ref={(domNode) => { this.dialog = domNode; }}
>
<IonField
name={"name"}
label={"Name: "}
type={"text"}
value={this.state.name}
onChange={this.handleChange}
/>
<IonField
name={"description"}
label={"Description: "}
type={"textarea"}
rows={"3"}
value={this.state.description}
onChange={this.handleChange}
/>
<IonField
name={"attribution"}
label={"Attribution: "}
type={"text"}
value={this.state.attribution}
onChange={this.handleChange}
/>
{this.getSourceFields()}
</FormDialog>
);
}
}

Wyświetl plik

@ -0,0 +1,7 @@
.modal-backdrop {
z-index: 100000 !important;
}
.ion-upload.modal button i {
margin-right: 1em;
}

Wyświetl plik

@ -0,0 +1,26 @@
import AssetType from "./AssetType";
const AssetConfig = {
[AssetType.ORTHOPHOTO]: {
name: "Orthophoto",
icon: "far fa-image"
},
[AssetType.TERRAIN_MODEL]: {
name: "Terrain Model",
icon: "fa fa-chart-area"
},
[AssetType.SURFACE_MODEL]: {
name: "Surface Model",
icon: "fa fa-chart-area"
},
[AssetType.POINTCLOUD]: {
name: "Pointcloud",
icon: "fa fa-cube"
},
[AssetType.TEXTURED_MODEL]: {
name: "Texture Model",
icon: "fab fa-connectdevelop"
}
};
export default AssetConfig;

Wyświetl plik

@ -0,0 +1,9 @@
const AssetType = {
ORTHOPHOTO: "ORTHOPHOTO",
TERRAIN_MODEL: "TERRAIN_MODEL",
SURFACE_MODEL: "SURFACE_MODEL",
POINTCLOUD: "POINTCLOUD",
TEXTURED_MODEL: "TEXTURED_MODEL"
};
export default AssetType;

Wyświetl plik

@ -0,0 +1,12 @@
const SourceType = {
RASTER_IMAGERY: "RASTER_IMAGERY",
RASTER_TERRAIN: "RASTER_TERRAIN",
TERRAIN_DATABASE: "TERRAIN_DATABASE",
CITYGML: "CITYGML",
KML: "KML",
CAPTURE: "3D_CAPTURE",
MODEL: "3D_MODEL",
POINTCLOUD: "POINT_CLOUD"
};
export default SourceType;

Wyświetl plik

@ -0,0 +1,5 @@
import AssetType from "./AssetType";
import SourceType from "./SourceType";
import AssetConfig from "./AssetConfig";
export { AssetType, SourceType, AssetConfig };

Wyświetl plik

@ -0,0 +1,25 @@
@font-face {
font-family: 'fa-cesium';
src:
url('fonts/fa-cesium.ttf?rw87j5') format('truetype'),
url('fonts/fa-cesium.woff?rw87j5') format('woff'),
url('fonts/fa-cesium.svg?rw87j5#fa-cesium') format('svg');
font-weight: normal;
font-style: normal;
}
.fa-cesium:before {
content: "\e900";
font-family: 'fa-cesium' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

Wyświetl plik

@ -0,0 +1,11 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Generated by IcoMoon</metadata>
<defs>
<font id="fa-cesium" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe900;" glyph-name="cesium" horiz-adv-x="1005" d="M964.309 524.102c-16.578 0-32.613-9.381-45.192-26.269l-158.092-213.11c-26.157-35.268-63.363-55.524-102.025-55.524h-0.555c-38.686 0-75.855 20.257-102 55.524l-158.080 213.11c-12.579 16.886-28.589 26.269-45.229 26.269-16.566 0-32.626-9.381-45.155-26.269l-158.154-213.11c-25.984-35.008-62.844-55.191-101.111-55.524 80.596-173.115 253.265-293.199 453.846-293.199 277.424 0 502.419 229.232 502.419 511.962 0 20.072-1.358 39.687-3.617 58.993-11.023 11.036-23.836 17.146-37.058 17.146zM502.562 959.999c-277.535 0-502.543-229.232-502.543-511.999 0-45.019 6.258-88.409 16.961-130.046 9.48-7.394 20.084-11.764 30.848-11.764 16.677 0 32.687 9.295 45.291 26.195l158.092 213.122c26.095 35.354 63.338 55.586 101.925 55.586 38.612 0 75.793-20.232 101.975-55.586l152.055-204.925 6.58-8.197c12.554-16.799 28.528-26.058 44.995-26.195 16.418 0.136 32.404 9.394 44.933 26.195l6.641 8.197 152.105 204.925c26.095 35.354 63.313 55.586 101.901 55.586 6.11 0 12.283-0.679 18.319-1.691-63.412 208.876-254.315 360.599-480.078 360.599zM670.356 634.891c-29.021 0-52.463 23.972-52.463 53.562 0 29.515 23.442 53.438 52.463 53.438 29.058 0 52.524-23.923 52.524-53.438 0-29.601-23.466-53.562-52.524-53.562z" />
</font></defs></svg>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.6 KiB

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -0,0 +1,7 @@
{
"scripts": {
"dev": "webpack --watch"
},
"dependencies": {
}
}

Wyświetl plik

@ -0,0 +1,24 @@
const makeCancelable = promise => {
let hasCanceled_ = false;
const wrappedPromise = new Promise((resolve, reject) => {
promise.then(
val => (hasCanceled_ ? reject({ isCanceled: true }) : resolve(val)),
error =>
hasCanceled_ ? reject({ isCanceled: true }) : reject(error)
);
});
return {
promise: wrappedPromise,
cancel() {
hasCanceled_ = true;
}
};
};
export { makeCancelable };
const fetchCancelable = (...args) => makeCancelable(fetch(...args));
export default fetchCancelable;

Wyświetl plik

@ -0,0 +1,9 @@
export default function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length == 2)
return parts
.pop()
.split(";")
.shift();
}

Wyświetl plik

@ -0,0 +1,4 @@
import fetchCancelable from "./fetchCancelable";
import getCookie from "./getCookie";
export { getCookie, fetchCancelable };

Wyświetl plik

@ -0,0 +1,64 @@
{% extends "app/plugins/templates/base.html" %}
{% block content %}
<style>
.alert {
position: absolute;
z-index: 100000;
width: 79vw;
bottom: 0.5em;
right: 1em;
width: 18em;
}
#navbar-top.cesium-navbar {
margin: -15px;
margin-top: -10px;
margin-bottom: 12px;
position: relative;
}
#navbar-top.cesium-navbar > .navbar-text {
margin: 0;
left: 15px;
top: 50%;
transform: translateY(-50%);
}
#navbar-top.cesium-navbar .description {
font-size: 10px;
margin-left: 28px
}
</style>
<nav id="navbar-top" class="navbar-default cesium-navbar">
<h4 class="navbar-text">
<i class="fa fa-cesium fa-fw"></i> <strong>Cesium Ion</strong>
<p class="description">
Use Cesium Ion's simple workflow to create 3D maps of your geospatial
data for visualization, analysis, and sharing
</p>
</h4>
</div>
{% if not form.token.value %}
<h5>
<strong>Instructions</strong>
</h5>
<ol>
<li>
Generate a token at
<a href="https://cesium.com/ion/tokens" target="_blank"> cesium.com/ion/tokens </a>
with <b>all permissions:</b>
<i>assets:list, assets:read, assets:write, geocode.</i>
</li>
<li>Copy and paste the token into the form below.</li>
</ol>
{% else %}
<p><b>You are all set!</b> To share a task, select it from the <a href="/dashboard/">dashboard</a> and press the <b>Tile in Cesium ion</b> button.</p>
<p>
<a class="btn btn-sm btn-primary" href="/dashboard"><i class="fa fa-external-link"></i> Go To Dashboard</a>
<a class="btn btn-sm btn-default" href="https://cesium.com/ion" target="_blank"><i class="fa fa-cesium"></i> Open Cesium Ion</a>
</p>
{% endif %}
<form action="" method="post" class="oam-form oam-token-form">
<h5><b>Token Settings</b></h5>
{% csrf_token %}
{% include "app/plugins/templates/form.html" %}
<button type="submit" class="btn btn-primary"><i class="fa fa-save fa-fw"></i> Set Token</i></button>
</form>
{% endblock %}

Wyświetl plik

@ -0,0 +1,13 @@
PluginsAPI.Dashboard.addTaskActionButton(
["{{ app_name }}/build/TaskView.js"],
function(args, TaskView) {
if ("{{ token }}"){
return React.createElement(TaskView, {
task: args.task,
token: "{{ token }}",
apiURL: "{{ api_url }}",
ionURL: "{{ ion_url }}"
});
}
}
);

Wyświetl plik

@ -0,0 +1,216 @@
# Arg order is very important for task deconstruction.
# If order is changed make sure that the refresh API call is updated
def upload_to_ion(
task_id,
asset_type,
token,
asset_path,
name,
description="",
attribution="",
options={},
):
import sys
import time
import logging
import requests
from os import path, remove
from shutil import rmtree
from enum import Enum
from app.plugins import logger
try:
# Import from coreplugins if using Docker
from coreplugins.cesiumion.api_views import (
get_asset_info,
set_asset_info,
AssetType,
ASSET_TO_OUTPUT,
ASSET_TO_SOURCE,
ASSET_TO_FILE,
pluck,
)
from coreplugins.cesiumion.model_tools import (
to_ion_texture_model,
IonInvalidZip,
)
from coreplugins.cesiumion.globals import ION_API_URL
except ImportError:
# Import from plugins if imported as a plugin on exe application
from plugins.cesiumion.api_views import (
get_asset_info,
set_asset_info,
AssetType,
ASSET_TO_OUTPUT,
ASSET_TO_SOURCE,
ASSET_TO_FILE,
pluck,
)
from plugins.cesiumion.model_tools import (
to_ion_texture_model,
IonInvalidZip,
)
from plugins.cesiumion.globals import ION_API_URL
class LoggerAdapter(logging.LoggerAdapter):
def __init__(self, prefix, logger):
super().__init__(logger, {})
self.prefix = prefix
def process(self, msg, kwargs):
return "[%s] %s" % (self.prefix, msg), kwargs
class TaskUploadProgress(object):
def __init__(self, file_path, task_id, asset_type, logger=None, log_step_size=0.05):
self._task_id = task_id
self._asset_type = asset_type
self._logger = logger
self._uploaded_bytes = 0
self._total_bytes = float(path.getsize(file_path))
self._asset_info = get_asset_info(task_id, asset_type)
self._last_log = 0
self._log_step_size = log_step_size
@property
def asset_info(self):
return self._asset_info
def __call__(self, total_bytes):
self._uploaded_bytes += total_bytes
progress = self._uploaded_bytes / self._total_bytes
if progress == 1:
progress = 1
self._asset_info["upload"]["progress"] = progress
if self._logger is not None and progress - self._last_log > self._log_step_size:
self._logger.info(f"Upload progress: {progress * 100}%")
self._last_log = progress
set_asset_info(self._task_id, self._asset_type, self._asset_info)
asset_logger = LoggerAdapter(prefix=f"Task {task_id} {asset_type}", logger=logger)
asset_type = AssetType[asset_type]
asset_info = get_asset_info(task_id, asset_type)
del_directory = None
try:
import boto3
except ImportError:
import subprocess
asset_logger.info(f"Manually installing boto3...")
subprocess.call([sys.executable, "-m", "pip", "install", "boto3"])
import boto3
try:
# Update asset_path based off
if asset_type == AssetType.TEXTURED_MODEL:
try:
generated_zipfile = asset_path
asset_path, del_directory = to_ion_texture_model(asset_path)
logger.info("Created ion texture model!")
except IonInvalidZip as e:
logger.info("Non geo-referenced texture model, using default file.")
except Exception as e:
logger.warning(f"Failed to convert to ion texture model: {e}")
if path.isfile(generated_zipfile):
remove(generated_zipfile)
logger.info(f"File {generated_zipfile} has been deleted.")
else:
logger.warning(f"The path {generated_zipfile} does not exist.")
headers = {"Authorization": f"Bearer {token}"}
data = {
"name": name,
"description": description,
"attribution": attribution,
"type": ASSET_TO_OUTPUT[asset_type],
"options": {**options, "sourceType": ASSET_TO_SOURCE[asset_type]},
}
# Create Asset Request
asset_logger.info(f"Creating asset of type {asset_type}")
res = requests.post(f"{ION_API_URL}/assets", json=data, headers=headers)
res.raise_for_status()
ion_info, upload_meta, on_complete = pluck(
res.json(), "assetMetadata", "uploadLocation", "onComplete"
)
ion_id = ion_info["id"]
access_key, secret_key, token, endpoint, bucket, file_prefix = pluck(
upload_meta,
"accessKey",
"secretAccessKey",
"sessionToken",
"endpoint",
"bucket",
"prefix",
)
# Upload
asset_logger.info("Starting upload")
uploat_stats = TaskUploadProgress(asset_path, task_id, asset_type, asset_logger)
key = path.join(file_prefix, ASSET_TO_FILE[asset_type])
boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
aws_session_token=token,
).upload_file(asset_path, Bucket=bucket, Key=key, Callback=uploat_stats)
asset_info = uploat_stats.asset_info
asset_info["id"] = ion_id
asset_info["upload"]["active"] = False
asset_info["process"]["active"] = True
set_asset_info(task_id, asset_type, asset_info)
# On Complete Handler
asset_logger.info("Upload complete")
method, url, fields = pluck(on_complete, "method", "url", "fields")
res = requests.request(method, url=url, headers=headers, data=fields)
res.raise_for_status()
# Processing Status Refresh
asset_logger.info("Starting processing")
refresh = True
while refresh:
res = requests.get(f"{ION_API_URL}/assets/{ion_id}", headers=headers)
res.raise_for_status()
state, percent_complete = pluck(res.json(), "status", "percentComplete")
progress = float(percent_complete) / 100
if "ERROR" in state.upper():
asset_info["error"] = f"Processing failed"
asset_logger.info("Processing failed...")
refresh = False
if progress >= 1:
refresh = False
if asset_info["process"]["progress"] != progress:
asset_info["process"]["progress"] = progress
asset_logger.info(f"Processing {percent_complete}% - {state}")
set_asset_info(task_id, asset_type, asset_info)
time.sleep(2)
asset_logger.info("Processing complete")
asset_info["process"]["progress"] = 1
asset_info["process"]["active"] = False
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
asset_info["error"] = "Invalid ion token!"
elif e.response.status_code == 404:
asset_info["error"] = "Missing permisssions on ion token!"
else:
asset_info["error"] = str(e)
asset_logger.error(e)
except Exception as e:
asset_info["error"] = str(e)
asset_logger.error(e)
if del_directory != None:
rmtree(del_directory)
set_asset_info(task_id, asset_type, asset_info)

Wyświetl plik

@ -2,7 +2,7 @@
"name": "Lightning",
"webodmMinVersion": "0.7.1",
"description": "Process in the cloud with webodm.net",
"version": "0.9.0",
"version": "1.0.0",
"author": "Piero Toffanin",
"email": "pt@masseranolabs.com",
"repository": "https://github.com/OpenDroneMap/WebODM",

Wyświetl plik

@ -113,7 +113,7 @@ export default class Dashboard extends React.Component {
let balance = "";
if (user){
balance = (<span><strong>{ user.balance }</strong> {_("credits")}</span>);
if (user.plan !== null){
if (user.plan !== null && !user.node.limits.freeTasksPerMonth){
balance = (<span><strong>{_("Unlimited")}</strong></span>);
}
}

Wyświetl plik

@ -37,7 +37,7 @@ export default class LightningPanel extends React.Component {
{ !apiKey ?
<div>
<h4><i className="fa fa-bolt"/> {_("Lightning")}</h4>
{_("Lightning is a service that allows you to quickly process small and large datasets using high performance servers in the cloud.")}
<p>{_("Lightning is a service that allows you to quickly process small and large datasets using high performance servers in the cloud.")}</p>
<Trans params={{ link: '<a href="https://webodm.net" target="_blank">webodm.net</a>', register: `<a href="https://webodm.net/register" target="_blank">${_("register")}</a>`}}>
{_("Below you can enter your %(link)s credentials to sync your account and automatically setup a new processing node. If you don't have an account, you can %(register)s for free.")}</Trans>
<Login onLogin={this.handleLogin} />

Wyświetl plik

@ -1,6 +1,6 @@
{
"name": "WebODM",
"version": "2.5.2",
"version": "2.5.5",
"description": "User-friendly, extendable application and API for processing aerial imagery.",
"main": "index.js",
"scripts": {