| """ |
| End-to-end cursor detection: generate synthetic YOLO dataset + train YOLOv8n. |
| """ |
| import os |
| import random |
| from pathlib import Path |
| from PIL import Image |
| from datasets import load_dataset |
| from tqdm import tqdm |
| from ultralytics import YOLO |
|
|
| |
| NUM_TRAIN = 500 |
| NUM_VAL = 100 |
| NUM_TEST = 50 |
| IMAGE_SIZE = (640, 640) |
| CURSOR_SIZE_RANGE = (16, 48) |
| OUTPUT_DIR = Path("/app/cursor_dataset") |
| YAML_PATH = OUTPUT_DIR / "cursor.yaml" |
|
|
| |
| for split in ["train", "val", "test"]: |
| (OUTPUT_DIR / "images" / split).mkdir(parents=True, exist_ok=True) |
| (OUTPUT_DIR / "labels" / split).mkdir(parents=True, exist_ok=True) |
|
|
| print("Loading datasets...") |
| cursors_ds = load_dataset("Fraser/cursors", split="train") |
| screenshots_ds = load_dataset("naorm/website-screenshots", split="train") |
|
|
| static_cursors = [] |
| animated_cursors = [] |
| for row in cursors_ds: |
| frames = row["frames"] |
| if len(frames) == 1: |
| static_cursors.append(row) |
| else: |
| animated_cursors.append(row) |
|
|
| print(f"Cursors: {len(static_cursors)} static, {len(animated_cursors)} animated") |
| print(f"Screenshots: {len(screenshots_ds)}") |
|
|
| screenshot_images = [row["image"] for row in screenshots_ds] |
|
|
|
|
| def get_random_cursor(): |
| if random.random() < 0.7 and static_cursors: |
| row = random.choice(static_cursors) |
| frame = row["frames"][0] |
| elif animated_cursors: |
| row = random.choice(animated_cursors) |
| frame = random.choice(row["frames"]) |
| else: |
| row = random.choice(static_cursors) |
| frame = row["frames"][0] |
| return frame, row["hotspot_x"], row["hotspot_y"], row["size_px"] |
|
|
|
|
| def composite_cursor(bg_img, cursor_frame, hotspot_x, hotspot_y, cursor_size): |
| bg = bg_img.convert("RGBA").resize(IMAGE_SIZE, Image.LANCZOS) |
| cw, ch = cursor_frame.size |
| scale = cursor_size / max(cw, ch) |
| new_w = max(1, round(cw * scale)) |
| new_h = max(1, round(ch * scale)) |
| cursor_scaled = cursor_frame.convert("RGBA").resize((new_w, new_h), Image.LANCZOS) |
|
|
| target_x = random.randint(0, IMAGE_SIZE[0] - 1) |
| target_y = random.randint(0, IMAGE_SIZE[1] - 1) |
|
|
| paste_x = target_x - round(hotspot_x * new_w) |
| paste_y = target_y - round(hotspot_y * new_h) |
|
|
| sw, sh = bg.size |
| src_left = max(0, -paste_x) |
| src_top = max(0, -paste_y) |
| src_right = min(new_w, sw - paste_x) |
| src_bottom = min(new_h, sh - paste_y) |
|
|
| if src_left >= src_right or src_top >= src_bottom: |
| return None, None |
|
|
| cropped = cursor_scaled.crop((src_left, src_top, src_right, src_bottom)) |
| dst_x = paste_x + src_left |
| dst_y = paste_y + src_top |
| bg.alpha_composite(cropped, (dst_x, dst_y)) |
|
|
| bbox_x = (dst_x + (src_right - src_left) / 2) / sw |
| bbox_y = (dst_y + (src_bottom - src_top) / 2) / sh |
| bbox_w = (src_right - src_left) / sw |
| bbox_h = (src_bottom - src_top) / sh |
|
|
| bbox_x = max(bbox_w / 2, min(1 - bbox_w / 2, bbox_x)) |
| bbox_y = max(bbox_h / 2, min(1 - bbox_h / 2, bbox_y)) |
| bbox_w = min(1.0, bbox_w) |
| bbox_h = min(1.0, bbox_h) |
|
|
| return bg.convert("RGB"), (0, bbox_x, bbox_y, bbox_w, bbox_h) |
|
|
|
|
| def generate_split(split_name, num_samples): |
| print(f"Generating {split_name} ({num_samples} samples)...") |
| img_dir = OUTPUT_DIR / "images" / split_name |
| lbl_dir = OUTPUT_DIR / "labels" / split_name |
|
|
| for i in tqdm(range(num_samples), desc=split_name): |
| bg = random.choice(screenshot_images) |
| cursor_frame, hs_x, hs_y, size_px = get_random_cursor() |
| cursor_size = random.randint(*CURSOR_SIZE_RANGE) |
|
|
| composed, bbox = composite_cursor(bg, cursor_frame, hs_x, hs_y, cursor_size) |
|
|
| retries = 0 |
| while composed is None and retries < 5: |
| bg = random.choice(screenshot_images) |
| cursor_frame, hs_x, hs_y, size_px = get_random_cursor() |
| cursor_size = random.randint(*CURSOR_SIZE_RANGE) |
| composed, bbox = composite_cursor(bg, cursor_frame, hs_x, hs_y, cursor_size) |
| retries += 1 |
|
|
| if composed is None: |
| continue |
|
|
| img_path = img_dir / f"{split_name}_{i:06d}.jpg" |
| lbl_path = lbl_dir / f"{split_name}_{i:06d}.txt" |
|
|
| composed.save(img_path, "JPEG", quality=95) |
| with open(lbl_path, "w") as f: |
| f.write(f"0 {bbox[1]:.6f} {bbox[2]:.6f} {bbox[3]:.6f} {bbox[4]:.6f}\n") |
|
|
|
|
| random.seed(42) |
| generate_split("train", NUM_TRAIN) |
| generate_split("val", NUM_VAL) |
| generate_split("test", NUM_TEST) |
|
|
| |
| with open(YAML_PATH, "w") as f: |
| f.write(f"path: {OUTPUT_DIR.absolute()}\n") |
| f.write("train: images/train\n") |
| f.write("val: images/val\n") |
| f.write("test: images/test\n") |
| f.write("nc: 1\n") |
| f.write("names: ['cursor']\n") |
|
|
| print(f"Dataset ready at {OUTPUT_DIR}") |
|
|
| |
| model = YOLO("yolov8n.pt") |
|
|
| results = model.train( |
| data=str(YAML_PATH), |
| epochs=30, |
| imgsz=640, |
| batch=16, |
| device=0, |
| project="cursor-detection", |
| name="yolov8n-cursor", |
| exist_ok=True, |
| verbose=True, |
| ) |
|
|
| |
| metrics = model.val(data=str(YAML_PATH), split="test") |
| print("Test metrics:", metrics) |
|
|
| |
| model.push_to_hub("AdithyaSK/cursor-detection-yolov8n") |
| print("Model pushed to Hub!") |
|
|