Metadata-Version: 2.4
Name: qsmy-deepseek-locator
Version: 0.2.1
Summary: 用 DeepSeek 视觉模型做物体定位：一张图 + 一句话，得到归一化坐标（bbox/point）与中文名称，可直接打标出图。
Author: qsmy
License-Expression: MIT
Project-URL: Homepage, https://github.com/QsmyHyly/qsmy-deepseek-locator
Project-URL: Repository, https://github.com/QsmyHyly/qsmy-deepseek-locator
Project-URL: Issues, https://github.com/QsmyHyly/qsmy-deepseek-locator/issues
Keywords: deepseek,vision,vlm,object-localization,grounding,multimodal,annotation
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Image Recognition
Classifier: Topic :: Multimedia :: Graphics
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Pillow>=9.5
Requires-Dist: requests>=2.28
Provides-Extra: openai
Requires-Dist: openai>=1.30; extra == "openai"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=6.0; extra == "dev"
Dynamic: license-file

# qsmy-deepseek-locator

> 用 DeepSeek 视觉模型做**物体定位**：给它一张图和一句「找什么」，
> 拿回 **0.0~1.0 的归一化坐标**（框 / 点）与中文名称，需要的话直接把框和标签画回图上。

```python
from qsmy_deepseek_locator import locate_to_file

# 最省事：图片 + 「找什么」+ 输出路径，回来时标注图已经写好
result = locate_to_file("photo.png", "红色圆形", "annotated.png")
print(result.annotated_path, result.labels)     # annotated.png ['红色圆形']
```

要自己掌控坐标与绘制：

```python
from qsmy_deepseek_locator import locate, draw

result = locate("photo.png", "红色圆形")
for d in result:
    print(d.label, d.bbox, d.center)      # 红色圆形 (0.101, 0.205, 0.298, 0.402) (0.1995, 0.3035)

draw("photo.png", result).save("annotated.png")
```

命令行同样一行：

```bash
qsmy-deepseek-locator photo.png -t "红色圆形" -o annotated.png
```

---

## 1. 安装

```bash
pip install qsmy-deepseek-locator
```

要改源码、跑测试就用可编辑安装：

```bash
git clone https://github.com/QsmyHyly/qsmy-deepseek-locator.git
cd qsmy-deepseek-locator
python -m venv .venv && .venv\Scripts\activate      # Windows；macOS/Linux 用 source .venv/bin/activate
pip install -e ".[dev]"
```

**必装依赖只有两个**：`Pillow`（读图 / 打标）与 `requests`（下载图片 URL + 裸 HTTP 客户端）。

`openai` 是可选的（本次改动引入）：它依赖的 `jiter` / `pydantic-core` 都是 **Rust 扩展**，
没有 Android/aarch64 的 wheel（`pip install --dry-run openai` 会报
`Target triple not supported by rustup`），在手机上「照文档装一遍」是装不上的。
不装它也能完整使用本库 —— 换成自带的自备客户端即可，它只用 `requests`：

```bash
pip install qsmy-deepseek-locator            # 最小安装（不含 openai）
pip install "qsmy-deepseek-locator[openai]"  # 想要 SDK 的重试与连接池时
```

```python
from qsmy_deepseek_locator import Locator, RequestsVisionClient

locator = Locator(client=RequestsVisionClient(api_key="sk-xxx"), thinking=False)
result = locator.locate("photo.png", "红色圆形")
```

两个客户端的差别写在 `http_client.py` 的模块头里（**有一节"与 DeepSeekVisionClient 的已知差别"
的诚实清单**，选型前值得看一眼）。一句话概括：

- `Locator(timeout=…)` / `QSML_TIMEOUT` 对**两个客户端都生效**（本次改动修：以前只对 SDK 版生效）；
- SDK 版会按 `max_retries` 自动重试、且会对"服务端不认 `stream_options`"退一步重发；裸 HTTP 版**都不会**；
- `log_file=…` 的 `chunks=True`（记原始 SSE 帧）只有 SDK 版有。

跑自测（**全程离线、不花 API**）：

```bash
python -m pytest tests -q
```

## 2. 配 API Key

```bash
# Windows（持久生效）
setx DEEPSEEK_API_KEY "sk-你的key"
# macOS / Linux
export DEEPSEEK_API_KEY="sk-你的key"
```

也可以不设环境变量，直接传参：`Locator(api_key="sk-...")` 或 `locate(..., api_key="sk-...")`。
其余可配项见 [`.env.example`](https://github.com/QsmyHyly/qsmy-deepseek-locator/blob/main/.env.example)。

> **没有 Key 会怎样**：直接抛 `MissingAPIKeyError`，并告诉你三种配法。
> 本库刻意**不提供**「无 Key 时返回假数据」的降级 —— 演示程序这样做很方便，
> 但库不行：用户会拿着一堆看起来正常的假坐标当真结果。

## 3. 三种用法

### 3.1 一行式出图（最省事）

给「图片 + 找什么 + 输出路径」，函数回来时标注图已经躺在磁盘上了：

```python
from qsmy_deepseek_locator import locate_to_file

result = locate_to_file("photo.png", "红色圆形", "runs/photo_annotated.png")
print(result.annotated_path)   # runs/photo_annotated.png（没写扩展名会自动补 .png）
print(result.labels)           # ['红色圆形']
```

可选参数（全部关键字，按需给）：

| 参数 | 默认 | 说明 |
|---|---|---|
| `api_key` | 读 `DEEPSEEK_API_KEY` | **传了就只用它**，不再看环境变量 |
| `thinking` | `False` | 默认显式关闭思考（实测不掉准确率、耗时约省一半）；传 `None` 交回环境变量决定 |
| `image_detail` | `"original"` | `low/high/original/auto`；传 `None` 表示不发送该字段 |
| `use_tools` | `False` | 工具（Agent）调用：v0.1 未实现，传 `True` 会**当场报错**而不是被静默忽略 |
| `prompt` | `None` | 直接给**整段**用户消息（给了就忽略第二个位置参数 `target`）。⚠️ 它不会自动套上本库那句「请找出图中所有的…」包装句式，所以「找什么」请走 `target` |
| `model` / `base_url` / `timeout` / `max_tokens` / `max_side` / `system_prompt` | 见第 6 节 | 与 `Locator.locate()` 同名同义 |
| `colors` / `box_width` / `point_radius` / `font_size` / `draw_label` / `scale_to_image` | 见 `drawing.draw` | 绘制样式 |

几条已定好的行为，不必去猜：

- **输出路径先校验、后调模型** —— 路径拼错不该等花掉一次 API 调用才发现；父目录会自动创建；
- 扩展名决定格式（`.png/.jpg/.jpeg/.webp/.bmp/.tif/.tiff/.gif`），**认不出的后缀直接报错**，
  绝不偷偷存成别的格式（文件名写着 `.jpg` 内容却是 PNG，是最难排查的一类问题）；
- 模型没找到目标**照样出图**（内容等于原图），这不是失败，`result.detections` 为空而已；
- 画的是**原图**，所以输出分辨率始终等于输入分辨率。

### 3.2 一行式（只要坐标）

```python
from qsmy_deepseek_locator import locate

result = locate("photo.png", "画面里的人")      # 本地路径 / URL / bytes / PIL.Image / data URL 都行
print(result.summary())                        # {'total': 3, 'bbox_count': 3, ...}
```

### 3.3 复用定位器（多图批量时用这个）

```python
from qsmy_deepseek_locator import Locator, draw

locator = Locator(thinking=False)              # 关掉思考：更快、更省 token（实测不掉准确率）
for path in ["a.png", "b.png", "c.png"]:
    result = locator.locate(path, "按钮")
    draw(path, result).save(path.replace(".png", "_annotated.png"))
    print(path, result.labels)
```

`locate()` / `locate_to_file()` 每次都会重新读环境变量、新建客户端；批量场景请自己建
`Locator`，再调用它的 `locate()` 或 `locate_to_file()`（后者同样会把落盘路径写进 `result.annotated_path`）。

### 3.4 命令行

```bash
qsmy-deepseek-locator photo.png -t "登录按钮"                 # 打印坐标 + 生成 photo_annotated.png
qsmy-deepseek-locator photo.png -t "人" --print-json          # 只吐 JSON（可直接管道给 jq）
qsmy-deepseek-locator photo.png -t "人" --json r.json         # 连证据一起存盘
qsmy-deepseek-locator photo.png -t "人" --no-thinking         # 关思考，更快
qsmy-deepseek-locator photo.png --show-reasoning              # 实时看模型的思考过程
qsmy-deepseek-locator photo.png -t "人" --log                 # 开调试日志（请求体/响应体，见 7.1）
qsmy-deepseek-locator https://example.com/a.jpg -t "商品"     # 直接给 URL

qsmy-deepseek-locator bench --count 5 --n-shapes 3            # 跑准确率评测（见第 5 节）
qsmy-deepseek-locator bench --images-only --count 3           # 只造图不调模型（不花钱）
```

退出码：`0` 成功、`1` 运行期错误（缺 Key / 图片读不了 / 接口报错）、`2` 命令行用法错误。

### 3.5 多轮：让模型自己调工具（Agent）

前面几种都是**单轮**：一次请求、一次回答。需要模型自己动手（查图片尺寸、解析坐标、
把标注画出来）时用 Agent 循环 —— 它会多轮调用模型，直到不再需要工具：

```python
from qsmy_deepseek_locator.agent import build_messages, run_agent, collect_items
from qsmy_deepseek_locator.tools import build_default_registry

messages = build_messages("把图里的红色圆点找出来", image_url="photo.png",
                          system_prompt="（你的系统提示词）")
events = []
for event in run_agent(messages, tool_context={"source": "photo.png"}):
    events.append(event)
    if event["type"] == "content":
        print(event["text"], end="", flush=True)   # 实时打字机

items = collect_items(events, final_text=events[-1]["content"])
```

自带 6 个工具（解析坐标 ×3、看图片信息、画标注图、列调色板），要加自己的工具：

```python
registry = build_default_registry()
registry.register(my_func, context_params={"source"})   # source 不暴露给模型，运行时注入
run_agent(messages, registry=registry, tool_context={"source": "photo.png"})
```

⚠️ **它会多次调用模型，也就是多次计费** —— 所以它**不是** `locate(use_tools=True)` 的一个开关，
而是一个要显式导入的独立入口：让"这次要花多少钱、要等多久"由调用方承接，
而不是被一个参数偷偷决定。`locate` / `locate_to_file` 传 `use_tools=True` 会当场报错并指向这里。

⚠️ `import qsmy_deepseek_locator` **不会**连带加载 agent 与工具框架（它们是可选能力），
单轮定位的 import 成本不受影响。

## 4. 坐标约定（本库最要紧的一条）

**所有坐标都是 0.0~1.0 的相对比例，小数位数不设上限。**

```python
d.bbox              # (x1, y1, x2, y2) 归一化，左上角到右下角
d.point             # (x, y) 归一化
d.center            # 框的几何中心 / 点本身
d.to_pixels(1920, 1080)   # {'bbox_px': (...), 'point_px': None, 'center_px': (...)}
```

为什么不是像素：**模型根本看不到图片的真实分辨率**。服务端会先把图缩放再喂给它，
而且不回传缩放后的尺寸；模型报的「像素」落在它每次自己编的画布上
（实测同一张图三次调用分别给出 1000x750 / 1000x800 / 1024x768）。
缩放本身是纯线性等比的，所以**相对比例是这个链路上唯一可靠的量**。

两条兜底措施：

- 模型偶尔输出 `0~1000` 旧刻度（视觉大模型圈的常见约定），整批会被**无损除以 1000** 换回来，
  并在 `result.warnings` 里留一条说明；
- 输出像是**像素坐标**时（越界），本库**只告警不猜测** —— 越界本身就是「提示词没被遵守」的情报，
  悄悄夹紧等于把情报抹掉。绘制时才夹到边界，保证画得出来。

## 5. 自带评测（改提示词之前请先跑它）

准确率几乎完全由**提示词口径**决定。同一批 15 个目标的 A/B 实测：

| 提示词版本 | 检出率 | 平均 IoU | 输出像素坐标的图片 |
|---|---|---|---|
| 旧：说「用归一化值」又说「无需考虑分辨率」 | **40%** | 0.655 | 3/5 |
| 新：显式换算公式 + 禁像素值 + 交代刻度不确定 | **100%** | 0.897 | 0/5 |

改一句话就是 60 个百分点，所以「改完提示词到底变好没有」必须能自动判分：

```bash
$ qsmy-deepseek-locator bench --count 5 --n-shapes 3 --annotate
图片与真值：runs/benchmark/images
图片 5 张 | 真值 15 个 | 预测 15 个
检出率 100.0%（15/15）   精确率 100.0%   平均 IoU 0.874
标签准确率 100.0%（颜色 100.0% / 形状 100.0%）
平均耗时 2.3s/张   带告警的图片 0 张
报告：runs/benchmark/report.json
```

（上面这段是本库的实跑输出：`deepseek-flash`、思考开启、默认参数、种子 42。
换成 `--no-thinking` 会更快 —— 另一轮 2 张图的实测是 1.8s/张、平均 IoU 0.915，准确率不掉。）

它用代码生成「已知答案」的几何图形图，真值顺手算出来，再按 IoU（阈值 0.5）匹配预测框。
产物落在 `runs/benchmark/`：`images/`（图 + `ground_truth.json`）、`annotated/`（预测画回图）、`report.json`。

编程接口：`from qsmy_deepseek_locator.benchmark import run_benchmark, evaluate_sample`。

### 5.1 想知道"模型的感知边界在哪"，用探测型测试图

几何图只能回答"定位准不准"。要量**模型能看清多小的东西**，用 `bench_generators` 里那几张图：

| 生成器 | 量什么 | 真值 |
|---|---|---|
| `make_marker_image` + `markers_to_gt` | 帧几何与坐标约定（四角/四边/中心铺开的彩色圆点） | 圆心 + 半径 → 框，`match="center"` 判命中 |
| `make_text_image` | 有效分辨率（字号阶梯，反推服务端缩放倍数） | 每行的字号与随机代码 |
| `make_band_image` | 最小可分辨线间距 | 每条的间距与线数 |
| `render_scaled` | 分辨率扫描（同一场景铺到不同栅格） | 各尺寸真值只差舍入（≤1e-4） |

```python
from qsmy_deepseek_locator.bench_generators import make_marker_image, markers_to_gt

truth = make_marker_image("runs/marker.png", 900, 720)   # 画图 + 拿到真值
gt = markers_to_gt(truth, 900, 720)                      # 转成 0.0~1.0 的可评测真值
```

⚠️ 这几张图**默认用你机器上的中文字体**。要跨机器可复现（对比历史结论），
传入自己的字体解析：`make_marker_image(path, w, h, font_resolver=my_resolve_font)` ——
见 §10.1 的说明。

## 6. API 速查

```python
from qsmy_deepseek_locator import (
    Locator, Detection, draw, save_annotated, locate_to_file, __version__,
)

# 一行式出图（模块级函数 = 建临时 Locator 再调下面的方法）
result = locate_to_file(
    "photo.png",           # 图片：路径 / URL / bytes / PIL.Image / data URL
    "红色圆形",             # 找什么（target，就是那句用户提示词）
    "out/annotated.png",   # 输出文件的完整路径（含文件名）
    api_key=None,          # 传了就不读 DEEPSEEK_API_KEY
    thinking=False,        # 默认显式关闭思考
    image_detail="original",  # 默认 original；None = 不发送该字段
    use_tools=False,       # v0.1 只能是 False
)

locator = Locator(
    api_key=None,          # 默认读 DEEPSEEK_API_KEY
    model="deepseek-flash",
    thinking=False,        # None=沿用服务端默认；False=关思考（更快更省）
    reasoning_effort=None, # low/medium/high/xhigh/max
    image_detail=None,     # low/high/original/auto；默认不发送该字段
    max_tokens=None,       # 输出上限（含思考 token）
    timeout=300,            # 单次请求超时（秒）；恒走流式，超时按「两次数据之间的静默」算
    max_side=None,         # 发送前把图缩到最长边不超过它（省流量，不影响坐标精度）
    font_path=None,        # 中文标签用的字体文件；None = 自动探测（见第 8 节）
)
```

上面这些名字都会被 `Settings.merged()` 收下（真实签名是 `Locator(*, settings=None, client=None, max_side=None, **overrides)`），
所以除了它们，还可以直接传 `client=`（自备客户端）、`settings=`（整份配置）、`max_retries=`、`log_file=`（调试日志）。

```python
result = locator.locate(
    "photo.png",           # 路径 / URL / bytes / PIL.Image / data URL
    "红色圆形",             # 找什么（省略 = 识别主要物体）
    prompt=None,           # 直接给完整用户消息（给了就忽略 target）
    system_prompt=None,    # 覆盖系统提示词 —— 承载坐标口径，慎改
    on_event=print,        # 流式事件回调：reasoning/content/tool_call/finish/usage/model（见第 7 节）
    cancel_event=None,     # threading.Event；set() 之后在下一个流式事件处抛 CancelledError
)
```

> ⚠️ **所有入口都是同步阻塞调用。** 最坏等待是 `timeout × (max_retries + 1)`，
> 默认就是 **300s × 3 = 900s**。别在主线程 / UI 线程里直接调 —— 安卓上会 ANR，
> 桌面 GUI 会卡住窗口。移动端与 GUI 请丢进后台线程，并用 `cancel_event` 接一个「取消」按钮。

`LocateResult` 上有什么：

| 字段 / 方法 | 说明 |
|---|---|
| `result.detections` | `list[Detection]`，主数据；也可直接 `for d in result` |
| `result.bboxes` / `.points` / `.labels` / `.centers` | 按类型取出的便捷视图 |
| `result.find("红")` | 按标签子串筛 |
| `result.annotated_path` | 标注图落盘路径；只有 `locate_to_file()` 会填，其余入口恒为 `None` |
| `result.warnings` | 旧刻度换算 / 坐标越界 / 没解析到坐标等告警 |
| `result.text` / `.reasoning` | 模型正文 / 思考过程（**证据**，排查时全靠它） |
| `result.usage` / `.duration_ms` / `.model` | token 用量、耗时、实际模型 |
| `result.to_dict()` / `.to_json()` / `.save(path)` | 序列化 |
| `result.describe()` | 人类可读的多行摘要（CLI 默认输出） |

打标：

```python
draw(image, result)                       # -> PIL.Image（不改动入参图）
draw(image, result, box_width=4, font_size=26, draw_label=True)
draw(image, result, scale_to_image=True)  # 线宽/字号按图片尺寸自动推（大图不再细到看不见）
draw(image, result, font_path="/system/fonts/NotoSansCJK-Regular.ttc")   # 指定中文字体
save_annotated(image, result, path="out.png")     # -> Path
```

### 6.1 异常一览（本次改动后闭合：识别与出图 API 抛出的东西**总是** `LocatorError`）

「闭合」指的是**正常用库**会碰到的那些路径：定位、打标、落盘、读图、调接口、取消。
本地评测工具（`benchmark` / `bench`）写中间产物时仍是原生 `OSError` —— 它跑在开发者
自己的机器上、失败就该看到完整栈，套一层包装反而更难查。

| 异常 | 什么时候抛 | 额外继承 |
|---|---|---|
| `LocatorError` | 基类，`except LocatorError` 一把兜住 | — |
| `MissingAPIKeyError` | 三处都没给 Key | — |
| `ImageLoadError` | 路径不存在 / URL 下载失败 / 字节不是有效图片 | — |
| `APIError` | 网络、鉴权、限流、服务端 5xx、读流中途断连（原始异常在 `__cause__`） | — |
| `EmptyResponseError` | 正文为空（九成是思考 token 吃光了 `max_tokens`） | — |
| `UnsupportedFeatureError` | `use_tools=True`（v0.1 没有 Agent 循环） | `NotImplementedError` |
| `OutputPathError` | 输出路径空 / 后缀不认识 | `ValueError` |
| `LogFileTypeError` | `log_file` 的类型不认识 | `ValueError` |
| `WriteError` | 输出目录建不出来、标注图/结果 JSON 最终写不进去（原始 `OSError` 在 `__cause__`） | 无（**唯一一个没留退路的**，见下） |
| `CancelledError` | 你传的 `cancel_event` 被 `set()` 了 | — |

⚠️ **升级时唯一要检查的一处**：以前「磁盘满 / 目录只读 / 父目录是个文件」这类失败抛的是
原生 `OSError`，现在抛 `WriteError`（它不继承 `OSError`）。如果你写过 `except OSError`
来接这些路径，请改成 `except WriteError` —— 否则会**静默漏接**（异常照旧往上冒，但你的兜底没生效）。
之所以不给它再叠一个 `OSError` 父类，理由见 `errors.py` 模块头最后一段。

「额外继承」那一列是**向后兼容**：以前这些路径抛的就是裸的 `NotImplementedError` /
`ValueError`，保留继承关系，旧的 `except` 子句才不会被静默漏接。取舍写在 `errors.py` 模块头。

## 7. 看过程：流式事件

本库内部**恒走流式**（原因见下一节 FAQ），所以「模型正在想什么 / 正在写什么 / 正在调哪个工具」
一路都是现成的，只是默认收完流才把结果交给你。想实时看，三层粒度随便挑：

| 粒度 | 入口 | 适合 |
|---|---|---|
| 一条龙 | `locate_to_file(img, target, "out.png", on_event=cb)` | 只要标注图，进度顺手打一下 |
| 结构化 + 进度 | `Locator.locate(img, target, on_event=cb)` | 要 `result`，同时想看过程 |
| 只要事件流 | `DeepSeekVisionClient().stream(messages, ...)` | 自己做分栏显示 / 自己接工具往返 |

三层拿到的是**同一批事件**，共六种：

| `type` | 字段 | 说明 |
|---|---|---|
| `reasoning` | `text` | 思考内容的一个片段 |
| `content` | `text` | 正文的一个片段 |
| `tool_call` | `index` / `id` / `name` / `arguments` | 工具调用的一个分片，`arguments` 是**增量** |
| `finish` | `reason` | `stop` / `length` / `tool_calls` |
| `usage` | `usage` | token 用量（只在最后一个 chunk，兼容服务可能不给） |
| `model` | `model` | 服务端实际使用的模型名（去重后只来一条） |

三点必须知道：

- 片段**切分是任意的**（按 token，不按字/句），拼起来才是完整内容；一次定位调用实测 122 条事件。
- `tool_call` 的 `arguments` 是**逐字符**吐的（实测一次 47 个分片），要 `json.loads` 得自己按 `index` 拼；
  `complete()` 已经替你拼好，放在 `ChatReply.tool_calls`。
- 别自己写解包 —— 现成的示例直接抄：

```bash
python examples/stream_events.py            # 定位请求的事件流，逐条带时间戳 + 首字延迟
python examples/stream_events.py --tools    # 带 tools 的请求：工具调用一片片吐出来、再拼回去
python examples/stream_events.py --raw      # 事件的 JSON 原样打印
```

关于工具的边界：`tools` / `tool_choice` 由你**原样透传**给服务端，`ChatReply.tool_calls`
给你拼好的调用请求，但**本库不声明工具、也不执行工具** —— 要不要跑、跑完怎么把结果发回去，
是调用方的事。`Locator.locate` 这一路不带 `tools`，所以它不会有 `tool_call` 事件；
`use_tools=True` 依旧直接抛 `NotImplementedError`（v0.1 没有 Agent 循环）。

### 7.1 调试日志：把请求体和响应体落盘

上面那套事件是「实时看」，调试日志是「事后查」—— 它把**网络层**的报文与响应写成
一行一个 JSON 的文件（JSONL）。**默认全程关闭**，一行参数开启：

```python
locate("photo.png", "红色圆形", log_file="runs/logs/run.jsonl")
```

四种开法（越靠前越优先）：`log_file=` 参数 / `Locator(log_file=...)` / 环境变量
`QSML_LOG_FILE` / CLI 的 `--log-file PATH`（`--log` 则自动落到
`runs/logs/qsml-<时间戳>.jsonl`）。`log_file=False` 是**明确关闭**，用来盖掉环境变量里开着的日志。

一次调用会写下这些行：

| `event` | 内容 |
|---|---|
| `request` | 完整请求体（messages、stream 参数、tools…）+ 脱敏后的配置 |
| `event` | 每个流式事件（思考 / 正文 / 工具调用 / …），与 `on_event` 收到的是同一批 |
| `chunk` | 原始 chunk —— **只在 `DebugLog(path, chunks=True)` 时有** |
| `reply` | 拼好的完整响应体（正文 / 思考 / 工具调用 / usage / 结束原因） |
| `result` | 解析后的结构化结果（坐标、告警、原始项） |
| `error` | 任何异常（含空正文那类），原样抛出前先记一笔 |

两条安全线：

- **图片不进日志**。报文里的 data URL 会被换成 `data:image/png;base64,（省略 N 字符）`，
  只保留「类型 + 体积」—— 否则一张 1200x900 的图就是几百 KB base64，日志比图还大且没法读。
- **API Key 不进日志**。配置行走 `config.redacted()` 脱敏，只留首尾几位。

⚠️ 除此之外日志里**有完整的模型输入输出**（提示词、思考过程、坐标），适合自己排查，
别默认往公共 CI artifact 或别人的机器上丢。日志写失败不影响识别（只往 stderr 提醒一次）。

要连原始 chunk 一起记（排查「服务端是不是发了奇怪的字段」），自己构造对象传进去：

```python
from qsmy_deepseek_locator import DebugLog
locate("photo.png", "红色圆形", log_file=DebugLog("runs/logs/full.jsonl", chunks=True))
```

---

## 8. 常见问题

**Q：返回「正文是空的」（`EmptyResponseError`）怎么办？**
A：九成是思考 token 吃光了输出上限（此时 HTTP 仍是 200，`content` 就成了空串）。
调大 `max_tokens`、或关掉思考（`thinking=False`）、或降 `reasoning_effort`。异常信息里就写着这三条。

**Q：调用会不会超时？需要自己开流式吗？**
A：不用管，**本库内部恒走流式**（报文里固定带 `stream: true` 与 `stream_options.include_usage`），
`locate` / `locate_to_file` / CLI 全是同一条路径，只是收完流之后一次性把结果交给你。
流式对超时的意义是实测过的：同一张图、同一份报文、`timeout=2` 秒时，
**流式跑了 7.42 秒正常返回**（2880 个 chunk，相邻 chunk 最大间隔 507ms），
**非流式 2.14 秒就被 `APITimeoutError` 打断** —— 换句话说，关掉流式会让本来能成的请求直接失败。
代价是它的超时口径是「两次数据之间的静默」而不是总时长：模型迟迟不吐第一个字时照样会被打断，
所以 `timeout` 不要设得太贴 —— 默认就是 **300s**（连接/写入超时也用它）。
真挂住时的最坏等待是 `timeout × (max_retries + 1)`，默认即 300s × 3，
想收紧就传 `timeout=60` 或设 `QSML_TIMEOUT`。

**Q：中文标签变成方块（豆腐块）了怎么办？**
A：说明没找到含中文字形的字体，库已经退回 PIL 内置位图字体 —— **并且会发一条 UserWarning 提醒你**
（以前这一步是静默的，图能正常出、只有标签是豆腐块，很难发现）。三种给法任选一种：

```python
resolve_font(22, font_path="/system/fonts/NotoSansCJK-Regular.ttc")   # 1) 当场指定
```

```bash
export QSML_FONT_PATH=/system/fonts/NotoSansCJK-Regular.ttc   # 2) 指定字体文件
export QSML_FONT_DIR=/system/fonts                           # 3) 只指定探测目录
```

也可以走配置：`Locator(font_path=...)`，或写进 `Settings.font_path`（跟着 `merged()` 走）。
探测顺序是**字体名优先**（外循环名字、内循环目录），而且选中后会**真渲染一遍确认它有中文字形**
—— 只看文件名会踩坑：安卓上 `/system/fonts/DroidSans.ttf` 是 Roboto 的软链，
名字像中文字体、实际只有拉丁字形。

**Q：升级后 `-o out.jpg` 画出来的东西和以前不一样了？**
A：是修好了一个老问题。以前**无论后缀一律存 PNG**，`out.jpg` 里其实是 PNG 字节（改名不改内容），
于是「按后缀读出来的格式」和「文件真实格式」对不上。现在后缀说了算：`.jpg` 就是真 JPEG。
如果下游代码正靠 `out.jpg` 当 PNG 用（比如直接喂给只认 PNG 字节的东西），升级后请改回 `.png` 后缀。
三条出图路径（`locate_to_file` / `save_annotated` / `Locator.locate_and_draw`）现在同一套规则。

**Q：调用会不会卡住主线程？能中途取消吗？**
A：**会卡，而且可能卡几分钟** —— 全部入口都是同步阻塞的，最坏 `timeout × (max_retries + 1)`；
所以别在主线程 / UI 线程里调。要能中途喊停就传 `cancel_event=threading.Event()`：
`set()` 之后，本库会在**下一个流式事件到达时**抛 `CancelledError`（进门前、每个事件、
模型返回后三个检查点）。注意它不会撤回已经发出去的请求，服务端可能仍在生成。

**Q：模型一个目标都没找到，是报错吗？**
A：不是。`result.empty` 为真、`warnings` 里会说清是「模型明确回了空数组」还是「正文里没有坐标」。
后者通常意味着提示词没被遵守，该改提示词而不是重试。

**Q：坐标看着偏了 / 报了越界告警？**
A：先看 `result.warnings` 与 `result.text`。越界基本等于模型给了像素坐标，
通常是 system 提示词被改过 —— 坐标口径写在 `prompts.py` 里，请不要在 `target` 里另写一套。

**Q：能换成别的模型 / 别的厂商吗？**
A：`model` 与 `base_url` 都能改，但请先用 `bench` 验证。
特别注意官方另一档 `deepseek-v4-pro` **不支持图像理解**：图片会被静默丢弃，
HTTP 照样返回 200，只能从 `usage.prompt_tokens` 没涨看出来。

**Q：`image_detail` 能提高定位精度吗？**
A：不能。每张图服务端最多只算 384 token，大图无论如何都会被缩到约 800x800。
它改的是「缩放发生在哪一层」，不是模型真正看到的像素数。

**Q：为什么极扁 / 极长的图上定位很差？**
A：那是模型能力的边界，不是库的缺陷：长边被缩到约 1000 后，密集小目标只剩几像素。
详见 [`docs/API-NOTES.md`](https://github.com/QsmyHyly/qsmy-deepseek-locator/blob/main/docs/API-NOTES.md) 第 9 节。

**Q：想实时看到模型的思考、正文、工具调用，有现成的代码吗？**
A：有，见第 7 节。一句话版：给 `locate` / `locate_to_file` 传 `on_event=你的回调`，
或者用最细的一层 `DeepSeekVisionClient().stream(messages)`。
现成可跑的示例是 `examples/stream_events.py`（加 `--tools` 演示工具调用分片，加 `--raw` 打事件 JSON）。

**Q：异常该怎么兜？网络中途断了抛什么？**
A：全都继承 `LocatorError`，`except LocatorError` 一把兜住即可，具体的子类见第 6 节。

**本次改动后这个承诺才是闭合的**：以前还会漏出三类裸异常 —— `NotImplementedError`（`use_tools=True`）、
`ValueError`（输出路径后缀不认识 / `log_file` 类型不认识）、`OSError`（标注图最终落盘那行）。
现在它们分别变成 `UnsupportedFeatureError` / `OutputPathError` / `LogFileTypeError` / `WriteError`，
且**全部多重继承**（`LocatorError` + 原来那个基类），所以旧的 `except ValueError` /
`except NotImplementedError` 照旧抓得住，不会被静默漏接。
唯一的例外是 `WriteError`：它**不**继承 `OSError`（理由与 `__cause__` 的去向写在 `errors.py` 模块头）。
**流跑到一半**才断（服务端断连、读超时、流里回一个 error 事件）也算 —— 本库会把它包成
`APIError`，原始异常挂在 `__cause__` 上，不会丢。所以 CLI 那种「接口报错就退出码 1 加一句
错误：…」的承诺，对中途失败同样成立。

**Q：出问题了，想看到底发出去什么、模型回了什么？**
A：开调试日志，见 7.1 节：`locate("photo.png", "红色圆形", log_file="runs/logs/run.jsonl")`，
或 CLI 加 `--log`。请求体、流式事件、完整响应体、解析结果、异常都会写成 JSONL；
图片 data URL 会省略成占位符，API Key 会脱敏。**默认不开。**

**Q：模型要调用工具时，本库会替我执行吗？**
A：**不会**。`tools` 原样透传、调用请求拼好放在 `ChatReply.tool_calls`，到这儿为止 ——
执行工具、把结果发回去、决定要不要再来一轮，全是调用方的事。
`locate` / `locate_to_file` 的 `use_tools=True` 会直接抛 `UnsupportedFeatureError`。
工具循环**已经实现**了，入口是 `qsmy_deepseek_locator.agent.run_agent`（见 §3.5）——
那个参数不会静默忽略，也不会替你打开一个会多次计费的循环。

## 9. 文档

- [`docs/API-NOTES.md`](https://github.com/QsmyHyly/qsmy-deepseek-locator/blob/main/docs/API-NOTES.md) —— DeepSeek 接口事实与踩坑记录（**这个库为什么长这样**）。
  代码里凡是为某条坑做了特殊处理的地方，都用 `@doc docs/API-NOTES.md#<锚点>` 指回对应小节。
- 其余说明按「文档就近写在代码里」的原则放在模块头注释：
  `prompts.py`（提示词为什么这么写）、`parsing.py`（刻度兜底与为何不猜）、
  `images.py`（编码策略）、`drawing.py`（中文字体）、`benchmark.py`（评测口径）、
  `bench_score.py`（判分：三处刻意不统一的判定口径，别顺手"统一"掉）、
  `bench_generators.py`（探测型测试图，以及"字体为什么必须可注入"）、
  `agent.py`（工具循环：事件协议、以及那两个同名不同义的 tool_call）、
  `tools/`（工具框架：为什么上下文参数要从 schema 里藏掉）、
  `debuglog.py`（调试日志记什么、为什么不记图片）、
  `http_client.py`（不装 openai 时用哪个客户端、两个客户端差在哪）、
  `errors.py`（异常为什么要多重继承、`WriteError` 为什么不继承 `OSError`）。

## 10. 与 `deepseek-vision-annotation` 的关系

本库最初是从那个演示项目里**抽出来的核心**；**2026-09-18 起这段关系变成双向共用** ——
（那时的工作标签叫「0.1.3」，但**从来没有 0.1.3 这个已发布版本**：它随 v0.2.0 一起发出去了。
仓内别处若还见到「0.1.3」，指的都是这同一批改动，不是 PyPI 上的某个版本。）
一批两边都在用的规则不再各持一份，而是收拢到本库，由两个项目（以及安卓 App）共同引用：

| | deepseek-vision-annotation | 本库 |
|---|---|---|
| 形态 | 完整演示程序（FastAPI + 网页对比 + 历史记录 + 工具执行框架） | 可 `pip install` 的库 |
| 交互 | 浏览器界面、SSE 流式控制台 | Python API + CLI |
| 无 Key 时 | 进 Mock 模式，页面照样能演示 | **直接报错**（不给假数据） |
| 坐标口径 / 提示词 / 打标逻辑 | 同一套，已在本库中保留 | 同一套 |

### 10.1 收拢过来的四样共用件（随 0.2.0 发布）

| 共用件 | 在库里 | 为什么它不该有两份 |
|---|---|---|
| thinking 的合并规则（按次覆盖 × 配置默认） | `request_build.merge_thinking` | 三处实现当时**已不严格等价**：下游写的是「不是真就关掉」，本库写的是「没表态就别发这个字段」 |
| 探测型测试图（圆点阵 / 文字阶梯 / 竖线带 / 分辨率缩放） | `bench_generators` | 「改提示词必须重跑评测」是本库自己的规矩，而要重跑就得有**能暴露问题**的图，光有几何图不够 |
| 判分口径（中心点命中 / 文本标签 / 坐标空间诊断） | `bench_score.center_hit`、`bench_shapes.text_label_ok`、`bench_score.evaluate_any_space` | 判分规则一旦分叉，两边的历史评测结论立刻不可比 —— 而且是**静默**不可比 |
| 工具循环（多轮编排 / 工具注册执行 / 上下文注入） | `agent.run_agent`、`tools/`、`tool_schema` | 事件协议、工具报错要回填而不是抛穿、上下文参数要从 schema 里藏掉 —— 这些细节三边各写一遍，就是三次改漏的机会 |

⚠️ **字体注入点**（`bench_generators` 的四个画图函数都有可选的 `font_resolver`）：
本库不打包字体文件（安装体积与字体许可都要求如此），默认探测**当前机器**的系统字体，
所以同一段代码在不同机器上画出的文字像素并不相同。自带字体的调用方必须注入自己的解析函数 ——
评测素材的基本要求是「换台机器跑，图还是同一张」。实测不注入时的差异是 5265 个像素、
**全部落在文字区域**，几何真值一字不差，图看上去完全正常。

**依赖方向仍然是单向的**：本库不 import 演示项目或安卓 App 的任何东西，
上面四样都是**先搬进本库**、再由它们反向引用。代价是演示项目多了一条
`qsmy-deepseek-locator` 依赖（见它的 `requirements.txt`），换来的是这三套规则只有一份实现。

## 11. 许可证

[MIT](https://github.com/QsmyHyly/qsmy-deepseek-locator/blob/main/LICENSE)。`docs/` 中的接口事实整理自 DeepSeek 官方文档与实际调用观测，
以官方站点 <https://api-docs.deepseek.com> 为准。
## 12. 已知限制

发布前做过一轮逐文件的对抗性复审，下面这些是**确认存在、但 0.1.0 没有修**的。
写在这里，免得你踩到了以为是自己的用法不对：

- **`Locator(thinking=True)` 会被 `locate_to_file()` 的函数默认值盖掉。** 后者的默认值是
  `thinking=False, image_detail="original"`（一行式入口图快），而它是**函数默认值**这一层，
  于是会盖过构造时传的设置。想沿用构造参数就显式写 `thinking=None, image_detail=None`。
  这是 0.1.0 里唯一一处「函数默认值赢了构造参数」的地方，与第 6 节写的优先级相反，计划在 0.2 改掉。
- **刻度兜底只看数值，看不出坐标的来源。** 整批最大值落在 `1.0 < max ≤ 1000` 时，一律按
  0~1000 旧口径除以 1000。**默认提示词路径下这是安全的**：库的 system_prompt 已明确告诉模型
  它拿不到真实分辨率、统一按 0.0~1.0 输出，模型因此给不出真实像素坐标（它"报的像素"落在自己
  每次编的画布上，实测同一张图三次给出 1000x750 / 1000x800 / 1024x768）。**但覆盖
  `system_prompt` 去要像素坐标、或把别的模型（如 Qwen2.5-VL，它返回真实像素）的输出喂给
  `parse_detections` 时，这一步会改错数据。** 换算一定会留下告警，但告警只陈述"已除以 1000"、
  不替你判断该不该除 —— 请对照结果里的 `image_size` 复核。
- **这个换算是整批判定的**（`max()` 取自所有检测项的所有坐标）：混着给（一部分 >1000、
  一部分没超）时，整批都会按旧口径处理或整批都不处理，不会逐项各判各的。
- **输出被截断时，提示语指的是提示词，而不是输出预算。** `finish_reason == "length"`
  且正文里没解析出坐标时，`warnings` 说的是「正文里没有可解析的坐标，可检查提示词」——
  真实原因往往是思考 token 吃光了 `max_tokens`。看到这条时请一并调大 `max_tokens`
  或关掉思考（`thinking=False`），别只改提示词。
- **`max_side=None`（默认）时图片零重编码，因此不做图像内容校验。** 把一个非图片文件
  （比如 `.png` 后缀的 HTML）喂进去，本库不会在本地拦下它，报错会来自服务端。
  想严格拦截就传 `max_side=`（例如 `max_side=1600`），那条路径会真的解码图片。
- **定位是「框出大概位置」，不是像素级分割。** 不做 NMS、不去重，同一个目标可能出现两个框；
  框的精度受模型限制（每张图服务端只算 384 token）。
- **同一张图多次调用，返回的目标集合不保证一致。** 本库不发送任何采样参数
  （`temperature` / `top_p` / `seed` 一个都没设），每次调用都是一次独立采样。实测同一张图、
  同一目标连跑三次，目标数给出过 `17 / 10 / 8` 这样的差别，命名与粒度也跟着变；
  **反倒是同一个目标的位置相对稳**（实测同一栋楼三轮中心点相差 1.5~2%）。所以结果看着"飘"时，
  先怀疑「这一次框了哪些目标」，而不是「坐标算错了」。要复现性就自己多跑几次按 label 聚类投票。
- **标注图的标签会自动避让，落点不保证和框的位置一一对应。** 标签默认画在框上方，贴图片
  边缘时翻进框内侧、被别的标签压住时向下错开，四边都保证不越出画布 —— 想完全固定位置，
  就自己拿 `Detection` 列表用 PIL 画。
- **0.1.0 没有 Agent / 工具执行循环。** `use_tools=True` 直接抛 `UnsupportedFeatureError`（也是 `NotImplementedError`）；
  `client.complete(..., tools=[...])` 能拿到完整工具调用参数，但本库不替你执行。
- **已知欠账：默认提示词里还没有「框选纪律」。** 安卓 App 在真机上实测到：延伸型 / 背景型目标
  （天空、地面、道路、头发、建筑群……）的框习惯性贴边、甚至覆盖整幅图，而独立小物体
  （罐子、领带、人脸）框得很准 —— 不是解析问题（`raw_items` 与 `detections` 逐字一致），
  是提示词没交代「框该收在哪」。反馈文档里有一版实测有效的追加文案（平均框面积缩小 41%、
  全幅框 1 -> 0）。**本轮没做**：`prompts.py` 自己定了规矩 —— 改它任何一句话都必须重跑
  `bench` 再下结论，而重跑要花真钱、要重新对齐 ground truth；而且这一条会改变所有既有用户的
  输出分布（框更小不总是更好：目标若真是大范围比如「天空」，过分收缩反而会漏）。
  证据与文案见本文档第 9 节提到的反馈文档 P0-3 节。
- **实测只跑过 CPython 3.11 与 3.12。** `requires-python = ">=3.9"` 是按语法静态核对的
  （全部模块都有 `from __future__ import annotations`，没有 3.10+ 独有语法），没有真在 3.9 / 3.10 上跑过。
## 13. 发版（维护者向）

```bash
# 1) 改版本号 —— 两处都要改，漏一处 tests/test_version.py 立刻红
#      pyproject.toml 的 [project].version
#      src/qsmy_deepseek_locator/__init__.py 里 PackageNotFoundError 分支的兜底字面量
# 2) 发版前置校验：已经有人装到的那份，和我手上这份是同一个东西吗
python tools/check_release.py       # 先跑一次 --self-test 确认它没变成空气
# 3) 构建并检查产物元数据
python -m build && twine check dist/*
# 4) 上传
twine upload dist/*
```

**第 2 步为什么是必需的**：2026-09-18 的事故 —— `to_items` 在 0.2.0 **发布之后**才补进库，
版本号没跟着升，于是 PyPI 上的 0.2.0 与源码里的 0.2.0 **不是同一份东西**：下游
`pip install qsmy-deepseek-locator` 之后 `from qsmy_deepseek_locator.parsing import to_items`
直接 ImportError，整个下游项目连模块都 import 不了。本机没暴露，是因为下游都按「源码在旁边、挂
PYTHONPATH」跑 —— **兜底把人骗过去了**。同类前科还有一次：0.1.2 发版漏改 `__version__` 的兜底
字面量，使用者看到的版本号谎报 0.1.1。两次都是「发版时记得改」这种靠人记的约定失效。

现在两次都钉成了可执行断言：兜底字面量归 `tests/test_version.py`，**产物与源码是否同一份**
归 `tools/check_release.py`。后者自带 `--self-test` 负向对照（伪造一个不一致必须被抓住）——
「检查脚本自己其实是空气」正是这类事故最常见的成因，不配负向对照的检查等于没有。

**同名同版本却不一致 = 红灯。** 修法是**升版本号重发**，不要原地覆盖已发布版本：
已经有人装过那一份了，覆盖只会让「你装的是哪个版本」这句话彻底失去意义。

发版前本地版本必然比 PyPI 新，脚本这时只报「本地 X 尚未发布，最新已发布 Y」并列出新增符号，
**不算失败** —— 真正要拦的是同名同版本却不一致。
