Files
leaudit-platform-backend/docs/文档图片质量校验模块/文档图片质量校验模块第3版接口与落地清单.md
T

38 KiB
Raw Blame History

文档图片质量校验模块第3版接口与落地清单

1. 目标边界

本模块用于对案卷文档中的拍照图片、扫描图片、附件图片做“图片清晰度预警检测”。

本模块的边界固定如下:

  • 与现有 OCR 抽取、评查主流程完全独立。
  • 即使检测出图片模糊,也不能阻断上传、不能阻断 OCR、不能阻断评查。
  • 结果只做预警展示和留痕,分三档:
    • pass:通过
    • review:疑似模糊待人工确认
    • reject:不通过需重拍
  • 第一版优先支持:
    • 图片文件
    • PDF / 扫描 PDF
    • 图片附件
    • PDF 附件
  • doc/docx/wps 第一版允许降级为“正文第 N 张图”定位,不强承诺精确页码。

2. 建议代码落点

2.1 后端目录

建议新增如下目录与文件:

  • fastapi_modules/fastapi_leaudit/domian/vo/imageQualityVo.py
  • fastapi_modules/fastapi_leaudit/services/imageQualityService.py
  • fastapi_modules/fastapi_leaudit/services/impl/imageQualityServiceImpl.py
  • fastapi_modules/fastapi_leaudit/controllers/imageQualityController.py
  • fastapi_modules/fastapi_leaudit/image_quality/tasks.py
  • fastapi_modules/fastapi_leaudit/image_quality/runner.py
  • fastapi_modules/fastapi_leaudit/image_quality/storage_adapter.py
  • fastapi_modules/fastapi_leaudit/image_quality/input_resolver.py
  • fastapi_modules/fastapi_leaudit/image_quality/extractors.py
  • fastapi_modules/fastapi_leaudit/image_quality/detector.py
  • fastapi_modules/fastapi_leaudit/image_quality/config_resolver.py

2.2 现有文件改动点

  • fastapi_modules/fastapi_leaudit/controllers/documentController.py
  • fastapi_modules/fastapi_leaudit/services/documentService.py
  • fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py
  • fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py
  • fastapi_admin/config/_settings.py
  • fastapi_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_configs
  • leaudit_image_quality_runs
  • leaudit_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 KEY
  • scope_type VARCHAR(32) NOT NULL
  • document_type_id BIGINT NULL
  • region VARCHAR(64) NULL
  • enabled BOOLEAN NOT NULL DEFAULT TRUE
  • warn_threshold NUMERIC(10,4) NULL
  • reject_threshold NUMERIC(10,4) NULL
  • max_images_per_doc INTEGER NULL
  • max_concurrency INTEGER NULL
  • created_at TIMESTAMP NOT NULL DEFAULT NOW()
  • updated_at TIMESTAMP NOT NULL DEFAULT NOW()
  • deleted_at TIMESTAMP NULL

建议索引:

  • idx_leaudit_image_quality_configs_scope
  • idx_leaudit_image_quality_configs_doc_type
  • idx_leaudit_image_quality_configs_region

10.2 leaudit_image_quality_runs

建议字段:

  • id BIGSERIAL PRIMARY KEY
  • document_id BIGINT NOT NULL
  • document_file_id BIGINT NULL
  • status VARCHAR(32) NOT NULL DEFAULT 'queued'
  • skip_reason VARCHAR(64) NULL
  • summary_status VARCHAR(32) NULL
  • total_images INTEGER NOT NULL DEFAULT 0
  • pass_count INTEGER NOT NULL DEFAULT 0
  • review_count INTEGER NOT NULL DEFAULT 0
  • reject_count INTEGER NOT NULL DEFAULT 0
  • task_id VARCHAR(255) NULL
  • error_message TEXT NULL
  • started_at TIMESTAMP NULL
  • finished_at TIMESTAMP NULL
  • created_by BIGINT NULL
  • created_at TIMESTAMP NOT NULL DEFAULT NOW()
  • updated_at TIMESTAMP NOT NULL DEFAULT NOW()
  • deleted_at TIMESTAMP NULL

建议索引:

  • idx_leaudit_image_quality_runs_document_id
  • idx_leaudit_image_quality_runs_status
  • idx_leaudit_image_quality_runs_document_created_at

10.3 leaudit_image_quality_items

建议字段:

  • id BIGSERIAL PRIMARY KEY
  • run_id BIGINT NOT NULL
  • document_id BIGINT NOT NULL
  • document_file_id BIGINT NULL
  • source_kind VARCHAR(64) NOT NULL
  • source_file_name VARCHAR(255) NULL
  • source_page_num INTEGER NULL
  • image_index_in_page INTEGER NULL
  • image_index_in_file INTEGER NULL
  • bbox_json JSONB NULL
  • image_key VARCHAR(255) NULL
  • parent_image_key VARCHAR(255) NULL
  • crop_oss_url TEXT NULL
  • quality_status VARCHAR(32) NOT NULL
  • quality_score NUMERIC(10,4) NULL
  • reason_code VARCHAR(64) NULL
  • reason_text TEXT NULL
  • extra_json JSONB NULL
  • created_at TIMESTAMP NOT NULL DEFAULT NOW()
  • updated_at TIMESTAMP NOT NULL DEFAULT NOW()
  • deleted_at TIMESTAMP NULL

建议索引:

  • idx_leaudit_image_quality_items_run_id
  • idx_leaudit_image_quality_items_document_id
  • idx_leaudit_image_quality_items_quality_status
  • idx_leaudit_image_quality_items_source_page_num

11. 配置项建议

fastapi_admin/config/_settings.pyLeauditSettings 增加:

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. 第一版实施顺序

建议按下面顺序做:

  1. 先补 SQL 与后端表模型
  2. 再补 imageQualityVo.py
  3. 再补 IImageQualityService / ImageQualityServiceImpl
  4. 再补 tasks.py / runner.py
  5. 再在 DocumentServiceImpl.Upload / AppendAttachments 挂触发
  6. 再补 imageQualityController.py
  7. 最后接前端上传页、列表页、详情页

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 时,页码定位策略建议按优先级分层:

  1. 优先复用 OCR 结果中的页结构和视觉对象定位
  2. 其次复用 visual_manifest 中已有的视觉对象元数据
  3. 如果 OCR 已能给出该图片或父图像所在页,则直接采用该页码
  4. 如果拿不到稳定页码,则降级为“正文第 N 张图”或“附件第 N 张图”

推荐优先使用的字段包括:

  • ocr_result.pages[].page_num
  • ocr_result.visual_manifest
  • visual_manifest 中对象的 page_num
  • visual_manifest 中对象的 bbox
  • image_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 里做一段统一的关联流程,优先级从高到低如下:

  1. image_key / parent_image_key 直接命中
  2. 图片二进制 hash 命中
  3. 图片尺寸 + 裁剪相似度命中
  4. 页级候选范围内按 bbox / area 比例匹配
  5. 全部失败则降级为文件内序号定位

这套顺序的原因是:

  • 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 固定记为 1
  • image_index_in_page = 1
  • 若 OCR 输出 visual object,则仅作为 bbox 或 image_key 补充

这类文件定位最简单,本身就是单页单图。

16.4.3 doc/docx/wps

推荐策略:

  • 抽图阶段只保证:
    • image_index_in_file
    • image_sha256
    • image_width / image_height
    • source_file_name
  • 页码不要在抽图阶段硬算
  • 等 OCR/visual_manifest 结果出来后,再做回填

这一类文件的关联优先级建议是:

  1. parent_image_key 命中
  2. image_key 命中
  3. hash 命中
  4. 尺寸与内容相似度命中
  5. 命不中则只展示“正文第 N 张图”

