"Which photo should I use?" is the question that decides most AI remakes of the Hit the Road Dude meme, also searched as the State Property meme. One photo goes in, and a 15-second clip comes out. In it, your face (or your dog's) sits at the open window of a white pickup, repeats "State?" and sends the visitor off. The model only knows what's in that one photo. A soft, dark or sideways upload shows up in the result.
I run hittheroaddude.app, one of the generators for this clip. This post covers the small script I use before uploading a photo anywhere. It checks the hard limits first, then flags the soft problems that usually lead to a weak result. It's plain Python with Pillow and NumPy, and I ran it on the synthetic test images below.
The hard rules vs the heuristics
Some rules are the site's, and some are mine. They're different kinds of thing, so the script keeps them apart.
Hard limits (from the driver photo guide, checked 2026-11-09):
- JPEG, PNG or WebP
- up to 10 MB
- one clear photo of one adult or one pet, face visible
Heuristics (my own thresholds, not rules):
- short side under 512 px: faces get mushy
- low Laplacian variance: the photo is probably soft or motion-blurred
- mean brightness far from the middle: too dark or washed out
- lots of pure black or pure white pixels: detail is clipped
- an EXIF orientation tag: the pixels are stored sideways, and some pipelines ignore the tag
The script can't check "one subject" or "face visible". Those need eyes, or a face detector, which this post skips on purpose. Pets break most face detectors anyway.
The checker
#!/usr/bin/env python3 """Pre-upload check for a Hit the Road Dude driver photo. Usage: python3 photo_check.py photo.jpg [more.jpg ...] Needs: pip install pillow numpy Hard limits come from the site (JPEG, PNG or WebP, up to 10 MB). Everything else is a heuristic warning, not a rule. """ import os, sys import numpy as np from PIL import Image, ImageOps MAX_BYTES = 10 * 1024 * 1024 # "up to 10 MB" ALLOWED = {"JPEG", "PNG", "WEBP"} # "JPEG, PNG or WebP" MIN_SHORT_SIDE = 512 # heuristic: below this, faces get mushy BLUR_MIN = 60.0 # heuristic: Laplacian variance on 1024 px DARK, BRIGHT = 60, 200 # heuristic: mean luma range (0-255) CLIP_MAX = 0.08 # heuristic: >8% pure black/white pixels def laplacian_var(gray): g = gray.astype(np.float32) lap = (g[1:-1, :-2] + g[1:-1, 2:] + g[:-2, 1:-1] + g[2:, 1:-1] - 4 * g[1:-1, 1:-1]) return float(lap.var()) def check(path): errors, warnings, info = [], [], {} size = os.path.getsize(path) info["bytes"] = size if size > MAX_BYTES: errors.append(f"file is {size / 1048576:.1f} MB, limit is 10 MB") try: im = Image.open(path) fmt = im.format except Exception as e: return {"path": path, "errors": [f"not an image Pillow can read: {e}"], "warnings": [], "info": info} info["format"] = fmt if fmt not in ALLOWED: errors.append(f"format {fmt}; upload JPEG, PNG or WebP") orient = im.getexif().get(0x0112, 1) if orient not in (1, None): warnings.append(f"EXIF orientation {orient}: the raw pixels are rotated; " "re-save upright if the preview looks sideways") im = ImageOps.exif_transpose(im) w, h = im.size info["size"] = f"{w}x{h}" if min(w, h) < MIN_SHORT_SIDE: warnings.append(f"short side {min(w, h)} px is small; use the original photo") if getattr(im, "n_frames", 1) > 1: warnings.append("animated file: only one frame will matter") gray = im.convert("L") gray.thumbnail((1024, 1024)) a = np.asarray(gray) sharp = laplacian_var(a) mean = float(a.mean()) clipped = float(((a <= 3) | (a >= 252)).mean()) info.update(sharpness=round(sharp, 1), mean_luma=round(mean, 1), clipped=round(clipped, 3)) if sharp < BLUR_MIN: warnings.append(f"looks soft (sharpness {sharp:.0f} < {BLUR_MIN:.0f})") if mean < DARK: warnings.append(f"looks dark (mean {mean:.0f})") elif mean > BRIGHT: warnings.append(f"looks washed out (mean {mean:.0f})") if clipped > CLIP_MAX: warnings.append(f"{clipped:.0%} of pixels are pure black or white") return {"path": path, "errors": errors, "warnings": warnings, "info": info} if __name__ == "__main__": if len(sys.argv) < 2: sys.exit(__doc__) worst = 0 for p in sys.argv[1:]: r = check(p) status = "FAIL" if r["errors"] else ("WARN" if r["warnings"] else "OK") worst = max(worst, {"OK": 0, "WARN": 1, "FAIL": 2}[status]) print(f"{status:4} {os.path.basename(p)} {r['info']}") for m in r["errors"]: print(f" error: {m}") for m in r["warnings"]: print(f" warn: {m}") sys.exit(worst) A few implementation notes:
-
ImageOps.exif_transposebefore measuring. Phones often save a portrait shot as landscape pixels plus an "orientation = 6" tag. If you measure or crop before applying it, you're working on a sideways image. - Sharpness on a 1024 px thumbnail. Laplacian variance depends on resolution, so the image is downsized first. That keeps the threshold comparable between a 12 MP phone photo and a 1 MP screenshot.
- The Laplacian is four NumPy slices. There's no OpenCV dependency. It's the classic 3×3 kernel (
0 1 0 / 1 -4 1 / 0 1 0), written as shifted arrays. - Exit codes: 0 means everything passed, 1 means warnings only, and 2 means a hard limit failed. That makes it easy to use in a shell loop or a pre-commit-style script.
Test images
No real faces were used here. The generator script draws a head-shaped ellipse, shoulders and random line texture, then saves the variants that typically go wrong:
#!/usr/bin/env python3 """Make synthetic test photos (no real faces) for photo_check.py.""" import os import numpy as np from PIL import Image, ImageDraw, ImageFilter os.makedirs("samples", exist_ok=True) rng = np.random.default_rng(7) def portrait(w=1200, h=1600): im = Image.new("RGB", (w, h), (120, 150, 170)) d = ImageDraw.Draw(im) d.ellipse((w*0.3, h*0.2, w*0.7, h*0.55), fill=(205, 170, 140)) # "head" d.rectangle((w*0.2, h*0.6, w*0.8, h), fill=(40, 60, 90)) # "shoulders" for _ in range(400): # texture x, y = rng.integers(0, w), rng.integers(0, h) c = tuple(int(v) for v in rng.integers(0, 255, 3)) d.line((x, y, x + rng.integers(-30, 30), y + rng.integers(-30, 30)), fill=c, width=2) return im base = portrait() base.save("samples/good.jpg", quality=90) base.filter(ImageFilter.GaussianBlur(6)).save("samples/blurry.jpg", quality=90) Image.eval(base, lambda v: v // 5).save("samples/dark.jpg", quality=90) base.resize((300, 400)).save("samples/tiny.png") base.save("samples/still.gif") # sideways phone photo: pixels stored rotated, EXIF says "rotate 90 CW" rot = base.rotate(90, expand=True) exif = Image.Exif(); exif[0x0112] = 6 rot.save("samples/sideways.jpg", quality=90, exif=exif) # oversized: 3000x4000 noise PNG is far above 10 MB Image.fromarray(rng.integers(0, 255, (4000, 3000, 3), dtype=np.uint8)).save("samples/huge.png") for f in sorted(os.listdir("samples")): print(f, os.path.getsize(f"samples/{f}")) Output from python3 make_samples.py && python3 photo_check.py samples/* (lightly trimmed):
WARN blurry.jpg sharpness 0.5 -> looks soft (sharpness 1 < 60) WARN dark.jpg sharpness 6.9, mean_luma 25.3 -> looks soft, looks dark (mean 25) OK good.jpg 1200x1600, sharpness 164.8, mean_luma 126.1 FAIL huge.png error: file is 34.4 MB, limit is 10 MB WARN sideways.jpg EXIF orientation 6: the raw pixels are rotated FAIL still.gif error: format GIF; upload JPEG, PNG or WebP WARN tiny.png short side 300 px is small; use the original photo One result is worth explaining. The dark photo is also flagged as soft, even though it's the same drawing as good.jpg. Laplacian variance measures edge contrast, so darkening an image lowers it. Fix exposure first, then re-check sharpness. Otherwise you'll go hunting for a sharper photo you don't need.
Fixing what's fixable
Format, size and orientation can be fixed in code. Blur and bad light can't, so use a different photo for those. This helper bakes the rotation into the pixels, caps the long side at 2048 px and re-saves as JPEG:
#!/usr/bin/env python3 """Re-save a photo upright as a JPEG under 10 MB (long side <= 2048 px). Usage: python3 fix_photo.py in.ext out.jpg """ import os, sys from PIL import Image, ImageOps src, dst = sys.argv[1], sys.argv[2] im = ImageOps.exif_transpose(Image.open(src)).convert("RGB") # bake in rotation im.thumbnail((2048, 2048), Image.LANCZOS) # never upscales for q in (92, 85, 78, 70): im.save(dst, "JPEG", quality=q, optimize=True) # no EXIF copied if os.path.getsize(dst) <= 10 * 1024 * 1024: break print(dst, im.size, os.path.getsize(dst), "bytes, quality", q) I ran the fixer on the three failing files, then re-checked them:
samples/fixed-huge.jpg (1536, 2048) 2136089 bytes, quality 92 samples/fixed-sideways.jpg (1200, 1600) 92914 bytes, quality 92 samples/fixed-still.jpg (1200, 1600) 99131 bytes, quality 92 OK fixed-huge.jpg ... OK fixed-sideways.jpg ... OK fixed-still.jpg ... Side effect: Pillow doesn't copy EXIF unless you pass it, so the re-saved JPEG also drops GPS and camera metadata. For a photo you're about to upload to any web service, that's usually what you want.
What the numbers can't tell you
The checks above are necessary but not sufficient. Before uploading, also look at the photo yourself:
- One subject. In a group photo the generator may pick the wrong face. Crop down to one person or one pet.
- Face and shoulders, not a scene. A wide shot of you next to a truck is the output look, not a useful input. The guide puts it as: "A wide shot of a truck is what the result looks like, not a useful upload."
- Your right to use it. Use yourself, an adult who agreed, or your own pet. Don't use a photo just because it's online (acceptable use).
- Pets: eyes, nose and mouth visible, roughly facing the camera. The dog and cat guide has a table of photos that do and don't work.
Where the photo goes next
On my studio, the photo is the only creative input. There's no prompt box, and the scene and lines are saved in a preset. You pick 480p or 720p, see the total on the button and pay before anything is generated. The live pricing page on 2026-11-09 listed $9.99 at 480p (6 credits) or $12.99 at 720p (14 credits) per clip. Signed-in accounts can also buy one-time credit packs. There's no subscription and no free clip, so a 30-second photo check costs less than a wasted run.
If you're ready, the create page takes the file directly. If the result still looks off, the help page points out that "Most problems come from the photo." That's why this script exists.