File size: 12,549 Bytes
0bdbf4d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"""Spatial tools: zoom_crop.

Currently the only spatial tool that requires no external model — pure PIL
crop + resize. The cropped patch is written to a temp dir and its path is
appended to ``ctx.next_round_images`` so that the agent main loop can inject
it as an extra ``<image>`` token in the next user turn.

Throttling (added 2026-06):
    The 27B agent tends to over-call zoom_crop (77-89% of all tool dispatches
    in 240-sample evals), starving track_* / asr_segment / memory_search.
    To mitigate this without changing the main loop, we now track per-frame
    zoom history on ``ctx.zoom_history`` and reject crops whose bbox is
    near-identical (IoU>=DUP_IOU) to a previous crop on the SAME frame.
    Optional per-frame / per-sample call caps are also available but
    DISABLED by default (set to 0). Tunables (env vars):
      * ``STREAMO_MAX_ZOOM_PER_FRAME``  (default 0 = unlimited)
      * ``STREAMO_MAX_ZOOM_PER_SAMPLE`` (default 0 = unlimited)
      * ``STREAMO_ZOOM_DUP_IOU``        (default 0.5)
"""
from __future__ import annotations

import os
import tempfile
import time
from typing import Any, Dict, List

from .base import Tool, ToolContext, ToolResult

try:
    from PIL import Image
except Exception:  # pragma: no cover - PIL is a hard dep elsewhere
    Image = None  # type: ignore


_DEFAULT_CACHE = os.path.join(tempfile.gettempdir(), "agent_zoom")
os.makedirs(_DEFAULT_CACHE, exist_ok=True)


# Throttling caps (env-overridable). 0 / negative disables that dimension.
def _env_int(name: str, default: int) -> int:
    try:
        v = int(os.environ.get(name, default))
        return v
    except (TypeError, ValueError):
        return default


_MAX_PER_FRAME = _env_int("STREAMO_MAX_ZOOM_PER_FRAME", 0)
_MAX_PER_SAMPLE = _env_int("STREAMO_MAX_ZOOM_PER_SAMPLE", 0)
# IoU threshold above which two crops are considered duplicates. Relaxed
# to 0.85 (was 0.5): near-duplicates are now RE-SERVED rather than
# rejected, so only almost-identical boxes are flagged as duplicates.
_DUP_IOU = float(os.environ.get("STREAMO_ZOOM_DUP_IOU", "0.85"))


def _bbox_iou(a: List[float], b: List[float]) -> float:
    ax1, ay1, ax2, ay2 = a; bx1, by1, bx2, by2 = b
    ix1, iy1 = max(ax1, bx1), max(ay1, by1)
    ix2, iy2 = min(ax2, bx2), min(ay2, by2)
    iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
    inter = iw * ih
    if inter <= 0:
        return 0.0
    a_area = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
    b_area = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
    union = a_area + b_area - inter
    return inter / union if union > 0 else 0.0


def _normalize_bbox(bbox, w: int, h: int):
    if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
        raise ValueError("bbox must be [x1,y1,x2,y2]")
    x1, y1, x2, y2 = [float(v) for v in bbox]
    # Allow normalized coords in [0,1]
    if max(x1, y1, x2, y2) <= 1.0:
        x1 *= w; x2 *= w; y1 *= h; y2 *= h
    x1 = max(0, min(int(round(x1)), w - 1))
    y1 = max(0, min(int(round(y1)), h - 1))
    x2 = max(0, min(int(round(x2)), w))
    y2 = max(0, min(int(round(y2)), h))
    if x2 <= x1 or y2 <= y1:
        raise ValueError(f"degenerate bbox after clamp: {[x1, y1, x2, y2]}")
    return x1, y1, x2, y2


