skuukzky
文章18
标签9
分类3

文章分类

从 vlcaptcha 到 VLCaptcha Studio:把视觉识别、Jev 判断和人工复核做成可靠的标注系统

从 vlcaptcha 到 VLCaptcha Studio:把视觉识别、Jev 判断和人工复核做成可靠的标注系统

看到 vlcaptcha 时,我首先想到的应用场景,是把它已有的图像识别能力接进一个 Web 标注工作台:模型先找出候选对象,再按题意选择,人可以直接在图片上修正,最后导出数据集。

这个想法很容易画成一条流程线,但真正实现时,难点很快转移到了模型调用之外。

假设模型把一个物体认错了,我已经手动改好了标签和点位,这时候重新识别,修改应该怎样保留?如果一个请求在十秒之后才返回,而这十秒里我已经切换图片、修改题目,甚至重新打开了浏览器,系统还能不能判断这份结果应该放在哪里?如果页面显示“保存成功”,它指的是模型草稿、浏览器缓存、数据库记录,还是一份已经确认可以导出的标注?

VLCaptcha Studio 的设计就是围绕这些具体问题展开的。最终形成的核心流程是:视觉模型生成候选,Jev 根据题意选择候选 ID,程序从对应版本中读取坐标,用户编辑并明确确认。

这篇文章从最初的分工讲起,再深入候选协议、数据库版本、坐标变换、自动保存、局部识别、后台任务和评测。文中的实现细节对应 0.2.0 版本;简化代码会明确标注,后续设想也会与当前能力分开。

一、从两阶段识别,走向可编辑的工作流

vlcaptcha 提供了图片加载、视觉模型适配、检测提示词、结果解析和多种题型处理逻辑。我参考的是它的两阶段点选方式:先检测图片中的对象,再根据题目选出需要点击的对象。

第二阶段天然存在一个适合人工介入的接口。第一阶段既然已经得到对象列表,就可以把列表和图片一起展示出来,让用户检查类别与位置。候选可以单独修改、单独保存,选择逻辑也可以在保留候选的前提下重新执行。

最初我明确希望让 TypeSafe 的 Jev 参与判断。项目采用的 Jev 接口处理文本和结构化状态,因此不能直接把“看图定位”这一职责交给它。图片仍然需要由视觉模型处理,Jev 则回答一个更有边界的问题:根据题目和候选描述,应该选中哪些对象,以及按什么顺序选中。

这里要接受一个事实:两个模型串起来,并不会自动消除第一个模型的错误。视觉阶段如果把剃须刀描述成高脚杯,Jev 可能非常一致地选中这个“高脚杯”。从它收到的信息看,判断甚至完全合理。

因此,工具必须把原图保留在复核的中心位置。人工需要同时检查三件事:候选描述是否符合图片、选择是否符合题目、最终落点是否合适。它们分别对应视觉语义、决策语义和几何精度,不能用一个“接口成功”状态代替。

我把第一版范围收敛到点选题的三种选择方式:单选、全部匹配、按指定顺序点选。它们可以共用同一套候选结构和画布编辑器,适合先验证完整工作流。滑块、旋转、轨迹和文字题则需要各自的几何结构与编辑控件,暂时留在后续范围。

二、先确定系统必须始终满足的规则

写页面之前,我先明确了几条会影响所有模块的规则。它们决定按钮意味着什么、接口何时拒绝请求,以及哪些数据可以被导出。

规则 对实现的直接要求
模型完成任务,不等于人工认可结果 模型运行、应用建议、人工确认是三个独立动作
模型重跑不能自动覆盖人工点位 候选、判断记录、人工草稿分别保存
判断必须能定位到它实际使用的输入 每份判断引用候选集合 ID 和题目版本
画布缩放不能改变标注的含义 持久化统一采用原图像素坐标
同一保存请求重试不能产生额外修订 请求 ID、内容摘要和回复需要持久化
旧页面不能直接覆盖新版本 保存、应用和确认都校验预期版本
导出必须能解释这份数据为何有效 导出读取人工确认快照,并检查当前题目及复核状态

这些规则也帮助区分三个界面动作。

“生成建议”只是获得一份可以检查的模型结果。“应用到标注”表示用户决定把结果放进当前草稿,草稿仍然可以修改。“确认”表示用户认可当前这一版内容,系统据此创建一个后续不会被编辑覆盖的快照。

如果把三个动作合成一个“识别并保存”,用户将很难知道数据库里究竟存的是哪种结果,也很难在模型出错后恢复正确版本。把它们拆开会增加一些操作和状态,但能够明确保留每一步的意义。

还有一个容易忽略的取舍:模型调用可以耗时很久,用户的编辑却必须立即反馈。因此,前端显示中的工作副本、服务端已保存的草稿,以及正在等待回复的请求,本来就可能短暂不同。后面的自动保存设计,需要正面处理这种不同步。

三、整体结构:让模型适配与编辑流程分别演进

当前工程使用 React、TypeScript 和 React Konva 构建前端,FastAPI 提供接口,SQLite 保存结构化状态,本地文件目录保存规范化后的图片,IndexedDB 保存浏览器中的未同步编辑副本。

VLCaptcha Studio 的职责分层与数据流

前端的主要职责是展示原图、编辑候选和点位、维护当前工作副本,以及将用户操作提交给后端。模型密钥和实际模型请求由 Python 服务处理。

后端进一步分成几类模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
studio/
  _vendor/vlcaptcha/   固定提交的上游库代码
  providers.py        视觉模型与 Jev 的请求构造、解析和校验
  chat_decision.py    通用兼容接口的候选选择适配
  pipeline.py         单张及批量模型任务
  regions.py          局部识别、预览和应用
  models.py           数据类型与几何校验
  editor.py           编辑保存事务与幂等记录
  review.py           复核状态和操作
  review_rules.py     待复核排序规则
  datasets.py         数据集与导入
  db.py               数据库、版本及查询
  api.py              上传、应用、确认和导出接口

frontend/src/
  Editor.tsx          画布交互
  editorSession.ts    base / working / pending 的协调
  recovery.ts         IndexedDB 恢复副本与工作位置
  RegionPanel.tsx     局部识别交互

这里最有用的一条边界,是模型适配层最终只向业务层交付受约束的数据。它可以调用 Jev,也可以调用兼容的聊天接口,但不能直接操作人工草稿,更不能直接创建确认记录。

第一版使用 SQLite 和进程内线程池,主要是为了降低本地运行的配置成本。它适合当前单用户工作台,但不能据此推断系统已经具备多节点调度、租约恢复或多人协同能力。以后需要扩展部署方式时,任务执行和存储层都要重新评估。

四、候选协议:把“模型描述”变成可检查的数据

4.1 候选必须有身份,也必须有几何约定

候选是视觉阶段、判断阶段和编辑器之间共同使用的数据。实际核心字段如下:

1
2
3
4
5
6
{
  "id": "c1",
  "label": "红色苹果",
  "center": [120, 80],
  "bbox": [95, 55, 145, 105]
}

center 是原图像素坐标,bbox 使用左上角、右下角的顺序。边框可以为空,中心和标签则是判断阶段的基本输入。

视觉模型返回的候选会先经过解析、归一化坐标转换和结构检查,再进入数据库。检查包括 ID 唯一性、候选数量、数值有限性、中心是否越界、边框宽高是否为正等。当前每张图片最多保留 64 个候选,超出范围需要调整输入或人工处理。

但编辑中的候选允许暂时不完整。例如用户刚补了一个点,还没有输入标签,自动保存应该能保存这一步。进入模型判断和人工确认前,再执行相应的严格检查。

这实际上是两套有不同目的的校验:草稿校验保护数据结构,提交校验保护业务语义。若把所有最终约束都提前放进自动保存,用户编辑到一半就会不断遇到保存失败。

4.2 ID 只在具体候选版本中有意义

c1 并不是跨所有检测永远指向同一物体的全局 ID。新的一次视觉检测也可能生成 c1,因此引用候选时必须同时记录 candidate_set_id。

1
2
3
4
候选集合 A / c1 = 红色苹果
候选集合 B / c1 = 黄色香蕉

可靠引用 = candidate_set_id + candidate_id

编辑器里的点还可以保存 prediction_id,说明这个点最初是从哪次判断应用过来的。后端会检查:候选集合是否属于当前任务,集合里是否有这个候选,判断记录是否引用了相同的候选集合。

这些引用主要说明数据来源,不意味着人工修改后的坐标仍然等于模型坐标。用户可以保留来源记录,再把落点移到正确位置;最终可信的坐标来自那一版人工确认快照。

4.3 让判断模型选择 ID,避免重新生成坐标

假设题目是“依次点击苹果、香蕉”,视觉阶段已经提供了两个候选。判断适配层最终交付给业务的结果可以简化为:

1
2
3
4
5
{
  "selected_ids": ["c1", "c2"],
  "warnings": [],
  "applicable": true
}

这里展示的是程序内部结果,不是完整的 Jev 原始响应。应用建议时,程序按选中顺序从这份判断引用的候选集合中读取标签和中心坐标。

这样可以把“选错对象”和“坐标来自错误版本”分开检查。未知 ID、重复分配和缺失位置都能在程序里发现,而无需再次询问模型。

五、Jev 的三种请求如何对应点选任务

5.1 单选:显式保留“无匹配”

单选构造一个 Choice 问题。候选 ID 作为选项键,候选标签、中心和边框作为选项信息,同时加入 __none__,表示没有匹配对象或信息不足。

下面是简化后的请求片段,实际请求还包含题目状态、图片尺寸和规则说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "questions": {
    "target": {
      "type": "choice",
      "criteria": {
        "c1": {"label": "红色苹果", "center_px": [120, 80]},
        "c2": {"label": "黄色香蕉", "center_px": [220, 100]},
        "__none__": "没有匹配对象,或无法可靠选择"
      },
      "instructions": {"question": "点击苹果"}
    }
  }
}

没有“无匹配”选项时,模型即使面对一组全错的候选,也可能只能选出其中相对接近的一个。保留这个出口,能把候选不足的情况交给复核流程。

解析时也不能只拿 choice 字段。当前代码还检查概率键是否覆盖全部选项、每个值是否落在 0 到 1 之间、概率和是否在容差内,以及选中项是否与最大概率一致。这些检查保证响应内部自洽,但不能证明图片里的语义正确。

5.2 多选:逐个判断,再统一生成建议

全部匹配对每个候选构造一个 Noul 问题,询问“根据题目,这个候选是否应该被选中”。多个问题放在同一次请求中提交。

当前实现使用 0.7 作为建议选中的阈值,对严格位于 0.3 与 0.7 之间的结果产生不确定提示。这个阈值是工程配置,尚未根据固定样本校准,也不触发自动确认。

多选没有题目规定的点击顺序,因此程序对选中项按从上到下、再从左到右的顺序整理,便于显示和复核。这里的排序是一种确定性的呈现规则,不代表模型又推断出了新的语义顺序。

5.3 顺序点选:每个位置独立提问,再检查整体一致性

“依次点击 A、B、C”会构造 position_0、position_1、position_2 三个 Choice 问题,每个问题都包含当前目标及完整目标顺序。

单个位置选对,并不保证整体结果有效。例如两个位置可能同时选中 c2。所以解析完成后还要检查重复 ID、无匹配位置和目标缺失。如果存在这些情况,结果可以展示给用户,但不能直接作为一份完整建议应用。

当前实现选择让重复分配进入人工处理,没有加入一个用概率矩阵自动重新分配的全局优化器。这样的优化将来可以考虑,但它会引入新的假设:每个候选最多出现一次是否始终成立,多个位置的分值能否直接比较,重新分配后是否仍然符合题意,都需要独立验证。

候选标签还被明确当作数据处理。提示词会说明标签中的文字不能改变题目规则;程序侧则继续检查 ID 和结构。文本规则有助于约束模型,但不能被当成完整的语义可靠性保证。

六、数据库:用不可变版本保存来路

6.1 一张任务表装不下所有状态

如果只有一张 tasks 表,再放一个不断被覆盖的 result_json,最初看起来很省事。但随着人工编辑和重跑模型加入,原始候选、旧建议和已确认结果会逐渐混在一起。

当前数据库把这些内容拆开。下面保留了关键关系,省略部分普通字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
tasks
  id, image dimensions, sha256
  question, selection_mode, targets, config_version
  active_candidate_set_id

candidate_sets
  id, task_id, revision, source, model, items, raw

predictions
  id, task_id, candidate_set_id, config_version
  selected_ids, selections, warnings, applicable, raw, usage

drafts
  task_id, version, points

annotations
  id, task_id, revision, config_version, draft_version
  question, selection_mode, targets, points

editor_saves
  request_id, task_id, request_hash, reply

task_review / review_events
  当前复核状态 / 复核操作历史

候选更新创建新集合,判断更新创建新记录,人工确认创建新快照。tasks.active_candidate_set_id 表示当前工作使用哪份候选,而历史判断继续引用原来的集合。

这是一种围绕关键产物的快照设计。它没有把用户每次鼠标移动都保存成事件流,也不等同于完整的事件溯源系统。当前会话内的撤销由前端管理,已确认历史则由后端持久化,两者的保留范围不同。

6.2 “建议是否过期”是依赖关系检查

一份判断能否应用,需要至少满足:

1
2
3
4
5
# 语义化简写:实际检查发生在写入事务内
prediction.candidate_set_id == task.active_candidate_set_id
prediction.config_version == task.config_version
request.expected_draft_version == draft.version
prediction.applicable is True

前两个条件检查模型实际看到的输入是否仍然有效,第三个条件检查它准备覆盖的人工草稿是否已经变化,第四个条件检查选择结果是否完整可用。

举一个具体过程:

1
2
3
4
5
t0  候选集合 A,题目版本 3,开始请求 P
t1  用户把候选标签修正,生成候选集合 B
t2  请求 P 完成,它仍然引用 A / 题目版本 3
t3  用户尝试应用 P
    → 当前候选已经是 B,拒绝应用,提示重新判断

过期不代表结果必须从历史里消失。它仍然可以用于了解当时的判断,只是不能被误当成当前输入的最新答案。

6.3 在事务里检查,避免检查和写入之间出现空隙

版本检查如果发生在事务外,检查完成后到实际写入前,其他请求仍可能改变数据。当前写连接使用 BEGIN IMMEDIATE,把读取预期版本、检查引用和提交结果放在同一个写事务中。

SQLite 开启 WAL,并为连接设置等待超时,这适合当前本地服务的读写模式。它提供事务一致性,但并不允许任意多个写事务同时提交。后续如果增加多人协作或长时间批量写入,需要重新衡量数据库与事务边界,不能把本地使用的表现直接外推。

七、坐标体系:把每一步转换写清楚

7.1 区分工作图片、模型输入和画布显示

导入图片后,工作台按显示方向规范化并保存为 PNG。后续标注所说的“原图”,对应这份规范化后的工作图片。其尺寸和 SHA-256 一起进入任务记录,导出也带上这份图片。

视觉模型可能看到经过缩放的输入;前端画布还可能显示为 50%、150% 或更大的倍率。所有这些显示、传输尺寸都不能改变数据库坐标的含义。

目前视觉适配约定模型输出 0–1000 的归一化坐标,适配层负责转换为工作图片的像素坐标。中心点采用:

1
2
x = round(normalized_x / 1000 * (width - 1), 2)
y = round(normalized_y / 1000 * (height - 1), 2)

例如图片宽度是 320,归一化中心横坐标为 500,转换结果是 159.5。中心点坐标允许小数,但范围限制在 0..319,不会因为处在最右边而得到不存在的第 320 个像素索引。

边框使用区域边界约定,右、下边界允许到达图片宽高。因此边框横坐标使用 normalized_x / 1000 * width,而不是 width - 1。这两个约定需要分别验证。

7.2 编辑边界限制和模型输出校验承担不同职责

用户拖动点位时,让它停在图片边缘是合理的交互行为。模型却不能返回一个越界数值,然后由程序悄悄截到边缘并显示为成功。

因此模型输出先检查类型、有限性和 0–1000 范围,换算后再检查像素范围与边框。非法输出可以进入有限次数的重新请求,最终失败则明确报错。

前端从画布坐标换回原图坐标,需先扣除对应平移,再除以缩放倍率;当前画布内的坐标处理围绕这一关系展开。方向键微调直接修改原图坐标,所以显示倍率不会改变一步的实际距离。

7.3 裁剪图必须先验证局部范围,再加偏移

假设识别区是 [100, 50, 300, 170],宽 200、高 120。视觉适配得到的局部中心如果是 [40, 30],映射回整图就是 [140, 80]。

顺序必须是:先确认 [40, 30] 合法地位于这个 200×120 的裁剪区域内,再加上左上角偏移,最后检查整图范围。

只做整图范围检查是不够的。如果模型返回局部横坐标 250,它已经超出识别区,但加偏移后仍可能落在整张大图内部。这样的结果必须拒绝,否则局部识别会偷偷影响区域外的位置。

放大裁剪图只是改变发送给模型的图像尺寸。适配层仍按未放大的裁剪区域宽高还原坐标,再加入整图偏移,避免把放大倍数重复计算。

八、自动保存:维护 base、working 和 pending

8.1 为什么一个防抖函数不够

自动保存从表面看,只需要“用户停止输入 900 毫秒后发送请求”。真正的问题发生在请求已经发出、用户继续编辑的时候。

假设点的横坐标原来是 100。用户把它移到 110,保存请求 R1 发出;在 R1 返回之前,用户又移到 120。如果收到 R1 的回复后直接用服务端数据替换表单,界面会突然跳回 110。

为了避免这种情况,前端 EditorSession 独立维护三份状态:

状态 含义
base 已经得到服务端确认的内容及版本
working 用户此刻正在编辑的内容
pending 已经构造、可能已经发出的那份不可变保存请求

保存请求迟到时,base、working 和 pending 的变化

R1 返回时,base 更新为 110,pending 清空,working 仍然是 120。再比较工作副本和基线,系统发现还有修改,于是基于新的服务端版本生成 R2。

这个处理方式同时避免了两个问题:旧回复覆盖新输入,以及新请求错误地沿用旧版本号。

8.2 网络重试必须复用同一个请求

当前编辑保存的请求形态如下,示例省略了候选和题目部分:

1
2
3
4
5
6
7
8
9
{
  "request_id": "save-example-001",
  "draft": {
    "expected_version": 7,
    "points": [
      {"id": "p1", "label": "苹果", "x": 110, "y": 80}
    ]
  }
}

在网络错误后,前端保留原始 pending,包括同一个请求 ID、同一个预期版本和同一份内容。即使用户随后继续编辑,也不能把 pending 的内容改掉;新的编辑留在 working,等待这一请求的结果得到确认。

原因是,客户端不知道一次超时发生在什么位置。可能服务端根本没收到,也可能已经提交,只是回复丢了。保留原请求,才能让后端识别这是重试。

后端在事务里先查询 editor_saves,再校验新请求的版本。下面是简化后的关键顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
with store.connect(write=True) as db:
    previous = find_save_by_request_id(db, request_id)

    if previous:
        if previous.task_id != task_id or previous.request_hash != digest:
            raise Conflict("同一个请求 ID 不能用于不同内容")
        return previous.reply

    validate_all_expected_versions(db, request)
    validate_all_editor_sections(request)
    reply = write_changed_sections(db, request)
    remember_request_and_reply(db, request_id, digest, reply)
    return reply

先查幂等记录很关键。R1 如果已经成功,数据库版本当然已经前进;此时重试应返回 R1 原来的回复,而不该先用旧预期版本触发冲突。

这里实现的是本地数据库保存的幂等性。它不能保证第三方模型只计费一次,也不能把跨网络、跨服务的执行变成全局“恰好一次”。这些是不同的边界。

8.3 同一次编辑保存必须整体成功或整体失败

用户可能同时改题目、候选标签和点位。当前保存接口允许一个请求携带三个部分,后端先检查全部版本与全部数据,再开始写入。

如果候选版本冲突,即使草稿点位本身合法,也不会先保存点位、再返回部分失败。任何一部分失败都会回滚,避免题目已经更新而点位仍停留在另一份编辑中的状态。

内容完全没有变化时,不必增加对应版本。新内容提交后,服务端返回新的草稿、候选或题目版本,前端更新 base,继续判断 working 是否还有尚未提交的修改。

8.4 区分网络、冲突和校验错误

网络错误保留请求并等待重试;版本冲突暂停自动保存并保留本地内容;数据校验错误要求用户修正内容后再次提交。

三者如果都被处理成“不断重试”,会出现无意义的请求循环:冲突不会因为重发旧版本而消失,非法数据也不会因为多发几次就合法。

切图、发起模型任务和确认之前,前端调用 flush(),等待正在进行的保存,并持续保存直到 pending 和未提交修改都清空。发生冲突就停在当前图片,让用户先处理,避免把未保存的工作误当成下一步输入。

8.5 浏览器恢复副本要能恢复尚未确定的请求

IndexedDB 保存的不只是点位,还包括 base、working、pending、图片签名和冲突信息。刷新页面时,如果之前的请求可能已提交,恢复后的会话可以携带同一个请求继续重试。

图片签名组合任务 ID、图片尺寸和 SHA-256,避免把旧副本恢复到不匹配的图片上。每个页面有自己的写入标识,防止多个标签页争用同一个缓存记录。

恢复前或处理冲突前还会保留备份,每张图片最多保留最近 20 份。自动恢复主要面向同页刷新和上次页面记录,其他页面的副本通过恢复界面选择。它仍然依赖同一个浏览器、同一个站点和可用的浏览器存储,不能替代后端备份。

九、局部重识别:把“修改哪里”也变成明确输入

9.1 区分识别区和参考区

整张图片里如果只有一个对象识别错误,重新跑全图可能再次扰动其他候选。局部重识别把目标收缩为用户指定的区域,但它仍然需要完整的数据约定。

界面区分两种框选:蓝色识别区决定需要返回哪些对象,金色参考区只提供题干或上下文。参考图也可以选择整张图片。发送给视觉模型时,第一张图是识别区,第二张图是可选参考,提示词明确要求只返回第一张图的对象。

识别区至少为 8×8 个原图像素。选区经过边界检查后,左上角向下取整,右下角向上取整,确保实际裁剪覆盖用户选择的范围。裁剪输入按大小处理后发送给模型,输出再按上一节的局部坐标规则映射回来。

9.2 完成识别,只写预览记录

局部结果保存在 region_results,记录关联任务、基础题目版本、基础候选集合、识别区域、参考区域和输出候选。

模型完成时不会直接替换当前候选,也不会移动人工点位。用户可以先检查预览,再选择添加哪些候选、替换哪些旧候选,或者只修正一个已有点的位置。

如果识别过程中题目或候选改变,返回结果仍可以保留为历史预览,但应用时会因基础版本过期而被拒绝。普通人工点位修改与候选修改有不同影响:只添加候选时不必覆盖点位;如果准备修正某个人工点,则还需要检查最新草稿版本。

9.3 替换必须限制在完整位于选区内的对象

判断一个旧候选能否被替换时,只有中心在选区内还不够。一个大边框可能横跨区域边界,如果把它删掉,选区之外的信息也会被一起删除。

当前采用较保守的规则:中心必须位于选区内;如果存在边框,整个边框也必须位于选区内。无边框的候选则只按中心判断。下面是对应规则的简写:

1
2
3
4
5
6
7
8
9
10
11
def can_replace(candidate, region):
    left, top, right, bottom = region
    x, y = candidate["center"]
    if not (left <= x < right and top <= y < bottom):
        return False

    box = candidate.get("bbox")
    return box is None or (
        left <= box[0] < box[2] <= right
        and top <= box[1] < box[3] <= bottom
    )

跨边界的候选需要扩大选区后重新处理,或者人工编辑。这个规则牺牲了一些“一键替换”的便利,换来更明确的影响范围。

9.4 只修一个点,不能顺带改掉整份标注

当用户选中人工点 A,再用局部候选修正位置时,后端先复制现有点列表,定位 A,只更新它的坐标和来源引用。A 的标签、列表位置以及其他点的内容继续保留。

新增候选和更新 A 在同一个事务里执行。如果草稿版本已经变化,整个操作失败,不能先把候选加进去,再告诉用户点位没有修成功。

局部应用也有请求 ID 和内容摘要。同一个请求重试返回已有回复;同一份局部结果已被应用后,不允许再用另一份不同请求重复应用。局部候选使用带任务标识的 ID,减少和当前集合内已有 ID 冲突的可能。

前端则把候选和点位一起纳入撤销快照。用户撤销这次修正时,需要恢复两者,不能只把点移回去,却留下刚才添加的候选。当前撤销只覆盖这一编辑会话,长期历史仍由后端版本承担。

十、后台任务:限制并发,明确取消的实际含义

10.1 两个工作线程,加一条数据库约束

当前模型任务由 ThreadPoolExecutor(max_workers=2) 执行。单张、批量和局部识别共用这个池,避免每增加一个入口就额外扩大并发。

但线程池限制的是整个进程的执行数,同一张图仍可能因为重复点击或多个页面同时发起而被排入多个任务。因此数据库额外建立部分唯一索引:

1
2
3
CREATE UNIQUE INDEX IF NOT EXISTS one_running_job
ON jobs(task_id)
WHERE status IN ('queued', 'running');

这条约束保证同一任务在排队或执行状态下只存在一份活动模型作业。检查放在数据库里,可以覆盖不同请求处理函数同时进入的情况。

模型任务记录 status 和 phase。前者表示排队、运行、完成、失败或取消,后者说明当前位于检测还是判断阶段。界面据此给出进度,错误记录也能区分失败发生在哪一段。

10.2 版本检查要在真正调用之前再做一次

入队时校验版本,并不能保证执行时输入仍然有效。任务可能在队列里等待,这段时间用户已经修改了题目或候选。

因此工作线程开始处理时,会再次对比任务保存的输入版本和当前版本。候选写入、判断写入也结合预期版本进行检查,避免使用旧输入更新当前状态。

模型调用在数据库事务之外进行。否则一个耗时十秒的网络请求会把写事务保持十秒,阻塞其他保存。事务只覆盖检查和提交这类短操作,网络请求返回后再进行后续校验。

10.3 取消意味着停止后续影响,不一定能撤销已发出的调用

尚未运行的任务可以直接转为取消,工作线程取到后不会再调用模型。已经发出的外部请求可能无法立即中断,当前实现会在请求返回后的检查点读取取消标记,停止下一阶段或丢弃结果。

如果视觉阶段已经完成并保存,用户随后取消判断阶段,之前的候选记录可能仍然存在。取消不是一次跨整个流水线的回滚,也不会撤销服务商已经发生的请求费用。

局部识别在保存预览之前还会检查取消状态;批量任务则允许单张失败,继续处理其他图片。“仅重试失败”会创建关联的新批次,并根据当前状态决定哪些任务可以重跑,避免把已确认样本重复处理。

10.4 服务重启时,明确结束旧的运行状态

进程内线程池不会在服务重启后继续执行旧的 Python 调用。因此启动时,数据库中仍为排队或运行的旧任务会被标成中断,用户可以重新发起。

这样至少保证页面不会永远显示“运行中”。已有候选、草稿和确认快照继续保留。

这还不是一个持久任务队列。如果以后需要多进程或多机器执行,就需要增加工作者身份、租约、心跳、超时回收、重复投递处理,以及外部调用的结果恢复策略。当前实现没有声称已经解决这些问题。

十一、复核队列:状态是多维的,不能只用一个 done

一张图片可以同时具有多种事实:存在人工确认历史、当前草稿有新修订、后台又生成了一份建议,或者用户暂时把它标为疑难。

如果只有一个 status = done,很难表达这些组合。因此当前实现把模型任务状态、人工草稿版本、确认快照和复核标记分别保存,再计算界面需要的摘要状态。

例如 has_approved_snapshot 和 is_approved 就有区别。前者表示存在当前条件下可用的确认快照,后者还要求当前草稿版本与确认版本一致。用户确认后继续修改草稿时,可以保留上一份有效确认,同时把当前任务显示为有新修订。

复核标记包括正常、疑难、稍后和无效,还保存原因、备注及独立版本。恢复一个无效或稍后处理的样本,不会自动沿用旧确认,而是要求重新确认。

当前待复核优先级是可以解释的规则:人工疑难优先,其次是识别失败、结果不完整、点数与题意不符、不确定建议、过期建议等。它没有额外调用一个模型去生成难度评分。

界面中的“确认并下一张”依次完成:保存全部编辑、检查当前版本并确认、刷新队列、选择下一张。任何一步失败都停在当前图片。顺序点选在确认前检查点数与目标数量一致,单选检查只有一个点,同时要求标签和坐标满足确认条件。

键盘操作也围绕这个流程设计:方向键做原图像素微调,Shift 加方向键移动十个像素,快捷键确认下一张,裁剪缩略图帮助快速定位对象。输入框和输入法组合输入期间不抢占相应快捷键,避免编辑文字时误删或移动点位。

十二、导入与导出:文件格式也是数据协议的一部分

12.1 导入不能只检查文件后缀

当前支持文件夹、ZIP、多图片,以及 JSON/JSONL 标注清单。文件夹分片上传,ZIP 在后台解析,导入进度与逐项错误保存在任务记录中。

图片需要实际解码并检查大小与像素限制,再按方向规范化。ZIP 导入还检查路径穿越、绝对路径、符号链接、加密压缩包及解压后的总量限制。标注清单则检查图片尺寸和坐标空间,避免把一套缩放图片的标注静默套到另一套尺寸上。

重复导入同一路径的相同图片会跳过;同一路径对应不同内容会报错,不会直接替换用户已经编辑的图片。这样的行为让中断后继续追加导入有明确语义。

