cv2.adaptiveThreshold
Stop blowing out half the page
- src
- result
Global thresholding picks one number for the whole image, which is fine until your photo has a bright corner and a shadowed one. Then the bright half turns white, the dark half turns black, and you go looking for a better filter. This is the better filter: the threshold is computed per pixel from its local neighbourhood.
Best use in this pack: cleaning up a photo of a document, a receipt, or printed text before you hand it to OCR - or producing a hard mask out of an image with uneven lighting.
How it works
For every pixel, OpenCV computes the mean (or Gaussian-weighted mean) of a blockSize × blockSize neighbourhood, subtracts C, and compares the pixel against that. Darker than the local average minus C → foreground.
That's the whole trick: a shadow gradient stops mattering, because the local mean moves with it. What used to be a global problem becomes a local contrast decision.
One genuinely nice pack-specific detail: src is registered as a single-channel-requiring parameter, so if you link a normal 3-channel IMAGE into it, the wrapper converts it to grayscale for you (BGR→GRAY) rather than making you insert a cvtColor node. You can also feed a MASK, which arrives as a single-channel array already.
Inputs and outputs
src(COMFY_MATCHTYPE_V3) - source 8-bit single-channel. IMAGE links work (auto-grayed); a MASK works; NPARRAY must be uint8 and single-channel.maxValue(FLOAT) - the value written where the condition is satisfied. The default is 0, which produces an all-black image. Set it to 255. This is the trap.adaptiveMethod-ADAPTIVE_THRESH_GAUSSIAN_C(default) orADAPTIVE_THRESH_MEAN_C. Gaussian weights the neighbourhood, so it's less jumpy around gradients and noise; it's slightly slower.thresholdType-THRESH_BINARYorTHRESH_BINARY_INV. Print is usuallyINVif you want dark text as white-on-black for a mask.blockSize(INT) - the neighbourhood size. Odd, > 1. It must be larger than the features you want to keep - too small and the interior of a thick stroke becomes "background".C(FLOAT) - the constant subtracted from the local mean. Small (2–5) keeps fine texture and noise; larger (10–15) drops both.- result - echoes
src's format: IMAGE in, IMAGE out; MASK in, MASK out.
The pack's own threshold playground uses blockSize = 31, C = 7 for a page of text - a sane starting point to copy.
Parameter intuition
blockSize is a scale control. C is a contrast gate: at C = 0 you get every speckle of dust, because any pixel below its local mean becomes foreground. Turn C up until the noise goes and the strokes survive. GAUSSIAN_C plus C ≈ 10 is the document-scanner default everyone converges on.
Common follow-ups: cv2_morphologyEx (open, then close) to despeckle, or the curated CV Find Contours on the result.
Install
ComfyUI Manager → comfyui_cv (ComfyUI CV), or:
cd ComfyUI/custom_nodes
git clone https://github.com/bmad4ever/comfyui_cv
pip install "opencv-contrib-python-headless~=5.0.0.93"
Restart ComfyUI afterwards. Python ≥ 3.12, recent V3-API ComfyUI, behaviour curated against OpenCV 5.0.0.93. The contrib wheel is the required one; a non-contrib install over it silently removes contrib submodules.
Where people get burned
blockSize left at 0, or set even. OpenCV asserts on it - you'll see an assertion failure in the log rather than a graceful error. Odd numbers only: 3, 5, 7 … 31.
maxValue left at 0. A perfectly valid operation that outputs an all-black image. It's not broken; it's doing what you asked.
blockSize smaller than the strokes. The inside of a letter becomes background and you get hollow outlines. Go bigger.
Expecting colour. The output is a binary single-channel image. If you need a matte, that's fine - but for an IMAGE output it comes back as a picture with 0 or 255.
When a global threshold would have been better. Evenly lit, high-contrast scans don't need local adaptation, and local methods add halo artefacts around big dark blobs. cv2_threshold with the OTSU flag (via CV Threshold Flags) is the right tool there, and it picks the level for you. For a statistically-motivated local alternative, the pack also wraps ximgproc.niBlackThreshold.
Inputs (6)
| Name | Type | Default | Description |
|---|---|---|---|
| src | COMFY_MATCHTYPE_V3 | Source 8-bit single-channel image. The image output(s) echo this input's format. Accepts a ComfyUI IMAGE/MASK directly (frame 0 of a batch) or an NPARRAY. Arithmetic ops (add, multiply, etc.) process the full IMAGE batch when both inputs have the same batch size. | |
| maxValue | FLOAT | 0.0000-1e+38–1e+38 | Non-zero value assigned to the pixels for which the condition is satisfied |
| adaptiveMethod | COMBO | ADAPTIVE_THRESH_GAUSSIAN_C | Adaptive thresholding algorithm to use, see #AdaptiveThresholdTypes. The #BORDER_REPLICATE | #BORDER_ISOLATED is used to process boundaries. |
| thresholdType | COMBO | THRESH_BINARY | Thresholding type that must be either #THRESH_BINARY or #THRESH_BINARY_INV, see #ThresholdTypes. |
| blockSize | INT | 0-2147483648–2147483647 | Size of a pixel neighborhood that is used to calculate a threshold value for the pixel: 3, 5, 7, and so on. |
| C | FLOAT | 0.0000-1e+38–1e+38 | Constant subtracted from the mean or weighted mean (see the details below). Normally, it is positive but may be zero or negative as well. |
Outputs (1)
| Name | Type | Description |
|---|---|---|
| result | COMFY_MATCHTYPE_V3 | Echoes the 'src' input's format: an IMAGE link comes back as IMAGE, MASK as MASK, NPARRAY stays NPARRAY. |