Nodes/ComfyUI-NunchakuFluxLoraStacker/ControlAltAI: Perturbation Texture
ComfyUI Node

ControlAltAI: Perturbation Texture

Put the grain back in, on purpose, after the upscale

By ussoewwin·Created about a year ago·Updated 5 days ago· 23
ControlAltAI: Perturbation Texture
  • image
  • mask
  • textured_image_output
  • texture_layer
◄noise_scale0.50►
◄texture_strength50►
◄texture_typeSkin Pore►
◄frequency1.0►
◄perturbation_factor0.30►
◄use_maskfalse►
◄seed-1►

Diffusion output is suspiciously clean. Real photographs aren't - they carry sensor noise, film grain, pore-level texture - and the gap between "technically sharp" and "looks captured" is often nothing more than a bit of noise in the right places. Adding it back is the cheapest single move for breaking the AI look, and it's not the model's job; it's a post pass.

This node is that post pass, in the graph, with a mask. Four texture flavours, a few knobs, and an output you can preview before you commit.

How it works

No model runs here - it's a deterministic pixel operation. The image is converted to a PIL frame, then the node builds a three-channel noise field whose character depends on texture_type:

  • Film Grain - broad normal noise plus a coarse, low-frequency layer, which is what stops it looking like TV static and starts it looking like grain clusters.
  • Skin Pore - the subtlest recipe: a low-amplitude base plus a very fine high-frequency layer, tuned for close-ups.
  • Natural - three stacked frequency layers at different weights, for an organic, general-purpose texture.
  • Fine Detail - the high-frequency end, for fabric, metal and mechanical surfaces.

noise_scale sets the amplitude at generation time and frequency sets the scale of those detail layers (each layer is generated small and upscaled with Lanczos, which is where the soft clumping comes from). The noise is normalised to roughly −1…1, multiplied by perturbation_factor × texture_strength/100, added to the base pixels and clipped. Two outputs come back: the textured image, and a texture_layer that is the same noise deliberately exaggerated, so you can see exactly what's being added instead of squinting at a 2% difference.

Inputs and outputs

Required: image, noise_scale (0–1), texture_strength (0–100), texture_type, frequency (0.2–5), perturbation_factor (0.01–0.5), use_mask.

Optional: mask, and seed (default −1, meaning different noise on every run - set it if you want reproducible output).

Outputs: textured_image_output (feed onward to save or upscale) and texture_layer.

Mask behaviour is the part to test once: white gets the texture, black is protected. It's the inverse of what plenty of people assume going in.

Settings that actually work

The author's recommended ranges are the honest guide, and note that the node's own defaults sit at the top of them: noise_scale 0.25–0.50 (default 0.5), texture_strength 10–50 (default 50), perturbation_factor 0.10–0.25 (default 0.30). Start at half the default and inch up; grain is one of those things that looks invisible on your monitor and obvious when someone else views the file at 100%.

Where you put it in the chain matters more than any single value. This is a pixel-to-pixel pass, so it can go pre- or post-upscale, but grain added before an upscaler gets amplified along with everything else - post-upscale, on the final resolution, is where it reads as capture noise rather than as damage.

Installing it

Ships in ussoewwin/ComfyUI-NunchakuFluxLoraStacker alongside a Nunchaku FLUX LoRA stacker, Florence-2 captioning nodes, a TensorRT CCSR upscaler and the rest of the ControlAltAI utility set (a Python 3.13 fork of gseth/ControlAltAI-Nodes, hence the hswq_ class prefix). ComfyUI Manager: search ComfyUI-NunchakuFluxLoraStacker. Manual:

cd ComfyUI/custom_nodes
git clone https://github.com/ussoewwin/ComfyUI-NunchakuFluxLoraStacker.git
pip install -r requirements.txt

Restart. Nothing to download for this node, but install the entire requirements file - the pack imports its CCSR dependencies (pytorch-lightning, taming-transformers, omegaconf, einops, open-clip-torch) at load time, and a missing one hides every node in the pack.

If something's off

An AttributeError mentioning float3232. In the copy of the source I read, two casts reference np.float3232, which isn't a numpy dtype - the intent is clearly np.float32, and the pack's own .backup copy of the file has it right. If the node dies on that name, open nodes/controlaltai/perturbation_texture_node.py and change np.float3232 to np.float32 in both places. It's the kind of one-token slip that's easy to hit if you're chasing an error code you can't explain.

It fails on a batch. As written the node squeezes off a single-image batch and hands the array to PIL, which can't take a stack. Queue images one at a time.

Texture is in the wrong region. Check your mask polarity (white = textured) and remember the mask is resized to match the image.

Everything looks worse, not more real. Back texture_strength down. Grain is seasoning; the author's own top-of-range settings are for very specific looks, and most portraits want the Skin Pore recipe thin enough that you can barely see it - which is exactly when it's working. If you want the full de-AI treatment rather than just grain, the wider recipe in this ecosystem is grain plus a touch of motion blur and vignette, and for video the missing piece is motion blur, which this node doesn't do.

CategoryControlAltAI Nodes/Image

Inputs (9)

NameTypeDefaultDescription
imageIMAGE—
noise_scaleFLOAT0.500–1—
texture_strengthINT500–100—
texture_typeCOMBOSkin Pore4 options: Film Grain, Skin Pore, Natural, Fine Detail
frequencyFLOAT1.00.2–5—
perturbation_factorFLOAT0.300.01–0.5—
use_maskBOOLEANfalse—
maskoptMASK—
seedoptINT-1—

Outputs (2)

NameTypeDescription
textured_image_outputIMAGE—
texture_layerIMAGE—