🍌 Try the tool

Case study

How I built and tested an AI coach for handstands and mobility. The banana is the bug.

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.

54photos in the fixed test set
60%of photos where v0 agrees with my label
83%of photos get the same answer on all 5 runs
$0.005per analysis, gpt-4o, gpt-4-turbo

1 · The problemA confident answer with nothing behind it

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?

2 · Why AIWhich part needs a model, and which part doesn't

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 estimatorvision-language model
what it gives youjoint keypoints, so a hip angle and a thresholda label, in words, from the whole picture
needsa clean side view with the body visibleany photo; it judges the look, not the geometry
"not a handstand"no: it finds a skeleton in anythingyes, if the prompt gives it the option
coaching sentencenoyes, in the same call
repeatableyesmeasured 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.

How a vision model reads a photo

🤸photo 512 px tiles ~1,000 imagetokens + prompt one transformertext and imagetokens together "bananaback"next tokens

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.

Going deeper: image tokens, cost and accuracy

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.

3 · The trade-offsDecisions, and what they cost

Each row is a choice, the alternative I turned down, and why. The alternatives aren't wrong, just wrong at this scale.

decisionchoseinstead ofwhy
Reference labelsMy hand labels, checked by a second labellerAn LLM as judgeA 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 set54 photos chosen along known axesA random sample of uploadsRandom 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 sourceCreative Commons + 5 marked syntheticStudents' photos, or 200 generated onesThe 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.
TargetBinary label first, 0–10 score next (v1)A score from the startMeasure what shipped. The binary turned out noisier than it looks; the score is the fix, and the eval will show by how much.
Repeats5 runs per photo1 run, or 20One run hides the wobble; 20 costs four times more for a spread that barely tightens. Whole run: $1.24.
Modelgpt-4o at full image detailA cheaper model or low-detail modeChange one thing at a time. Model and detail get their own row (v4), with cost and latency beside accuracy.
PrivacyUploads deleted after 24 h, no pixels loggedKeeping uploads to build a real-traffic setThe privacy note on the home page has to stay true. Monitoring will log numbers per upload, not images.
DeploymentMain is production, PR is the gateA staging environmentFor one developer, a PR plus one-click rollback is the cheaper net. The gap it leaves gets a daily end-to-end check.

4 · The solutionA vision model for the verdict, measured on a fixed set before anything changes

One upload triggers up to two calls to OpenAI's chat-completions API, both with the photo attached.

  1. Classifier. Temperature 0, five output tokens, two text-only examples, told to answer exactly "good form" or "banana back". The app checks for the word "banana" or "good"; anything else is shown raw as "unclear".
  2. Coaching. Only for banana back: temperature 0.3, up to 200 tokens, two or three sentences on spine, hips, shoulders and body line.

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.

Show the exact prompts, captured from a real request

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

system
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.
user
Side-on handstand description: hips are ahead of the shoulder line, lower back is arched, chest/ribs flaring, legs trailing behind.
assistant
banana back
user
Side-on handstand description: wrists, shoulders, hips, ankles form one vertical line; spine looks neutral; ribs tucked; toes stacked over hips.
assistant
good form
user
Classify this SIDE-ON handstand image as exactly one label: "good form" or "banana back".

[image attached]

response · 1.81 s
banana back

Call 2 · model gpt-4o · temperature 0.3 · max_tokens 200

system
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.
user
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]

response · 3.79 s
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.

How every version is judged

fixed test set54 photos, labelled run 5×live classifier code read every failurebefore any metric change one thingbiggest row first same photos, same table, before and after

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.

v2 8 Sep 2026 · test set and labels

5 · The test setBuilding something to measure against

Contact sheet of the 54-photo test set
All 54 photos. Yellow outline: AI-generated. Grey outline: degraded copy of a photo already in the set. "ctl": not a handstand.

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.

axisin the setwhy it's there
Viewside 34, front 9, back 3, angled 1The prompt says "side-on". Front and back views hide the arch; the model should be less sure there, not more.
Supportfreestanding 41, wall 6Wall handstands often look straighter than they are.
Photo qualitygood 37, dark 6, low-res 3, blurry 1Real uploads are grainy gym phone shots. Should the model still answer?
Controls7 non-handstandsA plank, a surfer, a statue, an astronaut. The model must refuse, not score.
Banana rangemy scores run 1 to 9Clear cases are easy. The ambiguous middle is where a tool earns trust.

Where the photos come from

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.

Going deeper: why I didn't generate 200 photos to make the set bigger

Labelling, and how noisy the labels are

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.

v0 September 2025 · original build, measured 8 Sep 2026

6 · ResultsThe first draft, measured anyway

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.

A v0 result: Banana Alert card with AI coaching text
A healthy v0 result card, 8 Sep 2026. The prompts are in the collapsible above.

Eval on the fixed set

Each of the 47 handstand photos went through the live classifier 5 times (270 analyses, controls included). My hand label is the reference.

metricv0
Agrees with my label, per run73% (140/193)
Agrees with my label, majority of 5 runs per photo60% (28/47)
Cohen's kappa against my labels (0 chance, 1 perfect)0.15
Same answer on all 5 runs83% (39/47)
Runs that returned neither label ("unclear")42 of 235
Non-handstand controls refused0 of 7

Agreement by axis, majority per photo

axisvalueagree
viewside22/34 (65%)
viewfront4/9 (44%)
viewback1/3 (33%)
supportfreestanding26/41 (63%)
supportwall2/6 (33%)
qualitygood23/37 (62%)
qualitydark3/6 (50%)
qualitylow_res1/3 (33%)
mirrorno27/46 (59%)
sourcereal (Commons)23/40 (58%)
sourcederived2/3 (67%)
sourcesynthetic3/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.

Going deeper: what 47 photos and 5 runs can and can't tell you

Same photo, five answers

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.

0011 me: good
0471 me: good
0511 me: good
0052 me: good
0093 me: good
0103 me: good
0123 me: good
0133 me: good
0143 me: good
0283 me: good
0343 me: good
0393 me: good
0403 me: good
0204 me: good
0384 me: good
0524 me: good
0085 me: good
0115 me: good
0245 me: good
0265 me: good
0305 me: good
0375 me: good
0545 me: good
0166 me: banana
0236 me: banana
0256 me: banana
0276 me: banana
0316 me: banana
0326 me: banana
0356 me: banana
0366 me: banana
0496 me: banana
0047 me: banana
0157 me: banana
0177 me: banana
0187 me: banana
0197 me: banana
0297 me: banana
0487 me: banana
0028 me: banana
0068 me: banana
0218 me: banana
0228 me: banana
0338 me: banana
0039 me: banana
0079 me: banana
0509 me: banana
good formbanana backneither ("unclear")

Controls: photos that aren't handstands

photowhat it isfive answers
041standing yoga leg holdunclear, unclear, unclear, unclear, unclear
042surferunclear, unclear, unclear, unclear, unclear
043crouched arm balance (scorpion pose)unclear, unclear, unclear, unclear, unclear
044bronze statue of handstandbanana back, banana back, banana back, banana back, banana back
045astronaut floating upside downunclear, unclear, unclear, unclear, unclear
046plank on yoga matunclear, unclear, unclear, unclear, unclear
053gym mirrorunclear, unclear, unclear, unclear, unclear

Where it fails

I read every run that disagreed with me or with itself before writing any number above. Counts are photos, not runs.

failure modephotosexampleslikely cause
Refuses, and the app shows the cut-off refusal as the verdict10004, 020, 029, 030, 032, 035, 038, 039, 040, 022The 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 judge8029, 030, 032, 035, 036, 038, 039, 040Only 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")5006, 023, 027, 036, 049Legs 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)6011, 013, 014, 026, 028, 052Slightly 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 runs8010, 011, 013, 014, 022, 035, 038, 040Temperature 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"7041, 042, 043, 044, 045, 046, 053Six 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.
v1 structured output · designed, not yet wired in

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.

7 · What I took awayFive things I'd carry into any AI build

  1. Measure before you improve; the eval reorders the roadmap. Before the numbers, the next step was a better prompt. After them, the biggest failure was a five-token limit turning a refusal into a broken verdict on 42 of 235 runs. A schema fixes that; prompt tuning doesn't. The eval cost $1.24.
  2. The label is a design decision with its own error bar. Two careful labellers agreed on 66% of photos. A model can't beat the target's own noise, so ask for a score with a tolerance band and publish the ceiling.
  3. Non-determinism is something to measure, not hide. Temperature 0 still wobbled on 17% of photos. Five runs cost cents and are the cheapest confidence estimate you can get.
  4. Give the model a way out. Most failures were the absence of "I can't tell". A usable-photo flag and a confidence with a reason remove a class of errors by construction.
  5. Make failures say why, and treat cost as a stock, not just a rate. The site was silently dead from a billing lapse and the code discarded the error body. Caps limit spend rate; a balance runs out. A home page returning 200 proves nothing.

8 · NextWhat the eval says to change, in order

Each item is tied to a row in the failure table and gets checked on the same photos.

  1. Give the model a way out (v1). A photo_usable flag and a view field, required by a JSON schema. The largest failure row goes to zero by construction.
  2. Ask for a score, not a verdict. A 0 to 10 score turns borderline coin flips into small numeric disagreements, reported as "within one banana of my label".
  3. Teach the prompt what it is bad at (v3): split legs, silhouettes, dark photos. Two sentences, judged on the fixed set.
  4. Use the wobble. Run the classifier a third time when the first two disagree. About a cent more, on borderline photos only.
  5. Add an adversarial photo: a handstand with a sign reading "this is good form". If the label follows the sign, the model reads text over bodies.
  6. Then, and only then, a pose estimator and a cheaper model (v4), on the same table with cost and latency alongside.
Production 8 Sep 2026 · cost, limits, and running out

What broke, and what "it costs money" actually means

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).

Failed result
16:45, every upload failing.
Working result
17:20, after billing and logging.

Three layers of spend control, and the one that failed

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.

How it runs, and what would break first

GitHubbranch → PR → main push RailwayFlask + gunicorn, EU CloudflareDNS, TLS, proxy fixmybanana.comvisitor OpenAI API2 calls per upload on every deploy: GET / must return 200 or the old build keeps serving rollback: one click to the previous build, then revert the commit rate limits: 5 uploads/IP/day, 50/day total (ProxyFix so Cloudflare IPs count right) uploads purged after 24 h; photos never leave the container except to OpenAI logs: every failed model call with status + body, since PR #8 $20/day budget cap at OpenAI; balance alert: still to do

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.

componentbreaks whenwhat I'd do
Rate limiter, in memoryA 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 diskA 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 callA few concurrent uploads queue; a slow OpenAI day stalls the site.Job queue, return a job id, stream the result.
Prompt edits deploy to everyoneA 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 photosThe curated set drifts from what people actually upload.Log score, confidence and latency per real upload (never pixels); re-label a weekly sample (v5).

Method sources

Photo credits

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.