diff --git a/opendm/osfm.py b/opendm/osfm.py index 313eb5b9..5c141991 100644 --- a/opendm/osfm.py +++ b/opendm/osfm.py @@ -120,30 +120,10 @@ class OSFMContext: # GPSDOP override if we have GPS accuracy information (such as RTK) if 'gps_accuracy_is_set' in args: log.ODM_INFO("Forcing GPS DOP to %s for all images" % args.gps_accuracy) - - log.ODM_INFO("Writing exif overrides") - exif_overrides = {} - for p in photos: - if 'gps_accuracy_is_set' in args: - dop = args.gps_accuracy - elif p.get_gps_dop() is not None: - dop = p.get_gps_dop() - else: - dop = args.gps_accuracy # default value + for p in photos: + p.override_gps_dop(args.gps_accuracy) - if p.latitude is not None and p.longitude is not None: - exif_overrides[p.filename] = { - 'gps': { - 'latitude': p.latitude, - 'longitude': p.longitude, - 'altitude': p.altitude if p.altitude is not None else 0, - 'dop': dop, - } - } - - with open(os.path.join(self.opensfm_project_path, "exif_overrides.json"), 'w') as f: - f.write(json.dumps(exif_overrides)) # Check image masks masks = [] @@ -287,6 +267,17 @@ class OSFMContext: metadata_dir = self.path("exif") if not io.dir_exists(metadata_dir) or rerun: self.run('extract_metadata') + + def photos_to_metadata(self, photos, rerun=False): + metadata_dir = self.path("exif") + if io.dir_exists(metadata_dir) and rerun: + shutil.rmtree(metadata_dir) + + os.makedirs(metadata_dir) + + for p in photos: + with open(os.path.join(metadata_dir, "%s.exif" % p.filename), 'w') as f: + f.write(json.dumps(p.to_opensfm_exif())) def is_feature_matching_done(self): features_dir = self.path("features") diff --git a/opendm/photo.py b/opendm/photo.py index 6e39275b..10e9ecdd 100644 --- a/opendm/photo.py +++ b/opendm/photo.py @@ -14,6 +14,9 @@ from opendm import system import xmltodict as x2d from opendm import get_image_size from xml.parsers.expat import ExpatError +from opensfm.sensors import sensor_data + +projections = ['perspective', 'fisheye', 'brown', 'dual', 'equirectangular', 'spherical'] def find_largest_photo(photos): max_photo = None @@ -36,6 +39,27 @@ def find_largest_photo_dim(photos): return max_dim +def get_mm_per_unit(resolution_unit): + """Length of a resolution unit in millimeters. + + Uses the values from the EXIF specs in + https://www.sno.phy.queensu.ca/~phil/exiftool/TagNames/EXIF.html + + Args: + resolution_unit: the resolution unit value given in the EXIF + """ + if resolution_unit == 2: # inch + return 25.4 + elif resolution_unit == 3: # cm + return 10 + elif resolution_unit == 4: # mm + return 1 + elif resolution_unit == 5: # um + return 0.001 + else: + log.ODM_WARNING("Unknown EXIF resolution unit value: {}".format(resolution_unit)) + return None + class PhotoCorruptedException(Exception): pass @@ -51,6 +75,7 @@ class ODM_Photo: self.height = None self.camera_make = '' self.camera_model = '' + self.orientation = 1 # Geo tags self.latitude = None @@ -91,6 +116,10 @@ class ODM_Photo: self.gps_xy_stddev = None # Dilution of Precision X/Y self.gps_z_stddev = None # Dilution of Precision Z + # Misc SFM + self.camera_projection = 'brown' + self.focal_ratio = 0.0 + # parse values from metadata self.parse_exif_values(path_file) @@ -143,9 +172,16 @@ class ODM_Photo: self.latitude = self.dms_to_decimal(tags['GPS GPSLatitude'], tags['GPS GPSLatitudeRef']) if 'GPS GPSLongitude' in tags and 'GPS GPSLongitudeRef' in tags: self.longitude = self.dms_to_decimal(tags['GPS GPSLongitude'], tags['GPS GPSLongitudeRef']) + if 'Image Orientation' in tags: + self.orientation = self.int_value(tags['Image Orientation']) except (IndexError, ValueError) as e: log.ODM_WARNING("Cannot read basic EXIF tags for %s: %s" % (_path_file, str(e))) + try: + self.focal_ratio = self.extract_focal(self.camera_make, self.camera_model, tags) + except (IndexError, ValueError) as e: + log.ODM_WARNING("Cannot extract focal ratio for %s: %s" % (_path_file, str(e))) + try: if 'Image Tag 0xC61A' in tags: self.black_level = self.list_values(tags['Image Tag 0xC61A']) @@ -241,6 +277,17 @@ class ODM_Photo: '@Camera:ImageUniqueID', # sentera 6x ]) + # DJI GPS tags + self.set_attr_from_xmp_tag('longitude', tags, [ + '@drone-dji:Longitude' + ], float) + self.set_attr_from_xmp_tag('latitude', tags, [ + '@drone-dji:Latitude' + ], float) + self.set_attr_from_xmp_tag('altitude', tags, [ + '@drone-dji:AbsoluteAltitude' + ], float) + # Phantom 4 RTK if '@drone-dji:RtkStdLon' in tags: y = float(self.get_xmp_tag(tags, '@drone-dji:RtkStdLon')) @@ -263,6 +310,13 @@ class ODM_Photo: self.set_attr_from_xmp_tag('dls_yaw', tags, ['DLS:Yaw'], float) self.set_attr_from_xmp_tag('dls_pitch', tags, ['DLS:Pitch'], float) self.set_attr_from_xmp_tag('dls_roll', tags, ['DLS:Roll'], float) + + camera_projection = self.get_xmp_tag(tags, ['@Camera:ModelType', 'Camera:ModelType']) + if camera_projection is not None: + camera_projection = camera_projection.lower() + if camera_projection in projections: + self.camera_projection = camera_projection + except Exception as e: log.ODM_WARNING("Cannot read XMP tags for %s: %s" % (_path_file, str(e))) @@ -281,6 +335,45 @@ class ODM_Photo: # Sanitize band name since we use it in folder paths self.band_name = re.sub('[^A-Za-z0-9]+', '', self.band_name) + def extract_focal(self, make, model, tags): + if make != "unknown": + # remove duplicate 'make' information in 'model' + model = model.replace(make, "") + + sensor_string = (make.strip() + " " + model.strip()).strip().lower() + + sensor_width = None + if ("EXIF FocalPlaneResolutionUnit" in tags and "EXIF FocalPlaneXResolution" in tags): + resolution_unit = self.float_value(tags["EXIF FocalPlaneResolutionUnit"]) + mm_per_unit = get_mm_per_unit(resolution_unit) + if mm_per_unit: + pixels_per_unit = self.float_value(tags["EXIF FocalPlaneXResolution"]) + if pixels_per_unit <= 0 and "EXIF FocalPlaneYResolution" in tags: + pixels_per_unit = self.float_value(tags["EXIF FocalPlaneYResolution"]) + + if pixels_per_unit > 0 and self.width is not None: + units_per_pixel = 1 / pixels_per_unit + sensor_width = self.width * units_per_pixel * mm_per_unit + + focal_35 = None + focal = None + if "EXIF FocalLengthIn35mmFilm" in tags: + focal_35 = self.float_value(tags["EXIF FocalLengthIn35mmFilm"]) + if "EXIF FocalLength" in tags: + focal = self.float_value(tags["EXIF FocalLength"]) + + if focal_35 is not None and focal_35 > 0: + focal_ratio = focal_35 / 36.0 # 35mm film produces 36x24mm pictures. + else: + if not sensor_width: + sensor_width = sensor_data().get(sensor_string, None) + if sensor_width and focal: + focal_ratio = focal / sensor_width + else: + focal_ratio = 0.0 + + return focal_ratio + def set_attr_from_xmp_tag(self, attr, xmp_tags, tags, cast=None): v = self.get_xmp_tag(xmp_tags, tags) if v is not None: @@ -487,5 +580,51 @@ class ODM_Photo: return None + def override_gps_dop(self, dop): + self.gps_xy_stddev = self.gps_z_stddev = dop + def is_thermal(self): - return self.band_name.upper() in ["LWIR"] # TODO: more? \ No newline at end of file + return self.band_name.upper() in ["LWIR"] # TODO: more? + + def to_opensfm_exif(self): + capture_time = 0.0 + if self.utc_time is not None: + capture_time = self.utc_time / 1000.0 + + gps = {} + if self.latitude is not None and self.longitude is not None: + gps['latitude'] = self.latitude + gps['longitude'] = self.longitude + if self.altitude is not None: + gps['altitude'] = self.altitude + else: + gps['altitude'] = 0.0 + + dop = self.get_gps_dop() + if dop is None: + dop = 10.0 # Default + + gps['dop'] = dop + + return { + "make": self.camera_make, + "model": self.camera_model, + "width": self.width, + "height": self.height, + "projection_type": self.camera_projection, + "focal_ratio": self.focal_ratio, + "orientation": self.orientation, + "capture_time": capture_time, + "gps": gps, + "camera": " ".join( + [ + "v2", + self.camera_make.strip(), + self.camera_model.strip(), + str(int(self.width)), + str(int(self.height)), + self.camera_projection, + str(float(self.focal_ratio))[:6], + ] + ).lower() + } \ No newline at end of file diff --git a/stages/run_opensfm.py b/stages/run_opensfm.py index 415778a4..37bdd639 100644 --- a/stages/run_opensfm.py +++ b/stages/run_opensfm.py @@ -30,7 +30,7 @@ class ODMOpenSfMStage(types.ODM_Stage): octx = OSFMContext(tree.opensfm) octx.setup(args, tree.dataset_raw, reconstruction=reconstruction, rerun=self.rerun()) - octx.extract_metadata(self.rerun()) + octx.photos_to_metadata(photos, self.rerun()) self.update_progress(20) octx.feature_matching(self.rerun()) self.update_progress(30) diff --git a/stages/splitmerge.py b/stages/splitmerge.py index bb28c18c..35bc01cf 100644 --- a/stages/splitmerge.py +++ b/stages/splitmerge.py @@ -52,7 +52,7 @@ class ODMSplitStage(types.ODM_Stage): ] octx.setup(args, tree.dataset_raw, reconstruction=reconstruction, append_config=config, rerun=self.rerun()) - octx.extract_metadata(self.rerun()) + octx.photos_to_metadata(photos, self.rerun()) self.update_progress(5)