_gradia-secure-blur.patch (5641B)
1 --- a/gradia/backend/tool_config.py 2 +++ b/gradia/backend/tool_config.py 3 @@ -344,7 +344,7 @@ 4 icon="checkerboard-big-symbolic", 5 column=3, 6 row=1, 7 - has_scale=False, 8 + has_scale=True, 9 has_primary_color=False, 10 ), 11 ToolConfig( 12 --- a/gradia/overlay/drawing_actions.py 13 +++ b/gradia/overlay/drawing_actions.py 14 @@ -21,6 +21,7 @@ 15 from gi.repository import Gtk, Gdk, Gio, Pango, PangoCairo, GdkPixbuf 16 from enum import Enum 17 import math 18 +import random 19 from gradia.backend.logger import Logger 20 from gradia.utils.colors import has_visible_color 21 import time 22 @@ -640,19 +641,25 @@ 23 24 25 class CensorAction(RectAction): 26 + # Secure blur: the area is averaged down to cells (4x the size slider, in 27 + # screen pixels), every cell gets random noise, then it is scaled back up 28 + # smoothly. Averaging throws the detail away; the noise means the cells no 29 + # longer equal the true averages, which is what depixelation tools match on. 30 + NOISE_SIGMA = 10 31 + 32 def __init__(self, start: tuple[int, int], end: tuple[int, int], background_pixbuf: GdkPixbuf.Pixbuf, options): 33 super().__init__(start, end, False, options) 34 35 self.original_scale = 1.0 36 - self.base_block_size = 8 37 self.background_pixbuf = background_pixbuf 38 + self.noise_seed = random.SystemRandom().getrandbits(64) 39 40 def set_original_scale(self, scale: float): 41 self.original_scale = scale 42 43 def _get_scaled_block_size(self, current_scale: float) -> float: 44 scale_ratio = current_scale / self.original_scale 45 - return self.base_block_size * scale_ratio 46 + return max(3, self.options.size) * 4 * scale_ratio 47 48 def draw(self, cr: cairo.Context, image_to_widget_coords: Callable[[int, int], tuple[float, float]], scale: float): 49 crop = self._get_image_crop() 50 @@ -675,37 +682,59 @@ 51 return 52 53 scaled_block_size = self._get_scaled_block_size(scale) 54 - self._draw_pixelation(cr, crop, x, y, width, height, scaled_block_size) 55 + self._draw_secure_blur(cr, crop, x, y, width, height, scaled_block_size) 56 + 57 + def _draw_secure_blur(self, cr: cairo.Context, crop: dict, x: float, y: float, width: float, height: float, block_size: float): 58 + blocks_x = max(2, round(width / block_size)) 59 + blocks_y = max(2, round(height / block_size)) 60 + 61 + cells = cairo.ImageSurface(cairo.FORMAT_ARGB32, blocks_x, blocks_y) 62 + cells_cr = cairo.Context(cells) 63 + cells_cr.scale(blocks_x / crop['width'], blocks_y / crop['height']) 64 + cells_cr.translate(-crop['x'], -crop['y']) 65 + Gdk.cairo_set_source_pixbuf(cells_cr, self.background_pixbuf, 0, 0) 66 + cells_cr.get_source().set_filter(cairo.FILTER_GOOD) # box-average when shrinking 67 + cells_cr.paint() 68 + cells.flush() 69 + self._add_noise(cells) 70 + cells.mark_dirty() 71 + 72 + # Two smooth upscales (cells -> 4x -> area) read as a blur, not a mosaic. 73 + mid_w, mid_h = blocks_x * 4, blocks_y * 4 74 + mid = cairo.ImageSurface(cairo.FORMAT_ARGB32, mid_w, mid_h) 75 + mid_cr = cairo.Context(mid) 76 + mid_cr.scale(4, 4) 77 + self._set_smooth_source(mid_cr, cells) 78 + mid_cr.paint() 79 80 - def _draw_pixelation(self, cr: cairo.Context, crop: dict, x: float, y: float, width: float, height: float, block_size: float): 81 cr.save() 82 cr.rectangle(x, y, width, height) 83 cr.clip() 84 - 85 - blocks_x = max(1, int(width / block_size)) 86 - blocks_y = max(1, int(height / block_size)) 87 - 88 - tiny_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, blocks_x, blocks_y) 89 - tiny_cr = cairo.Context(tiny_surface) 90 - 91 - tiny_cr.set_operator(cairo.OPERATOR_CLEAR) 92 - tiny_cr.paint() 93 - tiny_cr.set_operator(cairo.OPERATOR_OVER) 94 - 95 - tiny_cr.scale(blocks_x / crop['width'], blocks_y / crop['height']) 96 - tiny_cr.translate(-crop['x'], -crop['y']) 97 - Gdk.cairo_set_source_pixbuf(tiny_cr, self.background_pixbuf, 0, 0) 98 - tiny_cr.paint() 99 - 100 cr.translate(x, y) 101 - cr.scale(width / blocks_x, height / blocks_y) 102 - pattern = cairo.SurfacePattern(tiny_surface) 103 - pattern.set_filter(cairo.FILTER_NEAREST) 104 - cr.set_source(pattern) 105 + cr.scale(width / mid_w, height / mid_h) 106 + self._set_smooth_source(cr, mid) 107 cr.paint() 108 - 109 cr.restore() 110 111 + @staticmethod 112 + def _set_smooth_source(cr: cairo.Context, surface: cairo.ImageSurface): 113 + pattern = cairo.SurfacePattern(surface) 114 + pattern.set_filter(cairo.FILTER_BILINEAR) 115 + pattern.set_extend(cairo.EXTEND_PAD) # no see-through fringe at the edges 116 + cr.set_source(pattern) 117 + 118 + def _add_noise(self, surface: cairo.ImageSurface): 119 + rng = random.Random(self.noise_seed) # fixed per box, so it does not flicker 120 + data = surface.get_data() 121 + stride = surface.get_stride() 122 + for row in range(surface.get_height()): 123 + for col in range(surface.get_width()): 124 + i = row * stride + col * 4 125 + alpha = data[i + 3] 126 + shift = rng.gauss(0, self.NOISE_SIGMA) # brightness only, so it reads as grain 127 + for c in range(3): # premultiplied B, G, R stay within alpha 128 + data[i + c] = min(alpha, max(0, round(data[i + c] + shift))) 129 + 130 def _get_image_crop(self) -> dict | None: 131 img_w, img_h = self.background_pixbuf.get_width(), self.background_pixbuf.get_height() 132 x1 = int(self.start[0] + img_w / 2)