perf(nodes): compute the mask fade only on the fade band - #9430
Open
dexhunter wants to merge 1 commit into
Open
Conversation
ExpandMaskWithFadeInvocation evaluated the shaping polynomial over every pixel, then discarded most of the result: the black region is forced to 0 and everything at or beyond the fade distance is forced to exactly 1.0. Only the fade band in between depends on the polynomial. Threshold with cv2.threshold so the 0/255 array doubles as the finished image outside the band, drop the full-image normalisation, and evaluate the same numpy.poly1d object on the band's distances only. Also skip the polyfit when there is no band, and remove a redundant astype on a buffer that is already uint8. Output is byte-for-byte identical. At the shipped UI defaults, a 1024x1024 canvas bbox with maskBlur 16, one call drops from 7.4 ms to 2.9 ms; at 1536x1536 from 18.6 ms to 6.3 ms. Adds tests/app/invocations/test_expand_mask_with_fade.py, which asserts byte equality against a transcription of the previous implementation across 60 combinations of mask shape, size, threshold and fade size.
dexhunter
requested review from
JPPhoto,
Pfannkuchensack,
blessedcoolant,
dunkeroni and
lstein
as code owners
August 1, 2026 05:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ExpandMaskWithFadeInvocationruns once per canvas inpaint or outpaint generation.addInpaint.ts,addOutpaint.tsandaddFLUXFill.tsall emit it, withfade_size_pxbound to the mask-blur setting. It thresholds the mask, runs a distance transform, and
shapes the distance with a cubic polynomial.
The polynomial only matters inside the fade band. Outside the mask the result is black, and
at or beyond the fade distance the code already forces the feather to exactly 1.0, which is
white. Both of those are decided before any polynomial is evaluated, but the current code
still evaluates it over every pixel and then throws most of that away.
This computes it on the band only. The thresholded 0/255 array doubles as the finished
image everywhere else, so the black and white regions are already correct when the band is
written into it.
Concretely, the changes are:
cv2.thresholdin place ofnumpy.where(...255, 0).astype(uint8)followed by an equalitytest and an inversion. That removes two full-size intermediates, one of them
int64.d_normarray.dist == 0is the black region and0 < dist < fade_size_pxis theband, so the normalisation happens on the band's values rather than on the whole image.
numpy.asarrayrather thannumpy.arrayfor the input, and no redundantastype(uint8)on a buffer that is already
uint8.The output is byte-for-byte identical. This is not a quality trade or an approximation.
The same
numpy.poly1dobject is applied to the same values; it is only applied to fewerof them.
Measurements
Median wall time of one
invoke()call, warm process,cv2.setNumThreads(1), on a mask offour filled ellipses. Both settings below are the shipped UI defaults:
maskBluris 16, and1024×1024 is the default canvas bbox for SDXL and FLUX.
I reproduced this three times; run-to-run spread was under 3%. The first few calls in a fresh
process cost roughly twice the warm figure on both branches, because glibc raises its mmap
threshold as the process runs and the largest temporary here sits on that boundary. Those
calls are excluded from the medians above rather than averaged in.
cv2.distanceTransformis untouched and is now most of what remains.What this is not: it is one node in a generation graph. Saving 4.5 ms once per canvas
generation is not a user-visible change to generation time, and I am not claiming it is.
The reason to take it is that the work was redundant and the output is unchanged.
Related Issues / Discussions
None. Found by profiling the canvas compositing path.
QA Instructions
tests/app/invocations/test_expand_mask_with_fade.pyis new. It transcribes the previousimplementation and asserts byte equality against it across 60 combinations of mask shape,
size, threshold and fade size, including the cases that decide the edges:
fade_size_px=1fade_size_px=0, which takes the early returnI checked the test fails on a broken implementation rather than passing for free: changing
d_norm >= 1.0to> 1.0fails 25 of the 60 cases, and changing the threshold comparisonfrom
>to>=fails 21.The node version is unchanged, deliberately: the output is identical, so existing
workflows need no migration.
Merge Plan
Nothing special. No schema or API change.
Checklist
What's Newcopy (if doing a release after this PR) — not user-visibleThe band idea came out of an automated optimization run over this function, which I then
rewrote by hand. Trajectory: https://dashboard.weco.ai/share/DjiU_r-lNbWtosfHo_Bgs_SxQLbx-FC6
The run's own best candidate hand-inlined Horner's method with float64 coefficients against
a float32 array. That is byte-exact under the value-based casting in numpy 1.x, which this
repo pins, and would stop being byte-exact under NEP 50 in numpy 2. I kept the
poly()callinstead, which tracks whatever numpy does and matches the original by construction. It costs
about 3.6% against the run's best on my benchmark.