CV Roll (FFT Shift)
The quadrant swap everyone forgets
- array
- array
What it's for
cv2.dft puts the zero-frequency term - the DC, the average brightness - in the top-left corner. Every spectrum figure you have ever seen puts it in the centre, because that's where the structure is legible: a low-pass is a disc in the middle, a high-pass is the middle punched out, that diagonal line of yours is a stripe pointing at the origin.
The operation that moves between those two layouts is the quadra shift, and in this pack it's this node. You need it in both directions: once before you build a centred frequency mask, and once again before the inverse transform. Forgetting the second one is the classic Fourier bug, and it doesn't error - it gives you an image that's wrapped around by half its width.
How it works
Under the hood it's numpy.roll on the spatial axes: pixels pushed off one edge re-enter on the opposite edge. Cyclic, not cropped, not padded. Channels are never mixed. That cyclic property is the whole point - convolution in the frequency domain is circular, so a cyclic shift is the correct primitive here and not a simplification.
Three modes:
- fftshift (center the zero frequency) - computes the half-size shifts straight from the array and swaps the quadrants, moving DC to the middle. This is the one you use before building a mask.
- ifftshift (undo fftshift) - the exact inverse. On even sizes the two are the same operation; on odd sizes they are not, and using the wrong one leaves you off by a pixel. That detail is why both modes exist rather than one.
- custom (shift_x / shift_y) - rolls by explicit amounts, positives to the right and down.
The input is match-typed: an IMAGE, MASK, NPARRAY or LATENT, and the output echoes whatever format came in. A LATENT is processed in latent space - frame 0 unwrapped to a float32 [H,W,C] array with values untouched - so rolling works on latent cells, not pixels.
Where it fits
The repo's 19_fourier_playground.json is the reference build, and the pack's CV Wiener Filter (deblur) and CV Power Spectrum (PSD) subgraphs use it exactly as described: shift, mask or analyse in the frequency domain, shift back.
The standard chain, spelled out, because it's three nodes people get in the wrong order:
cv2_dft → CV Roll (fftshift) → your filtering (multiply by a mask) → CV Roll (ifftshift) → cv2_idft
Notice the mask itself is authored in the centred layout - that's the reason the first shift exists. Doing it the other way round (mask first, shift after) is a very reasonable-sounding mistake that produces a picture which is subtly the wrong thing rather than obviously broken.
Install
ComfyUI Manager, search ComfyUI CV. By hand:
cd ComfyUI/custom_nodes
git clone https://github.com/bmad4ever/comfyui_cv
Restart, then make sure the contrib wheel is the one installed:
pip install "opencv-contrib-python-headless~=5.0.0.93"
Python ≥ 3.12 and a ComfyUI recent enough for the V3 node API.
Where people get burned
Missing ifftshift. The spectrum was centred and never came back, so the inverse transform thinks the DC is in the middle of the frame. You don't get an error, you get a shifted image, sometimes with a visible wrap seam where the edges meet. If your "restored" picture looks spatially offset, check for the missing undo before you touch anything else.
Odd dimensions. On an odd-width spectrum, fftshift and ifftshift genuinely differ. Use the matching pair - shift out with fftshift, back with ifftshift - rather than whichever one you remember, and crop to even dimensions if you're fighting a one-pixel offset.
Custom mode is not centred mode. shift_x / shift_y are raw pixel counts, not quadrant swaps, and fftshift ignores those widgets entirely. If you left values in them and switched mode, nothing you typed is doing anything.
Rolling is not cropping or padding. It wraps. If you're trying to move an image without wrap-around - to reveal an edge, to hide a watermark - this is the wrong node and you want a warp or a copy-with-border.
The classic category error in this pack. The ~470 cv2_* wrappers are auto-generated from whatever OpenCV build is installed, and they're explicitly uncurated: expect to handle dtypes and edge cases yourself. This node is the curated version of one of those steps. If you're writing the same three raw wrappers to build a Fourier round trip, stop and use this instead.
Inputs (4)
| Name | Type | Default | Description |
|---|---|---|---|
| array | COMFY_MATCHTYPE_V3 | Array to roll (any dtype; a DFT spectrum, an image, a mask...). The output echoes this input's format. A LATENT link is processed in latent space: frame 0 becomes a float32 [H,W,C] array (any channel count), values untouched. Arithmetic ops (add, multiply, etc.) also accept a full LATENT batch ({samples: [B,C,H,W]}) — the whole batch flows through when both inputs have the same batch size. 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. | |
| mode | COMBO | fftshift (center the zero frequency) | fftshift/ifftshift compute the half-size shifts from the array itself; custom uses shift_x/shift_y. |
| shift_x | INT | 0-2147483648–2147483647 | Custom mode only: columns to roll right (negative = left). Ignored by fftshift/ifftshift. |
| shift_y | INT | 0-2147483648–2147483647 | Custom mode only: rows to roll down (negative = up). Ignored by fftshift/ifftshift. |
Outputs (1)
| Name | Type | Description |
|---|---|---|
| array | COMFY_MATCHTYPE_V3 | The rolled array, in the same format the input arrived in. |