openlut is working. 3D LUTs do not work, numpy optimization can certainly be done.

master
Sofus Albert Høgsbro Rose 2016-08-22 20:42:50 -04:00
commit b58a8f0374
4 changed files with 966 additions and 0 deletions

111
README.md 100644
View File

@ -0,0 +1,111 @@
# openlut #
# Open-source tools for practical color management. #
=====
What is it?
-----
openlut is, at its core, a color management library, accessible from **Python 3.5+**. It's built on my own color pipeline needs, which includes managing
Lookup Tables, Gamma/Gamut functions/matrices, applying color transformations, etc. .
openlut is also a tool. Included soon will be a command line utility letting you perform complex color transformations from the comfort of
your console. In all cases, interactive usage from a Python console is easy.
I wanted it to cover this niche simply and consistently, something color management often isn't! Take a look; hopefully you'll agree :) !
What About OpenColorIO? Why does this exist?
------
OpenColorIO is a wonderful library, but seems geared towards managing the complexity of many larger applications in a greater pipeline.
openlut is more simple; it doesn't care about the big picture - you simply read in images, transform them, then output them. openlut
also focuses greatly on the "how" of these transformations with tools that eg. create or resize LUTs, things missing in OCIO.
Since it's a library, though, it's perfectly feasable (if not easy) to build such a greater pipeline based on openlut's simple color transformations.
Installation
-----
I'll put it on pip eventually (when I figure out how!). For now, just download the repository.
To run openlut.py, first make sure you have the *Dependencies*. To run the test code at the bottom (make sure openlut is in the same
directory as testpath; it needs to load test.exr), you can then run:
`python3 openlut.py -t`
To use in your code, simply `import` the module at the top of your file.
Dependencies
-----
There are some dependencies that you must get. Keep in mind that it's **Python 3.X** *only*; all dependencies must be their 3.X versions.
### Getting python3 and pip3
If you're on a **Mac**, run this to get python3 and pip3: `brew install python3; curl https://bootstrap.pypa.io/get-pip.py | python3`
If you're on **Linux**, you should already have python3 and pip3 - otherwise see your distribution repositories.
### Dependency Installation
Run this to get all deps: `sudo pip3 install numpy wand numba scipy`
Basic Library Usage
-----
To represent images, use a **ColMap** object. This handles IO to/from all ImageMagick supported formats (**including EXR and DPX**),
as well as storing the image data.
Use any child of the **Transform** class to do a color transform on a ColMap, using ColMap's `apply(Transform)` method.
The **Transform** objects themselves have plenty of features - like LUT, with `open()`, `save()`, and `resize()` methods, or TransMat with auto-combining
input matrices, or automatic spline-based interpolation of very small 1D LUTs - to make them helpful in and of themselves!
The best way to demonstrate from here, I think, is to show some test code: (run python3 openlut.py -t to see it work)
```python
#Open any format image. Try it with exr/dpx/anything!
img = ColMap.open('testpath/test.exr') #Opens a test image 'test.exr', creating a ColMap object, automatically using the best image backend available to load the image at the correct bit depth.
'''
Gamma has gamma functions like Gamma.sRGB, called by value like Gamma.sRGB(val). All take one argument, the value (x), and returns the transformed value. Color doesn't matter for gamma.
TransMat has matrices, in 3x3 numpy array form. All are relative to ACES, with direction aptly named. So, TransMat.XYZ is a matrix from ACES --> XYZ, while TransMat.XYZinv goes from XYZ --> ACES. All use/are converted to the D65 illuminant, for consistency sake.
'''
#Gamma Functions: sRGB --> Linear.
gFunc = Gamma(Gamma.sRGBinv) #A Gamma Transform object using the sRGB-->Linear gamma formula. Apply to ColMaps!
gFuncManualsRGB = Gamma(lambda val: ((val + 0.055) / 1.055) ** 2.4 if val > 0.04045 else val / 12.92) #It's generic - specify any gamma function, even inline with a lambda!
#LUT from Function: sRGB --> Linear
oLut = LUT.lutFunc(Gamma.sRGBinv) #A LUT Transform object, created from a gamma function. Size is 16384 by default. LUTs are faster!
oLut.save('testpath/sRGB-->Lin.cube') #Saves the LUT to a format inferred from the extension. cube only for now!
#Opening LUTs from .cube files.
lut = LUT.open('testpath/sRGB-->Lin.cube') #Opens the lut we just made into a different LUT object.
lut.resized(17).save('testpath/sRGB-->Lin_tiny.cube') #Resizes the LUT, then saves it again to a much smaller file!
#Matrix Transformations
simpleMat = TransMat(TransMat.sRGBinv) #A Matrix Transform (TransMat) object, created from a color transform matrix for gamut transformations! This one is sRGB --> ACES.
mat = TransMat(TransMat.sRGBinv, TransMat.XYZ, TransMat.XYZinv, TransMat.aRGB) * TransMat.aRGBinv
#Indeed, specify many matrices which auto-multiply into a single one! You can also combine them after, with simple multiplication.
#Applying and saving.
img.apply(gFunc).save('testpath/openlut_gammafunc.png') #save saves an image using the appropriate image backend, based on the extension.
img.apply(lut).save('testpath/openlut_lut-lin-16384.png') #apply applies any color transformation object that inherits from Transform - LUT, Gamma, TransMat, etc., or make your own! It's easy ;) .
img.apply(lut.resized(17)).save('testpath/openlut_lut-lin-17.png') #Why so small? Because spline interpolation automatically turns on. It's identical to the larger LUT!
img.apply(mat).save('testpath/openlut_mat.png') #Applies the gamut transformation.
#As a proof of concept, here's a long list of transformations that should, in sum, do nothing :) :
img.apply(lut).apply(LUT.lutFunc(Gamma.sRGB)).apply(mat).apply(~mat).save('testpath/openlut_noop.png') #~mat is the inverse of mat. Easily undo the gamut operation!
#Format Test: All output images are in Linear ACES.
tImg = img.apply(mat)
tImg.save('testpath/output.exr')
tImg.save('testpath/output.dpx')
tImg.save('testpath/output.png')
tImg.save('testpath/output.jpg')
tImg.save('testpath/output.tif') #All sorts of formats work! Bit depth is 16, unless you say something else.
#Compression is impossible right now - wand is being difficult.
#Keep in mind, values are clipped from 0 to 1 when done. Scary transforms can make this an issue!
#Color management of openlut itself is simple: openlut doesn't touch your data, unless you tell it to with a Transform. So, the data that goes in, goes out, unless a Transform was applied.
```

View File

@ -0,0 +1,100 @@
#!/usr/bin/env python2
from __future__ import print_function
'''
The MIT License (MIT)
Copyright (c) 2016 Sofus Rose
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
#Image Dims: X (0-1919), Y (0-1079), C (0-2; RGB)
import sys, os
#~ import numpy as np
#~ from PIL import Image
#~ import tifffile as tff
#~ import pylut as pl
SEP=' '
SIZE=8 #The .cube resolution is this, squared.
BIT_DEPTH=16
def imgOpen(path) :
if path[-4:] == '.tif' or path[-4:] == 'tiff' :
return tff.TiffFile(path).asarray()
print("Not Supported!")
else :
return np.asarray(Image.open(path).convert('RGB'))
def rgbImg(img): return img.transpose(2, 0, 1) #X, Y, C --> C, X, Y
def xyImg(rgbImg): return rgbImg.transpose(1, 2, 0) #C, X, Y --> X, Y, C
def prHelp() :
print("ml_lhald.py: Generates modified (R/B swapped) HALD files for arbitrary grading, then converts them to .cube using pylut.")
print("\tGenerate modified HALD: ./mk_lhald.py gen <OPTIONAL: name, without extension, of png output>")
print("\tHALD --> CUBE: ./mk_lhald.py mk <path to HALD> <OPTIONAL: name, without extension, of cube output>\n\n")
print("Requires pylut. Install with 'pip2 install pylut'.")
sys.exit(1)
if __name__ == "__main__" :
if not sys.argv[1:]: prHelp()
if sys.argv[1] == "gen" :
iPath = "identity" if not sys.argv[2:] else sys.argv[2]
os.system('convert hald:{0} {1}.png'.format(SIZE, iPath)) # -separate -swap 0,2 -combine
print('Go ahead and grade the file "{}.png".'.format(iPath))
elif sys.argv[1] == "mk" and sys.argv[1:] :
fPath = ".converted_hald.ppm"
lPath = "grade" if not sys.argv[3:] else sys.argv[3]
os.system('convert -depth {2} {0} -compress none {1}'.format(sys.argv[2], fPath, BIT_DEPTH))
lines = ' '.join(line.strip() for line in open(fPath, 'r').readlines()[3:]).split(' ')
print(lines[:64*3])
lines = ['%.6f' % (float(line)/float(2 ** BIT_DEPTH)) for line in lines] #Direct .cube output only.
coords = [SEP.join(lines[i:i+3]) for i in range(0, len(lines)-2, 3)]
identity = ' '.join([line.split(SEP)[0] for line in coords[:SIZE**2]])
print(lines[:65], '\n\n', coords[:65], len(lines), len(coords))
#~ with open(lPath + '.3dl', 'w') as f :
#~ print(identity, end='\n', file=f)
#~ print(*coords, sep='\n', file=f)
print("Creating", lPath + '.cube')
#~ lut = pl.LUT.FromNuke3DLFile(lPath + '.3dl')
#~ #print(lut.ColorAtInterpolatedLatticePoint(0.00206,0.00227,0.00307))
#~ lut.ToCubeFile(lPath + '.cube')
with open(lPath + '.cube', 'w') as f :
print("LUT_3D_SIZE", SIZE ** 2, file=f)
print(*coords, sep='\n', file=f)
os.remove(fPath)
#~ os.remove(lPath + '.3dl')
else :
prHelp()

755
openlut.py 100755
View File

@ -0,0 +1,755 @@
#!/usr/bin/env python3.5
'''
openlut: A package for managing and applying 1D and 3D LUTs.
Color Management: openlut deals with the raw RGB values, does its work, then puts out images with correct raw RGB values - a no-op.
Dependencies:
-numpy: Like, everything.
-wand: Saving/loading all images.
-numba: 38% speedup for matrix math.
-scipy - OPTIONAL: For spline interpolation.
Easily get all deps: sudo pip3 install numpy wand numba scipy
*Make sure you get the Python 3.X version of these packages!!!
LICENCE:
The MIT License (MIT)
Copyright (c) 2016 Sofus Rose
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
import sys, os, math, abc, ctypes
import multiprocessing as mp
from functools import reduce
import operator as oper
MOD_SCIPY = False
try :
from scipy.interpolate import splrep, splev
MOD_SCIPY = True
except :
pass
import numpy as np
import numba
#~ import skimage as si
#~ import skimage.io
#~ si.io.use_plugin('freeimage')
#~ from PIL import Image
#~ import tifffile as tff
import wand
import wand.image
import wand.display
from wand.api import library
library.MagickSetCompressionQuality.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
library.MagickSetCompression.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
COMPRESS_TYPES = dict(zip(wand.image.COMPRESSION_TYPES, tuple(map(ctypes.c_int, range(len(wand.image.COMPRESSION_TYPES))))))
#~ from lib.files import Log #For Development
class Transform :
def apply(self, cMap) :
"""
Applies this transformation to a ColMap.
"""
return ColMap(self.sample(cMap.asarray()))
@abc.abstractmethod
def sample(self, fSeq) :
"""
Samples the Transformation.
"""
def spSeq(seq, outLen) :
"""
Utility function for splitting a sequence into equal parts, for multithreading.
"""
perfSep = (1/outLen) * len(seq)
return list(filter(len, [seq[round(perfSep * i):round(perfSep * (i + 1))] for i in range(len(seq))])) if len(seq) > 1 else seq
class ColMap :
def __init__(self, rgbArr) :
self.rgbArr = rgbArr
def fromIntArray(imgArr) :
bitDepth = int(''.join([i for i in str(imgArr.dtype) if i.isdigit()]))
return ColMap(np.divide(imgArr.astype(np.float64), 2 ** bitDepth - 1))
#Operations - returns new ColMaps.
def apply(self, transform) :
'''
Applies a Transform object by running its apply method.
'''
return transform.apply(self)
#IO Functions
def open(path) :
'''
Opens 8 and 16 bit images of many formats.
'''
try :
openFunction = {
"exr" : ColMap.openWand,
"dpx" : ColMap.openWand,
}[path[path.rfind('.') + 1:]]
return openFunction(path) #Any fancy formats will go here.
except :
#Fallback to opening using Wand.
return ColMap.openWand(path)
#Vendor-specific open methods.
#~ def openSci(path) :
#~ return ColMap.fromIntArray(si.io.imread(path)[:,:,:3])
def openWand(path) :
'''
Open a file using the Wand ImageMagick binding.
'''
with wand.image.Image(filename=path) as img:
#Quick inverse sRGB transform, to undo what Wand did.
img.colorspace = 'srgb'
img.transform_colorspace('rgb')
img.colorspace = 'srgb' if img.format == 'DPX' else 'rgb' #Fix for IM's dpx bug.
return ColMap.fromIntArray(np.fromstring(img.make_blob("RGB"), dtype='uint{}'.format(img.depth)).reshape(img.height, img.width, 3))
def save(self, path, compress = None, depth = None) :
'''
Save the image. The filetype will be inferred from the path, and the appropriate backend will be used.
Compression scheme will be applied based on the backend compatiblity. Wand compression types can be used: Browse then
at http://docs.wand-py.org/en/0.4.3/wand/image.html#wand.image.COMPRESSION_TYPES .
'''
if depth is None: depth = 16
try :
saveFunction = {
"exr" : self.saveWand,
"dpx" : self.saveWand,
"tif" : self.saveWand,
"tiff": self.saveWand
}[path[path.rfind('.') + 1:]]
return saveFunction(path, compress, depth)
except :
#Fallback to saving using Wand.
self.saveWand(path, compress, depth)
#Vendor-specific save methods
def saveWand(self, path, compress = None, depth = 16) :
data = self.apply(LUT.lutFunc(Gamma.sRGB)) if path[path.rfind('.')+1:] == 'dpx' else self
i = data.asWandImg(depth)
i.colorspace = 'srgb' #Make sure it saves without a colorspace transformation.
#~ if compress :
#~ library.MagickSetCompression(i.wand, 'rle')
#~ i.compression = 'lzma'
#~ i.compression_quality = 80
i.save(filename=path)
#~ def saveSci(self, path, compress = None, depth = 16) :
#~ if compress is not None: raise ValueError('Scipy Backend cannot compress the output image!')
#~ si.io.imsave(path, self.asIntArray())
#~ def savePil(self, path, compress = None, depth = 8) :
#~ if compress is not None: raise ValueError('Scipy Backend cannot compress the output image!')
#~ if depth != 8: raise ValueError('Cannot save non-8 bit image using PIL.')
#~ self.asPilImg().save(path)
def show(self) :
#~ ColMap.pilShow(self.apply(LUT.lutFunc(Gamma.sRGB)).asPilImg())
ColMap.wandShow(self.asWandImg())
#~ def pilShow(pilImg) :
#~ pilImg.show()
def wandShow(wandImg) :
#Do a quick sRGB transform for viewing. Must be in 'rgb' colorspace for this to take effect.
wandImg.transform_colorspace('srgb')
wand.display.display(wandImg)
wandImg.transform_colorspace('rgb') #This transforms it back to linearity.
#Data Form Functions
#~ def asPilImg(self) :
#~ return Image.fromarray(self.asIntArray(8), mode='RGB')
def asWandImg(self, depth = 16) :
i = wand.image.Image(blob=self.asIntArray(depth).tostring(), width=np.shape(self.rgbArr)[1], height=np.shape(self.rgbArr)[0], format='RGB')
i.colorspace = 'rgb' #Specify, to Wand, that this image is to be treated as raw, linear, data.
return i
def asarray(self) :
"""
Returns the base float array.
"""
return self.rgbArr
def asIntArray(self, depth = 16, us = True) :
u = 'u' if us else ''
return np.multiply(self.rgbArr.clip(0, 1), 2.0 ** depth - 1).astype("{0}int{1}".format(u, depth))
#Overloads
def __repr__(self) :
return 'ColMap( \n\trgbArr = {0}\n)'.format('\n\t\t'.join([line.strip() for line in repr(self.rgbArr).split('\n')]))
class LUT(Transform) :
def __init__(self, dims = 1, size = 16384, title = "openlut_LUT", array = None, iRange = (0.0, 1.0)) :
'''
Create an identity LUT with given dimensions (1 or 3), size, and title.
'''
if array is not None :
LUT.lutArray(array, size, dims, title)
else :
if dims != 1 and dims != 3: raise ValueError("Dimensions must be 1 or 3!")
self.title = title #The title.
self.size = size #The size. 1D LUTs: size numbers. 3D LUTs: size x size x size numbers.
self.range = iRange #The input range - creates data or legal LUTs. Should work fine, but untested.
self.dims = dims #The dimensions. 1 or 3; others aren't accepted.
self.ID = np.linspace(self.range[0], self.range[1], self.size) #Read Only.
if dims == 1 :
self.array = np.linspace(self.range[0], self.range[1], self.size) #Size number of floats.
elif dims == 3 :
print("3D LUT Not Implemented!")
#~ self.array = np.linspace(self.range[0], self.range[1], self.size**3).reshape(self.size, self.size, self.size) #Should make an identity size x size x size array.
def lutFunc(func, size = 16384, dims = 1, title="openlut_FuncGen") :
'''
Creates a LUT from a simple function.
'''
if dims == 1 :
lut = LUT(dims=dims, size=size, title=title)
vFunc = np.vectorize(func, otypes=[np.float])
lut.array = vFunc(lut.array)
return lut
elif dims == 3 :
print("3D LUT Not Implemented!")
def lutArray(array, title="Array_Generated") :
'''
Creates a LUT from a float array. Elements must be in range [0, 1].
'''
if len(np.shape(array)) == 1 :
lut = LUT(dims=1, size=len(array), title=title)
lut.array = array
return lut
elif len(np.shape(array)) == 3 :
print("3D LUT Not Implemented!")
else :
raise ValueError("lutArray input must be 1D or 3D!")
#LUT Functions.
def __interp(q, cpu, spSeq, ID, array, spl) :
if spl :
q.put( (cpu, splev(spSeq, splrep(ID, array))) ) #Spline Interpolation. Pretty quick, considering.
else :
q.put( (cpu, np.interp(spSeq, ID, array)) )
def sample(self, fSeq, spl=True) :
'''
Sample the LUT using a flat float sequence (ideally a numpy array; (0..1) ).
Each n (dimensions) clump of arguments will be used to sample the LUT. So:
1D LUT: in1, in2, in3 --> out1, out2, out3
*Min 1 argument.
3D LUT: inR, inG, inB --> outR, outG, outB
*Min 3 arguments, len(arguments) % 3 must equal 0.
Returns a numpy array with identical shape to the input array.
'''
fSeq = np.array(fSeq)
if self.dims == 1 :
#~ return np.interp(spSeq, self.ID, self.array)
#If scipy isn't loaded, we can't use spline interpolation!
if (not MOD_SCIPY) or self.size > 1023: spl = False # Auto-adapts big LUTs to use the faster, more brute-forceish, linear interpolation.
#~ spl = True
out = []
q = mp.Queue()
splt = Transform.spSeq(fSeq, mp.cpu_count())
for cpu in range(mp.cpu_count()) :
p = mp.Process(target=LUT.__interp, args=(q, cpu, splt[cpu], self.ID, self.array, spl))
p.start()
for num in range(len(splt)) :
out.append(q.get())
return np.concatenate([seq[1] for seq in sorted(out, key=lambda seq: seq[0])], axis=0)
elif self.dims == 3 :
print("3D LUT Not Implemented!")
def resized(self, newSize) :
if newSize == self.size: return self
fac = newSize / self.size
useSpl = self.size < newSize #If the new size is lower, we use Linear interpolation. If the new size is higher, we use Spline interpolation.
if self.size < 128: useSpl = True #If the current size is too low, use spline regardless.
if self.dims == 1 :
newID = np.linspace(self.range[0], self.range[1], newSize)
return LUT.lutArray(self.sample(newID, spl=useSpl), title="Resized to {0}".format(newSize))
if self.dims == 3 :
print("3D LUT Not Implemented")
#IO Functions.
def open(path) :
'''
Opens any supported file format, located at path.
'''
openFunction = {
"cube" : LUT.openCube,
}[path[path.rfind('.') + 1:]]
return openFunction(path)
def openCube(path) :
'''
Opens .cube files. They must be saved with whitespaces. Referenced by open().
'''
lut = LUT() #Mutable luts are not reccommended for users.
with open(path, 'r') as f :
i = 0
for line in f :
#~ if not line.strip(): continue
sLine = line.strip()
if not sLine: continue
if sLine[0] == '#': continue
index = sLine[:sLine.find(' ')]
data = sLine[sLine.find(' ') + 1:]
if index == "TITLE": lut.title = data.strip('"'); continue
if index == "LUT_1D_SIZE": lut.dims = 1; lut.size = int(data); continue
if index == "LUT_3D_SIZE": lut.dims = 3; lut.size = int(data); continue
if index == "LUT_1D_INPUT_RANGE": lut.range = (float(data[:data.find(' ')]), float(data[data.find(' ') + 1:])); continue
if lut.dims == 1 and sLine[:sLine.find(' ')] :
lut.array[i] = float(sLine[:sLine.find(' ')])
i += 1
elif lut.dims == 3 :
print("3D LUT Not Implemened!")
return lut
def save(self, path) :
'''
Method that saves the LUT in a supported format, based on the path.
'''
saveFunction = {
"cube" : self.saveCube,
}[path[path.rfind('.') + 1:]]
saveFunction(path)
def saveCube(self, path) :
with open(path, 'w') as f :
print('TITLE', '"{}"'.format(self.title), file=f)
if self.dims == 1 :
print('LUT_1D_SIZE', '{}'.format(self.size), file=f)
print('LUT_1D_INPUT_RANGE', '{0:.6f} {1:.6f}'.format(*self.range), file=f)
print('# Created by openlut.\n', file=f)
for itm in self.array :
entry = '{0:.6f}'.format(itm)
print(entry, entry, entry, file=f)
elif self.dims == 3 :
print("3D LUT Not Implemented!")
#Overloaded functions
def __iter__(self) :
if dims == 1 :
return iter(self.array)
elif dims == 3 :
iArr = self.array.reshape(self.dims, self.size / self.dims) #Group into triplets.
return iter(iArr)
def __getitem__(self, key) :
return self.sample(key)
def __repr__(self) :
return 'LUT(\tdims = {0},\n\tsize = {1},\n\ttitle = "{2}"\n\tarray = {3}\n)'.format(self.dims, self.size, self.title, '\n\t\t'.join([line.strip() for line in repr(self.array).split('\n')]))
class Gamma(Transform) :
def __init__(self, func) :
self.func = func
#Gamma Methods
def __gamma(q, cpu, f, spSeq) :
q.put( (cpu, f(spSeq)) )
def sample(self, fSeq) :
fSeq = np.array(fSeq)
fVec = np.vectorize(self.func)
out = []
q = mp.Queue()
splt = Transform.spSeq(fSeq, mp.cpu_count())
for cpu in range(mp.cpu_count()) :
p = mp.Process(target=Gamma.__gamma, args=(q, cpu, fVec, splt[cpu]))
p.start()
for num in range(len(splt)) :
out.append(q.get())
return np.concatenate([seq[1] for seq in sorted(out, key=lambda seq: seq[0])], axis=0) if len(fSeq) > 1 else self.func(fSeq[0])
return fVec(fSeq) if len(fSeq) > 1 else self.func(fSeq[0])
#Static Gamma Functions (partly adapted from MLRawViewer)
def lin(x): return x
def sRGB(x) :
'''
sRGB formula. Domain must be within [0, 1].
'''
return ( (1.055) * (x ** (1.0 / 2.4)) ) - 0.055 if x > 0.0031308 else x * 12.92
def sRGBinv(x) :
'''
Inverse sRGB formula. Domain must be within [0, 1].
'''
return ((x + 0.055) / 1.055) ** 2.4 if x > 0.04045 else x / 12.92
def Rec709(x) :
'''
Rec709 formula. Domain must be within [0, 1].
'''
return 1.099 * (x ** 0.45) - 0.099 if x >= 0.018 else 4.5 * x
def ReinhardHDR(x) :
'''
Reinhard Tonemapping formula. Domain must be within [0, 1].
'''
return x / (1.0 + x)
def sLog(x) :
'''
sLog 1 formula. Domain must be within [0, 1]. See https://pro.sony.com/bbsccms/assets/
files/mkt/cinema/solutions/slog_manual.pdf .
'''
return ( 0.432699 * math.log(x + 0.037584, 10.0) + 0.616596) + 0.03
def sLog2(x) :
'''
sLog2 formula. Domain must be within [0, 1]. See https://pro.sony.com/bbsccms/assets/files/micro/dmpc/training/S-Log2_Technical_PaperV1_0.pdf .
'''
return ( 0.432699 * math.log( (155.0 * x) / 219.0 + 0.037584, 10.0) + 0.616596 ) + 0.03
def sLog3(x) :
'''
Not yet implemented. See http://community.sony.com/sony/attachments/sony/large-sensor-camera-F5-F55/12359/2/TechnicalSummary_for_S-Gamut3Cine_S-Gamut3_S-Log3_V1_00.pdf .
'''
return x
def DanLog(x) :
return (10.0 ** ((x - 0.385537) / 0.2471896) - 0.071272) / 3.555556 if x > 0.1496582 else (x - 0.092809) / 5.367655
def DanLoginv(x) :
pass
class TransMat(Transform) :
def __init__(self, *mats) :
'''
Initializes a combined 3x3 Transformation Matrix from any number of input matrices. These may be numpy arrays, matrices,
other TransMats, or any combination thereof.
'''
if len(mats) == 1 :
mat = mats[0]
if isinstance(mat, TransMat) :
self.mat = mat.mat #Support a copy constructor.
else :
self.mat = np.array(mat) #Simply set self.mat with the numpy array version of the mat.
elif len(mats) > 1 :
self.mat = TransMat.__mats(*[TransMat(mat) for mat in mats]).mat
elif not mats :
self.mat = np.identity(3)
def __mats(*inMats) :
'''
Initialize a combined Transform matrix from several input TransMats.
'''
return TransMat(reduce(TransMat.__mul__, reversed(inMats))) #Works because multiply is actually non-commutative dot.
#This is why we reverse inMats.
@numba.jit(nopython=True)
def __optDot(img, mat, shp, out) :
shaped = img.reshape((shp[0] * shp[1], shp[2])) #Flatten to 2D array for iteration over colors.
i = 0
while i < shp[0] * shp[1] :
res = np.dot(mat, shaped[i])
out[i] = res
i += 1
def __applMat(q, cpu, shp, mat, img3D) :
out = np.zeros((shp[0] * shp[1], shp[2]))
TransMat.__optDot(img3D, mat, shp, out)
q.put( (cpu, out.reshape(shp)) )
def sample(self, fSeq) :
shp = np.shape(fSeq)
if len(shp) == 1 :
return self.mat.dot(fSeq)
if len(shp) == 3 :
cpus = mp.cpu_count()
out = []
q = mp.Queue()
splt = Transform.spSeq(fSeq, cpus)
for cpu in range(cpus) :
p = mp.Process(target=TransMat.__applMat, args=(q, cpu, np.shape(splt[cpu]), self.mat, splt[cpu]))
p.start()
for num in range(len(splt)) :
out.append(q.get())
return np.concatenate([seq[1] for seq in sorted(out, key=lambda seq: seq[0])], axis=0)
#~ out = np.zeros((shp[0] * shp[1], shp[2]))
#~ TransMat.__optDot(fSeq, self.mat, shp, out)
#~ return out.reshape(shp)
#~ return np.array([self.mat.dot(col) for col in fSeq.reshape(shp[0] * shp[1], shp[2])]).reshape(shp)
#~ p = mp.Pool(mp.cpu_count())
#~ return np.array(list(map(self.mat.dot, fSeq.reshape(shp[0] * shp[1], shp[2])))).reshape(shp)
#~ return fSeq.dot(self.mat)
def inv(obj) :
if isinstance(obj, TransMat) : #Works on any TransMat object - including self.
return TransMat(np.linalg.inv(obj.mat))
else : #Works on raw numpy arrays as well.
return np.linalg.inv(obj)
def transpose(self) :
return TransMat(np.transpose(self.mat))
#Overloading
def __mul__(self, other) :
'''
* implements matrix multiplication.
'''
if isinstance(other, TransMat) :
return TransMat(self.mat.dot(other.mat))
elif isinstance(other, float) or isinstance(other, int) :
return TransMat(np.multiply(self.mat, other))
elif isinstance(other, np.ndarray) or isinstance(other, np.matrixlib.defmatrix.matrix) :
return TransMat(self.mat.dot(np.array(other)))
else :
raise ValueError('Invalid multiplication arguments!')
__rmul__ = __mul__
def __add__(self, other) :
if isinstance(other, TransMat) :
return TransMat(self.mat + other.mat)
elif isinstance(other, np.ndarray) or isinstance(other, np.matrixlib.defmatrix.matrix) :
return TransMat(self.mat + np.array(other))
else :
raise ValueError('Invalid addition arguments!')
__radd__ = __add__
def __pow__(self, other) :
'''
** implements direct multiplication. You usually don't want this.
'''
if isinstance(other, TransMat) :
return TransMat(self.mat * other.mat)
elif isinstance(other, float) or isinstance(other, int) :
return TransMat(np.multiply(self.mat, other))
elif isinstance(other, np.ndarray) or isinstance(other, np.matrixlib.defmatrix.matrix) :
return TransMat(self.mat * np.array(other))
else :
raise ValueError('Invalid multiplication arguments!')
def __invert__(self) :
return self.inv()
def __len__(self) :
return len(self.mat)
def __getitem__(self, key) :
return self.mat[key]
def __iter__(self) :
return iter(self.mat)
def __repr__(self) :
return "\nTransMat (\n{0} )\n".format(str(self.mat))
#Static Transmat Matrices -- all go from ACES to <gamut name>. <gamut name>inv functions go from the gamut to ACES.
#Converted (CIECAT02) D65 Illuminant for all.
XYZ = np.array(
[
0.93863095, -0.00574192, 0.0175669,
0.33809359, 0.7272139, -0.0653075,
0.00072312, 0.00081844, 1.08751619
]
).reshape(3, 3)
XYZinv = np.array(
[
1.06236611, 0.00840695, -0.01665579,
-0.49394137, 1.37110953, 0.09031659,
-0.00033467, -0.00103746, 0.91946965
]
).reshape(3, 3)
sRGB = np.array(
[
2.52193473, -1.1370239, -0.38491083,
-0.27547943, 1.36982898, -0.09434955,
-0.01598287, -0.14778923, 1.1637721
]
).reshape(3, 3)
sRGBinv = np.array(
[
0.43957568, 0.38391259, 0.17651173,
0.08960038, 0.81471415, 0.09568546,
0.01741548, 0.10873435, 0.87385017
]
).reshape(3, 3)
aRGB = np.array(
[
1.72502307, -0.4228857, -0.30213736,
-0.27547943, 1.36982898, -0.09434955,
-0.02666425, -0.08532111, 1.11198537
]
).reshape(3, 3)
aRGBinv = np.array(
[
0.61468318, 0.20122762, 0.1840892,
0.12529321, 0.77491365, 0.09979314,
0.02435304, 0.06428329, 0.91136367
]
).reshape(3, 3)
if __name__ == "__main__" :
if not sys.argv: print('Use -t to test!')
if sys.argv[1] == '-t' :
print('Open openlut.py and scroll down to the end to see the code that\'s working!')
#Open any format image. Try it with exr/dpx/anything!
img = ColMap.open('testpath/test.exr') #Opens a test image 'test.exr', creating a ColMap object, automatically using the best image backend available to load the image at the correct bit depth.
'''
Gamma has gamma functions like Gamma.sRGB, called by value like Gamma.sRGB(val). All take one argument, the value (x), and returns the transformed value. Color doesn't matter for gamma.
TransMat has matrices, in 3x3 numpy array form. All are relative to ACES, with direction aptly named. So, TransMat.XYZ is a matrix from ACES --> XYZ, while TransMat.XYZinv goes from XYZ --> ACES. All use/are converted to the D65 illuminant, for consistency sake.
'''
#Gamma Functions: sRGB --> Linear.
gFunc = Gamma(Gamma.sRGBinv) #A Gamma Transform object using the sRGB-->Linear gamma formula. Apply to ColMaps!
gFuncManualsRGB = Gamma(lambda val: ((val + 0.055) / 1.055) ** 2.4 if val > 0.04045 else val / 12.92) #It's generic - specify any gamma function, even inline with a lambda!
#LUT from Function: sRGB --> Linear
oLut = LUT.lutFunc(Gamma.sRGBinv) #A LUT Transform object, created from a gamma function. Size is 16384 by default. LUTs are faster!
oLut.save('testpath/sRGB-->Lin.cube') #Saves the LUT to a format inferred from the extension. cube only for now!
#Opening LUTs from .cube files.
lut = LUT.open('testpath/sRGB-->Lin.cube') #Opens the lut we just made into a different LUT object.
lut.resized(17).save('testpath/sRGB-->Lin_tiny.cube') #Resizes the LUT, then saves it again to a much smaller file!
#Matrix Transformations
simpleMat = TransMat(TransMat.sRGBinv) #A Matrix Transform (TransMat) object, created from a color transform matrix for gamut transformations! This one is sRGB --> ACES.
mat = TransMat(TransMat.sRGBinv, TransMat.XYZ, TransMat.XYZinv, TransMat.aRGB) * TransMat.aRGBinv
#Indeed, specify many matrices which auto-multiply into a single one! You can also combine them after, with simple multiplication.
#Applying and saving.
img.apply(gFunc).save('testpath/openlut_gammafunc.png') #save saves an image using the appropriate image backend, based on the extension.
img.apply(lut).save('testpath/openlut_lut-lin-16384.png') #apply applies any color transformation object that inherits from Transform - LUT, Gamma, TransMat, etc., or make your own! It's easy ;) .
img.apply(lut.resized(17)).save('testpath/openlut_lut-lin-17.png') #Why so small? Because spline interpolation automatically turns on. It's identical to the larger LUT!
img.apply(mat).save('testpath/openlut_mat.png') #Applies the gamut transformation.
#As a proof of concept, here's a long list of transformations that should, in sum, do nothing :) :
img.apply(lut).apply(LUT.lutFunc(Gamma.sRGB)).apply(mat).apply(~mat).save('testpath/openlut_noop.png') #~mat is the inverse of mat. Easily undo the gamut operation!
#Format Test: All output images are in Linear ACES.
tImg = img.apply(mat)
tImg.save('testpath/output.exr')
tImg.save('testpath/output.dpx')
tImg.save('testpath/output.png')
tImg.save('testpath/output.jpg')
tImg.save('testpath/output.tif') #All sorts of formats work! Bit depth is 16, unless you say something else.
#Compression is impossible right now - wand is being difficult.
#Keep in mind, values are clipped from 0 to 1 when done. Scary transforms can make this an issue!
#Color management is simple: openlut doesn't touch your data, unless you tell it to with a Transform. So, the data that goes in, goes out, unless a Transform was applied.

BIN
testpath/test.exr 100644

Binary file not shown.