导入的既有标注会先成为待复核草稿。文件里有坐标,只能说明有人提供了标注,不能自动等同于当前工作台中的人工确认。

12.2 导出使用确认快照,而不是屏幕上的最后一份内容

当前导出会为每个任务查找与当前题目版本一致的最新人工确认快照,并排除不符合复核条件的样本。几个具体场景如下:

当前状态 导出行为
只有模型建议或未确认草稿 不导出
已确认版本 V1,随后只修改人工草稿 仍导出 V1,直到明确确认新版本
已确认后重新运行模型 保留既有确认快照,不由新建议替换
题目已经改变 旧题目的确认不再符合导出条件
标为疑难、稍后或无效 暂时排除
恢复正常但要求重新确认 重新确认后再进入导出

“继续修改草稿时仍导出之前的确认版”是一个需要明确告知用户的选择。它让未完成的编辑不会污染已认可的数据。如果旧版本身已经被发现错误,就应标记进入复核,或者完成新修订并确认,不能假设编辑动作已经自动撤销旧版的可用性。

ZIP 中包含图片和 annotations.jsonl。下面是示意记录,ID 和哈希均为说明用值,省略了部分字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "schema": "vlcaptcha-studio/v1",
  "kind": "click",
  "image": "images/task-example.png",
  "width": 320,
  "height": 160,
  "coordinate_space": "original_image_pixels",
  "question": "点击苹果",
  "selection_mode": "single",
  "points": [
    {
      "id": "p1",
      "label": "苹果",
      "x": 121,
      "y": 82,
      "candidate_set_id": "set-example",
      "candidate_id": "c1",
      "prediction_id": "prediction-example"
    }
  ],
  "annotation_revision": 2,
  "source": "human_confirmed"
}

完整导出还包含图片 SHA-256、原始名称、确认时间、数据集和示例标记等。内置示例有 demo_fixture 字段,后续做评测或训练时可以主动排除。

还要说明当前可追溯性的范围:导出包含来源 ID,但没有把所有候选集合和模型原始响应一并打包。要从 ID 继续追查完整模型过程,需要保留对应数据库。图片和 JSONL 足以交付标注数据,但不能独立替代整套项目审计记录。

十三、接入兼容模型时,保留协议的真实差别

项目最初固定让 Jev 参与候选判断,后续为了使用已有服务,又加入了通用 Chat Completions 兼容接口。两种适配器向业务层提供类似的候选选择结果,但请求和证据并不相同。

Jev 返回 Choice、Noul 等结构;兼容接口则按提示词返回 selected_ids、uncertain_ids 和简短原因。兼容适配器不会凭空生成 Jev 的概率或置信度,相关字段保留为空,并在记录中标明实际提供方。

无论用哪个适配器,应用前都必须检查候选 ID、重复选择、顺序缺失和数据版本。编辑器不必知道模型供应商的每个协议细节,只消费经过适配与检查的结果。

如果一个兼容服务实际支持图片输入,可以同时承担视觉阶段和判断阶段。这仍然是两个逻辑步骤,需要分别检查请求和输出,不能因为文本接口返回正常就推断看图能力也可用。

模型配置保存在后端,网页读取时只获得配置状态、地址和模型名称,不回显完整密钥。网页保存配置后新请求可以使用新设置;独立视觉模型配置也可以通过环境文件提供。

本地工作台并不意味着模型推理全部离线。在线模式下,图片发送给配置的视觉服务,结构化候选发送给配置的判断服务。恢复副本和标注导出不包含模型密钥,原始响应记录也需要继续避免混入认证头等内容。

当前使用的是固定提交的上游库代码,保留 MIT 许可及来源说明。外层实现负责 ID、校验、人工编辑与版本,不直接修改这份上游副本。这样升级上游时可以明确比较输入输出差异,也便于固定一份可复现的实现基线。

十四、用什么测试证明这些设计没有被破坏

14.1 先测数据行为,再测真实模型效果

模型输出有不确定性,付费接口也不适合成为每次本地回归的前置条件。因此测试分成几个层次。

协议测试使用模拟响应,检查请求形态、三种选择模式、非法 ID、概率、重复目标以及输出截断等问题。数据工作流测试使用临时数据库和真实的应用接口,检查版本与事务行为。前端会话测试控制请求返回顺序,检查迟到回复、重试和恢复是否覆盖新编辑。

这些测试最有价值的断言,是验证那些不能被破坏的规则。例如:

  • 已有人工点位时重跑模型,人工坐标与既有导出结果保持不变。
  • 一个编辑请求的候选版本冲突,题目和点位部分也不会被提交。
  • 保存已成功、回复丢失,再用同一请求 ID 重试,不新增修订。
  • 用户在保存期间继续编辑,旧回复只更新基线,不覆盖工作副本。
  • 局部结果预览生成后,候选、草稿和确认历史仍未变化。
  • 局部修正只移动指定点,保留其他点、标签与顺序。
  • 裁剪区域外的输出,即使能落在整图内,也必须拒绝。
  • 题目改变后,旧确认不能继续被当成当前题目的标注导出。

