Skip to content

Commit

Permalink
Merge pull request #5962 from ericmehl/TMI
Browse files Browse the repository at this point in the history
`ColorChooser` : TMI Sliders
  • Loading branch information
johnhaddon authored Jul 19, 2024
2 parents 1936a34 + cbb550d commit 6a95643
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 36 deletions.
2 changes: 1 addition & 1 deletion SConstruct
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,7 @@ libraries = {

"GafferUITest" : {

"additionalFiles" : glob.glob( "python/GafferUITest/scripts/*.gfr" ),
"additionalFiles" : glob.glob( "python/GafferUITest/scripts/*.gfr" ) + glob.glob( "python/GafferUITest/images/*" ),

},

Expand Down
150 changes: 115 additions & 35 deletions python/GafferUI/ColorChooser.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
#
##########################################################################

import collections
import enum
import sys
import imath
Expand All @@ -44,24 +45,72 @@

from Qt import QtGui

__tmiToRGBMatrix = imath.M33f(
-1.0 / 2.0, 0.0, 1.0 / 2.0,
1.0 / 3.0, -2.0 / 3.0, 1.0 / 3.0,
1.0, 1.0, 1.0
)
__rgb2tmiMatrix = __tmiToRGBMatrix.inverse()

def _tmiToRGB( c ) :
rgb = imath.V3f( c.r, c.g, c.b ) * __tmiToRGBMatrix

result = c.__class__( c )
result.r = rgb.x
result.g = rgb.y
result.b = rgb.z

return result

def _rgbToTMI( c ) :
tmi = imath.V3f( c.r, c.g, c.b ) * __rgb2tmiMatrix

result = c.__class__( c )
result.r = tmi.x
result.g = tmi.y
result.b = tmi.z

return result

__Range = collections.namedtuple( "__Range", [ "min", "max", "hardMin", "hardMax" ] )

_ranges = {
# We don't _really_ want to allow negative values for RGB, but
# they can arise from TMI values in the allowed TMI range. It's
# better to allow these to be displayed (as an "out of range"
# triangle in the sliders) than to show inconsistent values between
# components.
"r" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ),
"g" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ),
"b" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ),
"a" : __Range( 0, 1, 0, 1 ),
"h" : __Range( 0, 1, 0, 1 ),
"s" : __Range( 0, 1, 0, 1 ),
# As above, we're allowing out-of-regular-range values here too,
# because they can arise when authoring in-range values via RGB.
"v" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ),
"t" : __Range( -1, 1, -sys.float_info.max, sys.float_info.max ),
"m" : __Range( -1, 1, -sys.float_info.max, sys.float_info.max ),
"i" : __Range( 0, 1, -sys.float_info.max, sys.float_info.max ),
}

# A custom slider for drawing the backgrounds.
class _ComponentSlider( GafferUI.Slider ) :

def __init__( self, color, component, **kw ) :

min = hardMin = 0
max = hardMax = 1

if component in ( "r", "g", "b", "v" ) :
hardMax = sys.float_info.max

GafferUI.Slider.__init__( self, 0.0, min, max, hardMin, hardMax, **kw )
GafferUI.Slider.__init__(
self, 0.0,
min = _ranges[component].min, max = _ranges[component].max,
hardMin = _ranges[component].hardMin, hardMax = _ranges[component].hardMax,
**kw
)

self.color = color
self.component = component

# Sets the slider color in RGB space for RGBA channels and
# HSV space for HSV channels.
# Sets the slider color in RGB space for RGBA channels,
# HSV space for HSV channels and TMI space for TMI channels.
def setColor( self, color ) :

self.color = color
Expand All @@ -84,8 +133,8 @@ def _drawBackground( self, painter ) :
else :
c1 = imath.Color3f( self.color[0], self.color[1], self.color[2] )
c2 = imath.Color3f( self.color[0], self.color[1], self.color[2] )
a = { "r" : 0, "g" : 1, "b" : 2, "h" : 0, "s" : 1, "v": 2 }[self.component]
c1[a] = 0
a = { "r" : 0, "g" : 1, "b" : 2, "h" : 0, "s" : 1, "v": 2, "t" : 0, "m" : 1, "i" : 2 }[self.component]
c1[a] = -1 if self.component in "tm" else 0
c2[a] = 1

numStops = max( 2, size.x // 2 )
Expand All @@ -95,6 +144,8 @@ def _drawBackground( self, painter ) :
c = c1 + (c2-c1) * t
if self.component in "hsv" :
c = c.hsv2rgb()
elif self.component in "tmi" :
c = _tmiToRGB( c )

grad.setColorAt( t, self._qtColor( displayTransform( c ) ) )

Expand All @@ -110,6 +161,8 @@ class ColorChooser( GafferUI.Widget ) :

ColorChangedReason = enum.Enum( "ColorChangedReason", [ "Invalid", "SetColor", "Reset" ] )

__ColorSpace = enum.Enum( "__ColorSpace", [ "RGB", "HSV", "TMI" ] )

def __init__( self, color=imath.Color3f( 1 ), **kw ) :

self.__column = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, spacing = 4 )
Expand All @@ -118,27 +171,28 @@ def __init__( self, color=imath.Color3f( 1 ), **kw ) :

