def save_to_string(self, translate_x, translate_y): """Return a compressed bytes string representing the stroke shape. This can be used with init_from_string on subsequent file loads. >>> shape = StrokeShape._mock() >>> bstr = shape.save_to_string(-N, 2*N) >>> isinstance(bstr, bytes) True See lib.layer.data.PaintingLayer.save_to_openraster(). Format: "v2" strokemap format. """ assert translate_x % N == 0 assert translate_y % N == 0 translate_x = int(translate_x // N) translate_y = int(translate_y // N) self.tasks.finish_all() data = b'' for (tx, ty), tile in iteritems(self.strokemap): compressed_bitmap = tile.to_bytes() tx = int(tx + translate_x) ty = int(ty + translate_y) data += struct.pack('>iiI', tx, ty, len(compressed_bitmap)) data += compressed_bitmap return data
def unseep(seed_queue, filled, gc_filler, total_px, tiles_bbox, distances): """Seep inversion is basically a four-way 0-alpha fill with different conditions. It only backs off into the original fill and therefore does not require creation of new tiles or use of an input alpha tile. """ backup = {} while len(seed_queue) > 0: tile_coord, seeds, is_initial = seed_queue.pop(0) if tile_coord not in distances or tile_coord not in filled: continue if tile_coord not in backup: if filled[tile_coord] is _FULL_TILE: backup[tile_coord] = _FULL_TILE filled[tile_coord] = fc.new_full_tile(1 << 15) else: backup[tile_coord] = np.copy(filled[tile_coord]) result = gc_filler.unseep(distances[tile_coord], filled[tile_coord], seeds, is_initial) overflows = result[0:4] num_erased_pixels = result[4] total_px -= num_erased_pixels enqueue_overflows(seed_queue, tile_coord, overflows, tiles_bbox, (False, ) * 4) if total_px <= 0: # For small areas, when starting on a distance-marked pixel, # backing off may remove the entire fill, in which case we # roll back the tiles that were processed for tile_coord, tile in iteritems(backup): filled[tile_coord] = tile
def scanline_fill(handler, src, seed_lists, tiles_bbox, filler): """ Perform a scanline fill and return the filled tiles Perform a scanline fill using the given starting point and tile, with reference to the src surface and given bounding box, using the provided filler instance. :param handler: updates fill status and permits cancelling :type handler: FillHandler :param src: Source surface-like object :param seed_lists: dictionary, pairing tile coords with lists of seeds :type seed_lists: dict :param tiles_bbox: Bounding box for the fill :type tiles_bbox: lib.fill_common.TileBoundingBox :param filler: filler instance performing the per-tile fill operation :type filler: myplib.Filler :returns: a dictionary of coord->tile mappings for the filled tiles """ # Dict of coord->tile data populated during the fill filled = {} inv_edges = ( EDGE.south, EDGE.west, EDGE.north, EDGE.east ) # Starting coordinates + direction of origin (from within) tileq = [] for seed_tile_coord, seeds in iteritems(seed_lists): tileq.append((seed_tile_coord, seeds, myplib.edges.none)) tfs = _TileFillSkipper(tiles_bbox, filler, set({})) while len(tileq) > 0 and handler.run: tile_coord, seeds, from_dir = tileq.pop(0) # Skip if the tile has been fully processed already if tile_coord in tfs.final: continue # Flood-fill one tile with src.tile_request(*tile_coord, readonly=True) as src_tile: # See if the tile can be skipped overflows = tfs.check(tile_coord, src_tile, filled, from_dir) if overflows is None: if tile_coord not in filled: handler.inc_processed() filled[tile_coord] = np.zeros((N, N), 'uint16') overflows = filler.fill( src_tile, filled[tile_coord], seeds, from_dir, *tiles_bbox.tile_bounds(tile_coord) ) else: handler.inc_processed() enqueue_overflows(tileq, tile_coord, overflows, tiles_bbox, inv_edges) return filled
def _init_adjustments(self): """Initializes adjustments for the scales used internally When running as part of the MyPaint app, the brush setting ones are shared with it. """ # Brush setting base values if self.app: for s in brushsettings.settings_visible: adj = self.app.brush_adjustment[s.cname] self._base_adj[s.cname] = adj # The application instance manages value-changed callbacks itself. else: for s in brushsettings.settings_visible: adj = Gtk.Adjustment(value=s.default, lower=s.min, upper=s.max, step_increment=0.01, page_increment=0.1) self._base_adj[s.cname] = adj changed_cb = self._testmode_base_value_adj_changed_cb for cname, adj in iteritems(self._base_adj): adj.connect('value-changed', changed_cb, cname) # Per-input scale maxima and minima for inp in brushsettings.inputs: name = inp.name adj = Gtk.Adjustment(value=1.0 / 4.0, lower=-1.0, upper=1.0, step_increment=0.01, page_increment=0.1) adj.connect("value-changed", self.input_adj_changed_cb, inp) self._input_y_adj[name] = adj lower = -20.0 upper = +20.0 # Pre-libmypaint split, the limits were read from json and could be # None. Now that cannot be checked directly, so instead check if # the limits are extreme (in libmypaint, they are set to +-FLT_MAX) if abs(inp.hard_min) < 1e16: lower = inp.hard_min if abs(inp.hard_max) < 1e16: upper = inp.hard_max adj = Gtk.Adjustment(value=inp.soft_min, lower=lower, upper=upper - 0.1, step_increment=0.01, page_increment=0.1) adj.connect("value-changed", self.input_adj_changed_cb, inp) self._input_xmin_adj[name] = adj adj = Gtk.Adjustment(value=inp.soft_max, lower=lower + 0.1, upper=upper, step_increment=0.01, page_increment=0.1) adj.connect("value-changed", self.input_adj_changed_cb, inp) self._input_xmax_adj[name] = adj
def gap_closing_fill(handler, src, seed_lists, tiles_bbox, filler, gap_closing_options): """ Fill loop that finds and uses gap data to avoid unwanted leaks Gaps are defined as distances of fillable pixels enclosed on two sides by unfillable pixels. Each tile considered, and their neighbours, are flooded with alpha values based on the target color and threshold values. The resulting alphas are then searched for gaps, and the size of these gaps are marked in separate tiles - one for each tile filled. """ unseep_queue = [] filled = {} final = set({}) seed_queue = [] for seed_tile_coord, seeds in iteritems(seed_lists): seed_queue.append((seed_tile_coord, seeds)) options = gap_closing_options max_gap_size = lib.helpers.clamp(options.max_gap_size, 1, TILE_SIZE) gc_filler = myplib.GapClosingFiller(max_gap_size, options.retract_seeps) gc_handler = _GCTileHandler(final, max_gap_size, tiles_bbox, filler, src) total_px = 0 skip_unseeping = False while len(seed_queue) > 0 and handler.run: tile_coord, seeds = seed_queue.pop(0) if tile_coord in final: continue # Create distance-data and alpha output tiles for the fill # and check if the tile can be skipped directly alpha_t, dist_t, overflows = gc_handler.get_gc_data(tile_coord, seeds) if overflows: handler.inc_processed() filled[tile_coord] = _FULL_TILE else: # Complement data for initial seeds (if they are initial seeds) seeds, any_not_max = complement_gc_seeds(seeds, dist_t) # If the fill is starting at a point with a detected distance, # disable seep retraction - otherwise it is very likely # that the result will be completely empty. if any_not_max: skip_unseeping = True # Pixel limits within tiles can vary at the bounding box edges px_bounds = tiles_bbox.tile_bounds(tile_coord) # Create new output tile if not already present if tile_coord not in filled: handler.inc_processed() filled[tile_coord] = np.zeros((N, N), 'uint16') # Run the gap-closing fill for the tile result = gc_filler.fill(alpha_t, dist_t, filled[tile_coord], seeds, *px_bounds) overflows = result[0:4] fill_edges, px_f = result[4:6] # The entire tile was filled, despite potential gaps; # replace data w. constant and mark tile as final. if px_f == N * N: final.add(tile_coord) # When seep inversion is enabled, track total pixels filled # and coordinates where the fill stopped due to distance conditions total_px += px_f if not skip_unseeping and fill_edges: unseep_queue.append((tile_coord, fill_edges, True)) # Enqueue overflows, whether skipping or not enqueue_overflows(seed_queue, tile_coord, overflows, tiles_bbox) # If enabled, pull the fill back into the gaps to stop before them if not skip_unseeping and handler.run: unseep(unseep_queue, filled, gc_filler, total_px, tiles_bbox, gc_handler.distances) return filled
def composite(handler, fill_args, trim_result, filled, tiles_bbox, dst): """Composite the filled tiles into the destination surface""" handler.set_stage(handler.COMPOSITE, len(filled)) fill_col = fill_args.color # Prepare opaque color rgba tile for copying full_rgba = myplib.rgba_tile_from_alpha_tile( _FULL_TILE, *(fill_col + (0, 0, N - 1, N - 1))) # Bounding box of tiles that need updating dst_changed_bbox = None dst_tiles = dst.get_tiles() skip_empty_dst = fill_args.skip_empty_dst() mode = fill_args.mode lock_alpha = fill_args.lock_alpha opacity = fill_args.opacity tile_combine = myplib.tile_combine # Composite filled tiles into the destination surface for tile_coord, src_tile in iteritems(filled): if not handler.run: break handler.inc_processed() # Omit tiles outside of the bounding box _if_ the frame is enabled # Note:filled tiles outside bbox only originates from dilation/blur if trim_result and tiles_bbox.outside(tile_coord): continue # Skip empty destination tiles for erasing and alpha locking # Avoids completely unnecessary tile allocation and copying if skip_empty_dst and tile_coord not in dst_tiles: continue with dst.tile_request(*tile_coord, readonly=False) as dst_tile: # Only at this point might the bounding box need to be updated dst_changed_bbox = update_bbox(dst_changed_bbox, *tile_coord) # Under certain conditions, direct copies and dict manipulation # can be used instead of compositing operations. cut_off = trim_result and tiles_bbox.crossing(tile_coord) full_inner = src_tile is _FULL_TILE and not cut_off if full_inner: if mode == myplib.CombineNormal and opacity == 1.0: myplib.tile_copy_rgba16_into_rgba16(full_rgba, dst_tile) continue elif mode == myplib.CombineDestinationOut and opacity == 1.0: dst_tiles.pop(tile_coord) continue elif mode == myplib.CombineDestinationIn and opacity == 1.0: continue # Even if opacity != 1.0, we can reuse the full rgba tile src_tile_rgba = full_rgba else: if trim_result: tile_bounds = tiles_bbox.tile_bounds(tile_coord) else: tile_bounds = (0, 0, N - 1, N - 1) src_tile_rgba = myplib.rgba_tile_from_alpha_tile( src_tile, *(fill_col + tile_bounds)) # If alpha locking is enabled in combination with a mode other than # CombineNormal, we need to copy the dst tile to mask the result if lock_alpha and mode != myplib.CombineSourceAtop: mask = np.copy(dst_tile) mask_mode = myplib.CombineDestinationAtop tile_combine(mode, src_tile_rgba, dst_tile, True, opacity) tile_combine(mask_mode, mask, dst_tile, True, 1.0) else: tile_combine(mode, src_tile_rgba, dst_tile, True, opacity) # Handle dst-out and dst-atop: clear untouched tiles if mode in [myplib.CombineDestinationIn, myplib.CombineDestinationAtop]: for tile_coord in list(dst_tiles.keys()): if not handler.run: break if tile_coord not in filled: dst_changed_bbox = update_bbox(dst_changed_bbox, *tile_coord) with dst.tile_request(*tile_coord, readonly=False): dst_tiles.pop(tile_coord) if dst_changed_bbox and handler.run: min_tx, min_ty, max_tx, max_ty = dst_changed_bbox bbox = ( min_tx * N, min_ty * N, (1 + max_tx - min_tx) * N, (1 + max_ty - min_ty) * N, ) # Even for large fills on slow machines, this stage # will almost always be too short to even notice. # It is not cancellable once entered. handler.set_stage(FillHandler.FINISHING) # The observers may directly or indirectly use the # Gtk API, so the call is scheduled on the gui thread. GLib.idle_add(dst.notify_observers, *bbox)
def rename_button_clicked_cb(self, button): """Rename the current brush; user is prompted for a new name""" bm = self.app.brushmanager src_brush = bm.selected_brush if not src_brush.name: dialogs.error( self, C_( 'brush settings editor: rename brush: error message', 'No brush selected!', )) return src_name_pp = src_brush.name.replace('_', ' ') dst_name = dialogs.ask_for_name( self, C_( "brush settings editor: rename brush: dialog title", "Rename Brush", ), src_name_pp, ) if not dst_name: return dst_name = dst_name.replace(' ', '_') # ensure we don't overwrite an existing brush by accident dst_deleted = None for group, brushes in iteritems(bm.groups): for b2 in brushes: if b2.name == dst_name: if group == brushmanager.DELETED_BRUSH_GROUP: dst_deleted = b2 else: msg = C_( 'brush settings editor: ' 'rename brush: error message', 'A brush with this name already exists!', ) dialogs.error(self, msg) return logger.info("Renaming brush %r -> %r", src_brush.name, dst_name) if dst_deleted: deleted_group = brushmanager.DELETED_BRUSH_GROUP deleted_brushes = bm.get_group_brushes(deleted_group) deleted_brushes.remove(dst_deleted) bm.brushes_changed(deleted_brushes) # save src as dst src_name = src_brush.name src_brush.name = dst_name src_brush.save() src_brush.name = src_name # load dst dst_brush = brushmanager.ManagedBrush(bm, dst_name, persistent=True) dst_brush.load() # Replace src with dst, but keep src in the deleted list if it # is a stock brush self._delete_brush(src_brush, replacement=dst_brush) bm.select_brush(dst_brush)