Hello,

I've rewritten the lensfun calibration script from scratch. I don't find the 
current script very user friendly. Also it uses dcraw which gets updates only 
rarely. Also the current calibrate.py is spaghetti code. I find it hard to 
understand. It has also issues with reading exif data e.g. for Nikon lenses.

I rewrote the script from scratch using python3-exiv2 for reading exif data 
which can deal with lens numbers and get a human readable presentation. I've 
used darktable-cli for creating tif and ppm files. It supported my camera 
shortly after it was released. I store everything in ini file as python 
provides functions to write and read them.

Currently it is developed here:

https://gitlab.com/cryptomilk/lens_calibrate

Attached is patch to add it to the lensfun repository if you prefer that. I 
would delete my repo then. As lensfun is GPLv3 I licensed the new script under 
the same license.

Also I have written a completely new detailed tutorial for lens calibration. 
It is be ready to be released on https://pixls.us/ if that is OK.

A copy of the article can be found here:

https://hackmd.io/s/SkOIRlr5z


Feedback is very welcome.


Thanks,


        Andreas

-- 
Andreas Schneider                 a...@cryptomilk.org
GPG-ID:     8DFF53E18F2ABC8D8F3C92237EE0FC4DCC014E3D
From 07845ef51a7f8a26a1151215fbd0a56b4657eaa5 Mon Sep 17 00:00:00 2001
From: Andreas Schneider <a...@cryptomilk.org>
Date: Thu, 15 Nov 2018 11:47:44 +0100
Subject: [PATCH] tools: Add new lens_calibrate.py script

Signed-off-by: Andreas Schneider <a...@cryptomilk.org>
---
 tools/calibrate/README.md         |  59 ++
 tools/calibrate/lens_calibrate.py | 910 ++++++++++++++++++++++++++++++
 2 files changed, 969 insertions(+)
 create mode 100644 tools/calibrate/README.md
 create mode 100755 tools/calibrate/lens_calibrate.py

