kopia lustrzana https://github.com/OpenDroneMap/ODM
modify upper limit of band count from 8 to 10
MicaSense RedEdge-MX/Dual sensors has 10 bands usually. Based on modifying files in docker container and the two posts in the community, it seems to work fine just by changing the upper limit. https://community.opendronemap.org/t/problems-with-multispectral-10-band-16-bit-images/14718 https://community.opendronemap.org/t/problems-while-processing-micasense-rededge-mx-dual-data-10-bands-with-webodm/11622pull/1736/head
rodzic
1283df206e
commit
ca962d92b2
|
@ -0,0 +1,468 @@
|
|||
import os
|
||||
import shutil
|
||||
import warnings
|
||||
import numpy as np
|
||||
from opendm import get_image_size
|
||||
from opendm import location
|
||||
from opendm.gcp import GCPFile
|
||||
from pyproj import CRS
|
||||
import xmltodict as x2d
|
||||
from six import string_types
|
||||
|
||||
from opendm import log
|
||||
from opendm import io
|
||||
from opendm import system
|
||||
from opendm import context
|
||||
|
||||
from opendm.progress import progressbc
|
||||
from opendm.photo import ODM_Photo
|
||||
|
||||
# Ignore warnings about proj information being lost
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
class ODM_Reconstruction(object):
|
||||
def __init__(self, photos):
|
||||
self.photos = photos
|
||||
self.georef = None
|
||||
self.gcp = None
|
||||
self.multi_camera = self.detect_multi_camera()
|
||||
self.filter_photos()
|
||||
|
||||
def detect_multi_camera(self):
|
||||
"""
|
||||
Looks at the reconstruction photos and determines if this
|
||||
is a single or multi-camera setup.
|
||||
"""
|
||||
band_photos = {}
|
||||
band_indexes = {}
|
||||
|
||||
for p in self.photos:
|
||||
if not p.band_name in band_photos:
|
||||
band_photos[p.band_name] = []
|
||||
if not p.band_name in band_indexes:
|
||||
band_indexes[p.band_name] = str(p.band_index)
|
||||
|
||||
band_photos[p.band_name].append(p)
|
||||
|
||||
bands_count = len(band_photos)
|
||||
if bands_count >= 2 and bands_count <= 10:
|
||||
# Validate that all bands have the same number of images,
|
||||
# otherwise this is not a multi-camera setup
|
||||
img_per_band = len(band_photos[p.band_name])
|
||||
for band in band_photos:
|
||||
if len(band_photos[band]) != img_per_band:
|
||||
log.ODM_ERROR("Multi-camera setup detected, but band \"%s\" (identified from \"%s\") has only %s images (instead of %s), perhaps images are missing or are corrupted. Please include all necessary files to process all bands and try again." % (band, band_photos[band][0].filename, len(band_photos[band]), img_per_band))
|
||||
raise RuntimeError("Invalid multi-camera images")
|
||||
|
||||
mc = []
|
||||
for band_name in band_indexes:
|
||||
mc.append({'name': band_name, 'photos': band_photos[band_name]})
|
||||
|
||||
# We enforce a normalized band order for all bands that we can identify
|
||||
# and rely on the manufacturer's band_indexes as a fallback for all others
|
||||
normalized_band_order = {
|
||||
'RGB': '0',
|
||||
'REDGREENBLUE': '0',
|
||||
|
||||
'RED': '1',
|
||||
'R': '1',
|
||||
|
||||
'GREEN': '2',
|
||||
'G': '2',
|
||||
|
||||
'BLUE': '3',
|
||||
'B': '3',
|
||||
|
||||
'NIR': '4',
|
||||
'N': '4',
|
||||
|
||||
'REDEDGE': '5',
|
||||
'RE': '5',
|
||||
|
||||
'PANCHRO': '6',
|
||||
|
||||
'LWIR': '7',
|
||||
'L': '7',
|
||||
}
|
||||
|
||||
for band_name in band_indexes:
|
||||
if band_name.upper() not in normalized_band_order:
|
||||
log.ODM_WARNING(f"Cannot identify order for {band_name} band, using manufacturer suggested index instead")
|
||||
|
||||
# Sort
|
||||
mc.sort(key=lambda x: normalized_band_order.get(x['name'].upper(), '9' + band_indexes[x['name']]))
|
||||
|
||||
for c, d in enumerate(mc):
|
||||
log.ODM_INFO(f"Band {c + 1}: {d['name']}")
|
||||
|
||||
return mc
|
||||
|
||||
return None
|
||||
|
||||
def filter_photos(self):
|
||||
if not self.multi_camera:
|
||||
return # Nothing to do, use all images
|
||||
|
||||
else:
|
||||
# Sometimes people might try process both RGB + Blue/Red/Green bands
|
||||
# because these are the contents of the SD card from a drone (e.g. DJI P4 Multispectral)
|
||||
# But we don't want to process both, so we discard the RGB files in favor
|
||||
bands = {}
|
||||
for b in self.multi_camera:
|
||||
bands[b['name'].lower()] = b['name']
|
||||
|
||||
bands_to_remove = []
|
||||
|
||||
if 'rgb' in bands or 'redgreenblue' in bands:
|
||||
if 'red' in bands and 'green' in bands and 'blue' in bands:
|
||||
bands_to_remove.append(bands['rgb'] if 'rgb' in bands else bands['redgreenblue'])
|
||||
|
||||
# Mavic 3M's RGB camera lens are too different than the multispectral ones
|
||||
# so we drop the RGB channel instead
|
||||
elif self.photos[0].is_make_model("DJI", "M3M") and 'red' in bands and 'green' in bands:
|
||||
bands_to_remove.append(bands['rgb'] if 'rgb' in bands else bands['redgreenblue'])
|
||||
|
||||
else:
|
||||
for b in ['red', 'green', 'blue']:
|
||||
if b in bands:
|
||||
bands_to_remove.append(bands[b])
|
||||
|
||||
if len(bands_to_remove) > 0:
|
||||
log.ODM_WARNING("Redundant bands detected, probably because RGB images are mixed with single band images. We will trim some bands as needed")
|
||||
|
||||
for band_to_remove in bands_to_remove:
|
||||
self.multi_camera = [b for b in self.multi_camera if b['name'] != band_to_remove]
|
||||
photos_before = len(self.photos)
|
||||
self.photos = [p for p in self.photos if p.band_name != band_to_remove]
|
||||
photos_after = len(self.photos)
|
||||
|
||||
log.ODM_WARNING("Skipping %s band (%s images)" % (band_to_remove, photos_before - photos_after))
|
||||
|
||||
def is_georeferenced(self):
|
||||
return self.georef is not None
|
||||
|
||||
def has_gcp(self):
|
||||
return self.is_georeferenced() and self.gcp is not None and self.gcp.exists()
|
||||
|
||||
def has_geotagged_photos(self):
|
||||
for photo in self.photos:
|
||||
if photo.latitude is None and photo.longitude is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def georeference_with_gcp(self, gcp_file, output_coords_file, output_gcp_file, output_model_txt_geo, rerun=False):
|
||||
if not io.file_exists(output_coords_file) or not io.file_exists(output_gcp_file) or rerun:
|
||||
gcp = GCPFile(gcp_file)
|
||||
if gcp.exists():
|
||||
if gcp.entries_count() == 0:
|
||||
raise RuntimeError("This GCP file does not have any entries. Are the entries entered in the proper format?")
|
||||
|
||||
gcp.check_entries()
|
||||
|
||||
# Convert GCP file to a UTM projection since the rest of the pipeline
|
||||
# does not handle other SRS well.
|
||||
rejected_entries = []
|
||||
utm_gcp = GCPFile(gcp.create_utm_copy(output_gcp_file, filenames=[p.filename for p in self.photos], rejected_entries=rejected_entries, include_extras=True))
|
||||
|
||||
if not utm_gcp.exists():
|
||||
raise RuntimeError("Could not project GCP file to UTM. Please double check your GCP file for mistakes.")
|
||||
|
||||
for re in rejected_entries:
|
||||
log.ODM_WARNING("GCP line ignored (image not found): %s" % str(re))
|
||||
|
||||
if utm_gcp.entries_count() > 0:
|
||||
log.ODM_INFO("%s GCP points will be used for georeferencing" % utm_gcp.entries_count())
|
||||
else:
|
||||
raise RuntimeError("A GCP file was provided, but no valid GCP entries could be used. Note that the GCP file is case sensitive (\".JPG\" is not the same as \".jpg\").")
|
||||
|
||||
self.gcp = utm_gcp
|
||||
|
||||
# Compute RTC offsets from GCP points
|
||||
x_pos = [p.x for p in utm_gcp.iter_entries()]
|
||||
y_pos = [p.y for p in utm_gcp.iter_entries()]
|
||||
x_off, y_off = int(np.round(np.mean(x_pos))), int(np.round(np.mean(y_pos)))
|
||||
|
||||
# Create coords file, we'll be using this later
|
||||
# during georeferencing
|
||||
with open(output_coords_file, 'w') as f:
|
||||
coords_header = gcp.wgs84_utm_zone()
|
||||
f.write(coords_header + "\n")
|
||||
f.write("{} {}\n".format(x_off, y_off))
|
||||
log.ODM_INFO("Generated coords file from GCP: %s" % coords_header)
|
||||
|
||||
# Deprecated: This is mostly for backward compatibility and should be
|
||||
# be removed at some point
|
||||
shutil.copyfile(output_coords_file, output_model_txt_geo)
|
||||
log.ODM_INFO("Wrote %s" % output_model_txt_geo)
|
||||
else:
|
||||
log.ODM_WARNING("GCP file does not exist: %s" % gcp_file)
|
||||
return
|
||||
else:
|
||||
log.ODM_INFO("Coordinates file already exist: %s" % output_coords_file)
|
||||
log.ODM_INFO("GCP file already exist: %s" % output_gcp_file)
|
||||
self.gcp = GCPFile(output_gcp_file)
|
||||
|
||||
self.georef = ODM_GeoRef.FromCoordsFile(output_coords_file)
|
||||
return self.georef
|
||||
|
||||
def georeference_with_gps(self, images_path, output_coords_file, output_model_txt_geo, rerun=False):
|
||||
try:
|
||||
if not io.file_exists(output_coords_file) or rerun:
|
||||
location.extract_utm_coords(self.photos, images_path, output_coords_file)
|
||||
else:
|
||||
log.ODM_INFO("Coordinates file already exist: %s" % output_coords_file)
|
||||
|
||||
# Deprecated: This is mostly for backward compatibility and should be
|
||||
# be removed at some point
|
||||
if not io.file_exists(output_model_txt_geo) or rerun:
|
||||
with open(output_coords_file, 'r') as f:
|
||||
with open(output_model_txt_geo, 'w+') as w:
|
||||
w.write(f.readline()) # CRS
|
||||
w.write(f.readline()) # Offset
|
||||
else:
|
||||
log.ODM_INFO("Model geo file already exist: %s" % output_model_txt_geo)
|
||||
|
||||
self.georef = ODM_GeoRef.FromCoordsFile(output_coords_file)
|
||||
except:
|
||||
log.ODM_WARNING('Could not generate coordinates file. The orthophoto will not be georeferenced.')
|
||||
|
||||
self.gcp = GCPFile(None)
|
||||
return self.georef
|
||||
|
||||
def save_proj_srs(self, file):
|
||||
# Save proj to file for future use (unless this
|
||||
# dataset is not georeferenced)
|
||||
if self.is_georeferenced():
|
||||
with open(file, 'w') as f:
|
||||
f.write(self.get_proj_srs())
|
||||
|
||||
def get_proj_srs(self):
|
||||
if self.is_georeferenced():
|
||||
return self.georef.proj4()
|
||||
|
||||
def get_proj_offset(self):
|
||||
if self.is_georeferenced():
|
||||
return (self.georef.utm_east_offset, self.georef.utm_north_offset)
|
||||
else:
|
||||
return (None, None)
|
||||
|
||||
def get_photo(self, filename):
|
||||
for p in self.photos:
|
||||
if p.filename == filename:
|
||||
return p
|
||||
|
||||
|
||||
class ODM_GeoRef(object):
|
||||
@staticmethod
|
||||
def FromCoordsFile(coords_file):
|
||||
# check for coordinate file existence
|
||||
if not io.file_exists(coords_file):
|
||||
log.ODM_WARNING('Could not find file %s' % coords_file)
|
||||
return
|
||||
|
||||
srs = None
|
||||
utm_east_offset = None
|
||||
utm_north_offset = None
|
||||
|
||||
with open(coords_file) as f:
|
||||
# extract reference system and utm zone from first line.
|
||||
# We will assume the following format:
|
||||
# 'WGS84 UTM 17N' or 'WGS84 UTM 17N \n'
|
||||
line = f.readline().rstrip()
|
||||
srs = location.parse_srs_header(line)
|
||||
|
||||
# second line is a northing/easting offset
|
||||
line = f.readline().rstrip()
|
||||
utm_east_offset, utm_north_offset = map(float, line.split(" "))
|
||||
|
||||
return ODM_GeoRef(srs, utm_east_offset, utm_north_offset)
|
||||
|
||||
def __init__(self, srs, utm_east_offset, utm_north_offset):
|
||||
self.srs = srs
|
||||
self.utm_east_offset = utm_east_offset
|
||||
self.utm_north_offset = utm_north_offset
|
||||
self.transform = []
|
||||
|
||||
def proj4(self):
|
||||
return self.srs.to_proj4()
|
||||
|
||||
def utm_offset(self):
|
||||
return (self.utm_east_offset, self.utm_north_offset)
|
||||
|
||||
class ODM_Tree(object):
|
||||
def __init__(self, root_path, gcp_file = None, geo_file = None, align_file = None):
|
||||
# root path to the project
|
||||
self.root_path = io.absolute_path_file(root_path)
|
||||
self.input_images = os.path.join(self.root_path, 'images')
|
||||
|
||||
# modules paths
|
||||
|
||||
# here are defined where all modules should be located in
|
||||
# order to keep track all files al directories during the
|
||||
# whole reconstruction process.
|
||||
self.dataset_raw = os.path.join(self.root_path, 'images')
|
||||
self.opensfm = os.path.join(self.root_path, 'opensfm')
|
||||
self.openmvs = os.path.join(self.opensfm, 'undistorted', 'openmvs')
|
||||
self.odm_meshing = os.path.join(self.root_path, 'odm_meshing')
|
||||
self.odm_texturing = os.path.join(self.root_path, 'odm_texturing')
|
||||
self.odm_25dtexturing = os.path.join(self.root_path, 'odm_texturing_25d')
|
||||
self.odm_georeferencing = os.path.join(self.root_path, 'odm_georeferencing')
|
||||
self.odm_filterpoints = os.path.join(self.root_path, 'odm_filterpoints')
|
||||
self.odm_orthophoto = os.path.join(self.root_path, 'odm_orthophoto')
|
||||
self.odm_report = os.path.join(self.root_path, 'odm_report')
|
||||
|
||||
# important files paths
|
||||
|
||||
# benchmarking
|
||||
self.benchmarking = os.path.join(self.root_path, 'benchmark.txt')
|
||||
self.dataset_list = os.path.join(self.root_path, 'img_list.txt')
|
||||
|
||||
# opensfm
|
||||
self.opensfm_image_list = os.path.join(self.opensfm, 'image_list.txt')
|
||||
self.opensfm_reconstruction = os.path.join(self.opensfm, 'reconstruction.json')
|
||||
self.opensfm_reconstruction_nvm = os.path.join(self.opensfm, 'undistorted/reconstruction.nvm')
|
||||
self.opensfm_geocoords_reconstruction = os.path.join(self.opensfm, 'reconstruction.geocoords.json')
|
||||
self.opensfm_topocentric_reconstruction = os.path.join(self.opensfm, 'reconstruction.topocentric.json')
|
||||
|
||||
# OpenMVS
|
||||
self.openmvs_model = os.path.join(self.openmvs, 'scene_dense_dense_filtered.ply')
|
||||
|
||||
# filter points
|
||||
self.filtered_point_cloud = os.path.join(self.odm_filterpoints, "point_cloud.ply")
|
||||
self.filtered_point_cloud_stats = os.path.join(self.odm_filterpoints, "point_cloud_stats.json")
|
||||
|
||||
# odm_meshing
|
||||
self.odm_mesh = os.path.join(self.odm_meshing, 'odm_mesh.ply')
|
||||
self.odm_meshing_log = os.path.join(self.odm_meshing, 'odm_meshing_log.txt')
|
||||
self.odm_25dmesh = os.path.join(self.odm_meshing, 'odm_25dmesh.ply')
|
||||
self.odm_25dmeshing_log = os.path.join(self.odm_meshing, 'odm_25dmeshing_log.txt')
|
||||
|
||||
# texturing
|
||||
self.odm_textured_model_obj = 'odm_textured_model_geo.obj'
|
||||
self.odm_textured_model_glb = 'odm_textured_model_geo.glb'
|
||||
|
||||
# odm_georeferencing
|
||||
self.odm_georeferencing_coords = os.path.join(
|
||||
self.odm_georeferencing, 'coords.txt')
|
||||
self.odm_georeferencing_gcp = gcp_file or io.find('gcp_list.txt', self.root_path)
|
||||
self.odm_georeferencing_gcp_utm = os.path.join(self.odm_georeferencing, 'gcp_list_utm.txt')
|
||||
self.odm_geo_file = geo_file or io.find('geo.txt', self.root_path)
|
||||
self.odm_align_file = align_file or io.find('align.laz', self.root_path) or io.find('align.las', self.root_path) or io.find('align.tif', self.root_path)
|
||||
|
||||
self.odm_georeferencing_proj = 'proj.txt'
|
||||
self.odm_georeferencing_model_txt_geo = os.path.join(
|
||||
self.odm_georeferencing, 'odm_georeferencing_model_geo.txt')
|
||||
self.odm_georeferencing_xyz_file = os.path.join(
|
||||
self.odm_georeferencing, 'odm_georeferenced_model.csv')
|
||||
self.odm_georeferencing_model_laz = os.path.join(
|
||||
self.odm_georeferencing, 'odm_georeferenced_model.laz')
|
||||
self.odm_georeferencing_model_las = os.path.join(
|
||||
self.odm_georeferencing, 'odm_georeferenced_model.las')
|
||||
self.odm_georeferencing_alignment_matrix = os.path.join(
|
||||
self.odm_georeferencing, 'alignment_matrix.json'
|
||||
)
|
||||
|
||||
# odm_orthophoto
|
||||
self.odm_orthophoto_render = os.path.join(self.odm_orthophoto, 'odm_orthophoto_render.tif')
|
||||
self.odm_orthophoto_tif = os.path.join(self.odm_orthophoto, 'odm_orthophoto.tif')
|
||||
self.odm_orthophoto_corners = os.path.join(self.odm_orthophoto, 'odm_orthophoto_corners.txt')
|
||||
self.odm_orthophoto_log = os.path.join(self.odm_orthophoto, 'odm_orthophoto_log.txt')
|
||||
self.odm_orthophoto_tif_log = os.path.join(self.odm_orthophoto, 'gdal_translate_log.txt')
|
||||
|
||||
# tiles
|
||||
self.orthophoto_tiles = os.path.join(self.root_path, "orthophoto_tiles")
|
||||
|
||||
# Split-merge
|
||||
self.submodels_path = os.path.join(self.root_path, 'submodels')
|
||||
|
||||
# Tiles
|
||||
self.entwine_pointcloud = self.path("entwine_pointcloud")
|
||||
self.ogc_tiles = self.path("3d_tiles")
|
||||
|
||||
def path(self, *args):
|
||||
return os.path.join(self.root_path, *args)
|
||||
|
||||
|
||||
class ODM_Stage:
|
||||
def __init__(self, name, args, progress=0.0, **params):
|
||||
self.name = name
|
||||
self.args = args
|
||||
self.progress = progress
|
||||
self.params = params
|
||||
if self.params is None:
|
||||
self.params = {}
|
||||
self.next_stage = None
|
||||
self.prev_stage = None
|
||||
|
||||
def connect(self, stage):
|
||||
self.next_stage = stage
|
||||
stage.prev_stage = self
|
||||
return stage
|
||||
|
||||
def rerun(self):
|
||||
"""
|
||||
Does this stage need to be rerun?
|
||||
"""
|
||||
return (self.args.rerun is not None and self.args.rerun == self.name) or \
|
||||
(self.args.rerun_all) or \
|
||||
(self.args.rerun_from is not None and self.name in self.args.rerun_from)
|
||||
|
||||
def run(self, outputs = {}):
|
||||
start_time = system.now_raw()
|
||||
log.logger.log_json_stage_run(self.name, start_time)
|
||||
|
||||
log.ODM_INFO('Running %s stage' % self.name)
|
||||
|
||||
self.process(self.args, outputs)
|
||||
|
||||
# The tree variable should always be populated at this point
|
||||
if outputs.get('tree') is None:
|
||||
raise Exception("Assert violation: tree variable is missing from outputs dictionary.")
|
||||
|
||||
try:
|
||||
system.benchmark(start_time, outputs['tree'].benchmarking, self.name)
|
||||
except Exception as e:
|
||||
log.ODM_WARNING("Cannot write benchmark file: %s" % str(e))
|
||||
|
||||
log.ODM_INFO('Finished %s stage' % self.name)
|
||||
self.update_progress_end()
|
||||
|
||||
# Last stage?
|
||||
if self.args.end_with == self.name or self.args.rerun == self.name:
|
||||
log.ODM_INFO("No more stages to run")
|
||||
return
|
||||
|
||||
# Run next stage?
|
||||
elif self.next_stage is not None:
|
||||
self.next_stage.run(outputs)
|
||||
|
||||
def delta_progress(self):
|
||||
if self.prev_stage:
|
||||
return max(0.0, self.progress - self.prev_stage.progress)
|
||||
else:
|
||||
return max(0.0, self.progress)
|
||||
|
||||
def previous_stages_progress(self):
|
||||
if self.prev_stage:
|
||||
return max(0.0, self.prev_stage.progress)
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
def update_progress_end(self):
|
||||
self.update_progress(100.0)
|
||||
|
||||
def update_progress(self, progress):
|
||||
progress = max(0.0, min(100.0, progress))
|
||||
progressbc.send_update(self.previous_stages_progress() +
|
||||
(self.delta_progress() / 100.0) * float(progress))
|
||||
|
||||
def last_stage(self):
|
||||
if self.next_stage:
|
||||
return self.next_stage.last_stage()
|
||||
else:
|
||||
return self
|
||||
|
||||
|
||||
def process(self, args, outputs):
|
||||
raise NotImplementedError
|
Ładowanie…
Reference in New Issue