Skip to content

perf(nodes): compute the mask fade only on the fade band - #9430

Open
dexhunter wants to merge 1 commit into
invoke-ai:mainfrom
dexhunter:perf/expand-mask-fade-band
Open

perf(nodes): compute the mask fade only on the fade band#9430
dexhunter wants to merge 1 commit into
invoke-ai:mainfrom
dexhunter:perf/expand-mask-fade-band

Conversation

@dexhunter

Copy link
Copy Markdown
Contributor

Summary

ExpandMaskWithFadeInvocation runs once per canvas inpaint or outpaint generation. addInpaint.ts, addOutpaint.ts and addFLUXFill.ts all emit it, with fade_size_px
bound 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.threshold in place of numpy.where(...255, 0).astype(uint8) followed by an equality
    test and an inversion. That removes two full-size intermediates, one of them int64.
  • No d_norm array. dist == 0 is the black region and 0 < dist < fade_size_px is the
    band, so the normalisation happens on the band's values rather than on the whole image.
  • The polynomial fit moved inside the band branch, so a mask with no fade band skips it.
  • numpy.asarray rather than numpy.array for the input, and no redundant astype(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.poly1d object is applied to the same values; it is only applied to fewer
of them.

Measurements

Median wall time of one invoke() call, warm process, cv2.setNumThreads(1), on a mask of
four filled ellipses. Both settings below are the shipped UI defaults: maskBlur is 16, and
1024×1024 is the default canvas bbox for SDXL and FLUX.

mask fade_size_px before after
1024×1024 16 7.4 ms 2.9 ms
1536×1536 16 18.6 ms 6.3 ms

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.distanceTransform is 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.py is new. It transcribes the previous
implementation and asserts byte equality against it across 60 combinations of mask shape,
size, threshold and fade size, including the cases that decide the edges:

  • an empty mask and a fully covered mask, where the distance transform is degenerate
  • one-pixel lines with fade_size_px=1
  • a grey ramp with a non-zero threshold, so the threshold actually decides something
  • non-square, odd dimensions
  • fade_size_px=0, which takes the early return

I checked the test fails on a broken implementation rather than passing for free: changing
d_norm >= 1.0 to > 1.0 fails 25 of the 60 cases, and changing the threshold comparison
from > 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

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration — no frontend change
  • Documentation added / updated (if applicable) — behaviour is unchanged
  • Updated What's New copy (if doing a release after this PR) — not user-visible

The 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() call
instead, which tracks whatever numpy does and matches the original by construction. It costs
about 3.6% against the run's best on my benchmark.

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.
@github-actions github-actions Bot added python PRs that change python files invocations PRs that change invocations python-tests PRs that change python tests labels Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

invocations PRs that change invocations python PRs that change python files python-tests PRs that change python tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant