Implement dpi design
1. 需求
虚拟扫描仪需要能够按用户选择的 DPI(150 / 200 / 300 / 600 之一)输出图像,让上层应用看到的像素尺寸与"按选定 DPI 扫描指定纸张"得到的结果一致,而不仅仅是改 DPI 元数据字段。
主要需求:
- TWAIN 应用通过
ICAP_XRESOLUTION/ICAP_YRESOLUTION设定 DPI 时,DS 必须真实地按目标 DPI 重采样输出图像,使输出像素宽高 =纸张英寸数 × DPI。 - settings UI 中用户从下拉框选择 150 / 200 / 300 / 600 时,行为与应用通过能力设置时一致。
- 必须同时支持 Native Transfer 和 File Transfer 两条路径,二者必须输出相同像素的图像。
- 必须同时支持三种像素模式(
TWPT_BW/TWPT_GRAY/TWPT_RGB),DPI 切换不能与像素模式切换互相破坏。 TW_IMAGEINFO.XResolution/YResolution和 DIB 头biXPelsPerMeter/biYPelsPerMeter以及输出文件元数据必须报告与实际像素密度一致的 DPI。- 支持页面尺寸(A4 / Letter / 自定义等)与 DPI 的组合,目标像素宽高由二者共同决定。
- 重采样质量必须足够好,不能出现明显锯齿或马赛克;缩小不允许 nearest-neighbor 严重模糊文字。
非功能性需求:
- 一次扫描只允许重采样一次,避免对同一图像反复
FreeImage_Rescale。 - 当源图像本身分辨率比目标小(例如源 800×600,目标 A4@600DPI 需要约 4960×7016)时,仍然必须放大,不允许直接拒绝或返回原图。
- 单页扫描的 DPI 切换不依赖磁盘缓存,每次都从原图重新算,避免上一次设置影响下一次。
2. 领域知识
2.1 TWAIN 中 DPI 与图像尺寸的关系
TWAIN 把扫描分辨率表达成 ICAP_XRESOLUTION / ICAP_YRESOLUTION(FIX32,单位由 ICAP_UNITS 决定)。本项目固定 ICAP_UNITS = TWUN_INCHES,所以这两个能力的值就是 DPI。
应用看到的图像像素尺寸由三件事共同决定:
- 扫描区域大小(英寸):本项目用
ICAP_SUPPORTEDSIZES表达,UI 选择 A4 / Letter / Custom 之后会映射成CustomPageSize(宽高,英寸)。 - 水平和垂直 DPI:
ICAP_XRESOLUTION/ICAP_YRESOLUTION。 - 像素格式(BW / GRAY / RGB)只影响每像素位数,不影响宽高。
因此 TW_IMAGEINFO.ImageWidth = round(page_width_inch × XResolution),ImageLength = round(page_height_inch × YResolution)。所有 DS 必须保证返回的图像像素数和这个公式一致,否则应用拼版、缩放、OCR 都会失准。
2.2 FreeImage 重采样
FreeImage 提供 FIBITMAP* FreeImage_Rescale(FIBITMAP*, int dst_w, int dst_h, FREE_IMAGE_FILTER filter):
- 支持
FILTER_BOX/FILTER_BILINEAR/FILTER_BICUBIC/FILTER_LANCZOS3等滤波器。 - 输入输出像素格式一致:传入 24-bit BGR 返回 24-bit BGR,传入 8-bit 灰度返回 8-bit 灰度。
- 返回新 FIBITMAP,必须负责释放旧的。
对扫描类内容(文档、照片混合)一般选 FILTER_LANCZOS3 或 FILTER_BICUBIC 比较稳:LANCZOS3 锐度高、细节好;BICUBIC 平滑、不易振铃。本项目选 FILTER_LANCZOS3 作为默认。
2.3 像素格式转换与 DPI 的顺序
像素格式转换(24-bit RGB → 8-bit 灰度 / 1-bit 黑白)通常应该在最终目标分辨率上做,不要在原始分辨率上转完再重采样:
- 1-bit 黑白做完
FreeImage_Threshold后再FreeImage_Rescale会引入大量灰度像素,与 1-bit 不兼容,需要再重新二值化。 - 8-bit 灰度先做
FreeImage_Rescale不会出现颜色异常,但灰度阈值后处理会更脆弱。
合理的顺序:
源 RGB → 24-bit BGR → Rescale 到目标 (w,h) → 按 pixel type 转 (BW / GRAY / RGB) → 写 DPI 元数据
2.4 页面尺寸缩放模式
源图像和"纸张尺寸 × DPI"算出的目标像素宽高比例不一致时,需要决定如何填充:
- Stretch:直接非等比缩放到目标 (w,h),简单,但形变。
- Fit:保持宽高比缩到能放进目标,剩余区域用背景色填充(虚拟扫描仪用白色,模拟纸张)。
- Fill:保持宽高比放大到铺满目标,超出部分裁掉。
这三种由 ScannerSettings.page_fill_mode 控制。无论哪种模式,最终交给 TWAIN 的图像尺寸都必须是 page_width_inch × DPI 和 page_height_inch × DPI。
2.5 DPI 元数据与像素 DPI 必须一致
实现"按 DPI 出图"和"在文件里写 DPI 字段"是两件事,但二者必须一致:
- 像素维度按 DPI 重采样 → 决定
TW_IMAGEINFO、DIB 宽高、文件像素宽高。 - 元数据写 DPI → 决定 Windows Explorer / Photoshop 显示的物理分辨率。
如果只改元数据不重采样,Photoshop 会显示"图像 3 × 4 英寸 @ 600DPI"但实际像素只有 1800×2400 显示对,1024×768 显示就会是 1.7 × 1.3 英寸 @ 600DPI。两边必须由 DS 同步保证。
3. 设计
3.1 数据流概览
[App or UI sets DPI]
│
▼
TwainDataSource::handleDatCapability (ICAP_XRESOLUTION / YRESOLUTION SET)
│ 写入 caps_ 容器
▼
TwainDataSource::updateScannerFromCaps()
│ 从 caps_ 读 DPI / page size / pixel type
│ 填充 ScannerSettings { x_resolution, y_resolution,
│ page_size, pixel_type, ... }
▼
VirtualScanner::preScanPrep(ScannerSettings)
│ 1. acquireImage() 从 images/ 加载或回退 logo
│ 2. ensure24BitDib() 统一到 24-bit BGR
│ 3. applyPageSizeScaling() FreeImage_Rescale 到
│ (page_w × DPI, page_h × DPI)
│ 4. applyPixelFormat() Rescale 之后按 pixel_type 转
│ 5. applyDpiMetadata() 写 FIBITMAP 的 DPI 元数据
▼
current_fibitmap_ (已是目标 DPI 的目标格式)
│
├──► Native Transfer: getDibImage() → BITMAPINFOHEADER.biXPelsPerMeter
│ 按 DPI 算(dpi × 39.37)
│ 行数据按已缩放后的宽高输出
│
└──► File Transfer: saveImageToFile() → FreeImage_Save
patchSavedDpiMetadata()
3.2 ScannerSettings 字段
virtual_scanner.h 中 ScannerSettings 至少携带:
struct ScannerSettings {
TW_UINT16 pixel_type; // TWPT_BW / TWPT_GRAY / TWPT_RGB
double x_resolution; // DPI, 默认 300
double y_resolution; // DPI, 默认 300
PageSize page_size; // A4 / Letter / Custom (英寸)
PageFillMode page_fill_mode; // Stretch / Fit / Fill
// ...
};
x_resolution / y_resolution 直接以 DPI 表示(不是 FIX32),从 caps 读出的 FIX32 转 double 之后写入。
3.3 capability 层
capability.cpp 注册 ICAP_XRESOLUTION / ICAP_YRESOLUTION 为 ENUMERATION 容器:
- 类型
TW_FIX32 - 默认
300 - 可选
{150, 200, 300, 600} - 支持
GET / GETCURRENT / GETDEFAULT / SET / RESET全套操作
MSG_SET 时校验是否在可选集合内,不在则返回 TWRC_FAILURE / TWCC_BADVALUE,避免应用传入不支持的 DPI。
3.4 settings UI 行为
settings_server.cpp 在 HTML 中输出 DPI 下拉框:
<select name="dpi">
<option value="150">150</option>
<option value="200">200</option>
<option value="300" selected>300</option>
<option value="600">600</option>
</select>
用户点 Scan 提交表单后,服务端把 dpi 写回 caps_(同时设 ICAP_XRESOLUTION 和 ICAP_YRESOLUTION),然后 updateScannerFromCaps() 推到 ScannerSettings。
3.5 重采样实现
void VirtualScanner::applyPageSizeScaling(const ScannerSettings& s) {
if (!current_fibitmap_) return;
int src_w = FreeImage_GetWidth(current_fibitmap_);
int src_h = FreeImage_GetHeight(current_fibitmap_);
double page_w_inch = s.page_size.width_inch;
double page_h_inch = s.page_size.height_inch;
int dst_w = static_cast<int>(std::round(page_w_inch * s.x_resolution));
int dst_h = static_cast<int>(std::round(page_h_inch * s.y_resolution));
if (dst_w <= 0 || dst_h <= 0) return;
FIBITMAP* dst = nullptr;
switch (s.page_fill_mode) {
case PageFillMode::Stretch:
dst = FreeImage_Rescale(current_fibitmap_, dst_w, dst_h, FILTER_LANCZOS3);
break;
case PageFillMode::Fit:
dst = RescaleFit(current_fibitmap_, dst_w, dst_h); // 等比 + 白边
break;
case PageFillMode::Fill:
dst = RescaleFill(current_fibitmap_, dst_w, dst_h); // 等比 + 居中裁切
break;
}
if (dst) {
FreeImage_Unload(current_fibitmap_);
current_fibitmap_ = dst;
}
}
Fit 用 FreeImage_Allocate(dst_w, dst_h, 24) 填白色背景,再算等比缩放后的子图,FreeImage_Paste 到中央。
Fill 用 FreeImage_Rescale 到等比超出尺寸,再 FreeImage_Copy 居中裁剪到 (dst_w, dst_h)。
3.6 元数据写入
重采样完成后调用:
FreeImage_SetDotsPerMeterX(current_fibitmap_,
static_cast<unsigned>(s.x_resolution * 39.37));
FreeImage_SetDotsPerMeterY(current_fibitmap_,
static_cast<unsigned>(s.y_resolution * 39.37));
这样:
- DIB 复制时按
biXPelsPerMeter = FreeImage_GetDotsPerMeterX(...)写出。 FreeImage_Save写 PNG / TIFF / BMP 时也会以此为基础写元数据。- 文件层 patcher (见
file_dpi_design.md) 在 FreeImage 写完后再按 settings 中的 DPI 强制覆写关键字段。
3.7 Native Transfer 的 DPI 报告
twain_data_source.cpp::getImageInfo():
info.XResolution = floatToFix32(settings_.x_resolution);
info.YResolution = floatToFix32(settings_.y_resolution);
info.ImageWidth = FreeImage_GetWidth(current_fibitmap_);
info.ImageLength = FreeImage_GetHeight(current_fibitmap_);
DIB 头:
bih.biXPelsPerMeter = static_cast<LONG>(settings_.x_resolution * 39.37);
bih.biYPelsPerMeter = static_cast<LONG>(settings_.y_resolution * 39.37);
3.8 File Transfer 的 DPI 报告
saveImageToFile() 在 FreeImage_Save 之前已经按 settings 写好了 DPI 元数据。保存完成再调用 patchSavedDpiMetadata(path, x_dpi, y_dpi)(详见 file_dpi_design.md),保证:
- PNG
pHYs - JFIF APP0 density
- BMP
biXPelsPerMeter/biYPelsPerMeter - TIFF
XResolution/YResolution/ResolutionUnit
四种格式都以 ScannerSettings.x_resolution / y_resolution 为准。
4. 主要设计决策与原因
4.1 真正按 DPI 重采样,而不是只改元数据
- 决策:在
applyPageSizeScaling中按(page_w_inch × DPI, page_h_inch × DPI)重采样像素。 - 原因:扫描应用(XnView 的"扫描到 PDF"、NAPS2、专业扫描中间件)会按
TW_IMAGEINFO.ImageWidth / ImageLength排版,如果只改元数据,所有像素仍然是源尺寸,应用会显示 "300 DPI A4" 却只有缩略图大小的像素,OCR 也会拿不到足够分辨率。
4.2 DPI 可选值固定枚举 (150 / 200 / 300 / 600)
- 决策:
ICAP_XRESOLUTION/YRESOLUTION用ENUMERATION而非RANGE。 - 原因:测试矩阵明确;UI 下拉框简单;避免应用传入不合理 DPI(如 75 或 4800)导致 600 DPI A4 重采样到 19840×28057 的极端 case。后续若需要可加进枚举。
4.3 重采样滤波器选 FILTER_LANCZOS3
- 决策:
FreeImage_Rescale统一使用FILTER_LANCZOS3。 - 原因:文档类内容需要锐度,纯
BILINEAR太糊;BICUBIC折中但小字会发虚;LANCZOS3是 FreeImage 内支持的最高质量滤波,性能在虚拟扫描仪场景完全够用(每次只处理一张)。
4.4 重采样发生在像素格式转换之前
- 决策:先
Rescale,再按pixel_type转 BW / GRAY / RGB。 - 原因:见 §2.3,先量化再缩放会破坏 1-bit / 8-bit 阈值,得到的图像质量差。统一在 24-bit BGR 域做缩放,再一次性二值化或灰度化。
4.5 缩放和元数据由 VirtualScanner 完成,DS 只负责打包
- 决策:
TwainDataSource不直接调 FreeImage,只在preScanPrep时把ScannerSettings交给VirtualScanner,从current_fibitmap_取最终图像。 - 原因:单一职责。DS 关注 TWAIN 协议、状态机、DSM;VirtualScanner 关注像素和文件。这样 Native / File Transfer 共用同一份"按 DPI 缩放好的 FIBITMAP",避免两条路径各自缩放产生不一致。
4.6 page_w_inch × DPI 用 round 而不是 floor
- 决策:用
std::round。 - 原因:避免 A4@600 这种宽度 4960.629... 被
floor截到 4960 后 4960/600 = 8.2666... 英寸,导致元数据 DPI 与像素尺寸算回来的页面尺寸不严格相等。round让宽高更接近名义值。
4.7 三种 page_fill_mode 而不是固定一种
- 决策:暴露 Stretch / Fit / Fill。
- 原因:测试场景多样:开发者验证 OCR 需要 Stretch(不挑像素),UI 测试需要 Fit(保留原图比例 + 白边模拟真纸),破坏性测试需要 Fill(看应用如何处理出血裁切)。三种都简单实现,没必要削减到一种。
4.8 缩放和元数据一次性、扫描前完成
- 决策:在
preScanPrep一次性把current_fibitmap_准备成最终状态;strip / 行复制阶段不再触碰图像。 - 原因:Native Transfer 的 strip 循环会多次回调
getScanStrip,如果在 strip 阶段才缩放,性能差且会重复缩放。提前完成也让getImageInfo一次给出真实宽高。
5. 架构各组件改动点
5.1 src/capability.cpp
- 注册
ICAP_XRESOLUTION/ICAP_YRESOLUTION为 ENUMERATION,默认 300,枚举{150,200,300,600}。 MSG_SET时校验 FIX32 是否在集合内,不在则TWCC_BADVALUE。- 与
ICAP_UNITS = TWUN_INCHES联动,确保单位语义稳定。
5.2 src/twain_data_source.cpp
updateScannerFromCaps()把ICAP_XRESOLUTION/YRESOLUTION的 FIX32 转 double,写入ScannerSettings.x_resolution / y_resolution。handleDatImageInfo()用settings_.x_resolution / y_resolution填TW_IMAGEINFO.XResolution / YResolution,宽高用FreeImage_GetWidth / GetHeight(已经是缩放后的目标值)。allocAndFillDibHeader()用settings_.x_resolution * 39.37填biXPelsPerMeter / biYPelsPerMeter。enableDs()在用户提交 UI 后调用updateScannerFromCaps()+virtual_scanner_.preScanPrep(settings_)。
5.3 src/virtual_scanner.h/.cpp
- 增加
applyPageSizeScaling(const ScannerSettings&):按 (page_size, x/y_resolution, page_fill_mode) 调FreeImage_Rescale/ 自实现RescaleFit/RescaleFill。 - 增加
applyDpiMetadata(const ScannerSettings&):调FreeImage_SetDotsPerMeterX/Y。 - 重构
preScanPrep():acquireImage → ensure24BitDib → applyPageSizeScaling → applyPixelFormat → applyDpiMetadata,保证顺序固定。 saveImageToFile()不再二次缩放,只FreeImage_Save+patchSavedDpiMetadata。
5.4 src/settings_server.cpp
- DPI 下拉框:150 / 200 / 300 / 600,默认 300,从
caps_当前值同步选中。 - 提交时把 dpi 同时写
ICAP_XRESOLUTION和ICAP_YRESOLUTION。 - i18n:DPI 标签使用
dpi_label等本地化字符串。
5.5 测试影响
- 测试用例需要覆盖 4 种 DPI × 3 种 pixel type × 2 种 transfer mode × 至少 2 种 page size 的组合矩阵中的关键样本。
- 输出文件用脚本验证:像素宽高 =
round(page_w * dpi)、round(page_h * dpi);DPI 字段与 settings 一致。
6. 限制
- 当前只支持 150 / 200 / 300 / 600,应用如果传其他值会被拒绝 (
TWCC_BADVALUE)。 - 上采样 (低分辨率源 → 高 DPI 大图) 会显著放大模糊;建议
images/目录放高分辨率原图。 LANCZOS3滤波器在极端尺寸(如 9920×14040 600DPI A3)下内存和 CPU 占用明显,单次扫描可能耗时 1~2 秒;目前没有并行优化。page_size当前只支持几种标准纸张和 Custom,不支持运行时任意像素裁切框 (ICAP_FRAMES)。x_resolution与y_resolution在 UI 中只暴露同一档;通过 caps 单独设非对称 DPI 是合法的,但 UI 不会显示这一点。- DPI 改变只触发图像层重采样,不重新加载源文件;如果中途换源图,需要重新进入扫描流程。
- 极端组合(如 Custom 0.5×0.5 英寸 @ 150 DPI = 75×75 像素)应用拿到的图像会非常小,目前不做最小尺寸限制。
7. 下一步工作
- 把 DPI 可选集扩展到
{75, 100, 150, 200, 300, 400, 600, 1200}或改为 RANGE,方便压力测试。 - 在
VirtualScanner加缓存:相同(source_image, page_size, DPI, fill_mode)命中时直接复用上一次current_fibitmap_。 - 加单元测试 / 集成脚本:扫描后自动用 Python PIL 验证像素宽高和 DPI 元数据。
- 支持
ICAP_FRAMES,让应用按英寸坐标自定义裁切框,与 DPI 共同决定输出像素。 - 实现 X / Y 不对称 DPI 的真实路径(已在数据结构里支持,需在 UI 暴露 "advanced" 开关)。
- 评估更快的重采样路径(如把 Lanczos 改为多线程或在小缩放比时回退到 Bicubic)。
- 增加 ADF + 多页场景下,每页的 DPI 一致性测试。