by Frédéric Mantegazza and Stani
Introduction
Phatch actions are simple python scripts. They describe the GUI of the action, with all needed parameters, and the heart of the image manipulation, which is usually a simple function. There is no need to know wxPython to write an action: only python and PIL are mandatory.
Actions can be put in system place:
/usr/[local/]lib/python2.x/site-packages/phatch/actions/
or in user place:
~/.local/share/phatch/actions/
They are automatically loaded at Phatch startup.
Note that the actions are not based on their filename, but on the label attribute of their Action class (see below). So, you can overwrite an existing action even if its filename is different. Also note that actions in user place are loaded last, so they overwrite actions from system place if they have the same label. This is nice, because it allows you to experiment and improve existing actions, by copying the original one in your home dir.
Understanding how things work
Let's have a look at an existing action, brightness, which comes with the official package. Here is the code:
from core import models from core.translation import _t #---PIL def init(): global Image, ImageColor import Image, ImageColor def brightness(image,amount=50): """Adjust brightness from black to white - amount: -1(black) 0 (unchanged) 1(white) - repeat: how many times it should be repeated""" if amount == 0: return image elif amount < 0: #fade to black im = Image.blend( image, Image.new(image.mode, image.size, 0), -amount/100.0 ) else: #fade to white im = Image.blend( image, Image.new(image.mode, image.size, ImageColor.getcolor('white',image.mode)), amount/100.0 ) #fix image transparency mask if image.mode == 'RGBA': im.putalpha(image.split()[-1]) return im #---Phatch class Action(models.Action): label = _t('Brightness') author = 'Stani' email = 'xxx@xxx' init = staticmethod(init) pil = staticmethod(brightness) version = '0.1' tags = [_t('colours')] __doc__ = _t('Adjust brightness from black to white') def interface(self,fields): fields[_t('Amount')] = self.SliderField(50,-100,100) icon = \ 'x\xda\x01\xe4\t\x1b\xf6\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x000\x00\ ...
First, look at the structure: you can see there are 2 main parts: PIL, and Phatch. This design is very nice, because it allows to split the model (PIL section) and the view (included in Phatch section, even if this section does not only build the view, but also do error checking, variable interpolation…). This is how Phatch can also run in server mode, I guess. Thank's to Stani!
Common
from core import models from core.translation import _t
This is initial import, needed fo both model and view. Note that only Phatch internals can be imported here; your own import statements must be put in the init() function; see below.
Model
#---PIL def init(): global Image, ImageColor import Image, ImageColor
In the init() function, we just init the model. Here, we only need to import modules.
def brightness(image,amount=50): """Adjust brightness from black to white - amount: -1(black) 0 (unchanged) 1(white) - repeat: how many times it should be repeated""" ...
The brightness function is the core of this action model. It is called by Phatch for each image during batch process. Its first argument is a PIL image; the second argument is a param the user can adjust before launching batch process. We will see later where this param value comes from. I won't explain how this function deeply works, as it is not the goal of this tutorial. The function just has to return the new image.
View
#---Phatch class Action(models.Action): label = _t('Brightness') author = 'Stani' email = 'xxx@xxx' init = staticmethod(init) pil = staticmethod(brightness) version = '0.1' tags = [_t('colours')] __doc__ = _t('Adjust brightness from black to white')
Here, we define some class attributes. Some are easy to understand. The main thing to see is how we link the functions model (init() and brightness()) to the view, using static methods. Note that Phatch needs to bind main model function under pil name.
The label class attribute is the name of the action in Phatch menus; using _t() function allows maintainers to translate it in different languages. Never use _()!. The tags class attribute is a list containing all categories where the action will appear mainly used to put it in its main category, and also in the Select category, to have a quick access to it).
def interface(self,fields): fields[_t('Amount')] = self.SliderField(50,-100,100)
This method is called when Phatch needs to instanciate the action GUI; this is where we define the fields for the action. Here, we have a slider widget, named Amount. The value of this widget is automagically passed to the amount parameter of the pil() function: the parameter name just has to match the widget name, in lower case, with spaces replaced by _. That's it!
When Phatch launches the batch process, it first calls all actions init() method, then, for each image, calls all actions pil() method (which is binded to model function), giving it the values of the fields.
Icon
Last item in the code is the definition of the icon:
icon = \ 'x\xda\x01\xe4\t\x1b\xf6\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x000\x00\ ...
allows us to define a nice icon for the action. Icons are generated from 48x48 pixel png's with img2py of wxPython 2.6. For example in Ubuntu:
python /usr/lib/python2.5/site-packages/wx-2.6-gtk2-unicode/wx/tools/img2py.py highlight_plugin_icon.png icon.py
You can use this action list for it. Most icons come from the open clipart library and some are designed by Igor Kekeljevic .
Note that it is absolutely crucial that you use img2py of wx-2.6, not 2.8 as their output formats differ! If you happen to have wxPython 2.8 installed, you can get get 2.6 version of img2py from http://svn.wxwidgets.org/viewvc/wx/wxWidgets/tags/WX_2_6_4/wxPython/wx/tools/img2py.py . Besides that you will need to download http://svn.wxwidgets.org/viewvc/wx/wxWidgets/tags/WX_2_6_4/wxPython/wx/tools/img2img.py to the same directory. After this you should be ready to go.
Hope this is clear!
Custom example
Now, lets write our own action. As example, I'll use my first Phatch contribution, cms, which is an ICC color space converter. This code used my ImageCms module, based on littlecms library. Here is the entire code:
from core import models from core.translation import _t PERCEPTUAL = _t('Perceptual') RELATIVE_COLORIMETRIC = _t('Relative colorimetric') SATURATION = _t('Saturation') ABSOLUTE_COLORIMETRIC = _t('Absolute colorimetric') INTENT_STRINGS = [PERCEPTUAL, RELATIVE_COLORIMETRIC, SATURATION, ABSOLUTE_COLORIMETRIC] INTENT_VALUES = {PERCEPTUAL : 0, RELATIVE_COLORIMETRIC: 1, SATURATION : 2, ABSOLUTE_COLORIMETRIC: 3} #---PIL def init(): global ImageCms import ImageCms def _loadProfiles(sourceProfileName, destinationProfileName): """ Load profiles from file. """ return [ImageCms.Profile(profile) for profile in (sourceProfileName, destinationProfileName)] def convert(image, source_profile, destination_profile, intent): """ Convert the image. Convert the image from source profile to destination profile, using given intent. """ sourceProfile, destinationProfile = _loadProfiles(source_profile, destination_profile) convertedImage = ImageCms.profileToProfile(image, sourceProfile, destinationProfile, image.mode, INTENT_VALUES[intent]) return convertedImage #---Phatch from formField import ReadFileField class ProfileReadFileField(ReadFileField): """ ReadFileField for profiles files. """ extensions = ['icc', 'icm'] class Action(models.Action): label = _t('Color Management') author = 'Frédéric Mantegazza' email = 'xxx@xxx' init = staticmethod(init) pil = staticmethod(convert) version = '0.1' tags = [_t('colours')] __doc__ = _t('Color space converter') def interface(self, fields): fields[_t('Source profile')] = ProfileReadFileField("Choose profile") fields[_t('Destination profile')] = ProfileReadFileField("Choose profile") fields[_t('Intent')] = self.ChoiceField(INTENT_STRINGS[0], INTENT_STRINGS)
Let's explain the different parts of the code.
Common
At the top of our action, we define some constants, which will be used in both model and view:
PERCEPTUAL = _t('Perceptual')
RELATIVE_COLORIMETRIC = _t('Relative colorimetric')
SATURATION = _t('Saturation')
ABSOLUTE_COLORIMETRIC = _t('Absolute colorimetric')
INTENT_STRINGS = [PERCEPTUAL, RELATIVE_COLORIMETRIC, SATURATION, ABSOLUTE_COLORIMETRIC]
INTENT_VALUES = {PERCEPTUAL : 0,
RELATIVE_COLORIMETRIC: 1,
SATURATION : 2,
ABSOLUTE_COLORIMETRIC: 3}
Model
#---PIL
def init():
global ImageCms
import ImageCms
def _loadProfiles(sourceProfileName, destinationProfileName):
return [ImageCms.openProfileFromeFile(profile) for profile in (sourceProfileName, destinationProfileName)]
def convert(image, source_profile, destination_profile, intent):
sourceProfile, destinationProfile = _loadProfiles(source_profile, destination_profile)
convertedImage = ImageCms.profileToProfile(image, sourceProfile, destinationProfile, image.mode, INTENT_VALUES[intent])
return convertedImage
The main difference with the previous action is the use of an additional function, _loadProfiles(). It is not mandatory here, but let the code easier to read. The profileToProfile() function just returns a new image, which is the given image converted from the source profile to the destination profile using the given intent.
View
#---Phatch from formField import ReadFileField class ProfileReadFileField(ReadFileField): extensions = ['icc', 'icm']
As we need a custom ReadFileField widget, we just define it here. It is like the ImageReadFileField, but with different extensions.
Next part does not need comment:
class Action(models.Action): label = _t('Color Management') author = 'Frédéric Mantegazza' email = 'xxx@xxx' init = staticmethod(init) pil = staticmethod(convert) version = '0.1' tags = [_t('colours')] __doc__ = _t('A color space converter')
And last:
def interface(self, fields): fields[_t('Source profile')] = ProfileReadFileField("Choose profile") fields[_t('Destination profile')] = ProfileReadFileField("Choose profile") fields[_t('Intent')] = self.ChoiceField(INTENT_STRINGS[0], INTENT_STRINGS)
This is where we define the GUI. Note how we simply used our custom widget.
Advanced features
Cache
In the previous action, I used the profileToProfile() function of the ImageCms module, which creates and applies a transform on-the-fly. This is not very efficient, as it can be long (as far as I understand, it links the profiles, creating all transformations tables). So, the idea is to create the transform only once, and put in in the cache:
def convert(image, source_profile, destination_profile, intent, cache={}): """ Convert the image. Convert the image from source profile to destination profile, using given intent. This version uses a transform object. The transform is only created once, and put in the cache. """ transformId = "cms_%s_%s_%s_%s" % (image.mode, source_profile, destination_profile, intent) try: transform = cache[transformId] except KeyError: sourceProfile, destinationProfile = _loadProfiles(source_profile, destination_profile) transform = cache[transformId] = ImageCms.Transform(sourceProfile, image.mode, destinationProfile, image.mode, INTENT_VALUES[intent]) return transform.do(image) ... class Action(models.Action): cache = True ...
No major problem, here. You only have to specify that the action will use the cache by defining the class property "cache = True" and add the cache parameter to the pil method. Just remember that the cache is shared by all actions, so choose a unique id to avoid conflicts.
Here, this avoids that the colour transform is recalculated each time and profiles are reloaded from disk or the web.
Rules
The best way to learn is to study the examples in phatch/actions.
Coding style
- The style of your code should follow the PEP8 (http://www.python.org/dev/peps/pep-0008/). Style for the documentation in the docstrings should be written for Sphinx (http://sphinx.pocoo.org/).
Fields
- The labels of the action parameters should be "Title Case":
not: fields[_t('Auto crop')] = self.BooleanField(False) but: fields[_t('Auto Crop')] = self.BooleanField(False)
- Due to a limitation in Phatch, never use for a label of a parameter the a variable name. There might arise a conflict with translations (especially German).
not: fields[_t('Filename')] = self.FileNameField(choices=self.FILENAMES) #as <filename> exists but: fields[_t('File Name')] = self.FileNameField(choices=self.FILENAMES) not: fields[_t('Width')] = self.PixelField('50%', choices=self.PIXELS) but: fields[_t('Canvas Width')] = self.PixelField('50%', choices=self.PIXELS)
- The choices keyword you see above is often set to a list of predefined values (such as FILENAMES, PIXELS, …). These are class attributes of the Form class in core/lib/formField.py.
- Phatch provides many different fields for input which do validation as well. In case you need a new field type with its own validation, we can always add it.
- For simple PIL plugins, Phatch will translate the fields automagically in arguments for the PIL function. It does so by converting them to a dictionary (of which the keys are lowercase) and send it as keywords arguments to the pil function, which is defined as a class attribute of the action:
some_pil_function(image,**fields) class SomeAction(models.Action): pil = some_pil_function
- Conditionally showing and hiding fields can be triggered by a BooleanField or a ChoiceField. (If necessary others field types can easily be added.) The optional fields should be under the fields which controls it. (Otherwise the current focus field has to jump up and that is an usability nightmare.) To implement conditional fields, you just need to define a get_relevant_field_labels(self)() method. This method should return the label of the relevant fields in the same order as the fields are defined in the interface() method. See the save action for an example.
Info and metadata
- Always use photo.get_info() to look up the info of the photo. This will return a copy. Only use photo.info to explicitly change its values, but *nver* for look up.
not: photo.info but: info = photo.get_info()
- Actions which manipulate exif information should work with the photo.metadata dictionary instead of saving it to the file themselves. As such if there are many exif actions, the metadata of all actions will only be written once. Remember the exif actions work after the save action. So you manipulate the exif tags of the source file (if no save is present) or of the saved file. This is by design, as sometimes you just want to add/change tags, not to use a save action in Phatch. There is a photo.flush_metadata method (see core/pil.py) which is called before every save and at the end of the action list. This method takes care of saving the metadata to the image.
Cache
- Names of variables in the cache should be prefixed with the name of the action. This prevents namespace collision to occur.
not: cache['timedict'] but: cache['gps_timedict']
Logging errors and warnings
- Errors are logged automagically. The best thing if an error occurs in your action is to raise a meaningfull exception. When an exception occurs, Phatch will show an error dialog box to the user which asks to skip to the next action, to skip to the next photo or to abort. If needed, you can tailor your own custom exception:
raise CustomException('Some meaningful message.')
- If you want to log a warning, add it to the string photo.log. Warning messages are flushed at the end of the action execution automatically by Phatch. The difference with an error is that the warning will be silently logged. However at the end of the action list execution, it will mention the warning in a status dialog box. You need to format the string as you pleases with \n and \t. For example you can use (don't forget the trailing \n):
photo.log += 'This is a warning\n'
- If you use the methods above Phatch will provide the errors and warnings with the context: the name of the action and its parameters.
Artwork
- Of any icon/image you use, we'll need to know the author and the license. The license should be compatible with the GPL3, so creative commons is not an option. If possible, the copyright information (author+license) should be embedded in the image (such as SVG), and for sure added to phatch/copyright and phatch/data/info.py! If you altered a work which is in public domain, you have to claim authorship and you are not allowed to use the name of the original author. (If you need an icon you can always try to contact Igor from http://www.admiror-design-studio.com/, but he is often busy. So it is better to ask way in front. He designed several Phatch icons, the logo and the mascot.)
Conclusion
Writing a Phatch action is really easy, because Stani used a really good design. Thanks to him!
Note: the cms action described here is a first draft, and needs to be improved. Have a look at http://trac.gbiloba.org/phatch to see all my Phatch actions (coming soon).