self.__color = color
self.__colorHSV = self.__color.rgb2hsv()
self.__colorTMI = _rgbToTMI( self.__color )
self.__defaultColor = color

self.__sliders = {}
self.__numericWidgets = {}
self.__channelLabels = {}
self.__componentValueChangedConnections = []

with self.__column :

# sliders and numeric widgets
for component in "rgbahsv" :
with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) :
with GafferUI.GridContainer( spacing = 4 ) :

with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 8 ) :
GafferUI.Label( component.capitalize() )
numericWidget = GafferUI.NumericWidget( 0.0 )
# sliders and numeric widgets
for row, component in enumerate( "rgbahsv" ) :
self.__channelLabels[component] = GafferUI.Label( component.capitalize(), parenting = { "index" : ( 0, row ), "alignment" : ( GafferUI.HorizontalAlignment.Center, GafferUI.VerticalAlignment.Center ) } )
numericWidget = GafferUI.NumericWidget( 0.0, parenting = { "index" : ( 1, row ) } )

numericWidget.setFixedCharacterWidth( 6 )
numericWidget.component = component
self.__numericWidgets[component] = numericWidget

slider = _ComponentSlider( color, component )
slider = _ComponentSlider( color, component, parenting = { "index" : ( 2, row ) } )
self.__sliders[component] = slider

