diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index d7988a89..430c1da6 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -2,5 +2,5 @@ github: pierotofy custom: - - https://www.opendronemap.org/webodm/download/ + - https://webodm.net - https://odmbook.com diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 9cc0ce97..351d6cff 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -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 diff --git a/app/api/tasks.py b/app/api/tasks.py index c65e9425..2d60e38d 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -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) diff --git a/app/models/plugin_datum.py b/app/models/plugin_datum.py index d99b1a63..2fb67ab0 100644 --- a/app/models/plugin_datum.py +++ b/app/models/plugin_datum.py @@ -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")) diff --git a/app/static/app/img/accept.png b/app/static/app/img/accept.png new file mode 100644 index 00000000..1d85672a Binary files /dev/null and b/app/static/app/img/accept.png differ diff --git a/app/static/app/js/MapView.jsx b/app/static/app/js/MapView.jsx index a6316e69..84eb3027 100644 --- a/app/static/app/js/MapView.jsx +++ b/app/static/app/js/MapView.jsx @@ -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 (
- {this.props.title ? + {this.props.title ?

{this.props.title}

: ""}
{mapTypeButtons.map(mapType => - + className={"btn btn-sm " + (mapType.type === this.state.selectedMapType ? "btn-primary" : "btn-default")}> {mapType.label} )}
@@ -136,6 +154,7 @@ class MapView extends React.Component { public={this.props.public} shareButtons={this.props.shareButtons} permissions={this.props.permissions} + thermal={isThermal} />
); diff --git a/app/static/app/js/classes/Basemaps.js b/app/static/app/js/classes/Basemaps.js index b88636df..8a1fe186 100644 --- a/app/static/app/js/classes/Basemaps.js +++ b/app/static/app/js/classes/Basemaps.js @@ -20,10 +20,10 @@ export default [ }, { attribution: - '© OpenStreetMap', - maxZoom: 21, + '© OpenStreetMap', + 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" } ]; diff --git a/app/static/app/js/classes/Units.js b/app/static/app/js/classes/Units.js index 0885cc2c..88c8376c 100644 --- a/app/static/app/js/classes/Units.js +++ b/app/static/app/js/classes/Units.js @@ -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{ diff --git a/app/static/app/js/components/EditTaskForm.jsx b/app/static/app/js/components/EditTaskForm.jsx index 64a7ae17..4d76fa7a 100644 --- a/app/static/app/js/components/EditTaskForm.jsx +++ b/app/static/app/js/components/EditTaskForm.jsx @@ -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; } diff --git a/app/static/app/js/components/Map.jsx b/app/static/app/js/components/Map.jsx index 3ae58734..c85e2d67 100644 --- a/app/static/app/js/components/Map.jsx +++ b/app/static/app/js/components/Map.jsx @@ -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; diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx new file mode 100644 index 00000000..9208e079 --- /dev/null +++ b/app/static/app/js/components/MapPreview.jsx @@ -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 = ` ${_("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 ( +
+ + + + + {this.state.error === "" && this.exifData.length > this.MaxImagesPlot ? +
+ +
+ : ""} + + {this.state.error === "" ?
+ + +
: ""} + + {this.state.error === "" ? +
+ +
+ : ""} + +
(this.container = domNode)} + > + +
+ + +
+ ); + } +} + +export default MapPreview; diff --git a/app/static/app/js/components/NewTaskPanel.jsx b/app/static/app/js/components/NewTaskPanel.jsx index f72a2256..9367c047 100644 --- a/app/static/app/js/components/NewTaskPanel.jsx +++ b/app/static/app/js/components/NewTaskPanel.jsx @@ -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 { : ""} + {this.state.showMapPreview ? {this.mapPreview = domNode; }} + /> : ""} + { if (domNode) this.taskForm = domNode; }} /> diff --git a/app/static/app/js/components/ProjectListItem.jsx b/app/static/app/js/components/ProjectListItem.jsx index e689e591..3654f64e 100644 --- a/app/static/app/js/components/ProjectListItem.jsx +++ b/app/static/app/js/components/ProjectListItem.jsx @@ -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 => { diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index a2870307..bb783a5f 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -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(", "); } } diff --git a/app/static/app/js/components/tests/MapPreview.test.jsx b/app/static/app/js/components/tests/MapPreview.test.jsx new file mode 100644 index 00000000..e25993a0 --- /dev/null +++ b/app/static/app/js/components/tests/MapPreview.test.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import MapPreview from '../MapPreview'; + +describe('', () => { + it('renders without exploding', () => { + const wrapper = mount( []} />); + expect(wrapper.exists()).toBe(true); + }) +}); \ No newline at end of file diff --git a/app/static/app/js/components/tests/Units.test.jsx b/app/static/app/js/components/tests/Units.test.jsx index 174b38aa..4aed9a5a 100644 --- a/app/static/app/js/components/tests/Units.test.jsx +++ b/app/static/app/js/components/tests/Units.test.jsx @@ -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); }); }); diff --git a/app/static/app/js/css/MapPreview.scss b/app/static/app/js/css/MapPreview.scss new file mode 100644 index 00000000..2f0c37c9 --- /dev/null +++ b/app/static/app/js/css/MapPreview.scss @@ -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; + } +} \ No newline at end of file diff --git a/app/static/app/js/translations/odm_autogenerated.js b/app/static/app/js/translations/odm_autogenerated.js index 68b5269c..81a327e4 100644 --- a/app/static/app/js/translations/odm_autogenerated.js +++ b/app/static/app/js/translations/odm_autogenerated.js @@ -1,93 +1,93 @@ // Auto-generated with extract_odm_strings.py, do not edit! -_("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: 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: 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: 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: 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"); diff --git a/app/static/app/js/vendor/exifr.js b/app/static/app/js/vendor/exifr.js index ed757388..ca3aad96 100644 --- a/app/static/app/js/vendor/exifr.js +++ b/app/static/app/js/vendor/exifr.js @@ -1 +1 @@ -module.exports = require('exifr/dist/mini.umd'); \ No newline at end of file +module.exports = require('exifr/dist/full.legacy.umd'); \ No newline at end of file diff --git a/app/tests/test_api_task_import.py b/app/tests/test_api_task_import.py index 04e2bf82..9dc04d10 100644 --- a/app/tests/test_api_task_import.py +++ b/app/tests/test_api_task_import.py @@ -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') diff --git a/app/vendor/zipfly.py b/app/vendor/zipfly.py index 35a64c6d..108023ac 100644 --- a/app/vendor/zipfly.py +++ b/app/vendor/zipfly.py @@ -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) \ No newline at end of file + return next(self._generator) + + def generator(self): + self.lazy_load(0x8000) + return self._generator \ No newline at end of file diff --git a/coreplugins/cesiumion/README.md b/coreplugins/cesiumion/README.md new file mode 100644 index 00000000..41a98e5f --- /dev/null +++ b/coreplugins/cesiumion/README.md @@ -0,0 +1,61 @@ +

