| """ |
| Multimodal-Mind2Web dataset loader. |
| |
| Supports loading from HuggingFace (osunlp/Multimodal-Mind2Web) or local files. |
| |
| Key differences from the original Mind2Web dataset: |
| - Each HF row is a **single action step**, not a full trajectory. |
| - Screenshots are PIL Image objects (JPEG), not paths or base64 strings. |
| - Coordinates come from pos_candidates[].bounding_box_rect (absolute pixels); |
| we normalize by the screenshot dimensions. |
| - Operation type is stored in operation["op"]: CLICK | TYPE | SELECT | HOVER. |
| - Trajectories must be reconstructed by grouping rows on annotation_id. |
| |
| Available HF splits: |
| train – 7,775 steps / 1,009 tasks |
| test_task – 1,339 steps / 177 tasks (same websites as training) |
| test_website – 1,019 steps / 142 tasks (unseen websites) |
| test_domain – 4,060 steps / 694 tasks (entirely unseen domains) |
| |
| Usage:: |
| |
| loader = Mind2WebLoader() |
| trajectories = loader.load_trajectories_from_hf(split="train") |
| |
| Or as a standalone script:: |
| |
| python -m src.data_pipeline.mind2web_loader \ |
| --output_dir ./data/mind2web_raw \ |
| --split train \ |
| --save_images |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import logging |
| import os |
| from collections import defaultdict |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional, Tuple |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
|
|
| @dataclass |
| class Step: |
| """A single annotated step within a web trajectory.""" |
|
|
| action_uid: str |
| action_type: str |
| x: float |
| y: float |
| value: Optional[str] = None |
| screenshot_path: Optional[str] = None |
| element_id: Optional[str] = None |
| action_repr: Optional[str] = None |
| raw_annotation: Optional[Dict[str, Any]] = field(default=None, repr=False) |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| return { |
| "action_uid": self.action_uid, |
| "action_type": self.action_type, |
| "x": self.x, |
| "y": self.y, |
| "value": self.value, |
| "screenshot_path": self.screenshot_path, |
| "element_id": self.element_id, |
| "action_repr": self.action_repr, |
| } |
|
|
|
|
| @dataclass |
| class Trajectory: |
| """A complete annotated web-navigation trajectory.""" |
|
|
| annotation_id: str |
| website: str |
| domain: str |
| task: str |
| steps: List[Step] = field(default_factory=list) |
| raw: Optional[Dict[str, Any]] = field(default=None, repr=False) |
|
|
| def __len__(self) -> int: |
| return len(self.steps) |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| return { |
| "annotation_id": self.annotation_id, |
| "website": self.website, |
| "domain": self.domain, |
| "task": self.task, |
| "steps": [s.to_dict() for s in self.steps], |
| } |
|
|
| @classmethod |
| def from_dict(cls, d: Dict[str, Any]) -> "Trajectory": |
| steps = [ |
| Step( |
| action_uid=s.get("action_uid", ""), |
| action_type=s.get("action_type", "click"), |
| x=float(s.get("x", 0.5)), |
| y=float(s.get("y", 0.5)), |
| value=s.get("value"), |
| screenshot_path=s.get("screenshot_path"), |
| element_id=s.get("element_id"), |
| action_repr=s.get("action_repr"), |
| ) |
| for s in d.get("steps", []) |
| ] |
| return cls( |
| annotation_id=d.get("annotation_id", d.get("traj_id", "")), |
| website=d.get("website", "unknown"), |
| domain=d.get("domain", "unknown"), |
| task=d.get("task", ""), |
| steps=steps, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| _ACTION_MAP: Dict[str, str] = { |
| "CLICK": "click", |
| "TYPE": "type", |
| "SELECT": "select", |
| "SCROLL": "scroll", |
| "HOVER": "click", |
| "ENTER": "click", |
| } |
|
|
|
|
| def _map_action(raw_type: str) -> str: |
| return _ACTION_MAP.get(raw_type.upper(), "click") |
|
|
|
|
| |
| |
| |
|
|
| def _extract_bbox_rect(candidates: List[Any]) -> Optional[Dict[str, float]]: |
| """ |
| Find the target element's bounding_box_rect from pos_candidates. |
| |
| Multimodal-Mind2Web candidates look like: |
| {"tag": "button", "backend_node_id": "123", |
| "bounding_box_rect": {"x": 100, "y": 200, "width": 50, "height": 30}, |
| "is_original_target": True, ...} |
| |
| We prefer the candidate with is_original_target=True; fall back to the first. |
| """ |
| if not candidates: |
| return None |
|
|
| |
| for c in candidates: |
| if isinstance(c, dict) and c.get("is_original_target", False): |
| rect = c.get("bounding_box_rect") |
| if rect and isinstance(rect, dict): |
| return rect |
|
|
| |
| for c in candidates: |
| if isinstance(c, dict): |
| rect = c.get("bounding_box_rect") |
| if rect and isinstance(rect, dict): |
| return rect |
|
|
| return None |
|
|
|
|
| def _normalize_coords(rect: Dict[str, float], img_w: int, img_h: int) -> Tuple[float, float]: |
| """ |
| Convert an absolute-pixel bounding_box_rect to normalised centre (x, y). |
| |
| rect keys: x, y, width, height (top-left corner + dimensions, in pixels) |
| """ |
| x_px = float(rect.get("x", 0)) |
| y_px = float(rect.get("y", 0)) |
| w_px = float(rect.get("width", 0)) |
| h_px = float(rect.get("height", 0)) |
|
|
| cx = (x_px + w_px / 2.0) / max(img_w, 1) |
| cy = (y_px + h_px / 2.0) / max(img_h, 1) |
|
|
| return max(0.0, min(1.0, cx)), max(0.0, min(1.0, cy)) |
|
|
|
|
| |
| |
| |
|
|
| class Mind2WebLoader: |
| """ |
| Loads osunlp/Multimodal-Mind2Web from HuggingFace or local storage, |
| and reconstructs Trajectory objects by grouping individual action rows. |
| """ |
|
|
| HF_DATASET = "osunlp/Multimodal-Mind2Web" |
|
|
| def __init__( |
| self, |
| max_steps_per_trajectory: int = 10, |
| image_save_dir: Optional[str] = None, |
| ) -> None: |
| self.max_steps_per_trajectory = max_steps_per_trajectory |
| |
| self.image_save_dir = Path(image_save_dir) if image_save_dir else None |
| if self.image_save_dir: |
| self.image_save_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| |
| |
|
|
| def load_trajectories_from_hf( |
| self, split: str = "train" |
| ) -> List[Trajectory]: |
| """ |
| Download the dataset from HuggingFace using streaming mode to avoid |
| loading all 7k+ screenshots into memory at once. |
| |
| Rows are processed one-by-one, images saved to disk, then grouped |
| by annotation_id into Trajectory objects. |
| |
| Args: |
| split: One of "train", "test_task", "test_website", "test_domain". |
| """ |
| try: |
| import datasets as hf_datasets |
| except ImportError as exc: |
| raise ImportError("Install `datasets`: pip install datasets") from exc |
|
|
| |
| try: |
| from PIL import Image as PILImage |
| PILImage.MAX_IMAGE_PIXELS = None |
| except ImportError: |
| pass |
|
|
| logger.info( |
| "Streaming %s split=%r from HuggingFace …", self.HF_DATASET, split |
| ) |
|
|
| |
| ds = hf_datasets.load_dataset( |
| self.HF_DATASET, split=split, streaming=True |
| ) |
|
|
| |
| raw_rows: List[Dict[str, Any]] = [] |
| for i, row in enumerate(ds): |
| row_dict = dict(row) |
| |
| path, w, h = self._handle_screenshot( |
| row_dict, str(row_dict.get("action_uid", i)) |
| ) |
| row_dict["_screenshot_path"] = path |
| row_dict["_img_w"] = w |
| row_dict["_img_h"] = h |
| |
| row_dict.pop("screenshot", None) |
| raw_rows.append(row_dict) |
| if (i + 1) % 500 == 0: |
| logger.info(" … streamed %d rows", i + 1) |
|
|
| logger.info("Streamed %d rows total.", len(raw_rows)) |
| return self._group_into_trajectories(raw_rows) |
|
|
| def load_trajectories_from_local(self, data_dir: str) -> List[Trajectory]: |
| """ |
| Load from a local directory of .jsonl / .json files. |
| Expects the same per-step format as the HF dataset (serialised to JSON). |
| Screenshots should have already been saved to disk; screenshot_path |
| fields in the JSON point to the image files. |
| """ |
| data_dir_path = Path(data_dir) |
| if not data_dir_path.exists(): |
| raise FileNotFoundError(f"Data directory not found: {data_dir}") |
|
|
| records: List[Dict[str, Any]] = [] |
| for ext in ("*.jsonl", "*.json"): |
| for fp in sorted(data_dir_path.glob(ext)): |
| logger.info("Reading %s …", fp) |
| with open(fp, "r", encoding="utf-8") as f: |
| for line in f: |
| line = line.strip() |
| if line: |
| records.append(json.loads(line)) |
|
|
| if not records: |
| raise FileNotFoundError(f"No data found in {data_dir}") |
|
|
| logger.info("Loaded %d rows from local files.", len(records)) |
| return self._group_into_trajectories(records) |
|
|
| |
| |
| |
|
|
| def _group_into_trajectories(self, rows) -> List[Trajectory]: |
| """ |
| Group rows (HF dataset rows or dicts) by annotation_id. |
| Preserves step order based on target_action_index. |
| """ |
| |
| buckets: Dict[str, List[Any]] = defaultdict(list) |
| for row in rows: |
| aid = str(row.get("annotation_id", "unknown")) |
| buckets[aid].append(row) |
|
|
| trajectories: List[Trajectory] = [] |
| for annotation_id, step_rows in buckets.items(): |
| |
| step_rows.sort(key=lambda r: int(r.get("target_action_index", 0))) |
|
|
| |
| first = step_rows[0] |
| website = str(first.get("website", "unknown")) |
| domain = str(first.get("domain", "unknown")) |
| task = str(first.get("confirmed_task", first.get("task", ""))) |
|
|
| steps: List[Step] = [] |
| for row in step_rows: |
| if len(steps) >= self.max_steps_per_trajectory: |
| break |
| step = self._parse_step(row) |
| if step is not None: |
| steps.append(step) |
|
|
| if steps: |
| trajectories.append( |
| Trajectory( |
| annotation_id=annotation_id, |
| website=website, |
| domain=domain, |
| task=task, |
| steps=steps, |
| ) |
| ) |
|
|
| logger.info( |
| "Grouped into %d trajectories (from %d rows).", |
| len(trajectories), |
| sum(len(v) for v in buckets.values()), |
| ) |
| return trajectories |
|
|
| |
| |
| |
|
|
| def _parse_step(self, row: Any) -> Optional[Step]: |
| """Parse one Multimodal-Mind2Web row into a Step dataclass.""" |
| try: |
| row_dict = dict(row) if not isinstance(row, dict) else row |
|
|
| action_uid = str(row_dict.get("action_uid", "")) |
|
|
| |
| |
| operation = row_dict.get("operation", {}) or {} |
| if isinstance(operation, str): |
| try: |
| operation = json.loads(operation) |
| except Exception: |
| operation = {} |
| raw_op = operation.get("op", operation.get("original_op", "CLICK")) |
| action_type = _map_action(str(raw_op)) |
|
|
| |
| value: Optional[str] = None |
| if action_type in ("type", "select"): |
| value = operation.get("value") |
| if value is not None: |
| value = str(value) |
|
|
| |
| |
| |
| if "_screenshot_path" in row_dict: |
| screenshot_path = row_dict["_screenshot_path"] |
| img_w = int(row_dict.get("_img_w", 1280)) |
| img_h = int(row_dict.get("_img_h", 720)) |
| else: |
| screenshot_path, img_w, img_h = self._handle_screenshot( |
| row_dict, action_uid |
| ) |
|
|
| |
| pos_candidates = row_dict.get("pos_candidates", []) or [] |
| if isinstance(pos_candidates, str): |
| try: |
| pos_candidates = json.loads(pos_candidates) |
| except Exception: |
| pos_candidates = [] |
| |
| parsed_candidates = [] |
| for c in pos_candidates: |
| if isinstance(c, str): |
| try: |
| c = json.loads(c) |
| except Exception: |
| continue |
| if isinstance(c, dict): |
| rect_raw = c.get("bounding_box_rect") |
| if isinstance(rect_raw, str): |
| try: |
| c = dict(c) |
| c["bounding_box_rect"] = json.loads(rect_raw) |
| except Exception: |
| pass |
| parsed_candidates.append(c) |
| pos_candidates = parsed_candidates |
|
|
| rect = _extract_bbox_rect(pos_candidates) |
|
|
| if rect is not None: |
| x, y = _normalize_coords(rect, img_w, img_h) |
| else: |
| |
| logger.debug( |
| "No bounding_box_rect for action_uid=%s; using (0.5, 0.5).", |
| action_uid, |
| ) |
| x, y = 0.5, 0.5 |
|
|
| |
| element_id: Optional[str] = None |
| for c in pos_candidates: |
| if isinstance(c, dict) and c.get("is_original_target", False): |
| element_id = str(c.get("backend_node_id", "")) |
| break |
|
|
| |
| action_repr: Optional[str] = None |
| target_reprs = row_dict.get("target_action_reprs") |
| if target_reprs: |
| if isinstance(target_reprs, list) and target_reprs: |
| action_repr = str(target_reprs[0]) |
| else: |
| action_repr = str(target_reprs) |
|
|
| return Step( |
| action_uid=action_uid, |
| action_type=action_type, |
| x=x, |
| y=y, |
| value=value, |
| screenshot_path=screenshot_path, |
| element_id=element_id, |
| action_repr=action_repr, |
| raw_annotation=row_dict, |
| ) |
|
|
| except Exception as exc: |
| logger.warning("Failed to parse row: %s", exc) |
| return None |
|
|
| |
| |
| |
|
|
| def _handle_screenshot( |
| self, row_dict: Dict[str, Any], action_uid: str |
| ) -> Tuple[Optional[str], int, int]: |
| """ |
| Extract the PIL Image from a HF row (or path from a local row), |
| optionally save it, and return (path, width, height). |
| |
| Returns (None, 1280, 720) if the image cannot be obtained. |
| """ |
| DEFAULT_W, DEFAULT_H = 1280, 720 |
|
|
| |
| if "screenshot_path" in row_dict and row_dict["screenshot_path"]: |
| path = str(row_dict["screenshot_path"]) |
| try: |
| from PIL import Image as PILImage |
| with PILImage.open(path) as img: |
| w, h = img.size |
| return path, w, h |
| except Exception: |
| return path, DEFAULT_W, DEFAULT_H |
|
|
| |
| screenshot = row_dict.get("screenshot") |
| if screenshot is None: |
| return None, DEFAULT_W, DEFAULT_H |
|
|
| try: |
| |
| from PIL import Image as PILImage |
| if isinstance(screenshot, PILImage.Image): |
| pil_img = screenshot |
| elif isinstance(screenshot, dict): |
| |
| if screenshot.get("bytes"): |
| import io |
| pil_img = PILImage.open(io.BytesIO(screenshot["bytes"])) |
| elif screenshot.get("path"): |
| pil_img = PILImage.open(screenshot["path"]) |
| else: |
| return None, DEFAULT_W, DEFAULT_H |
| else: |
| return None, DEFAULT_W, DEFAULT_H |
|
|
| w, h = pil_img.size |
|
|
| if self.image_save_dir and action_uid: |
| save_path = self.image_save_dir / f"{action_uid}.jpg" |
| if not save_path.exists(): |
| pil_img.convert("RGB").save(save_path, format="JPEG", quality=95) |
| return str(save_path), w, h |
|
|
| return None, w, h |
|
|
| except Exception as exc: |
| logger.warning( |
| "Could not process screenshot for action_uid=%s: %s", action_uid, exc |
| ) |
| return None, DEFAULT_W, DEFAULT_H |
|
|
|
|
| |
| |
| |
|
|
| def _parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser( |
| description="Download and convert Multimodal-Mind2Web data" |
| ) |
| parser.add_argument("--output_dir", required=True, help="Where to save data") |
| parser.add_argument( |
| "--split", |
| default="train", |
| choices=["train", "test_task", "test_website", "test_domain"], |
| ) |
| parser.add_argument( |
| "--local_dir", default=None, help="Load from local dir instead of HF" |
| ) |
| parser.add_argument( |
| "--save_images", |
| action="store_true", |
| help="Save PIL screenshots to <output_dir>/images/", |
| ) |
| parser.add_argument("--max_trajectories", type=int, default=None) |
| parser.add_argument("--max_steps", type=int, default=10) |
| return parser.parse_args() |
|
|
|
|
| def main() -> None: |
| logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") |
| args = _parse_args() |
|
|
| out_dir = Path(args.output_dir) |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| image_save_dir = str(out_dir / "images") if args.save_images else None |
| loader = Mind2WebLoader( |
| max_steps_per_trajectory=args.max_steps, |
| image_save_dir=image_save_dir, |
| ) |
|
|
| if args.local_dir: |
| trajectories = loader.load_trajectories_from_local(args.local_dir) |
| else: |
| trajectories = loader.load_trajectories_from_hf(split=args.split) |
|
|
| if args.max_trajectories: |
| trajectories = trajectories[: args.max_trajectories] |
|
|
| out_file = out_dir / f"mind2web_{args.split}.jsonl" |
| with open(out_file, "w", encoding="utf-8") as f: |
| for traj in trajectories: |
| f.write(json.dumps(traj.to_dict(), ensure_ascii=False) + "\n") |
|
|
| logger.info( |
| "Saved %d trajectories (%d steps total) to %s", |
| len(trajectories), |
| sum(len(t) for t in trajectories), |
| out_file, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|