PoC ECC and homography scoring band alignment working

pull/1210/head
Piero Toffanin 2020-12-04 22:48:21 -05:00
rodzic e819dab16b
commit 8d417a5545
8 zmienionych plików z 301 dodań i 61 usunięć

Wyświetl plik

@ -157,15 +157,6 @@ def config(argv=None, parser=None):
'Can be one of: %(choices)s. Default: '
'%(default)s'))
parser.add_argument('--matcher-type',
metavar='<string>',
action=StoreValue,
default='flann',
choices=['flann', 'bow'],
help=('Set feature matcher algorithm. FLANN is more robust, but slower. BOW is much faster, but can miss some valid matches. '
'Can be one of: %(choices)s. Default: '
'%(default)s'))
parser.add_argument('--matcher-neighbors',
metavar='<integer>',
action=StoreValue,

Wyświetl plik

@ -1,11 +1,16 @@
import math
import re
import cv2
import os
from opendm import dls
import numpy as np
from opendm import log
from opensfm.io import imread
from skimage import exposure
from skimage.morphology import disk
from skimage.filters import rank, gaussian
# Loosely based on https://github.com/micasense/imageprocessing/blob/master/micasense/utils.py
def dn_to_radiance(photo, image):
@ -181,9 +186,11 @@ def get_primary_band_name(multi_camera, user_band_name):
return band_name_fallback
def compute_primary_band_map(multi_camera, primary_band):
def compute_band_maps(multi_camera, primary_band):
"""
Computes a map of { photo filename --> associated primary band photo }
Computes maps of:
- { photo filename --> associated primary band photo } (s2p)
- { primary band filename --> list of associated secondary band photos } (p2s)
by looking at capture time or filenames as a fallback
"""
band_name = get_primary_band_name(multi_camera, primary_band)
@ -196,7 +203,8 @@ def compute_primary_band_map(multi_camera, primary_band):
# Try using capture time as the grouping factor
try:
capture_time_map = {}
result = {}
s2p = {}
p2s = {}
for p in primary_band_photos:
t = p.get_utc_time()
@ -221,15 +229,19 @@ def compute_primary_band_map(multi_camera, primary_band):
if capture_time_map.get(t) is None:
raise Exception("Unreliable capture time detected (no primary band match)")
result[p.filename] = capture_time_map[t]
s2p[p.filename] = capture_time_map[t]
return result
if band['name'] != band_name:
p2s.setdefault(capture_time_map[t].filename, []).append(p)
return s2p, p2s
except Exception as e:
# Fallback on filename conventions
log.ODM_WARNING("%s, will use filenames instead" % str(e))
filename_map = {}
result = {}
s2p = {}
p2s = {}
file_regex = re.compile(r"^(.+)[-_]\w+(\.[A-Za-z]{3,4})$")
for p in primary_band_photos:
@ -251,15 +263,65 @@ def compute_primary_band_map(multi_camera, primary_band):
if filename_without_band == p.filename:
raise Exception("Cannot match bands by filename on %s, make sure to name your files [filename]_band[.ext] uniformly." % p.filename)
result[p.filename] = filename_map[filename_without_band]
return result
s2p[p.filename] = filename_map[filename_without_band]
def compute_aligned_image(image, align_image_filename, feature_retention=0.15):
if len(image.shape) != 3:
raise ValueError("Image should have shape length of 3 (got: %s)" % len(image.shape))
if band['name'] != band_name:
p2s.setdefault(filename_map[filename_without_band].filename, []).append(p)
return s2p, p2s
def compute_alignment_matrices(multi_camera, primary_band_name, images_path, s2p, p2s, max_samples=9999):
log.ODM_INFO("Computing band alignment")
alignment_info = {}
# For each secondary band
for band in multi_camera:
if band['name'] != primary_band_name:
matrices = []
# if band['name'] != "NIR":
# continue # TODO REMOVE
# Find good matrix candidates for alignment
for p in band['photos']:
primary_band_photo = s2p.get(p.filename)
if primary_band_photo is None:
log.ODM_WARNING("Cannot find primary band photo for %s" % p.filename)
continue
warp_matrix, score, dimension = compute_homography(os.path.join(images_path, p.filename),
os.path.join(images_path, primary_band_photo.filename))
if warp_matrix is not None:
log.ODM_INFO("%s --> %s good match (score: %s)" % (p.filename, primary_band_photo.filename, score))
matrices.append({
'warp_matrix': warp_matrix,
'score': score,
'dimension': dimension
})
else:
log.ODM_INFO("%s --> %s cannot be matched" % (p.filename, primary_band_photo.filename))
if len(matrices) >= max_samples:
log.ODM_INFO("Got enough samples for %s (%s)" % (band['name'], max_samples))
break
# Sort
matrices.sort(key=lambda x: x['score'], reverse=False)
if len(matrices) > 0:
alignment_info[band['name']] = matrices[0]
print(matrices[0])
else:
log.ODM_WARNING("Cannot find alignment matrix for band %s, The band will likely be misaligned!" % band['name'])
return alignment_info, p2s
def compute_homography(image_filename, align_image_filename):
# try:
# Convert images to grayscale if needed
image = imread(image_filename, unchanged=True, anydepth=True)
if image.shape[2] == 3:
image_gray = to_8bit(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY))
else:
@ -270,7 +332,60 @@ def compute_aligned_image(image, align_image_filename, feature_retention=0.15):
align_image_gray = to_8bit(cv2.cvtColor(align_image, cv2.COLOR_BGR2GRAY))
else:
align_image_gray = to_8bit(align_image[:,:,0])
def compute_using(algorithm):
h = algorithm(image_gray, align_image_gray)
if h is None:
return None, None, (None, None)
det = np.linalg.det(h)
# Check #1 homography's determinant will not be close to zero
if abs(det) < 0.25:
return None, None, (None, None)
# Check #2 the ratio of the first-to-last singular value is sane (not too high)
svd = np.linalg.svd(h, compute_uv=False)
if svd[-1] == 0:
return None, None, (None, None)
ratio = svd[0] / svd[-1]
if ratio > 100000:
return None, None, (None, None)
return h, compute_alignment_score(h, image_gray, align_image_gray), (align_image_gray.shape[1], align_image_gray.shape[0])
result = compute_using(find_features_homography)
if result[0] is None:
log.ODM_INFO("Can't use features matching, will use ECC")
result = compute_using(find_ecc_homography)
return result
# except Exception as e:
# log.ODM_WARNING("Compute homography: %s" % str(e))
# return None, None, (None, None)
def find_ecc_homography(image_gray, align_image_gray, number_of_iterations=5000, termination_eps=1e-8):
image_gray = to_8bit(gradient(gaussian(image_gray)))
align_image_gray = to_8bit(gradient(gaussian(align_image_gray)))
# Define the motion model
warp_matrix = np.eye(3, 3, dtype=np.float32)
# Define termination criteria
criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT,
number_of_iterations, termination_eps)
try:
(cc, warp_matrix) = cv2.findTransformECC (image_gray,align_image_gray,warp_matrix, cv2.MOTION_HOMOGRAPHY, criteria, inputMask=None, gaussFiltSize=1)
except:
(cc, warp_matrix) = cv2.findTransformECC (image_gray,align_image_gray,warp_matrix, cv2.MOTION_HOMOGRAPHY, criteria)
return warp_matrix
def find_features_homography(image_gray, align_image_gray, feature_retention=0.25):
# Detect SIFT features and compute descriptors.
detector = cv2.SIFT_create(edgeThreshold=10, contrastThreshold=0.1)
kp_image, desc_image = detector.detectAndCompute(image_gray, None)
@ -301,12 +416,62 @@ def compute_aligned_image(image, align_image_filename, feature_retention=0.15):
# Find homography
h, _ = cv2.findHomography(points_image, points_align_image, cv2.RANSAC)
return h
# Use homography
height, width = align_image_gray.shape
aligned_image = cv2.warpPerspective(image, h, (width, height))
def compute_alignment_score(warp_matrix, image_gray, align_image_gray, apply_gradient=True):
projected = align_image(image_gray, warp_matrix, (align_image_gray.shape[1], align_image_gray.shape[0]))
borders = projected==0
if apply_gradient:
image_gray = to_8bit(gradient(gaussian(image_gray)))
align_image_gray = to_8bit(gradient(gaussian(align_image_gray)))
# cv2.imwrite("/datasets/micasense/opensfm/undistorted/align_image_gray.jpg", align_image_gray)
# cv2.imwrite("/datasets/micasense/opensfm/undistorted/projected.jpg", projected)
# Threshold
align_image_gray[align_image_gray > 128] = 255
projected[projected > 128] = 255
align_image_gray[align_image_gray <= 128] = 0
projected[projected <= 128] = 0
# Mark borders
align_image_gray[borders] = 0
projected[borders] = 255
# cv2.imwrite("/datasets/micasense/opensfm/undistorted/threshold_align_image_gray.jpg", align_image_gray)
# cv2.imwrite("/datasets/micasense/opensfm/undistorted/threshold_projected.jpg", projected)
# cv2.imwrite("/datasets/micasense/opensfm/undistorted/delta.jpg", projected - align_image_gray)
# Compute delta --> the more the images overlap perfectly, the lower the score
return (projected - align_image_gray).sum()
def gradient(im, ksize=5):
im = local_normalize(im)
grad_x = cv2.Sobel(im,cv2.CV_32F,1,0,ksize=ksize)
grad_y = cv2.Sobel(im,cv2.CV_32F,0,1,ksize=ksize)
grad = cv2.addWeighted(np.absolute(grad_x), 0.5, np.absolute(grad_y), 0.5, 0)
return grad
def local_normalize(im):
width, _ = im.shape
disksize = int(width/5)
if disksize % 2 == 0:
disksize = disksize + 1
selem = disk(disksize)
im = rank.equalize(im, selem=selem)
return im
def align_image(image, warp_matrix, dimension):
if warp_matrix.shape == (3, 3):
return cv2.warpPerspective(image, warp_matrix, dimension)
else:
return cv2.warpAffine(image, warp_matrix, dimension)
return aligned_image
def to_8bit(image):
if image.dtype == np.uint8:
@ -323,6 +488,10 @@ def to_8bit(image):
image = image.astype(np.float32)
image *= 255.0 / value_range
np.around(image, out=image)
image[image > 255] = 255
image[image < 0] = 0
image = image.astype(np.uint8)
return image
return image

