Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/comic-audit-remediation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 漫画审查修复与验收边界

基于主分支 `6f5b3a3568d02df897723a511bcbc8f849ec8fcc`。修改只依据该源码和确定性反例,不依据 PR 合并记录推断效果。

## 本分支的变更

| 报告项 | 实现 | 仍需验收的边界 |
|---|---|---|
| F01 分镜候选丢失 | panel 跨推理合并增加 0.72 面积相近约束,父框和真实孩子均保留;结构融合决定是否舍弃页容器 | 不新增模型权重,不给出未测的检测准确率 |
| F02 长页尺度 | 几何保留整页分析补充跨窗大框,另切 50% 重叠局部窗口再生成 1024 分析栅格;拒绝窗口内部裁断的候选;按页尺寸缩放分镜最小宽高/面积及数量预算;结果映回原页 | 窗口不是导航分镜。无可靠证据仍整页阅读;极大的跨窗分镜可能需模型补充 |
| F03 OCR 先缩后切 | 已知长页从原图裁 OCR 窗口后逐片准备输入,统一映回全页;普通页保持原路径;长页备用图按短边和像素预算解码 | ImageIO 裁图可能解码整张 backing store,不能宣称压缩格式都支持区域解码。原图超过 6400 万像素或当前内存余量不足、EXIF 非正向时明确使用有界备用路径 |
| F04 本地无轮廓 | OCR 后从框内浅色闭合连通域恢复保守轮廓;限制局部栅格为 256 边;拒绝开放、泄漏、明显非凸及歧义区域;已有有效轮廓优先 | 本地模型仍只有框,未伪造 mask 输出。彩色、透明、凹形、相连气泡不保证恢复,保留原框降级 |
| F05 文字越轮廓 | 从验证过的轮廓求带边缘距离余量的全内接矩形;测量和绘制继续共用 CoreText;显示时对云端/离线轮廓也重新求安全区 | 采用保守内接区,不声称任意凹形的逐行最优排字。黑/彩色只影响颜色 |
| F06 加载取消竞态 | 不再加入 cancelled 预加载;为任务加独立 ID,旧完成只清理自己的 entry/成本;视图加加载 ID、URL、retain 和 cancellation 提交检查 | 同步 ImageIO 已开始后不可强制中止;原全局解码 permits 保留,不能把取消当已释放容量 |
| F07 OCR 热缓存解码 | 用源尺寸头部及预处理后的预计尺寸查询依赖缓存;将高清准备、模型/OCR 都放入共享任务池;CBZ 内页身份关联归档修订,OCR 请求及视觉分析共用冻结源身份,并在返回/落盘前复核 | 远端数据和没有可读头部的归档仍可能需要读取压缩数据;未声称零 I/O |
| F08 CBZ 头部完整解压 | 找到尺寸后抛出专用终止结果中断 ZIP consumer;头部预算 1 MiB;真正完整页读取继续 CRC | 某些格式无法在预算内读到头部时返回未知尺寸 |
| F09 PDF 批量渲染 | 阅读入口只建立 `mreader-pdf` 页面描述;读取某页时才渲染;接入阅读、几何、OCR、视觉、离线来源及带源修订缓存键;每页渲染有像素上限 | 导入转换到图像目录的现有功能未改为懒加载;PDF 仍只读,复杂 PDF 每页渲染需实测 |
| F10 字号承诺冲突 | 五种语言改成偏好字号,明确还会缩小;不可容纳显示被动省略标记;辅助功能可读完整译文 | 未恢复已删除的放大镜/展开按钮,也不把 2pt 当可读性能证明 |
| F11 布局缓存键 | 加入偏好可读字号,提升布局版本 | 需设备确认改设置立即重排 |
| F12 离线安全区丢失 | 复制完整 TextBlock 后改局部 OCR 几何,保留安全区和其余独立字段 | 匹配质量仍由现有文本/位置匹配器决定 |
| F13 校准说明 | 改为当前 V2B5 跨推理合并名称,说明 raw decoder 与 merge calibration 区别及五类输出;提升 merge revision | 原 adaptive manifest **已经**含 calibration revision,没有修改不存在的遗漏 |
| F14 上传认证顺序 | worker 在 header 阶段验证 POST token/路径和长度上限,创建 body 临时文件前拒绝;正确请求仍使用原 parser | 最大连接数/单次上传上限保持现有配置 |
| F15 质量证据不足 | 增加原生反例和真实 Reader 长页合成 UI 场景,记录 rapid reverse scroll 的内存/hitch telemetry | 合成图不是人工真实页面标注,不替代 Python/Swift 实图 parity、人工 gold、真机门禁,不能填入假通过结果 |
| F16 AI 响应上限 | 使用已有 chunk-based 有界 HTTP reader,JSON 响应上限 8 MiB,保留可注入 session configuration 和现有错误/重试策略 | 未改变各模型原生/兼容协议 |

