blend modes: add missing ones, redo as templates
[mypaint:maxy-experimental.git] / lib / tiledsurface.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2007-2008 by Martin Renold <martinxyz@gmx.ch>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8
9 # This module implements an unbounded tiled surface for painting.
10
11 from numpy import *
12 import time, sys, os
13 import mypaintlib, helpers
14 import math
15
16 TILE_SIZE = N = mypaintlib.TILE_SIZE
17 MAX_MIPMAP_LEVEL = mypaintlib.MAX_MIPMAP_LEVEL
18
19 use_gegl = True if os.environ.get('MYPAINT_ENABLE_GEGL', 0) else False
20
21 from layer import DEFAULT_COMPOSITE_OP
22
23 # Avoid pulling in PyGTK+ when using GI
24 if not os.environ.get('MYPAINT_ENABLE_GEGL', 0):
25     import pixbufsurface
26
27
28 class Tile:
29     def __init__(self, copy_from=None):
30         # note: pixels are stored with premultiplied alpha
31         #       15bits are used, but fully opaque or white is stored as 2**15 (requiring 16 bits)
32         #       This is to allow many calcuations to divide by 2**15 instead of (2**16-1)
33         if copy_from is None:
34             self.rgba = zeros((N, N, 4), 'uint16')
35         else:
36             self.rgba = copy_from.rgba.copy()
37         self.readonly = False
38
39     def copy(self):
40         return Tile(copy_from=self)
41
42
43 svg2composite_func = {
44     'svg:src-over': mypaintlib.tile_composite_normal,
45     'svg:multiply': mypaintlib.tile_composite_multiply,
46     'svg:screen': mypaintlib.tile_composite_screen,
47     'svg:overlay': mypaintlib.tile_composite_overlay,
48     'svg:darken': mypaintlib.tile_composite_darken,
49     'svg:lighten': mypaintlib.tile_composite_lighten,
50     'svg:hard-light': mypaintlib.tile_composite_hard_light,
51     'svg:soft-light': mypaintlib.tile_composite_soft_light,
52     'svg:color-burn': mypaintlib.tile_composite_color_burn,
53     'svg:color-dodge': mypaintlib.tile_composite_color_dodge,
54     'svg:difference': mypaintlib.tile_composite_difference,
55     'svg:exclusion': mypaintlib.tile_composite_exclusion,
56     'svg:hue': mypaintlib.tile_composite_hue,
57     'svg:saturation': mypaintlib.tile_composite_saturation,
58     'svg:color': mypaintlib.tile_composite_color,
59     'svg:luminosity': mypaintlib.tile_composite_luminosity,
60     }
61
62 # tile for read-only operations on empty spots
63 transparent_tile = Tile()
64 transparent_tile.readonly = True
65
66 # tile with invalid pixel memory (needs refresh)
67 mipmap_dirty_tile = Tile()
68 del mipmap_dirty_tile.rgba
69
70 def get_tiles_bbox(tiles):
71     res = helpers.Rect()
72     for tx, ty in tiles:
73         res.expandToIncludeRect(helpers.Rect(N*tx, N*ty, N, N))
74     return res
75
76 class SurfaceSnapshot:
77     pass
78
79 if use_gegl:
80
81     class GeglSurface(mypaintlib.GeglBackedSurface):
82
83         def __init__(self, mipmap_level=0):
84             mypaintlib.GeglBackedSurface.__init__(self, self)
85             self.observers = []
86
87         def notify_observers(self, *args):
88             for f in self.observers:
89                 f(*args)
90
91         def get_bbox(self):
92             rect = helpers.Rect(*self.get_bbox_c())
93             return rect
94
95         def clear(self):
96             pass
97
98         def save_as_png(self, path, *args, **kwargs):
99             return self.save_as_png_c(str(path))
100
101         def load_from_png(self, path, x, y, *args, **kwargs):
102             return self.load_from_png_c(str(path))
103
104         def save_snapshot(self):
105             sshot = SurfaceSnapshot()
106             sshot.tiledict = {}
107             return sshot
108
109         def load_snapshot(self, sshot):
110             pass
111
112         def is_empty(self):
113             return False
114
115         def remove_empty_tiles(self):
116             pass
117
118         def composite_tile(self, dst, dst_has_alpha, tx, ty, mipmap_level=0, opacity=1.0,
119                            mode=DEFAULT_COMPOSITE_OP):
120             pass
121
122         def load_from_numpy(self, arr, x, y):
123             return (0, 0, 0, 0)
124
125         def load_from_surface(self, other):
126             pass
127
128         def get_tiles(self):
129             return {}
130
131         def set_symmetry_state(self, enabled, center_axis):
132             pass
133
134 class MyPaintSurface(mypaintlib.TiledSurface):
135     # the C++ half of this class is in tiledsurface.hpp
136     def __init__(self, mipmap_level=0):
137         mypaintlib.TiledSurface.__init__(self, self)
138         self.tiledict = {}
139         self.observers = []
140
141         self.mipmap_level = mipmap_level
142         self.mipmap = None
143         self.parent = None
144
145         if mipmap_level < MAX_MIPMAP_LEVEL:
146             self.mipmap = Surface(mipmap_level+1)
147             self.mipmap.parent = self
148
149     def notify_observers(self, *args):
150         for f in self.observers:
151             f(*args)
152
153     def clear(self):
154         tiles = self.tiledict.keys()
155         self.tiledict = {}
156         self.notify_observers(*get_tiles_bbox(tiles))
157         if self.mipmap: self.mipmap.clear()
158
159     def get_tile_memory(self, tx, ty, readonly):
160         # OPTIMIZE: do some profiling to check if this function is a bottleneck
161         #           yes it is
162         # Note: we must return memory that stays valid for writing until the
163         # last end_atomic(), because of the caching in tiledsurface.hpp.
164         t = self.tiledict.get((tx, ty))
165         if t is None:
166             if readonly:
167                 t = transparent_tile
168             else:
169                 t = Tile()
170                 self.tiledict[(tx, ty)] = t
171         if t is mipmap_dirty_tile:
172             # regenerate mipmap
173             t = Tile()
174             self.tiledict[(tx, ty)] = t
175             empty = True
176             for x in xrange(2):
177                 for y in xrange(2):
178                     src = self.parent.get_tile_memory(tx*2 + x, ty*2 + y, True)
179                     mypaintlib.tile_downscale_rgba16(src, t.rgba, x*N/2, y*N/2)
180                     if src is not transparent_tile.rgba:
181                         empty = False
182             if empty:
183                 # rare case, no need to speed it up
184                 del self.tiledict[(tx, ty)]
185                 t = transparent_tile
186         if t.readonly and not readonly:
187             # shared memory, get a private copy for writing
188             t = t.copy()
189             self.tiledict[(tx, ty)] = t
190         if not readonly:
191             assert self.mipmap_level == 0
192             self._mark_mipmap_dirty(tx, ty)
193         return t.rgba
194
195     def _mark_mipmap_dirty(self, tx, ty):
196         if self.mipmap_level > 0:
197             self.tiledict[(tx, ty)] = mipmap_dirty_tile
198         if self.mipmap:
199             self.mipmap._mark_mipmap_dirty(tx/2, ty/2)
200
201     def blit_tile_into(self, dst, dst_has_alpha, tx, ty, mipmap_level=0):
202         # used mainly for saving (transparent PNG)
203         assert dst_has_alpha is True
204         if self.mipmap_level < mipmap_level:
205             return self.mipmap.blit_tile_into(dst, dst_has_alpha, tx, ty, mipmap_level)
206         assert dst.shape[2] == 4
207         src = self.get_tile_memory(tx, ty, readonly=True)
208         if src is transparent_tile.rgba:
209             #dst[:] = 0 # <-- notably slower than memset()
210             mypaintlib.tile_clear(dst)
211         else:
212             mypaintlib.tile_convert_rgba16_to_rgba8(src, dst)
213
214
215     def composite_tile(self, dst, dst_has_alpha, tx, ty, mipmap_level=0, opacity=1.0,
216                        mode=DEFAULT_COMPOSITE_OP):
217         """Composite one tile of this surface over a NumPy array.
218
219         Composite one tile of this surface over the array dst, modifying only dst.
220         """
221         if self.mipmap_level < mipmap_level:
222             return self.mipmap.composite_tile(dst, dst_has_alpha, tx, ty, mipmap_level, opacity, mode)
223         if not (tx,ty) in self.tiledict:
224             return
225         src = self.get_tile_memory(tx, ty, readonly=True)
226
227         func = svg2composite_func[mode]
228         func(src, dst, dst_has_alpha, opacity)
229
230     def save_snapshot(self):
231         sshot = SurfaceSnapshot()
232         for t in self.tiledict.itervalues():
233             t.readonly = True
234         sshot.tiledict = self.tiledict.copy()
235         return sshot
236
237     def load_snapshot(self, sshot):
238         self._load_tiledict(sshot.tiledict)
239
240     def _load_tiledict(self, d):
241         if d == self.tiledict:
242             # common case optimization, called from split_stroke() via stroke.redo()
243             # testcase: comparison above (if equal) takes 0.6ms, code below 30ms
244             return
245         old = set(self.tiledict.iteritems())
246         self.tiledict = d.copy()
247         new = set(self.tiledict.iteritems())
248         dirty = old.symmetric_difference(new)
249         for pos, tile in dirty:
250             self._mark_mipmap_dirty(*pos)
251         bbox = get_tiles_bbox([pos for (pos, tile) in dirty])
252         if not bbox.empty():
253             self.notify_observers(*bbox)
254
255     def load_from_surface(self, other):
256         self.load_snapshot(other.save_snapshot())
257
258     def _load_from_pixbufsurface(self, s):
259         dirty_tiles = set(self.tiledict.keys())
260         self.tiledict = {}
261
262         for tx, ty in s.get_tiles():
263             dst = self.get_tile_memory(tx, ty, readonly=False)
264             s.blit_tile_into(dst, True, tx, ty)
265
266         dirty_tiles.update(self.tiledict.keys())
267         bbox = get_tiles_bbox(dirty_tiles)
268         self.notify_observers(*bbox)
269
270     def load_from_numpy(self, arr, x, y):
271         h, w, channels = arr.shape
272         if h <= 0 or w <= 0:
273             return (x, y, w, h)
274
275         assert arr.dtype == 'uint8'
276         s = pixbufsurface.Surface(x, y, w, h, data=arr)
277         self._load_from_pixbufsurface(s)
278         return (x, y, w, h)
279
280     def load_from_png(self, filename, x, y, feedback_cb=None):
281         """Load from a PNG, one tilerow at a time, discarding empty tiles.
282         """
283         dirty_tiles = set(self.tiledict.keys())
284         self.tiledict = {}
285
286         state = {}
287         state['buf'] = None # array of height N, width depends on image
288         state['ty'] = y/N # current tile row being filled into buf
289         state['frame_size'] = None
290
291         def get_buffer(png_w, png_h):
292             state['frame_size'] = x, y, png_w, png_h
293             if feedback_cb:
294                 feedback_cb()
295             buf_x0 = x/N*N
296             buf_x1 = ((x+png_w-1)/N+1)*N
297             buf_y0 = state['ty']*N
298             buf_y1 = buf_y0+N
299             buf_w = buf_x1-buf_x0
300             buf_h = buf_y1-buf_y0
301             assert buf_w % N == 0
302             assert buf_h == N
303             if state['buf'] is not None:
304                 consume_buf()
305             else:
306                 state['buf'] = empty((buf_h, buf_w, 4), 'uint8')
307
308             png_x0 = x
309             png_x1 = x+png_w
310             subbuf = state['buf'][:,png_x0-buf_x0:png_x1-buf_x0]
311             if 1: # optimize: only needed for first and last
312                 state['buf'].fill(0)
313                 png_y0 = max(buf_y0, y)
314                 png_y1 = min(buf_y0+buf_h, y+png_h)
315                 assert png_y1 > png_y0
316                 subbuf = subbuf[png_y0-buf_y0:png_y1-buf_y0,:]
317
318             state['ty'] += 1
319             return subbuf
320
321         def consume_buf():
322             ty = state['ty']-1
323             for i in xrange(state['buf'].shape[1]/N):
324                 tx = x/N + i
325                 src = state['buf'][:,i*N:(i+1)*N,:]
326                 if src[:,:,3].any():
327                     dst = self.get_tile_memory(tx, ty, readonly=False)
328                     mypaintlib.tile_convert_rgba8_to_rgba16(src, dst)
329
330         filename_sys = filename.encode(sys.getfilesystemencoding()) # FIXME: should not do that, should use open(unicode_object)
331         flags = mypaintlib.load_png_fast_progressive(filename_sys, get_buffer)
332         consume_buf() # also process the final chunk of data
333         print flags
334
335         dirty_tiles.update(self.tiledict.keys())
336         bbox = get_tiles_bbox(dirty_tiles)
337         self.notify_observers(*bbox)
338
339         # return the bbox of the loaded image
340         return state['frame_size']
341
342     def render_as_pixbuf(self, *args, **kwargs):
343         if not self.tiledict:
344             print 'WARNING: empty surface'
345         t0 = time.time()
346         kwargs['alpha'] = True
347         res = pixbufsurface.render_as_pixbuf(self, *args, **kwargs)
348         print '  %.3fs rendering layer as pixbuf' % (time.time() - t0)
349         return res
350
351     def save_as_png(self, filename, *args, **kwargs):
352         assert 'alpha' not in kwargs
353         kwargs['alpha'] = True
354         pixbufsurface.save_as_png(self, filename, *args, **kwargs)
355
356     def get_tiles(self):
357         return self.tiledict
358
359     def get_bbox(self):
360         return get_tiles_bbox(self.tiledict)
361
362     def is_empty(self):
363         return not self.tiledict
364
365     def remove_empty_tiles(self):
366         # Only used in tests
367         for pos, data in self.tiledict.items():
368             if not data.rgba.any():
369                 self.tiledict.pop(pos)
370
371     def get_move(self, x, y):
372         return _InteractiveMove(self, x, y)
373
374
375 class _InteractiveMove:
376
377     def __init__(self, surface, x, y):
378         self.surface = surface
379         self.snapshot = surface.save_snapshot()
380         self.chunks = self.snapshot.tiledict.keys()
381         # print "Number of Tiledict_keys", len(self.chunks)
382         tx = x // N
383         ty = y // N
384         chebyshev = lambda p: max(abs(tx - p[0]), abs(ty - p[1]))
385         manhattan = lambda p: abs(tx - p[0]) + abs(ty - p[1])
386         euclidean = lambda p: math.sqrt((tx - p[0])**2 + (ty - p[1])**2)
387         self.chunks.sort(key=manhattan)
388         self.chunks_i = 0
389
390     def update(self, dx, dy):
391         # Tiles to be blanked at the end of processing
392         self.blanked = set(self.surface.tiledict.keys())
393         # Calculate offsets
394         self.slices_x = calc_translation_slices(int(dx))
395         self.slices_y = calc_translation_slices(int(dy))
396         self.chunks_i = 0
397
398     def cleanup(self):
399         # called at the end of each set of processing batches
400         for b in self.blanked:
401             self.surface.tiledict.pop(b, None)
402             self.surface._mark_mipmap_dirty(*b)
403         bbox = get_tiles_bbox(self.blanked)
404         self.surface.notify_observers(*bbox)
405         # Remove empty tile created by Layer Move
406         self.surface.remove_empty_tiles()
407
408     def process(self, n=200):
409         if self.chunks_i > len(self.chunks):
410             return False
411         written = set()
412         if n <= 0:
413             n = len(self.chunks)  # process all remaining
414         for tile_pos in self.chunks[self.chunks_i : self.chunks_i + n]:
415             src_tx, src_ty = tile_pos
416             src_tile = self.snapshot.tiledict[(src_tx, src_ty)]
417             is_integral = len(self.slices_x) == 1 and len(self.slices_y) == 1
418             for (src_x0, src_x1), (targ_tdx, targ_x0, targ_x1) in self.slices_x:
419                 for (src_y0, src_y1), (targ_tdy, targ_y0, targ_y1) in self.slices_y:
420                     targ_tx = src_tx + targ_tdx
421                     targ_ty = src_ty + targ_tdy
422                     if is_integral:
423                         self.surface.tiledict[(targ_tx, targ_ty)] = src_tile.copy()
424                     else:
425                         targ_tile = None
426                         if (targ_tx, targ_ty) in self.blanked:
427                             targ_tile = Tile()
428                             self.surface.tiledict[(targ_tx, targ_ty)] = targ_tile
429                             self.blanked.remove( (targ_tx, targ_ty) )
430                         else:
431                             targ_tile = self.surface.tiledict.get((targ_tx, targ_ty), None)
432                         if targ_tile is None:
433                             targ_tile = Tile()
434                             self.surface.tiledict[(targ_tx, targ_ty)] = targ_tile
435                         targ_tile.rgba[targ_y0:targ_y1, targ_x0:targ_x1] \
436                           = src_tile.rgba[src_y0:src_y1, src_x0:src_x1]
437                     written.add((targ_tx, targ_ty))
438         self.blanked -= written
439         for pos in written:
440             self.surface._mark_mipmap_dirty(*pos)
441         bbox = get_tiles_bbox(written) # hopefully relatively contiguous
442         self.surface.notify_observers(*bbox)
443         self.chunks_i += n
444         return self.chunks_i < len(self.chunks)
445
446
447 def calc_translation_slices(dc):
448     """Returns a list of offsets and slice extents for a translation of `dc`.
449
450     The returned slice list's members are of the form
451
452         ((src_c0, src_c1), (targ_tdc, targ_c0, targ_c1))
453
454     where ``src_c0`` and ``src_c1`` determine the extents of the source slice
455     within a tile, their ``targ_`` equivalents specify where to put that slice
456     in the target tile, and ``targ_tdc`` is the tile offset.
457     """
458     dcr = dc % N
459     tdc = (dc // N)
460     if dcr == 0:
461         return [ ((0, N), (tdc, 0, N)) ]
462     else:
463         return [ ((0, N-dcr), (tdc, dcr, N)) ,
464                  ((N-dcr, N), (tdc+1, 0, dcr)) ]
465
466 # Set which surface backend to use
467 Surface = GeglSurface if use_gegl else MyPaintSurface