Stitch by Mask
Put the Edit Back Where It Came From
- image_a
- image_b
- edit_config
- mask
- stitched
- processed_mask
Here's the situation this node is built for. You sent a face through an editor, it fixed the lighting beautifully, and it also quietly changed everything else in the frame - the background, the shirt, the wallpaper. Or you upscaled one region and now the texture of that region doesn't match the rest. Stitch by Mask takes two images and a mask and combines them, keeping image_a where the mask is black and swapping in image_b where it's white.
It's a compositor, not a generator. Nothing runs a model; it's a masked blend with feathering. That's the point - this is the layer where you finish an image with deterministic operations instead of re-rolling a sampler and hoping.
The inputs that matter
image_a is the base - the frame you're keeping. image_b is the replacement. mask is required unless you turn on bypass_mask, and it's the thing that decides everything.
edit_config is a required PORTRAIT_EDIT_CONFIG_V2 bundle from Edit & Composite Config. opacity_source picks which number inside it drives the blend: blend_opacity or stitch_opacity (the default). That's the pack's 2.0 pattern - the opacities live in the shared config node rather than as per-node widgets, so two stitch nodes in the same graph agree without you syncing them by hand. If you hate the shared-config approach, fair; for a workflow with three stitch stages it's the difference between one dial and three.
feather_radius (default 5) softens the mask edge over that many processing pixels. This is the control that makes or breaks the result: a hard mask gives you a visible seam where the model's colour balance doesn't quite match the original. Feather generously for skin and hair, tighter for a hard edge like a wall.
Then the options: invert_mask when your segmenter handed you the wrong polarity, bypass_mask to blend the whole frame at the selected opacity (useful for dialling an edit back to 30% instead of committing), and force_size with target_width/target_height (1344×768 by default) to bring both images and the mask onto one canvas.
Outputs
stitched is the composite. processed_mask is the mask that was actually used - after inversion and after feathering - and it's worth wiring into a preview while you tune, because "the mask isn't doing what I thought" accounts for most bad stitches. It comes out as a standard BHW mask, so it feeds any downstream mask consumer.
The 2.0 compositor applies opacity once, after the outward feathering (earlier versions applied it before, which dimmed the feathered edge), handles RGBA sanely, and treats only the mask as meaningful in the alpha channel rather than letting transparent source colour bleed. The old unconfigured StitchByMask IDs are gone; this is the replacement.
Sizing, which is where people get hurt
With force_size off, both images must share dimensions and the mask must match them. On a real-photo workflow that constraint bites constantly, because your editor result comes back at editor resolution while your source is whatever the scan was. Two ways out.
The intended one is to run both through the same preparation: prepared_image is at a known tier, and Smart Photo Prepare's native_crop gives you the source crop at original pixels. If you want the edit landed back onto the full-resolution original, the geometry you need is the crop box - a full-resolution base with the prepared-scale edit composited into it needs a resize before stitching.
The quick one is force_size: turn it on, set the target, and both images and the mask get resized to it. Fine for a comparison or a preview; less fine if you care about which direction the pixels moved. Either way, if you get a hard error about dimensions, that's this constraint, not a bug.
Tuning it so the seam disappears
Two ideas that do most of the work. First, mask generously and feather outward, so the transition happens over real texture rather than on the boundary you drew. Second, match the tones before you blend: an edited region that's the right shape but the wrong white balance reads as a patch no matter how well the mask is cut, and the fix is a deterministic colour match against the untouched original, not a stronger feather. Stitch handles geometry; colour agreement is a separate job, and doing it at modest strength keeps skin from going plastic.
Install
Part of PortraitUtils - ComfyUI Manager → search PortraitUtils → install → restart the ComfyUI server. Not a browser refresh; Python nodes load at startup.
cd ComfyUI/custom_nodes
git clone https://github.com/heyburns/PortraitUtils
/path/to/ComfyUI/venv/bin/python -m pip install -r PortraitUtils/requirements.txt
ComfyUI's own Python (or python_embeded on Windows portable). The pack pulls numpy, Pillow, opencv-python-headless and tifffile - this node's feathering runs through OpenCV, so it's the dependency that matters here, and installing a second OpenCV wheel variant into the same environment is a classic way to break it. It appears under PortraitUtils/Composite V2.
Mask generation lives upstream. Any segmenter works - the pack's docs point at SAM3 with an editable prompt - but generate the mask on the same grid as the images you're stitching, or you'll be feathering a mask that's already misaligned.
Inputs (11)
| Name | Type | Default | Description |
|---|---|---|---|
| image_a | IMAGE | — | |
| image_b | IMAGE | — | |
| edit_config | PORTRAIT_EDIT_CONFIG_V2 | — | |
| opacity_source | COMBO | stitch_opacity | 2 options: blend_opacity, stitch_opacity |
| invert_mask | BOOLEAN | false | — |
| bypass_mask | BOOLEAN | false | — |
| feather_radius | INT | 50–100 | — |
| force_size | BOOLEAN | false | — |
| target_width | INT | 134416–8192 | — |
| target_height | INT | 76816–8192 | — |
| maskopt | MASK | — |
Outputs (2)
| Name | Type | Description |
|---|---|---|
| stitched | IMAGE | — |
| processed_mask | MASK | — |