class ZoomCropTool(Tool):
    name = "zoom_crop"
    description = (
        "Crop a sub-region of the CURRENT frame at time `t` and upscale "
        "it to `target_size` square pixels. The cropped patch will be "
        "appended as an additional <image> in the very next round so "
        "you can read STATIC fine details (small printed text, a "
        "numeric label, a logo, a stationary face).\n"
        "**When NOT to use zoom_crop**:\n"
        "  - Subject is MOVING or you want to follow it across frames "
        "→ use `track_init` (zoom_crop's bbox is stale by next frame).\n"
        "  - Question depends on an EARLIER frame → use `replay` "
        "+ `memory_search` (zoom_crop only re-reads the "
        "CURRENT frame).\n"
        "  - Cue is AUDIO (speech / narration) → use `asr_segment`.\n"
        "  - You already inspected this region (high IoU with a prior "
        "crop on the same frame) → request will be auto-rejected; "
        "switch to a different tool family.\n"
        "**Best practice**: ONE well-chosen crop per question is "
        "usually enough. If the first crop didn't reveal the answer, "
        "the answer is probably NOT in the pixels — switch tool, do "
        "NOT chain more zooms."
    )
    parameters = {
        "type": "object",
        "properties": {
            "t": {"type": "number", "description": "Frame time in seconds."},
            "bbox": {
                "type": "array",
                "items": {"type": "number"},
                "minItems": 4,
                "maxItems": 4,
                "description": (
                    "[x1,y1,x2,y2] in absolute pixels OR normalized [0,1]."
                ),
            },
            "target_size": {
                "type": "integer",
                "description": "Output side length, default 448.",
            },
        },
        "required": ["t", "bbox"],
    }

    def run(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
        # Hard kill-switch (ablation): if STREAMO_DISABLE_ZOOM_CROP is set,
        # reject every call so the model is forced to rely on track_init /
        # asr_segment / memory_search instead.
        if os.environ.get(
            "STREAMO_DISABLE_ZOOM_CROP", ""
        ).strip().lower() in ("1", "true", "yes"):
            return ToolResult(
                self.name, False,
                error=(
                    "zoom_crop is DISABLED in this run. Use "
                    "`track_init` to follow a salient subject across "
                    "frames, `asr_segment` for spoken cues, or "
                    "`memory_search` to recall earlier observations."
                ),
            )
        if Image is None:
            return ToolResult(self.name, False, error="PIL not available")
        try:
            t = int(round(float(args["t"])))
            bbox = args["bbox"]
            target = int(args.get("target_size", 448))
        except (KeyError, TypeError, ValueError) as e:
            return ToolResult(self.name, False, error=f"bad args: {e}")
        if not (0 <= t < len(ctx.frame_paths)):
            return ToolResult(self.name, False, error=f"t out of range: {t}")
        src = ctx.frame_paths[t]
        try:
            img = Image.open(src).convert("RGB")
            w, h = img.size
            x1, y1, x2, y2 = _normalize_bbox(bbox, w, h)
        except Exception as e:  # noqa: BLE001
            return ToolResult(self.name, False, error=f"image fail: {e}")

        # ---- Throttling: per-sample / per-frame / dedupe -------------
        if ctx.zoom_history is None:
            ctx.zoom_history = {}
        # Helper: append a "MUST commit" tail when the model is at the
        # last frame past question_time so the model can't keep retrying.
        is_last_frame = (
            ctx.total_rounds > 0
            and ctx.current_round >= ctx.total_rounds - 1
        )
        past_q = (
            int(getattr(ctx, "question_time", 0) or 0) > 0
            and ctx.current_round >= int(ctx.question_time)
        )
        commit_tail = (
            " *** This is the LAST FRAME — after at most ONE more "
            "supporting tool call, you MUST emit `</Response> "
            "<your answer>`. Do not stop at `</Silence>`."
            if is_last_frame and past_q else ""
        )
        # 1) per-sample cap
        total = sum(len(v) for v in ctx.zoom_history.values())
        if _MAX_PER_SAMPLE > 0 and total >= _MAX_PER_SAMPLE:
            return ToolResult(
                self.name, False,
                error=(
                    f"zoom_crop sample cap reached "
                    f"({_MAX_PER_SAMPLE}); switch to memory_search / "
                    f"track_init / asr_segment to gather new evidence."
                    f"{commit_tail}"
                ),
            )
        frame_hist = ctx.zoom_history.get(t, [])
        # 2) per-frame cap
        if _MAX_PER_FRAME > 0 and len(frame_hist) >= _MAX_PER_FRAME:
            return ToolResult(
                self.name, False,
                error=(
                    f"zoom_crop per-frame cap reached "
                    f"({_MAX_PER_FRAME} on t={t}s); inspect a DIFFERENT "
                    f"frame, or call track_init/memory_search/"
                    f"asr_segment instead.{commit_tail}"
                ),
            )
        # 2.5) phys-round soft cap (改动 7 promptfix v2): 同一物理轮内
        # zoom_crop 调用次数 >= 3 时拒收。错误信息引导切换工具家族——
        # 不会消耗 PHYS_CAP/per_round 配额(被 throttle 在工具内层)。
        # 防止 STU id=641 (87 次)、HLD id=321 (28 次)、FPD id=1135
        # (31 次) 这种 zoom 滥用;上限降到 3 同时压低单轮注入图峰值,
        # 避免超长视频 (如 Sample-545, 309 帧) 把 prompt token 撑爆
        # max_model_len 导致 max_tokens 变负、vLLM 崩溃整个 worker。
        _PHYS_ROUND_ZOOM_CAP = 3
        if ctx.zoom_count_this_phys_round >= _PHYS_ROUND_ZOOM_CAP:
            return ToolResult(
                self.name, False,
                error=(
                    f"zoom_crop phys-round cap reached "
                    f"({_PHYS_ROUND_ZOOM_CAP} on this physical round); "
                    f"further visual inspection is unlikely to help. "
                    f"SWITCH: (a) `replay(t=<frame>)` to revisit a "
                    f"different moment; (b) `memory_search` with "
                    f"question keywords; (c) commit `</Response>` "
                    f"with current evidence.{commit_tail}"
                ),
            )
        # 3) near-duplicate detection (IoU>=DUP_IOU on same frame).
        # Instead of REJECTING a near-duplicate (which left the model
        # blind and spinning when it genuinely needed another look at the
        # same region, e.g. counting overlapping cards in a hand), we now
        # RE-SERVE the crop: the pixels are re-cropped and returned with a
        # nudge to look carefully THIS time. The IoU threshold is also
        # relaxed (default 0.85) so only almost-identical boxes count.
        cur_box = [float(x1), float(y1), float(x2), float(y2)]
        is_dup = False
        dup_iou = 0.0
        for prev in frame_hist:
            iou = _bbox_iou(prev, cur_box)
            if iou >= _DUP_IOU:
                is_dup = True
                dup_iou = iou
                break

        # All checks passed — perform the actual crop.
        try:
            patch = img.crop((x1, y1, x2, y2)).resize(
                (target, target), Image.BICUBIC)
        except Exception as e:  # noqa: BLE001
            return ToolResult(self.name, False, error=f"image fail: {e}")

        out_name = (
            f"{ctx.sample_id.replace('/', '_')}_t{t}_"
            f"{x1}-{y1}-{x2}-{y2}_{int(time.time()*1000)}.jpg"
        )
        out_path = os.path.join(_DEFAULT_CACHE, out_name)
        patch.save(out_path, quality=92)
        ctx.next_round_images.append(out_path)
        # Record the bbox so future calls can be deduped.
        ctx.zoom_history.setdefault(t, []).append(cur_box)
        # v2 改动 7: 累计本物理轮成功 zoom 次数(用于 5 次软上限)
        ctx.zoom_count_this_phys_round += 1
        if ctx.memory is not None:
            ctx.memory.write_event(
                ctx.current_round, "tool",
                f"zoom_crop t={t}s bbox=[{x1},{y1},{x2},{y2}] ({target}px)"
                + (" [re-served duplicate]" if is_dup else ""),
                ref_frame=t, salience=1.3,
            )
        note = "patch will appear as an extra <image> in the next round"
        if is_dup:
            note = (
                f"You already cropped this region (IoU={dup_iou:.2f}); "
                "the SAME zoomed patch is shown again. Look CAREFULLY and "
                "state a concrete reading (e.g. an exact count) THIS time, "
                "then act on it — do not request the identical crop again."
            )
        return ToolResult(
            self.name,
            True,
            data={
                "t": t,
                "bbox": [x1, y1, x2, y2],
                "saved": out_path,
                "shape": [target, target, 3],
                "duplicate": is_dup,
                "note": note,
            },
        )