editmamei 1.8.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/backends/gimp/bridge/lib.py +957 -22
- package/dist/backends/gimp/bridge/ops.py +1074 -62
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/core/server.js +58 -13
- package/dist/core/tool-activity.js +2 -0
- package/dist/core/tool-groups.js +2 -0
- package/dist/core/tool-tiers.js +3 -1
- package/dist/delivery/client.js +3 -0
- package/dist/modules/gimp/index.js +4 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/tools/document-tools.js +68 -12
- package/dist/tools/gimp-adjustment-tools.js +4 -3
- package/dist/tools/gimp-checkpoint-tools.js +7 -1
- package/dist/tools/gimp-core-tools.js +3 -3
- package/dist/tools/gimp-document-tools.js +96 -17
- package/dist/tools/gimp-effect-tools.js +54 -8
- package/dist/tools/gimp-fill-tools.js +256 -0
- package/dist/tools/gimp-filter-tools.js +4 -1
- package/dist/tools/gimp-inspect-tools.js +13 -5
- package/dist/tools/gimp-layer-tools.js +2 -0
- package/dist/tools/gimp-result-text.js +104 -0
- package/dist/tools/gimp-retouch-tools.js +238 -0
- package/dist/tools/gimp-text-tools.js +67 -7
- package/dist/tools/gimp-verify-tools.js +11 -2
- package/dist/tools/sky-tools.js +71 -21
- package/dist/utils/operation-timeouts.js +6 -2
- package/dist/utils/sky-presets.js +114 -0
- package/dist/version.js +1 -1
- package/package.json +7 -8
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# helpers below do real file I/O, same as any other stdlib code would.
|
|
14
14
|
|
|
15
15
|
import collections
|
|
16
|
+
import itertools
|
|
16
17
|
import json
|
|
17
18
|
import math
|
|
18
19
|
import os
|
|
@@ -21,7 +22,13 @@ import sys
|
|
|
21
22
|
import time
|
|
22
23
|
import traceback
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
# The newest ledger version this bridge reads and writes. A document is written at the lowest
|
|
26
|
+
# version that can hold its records: version 2 added the outline, outer_glow and surface_blur
|
|
27
|
+
# types, whose params an earlier bridge cannot read. Written as 2, an earlier bridge treats the
|
|
28
|
+
# ledger as newer than itself (it reads the filters back from GIMP and leaves the ledger alone)
|
|
29
|
+
# instead of failing on them; a ledger without them stays version 1, which every bridge reads.
|
|
30
|
+
LEDGER_VERSION = 2
|
|
31
|
+
LEDGER_V2_TYPES = frozenset(('outline', 'outer_glow', 'surface_blur'))
|
|
25
32
|
LEDGER_WRITER = 'editmamei'
|
|
26
33
|
|
|
27
34
|
HIST_CHANNELS = ('luminance', 'red', 'green', 'blue')
|
|
@@ -84,7 +91,9 @@ SPATIAL_SCALE_PROPS = {
|
|
|
84
91
|
'gegl:gaussian-blur': ('std-dev-x', 'std-dev-y'),
|
|
85
92
|
'gegl:motion-blur-linear': ('length',),
|
|
86
93
|
'gegl:focus-blur': ('blur-radius',),
|
|
87
|
-
'gegl:dropshadow': ('x', 'y', 'radius'),
|
|
94
|
+
'gegl:dropshadow': ('x', 'y', 'radius', 'grow-radius'),
|
|
95
|
+
# surface_blur's blur-radius is a pixel neighborhood, scaled like any other radius.
|
|
96
|
+
'gegl:bilateral-filter': ('blur-radius',),
|
|
88
97
|
# gegl:vignette and gegl:mono-mixer are deliberately absent: vignette's radius/x/y are
|
|
89
98
|
# proportional (fractions of the LAYER's own size, not absolute pixel lengths), and
|
|
90
99
|
# mono-mixer is a per-pixel channel-weight filter with no spatial extent at all.
|
|
@@ -128,6 +137,7 @@ MASK_TYPES = ('rectangle', 'ellipse', 'gradient_linear', 'gradient_radial')
|
|
|
128
137
|
# hard_light=hardlight darken=darken-only lighten=lighten-only difference=difference
|
|
129
138
|
# exclusion=exclusion addition=addition subtract=subtract divide=divide dodge=dodge burn=burn
|
|
130
139
|
# hue=hsv-hue saturation=hsv-saturation color=hsl-color luminosity=luminance
|
|
140
|
+
# grain_extract=grain-extract grain_merge=grain-merge
|
|
131
141
|
# hue/saturation/color/luminosity map to the HSV/HSL (not LCH) variants -- those are the ones
|
|
132
142
|
# GIMP's own UI labels plainly "Hue"/"Saturation"/"Color"/"Luminosity" without a "(LCH)" suffix.
|
|
133
143
|
LAYER_MODES = {
|
|
@@ -150,6 +160,8 @@ LAYER_MODES = {
|
|
|
150
160
|
'saturation': 'HSV_SATURATION',
|
|
151
161
|
'color': 'HSL_COLOR',
|
|
152
162
|
'luminosity': 'LUMINANCE',
|
|
163
|
+
'grain_extract': 'GRAIN_EXTRACT',
|
|
164
|
+
'grain_merge': 'GRAIN_MERGE',
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
|
|
@@ -159,6 +171,171 @@ def validate_layer_mode(value):
|
|
|
159
171
|
return value
|
|
160
172
|
|
|
161
173
|
|
|
174
|
+
# ---- gimp_fill ---------------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
FILL_OPS = ('solid', 'linear_gradient', 'radial_gradient')
|
|
177
|
+
FILL_MIN_STOPS = 2
|
|
178
|
+
FILL_MAX_STOPS = 8
|
|
179
|
+
# gimp_fill takes gimp_layer's blend-mode names plus 'lch_color': GIMP's LCh Color keeps the
|
|
180
|
+
# layer underneath's lightness exactly and swaps only hue and chroma, which is what a recolor
|
|
181
|
+
# wants. 'color' stays HSL Color (see LAYER_MODES), so existing callers are unchanged.
|
|
182
|
+
FILL_MODES = dict(LAYER_MODES, lch_color='LCH_COLOR')
|
|
183
|
+
_HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_number(v):
|
|
187
|
+
if not isinstance(v, (int, float)) or isinstance(v, bool):
|
|
188
|
+
return False
|
|
189
|
+
try:
|
|
190
|
+
return math.isfinite(v)
|
|
191
|
+
except OverflowError: # an int too large for a float (10**400)
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_hex_color(name, value):
|
|
196
|
+
"""'#rrggbb' (strictly: a leading '#' and six hex digits) -> (r, g, b) ints 0..255."""
|
|
197
|
+
if not isinstance(value, str) or not _HEX_COLOR_RE.match(value):
|
|
198
|
+
raise ValueError('%s must be a color like #rrggbb' % name)
|
|
199
|
+
return int(value[1:3], 16), int(value[3:5], 16), int(value[5:7], 16)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _fill_point(name, value, default):
|
|
203
|
+
if value is None:
|
|
204
|
+
return default
|
|
205
|
+
if not isinstance(value, dict) or not _is_number(value.get('x')) or not _is_number(value.get('y')):
|
|
206
|
+
raise ValueError('%s must be {x, y} with finite numbers' % name)
|
|
207
|
+
try:
|
|
208
|
+
x, y = float(value['x']), float(value['y'])
|
|
209
|
+
except OverflowError: # a Python int too large for a float (10**400)
|
|
210
|
+
raise ValueError('%s is out of range' % name)
|
|
211
|
+
if abs(x) > FILL_MAX_COORD_PX or abs(y) > FILL_MAX_COORD_PX:
|
|
212
|
+
raise ValueError('%s must be within +/-%d px' % (name, FILL_MAX_COORD_PX))
|
|
213
|
+
return x, y
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def validate_fill_stops(stops):
|
|
217
|
+
"""2..8 stops [{offset 0..1, color #rrggbb, opacity? 0..100}], offsets non-decreasing (they
|
|
218
|
+
must arrive sorted: a mis-ordered list is a caller mistake, not something to silently fix).
|
|
219
|
+
Returns [(offset, (r, g, b), alpha 0..1)]."""
|
|
220
|
+
if not isinstance(stops, list) or not FILL_MIN_STOPS <= len(stops) <= FILL_MAX_STOPS:
|
|
221
|
+
raise ValueError('stops must be a list of %d..%d stops' % (FILL_MIN_STOPS, FILL_MAX_STOPS))
|
|
222
|
+
out = []
|
|
223
|
+
for i, s in enumerate(stops):
|
|
224
|
+
if not isinstance(s, dict):
|
|
225
|
+
raise ValueError('stops[%d] must be an object {offset, color, opacity?}' % i)
|
|
226
|
+
offset = s.get('offset')
|
|
227
|
+
if not _is_number(offset) or not 0 <= offset <= 1:
|
|
228
|
+
raise ValueError('stops[%d].offset must be a number within 0..1' % i)
|
|
229
|
+
opacity = s.get('opacity', 100)
|
|
230
|
+
if not _is_number(opacity) or not 0 <= opacity <= 100:
|
|
231
|
+
raise ValueError('stops[%d].opacity must be within 0..100' % i)
|
|
232
|
+
if out and offset < out[-1][0]:
|
|
233
|
+
raise ValueError('stops must be sorted by offset: stops[%d] comes before stops[%d]' % (i, i - 1))
|
|
234
|
+
out.append((float(offset), parse_hex_color('stops[%d].color' % i, s.get('color')), opacity / 100.0))
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def gradient_ramp(stops):
|
|
239
|
+
"""The stops widened to cover the whole 0..1 range (the first color holds back to 0, the last
|
|
240
|
+
holds on to 1), so the gradient is defined everywhere. Returns a new list."""
|
|
241
|
+
ramp = list(stops)
|
|
242
|
+
if ramp[0][0] > 0:
|
|
243
|
+
ramp.insert(0, (0.0,) + ramp[0][1:])
|
|
244
|
+
if ramp[-1][0] < 1:
|
|
245
|
+
ramp.append((1.0,) + ramp[-1][1:])
|
|
246
|
+
return ramp
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _fill_name(value, op):
|
|
250
|
+
name = str(value or ('Fill' if op == 'solid' else 'Gradient')).strip() or 'Fill'
|
|
251
|
+
if len(name) > FILL_MAX_STRING_LEN:
|
|
252
|
+
raise ValueError('name must be at most %d characters' % FILL_MAX_STRING_LEN)
|
|
253
|
+
return name
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def validate_fill_args(args, width, height):
|
|
257
|
+
"""Everything gimp_fill needs, validated before any GIMP call. Points are image pixels."""
|
|
258
|
+
op = validate_choice('op', args.get('op'), FILL_OPS)
|
|
259
|
+
opts = {
|
|
260
|
+
'op': op,
|
|
261
|
+
'name': _fill_name(args.get('name'), op),
|
|
262
|
+
'mode': validate_choice('mode', args.get('mode', 'normal'), FILL_MODES),
|
|
263
|
+
'mask': args.get('mask'),
|
|
264
|
+
}
|
|
265
|
+
opacity = args.get('opacity', 100)
|
|
266
|
+
if not _is_number(opacity) or not 0 <= opacity <= 100:
|
|
267
|
+
raise ValueError('opacity must be within 0..100')
|
|
268
|
+
opts['opacity'] = float(opacity)
|
|
269
|
+
if opts['mask'] is not None and (not isinstance(opts['mask'], str) or not opts['mask']):
|
|
270
|
+
raise ValueError('mask must be a channel name')
|
|
271
|
+
if opts['mask'] is not None and len(opts['mask']) > FILL_MAX_STRING_LEN:
|
|
272
|
+
raise ValueError('mask must be at most %d characters' % FILL_MAX_STRING_LEN)
|
|
273
|
+
if op == 'solid':
|
|
274
|
+
opts['color'] = parse_hex_color('color', args.get('color'))
|
|
275
|
+
for k in ('stops', 'start', 'end', 'center', 'radius'):
|
|
276
|
+
if args.get(k) is not None:
|
|
277
|
+
raise ValueError('%s does not apply to op solid' % k)
|
|
278
|
+
return opts
|
|
279
|
+
if args.get('color') is not None:
|
|
280
|
+
raise ValueError('color applies to op solid only; a gradient takes stops')
|
|
281
|
+
opts['stops'] = validate_fill_stops(args.get('stops'))
|
|
282
|
+
if op == 'linear_gradient':
|
|
283
|
+
for k in ('center', 'radius'):
|
|
284
|
+
if args.get(k) is not None:
|
|
285
|
+
raise ValueError('%s does not apply to op linear_gradient' % k)
|
|
286
|
+
opts['start'] = _fill_point('start', args.get('start'), (width / 2.0, 0.0))
|
|
287
|
+
opts['end'] = _fill_point('end', args.get('end'), (width / 2.0, float(height)))
|
|
288
|
+
if math.hypot(opts['end'][0] - opts['start'][0], opts['end'][1] - opts['start'][1]) < 1:
|
|
289
|
+
raise ValueError('start and end must be at least 1 pixel apart')
|
|
290
|
+
else:
|
|
291
|
+
for k in ('start', 'end'):
|
|
292
|
+
if args.get(k) is not None:
|
|
293
|
+
raise ValueError('%s does not apply to op radial_gradient' % k)
|
|
294
|
+
opts['center'] = _fill_point('center', args.get('center'), (width / 2.0, height / 2.0))
|
|
295
|
+
radius = args.get('radius')
|
|
296
|
+
if radius is None:
|
|
297
|
+
radius = math.hypot(width, height) / 2.0
|
|
298
|
+
if not _is_number(radius) or not 1 <= radius <= FILL_MAX_COORD_PX:
|
|
299
|
+
raise ValueError('radius must be within 1..%d pixels' % FILL_MAX_COORD_PX)
|
|
300
|
+
opts['radius'] = float(radius)
|
|
301
|
+
return opts
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---- masked measurement (gimp_get_histogram `mask`) ---------------------------------------------
|
|
305
|
+
|
|
306
|
+
MASK_SELECTED_MIN = 128 # a mask pixel counts as selected at 50% or more (>= 128 of 255)
|
|
307
|
+
_MASK_FLAG_TABLE = bytes(1 if v >= MASK_SELECTED_MIN else 0 for v in range(256))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def mask_flags(mask_data):
|
|
311
|
+
"""One byte per mask pixel: 1 where the pixel is selected (>= 50%), else 0."""
|
|
312
|
+
return bytes(mask_data).translate(_MASK_FLAG_TABLE)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# A hard-edged mask scaled down keeps a ring of partial-coverage pixels around its edge; a thin
|
|
316
|
+
# one is ALL ring (a 1 px line reads as a pale smear whose pixels blend with their neighbors).
|
|
317
|
+
# Past this share of edge pixels among the touched ones the scaled copy is not trusted.
|
|
318
|
+
MASK_EDGE_FRACTION_MAX = 0.25
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def mask_is_proxy_reliable(mask_data):
|
|
322
|
+
"""Whether a downscaled copy's mask is solid enough to measure. Not when it touches nothing
|
|
323
|
+
(it may have vanished in the scale-down; the full-resolution pass tells that from a mask that
|
|
324
|
+
is truly empty) or when its partial-coverage pixels (not 0, not 255) are more than
|
|
325
|
+
MASK_EDGE_FRACTION_MAX of all the pixels it touches."""
|
|
326
|
+
data = bytes(mask_data)
|
|
327
|
+
touched = len(data) - data.count(0)
|
|
328
|
+
if touched == 0:
|
|
329
|
+
return False
|
|
330
|
+
edge = touched - data.count(255)
|
|
331
|
+
return edge <= MASK_EDGE_FRACTION_MAX * touched
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def select_masked(data, flags):
|
|
335
|
+
"""The bytes of `data` (one value per pixel) at the positions `flags` marks 1."""
|
|
336
|
+
return bytes(itertools.compress(data, flags))
|
|
337
|
+
|
|
338
|
+
|
|
162
339
|
# gimp_layer op=create's fill options -- verified live that `Gimp.FillType.TRANSPARENT` exists
|
|
163
340
|
# and that a freshly created layer's content is otherwise undefined, so `create` always fills
|
|
164
341
|
# explicitly rather than trusting whatever `Gimp.Layer.new` leaves behind.
|
|
@@ -175,12 +352,19 @@ IMAGE_MODES = ('rgb', 'grayscale')
|
|
|
175
352
|
EXPORT_FORMATS = {'.jpg': 'jpeg', '.jpeg': 'jpeg', '.png': 'png', '.webp': 'webp',
|
|
176
353
|
'.tif': 'tiff', '.tiff': 'tiff'}
|
|
177
354
|
|
|
355
|
+
# Formats `_export_layered` (ops.py) writes with the layer stack kept, instead of a flattened copy.
|
|
356
|
+
LAYERED_EXPORT_FORMATS = {'.psd': 'psd'}
|
|
357
|
+
|
|
178
358
|
BIT_DEPTHS = (8, 16)
|
|
179
359
|
|
|
180
360
|
# A DoS floor for `resize`: bounds a single call's memory/time cost regardless of how large the
|
|
181
361
|
# SOURCE document already is (crop/rotate/flip only ever shrink-or-preserve the existing canvas,
|
|
182
362
|
# so they don't need this; resize is the one op that can grow it arbitrarily from a tiny source).
|
|
183
363
|
MAX_RESIZE_SIDE_PX = 30_000
|
|
364
|
+
# gimp_fill's geometry bounds: a gradient axis may sit well off-canvas but not at 1e308, where
|
|
365
|
+
# GIMP's float arithmetic overflows. Also the longest layer name / mask channel name it takes.
|
|
366
|
+
FILL_MAX_COORD_PX = 10 * MAX_RESIZE_SIDE_PX
|
|
367
|
+
FILL_MAX_STRING_LEN = 255
|
|
184
368
|
_DEFAULT_MAX_RESIZE_MEGAPIXELS = 250
|
|
185
369
|
|
|
186
370
|
|
|
@@ -204,6 +388,17 @@ MAX_RESIZE_MEGAPIXELS = megapixel_cap_from_env(os.environ.get('EM_GIMP_MAX_MEGAP
|
|
|
204
388
|
|
|
205
389
|
MAX_FEATHER_PX = 1000
|
|
206
390
|
|
|
391
|
+
# Hardening a mask read as "Y' u8": 50% and above becomes fully selected (255), the rest 0. The
|
|
392
|
+
# same 128 cut `_channel_coverage` counts as selected. Applied with `bytes.translate`, a C pass.
|
|
393
|
+
HARDEN_TABLE = bytes(255 if v >= 128 else 0 for v in range(256))
|
|
394
|
+
# Rows per band when hardening, so a very large mask is never held twice in memory at once.
|
|
395
|
+
HARDEN_BAND_ROWS = 512
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def harden_mask_bytes(data):
|
|
399
|
+
"""`data` ("Y' u8" mask bytes) with every value hardened to 0 or 255 at the 50% cut."""
|
|
400
|
+
return bytes(data).translate(HARDEN_TABLE)
|
|
401
|
+
|
|
207
402
|
# gimp_modify_selection's expand/contract/border radius cap -- much lower than MAX_FEATHER_PX.
|
|
208
403
|
# `Gimp.Selection.grow`/`shrink`/`border` are morphological (structuring-element) operations whose
|
|
209
404
|
# cost scales with the radius, unlike `feather` (a GEGL blur, flat ~0.5s regardless of radius on a
|
|
@@ -331,6 +526,24 @@ def require(args, name):
|
|
|
331
526
|
return args[name]
|
|
332
527
|
|
|
333
528
|
|
|
529
|
+
GINT32_MAX = 2147483647
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def gimp_id(value):
|
|
533
|
+
"""`value` as an image or item id GIMP's gint32 `get_by_id` can take, or None when it can't be
|
|
534
|
+
one (a bool, not a whole number, below 1, or past gint32). The caller then answers "no ... with
|
|
535
|
+
id N" instead of letting PyGObject raise a raw OverflowError on the lookup."""
|
|
536
|
+
if isinstance(value, bool):
|
|
537
|
+
return None
|
|
538
|
+
try:
|
|
539
|
+
number = int(value)
|
|
540
|
+
except (TypeError, ValueError, OverflowError):
|
|
541
|
+
return None
|
|
542
|
+
if isinstance(value, float) and value != number:
|
|
543
|
+
return None
|
|
544
|
+
return number if 1 <= number <= GINT32_MAX else None
|
|
545
|
+
|
|
546
|
+
|
|
334
547
|
def require_bool(args, name):
|
|
335
548
|
"""Like `require`, but also rejects anything that isn't a real JSON boolean -- `bool("false")`
|
|
336
549
|
is `True` in Python (any non-empty string is truthy), so a caller that sends the STRING
|
|
@@ -916,10 +1129,18 @@ def build_saturation_params(args, defaults):
|
|
|
916
1129
|
}
|
|
917
1130
|
|
|
918
1131
|
|
|
1132
|
+
# gegl:vibrance's own -100..100 is far stronger than the Photoshop scale callers expect:
|
|
1133
|
+
# on a marina photo its +15 sent 38% of pixels to red 0, where Photoshop's +15 added about 0.014
|
|
1134
|
+
# mean saturation and clipped nothing. Dividing by this factor lines the two up (Photoshop +15 and
|
|
1135
|
+
# +50 match gegl about 0.5 and 2.0 on that photo), so the field keeps a Photoshop-like -100..100.
|
|
1136
|
+
VIBRANCE_GEGL_DIVISOR = 25.0
|
|
1137
|
+
|
|
1138
|
+
|
|
919
1139
|
def build_vibrance_params(args, defaults):
|
|
920
1140
|
return {
|
|
921
1141
|
'vibrance': resolve_field(
|
|
922
|
-
args, 'vibrance', defaults, 'vibrance',
|
|
1142
|
+
args, 'vibrance', defaults, 'vibrance',
|
|
1143
|
+
lambda v: validate_range('vibrance', v, -100.0, 100.0) / VIBRANCE_GEGL_DIVISOR,
|
|
923
1144
|
),
|
|
924
1145
|
'saturation': resolve_field(
|
|
925
1146
|
args, 'saturation', defaults, 'saturation', lambda v: validate_range('saturation', v, 0.0, 10.0)
|
|
@@ -1033,13 +1254,35 @@ EFFECT_OPERATIONS = {
|
|
|
1033
1254
|
'lens_blur': 'gegl:focus-blur',
|
|
1034
1255
|
'add_noise': 'gegl:noise-rgb',
|
|
1035
1256
|
'drop_shadow': 'gegl:dropshadow',
|
|
1257
|
+
# outline and outer_glow are also gegl:dropshadow (zero offset, grown by grow-radius): one
|
|
1258
|
+
# operation, three types -- the ledger's `type` tells them apart, see OPERATION_TYPES below.
|
|
1259
|
+
'outline': 'gegl:dropshadow',
|
|
1260
|
+
'outer_glow': 'gegl:dropshadow',
|
|
1261
|
+
'surface_blur': 'gegl:bilateral-filter',
|
|
1036
1262
|
}
|
|
1037
1263
|
|
|
1038
1264
|
# The inverse of ADJUST_OPERATIONS and EFFECT_OPERATIONS together, for a ledger record written
|
|
1039
1265
|
# without a `type` field -- one shared map, since `op_list_filters` looks a record's operation up
|
|
1040
|
-
# here regardless of which tool (gimp_add_adjustment or gimp_add_effect) created it.
|
|
1266
|
+
# here regardless of which tool (gimp_add_adjustment or gimp_add_effect) created it. Where several
|
|
1267
|
+
# types share one operation (gegl:dropshadow), the first listed wins: a record with no `type` is
|
|
1268
|
+
# from before outline/outer_glow existed, so it can only be a drop_shadow. THAT DEPENDS ON
|
|
1269
|
+
# INSERTION ORDER: `setdefault` keeps whichever type reached the map first, so 'drop_shadow' must
|
|
1270
|
+
# stay listed above 'outline' and 'outer_glow' in EFFECT_OPERATIONS (test_lib.py pins it).
|
|
1041
1271
|
OPERATION_TYPES = {operation: type_ for type_, operation in ADJUST_OPERATIONS.items()}
|
|
1042
|
-
|
|
1272
|
+
for _type, _operation in EFFECT_OPERATIONS.items():
|
|
1273
|
+
OPERATION_TYPES.setdefault(_operation, _type)
|
|
1274
|
+
del _type, _operation
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def record_type(rec):
|
|
1278
|
+
"""The tool `type` of a ledger filter record: its stored `type`, or -- for a record written
|
|
1279
|
+
before `type` was stored -- the type its operation implies (`OPERATION_TYPES`). None for an
|
|
1280
|
+
operation neither tool owns. The one place both the filter listing and the effect re-edit
|
|
1281
|
+
type guard resolve a record's type, so a typeless legacy gegl:dropshadow reads as drop_shadow
|
|
1282
|
+
in both."""
|
|
1283
|
+
if not isinstance(rec, dict):
|
|
1284
|
+
return None
|
|
1285
|
+
return rec.get('type') or OPERATION_TYPES.get(rec.get('operation'))
|
|
1043
1286
|
|
|
1044
1287
|
# Every validate_range/validate_int_range call below uses `val`, not `v`, as its lambda's
|
|
1045
1288
|
# parameter name -- deliberately, so it is NOT matched by gimp-adjustment-tools.test.ts's own
|
|
@@ -1194,6 +1437,79 @@ def build_drop_shadow_params(args, defaults):
|
|
|
1194
1437
|
}
|
|
1195
1438
|
|
|
1196
1439
|
|
|
1440
|
+
def build_outline_params(args, defaults):
|
|
1441
|
+
# A solid ring around the layer's alpha: gegl:dropshadow with no offset and no blur, grown by
|
|
1442
|
+
# `width` px (grow-radius, a circle) at full opacity. Only `grow-radius` and `color` are
|
|
1443
|
+
# recorded; `_set_drop_shadow` fills the rest in (offset 0, blur 0, opacity 1). The shadow is
|
|
1444
|
+
# drawn under the layer's own pixels, so the interior is untouched.
|
|
1445
|
+
#
|
|
1446
|
+
# GEGL allows grow-radius up to 100, but its cost grows linearly with the radius times the
|
|
1447
|
+
# pixel count. Measured live (GIMP 3.2.6, a 6000x4000 = 24 MP layer, full export incl. a ~1 s
|
|
1448
|
+
# baseline export with no filter): width 6 -> 4 s, 50 -> 18.5 s, 100 -> 39.5 s; outer_glow at
|
|
1449
|
+
# radius 250 with spread 50 -> 17.7 s, spread 100 -> 30.6 s. Width and spread stop at 50, the
|
|
1450
|
+
# same ~20 s worst case on 24 MP as SURFACE_BLUR_MAX_RADIUS.
|
|
1451
|
+
return {
|
|
1452
|
+
'grow-radius': resolve_field(
|
|
1453
|
+
args, 'width', defaults, 'grow-radius', lambda val: validate_range('width', val, 1.0, 50.0)
|
|
1454
|
+
),
|
|
1455
|
+
'color': resolve_field(
|
|
1456
|
+
args, 'color', defaults, 'color', lambda val: validate_hex_color('color', val)
|
|
1457
|
+
),
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def build_outer_glow_params(args, defaults):
|
|
1462
|
+
# The same gegl:dropshadow, centered (no offset, filled in by `_set_drop_shadow`): `spread`
|
|
1463
|
+
# grows the glow's solid core, `radius` blurs it.
|
|
1464
|
+
return {
|
|
1465
|
+
'radius': resolve_field(
|
|
1466
|
+
args, 'radius', defaults, 'radius', lambda val: validate_range('radius', val, 1.0, 250.0)
|
|
1467
|
+
),
|
|
1468
|
+
'grow-radius': resolve_field(
|
|
1469
|
+
args, 'spread', defaults, 'grow-radius',
|
|
1470
|
+
lambda val: validate_range('spread', val, 0.0, 50.0),
|
|
1471
|
+
),
|
|
1472
|
+
'opacity': resolve_field(
|
|
1473
|
+
args, 'opacity', defaults, 'opacity', lambda val: validate_range('opacity', val, 0.0, 1.0)
|
|
1474
|
+
),
|
|
1475
|
+
'color': resolve_field(
|
|
1476
|
+
args, 'color', defaults, 'color', lambda val: validate_hex_color('color', val)
|
|
1477
|
+
),
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
# gegl:bilateral-filter's `edge-preservation` runs 0..100 (default 8), where HIGHER keeps more of
|
|
1482
|
+
# the original, edges and noise alike -- probed live on a noisy patch beside a hard edge: 0 smooths
|
|
1483
|
+
# hard enough to blur the edge itself, 8 flattens the noise (stddev 17 -> 2) with the edge intact,
|
|
1484
|
+
# 100 barely smooths. The tool exposes it as 0..1 (this factor times the tool value).
|
|
1485
|
+
# gegl:mean-curvature-blur and gegl:snn-mean were not needed: GIMP attaches bilateral-filter fine.
|
|
1486
|
+
BILATERAL_EDGE_SCALE = 100.0
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
# gegl:bilateral-filter's cost grows with the SQUARE of the radius times the pixel count. Measured
|
|
1490
|
+
# live (GIMP 3.2.6, this dev box, a noisy 6016x4000 = 24 MP layer; full export incl. a ~2 s
|
|
1491
|
+
# baseline export with no filter): radius 5 -> 12 s, 6 -> 17 s, 7 -> 23 s, 8 -> 28 s, 10 -> 41 s;
|
|
1492
|
+
# radius 25 and 50 were not finished inside a 400 s timeout (extrapolating the r^2 trend: ~4 and
|
|
1493
|
+
# ~16 minutes). The tool's ~20 s worst case on 24 MP therefore stops at radius 6, and the remote
|
|
1494
|
+
# box is slower still. A smaller image has headroom (cost scales with pixels) but the cap is a flat
|
|
1495
|
+
# per-filter number: it does not look at the layer. TS schema, EFFECT_TRANSFORM_BOUNDS and this
|
|
1496
|
+
# builder share it.
|
|
1497
|
+
SURFACE_BLUR_MAX_RADIUS = 6.0
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def build_surface_blur_params(args, defaults):
|
|
1501
|
+
return {
|
|
1502
|
+
'blur-radius': resolve_field(
|
|
1503
|
+
args, 'radius', defaults, 'blur-radius',
|
|
1504
|
+
lambda val: validate_range('radius', val, 1.0, SURFACE_BLUR_MAX_RADIUS),
|
|
1505
|
+
),
|
|
1506
|
+
'edge-preservation': resolve_field(
|
|
1507
|
+
args, 'edge_preservation', defaults, 'edge-preservation',
|
|
1508
|
+
lambda val: validate_range('edge_preservation', val, 0.0, 1.0),
|
|
1509
|
+
),
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
|
|
1197
1513
|
EFFECT_PARAM_BUILDERS = {
|
|
1198
1514
|
'vignette': build_vignette_params,
|
|
1199
1515
|
'black_white': build_black_white_params,
|
|
@@ -1201,6 +1517,9 @@ EFFECT_PARAM_BUILDERS = {
|
|
|
1201
1517
|
'lens_blur': build_lens_blur_params,
|
|
1202
1518
|
'add_noise': build_add_noise_params,
|
|
1203
1519
|
'drop_shadow': build_drop_shadow_params,
|
|
1520
|
+
'outline': build_outline_params,
|
|
1521
|
+
'outer_glow': build_outer_glow_params,
|
|
1522
|
+
'surface_blur': build_surface_blur_params,
|
|
1204
1523
|
}
|
|
1205
1524
|
|
|
1206
1525
|
# Creation-time defaults, already in GEGL-property units -- probed live via `describe_operation`
|
|
@@ -1214,6 +1533,9 @@ EFFECT_CREATE_DEFAULTS = {
|
|
|
1214
1533
|
'lens_blur': {'blur-radius': 25.0, 'highlight-factor': 0.0},
|
|
1215
1534
|
'add_noise': {'red': 0.2, 'green': 0.2, 'blue': 0.2, 'alpha': 0.0, 'seed': 0},
|
|
1216
1535
|
'drop_shadow': {'x': 20.0, 'y': 20.0, 'radius': 10.0, 'opacity': 0.5},
|
|
1536
|
+
'outline': {'grow-radius': 4.0, 'color': '#000000'},
|
|
1537
|
+
'outer_glow': {'radius': 20.0, 'grow-radius': 4.0, 'opacity': 0.75, 'color': '#ffffff'},
|
|
1538
|
+
'surface_blur': {'blur-radius': 4.0, 'edge-preservation': 0.08},
|
|
1217
1539
|
}
|
|
1218
1540
|
|
|
1219
1541
|
|
|
@@ -1270,7 +1592,7 @@ USER_FIELDS = {
|
|
|
1270
1592
|
),
|
|
1271
1593
|
'saturation': (('scale', 'scale', _same),),
|
|
1272
1594
|
'vibrance': (
|
|
1273
|
-
('vibrance', 'vibrance',
|
|
1595
|
+
('vibrance', 'vibrance', _scaled(VIBRANCE_GEGL_DIVISOR)),
|
|
1274
1596
|
('saturation', 'saturation', _same),
|
|
1275
1597
|
),
|
|
1276
1598
|
'sharpen': (
|
|
@@ -1313,6 +1635,20 @@ USER_FIELDS = {
|
|
|
1313
1635
|
('radius', 'radius', _same),
|
|
1314
1636
|
('opacity', 'opacity', _same),
|
|
1315
1637
|
),
|
|
1638
|
+
'outline': (
|
|
1639
|
+
('width', 'grow-radius', _same),
|
|
1640
|
+
('color', 'color', _same),
|
|
1641
|
+
),
|
|
1642
|
+
'outer_glow': (
|
|
1643
|
+
('spread', 'grow-radius', _same),
|
|
1644
|
+
('radius', 'radius', _same),
|
|
1645
|
+
('opacity', 'opacity', _same),
|
|
1646
|
+
('color', 'color', _same),
|
|
1647
|
+
),
|
|
1648
|
+
'surface_blur': (
|
|
1649
|
+
('radius', 'blur-radius', _same),
|
|
1650
|
+
('edge_preservation', 'edge-preservation', _same),
|
|
1651
|
+
),
|
|
1316
1652
|
}
|
|
1317
1653
|
|
|
1318
1654
|
CURVES_USER_FIELDS = ('channel', 'points')
|
|
@@ -1399,7 +1735,6 @@ def json_safe(value):
|
|
|
1399
1735
|
# operations this engine actually uses.
|
|
1400
1736
|
ALLOWED_DESCRIBE_OPERATIONS = frozenset(ADJUST_OPERATIONS.values()) | frozenset(EFFECT_OPERATIONS.values())
|
|
1401
1737
|
|
|
1402
|
-
|
|
1403
1738
|
# ---- geometry-op masked-filter detection (pure logic; ops.py supplies the live GIMP state) -----
|
|
1404
1739
|
|
|
1405
1740
|
|
|
@@ -1632,7 +1967,7 @@ def flip_effect_params(operation, params, orientation):
|
|
|
1632
1967
|
else:
|
|
1633
1968
|
params['angle'] = _round_transform_value(_wrap_angle_deg(-params['angle']))
|
|
1634
1969
|
return params
|
|
1635
|
-
if operation == 'gegl:dropshadow':
|
|
1970
|
+
if operation == 'gegl:dropshadow' and 'x' in params: # outline / outer_glow have no offset
|
|
1636
1971
|
params = dict(params)
|
|
1637
1972
|
if orientation == 'horizontal':
|
|
1638
1973
|
params['x'] = _round_transform_value(-params['x'])
|
|
@@ -1669,7 +2004,7 @@ def rotate_effect_params(operation, params, degrees, layer_width, layer_height):
|
|
|
1669
2004
|
canonical_degrees = _right_angle_k(degrees) * 90.0
|
|
1670
2005
|
params['angle'] = _round_transform_value(_wrap_angle_deg(params['angle'] + canonical_degrees))
|
|
1671
2006
|
return params
|
|
1672
|
-
if operation == 'gegl:dropshadow':
|
|
2007
|
+
if operation == 'gegl:dropshadow' and 'x' in params:
|
|
1673
2008
|
params = dict(params)
|
|
1674
2009
|
cos_t, sin_t = _right_angle_cos_sin(degrees)
|
|
1675
2010
|
dx, dy = params['x'], params['y']
|
|
@@ -1719,9 +2054,18 @@ def resize_effect_params(operation, params, scale_x, scale_y):
|
|
|
1719
2054
|
if operation == 'gegl:dropshadow':
|
|
1720
2055
|
params = dict(params)
|
|
1721
2056
|
isotropic_scale = math.sqrt(scale_x * scale_y)
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
2057
|
+
# outline has only grow-radius, outer_glow lacks the offset: scale what is recorded.
|
|
2058
|
+
for prop, factor in (
|
|
2059
|
+
('x', scale_x), ('y', scale_y), ('radius', isotropic_scale),
|
|
2060
|
+
('grow-radius', isotropic_scale),
|
|
2061
|
+
):
|
|
2062
|
+
if prop in params:
|
|
2063
|
+
params[prop] = _round_transform_value(params[prop] * factor)
|
|
2064
|
+
return params
|
|
2065
|
+
if operation == 'gegl:bilateral-filter':
|
|
2066
|
+
params = dict(params)
|
|
2067
|
+
isotropic_scale = math.sqrt(scale_x * scale_y)
|
|
2068
|
+
params['blur-radius'] = _round_transform_value(params['blur-radius'] * isotropic_scale)
|
|
1725
2069
|
return params
|
|
1726
2070
|
return params
|
|
1727
2071
|
|
|
@@ -1735,7 +2079,12 @@ EFFECT_TRANSFORM_BOUNDS = {
|
|
|
1735
2079
|
'gegl:vignette': {'x': (0.0, 1.0), 'y': (0.0, 1.0)},
|
|
1736
2080
|
'gegl:motion-blur-linear': {'length': (0.0, 1000.0), 'angle': (-180.0, 180.0)},
|
|
1737
2081
|
'gegl:focus-blur': {'blur-radius': (0.0, 150.0)},
|
|
1738
|
-
'gegl:dropshadow': {
|
|
2082
|
+
'gegl:dropshadow': {
|
|
2083
|
+
'x': (-500.0, 500.0), 'y': (-500.0, 500.0), 'radius': (0.0, 1500.0),
|
|
2084
|
+
# outline's width and outer_glow's spread share a cap (see build_outline_params).
|
|
2085
|
+
'grow-radius': (0.0, 50.0),
|
|
2086
|
+
},
|
|
2087
|
+
'gegl:bilateral-filter': {'blur-radius': (0.0, SURFACE_BLUR_MAX_RADIUS)},
|
|
1739
2088
|
}
|
|
1740
2089
|
|
|
1741
2090
|
# The user-facing (gimp_add_effect) field name for each EFFECT_TRANSFORM_BOUNDS entry -- so a
|
|
@@ -1752,10 +2101,44 @@ EFFECT_TRANSFORM_FIELD_NAMES = {
|
|
|
1752
2101
|
('gegl:dropshadow', 'x'): 'offset_x',
|
|
1753
2102
|
('gegl:dropshadow', 'y'): 'offset_y',
|
|
1754
2103
|
('gegl:dropshadow', 'radius'): 'radius',
|
|
2104
|
+
('gegl:dropshadow', 'grow-radius'): 'width/spread',
|
|
2105
|
+
('gegl:bilateral-filter', 'blur-radius'): 'radius',
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
# Per-type floors a re-edit enforces above EFFECT_TRANSFORM_BOUNDS' own lower bound (those are
|
|
2110
|
+
# per operation, and gegl:dropshadow serves three types): shrinking resize would otherwise store a
|
|
2111
|
+
# value a re-edit of the same filter refuses. A resize that bottoms out is clamped to the floor
|
|
2112
|
+
# (the 1 px minimum the effect itself allows) instead of refused: the filter is still valid, only
|
|
2113
|
+
# no thinner than the tool can express.
|
|
2114
|
+
EFFECT_TRANSFORM_FLOORS = {
|
|
2115
|
+
('outline', 'grow-radius'): 1.0,
|
|
2116
|
+
('outer_glow', 'radius'): 1.0,
|
|
2117
|
+
('surface_blur', 'blur-radius'): 1.0,
|
|
1755
2118
|
}
|
|
1756
2119
|
|
|
1757
2120
|
|
|
1758
|
-
|
|
2121
|
+
# Per-type ceilings a re-edit enforces below EFFECT_TRANSFORM_BOUNDS' own upper bound: outer_glow
|
|
2122
|
+
# shares gegl:dropshadow (radius up to 1500) but its own builder caps radius at 250, so a growing
|
|
2123
|
+
# resize would otherwise store a radius a re-edit of the same glow refuses. Unlike a floor, a
|
|
2124
|
+
# value past a ceiling is refused, the same as any other out-of-range transform.
|
|
2125
|
+
EFFECT_TRANSFORM_CEILINGS = {
|
|
2126
|
+
('outer_glow', 'radius'): 250.0,
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
def clamp_effect_transform(type_, new_params):
|
|
2131
|
+
"""`new_params` with every EFFECT_TRANSFORM_FLOORS entry for `type_` applied (the same dict
|
|
2132
|
+
when nothing needed clamping, so callers' `==` change check stays honest)."""
|
|
2133
|
+
clamped = None
|
|
2134
|
+
for (floor_type, prop), floor in EFFECT_TRANSFORM_FLOORS.items():
|
|
2135
|
+
if floor_type == type_ and prop in new_params and new_params[prop] < floor:
|
|
2136
|
+
clamped = clamped if clamped is not None else dict(new_params)
|
|
2137
|
+
clamped[prop] = floor
|
|
2138
|
+
return new_params if clamped is None else clamped
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def validate_effect_transform(op_name, operation, filter_name, new_params, type_=None):
|
|
1759
2142
|
"""Refuse `op_name` (the geometry op about to run) outright if any of `new_params` -- already
|
|
1760
2143
|
computed for `operation` by flip_effect_params/rotate_effect_params/resize_effect_params --
|
|
1761
2144
|
would leave the range its own create/re-edit path enforces. Called BEFORE the geometry
|
|
@@ -1763,10 +2146,12 @@ def validate_effect_transform(op_name, operation, filter_name, new_params):
|
|
|
1763
2146
|
range never gets a chance to silently clamp via GObject's own property setter instead of a
|
|
1764
2147
|
clear, actionable refusal -- and the geometry op never partially applies. Names the filter
|
|
1765
2148
|
(`filter_name`, the same way `_refuse_if_masked_filters` does) and the field in gimp_add_
|
|
1766
|
-
effect's OWN terms (`EFFECT_TRANSFORM_FIELD_NAMES`), not the raw GEGL property name.
|
|
2149
|
+
effect's OWN terms (`EFFECT_TRANSFORM_FIELD_NAMES`), not the raw GEGL property name. `type_`
|
|
2150
|
+
(the record's effect type) adds that type's EFFECT_TRANSFORM_CEILINGS."""
|
|
1767
2151
|
for prop, (lo, hi) in EFFECT_TRANSFORM_BOUNDS.get(operation, {}).items():
|
|
1768
2152
|
if prop not in new_params:
|
|
1769
2153
|
continue
|
|
2154
|
+
hi = min(hi, EFFECT_TRANSFORM_CEILINGS.get((type_, prop), hi))
|
|
1770
2155
|
value = new_params[prop]
|
|
1771
2156
|
if not lo <= value <= hi:
|
|
1772
2157
|
field = EFFECT_TRANSFORM_FIELD_NAMES.get((operation, prop), prop)
|
|
@@ -2086,10 +2471,10 @@ def parse_ledger(raw):
|
|
|
2086
2471
|
parasite bytes ARE the filters map directly (no wrapper). Treated as
|
|
2087
2472
|
v1-compatible so files written before the ledger was versioned keep
|
|
2088
2473
|
working.
|
|
2089
|
-
- Versioned + "v"
|
|
2474
|
+
- Versioned + "v" in 1..LEDGER_VERSION: "filters" is the map; any OTHER
|
|
2090
2475
|
top-level key is returned as `unknown_top_level` so a future writer's
|
|
2091
2476
|
extra fields round-trip untouched through serialize_ledger.
|
|
2092
|
-
- Versioned + "v"
|
|
2477
|
+
- Versioned + "v" outside that range (in practice: greater, from a future
|
|
2093
2478
|
writer): every filter is treated as absent — the caller falls back to
|
|
2094
2479
|
readback for all of them, per the documented contract that we never
|
|
2095
2480
|
guess at a schema we don't recognize. `unknown_top_level` still
|
|
@@ -2125,7 +2510,7 @@ def parse_ledger(raw):
|
|
|
2125
2510
|
return _validate_filters(raw_filters), {}, raw_filters
|
|
2126
2511
|
unknown = {k: v for k, v in doc.items() if k not in ('v', 'writer', 'filters')}
|
|
2127
2512
|
raw_filters = dict(doc['filters'])
|
|
2128
|
-
if doc['v']
|
|
2513
|
+
if not 1 <= doc['v'] <= LEDGER_VERSION:
|
|
2129
2514
|
return {}, unknown, raw_filters
|
|
2130
2515
|
return _validate_filters(raw_filters), unknown, raw_filters
|
|
2131
2516
|
|
|
@@ -2173,12 +2558,19 @@ def ledger_is_newer_version(raw):
|
|
|
2173
2558
|
return doc['v'] > LEDGER_VERSION
|
|
2174
2559
|
|
|
2175
2560
|
|
|
2561
|
+
def ledger_write_version(filters_by_name):
|
|
2562
|
+
"""The lowest ledger version that holds every record in `filters_by_name` (see LEDGER_VERSION)."""
|
|
2563
|
+
if any(record_type(rec) in LEDGER_V2_TYPES for rec in filters_by_name.values()):
|
|
2564
|
+
return 2
|
|
2565
|
+
return 1
|
|
2566
|
+
|
|
2567
|
+
|
|
2176
2568
|
def serialize_ledger(filters_by_name, unknown_top_level=None):
|
|
2177
|
-
"""Serialize filters_by_name as
|
|
2569
|
+
"""Serialize filters_by_name as a ledger at `ledger_write_version`, preserving any
|
|
2178
2570
|
unknown_top_level keys carried over from parse_ledger byte-for-byte
|
|
2179
2571
|
(as JSON values -- not literal bytes, but nothing under them is rewritten)."""
|
|
2180
2572
|
doc = dict(unknown_top_level or {})
|
|
2181
|
-
doc['v'] =
|
|
2573
|
+
doc['v'] = ledger_write_version(filters_by_name)
|
|
2182
2574
|
doc['writer'] = LEDGER_WRITER
|
|
2183
2575
|
doc['filters'] = filters_by_name
|
|
2184
2576
|
return json.dumps(doc)
|
|
@@ -2268,13 +2660,16 @@ def list_requests(rpc_dir):
|
|
|
2268
2660
|
return [name for _req_id, name in numbered]
|
|
2269
2661
|
|
|
2270
2662
|
|
|
2271
|
-
def read_request(req_path, attempts=
|
|
2663
|
+
def read_request(req_path, attempts=8, delay_s=0.02, max_delay_s=0.25, on_retry=None):
|
|
2272
2664
|
"""Read + parse a request file. Retries ONLY on OSError (e.g. a
|
|
2273
2665
|
PermissionError from anti-virus or a file indexer holding a brief lock on
|
|
2274
2666
|
a just-renamed file on Windows) -- a JSONDecodeError means the content
|
|
2275
2667
|
itself is bad and is raised immediately; retrying that would just mask a
|
|
2276
2668
|
genuinely malformed file rather than a transient lock.
|
|
2277
2669
|
|
|
2670
|
+
The wait doubles from `delay_s` up to `max_delay_s`, about 1s in all at the defaults, since a Windows
|
|
2671
|
+
file lock can outlast 100ms.
|
|
2672
|
+
|
|
2278
2673
|
`on_retry(exc, attempt)`, if given, is called right before each sleep --
|
|
2279
2674
|
the bridge logs the exception type through it; tests can assert on it
|
|
2280
2675
|
without capturing stderr."""
|
|
@@ -2287,9 +2682,11 @@ def read_request(req_path, attempts=5, delay_s=0.02, on_retry=None):
|
|
|
2287
2682
|
raise
|
|
2288
2683
|
except OSError as e:
|
|
2289
2684
|
last_err = e
|
|
2685
|
+
if attempt == attempts - 1:
|
|
2686
|
+
break
|
|
2290
2687
|
if on_retry:
|
|
2291
2688
|
on_retry(e, attempt)
|
|
2292
|
-
time.sleep(delay_s)
|
|
2689
|
+
time.sleep(min(delay_s * (2 ** attempt), max_delay_s))
|
|
2293
2690
|
raise last_err
|
|
2294
2691
|
|
|
2295
2692
|
|
|
@@ -2568,6 +2965,69 @@ def metadata_strip_settings(fmt, prop_names):
|
|
|
2568
2965
|
return settings
|
|
2569
2966
|
|
|
2570
2967
|
|
|
2968
|
+
def profile_is_editors_own(texts):
|
|
2969
|
+
"""Whether a color profile is the editor's built-in one, from its description, manufacturer,
|
|
2970
|
+
model and copyright strings (None for an image that has no profile of its own, i.e. the
|
|
2971
|
+
built-in one). Exports leave such a profile out: the file is then plain untagged sRGB, which
|
|
2972
|
+
every viewer assumes anyway, and the profile's name ("GIMP built-in sRGB") would otherwise ride
|
|
2973
|
+
along in every file. A camera's or phone's own profile is kept, unless the editor itself
|
|
2974
|
+
rewrote it (a precision or mode conversion marks the variant it makes with its own name);
|
|
2975
|
+
such a file is exported untagged too."""
|
|
2976
|
+
if texts is None:
|
|
2977
|
+
return True
|
|
2978
|
+
return any('gimp' in (t or '').lower() for t in texts)
|
|
2979
|
+
|
|
2980
|
+
|
|
2981
|
+
PSD_ICC_RESOURCE = 1039
|
|
2982
|
+
|
|
2983
|
+
|
|
2984
|
+
def psd_resources_without(section, resource_id):
|
|
2985
|
+
"""A PSD image-resource section (the bytes after its 4-byte length) with every block of
|
|
2986
|
+
`resource_id` removed. Each block: '8BIM', a 2-byte id, a Pascal name padded to even length,
|
|
2987
|
+
a 4-byte size, then the data padded to even length. Raises ValueError on a malformed section."""
|
|
2988
|
+
out = bytearray()
|
|
2989
|
+
at = 0
|
|
2990
|
+
while at < len(section):
|
|
2991
|
+
start = at
|
|
2992
|
+
if section[at:at + 4] != b'8BIM' or at + 7 > len(section):
|
|
2993
|
+
raise ValueError('malformed PSD image resource at %d' % at)
|
|
2994
|
+
rid = int.from_bytes(section[at + 4:at + 6], 'big')
|
|
2995
|
+
at += 6
|
|
2996
|
+
at += 1 + section[at]
|
|
2997
|
+
at += at % 2
|
|
2998
|
+
if at + 4 > len(section):
|
|
2999
|
+
raise ValueError('malformed PSD image resource at %d' % start)
|
|
3000
|
+
size = int.from_bytes(section[at:at + 4], 'big')
|
|
3001
|
+
at += 4 + size + size % 2
|
|
3002
|
+
if at > len(section):
|
|
3003
|
+
raise ValueError('malformed PSD image resource at %d' % start)
|
|
3004
|
+
if rid != resource_id:
|
|
3005
|
+
out += section[start:at]
|
|
3006
|
+
return bytes(out)
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
def layered_bake_plan(nodes):
|
|
3010
|
+
"""Which layers a layered (.psd) export must flatten its live filters into first.
|
|
3011
|
+
|
|
3012
|
+
`nodes` is `(layer_id, parent_id, is_group, has_filters)` per layer in `_all_layers` order
|
|
3013
|
+
(a group before its children; `parent_id` None at top level). Returns `(merge_groups,
|
|
3014
|
+
bake_layers)`: a group carrying a live filter is merged whole into one raster layer, since the
|
|
3015
|
+
PSD exporter drops a group's own filter (verified live, GIMP 3.2.6) and merging is the only
|
|
3016
|
+
way to keep its look; everything inside it is absorbed by that merge. Any other layer with
|
|
3017
|
+
filters is baked in place, keeping its own mask, mode and opacity."""
|
|
3018
|
+
absorbed = set()
|
|
3019
|
+
merge_groups, bake_layers = [], []
|
|
3020
|
+
for layer_id, parent_id, is_group, has_filters in nodes:
|
|
3021
|
+
if parent_id is not None and parent_id in absorbed:
|
|
3022
|
+
absorbed.add(layer_id)
|
|
3023
|
+
elif is_group and has_filters:
|
|
3024
|
+
merge_groups.append(layer_id)
|
|
3025
|
+
absorbed.add(layer_id)
|
|
3026
|
+
elif has_filters:
|
|
3027
|
+
bake_layers.append(layer_id)
|
|
3028
|
+
return merge_groups, bake_layers
|
|
3029
|
+
|
|
3030
|
+
|
|
2571
3031
|
# ---- text layers (gimp_text) -----------------------------------------------------------------
|
|
2572
3032
|
|
|
2573
3033
|
TEXT_MAX_CHARS = 2000
|
|
@@ -2796,6 +3256,163 @@ def check_estimated_text_size(width, height):
|
|
|
2796
3256
|
)
|
|
2797
3257
|
|
|
2798
3258
|
|
|
3259
|
+
# ---- text box and fit (gimp_text box / fit) --------------------------------------------------
|
|
3260
|
+
|
|
3261
|
+
TEXT_FIT_DEFAULT_MIN_PX = 8
|
|
3262
|
+
TEXT_FIT_MAX_MIN_PX = 10000
|
|
3263
|
+
TEXT_FIT_MAX_PROBES = 12
|
|
3264
|
+
TEXT_BOX_SCAN_MAX_PIXELS = 16_000_000
|
|
3265
|
+
TEXT_OVERFLOW_LINES = 1_000_000 # "more lines than any box holds": what an overflowing probe reports
|
|
3266
|
+
TEXT_LINE_PITCH_SAMPLE_LINES = 10
|
|
3267
|
+
|
|
3268
|
+
# A text box's own area ceiling, precision-aware like DOCUMENT_MEGAPIXEL_CAP but much tighter (a
|
|
3269
|
+
# text layer is a caption, not a canvas): 64 MP at 8-bit, halved for 16-bit and quartered for
|
|
3270
|
+
# 32-bit, and never above the matching document cap when EM_GIMP_MAX_MEGAPIXELS lowers those.
|
|
3271
|
+
TEXT_BOX_MEGAPIXEL_CAP = {
|
|
3272
|
+
'8': min(64, MAX_RESIZE_MEGAPIXELS),
|
|
3273
|
+
'16': min(32, MAX_RESIZE_MEGAPIXELS / 2),
|
|
3274
|
+
'32': min(16, MAX_RESIZE_MEGAPIXELS / 4),
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
# Marks a text layer this tool put in a fixed box. GIMP cannot report a layer's box mode, so this
|
|
3278
|
+
# persistent layer parasite is how later edits know their size is bounded by the box.
|
|
3279
|
+
TEXT_BOX_TAG = 'editmamei-text-box'
|
|
3280
|
+
|
|
3281
|
+
|
|
3282
|
+
def check_text_box_area(width, height, precision='8'):
|
|
3283
|
+
"""Refuses a text box past the precision-aware text-box area cap (`TEXT_BOX_MEGAPIXEL_CAP`)."""
|
|
3284
|
+
cap = TEXT_BOX_MEGAPIXEL_CAP[precision]
|
|
3285
|
+
megapixels = (width * height) / 1_000_000.0
|
|
3286
|
+
if megapixels > cap:
|
|
3287
|
+
raise ValueError(
|
|
3288
|
+
'a %dx%d px text box is %.1f MP, past the %g MP limit for a %s-bit text layer; use a '
|
|
3289
|
+
'smaller box' % (width, height, megapixels, cap, precision)
|
|
3290
|
+
)
|
|
3291
|
+
|
|
3292
|
+
|
|
3293
|
+
def text_scan_max_pixels(precision='8'):
|
|
3294
|
+
"""The largest probe layer (px) a box scan renders: `TEXT_BOX_SCAN_MAX_PIXELS`, or the text-box
|
|
3295
|
+
area cap for `precision` when EM_GIMP_MAX_MEGAPIXELS has lowered it below that."""
|
|
3296
|
+
return min(TEXT_BOX_SCAN_MAX_PIXELS, int(TEXT_BOX_MEGAPIXEL_CAP[precision] * 1_000_000))
|
|
3297
|
+
|
|
3298
|
+
|
|
3299
|
+
def check_text_fit_box(width, height, precision='8'):
|
|
3300
|
+
"""Refuses `fit` for a box too large to probe in one render (`text_scan_max_pixels`)."""
|
|
3301
|
+
limit = text_scan_max_pixels(precision)
|
|
3302
|
+
if width * height > limit:
|
|
3303
|
+
raise ValueError(
|
|
3304
|
+
'fit supports a box of at most %g MP (got %dx%d px); use a smaller box, or set the '
|
|
3305
|
+
'box and font_size without fit' % (limit / 1_000_000.0, width, height)
|
|
3306
|
+
)
|
|
3307
|
+
|
|
3308
|
+
|
|
3309
|
+
def text_scan_cap(width, precision='8'):
|
|
3310
|
+
"""The tallest probe layer (px) the box scan renders at `width`."""
|
|
3311
|
+
return max(1, min(MAX_RESIZE_SIDE_PX, text_scan_max_pixels(precision) // max(1, width)))
|
|
3312
|
+
|
|
3313
|
+
|
|
3314
|
+
def fit_probe_plan(width, limit_h, pitch, precision='8'):
|
|
3315
|
+
"""(scan_h, clamped): the probe height for testing whether text fits `limit_h`, a little
|
|
3316
|
+
taller than the limit so overflow shows as ink past it. `clamped` means the scan cap cut it
|
|
3317
|
+
below that height, so ink on its last row only says "reaches the end of the probe"."""
|
|
3318
|
+
ideal = int(math.ceil(limit_h + 2 * pitch)) + 1
|
|
3319
|
+
scan_h = min(text_scan_cap(width, precision), ideal)
|
|
3320
|
+
return scan_h, scan_h < ideal
|
|
3321
|
+
|
|
3322
|
+
|
|
3323
|
+
def fit_probe_overflows(ink_bottom_row, scan_h, clamped):
|
|
3324
|
+
"""Whether a clamped probe (`fit_probe_plan`) with ink on its last row means the text
|
|
3325
|
+
overflows. An unclamped probe is judged by its line count instead."""
|
|
3326
|
+
return bool(clamped and ink_bottom_row >= scan_h - 1)
|
|
3327
|
+
|
|
3328
|
+
|
|
3329
|
+
def validate_text_box(box):
|
|
3330
|
+
"""(width, height) from a `box` object `{width, height?}` in image px; height is None when
|
|
3331
|
+
omitted (the layer grows to fit the wrapped text). Each side is held to the engine's resize
|
|
3332
|
+
caps, and the area to its megapixel cap."""
|
|
3333
|
+
if not isinstance(box, dict):
|
|
3334
|
+
raise ValueError('box must be an object {width, height?}')
|
|
3335
|
+
unknown = sorted(set(box) - {'width', 'height'})
|
|
3336
|
+
if unknown:
|
|
3337
|
+
raise ValueError('box has unknown keys: %s' % ', '.join(unknown))
|
|
3338
|
+
if box.get('width') is None:
|
|
3339
|
+
raise ValueError('box.width is required')
|
|
3340
|
+
width = _box_side('box.width', box['width'])
|
|
3341
|
+
height = None if box.get('height') is None else _box_side('box.height', box['height'])
|
|
3342
|
+
validate_resize_dims(width, height if height is not None else 1)
|
|
3343
|
+
return width, height
|
|
3344
|
+
|
|
3345
|
+
|
|
3346
|
+
def _box_side(name, value):
|
|
3347
|
+
if (isinstance(value, bool) or not isinstance(value, (int, float))
|
|
3348
|
+
or (isinstance(value, float) and not math.isfinite(value)) or int(value) != value):
|
|
3349
|
+
raise ValueError('%s must be an integer' % name)
|
|
3350
|
+
return validate_int_range(name, value, 1, MAX_RESIZE_SIDE_PX)
|
|
3351
|
+
|
|
3352
|
+
|
|
3353
|
+
def validate_text_fit(args, box):
|
|
3354
|
+
"""(fit, min_px) from `fit` / `min_size`. Fitting needs a box with both width and height;
|
|
3355
|
+
`min_size` (pixels) is only meaningful with `fit`."""
|
|
3356
|
+
fit = args.get('fit')
|
|
3357
|
+
if fit is not None and not isinstance(fit, bool):
|
|
3358
|
+
raise ValueError('fit must be a boolean')
|
|
3359
|
+
min_given = args.get('min_size') is not None
|
|
3360
|
+
if not fit:
|
|
3361
|
+
if min_given:
|
|
3362
|
+
raise ValueError('min_size only applies with fit: true')
|
|
3363
|
+
return False, None
|
|
3364
|
+
if box is None or box[1] is None:
|
|
3365
|
+
raise ValueError('fit needs a box with both width and height')
|
|
3366
|
+
min_px = TEXT_FIT_DEFAULT_MIN_PX
|
|
3367
|
+
if min_given:
|
|
3368
|
+
value = args['min_size']
|
|
3369
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
3370
|
+
raise ValueError('min_size must be a number')
|
|
3371
|
+
min_px = validate_range('min_size', value, 1, TEXT_FIT_MAX_MIN_PX)
|
|
3372
|
+
return True, min_px
|
|
3373
|
+
|
|
3374
|
+
|
|
3375
|
+
def fit_font_size(max_px, min_px, fits, max_probes=TEXT_FIT_MAX_PROBES):
|
|
3376
|
+
"""The largest font size in [min_px, max_px] for which `fits(size)` is true, searched in
|
|
3377
|
+
whole pixels (the requested max is tried exactly as given). Returns (size, fitted, probes);
|
|
3378
|
+
`fitted` is False when even `min_px` overflows (the size is then `min_px`). Never calls
|
|
3379
|
+
`fits` more than `max_probes` times: the search keeps the best size that fit so far."""
|
|
3380
|
+
if min_px > max_px:
|
|
3381
|
+
min_px = max_px
|
|
3382
|
+
probes = 0
|
|
3383
|
+
|
|
3384
|
+
def probe(size):
|
|
3385
|
+
nonlocal probes
|
|
3386
|
+
probes += 1
|
|
3387
|
+
return fits(size)
|
|
3388
|
+
|
|
3389
|
+
if probe(max_px):
|
|
3390
|
+
return max_px, True, probes
|
|
3391
|
+
if min_px >= max_px or not probe(min_px):
|
|
3392
|
+
return min_px, False, probes
|
|
3393
|
+
lo, hi = min_px, max_px # lo fits, hi does not
|
|
3394
|
+
while probes < max_probes:
|
|
3395
|
+
mid = math.floor((lo + hi) / 2.0)
|
|
3396
|
+
if mid <= lo:
|
|
3397
|
+
break
|
|
3398
|
+
if probe(mid):
|
|
3399
|
+
lo = mid
|
|
3400
|
+
else:
|
|
3401
|
+
hi = mid
|
|
3402
|
+
return lo, True, probes
|
|
3403
|
+
|
|
3404
|
+
|
|
3405
|
+
def wrapped_line_count(ink_bottom_row, pitch, explicit_lines):
|
|
3406
|
+
"""How many lines a wrapped text layout occupies, from the last row holding ink
|
|
3407
|
+
(`ink_bottom_row`, -1 when blank) and the line pitch (line height plus line spacing). The
|
|
3408
|
+
last line's ink always sits below the top of its own line box and within it, so its index is
|
|
3409
|
+
`floor(row / pitch)`; lines the content separates with newlines count even when blank."""
|
|
3410
|
+
lines = explicit_lines
|
|
3411
|
+
if ink_bottom_row >= 0 and pitch > 0:
|
|
3412
|
+
lines = max(lines, int(ink_bottom_row // pitch) + 1)
|
|
3413
|
+
return max(1, lines)
|
|
3414
|
+
|
|
3415
|
+
|
|
2799
3416
|
TEXT_REPORT_MAX_CHARS = 200
|
|
2800
3417
|
|
|
2801
3418
|
|
|
@@ -2815,3 +3432,321 @@ def check_text_layer_size(width, height):
|
|
|
2815
3432
|
'the text would render as a %dx%d px layer, past the size limit (%s); use a smaller '
|
|
2816
3433
|
'font_size or less text' % (width, height, exc)
|
|
2817
3434
|
)
|
|
3435
|
+
|
|
3436
|
+
|
|
3437
|
+
# ---- retouch: heal / clone / spot heal --------------------------------------------------------
|
|
3438
|
+
#
|
|
3439
|
+
# Pure helpers (no gi). Coordinates are image pixels; `bounds` is the half-open box
|
|
3440
|
+
# (x0, y0, x1, y1) of the layer clipped to the canvas.
|
|
3441
|
+
|
|
3442
|
+
RETOUCH_TAG = 'editmamei-retouch'
|
|
3443
|
+
RETOUCH_METHODS = ('heal', 'clone', 'spot_heal')
|
|
3444
|
+
RETOUCH_TARGETS = ('retouch_layer', 'in_place')
|
|
3445
|
+
RETOUCH_MAX_POINTS = 500
|
|
3446
|
+
RETOUCH_MAX_SPOTS = 50
|
|
3447
|
+
RETOUCH_MIN_BRUSH = 1
|
|
3448
|
+
RETOUCH_MAX_BRUSH = 1000
|
|
3449
|
+
RETOUCH_DEFAULT_BRUSH = 20
|
|
3450
|
+
RETOUCH_DEFAULT_HARDNESS = {'heal': 0.5, 'clone': 0.8}
|
|
3451
|
+
RETOUCH_SPOT_HARDNESS = 0.5
|
|
3452
|
+
RETOUCH_MAX_SPOT_RADIUS = 500
|
|
3453
|
+
# Spot-heal source search: 8 directions on two rings, as multiples of the spot radius.
|
|
3454
|
+
RETOUCH_SPOT_RINGS = (2.2, 3.5)
|
|
3455
|
+
RETOUCH_SPOT_DIRECTIONS = 8
|
|
3456
|
+
# Healing blends a dab to the pixels around its edge, so the dab must reach past the spot: 2.6x the
|
|
3457
|
+
# radius is 1.3x the spot's diameter. A smaller brush sits wholly inside the spot and keeps its color.
|
|
3458
|
+
RETOUCH_SPOT_BRUSH_FACTOR = 2.6
|
|
3459
|
+
RETOUCH_SPOT_MIN_BRUSH = 3.0
|
|
3460
|
+
# The annulus a spot's surround is measured over, as a multiple of the spot radius.
|
|
3461
|
+
RETOUCH_SPOT_RING_OUTER = 1.6
|
|
3462
|
+
# How far a spot's heal reaches, as a multiple of its radius: half the spot brush (1.3x) beyond the
|
|
3463
|
+
# spot's own edge. Source candidates must keep this footprint inside the layer, and the Retouch
|
|
3464
|
+
# layer's mask reveals a feathered disc of this radius around each spot.
|
|
3465
|
+
RETOUCH_SPOT_REACH = 2.3
|
|
3466
|
+
# Hardness of the dab that reveals a healed spot on the Retouch layer's mask.
|
|
3467
|
+
RETOUCH_REVEAL_HARDNESS = 0.7
|
|
3468
|
+
# Work budgets, measured on a 6000x4000 image so the worst accepted call stays under about a minute:
|
|
3469
|
+
# a heal stroke costs about (path length + one brush) x brush, at worst ~25 us per unit (brush 90-100;
|
|
3470
|
+
# ~2-9 us at other sizes; a clone is far cheaper), so 2,000,000 is ~50 s; spot_heal costs ~270 us per
|
|
3471
|
+
# unit of the sum of radius squared, so 200,000 is ~55 s. Mirrored in tools/gimp-retouch-tools.ts.
|
|
3472
|
+
RETOUCH_MAX_STROKE_WORK = 2000000
|
|
3473
|
+
RETOUCH_MAX_SPOT_WORK = 200000
|
|
3474
|
+
|
|
3475
|
+
|
|
3476
|
+
def _finite_number(name, value):
|
|
3477
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
3478
|
+
raise ValueError('%s must be a finite number, got %r' % (name, value))
|
|
3479
|
+
return float(value)
|
|
3480
|
+
|
|
3481
|
+
|
|
3482
|
+
def _xy(name, value):
|
|
3483
|
+
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
|
3484
|
+
raise ValueError('%s must be [x, y], got %r' % (name, value))
|
|
3485
|
+
return [_finite_number(name, value[0]), _finite_number(name, value[1])]
|
|
3486
|
+
|
|
3487
|
+
|
|
3488
|
+
def validate_retouch_args(args):
|
|
3489
|
+
"""The validated, defaulted `retouch` options as a dict. Raises ValueError (-> invalid_argument)
|
|
3490
|
+
naming the field. heal/clone need `points` plus exactly one of `source` / `source_offset`
|
|
3491
|
+
(returned as `source_offset` (dx, dy) relative to points[0]); spot_heal needs `spots`."""
|
|
3492
|
+
method = args.get('method')
|
|
3493
|
+
if method is None:
|
|
3494
|
+
raise ValueError('method is required')
|
|
3495
|
+
validate_choice('method', method, RETOUCH_METHODS)
|
|
3496
|
+
target = validate_choice('target', args.get('target') or 'retouch_layer', RETOUCH_TARGETS)
|
|
3497
|
+
opts = {'method': method, 'target': target}
|
|
3498
|
+
if method == 'spot_heal':
|
|
3499
|
+
if args.get('hardness') is not None:
|
|
3500
|
+
raise ValueError('hardness does not apply to method spot_heal (it sizes its own brush)')
|
|
3501
|
+
spots = args.get('spots')
|
|
3502
|
+
if not isinstance(spots, list) or not 1 <= len(spots) <= RETOUCH_MAX_SPOTS:
|
|
3503
|
+
raise ValueError('spots must be a list of 1..%d spots' % RETOUCH_MAX_SPOTS)
|
|
3504
|
+
clean = []
|
|
3505
|
+
for i, spot in enumerate(spots):
|
|
3506
|
+
if not isinstance(spot, dict):
|
|
3507
|
+
raise ValueError('spots[%d] must be {x, y, radius}' % i)
|
|
3508
|
+
radius = _finite_number('spots[%d].radius' % i, spot.get('radius'))
|
|
3509
|
+
if not 1 <= radius <= RETOUCH_MAX_SPOT_RADIUS:
|
|
3510
|
+
raise ValueError('spots[%d].radius must be within 1..%d' % (i, RETOUCH_MAX_SPOT_RADIUS))
|
|
3511
|
+
clean.append((
|
|
3512
|
+
_finite_number('spots[%d].x' % i, spot.get('x')),
|
|
3513
|
+
_finite_number('spots[%d].y' % i, spot.get('y')),
|
|
3514
|
+
radius,
|
|
3515
|
+
))
|
|
3516
|
+
work = retouch_spots_work(clean)
|
|
3517
|
+
if work > RETOUCH_MAX_SPOT_WORK:
|
|
3518
|
+
raise ValueError(
|
|
3519
|
+
'these spots are too large to heal in one call (the sum of radius squared is %g, '
|
|
3520
|
+
'the limit is %d): split them into several calls or use smaller radii'
|
|
3521
|
+
% (work, RETOUCH_MAX_SPOT_WORK)
|
|
3522
|
+
)
|
|
3523
|
+
opts['spots'] = clean
|
|
3524
|
+
return opts
|
|
3525
|
+
points = args.get('points')
|
|
3526
|
+
if not isinstance(points, list) or not 1 <= len(points) <= RETOUCH_MAX_POINTS:
|
|
3527
|
+
raise ValueError('points must be a list of 1..%d [x, y] points' % RETOUCH_MAX_POINTS)
|
|
3528
|
+
opts['points'] = [_xy('points', p) for p in points]
|
|
3529
|
+
source, offset = args.get('source'), args.get('source_offset')
|
|
3530
|
+
if (source is None) == (offset is None):
|
|
3531
|
+
raise ValueError('give exactly one of source {x, y} or source_offset {dx, dy}')
|
|
3532
|
+
if source is not None:
|
|
3533
|
+
if not isinstance(source, dict):
|
|
3534
|
+
raise ValueError('source must be {x, y}')
|
|
3535
|
+
sx = _finite_number('source.x', source.get('x'))
|
|
3536
|
+
sy = _finite_number('source.y', source.get('y'))
|
|
3537
|
+
opts['source_offset'] = (sx - opts['points'][0][0], sy - opts['points'][0][1])
|
|
3538
|
+
else:
|
|
3539
|
+
if not isinstance(offset, dict):
|
|
3540
|
+
raise ValueError('source_offset must be {dx, dy}')
|
|
3541
|
+
opts['source_offset'] = (
|
|
3542
|
+
_finite_number('source_offset.dx', offset.get('dx')),
|
|
3543
|
+
_finite_number('source_offset.dy', offset.get('dy')),
|
|
3544
|
+
)
|
|
3545
|
+
brush = args.get('brush_size')
|
|
3546
|
+
opts['brush_size'] = validate_range(
|
|
3547
|
+
'brush_size', RETOUCH_DEFAULT_BRUSH if brush is None else brush,
|
|
3548
|
+
RETOUCH_MIN_BRUSH, RETOUCH_MAX_BRUSH,
|
|
3549
|
+
)
|
|
3550
|
+
work = retouch_stroke_work(opts['points'], opts['brush_size'])
|
|
3551
|
+
if work > RETOUCH_MAX_STROKE_WORK:
|
|
3552
|
+
raise ValueError(
|
|
3553
|
+
'this stroke is too long for its brush size to paint in one call (about %d px of '
|
|
3554
|
+
'path x brush, the limit is %d): split the stroke into several calls or use a '
|
|
3555
|
+
'smaller brush' % (work, RETOUCH_MAX_STROKE_WORK)
|
|
3556
|
+
)
|
|
3557
|
+
hardness = args.get('hardness')
|
|
3558
|
+
opts['hardness'] = validate_range(
|
|
3559
|
+
'hardness', RETOUCH_DEFAULT_HARDNESS[method] if hardness is None else hardness, 0.0, 1.0
|
|
3560
|
+
)
|
|
3561
|
+
return opts
|
|
3562
|
+
|
|
3563
|
+
|
|
3564
|
+
def retouch_stroke_work(points, brush_size):
|
|
3565
|
+
"""The cost estimate of a heal/clone stroke: (path length + one brush) x brush, about the area
|
|
3566
|
+
the brush sweeps (a single dab still costs its own area)."""
|
|
3567
|
+
length = sum(
|
|
3568
|
+
math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(points, points[1:])
|
|
3569
|
+
)
|
|
3570
|
+
return (length + brush_size) * brush_size
|
|
3571
|
+
|
|
3572
|
+
|
|
3573
|
+
def retouch_spots_work(spots):
|
|
3574
|
+
"""The cost estimate of a spot_heal call: the sum of radius squared over its (x, y, r) spots."""
|
|
3575
|
+
return sum(r * r for _x, _y, r in spots)
|
|
3576
|
+
|
|
3577
|
+
|
|
3578
|
+
def retouch_bounds(layer_x, layer_y, layer_width, layer_height, canvas_width, canvas_height):
|
|
3579
|
+
"""The layer's box clipped to the canvas as half-open (x0, y0, x1, y1); None when empty."""
|
|
3580
|
+
x0, y0 = max(0, layer_x), max(0, layer_y)
|
|
3581
|
+
x1 = min(canvas_width, layer_x + layer_width)
|
|
3582
|
+
y1 = min(canvas_height, layer_y + layer_height)
|
|
3583
|
+
if x1 <= x0 or y1 <= y0:
|
|
3584
|
+
return None
|
|
3585
|
+
return (x0, y0, x1, y1)
|
|
3586
|
+
|
|
3587
|
+
|
|
3588
|
+
def retouch_check_points(points, bounds, what):
|
|
3589
|
+
"""Raises ValueError naming `bounds` for the first point outside the half-open box."""
|
|
3590
|
+
x0, y0, x1, y1 = bounds
|
|
3591
|
+
for i, (x, y) in enumerate(points):
|
|
3592
|
+
if not (x0 <= x < x1 and y0 <= y < y1):
|
|
3593
|
+
raise ValueError(
|
|
3594
|
+
'%s point %d (%g, %g) is outside the layer bounds x %d..%d, y %d..%d (image pixels)'
|
|
3595
|
+
% (what, i, x, y, x0, x1 - 1, y0, y1 - 1)
|
|
3596
|
+
)
|
|
3597
|
+
|
|
3598
|
+
|
|
3599
|
+
def retouch_spot_brush(radius):
|
|
3600
|
+
"""Brush diameter for healing a spot of `radius`: 1.3x the spot's diameter, within 3..1000."""
|
|
3601
|
+
return max(RETOUCH_SPOT_MIN_BRUSH,
|
|
3602
|
+
min(float(RETOUCH_MAX_BRUSH), radius * RETOUCH_SPOT_BRUSH_FACTOR))
|
|
3603
|
+
|
|
3604
|
+
|
|
3605
|
+
def retouch_cover_stroke(cx, cy, radius, brush_size, bounds=None, max_points=RETOUCH_MAX_POINTS):
|
|
3606
|
+
"""A stroke (list of [x, y]) whose brush (diameter `brush_size`) covers the disc at (cx, cy):
|
|
3607
|
+
an Archimedean spiral from the center out to `radius` with a turn pitch of 0.6 x the brush, then
|
|
3608
|
+
one full circle at `radius`, points spaced 0.4 x the brush apart (a heal dab blends to its
|
|
3609
|
+
own edge, so several overlapping dabs beat one). Clamped into `bounds`; the
|
|
3610
|
+
spacing widens if the stroke would exceed `max_points`."""
|
|
3611
|
+
if radius <= 0 or brush_size <= 0:
|
|
3612
|
+
return [[cx, cy]]
|
|
3613
|
+
scale = 1.0
|
|
3614
|
+
# Estimated point count (spiral length / spacing + the closing circle), so a stroke that would
|
|
3615
|
+
# blow the cap widens its spacing up front instead of generating it first.
|
|
3616
|
+
while ((math.pi * radius ** 2 / (0.6 * brush_size * scale) + 2 * math.pi * radius)
|
|
3617
|
+
/ (0.4 * brush_size * scale) > max_points * 0.9):
|
|
3618
|
+
scale *= 1.1
|
|
3619
|
+
while True:
|
|
3620
|
+
pitch, step = 0.6 * brush_size * scale, 0.4 * brush_size * scale
|
|
3621
|
+
pts = [(cx, cy)]
|
|
3622
|
+
theta = 0.0
|
|
3623
|
+
while True:
|
|
3624
|
+
r = min(radius, pitch * theta / (2 * math.pi))
|
|
3625
|
+
if r >= radius:
|
|
3626
|
+
break
|
|
3627
|
+
theta += step / max(r, step)
|
|
3628
|
+
r = min(radius, pitch * theta / (2 * math.pi))
|
|
3629
|
+
pts.append((cx + r * math.cos(theta), cy + r * math.sin(theta)))
|
|
3630
|
+
if r >= radius:
|
|
3631
|
+
break
|
|
3632
|
+
n = max(3, int(math.ceil(2 * math.pi * radius / step)))
|
|
3633
|
+
for k in range(1, n + 1):
|
|
3634
|
+
a = theta + 2 * math.pi * k / n
|
|
3635
|
+
pts.append((cx + radius * math.cos(a), cy + radius * math.sin(a)))
|
|
3636
|
+
if len(pts) <= max_points:
|
|
3637
|
+
break
|
|
3638
|
+
scale *= 1.25
|
|
3639
|
+
if bounds is not None:
|
|
3640
|
+
x0, y0, x1, y1 = bounds
|
|
3641
|
+
pts = [(min(max(x, x0), x1 - 1), min(max(y, y0), y1 - 1)) for x, y in pts]
|
|
3642
|
+
return [[round(x, 2), round(y, 2)] for x, y in pts]
|
|
3643
|
+
|
|
3644
|
+
|
|
3645
|
+
def retouch_painted_bbox(points, brush_size, bounds):
|
|
3646
|
+
"""[x, y, w, h] of the stroke's brush footprint (points grown by half the brush), clipped to
|
|
3647
|
+
`bounds`; [0, 0, 0, 0] when nothing is left."""
|
|
3648
|
+
half = brush_size / 2.0
|
|
3649
|
+
x0 = max(bounds[0], int(math.floor(min(p[0] for p in points) - half)))
|
|
3650
|
+
y0 = max(bounds[1], int(math.floor(min(p[1] for p in points) - half)))
|
|
3651
|
+
x1 = min(bounds[2], int(math.ceil(max(p[0] for p in points) + half)))
|
|
3652
|
+
y1 = min(bounds[3], int(math.ceil(max(p[1] for p in points) + half)))
|
|
3653
|
+
if x1 <= x0 or y1 <= y0:
|
|
3654
|
+
return [0, 0, 0, 0]
|
|
3655
|
+
return [x0, y0, x1 - x0, y1 - y0]
|
|
3656
|
+
|
|
3657
|
+
|
|
3658
|
+
def union_bbox_xywh(a, b):
|
|
3659
|
+
"""Union of two [x, y, w, h] boxes; an empty (zero-area) box is ignored."""
|
|
3660
|
+
if a is None or a[2] <= 0 or a[3] <= 0:
|
|
3661
|
+
return list(b) if b is not None else None
|
|
3662
|
+
if b is None or b[2] <= 0 or b[3] <= 0:
|
|
3663
|
+
return list(a)
|
|
3664
|
+
x0, y0 = min(a[0], b[0]), min(a[1], b[1])
|
|
3665
|
+
x1, y1 = max(a[0] + a[2], b[0] + b[2]), max(a[1] + a[3], b[1] + b[3])
|
|
3666
|
+
return [x0, y0, x1 - x0, y1 - y0]
|
|
3667
|
+
|
|
3668
|
+
|
|
3669
|
+
def spot_candidates(cx, cy, radius, bounds, reach=None):
|
|
3670
|
+
"""Candidate source offsets (dx, dy) for a spot: 8 directions on each of the two rings
|
|
3671
|
+
(2.2x and 3.5x the radius). A candidate is dropped when its source footprint -- a disc of
|
|
3672
|
+
`reach` (default 2.3 x radius, RETOUCH_SPOT_REACH: the spot plus the healing brush's edge)
|
|
3673
|
+
around the shifted center -- leaves `bounds`. Rounded to whole pixels; order is ring-major, then by angle."""
|
|
3674
|
+
if reach is None:
|
|
3675
|
+
reach = radius * RETOUCH_SPOT_REACH
|
|
3676
|
+
x0, y0, x1, y1 = bounds
|
|
3677
|
+
out = []
|
|
3678
|
+
for ring in RETOUCH_SPOT_RINGS:
|
|
3679
|
+
dist = ring * radius
|
|
3680
|
+
for k in range(RETOUCH_SPOT_DIRECTIONS):
|
|
3681
|
+
a = 2 * math.pi * k / RETOUCH_SPOT_DIRECTIONS
|
|
3682
|
+
dx, dy = int(round(dist * math.cos(a))), int(round(dist * math.sin(a)))
|
|
3683
|
+
sx, sy = cx + dx, cy + dy
|
|
3684
|
+
if sx - reach < x0 or sy - reach < y0 or sx + reach > x1 or sy + reach > y1:
|
|
3685
|
+
continue
|
|
3686
|
+
out.append((dx, dy))
|
|
3687
|
+
return out
|
|
3688
|
+
|
|
3689
|
+
|
|
3690
|
+
def spot_score(cand_mean, cand_std, ring_mean, ring_std):
|
|
3691
|
+
"""How far a candidate disc's (mean, std) is from the spot's surround ring: the sum of the two
|
|
3692
|
+
absolute differences, in histogram units (0-255). Lower is better."""
|
|
3693
|
+
return abs(cand_mean - ring_mean) + abs(cand_std - ring_std)
|
|
3694
|
+
|
|
3695
|
+
|
|
3696
|
+
def spot_pick_best(scored):
|
|
3697
|
+
"""The (dx, dy, score) with the lowest score from [(dx, dy, score), ...]; the earliest wins
|
|
3698
|
+
a tie. None when empty."""
|
|
3699
|
+
best = None
|
|
3700
|
+
for item in scored:
|
|
3701
|
+
if best is None or item[2] < best[2]:
|
|
3702
|
+
best = item
|
|
3703
|
+
return best
|
|
3704
|
+
|
|
3705
|
+
|
|
3706
|
+
# ---- render_region / keep_alpha ---------------------------------------------------------------
|
|
3707
|
+
|
|
3708
|
+
# render_region is never downscaled: a box past either bound is refused outright.
|
|
3709
|
+
RENDER_REGION_MAX_PIXELS = 16_000_000
|
|
3710
|
+
RENDER_REGION_MAX_SIDE = 4096
|
|
3711
|
+
KEEP_ALPHA_EXTENSIONS = ('.png', '.webp')
|
|
3712
|
+
|
|
3713
|
+
|
|
3714
|
+
def _whole_pixels(name, value):
|
|
3715
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
3716
|
+
raise ValueError('%s must be a whole number of pixels, got %r' % (name, value))
|
|
3717
|
+
if value != int(value):
|
|
3718
|
+
raise ValueError('%s must be a whole number of pixels, got %r' % (name, value))
|
|
3719
|
+
return int(value)
|
|
3720
|
+
|
|
3721
|
+
|
|
3722
|
+
def validate_render_box(box, canvas_width, canvas_height):
|
|
3723
|
+
"""`box` [x, y, w, h] in image pixels, clamped to the canvas, as ints. Refuses a malformed box,
|
|
3724
|
+
a box with no pixels on the canvas, and one past RENDER_REGION_MAX_PIXELS /
|
|
3725
|
+
RENDER_REGION_MAX_SIDE (checked after clamping)."""
|
|
3726
|
+
if not isinstance(box, (list, tuple)) or len(box) != 4:
|
|
3727
|
+
raise ValueError('box must be [x, y, width, height] in image pixels')
|
|
3728
|
+
x, y, w, h = [_whole_pixels('box', v) for v in box]
|
|
3729
|
+
if w <= 0 or h <= 0:
|
|
3730
|
+
raise ValueError('box width and height must be positive')
|
|
3731
|
+
x0, y0 = max(0, x), max(0, y)
|
|
3732
|
+
x1, y1 = min(canvas_width, x + w), min(canvas_height, y + h)
|
|
3733
|
+
if x1 <= x0 or y1 <= y0:
|
|
3734
|
+
raise ValueError(
|
|
3735
|
+
'box [%d, %d, %d, %d] does not overlap the %dx%d canvas' % (x, y, w, h, canvas_width, canvas_height)
|
|
3736
|
+
)
|
|
3737
|
+
w, h = x1 - x0, y1 - y0
|
|
3738
|
+
if max(w, h) > RENDER_REGION_MAX_SIDE or w * h > RENDER_REGION_MAX_PIXELS:
|
|
3739
|
+
raise ValueError(
|
|
3740
|
+
'box is %dx%d px; render_region allows at most %d px on a side and %d megapixels '
|
|
3741
|
+
'(it never downscales)' % (w, h, RENDER_REGION_MAX_SIDE, RENDER_REGION_MAX_PIXELS // 1_000_000)
|
|
3742
|
+
)
|
|
3743
|
+
return x0, y0, w, h
|
|
3744
|
+
|
|
3745
|
+
|
|
3746
|
+
def validate_keep_alpha(args, path):
|
|
3747
|
+
"""Whether `export` should keep transparency: `keep_alpha` must be a boolean (default False)
|
|
3748
|
+
and only PNG and WebP can carry it."""
|
|
3749
|
+
keep = optional_bool(args, 'keep_alpha', False)
|
|
3750
|
+
if keep and os.path.splitext(path)[1].lower() not in KEEP_ALPHA_EXTENSIONS:
|
|
3751
|
+
raise ValueError('keep_alpha only works with a .png or .webp file path')
|
|
3752
|
+
return keep
|