examples/dynamic-range.py

This is one of the example scripts included with Shady. These scripts can be run conventionally like any normal Python script, or you can choose to run them as interactive tutorials, for example with python -m Shady demo dynamic-range

#!/usr/bin/env python
# $BEGIN_SHADY_LICENSE$
# 
# This file is part of the Shady project, a Python framework for
# real-time manipulation of psychophysical stimuli for vision science.
# 
# Copyright (c) 2017-2022 Jeremy Hill, Scott Mooney
# 
# Shady is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program. If not, see http://www.gnu.org/licenses/ .
# 
# $END_SHADY_LICENSE$

#: Noisy-bit dithering and bit-stealing under the microscope
"""
This is a demo of the `Loupe` utility function. It creates a
`Stimulus` that allows you to examine other stimuli empirically
with enhanced contrast, spatial magnification, and temporal
sub-sampling.  So it's good for verifying the content of 
very low-contrast stimuli and the behavior of the dynamic-range
enhancement tricks (bit-stealing or noisy-bit dithering) that
enable them.  This demo will allow you to explore `Loupe`
behavior with keyboard commands. It requires the third-party
packages `numpy` and `pillow`.

The concepts explored in this demo are explained in greater
detail in the topic documentation::

	>>> help( Shady.Documentation.PreciseControlOfLuminance )

or::
	
	In [1]: Shady.Documentation.PreciseControlOfLuminance?

"""#.
if __name__ == '__main__':
	"""
	Parse command-line options:
	"""#:
	import Shady
	cmdline = Shady.WorldConstructorCommandLine()
	gauge        = cmdline.Option( 'gauge', False,  type=bool,  container=None, doc="Whether or not to show a `FrameIntervalGauge`." )
	global_gamma = cmdline.Option( 'gamma',    -1,  type=( int, float, tuple, list ),  container=None, min=-1, length=3, doc="Gamma-correction (when enabled). -1 means sRGB" )
	cmdline.Help().Finalize()

	"""
	We're going to need the `Shady.Text` plugin---be warned
	that that sometimes takes several seconds to import.
	Text-rendering also entails dependency on two third-party
	packages: `numpy` and `pillow`.
	"""#:
	import Shady.Text

	"""
	Create the World.  Add a FrameIntervalGauge if requested:
	"""#:
	world = Shady.World( **cmdline.opts )
	if gauge: f = Shady.FrameIntervalGauge( world )
	
	"""
	Create the Stimulus that a subject would actually see.
	We'll use a Gabor patch.
	"""#:
	ideal_size = 500
	size = int( min( ideal_size, world.width / 3.0 ) )
	shrink = size / float( ideal_size )
	margin = 100 * shrink

	gabor = world.Sine(  # convenience wrapper round `world.Stimulus`
		size = size,
		signalFrequency = 0.0125,
		plateauProportion = 0,
		position = world.Place( -1, 0 ) + [ margin, 0 ],
		anchor = [ -1, 0 ],
	)

	"""
	Create our diagnostic tool:
	"""#:
	enhanced = Shady.Loupe(
		target = gabor,
		update_period = 1.0,		
		scaling = 4,
		position = gabor.Place( +1, 0 ) + [ margin, 0 ],
		anchor = [ -1, 0 ],
	)

	"""
	Compute a bit-stealing LUT, or load a pre-computed one:
	"""#:
	lutArray = Shady.BitStealingLUT(
		maxDACDeparture = 2,
		Cmax = 3.0,
		nbits = 16,
		gamma = global_gamma,
		DACbits = world.dacBits,
		cache_dir = Shady.PackagePath( 'examples' ),
		# should pick up 'examples/BitStealingLUT_maxDACDeparture=2_Cmax=3.0_nbits=16_gamma=sRGB.npz'
        # unless you have requested a different --gamma, or your graphics card is not 8-bit
        # (in which case it will take a little extra time to calculate the LUT)
	) 
	lutObject = world.LookupTable( lutArray )  # keep this for later

	
	"""
	Accurate rendering breaks down as contrast gets low (close
	to detection threshold).  Exactly *how* it breaks down depends
	on whether the background luminance is an integer DAC value
	(say, 127) or not (say, 127.5 which is the true mid-point of
	an 8-bit DAC). We want to be able to visualize both cases.
	Let's create a function that allows us, regardless of whether
	we've got gamma correction turned on or off, to choose between
	a background luminance that maps to the nearest integer DAC
	value, and one that maps halfway between the two nearest integer
	DAC values.
	"""#:
	gabor.rounding = True
	approximateBackground = [ 0.5, 0.5, 0.5 ]
	targetDAC = []
	@world.AnimationCallback
	def WrangleBackground( t=None ):
		gamma = [ 1.0, 1.0, 1.0 ] if gabor.lut else gabor.gamma
		targetDAC[ : ] = [ int( world.dacMax * Shady.Linearize( val, gamma=g ) ) + ( 0 if gabor.rounding else 0.5 ) for val, g in zip( approximateBackground, gamma ) ]
		gabor.bg = [ Shady.ScreenNonlinearity( val / float( world.dacMax ), gamma=g ) for val, g in zip( targetDAC, gamma ) ]
	WrangleBackground()

	"""
	Create a dynamic text stimulus that reports all the relevant
	information about the Gabor's current linearization and
	dynamic-range enhancement settings:
	"""#:
	def Caption( t ):
		txt = '%g%% contrast\n' % ( gabor.contrast * 100 )
		if gabor.lut: txt += '%d-element look-up table' % gabor.lut.length
		else: txt += ( 'dithering off\n' if gabor.ditheringDenominator <= 0.0 else 'dithering on\n' ) + 'gamma = ' + str( list( gabor.gamma ) )
		if gabor.lut or gabor.rednoise: txt += '\nnoise = %g' % gabor.rednoise
		txt += '\nraw BG = %r' % targetDAC
		return txt
	msg = world.Stimulus( position=gabor.Place( -1, -1.2 ), anchor=[ -1, +1 ], text=Caption, text_align='left', text_size=35 * shrink  )

	"""
	Things only start to look interesting at lower contrasts,
	so let's start you there:
	"""#:
	gabor.contrast = 0.0625
	
	"""
	Register an event-handler that lets us play with the
	parameters:
	"""#:
	@world.EventHandler( slot=-1 )
	def KeyboardControl( self, event ):
		if event.type in [ 'key_release' ]:
			if event.key in [ 'right' ] and enhanced.update_period > 0.005: enhanced.update_period /= 2.0
			if event.key in [ 'left' ]  and enhanced.update_period < 30:    enhanced.update_period *= 2.0
			if event.key in [ 'down' ]: gabor.contrast /= 2.0
			if event.key in [ 'up' ]:   gabor.contrast *= 2.0
			if event.key in [ 'd' ]:    gabor.ditheringDenominator *= -1
			if event.key in [ 'l' ]:    gabor.lut = None if gabor.lut else lutObject
			if event.key in [ 'n' ]:    gabor.noise = 0 if any( gabor.noise ) else 1e-4
			if event.key in [ 'g' ]:    gabor.gamma = global_gamma if ( gabor.gamma[ 0 ] == 1.0 ) else 1.0
			if event.key in [ 'b' ]:    gabor.rounding = not gabor.rounding; WrangleBackground()
			enhanced.DeferredUpdate()
		if event.type in [ 'text' ]:
			if event.text in [ '-' ]:       enhanced.scaling /= 2.0
			if event.text in [ '+', '=' ]:  enhanced.scaling *= 2.0
			enhanced.DeferredUpdate()
			
	"""
	Print, and render, a reminder of the keyboard commands:
	"""#:
	instructions = """
  up / down  :  raise/lower contrast
left / right :  slower/faster capture rate
     D       :  toggle dithering
     G       :  toggle gamma-correction
     N       :  toggle additive noise
     L       :  toggle look-up table
     B       :  toggle integer/non-integer background DAC
   + / -     :  increase/decrease magnification
"""
	legend = world.Stimulus(
		text = instructions.strip( '\n' ),
		text_size = 20 * shrink,
		position = gabor.Place( 0, +1.2 ),
		anchor = ( 0, -1 ),
		z = +0.5,
	)
	print( instructions )
	
	"""
	Remember: the Loupe does not fake the effects of dithering
	and bit-stealing: it actually examines them empirically,
	enhancing them artificially so you can see them.  You can
	put whatever content you like into the target `Stimulus`
	(`gabor` in this case) and see the effects in the Loupe.
	"""#>
	Shady.AutoFinish( world )