16.5 建议的 ocr_match_mode 枚举

建议在 ocr_match_mode 里固定以下值,方便后续排查:

  • parent_image_key_exact
  • image_key_exact
  • image_sha256_exact
  • size_similarity_match
  • bbox_overlap_match
  • page_candidate_match
  • file_index_fallback
  • no_match

这样后面如果业务反馈“页码不准”,可以直接按命中模式排查是哪一层出了问题。

16.6 visual_manifest 关联的实现建议

建议把这块逻辑放在 image_quality/runner.py 或单独的 image_quality/locator.py 中,职责清晰一点:

  • extractors.py
    • 只负责抽图
    • 不负责猜页码
  • detector.py
    • 只负责判断图片清晰度
  • locator.pyrunner.py
    • 负责把图片索引记录与 OCR visual_manifest 做关联
    • 回填 ocr_page_num / ocr_bbox_json / ocr_match_mode

也就是说,页码定位不是抽图逻辑的一部分,而是“抽图后 + OCR结果可用后”的补全逻辑。

16.7 为什么建议先落图片索引,再做关联

因为如果把“抽图、关联、检测”混在一起,会有两个问题:

  • 当 OCR 结果未就绪时,图片质量模块会被迫等待主流程
  • 当 OCR side 结构调整时,图片质量模块会被连带影响

正确拆法是:

  1. 先独立抽图并持久化图片索引
  2. 独立跑清晰度检测
  3. 如果 OCR 结果已就绪,则补做 visual_manifest 关联
  4. 如果 OCR 结果尚未就绪,则先以弱定位展示,后续可异步补齐

这样既保持了两条 pipeline 独立,又能最大化复用 OCR 页结构。

16.8 页面展示的取值优先级

前端展示“第几页/第几张图”时,建议按下面顺序取值:

  1. ocr_page_num + image_index_in_page
  2. source_page_num + image_index_in_page
  3. image_index_in_file
  4. 仅展示来源文件名

示例文案:

  • 主文件第 12 页第 1 张图片模糊
  • 附件《现场照片2.pdf》第 3 页第 2 张图片疑似模糊
  • 正文第 5 张内嵌图片模糊
  • 附件《取证照片.jpg》图片模糊

16.9 对第一版实施的建议收口

为了控制复杂度,第一版建议这样收:

  • PDF / 扫描PDF / 图片文件 / 图片附件 / PDF附件
    • 做强定位
  • doc/docx/wps
    • 先把 OCR visual_manifest 关联能力做出来
    • 关联成功就展示页码
    • 关联失败就降级到“正文第 N 张图”

不要第一版就要求所有 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_id
  • document_file_id
  • file_role
  • file_name
  • file_ext
  • mime_type
  • source_type
  • source_path
  • file_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()

建议统一输出的数据结构:

  • documentId
  • documentFileId
  • sourceKind
  • sourceFileName
  • sourcePageNum
  • imageIndexInPage
  • imageIndexInFile
  • imageSha256
  • imageWidth
  • imageHeight
  • imageBytes
  • extraJson

其中:

  • PDF / 扫描PDF / 图片文件 / 图片附件 可以在这里直接给出 sourcePageNum
  • doc/docx/wps 这里不强行给精确页码,允许先留空

17.1.3 detector.py

职责:

  • 对抽出的图片执行清晰度检测
  • 返回三档结果
  • 输出原因码和说明
  • 不负责页码定位

建议统一返回:

  • qualityStatus
  • qualityScore
  • reasonCode
  • reasonText

建议原因码先收敛为有限集合:

  • blur_detected
  • low_resolution
  • over_exposure
  • under_exposure
  • motion_blur
  • text_unreadable
  • detector_timeout
  • detector_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 = 1
  • imageIndexInPage = 1
  • imageIndexInFile = 1

17.2.3 doc/docx/wps 抽图规则

建议:

  • 只抽出内嵌图片
  • 保证文件内顺序稳定
  • 第一版不在这里做页码推断
  • 重点保留:
    • image_sha256
    • image_width
    • image_height
    • image_index_in_file

原因:

  • 这类文件的难点不在抽图,而在“抽图后如何挂回 OCR 页结构”

17.2.4 附件抽图规则

建议:

  • 每个附件独立视为一个来源文件
  • 不要沿用主文件页码空间
  • source_file_name 必填
  • 前端展示时允许出现:
    • 附件《xxx.pdf》第 2 页第 1 张图片模糊
    • 附件《xxx.jpg》图片模糊

17.3 runner.py 建议执行时序

建议整条图片质量任务按下面时序执行:

  1. 创建或确认 image_quality_run
  2. 更新 run 状态为 running
  3. 调用 input_resolver.py 读取主文件与附件
  4. 调用 extractors.py 抽图
  5. 将抽图结果落表 leaudit_image_quality_items
  6. 判断是否无图
  7. 若无图则 run 标记 skipped/no_images
  8. 若有图则进入并发检测
  9. 调用 detector.py 批量检测,回写每条 item 的质量结果
  10. 尝试读取 OCR / visual_manifest
  11. 若可读,则做 locator 关联并回填页码/bbox
  12. 汇总 pass/review/reject 计数
  13. 更新 run 的 summary_status
  14. 标记 run completedpartial_failed

17.4 推荐时序图口径

可以把执行理解成下面这条线:

  1. 上传成功
  2. 文档主流程正常投递 OCR/评查
  3. 图片质量任务独立投递
  4. 图片质量任务先抽图、先检测
  5. OCR 若先完成,则图片质量任务顺手做 visual_manifest 关联
  6. OCR 若未完成,则图片质量任务先给弱定位结果
  7. 后续可通过补偿任务把弱定位升级成强定位

这意味着:

  • 图片质量任务不依赖 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
    • 不影响文档主流程
  • 单张图片检测失败:
    • 当前 item 标记失败原因
    • run 最终允许为 partial_failed
  • OCR 关联失败:
    • 不算质检失败
    • 只算定位降级
  • 全部图片检测完成但都无法定位页码:
    • run 仍可 completed
    • 只是展示层回退到“第 N 张图”

也就是说,质量检测失败和定位失败要分开统计,不能混成一类。

17.7 推荐的汇总规则

建议 runner.py 最终汇总时按下面规则判断:

  • 只要存在至少 1 张 rejectsummary_status = reject
  • 否则只要存在至少 1 张 reviewsummary_status = review
  • 否则若全部 passsummary_status = pass
  • 若任务本身没跑起来,则 status = failed
  • 若任务跑完但部分图片检测失败,则 status = partial_failed
  • 若命中无图、纯文本、开关关闭,则 status = skipped

17.8 第一版最小可交付实现

为了避免第一版过重,建议把可交付标准压到下面这一级:

  • extractors.py
    • 先支持 PDF、图片、docx 内嵌图、附件
  • detector.py
    • 先给出稳定三档判断
  • runner.py
    • 先能完成抽图、检测、汇总
  • locator
    • 先支持 OCR visual_manifest 存在时的强关联
    • 不存在时允许降级

只要能做到:

  • 不影响主流程
  • 能把问题图片列出来
  • 大多数 PDF/图片类文档能精确到页
  • docx 至少能稳定定位到“正文第 N 张图”

第一版就已经是可上线状态。

17.9 最终建议

研发拆任务时,建议直接按下面方式分:

  • A 同学:SQL + VO + Controller + Service
  • B 同学:input_resolver + extractors
  • C 同学:detector + runner
  • D 同学:locator / OCR visual_manifest 关联
  • 前端同学:上传页 + 列表页 + 详情页展示

这样拆分写入面冲突最小,也最贴合当前仓库结构。