Wyświetl plik

@ -105,7 +105,7 @@ class OSFMContext:
except Exception as e:
log.ODM_WARNING("Cannot set camera_models_overrides.json: %s" % str(e))
use_bow = args.matcher_type == "bow"
use_bow = bool(reconstruction.multi_camera)
feature_type = "SIFT"
# GPSDOP override if we have GPS accuracy information (such as RTK)
@ -315,16 +315,67 @@ class OSFMContext:
else:
log.ODM_INFO("Already extracted cameras")
def convert_and_undistort(self, rerun=False, imageFilter=None):
def convert_and_undistort(self, rerun=False, imageFilter=None, image_list=None):
log.ODM_INFO("Undistorting %s ..." % self.opensfm_project_path)
undistorted_images_path = self.path("undistorted", "images")
if not io.dir_exists(undistorted_images_path) or rerun:
undistort.run_dataset(DataSet(self.opensfm_project_path), "reconstruction.json",
ds = DataSet(self.opensfm_project_path)
if image_list is not None:
ds._set_image_list(image_list)
undistort.run_dataset(ds, "reconstruction.json",
0, None, "undistorted", imageFilter)
else:
log.ODM_WARNING("Found an undistorted directory in %s" % undistorted_images_path)
def add_shots_to_reconstruction(self, p2s):
recon_file = self.path("reconstruction.json")
recon_backup_file = self.path("reconstruction.backup.json")
# tracks_file = self.path("tracks.csv")
# tracks_backup_file = self.path("tracks.backup.csv")
# image_list_file = self.path("image_list.txt")
# image_list_backup_file = self.path("image_list.backup.txt")
log.ODM_INFO("Adding shots to reconstruction")
if os.path.exists(recon_backup_file):
os.remove(recon_backup_file)
# if os.path.exists(tracks_backup_file):
# os.remove(tracks_backup_file)
# if os.path.exists(image_list_backup_file):
# os.remove(image_list_backup_file)
log.ODM_INFO("Backing up reconstruction, tracks, image list")
shutil.copyfile(recon_file, recon_backup_file)
# shutil.copyfile(tracks_file, tracks_backup_file)
# shutil.copyfile(image_list_file, image_list_backup_file)
with open(recon_file) as f:
reconstruction = json.loads(f.read())
# Augment reconstruction.json
for recon in reconstruction:
shots = recon['shots']
sids = list(shots)
for shot_id in sids:
secondary_photos = p2s.get(shot_id)
if secondary_photos is None:
log.ODM_WARNING("Cannot find secondary photos for %s" % shot_id)
continue
for p in secondary_photos:
shots[p.filename] = shots[shot_id]
with open(recon_file, 'w') as f:
f.write(json.dumps(reconstruction))
return True #(recon_file, recon_backup_file)
# (tracks_file, tracks_backup_file),
# (image_list_file, image_list_backup_file)]
def update_config(self, cfg_dict):
cfg_file = self.get_config_file_path()

Wyświetl plik

@ -5,6 +5,7 @@ from opendm import io
from opendm import system
from opendm import context
from opendm import types
from opendm.multispectral import get_primary_band_name
class ODMMvsTexStage(types.ODM_Stage):
def process(self, args, outputs):
@ -36,8 +37,9 @@ class ODMMvsTexStage(types.ODM_Stage):
}]
if reconstruction.multi_camera:
for band in reconstruction.multi_camera:
primary = band == reconstruction.multi_camera[0]
primary = band['name'] == get_primary_band_name(reconstruction.multi_camera, args.primary_band)
nvm_file = os.path.join(tree.opensfm, "undistorted", "reconstruction_%s.nvm" % band['name'].lower())
add_run(nvm_file, primary, band['name'].lower())
else:

