"""
Copyright @ 2013 Mathias Westerdahl
The fonteffects module provides commonly used base functionality for the effects.
"""
import os, logging
import numpy as np
try:
import Image
except ImportError:
from PIL import Image
import utils
import freetype as ft
import fontutils as fu
import fontblend as fb
from editor.properties.propertyclass import WrapPropertyClass
import editor.properties.propertytypes as prop
# good reads:
# http://www.imagemagick.org/Usage/morphology/
# http://stackoverflow.com/questions/5919663/how-does-photoshop-blend-two-images-together
# http://dunnbypaul.net/blends/
# DistanceFields: http://www.valvesoftware.com/publications/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf
# TODO:
# Bevel effect
# Extrude effect
_CLASSES = dict()
colorfunctions = dict()
effectfunctions = dict()
#def _get_pixel(i, x, y):
# return i[:,:,0][y][x], i[:,:,1][y][x], i[:,:,2][y][x], i[:,:,3][y][x]
def GetClassByName(name):
return _CLASSES[name]
def GetClassType(cls):
return cls.function_type
[docs]def ColorFunction(cls):
""" Registers a class as a color function
"""
assert( getattr(cls, '__call__') )
WrapPropertyClass(cls)
cls.function_type = 'color'
_CLASSES[cls.__name__.lower()] = cls
colorfunctions[cls.__name__.lower()] = cls
logging.info( "Registered layer function %s" % cls.__name__.lower() )
return cls
def GetColorFunction(name):
return colorfunctions[name]
[docs]def EffectFunction(cls):
""" Registers a class as a effect function
"""
assert( getattr(cls, '__call__') )
WrapPropertyClass(cls)
cls.function_type = 'effect'
_CLASSES[cls.__name__.lower()] = cls
effectfunctions[cls.__name__.lower()] = cls
logging.info( "Registered effect function %s" % cls.__name__.lower() )
return cls
def GetEffectFunction(name):
return effectfunctions[name]
[docs]class FontEffectException(Exception):
""" An internal exception class used for font creator exceptions """
pass
# ****************************************************************************************************
# Found an excellent reference here:
# http://dunnbypaul.net/blends/
# ****************************************************************************************************
def _convert_color_to_units(colors):
""" Converts colors from 0-255 range into 0.0-1.0 range
:param colors: A 3-tuple of integers or floats (floats in range [0.0, 1.0])
"""
if isinstance(colors, tuple):
if len(colors) != 3:
raise FontEffectException("Colors should be specified with 3-tuples (float[0.0, 1.0] or int[0, 255])")
if isinstance(colors[0], float):
return colors
else:
return (colors[0] / 255.0, colors[1] / 255.0, colors[2] / 255.0)
out = []
for c in colors:
if len(c) != 3:
raise FontEffectException("Colors should be specified with 3-tuples (float[0.0, 1.0] or int[0, 255])")
if isinstance(c[0], float):
out.append( c )
else:
out.append( (c[0] / 255.0, c[1] / 255.0, c[2] / 255.0) )
return out
# ****************************************************************************************************
@ColorFunction
[docs]class Solid(object):
""" Creates solid fill with a single color
:param color: A color as (r,g,b) [0,255]
"""
color = prop.ColorProperty( (255,255,255) )
def __init__(self, *k, **kw):
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except NameError:
setattr(self, name, value )
except TypeError, e:
raise fu.FontException("Solid: Error while evaluating '%s':\n%s" % (value, str(e)))
self.color = _convert_color_to_units( self.color )
if len(self.color) == 3:
self.color = (self.color[0], self.color[1], self.color[2], 1.0)
def apply(self, info, glyph, startx, starty, size, maxsize, glyphimage, previmage):
return previmage * self.color
@ColorFunction
[docs]class Gradient(object):
""" Creates a gradient between two or more colors.
:param colors: A list of colors (3 tuples) to interpolate between. [bottom color, ..., top color]
:param angle: The angle of rotation (in degrees)
"""
colors = [(0,0,0), (255,255,255)]
angle = prop.AngleProperty( 120, help='The angle of rotation (in degrees)' )
def __init__(self, *k, **kw):
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except:
setattr(self, name, value)
if len(self.colors) < 2:
raise FontEffectException("Gradient must have more than one color to blend with")
self.colors = _convert_color_to_units(self.colors)
def set_dimensions(self, width, height):
angle = self.angle / 180.0 * np.pi
normal = np.array([0,-height/2.0])
cosa = np.cos(angle)
sina = np.sin(angle)
rotation = np.array([[cosa, -sina], [sina, cosa]])
normal = (rotation * normal)[:,1]
lengthsq = np.dot(normal,normal)
normal /= np.sqrt(lengthsq)
# calculate the dot product with the normal
halfextents = ( (width)/2.0, (height)/2.0 )
origin = np.array( [np.round(halfextents[0]), np.round(halfextents[1])], float)
maxextents = np.sqrt( halfextents[0]*halfextents[0]*np.abs(normal[0]) + halfextents[1]*halfextents[1]*np.abs(normal[1]) )
maxextents = np.round(maxextents)
data = np.empty((width,height,4), dtype=np.float32)
for x in xrange(0, width):
for y in xrange(0, height):
v = np.array([x,y], dtype=float)
v -= origin
d = (np.dot(v, normal) / maxextents) / 2.0 + 0.5
d = np.clip( d, 0.0, 1.0 )
lenminusone = len(self.colors) - 1
index = int(d * lenminusone)
subunit = (d * lenminusone) % 1.0
if index == lenminusone:
index -= 1
subunit = 1.0
c1 = self.colors[index]
c2 = self.colors[index+1]
r = c1[0] + (c2[0] - c1[0]) * subunit
g = c1[1] + (c2[1] - c1[1]) * subunit
b = c1[2] + (c2[2] - c1[2]) * subunit
a = 1.0
data[x,y] = (r,g,b,a)
self.bitmap = data
def apply(self, info, glyph, startx, starty, size, maxsize, glyphimage, previmage):
bm = np.array(self.bitmap[startx:startx+size[0], starty:starty+size[1]])
assert bm.shape == previmage.shape, "Wrong sizes: %s != %s" % ( str(bm.shape), str(previmage.shape) )
idx = np.where(glyphimage == 0)
r,g,b = (bm[:,:,0], bm[:,:,1], bm[:,:,2])
r[idx] = 0
g[idx] = 0
b[idx] = 0
a = previmage[:, :, 3]
return np.dstack((r,g,b,a))
@ColorFunction
[docs]class Stripes(object):
""" Creates stripes with alternating colors
:param width: The width of the stripes
:param offset: The start offset of the stripes
:param angle: The angle of rotation (in degrees)
:param colors: The alternating colors of the stripes. May be more than 2
"""
width = prop.IntProperty( 4, help='The width of the stripes' )
offset = prop.IntProperty( 0, help='The start offset of the stripes' )
angle = prop.AngleProperty( 0, help='The angle of rotation (in degrees)' )
colors = [(0,0,0), (255,255,255)]
def __init__(self, *k, **kw):
self.width = self.__class__.width
self.offset = self.__class__.offset
self.angle = self.__class__.angle
self.colors = self.__class__.colors
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except NameError:
setattr(self, name, value )
self.colors = _convert_color_to_units( self.colors )
def set_dimensions(self, width, height):
angle = self.angle / 180.0 * np.pi
normal = np.array([0,height/2.0])
cosa = np.cos(angle)
sina = np.sin(angle)
rotation = np.array([[cosa, -sina], [sina, cosa]])
normal = (rotation * normal)[:,1]
lengthsq = np.dot(normal,normal)
normal /= np.sqrt(lengthsq)
# place the rotation in the middle and project a corner point
# onto the normal. Then we can find out the max extents of the blend
#origin = np.array([width/2.0, height/2.0])
origin = np.array( [np.round(width/2.0), np.round(height/2.0)], float)
maxextents = np.sqrt( np.dot( origin, origin ) )
maxextents = np.round(maxextents)
data = np.empty((width,height,4), dtype=np.float32)
for x in xrange(0, width):
for y in xrange(0, height):
v = np.array([x,y], dtype=float)
v -= origin
lengthsq = np.dot(v,v)
d = (np.dot( v, normal ) / maxextents) / 2.0 + 0.5
unity = d
subunit = unity
invsubunit = 1 - subunit
index = 0
c1 = self.colors[index]
c2 = self.colors[index+1]
r = c1[0] * invsubunit + c2[0] * subunit
g = c1[1] * invsubunit + c2[1] * subunit
b = c1[2] * invsubunit + c2[2] * subunit
data[x,y] = (r,g,b, 1.0)
self.bitmap = data
def apply(self, info, glyph, startx, starty, size, maxsize, glyphimage, previmage):
return self.bitmap[startx:startx+size[0], starty:starty+size[1]]
@ColorFunction
[docs]class Texture(object):
""" Applies a texture as a color function
:param options: The command line options
:param name: The texture name relative to options.datadir.
"""
name = prop.FileProperty( default='', help='The texture name relative to options.datadir.' )
def __init__(self, *k, **kw):
options = k[0]
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except NameError:
setattr(self, name, value )
except SyntaxError:
if value and value[0] == '.': # relative paths are ok
setattr(self, name, value )
else:
raise
if not os.path.exists( os.path.join(options.datadir, self.name)):
raise fu.FontException("No such file: %s in dir %s" % (self.name, options.datadir))
self.bitmap = np.asarray( Image.open( os.path.join(options.datadir, self.name) ), float) / 255.0
if self.bitmap.shape[2] == 3:
r, g, b = fu.split_channels(self.bitmap)
a = np.ones_like(r)
#self.bitmap = np.dstack((r, g, b, a))
else:
r, g, b, a = fu.split_channels(self.bitmap)
self.bitmap = np.dstack( (r.T, g.T, b.T, a.T) )
def set_dimensions(self, width, height):
# TODO: Add option to enable this wrap
while self.bitmap.shape[0] < width:
self.bitmap = np.concatenate( (self.bitmap, self.bitmap), axis=0)
while self.bitmap.shape[1] < height:
self.bitmap = np.concatenate( (self.bitmap, self.bitmap), axis=1)
def apply(self, info, glyph, startx, starty, size, maxsize, glyphimage, previmage):
return self.bitmap[startx:startx+size[0], starty:starty+size[1]]
@EffectFunction
[docs]class Outline(object):
""" Creates an outline around the glyph
:param color: The color of the outline
:param opacity: The opacity of the color [0,100]
:param width: The width of the outline
:param spread: The radius of the blur of the outline
"""
color = prop.ColorProperty( (0, 0, 0) )
opacity = prop.OpacityProperty( 100 )
width = prop.Size1DProperty( 1 )
spread = prop.Size1DProperty( 0 )
def __init__(self, *k, **kw):
"""
@param color The color (r,g,b) of the outline
@param width The width of the outline
@param opacity The opacity of the outline (in percent)
"""
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except NameError:
setattr(self, name, value )
self.color = [ float(x) / 255.0 for x in self.color]
self.opacity = float(self.opacity) / 100.0
self.kernel = fu.create_2d_circle_kernel(self.width)
r = self.width + self.spread
self.padding = (r, r, r, r)
""" Return the amount of padding needed to fit the effect as a 4 tuple (left, top, right, bottom) """
def apply(self, info, glyph, image):
out = utils.maximum(image, self.kernel)
if self.spread:
out = fu.blur_image(out, self.spread)
r, g, b, a = fu.split_channels(out)
r[a > 0] = self.color[0]
g[a > 0] = self.color[1]
b[a > 0] = self.color[2]
a[a > 0] = self.opacity
return fu.alpha_blend(out, image)
@EffectFunction
[docs]class DropShadow(object):
""" Applies a drop shadow to the glyphs
"""
#: The color of the shadow
color = (0, 0, 0)
#: The opacity of the shadow (in percent)
opacity = 100
#: The angle of the shadow (in degrees)
angle = 120
#: The spread of the shadow (in pixels)
size = 1
#: The offset of the shadow
distance = 3
def __init__(self, *k, **kw):
"""
@param color The color (r,g,b) of the shadow. For best results, should be the same color as the background color
@param opacity The opacity of the shadow (in percent)
@param angle The lighting angle (in degrees)
@param size The spread of the shadow
@param distance The offset in the direction of the lighting angle
"""
self.color = self.__class__.color
self.opacity = self.__class__.opacity
self.angle = self.__class__.angle
self.size = self.__class__.size
self.distance = self.__class__.distance
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except NameError:
setattr(self, name, value )
self.color = [ float(x) / 255.0 for x in self.color]
self.opacity = float(self.opacity) / 100.0
self.angle = float(self.angle)
self.offsetx = -np.cos( self.angle * np.pi/180.0 ) * float(self.distance)
self.offsety = -np.sin( self.angle * np.pi/180.0 ) * float(self.distance)
@property
[docs] def padding(self):
""" Return the amount of padding needed to fit the effect as a 4 tuple (left, top, right, bottom) """
left = -self.size + self.offsetx
right = self.size + self.offsetx
top = self.size + self.offsety
bottom = -self.size + self.offsety
left = abs(left) if left < 0.0 else 0.0
right = right if right > 0.0 else 0.0
top = top if top > 0.0 else 0.0
bottom = abs(bottom) if bottom < 0.0 else 0.0
t = [left, top, right, bottom]
t = map(int, t)
return t
def apply(self, info, glyph, image):
x = int(self.offsetx)
y = -int(self.offsety)
shadowbitmap = np.copy( image )
shadowbitmap = np.roll(shadowbitmap, x, axis = 0)
shadowbitmap = np.roll(shadowbitmap, y, axis = 1)
# replace the color
r, g, b, a = fu.split_channels(shadowbitmap)
shadowbitmap = np.dstack( (r, g, b, a) ) * (self.color[0], self.color[1], self.color[2], self.opacity)
shadowbitmap = fu.blur_image(shadowbitmap, self.size)
topbitmap = np.zeros_like( shadowbitmap )
topbitmap[:image.shape[0], :image.shape[1], :] = image
return fu.alpha_blend(shadowbitmap, topbitmap)
@EffectFunction
[docs]class GaussianBlur(object):
""" Applies a gaussian blur to the glyph
"""
#: The radius (in pixels) of the kernel
size = 1
def __init__(self, *k, **kw):
"""
@param size The size of the blur kernel
"""
self.size = self.__class__.size
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except NameError:
setattr(self, name, value )
self.padding = (self.size, self.size, self.size, self.size)
def apply(self, info, glyph, image):
return fu.blur_image(image, self.size)
@EffectFunction
[docs]class KernelBlur(object):
""" Applies a simple box filter where the middle element has value 'strength'
"""
#: The radius of the kernel (in pixels). The kernel size is `radius*2+1`
size = 1
#: The center value of the kernel
strength = 1
def __init__(self, *k, **kw):
"""
@param size The size of the kernel
@param strength The strength of the middle element
"""
self.size = self.__class__.size
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except NameError:
setattr(self, name, value )
self.kernel = np.ones( self.size*2+1, dtype=np.float32 )
self.kernel[self.size] = self.strength
self.kernel /= np.sum(self.kernel)
self.padding = (self.size, self.size, self.size, self.size)
def apply(self, info, glyph, image):
return fu.blur_image_kernel1D(image, self.kernel)
@ColorFunction
[docs]class DistanceField(object):
""" Calculates a distance field for each glyph
"""
#: The spread of the "blur" that is applied to the glyphs
size = 16
#: The number of times the glyph is enlarged before the glyph is rendered.
factor = 4
def __init__(self, *k, **kw):
self.size = self.__class__.size
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except NameError:
setattr(self, name, value )
padding = self.size/self.factor
self.padding = (padding, padding, padding, padding)
def set_dimensions(self, width, height):
self.max_dim = (width, height)
def apply(self, info, glyph, startx, starty, size, maxsize, glyphimage, previmage):
factor = self.factor
face = info.face
flags = ft.LOAD_RENDER | ft.LOAD_TARGET_MONO
face.set_char_size( width=0, height=(info.size*factor)*64, hres=info.dpi, vres=info.dpi )
face.load_char( glyph.unicode, flags )
bitmap = fu.make_array_from_bitmap(face.glyph.contents.bitmap) * 255
metrics = face.glyph.contents.metrics
bearingY = (metrics.horiBearingY >> 6) + info.internalpadding[1]*factor + info.extrapadding[1]*factor
# Due to the down scaling, we need to make sure that the bitmap start at the correct pixels
offset_y = bearingY - glyph.bearingY * factor
bitmap = fu.pad_bitmap(bitmap, info.extrapadding[0]*factor, info.extrapadding[1]*factor - offset_y, info.extrapadding[2]*factor, info.extrapadding[3]*factor + offset_y, 0, debug=glyph.unicode=='r')
# TODO: Work out how to eliminate this issue
e = np.empty( bitmap.shape, bitmap.dtype, order='F' )
e[:, :] = bitmap
bitmap = e
i = utils.calculate_sedt(bitmap, self.size)
#i = bitmap
while factor > 1:
i = utils.half_size(i)
factor /= 2
i = i.astype(np.float64) / 255.0
a = np.zeros_like(i)
a[i > 0] = 1.0
return np.dstack( (i, i, i, a) )
@EffectFunction
[docs]class Halfsize(object):
""" Down scales the glyph by a factor, using bilinear factoring
"""
factor = prop.IntProperty( 1, help='The number of times the image should be minified' )
def __init__(self, *k, **kw):
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except NameError:
setattr(self, name, value )
def apply(self, glyph, info, image):
for n in xrange(self.factor):
out = utils.half_size(image)
image = out
return out
#To be used as a mask for each layer
class DefaultMask(object):
idx = None
def apply(self, info, glyph, image):
try:
image[DefaultMask.idx] = 0
return image
except:
print "image", image.shape
raise
[docs]class Layer(object):
""" A class that helps applying layers on top of each other
"""
def __init__(self, **kw):
self.opacity = 1.0
self.blend = fb.blendnormal
self.effects = []
self.mask = DefaultMask()
for name, value in kw.iteritems():
try:
setattr(self, name, eval(value) )
except (TypeError, NameError):
setattr(self, name, value )
if isinstance(self.opacity, int):
self.opacity = self.opacity / 255.0
@property
def padding(self):
zero = (0,0,0,0)
layerpadding = getattr(self.color, 'padding', zero)
for effect in self.effects:
layerpadding = np.add(layerpadding, getattr(effect, 'padding', zero) )
return layerpadding
def set_info(self, glyph, info):
self.glyph = glyph
self.info = info
def _verify(self, fn, image):
assert image.shape[0] != 0, "Bad image generated by " + fn.__class__.__name__
assert image.shape[1] != 0, "Bad image generated by " + fn.__class__.__name__
def apply_color(self, *k, **kw):
image = self.color.apply( self.info, self.glyph, *k, **kw )
self._verify(self.color, image)
return image
def apply_effects(self, image):
for effect in self.effects:
image = effect.apply( self.info, self.glyph, image )
self._verify(effect, image)
return image
def apply_mask(self, image):
if self.mask is not None:
image = self.mask.apply( self.info, self.glyph, image.copy() )
self._verify(self.mask, image)
return image
return image
def apply_blend(self, glyphimage, previmage, image):
if self.blend is not None:
image = np.clip( image, 0.0, 1.0 )
image = self.blend(base=previmage, blend=image)
image = fu.alpha_blend(previmage, image * self.opacity)
self._verify(self.blend, image)
return image
# ****************************************************************************************************