self.__componentValueChangedConnections.append(
Expand Down Expand Up @@ -229,9 +283,8 @@ def __componentValueChanged( self, componentWidget, reason ) :
# doesn't provide the capability itself. Add the functionality
# into the NumericWidget and remove this code.
componentValue = componentWidget.getValue()
componentValue = max( componentValue, 0 )
if componentWidget.component in ( "a", "h", "s" ) :
componentValue = min( componentValue, 1 )
componentValue = max( componentValue, _ranges[componentWidget.component].hardMin )
componentValue = min( componentValue, _ranges[componentWidget.component].hardMax )

if componentWidget.component in ( "r", "g", "b", "a" ) :
newColor = self.__color.__class__( self.__color )
Expand All @@ -240,15 +293,22 @@ def __componentValueChanged( self, componentWidget, reason ) :
newColor[a] = componentValue

self.__setColorInternal( newColor, reason )
else :
elif componentWidget.component in ( "h", "s", "v" ) :
newColor = self.__colorHSV.__class__( self.__colorHSV )

a = { "h" : 0, "s" : 1, "v" : 2 }[componentWidget.component]
newColor[a] = componentValue

self.__setColorInternal( newColor, reason, True )
self.__setColorInternal( newColor, reason, self.__ColorSpace.HSV )
elif componentWidget.component in ( "t", "m", "i" ) :
newColor = self.__colorTMI.__class__( self.__colorTMI )

a = { "t" : 0, "m" : 1, "i" : 2 }[componentWidget.component]
newColor[a] = componentValue

def __setColorInternal( self, color, reason, hsv = False ) :
self.__setColorInternal( newColor, reason, self.__ColorSpace.TMI )

def __setColorInternal( self, color, reason, colorSpace = __ColorSpace.RGB ) :

dragBeginOrEnd = reason in (
GafferUI.Slider.ValueChangedReason.DragBegin,
Expand All @@ -257,19 +317,34 @@ def __setColorInternal( self, color, reason, hsv = False ) :
GafferUI.NumericWidget.ValueChangedReason.DragEnd,
)

colorChanged = color != ( self.__colorHSV if hsv else self.__color )
colorChanged = color != {
self.__ColorSpace.RGB : self.__color,
self.__ColorSpace.HSV : self.__colorHSV,
self.__ColorSpace.TMI : self.__colorTMI
}[colorSpace]

if colorChanged :
colorRGB = color.hsv2rgb() if hsv else color
self.__color = colorRGB
self.__colorSwatch.setColor( colorRGB )

hsv = color if hsv else color.rgb2hsv()
if colorSpace == self.__ColorSpace.RGB :
colorRGB = color
colorHSV = color.rgb2hsv()
colorTMI = _rgbToTMI( colorRGB )
elif colorSpace == self.__ColorSpace.HSV :
colorRGB = color.hsv2rgb()
colorHSV = color
colorTMI = _rgbToTMI( colorRGB )
elif colorSpace == self.__ColorSpace.TMI :
colorRGB = _tmiToRGB( color )
colorHSV = colorRGB.rgb2hsv()
colorTMI = color

colorHSV[0] = colorHSV[0] if colorHSV[1] > 1e-7 and colorHSV[2] > 1e-7 else self.__colorHSV[0]
colorHSV[1] = colorHSV[1] if colorHSV[2] > 1e-7 else self.__colorHSV[1]

hsv[0] = hsv[0] if hsv[1] > 1e-7 and hsv[2] > 1e-7 else self.__colorHSV[0]
hsv[1] = hsv[1] if hsv[2] > 1e-7 else self.__colorHSV[1]
self.__color = colorRGB
self.__colorHSV = colorHSV
self.__colorTMI = colorTMI

self.__colorHSV = hsv
self.__colorSwatch.setColor( colorRGB )

## \todo This is outside the conditional because the clamping we do
# in __componentValueChanged means the color value may not correspond
Expand Down Expand Up @@ -299,9 +374,14 @@ def __updateUIFromColor( self ) :
if c.dimensions() == 4 :
self.__sliders["a"].setValue( c[3] )
self.__numericWidgets["a"].setValue( c[3] )
self.__sliders["a"].parent().setVisible( True )

self.__sliders["a"].setVisible( True )
self.__numericWidgets["a"].setVisible( True )
self.__channelLabels["a"].setVisible( True )
else :
self.__sliders["a"].parent().setVisible( False )
self.__sliders["a"].setVisible( False )
self.__numericWidgets["a"].setVisible( False )
self.__channelLabels["a"].setVisible( False )

for slider in [ v for k, v in self.__sliders.items() if k in "hsv" ] :
slider.setColor( self.__colorHSV )
Expand Down
89 changes: 89 additions & 0 deletions python/GafferUITest/ColorChooserTest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
##########################################################################
#
# Copyright (c) 2024, Cinesite VFX Ltd. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above
# copyright notice, this list of conditions and the following
# disclaimer.
#
# * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided with
# the distribution.
#
# * Neither the name of John Haddon nor the names of
# any other contributors to this software may be used to endorse or
# promote products derived from this software without specific prior
# written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
##########################################################################

import pathlib
import unittest

import imath
import OpenImageIO

from GafferUI.ColorChooser import _tmiToRGB
from GafferUI.ColorChooser import _rgbToTMI
import GafferUITest

class ColorChooserTest( GafferUITest.TestCase ) :

def testTMI( self ) :
# Load a precomputed image generated by Houdini to test our TMI <-> RGB calculation.
# The image is 200px x 20px. Each 20px square varies the temperature from -1.0 to 1.0
# on the X axis and magenta from -1.0 to 1.0 on the Y-axis. Each of the 10 squares, from
# left to right, varies the intensity from 0.0 to 0.9.
tileWidth = 20
tileCount = 10
tmiTargetImage = OpenImageIO.ImageBuf( ( pathlib.Path( __file__ ).parent / "images" / "tmi.exr" ).as_posix() )

for tile in range ( 0, tileCount ) :
for y in range( 0, tileWidth ) :
for x in range( 0, tileWidth ) :
tmiOriginal = imath.Color3f(
( float( x ) / tileWidth ) * 2.0 - 1.0,
( float( y ) / tileWidth ) * 2.0 - 1.0,
float( tile ) / tileCount
)

rgbConverted = _tmiToRGB( tmiOriginal )
tmiTarget = tmiTargetImage.getpixel( x + ( tile * tileWidth ), y )
self.assertAlmostEqual( tmiTarget[0], rgbConverted.r, places = 6 )
self.assertAlmostEqual( tmiTarget[1], rgbConverted.g, places = 6 )
self.assertAlmostEqual( tmiTarget[2], rgbConverted.b, places = 6 )

tmiConverted = _rgbToTMI( rgbConverted )
self.assertAlmostEqual( tmiConverted.r, tmiOriginal.r, places = 6 )
self.assertAlmostEqual( tmiConverted.g, tmiOriginal.g, places = 6 )
self.assertAlmostEqual( tmiConverted.b, tmiOriginal.b, places = 6 )

def testTMIAlpha( self ) :

rgb = imath.Color4f( 0.5 )
tmi = _rgbToTMI( rgb )
self.assertEqual( tmi.a, rgb.a )

rgbConverted = _tmiToRGB( tmi )
self.assertEqual( rgbConverted.a, rgb.a )


if __name__ == "__main__" :
unittest.main()
1 change: 1 addition & 0 deletions python/GafferUITest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
from .BoxIOUITest import BoxIOUITest
from .AnnotationsGadgetTest import AnnotationsGadgetTest
from .PopupWindowTest import PopupWindowTest
from .ColorChooserTest import ColorChooserTest

if __name__ == "__main__":
unittest.main()
Binary file added python/GafferUITest/images/tmi.exr
Binary file not shown.

0 comments on commit 6a95643

Please sign in to comment.