editmamei 1.7.1 → 1.9.0
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 +3 -3
- package/dist/backends/gimp/bridge/lib.py +1790 -26
- package/dist/backends/gimp/bridge/ops.py +3013 -168
- 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/cli/config.js +7 -0
- package/dist/core/server.js +58 -13
- package/dist/core/settings.js +7 -0
- package/dist/core/tool-activity.js +15 -1
- package/dist/core/tool-groups.js +9 -1
- package/dist/core/tool-tiers.js +9 -1
- package/dist/delivery/client.js +3 -0
- package/dist/modules/gimp/index.js +12 -2
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/sanitize.js +15 -1
- package/dist/tools/adjustment-tools.js +9 -1
- package/dist/tools/document-tools.js +118 -21
- package/dist/tools/filter-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +7 -6
- package/dist/tools/gimp-checkpoint-tools.js +7 -1
- package/dist/tools/gimp-core-tools.js +10 -11
- package/dist/tools/gimp-document-tools.js +100 -18
- package/dist/tools/gimp-effect-tools.js +56 -10
- package/dist/tools/gimp-fill-tools.js +256 -0
- package/dist/tools/gimp-filter-tools.js +4 -1
- package/dist/tools/gimp-geometry-tools.js +2 -2
- package/dist/tools/gimp-inspect-tools.js +48 -10
- package/dist/tools/gimp-layer-tools.js +7 -24
- package/dist/tools/gimp-match-tools.js +133 -0
- package/dist/tools/gimp-result-text.js +104 -0
- package/dist/tools/gimp-retouch-tools.js +238 -0
- package/dist/tools/gimp-selection-tools.js +374 -0
- package/dist/tools/gimp-shared.js +12 -0
- package/dist/tools/gimp-text-tools.js +241 -0
- package/dist/tools/gimp-transform-layer-tools.js +215 -0
- package/dist/tools/gimp-verify-tools.js +13 -15
- package/dist/tools/image-placement-tools.js +6 -1
- package/dist/tools/sky-tools.js +2 -1
- package/dist/utils/gimp-path.js +18 -22
- package/dist/utils/local-path.js +99 -0
- package/dist/utils/operation-timeouts.js +12 -2
- package/dist/version.js +1 -1
- package/package.json +5 -5
- package/dist/tools/gimp-mask-tools.js +0 -102
|
@@ -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,16 +352,170 @@ 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
|
|
184
|
-
|
|
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
|
|
368
|
+
_DEFAULT_MAX_RESIZE_MEGAPIXELS = 250
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def megapixel_cap_from_env(raw, default=_DEFAULT_MAX_RESIZE_MEGAPIXELS):
|
|
372
|
+
"""A host-supplied (`EM_GIMP_MAX_MEGAPIXELS`) ceiling that can only LOWER `default` -- a memory-
|
|
373
|
+
constrained host (e.g. a small container) tightens every pixel-growing op's cap this way. Anything
|
|
374
|
+
that is not a finite positive number below `default` (unset, empty, junk, NaN/inf, zero/negative,
|
|
375
|
+
or an attempt to RAISE it) is ignored and `default` is returned unchanged."""
|
|
376
|
+
if not raw:
|
|
377
|
+
return default
|
|
378
|
+
try:
|
|
379
|
+
value = float(raw)
|
|
380
|
+
except (TypeError, ValueError):
|
|
381
|
+
return default
|
|
382
|
+
if not math.isfinite(value) or value <= 0 or value >= default:
|
|
383
|
+
return default
|
|
384
|
+
return value
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
MAX_RESIZE_MEGAPIXELS = megapixel_cap_from_env(os.environ.get('EM_GIMP_MAX_MEGAPIXELS'))
|
|
185
388
|
|
|
186
389
|
MAX_FEATHER_PX = 1000
|
|
187
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
|
+
|
|
402
|
+
# gimp_modify_selection's expand/contract/border radius cap -- much lower than MAX_FEATHER_PX.
|
|
403
|
+
# `Gimp.Selection.grow`/`shrink`/`border` are morphological (structuring-element) operations whose
|
|
404
|
+
# cost scales with the radius, unlike `feather` (a GEGL blur, flat ~0.5s regardless of radius on a
|
|
405
|
+
# 24MP image, measured live). This is the cap for a document at or below MORPHOLOGY_BASELINE_MP;
|
|
406
|
+
# a bigger document gets a SMALLER cap -- see `effective_morphology_px`.
|
|
407
|
+
MAX_MORPHOLOGY_PX = 150
|
|
408
|
+
|
|
409
|
+
# Measured live at px=150 (border, the most expensive of the three): ~30s at 24MP (6016x4000),
|
|
410
|
+
# ~73.5s at 100MP (14000x7143), and a GIMP session OUTRIGHT TIMEOUT past 180s at ~217MP
|
|
411
|
+
# (30000x7228, this bridge's own largest allowed document -- MAX_RESIZE_SIDE_PX/
|
|
412
|
+
# MAX_RESIZE_MEGAPIXELS). Cost does not scale linearly with megapixels (100MP was only ~2.45x the
|
|
413
|
+
# 24MP cost for a ~4.17x bigger image), but it very much does not fit inside
|
|
414
|
+
# gimp_modify_selection's budget either, so the cap itself has to shrink as the document grows.
|
|
415
|
+
MORPHOLOGY_BASELINE_MP = 24.0
|
|
416
|
+
# A document-size floor so a huge document still gets a meaningfully large morphology radius
|
|
417
|
+
# rather than being squeezed to single-digit pixels.
|
|
418
|
+
MIN_MORPHOLOGY_PX = 10
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def effective_morphology_px(width, height):
|
|
422
|
+
"""The actual expand/contract/border cap for a `width`x`height` document: `MAX_MORPHOLOGY_PX`
|
|
423
|
+
at or below `MORPHOLOGY_BASELINE_MP`, shrinking as 1/megapixels above it (simple and, per the
|
|
424
|
+
live measurements on `MAX_MORPHOLOGY_PX`'s own comment, SAFELY conservative -- the real cost
|
|
425
|
+
curve is sub-linear in megapixels, so this reduces the cap by more than the measured cost
|
|
426
|
+
alone would require). Verified live at the two points that matter: at ~100MP the scaled cap
|
|
427
|
+
(36px) cost ~11.8s; at ~217MP (this bridge's largest allowed document) the scaled cap (17px)
|
|
428
|
+
cost ~27.2s -- both comfortably inside gimp_modify_selection's 45s budget with its own margin,
|
|
429
|
+
even though a large document also carries a substantial FIXED per-call cost that shrinking
|
|
430
|
+
`px` alone cannot remove (measured ~13-17s of that 27.2s at ~217MP comes from px=5 alone,
|
|
431
|
+
i.e. just handling a document that size, before any morphology radius is even considered)."""
|
|
432
|
+
megapixels = (width * height) / 1_000_000.0
|
|
433
|
+
if megapixels <= MORPHOLOGY_BASELINE_MP:
|
|
434
|
+
return MAX_MORPHOLOGY_PX
|
|
435
|
+
scaled = MAX_MORPHOLOGY_PX * MORPHOLOGY_BASELINE_MP / megapixels
|
|
436
|
+
return max(MIN_MORPHOLOGY_PX, min(MAX_MORPHOLOGY_PX, round(scaled)))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# gimp_transform_layer's scale/scale_x_percent/scale_y_percent bound -- generous enough for any
|
|
440
|
+
# real use (100x in either direction) while still keeping a single call's resulting layer size
|
|
441
|
+
# bounded alongside validate_resize_dims' own check on the actual pixel dimensions.
|
|
442
|
+
TRANSFORM_LAYER_MIN_SCALE_PERCENT = 1
|
|
443
|
+
TRANSFORM_LAYER_MAX_SCALE_PERCENT = 10_000
|
|
444
|
+
|
|
445
|
+
# gimp_transform_layer's `interpolation` choices -- the GIMP resampling filters this bridge
|
|
446
|
+
# exposes, by name. The gi-dependent mapping to the real `Gimp.InterpolationType` enum members
|
|
447
|
+
# lives in ops.py (this module stays gi-free).
|
|
448
|
+
TRANSFORM_LAYER_INTERPOLATIONS = ('none', 'linear', 'cubic', 'nohalo', 'lohalo')
|
|
449
|
+
|
|
450
|
+
# gimp_transform_layer's `free` op: offset_x/offset_y bound -- a sanity ceiling on the final
|
|
451
|
+
# absolute translation, well past MAX_RESIZE_SIDE_PX (30000) so any real placement fits, but
|
|
452
|
+
# tight enough that a wildly out-of-range value is refused here rather than only by the derived
|
|
453
|
+
# predicted-origin check (`_validated_move_offset`) it also feeds into.
|
|
454
|
+
TRANSFORM_LAYER_MAX_OFFSET_PX = 100_000
|
|
455
|
+
|
|
456
|
+
# gimp_transform_layer's own op -> the field names that op reads, the same role
|
|
457
|
+
# `type_fields`/`FILTER_COMMON_KEYS` play for gimp_add_adjustment/gimp_add_effect above --
|
|
458
|
+
# `reject_foreign_transform_fields` refuses any OTHER declared field a caller sent (e.g. a
|
|
459
|
+
# PS-style flat `delta_x`, or `skew_h_degrees` on op=scale), so a typo validates instead of
|
|
460
|
+
# silently being ignored by that op's own handler.
|
|
461
|
+
TRANSFORM_LAYER_COMMON_KEYS = frozenset(('image', 'op', 'layer', 'layer_id', 'interpolation'))
|
|
462
|
+
|
|
463
|
+
TRANSFORM_LAYER_OP_FIELDS = {
|
|
464
|
+
'fit': ('mode',),
|
|
465
|
+
'scale': ('scale_percent', 'scale_x_percent', 'scale_y_percent'),
|
|
466
|
+
'move': ('delta', 'absolute', 'center_on'),
|
|
467
|
+
'rotate': ('degrees',),
|
|
468
|
+
'flip': ('axis',),
|
|
469
|
+
'skew': ('skew_h_degrees', 'skew_v_degrees'),
|
|
470
|
+
'free': ('scale_x_percent', 'scale_y_percent', 'degrees', 'offset_x', 'offset_y'),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def reject_foreign_transform_fields(op, args):
|
|
475
|
+
"""Refuse any field `op` does not read -- the gimp_transform_layer analogue of
|
|
476
|
+
`reject_foreign_fields` above, over `TRANSFORM_LAYER_OP_FIELDS` instead of the filter-type
|
|
477
|
+
tables. `op`'s own schema is flat (one property per name across every op), so without this a
|
|
478
|
+
field meant for a DIFFERENT op (`skew_h_degrees` on op=scale, or PS's flat `delta_x` instead
|
|
479
|
+
of this tool's nested `delta`) validates, is ignored by that op's own handler, and silently
|
|
480
|
+
does nothing. None values are ignored (an omitted field)."""
|
|
481
|
+
own = TRANSFORM_LAYER_OP_FIELDS.get(op, ())
|
|
482
|
+
foreign = sorted(
|
|
483
|
+
k for k, v in args.items()
|
|
484
|
+
if v is not None and k not in TRANSFORM_LAYER_COMMON_KEYS and k not in own
|
|
485
|
+
)
|
|
486
|
+
if foreign:
|
|
487
|
+
raise ValueError(
|
|
488
|
+
"op '%s' does not use field(s) %s; its fields are: %s"
|
|
489
|
+
% (op, ', '.join(foreign), ', '.join(own) or '(none)')
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def ceil_with_margin(value):
|
|
494
|
+
"""`value` rounded UP to the next whole pixel, plus one more -- the conservative integer a
|
|
495
|
+
cap check validates against for a float `transformed_bounds` predicts, matching the extra
|
|
496
|
+
pixel GIMP's own ADJUST transform-resize can add by rounding each edge of a rotated/sheared
|
|
497
|
+
bounding box outward independently (floor the min edge, ceil the max edge), which can land
|
|
498
|
+
the ACTUAL resulting integer width/height one pixel past a plain `ceil` of the float width.
|
|
499
|
+
Never used for the transform's own corner coordinates (those stay exact floats) -- only for
|
|
500
|
+
deciding whether to refuse BEFORE any pixel moves."""
|
|
501
|
+
return int(math.ceil(value)) + 1
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def transform_layer_precision_bucket(precision_nick):
|
|
505
|
+
"""`Gimp.Precision.value_nick` (e.g. 'u8-non-linear', 'u16-perceptual', 'float-linear')
|
|
506
|
+
bucketed into the '8'/'16'/'32' strings `validate_document_dims`'s own
|
|
507
|
+
`DOCUMENT_MEGAPIXEL_CAP` is keyed by -- gimp_transform_layer validates a layer's predicted
|
|
508
|
+
size against the SAME precision-aware cap gimp_create_document does, rather than always
|
|
509
|
+
assuming 8-bit. u16 and half bucket to '16' (2 bytes/channel); every other precision (u32,
|
|
510
|
+
float, double) buckets to '32' (4+ bytes/channel, the most conservative bucket) -- this
|
|
511
|
+
bridge has no DOCUMENT_MEGAPIXEL_CAP entry finer than that."""
|
|
512
|
+
nick = precision_nick.lower()
|
|
513
|
+
if nick.startswith('u8'):
|
|
514
|
+
return '8'
|
|
515
|
+
if nick.startswith('u16') or nick.startswith('half'):
|
|
516
|
+
return '16'
|
|
517
|
+
return '32'
|
|
518
|
+
|
|
188
519
|
|
|
189
520
|
def require(args, name):
|
|
190
521
|
"""Fetch a required field from an op's `args`, raising ValueError naming it -- the
|
|
@@ -195,6 +526,24 @@ def require(args, name):
|
|
|
195
526
|
return args[name]
|
|
196
527
|
|
|
197
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
|
+
|
|
198
547
|
def require_bool(args, name):
|
|
199
548
|
"""Like `require`, but also rejects anything that isn't a real JSON boolean -- `bool("false")`
|
|
200
549
|
is `True` in Python (any non-empty string is truthy), so a caller that sends the STRING
|
|
@@ -270,7 +619,7 @@ def validate_resize_dims(width, height):
|
|
|
270
619
|
raise ValueError('width and height must each be at most %d px' % MAX_RESIZE_SIDE_PX)
|
|
271
620
|
megapixels = (width * height) / 1_000_000.0
|
|
272
621
|
if megapixels > MAX_RESIZE_MEGAPIXELS:
|
|
273
|
-
raise ValueError('resize target must be at most %
|
|
622
|
+
raise ValueError('resize target must be at most %g MP' % MAX_RESIZE_MEGAPIXELS)
|
|
274
623
|
return width, height
|
|
275
624
|
|
|
276
625
|
|
|
@@ -281,7 +630,13 @@ def validate_resize_dims(width, height):
|
|
|
281
630
|
# that picks its own bit depth up front (`open`/`resize`/etc. all work on whatever precision an
|
|
282
631
|
# already-open image happens to be), so it is the one place this DoS floor needs to vary by
|
|
283
632
|
# precision rather than assuming 8-bit throughout.
|
|
284
|
-
|
|
633
|
+
# A lowered EM_GIMP_MAX_MEGAPIXELS scales the 16/32-bit caps down with the 8-bit one, never above
|
|
634
|
+
# their own defaults.
|
|
635
|
+
DOCUMENT_MEGAPIXEL_CAP = {
|
|
636
|
+
'8': MAX_RESIZE_MEGAPIXELS,
|
|
637
|
+
'16': min(125, MAX_RESIZE_MEGAPIXELS / 2),
|
|
638
|
+
'32': min(60, MAX_RESIZE_MEGAPIXELS / 4),
|
|
639
|
+
}
|
|
285
640
|
|
|
286
641
|
|
|
287
642
|
def validate_document_dims(width, height, precision='8'):
|
|
@@ -295,14 +650,182 @@ def validate_document_dims(width, height, precision='8'):
|
|
|
295
650
|
cap = DOCUMENT_MEGAPIXEL_CAP[precision]
|
|
296
651
|
megapixels = (width * height) / 1_000_000.0
|
|
297
652
|
if megapixels > cap:
|
|
298
|
-
raise ValueError(
|
|
653
|
+
raise ValueError(
|
|
654
|
+
'%s %s-bit document must be at most %g MP'
|
|
655
|
+
% ('an' if precision == '8' else 'a', precision, cap)
|
|
656
|
+
)
|
|
299
657
|
return width, height
|
|
300
658
|
|
|
301
659
|
|
|
660
|
+
def validate_group_total_pixels(sizes, precision='8'):
|
|
661
|
+
"""A GROUP transform allocates every non-group descendant's own result buffer, so the SUM of
|
|
662
|
+
their predicted pixel counts (`sizes`: (width, height) pairs) must fit the same
|
|
663
|
+
precision-aware megapixel ceiling `validate_document_dims` applies to a single layer."""
|
|
664
|
+
cap = DOCUMENT_MEGAPIXEL_CAP[precision]
|
|
665
|
+
megapixels = sum(w * h for w, h in sizes) / 1_000_000.0
|
|
666
|
+
if megapixels > cap:
|
|
667
|
+
raise ValueError(
|
|
668
|
+
"the group's layers would total %.1f MP after this transform; the %s-bit limit is %d MP"
|
|
669
|
+
% (megapixels, precision, cap)
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
302
673
|
def validate_feather_px(value):
|
|
303
674
|
return validate_range('feather_px', value, 0.0, MAX_FEATHER_PX)
|
|
304
675
|
|
|
305
676
|
|
|
677
|
+
# ---- match_layer: statistics transfer as per-channel curves ---------------------------------
|
|
678
|
+
#
|
|
679
|
+
# Everything here is pure (no gi). Means and standard deviations are in GIMP's own histogram
|
|
680
|
+
# units (0-255 in the image's perceptual encoding, the same space a gimp:curves filter works in).
|
|
681
|
+
|
|
682
|
+
MATCH_TAG = 'match_layer'
|
|
683
|
+
MATCH_MODES = ('both', 'color', 'tone')
|
|
684
|
+
MATCH_REFERENCES = ('surround', 'below')
|
|
685
|
+
MATCH_DEFAULT_STRENGTH = 70.0
|
|
686
|
+
MATCH_MIN_PIXELS = 500
|
|
687
|
+
MATCH_GAIN_MIN = 0.5
|
|
688
|
+
MATCH_GAIN_MAX = 2.0
|
|
689
|
+
MATCH_MAX_EDGE_CONTRACT_PX = 20
|
|
690
|
+
MATCH_MAX_EDGE_FEATHER_PX = 50
|
|
691
|
+
MATCH_SURROUND_MIN_PX = 16
|
|
692
|
+
MATCH_SURROUND_MAX_PX = 400
|
|
693
|
+
# A std-dev below this (0-255 units) is treated as a flat region: its contrast ratio is
|
|
694
|
+
# meaningless, so the gain stays 1 and only the mean moves.
|
|
695
|
+
MATCH_FLAT_SD = 0.5
|
|
696
|
+
# Spacing of the helper points that keep a clamped curve on its line (see match_curve_points).
|
|
697
|
+
MATCH_CURVE_STEP = 16.0
|
|
698
|
+
# Measuring runs on a copy of the layer's region scaled so its long side is at most this: means
|
|
699
|
+
# and spreads survive the downscale, and growing the surround ring stays cheap at any size.
|
|
700
|
+
MATCH_MEASURE_MAX_SIDE = 2048
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def default_surround_px(layer_width, layer_height):
|
|
704
|
+
"""About 12% of the layer's longer side, clamped to 16..400 px."""
|
|
705
|
+
px = int(round(0.12 * max(layer_width, layer_height)))
|
|
706
|
+
return max(MATCH_SURROUND_MIN_PX, min(MATCH_SURROUND_MAX_PX, px))
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def validate_match_args(args, layer_width, layer_height):
|
|
710
|
+
"""The validated, defaulted `match_layer` options as a dict:
|
|
711
|
+
match/strength/reference/surround_px/edge_contract_px/edge_feather_px. `strength` is returned
|
|
712
|
+
as a 0..1 fraction under `s` as well."""
|
|
713
|
+
match = validate_choice('match', args.get('match') or 'both', MATCH_MODES)
|
|
714
|
+
reference = validate_choice('reference', args.get('reference') or 'surround', MATCH_REFERENCES)
|
|
715
|
+
strength = args.get('strength')
|
|
716
|
+
strength = validate_range(
|
|
717
|
+
'strength', MATCH_DEFAULT_STRENGTH if strength is None else strength, 0.0, 100.0
|
|
718
|
+
)
|
|
719
|
+
surround = args.get('surround_px')
|
|
720
|
+
surround = (
|
|
721
|
+
default_surround_px(layer_width, layer_height)
|
|
722
|
+
if surround is None
|
|
723
|
+
else validate_int_range('surround_px', surround, 1, MATCH_SURROUND_MAX_PX)
|
|
724
|
+
)
|
|
725
|
+
contract = args.get('edge_contract_px')
|
|
726
|
+
contract = validate_int_range(
|
|
727
|
+
'edge_contract_px', 0 if contract is None else contract, 0, MATCH_MAX_EDGE_CONTRACT_PX
|
|
728
|
+
)
|
|
729
|
+
feather = args.get('edge_feather_px')
|
|
730
|
+
feather = validate_int_range(
|
|
731
|
+
'edge_feather_px', 0 if feather is None else feather, 0, MATCH_MAX_EDGE_FEATHER_PX
|
|
732
|
+
)
|
|
733
|
+
return {
|
|
734
|
+
'match': match, 'reference': reference, 'strength': strength, 's': strength / 100.0,
|
|
735
|
+
'surround_px': surround, 'edge_contract_px': contract, 'edge_feather_px': feather,
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def match_transfer(mu_src, sd_src, mu_ref, sd_ref, s):
|
|
740
|
+
"""The Reinhard-style linear map moving the source's mean/std toward the reference's, scaled
|
|
741
|
+
by `s` (0..1): out = (in - mu_src) * gain + mu_src + shift, with
|
|
742
|
+
gain = clamp(1 + (sd_ref / sd_src - 1) * s, 0.5, 2.0) and shift = (mu_ref - mu_src) * s.
|
|
743
|
+
A flat source (or reference) keeps gain 1. Returns {gain, shift, mean_after, std_after};
|
|
744
|
+
the `_after` values ignore the 0-255 clamp of the curve."""
|
|
745
|
+
if sd_src < MATCH_FLAT_SD or sd_ref < MATCH_FLAT_SD:
|
|
746
|
+
ratio = 1.0
|
|
747
|
+
else:
|
|
748
|
+
ratio = sd_ref / sd_src
|
|
749
|
+
gain = max(MATCH_GAIN_MIN, min(MATCH_GAIN_MAX, 1.0 + (ratio - 1.0) * s))
|
|
750
|
+
shift = (mu_ref - mu_src) * s
|
|
751
|
+
return {
|
|
752
|
+
'gain': gain, 'shift': shift,
|
|
753
|
+
'mean_after': mu_src + shift, 'std_after': sd_src * gain,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def match_curve_points(mu_src, gain, shift):
|
|
758
|
+
"""The transfer map as gimp:curves [input, output] points (0-255, strictly ascending input).
|
|
759
|
+
The line y = gain * (x - mu_src) + mu_src + shift is clamped to 0..255. Unclamped it is just
|
|
760
|
+
its two endpoints (a two-point curve is exactly straight; gain 1 and shift 0 gives
|
|
761
|
+
[[0, 0], [255, 255]]). Where the line meets the clamp, a point is added there and helper
|
|
762
|
+
points every MATCH_CURVE_STEP along the sloped part: GIMP draws a smooth spline through the
|
|
763
|
+
points, and without the helpers it bows away from the line (measured: ~6 levels at the
|
|
764
|
+
middle of a clamped curve)."""
|
|
765
|
+
def y_at(x):
|
|
766
|
+
return gain * (x - mu_src) + mu_src + shift
|
|
767
|
+
|
|
768
|
+
def clamp(y):
|
|
769
|
+
return max(0.0, min(255.0, y))
|
|
770
|
+
|
|
771
|
+
y0, y1 = y_at(0.0), y_at(255.0)
|
|
772
|
+
xs = [0.0, 255.0]
|
|
773
|
+
if y0 < 0.0 or y1 > 255.0:
|
|
774
|
+
# y is strictly increasing (gain > 0): it crosses 0 once and 255 once.
|
|
775
|
+
low = 0.0 if y0 >= 0.0 else mu_src - (mu_src + shift) / gain
|
|
776
|
+
high = 255.0 if y1 <= 255.0 else mu_src + (255.0 - mu_src - shift) / gain
|
|
777
|
+
knots = [x for x in (low, high) if MATCH_CURVE_STEP / 4 < x < 255.0 - MATCH_CURVE_STEP / 4]
|
|
778
|
+
xs.extend(knots)
|
|
779
|
+
# Helpers fill the sloped part [low, high] only, kept clear of the knots.
|
|
780
|
+
x = MATCH_CURVE_STEP * (int(low // MATCH_CURVE_STEP) + 1)
|
|
781
|
+
while x < 255.0:
|
|
782
|
+
if low + MATCH_CURVE_STEP / 4 < x < high - MATCH_CURVE_STEP / 4 and x > MATCH_CURVE_STEP / 4:
|
|
783
|
+
xs.append(float(x))
|
|
784
|
+
x += MATCH_CURVE_STEP
|
|
785
|
+
xs = sorted(set(xs))
|
|
786
|
+
return [[round(x, 2), round(clamp(y_at(x)), 2)] for x in xs]
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def match_measure_scale(width, height):
|
|
790
|
+
"""The factor (0 < f <= 1) a measuring copy of a `width` x `height` region is scaled by."""
|
|
791
|
+
longest = max(width, height)
|
|
792
|
+
return 1.0 if longest <= MATCH_MEASURE_MAX_SIDE else MATCH_MEASURE_MAX_SIDE / float(longest)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def match_scaled_px(px, factor):
|
|
796
|
+
"""A pixel distance on a copy scaled by `factor`, never below 1 for a non-zero distance."""
|
|
797
|
+
return 0 if px <= 0 else max(1, int(round(px * factor)))
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def match_full_res_count(count, factor):
|
|
801
|
+
"""A pixel count measured on a copy scaled by `factor`, as the full-resolution count."""
|
|
802
|
+
return int(round(count / (factor * factor)))
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def histogram_scale(nick):
|
|
806
|
+
"""What a Drawable.histogram mean/std must be multiplied by to read 0-255: GIMP reports them
|
|
807
|
+
in 0-255 for 8-bit precisions and in 0-1 for every higher one."""
|
|
808
|
+
return 1.0 if nick.startswith('u8') else 255.0
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def is_linear_precision(nick):
|
|
812
|
+
"""Whether a GIMP precision nick ('u8-non-linear', 'float-linear', ...) stores linear light."""
|
|
813
|
+
return nick.endswith('-linear') and not nick.endswith('-non-linear')
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def match_roi(layer_x, layer_y, layer_width, layer_height, canvas_width, canvas_height, pad):
|
|
817
|
+
"""The layer's box grown by `pad` and clipped to the canvas, as (x, y, width, height); None
|
|
818
|
+
when the layer's own box does not touch the canvas."""
|
|
819
|
+
if (layer_x >= canvas_width or layer_y >= canvas_height
|
|
820
|
+
or layer_x + layer_width <= 0 or layer_y + layer_height <= 0):
|
|
821
|
+
return None
|
|
822
|
+
x0 = max(0, layer_x - pad)
|
|
823
|
+
y0 = max(0, layer_y - pad)
|
|
824
|
+
x1 = min(canvas_width, layer_x + layer_width + pad)
|
|
825
|
+
y1 = min(canvas_height, layer_y + layer_height + pad)
|
|
826
|
+
return x0, y0, x1 - x0, y1 - y0
|
|
827
|
+
|
|
828
|
+
|
|
306
829
|
_HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
|
|
307
830
|
|
|
308
831
|
# gimp_canvas's fill choices: the same white/black/transparent LAYER_FILLS a new layer gets, plus
|
|
@@ -319,6 +842,57 @@ def validate_canvas_fill(value):
|
|
|
319
842
|
)
|
|
320
843
|
|
|
321
844
|
|
|
845
|
+
def validate_hex_color(name, value):
|
|
846
|
+
"""A '#rrggbb' hex color -- the same `_HEX_COLOR_RE` gimp_canvas's own fill validates against,
|
|
847
|
+
reused here so a color field never silently reaches `Gegl.Color.new` with a string it cannot
|
|
848
|
+
parse (which would otherwise fall back to black)."""
|
|
849
|
+
if not isinstance(value, str) or not _HEX_COLOR_RE.fullmatch(value):
|
|
850
|
+
raise ValueError('%s must be a "#rrggbb" hex color, got %r' % (name, value))
|
|
851
|
+
return value
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _round_half_up(value):
|
|
855
|
+
"""Round half away from zero, unlike Python's builtin `round()` (round-half-to-even:
|
|
856
|
+
`round(0.5) == 0`, `round(2.5) == 2`) -- surprising for a user-facing pixel radius, where
|
|
857
|
+
"4.5 px" should become 5, not 4."""
|
|
858
|
+
return math.floor(value + 0.5) if value >= 0 else math.ceil(value - 0.5)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def validate_positive_px(name, value, max_px=MAX_FEATHER_PX):
|
|
862
|
+
"""A strictly positive pixel radius (expand/contract/border/feather). Rounds half-up to the
|
|
863
|
+
nearest whole pixel FIRST, then refuses anything that is not strictly positive or exceeds
|
|
864
|
+
max_px -- so a value that ROUNDS to 0 (e.g. 0.3) is refused, same as an outright 0 or
|
|
865
|
+
negative, rather than silently accepted as a no-op radius."""
|
|
866
|
+
rounded = int(_round_half_up(float(value)))
|
|
867
|
+
if not 0 < rounded <= max_px:
|
|
868
|
+
raise ValueError('%s must round to a value greater than 0 and at most %s' % (name, max_px))
|
|
869
|
+
return rounded
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
# load_mask's own DoS floor on a SOURCE mask image's dimensions. Checked right after
|
|
873
|
+
# `Gimp.file_load` decodes the file (GIMP's Python environment has no cheap way to read a PNG/
|
|
874
|
+
# JPEG header's own width/height without decoding -- GdkPixbuf is not bound in this environment),
|
|
875
|
+
# so it bounds the IN-MEMORY size before the heavier work that follows (alpha compositing, scale,
|
|
876
|
+
# flatten), not the decode itself. Reuses the same cap `validate_resize_dims` applies to a resize
|
|
877
|
+
# target, since both are "how big a single in-memory image may get" limits.
|
|
878
|
+
def validate_loaded_mask_dims(width, height):
|
|
879
|
+
return validate_resize_dims(width, height)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def compute_mask_paste_rect(ox, oy, tw, th, img_width, img_height):
|
|
883
|
+
"""Where a `tw`x`th` mask (already scaled to its target -- the whole image, or one layer's own
|
|
884
|
+
bounds, at offset `(ox, oy)`) actually lands inside the `img_width`x`img_height` canvas: the
|
|
885
|
+
intersection of [ox, ox+tw) x [oy, oy+th) with [0, img_width) x [0, img_height). Pure and
|
|
886
|
+
gi-free so the off-canvas cases (a negative offset, or a layer hanging off the right/bottom
|
|
887
|
+
edge) are unit-tested without GIMP. Returns (x0, y0, x1, y1); x1 <= x0 or y1 <= y0 means the
|
|
888
|
+
mask lands ENTIRELY off-canvas (nothing to paste -- the channel stays all-black there)."""
|
|
889
|
+
x0 = max(0, ox)
|
|
890
|
+
y0 = max(0, oy)
|
|
891
|
+
x1 = min(img_width, ox + tw)
|
|
892
|
+
y1 = min(img_height, oy + th)
|
|
893
|
+
return x0, y0, x1, y1
|
|
894
|
+
|
|
895
|
+
|
|
322
896
|
# gimp_canvas's anchor grid -> the fraction of the GROWTH (new size minus old size) that lands
|
|
323
897
|
# BEFORE the existing content on each axis. 0.0 pins that edge (no padding there); 1.0 puts all
|
|
324
898
|
# the padding there instead; 0.5 splits it evenly. Keyed by the full 3x3 grid a Photoshop-style
|
|
@@ -350,6 +924,69 @@ def canvas_anchor_offset(anchor, old_width, old_height, new_width, new_height):
|
|
|
350
924
|
)
|
|
351
925
|
|
|
352
926
|
|
|
927
|
+
# EXIF Orientation (1-8) -> the steps that turn the stored pixels upright, in order. Each step is
|
|
928
|
+
# 'cw90' / 'cw180' / 'cw270' (clockwise rotation), 'flip_h' (mirror left-right) or 'flip_v'
|
|
929
|
+
# (mirror top-bottom). 1 (normal) needs none.
|
|
930
|
+
EXIF_ORIENTATION_STEPS = {
|
|
931
|
+
1: (),
|
|
932
|
+
2: ('flip_h',),
|
|
933
|
+
3: ('cw180',),
|
|
934
|
+
4: ('flip_v',),
|
|
935
|
+
5: ('cw90', 'flip_h'),
|
|
936
|
+
6: ('cw90',),
|
|
937
|
+
7: ('cw90', 'flip_v'),
|
|
938
|
+
8: ('cw270',),
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def parse_exif_orientation(raw):
|
|
943
|
+
"""The EXIF Orientation value (1-8) from a metadata tag's raw value, or None when it is
|
|
944
|
+
missing, non-numeric or out of range -- a broken tag is ignored, never an error."""
|
|
945
|
+
try:
|
|
946
|
+
value = int(str(raw).strip())
|
|
947
|
+
except (TypeError, ValueError):
|
|
948
|
+
return None
|
|
949
|
+
return value if value in EXIF_ORIENTATION_STEPS else None
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def exif_orientation_steps(orientation):
|
|
953
|
+
"""The upright-making steps for an EXIF Orientation value; () for None, 1 or anything else."""
|
|
954
|
+
return EXIF_ORIENTATION_STEPS.get(orientation, ())
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
EXIF_ORIENTATION_TAG = 'Exif.Image.Orientation'
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def read_exif_orientation(metadata):
|
|
961
|
+
"""The orientation (1-8) a GimpMetadata-like object carries, or None when `metadata` is None,
|
|
962
|
+
has no usable tag, or reading it raises."""
|
|
963
|
+
if metadata is None:
|
|
964
|
+
return None
|
|
965
|
+
try:
|
|
966
|
+
return parse_exif_orientation(metadata.try_get_tag_string(EXIF_ORIENTATION_TAG))
|
|
967
|
+
except Exception:
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
# Every tag a viewer may read orientation from; the EXIF one is the one this bridge applies.
|
|
972
|
+
ORIENTATION_TAGS = (EXIF_ORIENTATION_TAG, 'Xmp.tiff.Orientation', 'Exif.Thumbnail.Orientation')
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def clear_exif_orientation(metadata):
|
|
976
|
+
"""Removes the orientation tags from `metadata` (absent reads as normal); True when the EXIF
|
|
977
|
+
one was cleared. Clearing is used rather than writing 1: GIMP's metadata object can silently
|
|
978
|
+
ignore a write of that tag while still reporting success."""
|
|
979
|
+
cleared = False
|
|
980
|
+
for tag in ORIENTATION_TAGS:
|
|
981
|
+
try:
|
|
982
|
+
ok = bool(metadata.try_clear_tag(tag))
|
|
983
|
+
except Exception:
|
|
984
|
+
ok = False
|
|
985
|
+
if tag == EXIF_ORIENTATION_TAG:
|
|
986
|
+
cleared = ok
|
|
987
|
+
return cleared
|
|
988
|
+
|
|
989
|
+
|
|
353
990
|
def pct_to_unit(name, value, lo=-100.0, hi=100.0):
|
|
354
991
|
"""User-facing -100..100 (or a narrower lo..hi) percent-like value -> the -1..1 unit
|
|
355
992
|
GEGL/GIMP properties in this family use."""
|
|
@@ -492,10 +1129,18 @@ def build_saturation_params(args, defaults):
|
|
|
492
1129
|
}
|
|
493
1130
|
|
|
494
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
|
+
|
|
495
1139
|
def build_vibrance_params(args, defaults):
|
|
496
1140
|
return {
|
|
497
1141
|
'vibrance': resolve_field(
|
|
498
|
-
args, 'vibrance', defaults, 'vibrance',
|
|
1142
|
+
args, 'vibrance', defaults, 'vibrance',
|
|
1143
|
+
lambda v: validate_range('vibrance', v, -100.0, 100.0) / VIBRANCE_GEGL_DIVISOR,
|
|
499
1144
|
),
|
|
500
1145
|
'saturation': resolve_field(
|
|
501
1146
|
args, 'saturation', defaults, 'saturation', lambda v: validate_range('saturation', v, 0.0, 10.0)
|
|
@@ -609,13 +1254,35 @@ EFFECT_OPERATIONS = {
|
|
|
609
1254
|
'lens_blur': 'gegl:focus-blur',
|
|
610
1255
|
'add_noise': 'gegl:noise-rgb',
|
|
611
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',
|
|
612
1262
|
}
|
|
613
1263
|
|
|
614
1264
|
# The inverse of ADJUST_OPERATIONS and EFFECT_OPERATIONS together, for a ledger record written
|
|
615
1265
|
# without a `type` field -- one shared map, since `op_list_filters` looks a record's operation up
|
|
616
|
-
# 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).
|
|
617
1271
|
OPERATION_TYPES = {operation: type_ for type_, operation in ADJUST_OPERATIONS.items()}
|
|
618
|
-
|
|
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'))
|
|
619
1286
|
|
|
620
1287
|
# Every validate_range/validate_int_range call below uses `val`, not `v`, as its lambda's
|
|
621
1288
|
# parameter name -- deliberately, so it is NOT matched by gimp-adjustment-tools.test.ts's own
|
|
@@ -770,6 +1437,79 @@ def build_drop_shadow_params(args, defaults):
|
|
|
770
1437
|
}
|
|
771
1438
|
|
|
772
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
|
+
|
|
773
1513
|
EFFECT_PARAM_BUILDERS = {
|
|
774
1514
|
'vignette': build_vignette_params,
|
|
775
1515
|
'black_white': build_black_white_params,
|
|
@@ -777,6 +1517,9 @@ EFFECT_PARAM_BUILDERS = {
|
|
|
777
1517
|
'lens_blur': build_lens_blur_params,
|
|
778
1518
|
'add_noise': build_add_noise_params,
|
|
779
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,
|
|
780
1523
|
}
|
|
781
1524
|
|
|
782
1525
|
# Creation-time defaults, already in GEGL-property units -- probed live via `describe_operation`
|
|
@@ -790,6 +1533,9 @@ EFFECT_CREATE_DEFAULTS = {
|
|
|
790
1533
|
'lens_blur': {'blur-radius': 25.0, 'highlight-factor': 0.0},
|
|
791
1534
|
'add_noise': {'red': 0.2, 'green': 0.2, 'blue': 0.2, 'alpha': 0.0, 'seed': 0},
|
|
792
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},
|
|
793
1539
|
}
|
|
794
1540
|
|
|
795
1541
|
|
|
@@ -846,7 +1592,7 @@ USER_FIELDS = {
|
|
|
846
1592
|
),
|
|
847
1593
|
'saturation': (('scale', 'scale', _same),),
|
|
848
1594
|
'vibrance': (
|
|
849
|
-
('vibrance', 'vibrance',
|
|
1595
|
+
('vibrance', 'vibrance', _scaled(VIBRANCE_GEGL_DIVISOR)),
|
|
850
1596
|
('saturation', 'saturation', _same),
|
|
851
1597
|
),
|
|
852
1598
|
'sharpen': (
|
|
@@ -889,6 +1635,20 @@ USER_FIELDS = {
|
|
|
889
1635
|
('radius', 'radius', _same),
|
|
890
1636
|
('opacity', 'opacity', _same),
|
|
891
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
|
+
),
|
|
892
1652
|
}
|
|
893
1653
|
|
|
894
1654
|
CURVES_USER_FIELDS = ('channel', 'points')
|
|
@@ -975,7 +1735,6 @@ def json_safe(value):
|
|
|
975
1735
|
# operations this engine actually uses.
|
|
976
1736
|
ALLOWED_DESCRIBE_OPERATIONS = frozenset(ADJUST_OPERATIONS.values()) | frozenset(EFFECT_OPERATIONS.values())
|
|
977
1737
|
|
|
978
|
-
|
|
979
1738
|
# ---- geometry-op masked-filter detection (pure logic; ops.py supplies the live GIMP state) -----
|
|
980
1739
|
|
|
981
1740
|
|
|
@@ -1208,7 +1967,7 @@ def flip_effect_params(operation, params, orientation):
|
|
|
1208
1967
|
else:
|
|
1209
1968
|
params['angle'] = _round_transform_value(_wrap_angle_deg(-params['angle']))
|
|
1210
1969
|
return params
|
|
1211
|
-
if operation == 'gegl:dropshadow':
|
|
1970
|
+
if operation == 'gegl:dropshadow' and 'x' in params: # outline / outer_glow have no offset
|
|
1212
1971
|
params = dict(params)
|
|
1213
1972
|
if orientation == 'horizontal':
|
|
1214
1973
|
params['x'] = _round_transform_value(-params['x'])
|
|
@@ -1245,7 +2004,7 @@ def rotate_effect_params(operation, params, degrees, layer_width, layer_height):
|
|
|
1245
2004
|
canonical_degrees = _right_angle_k(degrees) * 90.0
|
|
1246
2005
|
params['angle'] = _round_transform_value(_wrap_angle_deg(params['angle'] + canonical_degrees))
|
|
1247
2006
|
return params
|
|
1248
|
-
if operation == 'gegl:dropshadow':
|
|
2007
|
+
if operation == 'gegl:dropshadow' and 'x' in params:
|
|
1249
2008
|
params = dict(params)
|
|
1250
2009
|
cos_t, sin_t = _right_angle_cos_sin(degrees)
|
|
1251
2010
|
dx, dy = params['x'], params['y']
|
|
@@ -1295,9 +2054,18 @@ def resize_effect_params(operation, params, scale_x, scale_y):
|
|
|
1295
2054
|
if operation == 'gegl:dropshadow':
|
|
1296
2055
|
params = dict(params)
|
|
1297
2056
|
isotropic_scale = math.sqrt(scale_x * scale_y)
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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)
|
|
1301
2069
|
return params
|
|
1302
2070
|
return params
|
|
1303
2071
|
|
|
@@ -1311,7 +2079,12 @@ EFFECT_TRANSFORM_BOUNDS = {
|
|
|
1311
2079
|
'gegl:vignette': {'x': (0.0, 1.0), 'y': (0.0, 1.0)},
|
|
1312
2080
|
'gegl:motion-blur-linear': {'length': (0.0, 1000.0), 'angle': (-180.0, 180.0)},
|
|
1313
2081
|
'gegl:focus-blur': {'blur-radius': (0.0, 150.0)},
|
|
1314
|
-
'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)},
|
|
1315
2088
|
}
|
|
1316
2089
|
|
|
1317
2090
|
# The user-facing (gimp_add_effect) field name for each EFFECT_TRANSFORM_BOUNDS entry -- so a
|
|
@@ -1328,10 +2101,44 @@ EFFECT_TRANSFORM_FIELD_NAMES = {
|
|
|
1328
2101
|
('gegl:dropshadow', 'x'): 'offset_x',
|
|
1329
2102
|
('gegl:dropshadow', 'y'): 'offset_y',
|
|
1330
2103
|
('gegl:dropshadow', 'radius'): 'radius',
|
|
2104
|
+
('gegl:dropshadow', 'grow-radius'): 'width/spread',
|
|
2105
|
+
('gegl:bilateral-filter', 'blur-radius'): 'radius',
|
|
1331
2106
|
}
|
|
1332
2107
|
|
|
1333
2108
|
|
|
1334
|
-
|
|
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,
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
|
|
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):
|
|
1335
2142
|
"""Refuse `op_name` (the geometry op about to run) outright if any of `new_params` -- already
|
|
1336
2143
|
computed for `operation` by flip_effect_params/rotate_effect_params/resize_effect_params --
|
|
1337
2144
|
would leave the range its own create/re-edit path enforces. Called BEFORE the geometry
|
|
@@ -1339,10 +2146,12 @@ def validate_effect_transform(op_name, operation, filter_name, new_params):
|
|
|
1339
2146
|
range never gets a chance to silently clamp via GObject's own property setter instead of a
|
|
1340
2147
|
clear, actionable refusal -- and the geometry op never partially applies. Names the filter
|
|
1341
2148
|
(`filter_name`, the same way `_refuse_if_masked_filters` does) and the field in gimp_add_
|
|
1342
|
-
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."""
|
|
1343
2151
|
for prop, (lo, hi) in EFFECT_TRANSFORM_BOUNDS.get(operation, {}).items():
|
|
1344
2152
|
if prop not in new_params:
|
|
1345
2153
|
continue
|
|
2154
|
+
hi = min(hi, EFFECT_TRANSFORM_CEILINGS.get((type_, prop), hi))
|
|
1346
2155
|
value = new_params[prop]
|
|
1347
2156
|
if not lo <= value <= hi:
|
|
1348
2157
|
field = EFFECT_TRANSFORM_FIELD_NAMES.get((operation, prop), prop)
|
|
@@ -1358,6 +2167,155 @@ def validate_effect_transform(op_name, operation, filter_name, new_params):
|
|
|
1358
2167
|
)
|
|
1359
2168
|
|
|
1360
2169
|
|
|
2170
|
+
|
|
2171
|
+
# ---- gimp_transform_layer: pure geometry math (fit/fill scale, matrix composition, bounds) ----
|
|
2172
|
+
#
|
|
2173
|
+
# A layer-level affine transform (scale/rotate/skew/free), distinct from the whole-CANVAS
|
|
2174
|
+
# geometry ops above and from the direction/position-dependent EFFECT param remapping above.
|
|
2175
|
+
# `Item.transform_matrix` (ops.py) applies the given 3x3 matrix directly in ABSOLUTE
|
|
2176
|
+
# document-pixel coordinates -- verified live (GIMP 3.2.6): a matrix built by
|
|
2177
|
+
# `compose_layer_matrix` below, applied to a layer at a known offset, moved it to exactly the
|
|
2178
|
+
# bounding box this module's own `transformed_bounds` predicts for the same matrix and corners
|
|
2179
|
+
# (within floating-point rounding). So every matrix built here already carries whatever
|
|
2180
|
+
# translation is needed to anchor the transform at the layer's own center; there is no separate
|
|
2181
|
+
# "local" coordinate frame to convert into or out of first.
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
def fit_scale_fraction(layer_width, layer_height, canvas_width, canvas_height, mode):
|
|
2185
|
+
"""The uniform scale fraction (1.0 = 100%) that makes a `layer_width` x `layer_height`
|
|
2186
|
+
rectangle fit inside (mode='fit', letterbox -- the SMALLER of the two axis ratios) or fill
|
|
2187
|
+
(mode='fill', cover -- the LARGER) a `canvas_width` x `canvas_height` canvas, preserving
|
|
2188
|
+
aspect. `op_transform_layer`'s `fit` scales by this fraction and centers the result; fitting
|
|
2189
|
+
an already-fitted layer computes a fraction of 1.0, a no-op scale -- what makes that op
|
|
2190
|
+
idempotent."""
|
|
2191
|
+
if mode not in ('fit', 'fill'):
|
|
2192
|
+
raise ValueError("mode must be one of 'fit', 'fill'")
|
|
2193
|
+
width_ratio = canvas_width / float(layer_width)
|
|
2194
|
+
height_ratio = canvas_height / float(layer_height)
|
|
2195
|
+
return min(width_ratio, height_ratio) if mode == 'fit' else max(width_ratio, height_ratio)
|
|
2196
|
+
|
|
2197
|
+
|
|
2198
|
+
def _mat_mul(a, b):
|
|
2199
|
+
"""3x3 matrix product, both `a` and `b` flat row-major 9-lists (the same layout
|
|
2200
|
+
`Item.transform_matrix`'s own 9 positional args use), applied to a column vector [x,y,1] as
|
|
2201
|
+
p' = a*(b*p) -- `b` is the transform applied FIRST."""
|
|
2202
|
+
return [
|
|
2203
|
+
a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
|
|
2204
|
+
a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
|
|
2205
|
+
a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
|
|
2206
|
+
a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
|
|
2207
|
+
a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
|
|
2208
|
+
a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
|
|
2209
|
+
a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
|
|
2210
|
+
a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
|
|
2211
|
+
a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
|
|
2212
|
+
]
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
def _mat_translate(tx, ty):
|
|
2216
|
+
return [1.0, 0.0, tx, 0.0, 1.0, ty, 0.0, 0.0, 1.0]
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
def _mat_scale(sx, sy):
|
|
2220
|
+
return [sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def _mat_rotate(degrees):
|
|
2224
|
+
theta = math.radians(degrees)
|
|
2225
|
+
cos_t, sin_t = math.cos(theta), math.sin(theta)
|
|
2226
|
+
return [cos_t, -sin_t, 0.0, sin_t, cos_t, 0.0, 0.0, 0.0, 1.0]
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
def _mat_shear_h(skew_h_degrees):
|
|
2230
|
+
"""A pure horizontal shear: x' = x - tan(skew_h_degrees)*y, y' = y -- determinant exactly 1
|
|
2231
|
+
for any angle. Positive `skew_h_degrees` slants the top edge right (a point above center,
|
|
2232
|
+
smaller y, moves toward +x) -- the convention `gimp_transform_layer`'s own schema documents,
|
|
2233
|
+
and the one `ps_transform_layer`'s op=skew uses."""
|
|
2234
|
+
return [1.0, -math.tan(math.radians(skew_h_degrees)), 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def _mat_shear_v(skew_v_degrees):
|
|
2238
|
+
"""A pure vertical shear: x' = x, y' = -tan(skew_v_degrees)*x + y -- determinant exactly 1
|
|
2239
|
+
for any angle. Positive `skew_v_degrees` slants the left edge down (a point left of center,
|
|
2240
|
+
smaller x, moves toward +y)."""
|
|
2241
|
+
return [1.0, 0.0, 0.0, -math.tan(math.radians(skew_v_degrees)), 1.0, 0.0, 0.0, 0.0, 1.0]
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
|
+
def _mat_determinant(m):
|
|
2245
|
+
"""The determinant of `m`'s linear (non-translation) 2x2 part -- the translation column
|
|
2246
|
+
(indices 2, 5) never affects it. Zero means the transform collapses the rectangle to a line
|
|
2247
|
+
or a point; negative means it mirrors (flips handedness) rather than purely scaling/
|
|
2248
|
+
rotating/shearing it."""
|
|
2249
|
+
return m[0] * m[4] - m[1] * m[3]
|
|
2250
|
+
|
|
2251
|
+
|
|
2252
|
+
# A composed matrix below this |determinant| is treated the same as an exact 0: numerically
|
|
2253
|
+
# indistinguishable from a transform that collapses the layer to a line, so refused outright
|
|
2254
|
+
# rather than handed to `Item.transform_matrix` to silently produce a degenerate result.
|
|
2255
|
+
TRANSFORM_LAYER_MIN_DETERMINANT = 1e-6
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
def compose_layer_matrix(cx, cy, scale_x_percent, scale_y_percent, degrees,
|
|
2259
|
+
skew_h_degrees, skew_v_degrees, offset_x, offset_y):
|
|
2260
|
+
"""The 9 row-major coefficients for `Item.transform_matrix`: a scale (percent, 100 =
|
|
2261
|
+
unchanged), then a skew (skew_h_degrees/skew_v_degrees, composed as two independent real
|
|
2262
|
+
shears -- Sh_v . Sh_h, each with its OWN determinant of exactly 1, so the composed skew's
|
|
2263
|
+
determinant is always exactly 1 too, for any angle), then a rotation (degrees, clockwise),
|
|
2264
|
+
all anchored at the layer's own center (`cx`, `cy`, document pixels), followed by an absolute
|
|
2265
|
+
translation (`offset_x`, `offset_y`). Never the single combined-shear matrix
|
|
2266
|
+
[[1,-tan(h)],[-tan(v),1]] -- that matrix's OWN determinant is `1 - tan(h)*tan(v)`, which
|
|
2267
|
+
reaches exactly 0 at h=v=45 (collapsing the rectangle to a line) and goes NEGATIVE past that
|
|
2268
|
+
(silently mirroring it instead of shearing it). `op_transform_layer`'s `free` op calls this
|
|
2269
|
+
directly (scale + degrees + offset, no skew); `skew` calls it with scale 100/100, degrees 0,
|
|
2270
|
+
offset 0/0; `rotate`'s own bounds check (not its actual transform, which goes through
|
|
2271
|
+
`Item.transform_rotate` instead -- see ops.py) reuses it with scale 100/100, skew 0/0, offset
|
|
2272
|
+
0/0 purely to predict the post-rotation bounding box via `transformed_bounds`.
|
|
2273
|
+
|
|
2274
|
+
Raises if the FINAL composed matrix's determinant is below `TRANSFORM_LAYER_MIN_DETERMINANT`
|
|
2275
|
+
or negative -- scale alone is already bounded away from both by its own 1..10000% range, but
|
|
2276
|
+
this is the belt-and-braces check against the composed result as a whole, not just its own
|
|
2277
|
+
skew component."""
|
|
2278
|
+
m = _mat_translate(-cx, -cy)
|
|
2279
|
+
m = _mat_mul(_mat_scale(scale_x_percent / 100.0, scale_y_percent / 100.0), m)
|
|
2280
|
+
m = _mat_mul(_mat_shear_v(skew_v_degrees), _mat_mul(_mat_shear_h(skew_h_degrees), m))
|
|
2281
|
+
m = _mat_mul(_mat_rotate(degrees), m)
|
|
2282
|
+
m = _mat_mul(_mat_translate(cx, cy), m)
|
|
2283
|
+
m = _mat_mul(_mat_translate(offset_x, offset_y), m)
|
|
2284
|
+
det = _mat_determinant(m)
|
|
2285
|
+
if not math.isfinite(det) or det < 0 or abs(det) < TRANSFORM_LAYER_MIN_DETERMINANT:
|
|
2286
|
+
raise ValueError(
|
|
2287
|
+
'this combination of scale/skew/rotate collapses or mirrors the layer (determinant '
|
|
2288
|
+
'%.6g) instead of transforming it -- reduce the scale or skew and try again' % det
|
|
2289
|
+
)
|
|
2290
|
+
return m
|
|
2291
|
+
|
|
2292
|
+
|
|
2293
|
+
def transformed_bounds(matrix, x, y, width, height):
|
|
2294
|
+
"""The axis-aligned bounding box of a `width` x `height` rectangle at document-pixel origin
|
|
2295
|
+
(`x`, `y`), after mapping each of its four corners through `matrix` (9 row-major
|
|
2296
|
+
coefficients, `Item.transform_matrix`'s own layout) -- what the transformed layer's new
|
|
2297
|
+
offsets/width/height will measure to. Used to validate a rotate/skew/free transform against
|
|
2298
|
+
the same size cap `validate_resize_dims` enforces, BEFORE any pixel actually moves. Returns
|
|
2299
|
+
(new_x, new_y, new_width, new_height)."""
|
|
2300
|
+
corners = ((x, y), (x + width, y), (x, y + height), (x + width, y + height))
|
|
2301
|
+
xs = [matrix[0] * cx + matrix[1] * cy + matrix[2] for cx, cy in corners]
|
|
2302
|
+
ys = [matrix[3] * cx + matrix[4] * cy + matrix[5] for cx, cy in corners]
|
|
2303
|
+
min_x, max_x = min(xs), max(xs)
|
|
2304
|
+
min_y, max_y = min(ys), max(ys)
|
|
2305
|
+
return min_x, min_y, max_x - min_x, max_y - min_y
|
|
2306
|
+
|
|
2307
|
+
|
|
2308
|
+
def validate_transformed_layer_size(width, height):
|
|
2309
|
+
"""Refuses a scale/fit whose rounded target is under 1px in either dimension. Without this
|
|
2310
|
+
GIMP is handed a zero-size rectangle, returns nothing, and the caller reports a misleading
|
|
2311
|
+
"lock-position or lock-content" failure for a layer that was never locked."""
|
|
2312
|
+
if width < 1 or height < 1:
|
|
2313
|
+
raise ValueError(
|
|
2314
|
+
'the transform would shrink the layer to %dx%d px after rounding; use a larger scale'
|
|
2315
|
+
% (width, height)
|
|
2316
|
+
)
|
|
2317
|
+
|
|
2318
|
+
|
|
1361
2319
|
def region_to_proxy_px(region, scale):
|
|
1362
2320
|
"""Map a document-pixel `region` dict into proxy-pixel integer bounds at the proxy's
|
|
1363
2321
|
`scale` factor (0 < scale <= 1). Returns (x, y, width, height); width/height are at least
|
|
@@ -1370,6 +2328,13 @@ def region_to_proxy_px(region, scale):
|
|
|
1370
2328
|
return x, y, w, h
|
|
1371
2329
|
|
|
1372
2330
|
|
|
2331
|
+
def visible_image_ids(all_ids, hidden_ids):
|
|
2332
|
+
"""The ids in `all_ids` that are real documents: every id in `hidden_ids` (the bridge's own
|
|
2333
|
+
preview-proxy images, which are live GIMP images but not the user's) is dropped, order kept."""
|
|
2334
|
+
hidden = set(hidden_ids)
|
|
2335
|
+
return [i for i in all_ids if i not in hidden]
|
|
2336
|
+
|
|
2337
|
+
|
|
1373
2338
|
def read_pgm(raw):
|
|
1374
2339
|
"""Decode minimal binary PGM (P5, maxval 255) bytes. Returns (width, height, data).
|
|
1375
2340
|
|
|
@@ -1506,10 +2471,10 @@ def parse_ledger(raw):
|
|
|
1506
2471
|
parasite bytes ARE the filters map directly (no wrapper). Treated as
|
|
1507
2472
|
v1-compatible so files written before the ledger was versioned keep
|
|
1508
2473
|
working.
|
|
1509
|
-
- Versioned + "v"
|
|
2474
|
+
- Versioned + "v" in 1..LEDGER_VERSION: "filters" is the map; any OTHER
|
|
1510
2475
|
top-level key is returned as `unknown_top_level` so a future writer's
|
|
1511
2476
|
extra fields round-trip untouched through serialize_ledger.
|
|
1512
|
-
- Versioned + "v"
|
|
2477
|
+
- Versioned + "v" outside that range (in practice: greater, from a future
|
|
1513
2478
|
writer): every filter is treated as absent — the caller falls back to
|
|
1514
2479
|
readback for all of them, per the documented contract that we never
|
|
1515
2480
|
guess at a schema we don't recognize. `unknown_top_level` still
|
|
@@ -1545,7 +2510,7 @@ def parse_ledger(raw):
|
|
|
1545
2510
|
return _validate_filters(raw_filters), {}, raw_filters
|
|
1546
2511
|
unknown = {k: v for k, v in doc.items() if k not in ('v', 'writer', 'filters')}
|
|
1547
2512
|
raw_filters = dict(doc['filters'])
|
|
1548
|
-
if doc['v']
|
|
2513
|
+
if not 1 <= doc['v'] <= LEDGER_VERSION:
|
|
1549
2514
|
return {}, unknown, raw_filters
|
|
1550
2515
|
return _validate_filters(raw_filters), unknown, raw_filters
|
|
1551
2516
|
|
|
@@ -1593,12 +2558,19 @@ def ledger_is_newer_version(raw):
|
|
|
1593
2558
|
return doc['v'] > LEDGER_VERSION
|
|
1594
2559
|
|
|
1595
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
|
+
|
|
1596
2568
|
def serialize_ledger(filters_by_name, unknown_top_level=None):
|
|
1597
|
-
"""Serialize filters_by_name as
|
|
2569
|
+
"""Serialize filters_by_name as a ledger at `ledger_write_version`, preserving any
|
|
1598
2570
|
unknown_top_level keys carried over from parse_ledger byte-for-byte
|
|
1599
2571
|
(as JSON values -- not literal bytes, but nothing under them is rewritten)."""
|
|
1600
2572
|
doc = dict(unknown_top_level or {})
|
|
1601
|
-
doc['v'] =
|
|
2573
|
+
doc['v'] = ledger_write_version(filters_by_name)
|
|
1602
2574
|
doc['writer'] = LEDGER_WRITER
|
|
1603
2575
|
doc['filters'] = filters_by_name
|
|
1604
2576
|
return json.dumps(doc)
|
|
@@ -1688,13 +2660,16 @@ def list_requests(rpc_dir):
|
|
|
1688
2660
|
return [name for _req_id, name in numbered]
|
|
1689
2661
|
|
|
1690
2662
|
|
|
1691
|
-
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):
|
|
1692
2664
|
"""Read + parse a request file. Retries ONLY on OSError (e.g. a
|
|
1693
2665
|
PermissionError from anti-virus or a file indexer holding a brief lock on
|
|
1694
2666
|
a just-renamed file on Windows) -- a JSONDecodeError means the content
|
|
1695
2667
|
itself is bad and is raised immediately; retrying that would just mask a
|
|
1696
2668
|
genuinely malformed file rather than a transient lock.
|
|
1697
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
|
+
|
|
1698
2673
|
`on_retry(exc, attempt)`, if given, is called right before each sleep --
|
|
1699
2674
|
the bridge logs the exception type through it; tests can assert on it
|
|
1700
2675
|
without capturing stderr."""
|
|
@@ -1707,9 +2682,11 @@ def read_request(req_path, attempts=5, delay_s=0.02, on_retry=None):
|
|
|
1707
2682
|
raise
|
|
1708
2683
|
except OSError as e:
|
|
1709
2684
|
last_err = e
|
|
2685
|
+
if attempt == attempts - 1:
|
|
2686
|
+
break
|
|
1710
2687
|
if on_retry:
|
|
1711
2688
|
on_retry(e, attempt)
|
|
1712
|
-
time.sleep(delay_s)
|
|
2689
|
+
time.sleep(min(delay_s * (2 ** attempt), max_delay_s))
|
|
1713
2690
|
raise last_err
|
|
1714
2691
|
|
|
1715
2692
|
|
|
@@ -1986,3 +2963,790 @@ def metadata_strip_settings(fmt, prop_names):
|
|
|
1986
2963
|
if fmt == 'tiff' and 'save-geotiff' in names:
|
|
1987
2964
|
settings.append('save-geotiff')
|
|
1988
2965
|
return settings
|
|
2966
|
+
|
|
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
|
+
|
|
3031
|
+
# ---- text layers (gimp_text) -----------------------------------------------------------------
|
|
3032
|
+
|
|
3033
|
+
TEXT_MAX_CHARS = 2000
|
|
3034
|
+
TEXT_MIN_FONT_PT = 1
|
|
3035
|
+
TEXT_MAX_FONT_PT = 1296
|
|
3036
|
+
TEXT_DEFAULT_FONT_PT = 24
|
|
3037
|
+
TEXT_FONT_LIST_CAP = 200
|
|
3038
|
+
FONT_SUGGESTION_LIMIT = 8
|
|
3039
|
+
DEFAULT_TEXT_FONT = 'Sans-serif'
|
|
3040
|
+
DEFAULT_RESOLUTION_PPI = 72.0
|
|
3041
|
+
|
|
3042
|
+
# gimp_text's `alignment` enum is ps_text's. GIMP's own justification has only left / right /
|
|
3043
|
+
# center / fill, so the three "last line" variants Photoshop adds are refused rather than
|
|
3044
|
+
# approximated.
|
|
3045
|
+
TEXT_ALIGNMENTS = {'LEFT': 'left', 'CENTER': 'center', 'RIGHT': 'right', 'FULLYJUSTIFIED': 'fill'}
|
|
3046
|
+
TEXT_UNSUPPORTED_ALIGNMENTS = ('LEFTJUSTIFIED', 'CENTERJUSTIFIED', 'RIGHTJUSTIFIED')
|
|
3047
|
+
_JUSTIFICATION_TO_ALIGNMENT = {nick: name for name, nick in TEXT_ALIGNMENTS.items()}
|
|
3048
|
+
|
|
3049
|
+
# Words that describe a face of a family rather than the family itself, used to tell "Arial" +
|
|
3050
|
+
# "Narrow Bold" (a face of the family) from an unrelated font whose name merely starts the same.
|
|
3051
|
+
_FONT_STYLE_WORDS = frozenset((
|
|
3052
|
+
'regular', 'book', 'roman', 'normal', 'medium', 'bold', 'italic', 'oblique', 'light', 'thin',
|
|
3053
|
+
'extralight', 'ultralight', 'semilight', 'semibold', 'demibold', 'extrabold', 'ultrabold',
|
|
3054
|
+
'black', 'heavy', 'condensed', 'semicondensed', 'extracondensed', 'narrow', 'cond', 'expanded',
|
|
3055
|
+
'semiexpanded', 'extra', 'ultra', 'semi', 'demi', 'variable',
|
|
3056
|
+
))
|
|
3057
|
+
_FONT_REGULAR_ORDER = ('regular', 'book', 'roman', 'normal', 'medium')
|
|
3058
|
+
|
|
3059
|
+
|
|
3060
|
+
def validate_text_content(value):
|
|
3061
|
+
if not isinstance(value, str):
|
|
3062
|
+
raise ValueError('text must be a string')
|
|
3063
|
+
if value == '':
|
|
3064
|
+
raise ValueError('text must not be empty')
|
|
3065
|
+
if len(value) > TEXT_MAX_CHARS:
|
|
3066
|
+
raise ValueError('text must be at most %d characters (got %d)' % (TEXT_MAX_CHARS, len(value)))
|
|
3067
|
+
return value
|
|
3068
|
+
|
|
3069
|
+
|
|
3070
|
+
def validate_font_size_pt(value):
|
|
3071
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
3072
|
+
raise ValueError('font_size must be a number')
|
|
3073
|
+
return validate_range('font_size', value, TEXT_MIN_FONT_PT, TEXT_MAX_FONT_PT)
|
|
3074
|
+
|
|
3075
|
+
|
|
3076
|
+
def validate_text_rgb(args):
|
|
3077
|
+
"""(r, g, b) ints 0..255 from `red`/`green`/`blue`, or None when none of the three is given.
|
|
3078
|
+
Giving only some of them is refused rather than guessing the rest."""
|
|
3079
|
+
given = [k for k in ('red', 'green', 'blue') if args.get(k) is not None]
|
|
3080
|
+
if not given:
|
|
3081
|
+
return None
|
|
3082
|
+
if len(given) != 3:
|
|
3083
|
+
raise ValueError('red, green and blue must all be given together (missing: %s)'
|
|
3084
|
+
% ', '.join(k for k in ('red', 'green', 'blue') if k not in given))
|
|
3085
|
+
out = []
|
|
3086
|
+
for key in ('red', 'green', 'blue'):
|
|
3087
|
+
value = args[key]
|
|
3088
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or int(value) != value:
|
|
3089
|
+
raise ValueError('%s must be an integer 0..255' % key)
|
|
3090
|
+
out.append(validate_int_range(key, value, 0, 255))
|
|
3091
|
+
return tuple(out)
|
|
3092
|
+
|
|
3093
|
+
|
|
3094
|
+
def srgb_u8_to_linear(value):
|
|
3095
|
+
"""An 8-bit sRGB channel as the linear-light float GEGL's set_rgba expects."""
|
|
3096
|
+
c = value / 255.0
|
|
3097
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
3098
|
+
|
|
3099
|
+
|
|
3100
|
+
def linear_to_srgb_u8(value):
|
|
3101
|
+
"""A linear-light float channel (GEGL's get_rgba) as an 8-bit sRGB integer."""
|
|
3102
|
+
v = min(1.0, max(0.0, value))
|
|
3103
|
+
c = v * 12.92 if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
|
|
3104
|
+
return int(round(c * 255))
|
|
3105
|
+
|
|
3106
|
+
|
|
3107
|
+
def justification_nick(alignment):
|
|
3108
|
+
"""GIMP's justification nick ('left' | 'right' | 'center' | 'fill') for a ps_text-style
|
|
3109
|
+
`alignment`; refuses the variants GIMP has no equivalent for, naming what is supported."""
|
|
3110
|
+
if alignment in TEXT_UNSUPPORTED_ALIGNMENTS:
|
|
3111
|
+
raise ValueError(
|
|
3112
|
+
'alignment %s is not supported by GIMP text layers; use one of %s'
|
|
3113
|
+
% (alignment, ', '.join(TEXT_ALIGNMENTS))
|
|
3114
|
+
)
|
|
3115
|
+
if alignment not in TEXT_ALIGNMENTS:
|
|
3116
|
+
raise ValueError('alignment must be one of %s'
|
|
3117
|
+
% ', '.join(list(TEXT_ALIGNMENTS) + list(TEXT_UNSUPPORTED_ALIGNMENTS)))
|
|
3118
|
+
return TEXT_ALIGNMENTS[alignment]
|
|
3119
|
+
|
|
3120
|
+
|
|
3121
|
+
def alignment_name(nick):
|
|
3122
|
+
"""The ps_text-style alignment name for a GIMP justification nick (None when unknown)."""
|
|
3123
|
+
return _JUSTIFICATION_TO_ALIGNMENT.get(nick)
|
|
3124
|
+
|
|
3125
|
+
|
|
3126
|
+
def _resolution_ppi(ppi):
|
|
3127
|
+
return float(ppi) if ppi and ppi > 0 else DEFAULT_RESOLUTION_PPI
|
|
3128
|
+
|
|
3129
|
+
|
|
3130
|
+
def pt_to_px(pt, ppi):
|
|
3131
|
+
"""Points -> pixels at the image's vertical resolution (1 pt = 1/72 in)."""
|
|
3132
|
+
return pt * _resolution_ppi(ppi) / 72.0
|
|
3133
|
+
|
|
3134
|
+
|
|
3135
|
+
def px_to_pt(px, ppi):
|
|
3136
|
+
return round(px * 72.0 / _resolution_ppi(ppi), 2)
|
|
3137
|
+
|
|
3138
|
+
|
|
3139
|
+
def unit_size_to_pt(size, unit_is_pixel, units_per_inch, ppi):
|
|
3140
|
+
"""A text layer's font size in points, whichever unit it is stored in: pixels convert through
|
|
3141
|
+
the image resolution; any other unit through its own units-per-inch factor."""
|
|
3142
|
+
if unit_is_pixel or not units_per_inch:
|
|
3143
|
+
return px_to_pt(size, ppi)
|
|
3144
|
+
return round(size / units_per_inch * 72.0, 2)
|
|
3145
|
+
|
|
3146
|
+
|
|
3147
|
+
def _font_tokens(name):
|
|
3148
|
+
return re.sub(r'[-_]+', ' ', name).lower().split()
|
|
3149
|
+
|
|
3150
|
+
|
|
3151
|
+
def _font_key(name):
|
|
3152
|
+
return ' '.join(_font_tokens(name))
|
|
3153
|
+
|
|
3154
|
+
|
|
3155
|
+
def font_suggestions(query, names, limit=FONT_SUGGESTION_LIMIT):
|
|
3156
|
+
"""Up to `limit` installed font names closest to `query`: names containing it (or contained
|
|
3157
|
+
by it) first, then the nearest by spelling."""
|
|
3158
|
+
import difflib
|
|
3159
|
+
q = _font_key(query)
|
|
3160
|
+
ordered = sorted(set(names), key=lambda n: n.lower())
|
|
3161
|
+
out = [n for n in ordered if q and (q in _font_key(n) or _font_key(n) in q)][:limit]
|
|
3162
|
+
if len(out) < limit:
|
|
3163
|
+
by_key = {}
|
|
3164
|
+
for n in ordered:
|
|
3165
|
+
by_key.setdefault(_font_key(n), n)
|
|
3166
|
+
for key in difflib.get_close_matches(q, list(by_key), n=limit, cutoff=0.0):
|
|
3167
|
+
if by_key[key] not in out:
|
|
3168
|
+
out.append(by_key[key])
|
|
3169
|
+
if len(out) >= limit:
|
|
3170
|
+
break
|
|
3171
|
+
return out[:limit]
|
|
3172
|
+
|
|
3173
|
+
|
|
3174
|
+
def resolve_font(query, names):
|
|
3175
|
+
"""Resolve a family ("Inter", "Open Sans") or full name ("Inter Bold") to an installed font
|
|
3176
|
+
name, case- and spacing-insensitively. Returns (name, matched_by) with matched_by in
|
|
3177
|
+
'name' | 'family+regular' | 'family'. Raises ValueError listing the closest installed names
|
|
3178
|
+
when nothing matches."""
|
|
3179
|
+
if not isinstance(query, str) or not query.strip():
|
|
3180
|
+
raise ValueError('font_name must be a non-empty string')
|
|
3181
|
+
ordered = sorted(set(names), key=lambda n: n.lower())
|
|
3182
|
+
q = _font_key(query)
|
|
3183
|
+
for n in ordered:
|
|
3184
|
+
if _font_key(n) == q:
|
|
3185
|
+
return n, 'name'
|
|
3186
|
+
q_sorted = sorted(q.split())
|
|
3187
|
+
for n in ordered:
|
|
3188
|
+
if sorted(_font_tokens(n)) == q_sorted:
|
|
3189
|
+
return n, 'name'
|
|
3190
|
+
for style in _FONT_REGULAR_ORDER:
|
|
3191
|
+
wanted = q + ' ' + style
|
|
3192
|
+
for n in ordered:
|
|
3193
|
+
if _font_key(n) == wanted:
|
|
3194
|
+
return n, 'family+regular'
|
|
3195
|
+
family = [
|
|
3196
|
+
n for n in ordered
|
|
3197
|
+
if _font_key(n).startswith(q + ' ')
|
|
3198
|
+
and all(t in _FONT_STYLE_WORDS for t in _font_key(n)[len(q) + 1:].split())
|
|
3199
|
+
]
|
|
3200
|
+
if family:
|
|
3201
|
+
return min(family, key=lambda n: (len(n), n.lower())), 'family'
|
|
3202
|
+
suggestions = font_suggestions(query, ordered)
|
|
3203
|
+
if not suggestions:
|
|
3204
|
+
raise ValueError('no installed font matches %r (no fonts are installed)' % query[:100])
|
|
3205
|
+
raise ValueError(
|
|
3206
|
+
'no installed font matches %r. Closest installed names: %s' % (query[:100], ', '.join(suggestions))
|
|
3207
|
+
)
|
|
3208
|
+
|
|
3209
|
+
|
|
3210
|
+
def pick_default_font(names):
|
|
3211
|
+
"""The font a text layer gets when none is named: GIMP's built-in generic sans if listed,
|
|
3212
|
+
else the first installed name (None when there are none)."""
|
|
3213
|
+
ordered = sorted(set(names), key=lambda n: n.lower())
|
|
3214
|
+
for n in ordered:
|
|
3215
|
+
if _font_key(n) == _font_key(DEFAULT_TEXT_FONT):
|
|
3216
|
+
return n
|
|
3217
|
+
return ordered[0] if ordered else None
|
|
3218
|
+
|
|
3219
|
+
|
|
3220
|
+
def list_fonts(names, substring=None):
|
|
3221
|
+
"""(page, total) for gimp_inspect what=fonts: names sorted case-insensitively, filtered by a
|
|
3222
|
+
case-insensitive substring, capped at TEXT_FONT_LIST_CAP; `total` counts every match."""
|
|
3223
|
+
ordered = sorted(set(names), key=lambda n: n.lower())
|
|
3224
|
+
if substring:
|
|
3225
|
+
needle = substring.lower()
|
|
3226
|
+
ordered = [n for n in ordered if needle in n.lower()]
|
|
3227
|
+
return ordered[:TEXT_FONT_LIST_CAP], len(ordered)
|
|
3228
|
+
|
|
3229
|
+
|
|
3230
|
+
# Headroom on the small-size layout probe: glyph advances hinted at a few pixels round to whole
|
|
3231
|
+
# pixels, so scaling a probe up can undershoot the full-size layout.
|
|
3232
|
+
TEXT_ESTIMATE_MARGIN = 1.3
|
|
3233
|
+
|
|
3234
|
+
|
|
3235
|
+
def estimate_text_extent(probe_w, probe_h, probe_px, size_px, chars=0, lines=1, letter_spacing=0.0,
|
|
3236
|
+
line_spacing=0.0, indent=0.0):
|
|
3237
|
+
"""The (width, height) in px a text layer will render at `size_px`, from its layout measured
|
|
3238
|
+
at `probe_px` (`probe_w` x `probe_h`), with headroom. Letter spacing, line spacing and indent
|
|
3239
|
+
are absolute pixels that do not scale with the font, so each adds its full worst case: spacing
|
|
3240
|
+
after every character and between every line, plus the indent. Never below 1x1."""
|
|
3241
|
+
scale = size_px / float(probe_px)
|
|
3242
|
+
width = math.ceil(probe_w * scale * TEXT_ESTIMATE_MARGIN) + chars * max(0.0, letter_spacing) + max(0.0, indent)
|
|
3243
|
+
height = math.ceil(probe_h * scale * TEXT_ESTIMATE_MARGIN) + max(0, lines - 1) * max(0.0, line_spacing)
|
|
3244
|
+
return max(1, int(math.ceil(width))), max(1, int(math.ceil(height)))
|
|
3245
|
+
|
|
3246
|
+
|
|
3247
|
+
def check_estimated_text_size(width, height):
|
|
3248
|
+
"""Refuses text whose estimated size (`estimate_text_extent`) is past the engine's size cap,
|
|
3249
|
+
before anything is drawn at full size."""
|
|
3250
|
+
try:
|
|
3251
|
+
validate_resize_dims(width, height)
|
|
3252
|
+
except ValueError as exc:
|
|
3253
|
+
raise ValueError(
|
|
3254
|
+
'the text would render as about %dx%d px, past the size limit (%s); use a smaller '
|
|
3255
|
+
'font_size or less text' % (width, height, exc)
|
|
3256
|
+
)
|
|
3257
|
+
|
|
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
|
+
|
|
3416
|
+
TEXT_REPORT_MAX_CHARS = 200
|
|
3417
|
+
|
|
3418
|
+
|
|
3419
|
+
def text_for_report(text):
|
|
3420
|
+
"""(text, length, truncated) for reporting a layer's text: at most TEXT_REPORT_MAX_CHARS,
|
|
3421
|
+
since a layer opened from a file can hold any amount."""
|
|
3422
|
+
text = text or ''
|
|
3423
|
+
return text[:TEXT_REPORT_MAX_CHARS], len(text), len(text) > TEXT_REPORT_MAX_CHARS
|
|
3424
|
+
|
|
3425
|
+
|
|
3426
|
+
def check_text_layer_size(width, height):
|
|
3427
|
+
"""Refuses a rendered text layer past the engine's size cap (`validate_resize_dims`)."""
|
|
3428
|
+
try:
|
|
3429
|
+
validate_resize_dims(width, height)
|
|
3430
|
+
except ValueError as exc:
|
|
3431
|
+
raise ValueError(
|
|
3432
|
+
'the text would render as a %dx%d px layer, past the size limit (%s); use a smaller '
|
|
3433
|
+
'font_size or less text' % (width, height, exc)
|
|
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
|