diff --git a/tools/calibrate/README.md b/tools/calibrate/README.md
new file mode 100644
index 0000000..a9e3c57
--- /dev/null
+++ b/tools/calibrate/README.md
@@ -0,0 +1,59 @@
+lens_calibrate.py
+=================
+
+To setup the required directory structure simply run:
+
+    ./lens_calibrate.py init
+
+The next step is to copy the RAW files you created to the corresponding
+directories.
+
+Once you have done that run:
+
+    ./lens_calibrate.py distortion
+
+This will create tiff file you can use to figure out the the lens distortion
+values (a), (b) and (c) using hugin. It will also create a lenses.conf where
+you need to fill out missing values.
+
+If you don't want to do distortion corrections you need to create the
+lenses.conf file manually. It needs to look like this:
+
+    [MODEL NAME]
+    maker =
+    mount =
+    cropfactor =
+    aspect_ratio =
+    type =
+
+The values are:
+
+* *maker*: is the manufacturer or the lens, e.g. 'FE 16-35mm F2.8 GM'
+* *mount*: is the name of the mount system, e.g. 'Sony E'
+* *cropfactor*: Is the crop factor of the camera as a float, e.g. '1.0' for full frame
+* *aspect_ratio*: This is the aspect_ratio, e.g. '3:2'
+* *type*: is the type of the lens, e.g. 'normal' for rectilinear lenses. Other
+  values are: stereographic, equisolid, stereographic, panoramic or fisheye.
+
+If you want TCA corrections run:
+
+    ./lens_calibrate.py tca
+
+If you want vignetting corrections run:
+
+    ./lens_calibrate.py vignetting
+
+Once you have created data for all corrections you can generate an xml file
+which can be consumed by lensfun. Just call:
+
+    ./lens_calibrate.py generate_xml
+
+To use the data in your favourite software you just have to copy the generated
+lensfun.xml file to:
+
+    ~/.local/share/lensfun/
+
+Create a bug report or pull request to add the lens to the project at:
+
+* https://sourceforge.net/p/lensfun/bugs/
+* https://github.com/lensfun/lensfun/
diff --git a/tools/calibrate/lens_calibrate.py b/tools/calibrate/lens_calibrate.py
new file mode 100755
index 0000000..b833336
--- /dev/null
+++ b/tools/calibrate/lens_calibrate.py
@@ -0,0 +1,910 @@
+#!/usr/bin/python3
+
+#######################################################################
+#
+# A script to calibrate camera lenes for lensfun
+#
+# Copyright (c) 2012-2016 Torsten Bronger <bron...@physik.rwth-aachen.de>
+# Copyright (c)      2018 Andreas Schneider <a...@cryptomilk.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+#######################################################################
+#
+# Requires: python3-exiv2
+# Requires: python3-numpy
+# Requires: python3-spicy
+#
+# Requires: darktable (darktable-cli)
+# Requires: hugin-tools (tca_correct)
+# Requires: ImageMagick (convert)
+#
+
+import os
+import argparse
+import configparser
+import codecs
+import re
+import math
+import numpy
+import struct
+import subprocess
+from subprocess import DEVNULL
+from scipy.optimize.minpack import leastsq
+
+from pyexiv2.metadata import ImageMetadata
+from pyexiv2.exif import ExifTag
+
+# Sidecar for loading into hugin
+# Applies a neutral basecurve and enables sharpening
+DARKTABLE_DISTORTION_SIDECAR = '''<?xml version="1.0" encoding="UTF-8"?>
+<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 4.4.0-Exiv2">
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#";>
+  <rdf:Description rdf:about=""
+    xmlns:xmp="http://ns.adobe.com/xap/1.0/";
+    xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/";
+    xmlns:darktable="http://darktable.sf.net/";
+   xmp:Rating="1"
+   xmpMM:DerivedFrom="DISTORTION.ARW"
+   darktable:xmp_version="2"
+   darktable:raw_params="0"
+   darktable:auto_presets_applied="1"
+   darktable:history_end="3">
+   <darktable:mask_id>
+    <rdf:Seq/>
+   </darktable:mask_id>
+   <darktable:mask_type>
+    <rdf:Seq/>
+   </darktable:mask_type>
+   <darktable:mask_name>
+    <rdf:Seq/>
+   </darktable:mask_name>
+   <darktable:mask_version>
+    <rdf:Seq/>
+   </darktable:mask_version>
+   <darktable:mask>
+    <rdf:Seq/>
+   </darktable:mask>
+   <darktable:mask_nb>
+    <rdf:Seq/>
+   </darktable:mask_nb>
+   <darktable:mask_src>
+    <rdf:Seq/>
+   </darktable:mask_src>
+   <darktable:history>
+    <rdf:Seq>
+     <rdf:li
+      darktable:operation="sharpen"
+      darktable:enabled="1"
+      darktable:modversion="1"
+      darktable:params="000000400000003f0000003f"
+      darktable:multi_name=""
+      darktable:multi_priority="0"
+      darktable:blendop_version="7"
+      darktable:blendop_params="gz12eJxjYGBgkGAAgRNODESDBnsIHll8ANNSGQM="/>
+     <rdf:li
+      darktable:operation="flip"
+      darktable:enabled="1"
+      darktable:modversion="2"
+      darktable:params="ffffffff"
+      darktable:multi_name=""
+      darktable:multi_priority="0"
+      darktable:blendop_version="7"
+      darktable:blendop_params="gz12eJxjYGBgkGAAgRNODESDBnsIHll8ANNSGQM="/>
+     <rdf:li
+      darktable:operation="basecurve"
+      darktable:enabled="1"
+      darktable:modversion="5"
+      darktable:params="gz09eJxjYIAAruuLrbmuK1vPmilpN2vmTLuzZ87YGRsb2zMwONgbGxcD6QYoHgVDCbAhsZkwZCFxCgBDtg6p"
+      darktable:multi_name=""
+      darktable:multi_priority="0"
+      darktable:blendop_version="7"
+      darktable:blendop_params="gz12eJxjYGBgkGAAgRNODESDBnsIHll8ANNSGQM="/>
+    </rdf:Seq>
+   </darktable:history>
+  </rdf:Description>
+ </rdf:RDF>
+</x:xmpmeta>
+'''
+
+# Sidecar for TCA corrections
+# Disables the basecurve and sharpening
+DARKTABLE_TCA_SIDECAR = '''<?xml version="1.0" encoding="UTF-8"?>
+<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 4.4.0-Exiv2">
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#";>
+  <rdf:Description rdf:about=""
+    xmlns:xmp="http://ns.adobe.com/xap/1.0/";
+    xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/";
+    xmlns:darktable="http://darktable.sf.net/";
+   xmp:Rating="1"
+   xmpMM:DerivedFrom="TCA.ARW"
+   darktable:xmp_version="2"
+   darktable:raw_params="0"
+   darktable:auto_presets_applied="1"
+   darktable:history_end="3">
+   <darktable:mask_id>
+    <rdf:Seq/>
+   </darktable:mask_id>
+   <darktable:mask_type>
+    <rdf:Seq/>
+   </darktable:mask_type>
+   <darktable:mask_name>
+    <rdf:Seq/>
+   </darktable:mask_name>
+   <darktable:mask_version>
+    <rdf:Seq/>
+   </darktable:mask_version>
+   <darktable:mask>
+    <rdf:Seq/>
+   </darktable:mask>
+   <darktable:mask_nb>
+    <rdf:Seq/>
+   </darktable:mask_nb>
+   <darktable:mask_src>
+    <rdf:Seq/>
+   </darktable:mask_src>
+   <darktable:history>
+    <rdf:Seq>
+     <rdf:li
+      darktable:operation="flip"
+      darktable:enabled="1"
+      darktable:modversion="2"
+      darktable:params="ffffffff"
+      darktable:multi_name=""
+      darktable:multi_priority="0"
+      darktable:blendop_version="7"
+      darktable:blendop_params="gz12eJxjYGBgkGAAgRNODESDBnsIHll8ANNSGQM="/>
+     <rdf:li
+      darktable:operation="basecurve"
+      darktable:enabled="0"
+      darktable:modversion="5"
+      darktable:params="gz09eJxjYICAL3eYbKcsErU1fXPdVmRLpl1B+T07pyon+6WC0fb9R6rtGRgaoHgUDCXAhsRmwpCFxCkAdoEQ3Q=="
+      darktable:multi_name=""
+      darktable:multi_priority="0"
+      darktable:blendop_version="7"
+      darktable:blendop_params="gz12eJxjYGBgkGAAgRNODESDBnsIHll8ANNSGQM="/>
+     <rdf:li
+      darktable:operation="sharpen"
+      darktable:enabled="0"
+      darktable:modversion="1"
+      darktable:params="000000400000003f0000003f"
+      darktable:multi_name=""
+      darktable:multi_priority="0"
+      darktable:blendop_version="7"
+      darktable:blendop_params="gz12eJxjYGBgkGAAgRNODESDBnsIHll8ANNSGQM="/>
+    </rdf:Seq>
+   </darktable:history>
+  </rdf:Description>
+ </rdf:RDF>
+</x:xmpmeta>
+'''
+
+def is_raw_file(filename):
+    raw_file_extensions = [
+            ".3FR", ".ARI", ".ARW", ".BAY", ".CRW", ".CR2", ".CAP", ".DCS",
+            ".DCR", ".DNG", ".DRF", ".EIP", ".ERF", ".FFF", ".IIQ", ".K25",
+            ".KDC", ".MEF", ".MOS", ".MRW", ".NEF", ".NRW", ".OBM", ".ORF",
+            ".PEF", ".PTX", ".PXN", ".R3D", ".RAF", ".RAW", ".RWL", ".RW2",
+            ".RWZ", ".SR2", ".SRF", ".SRW", ".X3F", ".JPG", ".JPEG", ".TIF",
+            ".TIFF",
+        ]
+    file_ext = os.path.splitext(filename)[1]
+
+    return file_ext.upper() in raw_file_extensions
+
+def has_exif_tag(data, tag):
+    return tag in data
+
+def image_read_exif(filename):
+    data = ImageMetadata(filename)
+
+    # This reads the metadata and closes the file
+    data.read()
+
+    lens_model = None
+    tag = 'Exif.Photo.LensModel'
+    if has_exif_tag(data, tag):
+        lens_model = data[tag].value
+    else:
+        tag = 'Exif.NikonLd3.LensIDNumber'
+        if has_exif_tag(data, tag):
+            lens_model = data[tag].human_value
+
+        tag = 'Exif.Panasonic.LensType'
+        if has_exif_tag(data, tag):
+            lens_model = data[tag].value
+
+        tag = 'Exif.Sony1.LensID'
+        if has_exif_tag(data, tag):
+            lens_model = data[tag].human_value
+
+        tag = 'Exif.Minolta.LensID'
+        if has_exif_tag(data, tag):
+            lens_model = data[tag].human_value
+
+    if lens_model is None:
+       lens_model = 'Standard'
+
+    tag = 'Exif.Photo.FocalLength'
+    if has_exif_tag(data, tag):
+        focal_length = int(data[tag].value)
+    else:
+        print("%s doesn't have Exif.Photo.FocalLength set. " % (filename) +
+              "Please fix it manually.")
+
+    tag = 'Exif.Photo.FNumber'
+    if has_exif_tag(data, tag):
+        aperture = float(data[tag].value)
+    else:
+        print("%s doesn't have Exif.Photo.FNumber set. " % (filename) +
+              "Please fix it manually.")
+
+    return { "lens_model" : lens_model,
+             "focal_length" : focal_length,
+             "aperture" : aperture }
+
+# convert raw file to 16bit tiff
+def convert_raw_for_distortion(input_file, output_file=None):
+    if output_file is None:
+        output_file = ("%s.tif" % os.path.splitext(input_file)[0])
+    sidecar_file = (os.path.join(os.path.dirname(output_file), "distortion.xmp"))
+
+    if not os.path.isfile(sidecar_file):
+        with open(sidecar_file, 'w') as f:
+            f.write(DARKTABLE_DISTORTION_SIDECAR)
+
+    if not os.path.exists(output_file):
+        print("Converting %s to %s ..." % (input_file, output_file), end='', flush=True)
+
+        cmd = [
+                "darktable-cli",
+                input_file,
+                sidecar_file,
+                output_file,
+                "--core",
+                "--conf", "plugins/lighttable/export/iccintent=0", # perceptual
+                "--conf", "plugins/lighttable/export/iccprofile=sRGB",
+                "--conf", "plugins/lighttable/export/style=none",
+                "--conf", "plugins/imageio/format/tiff/bpp=16",
+                "--conf", "plugins/imageio/format/tiff/compress=5"
+            ]
+        try:
+            subprocess.check_call(cmd, stdout=DEVNULL, stderr=subprocess.STDOUT)
+        except subprocess.CalledProcessError:
+            raise
+        except OSError:
+            print("Could not find darktable-cli")
+            return None
+
+        print(" DONE", flush=True)
+
+    return output_file
+
+def convert_raw_for_tca_or_vignetting(input_file, output_file=None):
+    if output_file is None:
+        output_file = ("%s.ppm" % os.path.splitext(input_file)[0])
+    sidecar_file = (os.path.join(os.path.dirname(output_file), "darktable.xmp"))
+
+    if not os.path.isfile(sidecar_file):
+        with open(sidecar_file, 'w') as f:
+            f.write(DARKTABLE_TCA_SIDECAR)
+
+    if not os.path.exists(output_file):
+        cmd = [
+                "darktable-cli",
+                input_file,
+                sidecar_file,
+                output_file,
+                "--core",
+                "--conf", "plugins/lighttable/export/iccintent=0", # perceptual
+                "--conf", "plugins/lighttable/export/iccprofile=linear_rec2020_rgb",
+                "--conf", "plugins/lighttable/export/style=none",
+            ]
+        try:
+            subprocess.check_call(cmd, stdout=DEVNULL, stderr=subprocess.STDOUT)
+        except subprocess.CalledProcessError:
+            raise
+        except OSError:
+            print("Could not find darktable-cli")
+
+    return output_file
+
+def convert_ppm_for_vignetting(input_file):
+    output_file = ("%s.pgm" % os.path.splitext(input_file)[0])
+
+    if not os.path.exists(output_file):
+        # TODO: Ask for clarification for such a small image size
+        cmd = [ "convert",
+                input_file,
+                '-set',
+                'colorspace',
+                'RGB',
+                '-resize',
+                '250',
+                output_file ]
+        try:
+            subprocess.check_call(cmd, stdout=DEVNULL, stderr=subprocess.STDOUT)
+        except subprocess.CalledProcessError:
+            raise
+        except OSError:
+            print("Could not find convert")
+
+    return output_file
+
+def create_lenses_config(lenses_exif_group):
+    config = configparser.ConfigParser()
+    for lenses in lenses_exif_group:
+        config[lenses] = {
+                'maker' : '[unknown]',
+                'mount' : '[unknown]',
+                'cropfactor' : '1.0',
+                'aspect_ratio' : '3:2',
+                'type' : 'normal'
+                }
+        for exif_data in lenses_exif_group[lenses]:
+            distortion = ("distortion(%dmm)" % exif_data['focal_length'])
+            config[lenses][distortion] = '0.0, 0.0, 0.0'
+    with open('lenses.conf', 'w') as configfile:
+        config.write(configfile)
+
+    print("A template has been created for distortion corrections as lenses.conf.")
+    print("Please fill this file with proper information. The most important")
+    print("values are:")
+    print("")
+    print("maker:        is the manufacturer or the lens, e.g. 'FE 16-35mm F2.8 GM'")
+    print("mount:        is the name of the mount system, e.g. 'Sony E'")
+    print("cropfactor:   is the crop factor of the camera as a float, e.g. '1.0' for")
+    print("              full frame")
+    print("aspect_ratio: is the aspect_ratio, e.g. '3:2'")
+    print("type:         is the type of the lens, e.g. 'normal' for rectilinear")
+    print("              lenses. Other possible values are: stereographic, equisolid,")
+    print("              stereographic, panoramic or fisheye.")
+    print("")
+    print("You can find details for distortion calculations here:")
+    print("")
+    print("https://hackmd.io/s/SkOIRlr5z#";)
+
+    return
+
+def parse_lenses_config(filename):
+    config = configparser.ConfigParser()
+    config.read(filename)
+
+    lenses = {}
+
+    for section in config.sections():
+        lenses[section] = {}
+        lenses[section]['distortion'] = {}
+        lenses[section]['tca'] = {}
+        lenses[section]['vignetting'] = {}
+
+        for key in config[section]:
+            if key.startswith('distortion'):
+                focal_length = key[11:len(key) - 3]
+                lenses[section]['distortion'][focal_length] = config[section][key]
+            else:
+                lenses[section][key] = config[section][key]
+
+    return lenses
+
+def tca_correct(input_file, original_file, exif_data, complex_tca=False):
+    output_file = ("%s.tca" % os.path.splitext(input_file)[0])
+
+    if not os.path.exists(output_file):
+        print("Running TCA corrections for %s ..." % (input_file), end='', flush=True)
+
+        tca_complexity = 'v'
+        if complex_tca:
+            tca_complexity = 'bv'
+        cmd = [ "tca_correct", "-o", tca_complexity, input_file ]
+        try:
+            output = subprocess.check_output(cmd, stderr=DEVNULL)
+        except subprocess.CalledProcessError:
+            raise
+        except OSError:
+            print("Could not find darktable-cli")
+            return None
+
+        tca_data = re.match(r"-r [.0]+:(?P<br>[-.0-9]+):[.0]+:(?P<vr>[-.0-9]+) -b [.0]+:(?P<bb>[-.0-9]+):[.0]+:(?P<vb>[-.0-9]+)",
+                            output.decode('ascii')).groupdict()
+
+        tca_config = configparser.ConfigParser()
+        tca_config[exif_data['lens_model']] = {
+                'focal_length' : exif_data['focal_length'],
+                'complex_tca' : complex_tca,
+                'tca' : output.decode('ascii'),
+                'br' : tca_data['br'],
+                'vr' : tca_data['vr'],
+                'bb' : tca_data['bb'],
+                'vb' : tca_data['vb'],
+                }
+        with open(output_file, "w") as tcafile:
+            tca_config.write(tcafile)
+
+        if complex_tca:
+            gp_file = ("%s.gp" % output_file)
+            with open(gp_file, "w") as f:
+                f.write('set title "%s" noenhanced\n' % original_file)
+                f.write('plot [0:1.8] %s * x**2 + %s title "red", %s * x**2 + %s title "blue"\n' %
+                        (tca_data['br'], tca_data["vr"], tca_data["bb"], tca_data["vb"]))
+                f.write('pause -1')
+
+        print(" DONE", flush=True)
+
+def fit_function(radius, A, k1, k2, k3):
+    return A * (1 + k1 * radius**2 + k2 * radius**4 + k3 * radius**6)
+
+def calculate_vignetting(input_file, exif_data, distance):
+    basename = os.path.splitext(input_file)[0]
+    all_points_filename = ("%s.all_points.dat" % basename)
+    bins_filename = ("%s.bins.dat" % basename)
+    gp_filename = ("%s.gp" % basename)
+    vig_filename = ("%s.vig" % basename)
+
+    if os.path.exists(vig_filename):
+        return
+
+    print("Generating vignetting data for %s ... " % input_file, end='', flush=True)
+
+    content = ''
+    with open(input_file, 'rb') as f:
+        content = f.read()
+
+    width, height = None, None
+    header_size = 0
+    for i, line in enumerate(content.splitlines(True)):
+        header_size += len(line)
+        if i == 0:
+            assert (line == b"P5\n"), "Wrong image format (must be NetPGM binary)"
+        else:
+            line = line.partition(b"#")[0].strip()
+            if line:
+                if not width:
+                    width, height = line.split()
+                    width, height = int(width), int(height)
+                else:
+                    assert (line == b"65535"), "Wrong grayscale depth: %d (must be 65535)" % (line)
+                    break
+
+    half_diagonal = math.hypot(width // 2, height // 2)
+    image_data = struct.unpack("!{0}s{1}H".format(header_size, width * height), content)[1:]
+
+    radii, intensities = [], []
+    maximal_radius = 1
+    for i, intensity in enumerate(image_data):
+        y, x = divmod(i, width)
+        radius = math.hypot(x - width // 2, y - height // 2) / half_diagonal
+        if radius <= maximal_radius:
+            radii.append(radius)
+            intensities.append(intensity)
+
+    with open(all_points_filename, 'w') as f:
+        for radius, intensity in zip(radii, intensities):
+            f.write("%f %d\n" % (radius, intensity))
+
+    number_of_bins = 16
+    bins = [[] for i in range(number_of_bins)]
+    for radius, intensity in zip(radii, intensities):
+        # The zeroth and the last bin are only half bins which means that their
+        # means are skewed.  But this is okay: For the zeroth, the curve is
+        # supposed to be horizontal anyway, and for the last, it underestimates
+        # the vignetting at the rim which is a good thing (too much of
+        # correction is bad).
+        bin_index = int(round(radius / maximal_radius * (number_of_bins - 1)))
+        bins[bin_index].append(intensity)
+    radii = [i / (number_of_bins - 1) * maximal_radius for i in range(number_of_bins)]
+    intensities = [numpy.median(bin) for bin in bins]
+
+    with open(bins_filename, 'w') as f:
+        for radius, intensity in zip(radii, intensities):
+            f.write("%f %d\n" % (radius, intensity))
+
+    radii, intensities = numpy.array(radii), numpy.array(intensities)
+
+    A, k1, k2, k3 = leastsq(lambda p, x, y: y - fit_function(x, *p), [30000, -0.3, 0, 0], args=(radii, intensities))[0]
+
+    vig_config = configparser.ConfigParser()
+    vig_config[exif_data['lens_model']] = {
+                'focal_length' : exif_data['focal_length'],
+                'aperture' : exif_data['aperture'],
+                'distance' : distance,
+                'A' : A,
+                'k1' : k1,
+                'k2' : k2,
+                'k3' : k3,
+                }
+    with open(vig_filename, "w") as vigfile:
+        vig_config.write(vigfile)
+
+    if distance == float("inf"):
+        distance = "∞"
+    with codecs.open(gp_filename, "w", encoding="utf-8") as c:
+        c.write('set grid\n')
+        c.write('set title "%s, %f mm, f/%0.1f, %s m" noenhanced\n' %
+                (exif_data['lens_model'], exif_data['focal_length'],
+                 exif_data['aperture'], distance))
+        c.write('plot "%s" with dots title "samples", ' %
+                all_points_filename)
+        c.write('"%s" with linespoints lw 4 title "average", ' %
+                bins_filename)
+        c.write('%f * (1 + (%f) * x**2 + (%f) * x**4 + (%f) * x**6) title "fit"\n' %
+                (A, k1, k2, k3))
+        c.write('pause -1')
+
+    print(" DONE\n", flush=True)
+
+def init():
+    # Create directory structure
+    dirlist = ['distortion', 'tca', 'vignetting']
+
+    for d in dirlist:
+        if os.path.isfile(d):
+            print("ERROR: '%s' is a file, can't create directory!" % d)
+            return
+        elif not os.path.isdir(d):
+            os.mkdir(d)
+
+    print("The following directory structure has been created in the "
+          "local directory\n\n"
+          "1. distortion - Put RAW file created for distortion in here\n"
+          "2. tca        - Put chromatic abbrevation RAW files in here\n"
+          "3. vignetting - Put RAW files to calculate vignetting in here\n")
+
+def run_distortion():
+    lenses_config_exists = os.path.isfile('lenses.conf')
+    lenses_exif_group = {}
+
+    print('Running distortion corrections ...')
+
+    if not os.path.isdir("distortion"):
+        print("No distortion directory, you have to run init first!")
+        return
+
+    if not os.path.isdir("distortion/exported"):
+        os.mkdir("distortion/exported")
+
+    for path, directories, files in os.walk('distortion'):
+        for filename in files:
+            if path != "distortion":
+                continue
+            if not is_raw_file(filename):
+                continue
+
+            input_file = os.path.join(path, filename)
+            output_file = os.path.join(path, "exported", ("%s.tif" % os.path.splitext(filename)[0]))
+
+            exif_data = image_read_exif(input_file)
+            if exif_data is not None:
+                if exif_data['lens_model'] not in lenses_exif_group:
+                    lenses_exif_group[exif_data['lens_model']] = []
+                lenses_exif_group[exif_data['lens_model']].append(exif_data)
+
+                # Add focal length to file name for easier identification
+                if exif_data['focal_length'] > 1.0:
+                    output_file = os.path.join(path, "exported", ("%s_%dmm.tif" % (os.path.splitext(filename)[0], exif_data['focal_length'])))
+
+            # Convert RAW files to TIF for hugin
+            output_file = convert_raw_for_distortion(input_file, output_file)
+
+    if not lenses_config_exists:
+        sorted_lenses_exif_group = {}
+        for lenses in sorted(lenses_exif_group):
+            # TODO: Remove duplicates?
+            sorted_lenses_exif_group[lenses] = sorted(lenses_exif_group[lenses], key=lambda exif : exif['focal_length'])
+
+        create_lenses_config(sorted_lenses_exif_group)
+
+def run_tca(complex_tca):
+    if not os.path.isdir("tca"):
+        print("No tca directory, you have to run init first!")
+        return
+
+    if not os.path.isdir("tca/exported"):
+        os.mkdir("tca/exported")
+
+    for path, directories, files in os.walk('tca'):
+        for filename in files:
+            if path != "tca":
+                continue
+            if not is_raw_file(filename):
+                continue
+
+            # Convert RAW files to tiff for tca_correct
+            input_file = os.path.join(path, filename)
+
+            exif_data = image_read_exif(input_file)
+
+            output_file = os.path.join(path, "exported", ("%s.ppm" % os.path.splitext(filename)[0]))
+            output_file = convert_raw_for_tca_or_vignetting(input_file, output_file)
+
+            tca_correct(output_file, input_file, exif_data, complex_tca)
+
+def run_vignetting():
+    if not os.path.isdir("vignetting"):
+        print("No tca directory, you have to run init first!")
+        return
+    export_path = os.path.join("vignetting", "exported")
+
+    if not os.path.isdir("vignetting/exported"):
+        os.mkdir("vignetting/exported")
+
+    for path, directories, files in os.walk('vignetting'):
+        for filename in files:
+            distance = float("inf")
+
+            if not is_raw_file(filename):
+                continue
+
+            # Ignore the export path
+            if path == export_path:
+                continue
+
+            if path != "vignetting":
+                d = os.path.basename(path)
+                try:
+                    distance = float(d)
+                except:
+                    continue
+
+            # Convert RAW files to tiff for tca_correct
+            input_file = os.path.join(path, filename)
+
+            # Read EXIF data
+            exif_data = image_read_exif(input_file)
+
+            # Convert the RAW file to ppm
+            output_file = os.path.join(export_path, ("%s.ppm" % os.path.splitext(filename)[0]))
+
+            print("Processing %s ... " % (input_file), flush=True)
+
+            output_file = convert_raw_for_tca_or_vignetting(input_file, output_file)
+
+            # Create vignetting PGM files (grayscale)
+            pgm_file = convert_ppm_for_vignetting(output_file)
+
+            # Calculate vignetting data
+            calculate_vignetting(pgm_file, exif_data, distance)
+
+def run_generate_xml():
+    print("Generating lensfun.xml")
+
+    lenses_config_exists = os.path.isfile('lenses.conf')
+
+    if not lenses_config_exists:
+        print("lenses.conf doesn't exist, run distortion first")
+        return
+
+    # We need maker, model, mount, crop_factor etc.
+    lenses = parse_lenses_config('lenses.conf')
+
+    # Scan tca files and add to lenses
+    for path, directories, files in os.walk('tca/exported'):
+        for filename in files:
+            if os.path.splitext(filename)[1] != '.tca':
+                continue
+
+            config = configparser.ConfigParser()
+            config.read(os.path.join(path, filename))
+
+            for lens_model in config.sections():
+                focal_length = config[lens_model]['focal_length']
+                if not focal_length in lenses[lens_model]['tca']:
+                    lenses[lens_model]['tca'][focal_length] = {}
+
+                for key in config[lens_model]:
+                    if key != 'focal_length':
+                        lenses[lens_model]['tca'][focal_length][key] = config[lens_model][key]
+
+    # Scan vig files and add to lenses
+    for path, directories, files in os.walk('vignetting/exported'):
+        for filename in files:
+            if os.path.splitext(filename)[1] != '.vig':
+                continue
+
+            config = configparser.ConfigParser()
+            config.read(os.path.join(path, filename))
+
+            for lens_model in config.sections():
+                focal_length = config[lens_model]['focal_length']
+                if not focal_length in lenses[lens_model]['vignetting']:
+                    lenses[lens_model]['vignetting'][focal_length] = {}
+
+                aperture = config[lens_model]['aperture']
+                if not aperture in lenses[lens_model]['vignetting'][focal_length]:
+                    lenses[lens_model]['vignetting'][focal_length][aperture] = {}
+
+                distance = config[lens_model]['distance']
+                if not distance in lenses[lens_model]['vignetting'][focal_length][aperture]:
+                    lenses[lens_model]['vignetting'][focal_length][aperture][distance] = {}
+
+                for key in config[lens_model]:
+                    if key != 'focal_length' and key != 'aperture' and key != 'distance':
+                        lenses[lens_model]['vignetting'][focal_length][aperture][distance][key] = config[lens_model][key]
+
+    # write lenses to xml
+    with open('lensfun.xml', 'w') as f:
+        f.write('<lensdatabase>\n')
+        for lens_model in lenses:
+            f.write('    <lens>\n')
+            f.write('        <maker>%s</maker>\n' % lenses[lens_model]['maker'])
+            f.write('        <model>%s</model>\n' % lens_model)
+            f.write('        <mount>%s</mount>\n' % lenses[lens_model]['mount'])
+            f.write('        <cropfactor>%s</cropfactor>\n' % lenses[lens_model]['cropfactor'])
+            if lenses[lens_model]['type'] != 'normal':
+                f.write('        <type>%s</type>\n' % lenses[lens_model]['type'])
+
+            # Add calibration data
+            f.write('        <calibration>\n')
+
+            # Add distortion entries
+            focal_lengths = lenses[lens_model]['distortion'].keys()
+            for focal_length in sorted(focal_lengths, key=float):
+                data = list(map(str.strip, lenses[lens_model]['distortion'][focal_length].split(',')))
+                if data[1] is None:
+                    f.write('           '
+                            '<distortion model="poly3" focal="%s" k1="%s" />\n' %
+                            (focal_length, data[0]))
+                else:
+                    f.write('           '
+                            '<distortion model="ptlens" focal="%s" a="%s" b="%s" c="%s" />\n' %
+                            (focal_length, data[0], data[1], data[2]))
+
+            # Add tca entries
+            focal_lengths = lenses[lens_model]['tca'].keys()
+            for focal_length in sorted(focal_lengths, key=float):
+                data = lenses[lens_model]['tca'][focal_length]
+                if data['complex_tca'] == 'True':
+                    f.write('           '
+                            '<tca model="poly3" focal="%s" br="%s" vr="%s" bb="%s" vb="%s" />\n' %
+                            (focal_length, data['br'], data['vr'], data['bb'], data['vb']))
+                else:
+                    f.write('           '
+                            '<tca model="poly3" focal="%s" vr="%s" vb="%s" />\n' %
+                            (focal_length, data['vr'], data['vb']))
+
+            # Add vignetting entries
+            focal_lengths = lenses[lens_model]['vignetting'].keys()
+            for focal_length in sorted(focal_lengths, key=float):
+                apertures = lenses[lens_model]['vignetting'][focal_length].keys()
+                for aperture in sorted(apertures, key=float):
+                    distances = lenses[lens_model]['vignetting'][focal_length][aperture].keys()
+                    for distance in sorted(distances, key=float):
+                        data = lenses[lens_model]['vignetting'][focal_length][aperture][distance]
+
+                        if distance == 'inf':
+                            distance = '1000'
+
+                        _distances = [ distance ]
+
+                        # If we only have an infinite distance, we need to write two values
+                        if len(distances) == 1 and distance == '1000':
+                            _distances = [ '10', '1000' ]
+
+                        for _distance in _distances:
+                            f.write('           '
+                                    '<vignetting model="pa" focal="%s" aperture="%s" distance="%s" '
+                                    'k1="%s" k2="%s" k3="%s" />\n' %
+                                    (focal_length, aperture, _distance,
+                                     data['k1'], data['k2'], data['k3']))
+
+            f.write('        </calibration>\n')
+            f.write('    </lens>\n')
+        f.write('</lensdatabase>\n')
+
+class CustomDescriptionFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter):
+    pass
+
+def main():
+
+    description = '''
+This is an overview about the calibration steps.\n
+\n
+To setup the required directory structure simply run:
+
+    lens_calibrate.py init
+
+The next step is to copy the RAW files you created to the corresponding
+directories.
+
+Once you have done that run:
+
+    lens_calibrate.py distortion
+
+This will create tiff file you can use to figure out the the lens distortion
+values (a), (b) and (c) using hugin. It will also create a lenses.conf where
+you need to fill out missing values.
+
+If you don't want to do distortion corrections you need to create the
+lenses.conf file manually. It needs to look like this:
+
+    [MODEL NAME]
+    maker =
+    mount =
+    cropfactor =
+    aspect_ratio =
+    type =
+
+The section name needs to be the lens model name you can figure out with:
+
+    exiv2 -g LensModel -pt <raw file>
+
+The required options are:
+
+maker:        is the manufacturer or the lens, e.g. 'FE 16-35mm F2.8 GM'
+mount:        is the name of the mount system, e.g. 'Sony E'
+cropfactor:   is the crop factor of the camera as a float, e.g. '1.0' for full
+              frame
+aspect_ratio: is the aspect ratio of your camera, normally it is '3:2'
+type:         is the type of the lens, e.g. 'normal' for rectilinear lenses.
+              Other possible values are: stereographic, equisolid, stereographic,
+              panoramic or fisheye.
+
+If you want TCA corrections just run:
+
+    lens_calibrate.py tca
+
+If you want vignetting corrections run:
+
+    lens_calibrate.py vignetting
+
+Once you have created data for all corrections you can generate an xml file
+which can be consumed by lensfun. Just call:
+
+    lens_calibrate.py generate_xml
+
+To use the data in your favourite software you just have to copy the generated
+lensfun.xml file to:
+
+    ~/.local/share/lensfun/
+
+Create a bug report or pull request to add the lens to the project at:
+
+https://github.com/lensfun/lensfun/
+
+-----------------------------
+
+'''
+
+    parser = argparse.ArgumentParser(description=description,
+                                     formatter_class=CustomDescriptionFormatter)
+
+    parser.add_argument('--complex-tca',
+                        action='store_true',
+                        help='Turns on non-linear polynomials for TCA')
+    #parser.add_argument('-r, --rawconverter', choices=['darktable', 'dcraw'])
+
+    parser.add_argument('action',
+                        choices=[
+                            'init',
+                            'distortion',
+                            'tca',
+                            'vignetting',
+                            'generate_xml'],
+                        help='This runs one of the actions for lens calibration')
+
+    args = parser.parse_args()
+
+    if args.action == 'init':
+        init()
+    elif args.action == 'distortion':
+        run_distortion()
+    elif args.action == 'tca':
+        run_tca(args.complex_tca)
+    elif args.action == 'vignetting':
+        run_vignetting()
+    elif args.action == 'generate_xml':
+        run_generate_xml()
+
+if __name__ == "__main__":
+    main()
-- 
2.19.1

_______________________________________________
Lensfun-users mailing list
Lensfun-users@lists.sourceforge.net
https://lists.sourceforge.net/lists/listinfo/lensfun-users

Reply via email to