Wyświetl plik

@ -9,6 +9,7 @@ from opendm import system
from opendm import context
from opendm.cropper import Cropper
from opendm import point_cloud
from opendm.multispectral import get_primary_band_name
class ODMGeoreferencingStage(types.ODM_Stage):
def process(self, args, outputs):
@ -45,7 +46,7 @@ class ODMGeoreferencingStage(types.ODM_Stage):
if reconstruction.multi_camera:
for band in reconstruction.multi_camera:
primary = band == reconstruction.multi_camera[0]
primary = band['name'] == get_primary_band_name(reconstruction.multi_camera, args.primary_band)
add_run(primary, band['name'].lower())
else:
add_run()

Wyświetl plik

@ -11,6 +11,7 @@ from opendm.concurrency import get_max_memory
from opendm.cutline import compute_cutline
from pipes import quote
from opendm import pseudogeo
from opendm.multispectral import get_primary_band_name
class ODMOrthoPhotoStage(types.ODM_Stage):
def process(self, args, outputs):
@ -72,7 +73,7 @@ class ODMOrthoPhotoStage(types.ODM_Stage):
if reconstruction.multi_camera:
for band in reconstruction.multi_camera:
primary = band == reconstruction.multi_camera[0]
primary = band['name'] == get_primary_band_name(reconstruction.multi_camera, args.primary_band)
subdir = ""
if not primary:
subdir = band['name'].lower()