另外,条漫 fitWidth 使用 CATiledLayer 的按需 compositor tiles,避免将整条图作为一个巨大显示纹理;**解码源仍是已有分档、有界整页图像**,不是磁盘区域解码引擎。内存余量较小时不再由固定缓存 floor 维持高于余量的缓存预算。私有权重 materialize 使用当前实际权重的 SHA-256 进行校验,不能只凭文件大小接受不匹配权重。

## 本地可执行检查

当前工作环境没有 Xcode、iOS 模拟器或 Core ML 原生运行时。只运行并核验:

- `git diff --check`。
- shell 语法检查和使用当前已物化权重的 materialize 脚本。
- Swift tree-sitter 语法检查,与主分支诊断对照;它不是 Swift 类型检查或原生测试。
- 图和权重哈希分别为 `d60dc41d370ce20805e4f6e8e83e49cd6057e27ae0affb095ca1d243148ebaa5`、`8e708d893f752b14a42b9d478a8256a525a750121d4abe4446e019e61fdbec80`;两者内容未改。

原生 build-for-testing、Release、全部单测、UI smoke 由本次 PR 的 CI 执行;必须看该提交结果,不借用主分支绿灯。

## 补充的原生回归

`ComicAuditFixTests` 覆盖父子框两个置信度顺序、inset 和近似重复、800×20000 / 800×40000 尺度、800×20000 真实几何栅格、椭圆/凹形内接区与全文 CoreText 可见范围、像素闭合气泡/开放降级、cancelled entry、离线字段保留、认证前 body 写入、懒 PDF 描述及原文件不变、极低余量预算、替换归档后的虚拟页缓存失效。AI transport 测试覆盖无 Content-Length 的超限响应。

UI 测试使用普通 ComicManager + Reader 生命周期,五张 800×20000 的带分镜、椭圆气泡和文本合成页,快速上下往返;记录 XCTest memory 和 hitch 指标,并检查 reader 存活/加载失败状态。指标不硬编码为真实设备 FPS 或准确率。

## 发布前仍须用真实素材和真机执行

1. 同一失败页比较原始候选、合并前后、过滤原因和最终导航,不把模型框自身当人工真值。
2. 至少 5–10 张不同真实原页的 Python/Swift parity;人工审核分镜与椭圆、带尾、凹形、彩色、透明、相连气泡 gold。待审核条目不能自动改为 humanVerified。
3. iPad mini 5 / 低内存支持设备及常用设备执行冷启动、长页页边界、快速反向、恢复到书中间、OCR 热缓存、开启翻译和热状态的 Instruments memory/hitch 记录。
4. 黑/彩字切换时比较字号、换行、几何完全一致;小气泡不能扩大白底盖画,放不下的全文须可辅助阅读。
5. 图像超过原图 admission budget、非正向 EXIF、未知头部、复杂/旋转 PDF 和云端返回异常几何的降级截图。

