diff --git a/SuperBuild/CMakeLists.txt b/SuperBuild/CMakeLists.txt index 01773efe..75cc4160 100644 --- a/SuperBuild/CMakeLists.txt +++ b/SuperBuild/CMakeLists.txt @@ -178,6 +178,7 @@ set(custom_libs OpenSfM PyPopsift Obj2Tiles OpenPointClass + ExifTool ) externalproject_add(mve diff --git a/opendm/exiftool.py b/opendm/exiftool.py new file mode 100644 index 00000000..7dfe087a --- /dev/null +++ b/opendm/exiftool.py @@ -0,0 +1,94 @@ +import json +import os +import tempfile +import base64 +from rasterio.io import MemoryFile +from opendm.system import run +from opendm import log +from opendm.utils import double_quote + +def extract_raw_thermal_image_data(image_path): + try: + f, tmp_file_path = tempfile.mkstemp(suffix='.json') + os.close(f) + + try: + output = run("exiftool -b -x ThumbnailImage -x PreviewImage -j \"%s\" > \"%s\"" % (image_path, tmp_file_path), quiet=True) + + with open(tmp_file_path) as f: + j = json.loads(f.read()) + + if isinstance(j, list): + j = j[0] # single file + + if "RawThermalImage" in j: + imageBytes = base64.b64decode(j["RawThermalImage"][len("base64:"):]) + + with MemoryFile(imageBytes) as memfile: + with memfile.open() as dataset: + img = dataset.read() + bands, h, w = img.shape + + if bands != 1: + raise Exception("Raw thermal image has more than one band? This is not supported") + + # (1, 512, 640) --> (512, 640, 1) + img = img[0][:,:,None] + + del j["RawThermalImage"] + + return extract_temperature_params_from(j), img + else: + raise Exception("Invalid JSON (not a list)") + + except Exception as e: + log.ODM_WARNING("Cannot extract tags using exiftool: %s" % str(e)) + return {}, None + finally: + if os.path.isfile(tmp_file_path): + os.remove(tmp_file_path) + except Exception as e: + log.ODM_WARNING("Cannot create temporary file: %s" % str(e)) + return {}, None + +def unit(unit): + def _convert(v): + if isinstance(v, float): + return v + elif isinstance(v, str): + if not v[-1].isnumeric(): + if v[-1].upper() != unit.upper(): + log.ODM_WARNING("Assuming %s is in %s" % (v, unit)) + return float(v[:-1]) + else: + return float(v) + else: + return float(v) + return _convert + +def extract_temperature_params_from(tags): + # Defaults + meta = { + "Emissivity": float, + "ObjectDistance": unit("m"), + "AtmosphericTemperature": unit("C"), + "ReflectedApparentTemperature": unit("C"), + "IRWindowTemperature": unit("C"), + "IRWindowTransmission": float, + "RelativeHumidity": unit("%"), + "PlanckR1": float, + "PlanckB": float, + "PlanckF": float, + "PlanckO": float, + "PlanckR2": float, + } + + params = {} + + for m in meta: + if m not in tags: + # All or nothing + raise Exception("Cannot find %s in tags" % m) + params[m] = (meta[m])(tags[m]) + + return params \ No newline at end of file diff --git a/opendm/photo.py b/opendm/photo.py index 17a983a7..b0af5170 100644 --- a/opendm/photo.py +++ b/opendm/photo.py @@ -305,7 +305,7 @@ class ODM_Photo: for xtags in xmp: try: - band_name = self.get_xmp_tag(xtags, ['Camera:BandName', '@Camera:BandName']) + band_name = self.get_xmp_tag(xtags, ['Camera:BandName', '@Camera:BandName', 'FLIR:BandName']) if band_name is not None: self.band_name = band_name.replace(" ", "") diff --git a/opendm/system.py b/opendm/system.py index ade8ccd0..1ad88a52 100644 --- a/opendm/system.py +++ b/opendm/system.py @@ -66,11 +66,12 @@ def sighandler(signum, frame): signal.signal(signal.SIGINT, sighandler) signal.signal(signal.SIGTERM, sighandler) -def run(cmd, env_paths=[context.superbuild_bin_path], env_vars={}, packages_paths=context.python_packages_paths): +def run(cmd, env_paths=[context.superbuild_bin_path], env_vars={}, packages_paths=context.python_packages_paths, quiet=False): """Run a system command""" global running_subprocesses - log.ODM_INFO('running %s' % cmd) + if not quiet: + log.ODM_INFO('running %s' % cmd) env = os.environ.copy() sep = ":" @@ -101,7 +102,8 @@ def run(cmd, env_paths=[context.superbuild_bin_path], env_vars={}, packages_path retcode = p.wait() - log.logger.log_json_process(cmd, retcode, list(lines)) + if not quiet: + log.logger.log_json_process(cmd, retcode, list(lines)) running_subprocesses.remove(p) if retcode < 0: diff --git a/opendm/thermal.py b/opendm/thermal.py index 0f53ece4..315df60a 100644 --- a/opendm/thermal.py +++ b/opendm/thermal.py @@ -1,7 +1,9 @@ -from opendm import log -from opendm.thermal_tools import dji_unpack import cv2 import os +from opendm import log +from opendm.thermal_tools import dji_unpack +from opendm.exiftool import extract_raw_thermal_image_data +from opendm.thermal_tools.thermal_utils import sensor_vals_to_temp def resize_to_match(image, match_photo = None): """ @@ -19,17 +21,15 @@ def resize_to_match(image, match_photo = None): interpolation=cv2.INTER_LANCZOS4) return image -def dn_to_temperature(photo, image, dataset_tree): +def dn_to_temperature(photo, image, images_path): """ Convert Digital Number values to temperature (C) values :param photo ODM_Photo :param image numpy array containing image data - :param dataset_tree path to original source image to read data using PIL for DJI thermal photos + :param images_path path to original source image to read data using PIL for DJI thermal photos :return numpy array with temperature (C) image values """ - - # Handle thermal bands if photo.is_thermal(): # Every camera stores thermal information differently @@ -51,11 +51,18 @@ def dn_to_temperature(photo, image, dataset_tree): else: return image elif photo.camera_make == "DJI" and photo.camera_model == "MAVIC2-ENTERPRISE-ADVANCED": - image = dji_unpack.extract_temperatures_dji(photo, image, dataset_tree) + image = dji_unpack.extract_temperatures_dji(photo, image, images_path) image = image.astype("float32") return image else: - log.ODM_WARNING("Unsupported camera [%s %s], thermal band will have digital numbers." % (photo.camera_make, photo.camera_model)) + try: + params, image = extract_raw_thermal_image_data(os.path.join(images_path, photo.filename)) + image = sensor_vals_to_temp(image, **params) + except Exception as e: + log.ODM_WARNING("Cannot radiometrically calibrate %s: %s" % (photo.filename, str(e))) + + image = image.astype("float32") + return image else: image = image.astype("float32") log.ODM_WARNING("Tried to radiometrically calibrate a non-thermal image with temperature values (%s)" % photo.filename) diff --git a/opendm/thermal_tools/flir_unpack.py b/opendm/thermal_tools/flir_unpack.py deleted file mode 100644 index 903d34d9..00000000 --- a/opendm/thermal_tools/flir_unpack.py +++ /dev/null @@ -1,271 +0,0 @@ -""" -THIS IS WIP, DON'T USE THIS FILE, IT IS HERE FOR FURTHER IMPROVEMENT -Tools for extracting thermal data from FLIR images. -Derived from https://bitbucket.org/nimmerwoner/flyr/src/master/ -""" - -import os -from io import BufferedIOBase, BytesIO -from typing import BinaryIO, Dict, Optional, Tuple, Union - -import numpy as np -from PIL import Image - -# Constants -SEGMENT_SEP = b"\xff" -APP1_MARKER = b"\xe1" -MAGIC_FLIR_DEF = b"FLIR\x00" - -CHUNK_APP1_BYTES_COUNT = len(APP1_MARKER) -CHUNK_LENGTH_BYTES_COUNT = 2 -CHUNK_MAGIC_BYTES_COUNT = len(MAGIC_FLIR_DEF) -CHUNK_SKIP_BYTES_COUNT = 1 -CHUNK_NUM_BYTES_COUNT = 1 -CHUNK_TOT_BYTES_COUNT = 1 -CHUNK_PARTIAL_METADATA_LENGTH = CHUNK_APP1_BYTES_COUNT + CHUNK_LENGTH_BYTES_COUNT + CHUNK_MAGIC_BYTES_COUNT -CHUNK_METADATA_LENGTH = ( - CHUNK_PARTIAL_METADATA_LENGTH + CHUNK_SKIP_BYTES_COUNT + CHUNK_NUM_BYTES_COUNT + CHUNK_TOT_BYTES_COUNT -) - - -def unpack(path_or_stream: Union[str, BinaryIO]) -> np.ndarray: - """Unpacks the FLIR image, meaning that it will return the thermal data embedded in the image. - - Parameters - ---------- - path_or_stream : Union[str, BinaryIO] - Either a path (string) to a FLIR file, or a byte stream such as - BytesIO or file opened as `open(file_path, "rb")`. - - Returns - ------- - FlyrThermogram - When successful, a FlyrThermogram object containing thermogram data. - """ - if isinstance(path_or_stream, str) and os.path.isfile(path_or_stream): - with open(path_or_stream, "rb") as flirh: - return unpack(flirh) - elif isinstance(path_or_stream, BufferedIOBase): - stream = path_or_stream - flir_app1_stream = extract_flir_app1(stream) - flir_records = parse_flir_app1(flir_app1_stream) - raw_np = parse_thermal(flir_app1_stream, flir_records) - - return raw_np - else: - raise ValueError("Incorrect input") - - -def extract_flir_app1(stream: BinaryIO) -> BinaryIO: - """Extracts the FLIR APP1 bytes. - - Parameters - --------- - stream : BinaryIO - A full bytes stream of a JPEG file, expected to be a FLIR file. - - Raises - ------ - ValueError - When the file is invalid in one the next ways, a - ValueError is thrown. - - * File is not a JPEG - * A FLIR chunk number occurs more than once - * The total chunks count is inconsistent over multiple chunks - * No APP1 segments are successfully parsed - - Returns - ------- - BinaryIO - A bytes stream of the APP1 FLIR segments - """ - # Check JPEG-ness - _ = stream.read(2) - - chunks_count: Optional[int] = None - chunks: Dict[int, bytes] = {} - while True: - b = stream.read(1) - if b == b"": - break - - if b != SEGMENT_SEP: - continue - - parsed_chunk = parse_flir_chunk(stream, chunks_count) - if not parsed_chunk: - continue - - chunks_count, chunk_num, chunk = parsed_chunk - chunk_exists = chunks.get(chunk_num, None) is not None - if chunk_exists: - raise ValueError("Invalid FLIR: duplicate chunk number") - chunks[chunk_num] = chunk - - # Encountered all chunks, break out of loop to process found metadata - if chunk_num == chunks_count: - break - - if chunks_count is None: - raise ValueError("Invalid FLIR: no metadata encountered") - - flir_app1_bytes = b"" - for chunk_num in range(chunks_count + 1): - flir_app1_bytes += chunks[chunk_num] - - flir_app1_stream = BytesIO(flir_app1_bytes) - flir_app1_stream.seek(0) - return flir_app1_stream - - -def parse_flir_chunk(stream: BinaryIO, chunks_count: Optional[int]) -> Optional[Tuple[int, int, bytes]]: - """Parse flir chunk.""" - # Parse the chunk header. Headers are as follows (definition with example): - # - # \xff\xe1FLIR\x00\x01 - # \xff\xe1\xff\xfeFLIR\x00\x01\x01\x0b - # - # Meaning: Exif APP1, 65534 long, FLIR chunk 1 out of 12 - marker = stream.read(CHUNK_APP1_BYTES_COUNT) - - length_bytes = stream.read(CHUNK_LENGTH_BYTES_COUNT) - length = int.from_bytes(length_bytes, "big") - length -= CHUNK_METADATA_LENGTH - magic_flir = stream.read(CHUNK_MAGIC_BYTES_COUNT) - - if not (marker == APP1_MARKER and magic_flir == MAGIC_FLIR_DEF): - # Seek back to just after byte b and continue searching for chunks - stream.seek(-len(marker) - len(length_bytes) - len(magic_flir), 1) - return None - - stream.seek(1, 1) # skip 1 byte, unsure what it is for - - chunk_num = int.from_bytes(stream.read(CHUNK_NUM_BYTES_COUNT), "big") - chunks_tot = int.from_bytes(stream.read(CHUNK_TOT_BYTES_COUNT), "big") - - # Remember total chunks to verify metadata consistency - if chunks_count is None: - chunks_count = chunks_tot - - if ( # Check whether chunk metadata is consistent - chunks_tot is None or chunk_num < 0 or chunk_num > chunks_tot or chunks_tot != chunks_count - ): - raise ValueError(f"Invalid FLIR: inconsistent total chunks, should be 0 or greater, but is {chunks_tot}") - - return chunks_tot, chunk_num, stream.read(length + 1) - - -def parse_thermal(stream: BinaryIO, records: Dict[int, Tuple[int, int, int, int]]) -> np.ndarray: - """Parse thermal.""" - RECORD_IDX_RAW_DATA = 1 - raw_data_md = records[RECORD_IDX_RAW_DATA] - _, _, raw_data = parse_raw_data(stream, raw_data_md) - return raw_data - - -def parse_flir_app1(stream: BinaryIO) -> Dict[int, Tuple[int, int, int, int]]: - """Parse flir app1.""" - # 0x00 - string[4] file format ID = "FFF\0" - # 0x04 - string[16] file creator: seen "\0","MTX IR\0","CAMCTRL\0" - # 0x14 - int32u file format version = 100 - # 0x18 - int32u offset to record directory - # 0x1c - int32u number of entries in record directory - # 0x20 - int32u next free index ID = 2 - # 0x24 - int16u swap pattern = 0 (?) - # 0x28 - int16u[7] spares - # 0x34 - int32u[2] reserved - # 0x3c - int32u checksum - - # 1. Read 0x40 bytes and verify that its contents equals AFF\0 or FFF\0 - _ = stream.read(4) - - # 2. Read FLIR record directory metadata (ref 3) - stream.seek(16, 1) - _ = int.from_bytes(stream.read(4), "big") - record_dir_offset = int.from_bytes(stream.read(4), "big") - record_dir_entries_count = int.from_bytes(stream.read(4), "big") - stream.seek(28, 1) - _ = int.from_bytes(stream.read(4), "big") - - # 3. Read record directory (which is a FLIR record entry repeated - # `record_dir_entries_count` times) - stream.seek(record_dir_offset) - record_dir_stream = BytesIO(stream.read(32 * record_dir_entries_count)) - - # First parse the record metadata - record_details: Dict[int, Tuple[int, int, int, int]] = {} - for record_nr in range(record_dir_entries_count): - record_dir_stream.seek(0) - details = parse_flir_record_metadata(stream, record_nr) - if details: - record_details[details[1]] = details - - # Then parse the actual records - # for (entry_idx, type, offset, length) in record_details: - # parse_record = record_parsers[type] - # stream.seek(offset) - # record = BytesIO(stream.read(length + 36)) # + 36 needed to find end - # parse_record(record, offset, length) - - return record_details - - -def parse_flir_record_metadata(stream: BinaryIO, record_nr: int) -> Optional[Tuple[int, int, int, int]]: - """Parse flir record metadata.""" - # FLIR record entry (ref 3): - # 0x00 - int16u record type - # 0x02 - int16u record subtype: RawData 1=BE, 2=LE, 3=PNG; 1 for other record types - # 0x04 - int32u record version: seen 0x64,0x66,0x67,0x68,0x6f,0x104 - # 0x08 - int32u index id = 1 - # 0x0c - int32u record offset from start of FLIR data - # 0x10 - int32u record length - # 0x14 - int32u parent = 0 (?) - # 0x18 - int32u object number = 0 (?) - # 0x1c - int32u checksum: 0 for no checksum - entry = 32 * record_nr - stream.seek(entry) - record_type = int.from_bytes(stream.read(2), "big") - if record_type < 1: - return None - - _ = int.from_bytes(stream.read(2), "big") - _ = int.from_bytes(stream.read(4), "big") - _ = int.from_bytes(stream.read(4), "big") - record_offset = int.from_bytes(stream.read(4), "big") - record_length = int.from_bytes(stream.read(4), "big") - _ = int.from_bytes(stream.read(4), "big") - _ = int.from_bytes(stream.read(4), "big") - _ = int.from_bytes(stream.read(4), "big") - return (entry, record_type, record_offset, record_length) - - -def parse_raw_data(stream: BinaryIO, metadata: Tuple[int, int, int, int]): - """Parse raw data.""" - (_, _, offset, length) = metadata - stream.seek(offset) - - stream.seek(2, 1) - width = int.from_bytes(stream.read(2), "little") - height = int.from_bytes(stream.read(2), "little") - - stream.seek(offset + 32) - - # Read the bytes with the raw thermal data and decode using PIL - thermal_bytes = stream.read(length) - thermal_stream = BytesIO(thermal_bytes) - thermal_img = Image.open(thermal_stream) - thermal_np = np.array(thermal_img) - - # Check shape - if thermal_np.shape != (height, width): - msg = "Invalid FLIR: metadata's width and height don't match thermal data's actual width\ - and height ({} vs ({}, {})" - msg = msg.format(thermal_np.shape, height, width) - raise ValueError(msg) - - # FLIR PNG data is in the wrong byte order, fix that - fix_byte_order = np.vectorize(lambda x: (x >> 8) + ((x & 0x00FF) << 8)) - thermal_np = fix_byte_order(thermal_np) - - return width, height, thermal_np \ No newline at end of file