Case study
fixmybanana.com is a fun little tool with a real model behind it: upload a handstand photo and an OpenAI vision model tells you whether you have "good form" or a "banana back", the arched spine every beginner has.
I was a personal trainer before data science, and I built this the way I learn best: make something, then find out where it breaks. This page is the finding-out, kept as a lab notebook. It answers four questions: what problem the tool has, what I traded off, how it's built and why a vision model is the right tool, and how each version scores on the same 54 photos I labelled by hand. Some of the numbers are bad. That's the point.
Code, prompts, test set and every result CSV: github.com/shhirl/fixmybanana.
The first version did what most AI demos do: send a photo and a prompt to a model, show whatever comes back in a nice card. Four things were wrong with that, and none of them showed on screen.
The question this page answers is the one I'd ask about any model in production: how do you know, and what does it cost to find out?
A banana back is a curve in the lower spine. There are two honest ways to find it in a photo, and only one of them is AI in the sense people mean.
| pose estimator | vision-language model | |
|---|---|---|
| what it gives you | joint keypoints, so a hip angle and a threshold | a label, in words, from the whole picture |
| needs | a clean side view with the body visible | any photo; it judges the look, not the geometry |
| "not a handstand" | no: it finds a skeleton in anything | yes, if the prompt gives it the option |
| coaching sentence | no | yes, in the same call |
| repeatable | yes | measured below: 83% of photos |
The part that needs a language model is the coaching and the judgement on messy real phone shots. The angle itself doesn't. So: a vision-language model for verdict and feedback, measured against hand labels, with a pose-estimator baseline planned on the same photos (v4). If the geometry wins on accuracy per cent, it takes the verdict and the language model keeps the coaching. The model has to earn the job on the fixed set.
No skeleton, no joint angles. The photo is cut into tiles, each tile becomes a few hundred vectors, and those are appended to the prompt as extra words. The transformer that predicts the next word predicts the verdict. Three things follow, and all three show up in the results.
A vision encoder turns each 14- or 16-pixel patch into an embedding; pooling reduces those to a fixed budget per tile (OpenAI bills 170 tokens per 512-pixel tile plus 85 for a downscaled overview). The language model attends over image and text tokens with the same mechanism, so "banana back" in the prompt and the pixels of a lumbar curve compete in the same attention heads. Consequences: resolution is a cost lever (low-detail mode sends only the 85-token overview, about a quarter of the price, worth testing because arch is a coarse feature); fine angular judgements are unreliable by construction; and there is no calibrated probability behind the answer unless you ask for one, which is why v1 requests a confidence with a reason.
Each row is a choice, the alternative I turned down, and why. The alternatives aren't wrong, just wrong at this scale.
| decision | chose | instead of | why |
|---|---|---|---|
| Reference labels | My hand labels, checked by a second labeller | An LLM as judge | A judge needs 100+ validated labels and drifts. At 54 photos, reading every failure myself is cheaper. Labeller agreement, 66%, is published as the ceiling. |
| Test set | 54 photos chosen along known axes | A random sample of uploads | Random uploads are mostly side-view gym shots; the edges are where it fails. Price: the set doesn't match live traffic, which monitoring (v5) is for. |
| Photo source | Creative Commons + 5 marked synthetic | Students' photos, or 200 generated ones | The set is public, so it can only hold images I may republish. Synthetic images leak the label into the pixels; kept to the two axes Commons can't supply. |
| Target | Binary label first, 0–10 score next (v1) | A score from the start | Measure what shipped. The binary turned out noisier than it looks; the score is the fix, and the eval will show by how much. |
| Repeats | 5 runs per photo | 1 run, or 20 | One run hides the wobble; 20 costs four times more for a spread that barely tightens. Whole run: $1.24. |
| Model | gpt-4o at full image detail | A cheaper model or low-detail mode | Change one thing at a time. Model and detail get their own row (v4), with cost and latency beside accuracy. |
| Privacy | Uploads deleted after 24 h, no pixels logged | Keeping uploads to build a real-traffic set | The privacy note on the home page has to stay true. Monitoring will log numbers per upload, not images. |
| Deployment | Main is production, PR is the gate | A staging environment | For one developer, a PR plus one-click rollback is the cheaper net. The gap it leaves gets a daily end-to-end check. |
One upload triggers up to two calls to OpenAI's chat-completions API, both with the photo attached.
Models are tried in order until one returns 200. Uploads are rate-limited (5 per IP per day, 50 in total) and deleted after 24 hours. Flask on Railway, Cloudflare in front, every push to main deploys.
Recorded by eval/capture_v0.py, which runs the live code path with the network call logged, so this can't drift from what the site sends. Image bytes removed.
Call 1 · model gpt-4o · temperature 0 · max_tokens 5
You are a strict vision classifier. Goal: From a SIDE-ON photo of a handstand, output exactly one label: "good form" or "banana back". Definitions: • banana back = clear lumbar/spinal arch; ribs flare forward; hips in front of shoulders; legs/feet drift behind the body, making a C/banana shape. • good form = wrists–shoulders–hips–ankles vertically stacked; neutral spine; ribs tucked; no visible midsection curve. Rules: Output ONLY one of these strings with no punctuation or explanation.
Side-on handstand description: hips are ahead of the shoulder line, lower back is arched, chest/ribs flaring, legs trailing behind.
banana back
Side-on handstand description: wrists, shoulders, hips, ankles form one vertical line; spine looks neutral; ribs tucked; toes stacked over hips.
good form
Classify this SIDE-ON handstand image as exactly one label: "good form" or "banana back".
[image attached]
banana back
Call 2 · model gpt-4o · temperature 0.3 · max_tokens 200
You are a handstand coach providing detailed feedback. Analyze the handstand image and explain specifically why it shows 'banana back' form. Focus on: spinal alignment, hip position, shoulder position, and overall body line. Provide constructive, specific feedback in 2-3 sentences.
This handstand has been classified as 'banana back'. Please analyze the image and explain specifically why this is banana back form. What do you see in terms of spinal alignment, hip position, and overall body line?
[image attached]
In this handstand, the 'banana back' form is evident due to the pronounced arch in the lower back, causing the spine to curve rather than remain straight. The hips are not aligned over the shoulders and wrists, contributing to the arch. The overall body line deviates from a straight line, with the legs and feet extending backward instead of being stacked directly over the body. To improve, focus on engaging the core and tucking the pelvis to achieve a straighter alignment.
The eval imports the site's own classifier function, so it can't drift from what users get. The test set never changes, so the only thing that moves the numbers is the change I made.

54 photos, 47 handstands and 7 controls, chosen before I looked at any model output, along axes I knew mattered from teaching handstands. The set is built to cover the edges, not the average upload.
| axis | in the set | why it's there |
|---|---|---|
| View | side 34, front 9, back 3, angled 1 | The prompt says "side-on". Front and back views hide the arch; the model should be less sure there, not more. |
| Support | freestanding 41, wall 6 | Wall handstands often look straighter than they are. |
| Photo quality | good 37, dark 6, low-res 3, blurry 1 | Real uploads are grainy gym phone shots. Should the model still answer? |
| Controls | 7 non-handstands | A plank, a surfer, a statue, an astronaut. The model must refuse, not score. |
| Banana range | my scores run 1 to 9 | Clear cases are easy. The ambiguous middle is where a tool earns trust. |
46 are Creative Commons or public-domain images from Wikimedia Commons, licence and author recorded per photo. 3 are darkened, blurred or downscaled copies of photos already in the set: same pose, same label, one variable moved. 5 are AI-generated, because Commons has almost no wall or mirror handstands; they are marked, and one has anatomy no human could produce, kept as an edge case. No photos of students or friends: the set is public, so it holds only images I may republish.
I scored every handstand 0 to 10 (0 is a ruler, 10 is a croissant) before seeing any model output. The binary label is derived by a fixed rule, 0 to 5 good form and 6 to 10 banana back, so it can't drift from the score. The cut sits at 6 because the v0 prompt defines banana back as a clear arch.
A second labeller, Claude, scored the set independently with the same rule. We agreed on the binary label for 66% of handstands (31/47); Cohen's kappa 0.32, which corrects for the 50% two coin-flippers would get. Most disagreements sit at scores 4 to 6, where the rule cuts through a continuum. That number is the ceiling: a model can't reliably agree with me more often than a second careful reader does.
This is the site as first built: a free-text prompt, a string match on the answer, no schema, no logging. A bad baseline beats no baseline, because every later version is judged on the same photos.

