From 2e386a4297e401fcb3c0a95f10648ea17990004a Mon Sep 17 00:00:00 2001 From: Adam French Date: Mon, 16 Mar 2026 13:44:57 +0000 Subject: [PATCH] Uses claude to achieve extracting rowing times and distance --- extract_rowing_data.py | 178 +++++++++++++++++++++++++++++++++++++++++ rowing_results.csv | 35 ++++++++ 2 files changed, 213 insertions(+) create mode 100644 extract_rowing_data.py create mode 100644 rowing_results.csv diff --git a/extract_rowing_data.py b/extract_rowing_data.py new file mode 100644 index 0000000..7e5de28 --- /dev/null +++ b/extract_rowing_data.py @@ -0,0 +1,178 @@ +""" +Extract rowing machine time and distance from a display photo using Claude's vision API. + +Mirrors the extraction logic from handle_rowing.go. + +Usage: + python extract_rowing_data.py path/to/image.jpg + python extract_rowing_data.py --dir path/to/images/ +""" + +import argparse +import base64 +import json +import mimetypes +import sys +from pathlib import Path + +import anthropic +from PIL import Image +from PIL.ExifTags import Base as ExifBase + + +ALLOWED_MEDIA_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} + +PROMPT = """Look at this rowing machine display. Extract the total elapsed time and total distance. + +Return ONLY a JSON object with these exact keys and numeric values: +- "timeMinutes": total minutes (e.g. 2:30 = 2) +- "timeSeconds": total seconds (e.g. 2:30 = 30) +- "distance": distance in meters as a number (e.g. 5000) + +No text, no markdown, no explanation. Just the JSON object.""" + +# Validation bounds (same as Go handler) +MIN_DISTANCE = 100 # metres +MAX_DISTANCE = 100_000 # metres +MIN_TOTAL_SECS = 30 # 30 seconds +MAX_TOTAL_SECS = 7200 # 2 hours +MIN_PACE_PER_500M = 80 # ~1:20 /500m +MAX_PACE_PER_500M = 150 # ~2:30 /500m + + +def get_exif_date(image_path: str) -> str | None: + """Extract the date taken from EXIF data.""" + img = Image.open(image_path) + exif = img.getexif() + if not exif: + return None + return exif.get(ExifBase.DateTime) + + +def extract_rowing_data(image_path: str) -> dict: + """ + Send an image to Claude and extract rowing time/distance. + + Returns dict with keys: timeMinutes, timeSeconds, distance, totalSeconds, + timePer500m, calories, dateTaken. + """ + path = Path(image_path) + media_type = mimetypes.guess_type(str(path))[0] + if media_type not in ALLOWED_MEDIA_TYPES: + raise ValueError(f"Unsupported image type: {media_type}") + + encoded = base64.standard_b64encode(path.read_bytes()).decode("utf-8") + + client = anthropic.Anthropic() + message = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=256, + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": encoded, + }, + }, + { + "type": "text", + "text": PROMPT, + }, + ], + } + ], + ) + + if not message.content: + raise RuntimeError("Empty response from Claude") + + raw = message.content[0].text.strip() + # Strip markdown fencing if present + raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip() + + data = json.loads(raw) + time_minutes = int(data["timeMinutes"]) + time_seconds = int(data["timeSeconds"]) + distance = int(data["distance"]) + + if distance == 0: + raise ValueError("Invalid distance: 0") + + total_seconds = time_minutes * 60 + time_seconds + + # Validate + if not (MIN_DISTANCE <= distance <= MAX_DISTANCE): + raise ValueError(f"Anomalous distance: {distance}") + if not (MIN_TOTAL_SECS <= total_seconds <= MAX_TOTAL_SECS): + raise ValueError(f"Anomalous time: {total_seconds}s") + + per_500m = total_seconds / distance * 500.0 + if not (MIN_PACE_PER_500M <= per_500m <= MAX_PACE_PER_500M): + raise ValueError(f"Anomalous pace: {per_500m:.1f}s /500m") + + calories = distance / 7500.0 * 500.0 + + date_taken = get_exif_date(image_path) + + return { + "timeMinutes": time_minutes, + "timeSeconds": time_seconds, + "distance": distance, + "totalSeconds": total_seconds, + "timePer500m": round(per_500m, 2), + "calories": round(calories, 2), + "dateTaken": date_taken, + } + + +def main(): + parser = argparse.ArgumentParser(description="Extract rowing data from display photos") + parser.add_argument("--image", help="Path to a single image") + parser.add_argument("--dir", help="Path to a directory of images") + args = parser.parse_args() + + if not args.image and not args.dir: + parser.error("Provide --image or --dir") + if args.image and args.dir: + parser.error("--image and --dir are mutually exclusive") + + IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + + if args.dir: + dir_path = Path(args.dir) + image_paths = sorted( + p for p in dir_path.iterdir() if p.suffix.lower() in IMAGE_EXTS + ) + if not image_paths: + print(f"No images found in {args.dir}") + sys.exit(1) + else: + image_paths = [Path(args.image)] + + for img_path in image_paths: + print(f"\n {img_path.name}:") + try: + result = extract_rowing_data(str(img_path)) + mins = result["timeMinutes"] + secs = result["timeSeconds"] + dist = result["distance"] + pace = result["timePer500m"] + pace_m = int(pace) // 60 + pace_s = pace % 60 + print(f" Time: {mins}:{secs:02d}") + print(f" Distance: {dist}m") + print(f" Pace: {pace_m}:{pace_s:04.1f} /500m") + print(f" Calories: {result['calories']:.0f}") + if result["dateTaken"]: + print(f" Date: {result['dateTaken']}") + except Exception as e: + print(f" ERROR: {e}") + + +if __name__ == "__main__": + main() diff --git a/rowing_results.csv b/rowing_results.csv new file mode 100644 index 0000000..173967d --- /dev/null +++ b/rowing_results.csv @@ -0,0 +1,35 @@ +image,time,distance_m,pace_per_500m,calories,date_taken +IMG_5413.JPEG,30:06,7283,2:04.0,486,2025-11-05 12:08:00 +IMG_5414.JPEG,30:06,7337,2:03.1,489,2025-11-06 19:17:40 +IMG_5416.JPEG,40:05,9671,2:04.3,645,2025-11-07 15:06:23 +IMG_5417.JPEG,35:07,8629,2:02.1,575,2025-11-08 12:02:51 +IMG_5418.JPEG,30:06,7352,2:02.8,490,2025-11-08 15:32:17 +IMG_5428.JPEG,35:06,8543,2:03.3,570,2025-11-13 17:12:54 +IMG_5443.JPEG,30:07,7383,2:02.4,492,2025-11-18 18:15:02 +IMG_5448.JPEG,30:06,7488,2:00.6,499,2025-11-29 16:21:54 +IMG_5449.JPEG,30:06,7450,2:01.2,497,2025-11-30 19:51:28 +IMG_5451.JPEG,20:07,5060,1:59.3,337,2025-12-02 20:37:48 +IMG_5454.JPEG,30:06,7650,1:58.0,510,2025-12-04 22:05:44 +IMG_5457.JPEG,30:04,7613,1:58.5,508,2025-12-10 20:17:27 +IMG_5460.JPEG,30:07,7601,1:58.9,507,2025-12-12 18:50:41 +IMG_5461.JPEG,15:05,3907,1:55.8,260,2025-12-13 00:22:41 +IMG_5463.JPEG,30:05,7551,1:59.5,503,2025-12-16 00:05:34 +IMG_5464.JPEG,15:06,3791,1:59.5,253,2025-12-17 17:43:48 +IMG_5468.JPEG,30:06,7284,2:04.0,486,2025-12-20 23:00:16 +IMG_5471.JPEG,30:06,7586,1:59.0,506,2025-12-23 09:49:22 +IMG_5472.JPEG,30:07,7504,2:00.4,500,2025-12-24 11:58:45 +IMG_5474.JPEG,30:06,7493,2:00.5,500,2025-12-27 10:33:33 +IMG_5475.JPEG,30:05,7475,2:00.7,498,2025-12-28 12:59:48 +IMG_5476.JPEG,33:37,7972,2:06.5,531,2025-12-29 09:17:36 +IMG_5477.JPEG,30:06,7504,2:00.3,500,2025-12-29 13:25:33 +IMG_5479.JPEG,30:06,7663,1:57.8,511,2026-01-05 17:14:00 +IMG_5483.JPEG,30:07,7575,1:59.3,505,2026-01-07 23:05:19 +IMG_5484.JPEG,15:09,3816,1:59.1,254,2026-01-08 19:30:33 +IMG_5485.JPEG,20:05,4802,2:05.5,320,2026-01-10 20:22:14 +IMG_5486.JPEG,30:07,7453,2:01.2,497,2026-01-11 19:21:03 +IMG_5488.JPEG,30:06,7496,2:00.5,500,2026-01-12 18:12:31 +IMG_5489.JPEG,30:06,7318,2:03.4,488,2026-01-13 18:35:20 +IMG_5513.JPEG,30:29,7586,2:00.5,506,2026-01-18 18:12:02 +IMG_5519.JPEG,30:07,7547,1:59.7,503,2026-01-21 18:45:42 +IMG_5522.JPEG,30:07,7407,2:02.0,494,2026-01-23 20:02:53 +IMG_5523.JPEG,30:06,7559,1:59.5,504,2026-01-25 17:34:42