这些例子也比单纯断言“接口返回 200”更能说明系统是否可靠。浏览器验证则补充拖动、缩放、放大镜边缘显示、撤销和选区操作等交互问题。

14.2 五张真实图片揭示了什么

2026 年 9 月 27 日的真实测试使用了配置的兼容中转接口,模型标识为 deepseek-v4.1-flash。五张图片都完成视觉与候选判断,但人工检查发现:

任务 候选数 / 选中数 人工检查 完成耗时,含排队
蓝色圆形,单选 4 / 1 选中正确目标,中心偏上约 17 像素 9.32 秒
红色图形,多选 5 / 3 选中三个目标,中心偏移约 19–22 像素 7.44 秒
字母 A → B → C → D 6 / 4 顺序正确,中心偏移约 2–6 像素 6.74 秒
风景背景中的图标顺序 14 / 3 剃须刀与高脚杯混淆,落点偏移 13.85 秒
噪声背景中的飞机 11 / 1 选中了上方干扰图标,未命中目标 17.00 秒

第一张单独运行,其他四张用两个工作线程处理,后四张批次的总墙钟时间约 18.2 秒。表里的时间包含等待,不能直接拿来当作模型本身的推理延迟。

前三张是项目内置图形和字母图片,后两张是已有的复杂图标图片。位置比较使用人工检查参考区域和中心,没有建立像素级标准答案。这是一次帮助发现问题的小样本测试,不是准确率基准,更不能推导出“通用准确率为 60%”。

这次调用使用兼容服务,没有验证 Jev 的在线识别或判断质量;中转站的模型标识也不能单独证明底层模型身份和版本。

它实际帮助确定了下一轮功能优先级:类别混淆需要把裁剪图放到标签旁边,十几像素的偏差需要放大镜和键盘微调,局部漏检则需要独立识别区。这些功能来自已经观察到的问题。

14.3 失败也需要作为结果记录

接入过程中曾遇到图片请求被网关返回 HTTP 403。对照请求发现,同一配置下不同客户端标识表现不同,改用工作台自身标识后完整流程能够运行。这样的结果只能说明当时那条接入链路的兼容性问题得到处理,不能泛化为所有服务都应使用相同做法。

后续局部识别尝试又没有拿到有效候选,诊断请求捕获了中转站 HTTP 500。附带参考图与只发送裁剪图的应用内尝试均未成功,因此真实局部识别效果仍未验证通过。

界面和数据流程通过模拟测试,真实服务调用失败,这两个事实需要同时保留。错误提示也应区分服务异常、限流、超时、连接失败与数据解析失败,不能把所有失败都归因于密钥。

十五、接下来如何判断这套方案是否值得继续投入

下一阶段最有价值的工作,是建立固定样本和稳定的人工确认标准。只有这样,换模型、改提示词或加入局部识别之后,才能知道改动究竟解决了什么问题。

评测指标也应按阶段拆开。视觉阶段看对象是否漏检、类别是否混淆、中心和边框误差多大;判断阶段在固定候选上看是否选对、顺序是否正确;完整工作流则看有多少样本需要人工修改,平均修改几个点,完成一张确认标注要多久。

如果需要衡量定位质量,可以记录中心欧氏距离,也可以按图像对角线归一化以便比较不同尺寸。对于允许在对象内部任意点击的任务,还应检查落点是否位于有效区域。不同指标回答不同问题,不能仅用一个中心距离替代全部任务成功条件。

成本同样要区分两阶段。一次完整预标注包括视觉请求和判断请求;修改候选后只重跑判断,会减少重复视觉调用;局部识别则新增一次视觉成本。最终可以比较的是“每份人工确认样本的模型费用”和“每份样本的人工修正时间”。当前系统记录了部分模型返回的用量,但完整费用统计与固定样本评测尚未做成产品功能。

为了让历史实验更容易复现,后续还应明确保存提示词模板版本、适配器版本、模型参数及可确认的实际模型版本。当前已有 Git 提交、依赖锁、模型标识、候选版本及部分原始请求响应,但还没有形成独立完整的实验清单。尤其是视觉阶段,并没有独立持久化所有提示词版本信息,不能把现有记录夸大成完整复现实验平台。

其他后续工作包括同图多模型对比、更完善的调用统计,以及不同题型编辑器。多人协作则需要额外设计身份、权限、审核职责和数据隔离,不能只把当前服务监听地址改成公网地址就算完成。

对我来说,接下来的判断标准很具体:在同一批图片上,用户能否用更少的修正动作、更短的处理时间,得到一份来源清楚且明确确认的标注。视觉模型、Jev、编辑器和版本管理,都需要围绕这个标准继续改进。

资料与代码

本文依据 VLCaptcha Studio 0.2.0、2026 年 9 月 27 日开发与实测记录,以及 9 月 28 日整理时的代码撰写。正文中的请求、数据库结构和控制流程示例为说明关键机制而精简,具体字段与完整校验以实现为准。

本文作者:skuukzky
本文链接:https://lpy30m.github.io/skuukzky.github.io/2026/09/28/vlcaptcha-studio-design/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可