├── micamac ├── __init__.py ├── flask_utils.py ├── sixs.py ├── exif_utils.py ├── scripts │ ├── run_seamline_feathering.py │ ├── rerun_tawny.py │ ├── run_micmac.py │ └── align_images.py ├── micasense_utils.py └── micmac_utils.py ├── requirements.txt ├── setup.py ├── app.py ├── README.rst └── templates └── index.html /micamac/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.1" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fiona 2 | -e git+https://github.com/loicdtx/imageprocessing.git#egg=micasense 3 | shapely 4 | rasterio 5 | numpy 6 | pyexiftool 7 | flask 8 | Py6S 9 | 10 | -------------------------------------------------------------------------------- /micamac/flask_utils.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | 3 | 4 | def shutdown_server(): 5 | func = request.environ.get('werkzeug.server.shutdown') 6 | if func is None: 7 | raise RuntimeError('Not running with the Werkzeug Server') 8 | func() 9 | 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import codecs 5 | from setuptools import setup, find_packages 6 | import os 7 | 8 | # Parse the version from the main __init__.py 9 | with open('micamac/__init__.py') as f: 10 | for line in f: 11 | if line.find("__version__") >= 0: 12 | version = line.split("=")[1].strip() 13 | version = version.strip('"') 14 | version = version.strip("'") 15 | continue 16 | 17 | 18 | setup(name='micamac', 19 | version=version, 20 | description=u"Multi-spectral orthomosaic generation with micmac", 21 | author=u"Loic Dutrieux", 22 | author_email='loic.dutrieux@cirad.fr', 23 | license='GPLv3', 24 | packages=find_packages(), 25 | install_requires=[ 26 | 'shapely', 27 | 'fiona', 28 | 'rasterio', 29 | 'numpy', 30 | 'matplotlib', 31 | 'flask', 32 | 'micasense', 33 | 'Py6S', 34 | 'pyexiftool'], 35 | scripts=[ 36 | # 'micamac/scripts/get_centers.py', 37 | 'micamac/scripts/align_images.py', 38 | 'micamac/scripts/run_micmac.py', 39 | 'micamac/scripts/run_seamline_feathering.py', 40 | 'micamac/scripts/rerun_tawny.py' 41 | ]) 42 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import time 3 | 4 | from flask import Flask, render_template, jsonify, request 5 | 6 | app = Flask(__name__) 7 | 8 | EXAMPLE_FC = { 9 | "type": "FeatureCollection", 10 | "features": [ 11 | { 12 | "type": "Feature", 13 | "properties": {}, 14 | "geometry": { 15 | "type": "Point", 16 | "coordinates": [ 17 | 16.3092041015625, 18 | 1.6037944300589855 19 | ] 20 | } 21 | }, 22 | { 23 | "type": "Feature", 24 | "properties": {}, 25 | "geometry": { 26 | "type": "Point", 27 | "coordinates": [ 28 | 16.4794921875, 29 | 1.598303410509457 30 | ] 31 | } 32 | } 33 | ] 34 | } 35 | 36 | POLYGONS = [] 37 | 38 | def shutdown_server(): 39 | func = request.environ.get('werkzeug.server.shutdown') 40 | if func is None: 41 | raise RuntimeError('Not running with the Werkzeug Server') 42 | func() 43 | 44 | @app.route('/') 45 | def index(): 46 | return render_template('index.html', fc=EXAMPLE_FC) 47 | 48 | 49 | @app.route('/polygon', methods = ['POST']) 50 | def post_polygon(): 51 | content = request.get_json(silent=True) 52 | POLYGONS.append(content) 53 | shutdown_server() 54 | return jsonify('Bye') 55 | 56 | 57 | if __name__ == '__main__': 58 | print('Visit the address written below to select a subset of images to process') 59 | app.run(debug=False) 60 | print(POLYGONS) 61 | -------------------------------------------------------------------------------- /micamac/sixs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import contextlib 3 | 4 | from Py6S import * 5 | 6 | try: 7 | with open(os.devnull, 'w') as devnull: 8 | with contextlib.redirect_stdout(devnull): 9 | SixS.test() 10 | except Exception as e: 11 | _has_sixs = False 12 | else: 13 | _has_sixs = True 14 | 15 | 16 | 17 | WAVELENGTHS = [0.475, 0.560, 0.668, 0.840, 0.717] 18 | 19 | 20 | def modeled_irradiance_from_capture(c): 21 | """Retrieve an approximative modeled irradiance value for each band assuming clear sky conditions 22 | 23 | Args: 24 | c (micasense.capture.Capture): The capture from time and location will 25 | be used to model the irradiance 26 | 27 | Returns: 28 | list: List of five elements corresponding to the modeled irradiance for each 29 | of the five spectral chanels 30 | """ 31 | if not _has_sixs: 32 | raise ImportError('Py6S must be installed and properly configured (6s binary installed) to use that function') 33 | c_time = c.utc_time().strftime('%d/%m/%Y %H:%M:%S') 34 | c_lat,c_lon,_ = c.location() 35 | s = SixS() 36 | s.atmos_profile = AtmosProfile.FromLatitudeAndDate(c_lat, c_time) 37 | s.geometry.from_time_and_location(c_lat, c_lon, c_time, 0, 0) 38 | irradiance_list = SixSHelpers.Wavelengths.run_wavelengths(s, wavelengths=WAVELENGTHS, 39 | output_name='direct_solar_irradiance', 40 | verbose=False) 41 | return [x/1000 for x in irradiance_list[1]] 42 | 43 | -------------------------------------------------------------------------------- /micamac/exif_utils.py: -------------------------------------------------------------------------------- 1 | def dd2cardinals(lon, lat): 2 | """Determine cardinal direction from coordinates in decimal degrees 3 | 4 | Return: 5 | dict: Dict with cardinal directions under GPSLatitudeRef and GPSLongitudeRef keys 6 | """ 7 | out = dict() 8 | if lon >= 0: 9 | out['GPSLongitudeRef'] = 'E' 10 | else: 11 | out['GPSLongitudeRef'] = 'W' 12 | if lat >= 0: 13 | out['GPSLatitudeRef'] = 'N' 14 | else: 15 | out['GPSLatitudeRef'] = 'S' 16 | return out 17 | 18 | 19 | def exif_params_from_capture(c): 20 | """Build a valid exif paramaters set from a ``micasense.capture.Capture`` 21 | """ 22 | lat,lon,alt = c.location() 23 | gps_dt = c.utc_time() 24 | gps_date = gps_dt.date().isoformat() 25 | gps_time = gps_dt.time().isoformat() 26 | xres,yres = c.images[0].focal_plane_resolution_px_per_mm 27 | focal_length = c.images[0].focal_length 28 | focal_length_35 = c.images[0].focal_length_35 29 | GPS_dict = dd2cardinals(lon, lat) 30 | params = ['-GPSVersionID=2.2.0.0', 31 | '-GPSAltitudeRef="Above Sea Level"', 32 | '-GPSAltitude=%f' % alt, 33 | '-GPSLatitudeRef=%s' % GPS_dict['GPSLatitudeRef'], 34 | '-GPSLatitude=%f' % lat, 35 | '-GPSLongitudeRef=%s' % GPS_dict['GPSLongitudeRef'], 36 | '-GPSLongitude=%f' % lon, 37 | '-GPSDateStamp=%s' % gps_date, 38 | '-GPSTimeStamp=%s' % gps_time, 39 | '-FocalLength=%f' % focal_length, 40 | '-FocalPlaneXResolution=%f' % xres, 41 | '-FocalPlaneYResolution=%f' % yres, 42 | '-focallengthin35mmformat=%f' % focal_length_35, 43 | '-FocalPlaneResolutionUnit=mm'] 44 | return [str.encode(x) for x in params] 45 | 46 | -------------------------------------------------------------------------------- /micamac/scripts/run_seamline_feathering.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import subprocess 6 | import multiprocessing as mp 7 | 8 | 9 | COLORS = ['blue', 'green', 'red', 'nir', 'edge'] 10 | 11 | def sf_runner(ortho_dir): 12 | os.chdir(ortho_dir) 13 | subprocess.call(['mm3d', 'TestLib', 'SeamlineFeathering', 14 | 'Ort_.*tif', 'ApplyRE=1', 'ComputeRE=1', 15 | 'SzBox=[5000,5000]']) 16 | 17 | # MosaicFeathering.tif 18 | 19 | def main(img_dir, utm): 20 | # Create output dir and run gdal_translate 21 | if not os.path.exists('OUTPUT'): 22 | os.makedirs('OUTPUT') 23 | 24 | # Build iterable (list of the ortho dirs) 25 | ortho_dirs = [os.path.join(img_dir, 'Ortho-%s' % color) for color in COLORS] 26 | 27 | # Run Tawny for every band 28 | pool = mp.Pool(2) 29 | pool.map(sf_runner, ortho_dirs) 30 | 31 | os.chdir(img_dir) 32 | 33 | for color in COLORS: 34 | subprocess.call(['gdal_translate', '-a_srs', 35 | '+proj=utm +zone=%d +ellps=WGS84 +datum=WGS84 +units=m +no_defs' % utm, 36 | 'Ortho-%s/MosaicFeathering.tif' % color, 37 | 'OUTPUT/mosaicFeathering_%s.tif' % color]) 38 | 39 | 40 | if __name__ == '__main__': 41 | epilog = """ 42 | Run micmac SeamlineFeathering mosaicking tool 43 | 44 | Example usage: 45 | -------------- 46 | # Display help 47 | run_seamline_feathering.py --help 48 | 49 | # With specific parameters 50 | run_seamline_feathering.py -i /path/to/images --utm 33 51 | """ 52 | # Instantiate argparse parser 53 | parser = argparse.ArgumentParser(epilog=epilog, 54 | formatter_class=argparse.RawTextHelpFormatter) 55 | 56 | # parser arguments 57 | parser.add_argument('-i', '--img_dir', 58 | required=True, 59 | type=str, 60 | help='directory containing images') 61 | 62 | parser.add_argument('-utm', '--utm', 63 | default=33, 64 | type=int, 65 | help='UTM zone of the output orthomosaic') 66 | 67 | parsed_args = parser.parse_args() 68 | main(**vars(parsed_args)) 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | MicaMAC 3 | ******* 4 | 5 | *multispectral ortho-mosaics for micasense redEdge cameras using MICMAC* 6 | 7 | - `Micasense RedEdge camera `_ 8 | - `MICMAC `_ 9 | 10 | MicaMAC is a set of wrappers to facilitate the generation of ortho-mosaics out of micasense RedEdge data; it uses the `micasense python package `_, MICMAC and a bit of python. 11 | 12 | Processing micasense RedEdge data does not work out of the box with free/open-source photogrammetry software tools like MICMAC mainly for 3 reasons: 13 | 14 | - The individual bands of the raw captures are not aligned with each others 15 | - There are five bands while most software are optimized for 3 bands (RGB) images. 16 | - Many software do not support tiff file format and/or data in int16. Again, optimized for "classic" photographs (3 bands jpg image in ``int8``). MICMAC is the exception there as it's perfectly happy using tiff in ``int16``. 17 | 18 | 19 | Usage 20 | ===== 21 | 22 | There are two command lines: 23 | 24 | - ``align_images.py`` performs bands alignent, optional altitude and AOI filtering, and optional conversion to surface reflectance. 25 | - ``run_micmac.py`` runs a more or less standard micmac workflow on the results of the ``align_images.py`` command, resulting in the generation of ortho-mosaic, DEM and dense point cloud. 26 | 27 | Both command lines have a detailed manual that can be accessed by running the command with the ``--help`` flag. 28 | 29 | 30 | Installation 31 | ============ 32 | 33 | 1. Install MICMAC (see `installation guide `_ ) 34 | 2. Clone the repos: 35 | 36 | .. code-block:: bash 37 | 38 | git clone https://github.com/loicdtx/micamac.git 39 | 40 | 3. Install python dependencies and package (preferably inside a python3 virtualenv) 41 | 42 | .. code-block:: bash 43 | 44 | cd micamac 45 | pip install -r requirements.txt 46 | pip install -e . 47 | 48 | If it fails because of GDAL or rasterio, try running the commands below. 49 | 50 | .. code-block:: bash 51 | 52 | pip install numpy 53 | pip install GDAL==$(gdal-config --version) --global-option=build_ext --global-option="-I/usr/include/gdal" 54 | pip install -r requirements.txt 55 | pip install -e . 56 | 57 | 58 | 4. Optionally if you have flights without proper panel captures and DLS but still want some sort of conversion to reflectance/normalization among flights (using 6S radiative transfer modeling), you can use the ``irraiance-modeling`` branch. For that you must install `Py6S `_ (and ``6S``). 59 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Micamac, draw a subset 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | 36 | 126 | 127 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /micamac/scripts/rerun_tawny.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import glob 6 | import shutil 7 | import re 8 | import subprocess 9 | import multiprocessing as mp 10 | import functools as ft 11 | 12 | from micamac.micmac_utils import run_tawny 13 | 14 | 15 | COLORS = ['blue', 'green', 'red', 'nir', 'edge'] 16 | 17 | # Every orthomosaic may require radiometric equalization parameters tuning 18 | # The command re-runs tawny for all 5 bands without overwriting previous results 19 | def tawny_runner(color, args): 20 | subprocess.call(['mm3d', 'Tawny', 21 | 'Ortho-%s' % color, 22 | *args]) 23 | 24 | def main(img_dir, filename_prefix, utm, **kwargs): 25 | ## kwargs should contain: 26 | # RadiomEgal, DEq, DEqXY, AddCste, DegRap, DegRapXY, SzV 27 | # Filter unset 28 | tawny_kwargs = {k:v for k,v in kwargs.items() if v is not None} 29 | 30 | Out = '%s.tif' % filename_prefix 31 | tawny_kwargs['Out'] = Out 32 | 33 | arg_list = ['{k}={v}'.format(k=k,v=v) for k,v in tawny_kwargs.items()] 34 | print(arg_list) 35 | 36 | # Set workdir 37 | os.chdir(img_dir) 38 | 39 | # Create output dir and run gdal_translate 40 | if not os.path.exists('OUTPUT'): 41 | os.makedirs('OUTPUT') 42 | 43 | # Run Tawny for every band 44 | pool = mp.Pool(5) 45 | pool.map(ft.partial(tawny_runner, args=arg_list), COLORS) 46 | 47 | for color in COLORS: 48 | subprocess.call(['gdal_translate', '-a_srs', 49 | '+proj=utm +zone=%d +ellps=WGS84 +datum=WGS84 +units=m +no_defs' % utm, 50 | 'Ortho-%s/%s.tif' % (color, filename_prefix), 51 | 'OUTPUT/%s_%s.tif' % (filename_prefix, color)]) 52 | 53 | 54 | if __name__ == '__main__': 55 | epilog = """ 56 | Re-run tawny with specific parameters on an existing project 57 | See Tawny doc for more details on each paramaters 58 | 59 | Example usage: 60 | -------------- 61 | # Display help 62 | ./rerun_tawny.py --help 63 | 64 | # With specific parameters 65 | ./rerun_tawny.py -i /path/to/images --utm 33 --filename-prefix ortho_zero_deq --DEq 0 66 | """ 67 | # Instantiate argparse parser 68 | parser = argparse.ArgumentParser(epilog=epilog, 69 | formatter_class=argparse.RawTextHelpFormatter) 70 | 71 | # parser arguments 72 | parser.add_argument('-i', '--img_dir', 73 | required=True, 74 | type=str, 75 | help='directory containing images') 76 | 77 | parser.add_argument('-utm', '--utm', 78 | default=33, 79 | type=int, 80 | help='UTM zone of the output orthomosaic') 81 | 82 | parser.add_argument('--filename-prefix', '--filename-prefix', 83 | default='Ortho2', 84 | type=str, 85 | help='Prefix used for the orthomosaic name (suffix is {color}.tif)') 86 | 87 | parser.add_argument('--RadiomEgal', dest='RadiomEgal', action='store_const', const=1) 88 | parser.add_argument('--no-RadiomEgal', dest='RadiomEgal', action='store_const', const=0) 89 | parser.set_defaults(RadiomEgal=1) 90 | 91 | parser.add_argument('--DEq', '-DEq', 92 | default=1, 93 | type=int, 94 | help='Degree of equalization') 95 | 96 | parser.add_argument('--DEqXY', '-DEqXY', 97 | default=None, 98 | type=int, 99 | nargs=2, 100 | help='Degrees of equalization in X and Y directions, (supply two values)') 101 | 102 | parser.add_argument('--AddCste', 103 | help='Add unknown constant for equalization', 104 | action='store_const', 105 | const=1, 106 | default=0) 107 | 108 | parser.add_argument('--DegRap', '-DegRap', 109 | help='Degree of rappel to initial values', 110 | type=int, 111 | default=0) 112 | 113 | parser.add_argument('--DegRapXY', '-DegRapXY', 114 | help='Degree of rappel to initial values in X and Y directions (supply two values)', 115 | type=int, 116 | nargs=2, 117 | default=None) 118 | 119 | parser.add_argument('--SzV', '-SzV', 120 | help='Size of Window for equalization', 121 | type=int, 122 | default=1) 123 | 124 | # RadiomEgal, DEq, DEqXY, AddCste, DegRap, DegRapXY, SzV 125 | 126 | parsed_args = parser.parse_args() 127 | main(**vars(parsed_args)) 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /micamac/micasense_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import math 3 | 4 | from affine import Affine 5 | from shapely.geometry import Point 6 | import rasterio 7 | from rasterio.crs import CRS 8 | import numpy as np 9 | import exiftool 10 | 11 | from micasense import imageutils 12 | 13 | from micamac.exif_utils import exif_params_from_capture 14 | 15 | 16 | def affine_from_capture(c, res): 17 | """Build an affine transform from a ``micasense.capture.Capture`` 18 | 19 | Args: 20 | c (``micasense.captue.Capture``): The capture 21 | res (float): Expected ground resolution in meters 22 | 23 | TODO: I'm not at all sure that it's how affine rotation are handled 24 | 25 | Return: 26 | affine.Affine: Affine transform to be passed to rasterio open when writing 27 | the array to file 28 | """ 29 | lat,lon,_ = c.location() 30 | res_deg = res / 111320 31 | yaw_rad = c.dls_pose()[0] 32 | yaw_deg = 360 - math.degrees(yaw_rad) 33 | aff = Affine(res_deg, 0, lon, 34 | 0, -res_deg, lat) 35 | return aff * Affine.rotation(yaw_deg) 36 | 37 | 38 | def capture_to_files(cap_tuple, scaling, out_dir, warp_matrices, warp_mode, 39 | cropped_dimensions, match_index, img_type=None, 40 | irradiance_list=None, resolution=0.1): 41 | """Wrapper to align images of capture and write them to separate GeoTiffs on disk 42 | 43 | Args: 44 | cap_tuple (tuple): Tuple of (capture, is_valid, count) 45 | """ 46 | cap, valid, count = cap_tuple 47 | if valid: 48 | if img_type == 'reflectance': 49 | cap.compute_reflectance(irradiance_list=irradiance_list) 50 | aligned_stack = imageutils.aligned_capture(capture=cap, 51 | warp_matrices=warp_matrices, 52 | warp_mode=warp_mode, 53 | cropped_dimensions=cropped_dimensions, 54 | match_index=match_index, 55 | img_type=img_type) 56 | aligned_stack = aligned_stack * scaling 57 | aligned_stack[aligned_stack > 65535] = 65535 58 | aligned_stack = aligned_stack.astype('uint16') 59 | panchro_array = (0.299 * aligned_stack[:,:,2] + 0.587 * aligned_stack[:,:,1] + 0.114 * aligned_stack[:,:,0]) * 3 60 | panchro_array = panchro_array.astype('uint16') 61 | # Retrieve exif dict 62 | exif_params = exif_params_from_capture(cap) 63 | # Write to file 64 | blue_path = os.path.join(out_dir, 'blue_%05d.tif' % count) 65 | red_path = os.path.join(out_dir, 'red_%05d.tif' % count) 66 | green_path = os.path.join(out_dir, 'green_%05d.tif' % count) 67 | nir_path = os.path.join(out_dir, 'nir_%05d.tif' % count) 68 | edge_path = os.path.join(out_dir, 'edge_%05d.tif' % count) 69 | pan_path = os.path.join(out_dir, 'pan_%05d.tif' % count) 70 | # Write 5 bands stack to file on disk 71 | aff = affine_from_capture(cap, resolution) 72 | profile = {'driver': 'GTiff', 73 | 'count': 1, 74 | 'transform': aff, 75 | 'crs': CRS.from_epsg(4326), 76 | 'height': aligned_stack.shape[0], 77 | 'width': aligned_stack.shape[1], 78 | 'dtype': np.uint16} 79 | # Write blue 80 | with rasterio.open(blue_path, 'w', **profile) as dst: 81 | dst.write(aligned_stack[:,:,0], 1) 82 | # Write green 83 | with rasterio.open(green_path, 'w', **profile) as dst: 84 | dst.write(aligned_stack[:,:,1], 1) 85 | # Write red 86 | with rasterio.open(red_path, 'w', **profile) as dst: 87 | dst.write(aligned_stack[:,:,2], 1) 88 | # Write nir 89 | with rasterio.open(nir_path, 'w', **profile) as dst: 90 | dst.write(aligned_stack[:,:,3], 1) 91 | # Write rededge 92 | with rasterio.open(edge_path, 'w', **profile) as dst: 93 | dst.write(aligned_stack[:,:,4], 1) 94 | # Write panchromatic 95 | with rasterio.open(pan_path, 'w', **profile) as dst: 96 | dst.write(panchro_array, 1) 97 | with exiftool.ExifTool() as et: 98 | et.execute(*exif_params, 99 | str.encode('-overwrite_original'), 100 | str.encode(blue_path)) 101 | et.execute(*exif_params, 102 | str.encode('-overwrite_original'), 103 | str.encode(green_path)) 104 | et.execute(*exif_params, 105 | str.encode('-overwrite_original'), 106 | str.encode(red_path)) 107 | et.execute(*exif_params, 108 | str.encode('-overwrite_original'), 109 | str.encode(nir_path)) 110 | et.execute(*exif_params, 111 | str.encode('-overwrite_original'), 112 | str.encode(edge_path)) 113 | et.execute(*exif_params, 114 | str.encode('-overwrite_original'), 115 | str.encode(pan_path)) 116 | 117 | cap.clear_image_data() 118 | 119 | 120 | def capture_to_point(c, ndigits=6): 121 | """Build a shapely Point from a capture 122 | """ 123 | lat,lon,_ = [round(x, ndigits) for x in c.location()] 124 | return Point(lon, lat) 125 | -------------------------------------------------------------------------------- /micamac/micmac_utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import glob 3 | import multiprocessing as mp 4 | import re 5 | import os 6 | import shutil 7 | import xml.etree.ElementTree as ET 8 | 9 | from shapely.geometry import Point 10 | import exiftool 11 | import rasterio 12 | from rasterio.crs import CRS 13 | from rasterio.warp import transform_geom 14 | from rasterio.features import rasterize 15 | from affine import Affine 16 | from shapely.geometry import mapping, shape, MultiPoint 17 | 18 | 19 | def run_tawny(color): 20 | """tawny wrapper to be called in a multiprocessing map 21 | """ 22 | subprocess.call(['mm3d', 'Tawny', 23 | 'Ortho-%s' % color, 24 | 'DEq=1', 'DegRap=0', 'SzV=25']) 25 | 26 | 27 | def img_to_Point(img_path): 28 | """Given a file with exif geotag on disk, build a shapely Point 29 | 30 | Return: 31 | Tuple: A tuple (``shapely.geometry.Point``, path) 32 | """ 33 | with exiftool.ExifTool() as et: 34 | meta = et.get_metadata(img_path) 35 | geom = Point(meta['EXIF:GPSLongitude'], meta['EXIF:GPSLatitude']) 36 | return (geom, img_path) 37 | 38 | 39 | def dir_to_points(): 40 | """Wrapper to run ``img_to_point`` on all panchromatic images of the current working directory 41 | 42 | Uses all threads available, return a list of tuples (see help of img_to_Point) 43 | """ 44 | img_list = glob.glob('pan*tif') 45 | all_cpu = mp.cpu_count() 46 | pool = mp.Pool(all_cpu) 47 | point_list = pool.map(img_to_Point, img_list) 48 | return point_list 49 | 50 | 51 | def update_poubelle(): 52 | """Mirror content of Poubelle for all bands 53 | 54 | Move R,G,B,NIR,RE images corresponding to panchromatic images already 55 | present in Poubelle 56 | """ 57 | bad_files = glob.glob('Poubelle/pan*tif') 58 | bad_files = [os.path.basename(x) for x in bad_files] 59 | file_pattern = re.compile(r'pan_(\d{5}\.tif)$') 60 | [shutil.move(file_pattern.sub(r'blue_\1', x), 'Poubelle/') for x in bad_files] 61 | [shutil.move(file_pattern.sub(r'green_\1', x), 'Poubelle/') for x in bad_files] 62 | [shutil.move(file_pattern.sub(r'red_\1', x), 'Poubelle/') for x in bad_files] 63 | [shutil.move(file_pattern.sub(r'nir_\1', x), 'Poubelle/') for x in bad_files] 64 | [shutil.move(file_pattern.sub(r'edge_\1', x), 'Poubelle/') for x in bad_files] 65 | 66 | 67 | def update_ori(path='Ori-Ground_UTM'): 68 | """Create ori files for all bands by renaming existing panchromatic ori files 69 | 70 | Args: 71 | path (str): Orientation directory 72 | """ 73 | glob_pattern = os.path.join(path, 'Orientation-pan*xml') 74 | ori_pan_list = glob.glob(glob_pattern) 75 | ori_file_pattern = re.compile(r'(Orientation-)pan(_\d{5}\.tif\.xml)') 76 | [shutil.copyfile(x, ori_file_pattern.sub(r'\1blue\2', x)) for x in ori_pan_list] 77 | [shutil.copyfile(x, ori_file_pattern.sub(r'\1green\2', x)) for x in ori_pan_list] 78 | [shutil.copyfile(x, ori_file_pattern.sub(r'\1red\2', x)) for x in ori_pan_list] 79 | [shutil.copyfile(x, ori_file_pattern.sub(r'\1nir\2', x)) for x in ori_pan_list] 80 | [shutil.copyfile(x, ori_file_pattern.sub(r'\1edge\2', x)) for x in ori_pan_list] 81 | 82 | 83 | def clean_intermediary(exclude=['OUTPUT/']): 84 | """Delete all intermediary output of the micmac execution 85 | """ 86 | if isinstance(exclude, str): 87 | exclude = [exclude] 88 | dir_list = glob.glob('*/') 89 | [dir_list.remove(d) for d in exclude] 90 | [shutil.rmtree(x) for x in dir_list] 91 | 92 | 93 | def clean_images(): 94 | """Delete all images of the working directory 95 | """ 96 | tif_list = glob.glob('*tif') 97 | [os.remove(x) for x in tif_list] 98 | 99 | 100 | def create_proj_file(zone): 101 | proj_xml = """ 102 | 103 | 104 | eTC_Proj4 105 | 1 106 | 1 107 | 1 108 | +proj=utm +zone=%d +ellps=WGS84 +datum=WGS84 +units=m +no_defs 109 | 110 | 111 | 112 | """ % zone 113 | 114 | with open('SysUTM.xml', 'w') as dst: 115 | dst.write(proj_xml) 116 | 117 | 118 | def make_tarama_mask(point_list, utm_zone, buff=0, TA_dir='TA'): 119 | """Generate a mask using the gps coordinates of the captures 120 | 121 | This command follows the execution of tarama, after which a mask is normally 122 | created interactively by the user 123 | 124 | Args: 125 | point_list (list): List of (shapely.Point, str) tuples. See ``dir_to_points`` 126 | utm_zone (int): Utm zone of the project 127 | buffer (float): optional buffer to extend or reduce masked area around 128 | the convex hull of the point list 129 | TA_dir (str): tarama dir relative to current directory. Defaults to ``'TA'`` 130 | """ 131 | # Build study area polygon 132 | src_crs = CRS.from_epsg(4326) 133 | dst_crs = CRS(proj='utm', zone=utm_zone, ellps='WGS84', units='m') 134 | feature_list = [mapping(x[0]) for x in point_list] 135 | feature_list_proj = [transform_geom(src_crs, dst_crs, x) for x in feature_list] 136 | point_list_proj = [shape(x) for x in feature_list_proj] 137 | study_area = MultiPoint(point_list_proj).convex_hull.buffer(buff) 138 | 139 | # Retrieve Affine transform and shape from TA dir 140 | root = ET.parse(os.path.join(TA_dir, 'TA_LeChantier.xml')).getroot() 141 | x_ori, y_ori = [float(x) for x in root.find('OriginePlani').text.split(' ')] 142 | x_res, y_res = [float(x) for x in root.find('ResolutionPlani').text.split(' ')] 143 | arr_shape = tuple(reversed([int(x) for x in root.find('NombrePixels').text.split(' ')])) 144 | aff = Affine(x_res, 0, x_ori, 0, y_res, y_ori) 145 | 146 | # Rasterize study area to template raster 147 | arr = rasterize(shapes=[(study_area, 255)], out_shape=arr_shape, fill=0, 148 | transform=aff, default_value=255, dtype=rasterio.uint8) 149 | 150 | # Write mask to raster 151 | meta = {'driver': 'GTiff', 152 | 'dtype': 'uint8', 153 | 'width': arr_shape[1], 154 | 'height': arr_shape[0], 155 | 'count': 1, 156 | 'crs': dst_crs, 157 | 'transform': aff} 158 | filename = os.path.join(TA_dir, 'TA_LeChantier_Masq.tif') 159 | with rasterio.open(filename, 'w', **meta) as dst: 160 | dst.write(arr, 1) 161 | 162 | # Create associated xml file 163 | xml_content = """ 164 | 165 | %s 166 | %d %d 167 | 0 0 168 | 1 1 169 | 0 170 | 1 171 | eGeomMNTFaisceauIm1PrCh_Px1D 172 | """ % (filename, arr_shape[0], arr_shape[1]) 173 | 174 | xml_filename = os.path.join(TA_dir, 'TA_LeChantier_Masq.xml') 175 | with open(xml_filename, 'w') as dst: 176 | dst.write(xml_content) 177 | 178 | 179 | def get_and_georeference_dem(utm_zone): 180 | """Retrieve DEM from MEC-Malt directory and move it to the OUTPUT dir, while adding a CRS 181 | """ 182 | dem_filename = sorted(glob.glob('MEC-Malt/Z_Num*_DeZoom*tif'))[-1] 183 | # gdal_translate -a_srs "+proj=utm +zone=33 +ellps=WGS84 +datum=WGS84 +units=m +no_defs" MEC-Malt/Z_Num7_DeZoom4_STD-MALT.tif OUTPUT/dem_aac_1.tif 184 | subprocess.call(['gdal_translate', '-a_srs', 185 | '+proj=utm +zone=%d +ellps=WGS84 +datum=WGS84 +units=m +no_defs' % utm_zone, 186 | dem_filename, 'OUTPUT/dem.tif']) 187 | 188 | 189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /micamac/scripts/run_micmac.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import glob 6 | import shutil 7 | import re 8 | import subprocess 9 | import multiprocessing as mp 10 | 11 | from shapely.geometry import Point 12 | 13 | from micamac.micmac_utils import run_tawny, dir_to_points, update_poubelle, update_ori 14 | from micamac.micmac_utils import create_proj_file, clean_intermediary, clean_images 15 | from micamac.micmac_utils import make_tarama_mask, get_and_georeference_dem 16 | 17 | 18 | COLORS = ['blue', 'green', 'red', 'nir', 'edge'] 19 | 20 | STARTFROM_MAPPING = {'exif': 0, # Default 21 | 'tapioca': 1, 22 | 'schnaps': 2, 23 | 'tapas_subset': 3, 24 | 'martini': 4, 25 | 'tapas_full': 5, 26 | 'centerbascule': 6, 27 | 'campari': 7, 28 | 'chgsysco': 8, 29 | 'malt_pan': 9, 30 | 'malt_multi': 10, 31 | 'tawny': 11} 32 | 33 | def main(img_dir, lon, lat, radius, resolution, ortho, dem, ply, 34 | ncores, utm, clean_intermediary, clean_images, startfrom): 35 | if not any([ortho, dem, ply]): 36 | raise ValueError('You must select at least one of --ortho, --dem and --ply') 37 | startfrom = STARTFROM_MAPPING[startfrom.lower()] 38 | # Set workdir 39 | os.chdir(img_dir) 40 | proj_xml = """ 41 | 42 | 43 | eTC_Proj4 44 | 1 45 | 1 46 | 1 47 | +proj=utm +zone=%d +ellps=WGS84 +datum=WGS84 +units=m +no_defs 48 | 49 | 50 | 51 | """ % utm 52 | 53 | with open('SysUTM.xml', 'w') as dst: 54 | dst.write(proj_xml) 55 | 56 | 57 | # mm3d XifGps2Txt "rgb.*tif" 58 | subprocess.call(['mm3d', 'XifGps2Txt', 'pan.*tif']) 59 | 60 | # mm3d XifGps2Xml "rgb.*tif" RAWGNSS 61 | subprocess.call(['mm3d', 'XifGps2Xml', 'pan.*tif', 'RAWGNSS']) 62 | 63 | # mm3d OriConvert "#F=N X Y Z" GpsCoordinatesFromExif.txt RAWGNSS_N ChSys=DegreeWGS84@RTLFromExif.xml MTD1=1 NameCple=FileImagesNeighbour.xml NbImC=25 64 | subprocess.call(['mm3d', 'OriConvert', '#F=N X Y Z', 65 | 'GpsCoordinatesFromExif.txt', 'RAWGNSS_N', 66 | 'ChSys=DegreeWGS84@RTLFromExif.xml', 'MTD1=1', 67 | 'NameCple=FileImagesNeighbour.xml', 'NbImC=20']) 68 | 69 | if startfrom <= 1: 70 | # mm3d Tapioca File FileImagesNeighbour.xml -1 71 | subprocess.call(['mm3d', 'Tapioca', 'File', 72 | 'FileImagesNeighbour.xml', '-1']) 73 | 74 | if startfrom <= 2: 75 | # mm3d Schnaps "pan.*tif" MoveBadImgs=1 76 | subprocess.call(['mm3d', 'Schnaps', 'pan.*tif', 'MoveBadImgs=1']) 77 | 78 | # Build a list of file around the provided coordinate to compute a pre orientation model 79 | radius_dd = radius / 111320.0 80 | search_polygon = Point(lon, lat).buffer(radius_dd) 81 | point_list = dir_to_points() 82 | img_list = [] 83 | for point_tuple in point_list: 84 | if point_tuple[0].intersects(search_polygon): 85 | img_list.append(point_tuple[1]) 86 | 87 | if startfrom <= 3: 88 | # mm3d Tapas FraserBasic $file_list Out=Arbitrary_pre SH=_mini 89 | subprocess.call(['mm3d', 'Tapas', 'FraserBasic', 90 | '|'.join(img_list), 91 | 'Out=Arbitrary_pre', 'SH=_mini']) 92 | 93 | # mm3d Martini "pan.*tif" SH=_mini OriCalib=Arbitrary_pre 94 | if startfrom <= 4: 95 | subprocess.call(['mm3d', 'Martini', 'pan.*tif', 96 | 'SH=_mini', 'OriCalib=Arbitrary_pre']) 97 | 98 | if startfrom <= 5: 99 | # Compute orientation model for the full block 100 | # mm3d Tapas FraserBasic "pan.*tif" Out=Arbitrary SH=_mini InCal=Arbitrary_pre 101 | # mm3d Tapas FraserBasic "pan.*tif" Out=Arbitrary InCal=Arbitrary_pre SH=_mini InOri=Martini_miniArbitrary_pre 102 | p = subprocess.Popen(['mm3d', 'Tapas', 'FraserBasic', 'pan.*tif', 103 | 'Out=Arbitrary', 'SH=_mini', 'InCal=Arbitrary_pre', 104 | 'InOri=Martini_miniArbitrary_pre', 'EcMax=50'], 105 | stdin=subprocess.PIPE) 106 | p.communicate(input='\n'.encode('utf-8')) 107 | 108 | if startfrom <= 6: 109 | # mm3d CenterBascule "rgb.*tif" Arbitrary RAWGNSS_N Ground_Init_RTL 110 | subprocess.call(['mm3d', 'CenterBascule', 'pan.*tif', 111 | 'Arbitrary', 'RAWGNSS_N', 'Ground_Init_RTL']) 112 | 113 | if startfrom <= 7: 114 | # mm3d Campari "rgb.*tif" Ground_Init_RTL Ground_RTL EmGPS=\[RAWGNSS_N,5\] AllFree=1 SH=_mini 115 | subprocess.call(['mm3d', 'Campari', 'pan.*tif', 'Ground_Init_RTL', 'Ground_RTL', 116 | 'EmGPS=[RAWGNSS_N,5]', 'AllFree=1', 'SH=_mini']) 117 | 118 | if startfrom <= 8: 119 | # mm3d ChgSysCo "rgb.*tif" Ground_RTL RTLFromExif.xml@SysUTM.xml Ground_UTM 120 | subprocess.call(['mm3d', 'ChgSysCo', 'pan.*tif', 121 | 'Ground_RTL', 'RTLFromExif.xml@SysUTM.xml', 'Ground_UTM']) 122 | 123 | if startfrom <= 9: 124 | # Run Tarama (projection of all images on a horizontal plan), and auto define a mask for use in Malt 125 | subprocess.call(['mm3d', 'Tarama', 'pan_.*tif', 'Ground_UTM']) 126 | make_tarama_mask(point_list=point_list, utm_zone=utm, buff=50) 127 | 128 | if startfrom <= 9: 129 | # Run malt for panchromatic 130 | subprocess.call(['mm3d', 'Malt', 'Ortho', 131 | 'pan.*tif', 'Ground_UTM', 'DirTA=TA', 'NbProc=%d' % ncores, 132 | 'DefCor=0.0005', 'ZoomF=4', 'ResolTerrain=%f' % resolution]) 133 | 134 | # MIrror content of POubelle for all colors 135 | # In a try-except so that it doesn't fail on re-runs 136 | try: 137 | update_poubelle() 138 | except Exception as e: 139 | pass 140 | 141 | # Create orientation files for every color 142 | try: 143 | update_ori() 144 | except Exception as e: 145 | pass 146 | 147 | if startfrom <= 10: 148 | # Run malt for every band 149 | for color in COLORS: 150 | subprocess.call(['mm3d', 'Malt', 'Ortho', 151 | '(pan|%s).*tif' % color, 152 | 'Ground_UTM', 'DoMEC=0', 'DoOrtho=1', 153 | 'ImOrtho="%s.*.tif"' % color, 154 | 'DirOF=Ortho-%s' % color, 155 | 'DirMEC=MEC-Malt', 156 | 'ZoomF=4', 157 | 'NbProc=%d' % ncores, 158 | 'ImMNT="pan.*tif"', 159 | 'ResolTerrain=%f' % resolution]) 160 | 161 | # Create output dir and run gdal_translate 162 | if not os.path.exists('OUTPUT'): 163 | os.makedirs('OUTPUT') 164 | 165 | if ortho: 166 | # Run Tawny for every band 167 | pool = mp.Pool(ncores) 168 | pool.map(run_tawny, COLORS) 169 | 170 | for color in COLORS: 171 | subprocess.call(['gdal_translate', '-a_srs', 172 | '+proj=utm +zone=%d +ellps=WGS84 +datum=WGS84 +units=m +no_defs' % utm, 173 | 'Ortho-%s/Orthophotomosaic.tif' % color, 174 | 'OUTPUT/ortho_%s.tif' % color]) 175 | if dem: 176 | get_and_georeference_dem(utm_zone=utm) 177 | 178 | if ply: 179 | pass 180 | 181 | if clean_intermediary: 182 | clean_intermediary() 183 | 184 | if clean_images: 185 | clean_images() 186 | 187 | 188 | 189 | if __name__ == '__main__': 190 | epilog = """ 191 | Run micmac Ortho generation workflow on previously aligned micasense bands 192 | 193 | 194 | Example usage: 195 | -------------- 196 | # Display help 197 | ./run_micmac.py --help 198 | 199 | # Run workflow and clean intermediary outputs 200 | ./run_micmac.py -i /path/to/images --lon 12.43 --lat 1.234 --utm 33 --clean 201 | """ 202 | # Instantiate argparse parser 203 | parser = argparse.ArgumentParser(epilog=epilog, 204 | formatter_class=argparse.RawTextHelpFormatter) 205 | 206 | # parser arguments 207 | parser.add_argument('-i', '--img_dir', 208 | required=True, 209 | type=str, 210 | help='directory containing images') 211 | 212 | parser.add_argument('-lon', '--lon', 213 | required=True, 214 | type=float, 215 | help='Pre-orientation image cluster center longitude') 216 | 217 | parser.add_argument('-lat', '--lat', 218 | required=True, 219 | type=float, 220 | help='Pre-orientation image cluster center latitude') 221 | 222 | parser.add_argument('-rad', '--radius', 223 | required=True, 224 | type=float, 225 | help='Search radius in meters around provided coordinates, to select pre-orientation image subset') 226 | 227 | parser.add_argument('-res', '--resolution', 228 | default=0.1, 229 | type=float, 230 | help='Resolution of the produced orthomosaic in meters') 231 | 232 | parser.add_argument('-ortho', '--ortho', 233 | action='store_true', 234 | help='Export orthomosaic') 235 | 236 | parser.add_argument('-dem', '--dem', 237 | action='store_true', 238 | help='Export DEM') 239 | 240 | parser.add_argument('-ply', '--ply', 241 | action='store_true', 242 | help='Exporte dense point cloud') 243 | 244 | parser.add_argument('-n', '--ncores', 245 | default=20, 246 | type=int, 247 | help=""" 248 | Number of cores to use for multiprocessing. There\'s no use in setting it >5, 249 | for it\'s only used for running Tawny in parallel on the 5 bands. This argument has no impact 250 | on the other micmac steps that use all threads available""") 251 | 252 | parser.add_argument('-utm', '--utm', 253 | default=33, 254 | type=int, 255 | help='UTM zone of the output orthomosaic') 256 | 257 | parser.add_argument('-c-int', '--clean-intermediary', 258 | action='store_true', 259 | help='Clean all intermediary output after terminating (folder auto-generated by micmac)') 260 | 261 | parser.add_argument('-c-img', '--clean-images', 262 | action='store_true', 263 | help='Delete all input images after successful completion') 264 | 265 | parser.add_argument('-sf', '--startfrom', 266 | default='exif', 267 | type=str, 268 | help=""" 269 | Step from which to start the process, usefull for re-starting a failed or interupted 270 | previous process. 271 | Can be one of: 272 | exif 273 | tapioca 274 | schnaps 275 | tapas_subset 276 | martini 277 | tapas_full 278 | centerbascule 279 | campari 280 | chgsysco 281 | malt_pan 282 | malt_multi 283 | tawny 284 | """) 285 | 286 | 287 | parsed_args = parser.parse_args() 288 | main(**vars(parsed_args)) 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | -------------------------------------------------------------------------------- /micamac/scripts/align_images.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Loic Dutrieux, May 2019 5 | Command line to pre-process micasense red-edge images; performs: 6 | - Interactive subsetting to remove e.g. plane turns, approach, etc 7 | - Optional conversion to reflectance 8 | - bands alignment 9 | - export each band independently to geotiff 10 | - Produce panchromatic band and export it to geotiff 11 | - Add geotags and other georeferencing metadata to exif 12 | """ 13 | import os 14 | import glob 15 | import random 16 | import argparse 17 | import multiprocessing as mp 18 | import functools 19 | import tempfile 20 | import json 21 | 22 | import numpy as np 23 | import matplotlib.pyplot as plt 24 | import exiftool 25 | import fiona 26 | from shapely.geometry import mapping, shape 27 | from flask import Flask, render_template, jsonify, request, session 28 | 29 | from micasense import imageutils 30 | import micasense.imageset as imageset 31 | 32 | from micamac.micasense_utils import capture_to_point, capture_to_files 33 | from micamac.flask_utils import shutdown_server 34 | from micamac.sixs import modeled_irradiance_from_capture 35 | 36 | 37 | app = Flask(__name__, template_folder='../../templates') 38 | POLYGONS = [] 39 | 40 | 41 | @app.route('/') 42 | def index(): 43 | fc_tmp_file = os.path.join(tempfile.gettempdir(), 'micamac_fc.geojson') 44 | with open(fc_tmp_file) as src: 45 | fc = json.load(src) 46 | return render_template('index.html', fc=fc) 47 | 48 | 49 | @app.route('/polygon', methods = ['POST']) 50 | def post_polygon(): 51 | content = request.get_json(silent=True) 52 | POLYGONS.append(content) 53 | shutdown_server() 54 | return jsonify('Bye') 55 | 56 | 57 | 58 | def float_or_str(value): 59 | """Helper function to for mixed type input argument in argparse 60 | """ 61 | try: 62 | return float(value) 63 | except: 64 | return value 65 | 66 | 67 | def main(img_dir, out_dir, alt_thresh, ncores, start_count, scaling, 68 | irradiance, subset, layer, resolution): 69 | # Create output dir it doesn't exist yet 70 | if not os.path.exists(out_dir): 71 | os.makedirs(out_dir) 72 | # Load all images as imageset 73 | imgset = imageset.ImageSet.from_directory(img_dir) 74 | meta_list = imgset.as_nested_lists() 75 | # Make feature collection of image centers and write it to tmp file 76 | point_list = [capture_to_point(c) for c in imgset.captures] 77 | feature_list = [{'type': 'Feature', 78 | 'properties': {}, 79 | 'geometry': mapping(x)} 80 | for x in point_list] 81 | fc = {'type': 'FeatureCollection', 82 | 'features': feature_list} 83 | 84 | ########################### 85 | #### Optionally cut a spatial subset of the images 86 | ########################## 87 | if subset == 'interactive': 88 | # Write feature collection to tmp file, to make it accessible to the flask app 89 | # without messing up with the session context 90 | fc_tmp_file = os.path.join(tempfile.gettempdir(), 'micamac_fc.geojson') 91 | with open(fc_tmp_file, 'w') as dst: 92 | json.dump(fc, dst) 93 | # Select spatial subset interactively (available as feature in POLYGONS[0]) 94 | app.run(debug=False, host= '0.0.0.0') 95 | # Check which images intersect with the user defined polygon (list of booleans) 96 | poly_shape = shape(POLYGONS[0]['geometry']) 97 | in_polygon = [x.intersects(poly_shape) for x in point_list] 98 | print('Centroid of drawn polygon: %s' % poly_shape.centroid.wkt) 99 | elif subset is None: 100 | in_polygon = [True for x in point_list] 101 | elif os.path.exists(subset): 102 | with fiona.open(subset, layer) as src: 103 | poly_shape = shape(src[0]['geometry']) 104 | in_polygon = [x.intersects(poly_shape) for x in point_list] 105 | print('Centroid of supplied polygon: %s' % poly_shape.centroid.wkt) 106 | else: 107 | raise ValueError('--subset must be interactive, the path to an OGR file or left empty') 108 | 109 | ################################## 110 | ### Threshold on altitude 111 | ################################## 112 | if alt_thresh == 'interactive': 113 | alt_arr = np.array([x[3] for x in meta_list[0]]) 114 | n, bins, patches = plt.hist(alt_arr, 100) 115 | plt.xlabel('Altitude') 116 | plt.ylabel('Freq') 117 | plt.show() 118 | # Ask user for alt threshold 119 | alt_thresh = input('Enter altitude threshold:') 120 | alt_thresh = float(alt_thresh) 121 | above_alt = [x[3] > alt_thresh for x in meta_list[0]] 122 | elif isinstance(alt_thresh, float): 123 | above_alt = [x[3] > alt_thresh for x in meta_list[0]] 124 | else: 125 | raise ValueError('--alt_thresh argument must be a float or interactive') 126 | 127 | # Combine both boolean lists (altitude and in_polygon) 128 | is_valid = [x and y for x,y in zip(above_alt, in_polygon)] 129 | 130 | ######################### 131 | ### Optionally retrieve irradiance values 132 | ######################### 133 | if irradiance == 'panel': 134 | # Trying first capture, then last if doesn't work 135 | try: 136 | panel_cap = imgset.captures[0] 137 | # Auto-detect panel, perform visual check, retrieve corresponding irradiance values 138 | if panel_cap.detect_panels() != 5: 139 | raise AssertionError('Panels could not be detected') 140 | panel_cap.plot_panels() 141 | # Visual check and ask for user confirmation 142 | panel_check = input("Are panels properly detected ? (y/n):") 143 | if panel_check != 'y': 144 | raise AssertionError('User input, unsuitable detected panels !') 145 | except Exception as e: 146 | print("Failed to use pre flight panels; trying post flight panel capture") 147 | panel_cap = imgset.captures[-1] 148 | # Auto-detect panel, perform visual check, retrieve corresponding irradiance values 149 | if panel_cap.detect_panels() != 5: 150 | raise AssertionError('Panels could not be detected') 151 | panel_cap.plot_panels() 152 | # Visual check and ask for user confirmation 153 | panel_check = input("Are panels properly detected ? (y/n):") 154 | if panel_check != 'y': 155 | raise AssertionError('User input, unsuitable detected panels !') 156 | # Retrieve irradiance values from panels reflectance 157 | img_type = 'reflectance' 158 | irradiance_list = panel_cap.panel_irradiance() 159 | elif irradiance == 'dls': 160 | img_type = 'reflectance' 161 | irradiance_list = None 162 | elif irradiance == 'sixs': 163 | # Pick the middle cature, and use it to model clear sky irradiance using 6s 164 | middle_c = imgset.captures[round(len(imgset.captures)/2)] 165 | img_type = 'reflectance' 166 | irradiance_list = modeled_irradiance_from_capture(middle_c) 167 | elif irradiance is None: 168 | img_type = None 169 | irradiance_list = None 170 | else: 171 | raise ValueError('Incorrect value for --reflectance, must be panel, dls or left empty') 172 | 173 | 174 | ######################### 175 | ### Alignment parameters 176 | ######################### 177 | # Select an arbitrary image, find warping and croping parameters, apply to image, 178 | # assemble a rgb composite to perform visual check 179 | alignment_confirmed = False 180 | while not alignment_confirmed: 181 | warp_cap_ind = random.randint(1, len(imgset.captures) - 1) 182 | warp_cap = imgset.captures[warp_cap_ind] 183 | warp_matrices, alignment_pairs = imageutils.align_capture(warp_cap, 184 | max_iterations=100, 185 | multithreaded=True) 186 | print("Finished Aligning") 187 | # Retrieve cropping dimensions 188 | cropped_dimensions, edges = imageutils.find_crop_bounds(warp_cap, warp_matrices) 189 | warp_mode = alignment_pairs[0]['warp_mode'] 190 | match_index = alignment_pairs[0]['ref_index'] 191 | # Apply warping and cropping to the Capture used for finding the parameters to 192 | # later perform a visual check 193 | im_aligned = imageutils.aligned_capture(warp_cap, warp_matrices, warp_mode, 194 | cropped_dimensions, match_index, 195 | img_type='radiance') 196 | rgb_list = [imageutils.normalize(im_aligned[:,:,i]) for i in [0,1,2]] 197 | plt.imshow(np.stack(rgb_list, axis=-1)) 198 | plt.show() 199 | 200 | cir_list = [imageutils.normalize(im_aligned[:,:,i]) for i in [1,3,4]] 201 | plt.imshow(np.stack(cir_list, axis=-1)) 202 | plt.show() 203 | 204 | alignment_check = input(""" 205 | Are all bands properly aligned? (y/n) 206 | y: Bands are properly aligned, begin processing 207 | n: Bands are not properly aliged or image is not representative of the whole set, try another image 208 | """ 209 | ) 210 | if alignment_check.lower() == 'y': 211 | alignment_confirmed = True 212 | else: 213 | print('Trying another image') 214 | 215 | ################## 216 | ### Processing 217 | ################# 218 | # Build iterator of captures 219 | cap_tuple_iterator = zip(imgset.captures, is_valid, 220 | range(start_count, len(is_valid) + start_count)) 221 | process_kwargs = {'warp_matrices': warp_matrices, 222 | 'warp_mode': warp_mode, 223 | 'cropped_dimensions': cropped_dimensions, 224 | 'match_index': match_index, 225 | 'out_dir': out_dir, 226 | 'irradiance_list': irradiance_list, 227 | 'img_type': img_type, 228 | 'resolution': resolution, 229 | 'scaling': scaling} 230 | # Run process function with multiprocessing 231 | pool = mp.Pool(ncores) 232 | pool.map(functools.partial(capture_to_files, **process_kwargs), cap_tuple_iterator) 233 | 234 | 235 | if __name__ == '__main__': 236 | epilog = """ 237 | Process set of micasense rededge images from a flight. Performs: 238 | - Spatial subseting to retain only captures within Area of Interest (user defined 239 | either by interactively drawing a polygon, or by supplying vector file) 240 | - Optional conversion to reflectance using irradiance values extracted from 241 | either panel capture or DLS. 242 | - Write 6 single band geotiff in UInt16 for each capture (panchromatique + 5 rededge bands) 243 | 244 | The cli requires plotting capabilities for user confirmation, so X-window must 245 | be enabled when working over ssh 246 | 247 | When --irr is set to panel (default), the first image is assume to be of the panel 248 | 249 | 250 | Example usage: 251 | -------------- 252 | # Display help 253 | align_images.py --help 254 | 255 | # Process the whole directory, to reflectance, with interactive drawing of AOI 256 | align_images.py -i /path/to/images -o /path/to/output/dir -irr panel -subset interactive -n 40 257 | """ 258 | # Instantiate argparse parser 259 | parser = argparse.ArgumentParser(epilog=epilog, 260 | formatter_class=argparse.RawTextHelpFormatter) 261 | 262 | # parser arguments 263 | parser.add_argument('-i', '--img_dir', 264 | required=True, 265 | type=str, 266 | help='directory containing images (nested directories are fine)') 267 | 268 | parser.add_argument('-o', '--out_dir', 269 | required=True, 270 | type=str, 271 | help='output directory') 272 | # scaling, reflectance, subset, layer 273 | parser.add_argument('-scaling', '--scaling', 274 | type=int, 275 | default=60000, 276 | help='Scaling factor when storing reflectances or radiances as UInt16') 277 | 278 | parser.add_argument('-irr', '--irradiance', 279 | type=str, 280 | default=None, 281 | help=""" 282 | Way of retrieving irradiance values for computing reflectance: 283 | panel: Use reflectance panel. It is assumed that panel images are present in the 284 | first and/or the last image of the set 285 | dls: Use onboad Downwelling Light Sensor 286 | sixs: Model clear sky irradiance values using sixs radiative transfer modeling 287 | None (leave empty): Reflectance is not computed and radiance images are returned instead 288 | """) 289 | 290 | parser.add_argument('-subset', '--subset', 291 | type=str, 292 | default=None, 293 | help=""" 294 | Optional spatial subset to restrict images processed. 295 | interactive: Opens a interactive map in a browser window and select the area of interest interactively 296 | /path/to/file.gpkg: Path to an OGR file. The first feature of the vector layer will be used to spatially subset the images 297 | None (leave empty): No spatial subsetting 298 | """) 299 | 300 | parser.add_argument('-layer', '--layer', 301 | type=str, 302 | default=None, 303 | help='Layer name when --subset is a path to a multilayer file') 304 | 305 | parser.add_argument('-alt', '--alt_thresh', 306 | type=float_or_str, 307 | default='interactive', 308 | help = 'Consider only data above that altitude') 309 | 310 | parser.add_argument('-res', '--resolution', 311 | type=float, 312 | default=0.1, 313 | help = """ 314 | Expected ground resolution in meters. Not very important, only used for 315 | approximative display of images in GIS software""") 316 | 317 | parser.add_argument('-n', '--ncores', 318 | default=20, 319 | type=int, 320 | help='Number of cores to use for multiprocessing') 321 | 322 | parser.add_argument('-scount', '--start_count', 323 | default=0, 324 | type=int, 325 | help='Number of first image processed (useful for merging several batches)') 326 | 327 | parsed_args = parser.parse_args() 328 | main(**vars(parsed_args)) 329 | --------------------------------------------------------------------------------