Uses claude to achieve extracting rowing times and distance

This commit is contained in:
2026-03-16 13:44:57 +00:00
parent 1624a7a40a
commit 2e386a4297
2 changed files with 213 additions and 0 deletions

178
extract_rowing_data.py Normal file
View File

@@ -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()

35
rowing_results.csv Normal file
View File

@@ -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
1 image time distance_m pace_per_500m calories date_taken
2 IMG_5413.JPEG 30:06 7283 2:04.0 486 2025-11-05 12:08:00
3 IMG_5414.JPEG 30:06 7337 2:03.1 489 2025-11-06 19:17:40
4 IMG_5416.JPEG 40:05 9671 2:04.3 645 2025-11-07 15:06:23
5 IMG_5417.JPEG 35:07 8629 2:02.1 575 2025-11-08 12:02:51
6 IMG_5418.JPEG 30:06 7352 2:02.8 490 2025-11-08 15:32:17
7 IMG_5428.JPEG 35:06 8543 2:03.3 570 2025-11-13 17:12:54
8 IMG_5443.JPEG 30:07 7383 2:02.4 492 2025-11-18 18:15:02
9 IMG_5448.JPEG 30:06 7488 2:00.6 499 2025-11-29 16:21:54
10 IMG_5449.JPEG 30:06 7450 2:01.2 497 2025-11-30 19:51:28
11 IMG_5451.JPEG 20:07 5060 1:59.3 337 2025-12-02 20:37:48
12 IMG_5454.JPEG 30:06 7650 1:58.0 510 2025-12-04 22:05:44
13 IMG_5457.JPEG 30:04 7613 1:58.5 508 2025-12-10 20:17:27
14 IMG_5460.JPEG 30:07 7601 1:58.9 507 2025-12-12 18:50:41
15 IMG_5461.JPEG 15:05 3907 1:55.8 260 2025-12-13 00:22:41
16 IMG_5463.JPEG 30:05 7551 1:59.5 503 2025-12-16 00:05:34
17 IMG_5464.JPEG 15:06 3791 1:59.5 253 2025-12-17 17:43:48
18 IMG_5468.JPEG 30:06 7284 2:04.0 486 2025-12-20 23:00:16
19 IMG_5471.JPEG 30:06 7586 1:59.0 506 2025-12-23 09:49:22
20 IMG_5472.JPEG 30:07 7504 2:00.4 500 2025-12-24 11:58:45
21 IMG_5474.JPEG 30:06 7493 2:00.5 500 2025-12-27 10:33:33
22 IMG_5475.JPEG 30:05 7475 2:00.7 498 2025-12-28 12:59:48
23 IMG_5476.JPEG 33:37 7972 2:06.5 531 2025-12-29 09:17:36
24 IMG_5477.JPEG 30:06 7504 2:00.3 500 2025-12-29 13:25:33
25 IMG_5479.JPEG 30:06 7663 1:57.8 511 2026-01-05 17:14:00
26 IMG_5483.JPEG 30:07 7575 1:59.3 505 2026-01-07 23:05:19
27 IMG_5484.JPEG 15:09 3816 1:59.1 254 2026-01-08 19:30:33
28 IMG_5485.JPEG 20:05 4802 2:05.5 320 2026-01-10 20:22:14
29 IMG_5486.JPEG 30:07 7453 2:01.2 497 2026-01-11 19:21:03
30 IMG_5488.JPEG 30:06 7496 2:00.5 500 2026-01-12 18:12:31
31 IMG_5489.JPEG 30:06 7318 2:03.4 488 2026-01-13 18:35:20
32 IMG_5513.JPEG 30:29 7586 2:00.5 506 2026-01-18 18:12:02
33 IMG_5519.JPEG 30:07 7547 1:59.7 503 2026-01-21 18:45:42
34 IMG_5522.JPEG 30:07 7407 2:02.0 494 2026-01-23 20:02:53
35 IMG_5523.JPEG 30:06 7559 1:59.5 504 2026-01-25 17:34:42