Wyświetl plik

@ -8,6 +8,7 @@ from opendm import point_cloud
from opendm import types
from opendm.utils import get_depthmap_resolution
from opendm.osfm import OSFMContext
from opendm.multispectral import get_primary_band_name
class ODMOpenMVSStage(types.ODM_Stage):
def process(self, args, outputs):
@ -30,8 +31,9 @@ class ODMOpenMVSStage(types.ODM_Stage):
cmd = 'export_openmvs'
if reconstruction.multi_camera:
# Export only the primary band
primary = reconstruction.multi_camera[0]
image_list = os.path.join(tree.opensfm, "image_list_%s.txt" % primary['name'].lower())
primary_band = get_primary_band_name(reconstruction.multi_camera, args.primary_band)
image_list = os.path.join(tree.opensfm, "image_list_%s.txt" % primary_band.lower())
cmd += ' --image_list "%s"' % image_list
octx.run(cmd)

Wyświetl plik

@ -63,7 +63,8 @@ class ODMOpenSfMStage(types.ODM_Stage):
# Undistorted images will be used for texturing / MVS
primary_band_map = None
alignment_info = None
primary_band_name = None
undistort_pipeline = []
def undistort_callback(shot_id, image):
@ -77,38 +78,52 @@ class ODMOpenSfMStage(types.ODM_Stage):
def align_to_primary_band(shot_id, image):
primary_band_photo = primary_band_map.get(shot_id)
photo = reconstruction.get_photo(shot_id)
# No need to align primary
if photo.band_name == primary_band_name:
return image
ainfo = alignment_info.get(photo.band_name)
if ainfo is not None:
return multispectral.align_image(image, ainfo['warp_matrix'], ainfo['dimension'])
else:
log.ODM_WARNING("Cannot align %s, no alignment matrix could be computed. Band alignment quality might be affected." % (shot_id))
return image
try:
if primary_band_photo is None:
raise Exception("Cannot find primary band file for %s" % shot_id)
if primary_band_photo.filename == shot_id:
# This is a photo from the primary band, skip
return image
align_image_filename = os.path.join(tree.dataset_raw, primary_band_map[shot_id].filename)
image = multispectral.compute_aligned_image(image, align_image_filename)
except Exception as e:
log.ODM_WARNING("Cannot align %s: %s. Output quality might be affected." % (shot_id, str(e)))
return image
if args.radiometric_calibration != "none":
undistort_pipeline.append(radiometric_calibrate)
if reconstruction.multi_camera:
primary_band_map = multispectral.compute_primary_band_map(reconstruction.multi_camera, args.primary_band)
# TODO:
# if (not flag_file exists or rerun)
# Change reconstruction.json and tracks.csv by adding copies of the primary
# band camera to the reconstruction
image_list_override = None
if reconstruction.multi_camera:
# Undistort only secondary bands
image_list_override = [os.path.join(tree.dataset_raw, p.filename) for p in photos] # if p.band_name.lower() != primary_band_name.lower()
# We backup the original reconstruction.json, tracks.csv
# then we augment them by duplicating the primary band
# camera shots with each band, so that exports, undistortion,
# etc. include all bands
# We finally restore the original files later
added_shots_file = octx.path('added_shots_done.txt')
if not io.file_exists(added_shots_file) or self.rerun():
primary_band_name = multispectral.get_primary_band_name(reconstruction.multi_camera, args.primary_band)
s2p, p2s = multispectral.compute_band_maps(reconstruction.multi_camera, primary_band_name)
alignment_info = multispectral.compute_alignment_matrices(reconstruction.multi_camera, primary_band_name, tree.dataset_raw, s2p, p2s)
octx.add_shots_to_reconstruction(p2s)
# TODO: what happens to reconstruction.backup.json
# if the process fails here?
octx.touch(added_shots_file)
undistort_pipeline.append(align_to_primary_band)
octx.convert_and_undistort(self.rerun(), undistort_callback)
octx.convert_and_undistort(self.rerun(), undistort_callback, image_list_override)
self.update_progress(80)
@ -126,9 +141,6 @@ class ODMOpenSfMStage(types.ODM_Stage):
else:
log.ODM_WARNING("Found a valid image list in %s for %s band" % (image_list_file, band['name']))
# TODO!! CHANGE
print("TODO!")
exit(1)
nvm_file = octx.path("undistorted", "reconstruction_%s.nvm" % band['name'].lower())
if not io.file_exists(nvm_file) or self.rerun():
octx.run('export_visualsfm --points --image_list "%s"' % image_list_file)
@ -136,6 +148,17 @@ class ODMOpenSfMStage(types.ODM_Stage):
else:
log.ODM_WARNING("Found a valid NVM file in %s for %s band" % (nvm_file, band['name']))
recon_file = octx.path("reconstruction.json")
recon_backup_file = octx.path("reconstruction.backup.json")
if os.path.exists(recon_backup_file):
# This time export the actual reconstruction.json
# (containing only the primary band)
if os.path.exists(recon_backup_file):
os.remove(recon_file)
os.rename(recon_backup_file, recon_file)
octx.convert_and_undistort(self.rerun(), undistort_callback)
if not io.file_exists(tree.opensfm_reconstruction_nvm) or self.rerun():
octx.run('export_visualsfm --points')
else: