38 KiB
文档图片质量校验模块第3版接口与落地清单
1. 目标边界
本模块用于对案卷文档中的拍照图片、扫描图片、附件图片做“图片清晰度预警检测”。
本模块的边界固定如下:
- 与现有 OCR 抽取、评查主流程完全独立。
- 即使检测出图片模糊,也不能阻断上传、不能阻断 OCR、不能阻断评查。
- 结果只做预警展示和留痕,分三档:
pass:通过review:疑似模糊待人工确认reject:不通过需重拍
- 第一版优先支持:
- 图片文件
- PDF / 扫描 PDF
- 图片附件
- PDF 附件
doc/docx/wps第一版允许降级为“正文第 N 张图”定位,不强承诺精确页码。
2. 建议代码落点
2.1 后端目录
建议新增如下目录与文件:
fastapi_modules/fastapi_leaudit/domian/vo/imageQualityVo.pyfastapi_modules/fastapi_leaudit/services/imageQualityService.pyfastapi_modules/fastapi_leaudit/services/impl/imageQualityServiceImpl.pyfastapi_modules/fastapi_leaudit/controllers/imageQualityController.pyfastapi_modules/fastapi_leaudit/image_quality/tasks.pyfastapi_modules/fastapi_leaudit/image_quality/runner.pyfastapi_modules/fastapi_leaudit/image_quality/storage_adapter.pyfastapi_modules/fastapi_leaudit/image_quality/input_resolver.pyfastapi_modules/fastapi_leaudit/image_quality/extractors.pyfastapi_modules/fastapi_leaudit/image_quality/detector.pyfastapi_modules/fastapi_leaudit/image_quality/config_resolver.py
2.2 现有文件改动点
fastapi_modules/fastapi_leaudit/controllers/documentController.pyfastapi_modules/fastapi_leaudit/services/documentService.pyfastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.pyfastapi_modules/fastapi_leaudit/domian/vo/documentVo.pyfastapi_admin/config/_settings.pyfastapi_admin/config/__init__.pyi
3. 新增 VO 建议
按当前仓库 documentVo.py / auditVo.py / reviewPointVo.py 风格,建议单独新增 imageQualityVo.py。
3.1 配置相关 VO
from pydantic import BaseModel, Field
class ImageQualityConfigItemVO(BaseModel):
"""图片质量校验配置项。"""
id: int = Field(..., description="配置ID")
scopeType: str = Field(..., description="作用域类型:global/doc_type/region/doc_type_region")
documentTypeId: int | None = Field(None, description="文档类型ID")
region: str | None = Field(None, description="地区")
enabled: bool = Field(..., description="是否启用")
warnThreshold: float | None = Field(None, description="疑似模糊阈值")
rejectThreshold: float | None = Field(None, description="不通过阈值")
maxImagesPerDoc: int | None = Field(None, description="单文档最大检测图片数")
maxConcurrency: int | None = Field(None, description="单任务并发数")
updatedAt: str | None = Field(None, description="更新时间")
class ImageQualityConfigUpsertDTO(BaseModel):
"""图片质量校验配置新增/更新请求。"""
scopeType: str = Field(..., description="作用域类型")
documentTypeId: int | None = Field(None, description="文档类型ID")
region: str | None = Field(None, description="地区")
enabled: bool = Field(..., description="是否启用")
warnThreshold: float | None = Field(None, description="疑似模糊阈值")
rejectThreshold: float | None = Field(None, description="不通过阈值")
maxImagesPerDoc: int | None = Field(None, description="单文档最大检测图片数")
maxConcurrency: int | None = Field(None, description="单任务并发数")
3.2 明细与摘要相关 VO
class ImageQualityItemVO(BaseModel):
"""单张图片质检明细。"""
itemId: int = Field(..., description="明细ID")
runId: int = Field(..., description="质检运行ID")
documentId: int = Field(..., description="文档ID")
documentFileId: int | None = Field(None, description="文档文件ID")
sourceKind: str = Field(..., description="来源类型")
sourceFileName: str | None = Field(None, description="来源文件名")
sourcePageNum: int | None = Field(None, description="来源页码")
imageIndexInPage: int | None = Field(None, description="页内图片序号")
imageIndexInFile: int | None = Field(None, description="文件内图片序号")
bbox: dict | list | None = Field(None, description="图片定位框")
qualityStatus: str = Field(..., description="pass/review/reject")
qualityScore: float | None = Field(None, description="清晰度分值")
reasonCode: str | None = Field(None, description="原因编码")
reasonText: str | None = Field(None, description="原因说明")
cropOssUrl: str | None = Field(None, description="裁剪图OSS地址")
displayText: str | None = Field(None, description="展示文案,例如第12页第1张图片模糊")
class ImageQualitySummaryVO(BaseModel):
"""文档图片质检摘要。"""
runId: int | None = Field(None, description="最新运行ID")
runStatus: str | None = Field(None, description="queued/running/completed/failed/skipped")
summaryStatus: str | None = Field(None, description="pass/review/reject")
skipReason: str | None = Field(None, description="跳过原因")
totalImages: int = Field(0, description="总图片数")
passCount: int = Field(0, description="通过数")
reviewCount: int = Field(0, description="待人工确认数")
rejectCount: int = Field(0, description="需重拍数")
warningText: str | None = Field(None, description="摘要提示文案")
finishedAt: str | None = Field(None, description="完成时间")
class ImageQualityDetailVO(BaseModel):
"""文档图片质检详情。"""
summary: ImageQualitySummaryVO = Field(..., description="质检摘要")
items: list[ImageQualityItemVO] = Field(default_factory=list, description="问题图片列表")
3.3 批量状态与重检相关 VO
class ImageQualityStatusItemVO(BaseModel):
"""文档图片质检状态项。"""
documentId: int = Field(..., description="文档ID")
runId: int | None = Field(None, description="最新运行ID")
runStatus: str | None = Field(None, description="运行状态")
summaryStatus: str | None = Field(None, description="摘要状态")
rejectCount: int = Field(0, description="需重拍数")
reviewCount: int = Field(0, description="待确认数")
updatedAt: str | None = Field(None, description="更新时间")
class ImageQualityRecheckVO(BaseModel):
"""手工重检响应。"""
runId: int = Field(..., description="新质检运行ID")
documentId: int = Field(..., description="文档ID")
status: str = Field(..., description="queued")
message: str = Field("", description="提示信息")
4. 对现有 Document VO 的扩展建议
建议直接扩充 documentVo.py,这样上传页、列表页、详情页可以少开新接口或少拼数据。
4.1 DocumentUploadVO 增加字段
imageQualityEnabled: bool = Field(False, description="当前文档是否启用图片质量校验")
imageQualityRunId: int | None = Field(None, description="图片质量校验运行ID")
imageQualityRunStatus: str | None = Field(None, description="图片质量校验运行状态")
imageQualitySummaryStatus: str | None = Field(None, description="图片质量校验摘要状态")
4.2 DocumentStatusItemVO 增加字段
imageQualityRunId: int | None = Field(None, description="图片质量校验运行ID")
imageQualityRunStatus: str | None = Field(None, description="图片质量校验运行状态")
imageQualitySummaryStatus: str | None = Field(None, description="图片质量校验摘要状态")
imageQualityRejectCount: int = Field(0, description="需重拍图片数")
imageQualityReviewCount: int = Field(0, description="待人工确认图片数")
4.3 DocumentListItemVO 增加字段
imageQualityRunId: int | None = Field(None, description="图片质量校验运行ID")
imageQualityRunStatus: str | None = Field(None, description="图片质量校验运行状态")
imageQualitySummaryStatus: str | None = Field(None, description="图片质量校验摘要状态")
imageQualityIssueCount: int = Field(0, description="问题图片数")
imageQualityWarningText: str | None = Field(None, description="图片质量提示文案")
4.4 DocumentDetailVO 增加字段
imageQualitySummary: ImageQualitySummaryVO | None = Field(None, description="图片质量摘要")
5. 新增 Service 接口签名
建议新建 services/imageQualityService.py,不要污染 IDocumentService 主职责。
from abc import ABC, abstractmethod
from fastapi_modules.fastapi_leaudit.domian.vo.imageQualityVo import (
ImageQualityConfigItemVO,
ImageQualityConfigUpsertDTO,
ImageQualityDetailVO,
ImageQualityRecheckVO,
ImageQualityStatusItemVO,
ImageQualitySummaryVO,
)
class IImageQualityService(ABC):
"""图片质量校验服务接口。"""
@abstractmethod
async def DispatchForDocument(
self,
DocumentId: int,
TriggerUserId: int | None = None,
Force: bool = False,
Speed: str = "normal",
) -> ImageQualityRecheckVO | None:
"""按文档触发图片质量校验任务。"""
...
@abstractmethod
async def GetDocumentSummary(
self,
CurrentUserId: int,
DocumentId: int,
) -> ImageQualitySummaryVO:
"""获取文档图片质检摘要。"""
...
@abstractmethod
async def GetDocumentDetail(
self,
CurrentUserId: int,
DocumentId: int,
) -> ImageQualityDetailVO:
"""获取文档图片质检详情。"""
...
@abstractmethod
async def GetDocumentsStatus(
self,
CurrentUserId: int,
Ids: list[int],
) -> list[ImageQualityStatusItemVO]:
"""批量获取文档图片质检状态。"""
...
@abstractmethod
async def RecheckDocument(
self,
CurrentUserId: int,
DocumentId: int,
Speed: str = "normal",
) -> ImageQualityRecheckVO:
"""手工重跑文档图片质量校验。"""
...
@abstractmethod
async def ListConfigs(self) -> list[ImageQualityConfigItemVO]:
"""获取图片质量校验配置列表。"""
...
@abstractmethod
async def UpsertConfig(self, Body: ImageQualityConfigUpsertDTO) -> ImageQualityConfigItemVO:
"""新增或更新图片质量校验配置。"""
...
@abstractmethod
async def DeleteConfig(self, Id: int) -> None:
"""删除图片质量校验配置。"""
...
6. 对现有 DocumentService 的接入点建议
建议只在现有 IDocumentService / DocumentServiceImpl 中补“触发点”,不要把查询逻辑也塞进去。
6.1 DocumentServiceImpl.Upload
在上传主文件和附件落库完成后,补一段:
await self.ImageQualityService.DispatchForDocument(
DocumentId=document.Id,
TriggerUserId=CreatedBy,
Force=False,
Speed=Speed,
)
要求:
- try/except 包裹
- 失败只记 warning log
- 不影响现有上传成功返回
6.2 DocumentServiceImpl.AppendAttachments
在追加附件成功后,补一段:
await self.ImageQualityService.DispatchForDocument(
DocumentId=Id,
TriggerUserId=CurrentUserId,
Force=True,
Speed="normal",
)
原因:
- 附件变化后,图片质检结果已过期
- 需要按最新附件重新跑
7. Controller 路由建议
建议新建 controllers/imageQualityController.py,风格保持与 documentController.py / auditController.py 一致。
7.1 文档级路由
@self.router.get("/documents/{DocumentId}/image-quality/summary", response_model=Result[ImageQualitySummaryVO])
async def GetDocumentImageQualitySummary(
DocumentId: int,
payload: dict[str, Any] = Depends(verify_access_token),
):
"""获取单个文档图片质量校验摘要。"""
@self.router.get("/documents/{DocumentId}/image-quality", response_model=Result[ImageQualityDetailVO])
async def GetDocumentImageQualityDetail(
DocumentId: int,
payload: dict[str, Any] = Depends(verify_access_token),
):
"""获取单个文档图片质量校验详情。"""
@self.router.post("/documents/{DocumentId}/image-quality/recheck", response_model=Result[ImageQualityRecheckVO])
async def RecheckDocumentImageQuality(
DocumentId: int,
speed: str = Form("normal", description="执行速度档位:urgent/normal"),
payload: dict[str, Any] = Depends(verify_access_token),
):
"""手工重跑单个文档图片质量校验。"""
7.2 批量状态路由
@self.router.get("/documents/image-quality/status", response_model=Result[list[ImageQualityStatusItemVO]])
async def GetDocumentsImageQualityStatus(
ids: str = Query(..., description="逗号分隔的文档ID列表"),
payload: dict[str, Any] = Depends(verify_access_token),
):
"""批量获取文档图片质量校验状态。"""
7.3 配置管理路由
如果第一期不做后台配置页,可以先只保留 service,不先开放 controller。
如果要开接口,建议:
@self.router.get("/v3/image-quality/configs", response_model=Result[list[ImageQualityConfigItemVO]])
async def ListImageQualityConfigs():
"""获取图片质量校验配置。"""
@self.router.post("/v3/image-quality/configs", response_model=Result[ImageQualityConfigItemVO])
async def UpsertImageQualityConfig(Body: ImageQualityConfigUpsertDTO):
"""新增或更新图片质量校验配置。"""
@self.router.delete("/v3/image-quality/configs/{ConfigId}", response_model=Result[None])
async def DeleteImageQualityConfig(ConfigId: int):
"""删除图片质量校验配置。"""
8. Celery 任务与函数签名建议
新增文件:fastapi_modules/fastapi_leaudit/image_quality/tasks.py
def resolve_image_quality_queue(speed: str = "normal") -> str:
"""根据优先级返回图片质量校验队列名。"""
def dispatch_image_quality_task(
run_id: int,
*,
speed: str = "normal",
trigger_user_id: int | None = None,
) -> Any:
"""投递图片质量校验任务。"""
@celery_app.task(
bind=True,
name="leaudit.image_quality.process_document",
acks_late=True,
)
def image_quality_process_document_task(
self,
run_id: int,
trigger_user_id: int | None = None,
) -> dict[str, Any]:
"""Celery worker 入口。"""
新增文件:fastapi_modules/fastapi_leaudit/image_quality/runner.py
class ImageQualityRunner:
"""图片质量校验执行器。"""
async def Execute(
self,
RunId: int,
TriggerUserId: int | None = None,
) -> dict[str, Any]:
"""执行一次完整的图片质量校验。"""
9. SQL 草案文件命名建议
按你当前仓库习惯,建议直接放到:
scripts/创建sql/
建议拆 3 个 SQL 草案文件。
9.1 表结构 SQL
文件名:
scripts/创建sql/schema_add_image_quality_module.sql
内容范围:
leaudit_image_quality_configsleaudit_image_quality_runsleaudit_image_quality_items- 索引
- 唯一约束
- 软删除字段
9.2 权限点 SQL
文件名:
scripts/创建sql/seed_image_quality_permissions.sql
内容范围:
- 图片质量校验查询权限
- 图片质量校验重检权限
- 图片质量配置管理权限
9.3 路由/入口 SQL
文件名:
scripts/创建sql/seed_image_quality_routes.sql
内容范围:
- 新增接口路由资源
- 如需配置后台菜单,再补菜单项
10. 建议表结构草案
10.1 leaudit_image_quality_configs
建议字段:
id BIGSERIAL PRIMARY KEYscope_type VARCHAR(32) NOT NULLdocument_type_id BIGINT NULLregion VARCHAR(64) NULLenabled BOOLEAN NOT NULL DEFAULT TRUEwarn_threshold NUMERIC(10,4) NULLreject_threshold NUMERIC(10,4) NULLmax_images_per_doc INTEGER NULLmax_concurrency INTEGER NULLcreated_at TIMESTAMP NOT NULL DEFAULT NOW()updated_at TIMESTAMP NOT NULL DEFAULT NOW()deleted_at TIMESTAMP NULL
建议索引:
idx_leaudit_image_quality_configs_scopeidx_leaudit_image_quality_configs_doc_typeidx_leaudit_image_quality_configs_region
10.2 leaudit_image_quality_runs
建议字段:
id BIGSERIAL PRIMARY KEYdocument_id BIGINT NOT NULLdocument_file_id BIGINT NULLstatus VARCHAR(32) NOT NULL DEFAULT 'queued'skip_reason VARCHAR(64) NULLsummary_status VARCHAR(32) NULLtotal_images INTEGER NOT NULL DEFAULT 0pass_count INTEGER NOT NULL DEFAULT 0review_count INTEGER NOT NULL DEFAULT 0reject_count INTEGER NOT NULL DEFAULT 0task_id VARCHAR(255) NULLerror_message TEXT NULLstarted_at TIMESTAMP NULLfinished_at TIMESTAMP NULLcreated_by BIGINT NULLcreated_at TIMESTAMP NOT NULL DEFAULT NOW()updated_at TIMESTAMP NOT NULL DEFAULT NOW()deleted_at TIMESTAMP NULL
建议索引:
idx_leaudit_image_quality_runs_document_ididx_leaudit_image_quality_runs_statusidx_leaudit_image_quality_runs_document_created_at
10.3 leaudit_image_quality_items
建议字段:
id BIGSERIAL PRIMARY KEYrun_id BIGINT NOT NULLdocument_id BIGINT NOT NULLdocument_file_id BIGINT NULLsource_kind VARCHAR(64) NOT NULLsource_file_name VARCHAR(255) NULLsource_page_num INTEGER NULLimage_index_in_page INTEGER NULLimage_index_in_file INTEGER NULLbbox_json JSONB NULLimage_key VARCHAR(255) NULLparent_image_key VARCHAR(255) NULLcrop_oss_url TEXT NULLquality_status VARCHAR(32) NOT NULLquality_score NUMERIC(10,4) NULLreason_code VARCHAR(64) NULLreason_text TEXT NULLextra_json JSONB NULLcreated_at TIMESTAMP NOT NULL DEFAULT NOW()updated_at TIMESTAMP NOT NULL DEFAULT NOW()deleted_at TIMESTAMP NULL
建议索引:
idx_leaudit_image_quality_items_run_ididx_leaudit_image_quality_items_document_ididx_leaudit_image_quality_items_quality_statusidx_leaudit_image_quality_items_source_page_num
11. 配置项建议
在 fastapi_admin/config/_settings.py 的 LeauditSettings 增加:
LEAUDIT_IMAGE_QUALITY_ENABLED: bool = False
LEAUDIT_IMAGE_QUALITY_WARN_THRESHOLD: float = 0.45
LEAUDIT_IMAGE_QUALITY_REJECT_THRESHOLD: float = 0.30
LEAUDIT_IMAGE_QUALITY_MAX_IMAGES_PER_DOC: int = 80
LEAUDIT_IMAGE_QUALITY_MAX_CONCURRENCY: int = 4
LEAUDIT_IMAGE_QUALITY_QUEUE_NORMAL: str = "leaudit.image_quality.normal"
LEAUDIT_IMAGE_QUALITY_QUEUE_URGENT: str = "leaudit.image_quality.urgent"
LEAUDIT_IMAGE_QUALITY_TIMEOUT: int = 120
并同步补到 fastapi_admin/config/__init__.pyi。
12. 前端接口命名建议
建议前端 API 文件新增:
legal-platform-frontend/lib/api/legacy/files/image-quality.ts
建议函数:
export async function getDocumentImageQualitySummary(documentId: number)
export async function getDocumentImageQualityDetail(documentId: number)
export async function getDocumentsImageQualityStatus(ids: number[])
export async function recheckDocumentImageQuality(documentId: number, speed?: string)
13. 第一版实施顺序
建议按下面顺序做:
- 先补 SQL 与后端表模型
- 再补
imageQualityVo.py - 再补
IImageQualityService / ImageQualityServiceImpl - 再补
tasks.py / runner.py - 再在
DocumentServiceImpl.Upload / AppendAttachments挂触发 - 再补
imageQualityController.py - 最后接前端上传页、列表页、详情页
14. 结论
按当前仓库风格,这个模块最稳妥的落地方式是:
- 新开独立
VO + Service + Controller + Celery task + SQL - 只在
DocumentServiceImpl做触发,不侵入现有AuditServiceImpl - 对外暴露文档级摘要、明细、批量状态、手工重检四类接口
- SQL 先拆成结构、权限、路由三个脚本,方便分阶段上线
这个拆法能保证图片质量校验模块后续可单独演进,不会把现有 OCR / 评查主链路拖乱。
15. 页码定位策略修正版
这一节用于修正第一版里对 doc/docx/wps 页码定位的表述,避免研发误以为可以直接靠当前 python-docx 兜底逻辑拿到真实页码。
15.1 现有 OCR / 评查链路里的页码定位是怎么来的
当前系统里,评查详情页能展示字段页码、部分情况下还能做定位高亮,主来源并不是“原始 Word 文档页码”,而是下面两层能力:
- 第一层:OCR/归一化结果自带页结构
ocr_result.pages[].page_num- OCR chunk 的
bbox field_positions里的pageNum / bbox / matchPosition
- 第二层:详情页兜底文本匹配
- 对 PDF 重新逐页抽文本,再把字段文本匹配回页码
- 这是兜底逻辑,不是主定位来源
也就是说,当前评查模块“真正稳定可复用”的页码语义,主要来自 OCR / normalized document,而不是详情页自己的回推逻辑。
15.2 当前项目里 docx 的兜底页码能力并不是真分页
当前 documentServiceImpl.py 里的 _extract_page_texts_from_docx() 实现,实际是:
- 读取段落和表格文本
- 拼成整篇文档文本
- 最终只返回
[(1, text)]
这意味着:
- 当前详情页兜底逻辑对
docx并没有真实分页能力 - 它只是把整篇文档退化成“第 1 页”
- 因此不能把这条逻辑当成
docx图片定位的基础
结论要固定为:
- 当前系统里
docx不是完全不能定位 - 但不能依赖现有
python-docx兜底逻辑做精确页码 - 如果要把
docx图片定位做准,必须优先复用 OCR / 归一化后的页结构
15.3 图片质量模块对 docx 的正确做法
图片质量模块在处理 doc/docx/wps 时,页码定位策略建议按优先级分层:
- 优先复用 OCR 结果中的页结构和视觉对象定位
- 其次复用
visual_manifest中已有的视觉对象元数据 - 如果 OCR 已能给出该图片或父图像所在页,则直接采用该页码
- 如果拿不到稳定页码,则降级为“正文第 N 张图”或“附件第 N 张图”
推荐优先使用的字段包括:
ocr_result.pages[].page_numocr_result.visual_manifestvisual_manifest中对象的page_numvisual_manifest中对象的bboximage_key / parent_image_key
这样做的本质是:
- 不自己重新发明 Word 分页
- 而是复用现有 OCR pipeline 已经归一化出来的页概念
15.4 为什么图片质量模块不能直接复用详情页 docx 兜底逻辑
因为那条逻辑只适合“文本字段没有页码时,尽量补一个最小可用页码”,不适合图片质量场景。
图片质量场景要解决的是:
- 哪一张图片模糊
- 它在第几页
- 它在页内第几张
- 最好还能给出 bbox 或裁剪图
而当前 docx 兜底逻辑没有:
- 真分页
- 图片级索引
- 页内图片序号
- 图片 bbox
所以如果直接复用它,只会得到一个看似有页码、其实不可用的假定位。
15.5 图片质量模块的页码定位分级策略
建议在方案里明确写死:
PDF / 扫描PDF / 图片文件 / 图片附件 / PDF附件- 第一优先级支持精确定位
- 目标输出:
source_page_num + image_index_in_page + bbox
doc/docx/wps- 第一优先级复用 OCR / visual_manifest 的页码与 bbox
- 第二优先级降级为
image_index_in_file - 第一版不承诺所有 Office 文档都能稳定精确到预览页码
也就是说,docx 可以做定位,但实现前提不是当前 python-docx 的退化逻辑,而是 OCR 归一化之后的页结构。
15.6 对图片质量模块的实现修正
基于上面的分析,图片质量模块实现时建议补一个约束:
- 对
doc/docx/wps,图片抽取和质量判断可以独立做 - 但页码回指优先放在“抽图后结合 OCR visual metadata 回填”这一层完成
- 不允许直接调用当前
_extract_page_texts_from_docx()来生成图片页码
更具体地说:
extractors.py负责抽图和生成图片级唯一索引detector.py负责清晰度三档判断runner.py在汇总结果前,优先尝试将图片索引和 OCR/visual_manifest 做关联- 关联成功则写入
source_page_num / bbox - 关联失败则写入
image_index_in_file,前端展示“正文第 N 张图”
15.7 最终结论
本项目当前“评查页码定位”对 docx 的可用性,主要来自 OCR 归一化页结构,而不是 python-docx 的原生分页能力。
因此文档图片质量校验模块如果要把 docx 也做准,正确方向是:
- 参考现有 OCR / 评查流程的页结构设计
- 复用
ocr_result.pages / visual_manifest / bbox / image_key - 避免把当前
docx文本兜底逻辑误当成图片级定位能力
这一点在后续实施时应作为明确技术约束,不建议再走“直接解析 Word 文本推页码”的路线。
16. 图片索引表与 OCR visual_manifest 关联策略
这一节用于把图片质量模块里最关键的“抽图记录如何回绑到 OCR 页码与 bbox”讲清楚,尤其是 doc/docx/wps 场景。
16.1 为什么必须做关联层
图片质量模块自己的 extractors.py 会先把图片从原始文档里抽出来,并落到 leaudit_image_quality_items。
但抽图本身只能保证下面这些信息:
- 这张图片来自哪个文档文件
- 它是主文件还是附件
- 它是文件内第几张图
- 它在抽图阶段的原始宽高、字节、hash
仅靠这些信息,还不够支持前端稳定回指到:
- 第几页
- 页内第几张
- 具体 bbox
所以必须增加一层“图片索引表与 OCR visual metadata 的关联层”,把抽图记录和 OCR/visual_manifest 里的页码、坐标、父图像标识打通。
16.2 建议增加的落库字段
在 leaudit_image_quality_items 里,建议补充或明确以下字段:
image_sha256- 抽图后二进制 hash
image_width- 抽图宽度
image_height- 抽图高度
source_ext- 来源文件扩展名
ocr_page_num- 关联到 OCR 后得到的页码
ocr_bbox_json- 关联到 OCR 后得到的 bbox
ocr_image_key- 关联到 OCR visual object 的 image_key
ocr_parent_image_key- 关联到 OCR visual object 的 parent_image_key
ocr_match_mode- 关联命中方式
ocr_match_score- 关联命中置信度
说明:
source_page_num表示模块自己理解的来源页码ocr_page_num表示从 OCR / visual_manifest 里回填的页码- 第一版前端展示时,优先取
ocr_page_num,取不到再回退到source_page_num
16.3 关联优先级建议
建议在 runner.py 里做一段统一的关联流程,优先级从高到低如下:
image_key / parent_image_key直接命中- 图片二进制 hash 命中
- 图片尺寸 + 裁剪相似度命中
- 页级候选范围内按 bbox / area 比例匹配
- 全部失败则降级为文件内序号定位
这套顺序的原因是:
image_key / parent_image_key一旦能命中,最稳定- hash 命中次稳,但前提是 OCR side 和抽图 side 用的是同一图像字节
- 尺寸和相似度可以做弱匹配
- bbox/area 更适合已经知道页候选时做局部定位
16.4 各类文件的推荐关联策略
16.4.1 PDF / 扫描PDF / PDF附件
推荐策略:
- 抽图阶段直接记录
source_page_num - OCR 若返回同页 visual object,则用页内 bbox 做二次确认
- 最终定位以
source_page_num + image_index_in_page + bbox为准
这一类文件通常不需要过度依赖 visual_manifest,因为自身抽图时就有稳定页概念。
16.4.2 图片文件 / 图片附件
推荐策略:
source_page_num固定记为1image_index_in_page = 1- 若 OCR 输出 visual object,则仅作为 bbox 或
image_key补充
这类文件定位最简单,本身就是单页单图。
16.4.3 doc/docx/wps
推荐策略:
- 抽图阶段只保证:
image_index_in_fileimage_sha256image_width / image_heightsource_file_name
- 页码不要在抽图阶段硬算
- 等 OCR/visual_manifest 结果出来后,再做回填
这一类文件的关联优先级建议是:
parent_image_key命中image_key命中- hash 命中
- 尺寸与内容相似度命中
- 命不中则只展示“正文第 N 张图”
16.5 建议的 ocr_match_mode 枚举
建议在 ocr_match_mode 里固定以下值,方便后续排查:
parent_image_key_exactimage_key_exactimage_sha256_exactsize_similarity_matchbbox_overlap_matchpage_candidate_matchfile_index_fallbackno_match
这样后面如果业务反馈“页码不准”,可以直接按命中模式排查是哪一层出了问题。
16.6 visual_manifest 关联的实现建议
建议把这块逻辑放在 image_quality/runner.py 或单独的 image_quality/locator.py 中,职责清晰一点:
extractors.py- 只负责抽图
- 不负责猜页码
detector.py- 只负责判断图片清晰度
locator.py或runner.py- 负责把图片索引记录与 OCR
visual_manifest做关联 - 回填
ocr_page_num / ocr_bbox_json / ocr_match_mode
- 负责把图片索引记录与 OCR
也就是说,页码定位不是抽图逻辑的一部分,而是“抽图后 + OCR结果可用后”的补全逻辑。
16.7 为什么建议先落图片索引,再做关联
因为如果把“抽图、关联、检测”混在一起,会有两个问题:
- 当 OCR 结果未就绪时,图片质量模块会被迫等待主流程
- 当 OCR side 结构调整时,图片质量模块会被连带影响
正确拆法是:
- 先独立抽图并持久化图片索引
- 独立跑清晰度检测
- 如果 OCR 结果已就绪,则补做 visual_manifest 关联
- 如果 OCR 结果尚未就绪,则先以弱定位展示,后续可异步补齐
这样既保持了两条 pipeline 独立,又能最大化复用 OCR 页结构。
16.8 页面展示的取值优先级
前端展示“第几页/第几张图”时,建议按下面顺序取值:
ocr_page_num + image_index_in_pagesource_page_num + image_index_in_pageimage_index_in_file- 仅展示来源文件名
示例文案:
主文件第 12 页第 1 张图片模糊附件《现场照片2.pdf》第 3 页第 2 张图片疑似模糊正文第 5 张内嵌图片模糊附件《取证照片.jpg》图片模糊
16.9 对第一版实施的建议收口
为了控制复杂度,第一版建议这样收:
PDF / 扫描PDF / 图片文件 / 图片附件 / PDF附件- 做强定位
doc/docx/wps- 先把 OCR
visual_manifest关联能力做出来 - 关联成功就展示页码
- 关联失败就降级到“正文第 N 张图”
- 先把 OCR
不要第一版就要求所有 Word/WPS 内嵌图都 100% 精确到预览页码,否则开发成本和误判风险都会明显上升。
16.10 最终结论
图片质量模块能不能把 docx 场景做准,关键不在“能不能把图抽出来”,而在“抽出来以后,能不能与 OCR visual metadata 建立稳定关联”。
所以第一版技术路线应明确为:
- 先做图片索引表
- 再做 visual_manifest 关联层
- 最后才是页码/坐标展示
这比直接依赖 python-docx 或手工猜 Word 分页要稳得多,也更贴合当前项目已有的 OCR / 评查体系。
17. extractors.py 职责拆分与 runner.py 执行时序
这一节用于把模块内部分层再落细一点,方便研发直接按文件拆任务。
17.1 模块职责分层建议
建议图片质量模块按下面几层拆,不要把所有逻辑堆在一个 service 或一个 task 文件里。
17.1.1 input_resolver.py
职责:
- 读取文档主文件与附件
- 统一返回原始文件输入列表
- 不做抽图
- 不做检测
- 不做 OCR 关联
建议输出结构:
document_iddocument_file_idfile_rolefile_namefile_extmime_typesource_typesource_pathfile_bytes
17.1.2 extractors.py
职责:
- 针对不同文件类型抽图
- 生成图片级索引记录
- 记录图片最基础元数据
- 不负责模糊判断
- 不负责最终页码回指策略
建议内部再拆为:
extract_images_from_pdf()extract_images_from_image_file()extract_images_from_docx()extract_images_from_attachment()persist_image_index_items()
建议统一输出的数据结构:
documentIddocumentFileIdsourceKindsourceFileNamesourcePageNumimageIndexInPageimageIndexInFileimageSha256imageWidthimageHeightimageBytesextraJson
其中:
PDF / 扫描PDF / 图片文件 / 图片附件可以在这里直接给出sourcePageNumdoc/docx/wps这里不强行给精确页码,允许先留空
17.1.3 detector.py
职责:
- 对抽出的图片执行清晰度检测
- 返回三档结果
- 输出原因码和说明
- 不负责页码定位
建议统一返回:
qualityStatusqualityScorereasonCodereasonText
建议原因码先收敛为有限集合:
blur_detectedlow_resolutionover_exposureunder_exposuremotion_blurtext_unreadabledetector_timeoutdetector_failed
17.1.4 locator.py
职责:
- 读取 OCR / visual_manifest
- 把图片索引记录与 visual object 做关联
- 回填
ocr_page_num / ocr_bbox_json / ocr_match_mode - 不负责清晰度打分
如果第一期不想单独开 locator.py,也可以先内聚到 runner.py,但职责概念上仍然建议独立。
17.1.5 storage_adapter.py
职责:
- 创建 run
- 批量写入 image items
- 更新图片检测结果
- 更新关联结果
- 汇总 run 主状态
不要把业务判断写进这里,这里只做存取。
17.2 extractors.py 建议的实现规则
17.2.1 PDF 抽图规则
建议:
- 按页遍历
- 同页内按出现顺序编号
image_index_in_page - 全文件维度编号
image_index_in_file - 能拿到 bbox 就记录 bbox
- 原始图片字节计算
sha256
最终目标:
- PDF 在抽图阶段就拿到强定位能力
17.2.2 图片文件抽图规则
建议:
- 单文件直接视为 1 页 1 图
sourcePageNum = 1imageIndexInPage = 1imageIndexInFile = 1
17.2.3 doc/docx/wps 抽图规则
建议:
- 只抽出内嵌图片
- 保证文件内顺序稳定
- 第一版不在这里做页码推断
- 重点保留:
image_sha256image_widthimage_heightimage_index_in_file
原因:
- 这类文件的难点不在抽图,而在“抽图后如何挂回 OCR 页结构”
17.2.4 附件抽图规则
建议:
- 每个附件独立视为一个来源文件
- 不要沿用主文件页码空间
source_file_name必填- 前端展示时允许出现:
附件《xxx.pdf》第 2 页第 1 张图片模糊附件《xxx.jpg》图片模糊
17.3 runner.py 建议执行时序
建议整条图片质量任务按下面时序执行:
- 创建或确认
image_quality_run - 更新 run 状态为
running - 调用
input_resolver.py读取主文件与附件 - 调用
extractors.py抽图 - 将抽图结果落表
leaudit_image_quality_items - 判断是否无图
- 若无图则 run 标记
skipped/no_images - 若有图则进入并发检测
- 调用
detector.py批量检测,回写每条 item 的质量结果 - 尝试读取 OCR / visual_manifest
- 若可读,则做
locator关联并回填页码/bbox - 汇总
pass/review/reject计数 - 更新 run 的
summary_status - 标记 run
completed或partial_failed
17.4 推荐时序图口径
可以把执行理解成下面这条线:
- 上传成功
- 文档主流程正常投递 OCR/评查
- 图片质量任务独立投递
- 图片质量任务先抽图、先检测
- OCR 若先完成,则图片质量任务顺手做 visual_manifest 关联
- OCR 若未完成,则图片质量任务先给弱定位结果
- 后续可通过补偿任务把弱定位升级成强定位
这意味着:
- 图片质量任务不依赖 OCR 主任务完成后才能启动
- 但它可以在 OCR 结果可用时变得更准
17.5 是否需要补偿任务
建议保留“可选补偿任务”设计,但第一版不强求一定实现。
补偿任务适合处理:
- 图片质检先完成,但 OCR 尚未完成
- 首次只拿到了
image_index_in_file - OCR 完成后再补齐
ocr_page_num / bbox
如果要做,建议命名:
leaudit.image_quality.relocate_by_ocr
但第一版也可以先不单独开 task,而是在详情查询时做一次懒补偿,或者在 OCR 主流程结束后顺手触发一次回填。
17.6 runner.py 的失败处理原则
建议明确:
- 抽图失败:
- 当前 run 标记
failed - 不影响文档主流程
- 当前 run 标记
- 单张图片检测失败:
- 当前 item 标记失败原因
- run 最终允许为
partial_failed
- OCR 关联失败:
- 不算质检失败
- 只算定位降级
- 全部图片检测完成但都无法定位页码:
- run 仍可
completed - 只是展示层回退到“第 N 张图”
- run 仍可
也就是说,质量检测失败和定位失败要分开统计,不能混成一类。
17.7 推荐的汇总规则
建议 runner.py 最终汇总时按下面规则判断:
- 只要存在至少 1 张
reject,summary_status = reject - 否则只要存在至少 1 张
review,summary_status = review - 否则若全部
pass,summary_status = pass - 若任务本身没跑起来,则
status = failed - 若任务跑完但部分图片检测失败,则
status = partial_failed - 若命中无图、纯文本、开关关闭,则
status = skipped
17.8 第一版最小可交付实现
为了避免第一版过重,建议把可交付标准压到下面这一级:
extractors.py- 先支持 PDF、图片、docx 内嵌图、附件
detector.py- 先给出稳定三档判断
runner.py- 先能完成抽图、检测、汇总
locator- 先支持 OCR
visual_manifest存在时的强关联 - 不存在时允许降级
- 先支持 OCR
只要能做到:
- 不影响主流程
- 能把问题图片列出来
- 大多数 PDF/图片类文档能精确到页
docx至少能稳定定位到“正文第 N 张图”
第一版就已经是可上线状态。
17.9 最终建议
研发拆任务时,建议直接按下面方式分:
- A 同学:
SQL + VO + Controller + Service - B 同学:
input_resolver + extractors - C 同学:
detector + runner - D 同学:
locator / OCR visual_manifest 关联 - 前端同学:
上传页 + 列表页 + 详情页展示
这样拆分写入面冲突最小,也最贴合当前仓库结构。