DEV Community

Peiguan
Peiguan

Posted on

Does the Red Banner Flicker or Change Colour in My Bama Bound AI Clip? Tracking Banner Hue Frame by Frame in Python

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:

  1. Downscale to 160 px wide (enough for a banner, fast to process).
  2. 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.
  3. 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())
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen 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
Enter fullscreen mode Exit fullscreen mode

Things worth noticing:

  • The steady clip's hue is 350°, not 0°. 0xC8102E is a crimson, slightly toward magenta. That is why the hue needs a circular mean and a circular median. A plain np.median over 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 --jump to 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).

Top comments (0)