本分支不宣称真实分镜准确率、气泡 mask IoU、卡顿下降比例、峰值内存或被跳过的真机测试已通过。跨设备 iCloud sidecar 并发合并和全面架构拆分属于报告的额外风险,未借本次阅读链修复擅自迁移存储格式。
4 changes: 3 additions & 1 deletion mreader/AIModelProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,9 @@ nonisolated final class AITranslationClient: AITransporting, @unchecked Sendable
try Task.checkCancellation()
let startedAt = Date()
do {
let (data, response) = try await session.data(for: urlRequest)
let (data, response) = try await BoundedHTTPResponseReader.data(
for: urlRequest, maximumBytes: 8 * 1024 * 1024, using: session
)
guard let httpResponse = response as? HTTPURLResponse else {
throw AITranslationRequestError.invalidConfiguration("响应无效")
}
Expand Down
22 changes: 17 additions & 5 deletions mreader/AITranslator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ nonisolated enum TranslationLayoutRole: String, Codable, Sendable {
nonisolated struct TextBlock: Identifiable, Codable, Sendable {
let id: UUID
let text: String
let boundingBox: CGRect // 原图中的相对坐标 (0.0 ~ 1.0)
private(set) var boundingBox: CGRect // 原图中的相对坐标 (0.0 ~ 1.0)
var translation: String?
var confidence: Double
var ocrSource: String
Expand Down Expand Up @@ -207,6 +207,14 @@ nonisolated struct TextBlock: Identifiable, Codable, Sendable {
)
}

/// Geometry replacements copy the complete value, preserving independent
/// bubble/layout evidence and any fields subsequently added to this type.
nonisolated func replacingBoundingBox(_ rect: CGRect) -> TextBlock {
var copy = self
copy.boundingBox = rect
return copy
}

/// OCR 字号尺度始终取原文短边:横排文字对应 textBox 高度,竖排文字对应宽度。
/// 显示时必须用同一坐标轴的页面尺寸还原,不能统一乘图片短边。
nonisolated func sourceFontSize(in imageRect: CGRect) -> CGFloat {
Expand Down Expand Up @@ -3896,11 +3904,15 @@ static func visualReviewedBlockForDiagnostics(original: TextBlock, review: Visio
rect.maxY <= 1.02
}

nonisolated static func annotatedMangaTextBlocks(_ blocks: [TextBlock], safeAreaInset: Double, minimumTextHeight: Double, isRightToLeft: Bool) -> [TextBlock] {
nonisolated static func annotatedMangaTextBlocks(_ blocks: [TextBlock], safeAreaInset: Double, minimumTextHeight: Double, isRightToLeft: Bool, pageSize: CGSize? = nil) -> [TextBlock] {
let longPage = pageSize.map { max($0.width, $0.height) / max(min($0.width, $0.height), 1) >= 3 } ?? false
let xScale = longPage ? (pageSize.map { min(1, $0.height / max($0.width, 1)) } ?? 1) : 1
let yScale = longPage ? (pageSize.map { min(1, $0.width / max($0.height, 1)) } ?? 1) : 1
let inset = min(max(CGFloat(safeAreaInset), 0), 0.3)
let minimumHeight = min(max(CGFloat(minimumTextHeight), 0.002), 0.05)
let minimumArea = minimumHeight * 0.0048
let safeRect = CGRect(x: inset, y: inset, width: max(0, 1 - inset * 2), height: max(0, 1 - inset * 2))
let minimumHeight = min(max(CGFloat(minimumTextHeight), 0.002), 0.05) * yScale
let minimumArea = minimumHeight * 0.0048 * xScale
let safeRect = CGRect(x: inset * xScale, y: inset * yScale,
width: max(0, 1 - inset * xScale * 2), height: max(0, 1 - inset * yScale * 2))

return sortedTextBlocks(blocks.map { block in
var annotated = block
Expand Down
6 changes: 3 additions & 3 deletions mreader/Base.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,9 @@
"offlineTranslation.errorDetails" = "View error details";
"nav.close" = "Close";

"ocr.minimumReadableTranslationFontSize" = "最小可读译文字号";
"ocr.minimumReadableTranslationFontSizeValue" = "可读下限:%d pt";
"ocr.minimumReadableTranslationFontSizeDescription" = "低于此字号时不再继续缩小,而改为可展开阅读。";
"ocr.minimumReadableTranslationFontSize" = "偏好可读译文字号";
"ocr.minimumReadableTranslationFontSizeValue" = "偏好字号:%d pt";
"ocr.minimumReadableTranslationFontSizeDescription" = "气泡空间不足时仍会缩小以适配;完整译文可通过辅助功能朗读。";
"ocr.translationNeedsExpansion" = "译文需要展开阅读";
"ocr.inPlaceTranslation" = "In-place translation";
"ocr.inPlaceTranslationDescription" = "Use reversible in-place coverage only for reliable dialogue bubbles; sound effects keep their original artwork and use annotations.";
Expand Down
56 changes: 56 additions & 0 deletions mreader/BubbleContourGeometry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import CoreGraphics
import Foundation

/// A conservative inscribed rectangle, not the contour's bounding box. Every
/// accepted grid cell lies inside the path with a border-distance margin. This
/// also handles concave contours; testing only the four outer corners would not.
nonisolated enum BubbleContourGeometry {
static func safeRectangle(polygon: [CGPoint], bounds: CGRect) -> CGRect? {
guard polygon.count >= 3, polygon.count <= 512, bounds.width > 0, bounds.height > 0,
polygon.allSatisfy({ $0.x.isFinite && $0.y.isFinite }) else { return nil }
let points = polygon.map { CGPoint(x: ($0.x - bounds.minX) / bounds.width,
y: ($0.y - bounds.minY) / bounds.height) }
let path = CGMutablePath()
path.addLines(between: points)
path.closeSubpath()
let n = 40
let radius = sqrt(CGFloat(2)) / CGFloat(n * 2) + 0.025
var heights = [Int](repeating: 0, count: n)
var best = CGRect.null
var bestArea = 0
for y in 0..<n {
for x in 0..<n {
let center = CGPoint(x: (CGFloat(x) + 0.5) / CGFloat(n), y: (CGFloat(y) + 0.5) / CGFloat(n))
let inside = path.contains(center, using: .evenOdd)
&& points.indices.allSatisfy { i in
distance(center, to: points[i], points[(i + 1) % points.count]) >= radius
}
heights[x] = inside ? heights[x] + 1 : 0
}
// Largest all-safe rectangle ending in this row. At 40 columns this
// bounded search is cheaper and less error-prone than rasterizing page pixels.
for left in 0..<n {
var height = heights[left]
for right in left..<n {
height = min(height, heights[right])
let area = height * (right - left + 1)
if area > bestArea {
bestArea = area
best = CGRect(x: CGFloat(left) / CGFloat(n), y: CGFloat(y + 1 - height) / CGFloat(n),
width: CGFloat(right - left + 1) / CGFloat(n), height: CGFloat(height) / CGFloat(n))
}
}
}
}
guard bestArea >= 4 else { return nil }
return CGRect(x: bounds.minX + best.minX * bounds.width, y: bounds.minY + best.minY * bounds.height,
width: best.width * bounds.width, height: best.height * bounds.height)
}

private static func distance(_ p: CGPoint, to a: CGPoint, _ b: CGPoint) -> CGFloat {
let dx = b.x - a.x, dy = b.y - a.y
let length = dx * dx + dy * dy
let t = length > 0 ? min(1, max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length)) : 0
return hypot(p.x - a.x - t * dx, p.y - a.y - t * dy)
}
}
Loading
Loading