+ +

+ +# 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. diff --git a/coreplugins/cesiumion/ThirdParty.json b/coreplugins/cesiumion/ThirdParty.json new file mode 100644 index 00000000..a4c879fc --- /dev/null +++ b/coreplugins/cesiumion/ThirdParty.json @@ -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" + } +] diff --git a/coreplugins/cesiumion/__init__.py b/coreplugins/cesiumion/__init__.py new file mode 100644 index 00000000..48aad58e --- /dev/null +++ b/coreplugins/cesiumion/__init__.py @@ -0,0 +1 @@ +from .plugin import * diff --git a/coreplugins/cesiumion/api_views.py b/coreplugins/cesiumion/api_views.py new file mode 100644 index 00000000..a647eaf6 --- /dev/null +++ b/coreplugins/cesiumion/api_views.py @@ -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) + + + + + diff --git a/coreplugins/cesiumion/app_views.py b/coreplugins/cesiumion/app_views.py new file mode 100644 index 00000000..85c80e2b --- /dev/null +++ b/coreplugins/cesiumion/app_views.py @@ -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 diff --git a/coreplugins/cesiumion/disabled b/coreplugins/cesiumion/disabled new file mode 100644 index 00000000..e69de29b diff --git a/coreplugins/cesiumion/globals.py b/coreplugins/cesiumion/globals.py new file mode 100644 index 00000000..e6b3e5c3 --- /dev/null +++ b/coreplugins/cesiumion/globals.py @@ -0,0 +1,2 @@ +PROJECT_NAME = __name__.split(".")[-2] +ION_API_URL = "https://api.cesium.com/v1" diff --git a/coreplugins/cesiumion/manifest.json b/coreplugins/cesiumion/manifest.json new file mode 100644 index 00000000..142ace53 --- /dev/null +++ b/coreplugins/cesiumion/manifest.json @@ -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 +} diff --git a/coreplugins/cesiumion/model_tools.py b/coreplugins/cesiumion/model_tools.py new file mode 100644 index 00000000..56f5c113 --- /dev/null +++ b/coreplugins/cesiumion/model_tools.py @@ -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 diff --git a/coreplugins/cesiumion/plugin.py b/coreplugins/cesiumion/plugin.py new file mode 100644 index 00000000..9090d161 --- /dev/null +++ b/coreplugins/cesiumion/plugin.py @@ -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[^/.]+)/share", ShareTaskView.as_view()), + MountPoint("task/(?P[^/.]+)/refresh", RefreshIonTaskView.as_view()), + MountPoint("task/(?P[^/.]+)/clear", ClearErrorsTaskView.as_view()), + ] + + def app_mount_points(self): + return [ + MountPoint("$", HomeView(self)), + MountPoint("load_buttons.js$", LoadButtonView(self)), + ] diff --git a/coreplugins/cesiumion/public/TaskView.jsx b/coreplugins/cesiumion/public/TaskView.jsx new file mode 100644 index 00000000..07f8e316 --- /dev/null +++ b/coreplugins/cesiumion/public/TaskView.jsx @@ -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 ( + + +
+ (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 ( + + {available.length > 0 && ( + + Tile in Cesium Ion + + )} + + {exported.length > 0 && ( + + View in Cesium Ion + + )} + {items.length <= 0 && ( + + )} + {hasProcessingTasks && ( + + )} + { this.tasksDialog = domNode; }} + /> + + ); + }} + +
+ + + {({ 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 ( + { this.uploadDialog = domNode; }} + /> + ); + }} + + +
+ ); + } +} diff --git a/coreplugins/cesiumion/public/components/AppContext.jsx b/coreplugins/cesiumion/public/components/AppContext.jsx new file mode 100644 index 00000000..4971541c --- /dev/null +++ b/coreplugins/cesiumion/public/components/AppContext.jsx @@ -0,0 +1,8 @@ +const AppContext = React.createContext({ + apiUrl: null, + ionURL: null, + token: null, + task: null +}); + +export default AppContext; diff --git a/coreplugins/cesiumion/public/components/Fetcher.jsx b/coreplugins/cesiumion/public/components/Fetcher.jsx new file mode 100644 index 00000000..3cd10a0f --- /dev/null +++ b/coreplugins/cesiumion/public/components/Fetcher.jsx @@ -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 +}) => ( + + {context => ( + + )} + +); + +const APIFetcher = props => ( + +); + +const ImplicitTaskFetcher = props => ( + `/api${apiURL}/task/${task.id}`} + credentials={"same-origin"} + headers={{ + "X-CSRFToken": getCookie("csrftoken"), + Accept: "application/json", + "Content-Type": "application/json" + }} + {...props} + /> +); + +const ImplicitIonFetcher = props => ( + ionURL} + getOptions={({ token }) => ({ + headers: { + Authorization: `Bearer ${token}` + } + })} + {...props} + /> +); + +export { APIFetcher, ImplicitTaskFetcher, ImplicitIonFetcher }; diff --git a/coreplugins/cesiumion/public/components/FormikErrorFocus.jsx b/coreplugins/cesiumion/public/components/FormikErrorFocus.jsx new file mode 100644 index 00000000..2a57cd8e --- /dev/null +++ b/coreplugins/cesiumion/public/components/FormikErrorFocus.jsx @@ -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); diff --git a/coreplugins/cesiumion/public/components/IonAssetButton.jsx b/coreplugins/cesiumion/public/components/IonAssetButton.jsx new file mode 100644 index 00000000..7af52c06 --- /dev/null +++ b/coreplugins/cesiumion/public/components/IonAssetButton.jsx @@ -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 => ( +
  • + + + +
  • + )); + + const title = ( + + + {children} + + ); + + return ( +
    + + +
      + {menuItems} +
    +
    + ); + } +} diff --git a/coreplugins/cesiumion/public/components/IonAssetButton.scss b/coreplugins/cesiumion/public/components/IonAssetButton.scss new file mode 100644 index 00000000..4cf08bf4 --- /dev/null +++ b/coreplugins/cesiumion/public/components/IonAssetButton.scss @@ -0,0 +1,14 @@ +.ion-btn { + .fa-cesium { + margin-right: 0.5em; + } + + .caret { + margin-left: 1em; + } +} + +.ion-dropdowns .dropdown { + float: none; + margin-right: 4px; +} diff --git a/coreplugins/cesiumion/public/components/IonAssetLabel.jsx b/coreplugins/cesiumion/public/components/IonAssetLabel.jsx new file mode 100644 index 00000000..18554c5a --- /dev/null +++ b/coreplugins/cesiumion/public/components/IonAssetLabel.jsx @@ -0,0 +1,12 @@ +import React, { PureComponent, Fragment } from "react"; +import { AssetStyles } from "../defaults"; + +const IonAssetLabel = ({ asset, showIcon = false, ...options }) => ( + + {showIcon && } + {" "} + {AssetStyles[asset].name} + +); + +export default IonAssetLabel; diff --git a/coreplugins/cesiumion/public/components/IonField.jsx b/coreplugins/cesiumion/public/components/IonField.jsx new file mode 100644 index 00000000..cc7f8d59 --- /dev/null +++ b/coreplugins/cesiumion/public/components/IonField.jsx @@ -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 ( +
    + {label && !isCheckbox && } + + {label && isCheckbox && } + {isError && {error}} + {help && !isError && {help}} + {isError && showIcon && } +
    + ); +}; + +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 ( + + ); + } +} + +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; \ No newline at end of file diff --git a/coreplugins/cesiumion/public/components/TaskDialog.scss b/coreplugins/cesiumion/public/components/TaskDialog.scss new file mode 100644 index 00000000..b44d2095 --- /dev/null +++ b/coreplugins/cesiumion/public/components/TaskDialog.scss @@ -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; + } +} diff --git a/coreplugins/cesiumion/public/components/TasksDialog.jsx b/coreplugins/cesiumion/public/components/TasksDialog.jsx new file mode 100644 index 00000000..e3d3ece5 --- /dev/null +++ b/coreplugins/cesiumion/public/components/TasksDialog.jsx @@ -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' +}) => ( +
    +
    +
    +

    + +

    +
    +
    +

    Status: {task}

    +
    +
    + + {helpText && {helpText}} +
    +); + +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 ( + + ); + } + ); + + return { taskItems, hasErrors }; + } + + render() { + const { onClearFailed } = this.props; + const { taskItems, hasErrors } = this.renderTaskItems(); + + return ( +
    +
    +
    +
    + +

    + Cesium Ion Tasks +

    +
    +
    + {/* */} +
    {taskItems}
    + + {hasErrors && ( + + )} +
    +
    + +
    +
    +
    +
    + ); + } +} \ No newline at end of file diff --git a/coreplugins/cesiumion/public/components/UploadDialog.jsx b/coreplugins/cesiumion/public/components/UploadDialog.jsx new file mode 100644 index 00000000..70cd8445 --- /dev/null +++ b/coreplugins/cesiumion/public/components/UploadDialog.jsx @@ -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 ; + } + + let userItems = data.items + .filter(item => item.type === "TERRAIN") + .map(item => ( + + )); + + return [ + , + ...userItems + ]; + }; + + return ( + + + {loadOptions} + + + ); + case SourceType.CAPTURE: + return ( + + + + + ); + default: + return null; + } + } + + render() { + return ( + { this.dialog = domNode; }} + > + + + + {this.getSourceFields()} + + ); + } +} diff --git a/coreplugins/cesiumion/public/components/UploadDialog.scss b/coreplugins/cesiumion/public/components/UploadDialog.scss new file mode 100644 index 00000000..cfc6f36c --- /dev/null +++ b/coreplugins/cesiumion/public/components/UploadDialog.scss @@ -0,0 +1,7 @@ +.modal-backdrop { + z-index: 100000 !important; +} + +.ion-upload.modal button i { + margin-right: 1em; +} diff --git a/coreplugins/cesiumion/public/defaults/AssetConfig.jsx b/coreplugins/cesiumion/public/defaults/AssetConfig.jsx new file mode 100644 index 00000000..9efcb82f --- /dev/null +++ b/coreplugins/cesiumion/public/defaults/AssetConfig.jsx @@ -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; diff --git a/coreplugins/cesiumion/public/defaults/AssetType.jsx b/coreplugins/cesiumion/public/defaults/AssetType.jsx new file mode 100644 index 00000000..0d0c0fda --- /dev/null +++ b/coreplugins/cesiumion/public/defaults/AssetType.jsx @@ -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; diff --git a/coreplugins/cesiumion/public/defaults/SourceType.jsx b/coreplugins/cesiumion/public/defaults/SourceType.jsx new file mode 100644 index 00000000..75714611 --- /dev/null +++ b/coreplugins/cesiumion/public/defaults/SourceType.jsx @@ -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; diff --git a/coreplugins/cesiumion/public/defaults/index.jsx b/coreplugins/cesiumion/public/defaults/index.jsx new file mode 100644 index 00000000..4659c240 --- /dev/null +++ b/coreplugins/cesiumion/public/defaults/index.jsx @@ -0,0 +1,5 @@ +import AssetType from "./AssetType"; +import SourceType from "./SourceType"; +import AssetConfig from "./AssetConfig"; + +export { AssetType, SourceType, AssetConfig }; diff --git a/coreplugins/cesiumion/public/font.css b/coreplugins/cesiumion/public/font.css new file mode 100644 index 00000000..8913c538 --- /dev/null +++ b/coreplugins/cesiumion/public/font.css @@ -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; +} diff --git a/coreplugins/cesiumion/public/fonts/fa-cesium.svg b/coreplugins/cesiumion/public/fonts/fa-cesium.svg new file mode 100755 index 00000000..1cf24e5f --- /dev/null +++ b/coreplugins/cesiumion/public/fonts/fa-cesium.svg @@ -0,0 +1,11 @@ + + + +Generated by IcoMoon + + + + + + + \ No newline at end of file diff --git a/coreplugins/cesiumion/public/fonts/fa-cesium.ttf b/coreplugins/cesiumion/public/fonts/fa-cesium.ttf new file mode 100755 index 00000000..4937faff Binary files /dev/null and b/coreplugins/cesiumion/public/fonts/fa-cesium.ttf differ diff --git a/coreplugins/cesiumion/public/fonts/fa-cesium.woff b/coreplugins/cesiumion/public/fonts/fa-cesium.woff new file mode 100755 index 00000000..4a898b3b Binary files /dev/null and b/coreplugins/cesiumion/public/fonts/fa-cesium.woff differ diff --git a/coreplugins/cesiumion/public/package.json b/coreplugins/cesiumion/public/package.json new file mode 100644 index 00000000..084a2904 --- /dev/null +++ b/coreplugins/cesiumion/public/package.json @@ -0,0 +1,7 @@ +{ + "scripts": { + "dev": "webpack --watch" + }, + "dependencies": { + } +} diff --git a/coreplugins/cesiumion/public/utils/fetchCancelable.jsx b/coreplugins/cesiumion/public/utils/fetchCancelable.jsx new file mode 100644 index 00000000..4d5eb1a1 --- /dev/null +++ b/coreplugins/cesiumion/public/utils/fetchCancelable.jsx @@ -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; diff --git a/coreplugins/cesiumion/public/utils/getCookie.jsx b/coreplugins/cesiumion/public/utils/getCookie.jsx new file mode 100644 index 00000000..4bfe890f --- /dev/null +++ b/coreplugins/cesiumion/public/utils/getCookie.jsx @@ -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(); +} diff --git a/coreplugins/cesiumion/public/utils/index.jsx b/coreplugins/cesiumion/public/utils/index.jsx new file mode 100644 index 00000000..2a265f20 --- /dev/null +++ b/coreplugins/cesiumion/public/utils/index.jsx @@ -0,0 +1,4 @@ +import fetchCancelable from "./fetchCancelable"; +import getCookie from "./getCookie"; + +export { getCookie, fetchCancelable }; diff --git a/coreplugins/cesiumion/templates/app.html b/coreplugins/cesiumion/templates/app.html new file mode 100644 index 00000000..2d8cbaac --- /dev/null +++ b/coreplugins/cesiumion/templates/app.html @@ -0,0 +1,64 @@ +{% extends "app/plugins/templates/base.html" %} +{% block content %} + +