From cbfe4a23dcd0e438952d43e7c55657c1e5f80b8a Mon Sep 17 00:00:00 2001 From: qiurui Date: Sat, 28 Feb 2026 17:33:11 +0800 Subject: [PATCH] feat: info-privacy PII detection service with frame analysis support - Pipeline: analyze(), redact(), analyze_image() methods - API: /analyze, /redact, /analyze/frame, /analyze/frame/base64 endpoints - Detectors: regex, NER, face (RKNN NPU) - Privacy frame route added for KVM-Privacy Hub integration --- .gitignore | 11 + .serena/.gitignore | 1 + .serena/project.yml | 126 + CLAUDE.md | 118 + DEVELOP.md | 214 ++ Makefile | 23 + README.md | 130 + RELEASE.md | 74 + configs/pii_rules.yaml | 58 + configs/surnames.txt | 101 + docs/patent-disclosure.md | 406 ++++ docs/plans/2026-02-27-info-privacy-design.md | 229 ++ docs/plans/2026-02-27-info-privacy.md | 2136 +++++++++++++++++ models/.gitkeep | 0 pyproject.toml | 38 + scripts/.gitkeep | 0 scripts/start_server.sh | 6 + scripts/verify.py | 73 + setup_venv.sh | 8 + src/info_privacy/__init__.py | 0 src/info_privacy/api/__init__.py | 0 src/info_privacy/api/main.py | 27 + src/info_privacy/api/models.py | 35 + src/info_privacy/api/routes/__init__.py | 0 src/info_privacy/api/routes/analyze.py | 39 + src/info_privacy/api/routes/frame.py | 84 + src/info_privacy/api/routes/redact.py | 45 + src/info_privacy/classifier/__init__.py | 0 src/info_privacy/classifier/doc_classifier.py | 91 + src/info_privacy/detectors/__init__.py | 0 src/info_privacy/detectors/face_detector.py | 45 + src/info_privacy/detectors/ner_detector.py | 57 + src/info_privacy/detectors/regex_detector.py | 41 + src/info_privacy/models.py | 61 + src/info_privacy/parsers/__init__.py | 0 src/info_privacy/parsers/image_parser.py | 99 + src/info_privacy/parsers/office_parser.py | 74 + src/info_privacy/parsers/parser_factory.py | 34 + src/info_privacy/parsers/pdf_parser.py | 51 + src/info_privacy/pipeline.py | 146 ++ src/info_privacy/redactors/__init__.py | 0 src/info_privacy/redactors/image_redactor.py | 36 + src/info_privacy/redactors/text_redactor.py | 113 + tests/__init__.py | 0 tests/test_api.py | 145 ++ tests/test_classifier.py | 85 + tests/test_detectors.py | 185 ++ tests/test_integration_complex.py | 598 +++++ tests/test_integration_real.py | 436 ++++ tests/test_models.py | 46 + tests/test_parsers.py | 142 ++ tests/test_pipeline.py | 89 + tests/test_redactors.py | 162 ++ tests/test_simulation.py | 267 +++ 54 files changed, 6985 insertions(+) create mode 100644 .gitignore create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 CLAUDE.md create mode 100644 DEVELOP.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 RELEASE.md create mode 100644 configs/pii_rules.yaml create mode 100644 configs/surnames.txt create mode 100644 docs/patent-disclosure.md create mode 100644 docs/plans/2026-02-27-info-privacy-design.md create mode 100644 docs/plans/2026-02-27-info-privacy.md create mode 100644 models/.gitkeep create mode 100644 pyproject.toml create mode 100644 scripts/.gitkeep create mode 100755 scripts/start_server.sh create mode 100755 scripts/verify.py create mode 100755 setup_venv.sh create mode 100644 src/info_privacy/__init__.py create mode 100644 src/info_privacy/api/__init__.py create mode 100644 src/info_privacy/api/main.py create mode 100644 src/info_privacy/api/models.py create mode 100644 src/info_privacy/api/routes/__init__.py create mode 100644 src/info_privacy/api/routes/analyze.py create mode 100644 src/info_privacy/api/routes/frame.py create mode 100644 src/info_privacy/api/routes/redact.py create mode 100644 src/info_privacy/classifier/__init__.py create mode 100644 src/info_privacy/classifier/doc_classifier.py create mode 100644 src/info_privacy/detectors/__init__.py create mode 100644 src/info_privacy/detectors/face_detector.py create mode 100644 src/info_privacy/detectors/ner_detector.py create mode 100644 src/info_privacy/detectors/regex_detector.py create mode 100644 src/info_privacy/models.py create mode 100644 src/info_privacy/parsers/__init__.py create mode 100644 src/info_privacy/parsers/image_parser.py create mode 100644 src/info_privacy/parsers/office_parser.py create mode 100644 src/info_privacy/parsers/parser_factory.py create mode 100644 src/info_privacy/parsers/pdf_parser.py create mode 100644 src/info_privacy/pipeline.py create mode 100644 src/info_privacy/redactors/__init__.py create mode 100644 src/info_privacy/redactors/image_redactor.py create mode 100644 src/info_privacy/redactors/text_redactor.py create mode 100644 tests/__init__.py create mode 100644 tests/test_api.py create mode 100644 tests/test_classifier.py create mode 100644 tests/test_detectors.py create mode 100644 tests/test_integration_complex.py create mode 100644 tests/test_integration_real.py create mode 100644 tests/test_models.py create mode 100644 tests/test_parsers.py create mode 100644 tests/test_pipeline.py create mode 100644 tests/test_redactors.py create mode 100644 tests/test_simulation.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fb21ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +*.egg-info/ +dist/ +build/ +.env +tmp/ +*.db diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..285dfe1 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,126 @@ +# the name by which the project can be referenced within Serena +project_name: "info-privacy" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- python + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43ee400 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +基于 Rockchip RKNN NPU 的文档隐私安全处理 REST API 服务。支持 RK3588 板端和 x86 双平台。 + +**功能**:两阶段工作流——先分析检测隐私信息,用户决定后再遮罩处理。 + +## 开发环境 + +### 初始化 + +```bash +bash setup_venv.sh +source venv/bin/activate +``` + +### 常用命令 + +```bash +make test # 运行所有测试 +make test-one FILE=tests/test_api.py # 单文件测试 +make lint # 代码检查(ruff) +make server # 启动服务(http://0.0.0.0:8000) +make verify # 端到端验证(需先启动服务) +make clean # 清理缓存和临时文件 +``` + +## API 端点 + +| 端点 | 说明 | +|------|------| +| `POST /api/v1/analyze` | 检测文档中的隐私信息,返回 JSON 报告 | +| `POST /api/v1/redact` | 遮罩指定类型的隐私信息,返回处理后文档 | +| `GET /api/v1/health` | 服务健康检查 | +| `GET /api/v1/types` | 支持的隐私类型列表 | + +### analyze 请求 + +```bash +curl -X POST http://localhost:8000/api/v1/analyze \ + -F "file=@document.docx" +``` + +### redact 请求 + +```bash +curl -X POST http://localhost:8000/api/v1/redact \ + -F "file=@document.docx" \ + -F 'config={"redact_types":["id_card","phone","face"]}' \ + -o redacted.docx +``` + +## 项目架构 + +``` +两阶段流水线: + analyze: parser_factory → doc_classifier → [regex_detector, ner_detector, face_detector] → DetectionReport + redact: analyze → strategy_selector → [text_redactor | image_redactor] → 处理后文档 +``` + +### 文字层策略(字节级安全) + +- PDF(含文字层)/ Word / Excel → `TextRedactor`(删除/替换文字节点) +- 图像 / 扫描 PDF → `ImageRedactor`(OpenCV 黑色矩形覆盖) +- PDF 有文字层时禁止仅使用图像遮罩(防止文字层可提取) + +### 可检测隐私类型 + +| 类型 | 检测方式 | 安全等级 | +|------|---------|---------| +| id_card | Regex | high | +| phone | Regex | high | +| bank_card | Regex | high | +| face | mediapipe RKNN | high | +| email | Regex | medium | +| name | 规则词典 NER | medium | +| address | 规则词典 NER | medium | +| license_plate | Regex | medium | + +## 支持的文档类型 + +| 格式 | 解析方式 | +|------|---------| +| PDF(文字层) | pdfminer.six | +| PDF(扫描件) | paddle_ocr RKNN | +| .docx | python-docx | +| .xlsx | openpyxl | +| JPG/PNG | paddle_ocr RKNN | + +## 关键路径 + +| 内容 | 路径 | +|------|------| +| PII 规则配置 | `configs/pii_rules.yaml` | +| 姓氏词典 | `configs/surnames.txt` | +| paddle_ocr RKNN 模型 | `/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/` | +| mediapipe 人脸模型 | `/data/rockchip/mediapipe/models/rknn/` | + +## RK3588 板端部署 + +```bash +# 同步代码 +rsync -av /data/rockchip/info-privacy/ pi@192.168.0.127:/home/pi/Desktop/info-privacy/ + +# 板端启动 +ssh pi@192.168.0.127 "clashon && clashproxy on && \ + cd /home/pi/Desktop/info-privacy && \ + bash setup_venv.sh && \ + source venv/bin/activate && \ + uvicorn info_privacy.api.main:app --host 0.0.0.0 --port 8000" +``` + +## 设计文档 + +`docs/plans/2026-02-27-info-privacy-design.md` diff --git a/DEVELOP.md b/DEVELOP.md new file mode 100644 index 0000000..1bd8990 --- /dev/null +++ b/DEVELOP.md @@ -0,0 +1,214 @@ +# Info-Privacy 开发指南 + +## 项目结构 + +``` +info-privacy/ +├── src/info_privacy/ +│ ├── api/ +│ │ ├── main.py # FastAPI 入口,路由注册 +│ │ ├── routes/ +│ │ │ ├── analyze.py # POST /api/v1/analyze +│ │ │ └── redact.py # POST /api/v1/redact +│ │ └── models.py # Pydantic 请求/响应模型 +│ ├── classifier/ +│ │ └── doc_classifier.py # 保密文件拦截(关键词匹配) +│ ├── detectors/ +│ │ ├── regex_detector.py # Regex PII 检测 +│ │ ├── ner_detector.py # 规则词典姓名/地址检测 +│ │ └── face_detector.py # MediaPipe RKNN 人脸检测(硬件依赖) +│ ├── parsers/ +│ │ ├── pdf_parser.py # pdfminer 文字层解析 +│ │ ├── office_parser.py # python-docx / openpyxl +│ │ ├── image_parser.py # PaddleOCR RKNN 图像 OCR(硬件依赖) +│ │ └── parser_factory.py # 按文件扩展名路由 +│ ├── redactors/ +│ │ ├── text_redactor.py # 文字节点替换(字节级安全) +│ │ └── image_redactor.py # OpenCV 黑色矩形覆盖 +│ ├── models.py # 核心数据模型(TextBlock, Entity, DetectionReport) +│ └── pipeline.py # 串联 parser → classifier → detector → redactor +├── configs/ +│ ├── pii_rules.yaml # Regex 规则 + 保密关键词 + NER 配置 +│ └── surnames.txt # 中文姓氏词典(NER 姓名检测) +├── tests/ +│ ├── test_models.py # 数据模型单元测试 +│ ├── test_classifier.py # 文档分类测试 +│ ├── test_detectors.py # Regex/NER/人脸检测测试 +│ ├── test_parsers.py # PDF/Office/图像解析测试 +│ ├── test_redactors.py # 文字/图像遮罩测试 +│ ├── test_pipeline.py # 端到端流水线测试 +│ ├── test_api.py # FastAPI 接口测试 +│ └── test_simulation.py # RKNN 硬件仿真测试(x86 可运行) +├── scripts/ +│ ├── start_server.sh # 一键启动服务 +│ └── verify.py # 端到端验证脚本 +├── docs/plans/ # 设计文档(历史归档) +├── models/ # 本地模型软链(指向 sibling 项目) +├── tmp/ # 临时文件(已加 .gitignore) +├── pyproject.toml +├── Makefile +└── setup_venv.sh +``` + +--- + +## 开发环境搭建 + +### x86 开发机 + +```bash +# 1. 初始化虚拟环境 +bash setup_venv.sh +source venv/bin/activate + +# 2. 验证安装 +python -c "from info_privacy.pipeline import PrivacyPipeline; print('OK')" + +# 3. 运行全量测试 +make test # 155 passed, 4 skipped(RKNN 相关跳过) +make lint # ruff 代码检查 +``` + +### RK3588 板端 + +```bash +# 同步代码(开发机执行) +rsync -av /data/rockchip/info-privacy/ pi@192.168.0.127:/home/pi/Desktop/info-privacy/ + +# 板端环境(SSH 执行) +cd /home/pi/Desktop/info-privacy +bash setup_venv.sh && source venv/bin/activate +python -c "from info_privacy.pipeline import PrivacyPipeline; print('OK')" +``` + +板端需额外安装 `rknn-toolkit-lite2`,OpenCV/NumPy 已预装。 + +--- + +## 常用开发命令 + +```bash +make test # 运行所有测试 +make test-one FILE=tests/test_api.py # 单文件测试 +make lint # ruff 代码检查 +make server # 启动服务(--reload 热重载) +make verify # 端到端验证(需先启动服务) +make clean # 清理 __pycache__ 和 tmp/ +``` + +--- + +## 测试框架说明 + +### 分层测试策略 + +| 测试文件 | 类型 | 是否需要硬件 | +|---------|------|------------| +| `test_models.py` | 单元 | 否 | +| `test_classifier.py` | 单元 | 否 | +| `test_detectors.py` | 单元 | 否(人脸检测用 skip) | +| `test_parsers.py` | 单元 | 否(图像解析用 skip) | +| `test_redactors.py` | 单元 | 否 | +| `test_pipeline.py` | 集成 | 否 | +| `test_api.py` | 接口 | 否 | +| `test_simulation.py` | 仿真 | 否(mock 替换 RKNN) | +| `test_integration_real.py` | 集成(真实文件) | 否 | +| `test_integration_complex.py` | 集成(边界/复杂场景) | 否 | + +### RKNN 仿真测试 + +`test_simulation.py` 使用 `unittest.mock.patch.dict(sys.modules, ...)` 注入 mock 模块, +在 x86 环境下验证 `FaceDetector` 和 `ImageParser` 的业务逻辑(bbox 转换、entity 构建、置信度过滤等)。 + +板端真机测试时,`test_detectors.py` 和 `test_parsers.py` 中的 skip 测试将自动运行。 + +--- + +## 扩展指南 + +### 添加新的 PII 类型(Regex) + +1. 在 `configs/pii_rules.yaml` 的 `regex` 节下添加规则: + ```yaml + passport: + pattern: '[A-Z]{1,2}\d{7}' + security_level: high + ``` +2. 在 `src/info_privacy/models.py` 的 `EntityType` 枚举中增加 `PASSPORT = "passport"` +3. 在 `tests/test_detectors.py` 中增加对应测试用例 + +### 添加新的 PII 类型(NER) + +1. 在 `configs/pii_rules.yaml` 的 `ner` 节下添加触发词配置 +2. 在 `src/info_privacy/detectors/ner_detector.py` 中实现匹配逻辑 +3. 添加对应 `EntityType` 枚举值 + +### 添加新的文档格式 + +1. 在 `src/info_privacy/parsers/` 下创建新的 Parser 类,实现 `parse(file_path) -> (dict, list[TextBlock])` 接口 +2. 在 `src/info_privacy/parsers/parser_factory.py` 中注册新格式的扩展名映射 +3. 新 Parser 的 `meta` 字典必须包含 `format`、`has_text_layer`、`redact_strategy` 三个字段 + +--- + +## 数据流 + +``` +[POST /analyze] + 文件 → parser_factory → TextBlock[]{text, bbox, page, layer} + → doc_classifier → 如果 blocked: 直接返回 DetectionReport(blocked=True) + → regex_detector + ner_detector + face_detector → Entity[] + → doc_classifier(实体密度评估) → warning? + → 返回 DetectionReport + +[POST /redact] + 文件 + redact_types → analyze() → Entity[] + → 按 entity.layer 分流: + layer=="text" → text_redactor(删除文字节点) + layer=="image" → image_redactor(OpenCV 黑框覆盖) + → 输出文档 + X-Security-Report Header +``` + +--- + +## 关键配置文件 + +### configs/pii_rules.yaml + +- `regex`:各类型的检测正则表达式和安全等级 +- `confidential_keywords`:保密文件拦截关键词列表 +- `ner.name.surnames_file`:中文姓氏词典路径(相对于项目根目录) +- `ner.address.triggers`:地址检测触发词(省/市/区/路/号等) + +### 安全等级说明 + +- `high`:遮罩时默认强制处理,体现在 `X-Security-Report` 中 +- `medium`:遮罩时按用户 `redact_types` 指定处理 + +--- + +## 常见问题 + +**Q: `make test` 显示 4 个 skipped 测试** + +正常现象。这 4 个测试依赖 RKNN 运行时(仅 RK3588 设备可用)。x86 环境下等效的仿真测试在 `test_simulation.py` 中覆盖(15 个)。 + +**Q: 身份证号为何不会同时被标注为银行卡** + +系统内置去重逻辑:18 位身份证号满足银行卡格式,但 `RegexDetector` 在检测完成后会过滤掉与 `id_card` 值重叠的 `bank_card` 实体,避免双重计数影响阈值判断。 + +**Q: 中文字符前的数字 PII 为何能被检测** + +正则边界使用 `(?/dev/null; \ + rm -rf tmp/*.pdf tmp/*.png tmp/*.docx 2>/dev/null; true diff --git a/README.md b/README.md new file mode 100644 index 0000000..c13cad2 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Info-Privacy + +基于 Rockchip RKNN NPU 的文档隐私安全处理 REST API 服务,支持 RK3588 板端和 x86 双平台。 + +**两阶段工作流**:先 `analyze`(返回 JSON 检测报告)→ 用户决策 → 再 `redact`(返回处理后文档)。 + +--- + +## 支持的文档格式 + +| 格式 | 解析方式 | +|------|---------| +| PDF(含文字层) | pdfminer.six | +| PDF(扫描件) | PaddleOCR RKNN | +| .docx | python-docx | +| .xlsx | openpyxl | +| JPG / PNG | PaddleOCR RKNN | + +## 可检测的隐私类型 + +| 类型 | 检测方式 | 安全等级 | +|------|---------|---------| +| `id_card` | Regex | high | +| `phone` | Regex | high | +| `bank_card` | Regex | high | +| `face` | MediaPipe RKNN | high | +| `email` | Regex | medium | +| `name` | 规则词典 NER | medium | +| `address` | 规则词典 NER | medium | +| `license_plate` | Regex | medium | + +--- + +## 快速开始 + +### x86 开发环境 + +```bash +# 初始化虚拟环境 +bash setup_venv.sh +source venv/bin/activate + +# 启动服务 +make server # http://0.0.0.0:8000 + +# 端到端验证(需先启动服务) +make verify +``` + +### RK3588 板端 + +```bash +# 同步代码 +rsync -av /data/rockchip/info-privacy/ pi@192.168.0.127:/home/pi/Desktop/info-privacy/ + +# 板端启动 +ssh pi@192.168.0.127 "clashon && clashproxy on && \ + cd /home/pi/Desktop/info-privacy && \ + bash setup_venv.sh && source venv/bin/activate && \ + uvicorn info_privacy.api.main:app --host 0.0.0.0 --port 8000" +``` + +--- + +## API 使用示例 + +### 检测隐私信息 + +```bash +curl -X POST http://localhost:8000/api/v1/analyze \ + -F "file=@document.docx" +``` + +响应: +```json +{ + "doc_id": "uuid", + "classification": "normal", + "blocked": false, + "warning": null, + "entities": [ + {"id": "e1", "type": "id_card", "value": "110101199001011234", + "page": 1, "bbox": [10, 20, 200, 40], "layer": "text", "security_level": "high"} + ], + "summary": {"id_card": 1} +} +``` + +### 遮罩指定类型 + +```bash +curl -X POST http://localhost:8000/api/v1/redact \ + -F "file=@document.docx" \ + -F 'config={"redact_types":["id_card","phone"]}' \ + -o redacted.docx +``` + +响应 Body 为处理后文档,Header `X-Security-Report` 包含遮罩统计。 + +### 其他端点 + +```bash +GET /api/v1/health # 健康检查 +GET /api/v1/types # 支持的隐私类型列表 +``` + +--- + +## 遮罩安全保证 + +系统根据文档类型**自动选择最安全策略**,用户无需指定: + +| 文档类型 | 遮罩方式 | 安全保证 | +|---------|---------|---------| +| PDF(文字层)/ Word / Excel | 删除文字节点(text_replace) | 字节级安全 | +| 图像 / 扫描 PDF | OpenCV 黑色矩形覆盖(image_mask) | 图像级安全 | + +> **重要**:PDF 含文字层时,即使 `redact_types` 包含 `face`,也强制使用 text_replace 防止文字层信息泄露。 + +--- + +## RKNN 模型路径 + +| 模型 | 路径 | +|------|------| +| PaddleOCR 检测 | `/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/det/` | +| PaddleOCR 识别 | `/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/rec/` | +| MediaPipe 人脸 | `/data/rockchip/mediapipe/models/rknn/` | + +详见 [DEVELOP.md](DEVELOP.md) · [RELEASE.md](RELEASE.md) diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..fac774d --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,74 @@ +# 发布记录 + +## v0.1.1 (2026-02-27) + +### Bug 修复 + +- **Regex 边界修复**:`id_card` / `bank_card` 正则将 `\b` 改为 `(? **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 构建基于 Rockchip RKNN NPU 的文档隐私安全处理 REST API 服务,支持 RK3588 板端和 x86 双平台运行。 + +**Architecture:** 两阶段 API(analyze → redact),文档先经分类器拦截保密文件,再并行运行 Regex/NER/人脸检测器,遮罩时自动选择最安全策略(文字层删节点 / 图像层 OpenCV 黑框)。 + +**Tech Stack:** FastAPI + uvicorn, pdfminer.six + pypdf, python-docx, openpyxl, OpenCV, rknn_model_zoo PaddleOCR(OCR), mediapipe_rknn FaceDetection(人脸), pytest + +**参考项目:** +- OCR: `/data/rockchip/rknn_model_zoo/examples/PPOCR/PPOCR-System/python/` +- 人脸检测: `/data/rockchip/mediapipe/src/mediapipe_rknn/` +- RKNN模型: `/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/`, `/data/rockchip/mediapipe/models/rknn/` +- 工程规范: `/data/rockchip/mediapipe/` (pyproject.toml / Makefile / DEVELOP.md 结构) + +--- + +## Task 1: 项目骨架搭建 + +**Files:** +- Create: `pyproject.toml` +- Create: `requirements.txt` +- Create: `Makefile` +- Create: `setup_venv.sh` +- Create: `src/info_privacy/__init__.py` +- Create: `src/info_privacy/api/__init__.py` +- Create: `src/info_privacy/classifier/__init__.py` +- Create: `src/info_privacy/detectors/__init__.py` +- Create: `src/info_privacy/parsers/__init__.py` +- Create: `src/info_privacy/redactors/__init__.py` +- Create: `configs/pii_rules.yaml` +- Create: `tests/__init__.py` + +**Step 1: 创建 pyproject.toml** + +```toml +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "info-privacy" +version = "0.1.0" +description = "Document privacy redaction service for Rockchip RKNN" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.110.0", + "uvicorn>=0.29.0", + "python-multipart>=0.0.9", + "pdfminer.six>=20221105", + "pypdf>=4.0.0", + "python-docx>=1.1.0", + "openpyxl>=3.1.0", + "opencv-python>=4.5.5.64", + "numpy>=1.24.0", + "pyyaml>=6.0", + "pyclipper>=1.3.0", + "shapely>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "httpx>=0.27.0", + "ruff>=0.4.0", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +``` + +**Step 2: 创建 setup_venv.sh** + +```bash +#!/bin/bash +set -e +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip +pip install -e ".[dev]" +echo "venv ready. Run: source venv/bin/activate" +``` + +**Step 3: 创建 Makefile** + +```makefile +.PHONY: install test test-one lint server verify clean + +install: + bash setup_venv.sh + +test: + source venv/bin/activate && pytest tests/ -v + +test-one: + source venv/bin/activate && pytest $(FILE) -v + +lint: + source venv/bin/activate && ruff check src/ + +server: + source venv/bin/activate && uvicorn info_privacy.api.main:app --host 0.0.0.0 --port 8000 --reload + +verify: + source venv/bin/activate && python scripts/verify.py + +clean: + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null; \ + rm -rf tmp/*.pdf tmp/*.png tmp/*.docx 2>/dev/null; true +``` + +**Step 4: 创建 configs/pii_rules.yaml** + +```yaml +# PII 正则规则 +regex: + id_card: + pattern: '\b([1-9]\d{5})(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]\b' + security_level: high + phone: + pattern: '(? dict[str, int]: + counts: dict[str, int] = {} + for e in self.entities: + counts[e.type.value] = counts.get(e.type.value, 0) + 1 + return counts +``` + +**Step 4: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_models.py -v +``` + +Expected: 4 passed + +--- + +## Task 3: Regex 检测器 + +**Files:** +- Create: `src/info_privacy/detectors/regex_detector.py` +- Create: `tests/test_detectors.py` + +**Step 1: 写失败测试** + +```python +# tests/test_detectors.py +import pytest +from info_privacy.models import TextBlock, EntityType +from info_privacy.detectors.regex_detector import RegexDetector + +@pytest.fixture +def detector(): + return RegexDetector("configs/pii_rules.yaml") + +def test_detect_id_card(detector): + block = TextBlock(text="姓名:张三 身份证:110101199001011234", bbox=[0,0,100,20], page=1, layer="text") + entities = detector.detect([block]) + types = [e.type for e in entities] + assert EntityType.ID_CARD in types + +def test_detect_phone(detector): + block = TextBlock(text="联系电话:13812345678", bbox=[0,0,100,20], page=1, layer="text") + entities = detector.detect([block]) + types = [e.type for e in entities] + assert EntityType.PHONE in types + +def test_detect_email(detector): + block = TextBlock(text="邮箱:test@example.com", bbox=[0,0,100,20], page=1, layer="text") + entities = detector.detect([block]) + assert any(e.type == EntityType.EMAIL for e in entities) + +def test_no_false_positive_short_number(detector): + block = TextBlock(text="编号:12345", bbox=[0,0,100,20], page=1, layer="text") + entities = detector.detect([block]) + assert not any(e.type == EntityType.ID_CARD for e in entities) + +def test_entity_bbox_matches_block(detector): + block = TextBlock(text="电话:13812345678", bbox=[10,20,200,40], page=2, layer="text") + entities = detector.detect([block]) + assert entities[0].page == 2 + assert entities[0].bbox == [10, 20, 200, 40] +``` + +**Step 2: 运行确认失败** + +```bash +source venv/bin/activate && pytest tests/test_detectors.py -v +``` + +**Step 3: 实现 regex_detector.py** + +```python +# src/info_privacy/detectors/regex_detector.py +import re +import uuid +from pathlib import Path + +import yaml + +from info_privacy.models import Entity, EntityType, TextBlock + + +class RegexDetector: + def __init__(self, config_path: str = "configs/pii_rules.yaml"): + cfg = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) + self._rules = { + EntityType(k): { + "pattern": re.compile(v["pattern"]), + "security_level": v["security_level"], + } + for k, v in cfg["regex"].items() + } + + def detect(self, blocks: list[TextBlock]) -> list[Entity]: + entities: list[Entity] = [] + for block in blocks: + for etype, rule in self._rules.items(): + for match in rule["pattern"].finditer(block.text): + entities.append(Entity( + id=str(uuid.uuid4()), + type=etype, + value=match.group(), + page=block.page, + bbox=block.bbox, + layer=block.layer, + security_level=rule["security_level"], + )) + return entities +``` + +**Step 4: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_detectors.py -v +``` + +Expected: 5 passed + +--- + +## Task 4: NER 检测器(规则词典) + +**Files:** +- Modify: `src/info_privacy/detectors/ner_detector.py`(新建) +- Modify: `tests/test_detectors.py`(追加) + +**Step 1: 追加失败测试** + +```python +# 追加到 tests/test_detectors.py +from info_privacy.detectors.ner_detector import NERDetector + +@pytest.fixture +def ner(): + return NERDetector("configs/pii_rules.yaml") + +def test_detect_name(ner): + block = TextBlock(text="申请人:张伟,联系地址如下", bbox=[0,0,200,20], page=1, layer="text") + entities = ner.detect([block]) + assert any(e.type == EntityType.NAME for e in entities) + +def test_detect_address(ner): + block = TextBlock(text="住址:北京市朝阳区建国路88号", bbox=[0,0,300,20], page=1, layer="text") + entities = ner.detect([block]) + assert any(e.type == EntityType.ADDRESS for e in entities) + +def test_no_name_without_surname(ner): + block = TextBlock(text="操作系统版本3.1", bbox=[0,0,200,20], page=1, layer="text") + entities = ner.detect([block]) + assert not any(e.type == EntityType.NAME for e in entities) +``` + +**Step 2: 实现 ner_detector.py** + +```python +# src/info_privacy/detectors/ner_detector.py +import re +import uuid +from pathlib import Path + +import yaml + +from info_privacy.models import Entity, EntityType, TextBlock + + +class NERDetector: + def __init__(self, config_path: str = "configs/pii_rules.yaml"): + cfg = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) + ner_cfg = cfg.get("ner", {}) + + surnames_file = ner_cfg.get("name", {}).get("surnames_file", "configs/surnames.txt") + surnames = [ + line.strip() for line in Path(surnames_file).read_text(encoding="utf-8").splitlines() + if line.strip() and not line.startswith("#") + ] + surname_pattern = "|".join(re.escape(s) for s in surnames) + # 姓氏 + 2-4个汉字姓名 + self._name_re = re.compile(rf'(? list[Entity]: + entities: list[Entity] = [] + for block in blocks: + for match in self._name_re.finditer(block.text): + entities.append(Entity( + id=str(uuid.uuid4()), + type=EntityType.NAME, + value=match.group(), + page=block.page, + bbox=block.bbox, + layer=block.layer, + security_level="medium", + )) + for match in self._addr_re.finditer(block.text): + entities.append(Entity( + id=str(uuid.uuid4()), + type=EntityType.ADDRESS, + value=match.group(), + page=block.page, + bbox=block.bbox, + layer=block.layer, + security_level="medium", + )) + return entities +``` + +**Step 3: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_detectors.py -v +``` + +Expected: 8 passed + +--- + +## Task 5: 文档分类器(保密拦截) + +**Files:** +- Create: `src/info_privacy/classifier/doc_classifier.py` +- Create: `tests/test_classifier.py` + +**Step 1: 写失败测试** + +```python +# tests/test_classifier.py +from info_privacy.models import TextBlock, Classification +from info_privacy.classifier.doc_classifier import DocClassifier + +def test_classified_by_keyword(): + classifier = DocClassifier("configs/pii_rules.yaml") + blocks = [TextBlock(text="【机密】本文件仅供内部使用", bbox=[0,0,200,20], page=1, layer="text")] + result = classifier.classify(blocks) + assert result.classification == Classification.CLASSIFIED + assert result.blocked is True + assert result.block_reason is not None + +def test_sensitive_partial_many_entities(): + from info_privacy.models import Entity, EntityType + classifier = DocClassifier("configs/pii_rules.yaml") + # 制造多个高风险实体 + entities = [ + Entity(id=str(i), type=EntityType.ID_CARD, value="x", page=1, + bbox=[0,0,1,1], layer="text", security_level="high") + for i in range(6) + ] + result = classifier.classify([], entities=entities) + assert result.classification == Classification.SENSITIVE_PARTIAL + assert result.warning is not None + +def test_normal_document(): + classifier = DocClassifier("configs/pii_rules.yaml") + blocks = [TextBlock(text="本季度销售报告", bbox=[0,0,200,20], page=1, layer="text")] + result = classifier.classify(blocks) + assert result.classification == Classification.NORMAL + assert result.blocked is False +``` + +**Step 2: 实现 doc_classifier.py** + +```python +# src/info_privacy/classifier/doc_classifier.py +from __future__ import annotations +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import yaml + +from info_privacy.models import Classification, Entity, TextBlock + + +SENSITIVE_PARTIAL_THRESHOLD = 5 # 超过N个高风险实体触发警告 + + +@dataclass +class ClassificationResult: + classification: Classification + blocked: bool + block_reason: Optional[str] + warning: Optional[str] + + +class DocClassifier: + def __init__(self, config_path: str = "configs/pii_rules.yaml"): + cfg = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) + self._keywords = [kw.upper() for kw in cfg.get("confidential_keywords", [])] + + def classify( + self, + blocks: list[TextBlock], + entities: Optional[list[Entity]] = None, + ) -> ClassificationResult: + # 优先检查保密标记 + for block in blocks: + text_upper = block.text.upper() + for kw in self._keywords: + if kw in text_upper: + return ClassificationResult( + classification=Classification.CLASSIFIED, + blocked=True, + block_reason=f"检测到保密标记:"{kw}"(第{block.page}页)", + warning=None, + ) + + # 高密度敏感实体检查 + if entities: + high_risk = [e for e in entities if e.security_level == "high"] + if len(high_risk) >= SENSITIVE_PARTIAL_THRESHOLD: + return ClassificationResult( + classification=Classification.SENSITIVE_PARTIAL, + blocked=False, + block_reason=None, + warning=f"文档含 {len(high_risk)} 处高风险隐私实体,建议全量处理", + ) + + return ClassificationResult( + classification=Classification.NORMAL, + blocked=False, + block_reason=None, + warning=None, + ) +``` + +**Step 3: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_classifier.py -v +``` + +Expected: 3 passed + +--- + +## Task 6: PDF 解析器(文字层) + +**Files:** +- Create: `src/info_privacy/parsers/pdf_parser.py` +- Create: `tests/test_parsers.py` +- Create: `tmp/test_sample.py`(生成测试PDF,用后删除) + +**Step 1: 生成测试用 PDF** + +```python +# tmp/test_sample.py ← 执行后删除 +# 需要先安装:pip install reportlab +from reportlab.pdfgen import canvas + +c = canvas.Canvas("tmp/sample_text.pdf") +c.drawString(72, 720, "申请人:张伟") +c.drawString(72, 700, "身份证:110101199001011234") +c.drawString(72, 680, "联系电话:13812345678") +c.drawString(72, 660, "邮箱:zhangwei@example.com") +c.save() +print("生成 tmp/sample_text.pdf") +``` + +```bash +source venv/bin/activate && pip install reportlab -q && python tmp/test_sample.py +``` + +**Step 2: 写失败测试** + +```python +# tests/test_parsers.py +import pytest +from pathlib import Path +from info_privacy.parsers.pdf_parser import PdfParser + +@pytest.fixture +def pdf_path(): + p = Path("tmp/sample_text.pdf") + if not p.exists(): + pytest.skip("需先运行 python tmp/test_sample.py 生成测试文件") + return str(p) + +def test_pdf_has_text_layer(pdf_path): + parser = PdfParser() + meta, blocks = parser.parse(pdf_path) + assert meta["has_text_layer"] is True + assert meta["format"] == "pdf" + assert len(blocks) > 0 + +def test_pdf_blocks_have_text(pdf_path): + parser = PdfParser() + _, blocks = parser.parse(pdf_path) + all_text = " ".join(b.text for b in blocks) + assert "身份证" in all_text or "张伟" in all_text + +def test_pdf_blocks_layer_is_text(pdf_path): + parser = PdfParser() + _, blocks = parser.parse(pdf_path) + assert all(b.layer == "text" for b in blocks) + +def test_pdf_blocks_have_bbox(pdf_path): + parser = PdfParser() + _, blocks = parser.parse(pdf_path) + for b in blocks: + assert len(b.bbox) == 4 + assert b.bbox[2] > b.bbox[0] # x2 > x1 +``` + +**Step 3: 实现 pdf_parser.py** + +```python +# src/info_privacy/parsers/pdf_parser.py +from __future__ import annotations +from io import StringIO +from pathlib import Path + +from pdfminer.converter import PDFPageAggregator +from pdfminer.layout import LAParams, LTChar, LTTextBox, LTTextLine +from pdfminer.pdfdocument import PDFDocument +from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager +from pdfminer.pdfpage import PDFPage +from pdfminer.pdfparser import PDFParser as _PDFParser + +from info_privacy.models import TextBlock + + +class PdfParser: + def parse(self, file_path: str) -> tuple[dict, list[TextBlock]]: + path = Path(file_path) + blocks: list[TextBlock] = [] + has_text_layer = False + + rsrcmgr = PDFResourceManager() + laparams = LAParams(line_margin=0.5, char_margin=2.0) + device = PDFPageAggregator(rsrcmgr, laparams=laparams) + interpreter = PDFPageInterpreter(rsrcmgr, device) + + with open(path, "rb") as f: + parser = _PDFParser(f) + doc = PDFDocument(parser) + for page_num, page in enumerate(PDFPage.create_pages(doc), start=1): + interpreter.process_page(page) + layout = device.get_result() + for element in layout: + if isinstance(element, LTTextBox): + text = element.get_text().strip() + if not text: + continue + has_text_layer = True + # pdfminer bbox: (x0, y0, x1, y1) — bottom-left origin + x0, y0, x1, y1 = element.bbox + blocks.append(TextBlock( + text=text, + bbox=[x0, y0, x1, y1], + page=page_num, + layer="text", + )) + + meta = { + "format": "pdf", + "has_text_layer": has_text_layer, + "redact_strategy": "text_replace" if has_text_layer else "image_mask", + } + return meta, blocks +``` + +**Step 4: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_parsers.py -v +``` + +Expected: 4 passed + +**Step 5: 删除临时文件** + +```bash +rm tmp/test_sample.py +``` + +--- + +## Task 7: 图像 OCR 解析器(PaddleOCR RKNN) + +**Files:** +- Create: `src/info_privacy/parsers/image_parser.py` +- Modify: `tests/test_parsers.py`(追加) + +**Step 1: 追加失败测试** + +```python +# 追加到 tests/test_parsers.py +import cv2 +import numpy as np +from info_privacy.parsers.image_parser import ImageParser + +@pytest.fixture +def sample_image(tmp_path): + img = np.ones((200, 600, 3), dtype=np.uint8) * 255 + cv2.putText(img, "ID: 110101199001011234", (10, 50), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 2) + p = str(tmp_path / "sample.png") + cv2.imwrite(p, img) + return p + +def test_image_parser_returns_blocks(sample_image): + det_model = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/det/ppocrv4_det_ch_int8.rknn" + rec_model = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/rec/ppocrv4_rec_ch_fp16.rknn" + dict_file = "/data/rockchip/paddle_ocr/data/dicts/ppocr_keys_v1.txt" + import os + if not os.path.exists(det_model): + pytest.skip("RKNN模型不存在,跳过(需在RK3588或有模型的环境运行)") + parser = ImageParser(det_model_path=det_model, rec_model_path=rec_model, dict_path=dict_file) + meta, blocks = parser.parse(sample_image) + assert meta["format"] == "image" + assert meta["has_text_layer"] is False + assert meta["redact_strategy"] == "image_mask" + assert isinstance(blocks, list) + +def test_image_parser_block_layer(sample_image): + import os + det_model = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/det/ppocrv4_det_ch_int8.rknn" + if not os.path.exists(det_model): + pytest.skip("RKNN模型不存在") + rec_model = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/rec/ppocrv4_rec_ch_fp16.rknn" + dict_file = "/data/rockchip/paddle_ocr/data/dicts/ppocr_keys_v1.txt" + parser = ImageParser(det_model_path=det_model, rec_model_path=rec_model, dict_path=dict_file) + _, blocks = parser.parse(sample_image) + for b in blocks: + assert b.layer == "image" +``` + +**Step 2: 实现 image_parser.py** + +适配 rknn_model_zoo PaddleOCR-System 推理逻辑(检测 + 识别),输出 TextBlock(layer="image")。 + +```python +# src/info_privacy/parsers/image_parser.py +"""图像 OCR 解析器,基于 rknn_model_zoo PaddleOCR 推理。 + +参考: /data/rockchip/rknn_model_zoo/examples/PPOCR/PPOCR-System/python/ +平台: RK3588(rknn-toolkit-lite2)或 x86 仿真(rknn-toolkit2) +""" +from __future__ import annotations +import sys +import copy +import numpy as np +import cv2 +from pathlib import Path + +from info_privacy.models import TextBlock + +# 将 rknn_model_zoo ppocr 引用加入 path +_PPOCR_DIR = Path("/data/rockchip/rknn_model_zoo/examples/PPOCR/PPOCR-System/python") + + +class ImageParser: + def __init__( + self, + det_model_path: str, + rec_model_path: str, + dict_path: str, + target: str = "rk3588", + ): + if str(_PPOCR_DIR) not in sys.path: + sys.path.insert(0, str(_PPOCR_DIR)) + + import ppocr_det as predict_det + import ppocr_rec as predict_rec + + class Args: + pass + + args = Args() + args.det_model_path = det_model_path + args.rec_model_path = rec_model_path + args.dict_path = dict_path + args.target = target + + self._detector = predict_det.TextDetector(args) + self._recognizer = predict_rec.TextRecognizer(args) + + def parse(self, file_path: str) -> tuple[dict, list[TextBlock]]: + img = cv2.imread(file_path) + if img is None: + raise ValueError(f"无法读取图像: {file_path}") + + blocks = self._run_ocr(img, page=1) + meta = { + "format": "image", + "has_text_layer": False, + "redact_strategy": "image_mask", + } + return meta, blocks + + def run_on_image(self, img: np.ndarray, page: int = 1) -> list[TextBlock]: + """供 PDF 扫描件调用(page 已知)。""" + return self._run_ocr(img, page) + + def _run_ocr(self, img: np.ndarray, page: int) -> list[TextBlock]: + if str(_PPOCR_DIR) not in sys.path: + sys.path.insert(0, str(_PPOCR_DIR)) + import ppocr_det as predict_det + + ori_im = img.copy() + dt_boxes = self._detector.run(img) + if dt_boxes is None or len(dt_boxes) == 0: + return [] + + img_crop_list = [] + for box in sorted(dt_boxes, key=lambda b: (b[0][1], b[0][0])): + crop = predict_det.get_rotate_crop_image(ori_im, box) + img_crop_list.append(crop) + + rec_res = self._recognizer.run(img_crop_list) + + blocks: list[TextBlock] = [] + for box, (text, score) in zip(dt_boxes, rec_res): + if score < 0.5: + continue + xs = box[:, 0] + ys = box[:, 1] + bbox = [float(xs.min()), float(ys.min()), float(xs.max()), float(ys.max())] + blocks.append(TextBlock( + text=text, + bbox=bbox, + page=page, + layer="image", + confidence=float(score), + )) + return blocks +``` + +**Step 3: 运行测试** + +```bash +source venv/bin/activate && pytest tests/test_parsers.py -v +``` + +Expected: 图像测试在无RKNN模型时自动 skip,其余 passed + +--- + +## Task 8: Office 解析器(Word / Excel) + +**Files:** +- Create: `src/info_privacy/parsers/office_parser.py` +- Modify: `tests/test_parsers.py`(追加) + +**Step 1: 追加测试** + +```python +# 追加到 tests/test_parsers.py +import docx +from info_privacy.parsers.office_parser import OfficeParser + +@pytest.fixture +def sample_docx(tmp_path): + doc = docx.Document() + doc.add_paragraph("申请人:李明,身份证:310101198501011234") + doc.add_paragraph("手机:13999998888") + p = str(tmp_path / "sample.docx") + doc.save(p) + return p + +def test_office_parser_docx(sample_docx): + parser = OfficeParser() + meta, blocks = parser.parse(sample_docx) + assert meta["format"] == "docx" + assert meta["has_text_layer"] is True + assert meta["redact_strategy"] == "text_replace" + all_text = " ".join(b.text for b in blocks) + assert "李明" in all_text or "身份证" in all_text +``` + +**Step 2: 实现 office_parser.py** + +```python +# src/info_privacy/parsers/office_parser.py +from __future__ import annotations +from pathlib import Path + +from info_privacy.models import TextBlock + + +class OfficeParser: + def parse(self, file_path: str) -> tuple[dict, list[TextBlock]]: + suffix = Path(file_path).suffix.lower() + if suffix == ".docx": + return self._parse_docx(file_path) + elif suffix in (".xlsx", ".xls"): + return self._parse_excel(file_path) + raise ValueError(f"不支持的 Office 格式: {suffix}") + + def _parse_docx(self, path: str) -> tuple[dict, list[TextBlock]]: + import docx + doc = docx.Document(path) + blocks: list[TextBlock] = [] + for para_idx, para in enumerate(doc.paragraphs): + text = para.text.strip() + if not text: + continue + blocks.append(TextBlock( + text=text, + bbox=[0, para_idx * 20, 500, (para_idx + 1) * 20], + page=1, + layer="text", + )) + meta = { + "format": "docx", + "has_text_layer": True, + "redact_strategy": "text_replace", + } + return meta, blocks + + def _parse_excel(self, path: str) -> tuple[dict, list[TextBlock]]: + import openpyxl + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + blocks: list[TextBlock] = [] + for sheet_idx, ws in enumerate(wb.worksheets): + for row_idx, row in enumerate(ws.iter_rows(values_only=True)): + text = " ".join(str(c) for c in row if c is not None).strip() + if not text: + continue + blocks.append(TextBlock( + text=text, + bbox=[0, row_idx * 20, 500, (row_idx + 1) * 20], + page=sheet_idx + 1, + layer="text", + )) + meta = { + "format": "xlsx", + "has_text_layer": True, + "redact_strategy": "text_replace", + } + return meta, blocks +``` + +**Step 3: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_parsers.py -v +``` + +--- + +## Task 9: Parser Factory + +**Files:** +- Create: `src/info_privacy/parsers/parser_factory.py` +- Modify: `tests/test_parsers.py`(追加) + +**Step 1: 追加测试** + +```python +# 追加到 tests/test_parsers.py +from info_privacy.parsers.parser_factory import ParserFactory + +def test_factory_routes_pdf(pdf_path): + factory = ParserFactory() + parser = factory.get_parser(pdf_path) + from info_privacy.parsers.pdf_parser import PdfParser + assert isinstance(parser, PdfParser) + +def test_factory_routes_docx(sample_docx): + factory = ParserFactory() + parser = factory.get_parser(sample_docx) + from info_privacy.parsers.office_parser import OfficeParser + assert isinstance(parser, OfficeParser) + +def test_factory_unsupported_raises(): + factory = ParserFactory() + with pytest.raises(ValueError, match="不支持"): + factory.get_parser("file.xyz") +``` + +**Step 2: 实现 parser_factory.py** + +```python +# src/info_privacy/parsers/parser_factory.py +from pathlib import Path +from info_privacy.parsers.pdf_parser import PdfParser +from info_privacy.parsers.image_parser import ImageParser +from info_privacy.parsers.office_parser import OfficeParser + +_RKNN_DET = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/det/ppocrv4_det_ch_int8.rknn" +_RKNN_REC = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/rec/ppocrv4_rec_ch_fp16.rknn" +_DICT = "/data/rockchip/paddle_ocr/data/dicts/ppocr_keys_v1.txt" + + +class ParserFactory: + def __init__(self, det_model: str = _RKNN_DET, rec_model: str = _RKNN_REC, dict_path: str = _DICT): + self._det = det_model + self._rec = rec_model + self._dict = dict_path + self._image_parser: ImageParser | None = None + + def get_parser(self, file_path: str) -> PdfParser | ImageParser | OfficeParser: + suffix = Path(file_path).suffix.lower() + if suffix == ".pdf": + return PdfParser() + elif suffix in (".jpg", ".jpeg", ".png", ".tiff", ".bmp"): + return self._get_image_parser() + elif suffix in (".docx", ".xlsx", ".xls"): + return OfficeParser() + raise ValueError(f"不支持的文件格式: {suffix}") + + def _get_image_parser(self) -> ImageParser: + # 延迟初始化(RKNN加载慢) + if self._image_parser is None: + self._image_parser = ImageParser(self._det, self._rec, self._dict) + return self._image_parser +``` + +**Step 3: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_parsers.py -v +``` + +--- + +## Task 10: 文字遮罩器(字节级) + +**Files:** +- Create: `src/info_privacy/redactors/text_redactor.py` +- Create: `tests/test_redactors.py` + +**Step 1: 写失败测试** + +```python +# tests/test_redactors.py +import pytest +import docx +from pathlib import Path +from info_privacy.models import Entity, EntityType +from info_privacy.redactors.text_redactor import TextRedactor + +@pytest.fixture +def sample_docx_path(tmp_path): + doc = docx.Document() + doc.add_paragraph("身份证:310101198501011234 手机:13999998888") + p = tmp_path / "test.docx" + doc.save(str(p)) + return str(p) + +@pytest.fixture +def entity_id_card(): + return Entity( + id="e1", type=EntityType.ID_CARD, + value="310101198501011234", + page=1, bbox=[0,0,100,20], layer="text", security_level="high" + ) + +def test_redact_docx_removes_value(sample_docx_path, entity_id_card, tmp_path): + out_path = str(tmp_path / "out.docx") + redactor = TextRedactor() + redactor.redact(sample_docx_path, [entity_id_card], out_path) + doc = docx.Document(out_path) + all_text = " ".join(p.text for p in doc.paragraphs) + assert "310101198501011234" not in all_text + +def test_redact_docx_preserves_other_text(sample_docx_path, entity_id_card, tmp_path): + out_path = str(tmp_path / "out.docx") + redactor = TextRedactor() + redactor.redact(sample_docx_path, [entity_id_card], out_path) + doc = docx.Document(out_path) + all_text = " ".join(p.text for p in doc.paragraphs) + assert "手机" in all_text +``` + +**Step 2: 实现 text_redactor.py** + +```python +# src/info_privacy/redactors/text_redactor.py +"""文字层遮罩器:直接修改文档文字节点,字节级安全。 + +策略: +- DOCX: 在 python-docx 段落 run 中将目标字符串替换为 ████ +- PDF (含文字层): 使用 pypdf 的 overlay 方式 + 字符替换(见 _redact_pdf) +- XLSX: openpyxl 单元格值替换 +""" +from __future__ import annotations +import re +from pathlib import Path + +from info_privacy.models import Entity + +_MASK = "████" + + +class TextRedactor: + def redact(self, file_path: str, entities: list[Entity], out_path: str) -> None: + suffix = Path(file_path).suffix.lower() + if suffix == ".docx": + self._redact_docx(file_path, entities, out_path) + elif suffix == ".pdf": + self._redact_pdf(file_path, entities, out_path) + elif suffix in (".xlsx", ".xls"): + self._redact_excel(file_path, entities, out_path) + else: + raise ValueError(f"TextRedactor 不支持: {suffix}") + + def _redact_docx(self, path: str, entities: list[Entity], out: str) -> None: + import docx + doc = docx.Document(path) + values = {e.value for e in entities if e.value and e.layer == "text"} + for para in doc.paragraphs: + for run in para.runs: + for val in values: + if val in run.text: + run.text = run.text.replace(val, _MASK) + doc.save(out) + + def _redact_pdf(self, path: str, entities: list[Entity], out: str) -> None: + """PDF 文字层替换:使用 pypdf 读取,通过字节流替换字符串。 + + 注意:pypdf 直接修改文字流存在格式兼容性风险, + 此处采用逐字节替换 + 等长空白填充方案保证格式不变。 + """ + import pypdf + + values = {e.value for e in entities if e.value and e.layer == "text"} + reader = pypdf.PdfReader(path) + writer = pypdf.PdfWriter() + + for page in reader.pages: + # 提取页面内容流,替换目标字符串 + if "/Contents" in page: + writer.add_page(page) + else: + writer.add_page(page) + + # 将 PDF 写出后做字节级替换(更安全) + import tempfile, os + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + writer.write(tmp) + tmp_path = tmp.name + + with open(tmp_path, "rb") as f: + data = f.read() + + for val in values: + encoded = val.encode("utf-8") + replacement = b" " * len(encoded) + data = data.replace(encoded, replacement) + + with open(out, "wb") as f: + f.write(data) + + os.unlink(tmp_path) + + def _redact_excel(self, path: str, entities: list[Entity], out: str) -> None: + import openpyxl + wb = openpyxl.load_workbook(path) + values = {e.value for e in entities if e.value and e.layer == "text"} + for ws in wb.worksheets: + for row in ws.iter_rows(): + for cell in row: + if cell.value and isinstance(cell.value, str): + for val in values: + if val in cell.value: + cell.value = cell.value.replace(val, _MASK) + wb.save(out) +``` + +**Step 3: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_redactors.py -v +``` + +Expected: 2 passed + +--- + +## Task 11: 图像遮罩器(OpenCV) + +**Files:** +- Modify: `src/info_privacy/redactors/image_redactor.py`(新建) +- Modify: `tests/test_redactors.py`(追加) + +**Step 1: 追加测试** + +```python +# 追加到 tests/test_redactors.py +import cv2 +import numpy as np +from info_privacy.redactors.image_redactor import ImageRedactor + +@pytest.fixture +def sample_image_path(tmp_path): + img = np.ones((200, 400, 3), dtype=np.uint8) * 255 + cv2.putText(img, "ID: 123456789", (10, 100), cv2.FONT_HERSHEY_SIMPLEX, 1, (0,0,0), 2) + p = str(tmp_path / "test.png") + cv2.imwrite(p, img) + return p + +@pytest.fixture +def entity_face(): + return Entity(id="f1", type=EntityType.FACE, value=None, + page=1, bbox=[10, 80, 200, 120], layer="image", security_level="high") + +def test_image_redactor_black_box(sample_image_path, entity_face, tmp_path): + out = str(tmp_path / "out.png") + redactor = ImageRedactor() + redactor.redact(sample_image_path, [entity_face], out) + result = cv2.imread(out) + # 遮罩区域应为纯黑 + region = result[80:120, 10:200] + assert region.max() == 0 + +def test_image_redactor_output_same_size(sample_image_path, entity_face, tmp_path): + out = str(tmp_path / "out.png") + redactor = ImageRedactor() + redactor.redact(sample_image_path, [entity_face], out) + orig = cv2.imread(sample_image_path) + result = cv2.imread(out) + assert orig.shape == result.shape +``` + +**Step 2: 实现 image_redactor.py** + +```python +# src/info_privacy/redactors/image_redactor.py +from __future__ import annotations +from pathlib import Path + +import cv2 +import numpy as np + +from info_privacy.models import Entity + + +class ImageRedactor: + def redact(self, file_path: str, entities: list[Entity], out_path: str) -> None: + suffix = Path(file_path).suffix.lower() + if suffix == ".pdf": + self._redact_pdf_image(file_path, entities, out_path) + else: + img = cv2.imread(file_path) + if img is None: + raise ValueError(f"无法读取图像: {file_path}") + for entity in entities: + if entity.layer != "image": + continue + x1, y1, x2, y2 = (int(v) for v in entity.bbox) + cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 0), -1) + cv2.imwrite(out_path, img) + + def _redact_pdf_image(self, path: str, entities: list[Entity], out_path: str) -> None: + """扫描型 PDF:将每页渲染为图像,遮罩后重新组合为 PDF。""" + import pypdf + from PIL import Image + import io + + reader = pypdf.PdfReader(path) + writer = pypdf.PdfWriter() + + for page_num, page in enumerate(reader.pages, start=1): + page_entities = [e for e in entities if e.page == page_num and e.layer == "image"] + if not page_entities: + writer.add_page(page) + continue + + # 提取页面图像 + for img_obj in page.images: + img_data = img_obj.data + img = np.frombuffer(img_data, dtype=np.uint8) + img = cv2.imdecode(img, cv2.IMREAD_COLOR) + if img is None: + continue + for entity in page_entities: + x1, y1, x2, y2 = (int(v) for v in entity.bbox) + cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 0), -1) + # 将遮罩后图像编码回 PDF 页面 + _, buf = cv2.imencode(".png", img) + pil_img = Image.open(io.BytesIO(buf.tobytes())) + img_byte_arr = io.BytesIO() + pil_img.save(img_byte_arr, format="PDF") + break + + writer.add_page(page) + + with open(out_path, "wb") as f: + writer.write(f) +``` + +**Step 3: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_redactors.py -v +``` + +Expected: 4 passed + +--- + +## Task 12: 人脸检测器(mediapipe RKNN) + +**Files:** +- Create: `src/info_privacy/detectors/face_detector.py` +- Modify: `tests/test_detectors.py`(追加) + +**Step 1: 追加测试** + +```python +# 追加到 tests/test_detectors.py +import os, cv2, numpy as np +from info_privacy.detectors.face_detector import FaceDetector + +FACE_MODEL = "/data/rockchip/mediapipe/models/rknn/face_detection_short_range_rk3588.rknn" + +def test_face_detector_skip_if_no_model(): + if not os.path.exists(FACE_MODEL): + pytest.skip("人脸RKNN模型不存在,跳过") + +def test_face_detector_returns_entities(): + if not os.path.exists(FACE_MODEL): + pytest.skip("人脸RKNN模型不存在,跳过") + detector = FaceDetector(FACE_MODEL) + # 白色空图像,无人脸 + img = np.ones((300, 300, 3), dtype=np.uint8) * 255 + entities = detector.detect_in_image(img, page=1) + assert isinstance(entities, list) +``` + +**Step 2: 实现 face_detector.py** + +```python +# src/info_privacy/detectors/face_detector.py +"""人脸检测器,复用 mediapipe_rknn FaceDetection。 + +参考: /data/rockchip/mediapipe/src/mediapipe_rknn/solutions/face_detection.py +""" +from __future__ import annotations +import sys +import uuid +from pathlib import Path + +import numpy as np + +from info_privacy.models import Entity, EntityType + +_MEDIAPIPE_SRC = Path("/data/rockchip/mediapipe/src") + + +class FaceDetector: + def __init__(self, model_path: str = "/data/rockchip/mediapipe/models/rknn/face_detection_short_range_rk3588.rknn"): + if str(_MEDIAPIPE_SRC) not in sys.path: + sys.path.insert(0, str(_MEDIAPIPE_SRC)) + from mediapipe_rknn.solutions import FaceDetection + self._detector = FaceDetection(model_path=model_path) + self._detector.load() + + def detect_in_image(self, img: np.ndarray, page: int = 1) -> list[Entity]: + """在图像 numpy 数组中检测所有人脸,返回 Entity 列表。""" + results = self._detector.detect(img) + entities: list[Entity] = [] + if results is None: + return entities + for det in results: + bbox = det.bbox # [x1, y1, x2, y2] 像素坐标 + entities.append(Entity( + id=str(uuid.uuid4()), + type=EntityType.FACE, + value=None, + page=page, + bbox=[float(v) for v in bbox], + layer="image", + security_level="high", + )) + return entities +``` + +**Step 3: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_detectors.py -v +``` + +--- + +## Task 13: Pipeline 串联 + +**Files:** +- Create: `src/info_privacy/pipeline.py` +- Create: `tests/test_pipeline.py` + +**Step 1: 写失败测试** + +```python +# tests/test_pipeline.py +import pytest, docx +from info_privacy.pipeline import PrivacyPipeline + +@pytest.fixture +def pipeline(): + return PrivacyPipeline() + +@pytest.fixture +def sample_docx_path(tmp_path): + doc = docx.Document() + doc.add_paragraph("姓名:张伟 身份证:110101199001011234 电话:13812345678") + p = tmp_path / "test.docx" + doc.save(str(p)) + return str(p) + +def test_analyze_docx(pipeline, sample_docx_path): + report = pipeline.analyze(sample_docx_path) + assert report.blocked is False + assert len(report.entities) > 0 + types = {e.type.value for e in report.entities} + assert "id_card" in types or "phone" in types + +def test_analyze_classified_doc(pipeline, tmp_path): + doc = docx.Document() + doc.add_paragraph("【机密】本文件仅供内部使用") + p = tmp_path / "secret.docx" + doc.save(str(p)) + report = pipeline.analyze(str(p)) + assert report.blocked is True + assert report.classification.value == "classified" + +def test_redact_docx_removes_entity(pipeline, sample_docx_path, tmp_path): + out = str(tmp_path / "out.docx") + pipeline.redact(sample_docx_path, redact_types=["id_card"], out_path=out) + doc = docx.Document(out) + all_text = " ".join(p.text for p in doc.paragraphs) + assert "110101199001011234" not in all_text + assert "张伟" in all_text # 未指定遮罩 name,保留 +``` + +**Step 2: 实现 pipeline.py** + +```python +# src/info_privacy/pipeline.py +from __future__ import annotations +import uuid +from pathlib import Path + +from info_privacy.models import Classification, DetectionReport, EntityType, TextBlock +from info_privacy.classifier.doc_classifier import DocClassifier +from info_privacy.detectors.regex_detector import RegexDetector +from info_privacy.detectors.ner_detector import NERDetector +from info_privacy.parsers.parser_factory import ParserFactory +from info_privacy.redactors.text_redactor import TextRedactor +from info_privacy.redactors.image_redactor import ImageRedactor + +_CONFIG = "configs/pii_rules.yaml" + + +class PrivacyPipeline: + def __init__(self, config_path: str = _CONFIG): + self._factory = ParserFactory() + self._classifier = DocClassifier(config_path) + self._regex = RegexDetector(config_path) + self._ner = NERDetector(config_path) + self._text_redactor = TextRedactor() + self._image_redactor = ImageRedactor() + self._face_detector = None # 延迟加载(RKNN 初始化慢) + + def analyze(self, file_path: str) -> DetectionReport: + parser = self._factory.get_parser(file_path) + meta, blocks = parser.parse(file_path) + + # Step 0: 保密文件快速拦截(只看文字块) + clf = self._classifier.classify(blocks) + if clf.blocked: + return DetectionReport( + doc_id=str(uuid.uuid4()), + classification=clf.classification, + blocked=True, + block_reason=clf.block_reason, + warning=None, + doc_meta=meta, + entities=[], + ) + + # Step 1: 检测 PII + entities = self._regex.detect(blocks) + self._ner.detect(blocks) + + # Step 2: 图像文档额外做人脸检测 + if meta.get("redact_strategy") == "image_mask": + import cv2 + img = cv2.imread(file_path) + if img is not None: + entities += self._get_face_detector().detect_in_image(img, page=1) + + # Step 3: 再次评估分类(现在有 entities 了) + clf = self._classifier.classify(blocks, entities=entities) + + return DetectionReport( + doc_id=str(uuid.uuid4()), + classification=clf.classification, + blocked=False, + block_reason=None, + warning=clf.warning, + doc_meta=meta, + entities=entities, + ) + + def redact(self, file_path: str, redact_types: list[str], out_path: str) -> dict: + """遮罩指定类型的实体,自动选最安全策略,返回安全报告。""" + report = self.analyze(file_path) + if report.blocked: + raise ValueError(f"文件被拦截,无法遮罩: {report.block_reason}") + + target_types = {EntityType(t) for t in redact_types} + to_redact = [e for e in report.entities if e.type in target_types] + + text_entities = [e for e in to_redact if e.layer == "text"] + image_entities = [e for e in to_redact if e.layer == "image"] + + strategy = report.doc_meta.get("redact_strategy", "image_mask") + + if text_entities: + self._text_redactor.redact(file_path, text_entities, out_path) + if image_entities: + src = out_path if text_entities else file_path + self._image_redactor.redact(src, image_entities, out_path) + + if not text_entities and not image_entities: + import shutil + shutil.copy(file_path, out_path) + + return { + "redacted": {t.value: sum(1 for e in to_redact if e.type == t) for t in target_types}, + "strategy_used": strategy, + "security_guarantee": "byte_level" if strategy == "text_replace" else "image_level", + } + + def _get_face_detector(self): + if self._face_detector is None: + from info_privacy.detectors.face_detector import FaceDetector + self._face_detector = FaceDetector() + return self._face_detector +``` + +**Step 3: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_pipeline.py -v +``` + +Expected: 3 passed + +--- + +## Task 14: FastAPI 路由层 + +**Files:** +- Create: `src/info_privacy/api/models.py` +- Create: `src/info_privacy/api/routes/analyze.py` +- Create: `src/info_privacy/api/routes/redact.py` +- Create: `src/info_privacy/api/main.py` +- Create: `tests/test_api.py` + +**Step 1: 创建 Pydantic 响应模型** + +```python +# src/info_privacy/api/models.py +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel +from info_privacy.models import Classification, EntityType + + +class EntityOut(BaseModel): + id: str + type: str + value: Optional[str] + page: int + bbox: list[float] + layer: str + security_level: str + + +class DocMeta(BaseModel): + format: str + has_text_layer: bool + redact_strategy: str + + +class AnalyzeResponse(BaseModel): + doc_id: str + classification: str + blocked: bool + block_reason: Optional[str] + warning: Optional[str] + doc_meta: DocMeta + entities: list[EntityOut] + summary: dict[str, int] + + +class RedactConfig(BaseModel): + redact_types: list[str] +``` + +**Step 2: 创建 analyze 路由** + +```python +# src/info_privacy/api/routes/analyze.py +import tempfile, os +from fastapi import APIRouter, File, UploadFile, HTTPException +from info_privacy.api.models import AnalyzeResponse, EntityOut, DocMeta +from info_privacy.pipeline import PrivacyPipeline + +router = APIRouter() +_pipeline = PrivacyPipeline() + + +@router.post("/analyze", response_model=AnalyzeResponse) +async def analyze(file: UploadFile = File(...)): + suffix = os.path.splitext(file.filename or "")[1].lower() or ".tmp" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(await file.read()) + tmp_path = tmp.name + + try: + report = _pipeline.analyze(tmp_path) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + finally: + os.unlink(tmp_path) + + return AnalyzeResponse( + doc_id=report.doc_id, + classification=report.classification.value, + blocked=report.blocked, + block_reason=report.block_reason, + warning=report.warning, + doc_meta=DocMeta(**report.doc_meta), + entities=[EntityOut( + id=e.id, type=e.type.value, value=e.value, + page=e.page, bbox=e.bbox, layer=e.layer, + security_level=e.security_level, + ) for e in report.entities], + summary=report.summary, + ) +``` + +**Step 3: 创建 redact 路由** + +```python +# src/info_privacy/api/routes/redact.py +import tempfile, os, json +from pathlib import Path +from fastapi import APIRouter, File, Form, UploadFile, HTTPException +from fastapi.responses import FileResponse +from info_privacy.api.models import RedactConfig +from info_privacy.pipeline import PrivacyPipeline + +router = APIRouter() +_pipeline = PrivacyPipeline() + + +@router.post("/redact") +async def redact( + file: UploadFile = File(...), + config: str = Form(...), +): + try: + cfg = RedactConfig.model_validate_json(config) + except Exception as e: + raise HTTPException(status_code=422, detail=f"config 格式错误: {e}") + + suffix = os.path.splitext(file.filename or "")[1].lower() or ".tmp" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(await file.read()) + in_path = tmp.name + + out_path = in_path + "_redacted" + suffix + + try: + security_report = _pipeline.redact(in_path, cfg.redact_types, out_path) + except ValueError as e: + os.unlink(in_path) + raise HTTPException(status_code=403, detail=str(e)) + finally: + os.unlink(in_path) + + return FileResponse( + out_path, + media_type="application/octet-stream", + filename=f"redacted_{file.filename}", + headers={"X-Security-Report": json.dumps(security_report, ensure_ascii=False)}, + background=None, + ) +``` + +**Step 4: 创建 main.py** + +```python +# src/info_privacy/api/main.py +from fastapi import FastAPI +from info_privacy.api.routes.analyze import router as analyze_router +from info_privacy.api.routes.redact import router as redact_router + +app = FastAPI(title="Info-Privacy API", version="0.1.0") + +app.include_router(analyze_router, prefix="/api/v1") +app.include_router(redact_router, prefix="/api/v1") + + +@app.get("/api/v1/health") +def health(): + return {"status": "ok"} + + +@app.get("/api/v1/types") +def types(): + return { + "entity_types": [ + "id_card", "phone", "bank_card", "email", + "license_plate", "name", "address", "face", + ], + "note": "redact_strategy is auto-selected (text_replace for text layer, image_mask for images)" + } +``` + +**Step 5: 写 API 集成测试** + +```python +# tests/test_api.py +import pytest, docx, io +from fastapi.testclient import TestClient +from info_privacy.api.main import app + +client = TestClient(app) + +def test_health(): + r = client.get("/api/v1/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + +def test_types(): + r = client.get("/api/v1/types") + assert r.status_code == 200 + assert "id_card" in r.json()["entity_types"] + +def test_analyze_docx(tmp_path): + doc = docx.Document() + doc.add_paragraph("身份证:110101199001011234 电话:13812345678") + p = tmp_path / "test.docx" + doc.save(str(p)) + with open(p, "rb") as f: + r = client.post("/api/v1/analyze", files={"file": ("test.docx", f, "application/octet-stream")}) + assert r.status_code == 200 + data = r.json() + assert data["blocked"] is False + types = {e["type"] for e in data["entities"]} + assert "id_card" in types or "phone" in types + +def test_analyze_classified(tmp_path): + doc = docx.Document() + doc.add_paragraph("【机密】内部文件") + p = tmp_path / "secret.docx" + doc.save(str(p)) + with open(p, "rb") as f: + r = client.post("/api/v1/analyze", files={"file": ("secret.docx", f, "application/octet-stream")}) + assert r.status_code == 200 + assert r.json()["blocked"] is True + +def test_redact_docx(tmp_path): + import json + doc = docx.Document() + doc.add_paragraph("身份证:110101199001011234 地址:北京市") + p = tmp_path / "test.docx" + doc.save(str(p)) + cfg = json.dumps({"redact_types": ["id_card"]}) + with open(p, "rb") as f: + r = client.post("/api/v1/redact", + files={"file": ("test.docx", f, "application/octet-stream")}, + data={"config": cfg}) + assert r.status_code == 200 + result_doc = docx.Document(io.BytesIO(r.content)) + all_text = " ".join(para.text for para in result_doc.paragraphs) + assert "110101199001011234" not in all_text +``` + +**Step 6: 运行确认通过** + +```bash +source venv/bin/activate && pytest tests/test_api.py -v +``` + +Expected: 5 passed + +--- + +## Task 15: 启动脚本与端到端验证 + +**Files:** +- Create: `scripts/start_server.sh` +- Create: `scripts/verify.py` + +**Step 1: 创建 start_server.sh** + +```bash +#!/bin/bash +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." +source venv/bin/activate +uvicorn info_privacy.api.main:app --host 0.0.0.0 --port 8000 +``` + +**Step 2: 创建 verify.py** + +```python +# scripts/verify.py +"""端到端验证脚本:在本地启动的服务上测试完整流程。""" +import sys, json, io, docx, requests + +BASE = "http://localhost:8000/api/v1" + +def check(name, cond, detail=""): + mark = "✓" if cond else "✗" + print(f" {mark} {name}" + (f": {detail}" if detail else "")) + if not cond: + sys.exit(1) + +def make_docx_bytes(text: str) -> bytes: + doc = docx.Document() + doc.add_paragraph(text) + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() + +print("=== Info-Privacy 端到端验证 ===\n") + +# 1. health +r = requests.get(f"{BASE}/health") +check("health 接口", r.status_code == 200) + +# 2. analyze - 正常文档 +docx_bytes = make_docx_bytes("身份证:110101199001011234 电话:13812345678") +r = requests.post(f"{BASE}/analyze", files={"file": ("test.docx", docx_bytes)}) +check("analyze 状态码", r.status_code == 200) +data = r.json() +check("analyze 未拦截", not data["blocked"]) +types = {e["type"] for e in data["entities"]} +check("检测到 id_card 或 phone", "id_card" in types or "phone" in types, str(types)) + +# 3. analyze - 保密文件 +secret_bytes = make_docx_bytes("【机密】本文件严禁外传") +r = requests.post(f"{BASE}/analyze", files={"file": ("secret.docx", secret_bytes)}) +check("机密文件被拦截", r.json()["blocked"]) + +# 4. redact +docx_bytes = make_docx_bytes("身份证:110101199001011234 地址:北京市朝阳区") +cfg = json.dumps({"redact_types": ["id_card"]}) +r = requests.post(f"{BASE}/redact", + files={"file": ("test.docx", docx_bytes)}, + data={"config": cfg}) +check("redact 状态码", r.status_code == 200) +result = docx.Document(io.BytesIO(r.content)) +all_text = " ".join(p.text for p in result.paragraphs) +check("身份证已遮罩", "110101199001011234" not in all_text) +check("地址保留", "北京" in all_text) + +print("\n=== 所有验证通过 ✓ ===") +``` + +**Step 3: 验证完整流程** + +先启动服务(另一终端): +```bash +source venv/bin/activate && uvicorn info_privacy.api.main:app --port 8000 +``` + +再运行验证: +```bash +source venv/bin/activate && python scripts/verify.py +``` + +Expected: +``` +=== Info-Privacy 端到端验证 === + ✓ health 接口 + ✓ analyze 状态码 + ✓ analyze 未拦截 + ✓ 检测到 id_card 或 phone + ✓ 机密文件被拦截 + ✓ redact 状态码 + ✓ 身份证已遮罩 + ✓ 地址保留 +=== 所有验证通过 ✓ === +``` + +**Step 4: 更新 CLAUDE.md** + +用 Task 1 中的项目结构和实际命令更新 `/data/rockchip/info-privacy/CLAUDE.md`。 + +--- + +## 全量测试确认 + +```bash +source venv/bin/activate && pytest tests/ -v --tb=short +``` + +Expected: 所有测试通过(图像RKNN相关在无模型环境自动 skip) + +--- + +## RK3588 板端部署验证 + +```bash +# 同步到板端 +rsync -av /data/rockchip/info-privacy/ pi@192.168.0.127:/home/pi/Desktop/info-privacy/ + +# 板端验证 +ssh pi@192.168.0.127 "clashon && clashproxy on && \ + cd /home/pi/Desktop/info-privacy && \ + bash setup_venv.sh && \ + source venv/bin/activate && \ + pytest tests/ -v -k 'not skip'" +``` diff --git a/models/.gitkeep b/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..38fddc9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "info-privacy" +version = "0.1.0" +description = "Document privacy redaction service for Rockchip RKNN" +requires-python = ">=3.9" +dependencies = [ + "fastapi>=0.110.0", + "uvicorn>=0.29.0", + "python-multipart>=0.0.9", + "pdfminer.six>=20221105", + "pypdf>=4.0.0", + "python-docx>=1.1.0", + "openpyxl>=3.1.0", + "opencv-python>=4.5.5.64", + "numpy>=1.24.0", + "pyyaml>=6.0", + "pyclipper>=1.3.0", + "shapely>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "httpx>=0.27.0", + "ruff>=0.4.0", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/start_server.sh b/scripts/start_server.sh new file mode 100755 index 0000000..959758e --- /dev/null +++ b/scripts/start_server.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." +source venv/bin/activate +uvicorn info_privacy.api.main:app --host 0.0.0.0 --port 8000 diff --git a/scripts/verify.py b/scripts/verify.py new file mode 100755 index 0000000..b6b70a2 --- /dev/null +++ b/scripts/verify.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""端到端验证脚本:在本地启动的服务上测试完整流程。""" +import sys +import json +import io + +try: + import docx + import requests +except ImportError as e: + print(f"缺少依赖: {e}") + sys.exit(1) + +BASE = "http://localhost:8000/api/v1" + + +def check(name, cond, detail=""): + mark = "✓" if cond else "✗" + print(f" {mark} {name}" + (f": {detail}" if detail else "")) + if not cond: + sys.exit(1) + + +def make_docx_bytes(text: str) -> bytes: + doc = docx.Document() + doc.add_paragraph(text) + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() + + +print("=== Info-Privacy 端到端验证 ===\n") + +# 1. health +try: + r = requests.get(f"{BASE}/health", timeout=5) + check("health 接口", r.status_code == 200) +except requests.exceptions.ConnectionError: + print(" ✗ 服务未启动,请先运行: make server") + sys.exit(1) + +# 2. types +r = requests.get(f"{BASE}/types") +check("types 接口", r.status_code == 200) +check("types 包含 id_card", "id_card" in r.json().get("entity_types", [])) + +# 3. analyze - 正常文档 +docx_bytes = make_docx_bytes("身份证:110101199001011234 电话:13812345678") +r = requests.post(f"{BASE}/analyze", files={"file": ("test.docx", docx_bytes)}) +check("analyze 状态码", r.status_code == 200) +data = r.json() +check("analyze 未拦截", not data["blocked"]) +types_found = {e["type"] for e in data["entities"]} +check("检测到 id_card 或 phone", "id_card" in types_found or "phone" in types_found, str(types_found)) + +# 4. analyze - 保密文件 +secret_bytes = make_docx_bytes("【机密】本文件严禁外传") +r = requests.post(f"{BASE}/analyze", files={"file": ("secret.docx", secret_bytes)}) +check("机密文件被拦截", r.json()["blocked"]) + +# 5. redact +docx_bytes = make_docx_bytes("身份证:110101199001011234 地址:北京市朝阳区") +cfg = json.dumps({"redact_types": ["id_card"]}) +r = requests.post(f"{BASE}/redact", + files={"file": ("test.docx", docx_bytes)}, + data={"config": cfg}) +check("redact 状态码", r.status_code == 200) +result = docx.Document(io.BytesIO(r.content)) +all_text = " ".join(p.text for p in result.paragraphs) +check("身份证已遮罩", "110101199001011234" not in all_text) +check("地址保留", "北京" in all_text) + +print("\n=== 所有验证通过 ✓ ===") diff --git a/setup_venv.sh b/setup_venv.sh new file mode 100755 index 0000000..736ae71 --- /dev/null +++ b/setup_venv.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e +command -v python3 >/dev/null || { echo "错误: python3 未找到"; exit 1; } +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip +pip install -e ".[dev]" +echo "venv ready. Run: source venv/bin/activate" diff --git a/src/info_privacy/__init__.py b/src/info_privacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/info_privacy/api/__init__.py b/src/info_privacy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/info_privacy/api/main.py b/src/info_privacy/api/main.py new file mode 100644 index 0000000..3b2a3d3 --- /dev/null +++ b/src/info_privacy/api/main.py @@ -0,0 +1,27 @@ +# src/info_privacy/api/main.py +from fastapi import FastAPI +from info_privacy.api.routes.analyze import router as analyze_router +from info_privacy.api.routes.frame import router as frame_router +from info_privacy.api.routes.redact import router as redact_router + +app = FastAPI(title="Info-Privacy API", version="0.1.0") + +app.include_router(analyze_router, prefix="/api/v1") +app.include_router(frame_router, prefix="/api/v1") +app.include_router(redact_router, prefix="/api/v1") + + +@app.get("/api/v1/health") +def health(): + return {"status": "ok"} + + +@app.get("/api/v1/types") +def types(): + return { + "entity_types": [ + "id_card", "phone", "bank_card", "email", + "license_plate", "name", "address", "face", + ], + "note": "redact_strategy is auto-selected (text_replace for text layer, image_mask for images)" + } diff --git a/src/info_privacy/api/models.py b/src/info_privacy/api/models.py new file mode 100644 index 0000000..0862fdd --- /dev/null +++ b/src/info_privacy/api/models.py @@ -0,0 +1,35 @@ +# src/info_privacy/api/models.py +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel + + +class EntityOut(BaseModel): + id: str + type: str + value: Optional[str] + page: int + bbox: list[float] + layer: str + security_level: str + + +class DocMeta(BaseModel): + format: str + has_text_layer: bool + redact_strategy: str + + +class AnalyzeResponse(BaseModel): + doc_id: str + classification: str + blocked: bool + block_reason: Optional[str] + warning: Optional[str] + doc_meta: DocMeta + entities: list[EntityOut] + summary: dict[str, int] + + +class RedactConfig(BaseModel): + redact_types: list[str] diff --git a/src/info_privacy/api/routes/__init__.py b/src/info_privacy/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/info_privacy/api/routes/analyze.py b/src/info_privacy/api/routes/analyze.py new file mode 100644 index 0000000..801b0ab --- /dev/null +++ b/src/info_privacy/api/routes/analyze.py @@ -0,0 +1,39 @@ +# src/info_privacy/api/routes/analyze.py +import tempfile +import os +from fastapi import APIRouter, File, UploadFile, HTTPException +from info_privacy.api.models import AnalyzeResponse, EntityOut, DocMeta +from info_privacy.pipeline import PrivacyPipeline + +router = APIRouter() +_pipeline = PrivacyPipeline() + + +@router.post("/analyze", response_model=AnalyzeResponse) +async def analyze(file: UploadFile = File(...)): + suffix = os.path.splitext(file.filename or "")[1].lower() or ".tmp" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(await file.read()) + tmp_path = tmp.name + + try: + report = _pipeline.analyze(tmp_path) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + finally: + os.unlink(tmp_path) + + return AnalyzeResponse( + doc_id=report.doc_id, + classification=report.classification.value, + blocked=report.blocked, + block_reason=report.block_reason, + warning=report.warning, + doc_meta=DocMeta(**report.doc_meta), + entities=[EntityOut( + id=e.id, type=e.type.value, value=e.value, + page=e.page, bbox=e.bbox, layer=e.layer, + security_level=e.security_level, + ) for e in report.entities], + summary=report.summary, + ) diff --git a/src/info_privacy/api/routes/frame.py b/src/info_privacy/api/routes/frame.py new file mode 100644 index 0000000..10e20a2 --- /dev/null +++ b/src/info_privacy/api/routes/frame.py @@ -0,0 +1,84 @@ +# src/info_privacy/api/routes/frame.py +"""视频帧 PII 分析端点 —— 接受 base64 编码图像或 multipart 上传。""" +from __future__ import annotations + +import base64 +import io + +import numpy as np +from fastapi import APIRouter, File, HTTPException, UploadFile +from pydantic import BaseModel + +from info_privacy.api.models import AnalyzeResponse, DocMeta, EntityOut +from info_privacy.pipeline import PrivacyPipeline + +router = APIRouter() +_pipeline = PrivacyPipeline() + + +class FrameBase64Request(BaseModel): + """Base64 编码图像帧请求体。""" + image: str # base64-encoded JPEG/PNG bytes + format: str = "jpeg" + + +@router.post("/analyze/frame", response_model=AnalyzeResponse) +async def analyze_frame_upload(file: UploadFile = File(...)): + """接受 multipart 图像文件上传,返回 PII 检测报告。 + + 适用于 Privacy Gateway 上传附件预处理。 + 图像数据在内存处理,不写磁盘(临时文件由 pipeline 内部管理)。 + """ + try: + import cv2 + data = await file.read() + arr = np.frombuffer(data, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if img is None: + raise HTTPException(status_code=400, detail="无法解码图像文件") + report = _pipeline.analyze_image(img) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"图像分析失败: {e}") + + return _build_response(report) + + +@router.post("/analyze/frame/base64", response_model=AnalyzeResponse) +async def analyze_frame_base64(req: FrameBase64Request): + """接受 base64 编码图像,返回 PII 检测报告。 + + 适用于 KVM OCR Monitor 将视频帧直接传递(无磁盘 I/O)。 + """ + try: + import cv2 + raw = base64.b64decode(req.image) + arr = np.frombuffer(raw, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if img is None: + raise HTTPException(status_code=400, detail="base64 解码后无法识别为图像") + report = _pipeline.analyze_image(img) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"图像分析失败: {e}") + + return _build_response(report) + + +def _build_response(report) -> AnalyzeResponse: + return AnalyzeResponse( + doc_id=report.doc_id, + classification=report.classification.value, + blocked=report.blocked, + block_reason=report.block_reason, + warning=report.warning, + doc_meta=DocMeta(**report.doc_meta), + entities=[EntityOut( + id=e.id, type=e.type.value, value=e.value, + page=e.page, bbox=e.bbox, layer=e.layer, + security_level=e.security_level, + ) for e in report.entities], + summary=report.summary, + ) diff --git a/src/info_privacy/api/routes/redact.py b/src/info_privacy/api/routes/redact.py new file mode 100644 index 0000000..63440b5 --- /dev/null +++ b/src/info_privacy/api/routes/redact.py @@ -0,0 +1,45 @@ +# src/info_privacy/api/routes/redact.py +import tempfile +import os +import json +from fastapi import APIRouter, File, Form, UploadFile, HTTPException +from fastapi.responses import FileResponse +from info_privacy.api.models import RedactConfig +from info_privacy.pipeline import PrivacyPipeline + +router = APIRouter() +_pipeline = PrivacyPipeline() + + +@router.post("/redact") +async def redact( + file: UploadFile = File(...), + config: str = Form(...), +): + try: + cfg = RedactConfig.model_validate_json(config) + except Exception as e: + raise HTTPException(status_code=422, detail=f"config 格式错误: {e}") + + suffix = os.path.splitext(file.filename or "")[1].lower() or ".tmp" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(await file.read()) + in_path = tmp.name + + out_path = in_path + "_redacted" + suffix + + try: + security_report = _pipeline.redact(in_path, cfg.redact_types, out_path) + except ValueError as e: + os.unlink(in_path) + raise HTTPException(status_code=403, detail=str(e)) + finally: + if os.path.exists(in_path): + os.unlink(in_path) + + return FileResponse( + out_path, + media_type="application/octet-stream", + filename=f"redacted_{file.filename}", + headers={"X-Security-Report": json.dumps(security_report, ensure_ascii=False)}, + ) diff --git a/src/info_privacy/classifier/__init__.py b/src/info_privacy/classifier/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/info_privacy/classifier/doc_classifier.py b/src/info_privacy/classifier/doc_classifier.py new file mode 100644 index 0000000..0a1cea7 --- /dev/null +++ b/src/info_privacy/classifier/doc_classifier.py @@ -0,0 +1,91 @@ +# src/info_privacy/classifier/doc_classifier.py +from __future__ import annotations +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import yaml + +from info_privacy.models import Classification, Entity, TextBlock + + +SENSITIVE_PARTIAL_THRESHOLD = 5 # 超过N个高风险实体触发警告 + + +@dataclass +class ClassificationResult: + classification: Classification + blocked: bool + block_reason: Optional[str] + warning: Optional[str] + + +def _build_automaton(keywords: list[str]): + """构建 Aho-Corasick 自动机;不可用时降级为普通列表。""" + try: + import ahocorasick + A = ahocorasick.Automaton() + for kw in keywords: + A.add_word(kw, kw) + A.make_automaton() + return A + except ImportError: + return None + + +class DocClassifier: + def __init__(self, config_path: str = "configs/pii_rules.yaml"): + cfg = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) + raw = [kw.upper() for kw in cfg.get("confidential_keywords", [])] + self._keywords = raw + # Aho-Corasick 单次扫描 O(n+m),降级时用 O(n·m) list + self._automaton = _build_automaton(raw) + + def classify( + self, + blocks: list[TextBlock], + entities: Optional[list[Entity]] = None, + ) -> ClassificationResult: + # 优先检查保密标记 + hit = self._find_keyword(blocks) + if hit is not None: + kw, page = hit + return ClassificationResult( + classification=Classification.CLASSIFIED, + blocked=True, + block_reason=f"检测到保密标记:\u201c{kw}\u201d(第{page}页)", + warning=None, + ) + + # 高密度敏感实体检查 + if entities: + high_risk = [e for e in entities if e.security_level == "high"] + if len(high_risk) >= SENSITIVE_PARTIAL_THRESHOLD: + return ClassificationResult( + classification=Classification.SENSITIVE_PARTIAL, + blocked=False, + block_reason=None, + warning=f"文档含 {len(high_risk)} 处高风险隐私实体,建议全量处理", + ) + + return ClassificationResult( + classification=Classification.NORMAL, + blocked=False, + block_reason=None, + warning=None, + ) + + def _find_keyword(self, blocks: list[TextBlock]) -> Optional[tuple[str, int]]: + """返回首个命中的 (keyword, page),未命中返回 None。""" + if self._automaton is not None: + for block in blocks: + text_upper = block.text.upper() + for _, kw in self._automaton.iter(text_upper): + return kw, block.page + else: + for block in blocks: + text_upper = block.text.upper() + for kw in self._keywords: + if kw in text_upper: + return kw, block.page + return None diff --git a/src/info_privacy/detectors/__init__.py b/src/info_privacy/detectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/info_privacy/detectors/face_detector.py b/src/info_privacy/detectors/face_detector.py new file mode 100644 index 0000000..c971a5d --- /dev/null +++ b/src/info_privacy/detectors/face_detector.py @@ -0,0 +1,45 @@ +# src/info_privacy/detectors/face_detector.py +"""人脸检测器,复用 mediapipe_rknn FaceDetection。 + +参考: /data/rockchip/mediapipe/src/mediapipe_rknn/solutions/face_detection.py +""" +from __future__ import annotations +import sys +import uuid +from pathlib import Path +from typing import Optional + +import numpy as np + +from info_privacy.models import Entity, EntityType + +_MEDIAPIPE_SRC = Path("/data/rockchip/mediapipe/src") +_DEFAULT_MODEL = "/data/rockchip/mediapipe/models/rknn/face_detection_short_range_rk3588.rknn" + + +class FaceDetector: + def __init__(self, model_path: str = _DEFAULT_MODEL): + if str(_MEDIAPIPE_SRC) not in sys.path: + sys.path.insert(0, str(_MEDIAPIPE_SRC)) + from mediapipe_rknn.solutions import FaceDetection + self._detector = FaceDetection(model_path=model_path) + self._detector.load() + + def detect_in_image(self, img: np.ndarray, page: int = 1) -> list[Entity]: + """在图像 numpy 数组中检测所有人脸,返回 Entity 列表。""" + results = self._detector.detect(img) + entities: list[Entity] = [] + if results is None: + return entities + for det in results: + bbox = det.bbox # [x1, y1, x2, y2] 像素坐标 + entities.append(Entity( + id=str(uuid.uuid4()), + type=EntityType.FACE, + value=None, + page=page, + bbox=[float(v) for v in bbox], + layer="image", + security_level="high", + )) + return entities diff --git a/src/info_privacy/detectors/ner_detector.py b/src/info_privacy/detectors/ner_detector.py new file mode 100644 index 0000000..f4cebb6 --- /dev/null +++ b/src/info_privacy/detectors/ner_detector.py @@ -0,0 +1,57 @@ +# src/info_privacy/detectors/ner_detector.py +import re +import uuid +from pathlib import Path + +import yaml + +from info_privacy.models import Entity, EntityType, TextBlock + + +class NERDetector: + def __init__(self, config_path: str = "configs/pii_rules.yaml"): + cfg = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) + ner_cfg = cfg.get("ner", {}) + + surnames_file = ner_cfg.get("name", {}).get("surnames_file", "configs/surnames.txt") + surnames = [ + line.strip() for line in Path(surnames_file).read_text(encoding="utf-8").splitlines() + if line.strip() and not line.startswith("#") + ] + surname_pattern = "|".join(re.escape(s) for s in surnames) + # 姓氏 + 2-4个汉字姓名 + self._name_re = re.compile( + rf'(? list[Entity]: + entities: list[Entity] = [] + for block in blocks: + for match in self._name_re.finditer(block.text): + entities.append(Entity( + id=str(uuid.uuid4()), + type=EntityType.NAME, + value=match.group(), + page=block.page, + bbox=block.bbox, + layer=block.layer, + security_level="medium", + )) + for match in self._addr_re.finditer(block.text): + entities.append(Entity( + id=str(uuid.uuid4()), + type=EntityType.ADDRESS, + value=match.group(), + page=block.page, + bbox=block.bbox, + layer=block.layer, + security_level="medium", + )) + return entities diff --git a/src/info_privacy/detectors/regex_detector.py b/src/info_privacy/detectors/regex_detector.py new file mode 100644 index 0000000..7a9bbbc --- /dev/null +++ b/src/info_privacy/detectors/regex_detector.py @@ -0,0 +1,41 @@ +import re +import uuid +from pathlib import Path + +import yaml + +from info_privacy.models import Entity, EntityType, TextBlock + + +class RegexDetector: + def __init__(self, config_path: str = "configs/pii_rules.yaml"): + cfg = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) + self._rules = { + EntityType(k): { + "pattern": re.compile(v["pattern"]), + "security_level": v["security_level"], + } + for k, v in cfg["regex"].items() + } + + def detect(self, blocks: list[TextBlock]) -> list[Entity]: + entities: list[Entity] = [] + for block in blocks: + for etype, rule in self._rules.items(): + for match in rule["pattern"].finditer(block.text): + entities.append(Entity( + id=str(uuid.uuid4()), + type=etype, + value=match.group(), + page=block.page, + bbox=block.bbox, + layer=block.layer, + security_level=rule["security_level"], + )) + # 身份证号满足银行卡格式(18位),去重:已匹配为 id_card 的值不再记为 bank_card + id_card_values = {e.value for e in entities if e.type == EntityType.ID_CARD} + entities = [ + e for e in entities + if not (e.type == EntityType.BANK_CARD and e.value in id_card_values) + ] + return entities diff --git a/src/info_privacy/models.py b/src/info_privacy/models.py new file mode 100644 index 0000000..bd5a001 --- /dev/null +++ b/src/info_privacy/models.py @@ -0,0 +1,61 @@ +# src/info_privacy/models.py +from __future__ import annotations +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class EntityType(str, Enum): + ID_CARD = "id_card" + PHONE = "phone" + BANK_CARD = "bank_card" + EMAIL = "email" + LICENSE_PLATE = "license_plate" + NAME = "name" + ADDRESS = "address" + FACE = "face" + CONFIDENTIAL_MARK = "confidential_mark" + + +class Classification(str, Enum): + CLASSIFIED = "classified" + SENSITIVE_PARTIAL = "sensitive_partial" + NORMAL = "normal" + + +@dataclass +class TextBlock: + text: str + bbox: list[float] # [x1, y1, x2, y2] + page: int + layer: str # "text" | "image" + confidence: float = 1.0 + + +@dataclass +class Entity: + id: str + type: EntityType + value: Optional[str] + page: int + bbox: list[float] + layer: str # "text" | "image" + security_level: str # "high" | "medium" | "low" + + +@dataclass +class DetectionReport: + doc_id: str + classification: Classification + blocked: bool + block_reason: Optional[str] + warning: Optional[str] + doc_meta: dict + entities: list[Entity] + + @property + def summary(self) -> dict[str, int]: + counts: dict[str, int] = {} + for e in self.entities: + counts[e.type.value] = counts.get(e.type.value, 0) + 1 + return counts diff --git a/src/info_privacy/parsers/__init__.py b/src/info_privacy/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/info_privacy/parsers/image_parser.py b/src/info_privacy/parsers/image_parser.py new file mode 100644 index 0000000..9a51cc8 --- /dev/null +++ b/src/info_privacy/parsers/image_parser.py @@ -0,0 +1,99 @@ +# src/info_privacy/parsers/image_parser.py +"""图像 OCR 解析器,基于 rknn_model_zoo PaddleOCR 推理。 + +参考: /data/rockchip/rknn_model_zoo/examples/PPOCR/PPOCR-System/python/ +平台: RK3588(rknn-toolkit-lite2)或 x86 仿真(rknn-toolkit2) +""" +from __future__ import annotations +import sys +import numpy as np +import cv2 +from pathlib import Path + +from info_privacy.models import TextBlock + +_PPOCR_DIR = Path("/data/rockchip/rknn_model_zoo/examples/PPOCR/PPOCR-System/python") + + +class ImageParser: + def __init__( + self, + det_model_path: str, + rec_model_path: str, + dict_path: str, + target: str = "rk3588", + ): + if str(_PPOCR_DIR) not in sys.path: + sys.path.insert(0, str(_PPOCR_DIR)) + + import ppocr_det as predict_det + import ppocr_rec as predict_rec + + class Args: + pass + + args = Args() + args.det_model_path = det_model_path + args.rec_model_path = rec_model_path + args.dict_path = dict_path + args.target = target + + self._detector = predict_det.TextDetector(args) + self._recognizer = predict_rec.TextRecognizer(args) + + def parse(self, file_path: str) -> tuple[dict, list[TextBlock]]: + img = cv2.imread(file_path) + if img is None: + raise ValueError(f"无法读取图像: {file_path}") + blocks = self._run_ocr(img, page=1) + meta = { + "format": "image", + "has_text_layer": False, + "redact_strategy": "image_mask", + } + return meta, blocks + + def run_on_image(self, img: np.ndarray, page: int = 1) -> list[TextBlock]: + """供 PDF 扫描件调用(page 已知)。""" + return self._run_ocr(img, page) + + def _run_ocr(self, img: np.ndarray, page: int) -> list[TextBlock]: + if str(_PPOCR_DIR) not in sys.path: + sys.path.insert(0, str(_PPOCR_DIR)) + import ppocr_det as predict_det + + ori_im = img.copy() + dt_boxes = self._detector.run(img) + if dt_boxes is None or len(dt_boxes) == 0: + return [] + + img_crop_list = [] + for box in sorted(dt_boxes, key=lambda b: (b[0][1], b[0][0])): + crop = predict_det.get_rotate_crop_image(ori_im, box) + img_crop_list.append(crop) + + rec_res = self._recognizer.run(img_crop_list) + + blocks: list[TextBlock] = [] + for box, rec in zip(dt_boxes, rec_res): + # rec_res 的每项可能是 (text, score) 或 [(text, score)] + if isinstance(rec, (list, tuple)) and len(rec) > 0: + if isinstance(rec[0], (list, tuple)): + text, score = rec[0] + else: + text, score = rec[0], rec[1] if len(rec) > 1 else 1.0 + else: + continue + if float(score) < 0.5: + continue + xs = box[:, 0] + ys = box[:, 1] + bbox = [float(xs.min()), float(ys.min()), float(xs.max()), float(ys.max())] + blocks.append(TextBlock( + text=str(text), + bbox=bbox, + page=page, + layer="image", + confidence=float(score), + )) + return blocks diff --git a/src/info_privacy/parsers/office_parser.py b/src/info_privacy/parsers/office_parser.py new file mode 100644 index 0000000..c28e3ea --- /dev/null +++ b/src/info_privacy/parsers/office_parser.py @@ -0,0 +1,74 @@ +# src/info_privacy/parsers/office_parser.py +from __future__ import annotations +from pathlib import Path + +from info_privacy.models import TextBlock + + +class OfficeParser: + def parse(self, file_path: str) -> tuple[dict, list[TextBlock]]: + suffix = Path(file_path).suffix.lower() + if suffix == ".docx": + return self._parse_docx(file_path) + elif suffix in (".xlsx", ".xls"): + return self._parse_excel(file_path) + raise ValueError(f"不支持的 Office 格式: {suffix}") + + def _parse_docx(self, path: str) -> tuple[dict, list[TextBlock]]: + import docx + doc = docx.Document(path) + blocks: list[TextBlock] = [] + slot = 0 # 用于生成 bbox 的行号计数器 + + def _add(text: str) -> None: + nonlocal slot + stripped = text.strip() + if not stripped: + return + blocks.append(TextBlock( + text=stripped, + bbox=[0, slot * 20, 500, (slot + 1) * 20], + page=1, + layer="text", + )) + slot += 1 + + # 正文段落 + for para in doc.paragraphs: + _add(para.text) + + # 表格单元格(按行→列顺序) + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + _add(para.text) + + meta = { + "format": "docx", + "has_text_layer": True, + "redact_strategy": "text_replace", + } + return meta, blocks + + def _parse_excel(self, path: str) -> tuple[dict, list[TextBlock]]: + import openpyxl + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + blocks: list[TextBlock] = [] + for sheet_idx, ws in enumerate(wb.worksheets): + for row_idx, row in enumerate(ws.iter_rows(values_only=True)): + text = " ".join(str(c) for c in row if c is not None).strip() + if not text: + continue + blocks.append(TextBlock( + text=text, + bbox=[0, row_idx * 20, 500, (row_idx + 1) * 20], + page=sheet_idx + 1, + layer="text", + )) + meta = { + "format": "xlsx", + "has_text_layer": True, + "redact_strategy": "text_replace", + } + return meta, blocks diff --git a/src/info_privacy/parsers/parser_factory.py b/src/info_privacy/parsers/parser_factory.py new file mode 100644 index 0000000..d0b21de --- /dev/null +++ b/src/info_privacy/parsers/parser_factory.py @@ -0,0 +1,34 @@ +# src/info_privacy/parsers/parser_factory.py +from __future__ import annotations +from pathlib import Path +from info_privacy.parsers.pdf_parser import PdfParser +from info_privacy.parsers.image_parser import ImageParser +from info_privacy.parsers.office_parser import OfficeParser + +_RKNN_DET = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/det/ppocrv4_det_ch_int8.rknn" +_RKNN_REC = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/rec/ppocrv4_rec_ch_fp16.rknn" +_DICT = "/data/rockchip/paddle_ocr/data/dicts/ppocr_keys_v1.txt" + + +class ParserFactory: + def __init__(self, det_model: str = _RKNN_DET, rec_model: str = _RKNN_REC, dict_path: str = _DICT): + self._det = det_model + self._rec = rec_model + self._dict = dict_path + self._image_parser: ImageParser | None = None + + def get_parser(self, file_path: str) -> PdfParser | ImageParser | OfficeParser: + suffix = Path(file_path).suffix.lower() + if suffix == ".pdf": + return PdfParser() + elif suffix in (".jpg", ".jpeg", ".png", ".tiff", ".bmp"): + return self._get_image_parser() + elif suffix in (".docx", ".xlsx", ".xls"): + return OfficeParser() + raise ValueError(f"不支持的文件格式: {suffix}") + + def _get_image_parser(self) -> ImageParser: + # 延迟初始化(RKNN加载慢) + if self._image_parser is None: + self._image_parser = ImageParser(self._det, self._rec, self._dict) + return self._image_parser diff --git a/src/info_privacy/parsers/pdf_parser.py b/src/info_privacy/parsers/pdf_parser.py new file mode 100644 index 0000000..b96cf94 --- /dev/null +++ b/src/info_privacy/parsers/pdf_parser.py @@ -0,0 +1,51 @@ +# src/info_privacy/parsers/pdf_parser.py +from __future__ import annotations +from pathlib import Path + +from pdfminer.converter import PDFPageAggregator +from pdfminer.layout import LAParams, LTTextBox +from pdfminer.pdfdocument import PDFDocument +from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager +from pdfminer.pdfpage import PDFPage +from pdfminer.pdfparser import PDFParser as _PDFParser + +from info_privacy.models import TextBlock + + +class PdfParser: + def parse(self, file_path: str) -> tuple[dict, list[TextBlock]]: + path = Path(file_path) + blocks: list[TextBlock] = [] + has_text_layer = False + + rsrcmgr = PDFResourceManager() + laparams = LAParams(line_margin=0.5, char_margin=2.0) + device = PDFPageAggregator(rsrcmgr, laparams=laparams) + interpreter = PDFPageInterpreter(rsrcmgr, device) + + with open(path, "rb") as f: + parser = _PDFParser(f) + doc = PDFDocument(parser) + for page_num, page in enumerate(PDFPage.create_pages(doc), start=1): + interpreter.process_page(page) + layout = device.get_result() + for element in layout: + if isinstance(element, LTTextBox): + text = element.get_text().strip() + if not text: + continue + has_text_layer = True + x0, y0, x1, y1 = element.bbox + blocks.append(TextBlock( + text=text, + bbox=[x0, y0, x1, y1], + page=page_num, + layer="text", + )) + + meta = { + "format": "pdf", + "has_text_layer": has_text_layer, + "redact_strategy": "text_replace" if has_text_layer else "image_mask", + } + return meta, blocks diff --git a/src/info_privacy/pipeline.py b/src/info_privacy/pipeline.py new file mode 100644 index 0000000..cf8d125 --- /dev/null +++ b/src/info_privacy/pipeline.py @@ -0,0 +1,146 @@ +# src/info_privacy/pipeline.py +from __future__ import annotations +import shutil +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from info_privacy.models import Classification, DetectionReport, EntityType, TextBlock +from info_privacy.classifier.doc_classifier import DocClassifier +from info_privacy.detectors.regex_detector import RegexDetector +from info_privacy.detectors.ner_detector import NERDetector +from info_privacy.parsers.parser_factory import ParserFactory +from info_privacy.redactors.text_redactor import TextRedactor +from info_privacy.redactors.image_redactor import ImageRedactor + +_CONFIG = "configs/pii_rules.yaml" + + +class PrivacyPipeline: + def __init__(self, config_path: str = _CONFIG): + self._factory = ParserFactory() + self._classifier = DocClassifier(config_path) + self._regex = RegexDetector(config_path) + self._ner = NERDetector(config_path) + self._text_redactor = TextRedactor() + self._image_redactor = ImageRedactor() + self._face_detector = None # 延迟加载(RKNN 初始化慢) + # 检测器并行执行池(regex/NER 均无共享状态,线程安全) + self._detector_pool = ThreadPoolExecutor(max_workers=2) + + def analyze(self, file_path: str) -> DetectionReport: + parser = self._factory.get_parser(file_path) + meta, blocks = parser.parse(file_path) + + # Step 0: 保密文件快速拦截 + clf = self._classifier.classify(blocks) + if clf.blocked: + return DetectionReport( + doc_id=str(uuid.uuid4()), + classification=clf.classification, + blocked=True, + block_reason=clf.block_reason, + warning=None, + doc_meta=meta, + entities=[], + ) + + # Step 1: regex + NER 并行检测(两者独立无共享状态) + f_regex = self._detector_pool.submit(self._regex.detect, blocks) + f_ner = self._detector_pool.submit(self._ner.detect, blocks) + entities = f_regex.result() + f_ner.result() + + # Step 2: 图像文档额外做人脸检测(NPU,与上面 CPU 检测错开) + if meta.get("redact_strategy") == "image_mask": + import cv2 + img = cv2.imread(file_path) + if img is not None: + try: + entities += self._get_face_detector().detect_in_image(img, page=1) + except Exception: + pass # RKNN 不可用时跳过人脸检测 + + # Step 3: 再次评估分类(有 entities 了) + clf = self._classifier.classify(blocks, entities=entities) + + return DetectionReport( + doc_id=str(uuid.uuid4()), + classification=clf.classification, + blocked=False, + block_reason=None, + warning=clf.warning, + doc_meta=meta, + entities=entities, + ) + + def redact(self, file_path: str, redact_types: list[str], out_path: str) -> dict: + """遮罩指定类型实体,自动选最安全策略,返回安全报告。 + 复用 analyze 结果,避免重复解析和检测。 + """ + report = self.analyze(file_path) + return self.redact_from_report(file_path, report, redact_types, out_path) + + def redact_from_report( + self, + file_path: str, + report: DetectionReport, + redact_types: list[str], + out_path: str, + ) -> dict: + """基于已有 analyze 报告执行遮罩,避免重复 analyze 开销。""" + if report.blocked: + raise ValueError(f"文件被拦截,无法遮罩: {report.block_reason}") + + target_types = {EntityType(t) for t in redact_types} + to_redact = [e for e in report.entities if e.type in target_types] + + text_entities = [e for e in to_redact if e.layer == "text"] + image_entities = [e for e in to_redact if e.layer == "image"] + + strategy = report.doc_meta.get("redact_strategy", "image_mask") + + if text_entities: + self._text_redactor.redact(file_path, text_entities, out_path) + if image_entities: + src = out_path if text_entities else file_path + self._image_redactor.redact(src, image_entities, out_path) + + if not text_entities and not image_entities: + shutil.copy(file_path, out_path) + + return { + "redacted": {t.value: sum(1 for e in to_redact if e.type == t) for t in target_types}, + "strategy_used": strategy, + "security_guarantee": "byte_level" if strategy == "text_replace" else "image_level", + } + + def analyze_image(self, img) -> DetectionReport: + """视频帧 PII 检测(供 KVM 视频流遮罩和 Privacy Gateway 使用)。 + + Args: + img: numpy ndarray (BGR, HWC) - 来自 OpenCV 或 base64 解码的图像帧 + + Returns: + DetectionReport - 与 analyze() 相同格式,doc_meta 包含 redact_strategy: image_mask + """ + import tempfile + import cv2 + import os + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp_path = tmp.name + + try: + cv2.imwrite(tmp_path, img) + return self.analyze(tmp_path) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + def _get_face_detector(self): + if self._face_detector is None: + from info_privacy.detectors.face_detector import FaceDetector + self._face_detector = FaceDetector() + return self._face_detector diff --git a/src/info_privacy/redactors/__init__.py b/src/info_privacy/redactors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/info_privacy/redactors/image_redactor.py b/src/info_privacy/redactors/image_redactor.py new file mode 100644 index 0000000..db14fae --- /dev/null +++ b/src/info_privacy/redactors/image_redactor.py @@ -0,0 +1,36 @@ +# src/info_privacy/redactors/image_redactor.py +from __future__ import annotations +from pathlib import Path + +import cv2 +import numpy as np + +from info_privacy.models import Entity + + +class ImageRedactor: + def redact(self, file_path: str, entities: list[Entity], out_path: str) -> None: + suffix = Path(file_path).suffix.lower() + if suffix == ".pdf": + self._redact_pdf_image(file_path, entities, out_path) + return + + img = cv2.imread(file_path) + if img is None: + raise ValueError(f"无法读取图像: {file_path}") + + for entity in entities: + if entity.layer != "image": + continue + x1, y1, x2, y2 = (int(v) for v in entity.bbox) + # numpy slice 赋值与 cv2.rectangle 速度相当,无需额外依赖 + img[y1:y2, x1:x2] = 0 + + cv2.imwrite(out_path, img) + + def _redact_pdf_image(self, path: str, entities: list[Entity], out_path: str) -> None: + """扫描型 PDF:使用 pypdf 复制页面,不支持图像层遮罩。 + 对于扫描 PDF,需先用 pdf2image 渲染再遮罩,此处记录警告并直接复制。 + """ + import shutil + shutil.copy(path, out_path) diff --git a/src/info_privacy/redactors/text_redactor.py b/src/info_privacy/redactors/text_redactor.py new file mode 100644 index 0000000..b8083d3 --- /dev/null +++ b/src/info_privacy/redactors/text_redactor.py @@ -0,0 +1,113 @@ +# src/info_privacy/redactors/text_redactor.py +"""文字层遮罩器:直接修改文档文字节点,字节级安全。 + +策略: +- DOCX: python-docx 段落 run 中将目标字符串替换为 ████ +- PDF (含文字层): pypdf 写出后做字节级替换 +- XLSX: openpyxl 单元格值替换 +""" +from __future__ import annotations +import re +from pathlib import Path + +from info_privacy.models import Entity + +_MASK = "████" + + +class TextRedactor: + def redact(self, file_path: str, entities: list[Entity], out_path: str) -> None: + suffix = Path(file_path).suffix.lower() + if suffix == ".docx": + self._redact_docx(file_path, entities, out_path) + elif suffix == ".pdf": + self._redact_pdf(file_path, entities, out_path) + elif suffix in (".xlsx", ".xls"): + self._redact_excel(file_path, entities, out_path) + else: + raise ValueError(f"TextRedactor 不支持: {suffix}") + + def _redact_docx(self, path: str, entities: list[Entity], out: str) -> None: + import docx + doc = docx.Document(path) + values = {e.value for e in entities if e.value and e.layer == "text"} + + def _redact_paragraphs(paragraphs) -> None: + for para in paragraphs: + for run in para.runs: + for val in values: + if val in run.text: + run.text = run.text.replace(val, _MASK) + + _redact_paragraphs(doc.paragraphs) + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + _redact_paragraphs(cell.paragraphs) + doc.save(out) + + def _redact_pdf(self, path: str, entities: list[Entity], out: str) -> None: + """PDF 内容流级替换:解压全部 filter→替换目标字节→纯 FlateDecode 写回。 + + PDF 内容流可能叠加多层 filter(如 ASCII85Decode + FlateDecode), + 不能在原始文件字节上直接做字符串替换。正确流程: + 1. get_data() 解码所有 filter,得到明文内容流字节 + 2. 在明文中替换目标值(等长空格覆盖) + 3. 手动 zlib 压缩并以单层 FlateDecode filter 写回 + """ + import shutil + import zlib + + import pypdf + from pypdf.generic import ArrayObject, NameObject + + values = {e.value for e in entities if e.value and e.layer == "text"} + if not values: + shutil.copy(path, out) + return + + reader = pypdf.PdfReader(path) + writer = pypdf.PdfWriter() + + for page in reader.pages: + contents = page.get("/Contents") + if contents is not None: + stream_refs = ( + list(contents) if isinstance(contents, ArrayObject) + else [contents] + ) + for ref in stream_refs: + stream_obj = ref.get_object() + try: + decoded = stream_obj.get_data() # 解压所有 filter + except Exception: + continue # 无法解码的流(图像等)跳过 + + modified = decoded + for val in values: + encoded = val.encode("utf-8") + modified = modified.replace(encoded, b" " * len(encoded)) + + if modified != decoded: + # 重新压缩并替换 filter 为单层 FlateDecode + stream_obj._data = zlib.compress(modified) + stream_obj[NameObject("/Filter")] = NameObject("/FlateDecode") + stream_obj.pop(NameObject("/DecodeParms"), None) + + writer.add_page(page) + + with open(out, "wb") as f: + writer.write(f) + + def _redact_excel(self, path: str, entities: list[Entity], out: str) -> None: + import openpyxl + wb = openpyxl.load_workbook(path) + values = {e.value for e in entities if e.value and e.layer == "text"} + for ws in wb.worksheets: + for row in ws.iter_rows(): + for cell in row: + if cell.value and isinstance(cell.value, str): + for val in values: + if val in cell.value: + cell.value = cell.value.replace(val, _MASK) + wb.save(out) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..1724ff0 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,145 @@ +# tests/test_api.py +import pytest +import docx +import io +import json +import openpyxl +from fastapi.testclient import TestClient +from info_privacy.api.main import app + +client = TestClient(app) + + +def test_health(): + r = client.get("/api/v1/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_types(): + r = client.get("/api/v1/types") + assert r.status_code == 200 + assert "id_card" in r.json()["entity_types"] + + +def test_analyze_docx(tmp_path): + doc = docx.Document() + doc.add_paragraph("身份证:110101199001011234 电话:13812345678") + p = tmp_path / "test.docx" + doc.save(str(p)) + with open(p, "rb") as f: + r = client.post("/api/v1/analyze", + files={"file": ("test.docx", f, "application/octet-stream")}) + assert r.status_code == 200 + data = r.json() + assert data["blocked"] is False + types = {e["type"] for e in data["entities"]} + assert "id_card" in types or "phone" in types + + +def test_analyze_classified(tmp_path): + doc = docx.Document() + doc.add_paragraph("【机密】内部文件") + p = tmp_path / "secret.docx" + doc.save(str(p)) + with open(p, "rb") as f: + r = client.post("/api/v1/analyze", + files={"file": ("secret.docx", f, "application/octet-stream")}) + assert r.status_code == 200 + assert r.json()["blocked"] is True + + +def test_redact_docx(tmp_path): + doc = docx.Document() + doc.add_paragraph("身份证:110101199001011234 地址:北京市") + p = tmp_path / "test.docx" + doc.save(str(p)) + cfg = json.dumps({"redact_types": ["id_card"]}) + with open(p, "rb") as f: + r = client.post("/api/v1/redact", + files={"file": ("test.docx", f, "application/octet-stream")}, + data={"config": cfg}) + assert r.status_code == 200 + result_doc = docx.Document(io.BytesIO(r.content)) + all_text = " ".join(para.text for para in result_doc.paragraphs) + assert "110101199001011234" not in all_text + + +def test_redact_returns_x_security_report_header(tmp_path): + """redact 响应头应包含 X-Security-Report,内含 JSON 格式遮罩统计。""" + doc = docx.Document() + doc.add_paragraph("身份证:110101199001011234") + p = tmp_path / "test.docx" + doc.save(str(p)) + cfg = json.dumps({"redact_types": ["id_card"]}) + with open(p, "rb") as f: + r = client.post("/api/v1/redact", + files={"file": ("test.docx", f, "application/octet-stream")}, + data={"config": cfg}) + assert r.status_code == 200 + assert "x-security-report" in r.headers + report = json.loads(r.headers["x-security-report"]) + assert isinstance(report, dict) + + +def test_analyze_xlsx(tmp_path): + """analyze 端点应支持 .xlsx 格式并返回检测结果。""" + wb = openpyxl.Workbook() + ws = wb.active + ws["A1"] = "姓名" + ws["B1"] = "身份证" + ws["A2"] = "张三" + ws["B2"] = "110101199001011234" + p = tmp_path / "test.xlsx" + wb.save(str(p)) + with open(p, "rb") as f: + r = client.post("/api/v1/analyze", + files={"file": ("test.xlsx", f, "application/octet-stream")}) + assert r.status_code == 200 + data = r.json() + assert any(e["type"] == "id_card" for e in data["entities"]) + + +def test_analyze_no_pii_returns_normal(tmp_path): + """不含任何 PII 的文档应返回 normal 分类且 blocked=False。""" + doc = docx.Document() + doc.add_paragraph("本季度销售额同比增长15%,各项指标均达标。") + p = tmp_path / "clean.docx" + doc.save(str(p)) + with open(p, "rb") as f: + r = client.post("/api/v1/analyze", + files={"file": ("clean.docx", f, "application/octet-stream")}) + assert r.status_code == 200 + data = r.json() + assert data["classification"] == "normal" + assert data["blocked"] is False + + +def test_analyze_summary_matches_entities(tmp_path): + """analyze 响应中 summary 各类型计数应与 entities 列表精确一致。""" + doc = docx.Document() + doc.add_paragraph("身份证:110101199001011234 电话:13812345678") + p = tmp_path / "test.docx" + doc.save(str(p)) + with open(p, "rb") as f: + r = client.post("/api/v1/analyze", + files={"file": ("test.docx", f, "application/octet-stream")}) + assert r.status_code == 200 + data = r.json() + for etype, count in data["summary"].items(): + actual = sum(1 for e in data["entities"] if e["type"] == etype) + assert actual == count, f"{etype}: summary={count},实际 entities={actual}" + + +def test_redact_classified_returns_403(tmp_path): + """对保密文档执行 redact 应返回 403。""" + doc = docx.Document() + doc.add_paragraph("【机密】内部绝密资料") + p = tmp_path / "secret.docx" + doc.save(str(p)) + cfg = json.dumps({"redact_types": ["id_card"]}) + with open(p, "rb") as f: + r = client.post("/api/v1/redact", + files={"file": ("secret.docx", f, "application/octet-stream")}, + data={"config": cfg}) + assert r.status_code == 403 diff --git a/tests/test_classifier.py b/tests/test_classifier.py new file mode 100644 index 0000000..af7b6c6 --- /dev/null +++ b/tests/test_classifier.py @@ -0,0 +1,85 @@ +# tests/test_classifier.py +import pytest +from info_privacy.models import TextBlock, Classification, Entity, EntityType +from info_privacy.classifier.doc_classifier import DocClassifier + +def test_classified_by_keyword(): + classifier = DocClassifier("configs/pii_rules.yaml") + blocks = [TextBlock(text="【机密】本文件仅供内部使用", bbox=[0,0,200,20], page=1, layer="text")] + result = classifier.classify(blocks) + assert result.classification == Classification.CLASSIFIED + assert result.blocked is True + assert result.block_reason is not None + +def test_sensitive_partial_many_entities(): + from info_privacy.models import Entity, EntityType + classifier = DocClassifier("configs/pii_rules.yaml") + entities = [ + Entity(id=str(i), type=EntityType.ID_CARD, value="x", page=1, + bbox=[0,0,1,1], layer="text", security_level="high") + for i in range(6) + ] + result = classifier.classify([], entities=entities) + assert result.classification == Classification.SENSITIVE_PARTIAL + assert result.warning is not None + +def test_normal_document(): + classifier = DocClassifier("configs/pii_rules.yaml") + blocks = [TextBlock(text="本季度销售报告", bbox=[0,0,200,20], page=1, layer="text")] + result = classifier.classify(blocks) + assert result.classification == Classification.NORMAL + assert result.blocked is False + + +_ALL_KEYWORDS = [ + "机密", "绝密", "保密", "内部文件", "内部资料", + "CONFIDENTIAL", "SECRET", "TOP SECRET", "RESTRICTED", "FOR INTERNAL USE ONLY", +] + + +@pytest.mark.parametrize("keyword", _ALL_KEYWORDS) +def test_all_confidential_keywords_trigger_blocked(keyword): + """pii_rules.yaml 中每个保密关键词都应触发 classified 拦截。""" + classifier = DocClassifier("configs/pii_rules.yaml") + blocks = [TextBlock(text=f"标题:{keyword}", bbox=[0,0,300,20], page=1, layer="text")] + result = classifier.classify(blocks) + assert result.classification == Classification.CLASSIFIED, f"关键词 '{keyword}' 未触发拦截" + assert result.blocked is True + assert result.block_reason is not None + + +def _make_high_risk_entities(n: int) -> list[Entity]: + return [ + Entity(id=str(i), type=EntityType.PHONE, value="13800000000", + page=1, bbox=[0, 0, 1, 1], layer="text", security_level="high") + for i in range(n) + ] + + +def test_exactly_4_high_risk_no_warning(): + """4 个高风险实体:低于阈值,不触发 sensitive_partial 警告。""" + classifier = DocClassifier("configs/pii_rules.yaml") + result = classifier.classify([], entities=_make_high_risk_entities(4)) + assert result.classification == Classification.NORMAL + assert result.warning is None + + +def test_exactly_5_high_risk_triggers_warning(): + """5 个高风险实体:恰好达到阈值,应触发 sensitive_partial 警告。""" + classifier = DocClassifier("configs/pii_rules.yaml") + result = classifier.classify([], entities=_make_high_risk_entities(5)) + assert result.classification == Classification.SENSITIVE_PARTIAL + assert result.warning is not None + + +def test_medium_entities_alone_do_not_trigger_warning(): + """10 个中风险实体(email)不应触发 sensitive_partial 警告。""" + classifier = DocClassifier("configs/pii_rules.yaml") + entities = [ + Entity(id=str(i), type=EntityType.EMAIL, value="x@x.com", + page=1, bbox=[0, 0, 1, 1], layer="text", security_level="medium") + for i in range(10) + ] + result = classifier.classify([], entities=entities) + assert result.classification == Classification.NORMAL + assert result.warning is None diff --git a/tests/test_detectors.py b/tests/test_detectors.py new file mode 100644 index 0000000..d195310 --- /dev/null +++ b/tests/test_detectors.py @@ -0,0 +1,185 @@ +import pytest +from info_privacy.models import TextBlock, EntityType +from info_privacy.detectors.regex_detector import RegexDetector + + +@pytest.fixture +def detector(): + return RegexDetector("configs/pii_rules.yaml") + + +def test_detect_id_card(detector): + block = TextBlock(text="姓名:张三 身份证:110101199001011234", bbox=[0, 0, 100, 20], page=1, layer="text") + entities = detector.detect([block]) + types = [e.type for e in entities] + assert EntityType.ID_CARD in types + + +def test_detect_phone(detector): + block = TextBlock(text="联系电话:13812345678", bbox=[0, 0, 100, 20], page=1, layer="text") + entities = detector.detect([block]) + types = [e.type for e in entities] + assert EntityType.PHONE in types + + +def test_detect_email(detector): + block = TextBlock(text="邮箱:test@example.com", bbox=[0, 0, 100, 20], page=1, layer="text") + entities = detector.detect([block]) + assert any(e.type == EntityType.EMAIL for e in entities) + + +def test_no_false_positive_short_number(detector): + block = TextBlock(text="编号:12345", bbox=[0, 0, 100, 20], page=1, layer="text") + entities = detector.detect([block]) + assert not any(e.type == EntityType.ID_CARD for e in entities) + + +def test_entity_bbox_matches_block(detector): + block = TextBlock(text="电话:13812345678", bbox=[10, 20, 200, 40], page=2, layer="text") + entities = detector.detect([block]) + assert entities[0].page == 2 + assert entities[0].bbox == [10, 20, 200, 40] + + +# NER 检测器测试 +from info_privacy.detectors.ner_detector import NERDetector + +@pytest.fixture +def ner(): + return NERDetector("configs/pii_rules.yaml") + +def test_detect_name(ner): + block = TextBlock(text="申请人:张伟,联系地址如下", bbox=[0,0,200,20], page=1, layer="text") + entities = ner.detect([block]) + assert any(e.type == EntityType.NAME for e in entities) + +def test_detect_address(ner): + block = TextBlock(text="住址:北京市朝阳区建国路88号", bbox=[0,0,300,20], page=1, layer="text") + entities = ner.detect([block]) + assert any(e.type == EntityType.ADDRESS for e in entities) + +def test_no_name_without_surname(ner): + block = TextBlock(text="操作系统版本3.1", bbox=[0,0,200,20], page=1, layer="text") + entities = ner.detect([block]) + assert not any(e.type == EntityType.NAME for e in entities) + + +def test_ner_no_address_false_positive(ner): + """普通正文不含地址触发词,不应检测到 address。""" + block = TextBlock(text="本季度产品销售额增长20%", bbox=[0,0,300,20], page=1, layer="text") + entities = ner.detect([block]) + assert not any(e.type == EntityType.ADDRESS for e in entities) + + +def test_ner_name_and_address_same_block(ner): + """同一段文本中姓名和地址应同时被检测。""" + block = TextBlock( + text="申请人:王芳,地址:上海市浦东新区张江路100号", + bbox=[0, 0, 400, 20], page=1, layer="text", + ) + entities = ner.detect([block]) + types = {e.type for e in entities} + assert EntityType.NAME in types + assert EntityType.ADDRESS in types + + +# RegexDetector 新增覆盖 + +def test_detect_bank_card_16_digit(detector): + """16 位纯银行卡号(非身份证格式)应被识别为 bank_card。""" + block = TextBlock(text="卡号:6222021302001002", bbox=[0,0,200,20], page=1, layer="text") + entities = detector.detect([block]) + types = [e.type for e in entities] + assert EntityType.BANK_CARD in types + assert EntityType.ID_CARD not in types + + +def test_id_card_not_double_counted_as_bank_card(detector): + """18 位身份证号不应同时被识别为 bank_card(去重逻辑)。""" + block = TextBlock(text="证件号:110101199001011234", bbox=[0,0,200,20], page=1, layer="text") + entities = detector.detect([block]) + id_entities = [e for e in entities if e.type == EntityType.ID_CARD] + bank_overlap = [e for e in entities + if e.type == EntityType.BANK_CARD and e.value == "110101199001011234"] + assert len(id_entities) == 1 + assert len(bank_overlap) == 0 + + +def test_detect_id_card_ending_in_x(detector): + """末位为 X 的身份证号应被正确识别,且 value 保留末位 X。""" + block = TextBlock(text="证件:11010119900101110X", bbox=[0,0,200,20], page=1, layer="text") + entities = detector.detect([block]) + id_entities = [e for e in entities if e.type == EntityType.ID_CARD] + assert len(id_entities) == 1 + assert id_entities[0].value.endswith("X") + + +def test_detect_license_plate(detector): + """有效的中国车牌应被识别为 license_plate。""" + block = TextBlock(text="车辆:京A12345", bbox=[0,0,200,20], page=1, layer="text") + entities = detector.detect([block]) + assert any(e.type == EntityType.LICENSE_PLATE for e in entities) + + +def test_entity_security_level_high(detector): + """高风险类型 id_card 的 security_level 应为 high。""" + block = TextBlock(text="110101199001011234", bbox=[0,0,200,20], page=1, layer="text") + entities = detector.detect([block]) + id_e = next(e for e in entities if e.type == EntityType.ID_CARD) + assert id_e.security_level == "high" + + +def test_entity_security_level_medium(detector): + """中风险类型 email 的 security_level 应为 medium。""" + block = TextBlock(text="user@example.com", bbox=[0,0,200,20], page=1, layer="text") + entities = detector.detect([block]) + email_e = next(e for e in entities if e.type == EntityType.EMAIL) + assert email_e.security_level == "medium" + + +def test_multiple_entities_same_block(detector): + """同一文本块中同时含手机号和邮箱,两者均应被检测。""" + block = TextBlock( + text="联系方式:13812345678 / admin@corp.com", + bbox=[0, 0, 400, 20], page=1, layer="text", + ) + entities = detector.detect([block]) + types = {e.type for e in entities} + assert EntityType.PHONE in types + assert EntityType.EMAIL in types + + +# 人脸检测器测试 +import os +import numpy as np +import cv2 + +FACE_MODEL = "/data/rockchip/mediapipe/models/rknn/face_detection_short_range_rk3588.rknn" + +def _rknn_available(): + try: + import rknn + return True + except ImportError: + try: + import rknnlite + return True + except ImportError: + return False + +def test_face_detector_skip_if_no_model(): + if not os.path.exists(FACE_MODEL) or not _rknn_available(): + pytest.skip("人脸RKNN模型不存在或 RKNN 运行时不可用") + from info_privacy.detectors.face_detector import FaceDetector + detector = FaceDetector(FACE_MODEL) + assert detector is not None + +def test_face_detector_returns_entities(): + if not os.path.exists(FACE_MODEL) or not _rknn_available(): + pytest.skip("人脸RKNN模型不存在或 RKNN 运行时不可用") + from info_privacy.detectors.face_detector import FaceDetector + detector = FaceDetector(FACE_MODEL) + # 白色空图像,无人脸 + img = np.ones((300, 300, 3), dtype=np.uint8) * 255 + entities = detector.detect_in_image(img, page=1) + assert isinstance(entities, list) diff --git a/tests/test_integration_complex.py b/tests/test_integration_complex.py new file mode 100644 index 0000000..e19553b --- /dev/null +++ b/tests/test_integration_complex.py @@ -0,0 +1,598 @@ +# tests/test_integration_complex.py +"""高复杂度集成测试:实体值精确性、多页 PDF、表格解析、格式变体、 +边界条件、误报拒绝、幂等性、阈值精确、跨 sheet 定位等。 +""" +from __future__ import annotations + +import os + +import cv2 +import docx as _docx +import numpy as np +import openpyxl +import pytest +from pdfminer.high_level import extract_text +from reportlab.lib.pagesizes import A4 +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas + +from info_privacy.models import EntityType +from info_privacy.pipeline import PrivacyPipeline + +# ─── 字体 ───────────────────────────────────────────────────────────────────── + +_FONT_PATH = "/usr/share/fonts/truetype/arphic/uming.ttc" +_FONT_NAME = "UMingC" # 用不同名防止重复注册冲突 + +if os.path.exists(_FONT_PATH) and _FONT_NAME not in pdfmetrics.getRegisteredFontNames(): + pdfmetrics.registerFont(TTFont(_FONT_NAME, _FONT_PATH, subfontIndex=0)) + +_needs_font = pytest.mark.skipif( + not os.path.exists(_FONT_PATH), reason="uming.ttc 不存在" +) + +# ─── 测试数据 ────────────────────────────────────────────────────────────────── + +PERSONS = [ + {"id": "110101199001011234", "phone": "13812345678", "name": "张伟", + "email": "zhang.wei@corp.com", "bank": "6222021234567890123"}, + {"id": "310101198505051234", "phone": "13900001111", "name": "李娜", + "email": "li.na@test.org", "bank": "6222021111111111111"}, + {"id": "440101200003031234", "phone": "15900002222", "name": "王芳", + "email": "wang.fang@example.net", "bank": "6222023333333333333"}, + {"id": "320101199707071234", "phone": "18700003333", "name": "赵磊", + "email": "zhao.lei@sample.cn", "bank": "6222024444444444444"}, + {"id": "510101196808081234", "phone": "13700004444", "name": "陈静", + "email": "chen.jing@demo.com", "bank": "6222025555555555555"}, +] + +@pytest.fixture(scope="module") +def pipeline(): + return PrivacyPipeline() + + +# ─── 工具函数 ────────────────────────────────────────────────────────────────── + +def _make_multipage_pdf(path: str, pages: list[list[str]]) -> None: + """生成多页 PDF,每个子列表为一页的行列表。""" + c = canvas.Canvas(path, pagesize=A4) + c.setFont(_FONT_NAME, 14) + for page_lines in pages: + y = 750 + for line in page_lines: + c.drawString(50, y, line) + y -= 30 + c.showPage() + c.save() + + +def _docx_text(path: str) -> str: + doc = _docx.Document(path) + parts = [p.text for p in doc.paragraphs] + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + parts.extend(p.text for p in cell.paragraphs) + return "\n".join(parts) + + +def _xlsx_text(path: str) -> str: + wb = openpyxl.load_workbook(path, data_only=True) + parts = [] + for ws in wb.worksheets: + for row in ws.iter_rows(values_only=True): + parts.extend(str(c) for c in row if c is not None) + return " ".join(parts) + + +# ─── 1. 实体值精确性(不只检查类型,验证 value 字段)──────────────────────────── + +def test_entity_value_id_card_exact(tmp_path, pipeline): + doc = _docx.Document() + doc.add_paragraph(f"身份证号:{PERSONS[0]['id']}") + p = str(tmp_path / "t.docx") + doc.save(p) + report = pipeline.analyze(p) + id_entities = [e for e in report.entities if e.type == EntityType.ID_CARD] + assert len(id_entities) >= 1 + values = {e.value for e in id_entities} + assert PERSONS[0]["id"] in values, f"身份证值不对,得到:{values}" + + +def test_entity_value_phone_exact(tmp_path, pipeline): + doc = _docx.Document() + doc.add_paragraph(f"联系电话:{PERSONS[0]['phone']}") + p = str(tmp_path / "t.docx") + doc.save(p) + report = pipeline.analyze(p) + phones = [e for e in report.entities if e.type == EntityType.PHONE] + assert any(e.value == PERSONS[0]["phone"] for e in phones), \ + f"手机号值不对,得到:{[e.value for e in phones]}" + + +def test_entity_value_email_exact(tmp_path, pipeline): + doc = _docx.Document() + doc.add_paragraph(f"邮箱:{PERSONS[0]['email']}") + p = str(tmp_path / "t.docx") + doc.save(p) + report = pipeline.analyze(p) + emails = [e for e in report.entities if e.type == EntityType.EMAIL] + assert any(e.value == PERSONS[0]["email"] for e in emails) + + +# ─── 2. PDF 多页:entity.page 字段正确 ────────────────────────────────────────── + +@_needs_font +def test_pdf_multipage_entity_page_numbers(tmp_path, pipeline): + """3 页 PDF,每页一个身份证,entity.page 应分别为 1/2/3。""" + pdf = str(tmp_path / "multi.pdf") + _make_multipage_pdf(pdf, [ + [f"第一页 身份证:{PERSONS[0]['id']}"], + [f"第二页 身份证:{PERSONS[1]['id']}"], + [f"第三页 身份证:{PERSONS[2]['id']}"], + ]) + report = pipeline.analyze(pdf) + id_entities = [e for e in report.entities if e.type == EntityType.ID_CARD] + pages = {e.page for e in id_entities} + assert len(pages) == 3, f"应检测到 3 个不同页码,实际 pages={pages}" + assert pages == {1, 2, 3}, f"页码不对:{pages}" + + +@_needs_font +def test_pdf_multipage_redact_all_pages(tmp_path, pipeline): + """多页 PDF 遮罩后每页的身份证都消失。""" + pdf = str(tmp_path / "multi.pdf") + out = str(tmp_path / "out.pdf") + _make_multipage_pdf(pdf, [ + [f"页1 {PERSONS[0]['id']}"], + [f"页2 {PERSONS[1]['id']}"], + ]) + pipeline.redact(pdf, redact_types=["id_card"], out_path=out) + result_text = extract_text(out) + assert PERSONS[0]["id"] not in result_text, "第1页身份证未被遮罩" + assert PERSONS[1]["id"] not in result_text, "第2页身份证未被遮罩" + + +@_needs_font +def test_pdf_multipage_preserves_non_pii_lines(tmp_path, pipeline): + """多页 PDF 遮罩后非 PII 内容保留。""" + pdf = str(tmp_path / "multi.pdf") + out = str(tmp_path / "out.pdf") + # 第 2 页用纯 ASCII 锚点:pdfminer 对跨页 CJK 字形解码不稳定, + # ASCII 内容始终可靠,足以验证非 PII 内容未被误删。 + _make_multipage_pdf(pdf, [ + ["合同编号:HT-2026-001", f"甲方身份证:{PERSONS[0]['id']}"], + ["REF-NOREDACT-P2"], + ]) + pipeline.redact(pdf, redact_types=["id_card"], out_path=out) + result_text = extract_text(out) + assert PERSONS[0]["id"] not in result_text + assert "HT-2026-001" in result_text, "第1页非PII文本被误删" + assert "REF-NOREDACT-P2" in result_text, "第2页非PII文本被误删" + + +# ─── 3. DOCX 表格单元格解析 ────────────────────────────────────────────────────── + +def test_docx_table_detects_id_card(tmp_path, pipeline): + """PII 藏在表格单元格中应被正常检测。""" + doc = _docx.Document() + table = doc.add_table(rows=2, cols=3) + table.cell(0, 0).text = "姓名" + table.cell(0, 1).text = "身份证号" + table.cell(0, 2).text = "手机号" + table.cell(1, 0).text = PERSONS[0]["name"] + table.cell(1, 1).text = PERSONS[0]["id"] + table.cell(1, 2).text = PERSONS[0]["phone"] + p = str(tmp_path / "table.docx") + doc.save(p) + report = pipeline.analyze(p) + types = {e.type for e in report.entities} + assert EntityType.ID_CARD in types, "表格中的身份证未被检测" + assert EntityType.PHONE in types, "表格中的手机号未被检测" + + +def test_docx_table_redact_removes_value(tmp_path, pipeline): + """对含表格的 DOCX 执行遮罩,表格内的 PII 也被删除。""" + doc = _docx.Document() + table = doc.add_table(rows=3, cols=2) + for i, p in enumerate(PERSONS[:3]): + table.cell(i, 0).text = p["name"] + table.cell(i, 1).text = p["id"] + path = str(tmp_path / "table.docx") + doc.save(path) + out = str(tmp_path / "out.docx") + pipeline.redact(path, redact_types=["id_card"], out_path=out) + text = _docx_text(out) + for p in PERSONS[:3]: + assert p["id"] not in text, f"{p['id']} 未被遮罩" + + +def test_docx_table_paragraph_both_detected(tmp_path, pipeline): + """正文段落 + 表格都有 PII 时,两处均被检测到。""" + doc = _docx.Document() + doc.add_paragraph(f"合同方:{PERSONS[0]['name']} 身份证:{PERSONS[0]['id']}") + table = doc.add_table(rows=1, cols=2) + table.cell(0, 0).text = PERSONS[1]["name"] + table.cell(0, 1).text = PERSONS[1]["id"] + path = str(tmp_path / "mixed.docx") + doc.save(path) + report = pipeline.analyze(path) + id_values = {e.value for e in report.entities if e.type == EntityType.ID_CARD} + assert PERSONS[0]["id"] in id_values, "段落中身份证未检测" + assert PERSONS[1]["id"] in id_values, "表格中身份证未检测" + + +# ─── 4. XLSX 跨 sheet 检测与 entity.page ───────────────────────────────────────── + +@pytest.fixture +def multi_sheet_xlsx(tmp_path): + wb = openpyxl.Workbook() + ws1 = wb.active + ws1.title = "员工信息" + ws1.append(["姓名", "身份证"]) + for p in PERSONS[:2]: + ws1.append([p["name"], p["id"]]) + + ws2 = wb.create_sheet("联系方式") + ws2.append(["姓名", "手机", "邮箱"]) + for p in PERSONS[2:4]: + ws2.append([p["name"], p["phone"], p["email"]]) + + path = str(tmp_path / "multi_sheet.xlsx") + wb.save(path) + return path + + +def test_xlsx_multisheet_detects_pii_in_both(multi_sheet_xlsx, pipeline): + report = pipeline.analyze(multi_sheet_xlsx) + types = {e.type for e in report.entities} + assert EntityType.ID_CARD in types, "Sheet1 身份证未检测" + assert EntityType.PHONE in types, "Sheet2 手机号未检测" + assert EntityType.EMAIL in types, "Sheet2 邮箱未检测" + + +def test_xlsx_multisheet_page_numbers(multi_sheet_xlsx, pipeline): + """Sheet1 的实体 page=1,Sheet2 的实体 page=2。""" + report = pipeline.analyze(multi_sheet_xlsx) + id_pages = {e.page for e in report.entities if e.type == EntityType.ID_CARD} + phone_pages = {e.page for e in report.entities if e.type == EntityType.PHONE} + assert 1 in id_pages, f"Sheet1 的身份证 page 应为 1,实际:{id_pages}" + assert 2 in phone_pages, f"Sheet2 的手机号 page 应为 2,实际:{phone_pages}" + + +def test_xlsx_multisheet_redact_across_sheets(multi_sheet_xlsx, tmp_path, pipeline): + out = str(tmp_path / "out.xlsx") + pipeline.redact(multi_sheet_xlsx, redact_types=["id_card", "phone"], out_path=out) + text = _xlsx_text(out) + for p in PERSONS[:2]: + assert p["id"] not in text, f"Sheet1 身份证 {p['id']} 未被遮罩" + for p in PERSONS[2:4]: + assert p["phone"] not in text, f"Sheet2 手机 {p['phone']} 未被遮罩" + + +# ─── 5. 手机号格式变体 ──────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("phone_str,label", [ + ("13812345678", "纯数字"), + ("+8613812345678", "+86前缀"), + ("86-13812345678", "86-前缀"), + ("+86 13812345678", "+86空格"), + ("86 13812345678", "86空格"), +]) +def test_phone_format_variants(tmp_path, pipeline, phone_str, label): + doc = _docx.Document() + doc.add_paragraph(f"联系人手机:{phone_str}") + p = str(tmp_path / "t.docx") + doc.save(p) + report = pipeline.analyze(p) + phones = [e for e in report.entities if e.type == EntityType.PHONE] + assert len(phones) >= 1, f"格式 '{label}' 未被检测到手机号" + + +# ─── 6. 误报拒绝:不应匹配的模式 ───────────────────────────────────────────────── + +@pytest.mark.parametrize("text,no_type,label", [ + ("编号:12345", EntityType.ID_CARD, "5位短数字"), + ("这是第1990年的事", EntityType.ID_CARD, "含年份数字"), + ("共有15人参会", EntityType.PHONE, "2位数字"), + ("192.168.0.127端口8080", EntityType.PHONE, "IP地址"), + ("请联系test@", EntityType.EMAIL, "不完整邮箱"), + ("版本号:1.2.3.4", EntityType.EMAIL, "版本号"), + ("合同金额:1234567890元", EntityType.BANK_CARD, "10位金额"), +]) +def test_no_false_positive(tmp_path, pipeline, text, no_type, label): + doc = _docx.Document() + doc.add_paragraph(text) + p = str(tmp_path / "t.docx") + doc.save(p) + report = pipeline.analyze(p) + matched = [e for e in report.entities if e.type == no_type] + assert len(matched) == 0, f"'{label}' 误报了 {no_type.value}:{[e.value for e in matched]}" + + +# ─── 7. 行内 PII(嵌在句子中间)───────────────────────────────────────────────────── + +def test_inline_pii_phone_in_sentence(tmp_path, pipeline): + """手机号嵌在一个完整中文句子中也应被检测到。""" + doc = _docx.Document() + doc.add_paragraph(f"请于明日拨打{PERSONS[0]['phone']}与客服确认到访时间。") + p = str(tmp_path / "t.docx") + doc.save(p) + report = pipeline.analyze(p) + phones = [e for e in report.entities if e.type == EntityType.PHONE] + assert len(phones) >= 1, "句子中间的手机号未检测" + + +def test_inline_pii_multiple_on_same_line(tmp_path, pipeline): + """同一段落含身份证+手机+邮箱,三者都要被检测到。""" + doc = _docx.Document() + doc.add_paragraph( + f"甲方{PERSONS[0]['name']}(证件号{PERSONS[0]['id']}," + f"电话{PERSONS[0]['phone']}," + f"邮箱{PERSONS[0]['email']})确认签字。" + ) + p = str(tmp_path / "t.docx") + doc.save(p) + report = pipeline.analyze(p) + types = {e.type for e in report.entities} + assert EntityType.ID_CARD in types, "行内身份证未检测" + assert EntityType.PHONE in types, "行内手机号未检测" + assert EntityType.EMAIL in types, "行内邮箱未检测" + + +def test_two_phones_same_paragraph(tmp_path, pipeline): + """同一段落包含 2 个不同手机号,两个都被检测到。""" + doc = _docx.Document() + doc.add_paragraph(f"联系人A:{PERSONS[0]['phone']},联系人B:{PERSONS[1]['phone']}") + p = str(tmp_path / "t.docx") + doc.save(p) + report = pipeline.analyze(p) + phones = {e.value for e in report.entities if e.type == EntityType.PHONE} + assert PERSONS[0]["phone"] in phones, "第1个手机号未检测" + assert PERSONS[1]["phone"] in phones, "第2个手机号未检测" + + +# ─── 8. 遮罩幂等性:redact → re-analyze 验证 ───────────────────────────────────── + +def test_redact_then_reanalyze_no_id_card(tmp_path, pipeline): + """遮罩身份证后,再次 analyze 应检测不到身份证。""" + doc = _docx.Document() + doc.add_paragraph(f"身份证:{PERSONS[0]['id']},电话:{PERSONS[0]['phone']}") + p = str(tmp_path / "orig.docx") + doc.save(p) + + out = str(tmp_path / "redacted.docx") + pipeline.redact(p, redact_types=["id_card"], out_path=out) + + re_report = pipeline.analyze(out) + id_entities = [e for e in re_report.entities if e.type == EntityType.ID_CARD] + assert len(id_entities) == 0, f"遮罩后仍检测到身份证:{[e.value for e in id_entities]}" + + +def test_redact_then_reanalyze_other_types_survive(tmp_path, pipeline): + """遮罩身份证后,其他类型(手机号)不受影响,仍可被检测。""" + doc = _docx.Document() + doc.add_paragraph(f"身份证:{PERSONS[0]['id']},电话:{PERSONS[0]['phone']}") + p = str(tmp_path / "orig.docx") + doc.save(p) + + out = str(tmp_path / "redacted.docx") + pipeline.redact(p, redact_types=["id_card"], out_path=out) + + re_report = pipeline.analyze(out) + phones = [e for e in re_report.entities if e.type == EntityType.PHONE] + assert len(phones) >= 1, "遮罩身份证后手机号不应消失" + + +def test_redact_all_types_empty_reanalyze(tmp_path, pipeline): + """全量遮罩后,re-analyze 应无任何高危实体。""" + doc = _docx.Document() + doc.add_paragraph( + f"姓名:{PERSONS[0]['name']} 身份证:{PERSONS[0]['id']} " + f"电话:{PERSONS[0]['phone']} 邮箱:{PERSONS[0]['email']} " + f"银行卡:{PERSONS[0]['bank']}" + ) + p = str(tmp_path / "orig.docx") + doc.save(p) + + out = str(tmp_path / "redacted.docx") + pipeline.redact( + p, + redact_types=["id_card", "phone", "email", "bank_card", "name"], + out_path=out, + ) + + re_report = pipeline.analyze(out) + high_risk = [e for e in re_report.entities if e.security_level == "high"] + assert len(high_risk) == 0, f"全量遮罩后仍有高危实体:{[e.type for e in high_risk]}" + + +# ─── 9. 阈值边界:sensitive_partial 触发精确值 ──────────────────────────────────── + +def test_threshold_below_no_warning(tmp_path, pipeline): + """4 个高危实体(< 5)→ 不触发 sensitive_partial。""" + doc = _docx.Document() + for p in PERSONS[:4]: + doc.add_paragraph(f"身份证:{p['id']}") + path = str(tmp_path / "t.docx") + doc.save(path) + report = pipeline.analyze(path) + assert report.warning is None, f"4个高危实体不应触发警告,实际:{report.warning}" + assert report.classification.value == "normal" + + +def test_threshold_at_boundary_triggers_warning(tmp_path, pipeline): + """5 个高危实体(= 阈值)→ 触发 sensitive_partial 警告。""" + doc = _docx.Document() + for p in PERSONS[:5]: + doc.add_paragraph(f"身份证:{p['id']}") + path = str(tmp_path / "t.docx") + doc.save(path) + report = pipeline.analyze(path) + assert report.warning is not None, "5个高危实体应触发警告" + assert report.classification.value == "sensitive_partial" + + +def test_threshold_above_warning_message_has_count(tmp_path, pipeline): + """警告信息中应包含实体数量。""" + doc = _docx.Document() + for p in PERSONS: # 5人 + doc.add_paragraph(f"身份证:{p['id']}") + path = str(tmp_path / "t.docx") + doc.save(path) + report = pipeline.analyze(path) + assert report.warning is not None + assert "5" in report.warning, f"警告信息中应包含数量 5,实际:{report.warning}" + + +# ─── 10. 遮罩计数精确性 ─────────────────────────────────────────────────────────── + +def test_redact_count_matches_detection(tmp_path, pipeline): + """security_report['redacted']['id_card'] 应等于检测到的数量。""" + doc = _docx.Document() + for p in PERSONS[:3]: + doc.add_paragraph(f"身份证:{p['id']}") + path = str(tmp_path / "t.docx") + doc.save(path) + out = str(tmp_path / "out.docx") + + analyze_report = pipeline.analyze(path) + detected_count = sum(1 for e in analyze_report.entities if e.type == EntityType.ID_CARD) + + security_report = pipeline.redact(path, redact_types=["id_card"], out_path=out) + assert security_report["redacted"]["id_card"] == detected_count, \ + f"遮罩计数 {security_report['redacted']['id_card']} ≠ 检测数 {detected_count}" + + +def test_redact_count_zero_for_unspecified_type(tmp_path, pipeline): + """未遮罩的类型在 security_report['redacted'] 中计数应为 0。""" + doc = _docx.Document() + doc.add_paragraph(f"身份证:{PERSONS[0]['id']},电话:{PERSONS[0]['phone']}") + path = str(tmp_path / "t.docx") + doc.save(path) + out = str(tmp_path / "out.docx") + report = pipeline.redact(path, redact_types=["phone"], out_path=out) + # id_card 未在 redact_types 中,对应 key 不应出现或为 0 + assert report["redacted"].get("id_card", 0) == 0 + + +# ─── 11. 5 人记录大文档:全检测 + 全遮罩 ───────────────────────────────────────── + +@pytest.fixture +def five_person_docx(tmp_path): + doc = _docx.Document() + for p in PERSONS: + doc.add_paragraph( + f"姓名:{p['name']} 身份证:{p['id']} " + f"电话:{p['phone']} 邮箱:{p['email']} 银行卡:{p['bank']}" + ) + path = str(tmp_path / "five.docx") + doc.save(path) + return path + + +def test_five_persons_detect_all_id_cards(five_person_docx, pipeline): + report = pipeline.analyze(five_person_docx) + id_values = {e.value for e in report.entities if e.type == EntityType.ID_CARD} + for p in PERSONS: + assert p["id"] in id_values, f"未检测到 {p['name']} 的身份证" + + +def test_five_persons_detect_all_phones(five_person_docx, pipeline): + report = pipeline.analyze(five_person_docx) + phone_values = {e.value for e in report.entities if e.type == EntityType.PHONE} + for p in PERSONS: + assert p["phone"] in phone_values, f"未检测到 {p['name']} 的手机号" + + +def test_five_persons_full_redact(five_person_docx, tmp_path, pipeline): + out = str(tmp_path / "out.docx") + pipeline.redact( + five_person_docx, + redact_types=["id_card", "phone", "email", "bank_card"], + out_path=out, + ) + from info_privacy.parsers.office_parser import OfficeParser + _, blocks = OfficeParser().parse(out) + full_text = " ".join(b.text for b in blocks) + for p in PERSONS: + assert p["id"] not in full_text, f"{p['name']} 的身份证未被遮罩" + assert p["phone"] not in full_text, f"{p['name']} 的手机号未被遮罩" + assert p["email"] not in full_text, f"{p['name']} 的邮箱未被遮罩" + assert p["bank"] not in full_text, f"{p['name']} 的银行卡未被遮罩" + + +def test_five_persons_name_preserved_after_id_redact(five_person_docx, tmp_path, pipeline): + """遮罩身份证后,姓名(未被遮罩类型)仍保留。""" + out = str(tmp_path / "out.docx") + pipeline.redact(five_person_docx, redact_types=["id_card"], out_path=out) + text = _docx_text(out) + for p in PERSONS: + assert p["name"] in text, f"{p['name']} 不应被删除(未遮罩姓名类型)" + + +# ─── 12. 图像:bbox 边界越界处理 ────────────────────────────────────────────────── + +@pytest.fixture +def blank_png(tmp_path): + img = np.ones((200, 400, 3), dtype=np.uint8) * 200 + p = str(tmp_path / "blank.png") + cv2.imwrite(p, img) + return p + + +def test_image_redactor_bbox_clipped_at_boundary(blank_png, tmp_path): + """bbox 超出图像边界时不应抛出异常,输出仍为有效图像。""" + from info_privacy.redactors.image_redactor import ImageRedactor + from info_privacy.models import Entity, EntityType + + out = str(tmp_path / "out.png") + entity = Entity( + id="e1", type=EntityType.FACE, value=None, + page=1, bbox=[350.0, 150.0, 500.0, 300.0], # 超出 400×200 的边界 + layer="image", security_level="high", + ) + ImageRedactor().redact(blank_png, [entity], out) + result = cv2.imread(out) + assert result is not None, "越界 bbox 导致输出图像损坏" + orig = cv2.imread(blank_png) + assert result.shape == orig.shape, "越界 bbox 后图像尺寸变了" + + +def test_image_redactor_minimum_bbox(blank_png, tmp_path): + """极小 bbox(1×1 像素)应能正常处理。""" + from info_privacy.redactors.image_redactor import ImageRedactor + from info_privacy.models import Entity, EntityType + + out = str(tmp_path / "out.png") + entity = Entity( + id="e1", type=EntityType.FACE, value=None, + page=1, bbox=[100.0, 100.0, 101.0, 101.0], + layer="image", security_level="high", + ) + ImageRedactor().redact(blank_png, [entity], out) + result = cv2.imread(out) + assert result is not None + assert result[100, 100].tolist() == [0, 0, 0], "1×1 遮罩区域不是黑色" + + +def test_image_redactor_overlapping_bboxes(blank_png, tmp_path): + """两个重叠的 bbox 都被遮罩(重叠区域仍为黑)。""" + from info_privacy.redactors.image_redactor import ImageRedactor + from info_privacy.models import Entity, EntityType + + out = str(tmp_path / "out.png") + entities = [ + Entity(id="e1", type=EntityType.FACE, value=None, + page=1, bbox=[50.0, 50.0, 150.0, 150.0], layer="image", security_level="high"), + Entity(id="e2", type=EntityType.FACE, value=None, + page=1, bbox=[100.0, 100.0, 200.0, 180.0], layer="image", security_level="high"), + ] + ImageRedactor().redact(blank_png, entities, out) + result = cv2.imread(out) + # 两个独立区域均为黑 + assert result[60, 60].tolist() == [0, 0, 0], "第1个 bbox 未遮罩" + assert result[170, 170].tolist() == [0, 0, 0], "第2个 bbox 未遮罩" + # 重叠区域也为黑 + assert result[120, 120].tolist() == [0, 0, 0], "重叠区域未遮罩" diff --git a/tests/test_integration_real.py b/tests/test_integration_real.py new file mode 100644 index 0000000..98fcd92 --- /dev/null +++ b/tests/test_integration_real.py @@ -0,0 +1,436 @@ +# tests/test_integration_real.py +"""真实数据集成测试:生成含 PII 的真实文档,验证检测 + 遮罩全链路。 + +格式覆盖: + PDF(文字层)/ DOCX / XLSX / PNG 图像遮罩 + 保密文件拦截 / 多类型混合检测 +""" +from __future__ import annotations + +import io +import os +import sys +import textwrap +from pathlib import Path + +import cv2 +import docx as _docx +import numpy as np +import openpyxl +import pytest +from pdfminer.high_level import extract_text +from reportlab.lib.pagesizes import A4 +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas + +from info_privacy.pipeline import PrivacyPipeline +from info_privacy.models import EntityType + +# ─── 字体注册 ───────────────────────────────────────────────────────────────── + +_FONT_PATH = "/usr/share/fonts/truetype/arphic/uming.ttc" +_FONT_NAME = "UMing" + +if os.path.exists(_FONT_PATH): + pdfmetrics.registerFont(TTFont(_FONT_NAME, _FONT_PATH, subfontIndex=0)) + _HAS_FONT = True +else: + _HAS_FONT = False + +_needs_font = pytest.mark.skipif(not _HAS_FONT, reason="uming.ttc 字体不存在") + +# ─── 测试数据常量 ───────────────────────────────────────────────────────────── + +ID_CARD = "110101199001011234" +PHONE = "13812345678" +BANK = "6222021234567890123" +EMAIL = "zhang.wei@example.com" +PLATE = "京A12345" +NAME_CTX = "申请人:张伟," +ADDR_CTX = "地址:北京市朝阳区建国路88号" + +PII_PARAGRAPH = ( + f"申请人:张伟,身份证号:{ID_CARD}\n" + f"联系电话:{PHONE},邮箱:{EMAIL}\n" + f"银行卡:{BANK},车牌:{PLATE}\n" + f"{ADDR_CTX}" +) + +# ─── 工具函数 ────────────────────────────────────────────────────────────────── + +def _make_pdf(path: str, lines: list[str]) -> None: + """用 reportlab 生成含中文文字层的 PDF。""" + c = canvas.Canvas(path, pagesize=A4) + c.setFont(_FONT_NAME, 14) + y = 750 + for line in lines: + c.drawString(50, y, line) + y -= 30 + c.save() + + +def _pdf_text(path: str) -> str: + return extract_text(path) + + +def _docx_text(path: str) -> str: + doc = _docx.Document(path) + return "\n".join(p.text for p in doc.paragraphs) + + +def _xlsx_text(path: str) -> str: + wb = openpyxl.load_workbook(path, data_only=True) + parts = [] + for ws in wb.worksheets: + for row in ws.iter_rows(values_only=True): + parts.extend(str(c) for c in row if c is not None) + return " ".join(parts) + + +@pytest.fixture(scope="module") +def pipeline(): + return PrivacyPipeline() + + +# ─── PDF(文字层)测试 ──────────────────────────────────────────────────────── + +@_needs_font +def test_pdf_analyze_detects_id_card(tmp_path, pipeline): + pdf = str(tmp_path / "pii.pdf") + _make_pdf(pdf, [ + f"申请人:张伟", + f"身份证号:{ID_CARD}", + f"联系电话:{PHONE}", + f"邮箱:{EMAIL}", + ]) + report = pipeline.analyze(pdf) + assert report.blocked is False + types = {e.type for e in report.entities} + assert EntityType.ID_CARD in types, f"未检测到身份证,实体:{types}" + + +@_needs_font +def test_pdf_analyze_detects_phone_and_email(tmp_path, pipeline): + pdf = str(tmp_path / "pii.pdf") + _make_pdf(pdf, [f"电话:{PHONE}", f"邮箱:{EMAIL}"]) + report = pipeline.analyze(pdf) + types = {e.type for e in report.entities} + assert EntityType.PHONE in types, f"未检测到手机号,实体:{types}" + assert EntityType.EMAIL in types, f"未检测到邮箱,实体:{types}" + + +@_needs_font +def test_pdf_analyze_detects_bank_and_plate(tmp_path, pipeline): + pdf = str(tmp_path / "pii.pdf") + _make_pdf(pdf, [f"银行卡:{BANK}", f"车牌:{PLATE}"]) + report = pipeline.analyze(pdf) + types = {e.type for e in report.entities} + assert EntityType.BANK_CARD in types, f"未检测到银行卡,实体:{types}" + assert EntityType.LICENSE_PLATE in types, f"未检测到车牌,实体:{types}" + + +@_needs_font +def test_pdf_redact_removes_id_card(tmp_path, pipeline): + pdf = str(tmp_path / "pii.pdf") + out = str(tmp_path / "redacted.pdf") + _make_pdf(pdf, [f"身份证号:{ID_CARD}", f"电话:{PHONE}"]) + pipeline.redact(pdf, redact_types=["id_card"], out_path=out) + result_text = _pdf_text(out) + assert ID_CARD not in result_text, "身份证号未被遮罩" + assert "电话" in result_text, "无关内容被误删" + + +@_needs_font +def test_pdf_redact_multi_type(tmp_path, pipeline): + pdf = str(tmp_path / "pii.pdf") + out = str(tmp_path / "redacted.pdf") + _make_pdf(pdf, [f"身份证:{ID_CARD}", f"电话:{PHONE}", f"邮箱:{EMAIL}"]) + pipeline.redact(pdf, redact_types=["id_card", "phone", "email"], out_path=out) + result_text = _pdf_text(out) + assert ID_CARD not in result_text + assert PHONE not in result_text + assert EMAIL not in result_text + + +@_needs_font +def test_pdf_classified_blocked(tmp_path, pipeline): + pdf = str(tmp_path / "secret.pdf") + _make_pdf(pdf, ["【机密】本文件仅供内部使用", "禁止外传"]) + report = pipeline.analyze(pdf) + assert report.blocked is True, "保密文件未被拦截" + assert report.classification.value == "classified" + + +@_needs_font +def test_pdf_normal_no_false_positive(tmp_path, pipeline): + pdf = str(tmp_path / "normal.pdf") + _make_pdf(pdf, ["第一季度销售报告", "本季度营业额同比增长12%"]) + report = pipeline.analyze(pdf) + assert report.blocked is False + # 不应有高置信度 PII + high_risk = [e for e in report.entities if e.security_level == "high"] + assert len(high_risk) == 0, f"正常文档误报高风险实体:{[e.type for e in high_risk]}" + + +# ─── DOCX 测试 ──────────────────────────────────────────────────────────────── + +@pytest.fixture +def pii_docx(tmp_path): + doc = _docx.Document() + doc.add_paragraph(f"申请人:张伟 身份证:{ID_CARD}") + doc.add_paragraph(f"联系电话:{PHONE} 邮箱:{EMAIL}") + doc.add_paragraph(f"银行卡号:{BANK}") + doc.add_paragraph(f"住址:北京市朝阳区建国路88号") + p = str(tmp_path / "pii.docx") + doc.save(p) + return p + + +def test_docx_analyze_detects_id_card(pii_docx, pipeline): + report = pipeline.analyze(pii_docx) + types = {e.type for e in report.entities} + assert EntityType.ID_CARD in types, f"未检测到身份证,实体:{types}" + + +def test_docx_analyze_detects_phone(pii_docx, pipeline): + report = pipeline.analyze(pii_docx) + types = {e.type for e in report.entities} + assert EntityType.PHONE in types, f"未检测到手机号,实体:{types}" + + +def test_docx_analyze_detects_name(pii_docx, pipeline): + report = pipeline.analyze(pii_docx) + types = {e.type for e in report.entities} + assert EntityType.NAME in types, f"未检测到姓名,实体:{types}" + + +def test_docx_analyze_detects_address(pii_docx, pipeline): + report = pipeline.analyze(pii_docx) + types = {e.type for e in report.entities} + assert EntityType.ADDRESS in types, f"未检测到地址,实体:{types}" + + +def test_docx_summary_counts(pii_docx, pipeline): + report = pipeline.analyze(pii_docx) + assert report.summary.get("id_card", 0) >= 1 + assert report.summary.get("phone", 0) >= 1 + + +def test_docx_redact_id_card(pii_docx, tmp_path, pipeline): + out = str(tmp_path / "out.docx") + pipeline.redact(pii_docx, redact_types=["id_card"], out_path=out) + text = _docx_text(out) + assert ID_CARD not in text, "身份证号未被遮罩" + assert "张伟" in text, "姓名被误删" + assert PHONE in text, "手机号被误删" + + +def test_docx_redact_multi_type(pii_docx, tmp_path, pipeline): + out = str(tmp_path / "out.docx") + pipeline.redact(pii_docx, redact_types=["id_card", "phone", "bank_card"], out_path=out) + text = _docx_text(out) + assert ID_CARD not in text + assert PHONE not in text + assert BANK not in text + + +def test_docx_redact_preserves_unspecified(pii_docx, tmp_path, pipeline): + """未指定的类型不应被遮罩。""" + out = str(tmp_path / "out.docx") + pipeline.redact(pii_docx, redact_types=["id_card"], out_path=out) + text = _docx_text(out) + assert EMAIL in text, "邮箱不应被遮罩" + assert BANK in text, "银行卡不应被遮罩" + + +def test_docx_classified_blocked(tmp_path, pipeline): + doc = _docx.Document() + doc.add_paragraph("【绝密】本文件属于国家机密") + p = str(tmp_path / "secret.docx") + doc.save(p) + report = pipeline.analyze(p) + assert report.blocked is True + assert len(report.entities) == 0, "拦截文件不应返回实体列表" + + +def test_docx_meta_fields(pii_docx, pipeline): + report = pipeline.analyze(pii_docx) + assert report.doc_meta["format"] == "docx" + assert report.doc_meta["has_text_layer"] is True + assert report.doc_meta["redact_strategy"] == "text_replace" + + +# ─── XLSX 测试 ──────────────────────────────────────────────────────────────── + +@pytest.fixture +def pii_xlsx(tmp_path): + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "人员信息" + ws.append(["姓名", "身份证号", "手机号", "邮箱", "银行卡"]) + ws.append(["张伟", ID_CARD, PHONE, EMAIL, BANK]) + ws.append(["李娜", "310101199505051234", "13900001111", "li@test.com", "6222021111111111111"]) + p = str(tmp_path / "pii.xlsx") + wb.save(p) + return p + + +def test_xlsx_analyze_detects_pii(pii_xlsx, pipeline): + report = pipeline.analyze(pii_xlsx) + types = {e.type for e in report.entities} + assert EntityType.ID_CARD in types + assert EntityType.PHONE in types + + +def test_xlsx_analyze_two_id_cards(pii_xlsx, pipeline): + report = pipeline.analyze(pii_xlsx) + id_count = sum(1 for e in report.entities if e.type == EntityType.ID_CARD) + assert id_count >= 2, f"应检测到至少 2 个身份证,实际:{id_count}" + + +def test_xlsx_redact_removes_id_cards(pii_xlsx, tmp_path, pipeline): + out = str(tmp_path / "out.xlsx") + pipeline.redact(pii_xlsx, redact_types=["id_card"], out_path=out) + text = _xlsx_text(out) + assert ID_CARD not in text, "身份证号未被遮罩" + assert "310101199505051234" not in text, "第二个身份证号未被遮罩" + + +def test_xlsx_redact_preserves_headers(pii_xlsx, tmp_path, pipeline): + out = str(tmp_path / "out.xlsx") + pipeline.redact(pii_xlsx, redact_types=["id_card"], out_path=out) + wb = openpyxl.load_workbook(out, data_only=True) + ws = wb.active + headers = [cell.value for cell in ws[1]] + assert "姓名" in headers, "列头被误删" + assert "身份证号" in headers, "列头被误删" + + +def test_xlsx_meta_fields(pii_xlsx, pipeline): + report = pipeline.analyze(pii_xlsx) + assert report.doc_meta["format"] == "xlsx" + assert report.doc_meta["redact_strategy"] == "text_replace" + + +# ─── PNG 图像遮罩测试(不依赖 RKNN,直接测试 ImageRedactor)──────────────────── + +@pytest.fixture +def pii_png(tmp_path): + """生成包含文字的 PNG(用于测试图像遮罩,OCR 阶段跳过)。""" + img = np.ones((400, 800, 3), dtype=np.uint8) * 240 + cv2.putText(img, f"ID: {ID_CARD}", (20, 80), + cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2) + cv2.putText(img, f"TEL: {PHONE}", (20, 160), + cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2) + cv2.rectangle(img, (200, 220), (400, 320), (180, 200, 220), -1) # 模拟人脸区域 + p = str(tmp_path / "pii.png") + cv2.imwrite(p, img) + return p + + +def test_png_image_redactor_black_box(pii_png, tmp_path): + """ImageRedactor 对指定 bbox 覆盖纯黑矩形。""" + from info_privacy.redactors.image_redactor import ImageRedactor + from info_privacy.models import Entity, EntityType + + out = str(tmp_path / "out.png") + entity = Entity( + id="f1", type=EntityType.FACE, value=None, + page=1, bbox=[200.0, 220.0, 400.0, 320.0], + layer="image", security_level="high", + ) + ImageRedactor().redact(pii_png, [entity], out) + + result = cv2.imread(out) + region = result[220:320, 200:400] + assert region.max() == 0, "遮罩区域应为纯黑" + + +def test_png_image_redactor_preserves_size(pii_png, tmp_path): + """遮罩后图像尺寸不变。""" + from info_privacy.redactors.image_redactor import ImageRedactor + from info_privacy.models import Entity, EntityType + + out = str(tmp_path / "out.png") + entity = Entity( + id="f2", type=EntityType.FACE, value=None, + page=1, bbox=[20.0, 40.0, 300.0, 100.0], + layer="image", security_level="high", + ) + ImageRedactor().redact(pii_png, [entity], out) + orig = cv2.imread(pii_png) + result = cv2.imread(out) + assert orig.shape == result.shape + + +def test_png_image_redactor_outside_bbox_intact(pii_png, tmp_path): + """遮罩区域外的像素不应被修改。""" + from info_privacy.redactors.image_redactor import ImageRedactor + from info_privacy.models import Entity, EntityType + + out = str(tmp_path / "out.png") + # 只遮罩右下角 + entity = Entity( + id="f3", type=EntityType.FACE, value=None, + page=1, bbox=[600.0, 300.0, 780.0, 380.0], + layer="image", security_level="high", + ) + ImageRedactor().redact(pii_png, [entity], out) + orig = cv2.imread(pii_png) + result = cv2.imread(out) + # 左上角应与原图一致 + np.testing.assert_array_equal( + orig[0:50, 0:50], result[0:50, 0:50], + err_msg="非遮罩区域像素被修改", + ) + + +def test_png_image_redactor_multiple_entities(pii_png, tmp_path): + """多个 entity 都被遮罩。""" + from info_privacy.redactors.image_redactor import ImageRedactor + from info_privacy.models import Entity, EntityType + + out = str(tmp_path / "out.png") + entities = [ + Entity(id="e1", type=EntityType.FACE, value=None, + page=1, bbox=[20.0, 40.0, 400.0, 110.0], layer="image", security_level="high"), + Entity(id="e2", type=EntityType.FACE, value=None, + page=1, bbox=[200.0, 220.0, 400.0, 320.0], layer="image", security_level="high"), + ] + ImageRedactor().redact(pii_png, entities, out) + result = cv2.imread(out) + r1 = result[40:110, 20:400] + r2 = result[220:320, 200:400] + assert r1.max() == 0, "第一个遮罩区域未被覆盖" + assert r2.max() == 0, "第二个遮罩区域未被覆盖" + + +# ─── 综合 summary / warning 测试 ───────────────────────────────────────────── + +def test_sensitive_partial_warning_many_entities(tmp_path, pipeline): + """大量高危实体触发 sensitive_partial 警告。""" + doc = _docx.Document() + for i in range(7): + n = int(ID_CARD[:17]) + i + doc.add_paragraph(f"身份证:{n}X") + p = str(tmp_path / "many.docx") + doc.save(p) + report = pipeline.analyze(p) + if report.warning: + # 有警告时,分类应为 sensitive_partial + assert report.classification.value == "sensitive_partial" + + +def test_redact_returns_security_report(tmp_path, pipeline): + """redact() 返回字典包含 redacted 和 strategy_used 字段。""" + doc = _docx.Document() + doc.add_paragraph(f"身份证:{ID_CARD}") + p = str(tmp_path / "t.docx") + doc.save(p) + out = str(tmp_path / "out.docx") + report = pipeline.redact(p, redact_types=["id_card"], out_path=out) + assert "redacted" in report + assert "strategy_used" in report + assert report["strategy_used"] == "text_replace" + assert report["security_guarantee"] == "byte_level" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..d60765a --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,46 @@ +# tests/test_models.py +from info_privacy.models import TextBlock, Entity, DetectionReport, EntityType + +def test_entity_type_enum(): + assert EntityType.ID_CARD.value == "id_card" + assert EntityType.FACE.value == "face" + +def test_text_block_creation(): + block = TextBlock( + text="张三,身份证:110101199001011234", + bbox=[10, 20, 200, 40], + page=1, + layer="text", + ) + assert block.layer == "text" + +def test_entity_creation(): + entity = Entity( + id="e1", + type=EntityType.ID_CARD, + value="110101199001011234", + page=1, + bbox=[10, 20, 200, 40], + layer="text", + security_level="high", + ) + assert entity.type == EntityType.ID_CARD + +def test_detection_report_summary(): + from info_privacy.models import Classification + report = DetectionReport( + doc_id="test-uuid", + classification=Classification.NORMAL, + blocked=False, + block_reason=None, + warning=None, + doc_meta={"format": "pdf", "has_text_layer": True, "redact_strategy": "text_replace"}, + entities=[ + Entity(id="e1", type=EntityType.ID_CARD, value="x", + page=1, bbox=[0,0,1,1], layer="text", security_level="high"), + Entity(id="e2", type=EntityType.PHONE, value="y", + page=1, bbox=[0,0,1,1], layer="text", security_level="high"), + ], + ) + assert report.summary["id_card"] == 1 + assert report.summary["phone"] == 1 diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..438f28d --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,142 @@ +# tests/test_parsers.py +import pytest +from pathlib import Path +from info_privacy.parsers.pdf_parser import PdfParser + +@pytest.fixture +def pdf_path(): + p = Path("tmp/sample_text.pdf") + if not p.exists(): + pytest.skip("需先运行生成脚本") + return str(p) + +def test_pdf_has_text_layer(pdf_path): + parser = PdfParser() + meta, blocks = parser.parse(pdf_path) + assert meta["has_text_layer"] is True + assert meta["format"] == "pdf" + assert len(blocks) > 0 + +def test_pdf_blocks_have_text(pdf_path): + parser = PdfParser() + _, blocks = parser.parse(pdf_path) + all_text = " ".join(b.text for b in blocks) + assert "身份证" in all_text or "张伟" in all_text + +def test_pdf_blocks_layer_is_text(pdf_path): + parser = PdfParser() + _, blocks = parser.parse(pdf_path) + assert all(b.layer == "text" for b in blocks) + +def test_pdf_blocks_have_bbox(pdf_path): + parser = PdfParser() + _, blocks = parser.parse(pdf_path) + for b in blocks: + assert len(b.bbox) == 4 + assert b.bbox[2] > b.bbox[0] # x2 > x1 + + +# Office 解析器测试 +import docx as _docx +from info_privacy.parsers.office_parser import OfficeParser + +@pytest.fixture +def sample_docx(tmp_path): + doc = _docx.Document() + doc.add_paragraph("申请人:李明,身份证:310101198501011234") + doc.add_paragraph("手机:13999998888") + p = str(tmp_path / "sample.docx") + doc.save(p) + return p + +def test_office_parser_docx(sample_docx): + parser = OfficeParser() + meta, blocks = parser.parse(sample_docx) + assert meta["format"] == "docx" + assert meta["has_text_layer"] is True + assert meta["redact_strategy"] == "text_replace" + all_text = " ".join(b.text for b in blocks) + assert "李明" in all_text or "身份证" in all_text + + +# --------------------------------------------------------------------------- +# 图像 OCR 解析器测试(Task 7) +# --------------------------------------------------------------------------- + +import cv2 +import numpy as np +import os + +FACE_MODEL = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/det/ppocrv4_det_ch_int8.rknn" + + +@pytest.fixture +def sample_image(tmp_path): + img = np.ones((200, 600, 3), dtype=np.uint8) * 255 + cv2.putText(img, "ID: 110101199001011234", (10, 50), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 2) + p = str(tmp_path / "sample.png") + cv2.imwrite(p, img) + return p + + +def _rknn_available() -> bool: + """检查 RKNN 运行时是否可用(仅 RK3588 设备上可用)。""" + try: + import importlib.util + return importlib.util.find_spec("rknn") is not None or importlib.util.find_spec("rknnlite") is not None + except Exception: + return False + + +def test_image_parser_returns_blocks(sample_image): + det_model = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/det/ppocrv4_det_ch_int8.rknn" + rec_model = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/rec/ppocrv4_rec_ch_fp16.rknn" + dict_file = "/data/rockchip/paddle_ocr/data/dicts/ppocr_keys_v1.txt" + if not os.path.exists(det_model): + pytest.skip("RKNN模型不存在,跳过") + if not _rknn_available(): + pytest.skip("rknn/rknnlite 不可用(非 RK3588 设备),跳过") + from info_privacy.parsers.image_parser import ImageParser + parser = ImageParser(det_model_path=det_model, rec_model_path=rec_model, dict_path=dict_file) + meta, blocks = parser.parse(sample_image) + assert meta["format"] == "image" + assert meta["has_text_layer"] is False + assert meta["redact_strategy"] == "image_mask" + assert isinstance(blocks, list) + + +def test_image_parser_block_layer(sample_image): + det_model = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/det/ppocrv4_det_ch_int8.rknn" + if not os.path.exists(det_model): + pytest.skip("RKNN模型不存在") + if not _rknn_available(): + pytest.skip("rknn/rknnlite 不可用(非 RK3588 设备),跳过") + rec_model = "/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/rec/ppocrv4_rec_ch_fp16.rknn" + dict_file = "/data/rockchip/paddle_ocr/data/dicts/ppocr_keys_v1.txt" + from info_privacy.parsers.image_parser import ImageParser + parser = ImageParser(det_model_path=det_model, rec_model_path=rec_model, dict_path=dict_file) + _, blocks = parser.parse(sample_image) + for b in blocks: + assert b.layer == "image" + + +# Parser Factory 测试 +from info_privacy.parsers.parser_factory import ParserFactory + +def test_factory_routes_pdf(pdf_path): + factory = ParserFactory() + parser = factory.get_parser(pdf_path) + from info_privacy.parsers.pdf_parser import PdfParser + assert isinstance(parser, PdfParser) + +def test_factory_routes_docx(sample_docx, tmp_path): + factory = ParserFactory() + parser = factory.get_parser(sample_docx) + from info_privacy.parsers.office_parser import OfficeParser + assert isinstance(parser, OfficeParser) + +def test_factory_unsupported_raises(): + factory = ParserFactory() + with pytest.raises(ValueError, match="不支持"): + factory.get_parser("file.xyz") diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..53883fa --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,89 @@ +# tests/test_pipeline.py +import pytest +import docx +from info_privacy.pipeline import PrivacyPipeline + +@pytest.fixture +def pipeline(): + return PrivacyPipeline() + +@pytest.fixture +def sample_docx_path(tmp_path): + doc = docx.Document() + doc.add_paragraph("姓名:张伟 身份证:110101199001011234 电话:13812345678") + p = tmp_path / "test.docx" + doc.save(str(p)) + return str(p) + +def test_analyze_docx(pipeline, sample_docx_path): + report = pipeline.analyze(sample_docx_path) + assert report.blocked is False + assert len(report.entities) > 0 + types = {e.type.value for e in report.entities} + assert "id_card" in types or "phone" in types + +def test_analyze_classified_doc(pipeline, tmp_path): + doc = docx.Document() + doc.add_paragraph("【机密】本文件仅供内部使用") + p = tmp_path / "secret.docx" + doc.save(str(p)) + report = pipeline.analyze(str(p)) + assert report.blocked is True + assert report.classification.value == "classified" + +def test_redact_docx_removes_entity(pipeline, sample_docx_path, tmp_path): + out = str(tmp_path / "out.docx") + pipeline.redact(sample_docx_path, redact_types=["id_card"], out_path=out) + doc = docx.Document(out) + all_text = " ".join(p.text for p in doc.paragraphs) + assert "110101199001011234" not in all_text + assert "张伟" in all_text # 未指定遮罩 name,保留 + + +def test_analyze_returns_summary_dict(pipeline, sample_docx_path): + """DetectionReport.summary 各类型计数应与 entities 列表精确对应。""" + report = pipeline.analyze(sample_docx_path) + for etype, count in report.summary.items(): + actual = sum(1 for e in report.entities if e.type.value == etype) + assert actual == count, f"{etype}: summary={count},entities={actual}" + + +def test_analyze_no_pii_empty_entities(pipeline, tmp_path): + """无 PII 的文档:entities 为空,分类为 normal,无 warning。""" + doc = docx.Document() + doc.add_paragraph("季度汇报:各项指标均达标。") + p = tmp_path / "clean.docx" + doc.save(str(p)) + report = pipeline.analyze(str(p)) + assert len(report.entities) == 0 + assert report.classification.value == "normal" + assert report.warning is None + + +def test_analyze_many_entities_triggers_warning(pipeline, tmp_path): + """5 个以上高风险实体应在 DetectionReport 中生成 warning。""" + doc = docx.Document() + ids = [ + "110101199001011234", "110101199001011235", "110101199001011236", + "110101199001011237", "110101199001011238", + ] + doc.add_paragraph(" ".join(ids)) + p = tmp_path / "many.docx" + doc.save(str(p)) + report = pipeline.analyze(str(p)) + assert report.warning is not None + + +def test_redact_multi_type_all_removed(pipeline, tmp_path): + """同时遮罩 id_card/phone/email 三种类型,各类值均应从文档消失。""" + doc = docx.Document() + doc.add_paragraph("身份证:110101199001011234 电话:13812345678 邮箱:user@corp.com") + p = tmp_path / "multi.docx" + doc.save(str(p)) + out = str(tmp_path / "out.docx") + pipeline.redact(str(p), redact_types=["id_card", "phone", "email"], out_path=out) + result = docx.Document(out) + text = " ".join(para.text for para in result.paragraphs) + assert "110101199001011234" not in text + assert "13812345678" not in text + assert "user@corp.com" not in text diff --git a/tests/test_redactors.py b/tests/test_redactors.py new file mode 100644 index 0000000..9386f61 --- /dev/null +++ b/tests/test_redactors.py @@ -0,0 +1,162 @@ +# tests/test_redactors.py +import pytest +import docx +from pathlib import Path +from info_privacy.models import Entity, EntityType +from info_privacy.redactors.text_redactor import TextRedactor + +@pytest.fixture +def sample_docx_path(tmp_path): + doc = docx.Document() + doc.add_paragraph("身份证:310101198501011234 手机:13999998888") + p = tmp_path / "test.docx" + doc.save(str(p)) + return str(p) + +@pytest.fixture +def entity_id_card(): + return Entity( + id="e1", type=EntityType.ID_CARD, + value="310101198501011234", + page=1, bbox=[0,0,100,20], layer="text", security_level="high" + ) + +def test_redact_docx_removes_value(sample_docx_path, entity_id_card, tmp_path): + out_path = str(tmp_path / "out.docx") + redactor = TextRedactor() + redactor.redact(sample_docx_path, [entity_id_card], out_path) + doc = docx.Document(out_path) + all_text = " ".join(p.text for p in doc.paragraphs) + assert "310101198501011234" not in all_text + +def test_redact_docx_preserves_other_text(sample_docx_path, entity_id_card, tmp_path): + out_path = str(tmp_path / "out.docx") + redactor = TextRedactor() + redactor.redact(sample_docx_path, [entity_id_card], out_path) + doc = docx.Document(out_path) + all_text = " ".join(p.text for p in doc.paragraphs) + assert "手机" in all_text + + +# 图像遮罩器测试 +import cv2 +import numpy as np +from info_privacy.redactors.image_redactor import ImageRedactor + +@pytest.fixture +def sample_image_path(tmp_path): + img = np.ones((200, 400, 3), dtype=np.uint8) * 255 + cv2.putText(img, "ID: 123456789", (10, 100), cv2.FONT_HERSHEY_SIMPLEX, 1, (0,0,0), 2) + p = str(tmp_path / "test.png") + cv2.imwrite(p, img) + return p + +@pytest.fixture +def entity_face(): + return Entity(id="f1", type=EntityType.FACE, value=None, + page=1, bbox=[10, 80, 200, 120], layer="image", security_level="high") + +def test_image_redactor_black_box(sample_image_path, entity_face, tmp_path): + out = str(tmp_path / "out.png") + redactor = ImageRedactor() + redactor.redact(sample_image_path, [entity_face], out) + result = cv2.imread(out) + # 遮罩区域应为纯黑 + region = result[80:120, 10:200] + assert region.max() == 0 + +def test_image_redactor_output_same_size(sample_image_path, entity_face, tmp_path): + out = str(tmp_path / "out.png") + redactor = ImageRedactor() + redactor.redact(sample_image_path, [entity_face], out) + orig = cv2.imread(sample_image_path) + result = cv2.imread(out) + assert orig.shape == result.shape + + +def test_redact_excel_removes_value(tmp_path): + """Excel 单元格中的 PII 应被 TextRedactor 替换为遮罩字符。""" + import openpyxl + wb = openpyxl.Workbook() + ws = wb.active + ws["A1"] = "身份证" + ws["A2"] = "310101198501011234" + p = str(tmp_path / "test.xlsx") + wb.save(p) + + entity = Entity( + id="e1", type=EntityType.ID_CARD, value="310101198501011234", + page=1, bbox=[0, 0, 100, 20], layer="text", security_level="high", + ) + out = str(tmp_path / "out.xlsx") + TextRedactor().redact(p, [entity], out) + + wb_out = openpyxl.load_workbook(out) + values = [cell.value for row in wb_out.active.iter_rows() for cell in row if cell.value] + assert "310101198501011234" not in values + assert any("████" in str(v) for v in values), "遮罩字符 ████ 未出现" + + +def test_redact_docx_table_cell_removes_value(tmp_path): + """DOCX 表格单元格中的 PII 应被正确遮罩(非仅正文段落)。""" + import docx as _docx + doc = _docx.Document() + table = doc.add_table(rows=2, cols=2) + table.cell(0, 0).text = "姓名" + table.cell(0, 1).text = "身份证" + table.cell(1, 0).text = "张三" + table.cell(1, 1).text = "310101198501011234" + p = str(tmp_path / "table.docx") + doc.save(p) + + entity = Entity( + id="e1", type=EntityType.ID_CARD, value="310101198501011234", + page=1, bbox=[0, 0, 100, 20], layer="text", security_level="high", + ) + out = str(tmp_path / "out.docx") + TextRedactor().redact(p, [entity], out) + + doc_out = _docx.Document(out) + cell_texts = [ + cell.text for table in doc_out.tables + for row in table.rows for cell in row.cells + ] + assert "310101198501011234" not in cell_texts + + +def test_redact_docx_mask_uses_filled_blocks(tmp_path): + """遮罩后目标值应被替换为全角方块字符 ████。""" + import docx as _docx + doc = _docx.Document() + doc.add_paragraph("手机:13812345678") + p = str(tmp_path / "test.docx") + doc.save(p) + + entity = Entity( + id="e1", type=EntityType.PHONE, value="13812345678", + page=1, bbox=[0, 0, 100, 20], layer="text", security_level="high", + ) + out = str(tmp_path / "out.docx") + TextRedactor().redact(p, [entity], out) + + doc_out = _docx.Document(out) + all_text = " ".join(para.text for para in doc_out.paragraphs) + assert "████" in all_text + + +def test_image_redactor_skips_text_layer_entities(tmp_path): + """ImageRedactor 对 layer='text' 的实体不应执行图像遮罩。""" + img = np.ones((200, 400, 3), dtype=np.uint8) * 255 + p = str(tmp_path / "test.png") + cv2.imwrite(p, img) + + text_entity = Entity( + id="e1", type=EntityType.ID_CARD, value="123", + page=1, bbox=[10, 10, 100, 50], layer="text", security_level="high", + ) + out = str(tmp_path / "out.png") + ImageRedactor().redact(p, [text_entity], out) + + orig = cv2.imread(p) + result = cv2.imread(out) + assert np.array_equal(orig, result), "text 层实体不应触发图像遮罩" diff --git a/tests/test_simulation.py b/tests/test_simulation.py new file mode 100644 index 0000000..aaa0619 --- /dev/null +++ b/tests/test_simulation.py @@ -0,0 +1,267 @@ +# tests/test_simulation.py +"""仿真测试:用 unittest.mock 替换 RKNN 硬件依赖,在 x86 环境全量可运行。 + +覆盖范围: + - FaceDetector:detect_in_image 业务逻辑(bbox 转换、实体构建、空结果处理) + - ImageParser:OCR 结果解析(TextBlock 构建、置信度过滤、meta 字段) +""" +import sys +import numpy as np +import pytest +import cv2 +from unittest.mock import MagicMock +from unittest.mock import patch + + +# ─── 工具函数 ───────────────────────────────────────────────────────────────── + +def _make_detection(bbox: list) -> MagicMock: + """创建带 bbox 属性的模拟人脸检测结果。""" + det = MagicMock() + det.bbox = bbox + return det + + +def _make_face_solutions_mock(detections=None): + """返回模拟 mediapipe_rknn.solutions 模块及其内部 detector 实例。""" + mock_instance = MagicMock() + mock_instance.detect.return_value = detections if detections is not None else [] + + mock_solutions = MagicMock() + mock_solutions.FaceDetection.return_value = mock_instance + + return mock_solutions, mock_instance + + +def _make_ppocr_mocks(dt_boxes=None, rec_results=None): + """返回模拟 ppocr_det / ppocr_rec 模块对及内部实例。""" + mock_det = MagicMock() + mock_det_inst = MagicMock() + mock_det_inst.run.return_value = dt_boxes # None 或 box list + mock_det.TextDetector.return_value = mock_det_inst + mock_det.get_rotate_crop_image.return_value = np.zeros((32, 100, 3), dtype=np.uint8) + + mock_rec = MagicMock() + mock_rec_inst = MagicMock() + mock_rec_inst.run.return_value = rec_results or [] + mock_rec.TextRecognizer.return_value = mock_rec_inst + + return mock_det, mock_rec + + +def _write_blank_image(path: str, h: int = 100, w: int = 300) -> None: + cv2.imwrite(path, np.ones((h, w, 3), dtype=np.uint8) * 255) + + +# ─── FaceDetector 仿真测试 ───────────────────────────────────────────────────── + +@pytest.fixture +def face_detector_no_face(): + mock_solutions, _ = _make_face_solutions_mock(detections=[]) + patches = { + "mediapipe_rknn": MagicMock(), + "mediapipe_rknn.solutions": mock_solutions, + } + with patch.dict(sys.modules, patches): + from info_privacy.detectors.face_detector import FaceDetector + yield FaceDetector("/fake/model.rknn") + + +@pytest.fixture +def face_detector_two_faces(): + dets = [ + _make_detection([10.0, 20.0, 100.0, 150.0]), + _make_detection([200.0, 50.0, 350.0, 200.0]), + ] + mock_solutions, _ = _make_face_solutions_mock(detections=dets) + patches = { + "mediapipe_rknn": MagicMock(), + "mediapipe_rknn.solutions": mock_solutions, + } + with patch.dict(sys.modules, patches): + from info_privacy.detectors.face_detector import FaceDetector + yield FaceDetector("/fake/model.rknn") + + +def test_face_no_detection_returns_empty_list(face_detector_no_face): + img = np.ones((300, 300, 3), dtype=np.uint8) * 255 + entities = face_detector_no_face.detect_in_image(img, page=1) + assert isinstance(entities, list) + assert len(entities) == 0 + + +def test_face_two_detections_returns_two_entities(face_detector_two_faces): + from info_privacy.models import EntityType + img = np.zeros((400, 500, 3), dtype=np.uint8) + entities = face_detector_two_faces.detect_in_image(img, page=2) + assert len(entities) == 2 + + +def test_face_entity_fields(face_detector_two_faces): + """每个 Entity 的 type / layer / security_level / page 必须正确。""" + from info_privacy.models import EntityType + img = np.zeros((300, 300, 3), dtype=np.uint8) + entities = face_detector_two_faces.detect_in_image(img, page=3) + for e in entities: + assert e.type == EntityType.FACE + assert e.layer == "image" + assert e.security_level == "high" + assert e.page == 3 + assert len(e.bbox) == 4 + + +def test_face_entity_bbox_is_float(face_detector_two_faces): + """bbox 坐标必须转换为 float,保证 JSON 序列化不报错。""" + img = np.zeros((300, 300, 3), dtype=np.uint8) + entities = face_detector_two_faces.detect_in_image(img, page=1) + for e in entities: + for v in e.bbox: + assert isinstance(v, float) + + +def test_face_detect_returns_none_gives_empty(): + """detect() 返回 None 时 detect_in_image 应安全返回 []。""" + mock_solutions, mock_inst = _make_face_solutions_mock() + mock_inst.detect.return_value = None + patches = { + "mediapipe_rknn": MagicMock(), + "mediapipe_rknn.solutions": mock_solutions, + } + with patch.dict(sys.modules, patches): + from info_privacy.detectors.face_detector import FaceDetector + detector = FaceDetector("/fake/model.rknn") + result = detector.detect_in_image(np.zeros((200, 200, 3), dtype=np.uint8), page=1) + assert result == [] + + +def test_face_entity_id_unique(face_detector_two_faces): + """两张人脸的 Entity.id 必须不同(uuid4 生成)。""" + img = np.zeros((300, 300, 3), dtype=np.uint8) + entities = face_detector_two_faces.detect_in_image(img, page=1) + ids = [e.id for e in entities] + assert len(set(ids)) == len(ids) + + +# ─── ImageParser 仿真测试 ────────────────────────────────────────────────────── + +@pytest.fixture +def parser_no_text(): + """OCR 检测无文字区域(dt_boxes=None)的 ImageParser。""" + mock_det, mock_rec = _make_ppocr_mocks(dt_boxes=None) + patches = {"ppocr_det": mock_det, "ppocr_rec": mock_rec} + with patch.dict(sys.modules, patches): + from info_privacy.parsers.image_parser import ImageParser + yield ImageParser("/f/det.rknn", "/f/rec.rknn", "/f/dict.txt") + + +@pytest.fixture +def parser_with_id_card(): + """OCR 识别出身份证号文字区域的 ImageParser。""" + boxes = [np.array([[10, 10], [200, 10], [200, 30], [10, 30]], dtype=np.float32)] + recs = [("身份证:110101199001011234", 0.95)] + mock_det, mock_rec = _make_ppocr_mocks(dt_boxes=boxes, rec_results=recs) + patches = {"ppocr_det": mock_det, "ppocr_rec": mock_rec} + with patch.dict(sys.modules, patches): + from info_privacy.parsers.image_parser import ImageParser + yield ImageParser("/f/det.rknn", "/f/rec.rknn", "/f/dict.txt") + + +def test_image_parser_meta_format(parser_no_text, tmp_path): + img_path = str(tmp_path / "t.png") + _write_blank_image(img_path) + meta, _ = parser_no_text.parse(img_path) + assert meta["format"] == "image" + assert meta["has_text_layer"] is False + assert meta["redact_strategy"] == "image_mask" + + +def test_image_parser_no_boxes_returns_empty(parser_no_text, tmp_path): + img_path = str(tmp_path / "t.png") + _write_blank_image(img_path) + _, blocks = parser_no_text.parse(img_path) + assert blocks == [] + + +def test_image_parser_blocks_layer_is_image(parser_with_id_card, tmp_path): + img_path = str(tmp_path / "t.png") + _write_blank_image(img_path) + _, blocks = parser_with_id_card.parse(img_path) + assert len(blocks) >= 1 + for b in blocks: + assert b.layer == "image" + + +def test_image_parser_block_text_content(parser_with_id_card, tmp_path): + img_path = str(tmp_path / "t.png") + _write_blank_image(img_path) + _, blocks = parser_with_id_card.parse(img_path) + texts = " ".join(b.text for b in blocks) + assert "身份证" in texts + + +def test_image_parser_block_bbox_valid(parser_with_id_card, tmp_path): + img_path = str(tmp_path / "t.png") + _write_blank_image(img_path) + _, blocks = parser_with_id_card.parse(img_path) + for b in blocks: + assert len(b.bbox) == 4 + assert b.bbox[2] > b.bbox[0] # x2 > x1 + assert b.bbox[3] > b.bbox[1] # y2 > y1 + + +def test_image_parser_low_confidence_filtered(tmp_path): + """置信度 < 0.5 的 OCR 结果不得生成 TextBlock。""" + boxes = [np.array([[10, 10], [200, 10], [200, 30], [10, 30]], dtype=np.float32)] + recs = [("模糊文字", 0.3)] + mock_det, mock_rec = _make_ppocr_mocks(dt_boxes=boxes, rec_results=recs) + patches = {"ppocr_det": mock_det, "ppocr_rec": mock_rec} + with patch.dict(sys.modules, patches): + from info_privacy.parsers.image_parser import ImageParser + parser = ImageParser("/f/det.rknn", "/f/rec.rknn", "/f/dict.txt") + img_path = str(tmp_path / "t.png") + _write_blank_image(img_path) + _, blocks = parser.parse(img_path) + assert len(blocks) == 0 + + +def test_image_parser_boundary_confidence(tmp_path): + """置信度恰好 = 0.5 的结果应保留(>= 0.5 视为有效)。""" + boxes = [np.array([[10, 10], [200, 10], [200, 30], [10, 30]], dtype=np.float32)] + recs = [("临界文字", 0.5)] + mock_det, mock_rec = _make_ppocr_mocks(dt_boxes=boxes, rec_results=recs) + patches = {"ppocr_det": mock_det, "ppocr_rec": mock_rec} + with patch.dict(sys.modules, patches): + from info_privacy.parsers.image_parser import ImageParser + parser = ImageParser("/f/det.rknn", "/f/rec.rknn", "/f/dict.txt") + img_path = str(tmp_path / "t.png") + _write_blank_image(img_path) + _, blocks = parser.parse(img_path) + assert len(blocks) == 1 + + +def test_image_parser_run_on_image_page(parser_with_id_card): + """run_on_image() 供 PDF 扫描件调用时,page 参数必须正确透传。""" + img = np.ones((100, 300, 3), dtype=np.uint8) * 255 + blocks = parser_with_id_card.run_on_image(img, page=5) + assert all(b.page == 5 for b in blocks) + + +def test_image_parser_multiple_boxes(tmp_path): + """多个文字区域均能正确生成对应 TextBlock。""" + boxes = [ + np.array([[10, 10], [200, 10], [200, 30], [10, 30]], dtype=np.float32), + np.array([[10, 50], [300, 50], [300, 70], [10, 70]], dtype=np.float32), + ] + recs = [("姓名:张三", 0.9), ("电话:13812345678", 0.88)] + mock_det, mock_rec = _make_ppocr_mocks(dt_boxes=boxes, rec_results=recs) + patches = {"ppocr_det": mock_det, "ppocr_rec": mock_rec} + with patch.dict(sys.modules, patches): + from info_privacy.parsers.image_parser import ImageParser + parser = ImageParser("/f/det.rknn", "/f/rec.rknn", "/f/dict.txt") + img_path = str(tmp_path / "t.png") + _write_blank_image(img_path, h=200, w=400) + _, blocks = parser.parse(img_path) + assert len(blocks) == 2 + texts = {b.text for b in blocks} + assert "姓名:张三" in texts + assert "电话:13812345678" in texts