Porównaj commity

...

183 Commity

Autor SHA1 Wiadomość Data
Stephen Mather fa058e7567
Merge pull request #1502 from smathermather/drop-gitter
Update README.md to reflect no gitter
2024-05-14 23:23:03 -04:00
Stephen Mather a60dec4e69
Update README.md to reflect no gitter 2024-05-14 23:22:25 -04:00
Piero Toffanin c098b976c6 Bump measure plugin version 2024-05-13 15:20:17 -04:00
Piero Toffanin 32ba4ed707
Merge pull request #1496 from pierotofy/mapapi
Imperial units support, improvements, fixes, plugins API expansion
2024-05-13 15:02:46 -04:00
Piero Toffanin 7272ca55bc Update manifests 2024-05-13 14:35:59 -04:00
Piero Toffanin e1cb0c83bb Update locale strings 2024-05-13 13:56:56 -04:00
Piero Toffanin 7ab95bc8b1 Measure plugin support for imperial units 2024-05-13 13:54:49 -04:00
Piero Toffanin 80a7f2048d Potree units sync 2024-05-13 13:04:56 -04:00
Piero Toffanin e9c2409ea9 Bump version 2024-05-11 17:19:32 -04:00
Piero Toffanin 9ee58f7216 Reformat 2024-05-11 17:18:54 -04:00
Piero Toffanin 289ef48b12 Speed up DSM/DTM tiler 2024-05-11 17:08:53 -04:00
Piero Toffanin 8468fdff5c Refactor get_asset_file_or_stream 2024-05-11 15:01:26 -04:00
Piero Toffanin 35fc60aa2c Elevation layer units update works 2024-05-11 12:51:37 -04:00
Piero Toffanin 57ccd23234 Fix login next redirect 2024-05-11 12:43:40 -04:00
Piero Toffanin 4e2ffbb768 PoC elevation histogram imperial units display 2024-05-10 22:01:37 -04:00
Piero Toffanin 3b55ebd3e5 Add unit options 2024-05-10 21:24:34 -04:00
Piero Toffanin 1fc7e11c86 Contours plugin imperial units export/preview working 2024-05-10 13:44:12 -04:00
Piero Toffanin d76eacabd3 Contours imperial preview working 2024-05-09 15:46:30 -04:00
Piero Toffanin 75678b7a84 Fix unit selector 2024-05-09 14:03:23 -04:00
Piero Toffanin 681482983c Add volume units 2024-05-09 12:35:29 -04:00
Piero Toffanin adf9c7dc5f Add US imperial 2024-05-09 11:08:51 -04:00
Piero Toffanin ece6bba200 Moar unit tests 2024-05-09 09:27:10 -04:00
Piero Toffanin d46b582dcd Merge branch 'master' of https://github.com/OpenDroneMap/WebODM into mapapi 2024-05-08 12:13:21 -04:00
Piero Toffanin dd6b46a2c9
Merge pull request #1499 from pierotofy/upcheck
Add file size check on upload
2024-05-08 11:56:56 -04:00
Piero Toffanin 5375eb8a19 Add file size check on upload 2024-05-08 11:29:35 -04:00
Piero Toffanin e0eb7cad7e Add units tests 2024-05-08 10:48:29 -04:00
Piero Toffanin 9a8013d6ce Revert debug commit 2024-05-07 11:56:33 -04:00
Piero Toffanin f0cd13a464 Merge branch 'master' of https://github.com/OpenDroneMap/WebODM 2024-05-07 11:54:42 -04:00
Piero Toffanin 5c663b8435 More upload retries, don't update totalCount on failure 2024-05-07 11:54:33 -04:00
Piero Toffanin 30eff78d3b Units work 2024-05-07 11:53:21 -04:00
Piero Toffanin 9af1ee018b Merge branch 'master' of https://github.com/OpenDroneMap/WebODM into mapapi 2024-05-07 09:46:57 -04:00
Piero Toffanin 568e80a941
Merge pull request #1498 from pierotofy/kmzfix
Use export format AUTO for KMZ
2024-05-06 00:33:50 -04:00
Piero Toffanin cb173bd48c Use export format AUTO for KMZ 2024-05-06 00:24:32 -04:00
Piero Toffanin b86411c298
Merge pull request #1497 from pierotofy/upfix
Fix file size check on upload error retry
2024-05-05 16:10:07 -04:00
Piero Toffanin 06ccd29d09 Fix file size check on upload error retry 2024-05-05 15:12:39 -04:00
Piero Toffanin bf24be7b72 Started adding app-wide unit selector logic 2024-05-02 16:47:47 -04:00
Piero Toffanin d73558256a Cleaner map controls, fix opacity label alignment 2024-05-02 16:17:06 -04:00
Piero Toffanin 59e104946c Better error message on worker failure 2024-05-02 16:08:41 -04:00
Piero Toffanin 972b06d03b Fix triangle icon, bump version 2024-05-01 15:43:32 -04:00
Piero Toffanin 2352d838cf Shorten Lightning Network --> Lightning 2024-05-01 14:01:40 -04:00
Piero Toffanin e7337f3b5d Silence annoying React deprecation notice of useful functionality 2024-05-01 13:39:10 -04:00
Piero Toffanin a44c2ce86f Add isMobile function 2024-05-01 12:34:47 -04:00
Piero Toffanin 6f5d68d6ed Expand Map JS API 2024-04-30 19:17:28 -04:00
Piero Toffanin 04af329f78 Fix invalid PropType 2024-04-27 11:28:19 -04:00
Piero Toffanin cc2b7d5265 Fix build_plugins on Windows 2024-04-20 13:55:25 -04:00
Piero Toffanin 10a44851ac
Merge pull request #1485 from pierotofy/autoperc
Ability to share particular map type
2024-04-04 11:24:10 -04:00
Piero Toffanin c2c65a2cc2 Ability to share a particular map type 2024-04-04 10:46:53 -04:00
Piero Toffanin b1fdcbb242
Merge pull request #1484 from pierotofy/autoperc
Automatically select 2/98 percentile values in plant health
2024-04-04 10:39:29 -04:00
Piero Toffanin 02b4132daa Better histogram direct input 2024-04-04 10:09:45 -04:00
Piero Toffanin ed6a43699f Automatically select 2/98 percentile values in plant health 2024-04-04 09:46:36 -04:00
Piero Toffanin e8f8f2f130
Merge pull request #1475 from Zuline/patch-2
Update README.md
2024-02-29 21:53:16 +01:00
Zuline 57223fc539
Update README.md
Fixed a missed closing bracket in a new section of Readme.md
2024-02-29 07:40:21 +11:00
Piero Toffanin 9a3123c878
Merge pull request #1474 from Zuline/patch-1
Update README.md
2024-02-28 18:37:33 +01:00
Zuline 860942b80f
Update README.md
Added instructions to the Common Troubleshooting section for adding images to All Assets downloaded from Lightning.
2024-02-28 08:46:29 +11:00
Piero Toffanin 4cd3618442
Merge pull request #1472 from gonzalo-bulnes/fix-user-facing-misspellings
Fix user-facing misspelling, improve a few user-facing strings
2024-02-16 09:58:46 -05:00
Gonzalo Bulnes Guilpain 9209b52a19
Fix user-facing misspellings
- Missing letter.

- Both '.json' and 'JSON' are used in other strings,
  but '.JSON' doesn't seem as clear.

- Fixed capitalization and added verb to ensure the sentence is readable.
  I don't think that reproducing the error message exactly
  helps making it more readable in this case, because it is unlikely
  that people reading it will recognize the capitals in the middle
  of the sentence as an exact quote of some program's output.
2024-02-16 11:51:44 +00:00
Piero Toffanin 8150b822d9
Merge pull request #1468 from gonzalo-bulnes/add-task-id-to-expanded-list-item
Add task ID to expanded list item
2024-02-13 10:47:30 -05:00
Gonzalo Bulnes Guilpain 02b4061379
Add task ID to expanded list item
This ID is useful to locate the task directory for inspection.

Because it is fairly long, it seemed better to keep it relatively
low in the list of properties to avoid making the essential information
more difficult to read or skim through.

Yet placing it above the task output minimizes movement when the console
is toggled open.

See https://community.opendronemap.org/t/19285
2024-02-13 12:52:55 +00:00
Piero Toffanin 206edf1087
Merge pull request #1467 from gonzalo-bulnes/fix-plugin-error-shadowing
Fix error shadowing in log when plugin fails to load
2024-02-08 23:59:22 -05:00
Gonzalo Bulnes Guilpain 7c9b1da92a
Fix error shadowing in log when plugin fails to load
I didn't find a standard way of unwrapping the error,
short of printing an entire stack trace. I don't think printing
a stack trace in a log is useful, so I decided to print
the error.__cause__ explicitly.

Since there is always one single level of nesting in this code,
I think that's OK.

