The ImaGen package provides comprehensive support for creating resolution-independent two-dimensional pattern distributions. ImaGen consists of a large library of spatial patterns, including mathematical functions, geometric primitives, and images read from files, along with many ways to combine or select from any other patterns. These patterns can be used in any Python program that needs configurable patterns or streams of patterns. Basically, as long as the code can accept a Python callable and will call it each time it needs a new pattern, users can then specify any pattern possible in ImaGen's simple declarative pattern language, and the downstream code need not worry about any of the details about how the pattern is specified or generated. This approach gives users full flexibility about which patterns they wish to use, while relieving the downstream code from having to implement anything about patterns. The detailed examples below should help make this clear.
To create a pattern, just import imagen
, then instantiate one of ImaGen's PatternGenerator
classes. Each of these classes support various parameters, which are each described in the Reference Manual or via help(
pattern-object-or-class)
. Any parameter values specified on instantiation become the defaults for that object:
In [ ]:
import imagen as ig
line=ig.Line(xdensity=5, ydensity=5, smoothing=0)
Then whenever the line
object is called, you'll get a new NumPy array:
In [ ]:
line()
Here the parameters xdensity
and ydensity
specified that a continuous 1.0×1.0 region in (x,y) space should be sampled on a 5×5 grid. The line
object can now be called repeatedly to get new arrays of data, with any parameter values specified to override those declared above:
In [ ]:
import numpy as np
np.set_printoptions(1)
line(smoothing=0.1,orientation=0.8,thickness=0.4)
As you can see, the results are in the form of a Numpy array, and here we have used very small arrays to avoid generating a lot of numeric output. For larger arrays, it is convenient to view them as images, which you can do easily if you have the PIL
or pillow
libraries installed:
In [ ]:
line.set_param(xdensity=72,ydensity=72,orientation=np.pi/4, thickness=0.1, smoothing=0.02)
line.pil(orientation=3*np.pi/4)
As you can see, the .pil()
method accepts the same options as supported elsewhere, making it simple to create the image that you need to generate. .pil()
returns a PIL image, which can be exported to files on disk using the methods described in the PIL documentation.
ImaGen depends only on NumPy, Param, and HoloViews, none of which have any other required dependencies, and it is thus easy to incorporate ImaGen into your own code to generate or use patterns freely. That said, HoloViews supports various plotting libraries, including Matplotlib and Bokeh, and if you have one of those installed then imagen provides a convenient way to plot the pattern objects with axes and labels:
In [ ]:
import holoviews as hv
hv.notebook_extension("matplotlib")
line[:]
We will use this plotting interface to show off the remaining patterns, but please remember that the main purpose of ImaGen is to generate arrays for use in other programs, not simply to draw pretty patterns for plotting!
As you can see above, PatternGenerator
objects return different patterns depending on their parameter values. An important feature of these parameter values is that any of them can be set to "dynamic" values, which will then result in a different pattern each time (see the Param package and its numbergen
module for details). With dynamic parameters, PatternGenerators
provide streams of patterns, not just individual patterns. For example, let's define a SineGrating
object with a random orientation, collect four of them at different times (using the .anim()
method), and lay them out next to each other (using the NdLayout
class from HoloViews):
In [ ]:
import numbergen as ng
from holoviews import NdLayout
import param
param.Dynamic.time_dependent=True
NdLayout(ig.SineGrating(orientation=np.pi*ng.UniformRandom()).anim(3))
As you can see, each time the sine grating was rendered, the pattern differed, because the parameter value for orientation was chosen randomly. Of course, you can set any combination of patterns to dynamic values, to get arbitrarily complex variation over time:
In [ ]:
%%opts Image (cmap='gray')
sine_disk = ig.SineGrating(orientation=np.pi*ng.UniformRandom(),
scale=0.25*ng.ExponentialDecay(time_constant=3),
frequency=4+7*ng.UniformRandom(),
x=0.3*ng.NormalRandom(seed=1),
y=0.2*ng.UniformRandom(seed=2)-0.1,
mask_shape=ig.Disk(size=0.5,smoothing=0.01))
NdLayout(sine_disk.anim(3))
As you can see above, PatternGenerator
objects can also be used as a mask
for another PatternGenerator
, which is one simple way to combine them.
PatternGenerator
s can also be combined directly with each other to create Composite
PatternGenerator
s, which can make any possible 2D pattern. For instance, we can easily sum 10 oriented Gaussian
patterns, each with random positions and orientations, giving a different overall pattern at each time:
In [ ]:
gs = ig.Composite(operator=np.add,
generators=[ig.Gaussian(size=0.15,
x=ng.UniformRandom(seed=i+1)-0.5,
y=ng.UniformRandom(seed=i+2)-0.5,
orientation=np.pi*ng.UniformRandom(seed=i+3))
for i in range(10)])
NdLayout(gs.anim(4)).cols(5)
Once it has been defined, a Composite
pattern works just like any other pattern, so that it can be placed, rotated, combined with others, etc., allowing you to build up arbitrarily complex objects out of simple primitives. Here we created a Composite
pattern explicitly, but it's usually easier to create them by simply using any of the usual Python operators (+
, -
, *
, /
, **
, %
, &
(min), and |
(max)) as in the examples below.
For instance, here's an example using np.maximum
(via the |
operator on PatternGenerator
s), rotating the composite pattern together as a unit. We also leave it as a HoloViews animation rather than laying it out over space:
In [ ]:
%%opts Image.Pattern (cmap='Blues_r')
l1 = ig.Line(orientation=-np.pi/4)
l2 = ig.Line(orientation=+np.pi/4)
cross = l1 | l2
cross.orientation=ng.ScaledTime()*(np.pi/-20)
l1.anim(20) + l2.anim(20) + cross.anim(20)
The .anim()
method collects results at different times conveniently. What it's doing repeatedly is getting a copy of each pattern, then running param.Dynamic.time_fn.advance(1.0)
to advance the nominal time, then getting another copy of each pattern until 20 different times have been sampled. The values are "time dependent" (because we set them to be so above), so that any randomness changes only when the time changes, and the randomness is computed as a function of time. That way, regardless of the order you generate the patterns, or even if you go back and forward in time, you will always get the same results at a given nominal time. In your own code, you can turn off time dependence (param.Dynamic.time_dependent=False
), in which case new parameter values will be generated for every call to the PatternGenerator
. Or, if you are working in a domain that has a clear temporal component, such as simulation, you can set param.Dynamic.time_fn
to a function based on your own nominal time, advancing it as appropriate. You can even set that function to real time, in which case you'll get completely unpredictable randomness, which may be appropriate in some circumstances. Whenever there is some notion of time that governs the patterns you want to see, setting time_dependent=True
is a good idea, so that you have precise control over the randomness to ensure reproducible results.
We used one operator above to make the cross image, but we can combine operators in any combination, here to build a cartoon face and add the result to a sweeping Line
pattern masked with a Disk
, creating an animated GIF of the results with HoloViews:
In [ ]:
%opts Image (cmap='gray')
import param
param.Dynamic.time_fn.advance(1)
print("The current nominal time value is %s" % param.Dynamic.time_fn())
In [ ]:
%%output backend='matplotlib' holomap='gif'
lefteye = ig.Disk(aspect_ratio=0.7, x=0.04, y=0.10, size=0.08,smoothing=0.005)
leftpupil = ig.Disk(aspect_ratio=1.0, x=0.03, y=0.08, size=0.04,smoothing=0.005)
righteye = ig.Disk(aspect_ratio=0.7, x=0.04, y=-0.1, size=0.08,smoothing=0.005)
rightpupil = ig.Disk(aspect_ratio=1.0, x=0.03, y=-0.08,size=0.04,smoothing=0.005)
nose = ig.Gaussian(aspect_ratio=0.8, x=-0.1, y=0.00, size=0.04)
mouth = ig.Gaussian(aspect_ratio=0.8, x=-0.2, y=0.00, size=0.06)
head = ig.Disk( aspect_ratio=1.5, x=-0.02,y=0.00, size=0.40, scale=0.70,smoothing=0.005)
face=head + lefteye - 1.6*leftpupil + righteye - 1.6*rightpupil - 0.5*nose - 0.8*mouth
face.set_param(x=0.2, y=0.1, offset=0.5, size=0.75)
face.orientation=ng.ScaledTime()*np.pi/20
line = ig.Line(y=0.6-ng.ScaledTime()*0.03)
disk = ig.Disk(smoothing=0.01, size=0.4, x=-0.2, y=-0.2)
(face + line*disk).anim(39)
ImaGen can load and manipulate photographic images just like other patterns, apart from them not being resolution independent. For full functionality this requires the optional PIL or Pillow library, but support for Numpy arrays as images is provided with no further dependencies. For instance, if you have a database of images (here consisting of only one image for simplicity), you can repeatedly select an image at random from the database using Selector
, rotate it randomly if desired, and select a random patch of the image at each time:
In [ ]:
from imagen.image import FileImage
inputs=[FileImage(filename=f, size=6.0,
x=ng.UniformRandom(lbound=-2,ubound=2),
y=ng.UniformRandom(lbound=-2,ubound=2),
orientation=ng.NormalRandom(sigma=0.1*np.pi))
for f in ["images/ellen_arthur.pgm"]]
random_selection=ig.Selector(generators=inputs)
NdLayout(random_selection.anim(5)).cols(6)
Once the pattern has been generated, but before it is returned, you can apply any function to the data that you like, via the output_fns
parameter. A variety of useful TransferFn
s are supplied for use as output_fns
, such as thresholding functions, normalizing functions (L0, L1, L2, L-infinity, etc.), and convolutions. Any number of these or your own functions (anything that can operate on a 2D Numpy array) can be applied, in order:
In [ ]:
import imagen.transferfn as tf
from imagen.transferfn.sheet_tf import Convolve
(FileImage()[:] + \
FileImage(output_fns=[tf.BinaryThreshold()])[:] + \
FileImage(output_fns=[Convolve()])[:] + \
FileImage(output_fns=[Convolve(),tf.BinaryThreshold(threshold=0.45)])[:] + \
FileImage(output_fns=[Convolve(kernel_pattern=ig.DifferenceOfGaussians(size=0.12)),
tf.BinaryThreshold()])[:]).cols(5)
The above examples all show "single-channel" PatternGenerator
objects, which are very general and usable for a huge variety of applications, as they are simply Numpy arrays.
PatternGenerator
objects can have any number of channels, with each channel generating a Numpy array of the same size. Multi-channel patterns are used less often, but are particularly useful for generating color images. Color images loaded by the FileImage
pattern will have four channels, one for the monochrome image (as above), and the other three for the red, green, and blue channels (accessed using object.channels()
). RGB images can also be constructed by colorizing a monochrome pattern, or out of combinations of any of the other patterns, using the ComposeChannels
object:
In [ ]:
from imagen.image import ScaleChannels
ig.ComposeChannels(generators=[ig.Spiral(smoothing=0.02),ig.Spiral(),ig.Spiral(scale=0)])[:] + \
ig.ComposeChannels(generators=[ig.Line(orientation=np.pi/2),ig.Ring(),ig.SquareGrating()])[:]
Three- or four-channel patterns support .pil()
, generating RGB or RGBA color images.
In [ ]:
ig.ComposeChannels(generators=[ig.Line(orientation=np.pi/2),ig.Ring(),ig.SquareGrating(),ig.Disk()]).pil()
Below are shown examples of each of the pattern types currently provided, using their default parameter values. Very many different parameter values can be chosen, to produce a much wider range of patterns, and of course new patterns can be created as Composite
patterns as shown above.
In [ ]:
%opts Layout [sublabel_format="" horizontal_spacing=.1 vertical_spacing=.1]
%opts Image (cmap='gray') [xaxis=None yaxis=None show_frame=True] {+axiswise}
from imagen import *
from imagen.random import *
from imagen.image import *
pattern_classes = [x for _, x in sorted(locals().items()) if isinstance(x,type)
and issubclass(x,PatternGenerator) and not x.abstract]
all_patterns = [x()[:] for x in pattern_classes[10:]]
hv.Layout(all_patterns).cols(5)
New Composite
patterns can be created easily without writing new classes, as shown above. If you want to create a new non-composite type, you can simply define a new class inheriting from PatternGenerator
, then override self.function()
to draw the pattern, and declare any new parameter(s) used by self.function()
. The new pattern can then be rotated, scaled, translated, etc. automatically, with no further coding, it will support dynamic pattern streams automatically, and it can be combined with any existing or new pattern to make new Composite
patterns. If you don't want the automatic scaling, rotating, etc. (e.g. for a whole-field pattern like a new type of random distribution), you can override self.__call__
instead of self.function()
, which allows you to do anything that returns a Numpy array of the requested size. See the many classes in imagen/__init__.py
and imagen/random.py
for examples of each approach.