import re import os import logging import math from itertools import groupby log = logging.getLogger(__name__) LENS_ENTRY_DEFAULT_RE = re.compile('^\{\s*(?P[0-9]+),\s*"(?P.*)"') LENS_META_DEFAULT_RE = re.compile( ( # anything at the start ".*?" # maybe min focal length and hyphen, surely max focal length e.g.: 24-70mm "(?:(?P[0-9]+)-)?(?P[0-9]+)mm" # anything in-between ".*?" # maybe short focal length max aperture and hyphen, surely at least single max aperture e.g.: f/4.5-5.6 # short and tele indicate apertures at the short (focal_length_min) and tele (focal_length_max) position of the lens "(?:(?:f\/)|T|F)(?:(?P[0-9]+(?:\.[0-9]+)?)-)?(?P[0-9]+(?:\.[0-9])?)" # check if there is a teleconverter pattern e.g. + 1.4x "(?:.*?\+.*?(?P[0-9.]+)x)?" ) ) def aperture_to_raw_exif(aperture): # see https://github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/Canon.pm#L9678 """Transform aperture value to Canon maker note style hex format.""" # for apertures < 1 the below is negative num = math.log(aperture) * 2 / math.log(2) # temporarily make the number positive if num < 0: num = -num sign = -1 else: sign = 1 val = int(num) frac = num - val if abs(frac - 0.33) < 0.05: frac = 0x0C elif abs(frac - 0.67) < 0.05: frac = 0x14 else: frac = int(frac * 0x20 + 0.5) return sign * (val * 0x20 + frac) def raw_exif_to_aperture(raw): """The inverse operation of aperture_to_raw_exif""" val = raw if val < 0: val = -val sign = -1 else: sign = 1 frac = val & 0x1F val -= frac # Convert 1/3 and 2/3 codes if frac == 0x0C: frac = 0x20 / 3 elif frac == 0x14: frac = 0x40 / 3 ev = sign * (val + frac) / 0x20 return math.exp(ev * math.log(2) / 2) def parse_lens_entry(text, pattern=LENS_ENTRY_DEFAULT_RE): """ get the ID, and description from a lens entry field Expected input format: { 748, "Canon EF 100-400mm f/4.5-5.6L IS II USM + 1.4x" } We return a dict of: lens_id = 748 lens_description = "Canon EF 100-400mm f/4.5-5.6L IS II USM + 1.4x" """ result = pattern.match(text) return result.groups() if result else None def extract_meta(text, pattern=LENS_META_DEFAULT_RE): """ Extract metadata from lens description. Input expected in the form of e.g. "Canon EF 100-400mm f/4.5-5.6L IS II USM + 1.4x" We return a dict of: focal_length_min = 100 focal_length_max = 400 aperture_max_short = 4.5 aperture_max_tele = 5.6 tc = 1.4 """ result = pattern.match(text) if not result: # didn't match return None ret = result.groupdict() # set min to max value if we didn't get a range but a single value ret["focal_length_min"] = int(ret["focal_length_min"] or ret["focal_length_max"]) ret["focal_length_max"] = int(ret["focal_length_max"]) ret["aperture_max_short"] = float(ret["aperture_max_short"] or ret["aperture_max_tele"]) ret["aperture_max_tele"] = float(ret["aperture_max_tele"]) ret["tc"] = float(ret["tc"] or 1) return ret def lens_is_match(l1, l2): """ Test if lens l2 is compatible with lens l1 This assumes we write l1's metadata and pick its 'aperture_max_short' value as the maximum aperture value to write into exif. Normally the canon maker note holds the max aperture of the lens at the focal length the picture was taken at. Thus for a f/4-6.3 lens, this value could be anywhere in that range. """ # the problem is that the round trip transformation isn't exact # so we need to account for this here as well to not define a target # which isn't achievable for exiv2 reconstructed_aperture = raw_exif_to_aperture(aperture_to_raw_exif(l1["aperture_max_short"] * l1["tc"])) return all( [ l1["focal_length_min"] * l1["tc"] == l2["focal_length_min"] * l2["tc"], l1["focal_length_max"] * l1["tc"] == l2["focal_length_max"] * l2["tc"], (l2["aperture_max_short"] - 0.1) * l2["tc"] <= reconstructed_aperture <= (l2["aperture_max_tele"] + 0.1) * l2["tc"], ] ) def make_test_cases(lenses): """ Creates a test case for each lens Main job of this function is to collect all ambiguous lenses and define a test target as the " *OR* " joined string of all ambiguous lens descriptions """ test_cases = [] for lens_id, group in groupby(lenses, lambda x: x["id"]): lens_group = list(group) test_cases += [ { **lens["meta"], "id": lens["id"], "desc": lens["desc"], "target": " *OR* ".join([l["desc"] for l in lens_group if lens_is_match(lens["meta"], l["meta"])]), } for lens in lens_group ] return test_cases def extract_lenses_from_cpp(filename, start_pattern): """ Extract lens information from the lens descriptions array in a maker note cpp file filename: path to cpp file start_pattern: start_pattern == line.strip() should return True for the starting line of the array containing the lenses. returns: a list of lens entries containing a tuple of the form: (lens ID, lens description, metadata dictionary) for content of metadata see extract_meta() function. """ lenses = [] with open(filename, "r") as f: in_lens_array = False for line in f.readlines(): stripped = line.strip() if stripped == start_pattern: in_lens_array = True continue if stripped == "};": in_lens_array = False continue if in_lens_array: lens_entry = parse_lens_entry(stripped) if not lens_entry: log.error(f"Failure parsing lens entry: {stripped}.") continue if lens_entry[1] == "n/a": continue meta = extract_meta(lens_entry[1]) if not meta: log.error(f"Failure extracting metadata from lens description: {lens_entry[0]}: {lens_entry[1]}.") continue lenses.append({"id": lens_entry[0], "desc": lens_entry[1], "meta": meta}) return lenses