See https://docs.python.org/3/library/exceptions.html
2024-02-08 22:07:15 -05:00
Piero Toffanin a1331d0b0b
Update README.md 2024-02-02 12:16:31 -05:00
Saijin-Naib 864055b0db
Merge pull request #1460 from flyinmryan/patch-1 2024-01-16 13:47:06 -05:00
Mike Ryan faae70991a
Fixed typo in README.md 2024-01-16 10:39:16 -08:00
Piero Toffanin 712705d35a More CSS fixes 2024-01-09 15:32:40 -05:00
Piero Toffanin 863bf2477c Merge branch 'theme' 2024-01-09 15:02:43 -05:00
Piero Toffanin 94851d2ed3 Fix navbar top 2024-01-09 15:02:36 -05:00
Piero Toffanin d415b12806
Merge pull request #1459 from pierotofy/theme
Remove django-compressor
2024-01-09 13:30:21 -05:00
Piero Toffanin ccabacb3dc Remove theme.scss 2024-01-09 13:07:41 -05:00
Piero Toffanin d2755db412 remove libsass 2024-01-09 13:07:17 -05:00
Piero Toffanin 59195124a0 Fix test 2024-01-09 12:54:04 -05:00
Piero Toffanin 362ba7fc9b CSS fixes 2024-01-09 12:36:57 -05:00
Piero Toffanin b3f5b2de5d Vanilla css fix 2024-01-09 12:22:35 -05:00
Piero Toffanin f227bdb08b Remove django-compressor 2024-01-09 12:19:24 -05:00
Piero Toffanin 15ce002d78
Merge pull request #1458 from douw/patch-1
Update README.md
2024-01-04 11:07:43 -05:00
douw a41d4ceff7
Update README.md
The instructions for running the the docker image as a Linux service is incorrect (possibly referring to running WebODM natively). These rewritten instructions refers to how I got WebODM Docker running on my RHEL 9 system
2024-01-04 14:40:15 +02:00
Piero Toffanin be8bd6e7ee
Merge pull request #1439 from chris-bateman/master
Upgrade Node 14 to Node 20
2024-01-02 14:56:32 -05:00
Piero Toffanin 913db4c52b Linear backoff in file upload retries 2023-12-21 09:00:12 -05:00
Piero Toffanin 7bba3a38df
Merge pull request #1447 from pierotofy/pyodmb
Update PyODM
2023-12-18 14:26:03 -05:00
Piero Toffanin 136d270666 Update PyODM 2023-12-18 11:57:59 -05:00
Piero Toffanin 448a2cb2d6
Merge pull request #1443 from pierotofy/workercpu
Add --worker-cpus option
2023-12-04 23:47:07 -05:00
Piero Toffanin ae08c10ec7 Typo 2023-12-04 23:41:51 -05:00
Piero Toffanin bc0c4ac3e0 Add --worker-cpus option 2023-12-04 23:40:23 -05:00
chris-bateman 8ff81f74c4 Buffer Polyfill for WP5 2023-11-27 10:22:38 +11:00
chris-bateman 5ca0cf82db bump to node 20
bump to node 20
2023-11-23 12:54:42 +11:00
chris-bateman dae49a7b2c added buffer package and version change
Added buffer as webpack 5 does not use it anymore.
Version change in package.json
2023-11-23 12:32:35 +11:00
chris-bateman 6ea180dd43 Bump to node 18
Upgraded to node 18 and Webpack 5.
Package cleanup for webpack 5
2023-11-21 10:22:00 +11:00
chris-bateman 3f42eaa824 Upgrade Node 14 to Node 16
Upgrade Node from 14 to 16 and associated packages and WebPack config
2023-11-20 10:37:01 +11:00
Piero Toffanin 24f3b38dce
Merge pull request #1437 from pierotofy/potrec
Record Movie with Potree
2023-11-15 19:12:07 -05:00
Piero Toffanin 0b2e697c15 Potree: record movie (codec update) 2023-11-15 17:04:40 -05:00
Piero Toffanin 9af93014cc Potree: record movie 2023-11-15 17:00:20 -05:00
Piero Toffanin d4dcf3fed1 Potree: remove last camera animation 2023-11-15 16:24:13 -05:00
Piero Toffanin 425710862f
Merge pull request #1436 from pierotofy/volimp
Fix Potree polygon clipping
2023-11-14 19:24:33 -05:00
Piero Toffanin 4a529baace Potree: fix polygon clipping 2023-11-14 19:23:31 -05:00
Piero Toffanin e8ae09b96f
Merge pull request #1435 from pierotofy/volimp
10x volume calculations, base surface definitions. fix 3D vertical area measurements
2023-11-14 18:38:49 -05:00
Piero Toffanin c4b59721a3 Update locale 2023-11-14 17:58:39 -05:00
Piero Toffanin 9880d461d4 Update locales 2023-11-14 17:57:01 -05:00
Piero Toffanin a78a2e4206 Potree: fix vertical area measurements 2023-11-14 17:38:01 -05:00
Piero Toffanin fd7721ee6b Fix GeoJSON export for points/linestring measurements 2023-11-14 17:17:05 -05:00
Piero Toffanin c28d00f0b0 10x volume calculations, remove grass dependency 2023-11-14 16:10:16 -05:00
Piero Toffanin 5a94579a8e
Merge pull request #1432 from pierotofy/dsmonly
Allow display of 2D maps which have only a DSM
2023-11-11 17:56:36 -05:00
Piero Toffanin 512314ba67 Allow display of 2D maps which have only a DSM 2023-11-11 17:28:12 -05:00
Piero Toffanin 782d6ed7a9
Update issue-triage.yml 2023-11-10 10:02:26 -05:00
Piero Toffanin bf76edf4c0
Update issue-triage.yml 2023-11-10 09:31:55 -05:00
Piero Toffanin 824c88cd54 Rename issue triage 2023-11-07 17:36:13 -05:00
Piero Toffanin d0dc128ca9 Fix labels 2023-11-07 17:23:33 -05:00
Piero Toffanin c4c9ed9598 Add issue triage automation 2023-11-07 17:21:12 -05:00
Piero Toffanin 5fc886028e Automatically expand task in project if there's a single task 2023-11-06 16:29:51 -05:00
Piero Toffanin 8976aa51e3 Merge branch 'master' of https://github.com/OpenDroneMap/WebODM 2023-11-06 16:06:11 -05:00
Piero Toffanin 5e7ef34290 Bye dd 2023-11-06 16:05:42 -05:00
Piero Toffanin 9ea217c218
Merge pull request #1426 from pierotofy/upimp
Cancel upload improvements, automatic task cleanup, configurable doc links
2023-11-06 12:45:26 -05:00
Piero Toffanin ce9cd0a8b9 Add configurable docs/task options links 2023-11-06 12:19:41 -05:00
Piero Toffanin 58dcc46e40 Cleanup partial tasks 2023-11-06 11:22:39 -05:00
Piero Toffanin 9fb0c6db67 Cleanup partial tasks 2023-11-06 11:21:10 -05:00
Piero Toffanin 6b4230f233 Improve cancel task logic 2023-11-06 10:35:02 -05:00
Piero Toffanin bd183c6455
Update forest preset 2023-10-25 22:36:18 -04:00
Piero Toffanin 6bc90025aa
Merge pull request #1420 from pierotofy/structfix
Catch piexif.dump exceptions
2023-10-22 13:24:35 -04:00
Piero Toffanin bcc0f24fd5 Catch piexif.dump exceptions 2023-10-22 13:23:21 -04:00
Piero Toffanin 918ec48e6d
Merge pull request #1417 from rion-saeon/patch-noTGI
Removed TGI plant health equation
2023-10-18 10:45:37 -04:00
Rion Lerm 18cab57e9f
I realised the TGI equation may be erroneous thsu I removed it. I am surprised that no one questioned it before so hopefully it was not used much. 2023-10-17 16:34:32 +02:00
Saijin-Naib 38ba80b6ec
Merge pull request #1415 from allandaly/allandaly-readme-patch 2023-10-08 11:04:40 -04:00
allandaly a5cbac0ff8
Update README.md to use correct links to plugins directory.
Looks like the plugins directory changed from /plugins at some point to /coreplugins so this is a simple update to a few links in the readme.
2023-10-08 07:53:34 -07:00
Piero Toffanin 295bf3f99a
Merge pull request #1413 from pierotofy/pagination
Fix paginator overflow
2023-10-05 15:08:23 -04:00
Piero Toffanin bc86c7977b Add max number of pages in paginator 2023-10-05 12:47:28 -04:00
Piero Toffanin 2d5a403109
Merge pull request #1412 from pierotofy/autobands
Adds support for automatically selecting the proper band filter
2023-10-04 16:33:25 -04:00
Piero Toffanin 49c9f2d7b8 Fix test 2023-10-04 15:51:53 -04:00
Piero Toffanin 62d5185a79 Fix test 2023-10-04 13:39:16 -04:00
Piero Toffanin f67f435a1c Ignore celery results when appropriate 2023-10-04 13:34:31 -04:00
Piero Toffanin 13121566ad Update README 2023-10-04 13:13:32 -04:00
Piero Toffanin 80dcff41ca Add unit tests 2023-10-04 13:04:39 -04:00
Piero Toffanin 4897d4e52a Fix var override 2023-10-03 22:37:10 -04:00
Piero Toffanin 44b2495291 Fix raster export 2023-10-03 15:41:05 -04:00
Piero Toffanin b68e622234 Update locales 2023-10-03 15:31:06 -04:00
Piero Toffanin 43b24eb8b6 Bump version 2023-10-03 15:19:28 -04:00
Piero Toffanin 530720b699 Adds support for automatically selecting the proper band filter 2023-10-03 15:13:56 -04:00
Piero Toffanin 474e2d844b Add RGNRe 2023-10-02 10:19:16 -04:00
Piero Toffanin 1f75945fbb
Merge pull request #1411 from pierotofy/borg
Update formulas.py
2023-10-02 10:12:17 -04:00
Piero Toffanin d5e597fee8 Update formulas 2023-10-02 10:10:48 -04:00
Piero Toffanin 36e98818d3
Merge pull request #1407 from pierotofy/borg
Add borg backup media pattern generator
2023-09-28 17:00:57 -04:00
Piero Toffanin d9736cf11f Add borg backup media pattern generator 2023-09-28 16:48:47 -04:00
Piero Toffanin 74e41077cc
Merge pull request #1406 from pierotofy/logs
Disable logging
2023-09-27 19:52:27 -04:00
Piero Toffanin 0501938d61 Disable logging 2023-09-27 16:43:25 -04:00
Piero Toffanin 9b49ad777d
Merge pull request #1405 from pierotofy/contours
GDAL based contours
2023-09-26 13:04:21 -04:00
Piero Toffanin a852dfb04e Bump version 2023-09-26 12:37:10 -04:00
Piero Toffanin 0e7d9ee6f2 Shapefile support 2023-09-26 12:20:27 -04:00
Piero Toffanin c8c0f51805 Assign layer name 2023-09-26 12:08:11 -04:00
Piero Toffanin 1b327fb56e GDAL based contours 2023-09-26 12:04:04 -04:00
Piero Toffanin bf3eec282f
Merge pull request #1404 from pierotofy/encfix
Fix console write encoding
2023-09-22 15:43:50 -04:00
Piero Toffanin 0093ca71cd Fix encoding on Windows 2023-09-22 15:34:27 -04:00
Piero Toffanin 7bb818dda9
Merge pull request #1398 from pierotofy/chunkedimport
Chunked import uploads
2023-09-18 17:44:05 -04:00
Piero Toffanin 92b98389ad Faster map initialization 2023-09-18 17:38:03 -04:00
Piero Toffanin ef4db8f491 Disable rasterio warnings 2023-09-18 17:20:14 -04:00
Piero Toffanin 9ece192f28 Conditional webpack mode 2023-09-18 16:50:28 -04:00
Piero Toffanin cdeae25426 Build webpack with production 2023-09-18 16:05:08 -04:00
Piero Toffanin 9cf533f87c capitalize 2023-09-18 15:54:19 -04:00
Piero Toffanin a364de2176 Better validation error msg 2023-09-18 15:54:02 -04:00
Piero Toffanin 9d336a5c61 Add unit test 2023-09-18 15:45:57 -04:00
Piero Toffanin e2b7de81d3 Chunked import uploads 2023-09-18 14:08:45 -04:00
Piero Toffanin ac78176f2d
Merge pull request #1395 from pierotofy/pnodeimp
Processing node handling improvements
2023-09-16 14:11:23 -04:00
Piero Toffanin 950d54d51b Update locales 2023-09-16 13:49:34 -04:00
Piero Toffanin ef5336927d Persists secret_key between updates 2023-09-16 13:46:15 -04:00
Piero Toffanin c0fe407157 Add NODE_OPTIMISTIC_MODE 2023-09-16 12:23:49 -04:00
Piero Toffanin 82f3408b94 Add test, comments 2023-09-16 11:26:24 -04:00
Piero Toffanin c6d4c763f0 Add UI_MAX_PROCESSING_NODES setting 2023-09-16 10:55:04 -04:00
Piero Toffanin c54857d6e9 Do not boot on flush 2023-09-15 16:47:24 -04:00
Piero Toffanin 9f5c58fe9a Fix migration on Windows 2023-09-15 16:33:14 -04:00
Piero Toffanin 0de8a7e0fe
Merge pull request #1392 from pierotofy/maximg
Check for maxImages on frontend
2023-09-15 14:32:52 -04:00
Piero Toffanin 93704420c6 Add --worker-memory parameter 2023-09-15 14:11:23 -04:00
Piero Toffanin 9bfdf9c320 Fix tests 2023-09-15 13:49:12 -04:00
Piero Toffanin 74dc45a8ca Add liveupdate command 2023-09-15 13:22:02 -04:00
Piero Toffanin 3254968b63 Cleanup 2023-09-15 13:13:54 -04:00
Piero Toffanin be082a7d71 Check for maxImages on frontend 2023-09-15 13:11:48 -04:00
Piero Toffanin 95085301c2 Update presets 2023-09-14 23:32:09 -04:00
Piero Toffanin 3541882423 Return 100 when den is zero 2023-09-12 18:34:56 -04:00
Piero Toffanin 14aad55245 Add test 2023-09-12 18:06:52 -04:00
Piero Toffanin eda8e1abe0
Merge pull request #1389 from pierotofy/console
Add warning for zero quota
2023-09-12 16:51:02 -04:00
Piero Toffanin 8059900a58 task_count check in quota removal 2023-09-12 12:40:45 -04:00
Piero Toffanin b1fd36da26 Update locale 2023-09-12 11:38:33 -04:00
Piero Toffanin b7178c830a Warn when quota is zero 2023-09-12 11:36:52 -04:00
Piero Toffanin ec908cdc12
Merge pull request #1387 from pierotofy/console
Safer console writes
2023-09-11 17:56:43 -04:00
Piero Toffanin df245905c5 Safer console writes 2023-09-11 17:28:47 -04:00
Piero Toffanin 2c2b75a759
Merge pull request #1386 from pierotofy/console
Move task.console_output
2023-09-11 17:26:05 -04:00
Piero Toffanin ba2d42b3e5 Fix test 2023-09-11 17:05:03 -04:00
Piero Toffanin e510e2fc9b Move task.console_output 2023-09-11 16:35:54 -04:00
142 zmienionych plików z 2903 dodań i 3796 usunięć

Wyświetl plik

@ -1 +1,2 @@
**/.git
.secret_key

Wyświetl plik

@ -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."

3
.gitignore vendored
Wyświetl plik

@ -102,4 +102,5 @@ package-lock.json
# Debian builds
dpkg/build
dpkg/deb
dpkg/deb
.secret_key

Wyświetl plik

@ -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 && \

Wyświetl plik

@ -38,9 +38,11 @@ A user-friendly, commercial grade software for drone image processing. Generate
Windows and macOS users can purchase an automated [installer](https://www.opendronemap.org/webodm/download#installer), which makes the installation process easier.
To install WebODM manually, these steps should get you up and running:
There's also a cloud-hosted version of WebODM available from [webodm.net](https://webodm.net).
* Install the following applications (if they are not installed already):
To install WebODM manually on your machine:
* Install the following applications:
- [Git](https://git-scm.com/downloads)
- [Docker](https://www.docker.com/)
- [Docker-compose](https://docs.docker.com/compose/install/)
@ -154,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
@ -226,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`.
@ -259,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
@ -333,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.

Wyświetl plik

@ -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)

Wyświetl plik

@ -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.')
@ -144,16 +140,20 @@ camera_filters = [
'NRB',
'RGBN',
'RGNRe',
'GRReN',
'RGBNRe',
'BGRNRe',
'BGRReN',
'RGBNRe',
'RGBReN',
'RGBNReL',
'BGRNReL',
'BGRReNL',
'RGBNRePL',
'L', # FLIR camera has a single LWIR band
# more?
@ -169,7 +169,7 @@ def lookup_formula(algo, band_order = 'RGB'):
if algo not in algos:
raise ValueError("Cannot find algorithm " + algo)
input_bands = tuple(b for b in re.split(r"([A-Z][a-z]*)", band_order) if b != "")
def repl(matches):
@ -191,7 +191,7 @@ def get_algorithm_list(max_bands=3):
if k.startswith("_"):
continue
cam_filters = get_camera_filters_for(algos[k], max_bands)
cam_filters = get_camera_filters_for(algos[k]['expr'], max_bands)
if len(cam_filters) == 0:
continue
@ -204,9 +204,9 @@ def get_algorithm_list(max_bands=3):
return res
def get_camera_filters_for(algo, max_bands=3):
@lru_cache(maxsize=100)
def get_camera_filters_for(expr, max_bands=3):
result = []
expr = algo['expr']
pattern = re.compile("([A-Z]+?[a-z]*)")
bands = list(set(re.findall(pattern, expr)))
for f in camera_filters:
@ -224,3 +224,45 @@ def get_camera_filters_for(algo, max_bands=3):
return result
@lru_cache(maxsize=1)
def get_bands_lookup():
bands_aliases = {
'R': ['red', 'r'],
'G': ['green', 'g'],
'B': ['blue', 'b'],
'N': ['nir', 'n'],
'Re': ['rededge', 're'],
'P': ['panchro', 'p'],
'L': ['lwir', 'l']
}
bands_lookup = {}
for band in bands_aliases:
for a in bands_aliases[band]:
bands_lookup[a] = band
return bands_lookup
def get_auto_bands(orthophoto_bands, formula):
algo = algos.get(formula)
if not algo:
raise ValueError("Cannot find formula: " + formula)
max_bands = len(orthophoto_bands) - 1 # minus alpha
filters = get_camera_filters_for(algo['expr'], max_bands)
if not filters:
raise valueError(f"Cannot find filters for {algo} with max bands {max_bands}")
bands_lookup = get_bands_lookup()
band_order = ""
for band in orthophoto_bands:
if band['name'] == 'alpha' or (not band['description']):
continue
f_band = bands_lookup.get(band['description'].lower())
if f_band is not None:
band_order += f_band
if band_order in filters:
return band_order, True
else:
return filters[0], False # Fallback

Wyświetl plik

@ -6,7 +6,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from nodeodm.models import ProcessingNode
from webodm import settings
class ProcessingNodeSerializer(serializers.ModelSerializer):
online = serializers.SerializerMethodField()
@ -49,6 +49,18 @@ class ProcessingNodeViewSet(viewsets.ModelViewSet):
serializer_class = ProcessingNodeSerializer
queryset = ProcessingNode.objects.all()
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
if settings.UI_MAX_PROCESSING_NODES is not None:
queryset = queryset[:settings.UI_MAX_PROCESSING_NODES]
if settings.NODE_OPTIMISTIC_MODE:
for pn in queryset:
pn.update_node_info()
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class ProcessingNodeOptionsView(APIView):
"""

Wyświetl plik

@ -1,9 +1,11 @@
import os
import re
import shutil
from wsgiref.util import FileWrapper
import mimetypes
from shutil import copyfileobj
from shutil import copyfileobj, move
from django.core.exceptions import ObjectDoesNotExist, SuspiciousFileOperation, ValidationError
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.db import transaction
@ -23,7 +25,7 @@ from .common import get_and_check_project, get_asset_download_filename
from .tags import TagsField
from app.security import path_traversal_check
from django.utils.translation import gettext_lazy as _
from webodm import settings
def flatten_files(request_files):
# MultiValueDict in, flat array of files out
@ -74,7 +76,7 @@ class TaskSerializer(serializers.ModelSerializer):
class Meta:
model = models.Task
exclude = ('console_output', 'orthophoto_extent', 'dsm_extent', 'dtm_extent', )
exclude = ('orthophoto_extent', 'dsm_extent', 'dtm_extent', )
read_only_fields = ('processing_time', 'status', 'last_error', 'created_at', 'pending_action', 'available_assets', 'size', )
class TaskViewSet(viewsets.ViewSet):
@ -83,7 +85,7 @@ class TaskViewSet(viewsets.ViewSet):
A task represents a set of images and other input to be sent to a processing node.
Once a processing node completes processing, results are stored in the task.
"""
queryset = models.Task.objects.all().defer('orthophoto_extent', 'dsm_extent', 'dtm_extent', 'console_output', )
queryset = models.Task.objects.all().defer('orthophoto_extent', 'dsm_extent', 'dtm_extent', )
parser_classes = (parsers.MultiPartParser, parsers.JSONParser, parsers.FormParser, )
ordering_fields = '__all__'
@ -145,8 +147,7 @@ class TaskViewSet(viewsets.ViewSet):
raise exceptions.NotFound()
line_num = max(0, int(request.query_params.get('line', 0)))
output = task.console_output or ""
return Response('\n'.join(output.rstrip().split('\n')[line_num:]))
return Response('\n'.join(task.console.output().rstrip().split('\n')[line_num:]))
def list(self, request, project_pk=None):
get_and_check_project(request, project_pk)
@ -203,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):
@ -296,7 +296,7 @@ class TaskViewSet(viewsets.ViewSet):
class TaskNestedView(APIView):
queryset = models.Task.objects.all().defer('orthophoto_extent', 'dtm_extent', 'dsm_extent', 'console_output', )
queryset = models.Task.objects.all().defer('orthophoto_extent', 'dtm_extent', 'dsm_extent', )
permission_classes = (AllowAny, )
def get_and_check_task(self, request, pk, annotate={}):
@ -365,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
@ -421,18 +422,52 @@ class TaskAssetsImport(APIView):
if import_url and len(files) > 0:
raise exceptions.ValidationError(detail=_("Cannot create task, either specify a URL or upload 1 file."))
chunk_index = request.data.get('dzchunkindex')
uuid = request.data.get('dzuuid')
total_chunk_count = request.data.get('dztotalchunkcount', None)
# Chunked upload?
tmp_upload_file = None
if len(files) > 0 and chunk_index is not None and uuid is not None and total_chunk_count is not None:
byte_offset = request.data.get('dzchunkbyteoffset', 0)
try:
chunk_index = int(chunk_index)
byte_offset = int(byte_offset)
total_chunk_count = int(total_chunk_count)
except ValueError:
raise exceptions.ValidationError(detail="Some parameters are not integers")
uuid = re.sub('[^0-9a-zA-Z-]+', "", uuid)
tmp_upload_file = os.path.join(settings.FILE_UPLOAD_TEMP_DIR, f"{uuid}.upload")
if os.path.isfile(tmp_upload_file) and chunk_index == 0:
os.unlink(tmp_upload_file)
with open(tmp_upload_file, 'ab') as fd:
fd.seek(byte_offset)
if isinstance(files[0], InMemoryUploadedFile):
for chunk in files[0].chunks():
fd.write(chunk)
else:
with open(files[0].temporary_file_path(), 'rb') as file:
fd.write(file.read())
if chunk_index + 1 < total_chunk_count:
return Response({'uploaded': True}, status=status.HTTP_200_OK)
# Ready to import
with transaction.atomic():
task = models.Task.objects.create(project=project,
auto_processing_node=False,
name=task_name,
import_url=import_url if import_url else "file://all.zip",
status=status_codes.RUNNING,
pending_action=pending_actions.IMPORT)
auto_processing_node=False,
name=task_name,
import_url=import_url if import_url else "file://all.zip",
status=status_codes.RUNNING,
pending_action=pending_actions.IMPORT)
task.create_task_directories()
destination_file = task.assets_path("all.zip")
if len(files) > 0:
destination_file = task.assets_path("all.zip")
# Non-chunked file import
if tmp_upload_file is None and len(files) > 0:
with open(destination_file, 'wb+') as fd:
if isinstance(files[0], InMemoryUploadedFile):
for chunk in files[0].chunks():
@ -440,6 +475,9 @@ class TaskAssetsImport(APIView):
else:
with open(files[0].temporary_file_path(), 'rb') as file:
copyfileobj(file, fd)
elif tmp_upload_file is not None:
# Move
shutil.move(tmp_upload_file, destination_file)
worker_tasks.process_task.delay(task.id)

Wyświetl plik

@ -3,6 +3,7 @@ import rio_tiler.utils
from rasterio.enums import ColorInterp
from rasterio.crs import CRS
from rasterio.features import bounds as featureBounds
from rasterio.errors import NotGeoreferencedWarning
import urllib
import os
from .common import get_asset_download_filename
@ -16,19 +17,25 @@ from rio_tiler.models import Metadata as RioMetadata
from rio_tiler.profiles import img_profiles
from rio_tiler.colormap import cmap as colormap, apply_cmap
from rio_tiler.io import COGReader
from rio_tiler.errors import InvalidColorMapName
from rio_tiler.errors import InvalidColorMapName, AlphaBandWarning
import numpy as np
from .custom_colormaps_helper import custom_colormaps
from app.raster_utils import extension_for_export_format, ZOOM_EXTRA_LEVELS
from .hsvblend import hsv_blend
from .hillshade import LightSource
from .formulas import lookup_formula, get_algorithm_list
from .formulas import lookup_formula, get_algorithm_list, get_auto_bands
from .tasks import TaskNestedView
from rest_framework import exceptions
from rest_framework.response import Response
from worker.tasks import export_raster, export_pointcloud
from django.utils.translation import gettext as _
import warnings
# Disable: NotGeoreferencedWarning: Dataset has no geotransform, gcps, or rpcs. The identity matrix be returned.
warnings.filterwarnings("ignore", category=NotGeoreferencedWarning)
# Disable: Alpha band was removed from the output data array
warnings.filterwarnings("ignore", category=AlphaBandWarning)
for custom_colormap in custom_colormaps:
colormap = colormap.register(custom_colormap)
@ -134,6 +141,12 @@ class Metadata(TaskNestedView):
if boundaries_feature == '': boundaries_feature = None
if boundaries_feature is not None:
boundaries_feature = json.loads(boundaries_feature)
is_auto_bands_match = False
is_auto_bands = False
if bands == 'auto' and formula:
is_auto_bands = True
bands, is_auto_bands_match = get_auto_bands(task.orthophoto_bands, formula)
try:
expr, hrange = lookup_formula(formula, bands)
if defined_range is not None:
@ -194,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",
@ -217,6 +232,8 @@ class Metadata(TaskNestedView):
colormaps = []
algorithms = []
auto_bands = {'filter': '', 'match': None}
if tile_type in ['dsm', 'dtm']:
colormaps = ['viridis', 'jet', 'terrain', 'gist_earth', 'pastel1']
elif formula and bands:
@ -224,9 +241,14 @@ class Metadata(TaskNestedView):
'better_discrete_ndvi',
'viridis', 'plasma', 'inferno', 'magma', 'cividis', 'jet', 'jet_r']
algorithms = *get_algorithm_list(band_count),
if is_auto_bands:
auto_bands['filter'] = bands
auto_bands['match'] = is_auto_bands_match
info['color_maps'] = []
info['algorithms'] = algorithms
info['auto_bands'] = auto_bands
if colormaps:
for cmap in colormaps:
try:
@ -247,6 +269,7 @@ class Metadata(TaskNestedView):
info['maxzoom'] += ZOOM_EXTRA_LEVELS
info['minzoom'] -= ZOOM_EXTRA_LEVELS
info['bounds'] = {'value': src.bounds, 'crs': src.dataset.crs}
return Response(info)
@ -289,6 +312,8 @@ class Tiles(TaskNestedView):
if color_map == '': color_map = None
if hillshade == '' or hillshade == '0': hillshade = None
if tilesize == '' or tilesize is None: tilesize = 256
if bands == 'auto' and formula:
bands, _discard_ = get_auto_bands(task.orthophoto_bands, formula)
try:
tilesize = int(tilesize)
@ -374,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:
@ -446,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:
@ -465,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)
@ -537,6 +562,9 @@ class Export(TaskNestedView):
raise exceptions.ValidationError(_("Both formula and bands parameters are required"))
if formula and bands:
if bands == 'auto':
bands, _discard_ = get_auto_bands(task.orthophoto_bands, formula)
try:
expr, _discard_ = lookup_formula(formula, bands)
except ValueError as e:
@ -604,4 +632,4 @@ class Export(TaskNestedView):
else:
celery_task_id = export_pointcloud.delay(url, epsg=epsg,
format=export_format).task_id
return Response({'celery_task_id': celery_task_id, 'filename': filename})
return Response({'celery_task_id': celery_task_id, 'filename': filename})

Wyświetl plik

@ -26,7 +26,7 @@ from webodm.wsgi import booted
def boot():
# booted is a shared memory variable to keep track of boot status
# as multiple gunicorn workers could trigger the boot sequence twice
if (not settings.DEBUG and booted.value) or settings.MIGRATING: return
if (not settings.DEBUG and booted.value) or settings.MIGRATING or settings.FLUSHING: return
booted.value = True
logger = logging.getLogger('app.logger')
@ -110,8 +110,7 @@ def add_default_presets():
defaults={'options': [{'name': 'auto-boundary', 'value': True},
{'name': 'dsm', 'value': True},
{'name': 'dem-resolution', 'value': '2'},
{'name': 'pc-quality', 'value': 'high'},
{'name': 'use-3dmesh', 'value': True}]})
{'name': 'pc-quality', 'value': 'high'}]})
Preset.objects.update_or_create(name='3D Model', system=True,
defaults={'options': [{'name': 'auto-boundary', 'value': True},
{'name': 'mesh-octree-depth', 'value': "12"},
@ -121,24 +120,13 @@ def add_default_presets():
Preset.objects.update_or_create(name='Buildings', system=True,
defaults={'options': [{'name': 'auto-boundary', 'value': True},
{'name': 'mesh-size', 'value': '300000'},
{'name': 'pc-geometric', 'value': True},
{'name': 'feature-quality', 'value': 'high'},
{'name': 'pc-quality', 'value': 'high'}]})
Preset.objects.update_or_create(name='Buildings Ultra Quality', system=True,
defaults={'options': [{'name': 'auto-boundary', 'value': True},
{'name': 'mesh-size', 'value': '300000'},
{'name': 'pc-geometric', 'value': True},
{'name': 'feature-quality', 'value': 'ultra'},
{'name': 'pc-quality', 'value': 'ultra'}]})
Preset.objects.update_or_create(name='Point of Interest', system=True,
defaults={'options': [{'name': 'auto-boundary', 'value': True},
{'name': 'mesh-size', 'value': '300000'},
{'name': 'use-3dmesh', 'value': True}]})
Preset.objects.update_or_create(name='Forest', system=True,
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},

Wyświetl plik

@ -0,0 +1,53 @@
import os
import logging
logger = logging.getLogger('app.logger')
class Console:
def __init__(self, file):
self.file = file
self.base_dir = os.path.dirname(self.file)
self.parent_dir = os.path.dirname(self.base_dir)
def __repr__(self):
return "<Console output: %s>" % self.file
def __str__(self):
if not os.path.isfile(self.file):
return ""
try:
with open(self.file, 'r', encoding="utf-8") as f:
return f.read()
except IOError:
logger.warn("Cannot read console file: %s" % self.file)
return ""
def __add__(self, other):
self.append(other)
return self
def output(self):
return str(self)
def append(self, text):
if os.path.isdir(self.parent_dir):
try:
# Write
if not os.path.isdir(self.base_dir):
os.makedirs(self.base_dir, exist_ok=True)
with open(self.file, "a", encoding="utf-8") as f:
f.write(text)
except IOError:
logger.warn("Cannot append to console file: %s" % self.file)
def reset(self, text = ""):
if os.path.isdir(self.parent_dir):
try:
if not os.path.isdir(self.base_dir):
os.makedirs(self.base_dir, exist_ok=True)
with open(self.file, "w", encoding="utf-8") as f:
f.write(text)
except IOError:
logger.warn("Cannot reset console file: %s" % self.file)

Wyświetl plik

@ -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)

Wyświetl plik

@ -0,0 +1,63 @@
import os
from django.core.management.base import BaseCommand
from django.core.management import call_command
from app.models import Project
from webodm import settings
class Command(BaseCommand):
requires_system_checks = []
def add_arguments(self, parser):
parser.add_argument("action", type=str, choices=['mediapattern'])
parser.add_argument("--skip-images", action='store_true', required=False, help="Skip images")
parser.add_argument("--skip-no-quotas", action='store_true', required=False, help="Skip directories owned by users with no quota (0)")
parser.add_argument("--skip-tiles", action='store_true', required=False, help="Skip tiled assets which can be regenerated from other data")
parser.add_argument("--skip-legacy-textured-models", action='store_true', required=False, help="Skip textured models in OBJ format")
super(Command, self).add_arguments(parser)
def handle(self, **options):
if options.get('action') == 'mediapattern':
print("# BorgBackup pattern file for media directory")
print("# Generated with WebODM")
print("")
print("# Skip anything but project folder")
for d in os.listdir(settings.MEDIA_ROOT):
if d != "project":
print(f"! {d}")
if options.get('skip_no_quotas'):
skip_projects = Project.objects.filter(owner__profile__quota=0).order_by('id')
else:
skip_projects = []
print("")
print("# Skip projects")
for sp in skip_projects:
print("- " + os.path.join("project", str(sp.id)))
if options.get('skip_images'):
print("")
print("# Skip images/other files")
print("- project/*/task/*/*.*")
if options.get('skip_tiles'):
print("")
print("# Skip entwine/potree folders")
print("! project/*/task/*/assets/entwine_pointcloud")
print("! project/*/task/*/assets/potree_pointcloud")
print("")
print("# Skip tiles folders")
print("! project/*/task/*/assets/*_tiles")
print("# Skip data")
print("! project/*/task/*/data")
if options.get('skip_legacy_textured_models'):
print("")
print("# Skip OBJ texture model files")
print("+ project/*/task/*/assets/odm_texturing/*.glb")
print("- project/*/task/*/assets/odm_texturing")

Wyświetl plik

@ -0,0 +1,42 @@
# Generated by Django 2.2.27 on 2023-09-11 19:11
import os
from django.db import migrations
from webodm import settings
def data_path(project_id, task_id, *args):
return os.path.join(settings.MEDIA_ROOT,
"project",
str(project_id),
"task",
str(task_id),
"data",
*args)
def dump_console_outputs(apps, schema_editor):
Task = apps.get_model('app', 'Task')
for t in Task.objects.all():
if t.console_output is not None and len(t.console_output) > 0:
dp = data_path(t.project.id, t.id)
os.makedirs(dp, exist_ok=True)
outfile = os.path.join(dp, "console_output.txt")
with open(outfile, "w", encoding="utf-8") as f:
f.write(t.console_output)
print("Wrote console output for %s to %s" % (t, outfile))
else:
print("No task output for %s" % t)
class Migration(migrations.Migration):
dependencies = [
('app', '0037_profile'),
]
operations = [
migrations.RunPython(dump_console_outputs),
migrations.RemoveField(
model_name='task',
name='console_output',
),
]

Wyświetl plik

@ -0,0 +1,43 @@
# Generated by Django 2.2.27 on 2023-10-02 10:21
import rasterio
import os
import django.contrib.postgres.fields.jsonb
from django.db import migrations
from webodm import settings
def update_orthophoto_bands_fields(apps, schema_editor):
Task = apps.get_model('app', 'Task')
for t in Task.objects.all():
bands = []
orthophoto_path = os.path.join(settings.MEDIA_ROOT, "project", str(t.project.id), "task", str(t.id), "assets", "odm_orthophoto", "odm_orthophoto.tif")
if os.path.isfile(orthophoto_path):
try:
with rasterio.open(orthophoto_path) as f:
names = [c.name for c in f.colorinterp]
for i, n in enumerate(names):
bands.append({
'name': n,
'description': f.descriptions[i]
})
except Exception as e:
print(e)
print("Updating {} (with orthophoto bands: {})".format(t, str(bands)))
t.orthophoto_bands = bands
t.save()
class Migration(migrations.Migration):
dependencies = [
('app', '0038_remove_task_console_output'),
]
operations = [
migrations.RunPython(update_orthophoto_bands_fields),
]

Wyświetl plik

@ -2,6 +2,7 @@ import logging
import os
import shutil
import time
import struct
import uuid as uuid_module
from app.vendor import zipfly
@ -46,6 +47,7 @@ from django.utils.translation import gettext_lazy as _, gettext
from functools import partial
import subprocess
from app.classes.console import Console
logger = logging.getLogger('app.logger')
@ -156,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()
@ -247,7 +249,6 @@ class Task(models.Model):
last_error = models.TextField(null=True, blank=True, help_text=_("The last processing error received"), verbose_name=_("Last Error"))
options = fields.JSONField(default=dict, blank=True, help_text=_("Options that are being used to process this task"), validators=[validate_task_options], verbose_name=_("Options"))
available_assets = fields.ArrayField(models.CharField(max_length=80), default=list, blank=True, help_text=_("List of available assets to download"), verbose_name=_("Available Assets"))
console_output = models.TextField(null=False, default="", blank=True, help_text=_("Console output of the processing node"), verbose_name=_("Console Output"))
orthophoto_extent = GeometryField(null=True, blank=True, srid=4326, help_text=_("Extent of the orthophoto"), verbose_name=_("Orthophoto Extent"))
dsm_extent = GeometryField(null=True, blank=True, srid=4326, help_text="Extent of the DSM", verbose_name=_("DSM Extent"))
@ -290,6 +291,8 @@ class Task(models.Model):
# To help keep track of changes to the project id
self.__original_project_id = self.project.id
self.console = Console(self.data_path("console_output.txt"))
def __str__(self):
name = self.name if self.name is not None else gettext("unnamed")
@ -354,6 +357,12 @@ class Task(models.Model):
"""
return self.task_path("assets", *args)
def data_path(self, *args):
"""
Path to task data that does not fit in database fields (e.g. console output)
"""
return self.task_path("data", *args)
def task_path(self, *args):
"""
Get path relative to the root task directory
@ -441,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:
@ -460,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:
@ -490,7 +499,7 @@ class Task(models.Model):
raise FileNotFoundError("{} is not a valid asset".format(asset))
def handle_import(self):
self.console_output += gettext("Importing assets...") + "\n"
self.console += gettext("Importing assets...") + "\n"
self.save()
zip_path = self.assets_path("all.zip")
@ -709,7 +718,7 @@ class Task(models.Model):
self.options = list(filter(lambda d: d['name'] != 'rerun-from', self.options))
self.upload_progress = 0
self.console_output = ""
self.console.reset()
self.processing_time = -1
self.status = None
self.last_error = None
@ -740,10 +749,10 @@ class Task(models.Model):
# Need to update status (first time, queued or running?)
if self.uuid and self.status in [None, status_codes.QUEUED, status_codes.RUNNING]:
# Update task info from processing node
if not self.console_output:
if not self.console.output():
current_lines_count = 0
else:
current_lines_count = len(self.console_output.split("\n"))
current_lines_count = len(self.console.output().split("\n"))
info = self.processing_node.get_task_info(self.uuid, current_lines_count)
@ -751,7 +760,7 @@ class Task(models.Model):
self.status = info.status.value
if len(info.output) > 0:
self.console_output += "\n".join(info.output) + '\n'
self.console += "\n".join(info.output) + '\n'
# Update running progress
self.running_progress = (info.progress / 100.0) * self.TASK_PROGRESS_LAST_VALUE
@ -891,7 +900,7 @@ class Task(models.Model):
self.update_size()
self.potree_scene = {}
self.running_progress = 1.0
self.console_output += gettext("Done!") + "\n"
self.console += gettext("Done!") + "\n"
self.status = status_codes.COMPLETED
self.save()
@ -1016,7 +1025,12 @@ class Task(models.Model):
if os.path.isfile(orthophoto_path):
with rasterio.open(orthophoto_path) as f:
bands = [c.name for c in f.colorinterp]
names = [c.name for c in f.colorinterp]
for i, n in enumerate(names):
bands.append({
'name': n,
'description': f.descriptions[i]
})
self.orthophoto_bands = bands
if commit: self.save()
@ -1149,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:
@ -1167,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:

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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()

Wyświetl plik

@ -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,
},
},
},
]
},

Wyświetl plik

@ -1,7 +1,5 @@
import inspect
from worker.celery import app
# noinspection PyUnresolvedReferences
from worker.tasks import execute_grass_script
task = app.task

Wyświetl plik

@ -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])

Wyświetl plik

@ -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");
}
}

Wyświetl plik

@ -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}

Wyświetl plik

@ -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",

Wyświetl plik

@ -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;

Wyświetl plik

@ -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
};

Wyświetl plik

@ -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);
}
};

Wyświetl plik

@ -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);
});
};

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -19,7 +19,11 @@ export default {
endpoints: [
["willAddControls", leafletPreCheck],
["didAddControls", layersControlPreCheck],
["addActionButton", leafletPreCheck],
["addActionButton", leafletPreCheck]
],
functions: [
"handleClick"
]
};

Wyświetl plik

@ -85,6 +85,18 @@ class EditTaskForm extends React.Component {
this.state.selectedPreset;
}
checkFilesCount(filesCount){
if (!this.state.selectedNode) return true;
if (filesCount === 0) return true;
if (this.state.selectedNode.max_images === null) return true;
return this.state.selectedNode.max_images >= filesCount;
}
selectedNodeMaxImages(){
if (!this.state.selectedNode) return null;
return this.state.selectedNode.max_images;
}
notifyFormLoaded(){
if (this.props.onFormLoaded && this.formReady()) this.props.onFormLoaded();
}
@ -115,8 +127,6 @@ class EditTaskForm extends React.Component {
return;
}
let now = new Date();
let nodes = json.map(node => {
return {
id: node.id,
@ -124,6 +134,7 @@ class EditTaskForm extends React.Component {
label: `${node.label} (queue: ${node.queue_count})`,
options: node.available_options,
queue_count: node.queue_count,
max_images: node.max_images,
enabled: node.online,
url: `http://${node.hostname}:${node.port}`
};

Wyświetl plik

@ -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.")});
});
}

Wyświetl plik

@ -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>);
}
}

Wyświetl plik

@ -53,7 +53,8 @@ class ImportTaskPanel extends React.Component {
clickable: this.uploadButton,
chunkSize: 2147483647,
timeout: 2147483647,
chunking: true,
chunkSize: 16000000, // 16MB
headers: {
[csrf.header]: csrf.token
}
@ -69,6 +70,7 @@ class ImportTaskPanel extends React.Component {
this.setState({uploading: false, progress: 0, totalBytes: 0, totalBytesSent: 0});
})
.on("uploadprogress", (file, progress, bytesSent) => {
if (progress == 100) return; // Workaround for chunked upload progress bar jumping around
this.setState({
progress,
totalBytes: file.size,

Wyświetl plik

@ -65,7 +65,6 @@ export default class LayersControlLayer extends React.Component {
exportLoading: false,
error: ""
};
this.rescale = params.rescale || "";
}
@ -134,7 +133,7 @@ export default class LayersControlLayer extends React.Component {
// Check if bands need to be switched
const algo = this.getAlgorithm(e.target.value);
if (algo && algo['filters'].indexOf(bands) === -1) bands = algo['filters'][0]; // Pick first
if (algo && algo['filters'].indexOf(bands) === -1 && bands !== "auto") bands = algo['filters'][0]; // Pick first
this.setState({formula: e.target.value, bands});
}
@ -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();
@ -262,7 +268,7 @@ export default class LayersControlLayer extends React.Component {
render(){
const { colorMap, bands, hillshade, formula, histogramLoading, exportLoading } = this.state;
const { meta, tmeta } = this;
const { color_maps, algorithms } = tmeta;
const { color_maps, algorithms, auto_bands } = tmeta;
const algo = this.getAlgorithm(formula);
let cmapValues = null;
@ -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"]} />
@ -298,13 +318,17 @@ export default class LayersControlLayer extends React.Component {
{bands !== "" && algo ?
<div className="row form-group form-inline">
<label className="col-sm-3 control-label">{_("Filter:")}</label>
<label className="col-sm-3 control-label">{_("Bands:")}</label>
<div className="col-sm-9 ">
{histogramLoading ?
<i className="fa fa-circle-notch fa-spin fa-fw" /> :
<select className="form-control" value={bands} onChange={this.handleSelectBands}>
[<select key="sel" className="form-control" value={bands} onChange={this.handleSelectBands} title={auto_bands.filter !== "" && bands == "auto" ? auto_bands.filter : ""}>
<option key="auto" value="auto">{_("Automatic")}</option>
{algo.filters.map(f => <option key={f} value={f}>{f}</option>)}
</select>}
</select>,
bands == "auto" && !auto_bands.match ?
<i key="ico" style={{marginLeft: '4px'}} title={interpolate(_("Not every band for %(name)s could be automatically identified."), {name: algo.id}) + "\n" + _("Your sensor might not have the proper bands for using this algorithm.")} className="fa fa-exclamation-circle info-button"></i>
: ""]}
</div>
</div> : ""}

Wyświetl plik

@ -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 = {
@ -94,6 +94,16 @@ class Map extends React.Component {
return "";
}
hasBands = (bands, orthophoto_bands) => {
if (!orthophoto_bands) return false;
for (let i = 0; i < bands.length; i++){
if (orthophoto_bands.find(b => b.description !== null && b.description.toLowerCase() === bands[i].toLowerCase()) === undefined) return false;
}
return true;
}
loadImageryLayers(forceAddLayers = false){
// Cancel previous requests
if (this.tileJsonRequests) {
@ -125,17 +135,29 @@ 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){
// Single band, probably thermal dataset, in any case we can't render NDVI
// because it requires 3 bands
metaUrl += "?formula=Celsius&bands=L&color_map=magma";
}else if (meta.task && meta.task.orthophoto_bands){
let formula = this.hasBands(["red", "green", "nir"], meta.task.orthophoto_bands) ? "NDVI" : "VARI";
metaUrl += `?formula=${formula}&bands=auto&color_map=rdylgn`;
}else{
// This should never happen?
metaUrl += "?formula=NDVI&bands=RGN&color_map=rdylgn";
}
}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)
@ -155,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");
@ -181,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;
@ -356,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
@ -368,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({
@ -482,7 +532,11 @@ _('Example:'),
});
new AddOverlayCtrl().addTo(this.map);
this.map.fitWorld();
this.map.fitBounds([
[13.772919746115805,
45.664640939831735],
[13.772825784981254,
45.664591558975154]]);
this.map.attributionControl.setPrefix("");
this.setState({showLoading: true});
@ -491,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)){
@ -544,7 +600,6 @@ _('Example:'),
tiles: tiles,
controls:{
autolayers: this.autolayers,
scale: scaleControl,
zoom: zoomControl
}
});
@ -594,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
@ -615,6 +670,7 @@ _('Example:'),
ref={(ref) => { this.shareButton = ref; }}
task={this.state.singleTask}
linksTarget="map"
queryParams={{t: this.props.mapType}}
/>
: ""}
<SwitchModeButton

Wyświetl plik

@ -124,11 +124,24 @@ class NewTaskPanel extends React.Component {
}
render() {
let filesCountOk = true;
if (this.taskForm && !this.taskForm.checkFilesCount(this.props.filesCount)) filesCountOk = false;
return (
<div className="new-task-panel theme-background-highlight">
<div className="form-horizontal">
<div className={this.state.inReview ? "disabled" : ""}>
<p>{interpolate(_("%(count)s files selected. Please check these additional options:"), { count: this.props.filesCount})}</p>
{!filesCountOk ?
<div className="alert alert-warning">
{interpolate(_("Number of files selected exceeds the maximum of %(count)s allowed on this processing node."), { count: this.taskForm.selectedNodeMaxImages() })}
<button onClick={this.props.onCancel} type="button" className="btn btn-xs btn-primary redo">
<span><i className="glyphicon glyphicon-remove-circle"></i> {_("Cancel")}</span>
</button>
</div>
: ""}
<EditTaskForm
selectedNode={Storage.getItem("last_processing_node") || "auto"}
onFormLoaded={this.handleFormTaskLoaded}
@ -186,7 +199,7 @@ class NewTaskPanel extends React.Component {
{this.state.loading ?
<button type="submit" className="btn btn-primary" disabled={true}><i className="fa fa-circle-notch fa-spin fa-fw"></i>{_("Loading…")}</button>
:
<button type="submit" className="btn btn-primary" onClick={this.save} disabled={this.props.filesCount < 1}><i className="glyphicon glyphicon-saved"></i> {!this.state.inReview ? _("Review") : _("Start Processing")}</button>
<button type="submit" className="btn btn-primary" onClick={this.save} disabled={this.props.filesCount < 1 || !filesCountOk}><i className="glyphicon glyphicon-saved"></i> {!this.state.inReview ? _("Review") : _("Start Processing")}</button>
}
</div>
</div>

Wyświetl plik

@ -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">

Wyświetl plik

@ -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}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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 (

Wyświetl plik

@ -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}>

Wyświetl plik

@ -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
})
});
});
}

Wyświetl plik

@ -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>
: ""}

Wyświetl plik

@ -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;

Wyświetl plik

@ -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);
})
});

Wyświetl plik

@ -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);
});
});

Wyświetl plik

@ -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{

Wyświetl plik

@ -41,4 +41,9 @@
opacity: 0.8;
pointer-events:none;
}
button.redo{
margin-top: 0;
margin-left: 10px;
}
}

Wyświetl plik

@ -29,4 +29,8 @@
padding: 2px 4px 2px 4px;
margin-top: 12px;
}
.help-button:hover{
cursor: pointer;
}
}

Wyświetl plik

@ -0,0 +1,4 @@
.unit-selector{
font-size: 14px;
padding: 5px;
}

Wyświetl plik

@ -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;

Wyświetl plik

@ -1,94 +1,93 @@
// Auto-generated with extract_odm_strings.py, do not edit!
_("Specify the distance between camera shot locations and the outer edge of the boundary when computing the boundary with --auto-boundary. Set to 0 to automatically choose a value. Default: %(default)s");
_("Skip generation of PDF report. This can save time if you don't need a report. 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");
_("End processing at this stage. Can be one of: %(choices)s. Default: %(default)s");
_("Choose the algorithm for extracting keypoints and computing descriptors. Can be one of: %(choices)s. Default: %(default)s");
_("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");
_("Generate static tiles for orthophotos and DEMs that are suitable for viewers like Leaflet or OpenLayers. 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");
_("Octree depth used in the mesh reconstruction, increase to get more vertices, recommended values are 8-12. 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");
_("Export the georeferenced point cloud in Entwine Point Tile (EPT) format. Default: %(default)s");
_("Set this parameter if you want to generate a Google Earth (KMZ) rendering of the orthophoto. 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");
_("DSM/DTM resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate. 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");
_("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");
_("Simple Morphological Filter slope parameter (rise over run). 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");
_("Name of dataset (i.e subfolder name within project folder). Default: %(default)s");
_("Automatically compute image masks using AI to remove the background. Experimental. Default: %(default)s");
_("Generate OGC 3D Tiles outputs. Default: %(default)s");
_("Skip generation of the orthophoto. This can save time if you only need 3D results or 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");
_("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");
_("Classify the point cloud outputs. You can control the behavior of this option by tweaking the --dem-* parameters. 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");
_("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");
_("Generate OBJs that have a single material and a single texture file instead of multiple ones. Default: %(default)s");
_("Create Cloud-Optimized GeoTIFFs instead of normal GeoTIFFs. 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");
_("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");
_("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");
_("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");
_("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");
_("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");
_("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");
_("Keep faces in the mesh that are not seen in any camera. 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");
_("The maximum vertex count of the output mesh. 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");
_("Save the georeferenced point cloud in Cloud Optimized Point Cloud (COPC) format. 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");
_("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");
_("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");
_("Build orthophoto overviews for faster display in programs such as QGIS. Default: %(default)s");
_("Decimate the points before generating the DEM. 1 is no decimation (full quality). 100 decimates ~99%% of the points. Useful for speeding up generation of DEM results in very large datasets. Default: %(default)s");
_("Set this parameter if you want a striped GeoTIFF. 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");
_("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");
_("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");
_("show this help message and exit");
_("Rerun this stage only and stop. Can be one of: %(choices)s. 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");
_("Orthophoto resolution in cm / pixel. Note that this value is capped by a ground sampling distance (GSD) estimate.Default: %(default)s");
_("When processing multispectral datasets, you can specify the name of the primary band that will be used for reconstruction. It's recommended to choose a band which has sharp details and is in focus. Default: %(default)s");
_("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");
_("Simple Morphological Filter window radius parameter (meters). Default: %(default)s");
_("Do not use GPU acceleration, even if it's available. Default: %(default)s");
_("Export the georeferenced point cloud in LAS format. 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");
_("Maximum number of frames to extract from video files for processing. Set to 0 for no limit. Default: %(default)s");
_("URL to a ClusterODM instance for distributing a split-merge workflow on multiple nodes in parallel. 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");
_("Do not attempt to merge partial reconstructions. This can happen when images do not have sufficient overlap or are isolated. Default: %(default)s");
_("Set the compression to use for orthophotos. Can be one of: %(choices)s. 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.");
_("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");
_("Perform image matching with the nearest images based on GPS exif data. Set to 0 to match by triangulation. 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");
_("The maximum output resolution of extracted video frames in pixels. 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");
_("Simple Morphological Filter elevation threshold parameter (meters). 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");
_("Skip normalization of colors across all images. Useful when processing radiometric data. 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");
_("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");
_("Skip alignment of submodels in split-merge. Useful if GPS is good enough on very large datasets. 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");
_("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 to generate a PNG rendering of the orthophoto. Default: %(default)s");
_("Displays version number and exits. ");
_("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");
_("Rerun processing from this stage. Can be one of: %(choices)s. 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");
_("Permanently delete all previous results and rerun the processing pipeline.");
_("Simple Morphological Filter elevation scalar parameter. 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");
_("Copy output results to this folder after processing.");
_("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");
_("Keep faces in the mesh that are not seen in any camera. 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");
_("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");
_("Export the georeferenced point cloud in LAS format. 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");
_("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");
_("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");
_("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");
_("Skip the blending of colors near seams. 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");

