From 278f1e0f6934fce4475e3c6d347159d51be8df25 Mon Sep 17 00:00:00 2001 From: douboer Date: Sat, 28 Mar 2026 19:36:19 +0800 Subject: [PATCH] update at 2026-03-28 19:36:19 --- .DS_Store | Bin 8196 -> 8196 bytes README.md | 497 +++++------ __pycache__/remove_background.cpython-312.pyc | Bin 39104 -> 61409 bytes images/.DS_Store | Bin 10244 -> 10244 bytes remove_background.py | 806 +++++++++++++++--- tests/test_remove_background.py | 125 +++ 6 files changed, 1039 insertions(+), 389 deletions(-) diff --git a/.DS_Store b/.DS_Store index eb4eeed8c1ef2cc1d09fbebeaa38d6081c4aa226..644aa33d5478d7dc915b68b9b00e3c3f042cfe9c 100644 GIT binary patch delta 439 zcmZp1XmQw3Da>wUW~!rLYBsq+IGpj$XI17-ljo zW!T8Dhv6KwYYM`TFYCgF^IDGPN0T<4Q%;fyM;LNJj$+{v(CkqG($wj9ZWu^od zBqnF(r7PQiU;qPUhX4p;vY@EQ5ZEpylfrCMp!I{B_A(SDDp^{+&!yJYc44WB_ nGF)PK&hV3wol%fcdGd52>&-hw{g@^;aBpUp;AG{bmT?>a^29UX diff --git a/README.md b/README.md index 42a424d..8a2f4a1 100644 --- a/README.md +++ b/README.md @@ -1,293 +1,294 @@ # 图片去背景工具 -使用rembg库实现的Python去背景工具 +用于书画、篆刻作品的去背景与去主体补背景脚本。 -## 快速开始 +默认模式优先走书画专用前景提取逻辑: +- 对墨色笔画做局部背景校正与明暗差分 +- 对印章做更严格的红色检测 +- 在粗掩码内进一步细化 alpha,尽量减少字边残留底色 +- 仅在需要时才回退到 `rembg` + +## 功能概览 + +- 单张图片去背景,输出透明 PNG +- 批量处理单层目录中的图片 +- 支持 `artwork`、`auto`、`rembg` 三种前景提取模式 +- 支持 `calligraphy`、`seal`、`auto` 三种书画类型 +- 支持 AOT-GAN 去主体补背景 +- 支持常见格式:`jpg`、`jpeg`、`png`、`bmp`、`webp` +- 安装 `pillow-heif` 后可读取 `heic` / `heif` + +## 依赖 + +项目当前依赖见 [requirements.txt](/Users/gavin/removeback/requirements.txt): + +```txt +rembg[gpu] +pillow +pillow-heif +opencv-python +``` + +如果要使用 AOT-GAN 补背景,还需要额外安装 `torch` / `torchvision`,并准备 AOT-GAN 代码目录与权重文件。 + +## 环境准备 + +推荐使用现有虚拟环境: ```bash -# 激活虚拟环境 source ~/venv/bin/activate - -# 使用默认参数处理images文件夹 -python remove_background.py - -# 处理单个文件 -python remove_background.py input.jpg output.png - -# 查看所有参数 -python remove_background.py -h ``` -不同模型适用于不同场景: +安装依赖: -| 模型名称 | 大小 | 适用场景 | 推荐度 | -|---------|------|---------|--------| -| **isnet-general-use** | 179MB | 通用场景 | ⭐⭐⭐⭐⭐ 默认推荐 | -| birefnet-general | 250MB | 通用场景,质量更高 | ⭐⭐⭐⭐⭐ | -| birefnet-portrait | 250MB | 人像专用 | ⭐⭐⭐⭐⭐ | -| u2net | 176MB | 经典通用模型 | ⭐⭐⭐⭐ | -| u2netp | 4.7MB | 快速处理 | ⭐⭐⭐ | -| u2net_human_seg | 176MB | 人物分割 | ⭐⭐⭐⭐ | -| isnet-anime | 179MB | 动漫角色 | ⭐⭐⭐⭐ | -| silueta | 43MB | 精简快速 | ⭐⭐⭐ | - -**使用示例**: ```bash -# 使用默认模型 -python remove_background.py input.jpg - -# 使用人像专用模型 -python remove_background.py input.jpg output.png -m birefnet-portrait - -# 使用快速模型 -python remove_background.py input.jpg output.png -m u2netp - -# 书画类文字偏浅的温和参数示例 -python remove_background.py images output \ - --remove-subject --black_subject --gray_subject --save_mask \ - --black-threshold 30\ - --gray-saturation-threshold 30 --gray-value-threshold 30 \ - --edge-grow 2 \ - --feather --feather-radius 4 \ - --aot-pretrain experiments/G0000000.pt \ - --aot-max-size 1000 +pip install -r requirements.txt ``` -查看已下载模型: -```bash -ls -lh ~/.u2net/ -``` - -### 模型大小参考 - -| 模型名称 | 文件大小 | 特点 | -|---------|---------|------| -| u2net | 176MB | 通用模型 | -| u2netp | 4.7MB | 轻量级,速度快 | -| isnet-general-use | 179MB | 新一代通用,推荐 | -| birefnet-general | ~250MB | 最新通用模型 | -| birefnet-portrait | ~250MB | 人像专用 | - -### 手动下载模型(网络问题时) +如果你需要启用去主体补背景: ```bash -# 创建目录 -mkdir -p ~/.u2net/ - -# 下载指定模型(以isnet-general-use为例) -curl -L "https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx" \ - -o ~/.u2net/isnet-general-use.onnx -``` - -### 清理模型缓存 - -```bash -# 删除所有已下载的模型 -rm -rf ~/.u2net/ - -# 删除特定模型 -rm ~/.u2net/u2net.onnx -``` - -## AOT-GAN 修补后端 - -`--remove-subject` 默认使用 AOT-GAN 修补。 -AOT-GAN 依赖 PyTorch(官方仓库测试 Python 3.8 / torch 1.8.1)。建议使用独立虚拟环境或确保兼容版本。 - -```bash -# 安装依赖(示例) pip install torch torchvision ``` -下载预训练权重后,运行示例: +## 快速开始 + +处理默认 `images/` 目录中的图片,结果输出到 `output/`: + ```bash -python remove_background.py "images/IMG_9259 2.JPG" \ - --remove-subject --black-subject --gray-subject --save-mask \ - --aot-pretrain experiments/places2.pth +python remove_background.py ``` -CPU 无 GPU 时的加速建议(只裁剪主体区域并限制最大边): +处理单张图片: + ```bash -python remove_background.py "images/IMG_9259 2.JPG" \ - --remove-subject --black-subject --gray-subject --save-mask \ - --aot-pretrain experiments/places2.pth \ - --aot-crop --aot-crop-pad 24 --aot-max-size 1400 +python remove_background.py input.jpg output.png ``` -减少“补脸”倾向:启用随机噪声预填充 +处理指定目录: + ```bash -python remove_background.py "images/IMG_9259 2.JPG" \ - --remove-subject --black-subject --gray-subject --save-mask \ - --aot-pretrain experiments/places2.pth \ - --aot-crop --aot-crop-pad 64 --aot-max-size 900 \ - --aot-noise-prefill --aot-noise-strength 1.0 +python remove_background.py my_images/ my_output/ ``` -## 可调整参数说明 +指定为印章场景: -### 1. 模型选择 (model_name) - -不同模型适用于不同场景: - -- **u2net** (默认): 通用模型,适合大多数场景 -- **u2netp**: 轻量版,速度更快但精度稍低 -- **u2net_human_seg**: 专门用于人物分割 -- **silueta**: 精简版u2net (43MB),速度快 -- **isnet-general-use**: 新一代通用模型,效果可能更好 -- **isnet-anime**: 专门用于动漫角色 -- **birefnet-general**: 最新的通用模型,推荐尝试 -- **birefnet-portrait**: 专门用于人像 -- **birefnet-general-lite**: 轻量版birefnet - -**建议**: 如果u2net效果不好,试试 `isnet-general-use` 或 `birefnet-general` - -### 2. Alpha Matting 参数 - -Alpha Matting 是后处理步骤,可以显著改善边缘质量,特别是头发、毛发等细节。 - -#### alpha_matting(开关) -- **作用**: 是否启用 alpha matting,提升边缘质量 -- **默认**: 关闭(不传 `-a/--alpha-matting`) -- **启用方式**: 传入 `-a` 或 `--alpha-matting` -- **效果**: 有利于细节边缘(毛发/细线),但速度稍慢 - -#### alpha_matting_foreground_threshold (0-255) -- **作用**: 前景阈值,控制哪些区域被认为是前景 -- **默认**: 240 -- **调整建议**: - - 值越大(如270): 保留更多细节,但可能保留一些背景 - - 值越小(如210): 去除更彻底,但可能丢失细节 - - 如果前景被过度去除,增加此值 - - 如果背景残留太多,减小此值 - -#### alpha_matting_background_threshold (0-255) -- **作用**: 背景阈值,控制哪些区域被认为是背景 -- **默认**: 10 -- **调整建议**: - - 值越大(如20-30): 去除背景更彻底 - - 值越小(如5): 保留更多过渡区域 - - 如果背景残留,增加此值 - -#### alpha_matting_erode_size (像素) -- **作用**: 侵蚀大小,用于平滑边缘 -- **默认**: 10 -- **调整建议**: - - 值越大(如15-20): 边缘更平滑,但可能损失细节 - - 值越小(如5-8): 保留更多细节,但边缘可能不够平滑 - -### 3. Mask后处理 (post_process_mask) - -- **作用**: 对 mask 进行额外后处理 -- **默认**: 关闭(不传 `-p/--post-process`) -- **启用方式**: 传入 `-p` 或 `--post-process` -- **效果**: 有助于减少毛边,但可能略损失细节 - -### 4. 去主体补背景 (remove_subject) - -用于“去掉主体并补全背景”。当前仅使用 AOT-GAN 修补。 - -- **remove_subject(开关)**: 启用去主体补背景(默认关闭,传 `--remove-subject` 开启) -- **aot_root**: AOT-GAN 目录(默认: `AOT-GAN-for-Inpainting`) -- **aot_pretrain**: AOT-GAN 权重文件路径(必填) -- **aot_device**: AOT-GAN 设备(默认: `cpu`) -- **aot_block_num**: AOTBlock 数量(默认: 8) -- **aot_rates**: AOTBlock 膨胀率(默认: `1+2+4+8`) -- **aot_crop(开关)**: 仅对 mask 覆盖区域裁剪修补(默认关闭,传 `--aot-crop` 开启) -- **aot_crop_pad (像素)**: 裁剪边缘留白像素(默认: 0) -- **aot_max_size (像素)**: AOT 输入最大边限制(默认: 0 表示不限制) -- **aot_noise_prefill(开关)**: AOT使用随机噪声预填充(默认关闭) -- **aot_noise_strength (系数)**: 噪声强度(默认: 1.0) -- **mask_dilate (像素)**: mask 膨胀大小(默认: 3)。越大去除范围越大,风险更高 -- **mask_blur (像素)**: mask 模糊大小(默认: 3)。越大边缘越柔和但易过度 -- **mask_threshold (0-255)**: alpha 阈值(默认: 10)。越大保留越多主体 -- **edge_grow (像素)**: 主体边缘额外扩张(默认: 0)。用于清理残留边缘 -- **save_mask(开关)**: 保存 mask 方便检查(默认关闭,传 `--save-mask` 开启) -- **black_subject(开关)**: 将黑色内容也视为主体(默认关闭,传 `--black-subject` 开启) -- **black_threshold (0-255)**: 黑色阈值(默认: 50)。越大越容易把浅灰当黑 -- **gray_subject(开关)**: 将灰阶内容也视为主体(默认关闭,传 `--gray-subject` 开启) -- **gray_saturation_threshold (0-255)**: 灰阶饱和度阈值(默认: 30)。越大越容易把彩色当灰 -- **gray_value_threshold (0-255)**: 灰阶亮度阈值(默认: 200)。越大越容易把浅灰当灰 -- **feather(开关)**: 启用边缘过渡(默认关闭,传 `--feather` 开启) -- **feather_radius (像素)**: 过渡半径(默认: 5)。越大过渡越柔和但可能变糊 -- **说明**: 过渡仅在 mask 外侧进行,避免把主体边缘带回 - -### 5. 参数调优建议(针对书画/字迹) -- 先开启 `--remove-subject`,仅看主体遮罩是否覆盖到字迹 -- 文字残留:提高 `--black-threshold` 或 `--gray-*` 阈值 -- 过度修补:降低 `--black-threshold`、`--gray-value-threshold`,并减小 `--mask-dilate/--mask-blur` -- 边缘不自然:尝试开启 `--feather` 并使用较小的 `--feather-radius` - -## 常见问题解决 - -### 问题1: 前景被过度去除 -**解决方案**: -```python -alpha_matting = True -alpha_matting_foreground_threshold = 270 # 增加此值 -alpha_matting_background_threshold = 10 # 保持较小 +```bash +python remove_background.py input.jpg output.png --artwork-type seal ``` -### 问题2: 背景残留太多 -**解决方案**: -```python -alpha_matting = True -alpha_matting_foreground_threshold = 240 # 保持默认或减小 -alpha_matting_background_threshold = 20 # 增加此值 -post_process_mask = True # 启用后处理 +强制使用 `rembg`: + +```bash +python remove_background.py input.jpg output.png --foreground-mode rembg -m isnet-general-use ``` -### 问题3: 边缘不自然、有锯齿 -**解决方案**: -```python -alpha_matting = True -alpha_matting_erode_size = 15 # 增加平滑程度 +查看完整参数: + +```bash +python remove_background.py -h ``` -### 问题4: 毛发、头发细节丢失 -**解决方案**: -```python -model_name = "birefnet-portrait" # 使用人像专用模型 -alpha_matting = True -alpha_matting_foreground_threshold = 270 # 增加以保留细节 -alpha_matting_erode_size = 5 # 减小以保留细节 +## 输出规则 + +- 单张图片默认输出为 `*_nobg.png` +- 处理目录时,输出文件写入你指定的输出目录 +- 脚本内置的目录批处理只扫描输入目录的第一层文件,不递归子目录 +- 如果启用 `--remove-subject`,输出文件名改为 `*_bgfill.<原扩展>` 或 `*_bgfill.jpg` + +## 前景提取模式 + +### `artwork` + +默认模式。优先适用于书法、国画、篆刻等纸本图像。 + +### `auto` + +先尝试书画专用掩码;如果结果明显不可信,再回退到 `rembg`。 + +### `rembg` + +强制使用通用抠图模型。适合非书画类图片,或书画专用规则不适合的特殊样本。 + +## 书画类型 + +### `auto` + +自动兼容书法与印章。 + +### `calligraphy` + +更偏重墨色笔画、灰黑色文字。 + +### `seal` + +更偏重红章、篆刻印记。 + +## 常用参数 + +### 书画专用参数 + +- `--foreground-mode` +- `--artwork-type` +- `--artwork-max-size` + +建议: +- 大图先尝试 `--artwork-max-size 1600` +- 超大图如果速度较慢,可降低到 `1200` 或 `1000` +- 红章较多的图片优先试 `--artwork-type seal` +- 纯墨迹优先试 `--artwork-type calligraphy` + +### rembg 相关参数 + +这些参数只在 `--foreground-mode rembg` 或 `auto` 回退到 `rembg` 时生效: + +- `-m, --model` +- `-a, --alpha-matting` +- `-ft, --foreground-threshold` +- `-bt, --background-threshold` +- `-es, --erode-size` +- `-p, --post-process` + +当前支持的 `rembg` 模型包括: + +- `u2net` +- `u2netp` +- `u2net_human_seg` +- `silueta` +- `isnet-general-use` +- `isnet-anime` +- `birefnet-general` +- `birefnet-general-lite` +- `birefnet-portrait` +- `birefnet-dis` +- `birefnet-hrsod` +- `birefnet-cod` +- `birefnet-massive` + +### 去主体补背景参数 + +启用: + +```bash +python remove_background.py input.jpg output.jpg --remove-subject --aot-pretrain experiments/your_model.pt ``` -## 推荐配置 +常用参数: -### 配置1: 高质量人像 -```python -model_name = "birefnet-portrait" -alpha_matting = True -alpha_matting_foreground_threshold = 260 -alpha_matting_background_threshold = 15 -alpha_matting_erode_size = 10 -post_process_mask = True +- `--aot-root` +- `--aot-pretrain` +- `--aot-device` +- `--aot-block-num` +- `--aot-rates` +- `--aot-crop` +- `--aot-crop-pad` +- `--aot-max-size` +- `--aot-noise-prefill` +- `--aot-noise-strength` +- `--mask-dilate` +- `--mask-blur` +- `--mask-threshold` +- `--edge-grow` +- `--save-mask` +- `--black-subject` +- `--black-threshold` +- `--gray-subject` +- `--gray-saturation-threshold` +- `--gray-value-threshold` +- `--feather` +- `--feather-radius` + +一个偏保守的示例: + +```bash +python remove_background.py "images/inpaint/IMG_9259 2.JPG" output.jpg \ + --remove-subject \ + --foreground-mode artwork \ + --artwork-type auto \ + --aot-pretrain experiments/G0000000.pt \ + --aot-crop \ + --aot-crop-pad 64 \ + --aot-max-size 900 \ + --aot-noise-prefill \ + --aot-noise-strength 1.0 ``` -### 配置2: 通用高质量 -```python -model_name = "birefnet-general" -alpha_matting = True -alpha_matting_foreground_threshold = 250 -alpha_matting_background_threshold = 12 -alpha_matting_erode_size = 10 -post_process_mask = True +## 典型用法 + +书法图去背景: + +```bash +python remove_background.py input.jpg output.png \ + --foreground-mode artwork \ + --artwork-type calligraphy ``` -### 配置3: 快速处理 -```python -model_name = "u2netp" -alpha_matting = False -post_process_mask = False +印章图去背景: + +```bash +python remove_background.py input.jpg output.png \ + --foreground-mode artwork \ + --artwork-type seal ``` -## 测试不同参数 +通用图片走 `rembg`: -建议按以下顺序调整: +```bash +python remove_background.py input.jpg output.png \ + --foreground-mode rembg \ + --model birefnet-general +``` -1. 先尝试不同的模型 -2. 启用alpha_matting -3. 调整foreground_threshold和background_threshold -4. 最后调整erode_size +## 调参建议 -每次修改后运行脚本,对比结果。 +如果背景没有去掉: + +- 先尝试 `--artwork-type calligraphy` +- 再尝试 `--foreground-mode auto` +- 非书画图直接改用 `--foreground-mode rembg` + +如果字边仍有底色: + +- 先确认原图是否有严重纸纹、阴影或压缩噪声 +- 降低 `--artwork-max-size` 可能更快,但通常不利于细节 +- 对超大图可以保留 `1600`,必要时单独抽样检查结果 + +如果去主体补背景不自然: + +- 开启 `--aot-crop` +- 增加 `--aot-crop-pad` +- 尝试 `--aot-noise-prefill` +- 减小 `--mask-dilate`、`--mask-blur` + +## 校验命令 + +项目当前可用的检查命令: + +```bash +~/venv/bin/python -m pytest -q +~/venv/bin/python -m mypy remove_background.py tests/test_remove_background.py +~/venv/bin/python -m ruff check remove_background.py tests/test_remove_background.py +~/venv/bin/python -m ruff format remove_background.py tests/test_remove_background.py +``` + +说明: +- `mypy` 与 `ruff` 的项目配置见 [pyproject.toml](/Users/gavin/removeback/pyproject.toml) +- `images/` 与 `output/` 已在静态检查配置里排除 + +## 已知限制 + +- CLI 自带的目录批处理不递归子目录 +- 书画专用规则仍然可能受极端纸色、重阴影、扫描边框影响 +- 某些透明 PNG 在图片预览器里会显示白底或棋盘底,这是预览器合成效果,不代表 alpha 一定有问题 + +## 仓库结构 + +```txt +remove_background.py 主脚本 +tests/test_remove_background.py +requirements.txt +pyproject.toml +images/ 输入样例 +output/ 输出目录 +experiments/ AOT-GAN 权重示例 +``` diff --git a/__pycache__/remove_background.cpython-312.pyc b/__pycache__/remove_background.cpython-312.pyc index 1e78c225679d2ba54ede2c003cda0814980fdd69..f3b21166fcc1bb7ec72be10d26c3960cd1e3d092 100644 GIT binary patch literal 61409 zcmeFa34BvWmNzQNvb9ULN0$#v?%^E`hW6ZuZTNXNr$M_1cF*do9!_wE?B1^|k zNRSgch=lHlgicI{4o-Kb;{=j0)AMH1GyTm-$TL|e4tvYpTKXvNVsZ)Puu^3f&p8McWT0Zo6VcB?qm9u=qNG;QiVYWAzyqhY^Edy?>*)TTX@yeC;L!ldlcu`vA}J$|)qhC{|Z z#zUq(Cbi0?Zq1ewiT}1Dm5C3R%%!}g5q;Gv17X&+Sr6Iv*bb%cNmZ*_RD0|$hb!Hc zfq$9!&E)j^G&zW{PfIR*vRqj)ALj4M<_yabf|xmonIVP3-$?$P=}Fa|T+R$PkNZD3 z+mq@&`CKZ#3b-`5GdMfkLe2r#X+Vy|f80NEnSd;kzNKI8GcF7M#VqI9Z6$|F_mrwt z8kMW`Eu=*cYAa{YOfDDrW^s9N%edcg`G`}_72vBPhX0?q8Sty*3gI`KbHbg&ea@A@ zuZktANjZt`e}-+&^=(@m0gk!Pf%rU%0vWS{R4^YH!@X za*Ggu(N~K#8JhbyZZYsIb}iwS94uFYAUJquvk+{ms<*dUg;ygbN5`TeJe?P z-&51EBAgkBwXrpf>?}^E9BhU=$Q{TQ6<9E8cH$+qoUwPHxww-FMCLecb)P z`vCVK@ZPO-d$?}{*2+CJ4cLddM*!Q%{oXWSo4CDPGxEz#Bfl&2>%# z<6{i+$TT32BJE?-Nc*^JIhVnGXBrp6hMfP4{Y zpPojm{{R>laxYE5_?^jATACmCtiT+wk~{O3TB4}z+dtuZe0Z{JMXNUf&KQn8b*{SB zrxJw6yxavWjkAfdyrTM{W?bs^+{^KAVD6b3vroq-1LB#<5UtNm_LJ+N0XcboayVb+ zYT#aZOvO8Z(>FPsJ9iwOt_E`VY-lf>0_umcaN88&KN7={htreO@NSzTZCD4dm|G+-{!^lxuS$Os%gZz@Ee9TzWOmF}9yWOHyx0kaMZc_H1=+y;E=C7a;uA z$!STg!b5pdpBH>tO;1GYB?~umJ&c-0OV`ag8VTcFWe0>ak|_a7#A6=6mJ6Jjk{rCkbQvcn+(ZoxQp#- z?kD5RG6n2PhUqPY{$w(y@faB7B|u)97UWL>`S!FR-v(s=WXQzjV7a)A7>Ov%R3%RU z>(lVX3|BvJ{cLjT3CjKsAm5!9Er7w8x7 zjW5|0uwq{vKuEt=1LU7ihMcl)62Xdf^9R5boQx?!-TWaSuTO@YGR;J= zm8=i^5|{=jV@iDX{Rtq4CqpJqla>4v zX?DiiB_U<>-Z8vPrDB%rZ~a|0m> z=qp1079j~}4?_M9AqnUXLjE2h31|mGMi7#KZXje7AqnUNLT(}?!B|Jg7(x<^bA;SN zNP=;VklU_~7uCN|s*Z)BMJg=-V@g4p0KWW;3(lpiYM$?Fz9n-L}{150~-pT!I z66Z5cGIUIHOjDwArIo69J$8LGdu;eieNg4IoaBzEjwU^*I--t+ryx9i&rB9(mBK2% z>?}i?Bk#X4cKXW5!1H65zdzc2W#q$iqc5Hqeg4X=Cr;ixdwKM|ACEru-rsv4zfIZO zTA_|=zt!Y9P@#z?Z#dMn-xbxfpKXWTQQOwtTUOUK*4NdqZ)jYzVaM^*d7eXu=e2hp z+S}CF+|+!)g;*)P>rlsIuBfTqb)?bla=TkP+UG`Xb=!6|ZrQe`VY3)L>e$h+W%ato zoeev8ZrHX}^la8BYKSD!^!GWj0B2O5m?Tc65>yg*)f$slD`Kf68B3Q+P)E)C9=c7W?S%lcP-hBP$k9UhAo3{f|HvZfP;CNLdHivKhv)>)E%?#OQhHYiu^%0#T zsLP9F<#n4sGpo$$zK4RQqM)|u3xg^<-@iGWRncvZ*z$&KWg%PHpsivg-}&nLv+G~k z*q0y5Uo@1rB9ylxoVTibbHwD3;(m^_RLydeG7Y&6EzS!8%=G5 zr`yFUngR2)nLQJK?t9^QRgvtXS8Zo)eM#Z$Ie%rY@m2p!{mk~DxyIW7Cz4gU#8Hx&o9#sBtCNh-^Ne~X%m-LwyXTDMGlPPmOB{o8Hz$e206DkgN6_vE=d}H6mq9gGY+d( zP*1?B^=h%pnf#i%OY5Psut(5J*t?QFF%An_o@oQfRDq=@htiQi`5bDRwK3n+eZjYQEym5Um*#qTrKDn3B{wp!+)L zSD3o=*u|$ebC*HTw-zM8V~}#$WzwkDsW{`4&|Q%5beT0O!7S0UE=$5XvNYNxynl@$SGO{Lo0Re36G)<7437$Ea%GRp&5)lg3VJdy-VSI0cR0xXsHyz z##yL^x@_$Z&MNy^V}1tC#{7~2XA}MIf_ax`wv-xaa|pK9D(O4^E7jK;dESvq%r8WY zd2vYTEgs`k>Z!Emq!tj9hmj#R%2K=1gw)n*2{!)ArEzx7A??lzX)kI{=7Egax+sRV zbwM0bdP6zWFJ(yeq*eiHkrWpH6%_kYfLIa_k=~^I5(YuDPoo?W%K@bPrh46}Dp7fI zrP$zH(z=WuB5my9^iur@#>X|Brpx$U;}NyQuP_3e)RoqidPG%`*;$Avqt66xefZ(L zv8ykR{?so|mN)y(kN)Uc%#ocmGsULRu~*(6{qP4P1J40+^ju%9b89DMFcy0IocGon zCvKj6di3p=Z@>2Bt+#(VcIAniKYZf$)6a?X?mu4buByVcS0zoM|9G`WiC%fl^$bCH$(P3_IVAwSZL_qiEfXH+f~ zl&VyFG{@8xnlB3)_q#l7HW8yQZ|i7oYICoc+gQi%cW=dXNk8v)eW zM(^gv#z#Aw+Qg8H*cb$AafV7}G5Jz>ti_+3$orG3h~6y1RNxcLqWMtM;YLc!13oWm zbbB!IHF7P@o@g@5NYt?3)$YO!hr&zwM@$t}7F8m8 z#qpx4YE1^&jilYwv2UsV)vLNbYxlM4z@u*p=Y{?aLuD&Mvv&WRn~L&yUV)mY;T~1- z)NgkoBPp2vQ4tmSjE9P*xQ@2CJ??1Q+Lkug)(+2F;<_4mzJre%?`vx7bcyfDp3cK< zVDAu_&jKc%CN_K?b4Rs@!I4E%z}>lcPt?$I=x_(`X=~XVO{UsC5H)RJJ|Z5A7#wTq zs9`S}>0l#x^=LBVH=?N<+7E+M^sJNGC#r4hXyT&A7B^Dxpzpb&TGow2uIt-{XJWWm!M5l!J-kF_+r_|@c7Pd+A* zLy8p^xTAUu9LCA=)Ft?p1eN4E+8j-42Tx1gEvjuoU*_k~Ycfldr!f{)Kjx;{7QEGj zN9@Sf@r}$s0N6_Wx#wV*f}<^%*RLO#6E0X6(&l>CcejBLJ+-oD<)A5l*jgAYt{qr9 zP#JVKgsp3X`n4jrJ8aDk=G-5)J`mJD5HV#9nP!AcGX_o0ScK&RbA!$eVe7`Aeq%&u z_I5>6^ShIW?OFch7w3344V$dpLfDk=-*=(8uj2Lg{)0i|YHxi+XFS;v(iIHbX87G9 zTS-t~a>JJ82cuo+T|cZdb}xOR>s&qY&LLgtu-#_OO=N9;nzH;xNzT&U7 z>7Qwnk{?w6wLbkby~>n6WGo69ivn}Q#%gc9Qa8S{roXQLQD54boZcM&J%PP_Md7qL zeNCaXdBN0;-N~WUjS;P3NShPV=J;p!X@lCFL2VVho?d@qefNEX+H`u~cw*y|o53>& z7W-E|xy@G}(q<2va{UWJroy1M@L#`Jq%vpy6YAwb^{_Q3n7cJ>-4@(UZTo+ImPEe) z^Ycno+9tJo271jei?r)YRe!u}#s+oDC#8n63es@ji~m5yQ*a>s-(? zz{`BRVeJofC-XYV~%;@|t~zO(yYIq*u=B}dcVkTd_&5B8jI;7N;qy=?Z;ROZ6X3I*f@V`A7>#^VI=Y%l-o%G}wGJ9R7}0 z6jjq@jrY!WsJ8Z0YgJ)$dFNJjgsg=E(LX!|Zb zEzpD%Y7f*nkaZaagP>D^sY_l8PQpM zX^(%$_gEn9h3`acS>3i_v(1<3FY3(=s6(kmfwg_x&u{Fn4wWnnn-^g+#}uyhHwPNd z9_(AxKPzmyC#bzg?DcYwKoMvJu=(ttS+8e@HB?EKl1PcD@83zd zL*kY@3187O(f9a!g``$lbcAGL4;}P?NQ$V)gy)h@S}1qvri`nSUc^&gfrI+# zgeNW(BbXS~F}9K@a>4^8{3LmV8wDd;6*17lpjQ~rHa4CyI%%>M2khACS8sOvM$i6a z^!0u$tw+zlHTu+37}_m|_K%)=Y1Di5*2yPEUwIdCzFk#4$65U_#xonqTgSw-KU*1l zQLTc^o0doHikZ`bw7#)R`Nb9Zah&V zk146Wv90BxE1KNg)^zA_g$Z)rL;IsCjN$NL<>KL8?fX3kM4ZV8n_nH(ceYcw3lkyd zYVSY-hg?lyWZWJun)Fx`A5CuWXu$>vwYY++h!iEgac^f!8`sD{NUFcD1(~BA$`kl= ztI-v_s=wE!-T+Nlfa$CxWG(=?u-Se0^#~v>=JZpWdp1A26@EJN)5lL7e?sszU)SYC zZ0Vx!PF8>tz7 zeJIr_%KQ5&26hEW?7#Zj?vQOGq9It@YYwD^Y{h{GLbj?QYfZ>n(|_!f(xA15*ytVi zhphK^CrS7ne$Uz5u(cFEX5{=_1{N-bAxC-NqM_N#LbI0*G=yib4mnnb%&Wzv$EKc5 z{v`j-u(=4|CMxVQzawPI4{Gxnfm+Mcft_Y0>#KtYBU6u~m;97wUlNH?x@;)1B1}AK*Okg8amj54NHfzC$-o7hRIjZh6PfUE zDTu9>h<+<2GvSeIUB}3@U9fX{M%D~O*1GIdq>5b8lI$+M0PQ&Ok#Z~jCX~m^dk z!G0GAyWqGBghSABM%HhOfsLrg~SYJ!;%0Ru9 zh(1bJs_jK8NR4%TzGtQsDj;P+FSS%xCfY$QIH(P%HH1vO`xNOy zmf(;lsca!zj5i*hLFZ84mIqVKQ3|%>Q8JjtZu{k%6TY8hrl`EJ9gT(v` zM)sx=a%pEP(awXEx2`-PSIAS2TxnF3kX^_lxqme#rX$!SeGu&q0qrkD4TTCz=SF~x zeE9L$={{M6f9vB{Zk<0l^6}fZ&i|NX;vb(LedR~a)$4XRM_>79^odgt$3tV`)<-Xn zKK0{K-^tPb4@NH!KqODR1LF|>9XQ3wpL*(6HqVRG`;CrO{80*}nSsxTQ;{5(MY0(_ zE{NQZU_76FT#r%i{FFqXmTe+qf4VHO>X8#>s-hj4>TR7o%hFTqWb2?JoO!I z5Z+qqw{70GqY+uJ*|Bc5$O0Jm@g0Xq*osYc9Z(*^jy+E^cT~riiUl=MLub4D(N34^ zxQpL{049};>e@RR_w!90o9X!{D1vtHF~p@(fMH+=V+kU$8YCLkHg_C8R*@e~+Pk0U z8wr&xcb!nysAU{G5w(h+6892SABd`tM78_*7A~qeP!rW0sfi}>`}aom;Ak3~`HsU; z17msUl@*kKlG1yM943`jH}cO>PR%W(I>H1p;D5B>U*I-(96Ahc%H{5C^E4u}QBy~! z=WwT|kr*#Gl~BP3O?nmiqRhq?QEJ^t@_n}7(cHni_@5&->Akr>#=P!TeU`1#Gq&gS zjx!JRK5$)EfMwRH@AQ1)5cYyqTi{#mTLA9H?oWDoyKfnj^F~tBeLH=7eLMXn{u+OY z?}1*ccLOncM%$1vH)PE9FAW<@hm3PV#yNf4`_=u`VdJ797uSHJ_TYhttdZ9DV!x z+r#M%-3^hv8G)j}zVnr#ysG}>fs}WwP!8$Yd_(D-63ScLz4=oc6iot)!?sGa85(b2 zPnX{vXzH7FzBN=>9kNw-YyZWDBtA=3fzed=EID;=&%OS1zdLLyK?@S|*|QOBv#B7E z95T&>7n*r_&vL)oU+r%So18#i$bM~!fA`W8*jwgzQ&*S!jgzBC#cVf*fY+Q_m;m{>D?45tnhB~CHd_5V=er0 zklsLm zyUhPDWpOn!i<`tIPg3@{6HBa(O-6TSaV=d|PJ2{Mo8MqFnOqZWBT1-4R!+z7j8Bzu zwGS{^4liS$J@WdPm=SDigB_L_o33;rU4#tO zJ_ynShB{Mx<5M|E`|g-im5>USg-O<)(g-#oO?rP(!gnn(dGY6p+J)6|5gtAJsmCF5$3y!dFP&7uhp*jD6epGGLS zWRRG}uB^Mn$rj@%^5C3+7M}`y*&dlCM|#b#D{ijB*{1^%(x4o}|YrO3Q4pF4!qc`euTh}tSkui!F)lmyrJ{Nzuqm3yu zROqH;r1;kf!v#1M4*mstC!UdiksRVeM6vV_=xr}KBnTFFNf-|ZVKcV=dD=1IUxE`g zZrHl3VMk*f6hfkA@qP1#tqpZMNSl@<;8Ba{OMVSIcQJN{KaZ#tPX0AQb7#I!%Xl|E zxllXgAQf*q$|URYf=H5b&c*+j5WYc90!g_jW$&QS!{m^BJE{XWhpqb-LX0gqdP{ag z{lpd3QA#9_OD1(-2-xX|=`c`DM~x)bf~Xxa_@4oj+X-h9>o>te5II6v zeu>5f6F5VaY9}$fi3DKZI45Yec{gDn${sNoySMjbfDx2lKGm6qZ8`qZu&ofxiOJTz zJmSdg-Z%^~S9;&kfwY0Tfumu^`ffcm4T7dT78uDX>amKOTE5khRJ(6SuMYBhQ+M+C z4~W2)UN`0WI|E0r7tXuk$nIVf$;|5BgxxTEB+c&I-kZ_AT7J1B+9XCbo@1Td2eK44=!tJ8UoM)Rl=b1-dc;sr*(9-pxrR%%bLdfY)dPW$|EqJx^Y-Qm7zRrGEU;8I( z2XlA&bi;WwUadV_i>#pzUHc?J3@kw7>w5jbdkHpV`y9Ct}V7pY3bt-ilO*aw|i*m0#$znZ|D8Z=e&MI++=q zvlz^v#;sb{r;+ zon7Np4TvFROkpctplSeDe4uwr{_KgXM?>#c<f>#2@y#g=Tu+!Otx?HG)M9hzT zo}{rEKT~N8#?Mkzdi!9`oRDtLaOI-@)^KIrpgzyPF{CdWHqG!K51A^1+DdWvj3ycB zvEYR@(P^Z>G}xsO#V_rt#W*(&cGlG3+T`Yo^RnRDl1N|-@g(>aw@tA}Onc9Ycff!qwg*h!9ju6yw&MPrCsZ%gcPa9X@LIq&F&H*bCP{@B%vH&1^AiPTun z)3TH*=IMJLXEwwjI$OgaZ(cq<@)0Sfit;E?KE=dPg}}_y`BUsXF~)${RFO}hd7KG{ z7&E|Mq0a$w-iHIF80;rOn8JTdUL>v(x3_;dN{g#>Y$l;k8L)DHAdq~d;{+&UaGpKiBx9N1}xt)Q`zKn3eyl~e1ka<2fl_4a+mQqA-VOkiclTJ7I zX9i}TtqP}?hf^!UhRRFz{pxoYyxTc&-!<3!kA#Me#ZRyUB_eG2fxsL)v?2$%=)Z1g*Gzmu`woBx3 z)k{<_gc75aO z4O{DWJQy{_KDX`Kxtk4v3T;%^e4wel-POjnV>@{MFM*l=DIBJEOMPDK@wUbe~J{7$!dB+2fGd3_CIz z)05R#)VK59lA)UVP)+?GHUCM+A9RFjc84AJfe+fy{V3GMPP$Jo^5=vdB@ib6qOgU@2zS16)=i{jkf1xF8}p(VL+-wbPNt#rX)eiA0TX4Q z^N<2soX{ugknz)XT`6%f3a5qgVQkQ?Q9X?P1*H-jDLuq_gG4)rqCi9Hl2BmKV{&>3 zZKFO}hCPK8>@ewQfD)H+a=sRdYUr9NOn-mu%`3OhUby|k*T&wweCy+<(FN(K+{Zt< z?R^=_Ld0F1eQxB#=aqd?R7H^cVgZn1b!TqwKtF2sv^>`0ITl|CR_G`8&-@smi>rsk zx})9I?s4A_Y4c8KIlIN(0V8^KhdZVd0Q~?QlQh@YH}2e3w~MXfK;&sfCN8R)cvn-@ z2-$sOThm@wn>(5k`(--JZq}L6&D)?IK*|CvkW`&rO>SDNi)3By=h--;M+Yn$-G`dm z+8RlyE@qLx4d5o~@HD?rhqw7e+Py|RZ}*C=uljoS95DUers0xVLnZa0l6vU#`>VnG zmZpTXPVYuvia*)^=ocv}TiU5ZJ%{{j!qy`1y5Y1;-=qG@ze<}4EoR1%XIw}ca?TGq z=Ld7yr?St2F0$F--71a@EwU#)R6sT| zveCPgDzYHYHuf27={?Nkf5b~_js)M**iuH)hA!ZUa%3WO(vNVybelW1SLrBydOChDZ#CG-zCNj ztk;D%aR_Ma;s>K2KLMYc=f6LWDzG(&NEJpeymafsw@0tMc6?$&Dya5z)IzN-NWj_=`AO7D`k^jM%u3r=vj%V)6xIA4X4c&w%4qePLDUP2CGm zE$vzQZ0!@@^A}ymkzXPtH`24uZ0y}A@{Tf9xwa;F{{w?14=QL%#A-j)($n(n!SUoI zGPAO8(fNl$`r>YNcXjtZ-#$MV$;l;=#Pytq0@Z!$^NaD)*VI4jV(Y-}P}ORDU3>JC z#eZ--c;7>zwGVY$KgE`lIXz+`-N@t%$$^frb8#qpaX5X+b<+|#B;`Uzuxv@VXlW>W zX*m6!>!y1UG@Oy^U-;s;FD!zR_xApjfy{So;fht^lDbevUAHk}%7B8>cf&bz!=|~B zvT`L=%Oa|lt)ObCCi#)6nvYR6P$oGj{vwY0--#t99zcCe6Q^m@oztb!7>}7aZxS3E zwciZ)D6 z)W0M+)`n2E%dsPB!@QKH_@eg&eeQu1wKPK-?t!LbJ26$_;+RC^Q4{zuoGWy-?S@SMh!($nAaGSABpEjU}J$e zua5Sny=^Z3e*yX=hPHb)9O9`=PaivR%vbmLch5QeyIy_Z>;tdtd8M$g?s`rYmc=8s z^wT?rGG>M{W(K%$#_X_d&ZW-&UGF{k&V%83D?_%GqO^1tmLUH10nf|Z5NA9Ajj4F2 z1d_w)v-*ld>2t!CDrkA9XP(*GyY&XPt+RZyd|B8!&JCIKhs>oRb7^2l*j!FK+6d^) ze!1G02W8M9^URQWW?*mFT!Ek$(tPWD_u+4HECeB-cm2dzpBa)3#?O;<87bZ^U!iMlx1p@)mAudOSbU8^*Y( zd@gFNK|x&>a_JQLi{)90MZe?fGE*5MzRNX(l^cW_YS2{CHHliCU!zPo3^NX+`DE^&=q8jH>tjIjw_~k__=1T` zr84~P`Ij-Jif@V>C8|xGkS5h;mt6sysN{Ce&T{XVSaQto>Eiqzc>m^eZ;!s?z5PVr z&6m%Ne((mQ)FT5wz8SbW_Q7joZw1DB-XHzwgm`#k?8^6VJ@dG;sqOHACRut-6DuC) zs@Th}RTnj)bdTunMVNYWx9mUE^g!(Q!5CPL1iyY2F91+f!=xx|X%&Mim!MqI%feM- zfWfmy^4YK|#z4HYa{n)XTmI0gKm3Q46&8_0VC%6nfR0*4{Yz4ZT(_g{K_*j=n%6aT zLgL-jzPhcG#{rGby*R+t>=Ae8iS6LYHa2t;+gc7w8k~e-{m%Q?o^{Ki&O;Rr*zwuF zw^0oKE<?M2 z4{47k(%VKLfla=Yk87?dsDp?dN zSrj%ch5{U^FUGe8c7?J@283hk*0dq(jF5GP-xIj6fBm%w2CZ9Z!6&NA?GJcxdV45s zK`3p(yQ}H=NjPn#cjIuH<4jg>mcKIK3FXf2^MrC1^zR%f38$^_Zj`hqk6kDkDqavO zUJy1eWD1VYG=X!e>)zipXUJF(G8UZM`)cdi*08Yxp=s$aY=0Swe7^g8^M}$(LusWK zcD%Xg{GM>yd@mhve?k31wXeyi>)AYHE)1CqFKFI0o;QZgvvJ16=y;*-g-88GzI7pE z{*bXGWGuO``pu2!H-?R*Oli(|q3Pu`f1PiC$UH-2vM%g>v-Nyy*j!B~ROFPQSvX`W z4Vk2*P4l5+n4Wd6nC(gL?pqVitq!GEd$&Yz1b)k9>PgP%OD0gmuX((s$K+?d@a9SM-qqUwcK2z3P=CAG3hVth0YeTsU`hv7NBa8~h8*+y9sSK=Q*BUN%a}VzD6ulQy$YOHv&{QojOg)4-=F`l z)bSF%#E0G!ZNkL&sb$V5NwL~V5;T`&&P|f<3ED(kFgO>YjcupWv1`l{;*)6Q3Tc?k z>m`z5%d}bl74V=ACO$GA!&ktAIcee}<1xk+?6B6(kl^UZwge;EtdhREOp25faC_jN zAccdnm|o^@&59U_<}Nc%b;P*wE(>P=BrXX&qUai8Cx2iAYMj4TIuWG6ohUcfNx4bA zOAOnj7-?~-XCf6p*gm3Nf(egD{!XOKC-qHOZ2Fz!#yAj+8CZ+@ow48)&hH#Io`d5k ztyCuPAM)C*)hSnl`~tOABT0EV6K9rquDj%+SdJ{e#8boBC1sD1Ruy+HRjG_fevvWK zX(%PeSRcGg9`A%PeFDZ<@BJDWGjO<8na5bEaVl!OzchZbCiqL^C;KjKCLi6sbDp#N-#8qxzj&S3Ht@7yMaY1HWZrJ#;}F@Ji`;q3BzE^PV5=Ha`P{7xfLnY%=N#SWEkiO%Oi z8tkCG8vYLzWl9iB5$c>+UX;Rem;BC{97h`GT>3Xhh3=BeysweVjBhTNa_#4H)~*6B zvkUi2b`|>6?cYahWUuf+H@v zON_ipF^caJBY#qi5}_z=g;XfQv5`V4$4oMH=Na@4be=O)Kj|tJiWT^ag&E&4{+U9F z0)L56_zmNqCC@-rin^UMdA<;42{Wad>nghoY?)9RhizXg)77lI#G5rPUgh1O?-E`b z&v86AiZPksj5p_0tXLAX{-q)rQ@JAb`3dT)OtBWONf2Jn87>vetHZMR7=!IytUDPM zDjB!GTj5zG;StJ&ic5+*_AH5qNHhDmR$3~B$A5uC%tMUl=_(VFx%7kJO>hV+zRs_W z$B^C-Gby30z(AThQ-m=so^eXW%O!mA-%B$k4h*>|gCurV=MIEI*+krCou@RwfAf@& ztm7~(6mkERblI5Jim34p1vj<@`vFeFk9L25wC88C+1qFmWRaaD8?OzYmXPzJs=5e1m zc9r8&;mvicc>~o2Q$fW|2`XMkj+c<1CWmCfbj5;rgbNDS?VjqzypcSHR0kWI#GkzoChx}wxqG4ecmM_onnc+&pFuG)$;l!IT4%8POHMjD*>GZFW>J)l zeR$Y8h{?6B&=K0(0!77fa&8zkK&`|8#g90k_-%F{!Yu^{aO@0X^QcJ_l`}8W%w!3O zU$%G(qKARO(#f)@QG{gg&>M*5gg!;B0CRI&2U_T$i-*QA>pkvD)L?ACe;PlcN&wk& zWBUc;R0H_!sF{!O=l(?sNy|r!=2PW8<-TP@j#(kctUzbjA)f6Y&Y0a-KTtlHQ4h0j z>$-PG((}%2>D_`OKGnhWnW9*IBqmnZU2qH)EeI9iT=_zX)nm2-j*xm~b;yas{WY*^ znAmp;5&XzBMDRGPu9Sk4sU?L_oD9NQhfH}PoHJ_(EbcGAwsg?68M#aB3(@hC-X#%( z^;CXO{^`AET6M7Lx-4W^c1<19*TIGpZ6}=5ylOma#990R z_vXR#2j4AzukxMB@T_}7rhBi!Vr~6zlGKJZ>d%vs(o?*faV87;4@kqmysre70s0>8 zF9|yq4>|4)Iqto>_u~Wa9|$|vc{krMroB+~!gjyfR|Z1~qUqZU#c!6MFAp1Mlk7e& z+9%x!*vul){`&Ll!{#cI-P>QNexb>q=39b-51EQXrs4}VZ!SF#oz^*I^uZF}a+t8k zFkw>x^@sef>s{xsNAi{DEBki$uL&356Ux75pn9M=oW6P}eM2aH!>^0~qWo9o;q;x} zEioB?M4vIF&kyPI&+Q=HlCXYOUs_0CIgFlG)>G!I9I}^$>{6!fRsEWfy$1Gi-C^T0 zs4+{ufY+~8$P$_kc4$Y z5;jUARfP^w*<0y9kDostPOs_T6-pPY z)VzAgydh+ks?@ym^W>ylW49Ig&CG@-9~@I8C+`_+#E{`HxNewrx6G^wr7s;Q!Y+%s zZpgeoWM2Pk`(I@LDm!f6F=b|UP8lU}W_qk6-+YaH)l76-M;w`Cnnt`0fjA9>_-!8>uO( ztlXi@S)t5X5q)MvZ^gwg`2U3=IV;7x4dy@W1s81nrfao>&aI)etwX8zg;MVu)*H!` zHBQ*2rQFb)(CyO9-VM~#GS1oh%mbSSv)708>;ERjtmw{u2jJ`lIEe|k>~kyoR$iMs zn72Ko-<|+2qrkfbaQbwyAKL7vzT5L%|DnD;VcXJfE%q|mHy79tw$1I)%3VZ;doXNU z(xV-bzAx>({K&;e1~yzf9oVE5X3T4d?+p4j13R<|PAB1)}5!%YWg<;w;1QIen1Ixvm`?n98 zV6pp#&fM*O{5zCs81iF=OwQcXdk-udFAq50%sHQfGrajh``oL|Lo2q0R%}Ddq#C=m zG>%ifn?$`VaLMw6sZ89j`#a2RN)7z4wO^;uO^utarUQ+M41g$BE%FYWoIiO_0>&}G z*9**)2;Q-Ny$h}cdRX%6LZSJdP}`Jt544>2QnI|pQ5aiJbhisui7BKRi(LZwXyMn& zA6i3v5^8$k_~SlVV;|aQq*%$G)B)ZPU037ZA{>TR>an4IzdffW(5~;)w zym8xm>ZbpB@n$gS*gLV#s3VJ9IR8kh@=oN6??rrZg;7bLY?(nl3aS}f7m!2h z1qu$E*++PZKo}EFtTotU81ipE$ZP4tKxVTThhLGxFQdq$>nXCsdH9Sbi;53gsAV)w zI`fz-DsXx_8o|4@yK%n~PVU6CH||I51<0giig!xZ4QKQn;Nzgo2z~W~JqN}8`ml9o zUvkJ=)t?`-kdY-z!;pDP$h_s(d;j9VuMULG_hZT@yZQN0%JlCJtnFVg;2t#BldtvE z`kwW^T{wdO01litK~;0eJTGLP2hK?U4kovIh}*K9x~JzJU$cLa@1d}%n1+2ST|wXn z5pV{zPBv=4OlxuDCWI>SK8?1jPB*k-V{3=oG;W%8aHI#rgINmt{2`Pa>XtB&h--3+ z;{pU&Y=(7PY@|yIXgy$m;;oNT-I`S9;z zMb&j!H~%Fi^e|9XnAxg@=`r&pM&YTG@TZz3{s#xlA}gk zEF+!4w2GQXjTmFJ=&CS@qx2Cf#(yDnw)ok6x_QLKN*GO*mbjALktoi>z)*;MSTmY( z@Ce=Q5nl|mC7wBEAWqc4+Kp5aTg%Os*K5k;X;s(~t5!FyreP8!Q>D)e=2nGtRl`|x zBIc|}&df;0Oq?^$Hsbm^oHNezw_G=s4bEPBO&FZLE8-{&dCoH*zz&b8tM z43c4FL3D*vEqfzCQFugm0o6KV1~SL*7#1~01Ct;Cjkc3I(fJUzN zkG*~7=9yoB%F5;+Zx6Uk;csi&~S7%6Ah zb%_%cM;Y8lPMjw@fX~FlI4yQQIexV$&jYxLxUauK=5d&Ct}5Bsz13gqpA$5e4Qk7= zqIBd9*(*Z!ic2#uS6-~_-xID}6}H!r@E|iruWhADdd=1}4-T3uhs|j)ab)s$1?^RX=D88GeR?!|BmvEiXj6x@86j|&O}DAD)`oh%`qzR;ia3;y@pl6HuS3c`^YpeRL5}&j4cAdpEC|$ zW)no)C1-$y55R#To*=!GBs-b}mVW&0r>4fk&|3x{Ar7E_k*IA2Fe6(z%^O1#?3MIF~susGT=?QA^d$s{C7cp(=;Dfi+k#Ca8GD zAtGw13C~-x7@V4Im}&&#_%xlg`G(43Lm&Gzw@W)ibWb_`S#nbACt_MQw$Of?y6ZubD%ps*tJb z(%#DlE*=P*mJGnC>AlRJX`1+`>A+&r)~J>iqA=#9hvg95z|iJ`T^ucAFj+TbEbA2@ z6)B-EW+$&g!+K%Vgc||D#WMC*c@-f4dsMCDyiV0x4+M145VIWYyN`}Ir)He7^jiG; z!l^KQAccJUy3R=<=3cY^+wU%V@7{Or^_jz|b$~FXdN;G~$o96z-3vuE!D{|rksfMZ z&DTR{!1^V>BF?@f6uV%xgw02((h}CWG?;OCO|J8VM1e#UWb7c;vZ&)Eej}|9*}>k1 zJLiPcesj4{Fp4s}H7;MOR+&kZi!NWZO1I141{n>%L%?A)miH}qOFj;P7Nc58~Fqy%vOuK z&U@e;J9TsZ5)2m*sld@3kvkamJs%?-;_+KtrUC=$^I-Vo<^cGy9&rb^5GZ&ucf`*n z$4Q@zz8FN(vV?r&(w18KB;J-kPYx5loW>ih91x|6nkY$FX{3G1n1yc~MB$?b7Y8HW zu(SrN1LBriEc8GV&&6ILh2YpNj0x`ZvcU3`i^MQ&vB*D#Y)~(CN06vj^|v~^?>m9* zFMLlr-!MDyonX!aSf+m9od`S8@sEtHmC92qu!I%EYWhC=oJAA2yM&#*>K}VoxHer<5ODT|`5$RFNa#oA^Mx zE3T2^6teg(1$^p6Xvle$B_P#@36ETYnZitxB;z!M=qATbJwPLAoPSEpzYMxc*-Qht zOvs^M()#_{r7u^MCUNP@;ga4Y6V#EEB8&UHW&SLVWw$rL9twwp7Yz z{1+FLRgs3bq7TlBhe~gX)1-6bl7UuIl9})ba}_o=k z>FYRR{ORe-K3CJO9G{6W&6zGH#WYV;cETgnNOhR-K6Kf3 z609!e^LvB>@2t3Xnun%t_Qcmn1$W6?r39ZaZv|55IB8d^ zP@ojOj|)o^ilz*+Pm+>pD;%8%G0crIY1GZ$a6vq~F1uE`Q)ldzw?{wx0c$;KK?(=|g}tzrb{R&z#s20?Pmf&r#RPdF!Hd^) z%g`|YIC|mfaTw)m#AP5#el=Y#?u1C}_S2`vfkU6q-*?T-m&M$BPRpp40zFE{;?mN9XtCWv?s^j4UCHsmjEK`q6AeI`H|uH z?qAT-ozyl5o|C@g0#K@_arfwmIcC=z-G2Gh&2t}2xC(Jx+T*X>fjp62n>c#)102vF z`}q}Ap$x9z%4?nUAyyjMJ?oFxGJ_&^U4+4|(AdwP5n)Jv`{SpXpfa9)1}|^C)QV^R zMVp^;$|N&nJbSHEGzuh2Bq^#4!|;rgF_Vu7IH707rKe29tdx!xgDfhgqveo-i$n($ zMh5;s%nW%w285xP{>6Kk& zV3yw4bq4H98r0D=3L7^-qxQI$eA$&{ZyWRu8etit6-Gg$Ht|y)7*R8sbdZB{#4p); zgJSD4odq#t4hmo0J{QF4Fv(|)y`T(SOQ$<(kOvn`1fs7rD#p+RA$x3L?(XR1n_c2P z4@?2rDCU)7GdF0nA>tbFfK4S5M%kF82b&$8_>lA5oJwH?|oNtL8?!-5XmEn36nvKhph>Cm4q@Hx{Yx~v5jHdJd#E@$jEhDPn&;%tgvgXp}S2L z+O*y337c+0;N%551%-3Dxoq&gJJ zY2Dh9JlsxTj-(gT*cVn6F8O*eY%arIv1JAnK_mJ!=@J9nL{*5( zxFVK}NE&YFDu`s3MY1Zs$gx;cylekyhDvYqtq9azcg%zhkbt^(%aEfY$U}~Dy06vIcl6?ntB#NJ-p{+%#4fWKPPH?M=mVE_T;6kW&tU3O z$V2V0LW}VGKP574j*M%IV7drbM370JoIhKda_YSzP)kFOc3Y*C==wXGf zT3RMEv$4k48cHn!9!Iu+aqkM)$XIm#+fWoOT@rFE8G?xm`^rDk{E7Y#^kMr3BqEs^ zx!@iuTN)}`8cw^XCuJn&yYE8t(5xk)SxdrcOCc=M8lK*EVw-PmSerMbb;3|(Aa77x z^|!jzh$$~WT(Txy*zk!dRJb!}&L7rWPT6{FenVJa zJfts!@&91?(n0+_@b}q*u!~z*jT>+W^$V~)6f^^Qz!Egh9n{W4!jrd)Xd09r5{wX$ z{d`zIUi70_bWr$&hm2^&CFfWM(B#4%EO*Cg=Zp)38MEvN5;Z}w6z)Y#Mh+$L4P{5}ODZGYD!~G8S@ih+rN(G!l(2WmQgry7m zDZ?@Z!<1n#JCHbS1@0`tlo&=ZL6mc!0y0}joie2yA#KXATwy}V6*%(*$CUB%h4d-I z3Lucwa5GS=h2vr1uk7beA#;4VBJCm}Ys#=6|8hRqZ#Q-;kF ztW$=S3AQQ2%7xr1@|-&^&lN)6lsGCOB1v4Y`MC5qPJd=k5>dp%<_I&U#9t*8PJzF0 zTps5N&M9%s6N(b!P~>mEG%`VlWaS`;XR9Ug)Ebpf{qU~Xwa2r%$_y%ugF2z?tJ0IM zavWOJfu=YRR~BB!X%SaLVj`iO35zP)3#Wiu5DT|W5x!6ikMHEgXAYr)M28UH##>;K zFbfoE@f4J5$q$pcrI+rJY64<06aNNh>6(ptEsftBmoTlwWvr##Q7)ufhuD}FD(s14 z6w0Jbz%6Z4K zE4E}vIB$S>{@ZV%KIu`Tr=GlDtDOCny*u9OkC6C}YgEod$1Kgbyt!G^ReA)MIeX;o zs8SU_t80#;$2^37=VZx^bAaz*VGc&3Y~Glzf<|AN^W8bf)obG05%m#O>|*HO>pUfi z8*lc!57Cjh2P#UZMlZZSGVoK<#_I9jJl7*CrA#e)+=uH#T#$jp#5qjB1r!q`5wW$F z68Py4R!LRP&Kf9cJIilh@!xv;0^}FsQC{)*u2H#=ih)At zb?d_`sDQDaZWcEY5NrS*YHG(_Rr{g2_Q3~ZJr~7O!4qKJEwCZzX{vRO{p_Q$x4jZZ zXZgYfTUJ+00G^~#4uQ{0{iyUi<5#mB`h}BJF}6#Vn?lYenceqOYPjfhYrJSJ-i<-WQJcR z{&K|SQEcl6C?#hy5K?%XeJb!0x)xkc=xkby#V zK_yl*dmVcN)@J*DORCsJB*nfYb_@VZ-pc->@K&}syh-G`McH=nV!~+s?5#;ud z-x+=3BqkFXg;Wz__{axuD+8ruV}y{YODS?A>Q9Y6_sr-=Pb$hB-`1#gepyrXl^~ww>*{ZRsh)RS^RScc96*aP2@+QRInawln%0s=XpGoL zi^r3}L_~ZdOd?7g%^r^@PEMm~lZd2Z`y_$5p8o-5{UvhFkmD!kEIBWe^9niV$oU~T zKO*NCx=d_@ML(Hzd+89;aoH%stkUF!bi!uNzNEKx8N|+B+?#dCux*8p}Rl& z^ar<3pRILvk^+kuIQsUM*WCXgWI8(YY*~FGB&{Y?COe-Us6+be$!uGC+*Q+N>dM zK}cH=;QDt3wFQIP+L#=-FzBof8fyl%3rLW=)xX?7H)yOF)K*65pu+5sZZ=7PE&f*j zLqTihpnmp0rKqx3tApC~|EkM~=(C6Pg&}=mAgiw|=&bomh==u-i1`2xX&a1R7*+O6 zUt2h>1gF|lRCbuIE(xW<4ED@_PS!e7ZkR0*lbH^Ro?Payes$^DrLV3yyCR%7JBX`A z@>c#+vI=+8CIQg9=`$66=;Gll%8b_ckZ)_Scty~zmIm|I1dR=Y+O@P*qPGN7i-RQ%!Q!<+>$*Yx`l)@E1&fyl zt@jSTB3{IqrJAnDI$QuzXw4`mI6z z_D@auV#HwO@?gciLHmk9^GXIPiu9%Y1yh}YoPbHrAbroEZGbrL@mUO}&VAopnHvV~Kn>IBRjxzGTq6 zR4mTLit`x&bjARw`O2T%h7G9PlEAWl^-xYtj|EqB)qbW~r#7shJGXL5hH@%HIh8}W z0x)NOIH$&!5^>}N3$VSjE;4KWn;qvn1`MHDb=TI0W^L@vit6C?!lSW4ANyB0Dr8Go60_xx0I>Wi9S&s@eH<{&Uay&wtMO@8kUE|Nm3h zFXj(mN2wxLaQM&%TQwa%Zg_D6{UfoT&+yvppDp3sRk5X%(sN}^u}n%CI8SXXi&93; z-x4!X%FKC#F$<-vT-y#lW}~E?C9^5%;A(sMSPms~x$?GH9;NcR(wbNSr3yJ$MXZQY z#hkApRzfK!<+&*3=A8amDWyErw3kvoVyui(ermd$QWaFAl2TPvqnc7RR3kvCT1wSX zDo7mEQ>uZhXpc2gs)?vHQ>ukJ+DfT5s+ z#@9}4czJ_RR5R6&h%xDcy+bl~MvR@miy?kZ*B2x7{(s-re0!_z=f*Xr+dI;Pj?9hK z8Dd7!Mt_Fr&p>)sKZx}p&gdItC*Uf}Iy%Vt%#(9R@G<+Xxfc&(ZK^Cs=H7z&+gt3* zmsC2Jn6ZzdvQa9=PtanH+2PID&o*H7hNGad;Gwk`R+e73QcF<^DK;?5CyMe$N`8r; z?16_zAKbr>yddeIPxWiA_Q1oq%813kZjSvSoy^NVV!phd;x$TNrf%i+N?)co5{vt< zC%%YoLGPi5mCyD)HAoB%f5dW@VUsD z?+VetL++bYSP34K?qF@A%qT6$-zBrDFB#lL2MLYGjr{`TfSPbB1tqWh%2QaKpS<+; zQTA<})Xy;Q(Em|WyGPv(EkSqS{!w>*gF7%h;%){VGaCq;5*(J(!hdbkB6FSsI$tGNgpH=8%LgK=L7RJ&74~;Di z1emI|(lndFw4eCOCI#w&SquBGDn1eAK+$f7w&A;NEK8$`9J3~MhVcLyV>t_R0(ONcr|jeL`j zz>JZW8UbzVqglAG}J-D0a@D z!Xz##w;Bi}Z{dV$8`gtL?83@1^Nn{2I4mI!D+!bnhmV0nt?rC?BcE-$g`S?8d*WTv zCA|G7cv>bO!f2a3xZRh(`GdK`&tHA&7!EEXy~D9-9AAWsT4(QMnYmY{Q0CJ=ed*Je z-qG-hrYgtmDw8l53y>}`yfMkwmpFo>G15>@KTq)NcjM@HP6H)1`#VpnE!DRKgO#{* zbS#`BO{X%8X@Q+%7PL<~vOs)opUl9Uh-r2acX1S=0?+R7aG31=Z=wB7+_JlO?j0E> z0m7k7IOYiJEv3=)!0yqp+#BR4Y2kMD*PtI>L5(mwMMcqaD-Ce`q^S7bM!UPAskw42 z9nULjMtxHSlFfv>(1KQ}w^$1|D}mJkZ6o@_BV)!ufaxsQv@xGIb}Fz|F^GAAY~$3* zIgKh9nMqwXO&_~->AffMT)Oi3dzWTjf!yEJt4chX4vQYBa7&f zHP6z-#Ee-30p)cbh?~NxISC`fa4nI0?wwS2_luD=K%(}K20{p}8Lmfls3Zfotz56D zIW-mH`3wBf=m|_WE+`}{#lss^Dpz0mgDY=*@6&f4zu9UWr032)0q;Z_@^8*iya*Om zVs7HcH2m-xIC?C>#8H|$YD2Lu50fr1&X1yI)$qCNK$w`K8wC}Z6=(oDlrU|Qq7Jh& zSIo>cmD;FER(vGL;G9iNqIgn8tEmxp!=rlf5hSxx_L@XBdTgIzewlI8^FKa3cl7m3 zXWxKLvW7@f)W4zT+BpW!&0(W4lSvK*=%BDwd!WYY+Xa5zLxP~ z!h0R+=YZl^%5-w7Iy$j;lVra!K8dUf*|W3TfiOi?y;y~3=id48l{em)OBlJ+J;my) zPao1MvuCdIc++ypn1tAnmurB$T*Kuk9JfB#D83aB0!__CMY7~&?UC>$&3eP+&82FV zv8SSZ?<8)AWRH_04QXc!_&~GCtq?XgUrP1r8&r6@MK+_e{`8MM$4K zp`qA-xBFNo-l&Rscet8nhHcSmw;ry1``Fy^Ba6)mBs5+%pcsUQOF=4=KBK3l_5MUWBIn%9%3>iwWiNO`D0|8^<>*V*Ah;VO%5qg_@&^G;i&l$BT#fDmn+hJFYTCO7){dih(ldy8)0w1pb_5fy)rz)uLA zC2)?wPYJwF;Li#C1%baL@K*%>n!pDH{)WKM2>hJD-xByc0>2>e_XGq2B7q2j^8}&< zE)e)70f~T2;39!p0u-w$^bZ6+B9LkaC*`Ka|E~_7ZL5T?g43`Y1OMgwA(*Fsr;;9& zOh|?{@|-fwO4KDdr8l;!`$sn?Tdc9Ziq`{Xk7r)B0oXYf1ceB6C z-603AKA>Q~>_*rQws>%Yp~@xe*O6*7t;_%R2 zgM+ha`$s~{(IWh2LJz>Af#O9{jHkVO?q?fap$Wzg0g6mCn~vB&iX#%)EX*z)-gj_M z2wPJ)a&$Dl{%0`-m1t5hPLIbCr0|ZR+X#?^uQ_J%_dtxSzmY0$~Er5%@C#%zr6MF<=-Z^M}ySDEnV%(0@Ux2!UAwR|#Av z@E-(RRUb|z^b za~IL+ATUILzE0Q!YSy7@B~*i*S%uhcCqTj^>#1A9F5jMDXcP84epHE=S~YgxqqWa(R^Ni0O2E-p9I11ckhl z-_9Sh9CsWa9$!6~J-KVDna)C;&VQ|2C~g;Xm(OfH*YZKjxh}DDlaRYfv~51mZIdgj z1uh$LU%lf85%e*~f1blupn>J^vuD<9qpeb0%LnpKs#Z=iOGmBo^e1 zHrZ^E4QAO6*QVTS*(-TIFSaR7&CfK;CiYVb6zre^kESi*i$2l?Khfn!b%k}yOtKy1z?4&P zt+OwUFOS`=GDKfo~*aXW}9rymrW~UnOsKx zbuJ@gNzAAzU}XgyvZ-HFz^W-=BquJbcRtN*GsK*n`3^o-&L}xRsY+82!Kw;otu?VU z&fK4-A*O8&gOWRIt)nsN2^E4$AD-l(B2t!U*NlkN9a$QKnGV7qSAKnoem)2K_~^D`eA( z6a_Gc%COvArL1BxtN47D^H9$x%5MEdm{%A|5t9-L z+EB#?H!Mnq*oiE*QXKk{m7`ROW;rjC{X^Zfj?QP9f@MWVRNr|~Z#=T`*`<(MySkx|i#w)`^9!N7kVUqxJdaN0$pkBU`d~B6!(2DeN$UzdIY2If^p?1E}Wu32hW^$1>vPBTU?UG zFIxOjON9{Vlmb0speGvWm7TTIMWV9%)V1?w8wR;qM( zYE{HmD_h-?)h}B8Q=>COg4G|jqQ?piVzD+Ap{RAuC$McXkB^P-70lI9eGSGP=p*9~ zDYhbP8hn#mq_SmV*)qY`E|`}`^&Lv7QOOk)(Gh3dmy{JQo%BhbR?*Wcl(q@xWl?=Q zn+=zU_+#ECF{|k!cE|@NeRS~5B%K5iHP#V1%T$k4(-o=dI;B6?C%6U#^ZKZM1FKsS z$tpoD!Qr1AmC9F$XN1AgW)_823#( zr23vneb1?FA5;jw&4PJLRKHabB}g%`y#bRqaB5((Td+4qjjSigl6=jguUYWM?Nt&5 zH=Npl4h#0?sIeuUC;3`LUyI<4o6jVeKmGMns<{g*I<@7@J+I$$YR$O}!P_g?Z;KlH zAcoI>?dh*wwB`u;fvF*>wp*<276L1T+?7%5sst&;u~_gg6YT9#<8qc=Gd(1=^ha9y zU)w5dx>KmxCfK(}jXRiJmqjdPD#PZ0eiEvStB%Gjp6o_UU_1 z-7EOi{=t%k#H39uYZH9h<}qmtJ196BW_qNK^^uPCZ#D~CwhIkA1p8f4(uk#$ z@q|-Gr0Q<5x?8Byc9Yc|h;$5~?&fVm!*;>GBWk>h)pbWKZdSK(x<_i-BsOgl8aE5M zTcXyjEIUY(yjHAVD+JdGx&2Y=fHHD;&=|jOJQY4yCiwdV`&XjIwKU+5KK-c5NtamN zB~*0_xhta9mC7(o4V~Hd`o2?JQOy^x23b1t;IkI?1i?cHk|Vpk>|Teb{L^2*==D!N zB9(WE0fpdBgZ8lOuWoX0PfwnR$FI0(3zb9p6aFybT=A>dZ7sZDC^ zk2LnfP6)VN#j^=_a8Sg!IZrBw8^Q&B! z6JC*^R7qG>f>JeMH7cb5;k7DWM|hC+p)KMeyCdKYoUcOiwnw~VeFVHoZL68^7IlPM z32#&BEhD_0tFDu(`XW_*FiHa6!TGDCvaU!OnI-}6;tEP8j3YyNt1QH;L{h8B@T*3(Js%(iX9_MTQ7C zgQ7DiH@0GK#m0U$Q5iVndd($6C_Ig#rxB)2Xr`FUG?SFF2VZcyXxaor#0&av$eO_+ zWfqE=g%>i5ohG#a&#sUZ@EIMI6T6|*cg)s;x zFg+8GV5B$*;RJ5LM8OI#Qcwlq1a4$Q*$^pdfN%mgVA2`raQTwTOj2jK*s4-t}zY9mFp5KiEQmXb32$J1YKl0UAe_LPnDn$nTrDt=1Ky&x)k=7qI^xR+ zZ&&FpC%l6zua^8h5q}Sa6L=SUJhVqj+aaF7A)Yw}C;DILCkr~@5YFTvLOhWJ@l=M5 zL^RXz85Y=?X2IGp-Sk%bo9)vzirp^zHaoU7w#LNi3V#zDaC7?Xc`l8|!r6AHSGMOq zzAlyl^mnm4GT9}bFOqW#A74+}kD@O|e9(scG+lqt%f0Wm9Bkx1_hudprhi_?6Kq_1 Muq*xZP9E?-0qFDe`~Uy| literal 39104 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 diff --git a/images/.DS_Store b/images/.DS_Store index ec5b2aaa7eb324cdc192eb8f39a924413973471a..493a0f39a8cecf8fd9596c792a9d1974ef22c045 100644 GIT binary patch delta 14 VcmZn(XbISmC(LNRIbV3H2mmNg1n>X= delta 16 XcmZn(XbISmCp@`M%ysi-;Xn}pIE@A* diff --git a/remove_background.py b/remove_background.py index a59690f..3b71468 100644 --- a/remove_background.py +++ b/remove_background.py @@ -1,7 +1,5 @@ -""" -图片去背景工具 -使用rembg库自动去除图片背景 -""" +"""书画与篆刻作品去背景工具。""" + import os import sys import argparse @@ -13,9 +11,15 @@ from PIL import Image, ImageOps # 避免 numba 在某些环境下缓存失败 os.environ.setdefault("NUMBA_CACHE_DIR", "/tmp/numba_cache") -from rembg import remove, new_session +try: + from rembg import remove, new_session +except ImportError: + remove = None + new_session = None AOT_MODEL_CACHE: dict[tuple[str, str, int, tuple[int, ...]], tuple[object, object]] = {} +REMBG_SESSION_CACHE: dict[str, object] = {} + def _resolve_aot_paths(aot_root, aot_pretrain): """解析 AOT-GAN 的路径配置。""" @@ -29,10 +33,12 @@ def _resolve_aot_paths(aot_root, aot_pretrain): aot_pretrain = (Path.cwd() / aot_pretrain).resolve() return aot_root, aot_pretrain + def _parse_aot_rates(rates_str): parts = [p for p in rates_str.split("+") if p] return [int(p) for p in parts] + def _get_aot_model(aot_root, aot_pretrain, device="cpu", block_num=8, rates=None): """加载/缓存 AOT-GAN 模型。""" aot_root, aot_pretrain = _resolve_aot_paths(aot_root, aot_pretrain) @@ -53,14 +59,17 @@ def _get_aot_model(aot_root, aot_pretrain, device="cpu", block_num=8, rates=None if str(src_root) not in sys.path: sys.path.insert(0, str(src_root)) import importlib + try: import torch except ImportError as exc: raise ImportError("未找到 PyTorch,请先按README安装依赖。") from exc net = importlib.import_module("model.aotgan") + class _Args: pass + args = _Args() args.block_num = int(block_num) args.rates = rates @@ -81,12 +90,14 @@ def _get_aot_model(aot_root, aot_pretrain, device="cpu", block_num=8, rates=None AOT_MODEL_CACHE[key] = (model, device) return AOT_MODEL_CACHE[key] + def _mask_bbox(mask): ys, xs = np.where(mask > 0) if len(xs) == 0: return None return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()) + def _expand_bbox(bbox, pad, width, height): if bbox is None: return None @@ -98,6 +109,7 @@ def _expand_bbox(bbox, pad, width, height): y1 = min(height - 1, y1 + pad) return x0, y0, x1, y1 + def _expand_bbox_min_size(bbox, pad, width, height, min_size): """在给定 pad 基础上,确保裁剪区域至少为 min_size。""" expanded = _expand_bbox(bbox, pad, width, height) @@ -113,9 +125,11 @@ def _expand_bbox_min_size(bbox, pad, width, height, min_size): extra = max((need_w + 1) // 2, (need_h + 1) // 2) return _expand_bbox(bbox, pad + extra, width, height) + def _build_noise_prefill(img, mask_t, strength): """为mask区域生成噪声预填充,img取值范围为[-1, 1]。""" import torch + img01 = (img + 1.0) / 2.0 unmasked = 1.0 - mask_t denom = unmasked.sum() @@ -130,6 +144,7 @@ def _build_noise_prefill(img, mask_t, strength): noise = noise.clamp(0.0, 1.0) return noise * 2.0 - 1.0 + def _inpaint_with_aot_core( bgr, mask, @@ -161,6 +176,7 @@ def _inpaint_with_aot_core( mask_crop = mask[:h2, :w2] import torch + img = torch.from_numpy(img_crop).permute(2, 0, 1).float() / 255.0 img = img * 2.0 - 1.0 mask_t = torch.from_numpy((mask_crop > 0).astype(np.float32)).unsqueeze(0) @@ -196,6 +212,7 @@ def _inpaint_with_aot_core( output_full[:h2, :w2, :] = result_bgr return output_full + def _inpaint_with_aot( bgr, mask, @@ -259,7 +276,9 @@ def _inpaint_with_aot( interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR if scale != 1.0: bgr_roi = cv2.resize(bgr_roi, (new_w, new_h), interpolation=interp) - mask_roi = cv2.resize(mask_roi, (new_w, new_h), interpolation=cv2.INTER_NEAREST) + mask_roi = cv2.resize( + mask_roi, (new_w, new_h), interpolation=cv2.INTER_NEAREST + ) filled_roi = _inpaint_with_aot_core( bgr_roi, @@ -286,48 +305,373 @@ def _inpaint_with_aot( output_full[y0 : y1 + 1, x0 : x1 + 1] = filled_roi return output_full + # 支持HEIC格式 try: from pillow_heif import register_heif_opener + register_heif_opener() HEIC_SUPPORTED = True except ImportError: HEIC_SUPPORTED = False -def remove_background(input_path, output_path, session=None, **kwargs): + +def _get_rembg_session(model_name): + """按模型缓存 rembg 会话,避免重复加载。""" + if new_session is None: + raise ImportError( + "未找到 rembg,请先安装相关依赖或改用 --foreground-mode artwork。" + ) + if model_name not in REMBG_SESSION_CACHE: + REMBG_SESSION_CACHE[model_name] = new_session(model_name) + return REMBG_SESSION_CACHE[model_name] + + +def _resize_for_processing(image, max_size): + """按最大边缩放图片,返回缩放后的图片与缩放比例。""" + height, width = image.shape[:2] + max_size = int(max_size) if max_size else 0 + if max_size <= 0 or max(height, width) <= max_size: + return image.copy(), 1.0 + scale = max_size / float(max(height, width)) + resized = cv2.resize( + image, + (max(1, int(round(width * scale))), max(1, int(round(height * scale)))), + interpolation=cv2.INTER_AREA, + ) + return resized, scale + + +def _otsu_threshold(channel, floor=0): + """返回 Otsu 阈值,并设置一个最小下限避免纸张纹理误检。""" + if channel.size == 0 or int(channel.max()) <= 0: + return int(floor) + threshold, _ = cv2.threshold(channel, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + return max(int(round(threshold)), int(floor)) + + +def _remove_small_components(mask, min_area): + """移除面积过小的连通域,降低纸张纹理噪声。""" + if min_area <= 0 or int(mask.max()) == 0: + return mask + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats( + mask, connectivity=8 + ) + cleaned = np.zeros_like(mask) + for label in range(1, num_labels): + area = int(stats[label, cv2.CC_STAT_AREA]) + if area >= min_area: + cleaned[labels == label] = 255 + return cleaned + + +def _remove_border_frame_components( + mask, + min_width_ratio=0.7, + min_height_ratio=0.7, + max_fill_ratio=0.35, +): + """移除贴边的大型空心边框连通域,避免纸张外轮廓被误判为前景。""" + if int(mask.max()) == 0: + return mask + + height, width = mask.shape + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) + cleaned = mask.copy() + for label in range(1, num_labels): + x, y, comp_w, comp_h, area = stats[label] + touches_border = x == 0 or y == 0 or (x + comp_w) == width or (y + comp_h) == height + if not touches_border: + continue + if comp_w < int(width * float(min_width_ratio)): + continue + if comp_h < int(height * float(min_height_ratio)): + continue + fill_ratio = float(area) / float(max(1, comp_w * comp_h)) + if fill_ratio <= float(max_fill_ratio): + cleaned[labels == label] = 0 + return cleaned + + +def _artwork_mask_is_reasonable(mask): + """检查书画掩码是否可信,用于 auto 模式回退。""" + if mask.size == 0 or int(mask.max()) == 0: + return False + + coverage = float(np.count_nonzero(mask)) / float(mask.size) + if coverage < 0.0005 or coverage > 0.55: + return False + + height, width = mask.shape + border = max(4, min(height, width) // 32) + border_pixels = np.concatenate( + [ + mask[:border, :].reshape(-1), + mask[-border:, :].reshape(-1), + mask[:, :border].reshape(-1), + mask[:, -border:].reshape(-1), + ] + ) + border_ratio = float(np.count_nonzero(border_pixels)) / float(border_pixels.size) + return border_ratio <= 0.28 + + +def _compute_detail_alpha(rgb_image): + """根据局部暗度与严格红章特征生成细节 alpha。""" + height, width = rgb_image.shape[:2] + gray = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY) + sigma_bg = max(8.0, max(height, width) / 80.0) + bg_gray = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma_bg, sigmaY=sigma_bg) + dark_score = cv2.subtract(bg_gray, gray).astype(np.float32) + dark_alpha = np.clip((dark_score - 18.0) / 48.0, 0.0, 1.0) + + hsv = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2HSV) + hue = hsv[:, :, 0].astype(np.float32) + saturation = hsv[:, :, 1].astype(np.float32) + value = hsv[:, :, 2].astype(np.float32) + red = rgb_image[:, :, 0].astype(np.float32) + green = rgb_image[:, :, 1].astype(np.float32) + blue = rgb_image[:, :, 2].astype(np.float32) + red_dominance = red - np.maximum(green, blue) + red_hue_mask = ((hue <= 12.0) | (hue >= 168.0)).astype(np.float32) + seal_alpha = np.clip((red_dominance - 20.0) / 40.0, 0.0, 1.0) + seal_alpha *= np.clip((saturation - 50.0) / 80.0, 0.0, 1.0) + seal_alpha *= (value >= 40.0).astype(np.float32) + seal_alpha *= red_hue_mask + + detail_alpha = np.maximum(dark_alpha, seal_alpha) + return np.clip((detail_alpha - 0.07) / 0.93, 0.0, 1.0) + + +def _extract_artwork_mask(input_image, artwork_type="auto", max_size=1600): + """为书画/篆刻作品提取前景掩码,避免依赖通用人像分割模型。""" + rgb = np.array(input_image.convert("RGB")) + resized, scale = _resize_for_processing(rgb, max_size=max_size) + height, width = resized.shape[:2] + + gray = cv2.cvtColor(resized, cv2.COLOR_RGB2GRAY) + lab = cv2.cvtColor(resized, cv2.COLOR_RGB2LAB) + hsv = cv2.cvtColor(resized, cv2.COLOR_RGB2HSV) + sigma_bg = max(6.0, max(height, width) / 40.0) + + bg_gray = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma_bg, sigmaY=sigma_bg) + dark_score = cv2.subtract(bg_gray, gray) + + lab_a = lab[:, :, 1] + lab_b = lab[:, :, 2] + bg_a = cv2.GaussianBlur(lab_a, (0, 0), sigmaX=sigma_bg, sigmaY=sigma_bg) + bg_b = cv2.GaussianBlur(lab_b, (0, 0), sigmaX=sigma_bg, sigmaY=sigma_bg) + chroma_score = np.sqrt( + (lab_a.astype(np.float32) - bg_a.astype(np.float32)) ** 2 + + (lab_b.astype(np.float32) - bg_b.astype(np.float32)) ** 2 + ) + chroma_score = np.clip(chroma_score * 3.0, 0, 255).astype(np.uint8) + + red = resized[:, :, 0].astype(np.int16) + green = resized[:, :, 1].astype(np.int16) + blue = resized[:, :, 2].astype(np.int16) + hue = hsv[:, :, 0] + saturation = hsv[:, :, 1] + value = hsv[:, :, 2] + red_dominance = np.clip(red - np.maximum(green, blue), 0, 255).astype(np.uint8) + seal_score = np.clip( + red_dominance.astype(np.int16) * 2 + + np.clip(saturation.astype(np.int16) - 45, 0, 255), + 0, + 255, + ).astype(np.uint8) + seal_score = np.where( + ((hue <= 12) | (hue >= 168)) + & (saturation >= 55) + & (value >= 40) + & (red_dominance >= 20), + seal_score, + 0, + ).astype(np.uint8) + color_score = np.maximum(chroma_score, seal_score) + + if artwork_type == "seal": + combined_score = np.maximum( + np.clip(dark_score.astype(np.float32) * 1.0, 0, 255).astype(np.uint8), + np.clip(color_score.astype(np.float32) * 1.35, 0, 255).astype(np.uint8), + ) + dark_floor = 12 + color_floor = 18 + combined_floor = 20 + elif artwork_type == "calligraphy": + combined_score = np.maximum( + np.clip(dark_score.astype(np.float32) * 1.35, 0, 255).astype(np.uint8), + np.clip(color_score.astype(np.float32) * 0.8, 0, 255).astype(np.uint8), + ) + dark_floor = 14 + color_floor = 28 + combined_floor = 18 + else: + combined_score = np.maximum( + np.clip(dark_score.astype(np.float32) * 1.2, 0, 255).astype(np.uint8), + np.clip(color_score.astype(np.float32) * 1.2, 0, 255).astype(np.uint8), + ) + dark_floor = 14 + color_floor = 16 + combined_floor = 18 + + smooth_sigma = max(0.6, max(height, width) / 320.0) + dark_smooth = cv2.GaussianBlur( + dark_score, (0, 0), sigmaX=smooth_sigma, sigmaY=smooth_sigma + ) + color_smooth = cv2.GaussianBlur( + color_score, (0, 0), sigmaX=smooth_sigma, sigmaY=smooth_sigma + ) + combined_smooth = cv2.GaussianBlur( + combined_score, + (0, 0), + sigmaX=smooth_sigma, + sigmaY=smooth_sigma, + ) + + dark_mask = (dark_smooth >= _otsu_threshold(dark_smooth, floor=dark_floor)).astype( + np.uint8 + ) * 255 + color_mask = ( + color_smooth >= _otsu_threshold(color_smooth, floor=color_floor) + ).astype(np.uint8) * 255 + combined_mask = ( + combined_smooth >= _otsu_threshold(combined_smooth, floor=combined_floor) + ).astype(np.uint8) * 255 + + if artwork_type == "seal": + mask = cv2.bitwise_or(combined_mask, color_mask) + mask = cv2.bitwise_or(mask, dark_mask) + elif artwork_type == "calligraphy": + mask = cv2.bitwise_or(combined_mask, dark_mask) + else: + mask = cv2.bitwise_or(combined_mask, cv2.bitwise_or(dark_mask, color_mask)) + close_kernel = np.ones((3, 3), dtype=np.uint8) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, close_kernel, iterations=1) + min_area = max(12, (height * width) // 25000) + mask = _remove_small_components(mask, min_area=min_area) + mask = _remove_border_frame_components(mask) + mask = cv2.dilate(mask, close_kernel, iterations=1) + + if scale < 1.0: + mask = cv2.resize( + mask, (rgb.shape[1], rgb.shape[0]), interpolation=cv2.INTER_NEAREST + ) + return mask + + +def _mask_to_transparent_image(input_image, mask): + """将二值掩码转为带透明背景的 RGBA 图片。""" + rgb = np.array(input_image.convert("RGB")) + detail_alpha = _compute_detail_alpha(rgb) + alpha = ((mask.astype(np.float32) / 255.0) * detail_alpha * 255.0).astype(np.uint8) + alpha = cv2.GaussianBlur(alpha, (0, 0), sigmaX=0.8, sigmaY=0.8) + alpha = np.where(mask > 0, alpha, 0).astype(np.uint8) + rgba = np.dstack((rgb, alpha)) + return Image.fromarray(rgba) + + +def _extract_foreground_mask( + input_image, + session=None, + model_name="isnet-general-use", + foreground_mode="artwork", + artwork_type="auto", + artwork_max_size=1600, + mask_threshold=10, + **kwargs, +): + """按指定模式提取前景掩码。""" + if foreground_mode in {"artwork", "auto"}: + artwork_mask = _extract_artwork_mask( + input_image, + artwork_type=artwork_type, + max_size=artwork_max_size, + ) + if foreground_mode == "artwork" or _artwork_mask_is_reasonable(artwork_mask): + return artwork_mask, "artwork" + + if foreground_mode not in {"rembg", "auto"}: + raise ValueError(f"不支持的前景提取模式: {foreground_mode}") + + if remove is None: + raise ImportError( + "未找到 rembg,请先安装相关依赖或改用 --foreground-mode artwork。" + ) + if session is None: + session = _get_rembg_session(model_name) + output_image = remove(input_image, session=session, **kwargs) + output_image = _ensure_rgba_size(output_image, input_image.size) + alpha = np.array(output_image.getchannel("A")) + return _alpha_to_mask(alpha, threshold=mask_threshold), "rembg" + + +def remove_background( + input_path, + output_path, + session=None, + model_name="isnet-general-use", + foreground_mode="artwork", + artwork_type="auto", + artwork_max_size=1600, + **kwargs, +): """ - 去除图片背景 - + 去除图片背景。 + Args: input_path: 输入图片路径 output_path: 输出图片路径 session: rembg会话对象(可选) + model_name: rembg 模型名称 + foreground_mode: 前景提取模式,可选 artwork / auto / rembg + artwork_type: 书画类型,可选 auto / calligraphy / seal + artwork_max_size: 书画掩码估算时的最大边 **kwargs: 其他参数,如alpha_matting相关参数 """ print(f"正在处理: {input_path}") - + # 读取输入图片 input_image = ImageOps.exif_transpose(Image.open(input_path)) - - # 去除背景 + + if foreground_mode in {"artwork", "auto"}: + mask = _extract_artwork_mask( + input_image, + artwork_type=artwork_type, + max_size=artwork_max_size, + ) + if foreground_mode == "artwork" or _artwork_mask_is_reasonable(mask): + output_image = _mask_to_transparent_image(input_image, mask) + output_image.save(output_path) + print(f"已保存: {output_path}") + return + + if remove is None: + raise ImportError( + "未找到 rembg,请先安装相关依赖或改用 --foreground-mode artwork。" + ) + if session is None: + session = _get_rembg_session(model_name) + output_image = remove(input_image, session=session, **kwargs) - - # 保存输出图片 output_image.save(output_path) - + print(f"已保存: {output_path}") + def _pil_to_bgr(image): """将PIL图片转换为OpenCV BGR格式""" rgb = image.convert("RGB") arr = np.array(rgb) return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR) + def _alpha_to_mask(alpha_channel, threshold=10): """将alpha通道转换为二值mask(0或255)""" mask = (alpha_channel > threshold).astype(np.uint8) * 255 return mask + def _ensure_rgba_size(image, target_size): if image.mode != "RGBA": image = image.convert("RGBA") @@ -335,6 +679,7 @@ def _ensure_rgba_size(image, target_size): image = image.resize(target_size, resample=Image.NEAREST) return image + def _prepare_mask(mask, mask_dilate=3, mask_blur=3, edge_grow=0): """生成硬边mask与处理后mask(用于填补/过渡)""" if mask_dilate and mask_dilate > 0: @@ -352,10 +697,15 @@ def _prepare_mask(mask, mask_dilate=3, mask_blur=3, edge_grow=0): mask_used = (mask_used > 0).astype(np.uint8) * 255 return mask_hard, mask_used + def remove_subject_and_inpaint( input_path, output_path, session=None, + model_name="isnet-general-use", + foreground_mode="artwork", + artwork_type="auto", + artwork_max_size=1600, mask_dilate=3, mask_blur=3, mask_threshold=10, @@ -388,6 +738,10 @@ def remove_subject_and_inpaint( input_path: 输入图片路径 output_path: 输出图片路径 session: rembg会话对象(可选) + model_name: rembg 模型名称 + foreground_mode: 前景提取模式,可选 artwork / auto / rembg + artwork_type: 书画类型,可选 auto / calligraphy / seal + artwork_max_size: 书画掩码估算时的最大边 aot_root: AOT-GAN目录 aot_pretrain: AOT-GAN权重文件路径 aot_device: AOT-GAN设备 @@ -409,12 +763,16 @@ def remove_subject_and_inpaint( input_image = ImageOps.exif_transpose(Image.open(input_path)) - # 使用rembg获取主体mask - output_image = remove(input_image, session=session, **kwargs) - output_image = _ensure_rgba_size(output_image, input_image.size) - - alpha = np.array(output_image.getchannel("A")) - mask = _alpha_to_mask(alpha, threshold=mask_threshold) + mask, mask_source = _extract_foreground_mask( + input_image, + session=session, + model_name=model_name, + foreground_mode=foreground_mode, + artwork_type=artwork_type, + artwork_max_size=artwork_max_size, + mask_threshold=mask_threshold, + **kwargs, + ) bgr = _pil_to_bgr(input_image) if black_subject: @@ -425,7 +783,9 @@ def remove_subject_and_inpaint( hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) s = hsv[:, :, 1] v = hsv[:, :, 2] - gray_mask = ((s <= gray_saturation_threshold) & (v <= gray_value_threshold)).astype(np.uint8) * 255 + gray_mask = ( + (s <= gray_saturation_threshold) & (v <= gray_value_threshold) + ).astype(np.uint8) * 255 mask = cv2.bitwise_or(mask, gray_mask) mask_hard, mask_used = _prepare_mask( @@ -457,7 +817,9 @@ def remove_subject_and_inpaint( dist_out = cv2.distanceTransform(255 - mask_bin, cv2.DIST_L2, 3) alpha = np.ones_like(dist_out, dtype=np.float32) outside = mask_bin == 0 - alpha[outside] = np.clip(1.0 - (dist_out[outside] / float(feather_radius)), 0.0, 1.0) + alpha[outside] = np.clip( + 1.0 - (dist_out[outside] / float(feather_radius)), 0.0, 1.0 + ) alpha = alpha[:, :, None] blended = (alpha * filled + (1.0 - alpha) * bgr).astype(np.uint8) result = cv2.cvtColor(blended, cv2.COLOR_BGR2RGB) @@ -471,12 +833,17 @@ def remove_subject_and_inpaint( Image.fromarray(mask_used).save(mask_output_path) print(f"已保存mask: {mask_output_path}") + print(f"前景提取模式: {mask_source}") print(f"已保存: {output_path}") + def process_images_folder( input_folder, output_folder, - model_name="u2net", + model_name="isnet-general-use", + foreground_mode="artwork", + artwork_type="auto", + artwork_max_size=1600, alpha_matting=False, alpha_matting_foreground_threshold=240, alpha_matting_background_threshold=10, @@ -508,11 +875,11 @@ def process_images_folder( ): """ 批量处理文件夹中的所有图片 - + Args: input_folder: 输入文件夹路径 output_folder: 输出文件夹路径 - model_name: 模型名称,可选值: + model_name: rembg 模型名称,可选值: - u2net (默认): 通用模型 - u2netp: 轻量版u2net - u2net_human_seg: 人物分割 @@ -521,6 +888,9 @@ def process_images_folder( - isnet-anime: 动漫角色高精度分割 - birefnet-general: 通用模型 - birefnet-portrait: 人像模型 + foreground_mode: 前景提取模式,可选 artwork / auto / rembg + artwork_type: 书画类型,可选 auto / calligraphy / seal + artwork_max_size: 书画掩码估算时的最大边 alpha_matting: 是否启用alpha matting后处理(改善边缘质量) alpha_matting_foreground_threshold: 前景阈值 (0-255),值越大保留越多前景 alpha_matting_background_threshold: 背景阈值 (0-255),值越大去除越多背景 @@ -529,27 +899,33 @@ def process_images_folder( """ # 创建输出文件夹 Path(output_folder).mkdir(parents=True, exist_ok=True) - - # 创建会话(重用会话可以提高性能) - print(f"使用模型: {model_name}") - session = new_session(model_name) - + + print(f"前景提取模式: {foreground_mode}") + print(f"书画类型: {artwork_type}") + if foreground_mode in {"rembg", "auto"}: + print(f"rembg模型: {model_name}") + # 支持的图片格式 - image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.webp'} + image_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} if HEIC_SUPPORTED: - image_extensions.update({'.heic', '.heif'}) + image_extensions.update({".heic", ".heif"}) else: - print("提示: 未安装pillow-heif,HEIC格式不可用。安装方法: pip install pillow-heif") - + print( + "提示: 未安装pillow-heif,HEIC格式不可用。安装方法: pip install pillow-heif" + ) + # 获取所有图片文件 input_path = Path(input_folder) - image_files = [f for f in input_path.iterdir() - if f.is_file() and f.suffix.lower() in image_extensions] - + image_files = [ + f + for f in input_path.iterdir() + if f.is_file() and f.suffix.lower() in image_extensions + ] + if not image_files: print(f"在 {input_folder} 中没有找到图片文件") return - + print(f"找到 {len(image_files)} 张图片,开始处理...") print(f"Alpha Matting: {'启用' if alpha_matting else '禁用'}") if alpha_matting: @@ -587,7 +963,7 @@ def process_images_folder( print(f" - 过渡半径: {feather_radius}") print(f" - 保存mask: {'是' if save_mask else '否'}") print("-" * 50) - + # 处理每张图片 for i, image_file in enumerate(image_files, 1): try: @@ -601,16 +977,21 @@ def process_images_folder( # 去背景默认使用PNG格式以支持透明背景 output_filename = image_file.stem + "_nobg.png" output_path = Path(output_folder) / output_filename - + print(f"[{i}/{len(image_files)}] ", end="") if remove_subject: mask_output_path = None if save_mask: - mask_output_path = str(Path(output_folder) / (image_file.stem + "_mask.png")) + mask_output_path = str( + Path(output_folder) / (image_file.stem + "_mask.png") + ) remove_subject_and_inpaint( str(image_file), str(output_path), - session=session, + model_name=model_name, + foreground_mode=foreground_mode, + artwork_type=artwork_type, + artwork_max_size=artwork_max_size, alpha_matting=alpha_matting, alpha_matting_foreground_threshold=alpha_matting_foreground_threshold, alpha_matting_background_threshold=alpha_matting_background_threshold, @@ -644,25 +1025,29 @@ def process_images_folder( remove_background( str(image_file), str(output_path), - session=session, + model_name=model_name, + foreground_mode=foreground_mode, + artwork_type=artwork_type, + artwork_max_size=artwork_max_size, alpha_matting=alpha_matting, alpha_matting_foreground_threshold=alpha_matting_foreground_threshold, alpha_matting_background_threshold=alpha_matting_background_threshold, alpha_matting_erode_size=alpha_matting_erode_size, post_process_mask=post_process_mask, ) - + except Exception as e: print(f"处理 {image_file.name} 时出错: {e}") - + print("-" * 50) print(f"处理完成!结果保存在 {output_folder} 文件夹中") + if __name__ == "__main__": parser = argparse.ArgumentParser( - description='图片去背景工具 - 使用rembg自动去除图片背景', + description="书画与篆刻作品去背景工具 - 默认使用轻量书画掩码提取", formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=''' + epilog=""" 示例用法: # 使用默认参数处理images文件夹 python remove_background.py @@ -673,104 +1058,233 @@ if __name__ == "__main__": # 处理指定文件夹 python remove_background.py my_images/ my_output/ - # 使用不同模型 - python remove_background.py input.jpg output.png -m birefnet-portrait + # 强制使用 rembg 旧流程 + python remove_background.py input.jpg output.png --foreground-mode rembg -m isnet-general-use + + # 指定为篆刻模式 + python remove_background.py input.jpg output.png --artwork-type seal # 自定义alpha matting参数 python remove_background.py input.jpg output.png -ft 260 -bt 12 -es 5 - ''' + """, ) - + # 必需参数 - parser.add_argument('input', nargs='?', default='images', - help='输入文件或文件夹路径(默认: images)') - parser.add_argument('output', nargs='?', default=None, - help='输出文件或文件夹路径(可选,默认为output/)') - + parser.add_argument( + "input", + nargs="?", + default="images", + help="输入文件或文件夹路径(默认: images)", + ) + parser.add_argument( + "output", + nargs="?", + default=None, + help="输出文件或文件夹路径(可选,默认为output/)", + ) + # 模型选择 - parser.add_argument('-m', '--model', default='isnet-general-use', - choices=['u2net', 'u2netp', 'u2net_human_seg', 'silueta', - 'isnet-general-use', 'isnet-anime', - 'birefnet-general', 'birefnet-general-lite', - 'birefnet-portrait', 'birefnet-dis', - 'birefnet-hrsod', 'birefnet-cod', 'birefnet-massive'], - help='选择使用的模型 (默认: isnet-general-use)') - + parser.add_argument( + "-m", + "--model", + default="isnet-general-use", + choices=[ + "u2net", + "u2netp", + "u2net_human_seg", + "silueta", + "isnet-general-use", + "isnet-anime", + "birefnet-general", + "birefnet-general-lite", + "birefnet-portrait", + "birefnet-dis", + "birefnet-hrsod", + "birefnet-cod", + "birefnet-massive", + ], + help="选择 rembg 模型(仅在 --foreground-mode rembg/auto 时使用)", + ) + parser.add_argument( + "--foreground-mode", + default="artwork", + choices=["artwork", "auto", "rembg"], + help="前景提取模式:artwork 为书画专用快速模式,auto 失败时回退 rembg,rembg 为旧流程 (默认: artwork)", + ) + parser.add_argument( + "--artwork-type", + default="auto", + choices=["auto", "calligraphy", "seal"], + help="书画类型:auto 自动兼容书法与篆刻,seal 更偏重红色印章,calligraphy 更偏重墨色笔画 (默认: auto)", + ) + parser.add_argument( + "--artwork-max-size", + type=int, + default=1600, + help="书画掩码估算时的最大边,越小越快,越大越精细 (默认: 1600)", + ) + # Alpha Matting参数 - parser.add_argument('-a', '--alpha-matting', '--alpha_matting', action='store_true', - help='启用alpha matting后处理(默认: false)') - parser.add_argument('-ft', '--foreground-threshold', type=int, default=245, - help='前景阈值 (0-255),值越大保留越多细节 (默认: 245)') - parser.add_argument('-bt', '--background-threshold', type=int, default=8, - help='背景阈值 (0-255),值越大去除越多背景 (默认: 8)') - parser.add_argument('-es', '--erode-size', type=int, default=2, - help='侵蚀大小,用于平滑边缘,值越大越平滑但可能丢失细节 (默认: 2)') - + parser.add_argument( + "-a", + "--alpha-matting", + "--alpha_matting", + action="store_true", + help="启用alpha matting后处理(默认: false)", + ) + parser.add_argument( + "-ft", + "--foreground-threshold", + type=int, + default=245, + help="前景阈值 (0-255),值越大保留越多细节 (默认: 245)", + ) + parser.add_argument( + "-bt", + "--background-threshold", + type=int, + default=8, + help="背景阈值 (0-255),值越大去除越多背景 (默认: 8)", + ) + parser.add_argument( + "-es", + "--erode-size", + type=int, + default=2, + help="侵蚀大小,用于平滑边缘,值越大越平滑但可能丢失细节 (默认: 2)", + ) + # 其他选项 - parser.add_argument('-p', '--post-process', '--post_process', action='store_true', - help='启用mask后处理(默认: false)') + parser.add_argument( + "-p", + "--post-process", + "--post_process", + action="store_true", + help="启用mask后处理(默认: false)", + ) # 去主体补背景参数 - parser.add_argument('--remove-subject', '--remove_subject', action='store_true', - help='去掉主体并补全背景(默认: false)') - parser.add_argument('--aot-root', type=str, default='AOT-GAN-for-Inpainting', - help='AOT-GAN目录(默认: AOT-GAN-for-Inpainting)') - parser.add_argument('--aot-pretrain', type=str, default=None, - help='AOT-GAN预训练权重文件路径(必填,相对路径基于当前目录)') - parser.add_argument('--aot-device', type=str, default='cpu', - help='AOT-GAN设备(默认: cpu)') - parser.add_argument('--aot-block-num', type=int, default=8, - help='AOTBlock数量(默认: 8)') - parser.add_argument('--aot-rates', type=str, default='1+2+4+8', - help='AOTBlock膨胀率(默认: 1+2+4+8)') - parser.add_argument('--aot-crop', action='store_true', - help='AOT仅对mask区域裁剪修补(默认: false)') - parser.add_argument('--aot-crop-pad', type=int, default=0, - help='AOT裁剪边缘留白像素(默认: 0)') - parser.add_argument('--aot-max-size', type=int, default=0, - help='AOT输入最大边限制,0为不限制(默认: 0)') - parser.add_argument('--aot-noise-prefill', action='store_true', - help='AOT使用随机噪声预填充(默认: false)') - parser.add_argument('--aot-noise-strength', type=float, default=1.0, - help='AOT噪声强度系数(默认: 1.0)') - parser.add_argument('--mask-dilate', type=int, default=3, - help='mask膨胀大小(默认: 3)') - parser.add_argument('--mask-blur', type=int, default=3, - help='mask模糊大小(默认: 3,建议奇数)') - parser.add_argument('--mask-threshold', type=int, default=10, - help='alpha阈值(默认: 10)') - parser.add_argument('--edge-grow', type=int, default=0, - help='主体边缘扩张像素(默认: 0)') - parser.add_argument('--save-mask', '--save_mask', action='store_true', - help='保存mask到output目录(默认: false)') - parser.add_argument('--black-subject', '--black_subject', action='store_true', - help='将黑色内容也视为主体(默认: false)') - parser.add_argument('--black-threshold', type=int, default=50, - help='黑色阈值(0-255,灰度越小越黑,默认: 50)') - parser.add_argument('--gray-subject', '--gray_subject', action='store_true', - help='将灰阶内容也视为主体(默认: false)') - parser.add_argument('--gray-saturation-threshold', type=int, default=30, - help='灰阶饱和度阈值(0-255,越小越接近灰阶,默认: 30)') - parser.add_argument('--gray-value-threshold', type=int, default=200, - help='灰阶亮度阈值(0-255,越小越暗,默认: 200)') - parser.add_argument('--feather', action='store_true', - help='启用边缘过渡融合(默认: false)') - parser.add_argument('--feather-radius', type=int, default=5, - help='边缘过渡半径(默认: 5)') - + parser.add_argument( + "--remove-subject", + "--remove_subject", + action="store_true", + help="去掉主体并补全背景(默认: false)", + ) + parser.add_argument( + "--aot-root", + type=str, + default="AOT-GAN-for-Inpainting", + help="AOT-GAN目录(默认: AOT-GAN-for-Inpainting)", + ) + parser.add_argument( + "--aot-pretrain", + type=str, + default=None, + help="AOT-GAN预训练权重文件路径(必填,相对路径基于当前目录)", + ) + parser.add_argument( + "--aot-device", type=str, default="cpu", help="AOT-GAN设备(默认: cpu)" + ) + parser.add_argument( + "--aot-block-num", type=int, default=8, help="AOTBlock数量(默认: 8)" + ) + parser.add_argument( + "--aot-rates", + type=str, + default="1+2+4+8", + help="AOTBlock膨胀率(默认: 1+2+4+8)", + ) + parser.add_argument( + "--aot-crop", action="store_true", help="AOT仅对mask区域裁剪修补(默认: false)" + ) + parser.add_argument( + "--aot-crop-pad", type=int, default=0, help="AOT裁剪边缘留白像素(默认: 0)" + ) + parser.add_argument( + "--aot-max-size", + type=int, + default=0, + help="AOT输入最大边限制,0为不限制(默认: 0)", + ) + parser.add_argument( + "--aot-noise-prefill", + action="store_true", + help="AOT使用随机噪声预填充(默认: false)", + ) + parser.add_argument( + "--aot-noise-strength", + type=float, + default=1.0, + help="AOT噪声强度系数(默认: 1.0)", + ) + parser.add_argument( + "--mask-dilate", type=int, default=3, help="mask膨胀大小(默认: 3)" + ) + parser.add_argument( + "--mask-blur", type=int, default=3, help="mask模糊大小(默认: 3,建议奇数)" + ) + parser.add_argument( + "--mask-threshold", type=int, default=10, help="alpha阈值(默认: 10)" + ) + parser.add_argument( + "--edge-grow", type=int, default=0, help="主体边缘扩张像素(默认: 0)" + ) + parser.add_argument( + "--save-mask", + "--save_mask", + action="store_true", + help="保存mask到output目录(默认: false)", + ) + parser.add_argument( + "--black-subject", + "--black_subject", + action="store_true", + help="将黑色内容也视为主体(默认: false)", + ) + parser.add_argument( + "--black-threshold", + type=int, + default=50, + help="黑色阈值(0-255,灰度越小越黑,默认: 50)", + ) + parser.add_argument( + "--gray-subject", + "--gray_subject", + action="store_true", + help="将灰阶内容也视为主体(默认: false)", + ) + parser.add_argument( + "--gray-saturation-threshold", + type=int, + default=30, + help="灰阶饱和度阈值(0-255,越小越接近灰阶,默认: 30)", + ) + parser.add_argument( + "--gray-value-threshold", + type=int, + default=200, + help="灰阶亮度阈值(0-255,越小越暗,默认: 200)", + ) + parser.add_argument( + "--feather", action="store_true", help="启用边缘过渡融合(默认: false)" + ) + parser.add_argument( + "--feather-radius", type=int, default=5, help="边缘过渡半径(默认: 5)" + ) + args = parser.parse_args() - + print("=" * 50) print("图片去背景工具") print("=" * 50) - + # 判断输入是文件还是文件夹 input_path = Path(args.input) - + if not input_path.exists(): print(f"错误: 输入路径不存在: {args.input}") exit(1) - + # 处理单个文件 if input_path.is_file(): suffix = input_path.suffix.lower() @@ -787,7 +1301,7 @@ if __name__ == "__main__": if args.remove_subject: output_path = input_path.parent / "output" / output_name else: - output_path = input_path.parent / 'output' / output_name + output_path = input_path.parent / "output" / output_name output_path.parent.mkdir(parents=True, exist_ok=True) else: output_candidate = Path(args.output) @@ -800,10 +1314,14 @@ if __name__ == "__main__": else: output_path = output_candidate output_path.parent.mkdir(parents=True, exist_ok=True) - + print(f"输入文件: {input_path}") print(f"输出文件: {output_path}") - print(f"模型: {args.model}") + print(f"前景提取模式: {args.foreground_mode}") + print(f"书画类型: {args.artwork_type}") + print(f"书画最大边: {args.artwork_max_size}") + if args.foreground_mode in {"rembg", "auto"}: + print(f"rembg模型: {args.model}") print(f"Alpha Matting: {'启用' if args.alpha_matting else '禁用'}") if args.alpha_matting: print(f" - 前景阈值: {args.foreground_threshold}") @@ -840,10 +1358,7 @@ if __name__ == "__main__": print(f" - 过渡半径: {args.feather_radius}") print(f" - 保存mask: {'是' if args.save_mask else '否'}") print("-" * 50) - - # 创建会话 - session = new_session(args.model) - + # 处理图片 if args.remove_subject: mask_output_path = None @@ -852,7 +1367,10 @@ if __name__ == "__main__": remove_subject_and_inpaint( str(input_path), str(output_path), - session=session, + model_name=args.model, + foreground_mode=args.foreground_mode, + artwork_type=args.artwork_type, + artwork_max_size=args.artwork_max_size, alpha_matting=args.alpha_matting, alpha_matting_foreground_threshold=args.foreground_threshold, alpha_matting_background_threshold=args.background_threshold, @@ -886,25 +1404,31 @@ if __name__ == "__main__": remove_background( str(input_path), str(output_path), - session=session, + model_name=args.model, + foreground_mode=args.foreground_mode, + artwork_type=args.artwork_type, + artwork_max_size=args.artwork_max_size, alpha_matting=args.alpha_matting, alpha_matting_foreground_threshold=args.foreground_threshold, alpha_matting_background_threshold=args.background_threshold, alpha_matting_erode_size=args.erode_size, post_process_mask=args.post_process, ) - + print("-" * 50) print(f"处理完成!结果保存在: {output_path}") - + # 处理文件夹 elif input_path.is_dir(): - output_folder = args.output if args.output else 'output' - + output_folder = args.output if args.output else "output" + process_images_folder( str(input_path), output_folder, model_name=args.model, + foreground_mode=args.foreground_mode, + artwork_type=args.artwork_type, + artwork_max_size=args.artwork_max_size, alpha_matting=args.alpha_matting, alpha_matting_foreground_threshold=args.foreground_threshold, alpha_matting_background_threshold=args.background_threshold, @@ -934,7 +1458,7 @@ if __name__ == "__main__": feather_radius=args.feather_radius, save_mask=args.save_mask, ) - + else: print(f"错误: 不支持的输入类型: {args.input}") exit(1) diff --git a/tests/test_remove_background.py b/tests/test_remove_background.py index b9469e8..d0d9824 100644 --- a/tests/test_remove_background.py +++ b/tests/test_remove_background.py @@ -1,7 +1,9 @@ import importlib import sys +import tempfile import types import unittest +from pathlib import Path def _import_remove_background(): @@ -18,6 +20,15 @@ class RemoveBackgroundTests(unittest.TestCase): def setUp(self): self.mod = _import_remove_background() + def _make_paper_image(self, width=160, height=120): + rng = self.mod.np.random.default_rng(0) + base = self.mod.np.full((height, width, 3), 230, dtype=self.mod.np.uint8) + noise = rng.integers(-8, 9, size=(height, width, 3), dtype=self.mod.np.int16) + image = self.mod.np.clip(base.astype(self.mod.np.int16) + noise, 0, 255).astype( + self.mod.np.uint8 + ) + return image + def test_alpha_to_mask_threshold(self): alpha = self.mod.np.array([[0, 9, 10, 255]], dtype=self.mod.np.uint8) mask = self.mod._alpha_to_mask(alpha, threshold=10) @@ -67,12 +78,126 @@ class RemoveBackgroundTests(unittest.TestCase): expanded_min = self.mod._expand_bbox_min_size(bbox, 0, 5, 5, 4) self.assertEqual(expanded_min, (1, 0, 4, 3)) + def test_remove_border_frame_components_keeps_inner_content(self): + mask = self.mod.np.zeros((12, 12), dtype=self.mod.np.uint8) + mask[0, :] = 255 + mask[-1, :] = 255 + mask[:, 0] = 255 + mask[:, -1] = 255 + mask[4:8, 5:7] = 255 + + cleaned = self.mod._remove_border_frame_components( + mask, + min_width_ratio=0.7, + min_height_ratio=0.7, + max_fill_ratio=0.35, + ) + + self.assertEqual(int(cleaned[0, 0]), 0) + self.assertGreater(cleaned[4:8, 5:7].mean(), 200) + def test_ensure_rgba_size_resizes(self): img = self.mod.Image.new("RGB", (2, 3), color=(0, 0, 0)) out = self.mod._ensure_rgba_size(img, (4, 5)) self.assertEqual(out.mode, "RGBA") self.assertEqual(out.size, (4, 5)) + def test_extract_artwork_mask_detects_calligraphy_strokes(self): + image = self._make_paper_image() + self.mod.cv2.line(image, (20, 20), (130, 90), (20, 20, 20), thickness=6) + self.mod.cv2.line(image, (45, 15), (45, 105), (30, 30, 30), thickness=5) + + mask = self.mod._extract_artwork_mask( + self.mod.Image.fromarray(image), + artwork_type="calligraphy", + max_size=120, + ) + + self.assertGreater(mask[25:95, 42:49].mean(), 180) + self.assertLess(mask[:15, :15].mean(), 20) + + def test_extract_artwork_mask_detects_red_seal(self): + image = self._make_paper_image() + self.mod.cv2.rectangle(image, (40, 30), (120, 95), (165, 30, 40), thickness=5) + self.mod.cv2.line(image, (55, 40), (105, 85), (170, 25, 35), thickness=6) + + mask = self.mod._extract_artwork_mask( + self.mod.Image.fromarray(image), + artwork_type="seal", + max_size=120, + ) + + self.assertGreater(mask[38:92, 38:122].mean(), 40) + self.assertLess(mask[:15, :15].mean(), 20) + + def test_extract_artwork_mask_ignores_warm_paper_cast(self): + image = self.mod.np.full( + (120, 160, 3), (222, 188, 150), dtype=self.mod.np.uint8 + ) + self.mod.cv2.line(image, (30, 20), (30, 100), (28, 28, 28), thickness=6) + self.mod.cv2.line(image, (55, 20), (125, 95), (32, 32, 32), thickness=5) + + mask = self.mod._extract_artwork_mask( + self.mod.Image.fromarray(image), + artwork_type="calligraphy", + max_size=120, + ) + + self.assertGreater(mask[25:100, 26:34].mean(), 180) + self.assertLess(mask[:12, :12].mean(), 20) + + def test_mask_to_transparent_image_refines_alpha_inside_coarse_mask(self): + image = self.mod.np.full( + (120, 160, 3), (224, 190, 156), dtype=self.mod.np.uint8 + ) + self.mod.cv2.line(image, (40, 18), (40, 102), (24, 24, 24), thickness=6) + self.mod.cv2.rectangle(image, (102, 24), (132, 54), (170, 30, 40), thickness=-1) + + coarse_mask = self.mod.np.zeros((120, 160), dtype=self.mod.np.uint8) + coarse_mask[10:110, 20:140] = 255 + + out = self.mod._mask_to_transparent_image( + self.mod.Image.fromarray(image), + coarse_mask, + ) + alpha = self.mod.np.array(out.getchannel("A")) + + self.assertGreater(alpha[25:95, 36:45].mean(), 110) + self.assertGreater(alpha[30:50, 108:128].mean(), 90) + self.assertLess(alpha[70:90, 70:90].mean(), 25) + + def test_remove_background_artwork_mode_skips_rembg(self): + image = self._make_paper_image(width=100, height=80) + self.mod.cv2.line(image, (15, 15), (85, 60), (20, 20, 20), thickness=5) + + original_remove = self.mod.remove + + def _unexpected_remove(*args, **kwargs): + raise AssertionError("artwork 模式不应调用 rembg") + + self.mod.remove = _unexpected_remove + try: + with tempfile.TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / "input.png" + output_path = Path(tmpdir) / "output.png" + self.mod.Image.fromarray(image).save(input_path) + + self.mod.remove_background( + str(input_path), + str(output_path), + foreground_mode="artwork", + artwork_type="calligraphy", + artwork_max_size=120, + ) + + out = self.mod.Image.open(output_path) + alpha = self.mod.np.array(out.getchannel("A")) + self.assertEqual(out.mode, "RGBA") + self.assertGreater(alpha[30:55, 35:70].mean(), 70) + self.assertLess(alpha[:10, :10].mean(), 10) + finally: + self.mod.remove = original_remove + if __name__ == "__main__": unittest.main()