kopia lustrzana https://github.com/OpenDroneMap/WebODM
Merge branch 'master' of https://github.com/OpenDroneMap/WebODM into align_plugin
commit
b592d48796
|
@ -2,5 +2,5 @@
|
|||
|
||||
github: pierotofy
|
||||
custom:
|
||||
- https://www.opendronemap.org/webodm/download/
|
||||
- https://webodm.net
|
||||
- https://odmbook.com
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 |
|
@ -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>);
|
||||
|
|
|
@ -20,10 +20,10 @@ export default [
|
|||
},
|
||||
{
|
||||
attribution:
|
||||
'© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 21,
|
||||
'© <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"
|
||||
}
|
||||
];
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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; }}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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(", ");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
|
|
|
@ -1 +1 @@
|
|||
module.exports = require('exifr/dist/mini.umd');
|
||||
module.exports = require('exifr/dist/full.legacy.umd');
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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.
|
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -0,0 +1 @@
|
|||
from .plugin import *
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
PROJECT_NAME = __name__.split(".")[-2]
|
||||
ION_API_URL = "https://api.cesium.com/v1"
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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)),
|
||||
]
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
const AppContext = React.createContext({
|
||||
apiUrl: null,
|
||||
ionURL: null,
|
||||
token: null,
|
||||
task: null
|
||||
});
|
||||
|
||||
export default AppContext;
|
|
@ -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 };
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
.ion-btn {
|
||||
.fa-cesium {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.caret {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.ion-dropdowns .dropdown {
|
||||
float: none;
|
||||
margin-right: 4px;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>×</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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
.modal-backdrop {
|
||||
z-index: 100000 !important;
|
||||
}
|
||||
|
||||
.ion-upload.modal button i {
|
||||
margin-right: 1em;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,5 @@
|
|||
import AssetType from "./AssetType";
|
||||
import SourceType from "./SourceType";
|
||||
import AssetConfig from "./AssetConfig";
|
||||
|
||||
export { AssetType, SourceType, AssetConfig };
|
|
@ -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;
|
||||
}
|
|
@ -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=" " horiz-adv-x="512" d="" />
|
||||
<glyph unicode="" 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.
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"scripts": {
|
||||
"dev": "webpack --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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();
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import fetchCancelable from "./fetchCancelable";
|
||||
import getCookie from "./getCookie";
|
||||
|
||||
export { getCookie, fetchCancelable };
|
|
@ -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 %}
|
|
@ -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 }}"
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
|
@ -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)
|
|
@ -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",
|
||||
|
|
|
@ -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>);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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": {
|
||||
|
|
Ładowanie…
Reference in New Issue