Wyświetl plik

@ -45,6 +45,7 @@ _("Navigation cube");
_("Remove all clipping volumes");
_("Compass");
_("Camera Animation");
_("Remove last camera animation");
_("Point budget");
_("Point size");
_("Minimum size");

Wyświetl plik

@ -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]();;) {

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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);
};

Wyświetl plik

@ -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>");

Wyświetl plik

@ -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",

Wyświetl plik

@ -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();

Wyświetl plik

@ -25,7 +25,8 @@
<button class="btn btn-primary" onclick="location.href='{% url "admin:nodeodm_processingnode_add" %}';"><i class="fa fa-plus-circle"></i> {{ add_processing_node }}</button>
{% else %}
{% include "quota.html" %}
{% if no_tasks %}
<h3>{% trans 'Welcome!' %} ☺</h3>
{% trans 'Select Images and GCP' as upload_images %}
@ -37,13 +38,11 @@
<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 %}
{% include "quota.html" %}
<div id="dashboard-app" data-dashboard></div>
{% endif %}

Wyświetl plik

@ -1,11 +1,16 @@
{% load i18n %}
{% load settings %}
{% quota_exceeded_grace_period as when %}
{% if user.profile.has_exceeded_quota_cached %}
{% with total=user.profile.quota|disk_size used=user.profile.used_quota_cached|disk_size %}
{% quota_exceeded_grace_period as when %}
<div class="alert alert-warning alert-dismissible">
<i class="fas fa-info-circle"></i> {% blocktrans %}The disk quota is being exceeded ({{ used }} of {{ total }} used). The most recent tasks will be automatically deleted {{ when }}, until usage falls below {{ total }}.{% endblocktrans %}
</div>
{% endwith %}
{% elif user.profile.quota == 0 %}
<div class="alert alert-warning alert-dismissible">
<i class="fas fa-info-circle"></i> {% blocktrans %}Your account does not have a storage quota. Any new task will be automatically deleted {{ when }}{% endblocktrans %}
</div>
{% endif %}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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 }};
}

Wyświetl plik

@ -1,5 +1,6 @@
from django import template
from guardian.shortcuts import get_objects_for_user
from webodm import settings
from nodeodm.models import ProcessingNode
@ -8,7 +9,11 @@ register = template.Library()
@register.simple_tag(takes_context=True)
def get_visible_processing_nodes(context):
return get_objects_for_user(context['request'].user, "nodeodm.view_processingnode", ProcessingNode, accept_global_perms=False)
queryset = get_objects_for_user(context['request'].user, "nodeodm.view_processingnode", ProcessingNode, accept_global_perms=False)
if settings.UI_MAX_PROCESSING_NODES is not None:
return queryset[:settings.UI_MAX_PROCESSING_NODES]
else:
return queryset
@register.simple_tag(takes_context=True)

Wyświetl plik

@ -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
@ -32,7 +40,7 @@ def disk_size(megabytes):
@register.simple_tag
def percentage(num, den, maximum=None):
if den == 0:
return 0
return 100
perc = max(0, num / den * 100)
if maximum is not None:
perc = min(perc, maximum)
@ -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)

Wyświetl plik

@ -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())

Wyświetl plik

@ -1,4 +1,5 @@
import datetime
import os
from django.contrib.auth.models import User
from guardian.shortcuts import assign_perm, get_objects_for_user
@ -140,22 +141,26 @@ class TestApi(BootTestCase):
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertTrue(res.data == "")
task.console_output = "line1\nline2\nline3"
data_path = task.data_path()
if not os.path.exists(data_path):
os.makedirs(data_path, exist_ok=True)
task.console.reset("line1\nline2\nline3")
task.save()
res = client.get('/api/projects/{}/tasks/{}/output/'.format(project.id, task.id))
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertTrue(res.data == task.console_output)
self.assertEqual(res.data, task.console.output())
# Console output with line num
res = client.get('/api/projects/{}/tasks/{}/output/?line=2'.format(project.id, task.id))
self.assertTrue(res.data == "line3")
self.assertEqual(res.data, "line3")
# Console output with line num out of bounds
res = client.get('/api/projects/{}/tasks/{}/output/?line=3'.format(project.id, task.id))
self.assertTrue(res.data == "")
self.assertEqual(res.data, "")
res = client.get('/api/projects/{}/tasks/{}/output/?line=-1'.format(project.id, task.id))
self.assertTrue(res.data == task.console_output)
self.assertEqual(res.data, task.console.output())
# Cannot list task details for a task belonging to a project we don't have access to
res = client.get('/api/projects/{}/tasks/{}/'.format(other_project.id, other_task.id))
@ -455,6 +460,18 @@ class TestApi(BootTestCase):
self.assertTrue(len(res.data) == 1)
self.assertTrue(res.data[0]['name'] == 'a')
# Test optimistic mode
self.assertFalse(p4.is_online())
settings.NODE_OPTIMISTIC_MODE = True
self.assertTrue(p4.is_online())
res = client.get('/api/processingnodes/')
self.assertEqual(len(res.data), 3)
for nodes in res.data:
self.assertTrue(nodes['online'])
settings.NODE_OPTIMISTIC_MODE = False
def test_token_auth(self):
client = APIClient()

Wyświetl plik

@ -27,7 +27,6 @@ class TestApiPreset(BootTestCase):
self.assertTrue(Preset.objects.filter(name="Forest", system=True).exists())
self.assertTrue(Preset.objects.filter(name="Buildings", system=True).exists())
self.assertTrue(Preset.objects.filter(name="3D Model", system=True).exists())
self.assertTrue(Preset.objects.filter(name="Point of Interest", system=True).exists())
self.assertTrue(Preset.objects.filter(name="Multispectral", system=True).exists())
def test_preset(self):
@ -58,7 +57,7 @@ class TestApiPreset(BootTestCase):
self.assertTrue(res.status_code == status.HTTP_200_OK)
# Only ours and global presets are available
self.assertEqual(len(res.data), 15)
self.assertTrue(len(res.data) > 0)
self.assertTrue('My Local Preset' in [preset['name'] for preset in res.data])
self.assertTrue('High Resolution' in [preset['name'] for preset in res.data])
self.assertTrue('Global Preset #1' in [preset['name'] for preset in res.data])

Wyświetl plik

@ -497,6 +497,10 @@ class TestApiTask(BootTransactionTestCase):
self.assertEqual(metadata['algorithms'], [])
self.assertEqual(metadata['color_maps'], [])
# Auto bands
self.assertEqual(metadata['auto_bands']['filter'], '')
self.assertEqual(metadata['auto_bands']['match'], None)
# Address key is removed
self.assertFalse('address' in metadata)
@ -531,6 +535,10 @@ class TestApiTask(BootTransactionTestCase):
self.assertTrue(len(metadata['algorithms']) > 0)
self.assertTrue(len(metadata['color_maps']) > 0)
# Auto band is populated
self.assertEqual(metadata['auto_bands']['filter'], '')
self.assertEqual(metadata['auto_bands']['match'], None)
# Algorithms have valid keys
for k in ['id', 'filters', 'expr', 'help']:
for a in metadata['algorithms']:
@ -557,6 +565,10 @@ class TestApiTask(BootTransactionTestCase):
self.assertEqual(metadata['statistics']['1']['min'], algos['VARI']['range'][0])
self.assertEqual(metadata['statistics']['1']['max'], algos['VARI']['range'][1])
# Formula can be set to auto
res = client.get("/api/projects/{}/tasks/{}/orthophoto/metadata?formula=VARI&bands=auto".format(project.id, task.id))
self.assertEqual(res.status_code, status.HTTP_200_OK)
tile_path = {
'orthophoto': '17/32042/46185',
'dsm': '18/64083/92370',
@ -665,7 +677,9 @@ class TestApiTask(BootTransactionTestCase):
("orthophoto", "formula=VARI&bands=RGB", status.HTTP_200_OK),
("orthophoto", "formula=VARI&bands=invalid", status.HTTP_400_BAD_REQUEST),
("orthophoto", "formula=invalid&bands=RGB", status.HTTP_400_BAD_REQUEST),
("orthophoto", "formula=NDVI&bands=auto", status.HTTP_200_OK),
("orthophoto", "formula=NDVI&bands=auto", status.HTTP_200_OK),
("orthophoto", "formula=NDVI&bands=RGN&color_map=rdylgn&rescale=-1,1", status.HTTP_200_OK),
("orthophoto", "formula=NDVI&bands=RGN&color_map=rdylgn&rescale=1,-1", status.HTTP_200_OK),
@ -679,7 +693,7 @@ class TestApiTask(BootTransactionTestCase):
for k in algos:
a = algos[k]
filters = get_camera_filters_for(a)
filters = get_camera_filters_for(a['expr'])
for f in filters:
params.append(("orthophoto", "formula={}&bands={}&color_map=rdylgn".format(k, f), status.HTTP_200_OK))

Wyświetl plik

@ -166,3 +166,54 @@ class TestApiTask(BootTransactionTestCase):
self.assertEqual(corrupted_task.status, status_codes.FAILED)
self.assertTrue("Invalid" in corrupted_task.last_error)
# Test chunked upload import
assets_file = open(assets_path, 'rb')
assets_size = os.path.getsize(assets_path)
chunk_1_size = assets_size // 2
chunk_1_path = os.path.join(os.path.dirname(assets_path), "1.zip")
chunk_2_path = os.path.join(os.path.dirname(assets_path), "2.zip")
with open(chunk_1_path, 'wb') as f:
assets_file.seek(0)
f.write(assets_file.read(chunk_1_size))
with open(chunk_2_path, 'wb') as f:
f.write(assets_file.read())
chunk_1 = open(chunk_1_path, 'rb')
chunk_2 = open(chunk_2_path, 'rb')
assets_file.close()
res = client.post("/api/projects/{}/tasks/import".format(project.id), {
'file': [chunk_1],
'dzuuid': 'abc-test',
'dzchunkindex': 0,
'dztotalchunkcount': 2,
'dzchunkbyteoffset': 0
}, format="multipart")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertTrue(res.data['uploaded'])
chunk_1.close()
res = client.post("/api/projects/{}/tasks/import".format(project.id), {
'file': [chunk_2],
'dzuuid': 'abc-test',
'dzchunkindex': 1,
'dztotalchunkcount': 2,
'dzchunkbyteoffset': chunk_1_size
}, format="multipart")
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
chunk_2.close()
file_import_task = Task.objects.get(id=res.data['id'])
# Wait for completion
c = 0
while c < 10:
worker.tasks.process_pending_tasks()
file_import_task.refresh_from_db()
if file_import_task.status == status_codes.COMPLETED:
break
c += 1
time.sleep(1)
self.assertEqual(file_import_task.import_url, "file://all.zip")
self.assertEqual(file_import_task.images_count, 1)

Wyświetl plik

@ -1,6 +1,8 @@
from django.contrib.auth.models import User, Group
from django.test import Client
from rest_framework import status
from guardian.shortcuts import assign_perm
from nodeodm.models import ProcessingNode
from app.models import Project, Task
from app.models import Setting
@ -24,6 +26,11 @@ class TestApp(BootTestCase):
# Add user to test Group
User.objects.get(pk=1).groups.add(my_group)
# Add view permissions
user = User.objects.get(username=self.credentials['username'])
pns = ProcessingNode.objects.all()
for pn in pns:
assign_perm('view_processingnode', user, pn)
def test_user_login(self):
c = Client()
@ -89,6 +96,27 @@ class TestApp(BootTestCase):
self.assertEqual(message.tags, 'warning')
self.assertTrue("offline" in message.message)
# The menu should have 3 processing nodes
res = c.get('/dashboard/', follow=True)
self.assertEqual(res.content.decode("utf-8").count('href="/processingnode/'), 3)
self.assertTemplateUsed(res, 'app/dashboard.html')
# The API should return 3 nodes
res = c.get('/api/processingnodes/')
self.assertEqual(len(res.data), 3)
# We can change that with a setting
settings.UI_MAX_PROCESSING_NODES = 1
res = c.get('/dashboard/', follow=True)
self.assertEqual(res.content.decode("utf-8").count('href="/processingnode/'), 1)
self.assertTemplateUsed(res, 'app/dashboard.html')
res = c.get('/api/processingnodes/')
self.assertEqual(len(res.data), 1)
settings.UI_MAX_PROCESSING_NODES = None
res = c.get('/processingnode/9999/')
self.assertTrue(res.status_code == 404)

Wyświetl plik

@ -46,3 +46,6 @@ class TestAuth(BootTestCase):
# Re-test login
ok = client.login(username='extuser1', password='test1234')
self.assertTrue(ok)
# Check that the user has been added to the default group
self.assertTrue(user.groups.filter(name='Default').exists())

Wyświetl plik

@ -1,6 +1,6 @@
import re
from django.test import TestCase
from app.api.formulas import lookup_formula, get_algorithm_list, get_camera_filters_for, algos
from app.api.formulas import lookup_formula, get_algorithm_list, get_camera_filters_for, algos, get_auto_bands
class TestFormulas(TestCase):
def setUp(self):
@ -38,7 +38,7 @@ class TestFormulas(TestCase):
bands = list(set(re.findall(pattern, f)))
self.assertTrue(len(bands) <= 3)
self.assertTrue(get_camera_filters_for(algos['VARI']) == ['RGB'])
self.assertTrue(get_camera_filters_for(algos['VARI']['expr']) == ['RGB'])
# Request algorithms with more band filters
al = get_algorithm_list(max_bands=5)
@ -48,4 +48,32 @@ class TestFormulas(TestCase):
# Filters are less than 5 bands
for f in i['filters']:
bands = list(set(re.findall(pattern, f)))
self.assertTrue(len(bands) <= 5)
self.assertTrue(len(bands) <= 5)
def test_auto_bands(self):
obands = [{'name': 'red', 'description': 'red'},
{'name': 'green', 'description': 'green'},
{'name': 'blue', 'description': 'blue'},
{'name': 'gray', 'description': 'nir'},
{'name': 'alpha', 'description': None}]
self.assertEqual(get_auto_bands(obands, "NDVI")[0], "RGBN")
self.assertTrue(get_auto_bands(obands, "NDVI")[1])
self.assertEqual(get_auto_bands(obands, "Celsius")[0], "L")
self.assertFalse(get_auto_bands(obands, "Celsius")[1])
self.assertEqual(get_auto_bands(obands, "VARI")[0], "RGBN")
self.assertTrue(get_auto_bands(obands, "VARI")[0])
obands = [{'name': 'red', 'description': None},
{'name': 'green', 'description': None},
{'name': 'blue', 'description': None},
{'name': 'gray', 'description': None},
{'name': 'alpha', 'description': None}]
self.assertEqual(get_auto_bands(obands, "NDVI")[0], "RGN")
self.assertFalse(get_auto_bands(obands, "NDVI")[1])
self.assertEqual(get_auto_bands(obands, "VARI")[0], "RGB")
self.assertFalse(get_auto_bands(obands, "VARI")[1])

Wyświetl plik

@ -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")

Wyświetl plik

@ -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")

Wyświetl plik

@ -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"))
@ -109,7 +109,7 @@ def about(request):
def processing_node(request, processing_node_id):
pn = get_object_or_404(ProcessingNode, pk=processing_node_id)
if not pn.update_node_info():
messages.add_message(request, messages.constants.WARNING, '{} seems to be offline.'.format(pn))
messages.add_message(request, messages.constants.WARNING, _('%(node)s seems to be offline.') % {'node': pn})
return render(request, 'app/processing_node.html',
{

Wyświetl plik

@ -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()
})

Wyświetl plik

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

Wyświetl plik

@ -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'})

Wyświetl plik

@ -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)

Wyświetl plik

@ -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]

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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()),
]

Wyświetl plik

@ -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;
}
});

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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>);
}
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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}));
}
});

Wyświetl plik

@ -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"
}
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -1,3 +0,0 @@
geojson==2.4.1
opencv-python-headless==4.4.0.46

Wyświetl plik

@ -41,7 +41,7 @@ class ImportFolderTaskView(TaskView):
files = platform.import_from_folder(folder_url)
# Update the task with the new information
task.console_output += "Importing {} images...\n".format(len(files))
task.console += "Importing {} images...\n".format(len(files))
task.images_count = len(files)
task.pending_action = pending_actions.IMPORT
task.save()

Wyświetl plik

@ -3,10 +3,80 @@ import os
from rest_framework import status
from rest_framework.response import Response
from app.plugins.views import TaskView, CheckTask, GetTaskResult
from worker.tasks import execute_grass_script
from app.plugins.grass_engine import grass, GrassEngineException, cleanup_grass_context
from app.plugins.worker import run_function_async
from django.utils.translation import gettext_lazy as _
class ContoursException(Exception):
pass
def calc_contours(dem, epsg, interval, output_format, simplify, zfactor = 1):
import os
import subprocess
import tempfile
import shutil
import glob
from webodm import settings
ext = ""
if output_format == "GeoJSON":
ext = "json"
elif output_format == "GPKG":
ext = "gpkg"
elif output_format == "DXF":
ext = "dxf"
elif output_format == "ESRI Shapefile":
ext = "shp"
MIN_CONTOUR_LENGTH = 10
tmpdir = os.path.join(settings.MEDIA_TMP, os.path.basename(tempfile.mkdtemp('_contours', dir=settings.MEDIA_TMP)))
gdal_contour_bin = shutil.which("gdal_contour")
ogr2ogr_bin = shutil.which("ogr2ogr")
if gdal_contour_bin is None:
return {'error': 'Cannot find gdal_contour'}
if ogr2ogr_bin is None:
return {'error': 'Cannot find ogr2ogr'}
contours_file = f"contours.gpkg"
p = subprocess.Popen([gdal_contour_bin, "-q", "-a", "level", "-3d", "-f", "GPKG", "-i", str(interval), dem, contours_file], cwd=tmpdir, 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 not success:
return {'error', f'Error calling gdal_contour: {str(err)}'}
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 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()
err = err.decode('utf-8').strip()
success = p.returncode == 0
if not success:
return {'error', f'Error calling ogr2ogr: {str(err)}'}
if not os.path.isfile(outfile):
return {'error': f'Cannot find output file: {outfile}'}
if output_format == "ESRI Shapefile":
ext="zip"
shp_dir = os.path.join(tmpdir, "contours")
os.makedirs(shp_dir)
contour_files = glob.glob(os.path.join(tmpdir, "output.*"))
for cf in contour_files:
shutil.move(cf, shp_dir)
shutil.make_archive(os.path.join(tmpdir, 'output'), 'zip', shp_dir)
outfile = os.path.join(tmpdir, f"output.{ext}")
return {'file': outfile}
class TaskContoursGenerate(TaskView):
def post(self, request, pk=None):
task = self.get_and_check_task(request, pk)
@ -23,36 +93,25 @@ class TaskContoursGenerate(TaskView):
elif layer == 'DTM':
dem = os.path.abspath(task.get_asset_download_path("dtm.tif"))
else:
raise GrassEngineException('{} is not a valid layer.'.format(layer))
raise ContoursException('{} is not a valid layer.'.format(layer))
context = grass.create_context({'auto_cleanup' : False})
epsg = int(request.data.get('epsg', '3857'))
interval = float(request.data.get('interval', 1))
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)))
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))
context.add_param('dem_file', dem)
context.add_param('interval', interval)
context.add_param('format', format)
context.add_param('simplify', simplify)
context.add_param('epsg', epsg)
context.set_location(dem)
celery_task_id = execute_grass_script.delay(os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"calc_contours.py"
), context.serialize(), 'file').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 GrassEngineException as e:
except ContoursException as e:
return Response({'error': str(e)}, status=status.HTTP_200_OK)
class TaskContoursCheck(CheckTask):
def on_error(self, result):
cleanup_grass_context(result['context'])
pass
def error_check(self, result):
contours_file = result.get('file')

Wyświetl plik

@ -1,93 +0,0 @@
#%module
#% description: Calculate contours
#%end
#%option
#% key: dem_file
#% type: string
#% required: yes
#% multiple: no
#% description: GeoTIFF DEM containing the surface to calculate contours
#%end
#%option
#% key: interval
#% type: double
#% required: yes
#% multiple: no
#% description: Contours interval
#%end
#%option
#% key: format
#% type: string
#% required: yes
#% multiple: no
#% description: OGR output format
#%end
#%option
#% key: simplify
#% type: double
#% required: yes
#% multiple: no
#% description: OGR output format
#%end
#%option
#% key: epsg
#% type: string
#% required: yes
#% multiple: no
#% description: target EPSG code
#%end
# output: If successful, prints the full path to the contours file. Otherwise it prints "error"
import sys
import glob
import os
import shutil
from grass.pygrass.modules import Module
import grass.script as grass
import subprocess
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"
MIN_CONTOUR_LENGTH = 5
Module("r.external", input=opts['dem_file'], output="dem", overwrite=True)
Module("g.region", raster="dem")
Module("r.contour", input="dem", output="contours", step=opts["interval"], overwrite=True)
Module("v.generalize", input="contours", output="contours_smooth", method="douglas", threshold=opts["simplify"], overwrite=True)
Module("v.generalize", input="contours_smooth", output="contours_simplified", method="chaiken", threshold=1, overwrite=True)
Module("v.generalize", input="contours_simplified", output="contours_final", method="douglas", threshold=opts["simplify"], overwrite=True)
Module("v.edit", map="contours_final", tool="delete", threshold=[-1,0,-MIN_CONTOUR_LENGTH], query="length")
Module("v.out.ogr", input="contours_final", output="temp.gpkg", format="GPKG")
subprocess.check_call(["ogr2ogr", "-t_srs", "EPSG:%s" % opts['epsg'],
'-overwrite', '-f', opts["format"], "output.%s" % ext, "temp.gpkg"], 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")
return 0
if __name__ == "__main__":
opts, _ = grass.parser()
sys.exit(main())

Wyświetl plik

@ -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",

Some files were not shown because too many files have changed in this diff Show More