CV Wave Map
The flag-and-water ripple, as coordinate maths
- map_x
- map_y
- map_x
- map_y
Sinusoidal displacement, one axis at a time: each row of the image gets shifted horizontally by amplitude_x * sin(2π·y / wavelength_x + phase), and each column vertically by the same construction on x. That's the flag ripple, the heat-haze, the "underwater" look - and it's about eight lines of numpy behind a node.
The interesting part isn't the maths, it's where in the pipeline it sits. This node doesn't touch your image. It edits the coordinate maps.
How it works
Coordinate-map warping means you work in source-coordinate space: map_x and map_y are two float arrays where a pixel at (i, j) says "the output pixel here should read the source at (map_x[i,j], map_y[i,j])". Because these are just arrays, distortions compose. Feed them through a wave, then a vector displacement, then a pinch, and run one cv2_remap at the end.
The chain starts at CV Identity Map - coordinates that map each pixel to itself. Then this node adds its sine offsets into that, and downstream nodes add theirs.
That's the whole design: N distortions, one resample. Doing it the other way - remapping the image N times - blurs a little more each pass and can't express combinations nearly as cleanly.
The inputs
- map_x / map_y - the incoming chain. Same shape, both required. They're only "distortions" because they feed the remap later; on their own they're just arrays.
- amplitude_x (default 8) - horizontal shift in pixels, driven by y. Set it to
0to disable that axis entirely, which is how you get a purely vertical ripple. - wavelength_x (default 64) - the vertical period, in pixels, of that horizontal wave. Small values give tight ripples, huge values give a single slow bend across the frame.
- amplitude_y (default 0) / wavelength_y - the same two knobs for the vertical shift, driven by x. Note the default amplitudes: the node ships as a horizontal-only ripple, and you opt into the second axis.
- phase (default 0) - the offset in degrees. This is the animation knob: hold everything else fixed and sweep the phase from 0 to 360 and the wave travels.
Outputs are map_x and map_y, continuing the chain or heading for the remap.
What you need downstream
cv2_remap, with WARP_RELATIVE_MAP set - that's what CV Warp Flags is for. And this is the step people skip, then conclude the node is broken. Two failure modes look like "it did nothing": the remap reading the maps as absolute coordinates (in which case your wave is being interpreted as "read from these pixel positions", which for a small wave is close enough to the identity map to look like nothing happened), or the maps never actually reaching the remap at all.
Where it earns its place in a real graph: the underwater effect is a two-node chain (identity → wave → remap). Heat haze over a video is the same chain with phase driven from the batch index, so the ripple moves over time instead of being frozen. And where the pack is genuinely better than a general image-editing setup, you can compose it - wave plus radial lens distortion plus a hand-drawn vector field, in one remap, so a scene gets all three at once and stays sharp.
Installing it
Manager → ComfyUI CV, or:
cd ComfyUI/custom_nodes
git clone https://github.com/bmad4ever/comfyui_cv
Restart. Python ≥ 3.12, a recent ComfyUI on the V3 node API. opencv-contrib-python-headless~=5.0.0.93 is the only runtime dependency and it isn't optional-ish - the pack curates behaviour against that version. No model files. GPL-3.0, forked from opencv-comfyui, LLM-assisted; the author flags that a few nodes in the pack are genuine reimplementations rather than cv2 wrappers, and the remap family is where that's most visible.
Where people get burned
Shapes must match. map_x and map_y share a shape, and so should the whole chain - including the image you eventually remap. Mixing resolutions gives you a warp that's subtly misplaced rather than an error.
Reading amplitude as a percentage. These are pixels. amplitude_x = 8 on a 512-wide image is a gentle ripple; on a 64-wide thumbnail it's a wrecking ball.
Wavelength of zero. The minimum is 0.1, so it won't divide by zero, but a wavelength near zero is aliasing - a wave tighter than two pixels can't be sampled by the remap, and you'll get noise or moiré rather than a wave.
Expecting the colour to distort with the geometry. This node moves coordinates. It has no opinion about what's in the image, so as with any remap, areas that pull in from outside the frame get filled per the border mode on the remap.
Inputs (7)
| Name | Type | Default | Description |
|---|---|---|---|
| map_x | NPARRAY | Incoming source-x coordinate map to distort. Start the chain with 'CV Identity Map'; further remap nodes compose onto it. | |
| map_y | NPARRAY | Incoming source-y coordinate map (must share map_x's shape - both come from the same chain). | |
| amplitude_x | FLOAT | 8.00–10000 | Horizontal shift in pixels (driven by y). |
| wavelength_x | FLOAT | 640.1–100000 | Vertical period in pixels of the horizontal wave. |
| amplitude_y | FLOAT | 0.00–10000 | Vertical shift in pixels (driven by x). |
| wavelength_y | FLOAT | 640.1–100000 | Horizontal period in pixels of the vertical wave. |
| phaseopt | FLOAT | 0-360–360 | Phase offset in degrees - animate it to make the wave travel. |
Outputs (2)
| Name | Type | Description |
|---|---|---|
| map_x | NPARRAY | — |
| map_y | NPARRAY | — |