File size: 4,371 Bytes
c33a30c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
"""
Action / coordinate utility functions.

All coordinates are in normalised [0, 1] space.
"""

from __future__ import annotations

import re
from typing import Optional, Tuple


# ---------------------------------------------------------------------------
# Point parsing
# ---------------------------------------------------------------------------

_POINT_RE = re.compile(
    r"[\(\[]\s*"
    r"([+-]?\d+(?:\.\d+)?)"
    r"\s*[,\s]\s*"
    r"([+-]?\d+(?:\.\d+)?)"
    r"\s*[\)\]]"
)


def parse_point(pred_str: str) -> Optional[Tuple[float, float]]:
    """
    Parse a predicted (x, y) coordinate string.

    Handles formats:
      - "(0.5, 0.3)"
      - "[0.5 0.3]"
      - "(0.5,0.3)"
      - "(512, 384)" (absolute pixels — treated as-is, caller should normalise)

    Returns:
        (x, y) float tuple, or None if parsing fails.
    """
    if not pred_str:
        return None

    pred_str = pred_str.strip()

    # Try regex first
    m = _POINT_RE.search(pred_str)
    if m:
        x, y = float(m.group(1)), float(m.group(2))
        return x, y

    # Try comma/space separated without brackets
    parts = re.split(r"[,\s]+", pred_str.strip("()[] "))
    if len(parts) == 2:
        try:
            return float(parts[0]), float(parts[1])
        except ValueError:
            pass

    return None


# ---------------------------------------------------------------------------
# Bounding-box helpers
# ---------------------------------------------------------------------------

def point_in_bbox(
    point: Tuple[float, float],
    bbox: Tuple[float, float, float, float],
) -> bool:
    """
    Check whether a normalised point falls inside a normalised bounding box.

    Args:
        point: (x, y) in [0, 1].
        bbox: (x1, y1, x2, y2) in [0, 1] with x2 > x1, y2 > y1.

    Returns:
        True if point is inside or on the boundary of the bbox.
    """
    px, py = point
    x1, y1, x2, y2 = bbox
    return x1 <= px <= x2 and y1 <= py <= y2


def iou_click(
    pred_point: Tuple[float, float],
    gt_bbox: Tuple[float, float, float, float],
    click_radius: float = 0.05,
) -> bool:
    """
    Determine if a predicted click point "hits" the ground-truth bounding box.

    A click is considered a hit if:
      - It falls inside the gt_bbox, OR
      - It is within `click_radius` of the bbox centre (in Euclidean distance).

    Args:
        pred_point: Predicted (x, y) in [0, 1].
        gt_bbox: Ground-truth (x1, y1, x2, y2) in [0, 1].
        click_radius: Maximum allowed distance from bbox centre.

    Returns:
        True if the click is considered a hit.
    """
    # Inside the bbox
    if point_in_bbox(pred_point, gt_bbox):
        return True

    # Within radius of bbox centre
    x1, y1, x2, y2 = gt_bbox
    cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0
    px, py = pred_point
    dist = ((px - cx) ** 2 + (py - cy) ** 2) ** 0.5
    return dist <= click_radius


# ---------------------------------------------------------------------------
# Action matching
# ---------------------------------------------------------------------------

def action_match(
    pred_str: str,
    gt_str: str,
    threshold: float = 0.05,
) -> bool:
    """
    Check whether a predicted action string matches the ground-truth.

    Both strings should be "(x, y)" coordinate strings.  Returns True if
    the Euclidean distance between the two parsed points is ≤ threshold.

    Args:
        pred_str: Predicted action string.
        gt_str: Ground-truth action string.
        threshold: Maximum Euclidean distance (in normalised coords).

    Returns:
        True if the actions match.
    """
    pred_pt = parse_point(pred_str)
    gt_pt = parse_point(gt_str)

    if pred_pt is None or gt_pt is None:
        return False

    dx = pred_pt[0] - gt_pt[0]
    dy = pred_pt[1] - gt_pt[1]
    dist = (dx ** 2 + dy ** 2) ** 0.5
    return dist <= threshold


def normalize_action_str(action_str: str) -> str:
    """
    Normalise an action string to a canonical "(x,y)" representation.

    Args:
        action_str: Raw action string such as "(0.5, 0.3)" or "[0.5 0.3]".

    Returns:
        Canonical string like "(0.5000,0.3000)", or the original string if
        parsing fails.
    """
    pt = parse_point(action_str)
    if pt is None:
        return action_str.strip()
    return f"({pt[0]:.4f},{pt[1]:.4f})"