Initial source commit
authorHorshack <redacted>
Thu, 13 Nov 2025 20:29:18 +0000 (15:29 -0500)
committerHorshack <redacted>
Thu, 13 Nov 2025 20:29:18 +0000 (15:29 -0500)
14 files changed:
.gitignore [new file with mode: 0644]
Linux/nefencode.so [new file with mode: 0644]
Mac/nefencode.so [new file with mode: 0644]
Windows/nefencode.so [new file with mode: 0644]
cameras.py [new file with mode: 0644]
createoverlay.py [new file with mode: 0644]
img2nef.py [new file with mode: 0644]
nefencode.c [new file with mode: 0644]
templatenef/Z50II.NEF [new file with mode: 0644]
templatenef/Z6.NEF [new file with mode: 0644]
templatenef/Z6III.NEF [new file with mode: 0644]
templatenef/Z7.NEF [new file with mode: 0644]
templatenef/Z7II.NEF [new file with mode: 0644]
templatenef/Z8.NEF [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..ba0430d
--- /dev/null
@@ -0,0 +1 @@
+__pycache__/
\ No newline at end of file
diff --git a/Linux/nefencode.so b/Linux/nefencode.so
new file mode 100644 (file)
index 0000000..61faf6d
Binary files /dev/null and b/Linux/nefencode.so differ
diff --git a/Mac/nefencode.so b/Mac/nefencode.so
new file mode 100644 (file)
index 0000000..137591c
Binary files /dev/null and b/Mac/nefencode.so differ
diff --git a/Windows/nefencode.so b/Windows/nefencode.so
new file mode 100644 (file)
index 0000000..7b519d4
Binary files /dev/null and b/Windows/nefencode.so differ
diff --git a/cameras.py b/cameras.py
new file mode 100644 (file)
index 0000000..2655d6b
--- /dev/null
@@ -0,0 +1,252 @@
+# Last generated by genNikonCameras.py on 2025-11-01 11:43:20.005343\r
+\r
+#\r
+# cameras.py - Generated file with database of EXIF info from various cameras\r
+#\r
+\r
+# imports\r
+from   typing import Any, Callable, List, NamedTuple, Tuple, Type\r
+from   enum import Enum\r
+\r
+# types\r
+Dimensions = NamedTuple('Dimensions', [('columns', int), ('rows', int)])\r
+EmbeddedJpg = NamedTuple('EmbeddedJpg', [('exifName', str), ('dimensions', Dimensions)])\r
+CameraInfo = NamedTuple('CameraInfo', [('model', str), ('rawDimensions', Dimensions), ('blackLevel', int), ('embeddedJpgs', list)])\r
+\r
+# methods\r
+def getCamera(model: str) -> CameraInfo:\r
+    if model in CameraInfos:\r
+        return CameraInfos[model]\r
+    return None\r
+    \r
+def listCameras() -> None:\r
+    for index, (model, cameraInfo) in enumerate(CameraInfos.items()):\r
+        modelStrPadded = f'"{model}"'.ljust(10)\r
+        firstJpg = cameraInfo.embeddedJpgs[0]\r
+        firstJpgInfoStr = f"JPG: {firstJpg.dimensions.columns}x{firstJpg.dimensions.rows}" if firstJpg else ""\r
+        print(f"{modelStrPadded} RAW: {cameraInfo.rawDimensions.columns}x{cameraInfo.rawDimensions.rows}, {firstJpgInfoStr}")\r
+        \r
+\r
+CameraInfos = dict()\r
+CameraInfos["D3"] = CameraInfo(model="D3", rawDimensions=Dimensions(columns=4288, rows=2844), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4256, rows=2832)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D300"] = CameraInfo(model="D300", rawDimensions=Dimensions(columns=4352, rows=2868), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4288, rows=2848)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D300S"] = CameraInfo(model="D300S", rawDimensions=Dimensions(columns=4352, rows=2868), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4288, rows=2848)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D3S"] = CameraInfo(model="D3S", rawDimensions=Dimensions(columns=4288, rows=2844), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4256, rows=2832)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D3X"] = CameraInfo(model="D3X", rawDimensions=Dimensions(columns=6080, rows=4044), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6048, rows=4032)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D4"] = CameraInfo(model="D4", rawDimensions=Dimensions(columns=4992, rows=3292), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4928, rows=3280)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1632, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D4S"] = CameraInfo(model="D4S", rawDimensions=Dimensions(columns=4936, rows=3288), blackLevel=768,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4928, rows=3280)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D5"] = CameraInfo(model="D5", rawDimensions=Dimensions(columns=5584, rows=3728), blackLevel=400,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=5568, rows=3712)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D500"] = CameraInfo(model="D500", rawDimensions=Dimensions(columns=5600, rows=3728), blackLevel=400,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=5568, rows=3712)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D5100"] = CameraInfo(model="D5100", rawDimensions=Dimensions(columns=4992, rows=3280), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4928, rows=3264)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D5200"] = CameraInfo(model="D5200", rawDimensions=Dimensions(columns=6036, rows=4020), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6000, rows=4000)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D5300"] = CameraInfo(model="D5300", rawDimensions=Dimensions(columns=6016, rows=4016), blackLevel=600,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6000, rows=4000)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D5500"] = CameraInfo(model="D5500", rawDimensions=Dimensions(columns=6016, rows=4016), blackLevel=600,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6000, rows=4000)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D5600"] = CameraInfo(model="D5600", rawDimensions=Dimensions(columns=6016, rows=4016), blackLevel=600,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6000, rows=4000)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D6"] = CameraInfo(model="D6", rawDimensions=Dimensions(columns=5584, rows=3728), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=5568, rows=3712)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D600"] = CameraInfo(model="D600", rawDimensions=Dimensions(columns=6080, rows=4028), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6016, rows=4016)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1632, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D610"] = CameraInfo(model="D610", rawDimensions=Dimensions(columns=6080, rows=4028), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6016, rows=4016)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1632, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D700"] = CameraInfo(model="D700", rawDimensions=Dimensions(columns=4288, rows=2844), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4256, rows=2832)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D7000"] = CameraInfo(model="D7000", rawDimensions=Dimensions(columns=4992, rows=3280), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4928, rows=3264)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D7100"] = CameraInfo(model="D7100", rawDimensions=Dimensions(columns=6036, rows=4020), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6000, rows=4000)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D7200"] = CameraInfo(model="D7200", rawDimensions=Dimensions(columns=6016, rows=4016), blackLevel=600,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6000, rows=4000)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D750"] = CameraInfo(model="D750", rawDimensions=Dimensions(columns=6032, rows=4032), blackLevel=600,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6016, rows=4016)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D7500"] = CameraInfo(model="D7500", rawDimensions=Dimensions(columns=5600, rows=3728), blackLevel=400,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=5568, rows=3712)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D780"] = CameraInfo(model="D780", rawDimensions=Dimensions(columns=6064, rows=4040), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6048, rows=4024)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D800"] = CameraInfo(model="D800", rawDimensions=Dimensions(columns=7424, rows=4924), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=7360, rows=4912)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1632, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D800E"] = CameraInfo(model="D800E", rawDimensions=Dimensions(columns=7424, rows=4924), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=7360, rows=4912)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1632, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["D810"] = CameraInfo(model="D810", rawDimensions=Dimensions(columns=7380, rows=4928), blackLevel=600,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=7360, rows=4912)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D810A"] = CameraInfo(model="D810A", rawDimensions=Dimensions(columns=7380, rows=4928), blackLevel=600,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=7360, rows=4912)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["D850"] = CameraInfo(model="D850", rawDimensions=Dimensions(columns=8288, rows=5520), blackLevel=400,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=8256, rows=5504)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["DF"] = CameraInfo(model="DF", rawDimensions=Dimensions(columns=4992, rows=3292), blackLevel=0,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=4928, rows=3280)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=570, rows=375)),\r
+])\r
+CameraInfos["Z30"] = CameraInfo(model="Z30", rawDimensions=Dimensions(columns=5600, rows=3728), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=5568, rows=3712)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["Z5"] = CameraInfo(model="Z5", rawDimensions=Dimensions(columns=6040, rows=4032), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6016, rows=4016)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["Z50"] = CameraInfo(model="Z50", rawDimensions=Dimensions(columns=5600, rows=3728), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=5568, rows=3712)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["Z5II"] = CameraInfo(model="Z5II", rawDimensions=Dimensions(columns=6064, rows=4040), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6048, rows=4032)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+        EmbeddedJpg(exifName="Thumbnail", dimensions=Dimensions(columns=160, rows=120)),\r
+])\r
+CameraInfos["Z6II"] = CameraInfo(model="Z6II", rawDimensions=Dimensions(columns=6064, rows=4040), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6048, rows=4024)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["Z6III"] = CameraInfo(model="Z6III", rawDimensions=Dimensions(columns=6064, rows=4040), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6048, rows=4032)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+        EmbeddedJpg(exifName="Thumbnail", dimensions=Dimensions(columns=160, rows=120)),\r
+])\r
+CameraInfos["Z7II"] = CameraInfo(model="Z7II", rawDimensions=Dimensions(columns=8288, rows=5520), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=8256, rows=5504)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["Z8"] = CameraInfo(model="Z8", rawDimensions=Dimensions(columns=8280, rows=5520), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=8256, rows=5504)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+        EmbeddedJpg(exifName="Thumbnail", dimensions=Dimensions(columns=160, rows=120)),\r
+])\r
+CameraInfos["Z9"] = CameraInfo(model="Z9", rawDimensions=Dimensions(columns=8280, rows=5520), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=8256, rows=5504)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+        EmbeddedJpg(exifName="Thumbnail", dimensions=Dimensions(columns=160, rows=120)),\r
+])\r
+CameraInfos["ZF"] = CameraInfo(model="ZF", rawDimensions=Dimensions(columns=6064, rows=4040), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6048, rows=4032)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+        EmbeddedJpg(exifName="Thumbnail", dimensions=Dimensions(columns=160, rows=120)),\r
+])\r
+CameraInfos["ZFC"] = CameraInfo(model="ZFC", rawDimensions=Dimensions(columns=5600, rows=3728), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=5568, rows=3712)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["Z50II"] = CameraInfo(model="Z50II", rawDimensions=Dimensions(columns=5600, rows=3728), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=5568, rows=3712)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+        EmbeddedJpg(exifName="Thumbnail", dimensions=Dimensions(columns=160, rows=120)),\r
+])\r
+CameraInfos["Z6"] = CameraInfo(model="Z6", rawDimensions=Dimensions(columns=6064, rows=4040), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=6048, rows=4024)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+CameraInfos["Z7"] = CameraInfo(model="Z7", rawDimensions=Dimensions(columns=8288, rows=5520), blackLevel=1008,embeddedJpgs=[\r
+        EmbeddedJpg(exifName="JpgFromRaw", dimensions=Dimensions(columns=8256, rows=5504)),\r
+        EmbeddedJpg(exifName="OtherImage", dimensions=Dimensions(columns=1620, rows=1080)),\r
+        EmbeddedJpg(exifName="PreviewImage", dimensions=Dimensions(columns=640, rows=424)),\r
+])\r
+\r
+if __name__ == "__main__":\r
+    listCameras()\r
+\r
diff --git a/createoverlay.py b/createoverlay.py
new file mode 100644 (file)
index 0000000..af0ae9d
--- /dev/null
@@ -0,0 +1,1055 @@
+#!/usr/bin/env python3\r
+"""\r
+createoverlay.py\r
+This app generates a camera viewfinder/LCD overlay image that contains customing framing guides (lines) and shooting grids.\r
+The overlay image can be then passed into img2nef to genereat raw images to use as custom viewfinder overlays on Nikon cameras\r
+via the multiple-exposure overlay feature.\r
+\r
+To draw a framelines/gridlines this app needs two sets of dimensions - raw and jpg. Both are automatically\r
+generated if the user passes in a camera model listed in cameras.py. If a model is not specified or\r
+supported then the dimensions must be provided manually via the --dimensions option.\r
+\r
+The raw dimensions determine the size of the generated image and should match the camera's EXIF raw\r
+dimensions, which can be obtained via the ImageWidth and ImageHeight fields. The jpg dimensions\r
+determine which part of the raw image we draw on to. Most cameras have a slight crop from\r
+raw -> jpg, with the crop implemented as borders on all sides of the full raw dimensions\r
+"""\r
+\r
+#\r
+# verify python version early, before executing any logic that relies on features not available in all versions\r
+#\r
+import sys\r
+if (sys.version_info.major < 3) or (sys.version_info.minor < 10):\r
+    print("Requires Python v3.10 or later but you're running v{}.{}.{}".format(sys.version_info.major, sys.version_info.minor, sys.version_info.micro))\r
+    sys.exit(1)\r
+\r
+#\r
+# imports\r
+#\r
+import argparse\r
+import cameras\r
+from   enum import Enum\r
+import importlib\r
+import math\r
+import os\r
+import platform\r
+import re\r
+import subprocess\r
+import sys\r
+import types\r
+from   typing import Any, Callable, List, NamedTuple, Tuple, Type\r
+\r
+#\r
+# types\r
+#\r
+class IfFileExists(Enum): ADDSUFFIX=0; OVERWRITE=1; EXIT=2\r
+class Verbosity(Enum): SILENT=0; WARNING=1; INFO=2; VERBOSE=3; DEBUG=4\r
+class LineWidthExpansion(Enum): EXPAND_CENTERED=0; EXPAND_RIGHT_DOWN=1; EXPAND_LEFT_UP=2;\r
+class VertPos(Enum): TOP=0; CENTER=1; BOTTOM=2\r
+class HorzPos(Enum): LEFT=0; CENTER=1; RIGHT=2\r
+Dimensions = NamedTuple('Dimensions', [('columns', int), ('rows', int)])\r
+RGB = NamedTuple('RGB', [('red', int), ('green', int), ('blue', int)])\r
+DashedLine = NamedTuple('DashedLine', [('dashLength', int), ('dashGap', int)])\r
+LabelPos = NamedTuple('LabelPos', [('vertPos', VertPos), ('horzPos', HorzPos)])\r
+Frameline = NamedTuple('Frameline', [('aspectRatio', Dimensions), ('lineWidth', int), ('dashedLine', DashedLine), ('lineColor', RGB), ('fillColor', RGB), ('labelPos', LabelPos)])\r
+Gridline = NamedTuple('Gridline', [('gridDimensions', Dimensions), ('aspectRatio', Dimensions), ('lineWidth', int), ('dashedLine', DashedLine), ('lineColor', RGB)])\r
+\r
+#\r
+# module data\r
+#\r
+AppName = "createoverlay"\r
+AppVersion = "1.00"\r
+Config = types.SimpleNamespace()\r
+IfFileExistsStrs = [x.name for x in IfFileExists]\r
+VerbosityStrs = [x.name for x in Verbosity]\r
+VertPosStrs = [x.name for x in VertPos]\r
+HorzPosStrs = [x.name for x in HorzPos]\r
+\r
+\r
+#\r
+# verify all optional modules we need are installed before we attempt to import them.\r
+# this allows us to display a user-friendly message for the missing modules instead of the\r
+# python-generated error message for missing imports\r
+#\r
+if __name__ == "__main__":\r
+    RequiredModule = NamedTuple('RequiredModule', [('importName', str), ('pipInstallName', str)])\r
+    def verifyRequiredModulesInstalled():\r
+        requiredModules = [\r
+            RequiredModule(importName="PIL", pipInstallName="pillow"),\r
+        ]\r
+        missingModules = list()\r
+        for requiredModule in requiredModules:\r
+            try:\r
+                importlib.import_module(requiredModule.importName)\r
+            except ImportError:\r
+                missingModules.append(requiredModule)\r
+        if missingModules:\r
+            print(f"Run the following commands to install required modules before using {AppName}:\n")\r
+            for requiredModule in missingModules:\r
+                print(f"\tpip install {requiredModule.pipInstallName}")\r
+            sys.exit(1)\r
+\r
+    verifyRequiredModulesInstalled()\r
+\r
+\r
+#\r
+# import optional modules now we've established they're available\r
+#\r
+from   PIL import Image, ImageDraw, ImageFont\r
+\r
+\r
+#\r
+# methods to handle conditional printing based on user-specified verbosity level\r
+#\r
+def isVerbose() -> bool:\r
+    return Config.args.verbosity.value >= Verbosity.VERBOSE.value\r
+def printA(string: str): # print "always"\r
+    print(string)\r
+def printIfVerbosityAllows(string: str, requiredVerbosityLevel: Verbosity) -> None:\r
+    if hasattr(Config, "args"):\r
+        if Config.args.verbosity.value >= requiredVerbosityLevel.value:\r
+            printA(string)\r
+    else:\r
+        # called before we've initialized Config.args\r
+        printA(string)\r
+def printE(string: str): # print error\r
+    printA(f"ERROR: {string}")\r
+def printW(string: str): # print warnings, if verbosity config allows\r
+    printIfVerbosityAllows(f"WARNING: {string}", Verbosity.WARNING)\r
+def printI(string: str): # print "informational" messages, if verbosity config allows\r
+    printIfVerbosityAllows(f"INFO: {string}", Verbosity.INFO)\r
+def printV(string: str): # print "verbose" messages, if verbosity config allows\r
+    printIfVerbosityAllows(f"VERBOSE: {string}", Verbosity.VERBOSE)\r
+def printD(string: str): # print "debug" messages, if verbosity config allows\r
+    printIfVerbosityAllows(f"DEBUG: {string}", Verbosity.DEBUG)\r
+\r
+\r
+def getScriptDir() -> str:\r
+\r
+    """\r
+    Returns absolute path to the directory this script is running in\r
+\r
+    :return: Absolute dirctory\r
+    """\r
+\r
+    return os.path.dirname(os.path.realpath(__file__))\r
+\r
+\r
+def openFileInOS(filename: str) -> bool:\r
+\r
+    """\r
+    Opens a file in the OS's default viewer/editor/handler for file\r
+\r
+    :param filename: Filename to open\r
+    :return: False if successful, TRUE if error\r
+    """\r
+    printV(f"Opening \"{os.path.realpath(filename)}\" in default system image viewer")\r
+    try:\r
+        if platform.system() == "Windows":\r
+            os.startfile(filename)\r
+        else: # both Linux and Darwin (aka Mac) use "open"\r
+            subprocess.call(['open', filename])\r
+    except Exception as e:\r
+        printW(f"Unable to open file \"{filename}\" in your OS's file viewer, error: {e}")\r
+        return True\r
+    return False\r
+\r
+\r
+def processCmdLine() -> argparse.Namespace:\r
+\r
+    """\r
+    Processes the command line\r
+\r
+    :return: False if successful, True if error\r
+    """\r
+\r
+    # custom ArgumentParser that throws exception on parsing error\r
+    class ArgumentParserError(Exception): pass # from http://stackoverflow.com/questions/14728376/i-want-python-argparse-to-throw-an-exception-rather-than-usage\r
+    class ArgumentParserWithException(argparse.ArgumentParser):\r
+        def error(self, message):\r
+            raise ArgumentParserError(message)\r
+\r
+    # converts string value like "True", "T", "No", etc... to boolean\r
+    def strValueToBool(string: str) -> bool:\r
+        if string is None:\r
+            return True\r
+        if string.upper() in ['1', 'TRUE', 'T', 'YES', 'Y']:\r
+            return True\r
+        if string.upper() in ['0', 'FALSE', 'F', 'NO', 'N']:\r
+            return False\r
+        raise argparse.ArgumentTypeError(f"Boolean value expected but '{string}' was specified")\r
+\r
+    def strDimensionsToDimensions(string: str) -> Dimensions:\r
+        dimensionStrs = string.split('x')\r
+        if len(dimensionStrs) != 2:\r
+            printE(f"Dimension ust be specified as a pair, ex: 6000x4000, but \"{string}\" was specified instead.")\r
+            return None\r
+        columnStr, rowStr = dimensionStrs\r
+        if (not columnStr.isdigit()) or (not rowStr.isdigit()):\r
+            printE(f"Dimensions must be in the format of \"columns x rows\" (ex: \"6000x4000\") but {string} was specified instead.")\r
+            return None\r
+        return Dimensions(columns=int(columnStr), rows=int(rowStr))\r
+\r
+    def parseDelimitedString(string: str, delimiter: str, numValues: int, descForError: str):\r
+        if (not string) or (string == 'none'):\r
+            return False, None\r
+        string = string.replace(" ", "") # remove all spaces before parsing\r
+        strList = string.split(delimiter)\r
+        if len(strList) != numValues:\r
+            printE(f"{descForError} but \"{string}\" was specified instead.")\r
+            return True, None\r
+        return False, strList\r
+\r
+    def convertDelimitedIntValueString(string: str, delimiter: str, numValues: int, descForError: str) -> tuple[bool, List]:\r
+        fConversionError, strList = parseDelimitedString(string, delimiter, numValues, descForError)\r
+        if fConversionError or (not strList):\r
+            return fConversionError, strList\r
+        if not all(s.isdigit() for s in strList):\r
+            printE(f"{descForError} and values must be valid integer digits [0-9] but \"{string}\" was specified instead.")\r
+            return True, None\r
+        valueList = [int(s) for s in strList]\r
+        return False, valueList\r
+\r
+    def convertRgbStr(rgbHexStr: str) -> tuple[bool, RGB]:\r
+        if (not rgbHexStr) or (rgbHexStr == 'none'):\r
+            return False, None\r
+        m = re.search(r"#([0-9a-f][0-9a-f])([0-9a-f][0-9a-f])([0-9a-f][0-9a-f])", rgbHexStr)\r
+        if not m:\r
+            printE(f"RGB must be in the form of #RRGGBB, where RGB are hex digits. \"{rgbHexStr}\" was specified instead.")\r
+            return True, None\r
+        return False, RGB(int(m.group(1), 16), int(m.group(2), 16), int(m.group(3), 16))\r
+\r
+    def convertRatioStr(string: str, ratioDesc: str) -> tuple[bool, Dimensions]:\r
+        fConversionError, valueList = convertDelimitedIntValueString(string=string, delimiter=":", numValues=2, descForError=f"{ratioDesc} must be in the form of width:height")\r
+        if fConversionError or not valueList:\r
+            return fConversionError, valueList\r
+        return False, Dimensions(columns=valueList[0], rows=valueList[1])\r
+\r
+    def convertDimensionsStr(string: str, dimensionsDesc: str) -> tuple[bool, Dimensions]:\r
+        fConversionError, valueList = convertDelimitedIntValueString(string=string, delimiter="x", numValues=2, descForError=f"{dimensionsDesc} must be in the form of columns x rows")\r
+        if fConversionError or not valueList:\r
+            return fConversionError, valueList\r
+        return False, Dimensions(columns=valueList[0], rows=valueList[1])\r
+\r
+    def convertLineWidthStr(string: str) -> tuple[bool, int]:\r
+        if (not string) or (string == 'none'):\r
+            return False, None\r
+        try:\r
+            value = int(string)\r
+        except:\r
+            printE(f"Line width must be valid integer digits [0-9] but \"{string}\" was specified instead.")\r
+            return True, None\r
+        return False, value\r
+\r
+    def convertDashedLineStr(string: str) -> tuple[bool, DashedLine]:\r
+        fConversionError, dashedLineValues = convertDelimitedIntValueString(string, delimiter="-", numValues=2, descForError="Dashed line must be in the form of line_length-gap_length")\r
+        if fConversionError or not dashedLineValues:\r
+            return fConversionError, dashedLineValues\r
+        dashLength = dashedLineValues[0]\r
+        dashGap = dashedLineValues[1]\r
+        if dashLength <= 0:\r
+            printE(f"Dot length value must be >= 0 but \"{string}\" was specified instead.")\r
+            return True, None\r
+        return False, DashedLine(dashLength=dashLength, dashGap=dashGap)\r
+\r
+    def convertColorStr(string: str) -> tuple[bool, RGB]:\r
+        return convertRgbStr(string)\r
+\r
+    def convertLabelPosStr(string: str) -> tuple[bool, LabelPos]:\r
+        fConversionError, strList = parseDelimitedString(string.upper(), '-', 2, "Label position must be in the form of vertdesc:horzdesc")\r
+        if fConversionError or (not strList):\r
+            return fConversionError, strList\r
+        vertPosStr, horzPosStr = strList\r
+        if vertPosStr not in VertPosStrs:\r
+            printE(f"Invalid vertical position: Valid values are: {VertPosStrs}")\r
+            return True, None\r
+        if horzPosStr not in HorzPosStrs:\r
+            printE(f"Invalid horizontal position: Valid values are: {HorzPosStrs}")\r
+            return True, None\r
+        labelPos = LabelPos(vertPos=VertPos[vertPosStr], horzPos=HorzPos[horzPosStr])\r
+        return False, labelPos\r
+\r
+    def parseAttributeValuePairs(attributeValueStr: str, attributeOrderForUnamedFields: List, optionDescStr: str):\r
+\r
+        # initialize dict with None for all values, to handle the case of values not specified in 'attributeValueStr'\r
+        attributeValueDict = dict()\r
+        for attributeStr in attributeOrderForUnamedFields:\r
+            attributeValueDict[attributeStr] = None\r
+\r
+        attributeValueListStr = attributeValueStr.split(',')\r
+        indexForUnamedField = 0\r
+        for index, attributeAndValueStr in enumerate(attributeValueListStr):\r
+            if indexForUnamedField >= len(attributeOrderForUnamedFields):\r
+                printE(f"Too many values specified for \"{optionDescStr}\" \"{attributeValueStr}\"")\r
+                return None\r
+            if '=' in attributeAndValueStr:\r
+                attributeStr, valueStr = attributeAndValueStr.split('=')\r
+            else:\r
+                attributeStr = attributeOrderForUnamedFields[indexForUnamedField]\r
+                valueStr = attributeAndValueStr\r
+            attributeStr = attributeStr.lower()\r
+            if attributeStr not in attributeOrderForUnamedFields:\r
+                printE(f"Uknown or unexpected attribute \"{attributeStr}\" for \"{optionDescStr}\" \"{attributeValueStr}\"")\r
+                return None\r
+            match attributeStr:\r
+                case "aspectratio":\r
+                    fConversionError, value = convertRatioStr(valueStr, "Aspect ratio")\r
+                case "griddimensions":\r
+                    fConversionError, value = convertDimensionsStr(valueStr, "Grid dimensions")\r
+                case "linewidth":\r
+                    fConversionError, value = convertLineWidthStr(valueStr)\r
+                case "dashedline":\r
+                    fConversionError, value = convertDashedLineStr(valueStr)\r
+                case "linecolor":\r
+                    fConversionError, value = convertColorStr(valueStr)\r
+                case "fillcolor":\r
+                    fConversionError, value = convertColorStr(valueStr)\r
+                case "labelpos":\r
+                    fConversionError, value = convertLabelPosStr(valueStr)\r
+            if fConversionError:\r
+                return None\r
+            attributeValueDict[attributeStr] = value\r
+            # if user specified attribute by name, then next (possibly unamed) attribute we expect after is the one after the attribute user specified (orderi in attributeOrderForUnamedFields)\r
+            indexForUnamedField = attributeOrderForUnamedFields.index(attributeStr)+1\r
+        return attributeValueDict\r
+\r
+    # arg parser that throws exceptions on errors\r
+    parser = ArgumentParserWithException(fromfile_prefix_chars='!',\\r
+        formatter_class=argparse.RawDescriptionHelpFormatter,\r
+        description="""Generates custom camera frame and gridline display images, which can be passed into img2nef to generate an NEF to use with Nikon's multiple-exposure overlay feature for custom in-camera viewfinder overlays. (Written by Horshack)""",\r
+        epilog="Options can also be specified from a file. Use !<filename>. Each word in the file must be on its own line.\n\nYou "\\r
+            "can abbreviate any argument name provided you use enough characters to uniquely distinguish it from other argument names.\n")\r
+\r
+    parser.add_argument('camera', metavar="model", nargs='?', type=str.upper, default=None, help="""Build for a predefined camera model. Use --list-cameras to see models supported. If your model isn't\r
+        supported then you'll need to manually specify the raw and jpg dimensions via --dimensions""")\r
+    parser.add_argument('--dimensions', metavar="raw columns x rows, jpg columns x rows", type=str.lower, required=False, action='append', help="Manually specify dimensions (typically when camera model isn't specified). Ex: --dimensions 6048x4032,6000x4000")\r
+    parser.add_argument('--backgroundcolor', dest='backgroundColor', metavar="#RRGGBB", type=str, default='#000000', help="Backround color for entire overlay in #RRGGBB. Ex: --backgroundcolor #ff0000. Default is %(default)s.")\r
+    parser.add_argument('--frameline', metavar="aspectratio=columns:rows,[linewidth=#pixels],[dashedline=dashlen-gaplen,[linecolor=#RRGGBB],[fillcolor=#RRGGBB],[labelpos=vert-horz]", type=str.lower, required=False, action='append',\r
+        help="""Multiple framelines can be specified. Ex: --frameline 16:9,32,50-100,#ff0000,#000000,bottom-left. Creates 16:9 frameline, line width 32, dashed line segments of 50 pixels with 100 pixel gaps,\r
+        line color of red, fill color of black.\r
+        You can empty fields to skip values - default values are used for skipped fields. Ex: --frameline 16:9,,,#ff0000. Creates 16:9 frameline with default line width and dashed spec, line color of red, default fill color.""")\r
+    parser.add_argument('--gridline',  metavar="griddimensions=columns x rows,[aspectratio=columns:rows],[linewidth=pixels],[dashedline=dashlen-gaplen],[linecolor=#RRGGBB]", type=str.lower, required=False, action='append',\r
+        help="""Multiple gridlines can be specified. Ex: --gridline 4x4,16:9,32,none,#ff0000 - Draws a grid of 4 columns and rows inside a 16:9 aspect ratio area, line width of 32, no dashed lines, line color of red.\r
+        Use empty fields to skip value and use default for the value instead.""")\r
+    parser.add_argument('--labelpos', dest='labelPos', metavar="vertpos-horzpos", type=str.upper, default="BOTTOM-LEFT", help="""The position of the aspect-ratio label.\r
+        'vertpos' values are TOP, CENTER, BOTTOM. 'horzpos' values are LEFT, CENTER, RIGHT. Specify "none" for no label. Ex: --labelpos TOP-RIGHT. Default is %(default)s.""")\r
+    parser.add_argument('--fontsizepct', dest='fontSizePct', metavar="Font size as %", type=str.lower, default="5", help="Font size as percentage of raw height. Default is %(default)s%%.")\r
+    parser.add_argument('--outputfilename', metavar="<filename>", help="""Optional - If not specified then the output filename will be generated based on the frame and gridlines specified.\r
+        If specified and includes a path and --outputdir is also specified then path is replaced by --outputdir.""")\r
+    parser.add_argument('--outputdir', type=str, metavar="path", help="Directory to store generated output.  Default is current directory. If path contains any spaces enclose it in double quotes. Example: --outputdir \"c:\\My Documents\"", default=None, required=False)\r
+    parser.add_argument('--imagetype', dest='imageTypeExtension', type=str, metavar="extension", default="PNG", required=False, help="Image type, specified via extension. Default is \"%(default)s\".")\r
+    parser.add_argument('--openinviewer', dest='openInViewer', type=strValueToBool, nargs='?', default=True, const=True, metavar="yes/no", help="Open in default image viewer/editor after creating. Default is %(default)s.")\r
+    parser.add_argument('--ifexists', type=str.upper, choices=IfFileExistsStrs, default='ADDSUFFIX', required=False, help="""Action to take if an output file already exists. Default is \"%(default)s\",\r
+        which means a suffix is added to the output filename to create a unique filename.""")\r
+\r
+    defaultDrawOptions = parser.add_argument_group("Default Draw Options", "Default drawing options when not specified in --frameline and --gridline.")\r
+    defaultDrawOptions.add_argument('--linewidth', dest='lineWidth', metavar="pixels", type=str.lower, default="32", help="Line width. Default is %(default)s.")\r
+    defaultDrawOptions.add_argument('--dashedline', dest='dashedLine', metavar="dashlen-gaplen", type=str.lower, default=None, help="""Line dashing . Each segment drawn\r
+        for 'dashlen' pixels and 'gaplen' empty pixels between each segment. Specify \"NONE\" for solid lines. Default is %(default)s.\r
+        Ex: --dashedline=10-50. Creates a dashed line with solid segments of 10 pixels and gaps of 50 pixels.""")\r
+    defaultDrawOptions.add_argument('--linecolor', dest='lineColor', metavar="#RRGGBB", type=str.lower, default='#FFFFFF', help="Line color. Default is %(default)s.")\r
+    defaultDrawOptions.add_argument('--fillcolor', dest='fillColor', metavar="#RRGGBB", type=str.lower, default=None, help="Fill color. Default is %(default)s.")\r
+\r
+    parser.add_argument('--generatenef', dest='generatenef', type=strValueToBool, nargs='?', default=True, const=True, metavar="yes/no", help="Generate NEF with img2nef after creating overlay. Default is %(default)s.")\r
+\r
+    parser.add_argument('--verbosity', type=str.upper, choices=VerbosityStrs, default="INFO", required=False, help="How much information to print during execution.. Default is %(default)s.")\r
+    parser.add_argument('--list-cameras', dest='fListCameras', action='store_true', help="Show list of predefined camera models supported")\r
+\r
+    if len(sys.argv) == 1:\r
+        # print help if no parameters passed\r
+        parser.print_help()\r
+        return None\r
+\r
+       #\r
+       # if there is a default arguments file present, add it to the argument list so that parse_args() will process it\r
+       #\r
+    defaultOptionsFilename = os.path.join(getScriptDir(), f".{AppName}-defaultoptions")\r
+    if os.path.isfile(defaultOptionsFilename):\r
+        sys.argv.insert(1, "!" + defaultOptionsFilename) # insert as first arg (past script name), so that the options in the file can still be overriden by user-entered cmd line options\r
+\r
+    # perform the argparse\r
+    try:\r
+        args = parser.parse_args()\r
+    except ArgumentParserError as e:\r
+        print("Command line error: " + str(e))\r
+        return None\r
+\r
+    if args.fListCameras:\r
+        printA(f"Camera models supported:")\r
+        cameras.listCameras()\r
+        sys.exit(0)\r
+\r
+    # do post-processing/conversion of args\r
+\r
+    if args.camera and args.dimensions:\r
+        printW(f"Both a camera model and manual dimensions were provided. The values for --dimensions will be used.")\r
+\r
+    if args.generatenef and not args.camera:\r
+        printE(f"A camera model must be specified when --generatenef is used")\r
+        return None\r
+\r
+    if args.dimensions:\r
+        # ex: "6048x4032,6000x4000"\r
+        dimensionStrs = args.dimensions[0].split(',')\r
+        if len(dimensionStrs) != 2:\r
+            printE("--dimensions must be specified as a pair, the 1st for raw and 2nd for jpg. ex: --dimensions 6048x4032,6000x4000")\r
+            return None\r
+        args.rawDimensions = strDimensionsToDimensions(dimensionStrs[0])\r
+        if not args.rawDimensions:\r
+            return None\r
+        args.jpgDimensions = strDimensionsToDimensions(dimensionStrs[1])\r
+        if not args.jpgDimensions:\r
+            return None\r
+    else:\r
+        if not args.camera:\r
+            printE("Dimensions of image must be specified, either implicitly by a camera model or --dimensions")\r
+            return None\r
+\r
+    fConversionError, args.lineWidth = convertLineWidthStr(args.lineWidth)\r
+    if fConversionError:\r
+        return None\r
+\r
+    fConversionError, args.dashedLine = convertDashedLineStr(args.dashedLine)\r
+    if fConversionError:\r
+        return None\r
+\r
+    fConversionError, args.lineColor = convertRgbStr(args.lineColor)\r
+    if fConversionError:\r
+        return None\r
+\r
+    fConversionError, args.fillColor = convertRgbStr(args.fillColor)\r
+    if fConversionError:\r
+        return None\r
+\r
+    fConversionError, args.backgroundColor = convertRgbStr(args.backgroundColor)\r
+    if fConversionError:\r
+        return None\r
+\r
+    fConversionError, args.labelPos = convertLabelPosStr(args.labelPos)\r
+    if fConversionError:\r
+        return None\r
+\r
+    #\r
+    # convert --frameline entries into a Frameline list. Note we do this after processing/conversion\r
+    # of all the default values, since we'll potentially reference those\r
+    #\r
+    args.framelines = list()\r
+    if args.frameline:\r
+        for framelineArgStr in args.frameline:\r
+            attributeValueDict = parseAttributeValuePairs(framelineArgStr, ["aspectratio", "linewidth", "dashedline", "linecolor", "fillcolor", "labelpos"], "--frameline")\r
+            if not attributeValueDict:\r
+                return None\r
+            if not attributeValueDict['aspectratio']:\r
+                printE(f"Aspect ratio must be specified for --frameline")\r
+                return None\r
+            lineWidth = attributeValueDict['linewidth'] if (attributeValueDict['linewidth'] is not None) else args.lineWidth\r
+            dashedLine = attributeValueDict['dashedline'] if (attributeValueDict['dashedline'] is not None) else args.dashedLine\r
+            lineColor = attributeValueDict['linecolor'] if (attributeValueDict['linecolor'] is not None) else args.lineColor\r
+            fillColor = attributeValueDict['fillcolor'] if (attributeValueDict['fillcolor'] is not None) else args.fillColor\r
+            labelPos = attributeValueDict['labelpos'] if (attributeValueDict['labelpos'] is not None) else args.labelPos\r
+            args.framelines.append(Frameline(aspectRatio=attributeValueDict['aspectratio'], lineWidth=lineWidth, dashedLine=dashedLine, lineColor=lineColor, fillColor=fillColor, labelPos=labelPos))\r
+\r
+    #\r
+    # convert --gridline entries into a Gridline list. Note we do this after processing/conversion\r
+    # of all the default values, since we'll potentially reference those\r
+    #\r
+    args.gridlines = list()\r
+    if args.gridline:\r
+        for gridlineArgStr in args.gridline:\r
+            attributeValueDict = parseAttributeValuePairs(gridlineArgStr, ["griddimensions", "aspectratio", "linewidth", "dashedline", "linecolor"], "--gridline")\r
+            if not attributeValueDict:\r
+                return None\r
+            if not attributeValueDict['griddimensions']:\r
+                printE(f"Grid dimensions must be specified for --gridline")\r
+                return None\r
+            lineWidth = attributeValueDict['linewidth'] if (attributeValueDict['linewidth'] is not None) else args.lineWidth\r
+            dashedLine = attributeValueDict['dashedline'] if (attributeValueDict['dashedline'] is not None) else args.dashedLine\r
+            lineColor = attributeValueDict['linecolor'] if (attributeValueDict['linecolor'] is not None) else args.lineColor\r
+\r
+            args.gridlines.append(Gridline(gridDimensions=attributeValueDict['griddimensions'], aspectRatio= attributeValueDict['aspectratio'],\r
+                lineWidth=lineWidth, dashedLine=dashedLine, lineColor=lineColor))\r
+\r
+    if len(args.framelines) == 0 and len(args.gridlines) == 0:\r
+        printE("At least one --frameline or --gridline must be specified.")\r
+        sys.exit(1)\r
+\r
+    # process font size\r
+    fontSizePctStr = args.fontSizePct.rstrip("%")\r
+    try:\r
+        fontSizePct = float(fontSizePctStr)\r
+        if fontSizePct < 0 or fontSizePct > 100:\r
+            raise ValueError("")\r
+    except:\r
+        printE(f"--fontsizepct value specified \"{fontSizePctStr}\" is invalid. It must be a number from 0 to 100. Decimal values are allowed.")\r
+        return None\r
+    args.fontSizePct = fontSizePct/100\r
+\r
+    # convert from strs to enumerated values\r
+    args.ifexists = IfFileExists[args.ifexists]\r
+    args.verbosity = Verbosity[args.verbosity]\r
+\r
+    return args\r
+\r
+\r
+def runImg2nef(overlayFilename: str):\r
+\r
+    """\r
+    Executes img2nef to generate an NEF with the overlay we just created\r
+\r
+    :param overlayFilename: Path to overlay file\r
+    :return: False if successful, True if error\r
+    """\r
+\r
+    outputFilename = generateFilenameWithDifferentExtensionAndDir(overlayFilename, Config.args.outputdir, "NEF")\r
+    try:\r
+        import img2nef\r
+    except Exception as e:\r
+        printE(f"Unable to import the img2nef.py module needed to generate an NEF: {e}")\r
+        return True\r
+    argvSaved = sys.argv\r
+    sys.argv = ['img2nef.py', Config.args.camera, overlayFilename, f'{outputFilename}', '--src.hsl=1.0,1.0,1.0', f'--verbosity={Config.args.verbosity.name}']\r
+    if Config.args.outputdir:\r
+        # even though 'outputFilename' includes a possible outputdir, specify again in case there's a different default included in a .options file for img2nef\r
+        sys.argv.append(f'--outputdir={Config.args.outputdir}')\r
+    printV(f"Calling img2nef to generate NEF, args: {sys.argv}")\r
+    fimg2nefError = img2nef.run()\r
+    if fimg2nefError:\r
+        printE(f"Attempt to generate NEF with 'img2nef' failed")\r
+    sys.argv = argvSaved\r
+    return fimg2nefError\r
+\r
+\r
+def splitPathIntoParts(fullPath: str) -> tuple[str, str, str]:\r
+\r
+    """\r
+    Splits path into parts (directory, root filename, and extension)\r
+\r
+    :param fullPath: Full path to split\r
+    :return: Tuple containing (directory, root filename, extension)\r
+    """\r
+\r
+    dir, filename = os.path.split(fullPath)\r
+    root, ext = os.path.splitext(filename)\r
+    return (dir, root, ext)\r
+\r
+\r
+def generateFilenameWithDifferentExtensionAndDir(fullPath: str, newDir: str, newExt: str) -> str:\r
+\r
+    """\r
+    Generates filename based on existing filename but with different extension\r
+\r
+    :param fullPath: Full path to filename\r
+    :param newDir: New directory, or None to use existing directory of fullPath\r
+    :param newExt: New extension (with the leading period), or None to use existing extension\r
+    :return: Generated full path to filename with changed extension\r
+    """\r
+\r
+    dir, root, ext = splitPathIntoParts(fullPath)\r
+    if newDir is not None:\r
+        dir = newDir\r
+    if newExt is not None:\r
+        if newExt[0] != '.': newExt = '.' + newExt\r
+        ext = newExt\r
+    return os.path.join(dir, root + ext)\r
+\r
+\r
+def generateUniqueFilenameFromExistingIfNecessary(fullPath: str) -> str:\r
+\r
+    """\r
+    If a file with the specified name exists, adds a numerical suffix to the filename\r
+    to make it a unique filename for the path the file is in\r
+\r
+    :param fullPath: Full path to original filename\r
+    :return: fullPath if a file with that name doesn't already exist, or a\r
+    fullPath with a unique suffix\r
+    """\r
+\r
+    dir, root, ext = splitPathIntoParts(fullPath)\r
+\r
+    seqNum = 0; seqNumStr = "" # first candidate is without a suffix\r
+    while True:\r
+        filenameCandidate = os.path.join(dir, root + seqNumStr + ext)\r
+        if not os.path.exists(filenameCandidate):\r
+            return filenameCandidate\r
+        seqNum += 1\r
+        seqNumStr = f"-{seqNum}"\r
+\r
+\r
+def generateOutputFilename() -> str:\r
+\r
+    """\r
+    Generates the filename to hold encoded output, based on user settings\r
+\r
+    :return: Output filename. sys.exit() is called for the case where we can't overwrite an existing file\r
+    """\r
+\r
+    outputFilename = Config.args.outputfilename\r
+    if outputFilename is None:\r
+        # user didn't specify an output filename - generate one ourselves\r
+        outputFilename = "Overlay_"\r
+        if Config.args.camera:\r
+            outputFilename += f"{Config.args.camera}"\r
+        else:\r
+            outputFilename += f"{Config.args.jpgDimensions.columns}x{Config.args.jpgDimensions.rows}"\r
+        for index, frameline in enumerate(Config.args.framelines):\r
+            outputFilename += f"_Frame-{frameline.aspectRatio.columns}x{frameline.aspectRatio.rows}"\r
+        for index, gridline in enumerate(Config.args.gridlines):\r
+            outputFilename += f"_Grid-{gridline.gridDimensions.columns}x{gridline.gridDimensions.rows}"\r
+        outputFilename = generateFilenameWithDifferentExtensionAndDir(outputFilename, Config.args.outputdir, Config.args.imageTypeExtension)\r
+    else:\r
+        # user specified output filename. if it didn't specify an extension, add it\r
+        root, ext = os.path.splitext(outputFilename)\r
+        if not ext:\r
+            outputFilename = f"{outputFilename}.{Config.args.imageTypeExtension}"\r
+\r
+        outputFilename = generateFilenameWithDifferentExtensionAndDir(outputFilename, Config.args.outputdir, None)\r
+\r
+    match Config.args.ifexists:\r
+        case IfFileExists.ADDSUFFIX:\r
+            outputFilename = generateUniqueFilenameFromExistingIfNecessary(outputFilename)\r
+        case IfFileExists.OVERWRITE:\r
+             pass\r
+        case IfFileExists.EXIT:\r
+            if os.path.exists(outputFilename):\r
+                printE(f"Output file \"{outputFilename}\" already exists. Exiting per --ifexists setting")\r
+                sys.exit(1)\r
+\r
+    return outputFilename\r
+\r
+\r
+def calcAspectRatioDimensions(imageDimensions: Dimensions, desiredAspectRatioDimensions: Dimensions) -> Dimensions:\r
+\r
+    """\r
+    Calculates the dimensions of an aspect-ratio box that fits within the specified image dimensions\r
+\r
+    :param imageDimensions: Image dimensions the aspect-ratio box must fit within\r
+    :param desiredAspectRatioDimensions: Aspect ratio wanted\r
+    :return: Dimensions of box that fit within the specified image dimensions and have the specified aspect ratio\r
+    """\r
+\r
+    imageAspectRatio = imageDimensions.columns / imageDimensions.rows\r
+    desiredAspectRatio = desiredAspectRatioDimensions.columns / desiredAspectRatioDimensions.rows\r
+\r
+    multiplier = imageAspectRatio / desiredAspectRatio\r
+\r
+    if multiplier <= 1.0:\r
+        # we will lose rows in image, ie use all image columns and calculate how many rows can fit\r
+        columns = imageDimensions.columns\r
+        rows = int(columns * (1/desiredAspectRatio))\r
+    else:\r
+        # we will lose columns in image, ie use all image rows and calculate how many columns can fit\r
+        rows = imageDimensions.rows\r
+        columns = int(rows * desiredAspectRatio)\r
+    return Dimensions(columns, rows)\r
+\r
+\r
+def calcLineWidthExpansion(coordinate: int, lineWidth: int, lineWidthExpansion: LineWidthExpansion) -> Tuple[int, int]:\r
+\r
+    """\r
+    Calculates how to draw the width of a line based on a specified expansion type. PIL's internal width implementation\r
+    is always centered and doesn't handle fractional/remainders consistently, so we implement width ourselves by drawing\r
+    multiple lines. This method determines how the pixels are distributed relative to the starting coordinate of the line\r
+\r
+    :param coordinate: Starting coordinate to draw (either x or y coordinate, whichever is the variant)\r
+    :param lineWidth: Width of line\r
+    :param lineWidthExpansion: Determines how the line width is distributed around the coordinates. EXPAND_CENTERED splits\r
+    the expansion evenly across both sides of the coordinate. EXPAND_RIGHT_DOWN expands to the right or down of the coordinate,\r
+    while EXPAND_LEFT_UP expands to the left or up of the coordinate. EXPAND_RIGHT_DOWN and EXPAND_LEFT_UP are used to\r
+    implement "inward" expansion, for example of a rectangle shape.\r
+    :return: The starting and ending coordinate of the line, both inclusive\r
+    """\r
+\r
+    match lineWidthExpansion:\r
+        case LineWidthExpansion.EXPAND_CENTERED:\r
+            fIsOddWidth = lineWidth % 2 # if width is odd we'll including the remainder of one in ending coordinate\r
+            start = coordinate-(lineWidth//2)\r
+            end = coordinate+(lineWidth//2) + fIsOddWidth\r
+        case LineWidthExpansion.EXPAND_RIGHT_DOWN:\r
+            # y coordinate is a starting coordinate, so draw width by expanding down\r
+            start = coordinate\r
+            end = coordinate+lineWidth\r
+        case LineWidthExpansion.EXPAND_LEFT_UP:\r
+            # y coordinate is an ending coordinate, so draw width by expanding up\r
+            start = (coordinate-lineWidth)+1 # note: ending coordinate is inclusive, so +1 to handle properly in for() loop below\r
+            end = coordinate+1\r
+    return (start, end)\r
+\r
+\r
+def drawSolidHorzLine(draw: ImageDraw, x1: int, x2: int, y: int, lineWidth: int, lineWidthExpansion: LineWidthExpansion, lineColor: RGB):\r
+\r
+    """\r
+    Draws a solid horizontal line\r
+\r
+    :param draw: Canvas to draw on\r
+    :param x1: Starting x-coordiante\r
+    :param x2: Ending x-coordinate (inclusive)\r
+    :param y: y-coordinate\r
+    :param lineWidth: Line width\r
+    :param lineWidthExpansion: See calcLineWidthExpansion() documentation\r
+    """\r
+\r
+    sy, ey = calcLineWidthExpansion(y, lineWidth, lineWidthExpansion)\r
+    for wy in range(sy, ey):\r
+        draw.line([(x1, wy), (x2, wy)], width=1, fill=lineColor)\r
+\r
+\r
+def drawSolidVertLine(draw: ImageDraw, y1: int, y2: int, x: int, lineWidth: int, lineWidthExpansion: LineWidthExpansion, lineColor: RGB):\r
+\r
+    """\r
+    Draws a solid vertical line\r
+\r
+    :param draw: Canvas to draw on\r
+    :param y1: Starting y-coordiante\r
+    :param y2: Ending y-coordinate (inclusive)\r
+    :param x: x-coordinate\r
+    :param lineWidth: Line width\r
+    :param lineWidthExpansion: See calcLineWidthExpansion() documentation\r
+    :param lineColor: Color of line\r
+    """\r
+\r
+    sx, ex = calcLineWidthExpansion(x, lineWidth, lineWidthExpansion)\r
+    for wx in range(sx, ex):\r
+        draw.line([(wx, y1), (wx, y2)], width=1, fill=lineColor)\r
+\r
+\r
+def calcDashedLineLastSegmentDrawLength(dashedLine: DashedLine, lineStart: int, lineEnd: int, lastDrawnCoordinate: int) -> int:\r
+\r
+    """\r
+    Calculates the length of the last segment of a dashed line. This is done to prevent a long gap in the corners of shape\r
+    drawn with dashed lines.\r
+\r
+    :param dashedLine: Dashed line specification\r
+    :param lineStart: Starting line coordinate (x-coordinate for horizontal lines, y-coordinate for vertical lines)\r
+    :param lineEnd: Ending line coordinate (x-coordinate for horizontal lines, y-coordinate for vertical lines)\r
+    :param lastDrawnCoordinate: The ending coordinate of the last dashed line segment drawn\r
+    :return: Length of an additional segment to draw, or zero if no additional segment is necessary\r
+    """\r
+\r
+    totalLineLen = (lineEnd - lineStart)+1\r
+    maxEdgeGap = int(totalLineLen * .02) # debug: was .10\r
+    edgeGap = (lineEnd - lastDrawnCoordinate)+1\r
+    printD(f"Calc Dashed Last Segment: {totalLineLen=}, {maxEdgeGap=}, {edgeGap=}, return: {min(maxEdgeGap, dashedLine.dashLength) if edgeGap >= maxEdgeGap else 0}")\r
+    if edgeGap < maxEdgeGap:\r
+        return 0\r
+    return min(maxEdgeGap, dashedLine.dashLength)\r
+\r
+\r
+def drawHorzLine(draw: ImageDraw, x1: int, x2: int, y: int, lineWidth: int, lineWidthExpansion: LineWidthExpansion, dashedLine: DashedLine, lineColor: RGB, fCompleteDashedLineEdge: bool):\r
+\r
+    """\r
+    Draws a horizontal line, including support for dashed lines\r
+\r
+    :param draw: Canvas to draw on\r
+    :param x1: Starting x-coordiante\r
+    :param x2: Ending x-coordinate (inclusive)\r
+    :param y: y-coordinate\r
+    :param lineWidth: Line width\r
+    :param lineWidthExpansion: See drawSolidHorzLine() documentation\r
+    :param dashedLine: Dashed line specification, or None for a solid line\r
+    :param lineColor: Color of line\r
+    :param fCompleteDashedLineEdge:\r
+    """\r
+\r
+    if not dashedLine:\r
+        # solid line\r
+        dashedLine = DashedLine(dashLength=sys.maxsize, dashGap=0)\r
+    for x in range(x1, x2, dashedLine.dashLength + dashedLine.dashGap):\r
+        endx = min(x + dashedLine.dashLength - 1, x2)\r
+        drawSolidHorzLine(draw, x, endx, y, lineWidth, lineWidthExpansion, lineColor)\r
+    if fCompleteDashedLineEdge:\r
+        lastSegmentDrawLength = calcDashedLineLastSegmentDrawLength(dashedLine=dashedLine, lineStart=x1, lineEnd=x2, lastDrawnCoordinate=endx)\r
+        if lastSegmentDrawLength > 0:\r
+            drawSolidHorzLine(draw, x2-lastSegmentDrawLength, x2, y, lineWidth, lineWidthExpansion, lineColor)\r
+\r
+\r
+def drawVertLine(draw: ImageDraw, y1: int, y2: int, x: int, lineWidth: int, lineWidthExpansion: LineWidthExpansion, dashedLine: DashedLine, lineColor: RGB, fCompleteDashedLineEdge: bool):\r
+\r
+    """\r
+    Draws a vertical line, including support for dashed lines\r
+\r
+    :param draw: Canvas to draw on\r
+    :param y1: Starting y-coordiante\r
+    :param y2: Ending y-coordinate (inclusive)\r
+    :param x: y-coordinate\r
+    :param lineWidth: Line width\r
+    :param lineWidthExpansion: See drawSolidHorzLine() documentation\r
+    :param dashedLine: Dashed line specification, or None for a solid line\r
+    :param lineColor: Color of line\r
+    :param fCompleteDashedLineEdge:\r
+    """\r
+\r
+    if not dashedLine:\r
+        # solid line\r
+        dashedLine = DashedLine(dashLength=sys.maxsize, dashGap=0)\r
+    for y in range(y1, y2, dashedLine.dashLength + dashedLine.dashGap):\r
+        endy = min(y + dashedLine.dashLength - 1, y2)\r
+        drawSolidVertLine(draw, y, endy, x, lineWidth, lineWidthExpansion, lineColor)\r
+    if fCompleteDashedLineEdge:\r
+        lastSegmentDrawLength = calcDashedLineLastSegmentDrawLength(dashedLine=dashedLine, lineStart=y1, lineEnd=y2, lastDrawnCoordinate=endy)\r
+        if lastSegmentDrawLength > 0:\r
+            drawSolidVertLine(draw, y2-lastSegmentDrawLength, y2, x, lineWidth, lineWidthExpansion, lineColor)\r
+\r
+\r
+def drawRectangle(draw: ImageDraw, x1: int, y1: int, x2: int, y2: int, lineWidth: int, dashedLine: DashedLine, lineColor: RGB, fillColor: RGB, fCompleteCorners=True):\r
+\r
+    """\r
+    Draws a rectangle, including support for dashed lines\r
+\r
+    :param draw: Canvas to draw on\r
+    :param x1: x-coordinate of the top-left corner\r
+    :param y1: y-coordinate of the top-left corner\r
+    :param x2: x-coordinate of the bottom-right corner (inclusive)\r
+    :param y2: y-coordinate of the bottom-right corner (inclusive)\r
+    :param lineWidth: Width of lines\r
+    :param dashedLine: Dashed-line specification, or None for solid line\r
+    :param lineColor: Line color\r
+    :param fillColor: Fill color\r
+    :param fCompleteCorners: Make sure each of the rectangle's 4 corners has segments near them (for dashed lines)\r
+    """\r
+\r
+    if fillColor: # fill the rect if specified\r
+        draw.rectangle([(x1+lineWidth, y1+lineWidth), (x2-lineWidth, y2-lineWidth)], outline=fillColor, fill=fillColor)\r
+\r
+    if lineWidth > 0: # draw the rect if specified\r
+        # draw top and bottom horizontal lines of rect\r
+        drawHorzLine(draw, x1, x2, y1, lineWidth, LineWidthExpansion.EXPAND_RIGHT_DOWN, dashedLine, lineColor, fCompleteCorners)\r
+        drawHorzLine(draw, x1, x2, y2, lineWidth, LineWidthExpansion.EXPAND_LEFT_UP, dashedLine, lineColor, fCompleteCorners)\r
+        # draw left and right vertical lines of rect\r
+        drawVertLine(draw, y1, y2, x1, lineWidth, LineWidthExpansion.EXPAND_RIGHT_DOWN, dashedLine, lineColor, fCompleteCorners)\r
+        drawVertLine(draw, y1, y2, x2, lineWidth, LineWidthExpansion.EXPAND_LEFT_UP, dashedLine, lineColor, fCompleteCorners)\r
+\r
+\r
+def drawFrameline(draw: ImageDraw, rawDimensions: Dimensions, jpgDimensions: Dimensions, aspectRatioDimensions: Dimensions, lineWidth: int, dashedLine: DashedLine, lineColor: RGB, fillColor: RGB, labelPos: LabelPos) -> None:\r
+\r
+    """\r
+    Draws framelines with the specified aspect ratio that fits within the specified jpg dimensions\r
+\r
+    :param draw: Canvas to draw on\r
+    :param rawDimensions: Camera's raw image dimensions\r
+    :param jpgDimensions: Camera's jpg image dimensions\r
+    :param aspectRatioDimensions: Aspect ratio of box\r
+    :param lineWidth: Width of lines\r
+    :param dashedLine: Optional dash/dashed-line specification. If not provided then solid\r
+    :param lineColor: Color of lines\r
+    :param fillColor: Color to fill area with\r
+    :param labelPos: Position of text label for aspect ratio, or None for no label\r
+    lines will be  used.\r
+    """\r
+\r
+    def getFontBySizeForPixelHeight(string: str, maxiumHeightWantedInPixels: int) -> Any:\r
+\r
+        """\r
+        Returns a font whose size is as close to "maxiumHeightWantedInPixels" as possible without\r
+        going over when rendering the supplied string\r
+        :param string: Text to calucate font size from\r
+        :param maxiumHeightWantedInPixels: Maximum height of font desired, in pixels\r
+        :return: Default font whose size meets criteria\r
+        """\r
+\r
+        fontSize = 16384 # abusrdly high value to handle largest fontSizePct possible (100%)\r
+        while fontSize >= 8:\r
+            prevFontSize = fontSize\r
+            defaultFont = ImageFont.load_default(size=fontSize)\r
+            left, top, right, bottom = draw.textbbox((0,0), string, font=defaultFont)\r
+            textHeightPixels = int(bottom-top)\r
+            textWidthPixels = int(right-left)\r
+            if textHeightPixels <= maxiumHeightWantedInPixels:\r
+                break\r
+            multiple  = textHeightPixels // maxiumHeightWantedInPixels\r
+            if multiple >= 2:\r
+                fontSize //= multiple\r
+            else:\r
+                fontSize = int(math.floor(fontSize * .98))\r
+            printD(f"Tried prevFontSize {prevFontSize}, yielded textHeight {textHeightPixels} vs max-wanted {maxiumHeightWantedInPixels} - next fontSize {fontSize}")\r
+        printV(f"Selected fontSize {fontSize}, yielding text height of {textHeightPixels} (max-wanted {maxiumHeightWantedInPixels}), vs rawHeight {rawHeight} ({textHeightPixels/rawHeight*100:.2f}%), args.fontSizePct={Config.args.fontSizePct*100:.2f}%")\r
+        return (textWidthPixels, textHeightPixels, defaultFont)\r
+\r
+    rawWidth, rawHeight = rawDimensions\r
+    jpgWidth, jpgHeight = jpgDimensions\r
+\r
+    boxWidth, boxHeight = calcAspectRatioDimensions(jpgDimensions, aspectRatioDimensions)\r
+\r
+    rawBorderLeft = rawBorderRight = (rawWidth-jpgWidth)//2\r
+    rawBorderTop = rawBorderBottom = (rawHeight-jpgHeight)//2\r
+\r
+    # calculate box starting x,y relative to jpg dimensions\r
+    boxTopLeftInJpg_X = (jpgWidth - boxWidth)//2\r
+    boxTopLeftInJpg_Y = (jpgHeight - boxHeight)//2\r
+\r
+    # calculate box starting x,y relative to raw dimensions\r
+    x = rawBorderLeft + boxTopLeftInJpg_X\r
+    y = rawBorderTop + boxTopLeftInJpg_Y\r
+\r
+    if (lineWidth is None) or (lineColor is None):\r
+        lineWidth = 0\r
+\r
+    drawRectangle(draw=draw, x1=x, y1=y, x2=x+boxWidth-1, y2=y+boxHeight-1,\r
+        lineWidth=lineWidth, dashedLine=dashedLine, lineColor=lineColor, fillColor=fillColor, fCompleteCorners=True)\r
+\r
+    if labelPos:\r
+\r
+        text = f"{aspectRatioDimensions.columns}:{aspectRatioDimensions.rows}"\r
+        textHeightInPixelsWanted = int(rawHeight*Config.args.fontSizePct)\r
+        textWidthPixels, textHeightPixels, font = getFontBySizeForPixelHeight(text, textHeightInPixelsWanted)\r
+\r
+        textPosOffset = 20\r
+        match labelPos.horzPos:\r
+            case HorzPos.LEFT:\r
+                textX = x + lineWidth + textPosOffset\r
+            case HorzPos.CENTER:\r
+                textX = x + boxWidth/2 - textWidthPixels/2 - textPosOffset\r
+            case HorzPos.RIGHT:\r
+                textX = x + boxWidth - lineWidth - textWidthPixels - textPosOffset\r
+        match labelPos.vertPos:\r
+            case VertPos.TOP:\r
+                textY = y + lineWidth + textPosOffset\r
+            case VertPos.CENTER:\r
+                textY = y + boxHeight/2 - textHeightPixels/2 - textPosOffset\r
+            case VertPos.BOTTOM:\r
+                textY = y + boxHeight - lineWidth - textHeightPixels - textPosOffset\r
+\r
+        draw.text((textX, textY), text, fill=lineColor, font=font, anchor="lt")\r
+\r
+    printI(f"Frameline: Aspect ratio \"{aspectRatioDimensions.columns}:{aspectRatioDimensions.rows}\", resolution is {boxWidth}x{boxHeight}")\r
+\r
+\r
+def drawGridline(draw: ImageDraw, gridDimensions: Dimensions, rawDimensions: Dimensions, jpgDimensions: Dimensions, aspectRatioDimensions: Dimensions, lineWidth: int, dashedLine: DashedLine, lineColor: RGB) -> None:\r
+\r
+    """\r
+    Draws gridlines with the specified dimensions\r
+\r
+    :param draw: Canvas to draw on\r
+    :param gridDimensions: Dimensions with number of column and row segments.\r
+    :param rawDimensions: Camera's raw image dimensions\r
+    :param jpgDimensions: Camera's jpg image dimensions\r
+    :param aspectRatioDimensions: Aspect ratio to constrain gridlines to, or None to use jpgDImensions as only constraint\r
+    :param lineWidth: Width of lines\r
+    :param dashedLine: Optional dash/dashed-line specification. If not provided then solid lines will be drawn.\r
+    :param lineColor: Color of lines\r
+    :param fillColor: Color to fill area with\r
+    """\r
+\r
+    rawWidth, rawHeight = rawDimensions\r
+    jpgWidth, jpgHeight = jpgDimensions\r
+\r
+    if aspectRatioDimensions:\r
+        drawWidth, drawHeight = calcAspectRatioDimensions(jpgDimensions, aspectRatioDimensions)\r
+    else:\r
+        drawWidth, drawHeight = jpgDimensions\r
+\r
+    rawBorderLeft = rawBorderRight = (rawWidth-jpgWidth)//2\r
+    rawBorderTop = rawBorderBottom = (rawHeight-jpgHeight)//2\r
+\r
+    # calculate starting x,y of drawing area relative to raw dimensions\r
+    xOrigin = rawBorderLeft + (jpgWidth - drawWidth)//2\r
+    yOrigin = rawBorderTop + (jpgHeight - drawHeight)//2\r
+\r
+    # draw columns (vertical lines)\r
+    if gridDimensions.columns:\r
+        pixelsPerColumn = (drawWidth // gridDimensions.columns) + (drawWidth % gridDimensions.columns != 0)\r
+        for x in range(pixelsPerColumn, drawWidth, pixelsPerColumn):\r
+            drawVertLine(draw=draw, y1=yOrigin, y2=yOrigin+drawHeight, x=x+xOrigin, lineWidth=lineWidth, lineWidthExpansion=LineWidthExpansion.EXPAND_CENTERED, dashedLine=dashedLine, lineColor=lineColor, fCompleteDashedLineEdge=True)\r
+\r
+    # draw row (horizontal lines)\r
+    if gridDimensions.rows:\r
+        pixelsPerRow = (drawHeight // gridDimensions.rows) + (drawHeight % gridDimensions.rows != 0)\r
+        for y in range(pixelsPerRow, drawHeight, pixelsPerRow):\r
+            drawHorzLine(draw=draw, x1=xOrigin, x2=xOrigin+drawWidth, y=y+yOrigin, lineWidth=lineWidth, lineWidthExpansion=LineWidthExpansion.EXPAND_CENTERED, dashedLine=dashedLine, lineColor=lineColor, fCompleteDashedLineEdge=True)\r
+\r
+    # info print\r
+    if aspectRatioDimensions:\r
+        aspectRatioDesc = f" [inside aspect ratio \"{aspectRatioDimensions.columns}:{aspectRatioDimensions.rows}\"]"\r
+    else:\r
+        aspectRatioDesc =""\r
+    printI(f"Gridline: {gridDimensions.columns}x{gridDimensions.rows}{aspectRatioDesc}, area {drawWidth}x{drawHeight}")\r
+\r
+\r
+def run() -> bool:\r
+\r
+    """\r
+    main module routine\r
+\r
+    :return: False if successful, True if error\r
+    """\r
+\r
+    Config.args = args = processCmdLine()\r
+    if args is None:\r
+        return True\r
+\r
+    printI(f"{AppName} v{AppVersion}")\r
+    printD(f"Args: {args}")\r
+\r
+    # get raw and jpg dimensions, either form camera or those manually specified\r
+    if args.camera:\r
+        cameraInfo = cameras.getCamera(args.camera)\r
+        if not cameraInfo:\r
+            printE(f"There is no camera \"{args.camera}\" in the camera database. Use --list-cameras to see supported cameras.")\r
+            return True\r
+        if not args.dimensions:\r
+            rawDimensions = cameraInfo.rawDimensions\r
+            jpgDimensions = cameraInfo.embeddedJpgs[0].dimensions\r
+        else:\r
+            # the dimensions provided on the command-line override the camera dimensions\r
+            rawDimensions = args.rawDimensions\r
+            jpgDimensions = args.jpgDimensions\r
+    else:\r
+        cameraInfo = None\r
+        rawDimensions = args.rawDimensions\r
+        jpgDimensions = args.jpgDimensions\r
+\r
+    printI(f"RAW resolution is {rawDimensions.columns}x{rawDimensions.rows}, JPG is {jpgDimensions.columns}x{jpgDimensions.rows}")\r
+\r
+    if (rawDimensions.columns < jpgDimensions.columns) or (rawDimensions.rows < jpgDimensions.rows):\r
+        printE("Raw dimensions must be >= jpg dimensions on both axis")\r
+        return True\r
+\r
+    # draw framelines\r
+    img = Image.new('RGB', (rawDimensions.columns, rawDimensions.rows), args.backgroundColor)\r
+    draw = ImageDraw.Draw(img)\r
+    for frameline in args.framelines:\r
+        drawFrameline(draw,\r
+            rawDimensions,\r
+            jpgDimensions,\r
+            frameline.aspectRatio,\r
+            lineWidth=frameline.lineWidth,\r
+            dashedLine=frameline.dashedLine,\r
+            lineColor=frameline.lineColor,\r
+            fillColor=frameline.fillColor,\r
+            labelPos=frameline.labelPos)\r
+\r
+    # draw gridlines\r
+    for gridline in args.gridlines:\r
+        drawGridline(draw=draw,\r
+            gridDimensions=gridline.gridDimensions,\r
+            rawDimensions=rawDimensions,\r
+            jpgDimensions=jpgDimensions,\r
+            aspectRatioDimensions=gridline.aspectRatio,\r
+            lineWidth=gridline.lineWidth,\r
+            dashedLine=gridline.dashedLine,\r
+            lineColor=gridline.lineColor)\r
+\r
+    # Save the generated image\r
+    outputFilename = generateOutputFilename()\r
+    try:\r
+        img.save(outputFilename)\r
+    except Exception as e:\r
+        printE(f"Unable to save output to \"{outputFilename}, error: {e}")\r
+        return True\r
+    printI(f"Successfully generated \"{os.path.realpath(outputFilename)}\"")\r
+\r
+    # generate NEF if specified\r
+    if args.generatenef:\r
+        fError = runImg2nef(outputFilename)\r
+        if fError:\r
+            return True\r
+\r
+    # open in viewer if specified\r
+    if args.openInViewer:\r
+        openFileInOS(outputFilename) # we ignore any viewer errors since it's not an essential operation\r
+\r
+    return False\r
+\r
+\r
+if __name__ == "__main__":\r
+    fError = run()\r
+    sys.exit(fError)
\ No newline at end of file
diff --git a/img2nef.py b/img2nef.py
new file mode 100644 (file)
index 0000000..61b1fdb
--- /dev/null
@@ -0,0 +1,1787 @@
+#!/usr/bin/env python3\r
+"""\r
+img2nef.py\r
+This app converts image files into Nikon lossless compressed NEF raw files, which can then be used\r
+anywhere Nikon raw files can be viewed and processed. Some uses for this include Nikon's in-camera\r
+multi-exposure feature, which lets you specify an existing raw file as the first image of a\r
+multi-exposure composite - using this app expands the possibilities of image files you can use as\r
+the first image beyond just images taken with the camera. For example, you can use this feature to\r
+implement custom framing guides by creating them in your favorite imaging app and then converting\r
+them to NEF's for use in the camera's multi-exposure feature.\r
+\r
+Creating NEFs from images is also useful for scientific and research purposes, for example in developing\r
+and testing raw image development software\r
+\r
+This app is written in both Python and 'C' - nefencode.c contains the optimized code to compress a bayered\r
+image into Nikon's proprietary NEF lossless compression.\r
+\r
+"""\r
+\r
+#\r
+# verify python version early, before executing any logic that relies on features not available in all versions\r
+#\r
+import sys\r
+if (sys.version_info.major < 3) or (sys.version_info.minor < 10):\r
+    print("Requires Python v3.10 or later but you're running v{}.{}.{}".format(sys.version_info.major, sys.version_info.minor, sys.version_info.micro))\r
+    sys.exit(1)\r
+\r
+#\r
+# standard Python module imports\r
+#\r
+import argparse\r
+import ctypes\r
+from   dataclasses import dataclass\r
+from   enum import Enum\r
+from   functools import partial\r
+import importlib\r
+from   io import BytesIO\r
+import math\r
+import os\r
+import platform\r
+import re\r
+import struct\r
+import subprocess\r
+import sys\r
+import time\r
+import types\r
+from   typing import Any, Callable, List, NamedTuple, Tuple, Type\r
+\r
+\r
+#\r
+# types\r
+#\r
+class Alignment(Enum): CENTER=0; TOP=1; LEFT=2; BOTTOM=3; RIGHT=4\r
+class ResizeGeom(Enum): NONE=0; MINIMUM=1; FULL=2\r
+class IfFileExists(Enum): ADDSUFFIX=0; OVERWRITE=1; EXIT=2\r
+class Verbosity(Enum): SILENT=0; WARNING=1; INFO=2; VERBOSE=3; DEBUG=4\r
+class Endian(Enum): LITTLE=0; BIG=1\r
+class OutputFilenameMethod(Enum): TEMPLATENEF_AND_INPUTFILE=0; TEMPLATENEF=1; INPUTFILE=2; NONE=3\r
+\r
+Coordinate = NamedTuple('Coordinate', [('x', int), ('y', int)])\r
+Dimensions = NamedTuple('Dimensions', [('columns', int), ('rows', int)])\r
+Rect = NamedTuple('Rect', [('startx', int), ('starty', int), ('endx', int), ('endy', int)]) # ending values are exclusive\r
+SrcGeomAdjustments = NamedTuple('SrcGeomAdjustments', [('resizeDimensions', Dimensions), ('posInTgt', Coordinate), ('cropRect', Rect)])\r
+WhiteBalanceMultipliers = NamedTuple('WhiteBalanceMultipliers', [('red', float), ('blue', float)])\r
+NikonCropArea = NamedTuple('NikonCropArea', [('left', int), ('top', int), ('columns', int), ('rows', int)])\r
+EmbeddedJpgExifInfo = NamedTuple('EmbeddedJpg', [('exifName', str), ('start', int), ('length', int), ('offsetToLengthField', int)])\r
+\r
+\r
+#\r
+# module data\r
+#\r
+AppName = "img2nef"\r
+AppVersion = "1.00"\r
+ResizeAlgoNames = ['LANCZOS4', 'CUBIC', 'AREA', 'LINEAR', 'NEAREST']\r
+AlignmentStrs = [x.name for x in Alignment]\r
+ResizeGeomStrs = [x.name for x in ResizeGeom]\r
+IfFileExistsStrs = [x.name for x in IfFileExists]\r
+OutputFilenameMethodStrs = [x.name for x in OutputFilenameMethod]\r
+VerbosityStrs = [x.name for x in Verbosity]\r
+Config = types.SimpleNamespace()\r
+ImgFloatType = "float32"\r
+EmbeddedJpgExifNames = ["JpgFromRaw", "OtherImage", "PreviewImage", "Thumbnail"]\r
+\r
+#\r
+# verify all optional modules we need are installed before we attempt to import them.\r
+# this allows us to display a user-friendly message for the missing modules instead of the\r
+# python-generated error message for missing imports\r
+#\r
+if __name__ == "__main__":\r
+    def verifyRequiredModulesInstalled():\r
+        RequiredModule = NamedTuple('RequiredModule', [('importName', str), ('pipInstallName', str)])\r
+        requiredModules = [\r
+            RequiredModule(importName="cv2", pipInstallName="opencv-python"),\r
+            RequiredModule(importName="PIL", pipInstallName="pillow"),\r
+            RequiredModule(importName="numpy", pipInstallName="numpy"),\r
+        ]\r
+        missingModules = list()\r
+        for requiredModule in requiredModules:\r
+            try:\r
+                importlib.import_module(requiredModule.importName)\r
+            except ImportError:\r
+                missingModules.append(requiredModule)\r
+        if missingModules:\r
+            print(f"Run the following commands to install required modules before using {AppName}:\n")\r
+            for requiredModule in missingModules:\r
+                print(f"\tpip install {requiredModule.pipInstallName}")\r
+            print("")\r
+            sys.exit(1)\r
+\r
+    verifyRequiredModulesInstalled()\r
+\r
+\r
+#\r
+# import optional modules now we've established they're available\r
+#\r
+import cv2\r
+from   PIL import Image, ImageDraw, ImageFont\r
+import numpy as np\r
+\r
+\r
+#\r
+# methods to handle conditional printing based on user-specified verbosity level\r
+#\r
+def isVerbose() -> bool:\r
+    return Config.args.verbosity.value >= Verbosity.VERBOSE.value\r
+def printA(string: str): # print "always"\r
+    print(string)\r
+def printIfVerbosityAllows(string: str, requiredVerbosityLevel: Verbosity) -> None:\r
+    if hasattr(Config, "args"):\r
+        if Config.args.verbosity.value >= requiredVerbosityLevel.value:\r
+            printA(string)\r
+    else:\r
+        # called before we've initialized Config.args\r
+        printA(string)\r
+def printE(string: str): # print error\r
+    printA(f"ERROR: {string}")\r
+def printW(string: str): # print warnings, if verbosity config allows\r
+    printIfVerbosityAllows(f"WARNING: {string}", Verbosity.WARNING)\r
+def printI(string: str): # print "informational" messages, if verbosity config allows\r
+    printIfVerbosityAllows(f"INFO: {string}", Verbosity.INFO)\r
+def printV(string: str): # print "verbose" messages, if verbosity config allows\r
+    printIfVerbosityAllows(f"VERBOSE: {string}", Verbosity.VERBOSE)\r
+def printD(string: str): # print "debug" messages, if verbosity config allows\r
+    printIfVerbosityAllows(f"DEBUG: {string}", Verbosity.DEBUG)\r
+\r
+\r
+def getScriptDir() -> str:\r
+\r
+    """\r
+    Returns absolute path to the directory this script is running in\r
+\r
+    :return: Absolute dirctory\r
+    """\r
+\r
+    return os.path.dirname(os.path.realpath(__file__))\r
+\r
+\r
+def openFileInOS(filename: str) -> bool:\r
+\r
+    """\r
+    Opens a file in the OS's default viewer/editor/handler for file\r
+\r
+    :param filename: Filename to open\r
+    :return: False if successful, TRUE if error\r
+    """\r
+    printV(f"Opening \"{os.path.realpath(filename)}\" in default system image viewer")\r
+    try:\r
+        if platform.system() == "Windows":\r
+            os.startfile(filename)\r
+        else: # both Linux and Darwin (aka Mac) use "open"\r
+            subprocess.call(['open', filename])\r
+    except Exception as e:\r
+        printW(f"Unable to open file \"{filename}\" in your OS's file viewer, error: {e}")\r
+        return True\r
+    return False\r
+\r
+\r
+def processCmdLine() -> argparse.Namespace:\r
+\r
+    """\r
+    Processes the command line\r
+\r
+    :return: False if successful, True if error\r
+    """\r
+\r
+    # custom ArgumentParser that throws exception on parsing error\r
+    class ArgumentParserError(Exception): pass # from http://stackoverflow.com/questions/14728376/i-want-python-argparse-to-throw-an-exception-rather-than-usage\r
+    class ArgumentParserWithException(argparse.ArgumentParser):\r
+        def error(self, message):\r
+            raise ArgumentParserError(message)\r
+\r
+    # converts string value like "True", "T", "No", etc... to boolean\r
+    def strValueToBool(string: str) -> bool:\r
+        if string is None:\r
+            return True\r
+        if string.upper() in ['1', 'TRUE', 'T', 'YES', 'Y']:\r
+            return True\r
+        if string.upper() in ['0', 'FALSE', 'F', 'NO', 'N']:\r
+            return False\r
+        raise argparse.ArgumentTypeError(f"Boolean value expected but '{string}' was specified")\r
+\r
+    # converts comma-separated string to a list of floats\r
+    def commaSeparatedFloatListForArg(string: str) -> List[float]:\r
+        return [float(item.strip()) for item in string.split(',')]\r
+\r
+    # converts comma-separated string to a list of ints\r
+    def commaSeparatedIntListForArg(string: str) -> List[int]:\r
+        return [int(item.strip()) for item in string.split(',')]\r
+\r
+    # converts string to hex value\r
+    def strToHex(string: str) -> int:\r
+        string = string.lstrip("#")\r
+        return int(string, 16)\r
+\r
+    # removes one nesting of list. Ex: [[1.0, 0.5, 1.0]] -> [1.0, 0.5, 1.0]\r
+    def flattenList(listToFlatten: List) -> List:\r
+        flattenedList = list()\r
+        for item in listToFlatten:\r
+            flattenedList.extend(item)\r
+        return flattenedList\r
+\r
+    # arg parser that throws exceptions on errors\r
+    parser = ArgumentParserWithException(fromfile_prefix_chars='!',\\r
+        formatter_class=argparse.RawDescriptionHelpFormatter,\r
+        description='Converts an image into a Nikon Raw NEF (written by Horshack)',\\r
+        epilog="Options can also be specified from a file. Use !<filename>. Each word in the file must be on its own line.\n\nYou "\\r
+            "can abbreviate any argument name provided you use enough characters to uniquely distinguish it from other argument names.\n")\r
+\r
+    parser.add_argument('templatenef', metavar="<Camera Model> or <Template NEF filename>", help="""Required: Camera model name, or filename of an existing Nikon NEF lossless compressed raw file to use as the template. If a camera model\r
+        is specified then the NEF file located at ./templatenef/<camera model>.NEF will be used. Template NEFs are used by this app to form the skeleton of the new NEF generated - we read the template NEF into memory and overwrite the raw data\r
+        inside it with the raw data generated from your source image, storing the result as a new NEF, leaving the skeleton NEF untouched. Template NEFs for most Nikon Z models are included with this app. If your camera model\r
+        isn't included then you'll need to provide a template NEF. See the readme at the GitHub page for best practices on creating template NEFs.""")\r
+    parser.add_argument('inputfilename', metavar="<image filename>", help="Required: Input file, typically an image (ex: TIF, JPG, PNG) but Numpy (.npy) files of certain shapes are also supported.")\r
+    parser.add_argument('outputfilename', nargs="?", metavar="<output filename>", help="Optional - If not specified, name will be automatically generated using the the method defined by --outputfilenamemethod.")\r
+    parser.add_argument('--embeddedimg', metavar="<image filename>", type=str, help="Use a different image for the embedded jpgs. Default is to use the image data from <inputfilename>, ie same image that's used to generated the raw data.", required=False)\r
+    parser.add_argument('--openinviewer', type=strValueToBool, nargs='?', default=False, const=True, metavar="yes/no", help="Open generated NEF in default image editor after creating. Default is %(default)s.")\r
+    parser.add_argument('--outputdir', type=str, metavar="<path>", help="Directory to store image/file(s) to.  Default is current directory. If path contains any spaces enclose it in double quotes. Example: --outputdir \"c:\\My Documents\"", default=None, required=False)\r
+    parser.add_argument('--outputfilenamemethod', type=str.upper, choices=OutputFilenameMethodStrs, default='TEMPLATENEF_AND_INPUTFILE', required=False, help="""Method used to automatically generate output filename when not explicitly specified.\r
+        Default is \"%(default)s\", which means the template NEF + input filenames will be concatenated together to form the output filename, with the result stored in the input filename's directory (unless --outputdir is specified).""")\r
+    parser.add_argument('--ifexists', type=str.upper, choices=IfFileExistsStrs, default='ADDSUFFIX', required=False, help="""Action to take if the output file already exists. Default is \"%(default)s\", which means a suffix is\r
+        added to the output filename to create a unique filename.""")\r
+\r
+    sourceImgOptions = parser.add_argument_group("Source Image Processing", "These options control how the source image is processed into bayered raw data. They are not supported for Numpy (.npy) sources.")\r
+    sourceImgOptions.add_argument('--src.hsl', dest="src_hsl", type=commaSeparatedFloatListForArg, nargs=1, default=[[1.0, 0.5, 1.0]], metavar="H,S,L - each value between 0.0 and 1.0", help="Hue, Saturation, and Lightness adjustment on source image. Ex: --src.hsl=1.0,0.5,1.0. Default is %(default)s.", required=False)\r
+    sourceImgOptions.add_argument('--src.srgbtolinear', dest="src_srgbtolinear", type=strValueToBool, nargs='?', default=True, const=True, metavar="yes/no", help="Assume source is sRGB and convert to linear. Default is %(default)s.")\r
+    sourceImgOptions.add_argument('--src.wbmultipliers', dest="src_wbmultipliers", type=commaSeparatedFloatListForArg, nargs=1, required=False, metavar="RedValue, BlueValue - each value is float/decimal", help="Override white balance red/blue multipliers in template NEF.")\r
+    sourceImgOptions.add_argument('--src.grayscale', dest="src_grayscale", type=strValueToBool, nargs='?', default=False, const=True, metavar="yes/no", help="Convert source image to grayscale. Default is %(default)s.")\r
+\r
+    resizeOptions = parser.add_argument_group('Resize Options', "These options control how the source image is resized to fit the raw image dimensions.")\r
+    resizeOptions.add_argument('--re.geometry', dest='re_geometry', type=str.upper, choices=ResizeGeomStrs, default="FULL", required=False, help="""Resizing: Geometry to use. \"FULL\" means source image is\r
+        first resized so both axis reach the raw output dimensions, which means one axis will likely be oversized and need to be cropped back down for most aspect ratios. For example, a 1000x1000 1:1 source image for a 6000x4000 3:2 raw\r
+        will be resized to 6000x6000, then cropped to 6000x4000 to meet the raw's 3:2 aspect ratio. \"MINIMUM\" means source image is resized only to its nearest axis (in size) to meet a raw axis, with the other raw output axis potentially padded with a border.\r
+        For example, a 1000x1000 1:1 source image for a 6000x4000 3:2 raw will be resized to 4000x4000 (raw's \"row\" axis is closest in size to source image), with 1000 pixels of border on both horizontal sides of the raw image to fill\r
+        out the raw's 6000 horizontal pixels. Default is %(default)s.""")\r
+    resizeOptions.add_argument('--re.maintainaspectratio', dest='re_maintainaspectratio', type=strValueToBool, nargs='?', default=True, const=True, metavar="yes/no", help="""Resizing: Maintain aspect ratio of source. When false,\r
+        the --re.geometry value isn't applicable since the aspect ratio constraints it addresses don't exist when the source image aspect ratio doesn't need to be maintained. For example, a 1000x1000 1:1 source image can be resized\r
+        directly to a 6000x4000 3:2 raw output without needing to oversize it to 6000x6000 first. Default is %(default)s.""")\r
+    resizeOptions.add_argument('--re.horzalign', dest='re_horzalign', type=str.upper, choices=AlignmentStrs, default="CENTER", required=False, help="""Resizing: Horizontal alignment of source image position or crop. For example, if\r
+        resizing is disabled via --re.geometry=NONE and source image is 2000x2000, a 6000x4000 raw will have 1500 padding pixels on both horizontal sides, with source image centered in frame. Default is %(default)s.""")\r
+    resizeOptions.add_argument('--re.vertalign', dest='re_vertalign', type=str.upper, choices=AlignmentStrs, default="CENTER", required=False, help="Resizing: Vertical alignment of source image position or crop. Default is %(default)s.")\r
+    resizeOptions.add_argument('--re.resizealgo', dest='re_algorithm', type=str.upper, choices=ResizeAlgoNames, default='LANCZOS4', required=False, help="Resizing: Algorithm to use to resize source pixels. Default is %(default)s.")\r
+    resizeOptions.add_argument('--re.borderfillcolor', dest='re_borderfillcolor', type=strToHex, default='#000000', required=False, metavar="#RRGGBB (hex)", help="Resizing: Border color when source image doesn't fill out raw frame for resize parameters used.  Default is %(default)s.")\r
+\r
+    troubleshootingOptions = parser.add_argument_group("Troubleshooting Options", "These options help in troubleshooting issues")\r
+    troubleshootingOptions.add_argument('--showperfstats', metavar="yes/no", type=strValueToBool, nargs='?', default=False, const=True, help="Show performance statistics. Implicitly enabled when --verbosity is >= VERBOSE")\r
+    troubleshootingOptions.add_argument('--verbosity', type=str.upper, choices=VerbosityStrs, default="INFO", required=False, help="Verbosity of output during execution. Default is %(default)s.")\r
+\r
+    if len(sys.argv) == 1:\r
+        # print help if no parameters passed\r
+        parser.print_help()\r
+        return None\r
+\r
+       #\r
+       # if there is a default arguments file present, add it to the argument list so that parse_args() will process it\r
+       #\r
+    defaultOptionsFilename = os.path.join(getScriptDir(), f".{AppName}-defaultoptions")\r
+    if os.path.isfile(defaultOptionsFilename):\r
+        sys.argv.insert(1, "!" + defaultOptionsFilename) # insert as first arg (past script name), so that the options in the file can still be overriden by user-entered cmd line options\r
+\r
+    # perform the argparse\r
+    try:\r
+        args = parser.parse_args()\r
+    except ArgumentParserError as e:\r
+        print("Command line error: " + str(e))\r
+        return None\r
+\r
+    # do post-processing/conversion of args\r
+    args.outputfilenamemethod = OutputFilenameMethod[args.outputfilenamemethod] # convert from str to enumerated value\r
+    args.re_geometry = ResizeGeom[args.re_geometry]   # convert from str to enumerated value\r
+    args.re_horzalign = Alignment[args.re_horzalign]  # convert from str to enumerated value\r
+    args.re_vertalign = Alignment[args.re_vertalign]  # convert from str to enumerated value\r
+    args.ifexists = IfFileExists[args.ifexists]       # convert from str to enumerated value\r
+    args.verbosity = Verbosity[args.verbosity]        # convert from str to enumerated value\r
+\r
+    # flatten lists created by argparse. ex: args.src_hsl=[[1.0, 0.5, 1.0]] -> [1.0, 0.5, 1.0]\r
+    if args.src_hsl:\r
+        args.src_hsl = flattenList(args.src_hsl)\r
+\r
+    if args.src_wbmultipliers:                          # convert from list to WhiteBalanceMultipliers\r
+        args.src_wbmultipliers = WhiteBalanceMultipliers(red=args.src_wbmultipliers[0][0], blue=args.src_wbmultipliers[0][1])\r
+\r
+    args.re_algorithm = getattr(cv2, "INTER_" + args.re_algorithm) # convert algorithm as string into enumerated cv2 value\r
+\r
+    return args\r
+\r
+\r
+def calcSrcGeomAdjustments(srcDimensions: Dimensions, tgtDimensions: Dimensions, resizeGeom: ResizeGeom, fMaintainAspectRatio: bool, horzAlignment: Alignment, vertAlignment: Alignment) -> SrcGeomAdjustments:\r
+\r
+    """\r
+    Calculates the geometry adjustments (resize, crop, positioning) required to get a source image into\r
+    the raw image's size, based on a specified set of configurable parameters\r
+\r
+    :param srcDimensions: Source image dimensions\r
+    :param tgtDimensions: Target image dimensions (ie, the raw dimensions)\r
+    :param resizeGeom: Resize geometry to use\r
+    :param fMaintainAspectRatio: Whether or not aspect ratio of source image should be maintained\r
+    :param horzAlignment: Horizontal alignment\r
+    :param vertAlignment: Vertical alignment\r
+    :return: SrcGeomAdjustments, which specifies the adjustments that need to be made on the source image\r
+    """\r
+\r
+    def calcAxisCrop(srcAmount: int, tgtAmount: int, alignment: Alignment):\r
+        if srcAmount <= tgtAmount:\r
+            # not larger on column axis, no cropping on this axis is necessary\r
+            start = 0; end = srcAmount\r
+        else:\r
+            amountToCrop = srcAmount - tgtAmount\r
+            match alignment:\r
+                case Alignment.LEFT | Alignment.TOP:\r
+                    start = 0\r
+                    end = tgtAmount\r
+                case Alignment.CENTER:\r
+                    start = int(round(amountToCrop / 2))\r
+                    end = int(round(srcAmount - (amountToCrop / 2) - (amountToCrop % 2))) # % 2 to handle odd crop amounts\r
+                case Alignment.RIGHT | Alignment.BOTTOM:\r
+                    start = amountToCrop\r
+                    end = tgtAmount\r
+                case _:\r
+                    assert False, f"Uknown {alignment=}"\r
+        return (start, end)\r
+\r
+    def calcAxisPos(srcAmount: int, tgtAmount: int, alignment: Alignment):\r
+        if srcAmount >= tgtAmount:\r
+            # src is equal or larger than target, position at 0 in target\r
+            pos = 0\r
+        else:\r
+            amountShort = tgtAmount - srcAmount\r
+            match alignment:\r
+                case Alignment.LEFT | Alignment.TOP:\r
+                    pos = 0\r
+                case Alignment.CENTER:\r
+                    pos = int((tgtAmount/2) - (srcAmount/2))\r
+                case Alignment.RIGHT | Alignment.BOTTOM:\r
+                    pos = tgtAmount-srcAmount\r
+                case _:\r
+                    assert False, f"Uknown {alignment=}"\r
+        return pos\r
+\r
+\r
+    if srcDimensions == tgtDimensions:\r
+        # already at target dimensions, nothing to do\r
+        return None\r
+\r
+    #\r
+    # calculate resize enlargement\r
+    #\r
+    if (srcDimensions.columns >= tgtDimensions.columns) and (srcDimensions.rows >= tgtDimensions.rows):\r
+        # src is equal/larger vs tgt on both axis - no enlargement necessary (although a crop may be necessary)\r
+        resizeDimensions = None\r
+    else:\r
+        # src is smaller vs tgt on at least one axis\r
+        if resizeGeom == ResizeGeom.MINIMUM:\r
+            if not fMaintainAspectRatio:\r
+                # we already know just one axis needs enlargment, so enlarge that size and use original src side for other axis\r
+                if srcDimensions.columns < tgtDimensions.columns:\r
+                    # src columns needs to be enlarged, leve src rows the same\r
+                    resizeDimensions = Dimensions(columns=tgtDimensions.columns, rows=srcDimensions.rows)\r
+                else:\r
+                    # src rows needs to be enlarged, leve src columns the same\r
+                    resizeDimensions = Dimensions(columns=srcDimensions.columns, rows=tgtDimensions.rows)\r
+            else:\r
+                # need to maintain aspect ratio. determine which axis to enlarge\r
+                columnMultiplier = tgtDimensions.columns / srcDimensions.columns\r
+                rowMultiplier = tgtDimensions.rows / srcDimensions.rows\r
+                if columnMultiplier < rowMultiplier:\r
+                    # long edge is column side, so enlarge columns to tgt dimensions and rows to whatever size the aspect ratio allows\r
+                    resizeDimensions = Dimensions(columns=tgtDimensions.columns, rows=int(round(srcDimensions.rows * columnMultiplier)))\r
+                else:\r
+                    # long edge is row side, so enlarge rows to tgt dimensions and cols to whatever size the aspect ratio allows\r
+                    resizeDimensions = Dimensions(columns=int(round(srcDimensions.columns * rowMultiplier)), rows=tgtDimensions.rows)\r
+        elif resizeGeom == ResizeGeom.FULL:\r
+            if srcDimensions.columns < tgtDimensions.columns:\r
+                columnMultiplier = tgtDimensions.columns / srcDimensions.columns\r
+            else:\r
+                columnMultiplier = 1\r
+            if srcDimensions.rows*columnMultiplier < tgtDimensions.rows:\r
+                rowMultiplier = tgtDimensions.rows / (srcDimensions.rows * columnMultiplier)\r
+            else:\r
+                rowMultiplier = 1\r
+            totalMultiplier = columnMultiplier * rowMultiplier\r
+            resizeDimensions = Dimensions(int(round(srcDimensions.columns * totalMultiplier)), int(round(srcDimensions.rows * totalMultiplier)))\r
+        elif resizeGeom == ResizeGeom.NONE:\r
+            # resizing not enabled - src will be smaller than tgt on one axis\r
+            resizeDimensions = None\r
+        else:\r
+            assert False, f"Unknown {resizeGeom=}"\r
+\r
+    newDimensions = resizeDimensions if resizeDimensions else srcDimensions\r
+\r
+    #\r
+    # calculate positioning (if an one or more axis is shorter than tgt)\r
+    #\r
+    pos = Coordinate(x=calcAxisPos(newDimensions.columns, tgtDimensions.columns, horzAlignment),\r
+        y=calcAxisPos(newDimensions.rows, tgtDimensions.rows, vertAlignment))\r
+\r
+    #\r
+    # calcualte crop\r
+    #\r
+    if (newDimensions.columns <= tgtDimensions.columns) and (newDimensions.rows <= tgtDimensions.rows):\r
+        # new dimenions aren't larger than tgt dimensions on any axis, no cropping necessary\r
+        cropRect = None\r
+    else:\r
+        startx, endx = calcAxisCrop(newDimensions.columns, tgtDimensions.columns, horzAlignment)\r
+        starty, endy = calcAxisCrop(newDimensions.rows, tgtDimensions.rows, vertAlignment)\r
+        cropRect = Rect(startx=startx, endx=endx, starty=starty, endy=endy)\r
+\r
+    srcGeomAdjustments = SrcGeomAdjustments(resizeDimensions=resizeDimensions, posInTgt=pos, cropRect=cropRect)\r
+    return srcGeomAdjustments\r
+\r
+\r
+def execExternalProgram(executableFileName: str, cmdLineArgsList: List[str]) -> subprocess.CompletedProcess:\r
+\r
+    """\r
+    Executes an external app, waits for completion\r
+\r
+    :param executableFileName: Full path to executable\r
+    :param cmdLineArgsList: List structure with command-line arguments\r
+    :return: subprocess.run() return value, or None if error\r
+    """\r
+\r
+    fullCmdLine = [executableFileName] + cmdLineArgsList\r
+    try:\r
+        result = subprocess.run(fullCmdLine, check=False, capture_output=True, text=True)\r
+        return result\r
+    except FileNotFoundError as e:\r
+        printE(f"execExternalProgram() failed, \"{executableFileName}\" not found")\r
+        return None\r
+    except Exception as e:\r
+        printE(f"execExternalProgram(\"{executableFileName}\") failed: {e}")\r
+        return None\r
+\r
+\r
+def extractExifFromNEF(nefFilename: str) -> types.SimpleNamespace:\r
+\r
+    """\r
+     Extracts useful EXIF info we need from the template NEF\r
+\r
+    :param nefFilename: Full path to NEF filename\r
+    :return: Namespace with EXIF fields, or None if error\r
+    """\r
+\r
+    def reportExifFieldNotFound(fieldName: str) -> None:\r
+        printE(f"EXIF: Could not find field '{fieldName}' in NEF EXIF for \"{nefFilename}\"")\r
+        return None\r
+\r
+    def extractEmbeddedJpgExifInfo(exifName: str, lenDescSuffix: str) -> EmbeddedJpgExifInfo:\r
+\r
+        """\r
+        Extracts EXIF info about the specified embedded jpg\r
+\r
+        :param exifName: EXIF name of jpg, as named by exiftool\r
+        :param lenDescSuffix: Suffix applied to exifName for length field\r
+        :return: EmbeddedJpgExifInfo for embedded jpg, or None if the EXIF field doesn't exist\r
+        or error extracting its info\r
+        """\r
+\r
+        jpgStartDescInExif  = f"{exifName}{lenDescSuffix}"    # ex: exifName="JpgFromRaw" -> "JpgFromRawStart"\r
+        jpgLengthDescInExif = f"{exifName}Length"             # ex: exifName="JpgFromRaw" -> "JpgFromRawLength"\r
+\r
+        # extract starting offset to jpg\r
+        m = re.search(rf'{jpgStartDescInExif} = (\d+)', ifd0Str)\r
+        if m is None: return None\r
+        if exifName != "PreviewImage":\r
+            jpgStart = int(m.group(1))\r
+        else:\r
+            # ignore -v3 value and use separate tag value (exiftool bug) for "PreviewImage"\r
+            jpgStart = exif.PreviewImageStart\r
+\r
+        '''\r
+        Sample output that we're parsing to get the strip byte count and file offset to strip byte count\r
+          | | 6)  JpgFromRawLength = 503293\r
+          | |     - Tag 0x0202 (4 bytes, int32u[1]):\r
+          | |        22d68: fd ad 07 00                                     [....]\r
+        '''\r
+        m = re.search(rf'{jpgLengthDescInExif} = (\d+).*\n.*Tag.*\n.*?([0-9a-f]+):', ifd0Str)\r
+        if (m is None): return reportExifFieldNotFound(f"{jpgLengthDescInExif}")\r
+        jpgLength = int(m.group(1))\r
+        offsetToJpgLengthField = int(m.group(2), 16)\r
+        return EmbeddedJpgExifInfo(exifName=exifName, start=jpgStart, length=jpgLength, offsetToLengthField=offsetToJpgLengthField)\r
+\r
+    exif = types.SimpleNamespace()\r
+\r
+    # run exiftool in verbose v3 mode\r
+    # note we request the PreviewImageStart tag seperately because the value reported by -v3 isn't corect (https://exiftool.org/forum/index.php?topic=17665.0)\r
+    cmdLineArgList = ['-v3', '-PreviewImageStart', nefFilename];\r
+    result = execExternalProgram("exiftool", cmdLineArgList)\r
+\r
+    if result is None:\r
+        printE(f"Failed to execute exiftool on \"{nefFilename}\"")\r
+        return None\r
+    if result.stderr:\r
+        printE(f"exiftool reported:\n{result.stderr}")\r
+        return None\r
+\r
+    fullExifStr = result.stdout\r
+\r
+    # make sure the file specified is interprted as a valid NEF by exiftool (FileType = NEF)\r
+    m = re.search(r'FileType = (.+)', fullExifStr)\r
+    if (m.group(1) != "NEF"):\r
+        printE(f"\"{nefFilename}\" is not a valid Nikon NEF file")\r
+        return None\r
+\r
+    #\r
+    # parse the exiftool output to extract the fields we need\r
+    #\r
+\r
+    # extract byte order (ie, endian) of EXIF data\r
+    m = re.search(r'ExifByteOrder = (.+)', fullExifStr)\r
+    if m is None: return reportExifFieldNotFound("ExifByteOrder")\r
+    match m.group(1):\r
+        case "II": # "Intel"\r
+            exif.endian = Endian.LITTLE\r
+        case "MM": # "Motorola"\r
+            exif.endian = Endian.BIG\r
+        case _:\r
+            printE(f"EXIF: Unkown byte order value of \"{m.group(1)}\" for \"{nefFilename}\"")\r
+            return None\r
+\r
+    '''\r
+    Get to IFD0. Sample output that we're parsing:\r
+      + [IFD0 directory with 22 entries]\r
+    '''\r
+    m = re.search(r'.*\+ \[IFD0 directory', fullExifStr)\r
+    if m is None: return reportExifFieldNotFound("IFD0")\r
+    ifd0Str = fullExifStr[m.start():]\r
+\r
+    # extract model of camera\r
+    m = re.search(r'Model = (.+)', ifd0Str)\r
+    if m is None: return reportExifFieldNotFound("Model")\r
+    exif.cameraModel = m.group(1)\r
+\r
+    # obtain PreviewImageStart via the separate tag report because the value reported\r
+    # by -v3 isn't corect (https://exiftool.org/forum/index.php?topic=17665.0)\r
+    m = re.search(r'Preview Image Start\s+: (.+)', fullExifStr) # sample: "Preview Image Start             : 104744"\r
+    if m is None: return reportExifFieldNotFound("PreviewImageStart")\r
+    exif.PreviewImageStart = int(m.group(1))\r
+\r
+    # embedded jpgs\r
+    exif.embeddedJpgs = list()\r
+    for embeddedJpgExifName in EmbeddedJpgExifNames:\r
+        e = extractEmbeddedJpgExifInfo(embeddedJpgExifName, "Offset" if embeddedJpgExifName == "Thumbnail" else "Start")\r
+        if e is not None:\r
+            exif.embeddedJpgs.append(e)\r
+\r
+    # find the SubIFD with a subfiletype of 0, which is the NEF raw area\r
+    m = re.search(r'SubfileType = 0', ifd0Str)\r
+    if m is None: return reportExifFieldNotFound("SubfileType of 0")\r
+    subIFDStr = ifd0Str[m.start():]\r
+\r
+    # extract ImageWidth and ImageHeight\r
+    m1 = re.search(r'ImageWidth = (\d+)', subIFDStr)\r
+    m2 = re.search(r'ImageHeight = (\d+)', subIFDStr)\r
+    if (m1 is None) or (m2 is None): return reportExifFieldNotFound("ImageWidth/ImageHeight")\r
+    exif.rawDimensions = Dimensions(columns=int(m1.group(1)), rows=int(m2.group(1)))\r
+\r
+    # extract BitsPerSample\r
+    m = re.search(r'BitsPerSample = (\d+)', subIFDStr)\r
+    if m is None: return reportExifFieldNotFound("BitsPerSample")\r
+    exif.bitsPerSample = int(m.group(1))\r
+    if exif.bitsPerSample != 14:\r
+        printE(f'Template NEF "{nefFilename}" is a {exif.bitsPerSample}-bit raw; only 14-bit raws are supported')\r
+        return None\r
+\r
+\r
+    # extract StripOffset\r
+    m = re.search(r'StripOffsets = (\d+)', subIFDStr)\r
+    if m is None: return reportExifFieldNotFound("StripOffsets")\r
+    exif.stripOffset = int(m.group(1))\r
+\r
+    '''\r
+    Sample output that we're parsing to get the strip byte count and file offset to strip byte count\r
+      | | 9)  StripByteCounts = 23706986\r
+      | |     - Tag 0x0117 (4 bytes, int32u[1]):\r
+      | |        22e02: 6a bd 69 01                                     [j.i.]\r
+    '''\r
+    m = re.search(r'StripByteCounts = (\d+).*\n.*Tag.*\n.*?([0-9a-f]+):', subIFDStr)\r
+    if (m is None): return reportExifFieldNotFound("StripByteCounts")\r
+    exif.stripByteCount = int(m.group(1))\r
+    exif.fileOffsetToStripByteCount = int(m.group(2), 16)\r
+\r
+    #\r
+    # move on to Maker Notes\r
+    #\r
+    m = re.search(r'ExifIFD directory.*MakerNotes directory', ifd0Str, re.DOTALL)\r
+    if (m is None): return reportExifFieldNotFound("MakerNotes")\r
+    makerNotesStr = ifd0Str[m.start():]\r
+\r
+    '''\r
+    Sample output that we're parsing to get the Red and Black White Balance multipliers\r
+      | | | 6)  WB_RBLevels = 1.916015625 1.2578125 1 1 (981/512 644/512 512/512 512/512)\r
+    '''\r
+    m = re.search(r'.*WB_RBLevels = ([\d\.]+) ([\d\.]+)', makerNotesStr)\r
+    if (m is None): return reportExifFieldNotFound("WB_RBLevels")\r
+    exif.wbMultipliers = WhiteBalanceMultipliers(float(m.group(1)), float(m.group(2)))\r
+\r
+    m = re.search(r'.*NEFCompression = (\d+)', makerNotesStr)\r
+    if (m is None): return reportExifFieldNotFound("NEFCompression")\r
+    exif.nefCompressionType = int(m.group(1))\r
+    if exif.nefCompressionType != 3:\r
+        printE(f'Template NEF "{nefFilename}" is not using lossless compression')\r
+        return None\r
+\r
+    m = re.search(r'.*BlackLevel = (\d+)', makerNotesStr)\r
+    if m:\r
+        exif.blackLevel = int(m.group(1))\r
+    else:\r
+        # no blacklevel field. Assume it's an older model that subtracted blacks in-camera\r
+        printV("EXIF: No BlackLevel field found; assuming a black level of zero")\r
+        exif.blackLevel = 0\r
+\r
+    '''\r
+    Sample output for extracting the initial "pred" value for NEF lossless compression.\r
+    We want the little-endian 16-bit value starting at offset 2 into the NEFLinearizationTable (usually 0x800)\r
+      | | | 64) NEFLinearizationTable = F0....".*...r^.....e.... ../......Z!X...6\r
+      | | |     - Tag 0x0096 (46 bytes, undef[46]):\r
+      | | |        19584: 46 30 00 08 00 08 00 08 00 08 22 00 b7 2a aa 9d [F0........"..*..]\r
+      | | |        19594: e0 72 5e 91 18 f5 1c 99 65 82 f0 af bf 20 d2 d2 [.r^.....e.... ..]\r
+      | | |        195a4: 2f c6 c1 02 a7 86 c5 5a 21 58 d8 a6 ca 36       [/......Z!X...6]\r
+    '''\r
+    m = re.search(r'.*NEFLinearizationTable .*\n.*Tag.*\n.*?[0-9a-f]+: [0-9a-f][0-9a-f] [0-9a-f][0-9a-f] ([0-9a-f][0-9a-f]) ([0-9a-f][0-9a-f])', makerNotesStr)\r
+    if (m is None): return reportExifFieldNotFound("NEFLinearizationTable")\r
+    if exif.endian == Endian.LITTLE:\r
+        predValueStr = m.group(2)+m.group(1)\r
+    else:\r
+        predValueStr = m.group(1)+m.group(2)\r
+    exif.predValueNefCompression = int(predValueStr, 16)\r
+\r
+    '''\r
+     Sample output for extracting the initial "CropArea" values\r
+       | | | 35) CropArea = 8 4 6048 4032\r
+    '''\r
+    m = re.search(r'.*CropArea = (\d+) (\d+) (\d+) (\d+)', makerNotesStr)\r
+    if m:\r
+        exif.cropAreaDimensions = NikonCropArea(left=int(m.group(1)), top=int(m.group(2)), columns=int(m.group(3)), rows=int(m.group(4)))\r
+    else:\r
+        # older models don't have a CropArea tag\r
+        exif.cropAreaDimensions = None\r
+\r
+    return exif\r
+\r
+\r
+def src_EncodeToNikonLossless(bayerImage: np.ndarray[tuple[int, int], np.dtype[np.uint16]]) -> bytearray:\r
+\r
+    """\r
+    Encodes a bayered RGGB image using Nikon's lossless NEF compression, using my optimized 'C' logic\r
+\r
+    :param bayerImage: Bayered image to encode\r
+    :return: Encoded data (bytearray)\r
+    """\r
+\r
+    #\r
+    # load the compiled 'C' shared library. first we check the base directory of our script,\r
+    # in case the user compiled a version for a non-standard platform. if that doesn't exist\r
+    # then we'll load the pre-compiled version based on the standard platform we're running under\r
+    #\r
+    def loadSharedLibrary(path: str):\r
+        try:\r
+            lib = ctypes.CDLL(path)\r
+            return (lib, None)\r
+        except Exception as e:\r
+            return (None, f"Unable to open/read shared library {path}, error: {e}")\r
+\r
+    libNefencode, errorMsg = loadSharedLibrary('./nefencode2.so')\r
+    if libNefencode is None:\r
+        libDir = platform.system()\r
+        if libDir == "Darwin":\r
+            libDir = "Mac"\r
+        scriptDir = getScriptDir()\r
+        libNefencode, errorMsg = loadSharedLibrary(f"{scriptDir}/{libDir}/nefencode.so")\r
+        if libNefencode is None:\r
+            printE(errorMsg)\r
+            return None\r
+\r
+    # define python version of the NEF_ENCODE_PARAMS structure in nefencode.c\r
+    class NefEncodeParams(ctypes.Structure):\r
+        _fields_ = [\r
+            ("countColumns", ctypes.c_int),\r
+            ("countRows", ctypes.c_int),\r
+            ("sourceBufferSizeBytes", ctypes.c_int),\r
+            ("outputBufferSizeBytes", ctypes.c_int),\r
+            ("startingPredictiveValue", ctypes.c_uint16),\r
+            ("pad1", ctypes.c_uint16),\r
+            ("sourceData", ctypes.POINTER(ctypes.c_uint16)),\r
+            ("outputBuffer", ctypes.POINTER(ctypes.c_uint8)),\r
+        ]\r
+\r
+    nefEncodeParams = NefEncodeParams()\r
+\r
+    # configure paramters and return value to "t_NefEncodeError NefEncode(NEF_ENCODE_PARAMS *params)"\r
+    libNefencode.NefEncode.argtypes = [ ctypes.POINTER(NefEncodeParams) ]\r
+    libNefencode.NefEncode.restype = ctypes.c_int32\r
+\r
+    # build NefEncodeParams\r
+\r
+    sourceImageSizeBytes = bayerImage.nbytes\r
+    outputBufferSizeBytes = sourceImageSizeBytes + 1048576 # +1MB is somewhat arbitrary\r
+    outputBuffer = bytearray(outputBufferSizeBytes)\r
+    outputBufferCtypeArray = (ctypes.c_uint8 * outputBufferSizeBytes)\r
+\r
+    nefEncodeParams.countColumns = Config.exif.rawDimensions.columns\r
+    nefEncodeParams.countRows = Config.exif.rawDimensions.rows\r
+    nefEncodeParams.sourceBufferSizeBytes = sourceImageSizeBytes\r
+    nefEncodeParams.outputBufferSizeBytes = outputBufferSizeBytes\r
+    nefEncodeParams.startingPredictiveValue = Config.exif.predValueNefCompression\r
+    nefEncodeParams.pad1 = 0\r
+    nefEncodeParams.sourceData = bayerImage.ctypes.data_as(ctypes.POINTER(ctypes.c_uint16))\r
+    nefEncodeParams.outputBuffer = outputBufferCtypeArray.from_buffer(outputBuffer)\r
+\r
+    assert bayerImage.flags['C_CONTIGUOUS'] == True, "Image numpy array isn't contiguous!"\r
+\r
+    # call nefencode.c::NefEncode() in shared library to encode the data\r
+    result = libNefencode.NefEncode(ctypes.byref(nefEncodeParams))\r
+\r
+    if result < 0:\r
+        printE(f"Compressing image data into Nikon lossless failed - error={result}")\r
+        return None\r
+\r
+    return outputBuffer[:result]\r
+\r
+\r
+def splitPathIntoParts(fullPath: str) -> tuple[str, str, str]:\r
+\r
+    """\r
+    Splits path into parts (directory, root filename, and extension)\r
+\r
+    :param fullPath: Full path to split\r
+    :return: Tuple containing (directory, root filename, extension)\r
+    """\r
+\r
+    dir, filename = os.path.split(fullPath)\r
+    root, ext = os.path.splitext(filename)\r
+    return (dir, root, ext)\r
+\r
+\r
+def generateFilenameWithDifferentExtensionAndDir(fullPath: str, newDir: str, newExt: str) -> str:\r
+\r
+    """\r
+    Generates filename based on existing filename but with different extension\r
+\r
+    :param fullPath: Full path to filename\r
+    :param newDir: New directory, or None to use existing directory of fullPath\r
+    :param newExt: New extension (with the leading period), or None to use existing extension\r
+    :return: Generated full path to filename with changed extension\r
+    """\r
+\r
+    dir, root, ext = splitPathIntoParts(fullPath)\r
+    if newDir is not None:\r
+        dir = newDir\r
+    if newExt is not None:\r
+        if newExt[0] != '.': newExt = '.' + newExt\r
+        ext = newExt\r
+    return os.path.join(dir, root + ext)\r
+\r
+\r
+def generateUniqueFilenameFromExistingIfNecessary(fullPath: str) -> str:\r
+\r
+    """\r
+    If a file with the specified name exists, adds a numerical suffix to the filename\r
+    to make it a unique filename for the path the file is in\r
+\r
+    :param fullPath: Full path to original filename\r
+    :return: fullPath if a file with that name doesn't already exist, or a\r
+    fullPath with a unique suffix\r
+    """\r
+\r
+    dir, root, ext = splitPathIntoParts(fullPath)\r
+\r
+    seqNum = 0; seqNumStr = "" # first candidate is without a suffix\r
+    while True:\r
+        filenameCandidate = os.path.join(dir, root + seqNumStr + ext)\r
+        if not os.path.exists(filenameCandidate):\r
+            return filenameCandidate\r
+        seqNum += 1\r
+        seqNumStr = f"-{seqNum}"\r
+\r
+\r
+def generateOutputFilename() -> str:\r
+\r
+    """\r
+    Generates the filename to hold encoded output, based on user settings\r
+\r
+    :return: Output filename, or None if output filename couldn't be determined.\r
+    """\r
+\r
+    outputFilename = Config.args.outputfilename\r
+    if outputFilename is None:\r
+\r
+        # user didn't specify an output filename. generate one based on the template NEF and input filenames\r
+\r
+        templateNefDir, templateNefRootName, _ = splitPathIntoParts(Config.args.templatenef)\r
+        inputDir, inputRootName, _ = splitPathIntoParts(Config.args.inputfilename)\r
+\r
+        match Config.args.outputfilenamemethod:\r
+            case OutputFilenameMethod.NONE:\r
+                printE(f"No output filename specified and --outputfilenamemethod is NONE. Either specify an output filename or a different --outputfilenamemethod")\r
+                return None\r
+            case OutputFilenameMethod.TEMPLATENEF_AND_INPUTFILE:\r
+                outputFilename = f"{os.path.join(inputDir, templateNefRootName)}_{inputRootName}"\r
+            case OutputFilenameMethod.TEMPLATENEF:\r
+                outputFilename = f"{os.path.join(inputDir, templateNefRootName)}"\r
+            case OutputFilenameMethod.INPUTFILE:\r
+                outputFilename = f"{os.path.join(inputDir, inputRootName)}"\r
+\r
+        outputFilename = generateFilenameWithDifferentExtensionAndDir(outputFilename, Config.args.outputdir, "NEF")\r
+    else:\r
+        outputFilename = generateFilenameWithDifferentExtensionAndDir(outputFilename, Config.args.outputdir, None)\r
+\r
+    match Config.args.ifexists:\r
+        case IfFileExists.ADDSUFFIX:\r
+            outputFilename = generateUniqueFilenameFromExistingIfNecessary(outputFilename)\r
+        case IfFileExists.OVERWRITE:\r
+             pass\r
+        case IfFileExists.EXIT:\r
+            if os.path.exists(outputFilename):\r
+                printE(f"Output file \"{outputFilename}\" already exists. Exiting per --ifexists setting")\r
+                return None\r
+\r
+    return outputFilename\r
+\r
+\r
+def generateEmbeddedJpg(image: np.ndarray, embeddedJpgDimensions: Dimensions, maxSizeBytes: int, embeddedJpgExifName: str) -> np.ndarray:\r
+\r
+    """\r
+    Generates an in-memory jpg suitable for embedding into the output raw\r
+\r
+    :param image: Processed source image to generate the embedded jpg from\r
+    :param embeddedJpgDimensions: Dimensions of this embedded jpg\r
+    :param maxSizeBytes: Maximum size of embedded jpg that will fit within the EXIF of the template NEF\r
+    :param embeddedJpgExifName: Name of this embedded JPG in EXIF (used for debug prints only)\r
+    :return: Generated in-memory JPG image or None if unable to generate within the specified size constraint\r
+    """\r
+\r
+    # resize image to match embedded jpg dimensions, if necessary\r
+    imageDimensions = Dimensions(rows=image.shape[0], columns=image.shape[1])\r
+    if imageDimensions != embeddedJpgDimensions:\r
+        printV(f"Resizing image from {imageDimensions.columns}x{imageDimensions.rows} to {embeddedJpgDimensions.columns}x{embeddedJpgDimensions.rows} for \"{embeddedJpgExifName}\" embedded jpg")\r
+        image = cv2.resize(image, (embeddedJpgDimensions.columns, embeddedJpgDimensions.rows), interpolation=Config.args.re_algorithm)\r
+\r
+    #\r
+    # use PIL to generate the embedded jpg from he source data. We use PIL instead of cv2\r
+    # because cv2 doesn't support 4:2:2 encoding and Nikon cameras require 4:2:2 for their\r
+    # in-camera playback functionality\r
+    #\r
+    pilImage = Image.fromarray(image)\r
+    quality = 100\r
+    while True:\r
+        memoryFile = BytesIO()\r
+        pilImage.save(memoryFile, format='JPEG', subsampling='4:2:2', quality=quality)\r
+        jpg = memoryFile.getvalue()\r
+        jpgSizeBytes = len(jpg)\r
+        printD(f"Generated embedded jpg \"{embeddedJpgExifName}\" (qual={quality}): {jpgSizeBytes:,} bytes vs max fit {maxSizeBytes:,} bytes")\r
+        if jpgSizeBytes <= maxSizeBytes:\r
+            return jpg\r
+        if quality <= 20: # minimum quality we attempt is 20 (arbitrary)\r
+            break;\r
+        quality -= 10\r
+    printW(f"Unable to generate embedded jpg \"{embeddedJpgExifName}\" that fits within {maxSizeBytes:,} bytes - last attempt produced {jpgSizeBytes:,} bytes at a quality level of {quality}")\r
+    return None\r
+\r
+\r
+def generatePlaceholderEmbeddedJpg(embeddedJpgDimensions: Dimensions, maxSizeBytes: int, embeddedJpgExifName: str) -> np.ndarray:\r
+\r
+    """\r
+    Generates an placeholder in-memory jpg suitable for embedding into the output raw. The placeholder\r
+    is a simple all-black image with a single line of text\r
+\r
+    :param embeddedJpgDimensions: Dimensions of this embedded jpg\r
+    :param maxSizeBytes: Maximum size of embedded jpg that will fit within the EXIF of the template NEF\r
+    :param embeddedJpgExifName: Name of this embedded JPG in EXIF (used for debug prints only)\r
+    :return: Generated in-memory JPG image or None if unable to generate within the specified size constraint\r
+    """\r
+\r
+    #\r
+    # use PIL to generate an in-memory image of the specified dimensions. The image is all black except\r
+    # for a single line of text\r
+    #\r
+    pilImage = Image.new('RGB', (embeddedJpgDimensions.columns, embeddedJpgDimensions.rows), color = 'black')\r
+    draw = ImageDraw.Draw(pilImage)\r
+\r
+    text = f"{embeddedJpgExifName}, {embeddedJpgDimensions.columns} x {embeddedJpgDimensions.rows}"\r
+\r
+    #\r
+    # iterate through font sizes until we find one that allows our single line of text\r
+    # to fill out the horizontal axis entirely\r
+    #\r
+    centerX = embeddedJpgDimensions.columns/2\r
+    centerY = embeddedJpgDimensions.rows/2\r
+    fontSize = 512\r
+    while fontSize >= 8:\r
+        defaultFont = ImageFont.load_default(size=fontSize)\r
+        left, top, right, bottom = draw.textbbox((centerX, centerY), text, font=defaultFont, anchor="mm")\r
+        textWidthPixels = int(right-left)\r
+        if textWidthPixels < embeddedJpgDimensions.columns:\r
+            # found a good font size\r
+            break\r
+        multiple  = textWidthPixels // embeddedJpgDimensions.columns\r
+        if multiple >= 2:\r
+            fontSize //= multiple\r
+        else:\r
+            fontSize = int(math.floor(fontSize * .95))\r
+    draw.text((centerX,centerY), text, color="white", font=defaultFont, anchor="mm")\r
+\r
+    #\r
+    # save our drawing canvas to an in-memory jpg. unlike the normal embedded jpg case,\r
+    # it doesn't make sense to iterate different quality levels to meet the size constraint\r
+    # because the difference in sizes for such a simple image are negligible\r
+    #\r
+    memoryFile = BytesIO()\r
+    pilImage.save(memoryFile, format='JPEG', subsampling='4:2:2', quality=50)\r
+    jpg = memoryFile.getvalue()\r
+    jpgSizeBytes = len(jpg)\r
+    if jpgSizeBytes > maxSizeBytes:\r
+        printW(f"Unable to generate placeholder embedded jpg \"{embeddedJpgExifName}\" that fits within {maxSizeBytes:,} bytes - last attempt produced {jpgSizeBytes:,} bytes")\r
+        return None\r
+    printD(f"Generated placeholder embedded jpg \"{embeddedJpgExifName}\", {fontSize=}, {jpgSizeBytes:,} bytes")\r
+    return jpg\r
+\r
+\r
+def generateAndInsertEmbeddedJpgs(image: np.ndarray, newNefData: bytearray) -> bool:\r
+\r
+    """\r
+    Geneates and inserts (overwrites) embedded JPGs into NEF data in memory\r
+\r
+    :param image: Fully-processed source image to use as embedded jpg, or None if not available\r
+    :param newNefData: Bytearray containing entire template NEF, minus the raw data\r
+    :return: False if successful, True if error\r
+    """\r
+\r
+    def loadImageToUseAsEmbeddedJpg(filename: str) -> np.ndarray:\r
+\r
+        """\r
+        Loads a separate image file to use as the embedded jpg\r
+\r
+        :param filename: Filename of image\r
+        :return: Image, resized to the raw dimensions, with no specific dtype, or None if unable to load image\r
+        """\r
+\r
+        image = loadImage(filename)\r
+        if image is None:\r
+            return None\r
+        fImageResized, image = resizeImgToRawDimensions(image, f'"{filename}"')\r
+        return image\r
+\r
+\r
+    def getEmbeddedJpgDimensions(exifFieldName: str, origJpgStart: int, origJpgSize: int):\r
+\r
+        """\r
+        Determines dimensions of existing embedded JPG by telling CV2 to decode our in-memory copy of the\r
+        jpg. We have to do this because Nikon doesn't provide the dimensions of the embedded jpgs in a\r
+        separate EXIF field.\r
+\r
+        :param exifFieldName: EXIF field name for embedded jpg\r
+        :param origJpgStart: Offset to embedded jpg in template NEF\r
+        :param origJpgSize: Size embedded jpg in template NEF\r
+        :return: Dimensions of embedded jpg\r
+        """\r
+\r
+        jpgData = np.frombuffer(newNefData[origJpgStart : origJpgStart + origJpgSize], dtype=np.uint8)\r
+        jpgFromData = cv2.imdecode(jpgData, cv2.IMREAD_UNCHANGED)\r
+        if jpgFromData is None:\r
+            printE(f"Unable to decode embedded jpg \"{exifFieldName}\"")\r
+            return None\r
+        return Dimensions(rows=jpgFromData.shape[0], columns=jpgFromData.shape[1])\r
+\r
+\r
+    #\r
+    # if user specified their own image to use as the embedded image then load\r
+    # it now, otherwise use the image generated from the input source image (if it's available)\r
+    #\r
+    if Config.args.embeddedimg:\r
+        image = loadImageToUseAsEmbeddedJpg(Config.args.embeddedimg)\r
+    if image is not None:\r
+        #\r
+        # convert the image to 8-bit RGB, then crop using Nikon's crop field in EXIF. This\r
+        # image will serve as the source for all the embedded JPGs.\r
+        #\r
+        _, image8 = convertImageNumpyTypeIfNecessary(image, "uint8")\r
+        image8 = cv2.cvtColor(image8, cv2.COLOR_BGR2RGB)\r
+        if Config.exif.cropAreaDimensions:\r
+            cropRect = Rect(startx = Config.exif.cropAreaDimensions.left,\r
+                endx = Config.exif.cropAreaDimensions.left + Config.exif.cropAreaDimensions.columns,\r
+                starty = Config.exif.cropAreaDimensions.top,\r
+                endy = Config.exif.cropAreaDimensions.top +  Config.exif.cropAreaDimensions.rows)\r
+            image8 = image8[cropRect.starty : cropRect.endy, cropRect.startx : cropRect.endx]\r
+    else:\r
+        # no image available, either from the input source imnage or a separate image file specified\r
+        printW("No image available for embedded jpgs - will generate a placeholder image")\r
+        image8 = None\r
+\r
+    #\r
+    # generate and insert each of the embedded JPGs\r
+    #\r
+    for embeddedJpgExif in Config.exif.embeddedJpgs:\r
+\r
+        # get parameters of the embedded jpg\r
+        jpgExifName = embeddedJpgExif.exifName\r
+        origJpgStart = embeddedJpgExif.start\r
+        origJpgSize = embeddedJpgExif.length\r
+        offsetToOrigJpgSizeInTemplateNef = embeddedJpgExif.offsetToLengthField\r
+\r
+        dimensions = getEmbeddedJpgDimensions(jpgExifName, origJpgStart, origJpgSize)\r
+        if dimensions is None:\r
+            return True\r
+\r
+        #\r
+        # generate a new embedded jpg of the same dimensions, restricting its size to the\r
+        # existing embedded jpg since we're overwriting it memory and don't support rearranging\r
+        # exif fields to make room for larger jpgs\r
+        #\r
+        if image8 is not None:\r
+            embeddedJpg = generateEmbeddedJpg(image8, dimensions, origJpgSize, jpgExifName)\r
+        else:\r
+            embeddedJpg = None\r
+        if embeddedJpg is None:\r
+            # failed to fit an embedded image or none was available - try a placeholder image\r
+            embeddedJpg = generatePlaceholderEmbeddedJpg(dimensions, origJpgSize, jpgExifName)\r
+\r
+        #\r
+        # insert (overwrite) new jpg in memory. if we couldn't generate the jpg due to size\r
+        # constraint then skip this embedded jpg while still allowing the new NEF to be generated\r
+        #\r
+        if embeddedJpg is not None:\r
+            # overwrite original embedded jpg with the one we generated\r
+            newJpgSizeBytes = len(embeddedJpg)\r
+            printV(f"Overwriting \"{jpgExifName}\" at offset 0x{origJpgStart:x}, len={newJpgSizeBytes:,} [old size = {origJpgSize:,}]")\r
+            newNefData[origJpgStart : origJpgStart + newJpgSizeBytes] = embeddedJpg\r
+            # if new jpg is smaller than existing, fill unused portion with zeros (not necessary but for debug clarity)\r
+            if (countUnusedBytes := origJpgSize-newJpgSizeBytes) > 0:\r
+                newNefData[origJpgStart+newJpgSizeBytes : origJpgStart+origJpgSize] = bytes([0x0] * countUnusedBytes)\r
+            # update EXIF size field\r
+            newNefData[offsetToOrigJpgSizeInTemplateNef : offsetToOrigJpgSizeInTemplateNef+4] = struct.pack('<I', newJpgSizeBytes)\r
+\r
+    return False\r
+\r
+\r
+def writeOutputNEF(image: np.ndarray, encodedImageData: bytearray) -> bool:\r
+\r
+    """\r
+    Writes losslessly-encoded image data into a Nikon NEF raw file\r
+\r
+    :param image: Fully-processed source image (or None if not available)\r
+    :param encodedImageData: Encoded image data (bytearray)\r
+    :return: False if successful, TRUE if error\r
+    """\r
+\r
+    #\r
+    # read entire template NEF up to where the raw data starts but not the raw data itself,\r
+    # since we'l be replacing that with the new raw data we've generated\r
+    #\r
+    encodedImageDataSize = len(encodedImageData)\r
+    templateNefSizeExcludingRawData = Config.exif.stripOffset\r
+    try:\r
+        with open(Config.args.templatenef, 'rb') as f:\r
+            templateNefData = f.read(templateNefSizeExcludingRawData)\r
+    except Exception as e:\r
+        printE(f"Unable to open/read template NEF \"{Config.args.templatenef}\", error: {e}")\r
+        return True\r
+\r
+    # create mutable buffer we'll use to hold template NEF data and the data we're merging into it\r
+    newNefData = bytearray(templateNefSizeExcludingRawData + encodedImageDataSize)\r
+\r
+    # copy data from template NEF up to where the raw data starts\r
+    newNefData[0:templateNefSizeExcludingRawData] = templateNefData\r
+\r
+    #\r
+    # copy the raw data we encoded (compressed), which inludates updating the 32-bit strip count\r
+    # field that reflects the size of our encoded data\r
+    #\r
+    newNefData[templateNefSizeExcludingRawData:] = encodedImageData\r
+    newNefData[Config.exif.fileOffsetToStripByteCount:Config.exif.fileOffsetToStripByteCount+4] = struct.pack('<I', encodedImageDataSize)\r
+\r
+    #\r
+    # generate+insert embedded JPGs. We ignore errors for this because the embedded jpgs aren't essential\r
+    #\r
+    generateAndInsertEmbeddedJpgs(image, newNefData)\r
+\r
+    #\r
+    # determine the output filename\r
+    #\r
+    outputFilename = generateOutputFilename()\r
+    if not outputFilename:\r
+        return True\r
+\r
+    #\r
+    # write out the NEF\r
+    #\r
+    try:\r
+        with open(outputFilename, 'wb') as f:\r
+            f.write(newNefData)\r
+    except Exception as e:\r
+        printE(f"Unable to create/write output NEF \"{outputFilename}\", error: {e}")\r
+        return True\r
+\r
+    printI(f"Successfully generated \"{os.path.realpath(outputFilename)}\"")\r
+    if Config.args.openinviewer:\r
+        openFileInOS(outputFilename) # we ignore any viewer errors since it's not an essential operation\r
+\r
+    return False\r
+\r
+\r
+def src_Convert_8BitTo16Bit(image: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+    Converts image from 8-bit to 16-bit integer, if it's not already 16-bits\r
+\r
+    :param image: Image to convert\r
+    :return: (bool, image) True with new if image was converted, False with original image otherwise\r
+    """\r
+\r
+    assert (image.dtype == np.uint8) or (image.dtype == np.uint16), f"src_Convert_8BitTo16Bit: Expected image to be either 8-bit or 16-bits but it is \"{image.dtype}\""\r
+    origType, image = convertImageNumpyTypeIfNecessary(image, "uint16")\r
+    return (origType != image.dtype, image)\r
+\r
+\r
+def src_Convert16BitToNormalizedFloat(image: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+    Converts image from 16-bit uint to normalized float (ie, values from 0.0 to 1.0)\r
+\r
+    :param image: Image to convert\r
+    :return: (bool, image) True with new if image was converted, False with original image otherwise\r
+    """\r
+\r
+    origType, image = convertImageNumpyTypeIfNecessary(image, ImgFloatType)\r
+    return (origType != image.dtype, image)\r
+\r
+\r
+def src_ConvertNormalizedFloatTo16Bit(image: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+    Converts image from normalized float (ie, values from 0.0 to 1.0) to 16-bit uint\r
+\r
+    :param image: Image to convert\r
+    :return: (bool, image) True with new if image was converted, False with original image otherwise\r
+    """\r
+\r
+    origType, image = convertImageNumpyTypeIfNecessary(image, "uint16")\r
+    return (origType != image.dtype, image)\r
+\r
+\r
+def resizeImgToRawDimensions(image: np.ndarray, desc: str) -> np.ndarray:\r
+\r
+    """\r
+    Resizes image based on user's configuration\r
+\r
+    :param image: Image to resize\r
+    :param desc: Description of image, to be used in debug prints\r
+    :return: (bool, image) True with new if image was generated, False with original image otherwise\r
+    """\r
+\r
+    origSrcDimensions = Dimensions(rows=image.shape[0], columns=image.shape[1])\r
+    outputDimensions = Config.exif.rawDimensions;\r
+\r
+    printI(f"{desc} image dimensions: {origSrcDimensions.columns}x{origSrcDimensions.rows}, Raw Dimensions: {outputDimensions.columns}x{outputDimensions.rows}",)\r
+    srcGeomAdjustments = calcSrcGeomAdjustments(origSrcDimensions, outputDimensions,\r
+        Config.args.re_geometry, Config.args.re_maintainaspectratio, Config.args.re_horzalign, Config.args.re_vertalign)\r
+    printD(f"Resize: {desc} Calculated adjustments: {srcGeomAdjustments}")\r
+    if not srcGeomAdjustments:\r
+        # nothing to do\r
+        return (False, image)\r
+    if srcGeomAdjustments.resizeDimensions:\r
+        image = image = cv2.resize(image, (srcGeomAdjustments.resizeDimensions.columns, srcGeomAdjustments.resizeDimensions.rows), interpolation=Config.args.re_algorithm)\r
+    if srcGeomAdjustments.cropRect:\r
+        image = image[srcGeomAdjustments.cropRect.starty:srcGeomAdjustments.cropRect.endy, srcGeomAdjustments.cropRect.startx:srcGeomAdjustments.cropRect.endx]\r
+\r
+    newSrcDimensions = Dimensions(rows=image.shape[0], columns=image.shape[1])\r
+\r
+    if (srcGeomAdjustments.posInTgt.x > 0) or (srcGeomAdjustments.posInTgt.y > 0) or (newSrcDimensions != outputDimensions):\r
+        countBorderRowsAbove = srcGeomAdjustments.posInTgt.y\r
+        countBorderRowsBelow = outputDimensions.rows - (newSrcDimensions.rows + srcGeomAdjustments.posInTgt.y)\r
+        countBorderRowsLeft = srcGeomAdjustments.posInTgt.x\r
+        countBorderRowsRight =  outputDimensions.columns - (newSrcDimensions.columns + srcGeomAdjustments.posInTgt.x)\r
+        # RGB values below are multiplied by 256 to scale them from 8-bit to 16-bit\r
+        image = cv2.copyMakeBorder(image, countBorderRowsAbove, countBorderRowsBelow, countBorderRowsLeft, countBorderRowsRight,\r
+            cv2.BORDER_CONSTANT, value=((  ((Config.args.re_borderfillcolor & 0xff)*256), ((Config.args.re_borderfillcolor>>8 & 0xff)*256), ((Config.args.re_borderfillcolor>>16 & 0xff)*256))))\r
+\r
+    finalSrcDimensions = Dimensions(rows=image.shape[0], columns=image.shape[1])\r
+    assert finalSrcDimensions == outputDimensions, f"src_Resize: Internal error, {desc} resized from {origSrcDimensions} to {finalSrcDimensions} but raw is {outputDimensions}"\r
+\r
+    return (True, image)\r
+\r
+\r
+def src_Resize(image: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+    Resizes image based on user's configuration\r
+\r
+    :param image: Image to resize\r
+    :return: (bool, image) True with new if image was generated, False with original image otherwise\r
+    """\r
+\r
+    return resizeImgToRawDimensions(image, "source")\r
+\r
+\r
+\r
+def src_ConvertToBayer(image: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+    Converts RGB image to either bayer RGGB image or grayscale, depending on source image and user configuration\r
+\r
+    :param image: Image to convert\r
+    :return: (bool, image) True with new if image was generated, False with original image otherwise\r
+    """\r
+\r
+    def colorToBayer(image: np.ndarray[tuple[int, int], np.dtype[np.uint16]]) -> np.ndarray[tuple[int, int], np.dtype[np.uint16]]:\r
+\r
+        """\r
+        Converts an RGB image (rows x columns x 3) into an RGGB bayered image (rows x columns)\r
+\r
+        :param image: Image to convert\r
+        :return: bAYER IMAGE\r
+        """\r
+        dimensions = Dimensions(rows=image.shape[0], columns=image.shape[1])\r
+        bayer = np.empty((dimensions.rows, dimensions.columns), dtype=image.dtype)\r
+        bayer[0::2,0::2] = image[0::2,0::2,2] # RGGB Red  = BGR Red\r
+        bayer[0::2,1::2] = image[0::2,1::2,1] # RGGB G1   = BGR Green\r
+        bayer[1::2,0::2] = image[1::2,0::2,1] # RGGB G2   = BGR Green\r
+        bayer[1::2,1::2] = image[1::2,1::2,0] # RGGB Blue = BGR Blue\r
+        return bayer\r
+\r
+    fInputIsGrayscale = (image.ndim == 2)\r
+\r
+    if fInputIsGrayscale:\r
+        # image is already grayscale\r
+        return (False, image)\r
+\r
+    if Config.args.src_grayscale:\r
+        image = cv2.cvtColor(image, cv2.COLOR_3GRAY)\r
+    else:\r
+        image = colorToBayer(image)\r
+\r
+    return (True, image)\r
+\r
+\r
+def convertImageNumpyTypeIfNecessary(image: np.ndarray, typeWanted: Type[type]) -> np.ndarray:\r
+\r
+    """\r
+    Converts numpy image array to specified type, if not already of the desired type\r
+\r
+    :param image: image to change type\r
+    :param typeWanted: numpy type wanted. Ex: np.uint16\r
+    :return: (origType, image) The original numpy type of the image (used later to call this\r
+    function again to convert back to original type), converted image (or original image if\r
+    conversion wasn't necessary)\r
+    """\r
+\r
+    if type(typeWanted) is str:\r
+        # convert type name in str to numpy dtype\r
+        typeWanted = np.dtype(typeWanted)\r
+\r
+    if (origType := image.dtype) == typeWanted:\r
+        # already has desired type\r
+        return (origType, image)\r
+\r
+    if np.issubdtype(origType, np.integer) and np.issubdtype(typeWanted, np.floating):\r
+        # going from integer -> normalized float. scale 2^bits integer value to 0.0..1.0\r
+        image = image.astype(typeWanted)\r
+        image /= (1 << origType.itemsize*8)-1\r
+    elif np.issubdtype(origType, np.floating) and np.issubdtype(typeWanted, np.integer):\r
+        # going from normalized float to integer. scale float value to 2^bits integer value\r
+        image *= (1 << typeWanted.itemsize*8)-1\r
+        image = image.astype(typeWanted)\r
+    elif np.issubdtype(origType, np.integer) and np.issubdtype(typeWanted, np.integer):\r
+        # going from one integer type to another integer type\r
+        if origType.itemsize <= typeWanted.itemsize:\r
+            # increasing bit-depth - scale values up\r
+            image = image.astype(typeWanted)\r
+            image <<= ((typeWanted.itemsize - origType.itemsize) * 8)\r
+        else:\r
+            # decreasing bit-depth - scale values down\r
+            image >>= ((origType.itemsize - typeWanted.itemsize) * 8)\r
+            image = image.astype(typeWanted)\r
+    else:\r
+        # float to another float type - no scaling necessary\r
+        image = image.astype(typeWanted)\r
+\r
+    return (origType, image)\r
+\r
+\r
+def src_ModifyHSL(image: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+    Changes hue, saturation, and lightness of pixels\r
+\r
+    :param image: image to change\r
+    :return: (bool, image) True with modified image if HSL was modified, False with same image if not\r
+    """\r
+    if Config.args.src_hsl is None:\r
+        return (False, image)\r
+    assert np.issubdtype(image.dtype, np.floating), f"src_ModifyHSL: expecting image data type to be floating-point but encountered \"{image.dtype}\""\r
+    image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)\r
+    image *= Config.args.src_hsl\r
+    image = cv2.cvtColor(image, cv2.COLOR_HSV2BGR)\r
+\r
+    return (True, image)\r
+\r
+\r
+def src_ConvertSRGBToLinear(image: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+    Converts image from sRGB to Linear RGB\r
+\r
+    :param image: sRGB bayeredimage to convert (float, normalized to 0.0 to 1.0)\r
+    :return: (bool, image) True with new if image was generated, False with original image otherwise\r
+    """\r
+\r
+    if not Config.args.src_srgbtolinear:\r
+        return (False, image)\r
+    assert np.issubdtype(image.dtype, np.floating), f"src_ConvertSRGBToLinear: expecting image data type to be floating-point but encountered \"{image.dtype}\""\r
+    image = np.where(image <= 0.04045,\r
+        image / 12.92,\r
+        ((image + 0.055) / 1.055)**2.4)\r
+    return (True, image)\r
+\r
+\r
+def src_ApplyInverseWbMultipliers(bayerImageFloat: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+     Applies inverse of WB multipliers, which is done to simulate the uneven color response of the image\r
+     sensor since we're going from a normal image -> raw\r
+\r
+    :param bayerImageFloat: sRGB bayeredimage to apply inverse WB multipliers to\r
+    :return: (True, image) True with inverse WB multipliers applied\r
+    """\r
+\r
+    if Config.args.src_wbmultipliers is not None:\r
+        # use overrode WB multipliers in template NEF EXIF. use the mulitipliers they specified\r
+        wbMultipliers = Config.args.src_wbmultipliers\r
+    else:\r
+        wbMultipliers = Config.exif.wbMultipliers\r
+\r
+    assert np.issubdtype(bayerImageFloat.dtype, np.floating), f"src_ApplyInverseWbMultipliers() expecting image data type to be floating-point but encountered \"{bayerImageFloat.dtype}\""\r
+    assert bayerImageFloat.ndim==2, f"src_ApplyInverseWbMultipliers() expecting 2D bayer array but encountered {bayerImageFloat.shape}"\r
+    bayerImageFloat[0::2, 0::2] = bayerImageFloat[0::2, 0::2] * (1/wbMultipliers.red)\r
+    bayerImageFloat[1::2, 1::2] = bayerImageFloat[1::2, 1::2] * (1/wbMultipliers.blue)\r
+    return (True, bayerImageFloat)\r
+\r
+\r
+def src_ScaleNormalizedFloatTo14Bit(image: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+    Converts normalized float values to 14-bit uint16 values\r
+\r
+    :param image: sRGB bayeredimage to scale values to 14-bits\r
+    :return: (bool, image) True with image values scaled to 14-bits\r
+    """\r
+\r
+    assert np.issubdtype(image.dtype, np.floating), f"src_ScaleNormalizedFloatTo14Bit: expecting image data type to be floating-point but encountered \"{image.dtype}\""\r
+    image *= (16383 - Config.exif.blackLevel);\r
+    image = image.astype(np.uint16)\r
+    return (True, image)\r
+\r
+\r
+def src_AddBlackLevelBias(image: np.ndarray) -> np.ndarray:\r
+\r
+    """\r
+    Applies blacklevel bias to image data, as necessary to simulate raw data coming from the camera\r
+\r
+    :param image: sRGB bayeredimage to apply black level bias\r
+    :return: (bool, image) True with image with black level bias added\r
+    """\r
+\r
+    image += Config.exif.blackLevel\r
+    return (True, image)\r
+\r
+\r
+def printExecutionTime(desc: str, timeElapsed: float) -> None:\r
+\r
+    """\r
+    Prints the execution time of an operation\r
+\r
+    :param desc: Text description of operation\r
+    :param timeElapsed: Execution time of operation (from time.perf_counter)\r
+    :return: None\r
+    """\r
+\r
+    if Config.args.showperfstats or isVerbose():\r
+        printA(f"Perf: Completed {desc} in {timeElapsed / (1/1000):.2f} ms")\r
+\r
+\r
+def execMethodPartialAndPrintExecutionTime(partialInst: partial) -> Any:\r
+\r
+    """\r
+    Executes a method, timing and printing its execution time.\r
+\r
+    :param partialInst: Method and its argument, bound via functools.partial()\r
+    :return: The return value from the method called\r
+    """\r
+\r
+    timeStart = time.perf_counter()\r
+    result = partialInst()\r
+    timeElapsed = time.perf_counter() - timeStart\r
+    printExecutionTime(partialInst.func.__name__, timeElapsed)\r
+    return result\r
+\r
+\r
+def performSrcImageOpList(opsList: List[Callable[[np.ndarray], np.ndarray]], image):\r
+\r
+    """\r
+    Performs list of source image operations\r
+\r
+    :param opsList: List containing references to methods to invoke; each is called, with the image data as\r
+    a paramter and each returns the potentially-modified image data\r
+    :param image: Image data to pass through operation methods\r
+    :return: Image data modified by all operations in list\r
+    """\r
+\r
+    allOpsTimeStart = time.perf_counter()\r
+    for op in opsList:\r
+        timeStart = time.perf_counter()\r
+        (fPerformedOp, image) = op(image)\r
+        timeElapsed = time.perf_counter() - timeStart\r
+        if fPerformedOp:\r
+            printExecutionTime(op.__name__, timeElapsed)\r
+\r
+    timeElapsed = time.perf_counter() - allOpsTimeStart\r
+    return (timeElapsed, image)\r
+\r
+\r
+def loadImage(filename:str) -> np.ndarray:\r
+\r
+    """\r
+    Uses opencv to load an image file\r
+\r
+    :param filename: Filename of image to load\r
+    :return: Numpy array with image or None if error. Prints error message on failure\r
+    """\r
+\r
+    #\r
+    # load image into numpy array using opencv\r
+    #\r
+    image = cv2.imread(filename, cv2.IMREAD_UNCHANGED)\r
+    if image is not None:\r
+        return image\r
+\r
+    #\r
+    # image load failed. imread() doesn't throw exceptions or return error\r
+    # codes so attempt to present useful info about why the image didn't load\r
+    #\r
+    if not os.path.isfile(filename):\r
+        printE(f"Source image file \"{filename}\" doesn't exist")\r
+        return None\r
+\r
+    #\r
+    # file exists. either not a valid image file or format not supported\r
+    #\r
+    printE(f"Error loading image \"{filename}\". Image format not supported?")\r
+    return None\r
+\r
+\r
+def processSourceImageData(image: np.ndarray) -> tuple[ np.ndarray[tuple[int, int], np.dtype[np.uint16]], np.ndarray[tuple[int, int], np.dtype[np.uint16]] ]:\r
+\r
+    """\r
+    Performs all the operations necessary on the source image to produce a final source image and bayered output suitable for raw encoding\r
+\r
+    :param image: Source image\r
+    :return: (image, bayeredImage) Source image fully processed, both in RGB and bayered form.\r
+    """\r
+\r
+    #\r
+    # operations on the source image are split into two phases - first is all the\r
+    # non-bayer specific transforms, such as resizing to output (raw) resolution, color\r
+    # transforms, etc. Some of these operations are done against 16-bit image data, while\r
+    # others use normalized float data. When done we have a source image that's usable for\r
+    # encoding as the embedded jpg in the raw and for data that can be passed to the second\r
+    # phase, which involves bayering it and applying raw-specific transforms like inverse\r
+    # WB multipliers (to simulate RGB sensor response in reverse), black level bias addition,\r
+    # etc - this second phase is also done in both integer and float parts\r
+    #\r
+\r
+    #\r
+    # note: src_ConvertSRGBToLinear() is slow due to raised-power logic, so\r
+    # try to run it after bayer conversion to execute faster (less data to convert vs RGB array)\r
+    #\r
+\r
+    sourceOps = [\r
+\r
+        # first do integer-based operations\r
+        src_Convert_8BitTo16Bit,\r
+        src_Resize,\r
+\r
+        # now do operations that require float\r
+        src_Convert16BitToNormalizedFloat,\r
+        src_ModifyHSL,\r
+\r
+        # all source operations are done. convet back to 16-bit integer\r
+        src_ConvertNormalizedFloatTo16Bit,\r
+    ]\r
+\r
+    bayerOps = [\r
+\r
+        # we enter this phase with 16-bit data\r
+\r
+        # bayer the image\r
+        src_ConvertToBayer,\r
+\r
+        # now do operations on float bayered data\r
+        src_Convert16BitToNormalizedFloat,\r
+        src_ConvertSRGBToLinear,\r
+        src_ApplyInverseWbMultipliers,\r
+\r
+        # convert float bayered back to 16-bit but scaled to 14-bit values (in prepration for 14-bit raw encoding)\r
+        src_ScaleNormalizedFloatTo14Bit,\r
+        src_AddBlackLevelBias\r
+    ]\r
+\r
+    (totalTimeElapsed, image) = performSrcImageOpList(sourceOps, image)\r
+    printExecutionTime("*** all source ops ***", totalTimeElapsed)\r
+\r
+    (totalTimeElapsed, bayeredImage) = performSrcImageOpList(bayerOps, image)\r
+    printExecutionTime("*** all bayer ops ***", totalTimeElapsed)\r
+\r
+    return (image, bayeredImage)\r
+\r
+\r
+def processSourceImageFile(filename: str) -> tuple[ np.ndarray[tuple[int, int], np.dtype[np.uint16]], np.ndarray[tuple[int, int], np.dtype[np.uint16]] ]:\r
+\r
+    """\r
+    Performs all the operations necessary on the source image to get it ready for encoding to raw\r
+\r
+    :return: (image, bayeredImage) Source image fully processed, both in RGB and bayered form\r
+    """\r
+\r
+    image = loadImage(filename)\r
+    if image is None:\r
+        return (None, None)\r
+\r
+    return processSourceImageData(image)\r
+\r
+\r
+def processSourceNumpy(filename) -> tuple[ np.ndarray[tuple[int, int], np.dtype[np.uint16]], np.ndarray[tuple[int, int], np.dtype[np.uint16]] ]:\r
+\r
+    """\r
+    Performs all the operations necessary on the source image contained in a numpy file\r
+\r
+    :param filename: Numpy filename containing image data.\r
+    :return: (image, bayeredImage) Source image fully processed, both in RGB and bayered form. image will be\r
+    None if there was no non-bayered source available\r
+    """\r
+\r
+    def validateBayeredNpyData(data: np.ndarray) -> bool:\r
+\r
+        """\r
+        Validates numpy data loaded, to make sure it contains valid image data that's encodable\r
+\r
+        :param data: Numpy array to validate\r
+        :return: (image, bayeredImage) Source image fully processed, both in RGB and bayered form. image will be\r
+        None if there was no non-bayered source available\r
+        """\r
+\r
+        if data.max() >= 2**14:\r
+            printE(f"\"{filename}\" has pixels >= 2^14 - cannot encode")\r
+            return True\r
+        if Config.exif.blackLevel > 0:\r
+            if data.min() < Config.exif.blackLevel:\r
+                printW(f"\"{filename}\" has pixels < blackLevel of {Config.exif.blackLevel}")\r
+        return False\r
+\r
+    rawDimensions = Config.exif.rawDimensions;\r
+\r
+    try:\r
+        input = np.load(filename)\r
+    except Exception as e:\r
+        printE(f"Error loading numpy file \"{filename}\": {e}")\r
+        return (None, None)\r
+\r
+    if input.dtype != np.uint16:\r
+        printE(f"Numpy array must be uint16 - \"{filename}\" is {input.dtype}")\r
+        return (None, None)\r
+\r
+    numDimensions = input.ndim\r
+    if numDimensions >= 2:\r
+\r
+        inputDimensions = Dimensions(rows=input.shape[0], columns=input.shape[1])\r
+\r
+        if numDimensions == 2:\r
+            # appears to be in the format: rows x columns, so assume it's a bayered image (though it might also be a grayscale image)\r
+\r
+            if validateBayeredNpyData(input):\r
+                return (None, None)\r
+\r
+            #\r
+            # calculate crop and/or padding needed to fit input image onto raw dimensions.\r
+            #\r
+            srcGeomAdjustments = calcSrcGeomAdjustments(inputDimensions, rawDimensions,\r
+                ResizeGeom.NONE, fMaintainAspectRatio=False, horzAlignment=Config.args.re_horzalign, vertAlignment=Config.args.re_vertalign)\r
+            posInTgt = Coordinate(x=0, y=0) # assume no padding\r
+            cropRect = Rect(startx=0, starty=0, endx=inputDimensions.columns, endy=inputDimensions.rows) # assume no cropping\r
+            if srcGeomAdjustments:\r
+                posInTgt = srcGeomAdjustments.posInTgt\r
+                if srcGeomAdjustments.cropRect:\r
+                    cropRect = srcGeomAdjustments.cropRect\r
+\r
+            bayeredImage = np.full((rawDimensions.rows, rawDimensions.columns), Config.exif.blackLevel, dtype=np.uint16)\r
+\r
+            origShape = input.shape\r
+            bayeredImage[posInTgt.y : posInTgt.y+cropRect.endy :, posInTgt.x : posInTgt.x+cropRect.endx :] =\\r
+                input[cropRect.starty : cropRect.endy :, cropRect.startx : cropRect.endx :]\r
+\r
+            newShape = bayeredImage.shape\r
+            if origShape != newShape:\r
+                printV(f"Resized \"{filename}\" input from {origShape} to {newShape}")\r
+\r
+            return (None, bayeredImage)\r
+\r
+\r
+        if numDimensions == 3:\r
+\r
+            numColorChannels = input.shape[2]\r
+\r
+            if numColorChannels == 3:\r
+                # appears to be in the format: rows x columns x RGB color channels, so assume it's an RGB image\r
+                return processSourceImageData(input)\r
+\r
+            if numColorChannels == 4:\r
+                # appears to be in the format: rows x columns x bayer color channel, for example from https://github.com/SonyResearch/\r
+\r
+                if validateBayeredNpyData(input):\r
+                    return (None, None)\r
+\r
+                #\r
+                # calculate crop and/or padding needed to fit input bayer image onto raw dimensions. In reviewing these calculations,\r
+                # keep in mind that each color bayer layer in the input array has 1/2 the number of columns and rows\r
+                #\r
+\r
+                colorChannelDimensionsAsBayer = Dimensions(rows=inputDimensions.rows*2, columns=inputDimensions.columns*2) # x2 to normalize color channel resolution to full bayer for calcSrcGeomAdjustments()\r
+                srcGeomAdjustments = calcSrcGeomAdjustments(colorChannelDimensionsAsBayer, rawDimensions,\r
+                    ResizeGeom.NONE, fMaintainAspectRatio=False, horzAlignment=Config.args.re_horzalign, vertAlignment=Config.args.re_vertalign)\r
+                posInTgt = Coordinate(x=0, y=0) # assume no padding\r
+                cropRect = Rect(startx=0, starty=0, endx=colorChannelDimensionsAsBayer.columns, endy=colorChannelDimensionsAsBayer.rows) # assume no cropping\r
+                if srcGeomAdjustments:\r
+                    posInTgt = srcGeomAdjustments.posInTgt\r
+                    if srcGeomAdjustments.cropRect:\r
+                        cropRect = srcGeomAdjustments.cropRect\r
+\r
+                bayeredImage = np.full((rawDimensions.rows, rawDimensions.columns), Config.exif.blackLevel, dtype=np.uint16)\r
+\r
+                origShape = (input.shape[0]*2, input.shape[1]*2)\r
+                bayeredImage[0+posInTgt.y : posInTgt.y+cropRect.endy : 2, 0+posInTgt.x : posInTgt.x+cropRect.endx : 2] =\\r
+                    input[cropRect.starty//2  : cropRect.endy//2 :, cropRect.startx//2 : cropRect.endx//2 :,0]\r
+                bayeredImage[0+posInTgt.y : posInTgt.y+cropRect.endy : 2, 1+posInTgt.x : posInTgt.x+cropRect.endx : 2] =\\r
+                    input[cropRect.starty//2  : cropRect.endy//2 :, cropRect.startx//2 : cropRect.endx//2 :,1]\r
+                bayeredImage[1+posInTgt.y : posInTgt.y+cropRect.endy : 2, 0+posInTgt.x : posInTgt.x+cropRect.endx : 2] =\\r
+                    input[cropRect.starty//2  : cropRect.endy//2 :, cropRect.startx//2 : cropRect.endx//2 :,2]\r
+                bayeredImage[1+posInTgt.y : posInTgt.y+cropRect.endy : 2, 1+posInTgt.x : posInTgt.x+cropRect.endx : 2] =\\r
+                    input[cropRect.starty//2  : cropRect.endy//2 :, cropRect.startx//2 : cropRect.endx//2 :,3]\r
+\r
+                newShape = bayeredImage.shape\r
+                if origShape[0]*2 != newShape[0] or origShape[1]*2 != newShape[1]:\r
+                    printV(f"Resized \"{filename}\" input from {origShape} to {newShape}")\r
+\r
+                return (None, bayeredImage)\r
+\r
+            printE(f"Numpy array from \"{filename}\" is 3D {input.shape}: 1st dimension must be either x3 deep (RGB image with 3 color channels) or x4 deep (bayer image with 4 channels, ie RGGB)")\r
+            return (None, None)\r
+\r
+    printE(f"Can't identify image data type from \"{filename}\" from its shape {input.shape}")\r
+    return (None, None)\r
+\r
+\r
+def processSourceFile() -> tuple[ np.ndarray[tuple[int, int], np.dtype[np.uint16]], np.ndarray[tuple[int, int], np.dtype[np.uint16]] ]:\r
+\r
+    """\r
+    Performs all the operations necessary on the source image to get it ready for encoding to raw\r
+\r
+    :return: (image, bayeredImage) Source image fully processed, both in RGB and bayered form. image will be\r
+    None if there was no non-bayered source available\r
+    """\r
+\r
+    filename = Config.args.inputfilename\r
+\r
+    _, ext = os.path.splitext(filename)\r
+    if ext:\r
+        ext = ext.lstrip('.').upper()   # convert to uppercase, stirp off leading period\r
+        match ext:\r
+            case "NPY":\r
+                return processSourceNumpy(filename)\r
+\r
+    # asssume the file is an image that opencv can handle\r
+    return processSourceImageFile(filename)\r
+\r
+\r
+def resolveTemplateNefSource():\r
+\r
+    """\r
+    Determines the source of our template NEF filename. This app has a feature where the user can specify\r
+    a shorthand name of just the camera model for the template nef instead of a filename - we'll check that\r
+    shorthand name against a file in our script's "templatenef" directory and use it if available\r
+\r
+    :return: False if successful, True if error\r
+    """\r
+\r
+    if os.path.isfile(Config.args.templatenef):\r
+        # a file exits with the specified name, so presume it's not a shorthand name\r
+        return False\r
+\r
+    # check if an NEF exist by the shorthand name in our script's "templatenef" directory. for\r
+    # example, if the user specified "Z6III" as the template NEF, we'll check our\r
+    # script directory for a file named .\templatenef\Z6III.NEF\r
+    dir, root, ext = splitPathIntoParts(Config.args.templatenef)\r
+\r
+    templateNefDir = os.path.join(getScriptDir(), "templatenef")\r
+    potentialTemplateNefFilename = os.path.join(templateNefDir, root + ".NEF")\r
+    if os.path.isfile(potentialTemplateNefFilename):\r
+        # found a matching file for the shortname name. change the config to reflect this file\r
+        Config.args.templatenef = potentialTemplateNefFilename\r
+        return False\r
+\r
+    printE(f"The template NEF specified \"{Config.args.templatenef}\" doesn't exist, either as a file or in the template directory \"{templateNefDir}\"")\r
+    return True\r
+\r
+\r
+def run() -> bool:\r
+\r
+    """\r
+    main module routine\r
+\r
+    :return: False if successful, True if error\r
+    """\r
+\r
+    # process the cmd line\r
+    Config.args = processCmdLine()\r
+    if Config.args is None:\r
+        return True\r
+\r
+    printI(f"{AppName} v{AppVersion}")\r
+    printD(f"Args: {Config.args}")\r
+\r
+    # determine the template NEF we'll be using\r
+    if resolveTemplateNefSource():\r
+        return True\r
+\r
+    # process the template NEF and make sure it's the type we're expecting\r
+    Config.exif  = extractExifFromNEF(Config.args.templatenef)\r
+    if Config.exif is None:\r
+        return True\r
+    printD(f"Template EXIF: {Config.exif}")\r
+\r
+    # process the source image, which results in both bayer output and the fully-processed source image (to generated embedded jpgs from)\r
+    (image, bayeredImage) = processSourceFile()\r
+    if bayeredImage is None:\r
+        return True\r
+\r
+    # encode the source image using Nikon's lossless compression\r
+    encodedImageData = execMethodPartialAndPrintExecutionTime(partial(src_EncodeToNikonLossless, bayeredImage))\r
+    if encodedImageData is None:\r
+        return True\r
+    bytesCompressed = len(encodedImageData)\r
+    bytesUncompressed = int(Config.exif.rawDimensions.columns * Config.exif.rawDimensions.rows * 14 / 8);\r
+    printI(f"NEF: {bytesCompressed:,} compressed bytes, which is {float(bytesCompressed) / bytesUncompressed * 100:.2f}% of 14-bit image size ({bytesUncompressed:,} bytes)")\r
+\r
+    # write encoded raw data into NEF, including generating the embedded jpgs\r
+    fWriteFailed = writeOutputNEF(image, encodedImageData)\r
+    if fWriteFailed:\r
+        printE("Error generating/writing output NEF")\r
+        return True\r
+\r
+    # if running in Python debugger, break now to examine vars\r
+    if "pdb" in sys.modules:\r
+        breakpoint()\r
+\r
+    return False\r
+\r
+\r
+if __name__ == "__main__":\r
+    fError = run()\r
+    sys.exit(fError)\r
diff --git a/nefencode.c b/nefencode.c
new file mode 100644 (file)
index 0000000..e7893c9
--- /dev/null
@@ -0,0 +1,325 @@
+/*\r
+ ****************************************************************************\r
+ *\r
+ * nefencode.c - NEF Lossless Compression Encoding Logic\r
+ * Copyright (c) 2025, Horshack\r
+ *\r
+ */\r
+\r
+//\r
+// header files\r
+//\r
+#include <stdint.h>\r
+#include <stdio.h>\r
+#include <string.h>\r
+#include <limits.h>\r
+\r
+//#define F_DEBUG\r
+\r
+//\r
+// macros\r
+//\r
+#define MIN(a, b)       ((a)<(b) ? (a) : (b))\r
+#define MAX(a, b)       ((a)>(b) ? (a) : (b))\r
+#define COUNTOF(x)      ((sizeof((x))) / sizeof((x)[0]))\r
+#define FALSE           (0)\r
+#define TRUE            (1)\r
+#define BYTES_IN_MB     (1048576)\r
+\r
+#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__\r
+    #define SWAP_ENDIAN_32_BITS_IF_PLATFORM_LITTLE_ENDIAN(value) (uint32_t)__builtin_bswap32((uint32_t)(value))\r
+#elif __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__\r
+    #define SWAP_ENDIAN_32_BITS_IF_PLATFORM_LITTLE_ENDIAN(value) (value)\r
+#else\r
+    #error Endianness of platform cant be determined\r
+#endif\r
+\r
+\r
+//\r
+// types\r
+//\r
+typedef int BOOL;\r
+typedef struct _NEF_HUFF_TABLE_ENTRY {\r
+    int     countBitsNeededForDeltaPixelValue_IncludingSign;\r
+    int     countHuffEncodingBits;\r
+    uint8_t huffEncodingValue;\r
+} NEF_HUFF_TABLE_ENTRY;\r
+\r
+typedef struct _NEF_ENCODE_CONTEXT {\r
+    int         outputBufferBytesStored;\r
+    int         countBitsValidInCurrentBitWord;\r
+    uint32_t    currentBitWord;\r
+    uint8_t     *outputBuffer;\r
+} NEF_ENCODE_CONTEXT;\r
+\r
+typedef struct _NEF_ENCODE_PARAMS {\r
+    int         countColumns;\r
+    int         countRows;\r
+    int         sourceBufferSizeBytes;\r
+    int         outputBufferSizeBytes;\r
+    uint16_t    startingPredictiveValue;\r
+    uint16_t    pad1;\r
+    void        *sourceData;\r
+    void        *outputBuffer;\r
+} NEF_ENCODE_PARAMS;\r
+\r
+typedef enum {\r
+    NEF_ENCODE_ERROR_SOURCE_BUFFER_TOO_SMALL    = -1,\r
+    NEF_ENCODE_ERROR_NO_HUFF_TABLE_ENTRY        = -2,   // occurs when source data is > 14-bits\r
+    NEF_ENCODE_ERROR_OUTPUT_BUFFER_TOO_SMALL    = -3,\r
+} t_NefEncodeError;\r
+\r
+\r
+/**\r
+ *\r
+ * Returns the highest bit set in a value, or -1 if value is zero\r
+ * @param value Value to find MSB\r
+ * @returns Highest bit set in value, or -1 if value is zero\r
+ */\r
+static inline int findMsbSet(uint32_t value) {\r
+    if (value != 0) {\r
+        int countLeadingZeros = __builtin_clz(value);\r
+        return (sizeof(uint32_t) * CHAR_BIT - 1) - countLeadingZeros;\r
+    }\r
+    return -1;\r
+}\r
+\r
+\r
+/**\r
+ *\r
+ * Calculates the number of bits required to represent a value\r
+ * @param value Value to calculate # bits required to represent\r
+ * @returns Number of bits required to represent value\r
+ */\r
+static inline int calcBitsNeedForValue(uint32_t value) {\r
+    int highestBitSet = findMsbSet((uint32_t)value);\r
+    return highestBitSet != -1 ? highestBitSet : 0;\r
+}\r
+\r
+\r
+/**\r
+ *\r
+ * Returns Huffman encoding information for a pixel delta value requiring a specified number of bits\r
+ * @param numDeltaPixelValueBitsNeeded Number of bits in pixel delta value that need to be encoded\r
+ * @returns Pointer to a NEF_HUFF_TABLE_ENTRY entry. If there is no huffman\r
+ * entry associated with 'numDeltaPixelValueBitsNeeded' then NULL is returned\r
+ */\r
+static inline const NEF_HUFF_TABLE_ENTRY *getHuffTableEntryForCountBitsNeededEncodeDeltaPixelValue(int numDeltaPixelValueBitsNeeded) {\r
+\r
+    static NEF_HUFF_TABLE_ENTRY entries[] = {\r
+\r
+            {   7,      2,  0x00  },    // entry #00 - delta values requiring 7 bits to represent.  Uses 2-bits of huff-encoding, huff-encoded ID = 0x00\r
+\r
+            {   6,      3,  0x02  },    // entry #01 - delta values requiring 6 bits to represent.  Uses 3-bits of huff-encoding, huff-encoded ID = 0x02\r
+            {   8,      3,  0x03  },    // entry #02 - delta values requiring 8 bits to represent.  Uses 3-bits of huff-encoding, huff-encoded ID = 0x03\r
+            {   5,      3,  0x04  },    // entry #03 - delta values requiring 5 bits to represent.  Uses 3-bits of huff-encoding, huff-encoded ID = 0x04\r
+            {   9,      3,  0x05  },    // entry #04 - delta values requiring 9 bits to represent.  Uses 3-bits of huff-encoding, huff-encoded ID = 0x05\r
+\r
+            {   4,      4,  0x0c  },    // entry #05 - delta values requiring 4 bits to represent.  Uses 4-bits of huff-encoding, huff-encoded ID = 0x0c\r
+            {   10,     4,  0x0d  },    // entry #06 - delta values requiring 10 bits to represent. Uses 4-bits of huff-encoding, huff-encoded ID = 0x0c\r
+\r
+            {   3,      5,  0x1c  },    // entry #07 - delta values requiring 3 bits to represent.  Uses 5-bits of huff-encoding, huff-encoded ID = 0x1c\r
+            {   11,     5,  0x1d  },    // entry #08 - delta values requiring 11 bits to represent. Uses 5-bits of huff-encoding, huff-encoded ID = 0x1d\r
+\r
+            {   12,     6,  0x3c  },    // entry #09 - delta values requiring 12 bits to represent. Uses 6-bits of huff-encoding, huff-encoded ID = 0x3c\r
+            {    2,     6,  0x3d  },    // entry #10 - delta values requiring 2 bits to represent.  Uses 6-bits of huff-encoding, huff-encoded ID = 0x3d\r
+            {    0,     6,  0x3e  },    // entry #11 - delta values requiring 0 bits to represent.  Uses 6-bits of huff-encoding, huff-encoded ID = 0x3f\r
+\r
+            {    1,     7,  0x7e  },    // entry #12 - delta values requiring 1 bits to represent.  Uses 7-bits of huff-encoding, huff-encoded ID = 0x7e\r
+\r
+            {    13,    8,  0xfe  },    // entry #13 - delta values requiring 13 bits to represent. Uses 8-bits of huff-encoding, huff-encoded ID = 0xfe\r
+            {    14,    8,  0xff  },    // entry #14 - delta values requiring 14 bits to represent. Uses 8-bits of huff-encoding, huff-encoded ID = 0xff\r
+    };\r
+    static NEF_HUFF_TABLE_ENTRY *numBitsNeededToHuffTableEntry[] = {\r
+        &entries[11],   // 0 bits needed  -> entry #11\r
+        &entries[12],   // 1 bits needed  -> entry #12\r
+        &entries[10],   // 2 bits needed  -> entry #10\r
+        &entries[7],    // 3 bits needed  -> entry #7\r
+        &entries[5],    // 4 bits needed  -> entry #5\r
+        &entries[3],    // 5 bits needed  -> entry #3\r
+        &entries[1],    // 6 bits needed  -> entry #1\r
+        &entries[0],    // 7 bits needed  -> entry #0\r
+        &entries[2],    // 8 bits needed  -> entry #2\r
+        &entries[4],    // 9 bits needed  -> entry #4\r
+        &entries[6],    // 10 bits needed -> entry #6\r
+        &entries[8],    // 11 bits needed -> entry #8\r
+        &entries[9],    // 12 bits needed -> entry #9\r
+        &entries[13],   // 13 bits needed -> entry #13\r
+        &entries[14],   // 14 bits needed -> entry #14\r
+    };\r
+    if (numDeltaPixelValueBitsNeeded < COUNTOF(numBitsNeededToHuffTableEntry))\r
+        return numBitsNeededToHuffTableEntry[numDeltaPixelValueBitsNeeded];\r
+    return NULL;\r
+}\r
+\r
+\r
+/**\r
+ *\r
+ * Adds bits from passed value into encoded image output\r
+ * @param i Image structure we're adding bits into\r
+ * @param countBits Number of bits in 'value' to add.\r
+ * @param value Value we're encoding\r
+ */\r
+static void addBitsToOutput(NEF_ENCODE_CONTEXT *ctx, int countBits, uint32_t value) {\r
+\r
+    int countBitsLeftToAdd = countBits;\r
+    int countBitsAddThisIteration;\r
+\r
+    // assumed 'countBits < 32', as logic doesn't support shift operations == 32 bits, the behavior of which is undefined in 'C'\r
+    while (countBitsLeftToAdd > 0) {\r
+\r
+        countBitsAddThisIteration = MIN(countBitsLeftToAdd, 32 - ctx->countBitsValidInCurrentBitWord); // constrain this loop iteration to # bits left and #bits available in current bit word\r
+\r
+        ctx->currentBitWord <<= countBitsAddThisIteration; // make room for bits being inserted\r
+        ctx->currentBitWord |= value >> (countBitsLeftToAdd - countBitsAddThisIteration) & ((1 << countBitsAddThisIteration) - 1); // insert bits\r
+\r
+        ctx->countBitsValidInCurrentBitWord += countBitsAddThisIteration;\r
+\r
+        if (ctx->countBitsValidInCurrentBitWord == 32) {\r
+            *(uint32_t *)&ctx->outputBuffer[ctx->outputBufferBytesStored] = SWAP_ENDIAN_32_BITS_IF_PLATFORM_LITTLE_ENDIAN(ctx->currentBitWord);\r
+            ctx->outputBufferBytesStored += 4;\r
+            ctx->countBitsValidInCurrentBitWord = 0;\r
+            ctx->currentBitWord = 0x00;\r
+        }\r
+\r
+        countBitsLeftToAdd -= countBitsAddThisIteration;\r
+    }\r
+}\r
+\r
+\r
+/**\r
+ *\r
+ * Writes any bits pending in the current bitword from previous invocation(s) of addBitsToOutput()\r
+ * @param i Image structure we're flushing\r
+ */\r
+static void flushBitsToOutput(NEF_ENCODE_CONTEXT *ctx) {\r
+\r
+    int countPadBitsToFillWord;\r
+\r
+    if (ctx->countBitsValidInCurrentBitWord > 0) {\r
+        countPadBitsToFillWord = 32 - ctx->countBitsValidInCurrentBitWord;\r
+        addBitsToOutput(ctx, countPadBitsToFillWord, 0x00000000);\r
+        ctx->outputBufferBytesStored -= countPadBitsToFillWord / 8; // only count actual data we flushed, excluding bytes we used only to pad to a full 32-bit word\r
+    }\r
+}\r
+\r
+\r
+\r
+/**\r
+ *\r
+ * Encodes bayer pixel data into Nikon's proprietary NEF lossless compression\r
+ * @param params Input parameters\r
+ * @param Number of encoded bytes stored in output, or < 0 with t_NefEncodeError result\r
+ */\r
+t_NefEncodeError NefEncode(NEF_ENCODE_PARAMS *params) {\r
+\r
+    int         countBitsNeededForDeltaValue;\r
+    int         row, column, sourceDataIndex, outputBufferBytesAvail;\r
+    BOOL        fNegativeDeltaValue;\r
+    uint16_t    pixelValue, prevPixelValue;\r
+    uint16_t    deltaPixelValue, encodedDeltaPixelValueWithSignBit, deltaPixelValueSigned;\r
+    uint16_t    prevPixelValuesRows[2][2], prevPixelValuesThisRow[2];\r
+    uint16_t    *sourceData;\r
+    const NEF_HUFF_TABLE_ENTRY  *huffTableEntry;\r
+    NEF_ENCODE_CONTEXT          ctx;\r
+\r
+#if F_DEBUG\r
+    printf (">> NefEncode() called\n");\r
+    printf("&params = %p\n", params);\r
+    printf("  .countColumns = %d\n", params->countColumns);\r
+    printf("  .countRows = %d\n", params->countRows);\r
+    printf("  .sourceBufferSizeBytes = %d\n", params->sourceBufferSizeBytes);\r
+    printf("  .outputBufferSizeBytes = %d\n", params->outputBufferSizeBytes);\r
+    printf("  .startingPredictiveValue = %d\n", params->startingPredictiveValue);\r
+    printf("  .pad1 = %d\n", params->pad1);\r
+    printf("  .sourceData = %p\n", params->sourceData);\r
+    uint16_t *p = (uint16_t *)params->sourceData;\r
+    printf("       = %04x %04x %04x %04x\n", p[0], p[1], p[2], p[3]);\r
+    printf("  .outputBuffer = %p\n", params->outputBuffer);\r
+    printf("\n");\r
+#endif\r
+\r
+    //\r
+    // Nikon's lossless compression stores a delta value for each pixel, which starts from\r
+    // a per-channel seed value (0x800). The data for each pixel is stored in <length><data>\r
+    // form, where <length> is a Huffman-encoded bit length and <data> is a delta value\r
+    // to apply to the running/previous pixel value. The Huffman codes are optimized to\r
+    // represent the more-common delta bit lengths with the fewest number of bits for the\r
+    // Huffman code\r
+    //\r
+\r
+    if (params->sourceBufferSizeBytes < params->countRows*params->countColumns * sizeof(uint16_t))\r
+        return NEF_ENCODE_ERROR_SOURCE_BUFFER_TOO_SMALL;\r
+\r
+    memset(&ctx, 0, sizeof(ctx));\r
+    ctx.outputBuffer = params->outputBuffer;\r
+\r
+    prevPixelValuesThisRow[0] = prevPixelValuesThisRow[1] = params->startingPredictiveValue;\r
+    prevPixelValuesRows[0][0] = prevPixelValuesRows[0][1] = prevPixelValuesRows[1][0] = prevPixelValuesRows[1][1] = params->startingPredictiveValue;\r
+\r
+    sourceDataIndex = 0;\r
+    sourceData = (uint16_t *)params->sourceData;\r
+\r
+    for (row = 0; row < params->countRows; row++) {\r
+\r
+        outputBufferBytesAvail = params->outputBufferSizeBytes - ctx.outputBufferBytesStored;\r
+        if (outputBufferBytesAvail < BYTES_IN_MB)\r
+            return NEF_ENCODE_ERROR_OUTPUT_BUFFER_TOO_SMALL;\r
+\r
+        for (column = 0; column < params->countColumns; column++) {\r
+\r
+            pixelValue = sourceData[sourceDataIndex++];\r
+\r
+            if (column <= 1)\r
+                // first pixel of this channel for this row. seed using the running/previous pixel value for this row\r
+                prevPixelValue = prevPixelValuesRows[row & 1][column];\r
+            else\r
+                // use the previous pixel value\r
+                prevPixelValue = prevPixelValuesThisRow[column & 1];\r
+\r
+            // calculate delta pixel value for this next pixel, including handling the negative delta value case\r
+            if (pixelValue >= prevPixelValue) {\r
+                fNegativeDeltaValue = FALSE;\r
+                deltaPixelValue = pixelValue - prevPixelValue;\r
+            } else {\r
+                fNegativeDeltaValue = TRUE;\r
+                deltaPixelValue = prevPixelValue - pixelValue;\r
+            }\r
+\r
+            // calculate the #bits needed to represent this delta value\r
+            if (deltaPixelValue != 0)\r
+                countBitsNeededForDeltaValue = calcBitsNeedForValue((uint32_t)deltaPixelValue) + 1 /* +1 for sign bit */;\r
+            else\r
+                countBitsNeededForDeltaValue = 0;\r
+\r
+            // get the Huff table info to encode this bit length\r
+            huffTableEntry = getHuffTableEntryForCountBitsNeededEncodeDeltaPixelValue(countBitsNeededForDeltaValue);\r
+            if (huffTableEntry == NULL)\r
+                return NEF_ENCODE_ERROR_NO_HUFF_TABLE_ENTRY;\r
+\r
+            // build the pixel value with the sign bit encoded\r
+            if (!fNegativeDeltaValue) {\r
+                deltaPixelValueSigned = deltaPixelValue;\r
+                encodedDeltaPixelValueWithSignBit = deltaPixelValue;\r
+            } else {\r
+                encodedDeltaPixelValueWithSignBit = ((1 << countBitsNeededForDeltaValue) - 1) - deltaPixelValue;\r
+                deltaPixelValueSigned = -deltaPixelValue;\r
+            }\r
+\r
+            // save the running/previous pixel value to be used for next pixel. also handle the initial seed case for each row\r
+            if (column <= 1)\r
+                prevPixelValuesThisRow[column] = prevPixelValuesRows[row & 1][column] += deltaPixelValueSigned;\r
+            else\r
+                prevPixelValuesThisRow[column & 1] += deltaPixelValueSigned;\r
+\r
+            // pack the huffman-encoded length and pixel delta value into our output buffer\r
+            addBitsToOutput(&ctx, huffTableEntry->countHuffEncodingBits, (uint32_t)huffTableEntry->huffEncodingValue);\r
+            addBitsToOutput(&ctx, countBitsNeededForDeltaValue, (uint32_t)encodedDeltaPixelValueWithSignBit);\r
+\r
+        }\r
+    }\r
+\r
+    // flush out any remaining partial data\r
+    flushBitsToOutput(&ctx);\r
+    return ctx.outputBufferBytesStored;\r
+}\r
diff --git a/templatenef/Z50II.NEF b/templatenef/Z50II.NEF
new file mode 100644 (file)
index 0000000..dc3031e
Binary files /dev/null and b/templatenef/Z50II.NEF differ
diff --git a/templatenef/Z6.NEF b/templatenef/Z6.NEF
new file mode 100644 (file)
index 0000000..f23a2c5
Binary files /dev/null and b/templatenef/Z6.NEF differ
diff --git a/templatenef/Z6III.NEF b/templatenef/Z6III.NEF
new file mode 100644 (file)
index 0000000..3491ecd
Binary files /dev/null and b/templatenef/Z6III.NEF differ
diff --git a/templatenef/Z7.NEF b/templatenef/Z7.NEF
new file mode 100644 (file)
index 0000000..d8e32e9
Binary files /dev/null and b/templatenef/Z7.NEF differ
diff --git a/templatenef/Z7II.NEF b/templatenef/Z7II.NEF
new file mode 100644 (file)
index 0000000..c9ec0f0
Binary files /dev/null and b/templatenef/Z7II.NEF differ
diff --git a/templatenef/Z8.NEF b/templatenef/Z8.NEF
new file mode 100644 (file)
index 0000000..4dab86c
Binary files /dev/null and b/templatenef/Z8.NEF differ
git clone https://git.99rst.org/PROJECT