kopia lustrzana https://github.com/OpenDroneMap/WebODM
Porównaj commity
123 Commity
Autor | SHA1 | Data |
---|---|---|
Stephen Mather | fa058e7567 | |
Stephen Mather | a60dec4e69 | |
Piero Toffanin | c098b976c6 | |
Piero Toffanin | 32ba4ed707 | |
Piero Toffanin | 7272ca55bc | |
Piero Toffanin | e1cb0c83bb | |
Piero Toffanin | 7ab95bc8b1 | |
Piero Toffanin | 80a7f2048d | |
Piero Toffanin | e9c2409ea9 | |
Piero Toffanin | 9ee58f7216 | |
Piero Toffanin | 289ef48b12 | |
Piero Toffanin | 8468fdff5c | |
Piero Toffanin | 35fc60aa2c | |
Piero Toffanin | 57ccd23234 | |
Piero Toffanin | 4e2ffbb768 | |
Piero Toffanin | 3b55ebd3e5 | |
Piero Toffanin | 1fc7e11c86 | |
Piero Toffanin | d76eacabd3 | |
Piero Toffanin | 75678b7a84 | |
Piero Toffanin | 681482983c | |
Piero Toffanin | adf9c7dc5f | |
Piero Toffanin | ece6bba200 | |
Piero Toffanin | d46b582dcd | |
Piero Toffanin | dd6b46a2c9 | |
Piero Toffanin | 5375eb8a19 | |
Piero Toffanin | e0eb7cad7e | |
Piero Toffanin | 9a8013d6ce | |
Piero Toffanin | f0cd13a464 | |
Piero Toffanin | 5c663b8435 | |
Piero Toffanin | 30eff78d3b | |
Piero Toffanin | 9af1ee018b | |
Piero Toffanin | 568e80a941 | |
Piero Toffanin | cb173bd48c | |
Piero Toffanin | b86411c298 | |
Piero Toffanin | 06ccd29d09 | |
Piero Toffanin | bf24be7b72 | |
Piero Toffanin | d73558256a | |
Piero Toffanin | 59e104946c | |
Piero Toffanin | 972b06d03b | |
Piero Toffanin | 2352d838cf | |
Piero Toffanin | e7337f3b5d | |
Piero Toffanin | a44c2ce86f | |
Piero Toffanin | 6f5d68d6ed | |
Piero Toffanin | 04af329f78 | |
Piero Toffanin | cc2b7d5265 | |
Piero Toffanin | 10a44851ac | |
Piero Toffanin | c2c65a2cc2 | |
Piero Toffanin | b1fdcbb242 | |
Piero Toffanin | 02b4132daa | |
Piero Toffanin | ed6a43699f | |
Piero Toffanin | e8f8f2f130 | |
Zuline | 57223fc539 | |
Piero Toffanin | 9a3123c878 | |
Zuline | 860942b80f | |
Piero Toffanin | 4cd3618442 | |
Gonzalo Bulnes Guilpain | 9209b52a19 | |
Piero Toffanin | 8150b822d9 | |
Gonzalo Bulnes Guilpain | 02b4061379 | |
Piero Toffanin | 206edf1087 | |
Gonzalo Bulnes Guilpain | 7c9b1da92a | |
Piero Toffanin | a1331d0b0b | |
Saijin-Naib | 864055b0db | |
Mike Ryan | faae70991a | |
Piero Toffanin | 712705d35a | |
Piero Toffanin | 863bf2477c | |
Piero Toffanin | 94851d2ed3 | |
Piero Toffanin | d415b12806 | |
Piero Toffanin | ccabacb3dc | |
Piero Toffanin | d2755db412 | |
Piero Toffanin | 59195124a0 | |
Piero Toffanin | 362ba7fc9b | |
Piero Toffanin | b3f5b2de5d | |
Piero Toffanin | f227bdb08b | |
Piero Toffanin | 15ce002d78 | |
douw | a41d4ceff7 | |
Piero Toffanin | be8bd6e7ee | |
Piero Toffanin | 913db4c52b | |
Piero Toffanin | 7bba3a38df | |
Piero Toffanin | 136d270666 | |
Piero Toffanin | 448a2cb2d6 | |
Piero Toffanin | ae08c10ec7 | |
Piero Toffanin | bc0c4ac3e0 | |
chris-bateman | 8ff81f74c4 | |
chris-bateman | 5ca0cf82db | |
chris-bateman | dae49a7b2c | |
chris-bateman | 6ea180dd43 | |
chris-bateman | 3f42eaa824 | |
Piero Toffanin | 24f3b38dce | |
Piero Toffanin | 0b2e697c15 | |
Piero Toffanin | 9af93014cc | |
Piero Toffanin | d4dcf3fed1 | |
Piero Toffanin | 425710862f | |
Piero Toffanin | 4a529baace | |
Piero Toffanin | e8ae09b96f | |
Piero Toffanin | c4b59721a3 | |
Piero Toffanin | 9880d461d4 | |
Piero Toffanin | a78a2e4206 | |
Piero Toffanin | fd7721ee6b | |
Piero Toffanin | c28d00f0b0 | |
Piero Toffanin | 5a94579a8e | |
Piero Toffanin | 512314ba67 | |
Piero Toffanin | 782d6ed7a9 | |
Piero Toffanin | bf76edf4c0 | |
Piero Toffanin | 824c88cd54 | |
Piero Toffanin | d0dc128ca9 | |
Piero Toffanin | c4c9ed9598 | |
Piero Toffanin | 5fc886028e | |
Piero Toffanin | 8976aa51e3 | |
Piero Toffanin | 5e7ef34290 | |
Piero Toffanin | 9ea217c218 | |
Piero Toffanin | ce9cd0a8b9 | |
Piero Toffanin | 58dcc46e40 | |
Piero Toffanin | 9fb0c6db67 | |
Piero Toffanin | 6b4230f233 | |
Piero Toffanin | bd183c6455 | |
Piero Toffanin | 6bc90025aa | |
Piero Toffanin | bcc0f24fd5 | |
Piero Toffanin | 918ec48e6d | |
Rion Lerm | 18cab57e9f | |
Saijin-Naib | 38ba80b6ec | |
allandaly | a5cbac0ff8 | |
Piero Toffanin | 295bf3f99a | |
Piero Toffanin | bc86c7977b |
|
@ -0,0 +1,33 @@
|
|||
name: Issue Triage
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
jobs:
|
||||
issue_triage:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: pierotofy/issuewhiz@v1
|
||||
with:
|
||||
ghToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
openAI: ${{ secrets.OPENAI_TOKEN }}
|
||||
filter: |
|
||||
- "#"
|
||||
variables: |
|
||||
- Q: "A question about using a software or seeking guidance on doing something?"
|
||||
- B: "Reporting an issue or a software bug?"
|
||||
- P: "Describes an issue with processing a set of images or a particular dataset?"
|
||||
- D: "Contains a link to a dataset or images?"
|
||||
- E: "Contains a suggestion for an improvement or a feature request?"
|
||||
- SC: "Describes an issue related to compiling or building source code?"
|
||||
logic: |
|
||||
- 'Q and (not B) and (not P) and (not E) and (not SC) and not (title_lowercase ~= ".*bug: .+")': [comment: "Could we move this conversation over to the forum at https://community.opendronemap.org? :pray: The forum is the right place to ask questions (we try to keep the GitHub issue tracker for feature requests and bugs only). Thank you! :+1:", close: true, stop: true]
|
||||
- "B and (not P) and (not E) and (not SC)": [label: "software fault", stop: true]
|
||||
- "P and D": [label: "possible software fault", stop: true]
|
||||
- "P and (not D) and (not SC) and (not E)": [comment: "Thanks for the report, but it looks like you didn't include a copy of your dataset for us to reproduce this issue? Please make sure to follow our [issue guidelines](https://github.com/OpenDroneMap/WebODM/blob/master/ISSUE_TEMPLATE.md) :pray: ", close: true, stop: true]
|
||||
- "E": [label: enhancement, stop: true]
|
||||
- "SC": [label: "possible software fault"]
|
||||
|
||||
signature: "p.s. I'm just an automated script, not a human being."
|
13
Dockerfile
13
Dockerfile
|
@ -2,6 +2,7 @@ FROM ubuntu:21.04
|
|||
MAINTAINER Piero Toffanin <pt@masseranolabs.com>
|
||||
|
||||
ARG TEST_BUILD
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PYTHONPATH $PYTHONPATH:/webodm
|
||||
ENV PROJ_LIB=/usr/share/proj
|
||||
|
@ -13,19 +14,23 @@ WORKDIR /webodm
|
|||
# Use old-releases for 21.04
|
||||
RUN printf "deb http://old-releases.ubuntu.com/ubuntu/ hirsute main restricted\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute-updates main restricted\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute universe\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute-updates universe\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute multiverse\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute-updates multiverse\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute-backports main restricted universe multiverse" > /etc/apt/sources.list
|
||||
|
||||
# Install Node.js
|
||||
# Install Node.js using new Node install method
|
||||
RUN apt-get -qq update && apt-get -qq install -y --no-install-recommends wget curl && \
|
||||
wget --no-check-certificate https://deb.nodesource.com/setup_14.x -O /tmp/node.sh && bash /tmp/node.sh && \
|
||||
apt-get install -y ca-certificates gnupg && \
|
||||
mkdir -p /etc/apt/keyrings && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
||||
NODE_MAJOR=20 && \
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
|
||||
apt-get -qq update && apt-get -qq install -y nodejs && \
|
||||
# Install Python3, GDAL, PDAL, nginx, letsencrypt, psql
|
||||
apt-get -qq update && apt-get -qq install -y --no-install-recommends python3 python3-pip python3-setuptools python3-wheel git g++ python3-dev python2.7-dev libpq-dev binutils libproj-dev gdal-bin pdal libgdal-dev python3-gdal nginx certbot grass-core gettext-base cron postgresql-client-13 gettext tzdata && \
|
||||
apt-get -qq update && apt-get -qq install -y --no-install-recommends python3 python3-pip python3-setuptools python3-wheel git g++ python3-dev python2.7-dev libpq-dev binutils libproj-dev gdal-bin pdal libgdal-dev python3-gdal nginx certbot gettext-base cron postgresql-client-13 gettext tzdata && \
|
||||
update-alternatives --install /usr/bin/python python /usr/bin/python2.7 1 && update-alternatives --install /usr/bin/python python /usr/bin/python3.9 2 && \
|
||||
# Install pip reqs
|
||||
pip install -U pip && pip install -r requirements.txt "boto3==1.14.14" && \
|
||||
# Setup cron
|
||||
ln -s /webodm/nginx/crontab /var/spool/cron/crontabs/root && chmod 0644 /webodm/nginx/crontab && service cron start && chmod +x /webodm/nginx/letsencrypt-autogen.sh && \
|
||||
/webodm/nodeodm/setup.sh && /webodm/nodeodm/cleanup.sh && cd /webodm && \
|
||||
npm install --quiet -g webpack@4.16.5 && npm install --quiet -g webpack-cli@4.2.0 && npm install --quiet && webpack --mode production && \
|
||||
npm install --quiet -g webpack@5.89.0 && npm install --quiet -g webpack-cli@5.1.4 && npm install --quiet && webpack --mode production && \
|
||||
echo "UTC" > /etc/timezone && \
|
||||
python manage.py collectstatic --noinput && \
|
||||
python manage.py rebuildplugins && \
|
||||
|
|
50
README.md
50
README.md
|
@ -156,6 +156,23 @@ Cannot start WebODM via `./webodm.sh start`, error messages are different at eac
|
|||
While running WebODM with Docker Toolbox (VirtualBox) you cannot access WebODM from another computer in the same network. | As Administrator, run `cmd.exe` and then type `"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe" controlvm "default" natpf1 "rule-name,tcp,,8000,,8000"`
|
||||
On Windows, the storage space shown on the WebODM diagnostic page is not the same as what is actually set in Docker's settings. | From Hyper-V Manager, right-click “DockerDesktopVM”, go to Edit Disk, then choose to expand the disk and match the maximum size to the settings specified in the docker settings. Upon making the changes, restart docker.
|
||||
|
||||
#### Images Missing from Lightning Assets
|
||||
|
||||
When you use Lightning to process your task, you will need to download all assets to your local instance of WebODM. The all assets zip does *not* contain the images which were used to create the orthomosaic. This means that, although you can visualise the cameras layer in your local WebODM, when you click on a particular camera icon the image will not be shown.
|
||||
|
||||
The fix if you are using WebODM with Docker is as follows (instructions are for MacOS host):
|
||||
|
||||
1. Ensure that you have a directory which contains all of the images for the task and only the images;
|
||||
2. Open Docker Desktop and navigate to Containers. Identify your WebODM instance and navigate to the container that is named `worker`. You will need the Container ID. This is a hash which is listed under the container name. Click to copy the Container ID using the copy icon next to it.
|
||||
3. Open Terminal and enter `docker cp <sourcedirectory>/. <dockercontainerID>:/webodm/app/media/project/<projectID>/task/<taskID>`. Paste the Container ID to replace the location titled `<dockercontainerID>`. Enter the full directory path for your images to replace `<sourcedirectory>`;
|
||||
4. Go back to Docker Desktop and navigate to Volumes in the side bar. Click on the volume called `webodm_appmedia`, click on `project`, identify the correct project and click on it, click on `task` and identify the correct task.
|
||||
5. From Docker Desktop substitute the correct `<projectID>` and `<taskID>` into the command in Terminal;
|
||||
6. Execute the newly edited command in Terminal. You will see a series of progress messages and your images will be copied to Docker;
|
||||
7. Navigate to your project in your local instance of WebODM;
|
||||
8. Open the Map and turn on the Cameras layer (top left);
|
||||
9. Click on a Camera icon and the relevant image will be shown
|
||||
|
||||
|
||||
Have you had other issues? Please [report them](https://github.com/OpenDroneMap/WebODM/issues/new) so that we can include them in this document.
|
||||
|
||||
### Backup and Restore
|
||||
|
@ -228,17 +245,15 @@ Don't expect to process more than a few hundred images with these specifications
|
|||
|
||||
WebODM runs best on Linux, but works well on Windows and Mac too. If you are technically inclined, you can get WebODM to run natively on all three platforms.
|
||||
|
||||
[NodeODM](https://github.com/OpenDroneMap/NodeODM) and [ODM](https://github.com/OpenDroneMap/ODM) cannot run natively on Mac and this is the reason we mostly recommend people to use docker.
|
||||
|
||||
WebODM by itself is just a user interface (see [below](#odm-nodeodm-webodm-what)) and does not require many resources. WebODM can be loaded on a machine with just 1 or 2 GB of RAM and work fine without NodeODM. You can then use a processing service such as the [lightning network](https://webodm.net) or run NodeODM on a separate, more powerful machine.
|
||||
|
||||
## Customizing and Extending
|
||||
|
||||
Small customizations such as changing the application colors, name, logo, or addying custom CSS/HTML/Javascript can be performed directly from the Customize -- Brand/Theme panels within WebODM. No need to fork or change the code.
|
||||
Small customizations such as changing the application colors, name, logo, or adding custom CSS/HTML/Javascript can be performed directly from the Customize -- Brand/Theme panels within WebODM. No need to fork or change the code.
|
||||
|
||||
More advanced customizations can be achieved by writing [plugins](https://github.com/OpenDroneMap/WebODM/tree/master/plugins). This is the preferred way to add new functionality to WebODM since it requires less effort than maintaining a separate fork. The plugin system features server-side [signals](https://github.com/OpenDroneMap/WebODM/blob/master/app/plugins/signals.py) that can be used to be notified of various events, a ES6/React build system, a dynamic [client-side API](https://github.com/OpenDroneMap/WebODM/tree/master/app/static/app/js/classes/plugins) for adding elements to the UI, a built-in data store, an async task runner, a GRASS engine, hooks to add menu items and functions to rapidly inject CSS, Javascript and Django views.
|
||||
More advanced customizations can be achieved by writing [plugins](https://github.com/OpenDroneMap/WebODM/tree/master/coreplugins). This is the preferred way to add new functionality to WebODM since it requires less effort than maintaining a separate fork. The plugin system features server-side [signals](https://github.com/OpenDroneMap/WebODM/blob/master/app/plugins/signals.py) that can be used to be notified of various events, a ES6/React build system, a dynamic [client-side API](https://github.com/OpenDroneMap/WebODM/tree/master/app/static/app/js/classes/plugins) for adding elements to the UI, a built-in data store, an async task runner, a GRASS engine, hooks to add menu items and functions to rapidly inject CSS, Javascript and Django views.
|
||||
|
||||
For plugins, the best source of documentation currently is to look at existing [code](https://github.com/OpenDroneMap/WebODM/tree/master/plugins). If a particular hook / entrypoint for your plugin does not yet exist, [request it](https://github.com/OpenDroneMap/WebODM/issues). We are adding hooks and entrypoints as we go.
|
||||
For plugins, the best source of documentation currently is to look at existing [code](https://github.com/OpenDroneMap/WebODM/tree/master/coreplugins). If a particular hook / entrypoint for your plugin does not yet exist, [request it](https://github.com/OpenDroneMap/WebODM/issues). We are adding hooks and entrypoints as we go.
|
||||
|
||||
To create a plugin simply copy the `plugins/test` plugin into a new directory (for example, `plugins/myplugin`), then modify `manifest.json`, `plugin.py` and issue a `./webodm.sh restart`.
|
||||
|
||||
|
@ -261,7 +276,7 @@ We have several channels of communication for people to ask questions and to get
|
|||
- [OpenDroneMap Community Forum](http://community.opendronemap.org/c/webodm)
|
||||
- [Report Issues](https://github.com/OpenDroneMap/WebODM/issues)
|
||||
|
||||
We also have a [Gitter Chat](https://gitter.im/OpenDroneMap/web-development), but the preferred way to communicate is via the [OpenDroneMap Community Forum](http://community.opendronemap.org/c/webodm).
|
||||
The preferred way to communicate is via the [OpenDroneMap Community Forum](http://community.opendronemap.org/c/webodm).
|
||||
|
||||
## Support the Project
|
||||
|
||||
|
@ -335,36 +350,37 @@ If you wish to run the docker version with auto start/monitoring/stop, etc, as a
|
|||
|
||||
This should work on any Linux OS capable of running WebODM, and using a SystemD based service daemon (such as Ubuntu 16.04 server for example).
|
||||
|
||||
This has only been tested on Ubuntu 16.04 server.
|
||||
This has only been tested on Ubuntu 16.04 server and Red Hat Enterprise Linux 9.
|
||||
|
||||
The following pre-requisites are required:
|
||||
* Requires odm user
|
||||
* Requires docker installed via system (ubuntu: `sudo apt-get install docker.io`)
|
||||
* Requires screen to be installed
|
||||
* Requires 'screen' package to be installed
|
||||
* Requires odm user member of docker group
|
||||
* Required WebODM directory checked out to /webodm
|
||||
* Requires that /webodm is recursively owned by odm:odm
|
||||
* Requires that a Python 3 environment is used at /webodm/python3-venv
|
||||
* Required WebODM directory checked out/cloned to /opt/WebODM
|
||||
* Requires that /opt/WebODM is recursively owned by odm:odm
|
||||
* Requires that a Python 3 environment is used at /opt/WebODM/python3-venv
|
||||
|
||||
If all pre-requisites have been met, and repository is checked out to /opt/WebODM folder, then you can use the following steps to enable and manage the service:
|
||||
If all pre-requisites have been met, and repository is checked out/cloned to /opt/WebODM folder, then you can use the following steps to enable and manage the service:
|
||||
|
||||
First, to install the service, and enable the services to run at startup from now on:
|
||||
```bash
|
||||
sudo systemctl enable /webodm/service/webodm-gunicorn.service
|
||||
sudo systemctl enable /webodm/service/webodm-nginx.service
|
||||
sudo systemctl enable /opt/WebODM/service/webodm-docker.service
|
||||
```
|
||||
|
||||
To manually start/stop the service:
|
||||
```bash
|
||||
sudo systemctl stop webodm-gunicorn
|
||||
sudo systemctl start webodm-gunicorn
|
||||
sudo systemctl stop webodm-docker
|
||||
sudo systemctl start webodm-docker
|
||||
```
|
||||
|
||||
To manually check service status:
|
||||
```bash
|
||||
sudo systemctl status webodm-gunicorn
|
||||
sudo systemctl status webodm-docker
|
||||
```
|
||||
|
||||
For the adventurous, the repository can be put anyplace you like by editing the ./WebODM/service/webodm-docker.service file before enabling the service the reflect your repository location, and modifying the systemctl enable command to that directiory.
|
||||
|
||||
## Run it natively
|
||||
|
||||
WebODM can run natively on Windows, MacOS and Linux. We don't recommend to run WebODM natively (using docker is easier), but it's possible.
|
||||
|
|
|
@ -40,9 +40,9 @@ class TaskAdmin(admin.ModelAdmin):
|
|||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
list_display = ('id', 'project', 'processing_node', 'created_at', 'status', 'last_error')
|
||||
list_display = ('id', 'name', 'project', 'processing_node', 'created_at', 'status', 'last_error')
|
||||
list_filter = ('status', 'project',)
|
||||
search_fields = ('id', 'project__name')
|
||||
search_fields = ('id', 'name', 'project__name')
|
||||
|
||||
|
||||
admin.site.register(Task, TaskAdmin)
|
||||
|
|
|
@ -54,10 +54,6 @@ algos = {
|
|||
'expr': '(2 * G) - (R + B)',
|
||||
'help': _('Excess Green Index (derived from only the RGB bands) emphasizes the greenness of leafy crops such as potatoes.')
|
||||
},
|
||||
'TGI': {
|
||||
'expr': '(G - 0.39) * (R - 0.61) * B',
|
||||
'help': _('Triangular Greenness Index (derived from only the RGB bands) performs similarly to EXG but with improvements over certain environments.')
|
||||
},
|
||||
'BAI': {
|
||||
'expr': '1.0 / (((0.1 - R) ** 2) + ((0.06 - N) ** 2))',
|
||||
'help': _('Burn Area Index hightlights burned land in the red to near-infrared spectrum.')
|
||||
|
|
|
@ -204,18 +204,17 @@ class TaskViewSet(viewsets.ViewSet):
|
|||
raise exceptions.NotFound()
|
||||
|
||||
files = flatten_files(request.FILES)
|
||||
|
||||
if len(files) == 0:
|
||||
raise exceptions.ValidationError(detail=_("No files uploaded"))
|
||||
|
||||
task.handle_images_upload(files)
|
||||
uploaded = task.handle_images_upload(files)
|
||||
task.images_count = len(task.scan_images())
|
||||
# Update other parameters such as processing node, task name, etc.
|
||||
serializer = TaskSerializer(task, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
return Response({'success': True}, status=status.HTTP_200_OK)
|
||||
return Response({'success': True, 'uploaded': uploaded}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, pk=None, project_pk=None):
|
||||
|
@ -366,19 +365,20 @@ class TaskDownloads(TaskNestedView):
|
|||
|
||||
# Check and download
|
||||
try:
|
||||
asset_fs, is_zipstream = task.get_asset_file_or_zipstream(asset)
|
||||
asset_fs = task.get_asset_file_or_stream(asset)
|
||||
except FileNotFoundError:
|
||||
raise exceptions.NotFound(_("Asset does not exist"))
|
||||
|
||||
if not is_zipstream and not os.path.isfile(asset_fs):
|
||||
is_stream = not isinstance(asset_fs, str)
|
||||
if not is_stream and not os.path.isfile(asset_fs):
|
||||
raise exceptions.NotFound(_("Asset does not exist"))
|
||||
|
||||
download_filename = request.GET.get('filename', get_asset_download_filename(task, asset))
|
||||
|
||||
if not is_zipstream:
|
||||
return download_file_response(request, asset_fs, 'attachment', download_filename=download_filename)
|
||||
else:
|
||||
if is_stream:
|
||||
return download_file_stream(request, asset_fs, 'attachment', download_filename=download_filename)
|
||||
else:
|
||||
return download_file_response(request, asset_fs, 'attachment', download_filename=download_filename)
|
||||
|
||||
"""
|
||||
Raw access to the task's asset folder resources
|
||||
|
|
|
@ -207,6 +207,8 @@ class Metadata(TaskNestedView):
|
|||
for b in info['statistics']:
|
||||
info['statistics'][b]['min'] = hrange[0]
|
||||
info['statistics'][b]['max'] = hrange[1]
|
||||
info['statistics'][b]['percentiles'][0] = max(hrange[0], info['statistics'][b]['percentiles'][0])
|
||||
info['statistics'][b]['percentiles'][1] = min(hrange[1], info['statistics'][b]['percentiles'][1])
|
||||
|
||||
cmap_labels = {
|
||||
"viridis": "Viridis",
|
||||
|
@ -397,7 +399,7 @@ class Tiles(TaskNestedView):
|
|||
# Hillshading is not a local tile operation and
|
||||
# requires neighbor tiles to be rendered seamlessly
|
||||
if hillshade is not None:
|
||||
tile_buffer = tilesize
|
||||
tile_buffer = 16
|
||||
|
||||
try:
|
||||
if expr is not None:
|
||||
|
@ -469,17 +471,17 @@ class Tiles(TaskNestedView):
|
|||
# Remove elevation data from edge buffer tiles
|
||||
# (to keep intensity uniform across tiles)
|
||||
elevation = tile.data[0]
|
||||
elevation[0:tilesize, 0:tilesize] = nodata
|
||||
elevation[tilesize*2:tilesize*3, 0:tilesize] = nodata
|
||||
elevation[0:tilesize, tilesize*2:tilesize*3] = nodata
|
||||
elevation[tilesize*2:tilesize*3, tilesize*2:tilesize*3] = nodata
|
||||
elevation[0:tile_buffer, 0:tile_buffer] = nodata
|
||||
elevation[tile_buffer+tilesize:tile_buffer*2+tilesize, 0:tile_buffer] = nodata
|
||||
elevation[0:tile_buffer, tile_buffer+tilesize:tile_buffer*2+tilesize] = nodata
|
||||
elevation[tile_buffer+tilesize:tile_buffer*2+tilesize, tile_buffer+tilesize:tile_buffer*2+tilesize] = nodata
|
||||
|
||||
intensity = ls.hillshade(elevation, dx=dx, dy=dy, vert_exag=hillshade)
|
||||
intensity = intensity[tilesize:tilesize * 2, tilesize:tilesize * 2]
|
||||
intensity = intensity[tile_buffer:tile_buffer+tilesize, tile_buffer:tile_buffer+tilesize]
|
||||
|
||||
if intensity is not None:
|
||||
rgb = tile.post_process(in_range=(rescale_arr,))
|
||||
rgb_data = rgb.data[:,tilesize:tilesize * 2, tilesize:tilesize * 2]
|
||||
rgb_data = rgb.data[:,tile_buffer:tilesize+tile_buffer, tile_buffer:tilesize+tile_buffer]
|
||||
if colormap:
|
||||
rgb, _discard_ = apply_cmap(rgb_data, colormap.get(color_map))
|
||||
if rgb.data.shape[0] != 3:
|
||||
|
@ -488,7 +490,7 @@ class Tiles(TaskNestedView):
|
|||
intensity = intensity * 255.0
|
||||
rgb = hsv_blend(rgb, intensity)
|
||||
if rgb is not None:
|
||||
mask = tile.mask[tilesize:tilesize * 2, tilesize:tilesize * 2]
|
||||
mask = tile.mask[tile_buffer:tilesize+tile_buffer, tile_buffer:tilesize+tile_buffer]
|
||||
return HttpResponse(
|
||||
render(rgb, mask, img_format=driver, **options),
|
||||
content_type="image/{}".format(ext)
|
||||
|
|
|
@ -126,7 +126,7 @@ def add_default_presets():
|
|||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'min-num-features', 'value': '18000'},
|
||||
{'name': 'use-3dmesh', 'value': True},
|
||||
{'name': 'feature-quality', 'value': 'ultra'}]})
|
||||
{'name': 'feature-quality', 'value': 'medium'}]})
|
||||
Preset.objects.update_or_create(name='DSM + DTM', system=True,
|
||||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'dsm', 'value': True},
|
||||
|
|
|
@ -7,77 +7,3 @@ logger = logging.getLogger('app.logger')
|
|||
# Make the SETTINGS object available to all templates
|
||||
def load(request=None):
|
||||
return {'SETTINGS': Setting.objects.first()}
|
||||
|
||||
# Helper functions for libsass
|
||||
|
||||
def theme(color):
|
||||
"""Return a theme color from the currently selected theme"""
|
||||
try:
|
||||
return getattr(load()['SETTINGS'].theme, color)
|
||||
except Exception as e:
|
||||
logger.warning("Cannot load configuration from theme(): " + e.message)
|
||||
return "blue" # dah buh dih ah buh daa..
|
||||
|
||||
|
||||
def complementary(hexcolor):
|
||||
"""Returns complementary RGB color
|
||||
Example: complementaryColor('#FFFFFF') --> '#000000'
|
||||
"""
|
||||
if hexcolor[0] == '#':
|
||||
hexcolor = hexcolor[1:]
|
||||
rgb = (hexcolor[0:2], hexcolor[2:4], hexcolor[4:6])
|
||||
comp = ['%02X' % (255 - int(a, 16)) for a in rgb]
|
||||
return '#' + ''.join(comp)
|
||||
|
||||
|
||||
def scaleby(hexcolor, scalefactor, ignore_value = False):
|
||||
"""
|
||||
Scales a hex string by ``scalefactor``, but is color dependent, unless ignore_value is True
|
||||
scalefactor is now always between 0 and 1. A value of 0.8
|
||||
will cause bright colors to become darker and
|
||||
dark colors to become brigther by 20%
|
||||
"""
|
||||
|
||||
def calculate(hexcolor, scalefactor):
|
||||
"""
|
||||
Scales a hex string by ``scalefactor``. Returns scaled hex string.
|
||||
To darken the color, use a float value between 0 and 1.
|
||||
To brighten the color, use a float value greater than 1.
|
||||
|
||||
>>> colorscale("#DF3C3C", .5)
|
||||
#6F1E1E
|
||||
>>> colorscale("#52D24F", 1.6)
|
||||
#83FF7E
|
||||
>>> colorscale("#4F75D2", 1)
|
||||
#4F75D2
|
||||
"""
|
||||
|
||||
def clamp(val, minimum=0, maximum=255):
|
||||
if val < minimum:
|
||||
return minimum
|
||||
if val > maximum:
|
||||
return maximum
|
||||
return int(val)
|
||||
|
||||
hexcolor = hexcolor.strip('#')
|
||||
|
||||
if scalefactor < 0 or len(hexcolor) != 6:
|
||||
return hexcolor
|
||||
|
||||
r, g, b = int(hexcolor[:2], 16), int(hexcolor[2:4], 16), int(hexcolor[4:], 16)
|
||||
|
||||
r = clamp(r * scalefactor)
|
||||
g = clamp(g * scalefactor)
|
||||
b = clamp(b * scalefactor)
|
||||
|
||||
return "#%02x%02x%02x" % (r, g, b)
|
||||
|
||||
|
||||
hexcolor = hexcolor.strip('#')
|
||||
scalefactor = abs(float(scalefactor.value))
|
||||
scalefactor = min(1.0, max(0, scalefactor))
|
||||
|
||||
r, g, b = int(hexcolor[:2], 16), int(hexcolor[2:4], 16), int(hexcolor[4:], 16)
|
||||
value = max(r, g, b)
|
||||
|
||||
return calculate(hexcolor, scalefactor if ignore_value or value >= 127 else 2 - scalefactor)
|
||||
|
|
|
@ -2,6 +2,7 @@ import logging
|
|||
import os
|
||||
import shutil
|
||||
import time
|
||||
import struct
|
||||
import uuid as uuid_module
|
||||
from app.vendor import zipfly
|
||||
|
||||
|
@ -157,7 +158,7 @@ def resize_image(image_path, resize_to, done=None):
|
|||
os.rename(resized_image_path, image_path)
|
||||
|
||||
logger.info("Resized {} to {}x{}".format(image_path, resized_width, resized_height))
|
||||
except (IOError, ValueError) as e:
|
||||
except (IOError, ValueError, struct.error) as e:
|
||||
logger.warning("Cannot resize {}: {}.".format(image_path, str(e)))
|
||||
if done is not None:
|
||||
done()
|
||||
|
@ -449,16 +450,16 @@ class Task(models.Model):
|
|||
|
||||
return False
|
||||
|
||||
def get_asset_file_or_zipstream(self, asset):
|
||||
def get_asset_file_or_stream(self, asset):
|
||||
"""
|
||||
Get a stream to an asset
|
||||
:param asset: one of ASSETS_MAP keys
|
||||
:return: (path|stream, is_zipstream:bool)
|
||||
:return: (path|stream)
|
||||
"""
|
||||
if asset in self.ASSETS_MAP:
|
||||
value = self.ASSETS_MAP[asset]
|
||||
if isinstance(value, str):
|
||||
return self.assets_path(value), False
|
||||
return self.assets_path(value)
|
||||
|
||||
elif isinstance(value, dict):
|
||||
if 'deferred_path' in value and 'deferred_compress_dir' in value:
|
||||
|
@ -468,7 +469,7 @@ class Task(models.Model):
|
|||
paths = [p for p in paths if os.path.basename(p['fs']) not in value['deferred_exclude_files']]
|
||||
if len(paths) == 0:
|
||||
raise FileNotFoundError("No files available for download")
|
||||
return zipfly.ZipStream(paths), True
|
||||
return zipfly.ZipStream(paths)
|
||||
else:
|
||||
raise FileNotFoundError("{} is not a valid asset (invalid dict values)".format(asset))
|
||||
else:
|
||||
|
@ -1162,6 +1163,7 @@ class Task(models.Model):
|
|||
return path_traversal_check(p, self.task_path())
|
||||
|
||||
def handle_images_upload(self, files):
|
||||
uploaded = {}
|
||||
for file in files:
|
||||
name = file.name
|
||||
if name is None:
|
||||
|
@ -1180,6 +1182,9 @@ class Task(models.Model):
|
|||
else:
|
||||
with open(file.temporary_file_path(), 'rb') as f:
|
||||
shutil.copyfileobj(f, fd)
|
||||
|
||||
uploaded[name] = os.path.getsize(dst_path)
|
||||
return uploaded
|
||||
|
||||
def update_size(self, commit=False):
|
||||
try:
|
||||
|
|
|
@ -7,6 +7,8 @@ from django.db import models
|
|||
from colorfield.fields import ColorField
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.cache import cache
|
||||
from django.core.cache.utils import make_template_fragment_key
|
||||
|
||||
from webodm import settings
|
||||
|
||||
|
@ -54,14 +56,5 @@ def theme_post_save(sender, instance, created, **kwargs):
|
|||
|
||||
|
||||
def update_theme_css():
|
||||
"""
|
||||
Touch theme.scss to invalidate its cache and force
|
||||
compressor to regenerate it
|
||||
"""
|
||||
|
||||
theme_file = os.path.join('app', 'static', 'app', 'css', 'theme.scss')
|
||||
try:
|
||||
Path(theme_file).touch()
|
||||
logger.info("Regenerate cache for {}".format(theme_file))
|
||||
except:
|
||||
logger.warning("Failed to touch {}".format(theme_file))
|
||||
key = make_template_fragment_key("theme_css")
|
||||
cache.delete(key)
|
|
@ -110,7 +110,7 @@ def build_plugins():
|
|||
# Create entry configuration
|
||||
entry = {}
|
||||
for e in plugin.build_jsx_components():
|
||||
entry[os.path.splitext(os.path.basename(e))[0]] = [os.path.join('.', e)]
|
||||
entry[os.path.splitext(os.path.basename(e))[0]] = ['./' + e]
|
||||
wpc_content = tmpl.substitute({
|
||||
'entry_json': json.dumps(entry)
|
||||
})
|
||||
|
@ -210,9 +210,12 @@ def get_plugins():
|
|||
module = importlib.import_module("plugins.{}".format(dir))
|
||||
|
||||
plugin = (getattr(module, "Plugin"))()
|
||||
except (ImportError, AttributeError):
|
||||
module = importlib.import_module("coreplugins.{}".format(dir))
|
||||
plugin = (getattr(module, "Plugin"))()
|
||||
except (ImportError, AttributeError) as plugin_error:
|
||||
try:
|
||||
module = importlib.import_module("coreplugins.{}".format(dir))
|
||||
plugin = (getattr(module, "Plugin"))()
|
||||
except (ImportError, AttributeError) as coreplugin_error:
|
||||
raise coreplugin_error from plugin_error
|
||||
|
||||
# Check version
|
||||
manifest = plugin.get_manifest()
|
||||
|
@ -237,7 +240,7 @@ def get_plugins():
|
|||
|
||||
plugins.append(plugin)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to instantiate plugin {}: {}".format(dir, e))
|
||||
logger.warning("Failed to instantiate plugin {}: {}: {}".format(dir, e, e.__cause__))
|
||||
|
||||
return plugins
|
||||
|
||||
|
|
|
@ -1,152 +0,0 @@
|
|||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
import platform
|
||||
|
||||
from webodm import settings
|
||||
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
||||
class GrassEngine:
|
||||
def __init__(self):
|
||||
self.grass_binary = shutil.which('grass7') or \
|
||||
shutil.which('grass7.bat') or \
|
||||
shutil.which('grass72') or \
|
||||
shutil.which('grass72.bat') or \
|
||||
shutil.which('grass74') or \
|
||||
shutil.which('grass74.bat') or \
|
||||
shutil.which('grass76') or \
|
||||
shutil.which('grass76.bat') or \
|
||||
shutil.which('grass78') or \
|
||||
shutil.which('grass78.bat') or \
|
||||
shutil.which('grass80') or \
|
||||
shutil.which('grass80.bat')
|
||||
|
||||
if self.grass_binary is None:
|
||||
logger.warning("Could not find a GRASS 7 executable. GRASS scripts will not work.")
|
||||
|
||||
def create_context(self, serialized_context = {}):
|
||||
if self.grass_binary is None: raise GrassEngineException("GRASS engine is unavailable")
|
||||
return GrassContext(self.grass_binary, **serialized_context)
|
||||
|
||||
|
||||
class GrassContext:
|
||||
def __init__(self, grass_binary, tmpdir = None, script_opts = {}, location = None, auto_cleanup=True, python_path=None):
|
||||
self.grass_binary = grass_binary
|
||||
if tmpdir is None:
|
||||
tmpdir = os.path.basename(tempfile.mkdtemp('_grass_engine', dir=settings.MEDIA_TMP))
|
||||
self.tmpdir = tmpdir
|
||||
self.script_opts = script_opts.copy()
|
||||
self.location = location
|
||||
self.auto_cleanup = auto_cleanup
|
||||
self.python_path = python_path
|
||||
|
||||
def get_cwd(self):
|
||||
return os.path.join(settings.MEDIA_TMP, self.tmpdir)
|
||||
|
||||
def add_file(self, filename, source, use_as_location=False):
|
||||
param = os.path.splitext(filename)[0] # filename without extension
|
||||
|
||||
dst_path = os.path.abspath(os.path.join(self.get_cwd(), filename))
|
||||
with open(dst_path, 'w') as f:
|
||||
f.write(source)
|
||||
self.script_opts[param] = dst_path
|
||||
|
||||
if use_as_location:
|
||||
self.set_location(self.script_opts[param])
|
||||
|
||||
return dst_path
|
||||
|
||||
def add_param(self, param, value):
|
||||
self.script_opts[param] = value
|
||||
|
||||
def set_location(self, location):
|
||||
"""
|
||||
:param location: either a "epsg:XXXXX" string or a path to a geospatial file defining the location
|
||||
"""
|
||||
if not location.lower().startswith('epsg:'):
|
||||
location = os.path.abspath(location)
|
||||
self.location = location
|
||||
|
||||
def execute(self, script):
|
||||
"""
|
||||
:param script: path to .grass script
|
||||
:return: script output
|
||||
"""
|
||||
if self.location is None: raise GrassEngineException("Location is not set")
|
||||
|
||||
script = os.path.abspath(script)
|
||||
|
||||
# Make sure working directory exists
|
||||
if not os.path.exists(self.get_cwd()):
|
||||
os.mkdir(self.get_cwd())
|
||||
|
||||
# Create param list
|
||||
params = ["{}={}".format(opt,value) for opt,value in self.script_opts.items()]
|
||||
|
||||
# Track success, output
|
||||
success = False
|
||||
out = ""
|
||||
err = ""
|
||||
|
||||
# Setup env
|
||||
env = os.environ.copy()
|
||||
env["LC_ALL"] = "C.UTF-8"
|
||||
|
||||
if self.python_path:
|
||||
sep = ";" if platform.system() == "Windows" else ":"
|
||||
env["PYTHONPATH"] = "%s%s%s" % (self.python_path, sep, env.get("PYTHONPATH", ""))
|
||||
|
||||
# Execute it
|
||||
logger.info("Executing grass script from {}: {} -c {} location --exec python3 {} {}".format(self.get_cwd(), self.grass_binary, self.location, script, " ".join(params)))
|
||||
|
||||
command = [self.grass_binary, '-c', self.location, 'location', '--exec', 'python3', script] + params
|
||||
if platform.system() == "Windows":
|
||||
# communicate() hangs on Windows so we use check_output instead
|
||||
try:
|
||||
out = subprocess.check_output(command, cwd=self.get_cwd(), env=env).decode('utf-8').strip()
|
||||
success = True
|
||||
except:
|
||||
success = False
|
||||
err = out
|
||||
else:
|
||||
p = subprocess.Popen(command, cwd=self.get_cwd(), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
out, err = p.communicate()
|
||||
|
||||
out = out.decode('utf-8').strip()
|
||||
err = err.decode('utf-8').strip()
|
||||
success = p.returncode == 0
|
||||
|
||||
if success:
|
||||
return out
|
||||
else:
|
||||
raise GrassEngineException("Could not execute GRASS script {} from {}: {}".format(script, self.get_cwd(), err))
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'tmpdir': self.tmpdir,
|
||||
'script_opts': self.script_opts,
|
||||
'location': self.location,
|
||||
'auto_cleanup': self.auto_cleanup,
|
||||
'python_path': self.python_path,
|
||||
}
|
||||
|
||||
def cleanup(self):
|
||||
if os.path.exists(self.get_cwd()):
|
||||
shutil.rmtree(self.get_cwd())
|
||||
|
||||
def __del__(self):
|
||||
if self.auto_cleanup:
|
||||
self.cleanup()
|
||||
|
||||
class GrassEngineException(Exception):
|
||||
pass
|
||||
|
||||
def cleanup_grass_context(serialized_context):
|
||||
ctx = grass.create_context(serialized_context)
|
||||
ctx.cleanup()
|
||||
|
||||
grass = GrassEngine()
|
|
@ -6,7 +6,7 @@ process.env.NODE_PATH = webodmRoot + "node_modules";
|
|||
require("module").Module._initPaths();
|
||||
|
||||
let path = require("path");
|
||||
let ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
|
@ -21,8 +21,9 @@ module.exports = {
|
|||
},
|
||||
|
||||
plugins: [
|
||||
new ExtractTextPlugin('[name].css', {
|
||||
allChunks: true
|
||||
new MiniCssExtractPlugin({
|
||||
filename: '[name].css',
|
||||
chunkFilename: '[id].css'
|
||||
})
|
||||
],
|
||||
|
||||
|
@ -34,7 +35,7 @@ module.exports = {
|
|||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
query: {
|
||||
options: {
|
||||
plugins: [
|
||||
'@babel/syntax-class-properties',
|
||||
'@babel/proposal-class-properties'
|
||||
|
@ -49,22 +50,21 @@ module.exports = {
|
|||
},
|
||||
{
|
||||
test: /\.s?css$$/,
|
||||
use: ExtractTextPlugin.extract({
|
||||
use: [
|
||||
{ loader: 'css-loader' },
|
||||
{
|
||||
loader: 'sass-loader',
|
||||
options: {
|
||||
implementation: require("sass")
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
'css-loader',
|
||||
'sass-loader',
|
||||
]
|
||||
},
|
||||
{
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|svg)/,
|
||||
loader: "url-loader?limit=100000"
|
||||
}
|
||||
use: {
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 100000,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import inspect
|
||||
from worker.celery import app
|
||||
# noinspection PyUnresolvedReferences
|
||||
from worker.tasks import execute_grass_script
|
||||
|
||||
task = app.task
|
||||
|
||||
|
|
|
@ -51,14 +51,13 @@ def export_raster(input, output, **opts):
|
|||
output_raster = output
|
||||
jpg_background = 255 # white
|
||||
|
||||
# KMZ is special, we just export it as PNG with EPSG:4326
|
||||
# KMZ is special, we just export it as GeoTIFF
|
||||
# and then call GDAL to tile/package it
|
||||
kmz = export_format == "kmz"
|
||||
if kmz:
|
||||
export_format = "png"
|
||||
epsg = 4326
|
||||
export_format = "gtiff-rgb"
|
||||
path_base, _ = os.path.splitext(output)
|
||||
output_raster = path_base + ".png"
|
||||
output_raster = path_base + ".kmz.tif"
|
||||
|
||||
if export_format == "jpg":
|
||||
driver = "JPEG"
|
||||
|
@ -282,4 +281,4 @@ def export_raster(input, output, **opts):
|
|||
if kmz:
|
||||
subprocess.check_output(["gdal_translate", "-of", "KMLSUPEROVERLAY",
|
||||
"-co", "Name={}".format(name),
|
||||
"-co", "FORMAT=PNG", output_raster, output])
|
||||
"-co", "FORMAT=AUTO", output_raster, output])
|
||||
|
|
|
@ -1,278 +0,0 @@
|
|||
/* Primary */
|
||||
body,
|
||||
ul#side-menu.nav a,
|
||||
.console,
|
||||
.alert,
|
||||
.form-control,
|
||||
.dropdown-menu > li > a,
|
||||
.theme-color-primary,
|
||||
{
|
||||
color: theme("primary");
|
||||
}
|
||||
.theme-border-primary{
|
||||
border-color: theme("primary");
|
||||
}
|
||||
.tooltip{
|
||||
.tooltip-inner{
|
||||
background-color: theme("primary");
|
||||
}
|
||||
&.left .tooltip-arrow{ border-left-color: theme("primary"); }
|
||||
&.top .tooltip-arrow{ border-top-color: theme("primary"); }
|
||||
&.bottom .tooltip-arrow{ border-bottom-color: theme("primary"); }
|
||||
&.right .tooltip-arrow{ border-right-color: theme("primary"); }
|
||||
}
|
||||
.theme-fill-primary{
|
||||
fill: theme("primary");
|
||||
}
|
||||
.theme-stroke-primary{
|
||||
stroke: theme("primary");
|
||||
}
|
||||
|
||||
/* Secondary */
|
||||
body,
|
||||
.navbar-default,
|
||||
.console,
|
||||
.alert,
|
||||
.modal-content,
|
||||
.form-control,
|
||||
.dropdown-menu,
|
||||
.theme-secondary
|
||||
{
|
||||
background-color: theme("secondary");
|
||||
}
|
||||
|
||||
.tooltip > .tooltip-inner{
|
||||
color: theme("secondary");
|
||||
}
|
||||
|
||||
.alert{
|
||||
.close:hover, .close:focus{
|
||||
color: complementary(theme("secondary"));
|
||||
}
|
||||
}
|
||||
|
||||
.pagination li > a,
|
||||
.pagination .disabled > a,
|
||||
.pagination .disabled > a:hover, .pagination .disabled > a:focus{
|
||||
color: scaleby(theme("primary"), 0.7);
|
||||
background-color: theme("secondary");
|
||||
border-color: scaleby(theme("secondary"), 0.7);
|
||||
}
|
||||
.pagination li > a{
|
||||
color: theme("primary");
|
||||
}
|
||||
.theme-border-secondary-07{
|
||||
border-color: scaleby(theme("secondary"), 0.7) !important;
|
||||
}
|
||||
|
||||
.btn-secondary, .btn-secondary:active, .btn-secondary.active, .open>.dropdown-toggle.btn-secondary{
|
||||
background-color: theme("secondary");
|
||||
border-color: theme("secondary");
|
||||
color: theme("primary");
|
||||
|
||||
&:hover, &:active, &:focus{
|
||||
background-color: scalebyiv(theme("secondary"), 0.90);
|
||||
border-color: scalebyiv(theme("secondary"), 0.90);
|
||||
color: theme("primary");
|
||||
}
|
||||
}
|
||||
|
||||
/* Tertiary */
|
||||
a, a:hover, a:focus{
|
||||
color: theme("tertiary");
|
||||
}
|
||||
.progress-bar-success{
|
||||
background-color: theme("tertiary");
|
||||
}
|
||||
|
||||
/* Button primary */
|
||||
#navbar-top .navbar-top-links,{
|
||||
a:hover,a:focus,.open > a{
|
||||
background-color: theme("button_primary");
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
#navbar-top ul#side-menu a:focus{
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#navbar-top ul#side-menu a:hover, #navbar-top ul#side-menu a.active:hover{
|
||||
background-color: theme("button_primary");
|
||||
color: theme("secondary");
|
||||
}
|
||||
|
||||
.btn-primary, .btn-primary:active, .btn-primary.active, .open>.dropdown-toggle.btn-primary{
|
||||
background-color: theme("button_primary");
|
||||
border-color: theme("button_primary");
|
||||
color: theme("secondary");
|
||||
|
||||
&:hover, &:active, &:focus, &[disabled]:hover, &[disabled]:focus, &[disabled]:active{
|
||||
background-color: scalebyiv(theme("button_primary"), 0.90);
|
||||
border-color: scalebyiv(theme("button_primary"), 0.90);
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
/* Button default */
|
||||
.btn-default, .btn-default:active, .btn-default.active, .open>.dropdown-toggle.btn-default{
|
||||
background-color: theme("button_default");
|
||||
border-color: theme("button_default");
|
||||
color: theme("secondary");
|
||||
|
||||
&:hover, &:active, &:focus, &[disabled]:hover, &[disabled]:focus, &[disabled]:active{
|
||||
background-color: scalebyiv(theme("button_default"), 0.90);
|
||||
border-color: scalebyiv(theme("button_default"), 0.90);
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
.pagination>.active>a, .pagination>.active>span, .pagination>.active>a:hover, .pagination>.active>span:hover, .pagination>.active>a:focus, .pagination>.active>span:focus,
|
||||
.pagination .active > a:hover, .pagination .active > a:focus,
|
||||
.pagination li > a:hover, .pagination li > a:focus{
|
||||
background-color: theme("button_default");
|
||||
color: theme("secondary");
|
||||
}
|
||||
|
||||
/* Button danger */
|
||||
.btn-danger, .btn-danger:active, .btn-danger.active, .open>.dropdown-toggle.btn-danger{
|
||||
background-color: theme("button_danger");
|
||||
border-color: theme("button_danger");
|
||||
color: theme("secondary");
|
||||
|
||||
&:hover, &:active, &:focus, &[disabled]:hover, &[disabled]:focus, &[disabled]:active {
|
||||
background-color: scalebyiv(theme("button_danger"), 0.90);
|
||||
border-color: scalebyiv(theme("button_danger"), 0.90);
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
.theme-color-button-danger{
|
||||
color: theme("button_danger");
|
||||
}
|
||||
|
||||
.theme-color-button-primary{
|
||||
color: theme("button_primary");
|
||||
}
|
||||
|
||||
/* Header background */
|
||||
#navbar-top{
|
||||
background-color: theme("header_background");
|
||||
}
|
||||
|
||||
/* Header primary */
|
||||
.navbar-default .navbar-link,
|
||||
#navbar-top .navbar-top-links a.dropdown-toggle{
|
||||
color: theme("header_primary");
|
||||
|
||||
&:hover{
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
/* Border */
|
||||
.sidebar ul li,
|
||||
.project-list-item,
|
||||
#page-wrapper,
|
||||
table-bordered>thead>tr>th, .table-bordered>thead>tr>th, table-bordered>tbody>tr>th, .table-bordered>tbody>tr>th, table-bordered>tfoot>tr>th, .table-bordered>tfoot>tr>th, table-bordered>thead>tr>td, .table-bordered>thead>tr>td, table-bordered>tbody>tr>td, .table-bordered>tbody>tr>td, table-bordered>tfoot>tr>td, .table-bordered>tfoot>tr>td,
|
||||
footer,
|
||||
.modal-content,
|
||||
.modal-header,
|
||||
.modal-footer,
|
||||
.dropdown-menu
|
||||
{
|
||||
border-color: theme("border");
|
||||
}
|
||||
.dropdown-menu .divider{
|
||||
background-color: theme("border");
|
||||
}
|
||||
.popover-title{
|
||||
border-bottom-color: theme("border");
|
||||
}
|
||||
.theme-border{
|
||||
border-color: theme("border") !important;
|
||||
}
|
||||
|
||||
/* Highlight */
|
||||
.task-list-item:nth-child(odd),
|
||||
.table-striped>tbody>tr:nth-of-type(odd),
|
||||
select.form-control option[disabled],
|
||||
.theme-background-highlight{
|
||||
background-color: theme("highlight");
|
||||
}
|
||||
.dropdown-menu > li > a{
|
||||
&:hover, &:focus{
|
||||
background-color: theme("highlight");
|
||||
color: theme("primary");
|
||||
}
|
||||
}
|
||||
pre.prettyprint,
|
||||
.form-control{
|
||||
border-color: theme('highlight');
|
||||
&:focus{
|
||||
border-color: scalebyiv(theme('highlight'), 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dialog warning */
|
||||
.alert-warning{
|
||||
border-color: theme("dialog_warning");
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.task-list-item .status-label.done, .theme-background-success{
|
||||
background-color: theme("success");
|
||||
}
|
||||
|
||||
/* Failed */
|
||||
.task-list-item .status-label.error, .theme-background-failed{
|
||||
background-color: theme("failed");
|
||||
}
|
||||
|
||||
/* ModelView.jsx specific */
|
||||
.model-view #potree_sidebar_container {
|
||||
.dropdown-menu > li > a{
|
||||
color: theme("primary");
|
||||
}
|
||||
}
|
||||
|
||||
/* MapView.jsx specific */
|
||||
.leaflet-bar a, .leaflet-control > a{
|
||||
background-color: theme("secondary") !important;
|
||||
border-color: theme("secondary") !important;
|
||||
color: theme("primary") !important;
|
||||
|
||||
&:hover{
|
||||
background-color: scalebyiv(theme("secondary"), 0.90) !important;
|
||||
border-color: scalebyiv(theme("secondary"), 0.90) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper{
|
||||
background-color: theme("secondary") !important;
|
||||
color: theme("primary") !important;
|
||||
a{
|
||||
color: theme("tertiary") !important;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-container{
|
||||
a.leaflet-popup-close-button{
|
||||
color: theme("primary") !important;
|
||||
&:hover{
|
||||
color: complementary(theme("secondary")) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-badge{
|
||||
background-color: theme("button_default");
|
||||
border-color: theme("button_default");
|
||||
color: theme("secondary");
|
||||
|
||||
|
||||
a, a:hover{
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ import { _, interpolate } from './classes/gettext';
|
|||
class MapView extends React.Component {
|
||||
static defaultProps = {
|
||||
mapItems: [],
|
||||
selectedMapType: 'orthophoto',
|
||||
selectedMapType: 'auto',
|
||||
title: "",
|
||||
public: false,
|
||||
shareButtons: true
|
||||
|
@ -16,7 +16,7 @@ class MapView extends React.Component {
|
|||
|
||||
static propTypes = {
|
||||
mapItems: PropTypes.array.isRequired, // list of dictionaries where each dict is a {mapType: 'orthophoto', url: <tiles.json>},
|
||||
selectedMapType: PropTypes.oneOf(['orthophoto', 'plant', 'dsm', 'dtm']),
|
||||
selectedMapType: PropTypes.oneOf(['auto', 'orthophoto', 'plant', 'dsm', 'dtm']),
|
||||
title: PropTypes.string,
|
||||
public: PropTypes.bool,
|
||||
shareButtons: PropTypes.bool
|
||||
|
@ -25,9 +25,30 @@ class MapView extends React.Component {
|
|||
constructor(props){
|
||||
super(props);
|
||||
|
||||
let selectedMapType = props.selectedMapType;
|
||||
|
||||
// Automatically select type based on available tiles
|
||||
// and preference order (below)
|
||||
if (props.selectedMapType === "auto"){
|
||||
let preferredTypes = ['orthophoto', 'dsm', 'dtm'];
|
||||
|
||||
for (let i = 0; i < this.props.mapItems.length; i++){
|
||||
let mapItem = this.props.mapItems[i];
|
||||
for (let j = 0; j < preferredTypes.length; j++){
|
||||
if (mapItem.tiles.find(t => t.type === preferredTypes[j])){
|
||||
selectedMapType = preferredTypes[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selectedMapType !== "auto") break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedMapType === "auto") selectedMapType = "orthophoto"; // Hope for the best
|
||||
|
||||
this.state = {
|
||||
selectedMapType: props.selectedMapType,
|
||||
tiles: this.getTilesByMapType(props.selectedMapType)
|
||||
selectedMapType,
|
||||
tiles: this.getTilesByMapType(selectedMapType)
|
||||
};
|
||||
|
||||
this.getTilesByMapType = this.getTilesByMapType.bind(this);
|
||||
|
@ -101,7 +122,7 @@ class MapView extends React.Component {
|
|||
{this.props.title ?
|
||||
<h3><i className="fa fa-globe"></i> {this.props.title}</h3>
|
||||
: ""}
|
||||
|
||||
|
||||
<div className="map-container">
|
||||
<Map
|
||||
tiles={this.state.tiles}
|
||||
|
|
|
@ -10,6 +10,7 @@ import PropTypes from 'prop-types';
|
|||
import * as THREE from 'THREE';
|
||||
import $ from 'jquery';
|
||||
import { _, interpolate } from './classes/gettext';
|
||||
import { getUnitSystem, setUnitSystem } from './classes/Units';
|
||||
|
||||
require('./vendor/OBJLoader');
|
||||
require('./vendor/MTLLoader');
|
||||
|
@ -301,6 +302,20 @@ class ModelView extends React.Component {
|
|||
viewer.setPointBudget(10*1000*1000);
|
||||
viewer.setEDLEnabled(true);
|
||||
viewer.loadSettingsFromURL();
|
||||
|
||||
const currentUnit = getUnitSystem();
|
||||
const origSetUnit = viewer.setLengthUnitAndDisplayUnit;
|
||||
viewer.setLengthUnitAndDisplayUnit = (lengthUnit, displayUnit) => {
|
||||
if (displayUnit === 'm') setUnitSystem('metric');
|
||||
else if (displayUnit === 'ft'){
|
||||
// Potree doesn't have US/international imperial, so
|
||||
// we default to international unless the user has previously
|
||||
// selected US
|
||||
if (currentUnit === 'metric') setUnitSystem("imperial");
|
||||
else setUnitSystem(currentUnit);
|
||||
}
|
||||
origSetUnit.call(viewer, lengthUnit, displayUnit);
|
||||
};
|
||||
|
||||
viewer.loadGUI(() => {
|
||||
viewer.setLanguage('en');
|
||||
|
@ -335,7 +350,7 @@ class ModelView extends React.Component {
|
|||
directional.position.z = 99999999999;
|
||||
viewer.scene.scene.add( directional );
|
||||
|
||||
this.pointCloudFilePath(pointCloudPath => {
|
||||
this.pointCloudFilePath(pointCloudPath =>{
|
||||
Potree.loadPointCloud(pointCloudPath, "Point Cloud", e => {
|
||||
if (e.type == "loading_failed"){
|
||||
this.setState({error: "Could not load point cloud. This task doesn't seem to have one. Try processing the task again."});
|
||||
|
@ -351,6 +366,12 @@ class ModelView extends React.Component {
|
|||
|
||||
viewer.fitToScreen();
|
||||
|
||||
if (getUnitSystem() === 'metric'){
|
||||
viewer.setLengthUnitAndDisplayUnit('m', 'm');
|
||||
}else{
|
||||
viewer.setLengthUnitAndDisplayUnit('m', 'ft');
|
||||
}
|
||||
|
||||
// Load saved scene (if any)
|
||||
$.ajax({
|
||||
type: "GET",
|
||||
|
|
|
@ -18,6 +18,14 @@ class Storage{
|
|||
console.warn("Failed to call setItem " + key, e);
|
||||
}
|
||||
}
|
||||
|
||||
static removeItem(key){
|
||||
try{
|
||||
localStorage.removeItem(key);
|
||||
}catch(e){
|
||||
console.warn("Failed to call removeItem " + key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Storage;
|
|
@ -0,0 +1,378 @@
|
|||
import { _ } from './gettext';
|
||||
|
||||
const types = {
|
||||
LENGTH: 1,
|
||||
AREA: 2,
|
||||
VOLUME: 3
|
||||
};
|
||||
|
||||
const units = {
|
||||
acres: {
|
||||
factor: (1 / (0.3048 * 0.3048)) / 43560,
|
||||
abbr: 'ac',
|
||||
round: 5,
|
||||
label: _("Acres"),
|
||||
type: types.AREA
|
||||
},
|
||||
acres_us: {
|
||||
factor: Math.pow(3937 / 1200, 2) / 43560,
|
||||
abbr: 'ac (US)',
|
||||
round: 5,
|
||||
label: _("Acres"),
|
||||
type: types.AREA
|
||||
},
|
||||
feet: {
|
||||
factor: 1 / 0.3048,
|
||||
abbr: 'ft',
|
||||
round: 4,
|
||||
label: _("Feet"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
feet_us:{
|
||||
factor: 3937 / 1200,
|
||||
abbr: 'ft (US)',
|
||||
round: 4,
|
||||
label: _("Feet"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
hectares: {
|
||||
factor: 0.0001,
|
||||
abbr: 'ha',
|
||||
round: 4,
|
||||
label: _("Hectares"),
|
||||
type: types.AREA
|
||||
},
|
||||
meters: {
|
||||
factor: 1,
|
||||
abbr: 'm',
|
||||
round: 3,
|
||||
label: _("Meters"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
kilometers: {
|
||||
factor: 0.001,
|
||||
abbr: 'km',
|
||||
round: 5,
|
||||
label: _("Kilometers"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
centimeters: {
|
||||
factor: 100,
|
||||
abbr: 'cm',
|
||||
round: 1,
|
||||
label: _("Centimeters"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
miles: {
|
||||
factor: (1 / 0.3048) / 5280,
|
||||
abbr: 'mi',
|
||||
round: 5,
|
||||
label: _("Miles"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
miles_us: {
|
||||
factor: (3937 / 1200) / 5280,
|
||||
abbr: 'mi (US)',
|
||||
round: 5,
|
||||
label: _("Miles"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
sqfeet: {
|
||||
factor: 1 / (0.3048 * 0.3048),
|
||||
abbr: 'ft²',
|
||||
round: 2,
|
||||
label: _("Square Feet"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqfeet_us: {
|
||||
factor: Math.pow(3937 / 1200, 2),
|
||||
abbr: 'ft² (US)',
|
||||
round: 2,
|
||||
label: _("Square Feet"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqmeters: {
|
||||
factor: 1,
|
||||
abbr: 'm²',
|
||||
round: 2,
|
||||
label: _("Square Meters"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqkilometers: {
|
||||
factor: 0.000001,
|
||||
abbr: 'km²',
|
||||
round: 5,
|
||||
label: _("Square Kilometers"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqmiles: {
|
||||
factor: Math.pow((1 / 0.3048) / 5280, 2),
|
||||
abbr: 'mi²',
|
||||
round: 5,
|
||||
label: _("Square Miles"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqmiles_us: {
|
||||
factor: Math.pow((3937 / 1200) / 5280, 2),
|
||||
abbr: 'mi² (US)',
|
||||
round: 5,
|
||||
label: _("Square Miles"),
|
||||
type: types.AREA
|
||||
},
|
||||
cbmeters:{
|
||||
factor: 1,
|
||||
abbr: 'm³',
|
||||
round: 4,
|
||||
label: _("Cubic Meters"),
|
||||
type: types.VOLUME
|
||||
},
|
||||
cbyards:{
|
||||
factor: Math.pow(1/(0.3048*3), 3),
|
||||
abbr: 'yd³',
|
||||
round: 4,
|
||||
label: _("Cubic Yards"),
|
||||
type: types.VOLUME
|
||||
},
|
||||
cbyards_us:{
|
||||
factor: Math.pow(3937/3600, 3),
|
||||
abbr: 'yd³ (US)',
|
||||
round: 4,
|
||||
label: _("Cubic Yards"),
|
||||
type: types.VOLUME
|
||||
}
|
||||
};
|
||||
|
||||
class ValueUnit{
|
||||
constructor(value, unit){
|
||||
this.value = value;
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
toString(opts = {}){
|
||||
const mul = Math.pow(10, opts.precision !== undefined ? opts.precision : this.unit.round);
|
||||
const rounded = (Math.round(this.value * mul) / mul).toString();
|
||||
|
||||
let withCommas = "";
|
||||
let parts = rounded.split(".");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
withCommas = parts.join(".");
|
||||
|
||||
return `${withCommas} ${this.unit.abbr}`;
|
||||
}
|
||||
}
|
||||
|
||||
class NanUnit{
|
||||
constructor(){
|
||||
this.value = NaN;
|
||||
this.unit = units.meters; // Don't matter
|
||||
}
|
||||
toString(){
|
||||
return "NaN";
|
||||
}
|
||||
}
|
||||
|
||||
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"); }
|
||||
|
||||
getName(){ throw new Error("Not implemented"); }
|
||||
|
||||
area(sqmeters, opts = {}){
|
||||
sqmeters = parseFloat(sqmeters);
|
||||
if (isNaN(sqmeters)) return NanUnit();
|
||||
|
||||
const unit = this.areaUnit(sqmeters, opts);
|
||||
const val = unit.factor * sqmeters;
|
||||
return new ValueUnit(val, unit);
|
||||
}
|
||||
|
||||
length(meters, opts = {}){
|
||||
meters = parseFloat(meters);
|
||||
if (isNaN(meters)) return NanUnit();
|
||||
|
||||
const unit = this.lengthUnit(meters, opts);
|
||||
const val = unit.factor * meters;
|
||||
return new ValueUnit(val, unit);
|
||||
}
|
||||
|
||||
volume(cbmeters, opts = {}){
|
||||
cbmeters = parseFloat(cbmeters);
|
||||
if (isNaN(cbmeters)) return NanUnit();
|
||||
|
||||
const unit = this.volumeUnit(cbmeters, opts);
|
||||
const val = unit.factor * cbmeters;
|
||||
return new ValueUnit(val, unit);
|
||||
}
|
||||
};
|
||||
|
||||
function toMetric(valueUnit, unit){
|
||||
let value = NaN;
|
||||
if (typeof valueUnit === "object" && unit === undefined){
|
||||
value = valueUnit.value;
|
||||
unit = valueUnit.unit;
|
||||
}else{
|
||||
value = parseFloat(valueUnit);
|
||||
}
|
||||
if (isNaN(value)) return NanUnit();
|
||||
|
||||
const val = value / unit.factor;
|
||||
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{
|
||||
throw new Error(`Unrecognized unit type: ${unit.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
class MetricSystem extends UnitSystem{
|
||||
getName(){
|
||||
return _("Metric");
|
||||
}
|
||||
|
||||
lengthUnit(meters, opts = {}){
|
||||
if (opts.fixedUnit) return units.meters;
|
||||
|
||||
if (meters < 1) return units.centimeters;
|
||||
else if (meters >= 1000) return units.kilometers;
|
||||
else return units.meters;
|
||||
}
|
||||
|
||||
areaUnit(sqmeters, opts = {}){
|
||||
if (opts.fixedUnit) return units.sqmeters;
|
||||
|
||||
if (sqmeters >= 10000 && sqmeters < 1000000) return units.hectares;
|
||||
else if (sqmeters >= 1000000) return units.sqkilometers;
|
||||
return units.sqmeters;
|
||||
}
|
||||
|
||||
volumeUnit(cbmeters, opts = {}){
|
||||
return units.cbmeters;
|
||||
}
|
||||
}
|
||||
|
||||
class ImperialSystem extends UnitSystem{
|
||||
getName(){
|
||||
return _("Imperial");
|
||||
}
|
||||
|
||||
feet(){
|
||||
return units.feet;
|
||||
}
|
||||
|
||||
sqfeet(){
|
||||
return units.sqfeet;
|
||||
}
|
||||
|
||||
miles(){
|
||||
return units.miles;
|
||||
}
|
||||
|
||||
sqmiles(){
|
||||
return units.sqmiles;
|
||||
}
|
||||
|
||||
acres(){
|
||||
return units.acres;
|
||||
}
|
||||
|
||||
cbyards(){
|
||||
return units.cbyards;
|
||||
}
|
||||
|
||||
lengthUnit(meters, opts = {}){
|
||||
if (opts.fixedUnit) return this.feet();
|
||||
|
||||
const feet = this.feet().factor * meters;
|
||||
if (feet >= 5280) return this.miles();
|
||||
else return this.feet();
|
||||
}
|
||||
|
||||
areaUnit(sqmeters, opts = {}){
|
||||
if (opts.fixedUnit) return this.sqfeet();
|
||||
|
||||
const sqfeet = this.sqfeet().factor * sqmeters;
|
||||
if (sqfeet >= 43560 && sqfeet < 27878400) return this.acres();
|
||||
else if (sqfeet >= 27878400) return this.sqmiles();
|
||||
else return this.sqfeet();
|
||||
}
|
||||
|
||||
volumeUnit(cbmeters, opts = {}){
|
||||
return this.cbyards();
|
||||
}
|
||||
}
|
||||
|
||||
class ImperialUSSystem extends ImperialSystem{
|
||||
getName(){
|
||||
return _("Imperial (US)");
|
||||
}
|
||||
|
||||
feet(){
|
||||
return units.feet_us;
|
||||
}
|
||||
|
||||
sqfeet(){
|
||||
return units.sqfeet_us;
|
||||
}
|
||||
|
||||
miles(){
|
||||
return units.miles_us;
|
||||
}
|
||||
|
||||
sqmiles(){
|
||||
return units.sqmiles_us;
|
||||
}
|
||||
|
||||
acres(){
|
||||
return units.acres_us;
|
||||
}
|
||||
|
||||
cbyards(){
|
||||
return units.cbyards_us;
|
||||
}
|
||||
}
|
||||
|
||||
const systems = {
|
||||
metric: new MetricSystem(),
|
||||
imperial: new ImperialSystem(),
|
||||
imperialUS: new ImperialUSSystem()
|
||||
}
|
||||
|
||||
// Expose to allow every part of the app to access this information
|
||||
function getUnitSystem(){
|
||||
return localStorage.getItem("unit_system") || "metric";
|
||||
}
|
||||
function setUnitSystem(system){
|
||||
let prevSystem = getUnitSystem();
|
||||
localStorage.setItem("unit_system", system);
|
||||
if (prevSystem !== system){
|
||||
document.dispatchEvent(new CustomEvent("onUnitSystemChanged", { detail: system }));
|
||||
}
|
||||
}
|
||||
|
||||
function onUnitSystemChanged(callback){
|
||||
document.addEventListener("onUnitSystemChanged", callback);
|
||||
}
|
||||
|
||||
function offUnitSystemChanged(callback){
|
||||
document.removeEventListener("onUnitSystemChanged", callback);
|
||||
}
|
||||
|
||||
function unitSystem(){
|
||||
return systems[getUnitSystem()];
|
||||
}
|
||||
|
||||
export {
|
||||
systems,
|
||||
types,
|
||||
toMetric,
|
||||
unitSystem,
|
||||
getUnitSystem,
|
||||
setUnitSystem,
|
||||
onUnitSystemChanged,
|
||||
offUnitSystemChanged
|
||||
};
|
||||
|
|
@ -103,6 +103,10 @@ export default {
|
|||
var sizes = ['bytes', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb'];
|
||||
var i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
isMobile: function(){
|
||||
return navigator.userAgent.match(/(iPad)|(iPhone)|(iPod)|(android)|(webOS)/i);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ export default {
|
|||
}).fail(error => {
|
||||
console.warn(error);
|
||||
if (errorCount++ < 10) setTimeout(() => check(), 2000);
|
||||
else cb(JSON.stringify(error));
|
||||
else cb(error.statusText);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -15,13 +15,6 @@ export default class ApiFactory{
|
|||
// are more robust as we can detect more easily if
|
||||
// things break
|
||||
|
||||
// TODO: we should consider refactoring this code
|
||||
// to use functions instead of events. Originally
|
||||
// we chose to use events because that would have
|
||||
// decreased coupling, but since all API pubsub activity
|
||||
// evolved to require a call to the PluginsAPI object, we might have
|
||||
// added a bunch of complexity for no real advantage here.
|
||||
|
||||
const addEndpoint = (obj, eventName, preTrigger = () => {}) => {
|
||||
const emitResponse = response => {
|
||||
// Timeout needed for modules that have no dependencies
|
||||
|
@ -99,6 +92,26 @@ export default class ApiFactory{
|
|||
obj = Object.assign(obj, api.helpers);
|
||||
}
|
||||
|
||||
// Handle syncronous function on/off/export
|
||||
(api.functions || []).forEach(func => {
|
||||
let callbacks = [];
|
||||
obj[func] = (...args) => {
|
||||
for (let i = 0; i < callbacks.length; i++){
|
||||
if ((callbacks[i])(...args)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const onName = "on" + func[0].toUpperCase() + func.slice(1);
|
||||
const offName = "off" + func[0].toUpperCase() + func.slice(1);
|
||||
obj[onName] = f => {
|
||||
callbacks.push(f);
|
||||
};
|
||||
obj[offName] = f => {
|
||||
callbacks = callbacks.filter(cb => cb !== f);
|
||||
};
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,11 @@ export default {
|
|||
endpoints: [
|
||||
["willAddControls", leafletPreCheck],
|
||||
["didAddControls", layersControlPreCheck],
|
||||
["addActionButton", leafletPreCheck],
|
||||
["addActionButton", leafletPreCheck]
|
||||
],
|
||||
|
||||
functions: [
|
||||
"handleClick"
|
||||
]
|
||||
};
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ class EditTaskPanel extends React.Component {
|
|||
this.setState({saving: false});
|
||||
this.props.onSave(json);
|
||||
}).fail(() => {
|
||||
this.setState({saving: false, error: _("Could not update task information. Plese try again.")});
|
||||
this.setState({saving: false, error: _("Could not update task information. Please try again.")});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -3,20 +3,29 @@ import PropTypes from 'prop-types';
|
|||
import '../css/Histogram.scss';
|
||||
import d3 from 'd3';
|
||||
import { _ } from '../classes/gettext';
|
||||
import { onUnitSystemChanged, offUnitSystemChanged } from '../classes/Units';
|
||||
|
||||
export default class Histogram extends React.Component {
|
||||
static defaultProps = {
|
||||
width: 280,
|
||||
colorMap: null,
|
||||
unitForward: value => value,
|
||||
unitBackward: value => value,
|
||||
onUpdate: null,
|
||||
loading: false,
|
||||
min: null,
|
||||
max: null
|
||||
};
|
||||
static propTypes = {
|
||||
statistics: PropTypes.object.isRequired,
|
||||
colorMap: PropTypes.array,
|
||||
unitForward: PropTypes.func,
|
||||
unitBackward: PropTypes.func,
|
||||
width: PropTypes.number,
|
||||
onUpdate: PropTypes.func,
|
||||
loading: PropTypes.bool
|
||||
loading: PropTypes.bool,
|
||||
min: PropTypes.number,
|
||||
max: PropTypes.number
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
|
@ -53,11 +62,19 @@ export default class Histogram extends React.Component {
|
|||
this.rangeX = [minX, maxX];
|
||||
this.rangeY = [minY, maxY];
|
||||
|
||||
let min = minX;
|
||||
let max = maxX;
|
||||
|
||||
if (this.props.min !== null && this.props.max !== null){
|
||||
min = this.props.min;
|
||||
max = this.props.max;
|
||||
}
|
||||
|
||||
const st = {
|
||||
min: minX.toFixed(3),
|
||||
max: maxX.toFixed(3),
|
||||
minInput: minX.toFixed(3),
|
||||
maxInput: maxX.toFixed(3)
|
||||
min: min,
|
||||
max: max,
|
||||
minInput: this.props.unitForward(min).toFixed(3),
|
||||
maxInput: this.props.unitForward(max).toFixed(3)
|
||||
};
|
||||
|
||||
if (!this.state){
|
||||
|
@ -101,11 +118,14 @@ export default class Histogram extends React.Component {
|
|||
let x = d3.scale.linear()
|
||||
.domain(this.rangeX)
|
||||
.range([0, width]);
|
||||
let tickFormat = x => {
|
||||
return this.props.unitForward(x).toFixed(0);
|
||||
};
|
||||
|
||||
svg.append("g")
|
||||
.attr("class", "x axis theme-fill-primary")
|
||||
.attr("transform", "translate(0," + (height - 5) + ")")
|
||||
.call(d3.svg.axis().scale(x).tickValues(this.rangeX).orient("bottom"));
|
||||
.call(d3.svg.axis().scale(x).tickValues(this.rangeX).tickFormat(tickFormat).orient("bottom"));
|
||||
|
||||
// add the y Axis
|
||||
let y = d3.scale.linear()
|
||||
|
@ -183,7 +203,7 @@ export default class Histogram extends React.Component {
|
|||
maxLine.setAttribute('x2', newX);
|
||||
|
||||
if (prevX !== newX){
|
||||
self.setState({max: (self.rangeX[0] + ((self.rangeX[1] - self.rangeX[0]) / width) * newX).toFixed(3)});
|
||||
self.setState({max: (self.rangeX[0] + ((self.rangeX[1] - self.rangeX[0]) / width) * newX)});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -201,7 +221,7 @@ export default class Histogram extends React.Component {
|
|||
minLine.setAttribute('x2', newX);
|
||||
|
||||
if (prevX !== newX){
|
||||
self.setState({min: (self.rangeX[0] + ((self.rangeX[1] - self.rangeX[0]) / width) * newX).toFixed(3)});
|
||||
self.setState({min: (self.rangeX[0] + ((self.rangeX[1] - self.rangeX[0]) / width) * newX)});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -234,11 +254,28 @@ export default class Histogram extends React.Component {
|
|||
|
||||
componentDidMount(){
|
||||
this.redraw();
|
||||
onUnitSystemChanged(this.handleUnitSystemChanged);
|
||||
}
|
||||
|
||||
componentWillUnmount(){
|
||||
offUnitSystemChanged(this.handleUnitSystemChanged);
|
||||
}
|
||||
|
||||
handleUnitSystemChanged = e => {
|
||||
this.redraw();
|
||||
this.setState({
|
||||
minInput: this.props.unitForward(this.state.min).toFixed(3),
|
||||
maxInput: this.props.unitForward(this.state.max).toFixed(3)
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState){
|
||||
if (prevState.min !== this.state.min) this.state.minInput = this.state.min;
|
||||
if (prevState.max !== this.state.max) this.state.maxInput = this.state.max;
|
||||
if (prevState.min !== this.state.min || prevState.max !== this.state.max){
|
||||
this.setState({
|
||||
minInput: this.props.unitForward(this.state.min).toFixed(3),
|
||||
maxInput: this.props.unitForward(this.state.max).toFixed(3)
|
||||
});
|
||||
}
|
||||
|
||||
if (prevState.min !== this.state.min ||
|
||||
prevState.max !== this.state.max ||
|
||||
|
@ -277,28 +314,44 @@ export default class Histogram extends React.Component {
|
|||
|
||||
handleChangeMax = (e) => {
|
||||
this.setState({maxInput: e.target.value});
|
||||
const val = parseFloat(e.target.value);
|
||||
}
|
||||
|
||||
if (val >= this.state.min && val <= this.rangeX[1]){
|
||||
this.setState({max: val});
|
||||
handleMaxBlur = (e) => {
|
||||
let val = parseFloat(e.target.value);
|
||||
if (!isNaN(val)){
|
||||
val = this.props.unitBackward(val);
|
||||
val = Math.max(this.state.min, Math.min(this.rangeX[1], val));
|
||||
this.setState({max: val, maxInput: val.toFixed(3)});
|
||||
}
|
||||
}
|
||||
|
||||
handleMaxKeyDown = (e) => {
|
||||
if (e.key === 'Enter') this.handleMaxBlur(e);
|
||||
}
|
||||
|
||||
handleChangeMin = (e) => {
|
||||
this.setState({minInput: e.target.value});
|
||||
const val = parseFloat(e.target.value);
|
||||
}
|
||||
|
||||
if (val <= this.state.max && val >= this.rangeX[0]){
|
||||
this.setState({min: val});
|
||||
handleMinBlur = (e) => {
|
||||
let val = parseFloat(e.target.value);
|
||||
if (!isNaN(val)){
|
||||
val = this.props.unitBackward(val);
|
||||
val = Math.max(this.rangeX[0], Math.min(this.state.max, val));
|
||||
this.setState({min: val, minInput: val.toFixed(3)});
|
||||
}
|
||||
};
|
||||
|
||||
handleMinKeyDown = (e) => {
|
||||
if (e.key === 'Enter') this.handleMinBlur(e);
|
||||
}
|
||||
|
||||
render(){
|
||||
return (<div className={"histogram " + (this.props.loading ? "disabled" : "")}>
|
||||
<div ref={(domNode) => { this.hgContainer = domNode; }}>
|
||||
</div>
|
||||
<label>{_("Min:")}</label> <input onChange={this.handleChangeMin} type="number" className="form-control min-max" size={5} value={this.state.minInput} />
|
||||
<label>{_("Max:")}</label> <input onChange={this.handleChangeMax} type="number" className="form-control min-max" size={5} value={this.state.maxInput} />
|
||||
<label>{_("Min:")}</label> <input onKeyDown={this.handleMinKeyDown} onBlur={this.handleMinBlur} onChange={this.handleChangeMin} type="number" className="form-control min-max" size={5} value={this.state.minInput} />
|
||||
<label>{_("Max:")}</label> <input onKeyDown={this.handleMaxKeyDown} onBlur={this.handleMaxBlur} onChange={this.handleChangeMax} type="number" className="form-control min-max" size={5} value={this.state.maxInput} />
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,7 +65,6 @@ export default class LayersControlLayer extends React.Component {
|
|||
exportLoading: false,
|
||||
error: ""
|
||||
};
|
||||
|
||||
this.rescale = params.rescale || "";
|
||||
}
|
||||
|
||||
|
@ -170,7 +169,14 @@ export default class LayersControlLayer extends React.Component {
|
|||
// Update rescale values
|
||||
const { statistics } = this.tmeta;
|
||||
if (statistics && statistics["1"]){
|
||||
this.rescale = `${statistics["1"]["min"]},${statistics["1"]["max"]}`;
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
|
||||
for (let b in statistics){
|
||||
min = Math.min(statistics[b]["percentiles"][0]);
|
||||
max = Math.max(statistics[b]["percentiles"][1]);
|
||||
}
|
||||
this.rescale = `${min},${max}`;
|
||||
}
|
||||
|
||||
this.updateLayer();
|
||||
|
@ -270,6 +276,16 @@ export default class LayersControlLayer extends React.Component {
|
|||
cmapValues = (color_maps.find(c => c.key === colorMap) || {}).color_map;
|
||||
}
|
||||
|
||||
let hmin = null;
|
||||
let hmax = null;
|
||||
if (this.rescale){
|
||||
let parts = decodeURIComponent(this.rescale).split(",");
|
||||
if (parts.length === 2 && parts[0] && parts[1]){
|
||||
hmin = parseFloat(parts[0]);
|
||||
hmax = parseFloat(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return (<div className="layers-control-layer">
|
||||
{!this.props.overlay ? <ExpandButton bind={[this, 'expanded']} /> : <div className="overlayIcon"><i className={meta.icon || "fa fa-vector-square fa-fw"}></i></div>}<Checkbox bind={[this, 'visible']}/>
|
||||
<a title={meta.name} className="layer-label" href="javascript:void(0);" onClick={this.handleLayerClick}>{meta.name}</a>
|
||||
|
@ -278,8 +294,12 @@ export default class LayersControlLayer extends React.Component {
|
|||
<div className="layer-expanded">
|
||||
<Histogram width={274}
|
||||
loading={histogramLoading}
|
||||
statistics={tmeta.statistics}
|
||||
statistics={tmeta.statistics}
|
||||
unitForward={meta.unitForward}
|
||||
unitBackward={meta.unitBackward}
|
||||
colorMap={cmapValues}
|
||||
min={hmin}
|
||||
max={hmax}
|
||||
onUpdate={this.handleHistogramUpdate} />
|
||||
|
||||
<ErrorMessage bind={[this, "error"]} />
|
||||
|
|
|
@ -4,8 +4,6 @@ import '../css/Map.scss';
|
|||
import 'leaflet/dist/leaflet.css';
|
||||
import Leaflet from 'leaflet';
|
||||
import async from 'async';
|
||||
import '../vendor/leaflet/L.Control.MousePosition.css';
|
||||
import '../vendor/leaflet/L.Control.MousePosition';
|
||||
import '../vendor/leaflet/Leaflet.Autolayers/css/leaflet.auto-layers.css';
|
||||
import '../vendor/leaflet/Leaflet.Autolayers/leaflet-autolayers';
|
||||
// import '../vendor/leaflet/L.TileLayer.NoGap';
|
||||
|
@ -29,6 +27,8 @@ import '../vendor/leaflet/Leaflet.Ajax';
|
|||
import 'rbush';
|
||||
import '../vendor/leaflet/leaflet-markers-canvas';
|
||||
import { _ } from '../classes/gettext';
|
||||
import UnitSelector from './UnitSelector';
|
||||
import { unitSystem, toMetric } from '../classes/Units';
|
||||
|
||||
class Map extends React.Component {
|
||||
static defaultProps = {
|
||||
|
@ -135,6 +135,8 @@ class Map extends React.Component {
|
|||
const { url, meta, type } = tile;
|
||||
|
||||
let metaUrl = url + "metadata";
|
||||
let unitForward = value => value;
|
||||
let unitBackward = value => value;
|
||||
|
||||
if (type == "plant"){
|
||||
if (meta.task && meta.task.orthophoto_bands && meta.task.orthophoto_bands.length === 2){
|
||||
|
@ -150,6 +152,12 @@ class Map extends React.Component {
|
|||
}
|
||||
}else if (type == "dsm" || type == "dtm"){
|
||||
metaUrl += "?hillshade=6&color_map=viridis";
|
||||
unitForward = value => {
|
||||
return unitSystem().length(value, { fixedUnit: true }).value;
|
||||
};
|
||||
unitBackward = value => {
|
||||
return toMetric(value).value;
|
||||
};
|
||||
}
|
||||
|
||||
this.tileJsonRequests.push($.getJSON(metaUrl)
|
||||
|
@ -169,7 +177,22 @@ class Map extends React.Component {
|
|||
const params = Utils.queryParams({search: tileUrl.slice(tileUrl.indexOf("?"))});
|
||||
if (statistics["1"]){
|
||||
// Add rescale
|
||||
params["rescale"] = encodeURIComponent(`${statistics["1"]["min"]},${statistics["1"]["max"]}`);
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
if (type === 'plant'){
|
||||
// percentile
|
||||
for (let b in statistics){
|
||||
min = Math.min(statistics[b]["percentiles"][0]);
|
||||
max = Math.max(statistics[b]["percentiles"][1]);
|
||||
}
|
||||
}else{
|
||||
// min/max
|
||||
for (let b in statistics){
|
||||
min = Math.min(statistics[b]["min"]);
|
||||
max = Math.max(statistics[b]["max"]);
|
||||
}
|
||||
}
|
||||
params["rescale"] = encodeURIComponent(`${min},${max}`);
|
||||
}else{
|
||||
console.warn("Cannot find min/max statistics for dataset, setting to -1,1");
|
||||
params["rescale"] = encodeURIComponent("-1,1");
|
||||
|
@ -195,6 +218,8 @@ class Map extends React.Component {
|
|||
// Associate metadata with this layer
|
||||
meta.name = name + ` (${this.typeToHuman(type)})`;
|
||||
meta.metaUrl = metaUrl;
|
||||
meta.unitForward = unitForward;
|
||||
meta.unitBackward = unitBackward;
|
||||
layer[Symbol.for("meta")] = meta;
|
||||
layer[Symbol.for("tile-meta")] = mres;
|
||||
|
||||
|
@ -370,7 +395,7 @@ class Map extends React.Component {
|
|||
|
||||
this.map = Leaflet.map(this.container, {
|
||||
scrollWheelZoom: true,
|
||||
positionControl: true,
|
||||
positionControl: false,
|
||||
zoomControl: false,
|
||||
minZoom: 0,
|
||||
maxZoom: 24
|
||||
|
@ -382,12 +407,23 @@ class Map extends React.Component {
|
|||
|
||||
PluginsAPI.Map.triggerWillAddControls({
|
||||
map: this.map,
|
||||
tiles
|
||||
tiles,
|
||||
mapView: this
|
||||
});
|
||||
|
||||
let scaleControl = Leaflet.control.scale({
|
||||
maxWidth: 250,
|
||||
}).addTo(this.map);
|
||||
const UnitsCtrl = Leaflet.Control.extend({
|
||||
options: {
|
||||
position: 'bottomleft'
|
||||
},
|
||||
|
||||
onAdd: function () {
|
||||
this.container = Leaflet.DomUtil.create('div', 'leaflet-control-units-selection leaflet-control');
|
||||
Leaflet.DomEvent.disableClickPropagation(this.container);
|
||||
ReactDOM.render(<UnitSelector />, this.container);
|
||||
return this.container;
|
||||
}
|
||||
});
|
||||
new UnitsCtrl().addTo(this.map);
|
||||
|
||||
//add zoom control with your options
|
||||
let zoomControl = Leaflet.control.zoom({
|
||||
|
@ -509,6 +545,8 @@ _('Example:'),
|
|||
this.map.fitBounds(this.mapBounds);
|
||||
|
||||
this.map.on('click', e => {
|
||||
if (PluginsAPI.Map.handleClick(e)) return;
|
||||
|
||||
// Find first tile layer at the selected coordinates
|
||||
for (let layer of this.state.imageryLayers){
|
||||
if (layer._map && layer.options.bounds.contains(e.latlng)){
|
||||
|
@ -562,7 +600,6 @@ _('Example:'),
|
|||
tiles: tiles,
|
||||
controls:{
|
||||
autolayers: this.autolayers,
|
||||
scale: scaleControl,
|
||||
zoom: zoomControl
|
||||
}
|
||||
});
|
||||
|
@ -612,7 +649,7 @@ _('Example:'),
|
|||
<div style={{height: "100%"}} className="map">
|
||||
<ErrorMessage bind={[this, 'error']} />
|
||||
<div className="opacity-slider theme-secondary hidden-xs">
|
||||
{_("Opacity:")} <input type="range" step="1" value={this.state.opacity} onChange={this.updateOpacity} />
|
||||
<div className="opacity-slider-label">{_("Opacity:")}</div> <input type="range" step="1" value={this.state.opacity} onChange={this.updateOpacity} />
|
||||
</div>
|
||||
|
||||
<Standby
|
||||
|
@ -633,6 +670,7 @@ _('Example:'),
|
|||
ref={(ref) => { this.shareButton = ref; }}
|
||||
task={this.state.singleTask}
|
||||
linksTarget="map"
|
||||
queryParams={{t: this.props.mapType}}
|
||||
/>
|
||||
: ""}
|
||||
<SwitchModeButton
|
||||
|
|
|
@ -146,8 +146,16 @@ class Paginator extends React.Component {
|
|||
}
|
||||
|
||||
if (itemsPerPage && itemsPerPage && totalItems > itemsPerPage){
|
||||
const numPages = Math.ceil(totalItems / itemsPerPage),
|
||||
pages = [...Array(numPages).keys()]; // [0, 1, 2, ...numPages]
|
||||
const numPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const MAX_PAGE_BUTTONS = 7;
|
||||
|
||||
let rangeStart = Math.max(1, currentPage - Math.floor(MAX_PAGE_BUTTONS / 2));
|
||||
let rangeEnd = rangeStart + Math.min(numPages, MAX_PAGE_BUTTONS);
|
||||
if (rangeEnd > numPages){
|
||||
rangeStart -= rangeEnd - numPages - 1;
|
||||
rangeEnd -= rangeEnd - numPages - 1
|
||||
}
|
||||
let pages = [...Array(rangeEnd - rangeStart).keys()].map(i => i + rangeStart - 1);
|
||||
|
||||
paginator = (
|
||||
<ul className="pagination pagination-sm">
|
||||
|
|
|
@ -106,6 +106,13 @@ class ProcessingNodeOption extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
handleHelp = e => {
|
||||
e.preventDefault();
|
||||
if (window.__taskOptionsDocsLink){
|
||||
window.open(window.__taskOptionsDocsLink + "#" + encodeURIComponent(this.props.name), "task-options")
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let inputControl = "";
|
||||
let warningMsg = "";
|
||||
|
@ -152,7 +159,7 @@ class ProcessingNodeOption extends React.Component {
|
|||
let loadFileControl = "";
|
||||
if (this.supportsFileAPI() && this.props.domain === 'json'){
|
||||
loadFileControl = ([
|
||||
<button key="btn" type="file" className="btn glyphicon glyphicon-import btn-primary" data-toggle="tooltip" data-placement="left" title={_("Click to import a .JSON file")} onClick={() => this.loadFile()}></button>,
|
||||
<button key="btn" type="file" className="btn glyphicon glyphicon-import btn-primary" data-toggle="tooltip" data-placement="left" title={_("Click to import a JSON file")} onClick={() => this.loadFile()}></button>,
|
||||
<input key="file-ctrl" className="file-control" type="file"
|
||||
accept="text/plain,application/json,application/geo+json,.geojson"
|
||||
onChange={this.handleFileSelect}
|
||||
|
@ -168,7 +175,7 @@ class ProcessingNodeOption extends React.Component {
|
|||
|
||||
return (
|
||||
<div className="processing-node-option form-inline form-group form-horizontal" ref={this.setTooltips}>
|
||||
<label>{this.props.name} {(!this.isEnumType() && this.props.domain ? `(${this.props.domain})` : "")} <i data-toggle="tooltip" data-placement="bottom" title={this.props.help} onClick={e => e.preventDefault()} className="fa fa-info-circle info-button"></i></label><br/>
|
||||
<label>{this.props.name} {(!this.isEnumType() && this.props.domain ? `(${this.props.domain})` : "")} <i data-toggle="tooltip" data-placement="bottom" title={this.props.help} onClick={this.handleHelp} className="fa fa-info-circle info-button help-button"></i></label><br/>
|
||||
{inputControl}
|
||||
{loadFileControl}
|
||||
|
||||
|
|
|
@ -60,6 +60,7 @@ class ProjectListItem extends React.Component {
|
|||
this.toggleTaskList = this.toggleTaskList.bind(this);
|
||||
this.closeUploadError = this.closeUploadError.bind(this);
|
||||
this.cancelUpload = this.cancelUpload.bind(this);
|
||||
this.handleCancel = this.handleCancel.bind(this);
|
||||
this.handleTaskSaved = this.handleTaskSaved.bind(this);
|
||||
this.viewMap = this.viewMap.bind(this);
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
|
@ -192,7 +193,7 @@ class ProjectListItem extends React.Component {
|
|||
.on("complete", (file) => {
|
||||
// Retry
|
||||
const retry = () => {
|
||||
const MAX_RETRIES = 10;
|
||||
const MAX_RETRIES = 20;
|
||||
|
||||
if (file.retries < MAX_RETRIES){
|
||||
// Update progress
|
||||
|
@ -208,7 +209,9 @@ class ProjectListItem extends React.Component {
|
|||
file.deltaBytesSent = 0;
|
||||
file.trackedBytesSent = 0;
|
||||
file.retries++;
|
||||
this.dz.processQueue();
|
||||
setTimeout(() => {
|
||||
this.dz.processQueue();
|
||||
}, 5000 * file.retries);
|
||||
}else{
|
||||
throw new Error(interpolate(_('Cannot upload %(filename)s, exceeded max retries (%(max_retries)s)'), {filename: file.name, max_retries: MAX_RETRIES}));
|
||||
}
|
||||
|
@ -216,19 +219,19 @@ class ProjectListItem extends React.Component {
|
|||
|
||||
try{
|
||||
if (file.status === "error"){
|
||||
if ((file.size / 1024) > this.dz.options.maxFilesize) {
|
||||
if ((file.size / 1024 / 1024) > this.dz.options.maxFilesize) {
|
||||
// Delete from upload queue
|
||||
this.setUploadState({
|
||||
totalCount: this.state.upload.totalCount - 1,
|
||||
totalBytes: this.state.upload.totalBytes - file.size
|
||||
});
|
||||
throw new Error(interpolate(_('Cannot upload %(filename)s, File too Large! Default MaxFileSize is %(maxFileSize)s MB!'), { filename: file.name, maxFileSize: this.dz.options.maxFilesize }));
|
||||
throw new Error(interpolate(_('Cannot upload %(filename)s, file is too large! Default MaxFileSize is %(maxFileSize)s MB!'), { filename: file.name, maxFileSize: this.dz.options.maxFilesize }));
|
||||
}
|
||||
retry();
|
||||
}else{
|
||||
// Check response
|
||||
let response = JSON.parse(file.xhr.response);
|
||||
if (response.success){
|
||||
if (response.success && response.uploaded && response.uploaded[file.name] === file.size){
|
||||
// Update progress by removing the tracked progress and
|
||||
// use the file size as the true number of bytes
|
||||
let totalBytesSent = this.state.upload.totalBytesSent + file.size;
|
||||
|
@ -248,13 +251,19 @@ class ProjectListItem extends React.Component {
|
|||
}
|
||||
}
|
||||
}catch(e){
|
||||
this.setUploadState({error: `${e.message}`, uploading: false});
|
||||
this.dz.cancelUpload();
|
||||
if (this.manuallyCanceled){
|
||||
// Manually canceled, ignore error
|
||||
this.setUploadState({uploading: false});
|
||||
}else{
|
||||
this.setUploadState({error: `${e.message}`, uploading: false});
|
||||
}
|
||||
|
||||
if (this.dz.files.length) this.dz.cancelUpload();
|
||||
}
|
||||
})
|
||||
.on("queuecomplete", () => {
|
||||
const remainingFilesCount = this.state.upload.totalCount - this.state.upload.uploadedCount;
|
||||
if (remainingFilesCount === 0){
|
||||
if (remainingFilesCount === 0 && this.state.upload.uploadedCount > 0){
|
||||
// All files have uploaded!
|
||||
this.setUploadState({uploading: false});
|
||||
|
||||
|
@ -275,7 +284,6 @@ class ProjectListItem extends React.Component {
|
|||
}else if (this.dz.getQueuedFiles() === 0){
|
||||
// Done but didn't upload all?
|
||||
this.setUploadState({
|
||||
totalCount: this.state.upload.totalCount - remainingFilesCount,
|
||||
uploading: false,
|
||||
error: interpolate(_('%(count)s files cannot be uploaded. As a reminder, only images (.jpg, .tif, .png) and GCP files (.txt) can be uploaded. Try again.'), { count: remainingFilesCount })
|
||||
});
|
||||
|
@ -332,10 +340,26 @@ class ProjectListItem extends React.Component {
|
|||
this.setUploadState({error: ""});
|
||||
}
|
||||
|
||||
cancelUpload(e){
|
||||
cancelUpload(){
|
||||
this.dz.removeAllFiles(true);
|
||||
}
|
||||
|
||||
handleCancel(){
|
||||
this.manuallyCanceled = true;
|
||||
this.cancelUpload();
|
||||
if (this.dz._taskInfo && this.dz._taskInfo.id !== undefined){
|
||||
$.ajax({
|
||||
url: `/api/projects/${this.state.data.id}/tasks/${this.dz._taskInfo.id}/remove/`,
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
type: 'POST'
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.manuallyCanceled = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
taskDeleted(){
|
||||
this.refresh();
|
||||
}
|
||||
|
@ -628,7 +652,7 @@ class ProjectListItem extends React.Component {
|
|||
<button disabled={this.state.upload.error !== ""}
|
||||
type="button"
|
||||
className={"btn btn-danger btn-sm " + (!this.state.upload.uploading ? "hide" : "")}
|
||||
onClick={this.cancelUpload}>
|
||||
onClick={this.handleCancel}>
|
||||
<i className="glyphicon glyphicon-remove-circle"></i>
|
||||
Cancel Upload
|
||||
</button>
|
||||
|
|
|
@ -12,7 +12,8 @@ class ShareButton extends React.Component {
|
|||
static propTypes = {
|
||||
task: PropTypes.object.isRequired,
|
||||
linksTarget: PropTypes.oneOf(['map', '3d']).isRequired,
|
||||
popupPlacement: PropTypes.string
|
||||
popupPlacement: PropTypes.string,
|
||||
queryParams: PropTypes.object
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
|
@ -45,6 +46,7 @@ class ShareButton extends React.Component {
|
|||
taskChanged={this.handleTaskChanged}
|
||||
placement={this.props.popupPlacement}
|
||||
linksTarget={this.props.linksTarget}
|
||||
queryParams={this.props.queryParams}
|
||||
/>;
|
||||
|
||||
return (
|
||||
|
|
|
@ -15,7 +15,8 @@ class SharePopup extends React.Component{
|
|||
task: PropTypes.object.isRequired,
|
||||
linksTarget: PropTypes.oneOf(['map', '3d']).isRequired,
|
||||
placement: PropTypes.string,
|
||||
taskChanged: PropTypes.func
|
||||
taskChanged: PropTypes.func,
|
||||
queryParams: PropTypes.object
|
||||
};
|
||||
static defaultProps = {
|
||||
placement: 'top',
|
||||
|
@ -38,7 +39,11 @@ class SharePopup extends React.Component{
|
|||
}
|
||||
|
||||
getRelShareLink = () => {
|
||||
return `/public/task/${this.props.task.id}/${this.props.linksTarget}/`;
|
||||
let url = `/public/task/${this.props.task.id}/${this.props.linksTarget}/`;
|
||||
if (this.props.queryParams){
|
||||
url += Utils.toSearchQuery(this.props.queryParams);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
componentDidMount(){
|
||||
|
@ -86,8 +91,8 @@ class SharePopup extends React.Component{
|
|||
}
|
||||
|
||||
render(){
|
||||
const shareLink = Utils.absoluteUrl(this.state.relShareLink);
|
||||
const iframeUrl = Utils.absoluteUrl(`public/task/${this.state.task.id}/iframe/${this.props.linksTarget}/`);
|
||||
const shareLink = Utils.absoluteUrl(this.getRelShareLink());
|
||||
const iframeUrl = Utils.absoluteUrl(`public/task/${this.state.task.id}/iframe/${this.props.linksTarget}/${Utils.toSearchQuery(this.props.queryParams)}`);
|
||||
const iframeCode = `<iframe scrolling="no" title="WebODM" width="61.8033%" height="360" frameBorder="0" src="${iframeUrl}"></iframe>`;
|
||||
|
||||
return (<div onMouseDown={e => { e.stopPropagation(); }} className={"sharePopup " + this.props.placement}>
|
||||
|
|
|
@ -3,6 +3,7 @@ import '../css/TaskList.scss';
|
|||
import TaskListItem from './TaskListItem';
|
||||
import PropTypes from 'prop-types';
|
||||
import $ from 'jquery';
|
||||
import HistoryNav from '../classes/HistoryNav';
|
||||
import { _, interpolate } from '../classes/gettext';
|
||||
|
||||
class TaskList extends React.Component {
|
||||
|
@ -19,6 +20,8 @@ class TaskList extends React.Component {
|
|||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.historyNav = new HistoryNav(props.history);
|
||||
|
||||
this.state = {
|
||||
tasks: [],
|
||||
error: "",
|
||||
|
@ -54,6 +57,10 @@ class TaskList extends React.Component {
|
|||
|
||||
this.taskListRequest =
|
||||
$.getJSON(this.props.source, json => {
|
||||
if (json.length === 1){
|
||||
this.historyNav.addToQSList("project_task_expanded", json[0].id);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
tasks: json
|
||||
});
|
||||
|
@ -67,7 +74,7 @@ class TaskList extends React.Component {
|
|||
.always(() => {
|
||||
this.setState({
|
||||
loading: false
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -266,7 +266,7 @@ class TaskListItem extends React.Component {
|
|||
<li>${_("Not enough overlap between images")}</li>
|
||||
<li>${_("Images might be too blurry (common with phone cameras)")}</li>
|
||||
<li>${_("The min-num-features task option is set too low, try increasing it by 25%")}</li>
|
||||
</ul>`, link: `<a href='https://help.dronedeploy.com/hc/en-us/articles/1500004964282-Making-Successful-Maps' target='_blank'>${_("here")}</a>`})});
|
||||
</ul>`, link: `<a href='https://docs.webodm.net/references/create-successful-maps' target='_blank'>${_("here")}</a>`})});
|
||||
}else if (line.indexOf("Illegal instruction") !== -1 ||
|
||||
line.indexOf("Child returned 132") !== -1){
|
||||
this.setState({friendlyTaskError: interpolate(_("It looks like this computer might be too old. WebODM requires a computer with a 64-bit CPU supporting MMX, SSE, SSE2, SSE3 and SSSE3 instruction set support or higher. You can still run WebODM if you compile your own docker images. See %(link)s for more information."), { link: `<a href='https://github.com/OpenDroneMap/WebODM#common-troubleshooting'>${_("this page")}</a>` } )});
|
||||
|
@ -578,6 +578,10 @@ class TaskListItem extends React.Component {
|
|||
<td><strong>{_("Disk Usage:")}</strong></td>
|
||||
<td>{Utils.bytesToSize(task.size * 1024 * 1024)}</td>
|
||||
</tr>}
|
||||
<tr>
|
||||
<td><strong>{_("Task ID:")}</strong></td>
|
||||
<td>{task.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>{_("Task Output:")}</strong></td>
|
||||
<td><div className="btn-group btn-toggle">
|
||||
|
@ -602,17 +606,17 @@ class TaskListItem extends React.Component {
|
|||
/> : ""}
|
||||
|
||||
{showOrthophotoMissingWarning ?
|
||||
<div className="task-warning"><i className="fa fa-warning"></i> <span>{_("An orthophoto could not be generated. To generate one, make sure GPS information is embedded in the EXIF tags of your images, or use a Ground Control Points (GCP) file.")}</span></div> : ""}
|
||||
<div className="task-warning"><i className="fa fa-exclamation-triangle"></i> <span>{_("An orthophoto could not be generated. To generate one, make sure GPS information is embedded in the EXIF tags of your images, or use a Ground Control Points (GCP) file.")}</span></div> : ""}
|
||||
|
||||
{showMemoryErrorWarning ?
|
||||
<div className="task-warning"><i className="fa fa-support"></i> <Trans params={{ memlink: `<a href="${memoryErrorLink}" target='_blank'>${_("enough RAM allocated")}</a>`, cloudlink: `<a href='https://www.opendronemap.org/webodm/lightning/' target='_blank'>${_("cloud processing node")}</a>` }}>{_("It looks like your processing node ran out of memory. If you are using docker, make sure that your docker environment has %(memlink)s. Alternatively, make sure you have enough physical RAM, reduce the number of images, make your images smaller, or reduce the max-concurrency parameter from the task's options. You can also try to use a %(cloudlink)s.")}</Trans></div> : ""}
|
||||
<div className="task-warning"><i className="fa fa-support"></i> <Trans params={{ memlink: `<a href="${memoryErrorLink}" target='_blank'>${_("enough RAM allocated")}</a>`, cloudlink: `<a href='https://webodm.net' target='_blank'>${_("cloud processing node")}</a>` }}>{_("It looks like your processing node ran out of memory. If you are using docker, make sure that your docker environment has %(memlink)s. Alternatively, make sure you have enough physical RAM, reduce the number of images, make your images smaller, or reduce the max-concurrency parameter from the task's options. You can also try to use a %(cloudlink)s.")}</Trans></div> : ""}
|
||||
|
||||
{showTaskWarning ?
|
||||
<div className="task-warning"><i className="fa fa-support"></i> <span dangerouslySetInnerHTML={{__html: this.state.friendlyTaskError}} /></div> : ""}
|
||||
|
||||
{showExitedWithCodeOneHints ?
|
||||
<div className="task-warning"><i className="fa fa-info-circle"></i> <div className="inline">
|
||||
<Trans params={{link: `<a href="https://docs.opendronemap.org" target="_blank">docs.opendronemap.org</a>` }}>{_("\"Process exited with code 1\" means that part of the processing failed. Sometimes it's a problem with the dataset, sometimes it can be solved by tweaking the Task Options. Check the documentation at %(link)")}</Trans>
|
||||
<Trans params={{link: `<a href="${window.__taskOptionsDocsLink}" target="_blank">${window.__taskOptionsDocsLink.replace("https://", "")}</a>` }}>{_("\"Process exited with code 1\" means that part of the processing failed. Sometimes it's a problem with the dataset, sometimes it can be solved by tweaking the Task Options. Check the documentation at %(link)s")}</Trans>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { systems, getUnitSystem, setUnitSystem } from '../classes/Units';
|
||||
import '../css/UnitSelector.scss';
|
||||
|
||||
class UnitSelector extends React.Component {
|
||||
static propTypes = {
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
system: getUnitSystem()
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = e => {
|
||||
this.setState({system: e.target.value});
|
||||
setUnitSystem(e.target.value);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<select className="unit-selector" value={this.state.system} onChange={this.handleChange}>
|
||||
{Object.keys(systems).map(k =>
|
||||
<option value={k} key={k}>{systems[k].getName()}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UnitSelector;
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import UnitSelector from '../UnitSelector';
|
||||
|
||||
describe('<UnitSelector />', () => {
|
||||
it('renders without exploding', () => {
|
||||
const wrapper = shallow(<UnitSelector />);
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
})
|
||||
});
|
|
@ -0,0 +1,122 @@
|
|||
import { systems, toMetric } from '../../classes/Units';
|
||||
|
||||
describe('Metric system', () => {
|
||||
it('it should display units properly', () => {
|
||||
|
||||
const { metric } = systems;
|
||||
|
||||
const lengths = [
|
||||
[1, "1 m"],
|
||||
[0.01, "1 cm"],
|
||||
[0.0154, "1.5 cm"],
|
||||
[0.99, "99 cm"],
|
||||
[0.995555, "99.6 cm"],
|
||||
[1.01, "1.01 m"],
|
||||
[999, "999 m"],
|
||||
[1000, "1 km"],
|
||||
[1001, "1.001 km"],
|
||||
[1000010, "1,000.01 km"],
|
||||
[1000012.349, "1,000.01235 km"],
|
||||
];
|
||||
|
||||
lengths.forEach(l => {
|
||||
expect(metric.length(l[0]).toString()).toBe(l[1]);
|
||||
});
|
||||
|
||||
const areas = [
|
||||
[1, "1 m²"],
|
||||
[9999, "9,999 m²"],
|
||||
[10000, "1 ha"],
|
||||
[11005, "1.1005 ha"],
|
||||
[11005, "1.1005 ha"],
|
||||
[999999, "99.9999 ha"],
|
||||
[1000000, "1 km²"],
|
||||
[1000000000, "1,000 km²"],
|
||||
[1000255558, "1,000.25556 km²"]
|
||||
];
|
||||
|
||||
areas.forEach(a => {
|
||||
expect(metric.area(a[0]).toString()).toBe(a[1]);
|
||||
});
|
||||
|
||||
const volumes = [
|
||||
[1, "1 m³"],
|
||||
[9000, "9,000 m³"],
|
||||
[9000.25559, "9,000.2556 m³"],
|
||||
];
|
||||
|
||||
volumes.forEach(v => {
|
||||
expect(metric.volume(v[0]).toString()).toBe(v[1]);
|
||||
});
|
||||
|
||||
expect(metric.area(11005.09, { fixedUnit: true }).toString({precision: 1})).toBe("11,005.1 m²");
|
||||
})
|
||||
});
|
||||
|
||||
describe('Imperial systems', () => {
|
||||
it('it should display units properly', () => {
|
||||
|
||||
const { imperial, imperialUS } = systems;
|
||||
|
||||
const lengths = [
|
||||
[1, "3.2808 ft", "3.2808 ft (US)"],
|
||||
[0.01, "0.0328 ft", "0.0328 ft (US)"],
|
||||
[0.0154, "0.0505 ft", "0.0505 ft (US)"],
|
||||
[1609, "5,278.8714 ft", "5,278.8608 ft (US)"],
|
||||
[1609.344, "1 mi", "5,279.9894 ft (US)"],
|
||||
[1609.3472187, "1 mi", "1 mi (US)"],
|
||||
[3218.69, "2 mi", "2 mi (US)"]
|
||||
];
|
||||
|
||||
lengths.forEach(l => {
|
||||
expect(imperial.length(l[0]).toString()).toBe(l[1]);
|
||||
expect(imperialUS.length(l[0]).toString()).toBe(l[2]);
|
||||
});
|
||||
|
||||
const areas = [
|
||||
[1, "10.76 ft²", "10.76 ft² (US)"],
|
||||
[9999, "2.47081 ac", "2.4708 ac (US)"],
|
||||
[4046.86, "1 ac", "43,559.86 ft² (US)"],
|
||||
[4046.87261, "1 ac", "1 ac (US)"],
|
||||
[2587398.1, "639.35999 ac", "639.35744 ac (US)"],
|
||||
[2.59e+6, "1 mi²", "1 mi² (US)"]
|
||||
];
|
||||
|
||||
areas.forEach(a => {
|
||||
expect(imperial.area(a[0]).toString()).toBe(a[1]);
|
||||
expect(imperialUS.area(a[0]).toString()).toBe(a[2]);
|
||||
});
|
||||
|
||||
const volumes = [
|
||||
[1, "1.308 yd³", "1.3079 yd³ (US)"],
|
||||
[1000, "1,307.9506 yd³", "1,307.9428 yd³ (US)"]
|
||||
];
|
||||
|
||||
volumes.forEach(v => {
|
||||
expect(imperial.volume(v[0]).toString()).toBe(v[1]);
|
||||
expect(imperialUS.volume(v[0]).toString()).toBe(v[2]);
|
||||
});
|
||||
|
||||
expect(imperial.area(9999, { fixedUnit: true }).toString({precision: 1})).toBe("107,628.3 ft²");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metric conversion', () => {
|
||||
it('it should convert units properly', () => {
|
||||
const { metric, imperial } = systems;
|
||||
|
||||
const km = metric.length(2000);
|
||||
const mi = imperial.length(3220);
|
||||
|
||||
expect(km.unit.abbr).toBe("km");
|
||||
expect(km.value).toBe(2);
|
||||
expect(mi.unit.abbr).toBe("mi");
|
||||
expect(Math.round(mi.value)).toBe(2)
|
||||
|
||||
expect(toMetric(km).toString()).toBe("2,000 m");
|
||||
expect(toMetric(mi).toString()).toBe("3,220 m");
|
||||
|
||||
expect(toMetric(km).value).toBe(2000);
|
||||
expect(toMetric(mi).value).toBe(3220);
|
||||
});
|
||||
});
|
|
@ -11,6 +11,11 @@
|
|||
margin-left: -100px;
|
||||
z-index: 999;
|
||||
padding-bottom: 6px;
|
||||
.opacity-slider-label{
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-layers-toggle, .leaflet-control-layers-toggle{
|
||||
|
|
|
@ -29,4 +29,8 @@
|
|||
padding: 2px 4px 2px 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.help-button:hover{
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.unit-selector{
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
}
|
|
@ -8,6 +8,16 @@ import { setLocale } from './translations/functions';
|
|||
|
||||
// Main is always executed first in the page
|
||||
|
||||
// Silence annoying React deprecation notice of useful functionality
|
||||
const originalError = console.error;
|
||||
console.error = function(...args) {
|
||||
let message = args[0];
|
||||
if (typeof message === 'string' && message.indexOf('Warning: A future version of React will block javascript:') !== -1) {
|
||||
return;
|
||||
}
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
// We share some objects to avoid having to include them
|
||||
// as a dependency in each component (adds too much space overhead)
|
||||
window.ReactDOM = ReactDOM;
|
||||
|
|
|
@ -1,94 +1,93 @@
|
|||
// Auto-generated with extract_odm_strings.py, do not edit!
|
||||
|
||||
_("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");
|
||||
_("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");
|
||||
_("Path to the image geolocation file containing the camera center coordinates used for georeferencing. If you don't have values for yaw/pitch/roll you can set them to 0. The file needs to use the following format: EPSG:<code> or <+proj definition>image_name geo_x geo_y geo_z [yaw (degrees)] [pitch (degrees)] [roll (degrees)] [horz accuracy (meters)] [vert accuracy (meters)]Default: %(default)s");
|
||||
_("Octree depth used in the mesh reconstruction, increase to get more vertices, recommended values are 8-12. Default: %(default)s");
|
||||
_("Maximum number of frames to extract from video files for processing. Set to 0 for no limit. 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");
|
||||
_("Skip generation of the orthophoto. This can save time if you only need 3D results or DEMs. Default: %(default)s");
|
||||
_("Simple Morphological Filter slope parameter (rise over run). 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");
|
||||
_("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 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");
|
||||
_("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");
|
||||
_("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 feature extraction quality. Higher quality generates better features, but requires more memory and takes longer. Can be one of: %(choices)s. Default: %(default)s");
|
||||
_("Name of dataset (i.e subfolder name within project folder). 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");
|
||||
_("End processing at this stage. Can be one of: %(choices)s. 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");
|
||||
_("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");
|
||||
_("The maximum output resolution of extracted video frames in pixels. 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");
|
||||
_("Do not attempt to merge partial reconstructions. This can happen when images do not have sufficient overlap or are isolated. 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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("Simple Morphological Filter elevation threshold parameter (meters). 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, 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");
|
||||
_("Build orthophoto overviews for faster display in programs such as QGIS. Default: %(default)s");
|
||||
_("Classify the point cloud outputs. You can control the behavior of this option by tweaking the --dem-* parameters. 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");
|
||||
_("Skip generation of the orthophoto. This can save time if you only need 3D results or DEMs. Default: %(default)s");
|
||||
_("Automatically compute image masks using AI to remove the sky. Experimental. Default: %(default)s");
|
||||
_("Generate static tiles for orthophotos and DEMs that are suitable for viewers like Leaflet or OpenLayers. Default: %(default)s");
|
||||
_("Path to the project folder. Your project folder should contain subfolders for each dataset. Each dataset should have an \"images\" folder.");
|
||||
_("Rerun processing from this stage. Can be one of: %(choices)s. 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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("Set this parameter if you want a striped GeoTIFF. 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");
|
||||
_("Keep faces in the mesh that are not seen in any camera. Default: %(default)s");
|
||||
_("Create Cloud-Optimized GeoTIFFs instead of normal GeoTIFFs. Default: %(default)s");
|
||||
_("Simple Morphological Filter slope parameter (rise over run). 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");
|
||||
_("Set the compression to use for orthophotos. 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");
|
||||
_("URL to a ClusterODM instance for distributing a split-merge workflow on multiple nodes in parallel. Default: %(default)s");
|
||||
_("Rerun this stage only and stop. Can be one of: %(choices)s. Default: %(default)s");
|
||||
_("Save the georeferenced point cloud in Cloud Optimized Point Cloud (COPC) format. 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");
|
||||
_("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");
|
||||
_("Generate single file Binary glTF (GLB) textured models. 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");
|
||||
_("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");
|
||||
_("Do not use GPU acceleration, even if it's available. Default: %(default)s");
|
||||
_("Permanently delete all previous results and rerun the processing pipeline.");
|
||||
_("Displays version number and exits. ");
|
||||
_("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 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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("Skip normalization of colors across all images. Useful when processing radiometric data. 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");
|
||||
_("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");
|
||||
_("Export the georeferenced point cloud in CSV format. Default: %(default)s");
|
||||
_("Generate static tiles for orthophotos and DEMs that are suitable for viewers like Leaflet or OpenLayers. Default: %(default)s");
|
||||
_("Displays version number and exits. ");
|
||||
_("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");
|
||||
_("Skip the blending of colors near seams. 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");
|
||||
_("Rerun this stage only and stop. Can be one of: %(choices)s. Default: %(default)s");
|
||||
_("Set the compression to use for orthophotos. Can be one of: %(choices)s. Default: %(default)s");
|
||||
_("Copy output results to this folder after processing.");
|
||||
_("The maximum vertex count of the output mesh. 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");
|
||||
_("Path to the project folder. Your project folder should contain subfolders for each dataset. Each dataset should have an \"images\" folder.");
|
||||
_("Choose the algorithm for extracting keypoints and computing descriptors. 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");
|
||||
_("Automatically compute image masks using AI to remove the sky. Experimental. Default: %(default)s");
|
||||
_("show this help message and exit");
|
||||
_("Rerun processing from this stage. Can be one of: %(choices)s. 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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("Path to a GeoTIFF DEM or a LAS/LAZ point cloud that the reconstruction outputs should be automatically aligned to. Experimental. 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");
|
||||
_("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");
|
||||
_("Skip alignment of submodels in split-merge. Useful if GPS is good enough on very large datasets. 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");
|
||||
_("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");
|
||||
_("Simple Morphological Filter elevation scalar parameter. Default: %(default)s");
|
||||
_("Export the georeferenced point cloud in Entwine Point Tile (EPT) format. Default: %(default)s");
|
||||
_("Export the georeferenced point cloud in LAS format. Default: %(default)s");
|
||||
_("Build orthophoto overviews for faster display in programs such as QGIS. 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");
|
||||
_("Skip generation of PDF report. This can save time if you don't need a report. Default: %(default)s");
|
||||
_("Classify the point cloud outputs. You can control the behavior of this option by tweaking the --dem-* parameters. Default: %(default)s");
|
||||
_("Orthophoto resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate.Default: %(default)s");
|
||||
_("Simple Morphological Filter window radius parameter (meters). 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");
|
||||
_("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");
|
||||
_("Use this tag if you have a GCP File but want to use the EXIF information for georeferencing instead. 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");
|
||||
_("Path to the file containing the ground control points used for georeferencing. The file needs to use the following format: EPSG:<code> or <+proj definition>geo_x geo_y geo_z im_x im_y image_name [gcp_name] [extra1] [extra2]Default: %(default)s");
|
||||
_("Set this parameter if you want to generate a PNG rendering of the orthophoto. Default: %(default)s");
|
||||
_("Automatically compute image masks using AI to remove the background. Experimental. Default: %(default)s");
|
||||
_("Generate OBJs that have a single material and a single texture file instead of multiple ones. 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");
|
||||
_("Name of dataset (i.e subfolder name within project folder). 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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("Path to the image geolocation file containing the camera center coordinates used for georeferencing. If you don't have values for yaw/pitch/roll you can set them to 0. The file needs to use the following format: EPSG:<code> or <+proj definition>image_name geo_x geo_y geo_z [yaw (degrees)] [pitch (degrees)] [roll (degrees)] [horz accuracy (meters)] [vert accuracy (meters)]Default: %(default)s");
|
||||
_("Do not use GPU acceleration, even if it's available. 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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("Octree depth used in the mesh reconstruction, increase to get more vertices, recommended values are 8-12. Default: %(default)s");
|
||||
_("Generate OGC 3D Tiles outputs. Default: %(default)s");
|
||||
_("Save the georeferenced point cloud in Cloud Optimized Point Cloud (COPC) format. 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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("Create Cloud-Optimized GeoTIFFs instead of normal GeoTIFFs. 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");
|
||||
_("Export the georeferenced point cloud in Entwine Point Tile (EPT) format. 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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("Skip normalization of colors across all images. Useful when processing radiometric data. Default: %(default)s");
|
||||
_("Simple Morphological Filter window radius parameter (meters). 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");
|
||||
_("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");
|
||||
_("The maximum vertex count of the output mesh. Default: %(default)s");
|
||||
_("Permanently delete all previous results and rerun the processing pipeline.");
|
||||
_("Export the georeferenced point cloud in CSV format. Default: %(default)s");
|
||||
_("Simple Morphological Filter elevation threshold parameter (meters). Default: %(default)s");
|
||||
_("Maximum number of frames to extract from video files for processing. Set to 0 for no limit. 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");
|
||||
_("Set this parameter if you want to generate a Google Earth (KMZ) rendering of the orthophoto. 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");
|
||||
_("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");
|
||||
_("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");
|
||||
_("Choose the algorithm for extracting keypoints and computing descriptors. Can be one of: %(choices)s. 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");
|
||||
_("Automatically compute image masks using AI to remove the background. Experimental. 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");
|
||||
_("Simple Morphological Filter elevation scalar parameter. 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");
|
||||
_("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");
|
||||
_("Skip alignment of submodels in split-merge. Useful if GPS is good enough on very large datasets. Default: %(default)s");
|
||||
_("Path to the file containing the ground control points used for georeferencing. The file needs to use the following format: EPSG:<code> or <+proj definition>geo_x geo_y geo_z im_x im_y image_name [gcp_name] [extra1] [extra2]Default: %(default)s");
|
||||
_("DSM/DTM resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate. Default: %(default)s");
|
||||
_("End processing at this stage. Can be one of: %(choices)s. Default: %(default)s");
|
||||
_("Set this parameter if you want a striped GeoTIFF. Default: %(default)s");
|
||||
_("Set this parameter if you want to generate a PNG rendering of the orthophoto. Default: %(default)s");
|
||||
_("Generate OBJs that have a single material and a single texture file instead of multiple ones. 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");
|
||||
_("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");
|
||||
_("show this help message and exit");
|
||||
_("Use this tag if you have a GCP File but want to use the EXIF information for georeferencing instead. Default: %(default)s");
|
||||
_("The maximum output resolution of extracted video frames in pixels. Default: %(default)s");
|
||||
_("Orthophoto resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate.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");
|
||||
_("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");
|
||||
_("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");
|
||||
|
|
|
@ -45,6 +45,7 @@ _("Navigation cube");
|
|||
_("Remove all clipping volumes");
|
||||
_("Compass");
|
||||
_("Camera Animation");
|
||||
_("Remove last camera animation");
|
||||
_("Point budget");
|
||||
_("Point size");
|
||||
_("Minimum size");
|
||||
|
|
|
@ -2199,6 +2199,8 @@ var Dropzone = function (_Emitter) {
|
|||
}, {
|
||||
key: "cancelUpload",
|
||||
value: function cancelUpload(file) {
|
||||
if (file === undefined) return;
|
||||
|
||||
if (file.status === Dropzone.UPLOADING) {
|
||||
var groupedFiles = this._getFilesWithXhr(file.xhr);
|
||||
for (var _iterator19 = groupedFiles, _isArray19 = true, _i20 = 0, _iterator19 = _isArray19 ? _iterator19 : _iterator19[Symbol.iterator]();;) {
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
.leaflet-container .leaflet-control-mouseposition {
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 0 5px #bbb;
|
||||
padding: 0 5px;
|
||||
margin:0;
|
||||
color: #333;
|
||||
font: 11px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
L.Control.MousePosition = L.Control.extend({
|
||||
options: {
|
||||
position: 'bottomleft',
|
||||
separator: ' : ',
|
||||
emptyString: 'Unavailable',
|
||||
lngFirst: false,
|
||||
numDigits: 5,
|
||||
lngFormatter: undefined,
|
||||
latFormatter: undefined,
|
||||
prefix: ""
|
||||
},
|
||||
|
||||
onAdd: function (map) {
|
||||
this._container = L.DomUtil.create('div', 'leaflet-control-mouseposition');
|
||||
L.DomEvent.disableClickPropagation(this._container);
|
||||
map.on('mousemove', this._onMouseMove, this);
|
||||
this._container.innerHTML=this.options.emptyString;
|
||||
return this._container;
|
||||
},
|
||||
|
||||
onRemove: function (map) {
|
||||
map.off('mousemove', this._onMouseMove)
|
||||
},
|
||||
|
||||
_onMouseMove: function (e) {
|
||||
var lng = this.options.lngFormatter ? this.options.lngFormatter(e.latlng.lng) : L.Util.formatNum(e.latlng.lng, this.options.numDigits);
|
||||
var lat = this.options.latFormatter ? this.options.latFormatter(e.latlng.lat) : L.Util.formatNum(e.latlng.lat, this.options.numDigits);
|
||||
var value = this.options.lngFirst ? lng + this.options.separator + lat : lat + this.options.separator + lng;
|
||||
var prefixAndValue = this.options.prefix + ' ' + value;
|
||||
this._container.innerHTML = prefixAndValue;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
L.Map.mergeOptions({
|
||||
positionControl: false
|
||||
});
|
||||
|
||||
L.Map.addInitHook(function () {
|
||||
if (this.options.positionControl) {
|
||||
this.positionControl = new L.Control.MousePosition();
|
||||
this.addControl(this.positionControl);
|
||||
}
|
||||
});
|
||||
|
||||
L.control.mousePosition = function (options) {
|
||||
return new L.Control.MousePosition(options);
|
||||
};
|
|
@ -54099,14 +54099,16 @@
|
|||
getArea () {
|
||||
let area = 0;
|
||||
let j = this.points.length - 1;
|
||||
|
||||
for (let i = 0; i < this.points.length; i++) {
|
||||
let p0 = this.points[0].position;
|
||||
let p1 = this.points[i].position;
|
||||
let p2 = this.points[j].position;
|
||||
area += (p2.x + p1.x) * (p1.y - p2.y);
|
||||
let a = (p2.y - p0.y) * (p1.z - p0.z) - (p2.z - p0.z) * (p1.y - p0.y);
|
||||
let b = (p2.x - p0.x) * (p1.z - p0.z) - (p2.z - p0.z) * (p1.x - p0.x);
|
||||
let c = (p2.x - p0.x) * (p1.y - p0.y) - (p2.y - p0.y) * (p1.x - p0.x);
|
||||
area += Math.sqrt(a * a + b * b + c * c);
|
||||
j = i;
|
||||
}
|
||||
|
||||
return Math.abs(area / 2);
|
||||
};
|
||||
|
||||
|
@ -67901,13 +67903,13 @@ void main() {
|
|||
this.viewer.scene.removePolygonClipVolume(polyClipVol);
|
||||
}
|
||||
|
||||
this.viewer.renderer.domElement.removeEventListener("mouseup", insertionCallback, true);
|
||||
this.viewer.renderer.domElement.removeEventListener("mouseup", insertionCallback, false);
|
||||
this.viewer.removeEventListener("cancel_insertions", cancel.callback);
|
||||
this.viewer.inputHandler.enabled = true;
|
||||
};
|
||||
|
||||
this.viewer.addEventListener("cancel_insertions", cancel.callback);
|
||||
this.viewer.renderer.domElement.addEventListener("mouseup", insertionCallback , true);
|
||||
this.viewer.renderer.domElement.addEventListener("mouseup", insertionCallback , false);
|
||||
this.viewer.inputHandler.enabled = false;
|
||||
|
||||
polyClipVol.addMarker();
|
||||
|
@ -71452,7 +71454,7 @@ void main() {
|
|||
};
|
||||
|
||||
removeCameraAnimation(animation){
|
||||
let index = this.cameraAnimations.indexOf(volume);
|
||||
let index = this.cameraAnimations.indexOf(animation);
|
||||
if (index > -1) {
|
||||
this.cameraAnimations.splice(index, 1);
|
||||
|
||||
|
@ -75384,6 +75386,7 @@ ENDSEC
|
|||
<span>Time: </span><span id="lblTime"></span> <div id="sldTime"></div>
|
||||
|
||||
<input name="play" type="button" value="play"/>
|
||||
<input name="record" type="button" value="record movie"/>
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
|
@ -75393,6 +75396,52 @@ ENDSEC
|
|||
animation.play();
|
||||
});
|
||||
|
||||
function record(canvas, time) {
|
||||
var recordedChunks = [];
|
||||
return new Promise(function (res, rej) {
|
||||
var stream = canvas.captureStream(29.97 /*fps*/);
|
||||
let mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: "video/webm; codecs=vp8"
|
||||
});
|
||||
|
||||
//ondataavailable will fire in interval of `time || 4000 ms`
|
||||
mediaRecorder.start(time || 4000);
|
||||
|
||||
mediaRecorder.ondataavailable = function (event) {
|
||||
recordedChunks.push(event.data);
|
||||
// after stop `dataavilable` event run one more time
|
||||
if (mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = function (event) {
|
||||
var blob = new Blob(recordedChunks, {type: "video/webm" });
|
||||
var url = URL.createObjectURL(blob);
|
||||
res(url);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const elRecord = this.elContent.find("input[name=record]");
|
||||
elRecord.click( () => {
|
||||
const t = parseFloat(elDuration.val()) * 1000 + 1000;
|
||||
this.viewer.toggleSidebar();
|
||||
animation.setVisible(false);
|
||||
setTimeout(() => {
|
||||
animation.play();
|
||||
record(this.viewer.renderer.domElement, t).then(url => {
|
||||
let link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', 'recording.webm');
|
||||
link.click();
|
||||
this.viewer.toggleSidebar();
|
||||
animation.setVisible(true);
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
const elSlider = this.elContent.find('#sldTime');
|
||||
elSlider.slider({
|
||||
value: 0,
|
||||
|
@ -79720,10 +79769,18 @@ ENDSEC
|
|||
tree.jstree("delete_node", jsonNode.id);
|
||||
};
|
||||
|
||||
let oCameraAnimationRemoved = (e) => {
|
||||
let otherRoot = $("#jstree_scene").jstree().get_json("other");
|
||||
let jsonNode = otherRoot.children.find(child => child.data.uuid === e.animation.uuid);
|
||||
|
||||
tree.jstree("delete_node", jsonNode.id);
|
||||
};
|
||||
|
||||
this.viewer.scene.addEventListener("measurement_removed", onMeasurementRemoved);
|
||||
this.viewer.scene.addEventListener("volume_removed", onVolumeRemoved);
|
||||
this.viewer.scene.addEventListener("polygon_clip_volume_removed", onPolygonClipVolumeRemoved);
|
||||
this.viewer.scene.addEventListener("profile_removed", onProfileRemoved);
|
||||
this.viewer.scene.addEventListener("camera_animation_removed", oCameraAnimationRemoved);
|
||||
|
||||
{
|
||||
let annotationIcon = `${Potree.resourcePath}/icons/annotation.svg`;
|
||||
|
@ -80445,6 +80502,17 @@ ENDSEC
|
|||
}
|
||||
));
|
||||
|
||||
elNavigation.append(this.createToolIcon(
|
||||
Potree.resourcePath + '/icons/reset_tools.svg',
|
||||
'[title]tt.remove_last_camera_animation',
|
||||
() => {
|
||||
if (viewer.scene.cameraAnimations.length > 0){
|
||||
let a = viewer.scene.cameraAnimations[viewer.scene.cameraAnimations.length - 1];
|
||||
viewer.scene.removeCameraAnimation(a);
|
||||
a.setVisible(false);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
elNavigation.append("<br>");
|
||||
|
||||
|
|
|
@ -46,7 +46,8 @@
|
|||
"navigation_cube_control": "Navigation cube",
|
||||
"remove_all_clipping_volumes": "Remove all clipping volumes",
|
||||
"compass": "Compass",
|
||||
"camera_animation": "Camera Animation"
|
||||
"camera_animation": "Camera Animation",
|
||||
"remove_last_camera_animation": "Remove last camera animation"
|
||||
},
|
||||
"appearance": {
|
||||
"nb_max_pts": "Point budget",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% load i18n static settings compress plugins %}
|
||||
{% load i18n cache static settings plugins %}
|
||||
<!--
|
||||
WebODM - User-friendly, commercial grade software for processing aerial imagery.
|
||||
Copyright (C) 2020 WebODM Authors
|
||||
|
@ -51,9 +51,11 @@
|
|||
|
||||
<title>{{title|default:"Login"}} - {{ SETTINGS.app_name }}</title>
|
||||
|
||||
{% compress css inline %}
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static 'app/css/theme.scss' %}" />
|
||||
{% endcompress %}
|
||||
{% cache 3600 theme_css %}
|
||||
<style type="text/css">
|
||||
{% include "theme.css" %}
|
||||
</style>
|
||||
{% endcache %}
|
||||
|
||||
{% is_desktop_mode as desktop_mode %}
|
||||
{% if desktop_mode %}
|
||||
|
@ -115,6 +117,9 @@
|
|||
</body>
|
||||
<script src="{% static 'app/js/vendor/metisMenu.min.js' %}"></script>
|
||||
<script>
|
||||
{% task_options_docs_link as to_link %}
|
||||
window.__taskOptionsDocsLink = "{{ to_link|safe }}";
|
||||
|
||||
$(function(){
|
||||
$('#side-menu').metisMenu();
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
<li>{% trans 'You need at least 5 images, but 16-32 is typically the minimum.' %}</li>
|
||||
<li>{% trans 'Images must overlap by 65% or more. Aim for 70-72%' %}</li>
|
||||
<li>{% trans 'For great 3D, images must overlap by 83%' %}</li>
|
||||
<li>{% blocktrans with link_start='<a href="https://github.com/OpenDroneMap/OpenDroneMap/wiki/Running-OpenDroneMap#running-odm-with-ground-control" target="_blank">' link_end='</a>' %}A {{link_start}}GCP File{{link_end}} is optional, but can increase georeferencing accuracy{% endblocktrans %}</li>
|
||||
<li>{% gcp_docs_link as gcp_link %}{% blocktrans with link_start=gcp_link|safe link_end='</a>' %}A {{link_start}}GCP File{{link_end}} is optional, but can increase georeferencing accuracy{% endblocktrans %}</li>
|
||||
</ul>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
|
|
@ -20,6 +20,12 @@
|
|||
{% for field in form %}
|
||||
{% include 'registration/form_field.html' %}
|
||||
{% endfor %}
|
||||
<input type="hidden" name="next" value="" id="loginNext" />
|
||||
<script>
|
||||
var loginNext = document.getElementById("loginNext");
|
||||
var value = new URLSearchParams(new URL(window.location.href).search).get('next');
|
||||
if (value) loginNext.value = value;
|
||||
</script>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-default">{% trans 'Log in' %}</button>
|
||||
|
|
|
@ -0,0 +1,319 @@
|
|||
{% load settings %}
|
||||
{% theme "primary" as theme_primary %}
|
||||
{% theme "secondary" as theme_secondary %}
|
||||
{% theme "tertiary" as theme_tertiary %}
|
||||
{% theme "button_primary" as theme_button_primary %}
|
||||
{% theme "button_default" as theme_button_default %}
|
||||
{% theme "button_danger" as theme_button_danger %}
|
||||
{% theme "header_background" as theme_header_background %}
|
||||
{% theme "header_primary" as theme_header_primary %}
|
||||
{% theme "border" as theme_border %}
|
||||
{% theme "highlight" as theme_highlight %}
|
||||
{% theme "dialog_warning" as theme_dialog_warning %}
|
||||
{% theme "success" as theme_success %}
|
||||
{% theme "failed" as theme_failed %}
|
||||
|
||||
/* Primary */
|
||||
body,
|
||||
ul#side-menu.nav a,
|
||||
.console,
|
||||
.alert,
|
||||
.form-control,
|
||||
.dropdown-menu > li > a,
|
||||
.theme-color-primary
|
||||
{
|
||||
color: {{ theme_primary }};
|
||||
}
|
||||
.theme-border-primary{
|
||||
border-color: {{ theme_primary }};
|
||||
}
|
||||
.tooltip .tooltip-inner{
|
||||
background-color: {{ theme_primary }};
|
||||
}
|
||||
.tooltip.left .tooltip-arrow{ border-left-color: {{ theme_primary }}; }
|
||||
.tooltip.top .tooltip-arrow{ border-top-color: {{ theme_primary }}; }
|
||||
.tooltip.bottom .tooltip-arrow{ border-bottom-color: {{ theme_primary }}; }
|
||||
.tooltip.right .tooltip-arrow{ border-right-color: {{ theme_primary }}; }
|
||||
|
||||
.theme-fill-primary{
|
||||
fill: {{ theme_primary }};
|
||||
}
|
||||
.theme-stroke-primary{
|
||||
stroke: {{ theme_primary }};
|
||||
}
|
||||
|
||||
/* Secondary */
|
||||
body,
|
||||
.navbar-default,
|
||||
.console,
|
||||
.alert,
|
||||
.modal-content,
|
||||
.form-control,
|
||||
.dropdown-menu,
|
||||
.theme-secondary
|
||||
{
|
||||
background-color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
.tooltip > .tooltip-inner{
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
.alert.close:hover{
|
||||
color: {% complementary theme_secondary %};
|
||||
}
|
||||
.alert.close:focus{
|
||||
color: {% complementary theme_secondary %};
|
||||
}
|
||||
|
||||
.pagination li > a,
|
||||
.pagination .disabled > a,
|
||||
.pagination .disabled > a:hover, .pagination .disabled > a:focus{
|
||||
color: {% scaleby theme_primary 0.7 %};
|
||||
background-color: {{ theme_secondary }};
|
||||
border-color: {% scaleby theme_secondary 0.7 %};
|
||||
}
|
||||
.pagination li > a{
|
||||
color: {{ theme_primary }};
|
||||
}
|
||||
.theme-border-secondary-07{
|
||||
border-color: {% scaleby theme_secondary 0.7 %} !important;
|
||||
}
|
||||
|
||||
.btn-secondary, .btn-secondary:active, .open>.dropdown-toggle.btn-secondary{
|
||||
background-color: {{ theme_secondary }};
|
||||
border-color: {{ theme_secondary }};
|
||||
color: {{ theme_primary }};
|
||||
}
|
||||
|
||||
.btn-secondary:hover, .open>.dropdown-toggle.btn-secondary:hover,
|
||||
.btn-secondary:active, .open>.dropdown-toggle.btn-secondary:active,
|
||||
.btn-secondary:focus, .open>.dropdown-toggle.btn-secondary:focus{
|
||||
background-color: {% scalebyiv theme_secondary 0.90 %};
|
||||
border-color: {% scalebyiv theme_secondary 0.90 %};
|
||||
color: {{ theme_primary }};
|
||||
}
|
||||
|
||||
|
||||
/* Tertiary */
|
||||
a, a:hover, a:focus{
|
||||
color: {{ theme_tertiary }};
|
||||
}
|
||||
.progress-bar-success{
|
||||
background-color: {{ theme_tertiary }};
|
||||
}
|
||||
|
||||
/* Button primary */
|
||||
#navbar-top .navbar-top-links a:hover,
|
||||
#navbar-top .navbar-top-links a:focus,
|
||||
#navbar-top .navbar-top-links .open > a{
|
||||
background-color: {{ theme_button_primary }};
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
#navbar-top ul#side-menu a:focus{
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#navbar-top ul#side-menu a:hover, #navbar-top ul#side-menu a.active:hover{
|
||||
background-color: {{ theme_button_primary }};
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
.btn-primary, .btn-primary:active, .btn-primary.active, .open>.dropdown-toggle.btn-primary{
|
||||
background-color: {{ theme_button_primary }};
|
||||
border-color: {{ theme_button_primary }};
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
.btn-primary:hover, .btn-primary.active:hover, .open>.dropdown-toggle.btn-primary:hover,
|
||||
.btn-primary:active, .btn-primary.active:active, .open>.dropdown-toggle.btn-primary:active,
|
||||
.btn-primary:focus, .btn-primary.active:focus, .open>.dropdown-toggle.btn-primary:focus,
|
||||
.btn-primary[disabled]:hover, .btn-primary.active[disabled]:hover, .open>.dropdown-toggle.btn-primary[disabled]:hover,
|
||||
.btn-primary[disabled]:focus, .btn-primary.active[disabled]:focus, .open>.dropdown-toggle.btn-primary[disabled]:focus,
|
||||
.btn-primary[disabled]:active, .btn-primary.active[disabled]:active, .open>.dropdown-toggle.btn-primary[disabled]:active{
|
||||
background-color: {% scalebyiv theme_button_primary 0.90 %};
|
||||
border-color: {% scalebyiv theme_button_primary 0.90 %};
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
/* Button default */
|
||||
.btn-default, .btn-default:active, .open>.dropdown-toggle.btn-default{
|
||||
background-color: {{ theme_button_default }};
|
||||
border-color: {{ theme_button_default }};
|
||||
color: {{ theme_secondary }};
|
||||
|
||||
}
|
||||
.btn-default:hover, .open>.dropdown-toggle.btn-default:hover,
|
||||
.btn-default:active, .open>.dropdown-toggle.btn-default:active,
|
||||
.btn-default:focus, .open>.dropdown-toggle.btn-default:focus,
|
||||
.btn-default[disabled]:hover, .open>.dropdown-toggle.btn-default[disabled]:hover,
|
||||
.btn-default[disabled]:focus, .open>.dropdown-toggle.btn-default[disabled]:focus,
|
||||
.btn-default[disabled]:active, .open>.dropdown-toggle.btn-default[disabled]:active{
|
||||
background-color: {% scalebyiv theme_button_default 0.90 %};
|
||||
border-color: {% scalebyiv theme_button_default 0.90 %};
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
.pagination>.active>a, .pagination>.active>span, .pagination>.active>a:hover, .pagination>.active>span:hover, .pagination>.active>a:focus, .pagination>.active>span:focus,
|
||||
.pagination .active > a:hover, .pagination .active > a:focus,
|
||||
.pagination li > a:hover, .pagination li > a:focus{
|
||||
background-color: {{ theme_button_default }};
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
/* Button danger */
|
||||
.btn-danger, .btn-danger:active, .open>.dropdown-toggle.btn-danger{
|
||||
background-color: {{ theme_button_danger }};
|
||||
border-color: {{ theme_button_danger }};
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
.btn-danger:hover, .open>.dropdown-toggle.btn-danger:hover,
|
||||
.btn-danger:active, .open>.dropdown-toggle.btn-danger:active,
|
||||
.btn-danger:focus, .open>.dropdown-toggle.btn-danger:focus,
|
||||
.btn-danger[disabled]:hover, .open>.dropdown-toggle.btn-danger[disabled]:hover,
|
||||
.btn-danger[disabled]:active, .open>.dropdown-toggle.btn-danger[disabled]:active,
|
||||
.btn-danger[disabled]:focus, .open>.dropdown-toggle.btn-danger[disabled]:focus{
|
||||
background-color: {% scalebyiv theme_button_danger 0.90 %};
|
||||
border-color: {% scalebyiv theme_button_danger 0.90 %};
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
.theme-color-button-danger{
|
||||
color: {{ theme_button_danger }};
|
||||
}
|
||||
|
||||
.theme-color-button-primary{
|
||||
color: {{ theme_button_primary }};
|
||||
}
|
||||
|
||||
/* Header background */
|
||||
#navbar-top{
|
||||
background-color: {{ theme_header_background }};
|
||||
}
|
||||
|
||||
/* Header primary */
|
||||
.navbar-default .navbar-link,
|
||||
#navbar-top .navbar-top-links a.dropdown-toggle,
|
||||
#navbar-top .navbar-top-links a.nav-link,
|
||||
#navbar-top .navbar-text{
|
||||
color: {{ theme_header_primary }};
|
||||
}
|
||||
|
||||
.navbar-default .navbar-toggle .icon-bar{
|
||||
background-color: {{ theme_header_primary }};
|
||||
}
|
||||
.navbar-default .navbar-toggle:hover .icon-bar,
|
||||
.navbar-default .navbar-toggle:active .icon-bar,
|
||||
.navbar-default .navbar-toggle:focus .icon-bar{
|
||||
background-color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
.navbar-default .navbar-link:hover,
|
||||
#navbar-top .navbar-top-links a.dropdown-toggle:hover,
|
||||
#navbar-top .navbar-top-links a.nav-link:hover,
|
||||
#navbar-top .navbar-top-links .dropdown.open a.dropdown-toggle{
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
/* Border */
|
||||
.sidebar ul li,
|
||||
.project-list-item,
|
||||
#page-wrapper,
|
||||
table-bordered>thead>tr>th, .table-bordered>thead>tr>th, table-bordered>tbody>tr>th, .table-bordered>tbody>tr>th, table-bordered>tfoot>tr>th, .table-bordered>tfoot>tr>th, table-bordered>thead>tr>td, .table-bordered>thead>tr>td, table-bordered>tbody>tr>td, .table-bordered>tbody>tr>td, table-bordered>tfoot>tr>td, .table-bordered>tfoot>tr>td,
|
||||
footer,
|
||||
.modal-content,
|
||||
.modal-header,
|
||||
.modal-footer,
|
||||
.dropdown-menu
|
||||
{
|
||||
border-color: {{ theme_border }};
|
||||
}
|
||||
.dropdown-menu .divider{
|
||||
background-color: {{ theme_border }};
|
||||
}
|
||||
.popover-title{
|
||||
border-bottom-color: {{ theme_border }};
|
||||
}
|
||||
.theme-border{
|
||||
border-color: {{ theme_border }} !important;
|
||||
}
|
||||
|
||||
/* Highlight */
|
||||
.task-list-item:nth-child(odd),
|
||||
.table-striped>tbody>tr:nth-of-type(odd),
|
||||
select.form-control option[disabled],
|
||||
.theme-background-highlight{
|
||||
background-color: {{ theme_highlight }};
|
||||
}
|
||||
.dropdown-menu > li > a:hover,
|
||||
.dropdown-menu > li > a:focus{
|
||||
background-color: {{ theme_highlight }};
|
||||
color: {{ theme_primary }};
|
||||
}
|
||||
pre.prettyprint,
|
||||
.form-control{
|
||||
border-color: {{ theme_highlight }};
|
||||
}
|
||||
pre.prettyprint:focus,
|
||||
.form-control:focus{
|
||||
border-color: {% scalebyiv theme_highlight 0.7 %};
|
||||
}
|
||||
|
||||
/* Dialog warning */
|
||||
.alert-warning{
|
||||
border-color: {{ theme_dialog_warning }};
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.task-list-item .status-label.done, .theme-background-success{
|
||||
background-color: {{ theme_success }};
|
||||
}
|
||||
|
||||
/* Failed */
|
||||
.task-list-item .status-label.error, .theme-background-failed{
|
||||
background-color: {{ theme_failed }};
|
||||
}
|
||||
|
||||
/* ModelView.jsx specific */
|
||||
.model-view #potree_sidebar_container .dropdown-menu > li > a{
|
||||
color: {{ theme_primary }};
|
||||
}
|
||||
|
||||
/* MapView.jsx specific */
|
||||
.leaflet-bar a, .leaflet-control > a{
|
||||
background-color: {{ theme_secondary }} !important;
|
||||
border-color: {{ theme_secondary }} !important;
|
||||
color: {{ theme_primary }} !important;
|
||||
}
|
||||
|
||||
.leaflet-bar a:hover, .leaflet-control > a:hover{
|
||||
background-color: {% scalebyiv theme_secondary 0.90 %} !important;
|
||||
border-color: {% scalebyiv theme_secondary 0.90 %} !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper{
|
||||
background-color: {{ theme_secondary }} !important;
|
||||
color: {{ theme_primary }} !important;
|
||||
}
|
||||
.leaflet-popup-content-wrapper a{
|
||||
color: {{ theme_tertiary }} !important;
|
||||
}
|
||||
|
||||
.leaflet-container a.leaflet-popup-close-button{
|
||||
color: {{ theme_primary }} !important;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover{
|
||||
color: {% complementary theme_secondary %} !important;
|
||||
}
|
||||
|
||||
.tag-badge{
|
||||
background-color: {{ theme_button_default }};
|
||||
border-color: {{ theme_button_default }};
|
||||
color: {{ theme_secondary }};
|
||||
}
|
||||
|
||||
.tag-badge a, .tag-badge a:hover{
|
||||
color: {{ theme_secondary }};
|
||||
}
|
|
@ -9,6 +9,14 @@ from django.utils.translation import gettext as _
|
|||
register = template.Library()
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
||||
@register.simple_tag
|
||||
def task_options_docs_link():
|
||||
return settings.TASK_OPTIONS_DOCS_LINK
|
||||
|
||||
@register.simple_tag
|
||||
def gcp_docs_link():
|
||||
return '<a href="%s" target="_blank">' % settings.GCP_DOCS_LINK
|
||||
|
||||
@register.simple_tag
|
||||
def reset_password_link():
|
||||
return settings.RESET_PASSWORD_LINK
|
||||
|
@ -104,3 +112,80 @@ def get_footer(context):
|
|||
return "<footer>" + \
|
||||
footer + \
|
||||
"</footer>"
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def theme(context, color):
|
||||
"""Return a theme color from the currently selected theme"""
|
||||
try:
|
||||
return getattr(context['SETTINGS'].theme, color)
|
||||
except Exception as e:
|
||||
logger.warning("Cannot load configuration from theme(): " + str(e))
|
||||
return "blue" # dah buh dih ah buh daa..
|
||||
|
||||
@register.simple_tag
|
||||
def complementary(hexcolor):
|
||||
"""Returns complementary RGB color
|
||||
Example: complementaryColor('#FFFFFF') --> '#000000'
|
||||
"""
|
||||
if hexcolor[0] == '#':
|
||||
hexcolor = hexcolor[1:]
|
||||
rgb = (hexcolor[0:2], hexcolor[2:4], hexcolor[4:6])
|
||||
comp = ['%02X' % (255 - int(a, 16)) for a in rgb]
|
||||
return '#' + ''.join(comp)
|
||||
|
||||
@register.simple_tag
|
||||
def scaleby(hexcolor, scalefactor, ignore_value = False):
|
||||
"""
|
||||
Scales a hex string by ``scalefactor``, but is color dependent, unless ignore_value is True
|
||||
scalefactor is now always between 0 and 1. A value of 0.8
|
||||
will cause bright colors to become darker and
|
||||
dark colors to become brigther by 20%
|
||||
"""
|
||||
|
||||
def calculate(hexcolor, scalefactor):
|
||||
"""
|
||||
Scales a hex string by ``scalefactor``. Returns scaled hex string.
|
||||
To darken the color, use a float value between 0 and 1.
|
||||
To brighten the color, use a float value greater than 1.
|
||||
|
||||
>>> colorscale("#DF3C3C", .5)
|
||||
#6F1E1E
|
||||
>>> colorscale("#52D24F", 1.6)
|
||||
#83FF7E
|
||||
>>> colorscale("#4F75D2", 1)
|
||||
#4F75D2
|
||||
"""
|
||||
|
||||
def clamp(val, minimum=0, maximum=255):
|
||||
if val < minimum:
|
||||
return minimum
|
||||
if val > maximum:
|
||||
return maximum
|
||||
return int(val)
|
||||
|
||||
hexcolor = hexcolor.strip('#')
|
||||
|
||||
if scalefactor < 0 or len(hexcolor) != 6:
|
||||
return hexcolor
|
||||
|
||||
r, g, b = int(hexcolor[:2], 16), int(hexcolor[2:4], 16), int(hexcolor[4:], 16)
|
||||
|
||||
r = clamp(r * scalefactor)
|
||||
g = clamp(g * scalefactor)
|
||||
b = clamp(b * scalefactor)
|
||||
|
||||
return "#%02x%02x%02x" % (r, g, b)
|
||||
|
||||
|
||||
hexcolor = hexcolor.strip('#')
|
||||
scalefactor = abs(float(scalefactor))
|
||||
scalefactor = min(1.0, max(0, scalefactor))
|
||||
|
||||
r, g, b = int(hexcolor[:2], 16), int(hexcolor[2:4], 16), int(hexcolor[4:], 16)
|
||||
value = max(r, g, b)
|
||||
|
||||
return calculate(hexcolor, scalefactor if ignore_value or value >= 127 else 2 - scalefactor)
|
||||
|
||||
@register.simple_tag
|
||||
def scalebyiv(hexcolor, scalefactor):
|
||||
return scaleby(hexcolor, scalefactor, True)
|
|
@ -1,25 +0,0 @@
|
|||
#%module
|
||||
#% description: greets the user and prints the information of a spatial file
|
||||
#%end
|
||||
#%option
|
||||
#% key: test
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Geospatial test file
|
||||
#%end
|
||||
|
||||
import sys
|
||||
from grass.pygrass.modules import Module
|
||||
import grass.script as grass
|
||||
|
||||
def main():
|
||||
# Import raster and vector
|
||||
Module("v.in.ogr", input=opts['test'], layer="test", output="test", overwrite=True)
|
||||
info = grass.vector_info("test")
|
||||
print("Number of points: %s" % info['points'])
|
||||
|
||||
if __name__ == "__main__":
|
||||
opts, _ = grass.parser()
|
||||
sys.exit(main())
|
||||
|
|
@ -16,9 +16,7 @@ from app.plugins import sync_plugin_db, get_plugins_persistent_path
|
|||
from app.plugins.data_store import InvalidDataStoreValue
|
||||
from app.plugins.pyutils import parse_requirements, compute_file_md5, requirements_installed
|
||||
from .classes import BootTestCase
|
||||
from app.plugins.grass_engine import grass, GrassEngineException
|
||||
|
||||
from worker.tasks import execute_grass_script
|
||||
|
||||
class TestPlugins(BootTestCase):
|
||||
def setUp(self):
|
||||
|
@ -140,71 +138,6 @@ class TestPlugins(BootTestCase):
|
|||
self.assertEqual(test_plugin.get_current_plugin_test(), test_plugin)
|
||||
|
||||
|
||||
|
||||
def test_grass_engine(self):
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
grass_scripts_dir = os.path.join(cwd, "grass_scripts")
|
||||
|
||||
ctx = grass.create_context()
|
||||
points = """{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
13.770675659179686,
|
||||
45.655328041141374
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}"""
|
||||
ctx.add_file('test.geojson', points)
|
||||
ctx.set_location("EPSG:4326")
|
||||
|
||||
result = execute_grass_script.delay(
|
||||
os.path.join(grass_scripts_dir, "simple_test.py"),
|
||||
ctx.serialize()
|
||||
).get()
|
||||
|
||||
self.assertEqual("Number of points: 1", result.get('output'))
|
||||
|
||||
self.assertTrue(result.get('context') == ctx.serialize())
|
||||
|
||||
# Context dir has been cleaned up automatically
|
||||
self.assertFalse(os.path.exists(ctx.get_cwd()))
|
||||
|
||||
error = execute_grass_script.delay(
|
||||
os.path.join(grass_scripts_dir, "nonexistant_script.py"),
|
||||
ctx.serialize()
|
||||
).get()
|
||||
self.assertIsInstance(error, dict)
|
||||
self.assertIsInstance(error['error'], str)
|
||||
|
||||
with self.assertRaises(GrassEngineException):
|
||||
ctx.execute(os.path.join(grass_scripts_dir, "nonexistant_script.py"))
|
||||
|
||||
ctx = grass.create_context({"auto_cleanup": False})
|
||||
ctx.add_file('test.geojson', points)
|
||||
ctx.set_location("EPSG:4326")
|
||||
|
||||
result = execute_grass_script.delay(
|
||||
os.path.join(grass_scripts_dir, "simple_test.py"),
|
||||
ctx.serialize()
|
||||
).get()
|
||||
self.assertEqual("Number of points: 1", result.get('output'))
|
||||
|
||||
# Path still there
|
||||
self.assertTrue(os.path.exists(ctx.get_cwd()))
|
||||
|
||||
ctx.cleanup()
|
||||
|
||||
# Cleanup worked
|
||||
self.assertFalse(os.path.exists(ctx.get_cwd()))
|
||||
|
||||
def test_plugin_datastore(self):
|
||||
enable_plugin("test")
|
||||
test_plugin = get_plugin_by_name("test")
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
// Auto-generated with extract_plugin_manifest_strings.py, do not edit!
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
_("Detect changes between two different tasks in the same project.")
|
||||
_("Import images from external sources directly")
|
||||
_("Compute, preview and export contours from DEMs")
|
||||
_("Display program version, memory and disk space usage statistics")
|
||||
_("Integrate WebODM with DroneDB: import images and share results")
|
||||
_("Create editable short links when sharing task URLs")
|
||||
_("Calculate and draw an elevation map based on a task's DEMs")
|
||||
_("Add a fullscreen button to the 2D map view")
|
||||
_("Sync accounts from webodm.net")
|
||||
_("Process in the cloud with webodm.net")
|
||||
_("Compute volume, area and length measurements on Leaflet")
|
||||
_("A plugin to upload orthophotos to OpenAerialMap")
|
||||
_("A plugin to add a button for quickly opening OpenStreetMap's iD editor and setup a TMS basemap.")
|
||||
|
@ -18,3 +16,4 @@ _("A plugin to show charts of projects and tasks")
|
|||
_("Create short links when sharing task URLs")
|
||||
_("Get notified when a task has finished processing, has been removed or has failed")
|
||||
_("A plugin to create GCP files from images")
|
||||
_("Annotate and measure on 2D maps with ease")
|
||||
|
|
|
@ -39,7 +39,7 @@ def dashboard(request):
|
|||
|
||||
no_tasks = Task.objects.filter(project__owner=request.user).count() == 0
|
||||
no_projects = Project.objects.filter(owner=request.user).count() == 0
|
||||
|
||||
|
||||
# Create first project automatically
|
||||
if no_projects and request.user.has_perm('app.add_project'):
|
||||
Project.objects.create(owner=request.user, name=_("First Project"))
|
||||
|
|
|
@ -29,7 +29,8 @@ def handle_map(request, template, task_pk=None, hide_title=False):
|
|||
'map-items': json.dumps([task.get_map_items()]),
|
||||
'title': task.name if not hide_title else '',
|
||||
'public': 'true',
|
||||
'share-buttons': 'false' if settings.DESKTOP_MODE else 'true'
|
||||
'share-buttons': 'false' if settings.DESKTOP_MODE else 'true',
|
||||
'selected-map-type': request.GET.get('t', 'auto'),
|
||||
}.items()
|
||||
})
|
||||
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
from .plugin import *
|
|
@ -1,126 +0,0 @@
|
|||
import mimetypes
|
||||
import os
|
||||
|
||||
from django.http import FileResponse
|
||||
from django.http import HttpResponse
|
||||
from wsgiref.util import FileWrapper
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from app.plugins.views import TaskView
|
||||
from worker.tasks import execute_grass_script
|
||||
from app.plugins.grass_engine import grass, GrassEngineException, cleanup_grass_context
|
||||
from worker.celery import app as celery
|
||||
from app.plugins import get_current_plugin
|
||||
|
||||
class TaskChangeMapGenerate(TaskView):
|
||||
def post(self, request, pk=None):
|
||||
|
||||
role = request.data.get('role', 'reference')
|
||||
if role == 'reference':
|
||||
reference_pk = pk
|
||||
compare_task_pk = request.data.get('other_task', None)
|
||||
else:
|
||||
reference_pk = request.data.get('other_task', None)
|
||||
compare_task_pk = pk
|
||||
|
||||
reference_task = self.get_and_check_task(request, reference_pk)
|
||||
if compare_task_pk is None:
|
||||
return Response({'error': 'You must select a task to compare to.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
compare_task = self.get_and_check_task(request, compare_task_pk)
|
||||
|
||||
reference_pc = os.path.abspath(reference_task.get_asset_download_path("georeferenced_model.laz"))
|
||||
reference_dsm = os.path.abspath(reference_task.get_asset_download_path("dsm.tif"))
|
||||
reference_dtm = os.path.abspath(reference_task.get_asset_download_path("dtm.tif"))
|
||||
|
||||
compare_pc = os.path.abspath(compare_task.get_asset_download_path("georeferenced_model.laz"))
|
||||
compare_dsm = os.path.abspath(compare_task.get_asset_download_path("dsm.tif"))
|
||||
compare_dtm = os.path.abspath(compare_task.get_asset_download_path("dtm.tif"))
|
||||
|
||||
plugin = get_current_plugin()
|
||||
|
||||
# We store the aligned DEMs on the persistent folder, to avoid recalculating them in the future
|
||||
aligned_dsm = plugin.get_persistent_path("{}_{}_dsm.tif".format(pk, compare_task_pk))
|
||||
aligned_dtm = plugin.get_persistent_path("{}_{}_dtm.tif".format(pk, compare_task_pk))
|
||||
|
||||
try:
|
||||
context = grass.create_context({'auto_cleanup' : False, 'location': 'epsg:3857', 'python_path': plugin.get_python_packages_path()})
|
||||
format = request.data.get('format', 'GPKG')
|
||||
epsg = int(request.data.get('epsg', '3857'))
|
||||
supported_formats = ['GPKG', 'ESRI Shapefile', 'DXF', 'GeoJSON']
|
||||
if not format in supported_formats:
|
||||
raise GrassEngineException("Invalid format {} (must be one of: {})".format(format, ",".join(supported_formats)))
|
||||
min_area = float(request.data.get('min_area', 40))
|
||||
min_height = float(request.data.get('min_height', 5))
|
||||
resolution = float(request.data.get('resolution', 0.5))
|
||||
display_type = request.data.get('display_type', 'contour')
|
||||
can_align_and_rasterize = request.data.get('align', 'false')
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
context.add_param('reference_pc', reference_pc)
|
||||
context.add_param('compare_pc', compare_pc)
|
||||
context.add_param('reference_dsm', reference_dsm)
|
||||
context.add_param('reference_dtm', reference_dtm)
|
||||
context.add_param('compare_dsm', compare_dsm)
|
||||
context.add_param('compare_dtm', compare_dtm)
|
||||
context.add_param('aligned_dsm', aligned_dsm)
|
||||
context.add_param('aligned_dtm', aligned_dtm)
|
||||
context.add_param('format', format)
|
||||
context.add_param('epsg', epsg)
|
||||
context.add_param('display_type', display_type)
|
||||
context.add_param('resolution', resolution)
|
||||
context.add_param('min_area', min_area)
|
||||
context.add_param('min_height', min_height)
|
||||
context.add_param('can_align_and_rasterize', can_align_and_rasterize)
|
||||
|
||||
celery_task_id = execute_grass_script.delay(os.path.join(current_dir, "changedetection.py"), context.serialize()).task_id
|
||||
|
||||
return Response({'celery_task_id': celery_task_id}, status=status.HTTP_200_OK)
|
||||
except GrassEngineException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_200_OK)
|
||||
|
||||
class TaskChangeMapCheck(TaskView):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
res = celery.AsyncResult(celery_task_id)
|
||||
if not res.ready():
|
||||
return Response({'ready': False}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
result = res.get()
|
||||
if result.get('error', None) is not None:
|
||||
cleanup_grass_context(result['context'])
|
||||
return Response({'ready': True, 'error': result['error']})
|
||||
|
||||
output = result.get('output')
|
||||
if not output or not os.path.exists(output):
|
||||
cleanup_grass_context(result['context'])
|
||||
return Response({'ready': True, 'error': output})
|
||||
|
||||
request.session['change_detection_' + celery_task_id] = output
|
||||
return Response({'ready': True})
|
||||
|
||||
|
||||
class TaskChangeMapDownload(TaskView):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
change_detection_file = request.session.get('change_detection_' + celery_task_id, None)
|
||||
|
||||
if change_detection_file is not None:
|
||||
filename = os.path.basename(change_detection_file)
|
||||
filesize = os.stat(change_detection_file).st_size
|
||||
|
||||
f = open(change_detection_file, "rb")
|
||||
|
||||
# More than 100mb, normal http response, otherwise stream
|
||||
# Django docs say to avoid streaming when possible
|
||||
stream = filesize > 1e8
|
||||
if stream:
|
||||
response = FileResponse(f)
|
||||
else:
|
||||
response = HttpResponse(FileWrapper(f),
|
||||
content_type=(mimetypes.guess_type(filename)[0] or "application/zip"))
|
||||
|
||||
response['Content-Type'] = mimetypes.guess_type(filename)[0] or "application/zip"
|
||||
response['Content-Disposition'] = "attachment; filename={}".format(filename)
|
||||
response['Content-Length'] = filesize
|
||||
|
||||
return response
|
||||
else:
|
||||
return Response({'error': 'Invalid change_detecton download id'})
|
|
@ -1,224 +0,0 @@
|
|||
#%module
|
||||
#% description: This script detectes changes by comparing two different sets of DEMs.
|
||||
#%end
|
||||
#%option
|
||||
#% key: reference_pc
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the reference point cloud file
|
||||
#%end
|
||||
#%option
|
||||
#% key: reference_dsm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the reference dsm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: reference_dtm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the reference dtm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: compare_pc
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare point cloud file
|
||||
#%end
|
||||
#%option
|
||||
#% key: compare_dsm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare dsm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: compare_dtm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare dtm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: aligned_compare_dsm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare dtm file that should be aligned to the reference cloud
|
||||
#%end
|
||||
#%option
|
||||
#% key: aligned_compare_dtm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the compare dtm file that should be aligned to the reference cloud
|
||||
#%end
|
||||
#%option
|
||||
#% key: format
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: OGR output format
|
||||
#%end
|
||||
#%option
|
||||
#% key: epsg
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The epsg code that will be used for output
|
||||
#%end
|
||||
#%option
|
||||
#% key: display_type
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Whether to display a heatmap or contours
|
||||
#%end
|
||||
#%option
|
||||
#% key: resolution
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Target resolution in meters
|
||||
#%end
|
||||
#%option
|
||||
#% key: min_height
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Min height in meters for a difference to be considered change
|
||||
#%end
|
||||
#%option
|
||||
#% key: min_area
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Min area in meters for a difference to be considered change
|
||||
#%end
|
||||
#%option
|
||||
#% key: can_align_and_rasterize
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Whether the comparison should be done after aligning the reference and compare clouds
|
||||
#%end
|
||||
|
||||
from os import path, makedirs, getcwd
|
||||
from compare import compare
|
||||
import sys
|
||||
import subprocess
|
||||
import grass.script as grass
|
||||
|
||||
def main():
|
||||
# Read params
|
||||
reference_pc = opts['reference_pc']
|
||||
compare_pc = opts['compare_pc']
|
||||
reference_dsm = opts['reference_dsm']
|
||||
reference_dtm = opts['reference_dtm']
|
||||
compare_dsm = opts['compare_dsm']
|
||||
compare_dtm = opts['compare_dtm']
|
||||
aligned_compare_dsm = opts['aligned_compare_dsm']
|
||||
aligned_compare_dtm = opts['aligned_compare_dtm']
|
||||
epsg = opts['epsg']
|
||||
resolution = float(opts['resolution'])
|
||||
min_height = float(opts['min_height'])
|
||||
min_area = float(opts['min_area'])
|
||||
display_type = opts['display_type']
|
||||
format = opts['format']
|
||||
can_align_and_rasterize = opts['can_align_and_rasterize'] == 'true'
|
||||
|
||||
if can_align_and_rasterize:
|
||||
handle_if_should_align_align_and_rasterize(reference_pc, compare_pc, reference_dsm, reference_dtm, aligned_compare_dsm, aligned_compare_dtm)
|
||||
result_dump = compare(reference_dsm, reference_dtm, aligned_compare_dsm, aligned_compare_dtm, epsg, resolution, display_type, min_height, min_area)
|
||||
else:
|
||||
handle_if_shouldnt_align_and_rasterize(reference_dsm, reference_dtm, compare_dsm, compare_dtm)
|
||||
result_dump = compare(reference_dsm, reference_dtm, compare_dsm, compare_dtm, epsg, resolution, display_type, min_height, min_area)
|
||||
|
||||
# Write the geojson as the expected format file
|
||||
write_to_file(result_dump, format)
|
||||
|
||||
|
||||
def handle_if_shouldnt_align_and_rasterize(reference_dsm, reference_dtm, compare_dsm, compare_dtm):
|
||||
if not path.exists(reference_dsm) or not path.exists(reference_dtm) or not path.exists(compare_dsm) or not path.exists(compare_dtm):
|
||||
raise Exception('Failed to find all four required DEMs to detect changes.')
|
||||
|
||||
|
||||
def handle_if_should_align_align_and_rasterize(reference_pc, compare_pc, reference_dsm, reference_dtm, aligned_compare_dsm, aligned_compare_dtm):
|
||||
from align.align_and_rasterize import align, rasterize
|
||||
|
||||
if not path.exists(reference_pc) or not path.exists(compare_pc):
|
||||
raise Exception('Failed to find both the reference and compare point clouds')
|
||||
|
||||
# Create reference DSM if it does not exist
|
||||
if not path.exists(reference_dsm):
|
||||
make_dirs_if_necessary(reference_dsm)
|
||||
rasterize(reference_pc, 'dsm', reference_dsm)
|
||||
|
||||
# Create reference DTM if it does not exist
|
||||
if not path.exists(reference_dtm):
|
||||
make_dirs_if_necessary(reference_dtm)
|
||||
rasterize(reference_pc, 'dtm', reference_dtm)
|
||||
|
||||
if not path.exists(aligned_compare_dsm) or not path.exists(aligned_compare_dtm):
|
||||
aligned_compare_pc = 'aligned.laz'
|
||||
|
||||
# Run ICP and align the compare point cloud
|
||||
align(reference_pc, compare_pc, aligned_compare_pc)
|
||||
|
||||
# Create compare DSM if it does not exist
|
||||
if not path.exists(aligned_compare_dsm):
|
||||
make_dirs_if_necessary(aligned_compare_dsm)
|
||||
rasterize(aligned_compare_pc, 'dsm', aligned_compare_dsm)
|
||||
|
||||
# Create compare DTM if it does not exist
|
||||
if not path.exists(aligned_compare_dtm):
|
||||
make_dirs_if_necessary(aligned_compare_dtm)
|
||||
rasterize(aligned_compare_pc, 'dtm', aligned_compare_dtm)
|
||||
|
||||
|
||||
def make_dirs_if_necessary(file_path):
|
||||
dirname = path.dirname(file_path)
|
||||
makedirs(dirname, exist_ok = True)
|
||||
|
||||
|
||||
def write_to_file(result_dump, format):
|
||||
ext = ""
|
||||
if format == "GeoJSON":
|
||||
ext = "json"
|
||||
elif format == "GPKG":
|
||||
ext = "gpkg"
|
||||
elif format == "DXF":
|
||||
ext = "dxf"
|
||||
elif format == "ESRI Shapefile":
|
||||
ext = "shp"
|
||||
|
||||
with open("output.json", 'w+') as output:
|
||||
output.write(result_dump)
|
||||
|
||||
if ext != "json":
|
||||
subprocess.check_call(["ogr2ogr", "-f", format, "output.%s" % ext, "output.json"], stdout=subprocess.DEVNULL)
|
||||
|
||||
if path.isfile("output.%s" % ext):
|
||||
if format == "ESRI Shapefile":
|
||||
ext="zip"
|
||||
makedirs("changes")
|
||||
contour_files = glob.glob("output.*")
|
||||
for cf in contour_files:
|
||||
shutil.move(cf, path.join("changes", path.basename(cf)))
|
||||
|
||||
shutil.make_archive('output', 'zip', 'changes/')
|
||||
|
||||
print(path.join(getcwd(), "output.%s" % ext))
|
||||
else:
|
||||
print("error")
|
||||
|
||||
if __name__ == "__main__":
|
||||
opts, _ = grass.parser()
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e:
|
||||
print(e)
|
|
@ -1,146 +0,0 @@
|
|||
import rasterio as rio
|
||||
from rasterio import warp, transform
|
||||
import numpy as np
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from geojson import Feature, FeatureCollection, dumps, Polygon
|
||||
from rasteralign import align, align_altitudes
|
||||
|
||||
from webodm import settings
|
||||
|
||||
sys.path.insert(0, os.path.join(settings.MEDIA_ROOT, "plugins", "changedetection", "site-packages"))
|
||||
import cv2
|
||||
|
||||
KERNEL_10_10 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (10, 10))
|
||||
KERNEL_20_20 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (20, 20))
|
||||
|
||||
def compare(reference_dsm_path, reference_dtm_path, compare_dsm_path, compare_dtm_path, epsg, resolution, display_type, min_height, min_area):
|
||||
# Read DEMs and align them
|
||||
with rio.open(reference_dsm_path) as reference_dsm, \
|
||||
rio.open(reference_dtm_path) as reference_dtm, \
|
||||
rio.open(compare_dsm_path) as compare_dsm, \
|
||||
rio.open(compare_dtm_path) as compare_dtm:
|
||||
reference_dsm, reference_dtm, compare_dsm, compare_dtm = align(reference_dsm, reference_dtm, compare_dsm, compare_dtm, resolution=resolution)
|
||||
reference_dsm, reference_dtm, compare_dsm, compare_dtm = align_altitudes(reference_dsm, reference_dtm, compare_dsm, compare_dtm)
|
||||
|
||||
# Get arrays from DEMs
|
||||
reference_dsm_array = reference_dsm.read(1, masked=True)
|
||||
reference_dtm_array = reference_dtm.read(1, masked=True)
|
||||
compare_dsm_array = compare_dsm.read(1, masked=True)
|
||||
compare_dtm_array = compare_dtm.read(1, masked=True)
|
||||
|
||||
# Calculate CHMs
|
||||
chm_reference = reference_dsm_array - reference_dtm_array
|
||||
chm_compare = compare_dsm_array - compare_dtm_array
|
||||
|
||||
# Calculate diff between CHMs
|
||||
diff = chm_reference - chm_compare
|
||||
|
||||
# Add to the mask everything below the min height
|
||||
diff.mask = np.ma.mask_or(diff.mask, diff < min_height)
|
||||
|
||||
# Copy the diff, and set everything on the mask to 0
|
||||
process = np.copy(diff)
|
||||
process[diff.mask] = 0
|
||||
|
||||
# Apply open filter to filter out noise
|
||||
process = cv2.morphologyEx(process, cv2.MORPH_OPEN, KERNEL_10_10)
|
||||
|
||||
# Apply close filter to fill little areas
|
||||
process = cv2.morphologyEx(process, cv2.MORPH_CLOSE, KERNEL_20_20)
|
||||
|
||||
# Transform to uint8
|
||||
process = process.astype(np.uint8)
|
||||
|
||||
if display_type == 'contours':
|
||||
return calculate_contours(process, reference_dsm, epsg, min_height, min_area)
|
||||
else:
|
||||
return calculate_heatmap(process, diff.mask, reference_dsm, epsg, min_height)
|
||||
|
||||
def calculate_contours(diff, reference_dem, epsg, min_height, min_area):
|
||||
# Calculate contours
|
||||
contours, _ = cv2.findContours(diff, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
# Convert contours into features
|
||||
features = [map_contour_to_geojson_feature(contour, diff, epsg, reference_dem, min_height) for contour in contours]
|
||||
|
||||
# Keep features that meet the threshold
|
||||
features = [feature for feature in features if feature.properties['area'] >= min_area]
|
||||
|
||||
# Write the GeoJSON to a string
|
||||
return dumps(FeatureCollection(features))
|
||||
|
||||
def map_contour_to_geojson_feature(contour, diff_array, epsg, reference_dem, min_height):
|
||||
# Calculate how much area is inside a pixel
|
||||
pixel_area = reference_dem.res[0] * reference_dem.res[1]
|
||||
|
||||
# Calculate the area of the contour
|
||||
area = cv2.contourArea(contour) * pixel_area
|
||||
|
||||
# Calculate the indices of the values inside the contour
|
||||
cimg = np.zeros_like(diff_array)
|
||||
cv2.drawContours(cimg, [contour], 0, color=255, thickness=-1)
|
||||
indices = cimg == 255
|
||||
|
||||
# Calculate values inside the contour
|
||||
values = diff_array[indices]
|
||||
masked_values = np.ma.masked_array(values, values < min_height)
|
||||
|
||||
# Calculate properties regarding the difference values
|
||||
avg = float(masked_values.mean())
|
||||
min = float(masked_values.min())
|
||||
max = float(masked_values.max())
|
||||
std = float(masked_values.std())
|
||||
|
||||
# Map the contour to pixels
|
||||
pixels = to_pixel_format(contour)
|
||||
|
||||
rows = [row for (row, _) in pixels]
|
||||
cols = [col for (_, col) in pixels]
|
||||
|
||||
# Map from pixels to coordinates
|
||||
xs, ys = map_pixels_to_coordinates(reference_dem, epsg, rows, cols)
|
||||
coords = [(x, y) for x, y in zip(xs, ys)]
|
||||
|
||||
# Build polygon, based on the contour
|
||||
polygon = Polygon([coords])
|
||||
|
||||
# Build the feature
|
||||
feature = Feature(geometry = polygon, properties = { 'area': area, 'avg': avg, 'min': min, 'max': max, 'std': std })
|
||||
|
||||
return feature
|
||||
|
||||
|
||||
def calculate_heatmap(diff, mask, dem, epsg, min_height):
|
||||
# Calculate the pixels of valid values
|
||||
pixels = np.argwhere(~mask)
|
||||
xs = pixels[:, 0]
|
||||
ys = pixels[:, 1]
|
||||
|
||||
|
||||
# Map pixels to coordinates
|
||||
coords_xs, coords_ys = map_pixels_to_coordinates(dem, epsg, xs, ys)
|
||||
|
||||
# Calculate the actual values
|
||||
values = diff[~mask]
|
||||
|
||||
# Substract the min, so all values are between 0 and max
|
||||
values = values - np.min(values)
|
||||
|
||||
array = np.column_stack((coords_ys, coords_xs, values))
|
||||
return json.dumps({ 'values': array.tolist(), 'max': float(max(values)) })
|
||||
|
||||
|
||||
def map_pixels_to_coordinates(reference_tiff, dst_epsg, rows, cols):
|
||||
xs, ys = transform.xy(reference_tiff.transform, rows, cols)
|
||||
dst_crs = rio.crs.CRS.from_epsg(dst_epsg)
|
||||
return map_to_new_crs(reference_tiff.crs, dst_crs, xs, ys)
|
||||
|
||||
def map_to_new_crs(src_crs, target_crs, xs, ys):
|
||||
"""Map the given arrays from one crs to the other"""
|
||||
return warp.transform(src_crs, target_crs, xs, ys)
|
||||
|
||||
def to_pixel_format(contour):
|
||||
"""OpenCV contours have a weird format. We are converting them to (row, col)"""
|
||||
return [(pixel[0][1], pixel[0][0]) for pixel in contour]
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "ChangeDetection",
|
||||
"webodmMinVersion": "1.1.1",
|
||||
"description": "Detect changes between two different tasks in the same project.",
|
||||
"version": "1.0.1",
|
||||
"author": "Nicolas Chamo",
|
||||
"email": "nicolas@chamo.com.ar",
|
||||
"repository": "https://github.com/OpenDroneMap/WebODM",
|
||||
"tags": ["change", "detection", "dsm", "dem", "dtm"],
|
||||
"homepage": "https://github.com/OpenDroneMap/WebODM",
|
||||
"experimental": false,
|
||||
"deprecated": false
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
from app.plugins import PluginBase
|
||||
from app.plugins import MountPoint
|
||||
from .api import TaskChangeMapGenerate
|
||||
from .api import TaskChangeMapCheck
|
||||
from .api import TaskChangeMapDownload
|
||||
|
||||
class Plugin(PluginBase):
|
||||
def include_js_files(self):
|
||||
return ['main.js']
|
||||
|
||||
def build_jsx_components(self):
|
||||
return ['ChangeDetection.jsx']
|
||||
|
||||
def api_mount_points(self):
|
||||
return [
|
||||
MountPoint('task/(?P<pk>[^/.]+)/changedetection/generate', TaskChangeMapGenerate.as_view()),
|
||||
MountPoint('task/(?P<pk>[^/.]+)/changedetection/check/(?P<celery_task_id>.+)', TaskChangeMapCheck.as_view()),
|
||||
MountPoint('task/(?P<pk>[^/.]+)/changedetection/download/(?P<celery_task_id>.+)', TaskChangeMapDownload.as_view()),
|
||||
]
|
|
@ -1,56 +0,0 @@
|
|||
import L from 'leaflet';
|
||||
import ReactDOM from 'ReactDOM';
|
||||
import React from 'React';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ChangeDetection.scss';
|
||||
import ChangeDetectionPanel from './ChangeDetectionPanel';
|
||||
|
||||
class ChangeDetectionButton extends React.Component {
|
||||
static propTypes = {
|
||||
tasks: PropTypes.object.isRequired,
|
||||
map: PropTypes.object.isRequired,
|
||||
alignSupported: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showPanel: false
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
this.setState({showPanel: true});
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({showPanel: false});
|
||||
}
|
||||
|
||||
render(){
|
||||
const { showPanel } = this.state;
|
||||
|
||||
return (<div className={showPanel ? "open" : ""}>
|
||||
<a href="javascript:void(0);"
|
||||
onClick={this.handleOpen}
|
||||
className="leaflet-control-changedetection-button leaflet-bar-part theme-secondary"></a>
|
||||
<ChangeDetectionPanel map={this.props.map} isShowed={showPanel} alignSupported={this.props.alignSupported} tasks={this.props.tasks} onClose={this.handleClose} />
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export default L.Control.extend({
|
||||
options: {
|
||||
position: 'topright'
|
||||
},
|
||||
|
||||
onAdd: function (map) {
|
||||
var container = L.DomUtil.create('div', 'leaflet-control-changedetection leaflet-bar leaflet-control');
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
ReactDOM.render(<ChangeDetectionButton map={this.options.map} alignSupported={this.options.alignSupported} tasks={this.options.tasks} />, container);
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
|
@ -1,24 +0,0 @@
|
|||
.leaflet-control-changedetection{
|
||||
z-index: 999;
|
||||
|
||||
a.leaflet-control-changedetection-button{
|
||||
background: url(icon.png) no-repeat 0 0;
|
||||
background-size: 26px 26px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
div.changedetection-panel{ display: none; }
|
||||
|
||||
.open{
|
||||
a.leaflet-control-changedetection-button{
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.changedetection-panel{
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
.leaflet-touch .leaflet-control-changedetection a {
|
||||
background-position: 2px 2px;
|
||||
}
|
|
@ -1,476 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Storage from 'webodm/classes/Storage';
|
||||
import L from 'leaflet';
|
||||
require('leaflet.heat')
|
||||
import './ChangeDetectionPanel.scss';
|
||||
import ErrorMessage from 'webodm/components/ErrorMessage';
|
||||
import ReactTooltip from 'react-tooltip'
|
||||
|
||||
export default class ChangeDetectionPanel extends React.Component {
|
||||
static defaultProps = {
|
||||
};
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
tasks: PropTypes.object.isRequired,
|
||||
isShowed: PropTypes.bool.isRequired,
|
||||
map: PropTypes.object.isRequired,
|
||||
alignSupported: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: "",
|
||||
permanentError: "",
|
||||
epsg: Storage.getItem("last_changedetection_epsg") || "4326",
|
||||
customEpsg: Storage.getItem("last_changedetection_custom_epsg") || "4326",
|
||||
displayType: Storage.getItem("last_changedetection_display_type") || "contours",
|
||||
resolution: Storage.getItem("last_changedetection_resolution") || 0.2,
|
||||
minArea: Storage.getItem("last_changedetection_min_area") || 40,
|
||||
minHeight: Storage.getItem("last_changedetection_min_height") || 5,
|
||||
role: Storage.getItem("last_changedetection_role") || 'reference',
|
||||
align: this.props.alignSupported ? (Storage.getItem("last_changedetection_align") === 'true') : false,
|
||||
other: "",
|
||||
otherTasksInProject: new Map(),
|
||||
loading: true,
|
||||
task: props.tasks[0] || null,
|
||||
previewLoading: false,
|
||||
exportLoading: false,
|
||||
previewLayer: null,
|
||||
opacity: 100,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
componentDidUpdate(){
|
||||
if (this.props.isShowed && this.state.loading){
|
||||
const {id: taskId, project} = this.state.task;
|
||||
|
||||
this.loadingReq = $.getJSON(`/api/projects/${project}/tasks/`)
|
||||
.done(res => {
|
||||
|
||||
const otherTasksInProject = new Map()
|
||||
|
||||
if (!this.props.alignSupported) {
|
||||
const myTask = res.filter(({ id }) => id === taskId)[0]
|
||||
const { available_assets: myAssets } = myTask;
|
||||
const errors = []
|
||||
|
||||
if (myAssets.indexOf("dsm.tif") === -1)
|
||||
errors.push("No DSM is available. Make sure to process a task with either the --dsm option checked");
|
||||
if (myAssets.indexOf("dtm.tif") === -1)
|
||||
errors.push("No DTM is available. Make sure to process a task with either the --dtm option checked");
|
||||
|
||||
if (errors.length > 0) {
|
||||
this.setState({permanentError: errors.join('\n')});
|
||||
return
|
||||
}
|
||||
|
||||
const otherTasksWithDEMs = res.filter(({ id }) => id !== taskId)
|
||||
.filter(({ available_assets }) => available_assets.indexOf("dsm.tif") >= 0 && available_assets.indexOf("dtm.tif") >= 0)
|
||||
|
||||
if (otherTasksWithDEMs.length === 0) {
|
||||
this.setState({permanentError: "Couldn't find other tasks on the project. Please make sure there are other tasks on the project that have both a DTM and DSM."});
|
||||
return
|
||||
}
|
||||
otherTasksWithDEMs.forEach(({ id, name }) => otherTasksInProject.set(id, name))
|
||||
} else {
|
||||
res.filter(({ id }) => id !== taskId)
|
||||
.forEach(({ id, name }) => otherTasksInProject.set(id, name))
|
||||
}
|
||||
|
||||
if (otherTasksInProject.size === 0) {
|
||||
this.setState({permanentError: `Couldn't find other tasks on this project. This plugin must be used on projects with 2 or more tasks.`})
|
||||
} else {
|
||||
const firstOtherTask = Array.from(otherTasksInProject.entries())[0][0]
|
||||
this.setState({otherTasksInProject, other: firstOtherTask});
|
||||
}
|
||||
})
|
||||
.fail(() => {
|
||||
this.setState({permanentError: `Cannot retrieve information for the current project. Are you are connected to the internet?`})
|
||||
})
|
||||
.always(() => {
|
||||
this.setState({loading: false});
|
||||
this.loadingReq = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(){
|
||||
if (this.loadingReq){
|
||||
this.loadingReq.abort();
|
||||
this.loadingReq = null;
|
||||
}
|
||||
if (this.generateReq){
|
||||
this.generateReq.abort();
|
||||
this.generateReq = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectMinArea = e => {
|
||||
this.setState({minArea: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectResolution = e => {
|
||||
this.setState({resolution: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectMinHeight = e => {
|
||||
this.setState({minHeight: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectRole = e => {
|
||||
this.setState({role: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectOther = e => {
|
||||
this.setState({other: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectEpsg = e => {
|
||||
this.setState({epsg: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectDisplayType = e => {
|
||||
this.setState({displayType: e.target.value});
|
||||
}
|
||||
|
||||
handleChangeAlign = e => {
|
||||
this.setState({align: e.target.checked});
|
||||
}
|
||||
|
||||
handleChangeCustomEpsg = e => {
|
||||
this.setState({customEpsg: e.target.value});
|
||||
}
|
||||
|
||||
getFormValues = () => {
|
||||
const { epsg, customEpsg, displayType, align,
|
||||
resolution, minHeight, minArea, other, role } = this.state;
|
||||
return {
|
||||
display_type: displayType,
|
||||
resolution: resolution,
|
||||
min_height: minHeight,
|
||||
min_area: minArea,
|
||||
role: role,
|
||||
epsg: epsg !== "custom" ? epsg : customEpsg,
|
||||
other_task: other,
|
||||
align: align,
|
||||
};
|
||||
}
|
||||
|
||||
waitForCompletion = (taskId, celery_task_id, cb) => {
|
||||
let errorCount = 0;
|
||||
|
||||
const check = () => {
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: `/api/plugins/changedetection/task/${taskId}/changedetection/check/${celery_task_id}`
|
||||
}).done(result => {
|
||||
if (result.error){
|
||||
cb(result.error);
|
||||
}else if (result.ready){
|
||||
cb();
|
||||
}else{
|
||||
// Retry
|
||||
setTimeout(() => check(), 2000);
|
||||
}
|
||||
}).fail(error => {
|
||||
console.warn(error);
|
||||
if (errorCount++ < 10) setTimeout(() => check(), 2000);
|
||||
else cb(JSON.stringify(error));
|
||||
});
|
||||
};
|
||||
|
||||
check();
|
||||
}
|
||||
|
||||
addPreview = (url, cb) => {
|
||||
const { map } = this.props;
|
||||
|
||||
$.getJSON(url)
|
||||
.done((result) => {
|
||||
try{
|
||||
this.removePreview();
|
||||
|
||||
if (result.max) {
|
||||
const heatMap = L.heatLayer(result.values, { max: result.max, radius: 9, minOpacity: 0 })
|
||||
heatMap.setStyle = ({ opacity }) => heatMap.setOptions({ max: result.max / opacity } )
|
||||
this.setState({ previewLayer: heatMap });
|
||||
} else {
|
||||
let featureGroup = L.featureGroup();
|
||||
result.features.forEach(feature => {
|
||||
const area = feature.properties.area.toFixed(2);
|
||||
const min = feature.properties.min.toFixed(2);
|
||||
const max = feature.properties.max.toFixed(2);
|
||||
const avg = feature.properties.avg.toFixed(2);
|
||||
const std = feature.properties.std.toFixed(2);
|
||||
let geojsonForLevel = L.geoJSON(feature)
|
||||
.bindPopup(`Area: ${area}m2<BR/>Min: ${min}m<BR/>Max: ${max}m<BR/>Avg: ${avg}m<BR/>Std: ${std}m`)
|
||||
featureGroup.addLayer(geojsonForLevel);
|
||||
});
|
||||
featureGroup.geojson = result;
|
||||
this.setState({ previewLayer: featureGroup });
|
||||
}
|
||||
|
||||
this.state.previewLayer.addTo(map);
|
||||
|
||||
cb();
|
||||
}catch(e){
|
||||
throw e
|
||||
cb(e.message);
|
||||
}
|
||||
})
|
||||
.fail(cb);
|
||||
}
|
||||
|
||||
removePreview = () => {
|
||||
const { map } = this.props;
|
||||
|
||||
if (this.state.previewLayer){
|
||||
map.removeLayer(this.state.previewLayer);
|
||||
this.setState({previewLayer: null});
|
||||
}
|
||||
}
|
||||
|
||||
generateChangeMap = (data, loadingProp, isPreview) => {
|
||||
this.setState({[loadingProp]: true, error: ""});
|
||||
const taskId = this.state.task.id;
|
||||
|
||||
// Save settings for next time
|
||||
Storage.setItem("last_changedetection_display_type", this.state.displayType);
|
||||
Storage.setItem("last_changedetection_resolution", this.state.resolution);
|
||||
Storage.setItem("last_changedetection_min_height", this.state.minHeight);
|
||||
Storage.setItem("last_changedetection_min_area", this.state.minArea);
|
||||
Storage.setItem("last_changedetection_epsg", this.state.epsg);
|
||||
Storage.setItem("last_changedetection_custom_epsg", this.state.customEpsg);
|
||||
Storage.setItem("last_changedetection_role", this.state.role);
|
||||
Storage.setItem("last_changedetection_align", this.state.align);
|
||||
|
||||
|
||||
|
||||
this.generateReq = $.ajax({
|
||||
type: 'POST',
|
||||
url: `/api/plugins/changedetection/task/${taskId}/changedetection/generate`,
|
||||
data: data
|
||||
}).done(result => {
|
||||
if (result.celery_task_id){
|
||||
this.waitForCompletion(taskId, result.celery_task_id, error => {
|
||||
if (error) this.setState({[loadingProp]: false, 'error': error});
|
||||
else{
|
||||
const fileUrl = `/api/plugins/changedetection/task/${taskId}/changedetection/download/${result.celery_task_id}`;
|
||||
|
||||
// Preview
|
||||
if (isPreview){
|
||||
this.addPreview(fileUrl, e => {
|
||||
if (e) this.setState({error: JSON.stringify(e)});
|
||||
this.setState({[loadingProp]: false});
|
||||
});
|
||||
}else{
|
||||
// Download
|
||||
location.href = fileUrl;
|
||||
this.setState({[loadingProp]: false});
|
||||
}
|
||||
}
|
||||
});
|
||||
}else if (result.error){
|
||||
this.setState({[loadingProp]: false, error: result.error});
|
||||
}else{
|
||||
this.setState({[loadingProp]: false, error: "Invalid response: " + result});
|
||||
}
|
||||
}).fail(error => {
|
||||
this.setState({[loadingProp]: false, error: JSON.stringify(error)});
|
||||
});
|
||||
}
|
||||
|
||||
handleExport = (format) => {
|
||||
return () => {
|
||||
const data = this.getFormValues();
|
||||
data.format = format;
|
||||
data.display_type = 'contours'
|
||||
this.generateChangeMap(data, 'exportLoading', false);
|
||||
};
|
||||
}
|
||||
|
||||
handleShowPreview = () => {
|
||||
this.setState({previewLoading: true});
|
||||
|
||||
const data = this.getFormValues();
|
||||
data.epsg = 4326;
|
||||
data.format = "GeoJSON";
|
||||
this.generateChangeMap(data, 'previewLoading', true);
|
||||
}
|
||||
|
||||
handleChangeOpacity = (evt) => {
|
||||
const opacity = parseFloat(evt.target.value) / 100;
|
||||
this.setState({opacity: opacity});
|
||||
this.state.previewLayer.setStyle({ opacity: opacity });
|
||||
this.props.map.closePopup();
|
||||
}
|
||||
|
||||
render(){
|
||||
const { loading, task, otherTasksInProject, error, permanentError, other,
|
||||
epsg, customEpsg, exportLoading, minHeight, minArea, displayType,
|
||||
resolution, previewLoading, previewLayer, opacity, role, align } = this.state;
|
||||
|
||||
const disabled = (epsg === "custom" && !customEpsg) || !other;
|
||||
|
||||
let content = "";
|
||||
if (loading) content = (<span><i className="fa fa-circle-notch fa-spin"></i> Loading...</span>);
|
||||
else if (permanentError) content = (<div className="alert alert-warning">{permanentError}</div>);
|
||||
else{
|
||||
content = (<div>
|
||||
<ErrorMessage bind={[this, "error"]} />
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Role:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={role} onChange={this.handleSelectRole}>
|
||||
<option value="reference">Reference</option>
|
||||
<option value="compare">Compare</option>
|
||||
</select>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="This plugin will take the reference task, and substract the compare task. Then, we will apply the filters<BR/>available below to determine if some difference is a valid change or not." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Other:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={other} onChange={this.handleSelectOther}>
|
||||
{Array.from(otherTasksInProject.entries()).map(([id, name]) => <option value={id} title={name}>{name.length > 20 ? name.substring(0, 19) + '...' : name}</option>)}
|
||||
</select>
|
||||
{this.props.alignSupported ?
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="Select the other task on the project to compare this task against." />
|
||||
:
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="Select the other task on the project to compare this task against.<BR/>Take into account that only tasks with both a DSM and DTM will be available here." />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.props.alignSupported ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Align:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="checkbox" className="form-control" checked={align} onChange={this.handleChangeAlign} />
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="It is possible to align the two tasks to detect changes more accurately.<BR/>But take into account that the processing can take longer if you do so." />
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Display mode:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={displayType} onChange={this.handleSelectDisplayType}>
|
||||
<option value="contours">Contours</option>
|
||||
<option value="heatmap">Heatmap</option>
|
||||
</select>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You can select to display a heatmap with all the substraction, or the contours of the filtered changes.<BR/>Export is only available for the 'Contours' mode." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Resolution:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={resolution} onChange={this.handleSelectResolution} /><span> meters/pixel</span>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You can indicate the resolution to use when detecting changes. The final resolution used will be: max(input, resolution(reference), resolution(compare)).<BR/>The higher the resolution, the faster the result will be calculated. You can set to 0 to use the DEMs resolutions." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Min Height:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={minHeight} onChange={this.handleSelectMinHeight} /><span> meters</span>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="When detecting change, there can be some noise. Please indicate the min height that change needs to have to consider it a valid change." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Min Area:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" disabled={displayType === 'heatmap'} className="form-control custom-interval" value={minArea} onChange={this.handleSelectMinArea} /><span> sq meters</span>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="When detecting change, there can be some noise. Please indicate the min area that change needs to have to consider it a valid change.<BR/>This option is only available with the 'Contours' display mode." />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Projection:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={epsg} onChange={this.handleSelectEpsg}>
|
||||
<option value="4326">WGS84 (EPSG:4326)</option>
|
||||
<option value="3857">Web Mercator (EPSG:3857)</option>
|
||||
<option value="custom">Custom EPSG</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{epsg === "custom" ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">EPSG:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={customEpsg} onChange={this.handleChangeCustomEpsg} />
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
{previewLayer ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Opacity:</label>
|
||||
<div className="col-sm-9">
|
||||
<input type="range" className="slider" step="1" value={opacity * 100} onChange={this.handleChangeOpacity} />
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="Control the opacity of the change map. You must generate a preview to be able to control the opacity." />
|
||||
<ReactTooltip place="left" effect="solid" html={true}/>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
<div className="row action-buttons">
|
||||
<div className="col-sm-9 text-right">
|
||||
<button onClick={this.handleShowPreview}
|
||||
disabled={disabled || previewLoading} type="button" className="btn btn-sm btn-primary btn-preview">
|
||||
{previewLoading ? <i className="fa fa-spin fa-circle-notch"/> : <i className="glyphicon glyphicon-eye-open"/>} Preview
|
||||
</button>
|
||||
|
||||
<div className="btn-group">
|
||||
<button disabled={disabled || exportLoading || displayType === 'heatmap'} title={displayType === 'heatmap' ? "Export is only available for the 'Contours' display mode" : ""} type="button" className="btn btn-sm btn-primary" data-toggle="dropdown">
|
||||
{exportLoading ? <i className="fa fa-spin fa-circle-notch"/> : <i className="glyphicon glyphicon-download" />} Export
|
||||
</button>
|
||||
<button disabled={disabled|| exportLoading || displayType === 'heatmap'} title={displayType === 'heatmap' ? "Export is only available for the 'Contours' display mode" : ""} type="button" className="btn btn-sm dropdown-toggle btn-primary" data-toggle="dropdown"><span className="caret"></span></button>
|
||||
<ul className="dropdown-menu pull-right">
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("GPKG")}>
|
||||
<i className="fa fa-globe fa-fw"></i> GeoPackage (.GPKG)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("DXF")}>
|
||||
<i className="fa fa-file fa-fw"></i> AutoCAD (.DXF)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("GeoJSON")}>
|
||||
<i className="fa fa-code fa-fw"></i> GeoJSON (.JSON)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("ESRI Shapefile")}>
|
||||
<i className="fa fa-file-archive fa-fw"></i> ShapeFile (.SHP)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReactTooltip place="left" effect="solid" html={true}/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (<div className="changedetection-panel">
|
||||
<span className="close-button" onClick={this.props.onClose}/>
|
||||
<div className="title">Change Detection</div>
|
||||
<hr/>
|
||||
{content}
|
||||
</div>);
|
||||
}
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
.leaflet-control-changedetection .changedetection-panel{
|
||||
padding: 6px 10px 6px 6px;
|
||||
background: #fff;
|
||||
min-width: 250px;
|
||||
max-width: 300px;
|
||||
|
||||
.close-button{
|
||||
display: inline-block;
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAAAkUlEQVR4AZWRxQGDUBAFJ9pMflNIP/iVSkIb2wgccXd7g7O+3JXCQUgqBAfFSl8CMooJGQHfuUlEwZpoahZQ7ODTSXWJQkxyioock7BL2tXmdF4moJNX6IDZfbUBQNrX7qfeXfPuqwBAQjEz60w64htGJ+luFH48gt+NYe6v5b/cnr9asM+HlRQ2Qlwh2CjuqQQ9vKsKTwhQ1wAAAABJRU5ErkJggg==);
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
margin-right: 0;
|
||||
float: right;
|
||||
vertical-align: middle;
|
||||
text-align: right;
|
||||
margin-top: 0px;
|
||||
margin-left: 16px;
|
||||
position: relative;
|
||||
left: 2px;
|
||||
|
||||
&:hover{
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.title{
|
||||
font-size: 120%;
|
||||
margin-right: 60px;
|
||||
}
|
||||
|
||||
hr{
|
||||
clear: both;
|
||||
margin: 6px 0px;
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
label{
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
select, input{
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
input.custom-interval{
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
*{
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row.form-group.form-inline{
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dropdown-menu{
|
||||
a{
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: block;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-preview{
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.action-buttons{
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.help {
|
||||
margin-left: 4px;
|
||||
top: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
padding: 0px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
Plik binarny nie jest wyświetlany.
Przed Szerokość: | Wysokość: | Rozmiar: 1.1 KiB |
|
@ -1,13 +0,0 @@
|
|||
PluginsAPI.Map.didAddControls([
|
||||
'changedetection/build/ChangeDetection.js',
|
||||
'changedetection/build/ChangeDetection.css'
|
||||
], function(args, ChangeDetection){
|
||||
var tasks = [];
|
||||
for (var i = 0; i < args.tiles.length; i++){
|
||||
tasks.push(args.tiles[i].meta.task);
|
||||
}
|
||||
|
||||
if (tasks.length === 1){
|
||||
args.map.addControl(new ChangeDetection({map: args.map, tasks, alignSupported: false}));
|
||||
}
|
||||
});
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"name": "changedetection",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"react-tooltip": "^3.10.0"
|
||||
}
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
from rasterio.io import MemoryFile
|
||||
from rasterio.transform import from_origin
|
||||
from rasterio.warp import aligned_target, reproject
|
||||
import rasterio as rio
|
||||
import numpy as np
|
||||
|
||||
def align(reference, other, *more_others, **kwargs):
|
||||
others = [other] + list(more_others)
|
||||
assert_same_crs(reference, others)
|
||||
reference, others = build_complex_rasters(reference, others)
|
||||
match_pixel_size(reference, others, kwargs)
|
||||
intersect_rasters(reference, others)
|
||||
return [reference.raster] + [other.raster for other in others]
|
||||
|
||||
def align_altitudes(reference, other, *more_others):
|
||||
others = [other] + list(more_others)
|
||||
reference, others = build_complex_rasters(reference, others)
|
||||
|
||||
reference.align_altitude_to_zero()
|
||||
for other in others:
|
||||
other.align_altitude_to_zero()
|
||||
|
||||
return [reference.raster] + [other.raster for other in others]
|
||||
|
||||
def assert_same_crs(reference, others):
|
||||
for other in others:
|
||||
assert reference.crs == other.crs, "All rasters should have the same CRS."
|
||||
|
||||
def build_complex_rasters(reference, others):
|
||||
"""Build Raster objects from the rasterio rasters"""
|
||||
return Raster(reference), [Raster(other) for other in others]
|
||||
|
||||
def match_pixel_size(reference, others, kwargs):
|
||||
"""Take two or more rasters and modify them so that they have the same pixel size"""
|
||||
rasters = [reference] + others
|
||||
max_xres = max([raster.xres for raster in rasters])
|
||||
max_yres = max([raster.yres for raster in rasters])
|
||||
|
||||
if 'resolution' in kwargs:
|
||||
max_xres = max(max_xres, kwargs['resolution'])
|
||||
max_yres = max(max_yres, kwargs['resolution'])
|
||||
|
||||
reference.match_pixel_size(max_xres, max_yres)
|
||||
for other in others:
|
||||
other.match_pixel_size(max_xres, max_yres)
|
||||
|
||||
def intersect_rasters(reference, others):
|
||||
"""Take two or more rasters with the same size per pixel, and calculate the areas where they intersect, based on their position. Then, we keep only those areas, discarding the other pixels."""
|
||||
final_bounds = reference.get_bounds()
|
||||
|
||||
for other in others:
|
||||
final_bounds = final_bounds.intersection(other.get_bounds())
|
||||
|
||||
reference.reduce_to_bounds(final_bounds)
|
||||
for other in others:
|
||||
other.reduce_to_bounds(final_bounds)
|
||||
|
||||
|
||||
class Raster:
|
||||
def __init__(self, raster):
|
||||
self.raster = raster
|
||||
self.xres, self.yres = raster.res
|
||||
|
||||
def get_bounds(self):
|
||||
(left, bottom, right, top) = self.raster.bounds
|
||||
return Bounds(left, bottom, right, top)
|
||||
|
||||
def get_window(self):
|
||||
print(self.raster.bounds)
|
||||
(left, bottom, right, top) = self.raster.bounds
|
||||
return self.raster.window(left, bottom, right, top)
|
||||
|
||||
def match_pixel_size(self, xres, yres):
|
||||
dst_transform, dst_width, dst_height = aligned_target(self.raster.transform, self.raster.width, self.raster.height, (xres, yres))
|
||||
with MemoryFile() as mem_file:
|
||||
aligned = mem_file.open(driver = 'GTiff', height = dst_height, width = dst_width, count = self.raster.count, dtype = self.raster.dtypes[0], crs = self.raster.crs, transform = dst_transform, nodata = self.raster.nodata)
|
||||
for band in range(1, self.raster.count + 1):
|
||||
reproject(rio.band(self.raster, band), rio.band(aligned, band))
|
||||
self.raster = aligned
|
||||
|
||||
def reduce_to_bounds(self, bounds):
|
||||
"""Take some bounds and remove the pixels outside of it"""
|
||||
(left, bottom, right, top) = bounds.as_tuple()
|
||||
window = self.raster.window(left, bottom, right, top)
|
||||
with MemoryFile() as mem_file:
|
||||
raster = mem_file.open(driver = 'GTiff', height = window.height, width = window.width, count = self.raster.count, dtype = self.raster.dtypes[0], crs = self.raster.crs, transform = self.raster.window_transform(window), nodata = self.raster.nodata)
|
||||
for band in range(1, self.raster.count + 1):
|
||||
band_array = self.raster.read(band, window = window)
|
||||
raster.write(band_array, band)
|
||||
self.raster = raster
|
||||
|
||||
def align_altitude_to_zero(self):
|
||||
with MemoryFile() as mem_file:
|
||||
raster = mem_file.open(driver = 'GTiff', height = self.raster.height, width = self.raster.width, count = self.raster.count, dtype = self.raster.dtypes[0], crs = self.raster.crs, transform = self.raster.transform, nodata = self.raster.nodata)
|
||||
for band in range(1, self.raster.count + 1):
|
||||
band_array = self.raster.read(band, masked = True)
|
||||
min = band_array.min()
|
||||
aligned = band_array - min
|
||||
raster.write(aligned, band)
|
||||
self.raster = raster
|
||||
|
||||
class Bounds:
|
||||
def __init__(self, left, bottom, right, top):
|
||||
self.left = left
|
||||
self.bottom = bottom
|
||||
self.right = right
|
||||
self.top = top
|
||||
|
||||
def intersection(self, other_bounds):
|
||||
max_left = max(self.left, other_bounds.left)
|
||||
max_bottom = max(self.bottom, other_bounds.bottom)
|
||||
min_right = min(self.right, other_bounds.right)
|
||||
min_top = min(self.top, other_bounds.top)
|
||||
return Bounds(max_left, max_bottom, min_right, min_top)
|
||||
|
||||
def as_tuple(self):
|
||||
return (self.left, self.bottom, self.right, self.top)
|
|
@ -1,3 +0,0 @@
|
|||
geojson==2.4.1
|
||||
opencv-python-headless==4.4.0.46
|
||||
|
|
@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
|||
class ContoursException(Exception):
|
||||
pass
|
||||
|
||||
def calc_contours(dem, epsg, interval, output_format, simplify):
|
||||
def calc_contours(dem, epsg, interval, output_format, simplify, zfactor = 1):
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
@ -50,7 +50,7 @@ def calc_contours(dem, epsg, interval, output_format, simplify):
|
|||
|
||||
outfile = os.path.join(tmpdir, f"output.{ext}")
|
||||
p = subprocess.Popen([ogr2ogr_bin, outfile, contours_file, "-simplify", str(simplify), "-f", output_format, "-t_srs", f"EPSG:{epsg}", "-nln", "contours",
|
||||
"-dialect", "sqlite", "-sql", f"SELECT * FROM contour WHERE ST_Length(GEOM) >= {MIN_CONTOUR_LENGTH}"], cwd=tmpdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
"-dialect", "sqlite", "-sql", f"SELECT ID, ROUND(level * {zfactor}, 5) AS level, GeomFromGML(AsGML(ATM_Transform(GEOM, ATM_Scale(ATM_Create(), 1, 1, {zfactor})), 10)) as GEOM FROM contour WHERE ST_Length(GEOM) >= {MIN_CONTOUR_LENGTH}"], cwd=tmpdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
out, err = p.communicate()
|
||||
|
||||
out = out.decode('utf-8').strip()
|
||||
|
@ -102,8 +102,9 @@ class TaskContoursGenerate(TaskView):
|
|||
if not format in supported_formats:
|
||||
raise ContoursException("Invalid format {} (must be one of: {})".format(format, ",".join(supported_formats)))
|
||||
simplify = float(request.data.get('simplify', 0.01))
|
||||
zfactor = float(request.data.get('zfactor', 1))
|
||||
|
||||
celery_task_id = run_function_async(calc_contours, dem, epsg, interval, format, simplify).task_id
|
||||
celery_task_id = run_function_async(calc_contours, dem, epsg, interval, format, simplify, zfactor).task_id
|
||||
return Response({'celery_task_id': celery_task_id}, status=status.HTTP_200_OK)
|
||||
except ContoursException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_200_OK)
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"name": "Contours",
|
||||
"webodmMinVersion": "0.9.0",
|
||||
"webodmMinVersion": "2.5.0",
|
||||
"description": "Compute, preview and export contours from DEMs",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Piero Toffanin",
|
||||
"email": "pt@masseranolabs.com",
|
||||
"repository": "https://github.com/OpenDroneMap/WebODM",
|
||||
|
|
|
@ -6,6 +6,7 @@ import './ContoursPanel.scss';
|
|||
import ErrorMessage from 'webodm/components/ErrorMessage';
|
||||
import Workers from 'webodm/classes/Workers';
|
||||
import { _ } from 'webodm/classes/gettext';
|
||||
import { systems, getUnitSystem, onUnitSystemChanged, offUnitSystemChanged, toMetric } from 'webodm/classes/Units';
|
||||
|
||||
export default class ContoursPanel extends React.Component {
|
||||
static defaultProps = {
|
||||
|
@ -20,13 +21,23 @@ export default class ContoursPanel extends React.Component {
|
|||
constructor(props){
|
||||
super(props);
|
||||
|
||||
const unitSystem = getUnitSystem();
|
||||
const defaultInterval = unitSystem === "metric" ? "1" : "4";
|
||||
const defaultSimplify = unitSystem === "metric" ? "0.2" : "0.6";
|
||||
|
||||
// Remove legacy parameters
|
||||
Storage.removeItem("last_contours_interval");
|
||||
Storage.removeItem("last_contours_custom_interval");
|
||||
Storage.removeItem("last_contours_simplify");
|
||||
Storage.removeItem("last_contours_custom_simplify");
|
||||
|
||||
this.state = {
|
||||
error: "",
|
||||
permanentError: "",
|
||||
interval: Storage.getItem("last_contours_interval") || "1",
|
||||
customInterval: Storage.getItem("last_contours_custom_interval") || "1",
|
||||
simplify: Storage.getItem("last_contours_simplify") || "0.2",
|
||||
customSimplify: Storage.getItem("last_contours_custom_simplify") || "0.2",
|
||||
interval: Storage.getItem("last_contours_interval_" + unitSystem) || defaultInterval,
|
||||
customInterval: Storage.getItem("last_contours_custom_interval_" + unitSystem) || defaultInterval,
|
||||
simplify: Storage.getItem("last_contours_simplify_" + unitSystem) || defaultSimplify,
|
||||
customSimplify: Storage.getItem("last_contours_custom_simplify_" + unitSystem) || defaultSimplify,
|
||||
layer: "",
|
||||
epsg: Storage.getItem("last_contours_epsg") || "4326",
|
||||
customEpsg: Storage.getItem("last_contours_custom_epsg") || "4326",
|
||||
|
@ -36,13 +47,18 @@ export default class ContoursPanel extends React.Component {
|
|||
previewLoading: false,
|
||||
exportLoading: false,
|
||||
previewLayer: null,
|
||||
unitSystem
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(){
|
||||
onUnitSystemChanged(this.unitsChanged);
|
||||
}
|
||||
|
||||
componentDidUpdate(){
|
||||
if (this.props.isShowed && this.state.loading){
|
||||
const {id, project} = this.state.task;
|
||||
|
||||
|
||||
this.loadingReq = $.getJSON(`/api/projects/${project}/tasks/${id}/`)
|
||||
.done(res => {
|
||||
const { available_assets } = res;
|
||||
|
@ -76,6 +92,24 @@ export default class ContoursPanel extends React.Component {
|
|||
this.generateReq.abort();
|
||||
this.generateReq = null;
|
||||
}
|
||||
|
||||
offUnitSystemChanged(this.unitsChanged);
|
||||
}
|
||||
|
||||
unitsChanged = e => {
|
||||
this.saveInputValues();
|
||||
|
||||
const unitSystem = e.detail;
|
||||
|
||||
const defaultInterval = unitSystem === "metric" ? "1" : "4";
|
||||
const defaultSimplify = unitSystem === "metric" ? "0.2" : "0.5";
|
||||
|
||||
const interval = Storage.getItem("last_contours_interval_" + unitSystem) || defaultInterval;
|
||||
const customInterval = Storage.getItem("last_contours_custom_interval_" + unitSystem) || defaultInterval;
|
||||
const simplify = Storage.getItem("last_contours_simplify_" + unitSystem) || defaultSimplify;
|
||||
const customSimplify = Storage.getItem("last_contours_custom_simplify_" + unitSystem) || defaultSimplify;
|
||||
|
||||
this.setState({unitSystem, interval, customInterval, simplify, customSimplify });
|
||||
}
|
||||
|
||||
handleSelectInterval = e => {
|
||||
|
@ -106,19 +140,31 @@ export default class ContoursPanel extends React.Component {
|
|||
this.setState({customEpsg: e.target.value});
|
||||
}
|
||||
|
||||
getFormValues = () => {
|
||||
getFormValues = (preview) => {
|
||||
const { interval, customInterval, epsg, customEpsg,
|
||||
simplify, customSimplify, layer } = this.state;
|
||||
simplify, customSimplify, layer, unitSystem } = this.state;
|
||||
const su = systems[unitSystem];
|
||||
|
||||
let meterInterval = interval !== "custom" ? interval : customInterval;
|
||||
let meterSimplify = simplify !== "custom" ? simplify : customSimplify;
|
||||
|
||||
meterInterval = toMetric(meterInterval, su.lengthUnit(1)).value;
|
||||
meterSimplify = toMetric(meterSimplify, su.lengthUnit(1)).value;
|
||||
|
||||
const zfactor = preview ? 1 : su.lengthUnit(1).factor;
|
||||
|
||||
return {
|
||||
interval: interval !== "custom" ? interval : customInterval,
|
||||
interval: meterInterval,
|
||||
epsg: epsg !== "custom" ? epsg : customEpsg,
|
||||
simplify: simplify !== "custom" ? simplify : customSimplify,
|
||||
simplify: meterSimplify,
|
||||
zfactor,
|
||||
layer
|
||||
};
|
||||
}
|
||||
|
||||
addGeoJSONFromURL = (url, cb) => {
|
||||
const { map } = this.props;
|
||||
const us = systems[this.state.unitSystem];
|
||||
|
||||
$.getJSON(url)
|
||||
.done((geojson) => {
|
||||
|
@ -128,7 +174,7 @@ export default class ContoursPanel extends React.Component {
|
|||
this.setState({previewLayer: L.geoJSON(geojson, {
|
||||
onEachFeature: (feature, layer) => {
|
||||
if (feature.properties && feature.properties.level !== undefined) {
|
||||
layer.bindPopup(`<b>${_("Elevation:")}</b> ${feature.properties.level} ${_("meters")}`);
|
||||
layer.bindPopup(`<div style="margin-right: 32px;"><b>${_("Elevation:")}</b> ${us.length(feature.properties.level)}</div>`);
|
||||
}
|
||||
},
|
||||
style: feature => {
|
||||
|
@ -155,18 +201,23 @@ export default class ContoursPanel extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
saveInputValues = () => {
|
||||
const us = this.state.unitSystem;
|
||||
|
||||
// Save settings
|
||||
Storage.setItem("last_contours_interval_" + us, this.state.interval);
|
||||
Storage.setItem("last_contours_custom_interval_" + us, this.state.customInterval);
|
||||
Storage.setItem("last_contours_simplify_" + us, this.state.simplify);
|
||||
Storage.setItem("last_contours_custom_simplify_" + us, this.state.customSimplify);
|
||||
Storage.setItem("last_contours_epsg", this.state.epsg);
|
||||
Storage.setItem("last_contours_custom_epsg", this.state.customEpsg);
|
||||
}
|
||||
|
||||
generateContours = (data, loadingProp, isPreview) => {
|
||||
this.setState({[loadingProp]: true, error: ""});
|
||||
const taskId = this.state.task.id;
|
||||
this.saveInputValues();
|
||||
|
||||
// Save settings for next time
|
||||
Storage.setItem("last_contours_interval", this.state.interval);
|
||||
Storage.setItem("last_contours_custom_interval", this.state.customInterval);
|
||||
Storage.setItem("last_contours_simplify", this.state.simplify);
|
||||
Storage.setItem("last_contours_custom_simplify", this.state.customSimplify);
|
||||
Storage.setItem("last_contours_epsg", this.state.epsg);
|
||||
Storage.setItem("last_contours_custom_epsg", this.state.customEpsg);
|
||||
|
||||
this.generateReq = $.ajax({
|
||||
type: 'POST',
|
||||
url: `/api/plugins/contours/task/${taskId}/contours/generate`,
|
||||
|
@ -203,7 +254,7 @@ export default class ContoursPanel extends React.Component {
|
|||
|
||||
handleExport = (format) => {
|
||||
return () => {
|
||||
const data = this.getFormValues();
|
||||
const data = this.getFormValues(false);
|
||||
data.format = format;
|
||||
this.generateContours(data, 'exportLoading', false);
|
||||
};
|
||||
|
@ -212,7 +263,7 @@ export default class ContoursPanel extends React.Component {
|
|||
handleShowPreview = () => {
|
||||
this.setState({previewLoading: true});
|
||||
|
||||
const data = this.getFormValues();
|
||||
const data = this.getFormValues(true);
|
||||
data.epsg = 4326;
|
||||
data.format = "GeoJSON";
|
||||
this.generateContours(data, 'previewLoading', true);
|
||||
|
@ -222,11 +273,15 @@ export default class ContoursPanel extends React.Component {
|
|||
const { loading, task, layers, error, permanentError, interval, customInterval, layer,
|
||||
epsg, customEpsg, exportLoading,
|
||||
simplify, customSimplify,
|
||||
previewLoading, previewLayer } = this.state;
|
||||
const intervalValues = [0.25, 0.5, 1, 1.5, 2];
|
||||
previewLoading, previewLayer, unitSystem } = this.state;
|
||||
const us = systems[unitSystem];
|
||||
const lengthUnit = us.lengthUnit(1);
|
||||
|
||||
const intervalStart = unitSystem === "metric" ? 1 : 4;
|
||||
const intervalValues = [intervalStart / 4, intervalStart / 2, intervalStart, intervalStart * 2, intervalStart * 4];
|
||||
const simplifyValues = [{label: _('Do not simplify'), value: 0},
|
||||
{label: _('Normal'), value: 0.2},
|
||||
{label: _('Aggressive'), value: 1}];
|
||||
{label: _('Normal'), value: unitSystem === "metric" ? 0.2 : 0.5},
|
||||
{label: _('Aggressive'), value: unitSystem === "metric" ? 1 : 4}];
|
||||
|
||||
const disabled = (interval === "custom" && !customInterval) ||
|
||||
(epsg === "custom" && !customEpsg) ||
|
||||
|
@ -242,7 +297,7 @@ export default class ContoursPanel extends React.Component {
|
|||
<label className="col-sm-3 control-label">{_("Interval:")}</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={interval} onChange={this.handleSelectInterval}>
|
||||
{intervalValues.map(iv => <option value={iv}>{iv} {_("meter")}</option>)}
|
||||
{intervalValues.map(iv => <option value={iv}>{iv} {lengthUnit.label}</option>)}
|
||||
<option value="custom">{_("Custom")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
@ -251,7 +306,7 @@ export default class ContoursPanel extends React.Component {
|
|||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">{_("Value:")}</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={customInterval} onChange={this.handleChangeCustomInterval} /><span> {_("meter")}</span>
|
||||
<input type="number" className="form-control custom-interval" value={customInterval} onChange={this.handleChangeCustomInterval} /><span> {lengthUnit.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
@ -269,7 +324,7 @@ export default class ContoursPanel extends React.Component {
|
|||
<label className="col-sm-3 control-label">{_("Simplify:")}</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={simplify} onChange={this.handleSelectSimplify}>
|
||||
{simplifyValues.map(sv => <option value={sv.value}>{sv.label} ({sv.value} {_("meter")})</option>)}
|
||||
{simplifyValues.map(sv => <option value={sv.value}>{sv.label} ({sv.value} {lengthUnit.label})</option>)}
|
||||
<option value="custom">{_("Custom")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
@ -278,7 +333,7 @@ export default class ContoursPanel extends React.Component {
|
|||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">{_("Value:")}</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={customSimplify} onChange={this.handleChangeCustomSimplify} /><span> {_("meter")}</span>
|
||||
<input type="number" className="form-control custom-interval" value={customSimplify} onChange={this.handleChangeCustomSimplify} /><span> {lengthUnit.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
|
|
@ -343,7 +343,7 @@ class ShareTaskView(TaskView):
|
|||
|
||||
settings = get_settings(request)
|
||||
|
||||
available_assets = [task.get_asset_file_or_zipstream(f)[0] for f in list(set(task.available_assets) & set(DRONEDB_ASSETS))]
|
||||
available_assets = [task.get_asset_file_or_stream(f) for f in list(set(task.available_assets) & set(DRONEDB_ASSETS))]
|
||||
|
||||
if 'textured_model.zip' in task.available_assets:
|
||||
texture_files = [join(task.assets_path('odm_texturing'), f) for f in listdir(task.assets_path('odm_texturing')) if isfile(join(task.assets_path('odm_texturing'), f))]
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
* Save the "ground" choice on the plugin panel
|
||||
* Consider fetching (or creating if it doesn't exist) a smaller version of the dsm/dtm. There is no need to work with a high resolution image in this case, and it should speed things up.
|
|
@ -1 +0,0 @@
|
|||
from .plugin import *
|
|
@ -1,102 +0,0 @@
|
|||
import mimetypes
|
||||
import os
|
||||
|
||||
from django.http import FileResponse
|
||||
from django.http import HttpResponse
|
||||
from wsgiref.util import FileWrapper
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from app.plugins.views import TaskView
|
||||
from worker.tasks import execute_grass_script
|
||||
from app.plugins.grass_engine import grass, GrassEngineException, cleanup_grass_context
|
||||
from worker.celery import app as celery
|
||||
from app.plugins import get_current_plugin
|
||||
|
||||
class TaskElevationMapGenerate(TaskView):
|
||||
def post(self, request, pk=None):
|
||||
task = self.get_and_check_task(request, pk)
|
||||
plugin = get_current_plugin()
|
||||
|
||||
if task.dsm_extent is None:
|
||||
return Response({'error': 'No DSM layer is available.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
reference = request.data.get('reference', 'global')
|
||||
if reference.lower() == 'ground' and task.dtm_extent is None:
|
||||
return Response({'error': 'No DTM layer is available. You need one to set the ground as reference.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
context = grass.create_context({'auto_cleanup' : False, 'location': 'epsg:3857', 'python_path': plugin.get_python_packages_path()})
|
||||
dsm = os.path.abspath(task.get_asset_download_path("dsm.tif"))
|
||||
dtm = os.path.abspath(task.get_asset_download_path("dtm.tif")) if reference.lower() == 'ground' else None
|
||||
epsg = int(request.data.get('epsg', '3857'))
|
||||
interval = request.data.get('interval', '5')
|
||||
format = request.data.get('format', 'GPKG')
|
||||
supported_formats = ['GPKG', 'ESRI Shapefile', 'DXF', 'GeoJSON']
|
||||
if not format in supported_formats:
|
||||
raise GrassEngineException("Invalid format {} (must be one of: {})".format(format, ",".join(supported_formats)))
|
||||
noise_filter_size = float(request.data.get('noise_filter_size', 2))
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
context.add_param('dsm', dsm)
|
||||
context.add_param('interval', interval)
|
||||
context.add_param('format', format)
|
||||
context.add_param('noise_filter_size', noise_filter_size)
|
||||
context.add_param('epsg', epsg)
|
||||
|
||||
if dtm != None:
|
||||
context.add_param('dtm', dtm)
|
||||
|
||||
context.set_location(dsm)
|
||||
|
||||
celery_task_id = execute_grass_script.delay(os.path.join(current_dir, "elevationmap.py"), context.serialize()).task_id
|
||||
|
||||
return Response({'celery_task_id': celery_task_id}, status=status.HTTP_200_OK)
|
||||
except GrassEngineException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_200_OK)
|
||||
|
||||
class TaskElevationMapCheck(TaskView):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
res = celery.AsyncResult(celery_task_id)
|
||||
if not res.ready():
|
||||
return Response({'ready': False}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
result = res.get()
|
||||
if result.get('error', None) is not None:
|
||||
cleanup_grass_context(result['context'])
|
||||
return Response({'ready': True, 'error': result['error']})
|
||||
|
||||
output = result.get('output')
|
||||
if not output or not os.path.exists(output):
|
||||
cleanup_grass_context(result['context'])
|
||||
return Response({'ready': True, 'error': output})
|
||||
|
||||
request.session['elevation_map_' + celery_task_id] = output
|
||||
return Response({'ready': True})
|
||||
|
||||
|
||||
class TaskElevationMapDownload(TaskView):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
elevation_map_file = request.session.get('elevation_map_' + celery_task_id, None)
|
||||
|
||||
if elevation_map_file is not None:
|
||||
filename = os.path.basename(elevation_map_file)
|
||||
filesize = os.stat(elevation_map_file).st_size
|
||||
|
||||
f = open(elevation_map_file, "rb")
|
||||
|
||||
# More than 100mb, normal http response, otherwise stream
|
||||
# Django docs say to avoid streaming when possible
|
||||
stream = filesize > 1e8
|
||||
if stream:
|
||||
response = FileResponse(f)
|
||||
else:
|
||||
response = HttpResponse(FileWrapper(f),
|
||||
content_type=(mimetypes.guess_type(filename)[0] or "application/zip"))
|
||||
|
||||
response['Content-Type'] = mimetypes.guess_type(filename)[0] or "application/zip"
|
||||
response['Content-Disposition'] = "attachment; filename={}".format(filename)
|
||||
response['Content-Length'] = filesize
|
||||
|
||||
return response
|
||||
else:
|
||||
return Response({'error': 'Invalid elevation_map download id'})
|
|
@ -1,240 +0,0 @@
|
|||
#%module
|
||||
#% description: This script takes a GeoTIFF file, calculates its heighmap, and outputs it as a GeoJSON
|
||||
#%end
|
||||
#%option
|
||||
#% key: dsm
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The path for the dsm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: intervals
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The intervals used to generate the diferent elevation levels
|
||||
#%end
|
||||
#%option
|
||||
#% key: format
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: OGR output format
|
||||
#%end
|
||||
#%option
|
||||
#% key: dtm
|
||||
#% type: string
|
||||
#% required: no
|
||||
#% multiple: no
|
||||
#% description: The path for the dtm file
|
||||
#%end
|
||||
#%option
|
||||
#% key: epsg
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: The epsg code that will be used for output
|
||||
#%end
|
||||
#%option
|
||||
#% key: noise_filter_size
|
||||
#% type: double
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Area in meters where we will clean up noise in the contours
|
||||
#%end
|
||||
|
||||
|
||||
import math, argparse
|
||||
import numpy as np
|
||||
import rasterio as rio
|
||||
from rasterio import warp, transform
|
||||
from geojson import Feature, FeatureCollection, MultiPolygon, dumps
|
||||
import subprocess
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import sys
|
||||
import grass.script as grass
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||||
from webodm import settings
|
||||
sys.path.insert(0, os.path.join(settings.MEDIA_ROOT, "plugins", "elevationmap", "site-packages"))
|
||||
import cv2
|
||||
|
||||
def main():
|
||||
ext = ""
|
||||
if opts['format'] == "GeoJSON":
|
||||
ext = "json"
|
||||
elif opts['format'] == "GPKG":
|
||||
ext = "gpkg"
|
||||
elif opts['format'] == "DXF":
|
||||
ext = "dxf"
|
||||
elif opts['format'] == "ESRI Shapefile":
|
||||
ext = "shp"
|
||||
|
||||
# Open dsm
|
||||
dsm = rio.open(opts['dsm'])
|
||||
# Read the tiff as an numpy masked array
|
||||
dsm_array = dsm.read(1, masked = True)
|
||||
# Create a kernel based on the parameter 'noise_filter_size' and the tiff resolution
|
||||
kernel = get_kernel(float(opts['noise_filter_size']), dsm)
|
||||
|
||||
# Check if we want to use the dtm also
|
||||
if opts['dtm'] != '':
|
||||
# Open the dtm
|
||||
dtm = rio.open(opts['dtm'])
|
||||
# Assert that the dtm and dsm have the same bounds and resolution
|
||||
assert_same_bounds_and_resolution(dsm, dtm)
|
||||
# Calculate the different between the dsm and dtm
|
||||
array = calculate_difference(dsm_array, dtm)
|
||||
else:
|
||||
array = dsm_array
|
||||
|
||||
# Calculate the ranges based on the parameter 'intervals' and the elevation array
|
||||
ranges = calculate_ranges(opts['intervals'], array)
|
||||
|
||||
features = []
|
||||
|
||||
for bottom, top in ranges:
|
||||
# Binarize the image. Everything in [bottom, top) is white. Everything else is black
|
||||
surface_array = np.ma.where((bottom <= array) & (array < top), 255, 0).astype(np.uint8)
|
||||
# Apply kernel to reduce noise
|
||||
without_noise = cv2.morphologyEx(surface_array, cv2.MORPH_CLOSE, kernel) if kernel is not None else surface_array
|
||||
# Find contours
|
||||
contours, hierarchy = cv2.findContours(without_noise, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
|
||||
# Check if we found something
|
||||
if len(contours) > 0:
|
||||
# Transform contours from pixels to coordinates
|
||||
mapped_contours = [map_pixels_to_coordinates(dsm, opts['epsg'], to_pixel_format(contour)) for contour in contours]
|
||||
# Build the MultiPolygon for based on the contours and their hierarchy
|
||||
built_multi_polygon = LevelBuilder(bottom, top, mapped_contours, hierarchy[0]).build_multi_polygon()
|
||||
features.append(built_multi_polygon)
|
||||
|
||||
# Write the GeoJSON to a file
|
||||
dump = dumps(FeatureCollection(features))
|
||||
with open("output.json", 'w+') as output:
|
||||
output.write(dump)
|
||||
|
||||
if ext != "json":
|
||||
subprocess.check_call(["ogr2ogr", "-f", opts['format'], "output.%s" % ext, "output.json"], stdout=subprocess.DEVNULL)
|
||||
|
||||
if os.path.isfile("output.%s" % ext):
|
||||
if opts['format'] == "ESRI Shapefile":
|
||||
ext="zip"
|
||||
os.makedirs("contours")
|
||||
contour_files = glob.glob("output.*")
|
||||
for cf in contour_files:
|
||||
shutil.move(cf, os.path.join("contours", os.path.basename(cf)))
|
||||
|
||||
shutil.make_archive('output', 'zip', 'contours/')
|
||||
|
||||
print(os.path.join(os.getcwd(), "output.%s" % ext))
|
||||
else:
|
||||
print("error")
|
||||
|
||||
def get_kernel(noise_filter_size, dsm):
|
||||
"""Generate a kernel for noise filtering. Will return none if the noise_filter_size isn't positive"""
|
||||
if noise_filter_size <= 0:
|
||||
return None
|
||||
if dsm.crs.linear_units != 'metre':
|
||||
noise_filter_size *= 3.2808333333465 # Convert meter to feets
|
||||
return cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (int(round(noise_filter_size / dsm.res[0])), int(round(noise_filter_size / dsm.res[1]))))
|
||||
|
||||
def assert_same_bounds_and_resolution(dsm, dtm):
|
||||
if dtm.bounds != dsm.bounds or dtm.res != dsm.res:
|
||||
raise Exception("DTM and DSM have differenct bounds or resolution.")
|
||||
|
||||
def calculate_difference(dsm_array, dtm):
|
||||
"""Calculate the difference between the dsm and dtm"""
|
||||
dtm_array = dtm.read(1, masked = True)
|
||||
difference = dsm_array - dtm_array
|
||||
difference.data[difference < 0] = 0 # We set to 0 anything that might have been negative
|
||||
return difference
|
||||
|
||||
def calculate_ranges(interval_text, array):
|
||||
"""Calculate the ranges based on the provided 'interval_text'"""
|
||||
if is_number(interval_text):
|
||||
# If it is a number, then consider it the step
|
||||
min_elevation = math.floor(np.amin(array))
|
||||
max_elevation = math.ceil(np.amax(array))
|
||||
interval = float(interval_text)
|
||||
return [(bottom, bottom + interval) for bottom in np.arange(min_elevation, max_elevation, interval)]
|
||||
else:
|
||||
# If it is not a number, then we consider the text the intervals. We are going to validate them
|
||||
ranges = [validate_and_convert_to_range(range) for range in interval_text.split(',')]
|
||||
if len(ranges) == 0:
|
||||
raise Exception('Please add a range.')
|
||||
elif len(ranges) > 1:
|
||||
ranges.sort()
|
||||
for i in range(len(ranges) - 1):
|
||||
if ranges[i][1] > ranges[i + 1][0]:
|
||||
raise Exception('Please make sure that the ranges don\'t overlap.')
|
||||
return ranges
|
||||
|
||||
def to_pixel_format(contour):
|
||||
"""OpenCV contours have a weird format. We are converting them to (row, col)"""
|
||||
return [(pixel[0][1], pixel[0][0]) for pixel in contour]
|
||||
|
||||
def map_pixels_to_coordinates(reference_tiff, dst_epsg, pixels):
|
||||
"""We are assuming that the pixels are a list of tuples. For example: [(row1, col1), (row2, col2)]"""
|
||||
rows = [row for (row, _) in pixels]
|
||||
cols = [col for (_, col) in pixels]
|
||||
xs, ys = transform.xy(reference_tiff.transform, rows, cols)
|
||||
dst_crs = rio.crs.CRS.from_epsg(dst_epsg)
|
||||
return map_to_new_crs(reference_tiff.crs, dst_crs, xs, ys)
|
||||
|
||||
def map_to_new_crs(src_crs, target_crs, xs, ys):
|
||||
"""Map the given arrays from one crs to the other"""
|
||||
transformed = warp.transform(src_crs, target_crs, xs, ys)
|
||||
return [(x, y) for x, y in zip(transformed[0], transformed[1])]
|
||||
|
||||
def is_number(text):
|
||||
try:
|
||||
float(text)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def validate_and_convert_to_range(range):
|
||||
"""Validate the given range and return a tuple (start, end) if it is valid"""
|
||||
range = range.strip().split('-')
|
||||
if len(range) != 2:
|
||||
raise Exception('Ranges must have a beggining and an end.')
|
||||
if not is_number(range[0]) or not is_number(range[1]):
|
||||
raise Exception('Please make sure that both the beggining and end of the range are numeric.')
|
||||
range = (float(range[0]), float(range[1]))
|
||||
if (range[0] >= range[1]):
|
||||
raise Exception('The end of the range must be greater than the beggining.')
|
||||
return range
|
||||
|
||||
class LevelBuilder:
|
||||
def __init__(self, bottom, top, contours, hierarchy):
|
||||
self.bottom = bottom
|
||||
self.top = top
|
||||
self.contours = contours
|
||||
self.hierarchy = hierarchy
|
||||
|
||||
def build_polygon(self, idx):
|
||||
polygon_contours = [self.contours[idx]]
|
||||
[_, _, child, _] = self.hierarchy[idx]
|
||||
while child >= 0:
|
||||
polygon_contours.append(self.contours[child])
|
||||
next, _, _, _ = self.hierarchy[child]
|
||||
child = next
|
||||
return polygon_contours
|
||||
|
||||
def build_multi_polygon(self):
|
||||
polygons = []
|
||||
idx = 0
|
||||
while idx >= 0:
|
||||
polygons.append(self.build_polygon(idx))
|
||||
[next, _, _, _] = self.hierarchy[idx]
|
||||
idx = next
|
||||
multi_polygon = MultiPolygon(polygons)
|
||||
return Feature(geometry = multi_polygon, properties = { 'bottom': int(self.bottom), 'top': int(self.top) })
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
opts, _ = grass.parser()
|
||||
sys.exit(main())
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "ElevationMap",
|
||||
"webodmMinVersion": "1.1.1",
|
||||
"description": "Calculate and draw an elevation map based on a task's DEMs",
|
||||
"version": "1.0.0",
|
||||
"author": "Nicolas Chamo",
|
||||
"email": "nicolas@chamo.com.ar",
|
||||
"repository": "https://github.com/OpenDroneMap/WebODM",
|
||||
"tags": ["contours", "elevationmap", "dsm", "dem", "dtm"],
|
||||
"homepage": "https://github.com/OpenDroneMap/WebODM",
|
||||
"experimental": false,
|
||||
"deprecated": false
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
from app.plugins import PluginBase
|
||||
from app.plugins import MountPoint
|
||||
from .api import TaskElevationMapGenerate
|
||||
from .api import TaskElevationMapCheck
|
||||
from .api import TaskElevationMapDownload
|
||||
|
||||
class Plugin(PluginBase):
|
||||
def include_js_files(self):
|
||||
return ['main.js']
|
||||
|
||||
def build_jsx_components(self):
|
||||
return ['ElevationMap.jsx']
|
||||
|
||||
def api_mount_points(self):
|
||||
return [
|
||||
MountPoint('task/(?P<pk>[^/.]+)/elevationmap/generate', TaskElevationMapGenerate.as_view()),
|
||||
MountPoint('task/(?P<pk>[^/.]+)/elevationmap/check/(?P<celery_task_id>.+)', TaskElevationMapCheck.as_view()),
|
||||
MountPoint('task/(?P<pk>[^/.]+)/elevationmap/download/(?P<celery_task_id>.+)', TaskElevationMapDownload.as_view()),
|
||||
]
|
|
@ -1,56 +0,0 @@
|
|||
import L from 'leaflet';
|
||||
import ReactDOM from 'ReactDOM';
|
||||
import React from 'React';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ElevationMap.scss';
|
||||
import ElevationMapPanel from './ElevationMapPanel';
|
||||
|
||||
class ElevationMapButton extends React.Component {
|
||||
static propTypes = {
|
||||
tasks: PropTypes.object.isRequired,
|
||||
map: PropTypes.object.isRequired,
|
||||
layersControl: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showPanel: false
|
||||
};
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
this.setState({showPanel: true});
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({showPanel: false});
|
||||
}
|
||||
|
||||
render(){
|
||||
const { showPanel } = this.state;
|
||||
|
||||
return (<div className={showPanel ? "open" : ""}>
|
||||
<a href="javascript:void(0);"
|
||||
onClick={this.handleOpen}
|
||||
className="leaflet-control-elevationmap-button leaflet-bar-part theme-secondary"></a>
|
||||
<ElevationMapPanel map={this.props.map} layersControl={this.props.layersControl} isShowed={showPanel} tasks={this.props.tasks} onClose={this.handleClose} />
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export default L.Control.extend({
|
||||
options: {
|
||||
position: 'topright'
|
||||
},
|
||||
|
||||
onAdd: function (map) {
|
||||
var container = L.DomUtil.create('div', 'leaflet-control-elevationmap leaflet-bar leaflet-control');
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
ReactDOM.render(<ElevationMapButton map={this.options.map} layersControl={this.options.layersControl} tasks={this.options.tasks} />, container);
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
.leaflet-control-elevationmap{
|
||||
z-index: 999;
|
||||
|
||||
a.leaflet-control-elevationmap-button{
|
||||
background: url(icon.png) no-repeat 0 0;
|
||||
background-size: 26px 26px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
div.elevationmap-panel{ display: none; }
|
||||
|
||||
.open{
|
||||
a.leaflet-control-elevationmap-button{
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.elevationmap-panel{
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
.leaflet-touch .leaflet-control-elevationmap a {
|
||||
background-position: 2px 2px;
|
||||
}
|
|
@ -1,431 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Storage from 'webodm/classes/Storage';
|
||||
import L from 'leaflet';
|
||||
import area from '@turf/area'
|
||||
import './ElevationMapPanel.scss';
|
||||
import ErrorMessage from 'webodm/components/ErrorMessage';
|
||||
import ReactTooltip from 'react-tooltip'
|
||||
|
||||
export default class ElevationMapPanel extends React.Component {
|
||||
static defaultProps = {
|
||||
};
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
tasks: PropTypes.object.isRequired,
|
||||
isShowed: PropTypes.bool.isRequired,
|
||||
map: PropTypes.object.isRequired,
|
||||
layersControl: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: "",
|
||||
permanentError: "",
|
||||
interval: Storage.getItem("last_elevationmap_interval") || "5",
|
||||
reference: "Sea",
|
||||
noiseFilterSize: Storage.getItem("last_elevationmap_noise_filter_size") || "3",
|
||||
customNoiseFilterSize: Storage.getItem("last_elevationmap_custom_noise_filter_size") || "3",
|
||||
epsg: Storage.getItem("last_elevationmap_epsg") || "4326",
|
||||
customEpsg: Storage.getItem("last_elevationmap_custom_epsg") || "4326",
|
||||
references: [],
|
||||
loading: true,
|
||||
task: props.tasks[0] || null,
|
||||
previewLoading: false,
|
||||
exportLoading: false,
|
||||
previewLayer: null,
|
||||
opacity: 100,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(){
|
||||
if (this.props.isShowed && this.state.loading){
|
||||
const {id, project} = this.state.task;
|
||||
|
||||
this.loadingReq = $.getJSON(`/api/projects/${project}/tasks/${id}/`)
|
||||
.done(res => {
|
||||
const { available_assets } = res;
|
||||
let references = ['Sea'];
|
||||
|
||||
if (available_assets.indexOf("dsm.tif") === -1)
|
||||
this.setState({permanentError: "No DSM is available. Make sure to process a task with either the --dsm option checked"});
|
||||
if (available_assets.indexOf("dtm.tif") !== -1)
|
||||
references.push("Ground");
|
||||
this.setState({references, reference: references[0]});
|
||||
})
|
||||
.fail(() => {
|
||||
this.setState({permanentError: `Cannot retrieve information for task ${id}. Are you are connected to the internet?`})
|
||||
})
|
||||
.always(() => {
|
||||
this.setState({loading: false});
|
||||
this.loadingReq = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(){
|
||||
if (this.loadingReq){
|
||||
this.loadingReq.abort();
|
||||
this.loadingReq = null;
|
||||
}
|
||||
if (this.generateReq){
|
||||
this.generateReq.abort();
|
||||
this.generateReq = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectInterval = e => {
|
||||
this.setState({interval: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectNoiseFilterSize = e => {
|
||||
this.setState({noiseFilterSize: e.target.value});
|
||||
}
|
||||
|
||||
handleChangeCustomNoiseFilterSize = e => {
|
||||
this.setState({customNoiseFilterSize: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectReference = e => {
|
||||
this.setState({reference: e.target.value});
|
||||
}
|
||||
|
||||
handleChangeCustomInterval = e => {
|
||||
this.setState({customInterval: e.target.value});
|
||||
}
|
||||
|
||||
handleSelectEpsg = e => {
|
||||
this.setState({epsg: e.target.value});
|
||||
}
|
||||
|
||||
handleChangeCustomEpsg = e => {
|
||||
this.setState({customEpsg: e.target.value});
|
||||
}
|
||||
|
||||
getFormValues = () => {
|
||||
const { interval, customInterval, epsg, customEpsg,
|
||||
noiseFilterSize, customNoiseFilterSize, reference } = this.state;
|
||||
return {
|
||||
interval: interval !== "custom" ? interval : customInterval,
|
||||
epsg: epsg !== "custom" ? epsg : customEpsg,
|
||||
noise_filter_size: noiseFilterSize !== "custom" ? noiseFilterSize : customNoiseFilterSize,
|
||||
reference
|
||||
};
|
||||
}
|
||||
|
||||
waitForCompletion = (taskId, celery_task_id, cb) => {
|
||||
let errorCount = 0;
|
||||
|
||||
const check = () => {
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: `/api/plugins/elevationmap/task/${taskId}/elevationmap/check/${celery_task_id}`
|
||||
}).done(result => {
|
||||
if (result.error){
|
||||
cb(result.error);
|
||||
}else if (result.ready){
|
||||
cb();
|
||||
}else{
|
||||
// Retry
|
||||
setTimeout(() => check(), 2000);
|
||||
}
|
||||
}).fail(error => {
|
||||
console.warn(error);
|
||||
if (errorCount++ < 10) setTimeout(() => check(), 2000);
|
||||
else cb(JSON.stringify(error));
|
||||
});
|
||||
};
|
||||
|
||||
check();
|
||||
}
|
||||
|
||||
heatmap_coloring = (value, lowest, highest) => {
|
||||
const ratio = (value - lowest) / (highest - lowest);
|
||||
const h = 315 * (1 - ratio) / 360;
|
||||
const s = 1;
|
||||
const l = 0.5;
|
||||
let r, g, b;
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
const toHex = x => {
|
||||
const hex = Math.round(x * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
addGeoJSONFromURL = (url, cb) => {
|
||||
const { map, layersControl } = this.props;
|
||||
|
||||
$.getJSON(url)
|
||||
.done((geojson) => {
|
||||
try{
|
||||
this.removePreview();
|
||||
|
||||
// Calculating all the elevation levels present
|
||||
const allLevels = geojson.features.map(feature => [feature.properties.bottom, feature.properties.top]).flat().sort((a, b) => a - b);
|
||||
const lowestLevel = allLevels[0];
|
||||
const highestLevel = allLevels[allLevels.length - 1];
|
||||
|
||||
let featureGroup = L.featureGroup();
|
||||
geojson.features.forEach(levelFeature => {
|
||||
const top = levelFeature.properties.top;
|
||||
const bottom = levelFeature.properties.bottom;
|
||||
const rgbHex = this.heatmap_coloring((bottom + top) / 2, lowestLevel, highestLevel);
|
||||
const areaInLevel = area(levelFeature).toFixed(2);
|
||||
let geojsonForLevel = L.geoJSON(levelFeature).setStyle({color: rgbHex, fill: true, fillColor: rgbHex, fillOpacity: 1})
|
||||
.bindPopup(`Altitude: Between ${bottom}m and ${top}m<BR>Area: ${areaInLevel}m2`)
|
||||
.on('popupopen', popup => {
|
||||
// Make all other layers transparent and highlight the clicked one
|
||||
featureGroup.getLayers().forEach(layer => layer.setStyle({ fillOpacity: 0.4 * this.state.opacity}));
|
||||
popup.propagatedFrom.setStyle({ color: "black", fillOpacity: this.state.opacity }).bringToFront()
|
||||
})
|
||||
.on('popupclose', popup => {
|
||||
// Reset all layers to their original state
|
||||
featureGroup.getLayers().forEach(layer => layer.bringToFront().setStyle({ fillOpacity: this.state.opacity }));
|
||||
popup.propagatedFrom.setStyle({ color: rgbHex });
|
||||
});
|
||||
featureGroup.addLayer(geojsonForLevel);
|
||||
});
|
||||
|
||||
featureGroup.geojson = geojson;
|
||||
|
||||
this.setState({ previewLayer: featureGroup });
|
||||
this.state.previewLayer.addTo(map);
|
||||
layersControl.addOverlay(this.state.previewLayer, "Elevation Map");
|
||||
|
||||
cb();
|
||||
}catch(e){
|
||||
cb(e.message);
|
||||
}
|
||||
})
|
||||
.fail(cb);
|
||||
}
|
||||
|
||||
removePreview = () => {
|
||||
const { map, layersControl } = this.props;
|
||||
|
||||
if (this.state.previewLayer){
|
||||
map.removeLayer(this.state.previewLayer);
|
||||
layersControl.removeLayer(this.state.previewLayer);
|
||||
this.setState({previewLayer: null});
|
||||
}
|
||||
}
|
||||
|
||||
generateElevationMap = (data, loadingProp, isPreview) => {
|
||||
this.setState({[loadingProp]: true, error: ""});
|
||||
const taskId = this.state.task.id;
|
||||
|
||||
// Save settings for next time
|
||||
Storage.setItem("last_elevationmap_interval", this.state.interval);
|
||||
Storage.setItem("last_elevationmap_custom_interval", this.state.customInterval);
|
||||
Storage.setItem("last_elevationmap_noise_filter_size", this.state.noiseFilterSize);
|
||||
Storage.setItem("last_elevationmap_custom_noise_filter_size", this.state.customNoiseFilterSize);
|
||||
Storage.setItem("last_elevationmap_epsg", this.state.epsg);
|
||||
Storage.setItem("last_elevationmap_custom_epsg", this.state.customEpsg);
|
||||
|
||||
this.generateReq = $.ajax({
|
||||
type: 'POST',
|
||||
url: `/api/plugins/elevationmap/task/${taskId}/elevationmap/generate`,
|
||||
data: data
|
||||
}).done(result => {
|
||||
if (result.celery_task_id){
|
||||
this.waitForCompletion(taskId, result.celery_task_id, error => {
|
||||
if (error) this.setState({[loadingProp]: false, 'error': error});
|
||||
else{
|
||||
const fileUrl = `/api/plugins/elevationmap/task/${taskId}/elevationmap/download/${result.celery_task_id}`;
|
||||
|
||||
// Preview
|
||||
if (isPreview){
|
||||
this.addGeoJSONFromURL(fileUrl, e => {
|
||||
if (e) this.setState({error: JSON.stringify(e)});
|
||||
this.setState({[loadingProp]: false});
|
||||
});
|
||||
}else{
|
||||
// Download
|
||||
location.href = fileUrl;
|
||||
this.setState({[loadingProp]: false});
|
||||
}
|
||||
}
|
||||
});
|
||||
}else if (result.error){
|
||||
this.setState({[loadingProp]: false, error: result.error});
|
||||
}else{
|
||||
this.setState({[loadingProp]: false, error: "Invalid response: " + result});
|
||||
}
|
||||
}).fail(error => {
|
||||
this.setState({[loadingProp]: false, error: JSON.stringify(error)});
|
||||
});
|
||||
}
|
||||
|
||||
handleExport = (format) => {
|
||||
return () => {
|
||||
const data = this.getFormValues();
|
||||
data.format = format;
|
||||
this.generateElevationMap(data, 'exportLoading', false);
|
||||
};
|
||||
}
|
||||
|
||||
handleShowPreview = () => {
|
||||
this.setState({previewLoading: true});
|
||||
|
||||
const data = this.getFormValues();
|
||||
data.epsg = 4326;
|
||||
data.format = "GeoJSON";
|
||||
this.generateElevationMap(data, 'previewLoading', true);
|
||||
}
|
||||
|
||||
handleChangeOpacity = (evt) => {
|
||||
const opacity = parseFloat(evt.target.value) / 100;
|
||||
this.setState({opacity: opacity});
|
||||
this.state.previewLayer.setStyle({ opacity: opacity, fillOpacity: opacity });
|
||||
this.props.map.closePopup();
|
||||
}
|
||||
|
||||
render(){
|
||||
const { loading, task, references, error, permanentError, interval, reference,
|
||||
epsg, customEpsg, exportLoading,
|
||||
noiseFilterSize, customNoiseFilterSize,
|
||||
previewLoading, previewLayer, opacity} = this.state;
|
||||
const noiseFilterSizeValues = [{label: 'Do not filter noise', value: 0},
|
||||
{label: 'Normal', value: 3},
|
||||
{label: 'Aggressive', value: 5}];
|
||||
|
||||
const disabled = (epsg === "custom" && !customEpsg) ||
|
||||
(noiseFilterSize === "custom" && !customNoiseFilterSize);
|
||||
|
||||
let content = "";
|
||||
if (loading) content = (<span><i className="fa fa-circle-notch fa-spin"></i> Loading...</span>);
|
||||
else if (permanentError) content = (<div className="alert alert-warning">{permanentError}</div>);
|
||||
else{
|
||||
content = (<div>
|
||||
<ErrorMessage bind={[this, "error"]} />
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Interval:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="text" className="form-control" value={interval} onChange={this.handleSelectInterval} /><span></span>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You have two options:<br/>• Insert your custom elevation intervals, in the form: 10-15,20-30. <br/>• Insert a number (for example 5) and the intervals will be auto generated every 5 meters based on the elevation data." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Reference:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={reference} onChange={this.handleSelectReference}>
|
||||
{references.map(r => <option value={r}>{r}</option>)}
|
||||
</select>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You can determine if the intervals specified above will be based on the sea level, or on the ground.<br/>Take into account that in order to be able to select 'ground' you need to have run the task with the --dtm option." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Noise Filter:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={noiseFilterSize} onChange={this.handleSelectNoiseFilterSize}>
|
||||
{noiseFilterSizeValues.map(sv => <option value={sv.value}>{sv.label} ({sv.value} meter)</option>)}
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="You can determine the diameter of the area used to filter noise." />
|
||||
</div>
|
||||
</div>
|
||||
{noiseFilterSize === "custom" ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Value:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={customNoiseFilterSize} onChange={this.handleChangeCustomNoiseFilterSize} /><span> meter</span>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Projection:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<select className="form-control" value={epsg} onChange={this.handleSelectEpsg}>
|
||||
<option value="4326">WGS84 (EPSG:4326)</option>
|
||||
<option value="3857">Web Mercator (EPSG:3857)</option>
|
||||
<option value="custom">Custom EPSG</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{epsg === "custom" ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">EPSG:</label>
|
||||
<div className="col-sm-9 ">
|
||||
<input type="number" className="form-control custom-interval" value={customEpsg} onChange={this.handleChangeCustomEpsg} />
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
{previewLayer ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">Opacity:</label>
|
||||
<div className="col-sm-9">
|
||||
<input type="range" className="slider" step="1" value={opacity * 100} onChange={this.handleChangeOpacity} />
|
||||
<p className="glyphicon glyphicon-info-sign help" data-tip="Control the opacity of the elevation map. You must generate a preview to be able to control the opacity." />
|
||||
<ReactTooltip place="left" effect="solid" html={true}/>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
<div className="row action-buttons">
|
||||
<div className="col-sm-9 text-right">
|
||||
<button onClick={this.handleShowPreview}
|
||||
disabled={disabled || previewLoading} type="button" className="btn btn-sm btn-primary btn-preview">
|
||||
{previewLoading ? <i className="fa fa-spin fa-circle-notch"/> : <i className="glyphicon glyphicon-eye-open"/>} Preview
|
||||
</button>
|
||||
|
||||
<div className="btn-group">
|
||||
<button disabled={disabled || exportLoading} type="button" className="btn btn-sm btn-primary" data-toggle="dropdown">
|
||||
{exportLoading ? <i className="fa fa-spin fa-circle-notch"/> : <i className="glyphicon glyphicon-download" />} Export
|
||||
</button>
|
||||
<button disabled={disabled|| exportLoading} type="button" className="btn btn-sm dropdown-toggle btn-primary" data-toggle="dropdown"><span className="caret"></span></button>
|
||||
<ul className="dropdown-menu pull-right">
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("GPKG")}>
|
||||
<i className="fa fa-globe fa-fw"></i> GeoPackage (.GPKG)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("DXF")}>
|
||||
<i className="fa fa-file fa-fw"></i> AutoCAD (.DXF)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("GeoJSON")}>
|
||||
<i className="fa fa-code fa-fw"></i> GeoJSON (.JSON)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:void(0);" onClick={this.handleExport("ESRI Shapefile")}>
|
||||
<i className="fa fa-file-archive fa-fw"></i> ShapeFile (.SHP)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReactTooltip place="left" effect="solid" html={true}/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (<div className="elevationmap-panel">
|
||||
<span className="close-button" onClick={this.props.onClose}/>
|
||||
<div className="title">Elevation Map</div>
|
||||
<hr/>
|
||||
{content}
|
||||
</div>);
|
||||
}
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
.leaflet-control-elevationmap .elevationmap-panel{
|
||||
padding: 6px 10px 6px 6px;
|
||||
background: #fff;
|
||||
min-width: 250px;
|
||||
max-width: 300px;
|
||||
|
||||
.close-button{
|
||||
display: inline-block;
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAAAkUlEQVR4AZWRxQGDUBAFJ9pMflNIP/iVSkIb2wgccXd7g7O+3JXCQUgqBAfFSl8CMooJGQHfuUlEwZpoahZQ7ODTSXWJQkxyioock7BL2tXmdF4moJNX6IDZfbUBQNrX7qfeXfPuqwBAQjEz60w64htGJ+luFH48gt+NYe6v5b/cnr9asM+HlRQ2Qlwh2CjuqQQ9vKsKTwhQ1wAAAABJRU5ErkJggg==);
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
margin-right: 0;
|
||||
float: right;
|
||||
vertical-align: middle;
|
||||
text-align: right;
|
||||
margin-top: 0px;
|
||||
margin-left: 16px;
|
||||
position: relative;
|
||||
left: 2px;
|
||||
|
||||
&:hover{
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.title{
|
||||
font-size: 120%;
|
||||
margin-right: 60px;
|
||||
}
|
||||
|
||||
hr{
|
||||
clear: both;
|
||||
margin: 6px 0px;
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
label{
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
select, input{
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
input.custom-interval{
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
*{
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row.form-group.form-inline{
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dropdown-menu{
|
||||
a{
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: block;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-preview{
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.action-buttons{
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.help {
|
||||
margin-left: 4px;
|
||||
top: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
padding: 0px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
Plik binarny nie jest wyświetlany.
Przed Szerokość: | Wysokość: | Rozmiar: 4.9 KiB |
|
@ -1,14 +0,0 @@
|
|||
PluginsAPI.Map.didAddControls([
|
||||
'elevationmap/build/ElevationMap.js',
|
||||
'elevationmap/build/ElevationMap.css'
|
||||
], function(args, ElevationMap){
|
||||
var tasks = [];
|
||||
for (var i = 0; i < args.tiles.length; i++){
|
||||
tasks.push(args.tiles[i].meta.task);
|
||||
}
|
||||
|
||||
// TODO: add support for map view where multiple tasks are available?
|
||||
if (tasks.length === 1){
|
||||
args.map.addControl(new ElevationMap({map: args.map, layersControl: args.controls.autolayers, tasks: tasks}));
|
||||
}
|
||||
});
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"name": "elevationmap",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@turf/turf": "^5.1.6",
|
||||
"react-tooltip": "^3.10.0"
|
||||
}
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
geojson==2.4.1
|
||||
opencv-python-headless==4.4.0.46
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "Lightning Network Bridge",
|
||||
"name": "Lightning",
|
||||
"webodmMinVersion": "0.7.1",
|
||||
"description": "Sync accounts from webodm.net",
|
||||
"description": "Process in the cloud with webodm.net",
|
||||
"version": "0.9.0",
|
||||
"author": "Piero Toffanin",
|
||||
"email": "pt@masseranolabs.com",
|
||||
|
|
|
@ -22,7 +22,7 @@ def JsonResponse(dict):
|
|||
|
||||
class Plugin(PluginBase):
|
||||
def main_menu(self):
|
||||
return [Menu(_("Lightning Network"), self.public_url(""), "fa fa-bolt fa-fw")]
|
||||
return [Menu(_("Lightning"), self.public_url(""), "fa fa-bolt fa-fw")]
|
||||
|
||||
def include_js_files(self):
|
||||
return ['add_cost_estimate.js']
|
||||
|
@ -36,7 +36,7 @@ class Plugin(PluginBase):
|
|||
uds = UserDataStore('lightning', request.user)
|
||||
|
||||
return render(request, self.template_path("index.html"), {
|
||||
'title': _('Lightning Network'),
|
||||
'title': _('Lightning'),
|
||||
'api_key': uds.get_string("api_key")
|
||||
})
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ export default class LightningPanel extends React.Component {
|
|||
return (<div className="plugin-lightning">
|
||||
{ !apiKey ?
|
||||
<div>
|
||||
<h4><i className="fa fa-bolt"/> {_("Lightning Network")}</h4>
|
||||
<h4><i className="fa fa-bolt"/> {_("Lightning")}</h4>
|
||||
{_("Lightning is a service that allows you to quickly process small and large datasets using high performance servers in the cloud.")}
|
||||
<Trans params={{ link: '<a href="https://webodm.net" target="_blank">webodm.net</a>', register: `<a href="https://webodm.net/register" target="_blank">${_("register")}</a>`}}>
|
||||
{_("Below you can enter your %(link)s credentials to sync your account and automatically setup a new processing node. If you don't have an account, you can %(register)s for free.")}</Trans>
|
||||
|
|
|
@ -1,24 +1,18 @@
|
|||
import os
|
||||
import json
|
||||
import math
|
||||
from rest_framework import serializers
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
import rasterio
|
||||
|
||||
from app.api.workers import GetTaskResult, TaskResultOutputError, CheckTask
|
||||
from app.models import Task
|
||||
from app.plugins.views import TaskView
|
||||
|
||||
from worker.tasks import execute_grass_script
|
||||
|
||||
from app.plugins.grass_engine import grass, GrassEngineException, cleanup_grass_context
|
||||
from geojson import Feature, Point, FeatureCollection
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from app.plugins.worker import run_function_async
|
||||
|
||||
class GeoJSONSerializer(serializers.Serializer):
|
||||
area = serializers.JSONField(help_text="Polygon contour defining the volume area to compute")
|
||||
from .volume import calc_volume
|
||||
|
||||
class VolumeRequestSerializer(serializers.Serializer):
|
||||
area = serializers.JSONField(help_text="GeoJSON Polygon contour defining the volume area to compute")
|
||||
method = serializers.CharField(help_text="One of: [plane,triangulate,average,custom,highest,lowest]", default="triangulate", allow_blank=True)
|
||||
|
||||
class TaskVolume(TaskView):
|
||||
def post(self, request, pk=None):
|
||||
|
@ -26,56 +20,26 @@ class TaskVolume(TaskView):
|
|||
if task.dsm_extent is None:
|
||||
return Response({'error': _('No surface model available. From the Dashboard, select this task, press Edit, from the options make sure to check "dsm", then press Restart --> From DEM.')})
|
||||
|
||||
serializer = GeoJSONSerializer(data=request.data)
|
||||
serializer = VolumeRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
area = serializer['area'].value
|
||||
points = FeatureCollection([Feature(geometry=Point(coords)) for coords in area['geometry']['coordinates'][0]])
|
||||
method = serializer['method'].value
|
||||
points = [coord for coord in area['geometry']['coordinates'][0]]
|
||||
dsm = os.path.abspath(task.get_asset_download_path("dsm.tif"))
|
||||
|
||||
try:
|
||||
context = grass.create_context({'auto_cleanup': False})
|
||||
context.add_file('area_file.geojson', json.dumps(area))
|
||||
context.add_file('points_file.geojson', str(points))
|
||||
context.add_param('dsm_file', dsm)
|
||||
context.set_location(dsm)
|
||||
|
||||
celery_task_id = execute_grass_script.delay(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"calc_volume.py"
|
||||
), context.serialize()).task_id
|
||||
|
||||
try:
|
||||
celery_task_id = run_function_async(calc_volume, input_dem=dsm, pts=points, pts_epsg=4326, base_method=method).task_id
|
||||
return Response({'celery_task_id': celery_task_id}, status=status.HTTP_200_OK)
|
||||
except GrassEngineException as e:
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_200_OK)
|
||||
|
||||
class TaskVolumeCheck(CheckTask):
|
||||
def on_error(self, result):
|
||||
cleanup_grass_context(result['context'])
|
||||
pass
|
||||
|
||||
class TaskVolumeResult(GetTaskResult):
|
||||
def get(self, request, pk=None, celery_task_id=None):
|
||||
task = Task.objects.only('dsm_extent').get(pk=pk)
|
||||
return super().get(request, celery_task_id, task=task)
|
||||
|
||||
def handle_output(self, output, result, task):
|
||||
cleanup_grass_context(result['context'])
|
||||
|
||||
cols = output.split(':')
|
||||
if len(cols) == 7:
|
||||
# Legacy: we had rasters in EPSG:3857 for a while
|
||||
# This could be removed at some point in the future
|
||||
# Correct scale measurement for web mercator
|
||||
# https://gis.stackexchange.com/questions/93332/calculating-distance-scale-factor-by-latitude-for-mercator#93335
|
||||
scale_factor = 1.0
|
||||
dsm = os.path.abspath(task.get_asset_download_path("dsm.tif"))
|
||||
with rasterio.open(dsm) as dst:
|
||||
if str(dst.crs) == 'EPSG:3857':
|
||||
latitude = task.dsm_extent.centroid[1]
|
||||
scale_factor = math.cos(math.radians(latitude)) ** 2
|
||||
|
||||
volume = abs(float(cols[6]) * scale_factor)
|
||||
return str(volume)
|
||||
else:
|
||||
raise TaskResultOutputError(output)
|
||||
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
#%module
|
||||
#% description: Calculate volume of area and prints the volume to stdout
|
||||
#%end
|
||||
#%option
|
||||
#% key: area_file
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Geospatial file containing the area to measure
|
||||
#%end
|
||||
#%option
|
||||
#% key: points_file
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: Geospatial file containing the points defining the area
|
||||
#%end
|
||||
#%option
|
||||
#% key: dsm_file
|
||||
#% type: string
|
||||
#% required: yes
|
||||
#% multiple: no
|
||||
#% description: GeoTIFF DEM containing the surface
|
||||
#%end
|
||||
|
||||
import sys
|
||||
from grass.pygrass.modules import Module
|
||||
import grass.script as grass
|
||||
|
||||
def main():
|
||||
# Import raster and vector
|
||||
Module("v.import", input=opts['area_file'], output="polygon_area", overwrite=True)
|
||||
Module("v.import", input=opts['points_file'], output="polygon_points", overwrite=True)
|
||||
Module("v.buffer", input="polygon_area", s=True, type="area", output="region", distance=1, minordistance=1, overwrite=True)
|
||||
Module("r.external", input=opts['dsm_file'], output="dsm", overwrite=True)
|
||||
|
||||
# Set Grass region and resolution to DSM
|
||||
Module("g.region", raster="dsm")
|
||||
|
||||
# Set Grass region to vector bbox
|
||||
Module("g.region", vector="region")
|
||||
|
||||
# Create a mask to speed up computation
|
||||
Module("r.mask", vector="region")
|
||||
|
||||
# Transfer dsm raster data to vector
|
||||
Module("v.what.rast", map="polygon_points", raster="dsm", column="height")
|
||||
|
||||
# Decimate DSM and generate interpolation of new terrain
|
||||
Module("v.surf.rst", input="polygon_points", zcolumn="height", elevation="dsm_below_pile", smooth=0, overwrite=True)
|
||||
|
||||
# Compute difference between dsm and new dsm
|
||||
Module("r.mapcalc", expression='pile_height_above_dsm=dsm-dsm_below_pile', overwrite=True)
|
||||
|
||||
# Update region and mask to polygon area to calculate volume
|
||||
Module("g.region", vector="polygon_area")
|
||||
Module("r.mask", vector="polygon_area", overwrite=True)
|
||||
|
||||
# Volume output from difference
|
||||
Module("r.volume", input="pile_height_above_dsm", f=True)
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
opts, _ = grass.parser()
|
||||
sys.exit(main())
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue