qsmy-deepseek-locator
用 DeepSeek 视觉模型做物体定位:给它一张图和一句「找什么」, 拿回 0.0~1.0 的归一化坐标(框 / 点)与中文名称,需要的话直接把框和标签画回图上。
from qsmy_deepseek_locator import locate_to_file
# 最省事:图片 + 「找什么」+ 输出路径,回来时标注图已经写好
result = locate_to_file("photo.png", "红色圆形", "annotated.png")
print(result.annotated_path, result.labels) # annotated.png ['红色圆形']
要自己掌控坐标与绘制:
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")
命令行同样一行:
qsmy-deepseek-locator photo.png -t "红色圆形" -o annotated.png
1. 安装
pip install qsmy-deepseek-locator
要改源码、跑测试就用可编辑安装:
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:
pip install qsmy-deepseek-locator # 最小安装(不含 openai)
pip install "qsmy-deepseek-locator[openai]" # 想要 SDK 的重试与连接池时
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):
python -m pytest tests -q
2. 配 API Key
# 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。
没有 Key 会怎样:直接抛
MissingAPIKeyError,并告诉你三种配法。 本库刻意不提供「无 Key 时返回假数据」的降级 —— 演示程序这样做很方便, 但库不行:用户会拿着一堆看起来正常的假坐标当真结果。
3. 三种用法
3.1 一行式出图(最省事)
给「图片 + 找什么 + 输出路径」,函数回来时标注图已经躺在磁盘上了:
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 一行式(只要坐标)
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 复用定位器(多图批量时用这个)
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 命令行
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 循环 —— 它会多轮调用模型,直到不再需要工具:
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、看图片信息、画标注图、列调色板),要加自己的工具:
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 的相对比例,小数位数不设上限。
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 个百分点,所以「改完提示词到底变好没有」必须能自动判分:
$ 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) |
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 速查
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=(调试日志)。
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 默认输出) |
打标:
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。- 别自己写解包 —— 现成的示例直接抄:
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)。默认全程关闭,一行参数开启:
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 一起记(排查「服务端是不是发了奇怪的字段」),自己构造对象传进去:
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 提醒你 (以前这一步是静默的,图能正常出、只有标签是豆腐块,很难发现)。三种给法任选一种:
resolve_font(22, font_path="/system/fonts/NotoSansCJK-Regular.ttc") # 1) 当场指定
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 第 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—— 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。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时,一律按 01000 旧口径除以 1000。默认提示词路径下这是安全的:库的 system_prompt 已明确告诉模型 它拿不到真实分辨率、统一按 0.01.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. 发版(维护者向)
# 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」并列出新增符号, 不算失败 —— 真正要拦的是同名同版本却不一致。
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file qsmy_deepseek_locator-0.2.1.tar.gz.
File metadata
- Download URL: qsmy_deepseek_locator-0.2.1.tar.gz
- Upload date:
- Size: 240.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4bad010db2694ba0c8e3b70d4c7eccb769e68c6e12deaadb0e01a9e50868b1c2
|
|
| MD5 |
a18d2e40b718aba524901a2ca6a0bf33
|
|
| BLAKE2b-256 |
8a1e316601c1c11c8a756658c8e0e23e692d367be2513505dbf2213a820484af
|
File details
Details for the file qsmy_deepseek_locator-0.2.1-py3-none-any.whl.
File metadata
- Download URL: qsmy_deepseek_locator-0.2.1-py3-none-any.whl
- Upload date:
- Size: 152.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
37d1742888454165ebd1c13ff310435bfd94177001540be0e0e693d43c8af524
|
|
| MD5 |
0d7fd84cf080fd8b71f053011fe6a345
|
|
| BLAKE2b-256 |
a5e439de121493e6ebae72e43a1264d41e4f6ec09fb9fa5ee19a9c5fb7a0d322
|