Each of the 47 handstand photos went through the live classifier 5 times (270 analyses, controls included). My hand label is the reference.
| metric | v0 |
|---|---|
| Agrees with my label, per run | 73% (140/193) |
| Agrees with my label, majority of 5 runs per photo | 60% (28/47) |
| Cohen's kappa against my labels (0 chance, 1 perfect) | 0.15 |
| Same answer on all 5 runs | 83% (39/47) |
| Runs that returned neither label ("unclear") | 42 of 235 |
| Non-handstand controls refused | 0 of 7 |
| axis | value | agree |
|---|---|---|
| view | side | 22/34 (65%) |
| view | front | 4/9 (44%) |
| view | back | 1/3 (33%) |
| support | freestanding | 26/41 (63%) |
| support | wall | 2/6 (33%) |
| quality | good | 23/37 (62%) |
| quality | dark | 3/6 (50%) |
| quality | low_res | 1/3 (33%) |
| mirror | no | 27/46 (59%) |
| source | real (Commons) | 23/40 (58%) |
| source | derived | 2/3 (67%) |
| source | synthetic | 3/4 (75%) |
Full per-run data: eval/results/v0.csv. Mean $0.0046 per upload (about 1,163 input tokens per call, mostly the image), 2.5 s mean latency; the whole run cost $1.24 at 8 Sep 2026 prices.
max_tokens=5 a refusal ("I'm unable to…") gets cut off, the string match finds neither label, and the user sees the fragment. Structured output (v1) removes that by construction.The chart I'd show a client first. Each row is a photo, sorted by my score; the five marks are the label from each run. Mixed rows got different answers from the same model, prompt and image. Any upload on the live site is one draw from this.
| photo | what it is | five answers |
|---|---|---|
| 041 | standing yoga leg hold | unclear, unclear, unclear, unclear, unclear |
| 042 | surfer | unclear, unclear, unclear, unclear, unclear |
| 043 | crouched arm balance (scorpion pose) | unclear, unclear, unclear, unclear, unclear |
| 044 | bronze statue of handstand | banana back, banana back, banana back, banana back, banana back |
| 045 | astronaut floating upside down | unclear, unclear, unclear, unclear, unclear |
| 046 | plank on yoga mat | unclear, unclear, unclear, unclear, unclear |
| 053 | gym mirror | unclear, unclear, unclear, unclear, unclear |
I read every run that disagreed with me or with itself before writing any number above. Counts are photos, not runs.
| failure mode | photos | examples | likely cause |
|---|---|---|---|
| Refuses, and the app shows the cut-off refusal as the verdict | 10 | 004, 020, 029, 030, 032, 035, 038, 039, 040, 022 | The prompt insists on a side-on photo; on anything else the model hedges ("I'm unable to classify…"). max_tokens=5 truncates the hedge, the string match finds neither label, and the fragment is shown to the user. 8 of the 12 front/back photos end here. |
| Front and back views: guesses or refuses instead of saying it can't judge | 8 | 029, 030, 032, 035, 036, 038, 039, 040 | Only 4 of 12 front/back photos come out right. The arch is invisible from the front; the model has no field for "can't see the spine", so it either refuses (row above) or answers with side-view confidence. |
| Misses a clear arch (calls a banana back "good form") | 5 | 006, 023, 027, 036, 049 | Legs split or bent, silhouettes at dusk, and a low-resolution copy. The model reads the leg line as the body line; when the torso has no contrast it defaults to "good form". 049 is the downscaled copy of a photo it labels correctly at full size. |
| Over-calls an arch on straight-ish photos (my score 3 to 5) | 6 | 011, 013, 014, 026, 028, 052 | Slightly piked hips, a one-arm balance, a night shot and a dark garage. Dark or unusual photos push it toward "banana back", the label its examples describe in most detail. |
| Coin-flip on borderline photos: different answers across the five runs | 8 | 010, 011, 013, 014, 022, 035, 038, 040 | Temperature 0 does not make the arg-max stable when the two labels are nearly tied. These are the photos a 0–10 score would place at 4 to 6; the binary prompt has no way to express that. |
| Controls: no way to say "not a handstand" | 7 | 041, 042, 043, 044, 045, 046, 053 | Six of seven controls are refused, which is the right instinct shown as a broken message. The bronze statue of a handstand is scored "banana back" five times out of five. |
Next: force a JSON schema. A usable-photo flag, view, support, a 0–10 score, five body segments, and a confidence with a reason. Same photos, same table.
Each item is tied to a row in the failure table and gets checked on the same photos.
photo_usable flag and a view field, required by a JSON schema. The largest failure row goes to zero by construction.On 8 Sep 2026, while I was taking baseline screenshots, every upload returned "All Vision Models Failed". The site had been silently broken for an unknown time. The cause was a billing lapse; the real problem was that the code caught the error and threw the body away. Fix: sort out billing, log status and body for each failed call, and tell users whether we're out of quota or they're rate-limited (PR #8).


An upload is one or two gpt-4o calls with an image, roughly a quarter of a cent per call; measured mean $0.0046 per upload.
For a client: a balance alert with a week's warning, auto-recharge with a monthly ceiling, code that separates insufficient_quota (wake a human) from rate_limit_exceeded (retry), and a daily synthetic upload that asserts on the label.
Nothing here runs at scale. Each row is a decision that is right today and wrong at a hundred times the load, in the order it would break.
| component | breaks when | what I'd do |
|---|---|---|
| Rate limiter, in memory | A second worker or replica: each keeps its own count, so limits silently double. | Redis; key on session as well as IP, since offices share one IP. |
| Uploads on container disk | A second replica can't find the other's photo. Wiped on deploy. | Object storage with a 24-hour lifecycle rule, so the privacy promise is a bucket policy. |
| Synchronous model call | A few concurrent uploads queue; a slow OpenAI day stalls the site. | Job queue, return a job id, stream the result. |
| Prompt edits deploy to everyone | A regression reaches 100% of users before anyone sees a number. | Prompts as versioned data; the eval runs in CI and blocks a merge that gets worse. |
| Eval by hand on 54 photos | The curated set drifts from what people actually upload. | Log score, confidence and latency per real upload (never pixels); re-label a weekly sample (v5). |
Test-set photos are from Wikimedia Commons under CC0, CC BY, CC BY-SA and public-domain licences, plus 5 AI-generated images. Every photo's author, licence and source page is in eval/labels.csv, so the credits live with the data and can't go stale.