Checked November 9, 2026 (15:00 CST, UTC+8) against the live pages linked below. I build bamabound.app, an independent studio that turns one photo of one person into a vertical clip of them holding a red I'M BAMA BOUND banner. The script in this post was written for it and tested on synthetic clips (a drawn red box on a green field), not real people.
The joke in the Bama Bound meme is the sign: a red banner with white capitals reading I'M BAMA BOUND, held up in a sunny field (FAQ). When you generate that clip with an image-to-video model, the part people notice first is the banner. If its red shifts to orange for three frames, or slowly turns pink over eight seconds, the clip looks "AI" in a way a slightly different face does not.
So the question I get, and that people ask AI assistants, is roughly: "Why does the banner in my Bama Bound video flicker?" and "How can I check a clip for that before I post it?" This post answers the second question with about 100 lines of Python and NumPy. It answers the first one briefly.
Why a generated banner can flicker
An image-to-video model doesn't keep a "banner object" with a fixed colour. It predicts each frame, or a block of frames, from noise, guided by your photo and by whatever conditions the pipeline adds (on this site that is a preset with a reference clip; you don't write a prompt). Large flat areas of a saturated colour are where you see the effects of that:
- Temporal chunking. Many video models generate in windows of frames. Where two windows meet, colour can jump a little.
- Lighting changes. The model is also predicting sunlight and shadows on the cloth. A cloud-like shading change reads as a hue shift on a flat red.
- Encoding. After generation the frames are compressed to H.264. Chroma is stored at quarter resolution (4:2:0), so thin white letters on red bleed slightly. That is not flicker, but it does move the average colour a bit.
You can't fix any of that after the fact without regenerating or grading by hand. You can measure it, so you know whether you're looking at a real problem or your phone's auto-brightness.
What we measure
For each frame:
- Downscale to 160 px wide (enough for a banner, fast to process).
- Convert RGB to HSV and keep pixels that are saturated, bright and within 40° of pure red. The window is wide on purpose: if the banner drifts toward orange, it should stay inside the mask so we can see the drift instead of losing the banner.
- Record the mask's area share and its circular mean hue. Hue is an angle, so 355° and 5° are 10° apart, not 350°. The mean has to use sin/cos.
Across the clip we flag three things:
| Flag | Meaning | Default |
|---|---|---|
flicker | hue jumps more than N degrees between consecutive frames | 8° |
missing | banner area falls below half the clip's median area (occluded, cut, or out of frame) | 0.5 × median |
drift | the median hue of the last second differs from the first second | 6° |
The thresholds are starting points I picked on synthetic clips. Treat them as knobs, not standards.
The script
#!/usr/bin/env python3 """Track the red banner's colour frame by frame in a vertical clip. For every decoded frame (downscaled to 160 px wide) we build a "banner red" mask in HSV, then record the mask's area share and its circular-mean hue. We flag: * flicker: hue jumps more than --jump degrees between consecutive frames * missing: banner area drops below --min-area of the clip's median area * drift: median hue of the last second differs from the first second by > --drift deg Usage: banner_hue.py clip.mp4 [--csv out.csv] exit 0 = clean, 1 = flagged, 2 = error Needs ffmpeg/ffprobe on PATH and numpy. """ import argparse, csv, json, subprocess, sys import numpy as np W = 160 def probe(path): out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,avg_frame_rate", "-of", "json", path], capture_output=True, text=True, check=True).stdout s = json.loads(out)["streams"][0] num, den = s["avg_frame_rate"].split("/") return s["width"], s["height"], float(num) / float(den) def frames(path, w, h): hh = int(round(h * W / w / 2)) * 2 p = subprocess.Popen(["ffmpeg", "-v", "error", "-i", path, "-vf", f"scale={W}:{hh}", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE) size = W * hh * 3 while True: buf = p.stdout.read(size) if len(buf) < size: break yield np.frombuffer(buf, np.uint8).reshape(hh, W, 3) p.wait() def hsv(rgb): x = rgb.astype(np.float32) / 255.0 r, g, b = x[..., 0], x[..., 1], x[..., 2] mx, mn = x.max(-1), x.min(-1) d = mx - mn + 1e-6 h = np.where(mx == r, ((g - b) / d) % 6, np.where(mx == g, (b - r) / d + 2, (r - g) / d + 4)) * 60 s = np.where(mx > 0, (mx - mn) / (mx + 1e-6), 0) return h, s, mx def banner_stats(rgb): h, s, v = hsv(rgb) # "red-ish": hue within 40 deg of 0 (covers crimson..orange so a shift stays inside the mask) dist = np.minimum(h, 360 - h) mask = (dist < 40) & (s > 0.5) & (v > 0.35) area = float(mask.mean()) if mask.sum() < 20: return area, None a = np.deg2rad(h[mask]) hue = float(np.rad2deg(np.arctan2(np.sin(a).mean(), np.cos(a).mean()))) % 360 return area, hue def circ_median(hs): # hues sit around 0/360, so take the median of offsets from the first value ref = hs[0] off = [((x - ref + 180) % 360) - 180 for x in hs] return (ref + float(np.median(off))) % 360 def hue_diff(a, b): d = abs(a - b) % 360 return min(d, 360 - d) def main(): ap = argparse.ArgumentParser() ap.add_argument("clip"); ap.add_argument("--csv") ap.add_argument("--jump", type=float, default=8.0) ap.add_argument("--drift", type=float, default=6.0) ap.add_argument("--min-area", type=float, default=0.5) a = ap.parse_args() try: w, h, fps = probe(a.clip) except Exception as e: print(f"cannot read {a.clip}: {e}", file=sys.stderr); return 2 rows = [(i / fps, *banner_stats(f)) for i, f in enumerate(frames(a.clip, w, h))] if not rows: print("no frames decoded", file=sys.stderr); return 2 if a.csv: with open(a.csv, "w", newline="") as f: cw = csv.writer(f); cw.writerow(["t", "area", "hue"]) cw.writerows([(f"{t:.3f}", f"{ar:.4f}", "" if hu is None else f"{hu:.1f}") for t, ar, hu in rows]) med_area = float(np.median([r[1] for r in rows])) flags = [] for (t0, _, h0), (t1, ar1, h1) in zip(rows, rows[1:]): if h0 is not None and h1 is not None and hue_diff(h0, h1) > a.jump: flags.append({"t": round(t1, 3), "kind": "flicker", "detail": f"hue {h0:.1f} -> {h1:.1f}"}) if med_area > 0 and ar1 < a.min_area * med_area: flags.append({"t": round(t1, 3), "kind": "missing", "detail": f"area {ar1:.3f} vs median {med_area:.3f}"}) n = max(1, int(round(fps))) first = [r[2] for r in rows[:n] if r[2] is not None] last = [r[2] for r in rows[-n:] if r[2] is not None] drift = hue_diff(circ_median(first), circ_median(last)) if first and last else None if drift is not None and drift > a.drift: flags.append({"t": round(rows[-1][0], 3), "kind": "drift", "detail": f"{drift:.1f} deg first->last second"}) hues = [r[2] for r in rows if r[2] is not None] print(json.dumps({"clip": a.clip, "frames": len(rows), "fps": round(fps, 3), "median_area": round(med_area, 4), "median_hue": None if not hues else round(circ_median(hues), 1), "drift_deg": None if drift is None else round(drift, 1), "flags": flags}, indent=2)) return 1 if flags else 0 if __name__ == "__main__": sys.exit(main()) It reads frames from an ffmpeg pipe (-f rawvideo -pix_fmt rgb24), so it handles anything ffmpeg can decode. It needs no OpenCV, only NumPy.
Test it on synthetic clips first
Never tune a checker on the clip you're worried about. Make clips where you know the answer. This script draws a red box with white text on a sky-and-grass background, with a 440 Hz tone. There are three modes: steady, a 0.2-second orange flash at 4 s, and a slow 2°/s hue rotation.
#!/usr/bin/env bash # Synthetic stand-in for a vertical banner clip: sky, green field, a red banner with # white text, a 440 Hz tone. No people, no real footage. # ./make_banner_test_clip.sh out.mp4 [steady|flicker|drift] # env BX, BY move the banner (default 60, 330) # flicker = banner turns orange at 4.0-4.2 s; drift = whole frame hue rotates 2 deg/s set -euo pipefail out=${1:-banner_test.mp4}; mode=${2:-steady} FONT=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf banner="drawbox=x=${BX:-60}:y=${BY:-330}:w=360:h=150:color=0xC8102E:t=fill" [ "$mode" = flicker ] && banner="$banner,drawbox=x=${BX:-60}:y=${BY:-330}:w=360:h=150:color=0xF07820:t=fill:enable='between(t,4,4.2)'" post=""; [ "$mode" = drift ] && post=",hue=h=2*t" ffmpeg -v error -y -f lavfi -i "color=c=0x3A8A3A:s=480x854:r=30:d=8" \ -f lavfi -i "sine=frequency=440:duration=8:sample_rate=48000" \ -filter_complex "[0:v]drawbox=x=0:y=0:w=iw:h=300:color=0x87CEEB:t=fill,${banner},drawtext=fontfile=${FONT}:text='TEST BANNER':fontcolor=white:fontsize=40:x=${BX:-60}+(360-text_w)/2:y=${BY:-330}+55${post},format=yuv420p[v]" \ -map "[v]" -map 1:a -c:v libx264 -preset veryfast -crf 20 -c:a aac -b:a 128k -shortest "$out" echo "wrote $out ($mode)" Results on my box (ffmpeg 7.1, NumPy, 480×854 at 30 fps, 8 s):
$ ./make_banner_test_clip.sh steady.mp4 $ ./make_banner_test_clip.sh flicker.mp4 flicker $ ./make_banner_test_clip.sh drift.mp4 drift $ python3 banner_hue.py steady.mp4 -> median_hue 350.4, drift 0.0, flags [] exit 0 $ python3 banner_hue.py flicker.mp4 -> flags: flicker at 4.0 s (hue -> 25.5), flicker at 4.233 s exit 1 $ python3 banner_hue.py drift.mp4 -> drift 16.2 deg first->last second exit 1 Things worth noticing:
- The steady clip's hue is 350°, not 0°.
0xC8102Eis a crimson, slightly toward magenta. That is why the hue needs a circular mean and a circular median. A plainnp.medianover values near 0/360 gives nonsense. - The flicker is caught at both edges (into orange and back), 7 frames apart. That matches the 0.2 s at 30 fps.
- Drift is caught only by the first-vs-last comparison. Frame to frame, 2°/s is 0.07° per frame, far under the flicker threshold. You need both checks.
Pass --csv hue.csv to get t, area, hue per frame and plot it in a spreadsheet. A real problem looks like a spike or a ramp. Noise looks like fuzz of ±1–2°.
Reading results on a real clip
Some practical notes for real clips:
- A hand or arm crossing the banner shows up as
missing(area drops), sometimes with a small hue wobble. That's motion, not a defect. - Hue fuzz of ±2–3° in sunlit scenes is normal. If you see that, raise
--jumpto 10 before you worry. - If the whole picture warms up (grass and sky shift too), the cause is probably the model's lighting, or a filter you applied when editing. Compare against the untouched download.
- Check the file you'll post, not just the original. A TikTok-style edit with a colour filter will "drift" on purpose.
If the original download flickers badly, regenerating is the only real fix. On bamabound.app, that is a new paid generation. A clip that looks different from your photo is not by itself a failed order, while a paid generation that never arrived goes to refund review (help page). Resolution matters less than you'd think for flicker. Higher resolutions give the letters more pixels, not more temporal stability. For reference, today's prices are $7.98 for 480p, $9.98 for 720p, $16.98 for 1080p and $24.98 for 4k per generation, with buyer tax added at checkout.
Why not just eyeball it?
You should eyeball it too. But a 3-frame flash at 30 fps lasts 100 ms. On a phone screen at arm's length, most people won't see it until the third replay, and then they can't stop seeing it. Running a script takes a few seconds and gives you a timestamp you can scrub to.
The same approach works for any meme whose identity is one flat, saturated object: a red banner, a yellow jersey, a blue sign. Change the hue window and you have a "did the AI keep the colour" check.
Related: what the clip is and isn't (FAQ), how to pick the photo (guides).
