Uses claude to achieve extracting rowing times and distance
This commit is contained in:
178
extract_rowing_data.py
Normal file
178
extract_rowing_data.py
Normal 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
35
rowing_results.csv
Normal 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
|
||||
|
Reference in New Issue
Block a user