Added LUT interp and Matrix math to C++ code. It's super duper fast now! See performance.txt.
parent
59f42a2622
commit
b19fa24751
24
README.md
24
README.md
|
@ -4,23 +4,25 @@
|
||||||
|
|
||||||
What is it?
|
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
|
openlut is, at its core, a transform-focused 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. .
|
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
|
openlut is also a practical 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.
|
your console. Included already is an OpenGL image viewer, which might grow in the future to play sequences.
|
||||||
|
|
||||||
I wanted it to cover this niche simply and consistently, something color management often isn't! Take a look; hopefully you'll agree :) !
|
I wanted it to cover this niche simply and consistently, with batteries included (a library of gamma functions and color gamut matrices).
|
||||||
|
Color management doesn't have to be so difficult!
|
||||||
|
|
||||||
|
|
||||||
What About OpenColorIO? Why does this exist?
|
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.
|
OpenColorIO does amazing work - but mostly in the context of large applications, not-simple config files, and self-defined color space
|
||||||
openlut is more simple; it doesn't care about the big picture - you just do consistent operations on images. openlut also has tools to deal
|
(with the full range of int/float bit depth specifics, etc.)
|
||||||
with these building blocks, unlike OCIO - resizing LUTs, etc. .
|
|
||||||
|
|
||||||
Indeed, OCIO is just a system these basic operations using LUTs - in somewhat unintuitive ways, in my opinion. You could setup a similar system
|
openlut is all about images and the transforms on images. Everything happens in (0, 1) float space. Large emphasis is placed on managing the
|
||||||
using openlut's toolkit.
|
tools themselves as well - composing matrices, resizing LUTs, defining new gamma functions, etc. .
|
||||||
|
|
||||||
|
In many ways, OCIO is a system stringing basic operations together. I'd be perfectly plausible to write an OCIO alternative with openlut in the backend.
|
||||||
|
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
|
@ -33,9 +35,9 @@ Simply use pip: `sudo pip3 install openlut` (pip3 denotes that you must use a Py
|
||||||
|
|
||||||
*If it's breaking, try running `sudo pip3 install -U pip setuptools`. Sometimes they are out of date.*
|
*If it's breaking, try running `sudo pip3 install -U pip setuptools`. Sometimes they are out of date.*
|
||||||
|
|
||||||
Installing Dependencies
|
Installing Compile Dependencies
|
||||||
-----
|
-----
|
||||||
Not Difficult, I promise!
|
For the moment, I don't have a Mac wheel. Not Difficult, I promise!
|
||||||
|
|
||||||
On Debian/Ubuntu: `sudo apt-get install python3-pip gcc pybind11-dev libmagickwand-dev`
|
On Debian/Ubuntu: `sudo apt-get install python3-pip gcc pybind11-dev libmagickwand-dev`
|
||||||
On Mac: `brew install python3 gcc pybind11 imagemagick`
|
On Mac: `brew install python3 gcc pybind11 imagemagick`
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
from functools import reduce
|
||||||
|
from imp import reload
|
||||||
|
|
||||||
|
import openlut as ol
|
||||||
|
from openlut.lib.files import Log
|
||||||
|
|
||||||
|
img = ol.ColMap.open('img_test/rock.exr')
|
||||||
|
fSeq = img.rgbArr
|
||||||
|
lut = ol.LUT.lutFunc(ol.gamma.sRGB)
|
|
@ -86,7 +86,6 @@ class ColMap :
|
||||||
with wand.image.Image(blob=binData, format=fmt, width=width, height=height) as img:
|
with wand.image.Image(blob=binData, format=fmt, width=width, height=height) as img:
|
||||||
return ColMap.fromIntArray(np.fromstring(img.make_blob("RGB"), dtype='uint{}'.format(img.depth)).reshape(img.height, img.width, 3))
|
return ColMap.fromIntArray(np.fromstring(img.make_blob("RGB"), dtype='uint{}'.format(img.depth)).reshape(img.height, img.width, 3))
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def toBinary(self, fmt, depth=16) :
|
def toBinary(self, fmt, depth=16) :
|
||||||
'''
|
'''
|
||||||
Using Wand blob functionality
|
Using Wand blob functionality
|
||||||
|
@ -95,7 +94,6 @@ class ColMap :
|
||||||
img.format = fmt
|
img.format = fmt
|
||||||
return img.make_blob()
|
return img.make_blob()
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def save(self, path, compress = None, depth = None) :
|
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.
|
Save the image. The filetype will be inferred from the path, and the appropriate backend will be used.
|
||||||
|
@ -140,7 +138,7 @@ class ColMap :
|
||||||
#Display Functions
|
#Display Functions
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def display(path, width = 1200) :
|
def display(path, width = 1000) :
|
||||||
'''
|
'''
|
||||||
Shows an image at a path without making a ColMap.
|
Shows an image at a path without making a ColMap.
|
||||||
'''
|
'''
|
||||||
|
@ -153,7 +151,7 @@ class ColMap :
|
||||||
|
|
||||||
Viewer.run(img, xRes, yRes, title = os.path.basename(path))
|
Viewer.run(img, xRes, yRes, title = os.path.basename(path))
|
||||||
|
|
||||||
def show(self, width = 1200) :
|
def show(self, width = 1000) :
|
||||||
#Use my custom OpenGL viewer!
|
#Use my custom OpenGL viewer!
|
||||||
Viewer.run(self.rgbArr, width, int(width * self.rgbArr.shape[0]/self.rgbArr.shape[1]))
|
Viewer.run(self.rgbArr, width, int(width * self.rgbArr.shape[0]/self.rgbArr.shape[1]))
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import numpy as np
|
||||||
#~ import numba
|
#~ import numba
|
||||||
|
|
||||||
from .Transform import Transform
|
from .Transform import Transform
|
||||||
|
from .lib import olOpt as olo
|
||||||
|
|
||||||
class ColMat(Transform) :
|
class ColMat(Transform) :
|
||||||
def __init__(self, *mats) :
|
def __init__(self, *mats) :
|
||||||
|
@ -21,66 +22,24 @@ class ColMat(Transform) :
|
||||||
else :
|
else :
|
||||||
self.mat = np.array(mat) #Simply set self.mat with the numpy array version of the mat.
|
self.mat = np.array(mat) #Simply set self.mat with the numpy array version of the mat.
|
||||||
elif len(mats) > 1 :
|
elif len(mats) > 1 :
|
||||||
self.mat = ColMat.__mats(*[ColMat(mat) for mat in mats]).mat
|
self.mat = ColMat._mats(*[ColMat(mat) for mat in mats]).mat
|
||||||
elif not mats :
|
elif not mats :
|
||||||
self.mat = np.identity(3)
|
self.mat = np.identity(3)
|
||||||
|
|
||||||
def __mats(*inMats) :
|
def _mats(*inMats) :
|
||||||
'''
|
'''
|
||||||
Initialize a combined Transform matrix from several input ColMats.
|
Initialize a combined Transform matrix from several input ColMats. Use constructor instead.
|
||||||
'''
|
'''
|
||||||
return ColMat(reduce(ColMat.__mul__, reversed(inMats))) #Works because multiply is actually non-commutative dot.
|
return ColMat(reduce(ColMat.__mul__, reversed(inMats))) #Works because multiply is actually non-commutative dot.
|
||||||
#This is why we reverse inMats.
|
#This is why we reverse inMats.
|
||||||
|
|
||||||
#~ @numba.jit(nopython=True)
|
|
||||||
def __optDot(img, mat, shp, out) :
|
|
||||||
'''
|
|
||||||
Dots the matrix with each tuple of colors in the img.
|
|
||||||
|
|
||||||
img: Numpy array of shape (height, width, 3).
|
|
||||||
mat: The 3x3 numpy array representing the color transform matrix.
|
|
||||||
shp: The shape of the image.
|
|
||||||
out: the output list. Built mutably for numba's sake.
|
|
||||||
'''
|
|
||||||
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]))
|
|
||||||
ColMat.__optDot(img3D, mat, shp, out)
|
|
||||||
q.put( (cpu, out.reshape(shp)) )
|
|
||||||
|
|
||||||
def sample(self, fSeq) :
|
def sample(self, fSeq) :
|
||||||
shp = np.shape(fSeq)
|
shp = np.shape(fSeq)
|
||||||
if len(shp) == 1 :
|
if len(shp) == 1 :
|
||||||
return self.mat.dot(fSeq)
|
return self.mat.dot(fSeq)
|
||||||
if len(shp) == 3 :
|
if len(shp) == 3 :
|
||||||
cpus = mp.cpu_count()
|
#C++ based olo.matr replaces & sped up the operation by 50x with same output!!!
|
||||||
out = []
|
return olo.matr(fSeq.reshape(reduce(lambda a, b: a*b, fSeq.shape)), self.mat.reshape(reduce(lambda a, b: a*b, self.mat.shape))).reshape(fSeq.shape)
|
||||||
q = mp.Queue()
|
|
||||||
splt = Transform.spSeq(fSeq, cpus)
|
|
||||||
for cpu in range(cpus) :
|
|
||||||
p = mp.Process(target=ColMat.__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]))
|
|
||||||
#~ ColMat.__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) :
|
def inv(obj) :
|
||||||
if isinstance(obj, ColMat) : #Works on any ColMat object - including self.
|
if isinstance(obj, ColMat) : #Works on any ColMat object - including self.
|
||||||
|
|
|
@ -15,7 +15,7 @@ from .Transform import Transform
|
||||||
from .lib import olOpt as olo
|
from .lib import olOpt as olo
|
||||||
|
|
||||||
class LUT(Transform) :
|
class LUT(Transform) :
|
||||||
def __init__(self, dims = 1, size = 16384, title = "openlut_LUT", iRange = (0.0, 1.0)) :
|
def __init__(self, dims = 1, size = 4096, title = "openlut_LUT", iRange = (0.0, 1.0)) :
|
||||||
'''
|
'''
|
||||||
Create an identity LUT with given dimensions (1 or 3), size, and title.
|
Create an identity LUT with given dimensions (1 or 3), size, and title.
|
||||||
'''
|
'''
|
||||||
|
@ -33,7 +33,7 @@ class LUT(Transform) :
|
||||||
print("3D LUT Not Implemented!")
|
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.
|
#~ 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", iRange = (0.0, 1.0)) :
|
def lutFunc(func, size = 4096, dims = 1, title="openlut_FuncGen", iRange = (0.0, 1.0)) :
|
||||||
'''
|
'''
|
||||||
Creates a LUT from a simple function.
|
Creates a LUT from a simple function.
|
||||||
'''
|
'''
|
||||||
|
@ -69,11 +69,8 @@ class LUT(Transform) :
|
||||||
return LUT.lutArray(splev(np.linspace(0, 1, num=len(idArr)), splrep(idArr, mapArr)))
|
return LUT.lutArray(splev(np.linspace(0, 1, num=len(idArr)), splrep(idArr, mapArr)))
|
||||||
|
|
||||||
#LUT Functions.
|
#LUT Functions.
|
||||||
def __interp(q, cpu, spSeq, ID, array, spl) :
|
def _splInterp(q, cpu, spSeq, ID, array) :
|
||||||
if spl :
|
|
||||||
q.put( (cpu, splev(spSeq, splrep(ID, array))) ) #Spline Interpolation. Pretty quick, considering.
|
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) :
|
def sample(self, fSeq, spl=True) :
|
||||||
'''
|
'''
|
||||||
|
@ -91,15 +88,16 @@ class LUT(Transform) :
|
||||||
|
|
||||||
fSeq = np.array(fSeq)
|
fSeq = np.array(fSeq)
|
||||||
if self.dims == 1 :
|
if self.dims == 1 :
|
||||||
#~ return np.interp(spSeq, self.ID, self.array)
|
|
||||||
|
|
||||||
#If scipy isn't loaded, we can't use spline interpolation!
|
#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.
|
if (not MOD_SCIPY) or self.size > 25 : # Auto-adapts all but the smallest LUTs to use the faster linear interpolation.
|
||||||
|
return olo.lut1dlin(fSeq.reshape(reduce(lambda a, b: a*b, fSeq.shape)), self.array, self.range[0], self.range[1]).reshape(fSeq.shape)
|
||||||
|
else :
|
||||||
|
#~ return np.interp(spSeq, self.ID, self.array) #non-threaded way.
|
||||||
out = []
|
out = []
|
||||||
q = mp.Queue()
|
q = mp.Queue()
|
||||||
splt = Transform.spSeq(fSeq, mp.cpu_count())
|
splt = Transform.spSeq(fSeq, mp.cpu_count())
|
||||||
for cpu in range(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 = mp.Process(target=LUT._splInterp, args=(q, cpu, splt[cpu], self.ID, self.array))
|
||||||
p.start()
|
p.start()
|
||||||
|
|
||||||
for num in range(len(splt)) :
|
for num in range(len(splt)) :
|
||||||
|
|
|
@ -1,10 +1,20 @@
|
||||||
|
import multiprocessing as mp
|
||||||
|
|
||||||
|
#Future: Use GLFW
|
||||||
import pygame
|
import pygame
|
||||||
from pygame.locals import *
|
from pygame.locals import *
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
MOD_OPENGL = True
|
MOD_OPENGL = True
|
||||||
try :
|
try :
|
||||||
from OpenGL.GL import *
|
from OpenGL.GL import *
|
||||||
|
from OpenGL.GL.shaders import compileShader,ShaderProgram
|
||||||
from OpenGL.GLU import *
|
from OpenGL.GLU import *
|
||||||
|
from OpenGL.arrays import vbo #This is a class that makes it easy to use Vertex Buffer Objects.
|
||||||
|
from OpenGL.GL.framebufferobjects import *
|
||||||
|
from OpenGL.GL.EXT.framebuffer_object import *
|
||||||
|
#~ from OpenGLContext.arrays import *
|
||||||
except :
|
except :
|
||||||
print('Unable to load OpenGL. Make sure your graphics drivers are installed & up to date!')
|
print('Unable to load OpenGL. Make sure your graphics drivers are installed & up to date!')
|
||||||
MOD_OPENGL = False
|
MOD_OPENGL = False
|
||||||
|
@ -16,40 +26,94 @@ class Viewer :
|
||||||
def __init__(self, res, title="OpenLUT Image Viewer") :
|
def __init__(self, res, title="OpenLUT Image Viewer") :
|
||||||
self.res = res
|
self.res = res
|
||||||
|
|
||||||
|
#Vertex shaders calculate vertex positions - gl_position, which is a vec4.
|
||||||
|
#In our case, this vec4 is on a ortho projected square in front of the screen.
|
||||||
|
#~ self.shaderVertex = compileShader("""#version 330 core
|
||||||
|
#~ layout (location = 0) in vec2 position;
|
||||||
|
#~ layout (location = 1) in vec2 texCoords;
|
||||||
|
|
||||||
|
#~ out vec2 TexCoords;
|
||||||
|
|
||||||
|
#~ void main()
|
||||||
|
#~ {
|
||||||
|
#~ gl_Position = vec4(position.x, position.y, 0.0f, 1.0f);
|
||||||
|
#~ TexCoords = texCoords;
|
||||||
|
#~ }
|
||||||
|
#~ """, GL_VERTEX_SHADER )
|
||||||
|
|
||||||
|
#After a vertex is processed, clupping happens, etc. Then frag shader.
|
||||||
|
#Fragment shaders make "fragments" - pixels, subpixels, hidden stuff, etc. . They can do per pixel stuff.
|
||||||
|
#Goal: Make gl_FragColor, the color of the fragment. It's a vec4.
|
||||||
|
#In this case, we're sampling the texture coordinates.
|
||||||
|
#~ self.shaderFrag = compileShader("""#version 330 core
|
||||||
|
#~ in vec2 TexCoords;
|
||||||
|
#~ out vec4 color;
|
||||||
|
|
||||||
|
#~ uniform sampler2D screenTexture;
|
||||||
|
|
||||||
|
#~ void main()
|
||||||
|
#~ {
|
||||||
|
#~ color = texture(screenTexture, TexCoords);
|
||||||
|
#~ }
|
||||||
|
#~ """, GL_FRAGMENT_SHADER )
|
||||||
|
|
||||||
|
#Convenience for glCreateProgram, then attaches each shader via pointer, links with glLinkProgram,
|
||||||
|
#validates with glValidateProgram and glGetProgramiv, then cleanup & return shader program.
|
||||||
|
#~ self.shader = Viewer.shaderProgramCompile(self.shaderVertex, self.shaderFrag)
|
||||||
|
#~ self.vbo = self.bindVBO()
|
||||||
|
|
||||||
|
#Init pygame in OpenGL double-buffered mode.
|
||||||
pygame.init()
|
pygame.init()
|
||||||
pygame.display.set_caption(title)
|
pygame.display.set_caption(title)
|
||||||
pygame.display.set_mode(res, DOUBLEBUF|OPENGL)
|
pygame.display.set_mode((res), DOUBLEBUF|OPENGL)
|
||||||
|
|
||||||
|
#Initialize OpenGL.
|
||||||
self.initGL()
|
self.initGL()
|
||||||
|
|
||||||
|
def shaderProgramCompile(*shaders) :
|
||||||
|
prog = glCreateProgram()
|
||||||
|
for shader in shaders :
|
||||||
|
glAttachShader(prog, shader)
|
||||||
|
prog = ShaderProgram(prog)
|
||||||
|
glLinkProgram(prog)
|
||||||
|
return prog
|
||||||
|
|
||||||
def initGL(self) :
|
def initGL(self) :
|
||||||
'''
|
'''
|
||||||
Initialize OpenGL.
|
Initialize OpenGL.
|
||||||
'''
|
'''
|
||||||
|
#Start up OpenGL in Ortho projection mode.
|
||||||
glEnable(GL_TEXTURE_2D)
|
glEnable(GL_TEXTURE_2D)
|
||||||
|
|
||||||
|
glViewport(0, 0, self.res[0], self.res[1])
|
||||||
|
|
||||||
glMatrixMode(GL_PROJECTION)
|
glMatrixMode(GL_PROJECTION)
|
||||||
glLoadIdentity()
|
glLoadIdentity()
|
||||||
glOrtho(0, self.res[0], self.res[1], 0, 0, 100)
|
glOrtho(0, self.res[0], self.res[1], 0, 0, 100)
|
||||||
|
|
||||||
glMatrixMode(GL_MODELVIEW)
|
glMatrixMode(GL_MODELVIEW)
|
||||||
|
#~ glUseProgram(self.shader)
|
||||||
|
|
||||||
#~ glClearColor(0, 0, 0, 0)
|
def resizeWindow(self, newRes) :
|
||||||
#~ glClearDepth(0)
|
#~ print(newRes)
|
||||||
#~ glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
|
self.res = newRes
|
||||||
|
pygame.display.set_mode(self.res, DOUBLEBUF|OPENGL)
|
||||||
|
glViewport(0, 0, self.res[0], self.res[1]) #Reset viewport
|
||||||
|
|
||||||
#~ def resizeWindow(self, newRes) :
|
glMatrixMode(GL_PROJECTION) #Modify projection matrix
|
||||||
#~ self.res = newRes
|
glLoadIdentity() #Load in identity matrix
|
||||||
#~ pygame.display.set_mode(self.res, RESIZABLE|DOUBLEBUF|OPENGL)
|
glOrtho(0, self.res[0], self.res[1], 0, 0, 100) #New projection matrix
|
||||||
##~ glLoadIdentity()
|
|
||||||
##~ glOrtho(0, self.res[0], self.res[1], 0, 0, 100)
|
|
||||||
|
|
||||||
##~ glMatrixMode(GL_MODELVIEW)
|
glMatrixMode(GL_MODELVIEW) #Switch back to model matrix.
|
||||||
|
glLoadIdentity() #Load an identity matrix into the model-view matrix
|
||||||
|
|
||||||
def drawQuad(self) :
|
#~ pygame.display.flip()
|
||||||
|
|
||||||
|
def drawImage(self) :
|
||||||
'''
|
'''
|
||||||
Draws an image to the screen.
|
Draws an image to the screen.
|
||||||
'''
|
'''
|
||||||
|
#~ print("\r", self.res, end="", sep="")
|
||||||
glBegin(GL_QUADS)
|
glBegin(GL_QUADS)
|
||||||
|
|
||||||
glTexCoord2i(0, 0)
|
glTexCoord2i(0, 0)
|
||||||
|
@ -66,40 +130,100 @@ class Viewer :
|
||||||
|
|
||||||
glEnd()
|
glEnd()
|
||||||
|
|
||||||
def bindTex(self, img) :
|
def bindVBO(self, verts=np.array([[0,1,0],[-1,-1,0],[1,-1,0]], dtype='f')) :
|
||||||
|
vertPos = vbo.VBO(verts)
|
||||||
|
|
||||||
|
indices = np.array([[0, 1, 2]], dtype=np.int32)
|
||||||
|
indPos = vbo.VBO(indices, target=GL_ELEMENT_ARRAY_BUFFER)
|
||||||
|
|
||||||
|
return (vertPos, indPos)
|
||||||
|
|
||||||
|
def bindFBO(self) :
|
||||||
|
'''
|
||||||
|
Create and bind a framebuffer for rendering (loading images) to.
|
||||||
|
'''
|
||||||
|
|
||||||
|
fbo = glGenFramebuffers(1) #Create framebuffer
|
||||||
|
|
||||||
|
#Binding it makes the next read and write framebuffer ops affect the bound framebuffer.
|
||||||
|
#You can also bind it specifically to read/write targets. GL_READ_FRAMEBUFFER and GL_DRAW_FRAMEBUFFER.
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo)
|
||||||
|
|
||||||
|
#It needs 1+ same sampled buffers (color, depth, stencil) and a "complete" color attachment.
|
||||||
|
|
||||||
|
#Create a texture to render to. Empty for now; size is screen size.
|
||||||
|
tex = self.bindTex(None, res=self.res) #Fill it up with nothing, for now. It's our color attachment.
|
||||||
|
|
||||||
|
glBindTexture(GL_TEXTURE_2D, 0)
|
||||||
|
|
||||||
|
#Target is framebuffer, attachment is color, textarget is 2D texture, the texture is tex, the mipmap level is 0.
|
||||||
|
#We attach the texture to the frame buffer.
|
||||||
|
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, tex, 0)
|
||||||
|
|
||||||
|
#Renderbuffers are write-only; can't be sampled, just displayed. Often used as depth and stencil. So useless here :).
|
||||||
|
|
||||||
|
if glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE :
|
||||||
|
print("Framebuffer not complete!")
|
||||||
|
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, 0); #Finally - bind the framebuffer!
|
||||||
|
|
||||||
|
#We're now rendering to the framebuffer texture. How cool!
|
||||||
|
|
||||||
|
return fbo
|
||||||
|
|
||||||
|
|
||||||
|
def bindTex(self, img, res=None) :
|
||||||
'''
|
'''
|
||||||
Binds the image contained the numpy float array img to a 2D texture on the GPU.
|
Binds the image contained the numpy float array img to a 2D texture on the GPU.
|
||||||
'''
|
'''
|
||||||
id = glGenTextures(1)
|
if not res: res = img.shape
|
||||||
|
|
||||||
|
tex = glGenTextures(1)
|
||||||
|
|
||||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
|
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
|
||||||
glBindTexture(GL_TEXTURE_2D, id)
|
glBindTexture(GL_TEXTURE_2D, tex)
|
||||||
|
|
||||||
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
|
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP) #Clamp to edge
|
||||||
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
|
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP)
|
||||||
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
|
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) #Mag/Min Interpolation
|
||||||
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
||||||
|
|
||||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, img.shape[1], img.shape[0], 0, GL_RGB, GL_FLOAT, img)
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, res[1], res[0], 0, GL_RGB, GL_FLOAT, img)
|
||||||
|
|
||||||
def display(self) :
|
return tex
|
||||||
|
|
||||||
|
def display(self, fbo = 1, tex = 1) :
|
||||||
'''
|
'''
|
||||||
Repaints the window.
|
Repaints the window.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
#Clears the "canvas"
|
#Here, we do things to the framebuffer. Not the screen. Important.
|
||||||
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
|
#~ glBindFramebuffer(GL_FRAMEBUFFER, fbo)
|
||||||
|
#~ glClearColor(0, 0, 0, 1.0)
|
||||||
|
glClear(GL_COLOR_BUFFER_BIT)
|
||||||
glMatrixMode(GL_MODELVIEW)
|
glMatrixMode(GL_MODELVIEW)
|
||||||
|
|
||||||
#Maybe do them here.
|
#This render is rendering to framebuffer
|
||||||
glEnable(GL_TEXTURE_2D)
|
glEnable(GL_TEXTURE_2D)
|
||||||
self.drawQuad()
|
self.drawImage()
|
||||||
|
|
||||||
|
#~ #Back to the screen.
|
||||||
|
#~ glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
||||||
|
#~ glClearColor(0, 1, 1, 1)
|
||||||
|
#~ glClear(GL_COLOR_BUFFER_BIT)
|
||||||
|
|
||||||
|
#~ glUseProgram(self.shader)
|
||||||
|
#~ glBindTexture(GL_TEXTURE_2D, tex)
|
||||||
|
|
||||||
|
#~ glBindVertexArray(0)
|
||||||
|
#~ glUseProgram(0)
|
||||||
|
|
||||||
#Updates the display.
|
#Updates the display.
|
||||||
pygame.display.flip()
|
pygame.display.flip()
|
||||||
|
|
||||||
def close() :
|
def close() :
|
||||||
#~ print()
|
print()
|
||||||
|
#~ glUseProgram(0)
|
||||||
pygame.quit()
|
pygame.quit()
|
||||||
|
|
||||||
def run(img, xRes, yRes, title = "OpenLUT Image Viewer") :
|
def run(img, xRes, yRes, title = "OpenLUT Image Viewer") :
|
||||||
|
@ -109,7 +233,8 @@ class Viewer :
|
||||||
if not MOD_OPENGL: print("OpenGL not enabled. Viewer won't start."); return
|
if not MOD_OPENGL: print("OpenGL not enabled. Viewer won't start."); return
|
||||||
|
|
||||||
v = Viewer((xRes, yRes), title)
|
v = Viewer((xRes, yRes), title)
|
||||||
v.bindTex(img)
|
gpuImg = v.bindTex(img)
|
||||||
|
#~ gpuBuf = v.bindFBO()
|
||||||
|
|
||||||
FPS = None
|
FPS = None
|
||||||
clock = pygame.time.Clock()
|
clock = pygame.time.Clock()
|
||||||
|
@ -118,19 +243,21 @@ class Viewer :
|
||||||
for event in pygame.event.get() :
|
for event in pygame.event.get() :
|
||||||
if event.type == pygame.QUIT: Viewer.close(); break
|
if event.type == pygame.QUIT: Viewer.close(); break
|
||||||
|
|
||||||
#~ if event.type == pygame.VIDEORESIZE :
|
if event.type == pygame.VIDEORESIZE :
|
||||||
#~ v.resizeWindow((event.w, event.h))
|
v.resizeWindow((event.w, event.h))
|
||||||
|
|
||||||
if event.type == pygame.KEYDOWN :
|
if event.type == pygame.KEYDOWN :
|
||||||
|
if str(event.key) == "27": Viewer.close(); break #Need to catch ESC to close the window.
|
||||||
|
|
||||||
try :
|
try :
|
||||||
{
|
{
|
||||||
|
|
||||||
}[event.key]()
|
}[event.key]()
|
||||||
except KeyError as key :
|
except KeyError as key :
|
||||||
if str(key) == "27": Viewer.close(); break #Need to catch ESC to close the window.
|
|
||||||
print("Key not mapped!")
|
print("Key not mapped!")
|
||||||
else :
|
else :
|
||||||
#This else will only run if the event loop is completed.
|
#This else will only run if the event loop is completed.
|
||||||
|
#~ v.display(fbo = gpuBuf, tex = gpuImg)
|
||||||
v.display()
|
v.display()
|
||||||
|
|
||||||
#Smooth playback at FPS.
|
#Smooth playback at FPS.
|
||||||
|
|
|
@ -6,5 +6,9 @@ from .Func import Func
|
||||||
from .ColMat import ColMat
|
from .ColMat import ColMat
|
||||||
from .Viewer import Viewer
|
from .Viewer import Viewer
|
||||||
|
|
||||||
|
#Ensure the package namespace lines up.
|
||||||
|
from . import gamma
|
||||||
|
from . import gamut
|
||||||
|
|
||||||
__all__ = ['ColMap', 'Transform', 'LUT', 'Func', 'ColMat', 'Viewer', 'gamma', 'gamut']
|
__all__ = ['ColMap', 'Transform', 'LUT', 'Func', 'ColMat', 'Viewer', 'gamma', 'gamut']
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,17 @@ Copyright 2016 Sofus Rose
|
||||||
import sys, os, time
|
import sys, os, time
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
MOD_MATPLOTLIB = False
|
||||||
|
try:
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.mlab as mlab
|
||||||
|
|
||||||
|
MOD_MATPLOTLIB = True
|
||||||
|
except:
|
||||||
|
print("Matplotlib not installed. Graphs won't be drawn")
|
||||||
|
|
||||||
class Files :
|
class Files :
|
||||||
"""
|
"""
|
||||||
The Files object is an immutable sequence of files, which supports writing simultaneously to all the files.
|
The Files object is an immutable sequence of files, which supports writing simultaneously to all the files.
|
||||||
|
@ -197,6 +208,41 @@ class Log(ColLib) :
|
||||||
else :
|
else :
|
||||||
raise ValueError('Run wasn\'t found!!')
|
raise ValueError('Run wasn\'t found!!')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def bench(f, args=[], kwargs={}, trials=15, graph=False) :
|
||||||
|
def t(): l = Log(); l.startTime(0); f(*args, **kwargs); return l.getTime(0)
|
||||||
|
|
||||||
|
data = np.array([t() for i in range(trials)])
|
||||||
|
anyl = { 'mean' : np.mean(data),
|
||||||
|
'median' : np.median(data),
|
||||||
|
'std_dev' : np.std(data),
|
||||||
|
'vari' : np.std(data) ** 2,
|
||||||
|
'total' : sum(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if graph: Log.graphBench(anyl)
|
||||||
|
|
||||||
|
return anyl
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def graphBench(anyl) :
|
||||||
|
if MOD_MATPLOTLIB :
|
||||||
|
fig = plt.figure()
|
||||||
|
|
||||||
|
x = np.linspace(-3 * anyl['std_dev'] + anyl['mean'], 3 * anyl['std_dev'] + anyl['mean'], 100)
|
||||||
|
|
||||||
|
plt.plot(x, mlab.normpdf(x, anyl['mean'], anyl['std_dev']))
|
||||||
|
|
||||||
|
plt.axvline(x = anyl['mean'], color='red', linestyle = "--")
|
||||||
|
plt.text( anyl['mean'] - 0.2 * anyl['std_dev'], 0, 'mean',
|
||||||
|
horizontalalignment = 'left', verticalalignment='bottom',
|
||||||
|
rotation = 90, fontsize=10, fontstyle='italic'
|
||||||
|
)
|
||||||
|
plt.xlabel('Time (Seconds)', fontsize=15)
|
||||||
|
plt.ylabel('Distribution', fontsize=11)
|
||||||
|
|
||||||
|
plt.show()
|
||||||
|
|
||||||
def compItem(self, state, time, *text) :
|
def compItem(self, state, time, *text) :
|
||||||
"""
|
"""
|
||||||
Returns a displayable log item as a string, formatted with or without color.
|
Returns a displayable log item as a string, formatted with or without color.
|
||||||
|
|
|
@ -10,6 +10,8 @@
|
||||||
|
|
||||||
//~ #include "samplers.h"
|
//~ #include "samplers.h"
|
||||||
|
|
||||||
|
//~ #define EPSILON 0.0001
|
||||||
|
|
||||||
namespace py = pybind11;
|
namespace py = pybind11;
|
||||||
using namespace std;
|
using namespace std;
|
||||||
|
|
||||||
|
@ -26,7 +28,6 @@ float sLog(float x) { return (0.432699 * log10(x + 0.037584) + 0.616596) + 0.03;
|
||||||
float sLog2(float x) { return ( 0.432699 * log10( (155.0 * x) / 219.0 + 0.037584) + 0.616596 ) + 0.03; }
|
float sLog2(float x) { return ( 0.432699 * log10( (155.0 * x) / 219.0 + 0.037584) + 0.616596 ) + 0.03; }
|
||||||
float DanLog(float x) { return x > 0.1496582 ? (pow(10.0, ((x - 0.385537) / 0.2471896)) - 0.071272) / 3.555556 : (x - 0.092809) / 5.367655; }
|
float DanLog(float x) { return x > 0.1496582 ? (pow(10.0, ((x - 0.385537) / 0.2471896)) - 0.071272) / 3.555556 : (x - 0.092809) / 5.367655; }
|
||||||
|
|
||||||
|
|
||||||
//gam lets the user pass in any 1D array, any one-arg C++ function, and get a result. It's multithreaded, vectorized, etc. .
|
//gam lets the user pass in any 1D array, any one-arg C++ function, and get a result. It's multithreaded, vectorized, etc. .
|
||||||
py::array_t<float> gam(py::array_t<float> arr, const std::function<float(float)> &g_func) {
|
py::array_t<float> gam(py::array_t<float> arr, const std::function<float(float)> &g_func) {
|
||||||
py::buffer_info bufIn = arr.request();
|
py::buffer_info bufIn = arr.request();
|
||||||
|
@ -54,6 +55,95 @@ py::array_t<float> gam(py::array_t<float> arr, const std::function<float(float)>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//lut1d takes a flattened image array and a flattened 1D array, and returns a linearly interpolated result.
|
||||||
|
py::array_t<float> lut1dlin(py::array_t<float> img, py::array_t<float> lut, float lBound, float hBound) {
|
||||||
|
py::buffer_info bufImg = img.request(), bufLUT = lut.request();
|
||||||
|
|
||||||
|
//To use with an image, MAKE SURE to flatten the 3D array to a 1D array, then back out to a 3D array after.
|
||||||
|
if (bufImg.ndim == 1 && bufLUT.ndim == 1) {
|
||||||
|
//Make numpy allocate the buffer of the new array.
|
||||||
|
auto result = py::array_t<float>(bufImg.size);
|
||||||
|
|
||||||
|
//Get the bufOut pointers that we can manipulate from C++.
|
||||||
|
auto bufOut = result.request();
|
||||||
|
|
||||||
|
float *ptrImg = (float *) bufImg.ptr,
|
||||||
|
*ptrLUT = (float *) bufLUT.ptr,
|
||||||
|
*ptrOut = (float *) bufOut.ptr;
|
||||||
|
|
||||||
|
//Iterate over flat array. Each value gets scaled according to the LUT.
|
||||||
|
#pragma omp parallel for
|
||||||
|
for (size_t i = 0; i < bufImg.shape[0]; i++) {
|
||||||
|
//~ std::cout << g_func(ptrImg[i]) << std::endl;
|
||||||
|
//~ std::cout << g_func(ptrImg[i]) << std::endl;
|
||||||
|
|
||||||
|
float val = ptrImg[i];
|
||||||
|
|
||||||
|
if (val <= lBound) { ptrOut[i] = ptrLUT[0]; continue; }
|
||||||
|
else if (val >= hBound) { ptrOut[i] = ptrLUT[bufLUT.shape[0] - 1]; continue; } //Some simple clipping. So it's safe to index.
|
||||||
|
|
||||||
|
float lutVal = val * bufLUT.shape[0]; //Need the value in relation to LUT indices.
|
||||||
|
//Essentially, we're gonna index by this above with simple math.
|
||||||
|
|
||||||
|
// Linear Interpolation: y = y0 + (x - x0) * ( (y1 - y0) / (x1 - x0) )
|
||||||
|
// See https://en.wikipedia.org/wiki/Linear_interpolation#Linear_interpolation_between_two_known_points .
|
||||||
|
// (x0, y0) is lower point, (x, y) is higher point.
|
||||||
|
int x0 = (int)floor(lutVal);
|
||||||
|
int x1 = (int)ceil(lutVal); //Internet says this is safe. Yay internet...
|
||||||
|
|
||||||
|
float y0 = ptrLUT[x0];
|
||||||
|
float y1 = ptrLUT[x1];
|
||||||
|
|
||||||
|
// (y1 - y0) is divided by the result of (float)(x1 - x0) - but no need to write it; a ceil'ed minus a floor'ed int is just 1.
|
||||||
|
ptrOut[i] = y0 + (lutVal - (float)x0) * ( (y1 - y0) );
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//matr takes a flattened image array and a flattened 3x3 matrix.
|
||||||
|
py::array_t<float> matr(py::array_t<float> img, py::array_t<float> mat) {
|
||||||
|
py::buffer_info bufImg = img.request(), bufMat = mat.request();
|
||||||
|
|
||||||
|
//To use with an image, MAKE SURE to flatten the 3D array to a 1D array, then back out to a 3D array after.
|
||||||
|
if (bufImg.ndim == 1 && bufMat.ndim == 1) {
|
||||||
|
//Make numpy allocate the buffer of the new array.
|
||||||
|
auto result = py::array_t<float>(bufImg.size);
|
||||||
|
|
||||||
|
//Get the bufOut pointers that we can manipulate from C++.
|
||||||
|
auto bufOut = result.request();
|
||||||
|
|
||||||
|
float *ptrImg = (float *) bufImg.ptr,
|
||||||
|
*ptrMat = (float *) bufMat.ptr,
|
||||||
|
*ptrOut = (float *) bufOut.ptr;
|
||||||
|
|
||||||
|
//We flatly (parallelly) iterate by threes - r, g, b. To do matrix math. Yay!
|
||||||
|
#pragma omp parallel for
|
||||||
|
for (size_t i = 0; i < bufImg.shape[0]; i+=3) {
|
||||||
|
//~ std::cout << g_func(ptrImg[i]) << std::endl;
|
||||||
|
//~ std::cout << g_func(ptrImg[i]) << std::endl;
|
||||||
|
|
||||||
|
/* Remember: We're dealing with a flattened matrix here. Indices for ptrMat:
|
||||||
|
* 0 1 2
|
||||||
|
* 3 4 5
|
||||||
|
* 6 7 8
|
||||||
|
*/
|
||||||
|
|
||||||
|
float r = ptrImg[i],
|
||||||
|
g = ptrImg[i + 1],
|
||||||
|
b = ptrImg[i + 2];
|
||||||
|
|
||||||
|
ptrOut[i] = r * ptrMat[0] + g * ptrMat[1] + b * ptrMat[2]; //Red
|
||||||
|
ptrOut[i + 1] = r * ptrMat[3] + g * ptrMat[4] + b * ptrMat[5]; //Green
|
||||||
|
ptrOut[i + 2] = r * ptrMat[6] + g * ptrMat[7] + b * ptrMat[8]; //Blue
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -62,9 +152,23 @@ PYBIND11_PLUGIN(olOpt) {
|
||||||
|
|
||||||
mod.def( "gam",
|
mod.def( "gam",
|
||||||
&gam,
|
&gam,
|
||||||
"The sRGB function, vectorized."
|
"Apply any one-argument C++ function to a flattened numpy array; vectorized & parallel."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
mod.def( "matr",
|
||||||
|
&matr,
|
||||||
|
"Apply any flattened color matrix to a flattened numpy image array; vectorized & parallel."
|
||||||
|
);
|
||||||
|
|
||||||
|
mod.def( "lut1dlin",
|
||||||
|
&lut1dlin,
|
||||||
|
"Apply any 1D LUT to a flattened numpy image array; vectorized & parallel."
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//Simple Gamma Functions
|
||||||
|
|
||||||
mod.def( "lin",
|
mod.def( "lin",
|
||||||
&lin,
|
&lin,
|
||||||
"The linear function."
|
"The linear function."
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
1080p image (rock.exr), preloaded into the ColMap img. Transform preloaded into the Transform tran. What's timed is the application with apply().
|
||||||
|
|
||||||
|
The amount of time to apply each given Transform to a 1920*1080 Image on my 4 code (8 thread) CPU:
|
||||||
|
|
||||||
|
apply(ol.LUT): 0.026462205679908948,, (avg. 100 Trials) *sRGB LUT
|
||||||
|
apply(ol.Func): 0.064781568400030659, (avg. 100 Trials) *C++ Function sRGB
|
||||||
|
apply(ol.Func): 0.55080005893347939, (avg. 15 Trials) *Python Function sRGB
|
||||||
|
apply(ol.ColMat): 0.019661276286992234, (avg. 1000 Trials)
|
||||||
|
|
||||||
|
#OLD
|
||||||
|
apply(ol.ColMat): 0.98610644233346345, (avg. 15 Trials) *ACES --> sRGB
|
||||||
|
apply(ol.LUT): 0.15440896909999538, (avg. 100 Trials) *sRGB LUT
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -15,7 +15,7 @@ from setuptools import find_packages
|
||||||
#Better - Mac & Linux only.
|
#Better - Mac & Linux only.
|
||||||
#~ pyPath = '/usr/local/include/python{}'.format(get_python_version())'
|
#~ pyPath = '/usr/local/include/python{}'.format(get_python_version())'
|
||||||
|
|
||||||
cpp_args = ['-fopenmp', '-std=gnu++14']
|
cpp_args = ['-fopenmp', '-std=gnu++14', '-O3']
|
||||||
link_args = ['-fopenmp']
|
link_args = ['-fopenmp']
|
||||||
|
|
||||||
olOpt = Extension( 'openlut.lib.olOpt',
|
olOpt = Extension( 'openlut.lib.olOpt',
|
||||||
|
@ -27,7 +27,7 @@ olOpt = Extension( 'openlut.lib.olOpt',
|
||||||
)
|
)
|
||||||
|
|
||||||
setup( name = 'openlut',
|
setup( name = 'openlut',
|
||||||
version = '0.1.4',
|
version = '0.2.0',
|
||||||
description = 'OpenLUT is a practical color management library.',
|
description = 'OpenLUT is a practical color management library.',
|
||||||
author = 'Sofus Rose',
|
author = 'Sofus Rose',
|
||||||
author_email = 'sofus@sofusrose.com',
|
author_email = 'sofus@sofusrose.com',
|
||||||
|
|
Loading…
Reference in New Issue