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
156 changes: 150 additions & 6 deletions mreader/MangaTextSegmenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,14 @@ nonisolated enum MangaTextSegmenter {
from lines: [TextBlock],
isRightToLeft: Bool
) -> [TextBlock] {
var regions: [DetectedBubbleRegion] = []
// First recover a physical balloon collectively when several OCR rows
// carry the same visual bubble geometry. Per-row validation is deliberately
// stricter and can reject a real balloon when one row is only a short word.
let sharedSeed = sharedReliableBubbleRegions(from: lines)
var regions = sharedSeed.regions
var unassignedLines: [TextBlock] = []

for line in lines {
for line in lines where !sharedSeed.assignedLineIDs.contains(line.id) {
guard let geometry = reliableBubbleGeometry(for: line) else {
unassignedLines.append(line)
continue
Expand Down Expand Up @@ -226,8 +230,12 @@ nonisolated enum MangaTextSegmenter {
guard !isVertical(previous) else { return false }
guard paragraphStylesAreCompatible(previous, current),
previous.textOrientation == current.textOrientation,
followsReadingOrder(previous, current, isRightToLeft: isRightToLeft),
projectionsAreAligned(previous, current) else {
followsReadingOrder(previous, current, isRightToLeft: isRightToLeft) else {
return false
}

let sharesSafeRegion = measuredSafeRegionsRepresentSameParagraph(previous, current)
guard sharesSafeRegion || projectionsAreAligned(previous, current) else {
return false
}

Expand All @@ -252,7 +260,9 @@ nonisolated enum MangaTextSegmenter {
// slightly wider continuation window handles a third line whose
// spacing differs from the first pair, while 1.5x remains a useful
// guard against merging adjacent independent bubbles.
let maximumGap = max(smallerScale * 1.20, 0.006)
let maximumGap = sharesSafeRegion
? max(smallerScale * 1.65, 0.010)
: max(smallerScale * 1.20, 0.006)
guard lineGap <= maximumGap else { return false }

let union = currentGroup.dropFirst().reduce(currentGroup[0].boundingBox) {
Expand Down Expand Up @@ -326,6 +336,129 @@ nonisolated enum MangaTextSegmenter {
)
}

private static func sharedReliableBubbleRegions(
from lines: [TextBlock]
) -> (regions: [DetectedBubbleRegion], assignedLineIDs: Set<UUID>) {
var clusters: [DetectedBubbleRegion] = []
for line in lines {
guard let bubble = line.bubbleBox,
bubble.width > 0,
bubble.height > 0 else {
continue
}
if let index = clusters.firstIndex(where: {
canonicalBubbleBoxesRepresentSame($0.rect, bubble)
}) {
clusters[index].lines.append(line)
if clusters[index].rect != bubble {
clusters[index].rect = clusters[index].rect.union(bubble)
}
if clusters[index].polygon.isEmpty, !line.bubblePolygon.isEmpty {
clusters[index].polygon = line.bubblePolygon
}
} else {
clusters.append(
DetectedBubbleRegion(
id: line.id,
rect: bubble,
polygon: line.bubblePolygon,
lines: [line]
)
)
}
}

let reliable = clusters.filter {
$0.lines.count >= 2 && collectivelyReliableBubbleGeometry(
$0.rect,
lines: $0.lines
)
}
return (
reliable,
Set(reliable.flatMap { $0.lines.map(\.id) })
)
}

/// Same geometric limits as the line-level validator, but evaluated against
/// the union of all rows that claim the same physical balloon. This is the
/// key distinction for short words/rows: the balloon is not compared with a
/// single tiny OCR fragment.
private static func collectivelyReliableBubbleGeometry(
_ rawBubble: CGRect,
lines: [TextBlock]
) -> Bool {
guard let first = lines.first else { return false }
let bubble = rawBubble.standardized
let textUnion = lines.dropFirst().reduce(first.boundingBox.standardized) {
$0.union($1.boundingBox.standardized)
}
guard bubble.width > 0,
bubble.height > 0,
bubble.minX >= 0,
bubble.minY >= 0,
bubble.maxX <= 1.02,
bubble.maxY <= 1.02,
textUnion.width > 0,
textUnion.height > 0 else {
return false
}

let toleranceX = max(0.004, textUnion.width * 0.10)
let toleranceY = max(0.004, textUnion.height * 0.10)
guard lines.allSatisfy({
bubble.insetBy(dx: -toleranceX, dy: -toleranceY)
.contains($0.boundingBox.standardized)
}) else {
return false
}

let bubbleArea = rectArea(bubble)
let textArea = max(rectArea(textUnion), 0.000_001)
let widthExpansion = bubble.width / max(textUnion.width, 0.001)
let heightExpansion = bubble.height / max(textUnion.height, 0.001)
let centerDistance = hypot(
bubble.midX - textUnion.midX,
bubble.midY - textUnion.midY
)
let bubbleDiagonal = max(hypot(bubble.width, bubble.height), 0.001)
return bubbleArea <= 0.30
&& bubbleArea / textArea <= 120
&& max(widthExpansion, heightExpansion) <= 12
&& centerDistance / bubbleDiagonal <= 0.48
}

private static func measuredSafeRegionsRepresentSameParagraph(
_ lhs: TextBlock,
_ rhs: TextBlock
) -> Bool {
guard let left = lhs.layoutSafeRegion?.standardized,
let right = rhs.layoutSafeRegion?.standardized,
left.width > 0,
left.height > 0,
right.width > 0,
right.height > 0 else {
return false
}

let intersection = left.intersection(right)
guard !intersection.isNull,
intersection.width > 0,
intersection.height > 0 else {
return false
}
let intersectionArea = rectArea(intersection)
let smallerArea = max(min(rectArea(left), rectArea(right)), 0.000_001)
guard intersectionArea / smallerArea >= 0.50 else { return false }

let centerDistance = hypot(left.midX - right.midX, left.midY - right.midY)
let smallerDiagonal = max(
min(hypot(left.width, left.height), hypot(right.width, right.height)),
0.001
)
return centerDistance / smallerDiagonal <= 0.65
}

/// Same-region matching is deliberately more tolerant than line-level
/// identity. A small geometry drift from two OCR/vision passes must not turn
/// one physical bubble into multiple translation requests. Large one-way
Expand Down Expand Up @@ -551,7 +684,18 @@ nonisolated enum MangaTextSegmenter {
from blocks: [TextBlock],
containing textBounds: CGRect
) -> (box: CGRect, polygon: [CGPoint])? {
OCRCandidateResolver.validatedBubbleGeometry(
// Preserve a bubble collectively established by multiple OCR fragments
// on the same source line. Otherwise line merging can erase the shared
// physical identity before canonical bubble grouping gets a chance to
// combine rows.
let shared = sharedReliableBubbleRegions(from: blocks)
if shared.regions.count == 1,
shared.assignedLineIDs.count == blocks.count,
let region = shared.regions.first {
return (region.rect, region.polygon)
}

return OCRCandidateResolver.validatedBubbleGeometry(
for: textBounds,
candidates: blocks
)
Expand Down
146 changes: 145 additions & 1 deletion mreader/MangaVisionOCRIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ nonisolated enum MangaVisionOCRGeometry {
)
guard !balloons.isEmpty || !textRegions.isEmpty else { return blocks }

// Validate balloon geometry against the union of all OCR rows provisionally
// assigned to the same model balloon. Comparing a full speech balloon with
// one tiny word/row is what caused legitimate balloons to be rejected as
// "too large" and split into many translation cards.
let groupedBalloonAssignments = groupedBalloonAssignments(
for: blocks,
balloons: balloons
)

return blocks.map { block in
var enriched = block

Expand Down Expand Up @@ -87,7 +96,8 @@ nonisolated enum MangaVisionOCRGeometry {
}

if enriched.layoutRole == .dialogue,
let balloon = bestBalloon(for: enriched.boundingBox, balloons: balloons) {
let balloon = groupedBalloonAssignments[enriched.id]
?? bestBalloon(for: enriched.boundingBox, balloons: balloons) {
enriched.bubbleBox = balloon.rect
if enriched.bubblePolygon.isEmpty {
enriched.bubblePolygon = balloon.polygon
Expand Down Expand Up @@ -131,6 +141,140 @@ nonisolated enum MangaVisionOCRGeometry {
bestBalloon(for: textRect, balloons: balloons)?.rect
}

private struct ProvisionalBalloonMatch {
let region: MangaVisionRegion
let score: CGFloat
}

/// Two-stage assignment for multi-row speech balloons:
/// 1. provisionally assign each dialogue row using containment/centre evidence only;
/// 2. validate the physical balloon against the union of all rows assigned to it.
///
/// This preserves the frame/panel rejection limits while avoiding a pathological
/// comparison between a complete balloon and a single very short OCR fragment.
private static func groupedBalloonAssignments(
for blocks: [TextBlock],
balloons: [MangaVisionRegion]
) -> [UUID: RegionCandidate] {
guard !balloons.isEmpty else { return [:] }

var provisional: [UUID: ProvisionalBalloonMatch] = [:]
for block in blocks where block.layoutRole == .dialogue && block.bubbleBox == nil {
if let match = provisionalBalloonMatch(
for: block.boundingBox,
balloons: balloons
) {
provisional[block.id] = match
}
}
guard !provisional.isEmpty else { return [:] }

let blockByID = Dictionary(uniqueKeysWithValues: blocks.map { ($0.id, $0) })
var idsByBalloon: [UUID: [UUID]] = [:]
for (blockID, match) in provisional {
idsByBalloon[match.region.id, default: []].append(blockID)
}

var result: [UUID: RegionCandidate] = [:]
for balloon in balloons {
guard let blockIDs = idsByBalloon[balloon.id], !blockIDs.isEmpty else { continue }
let assignedBlocks = blockIDs.compactMap { blockByID[$0] }
guard let first = assignedBlocks.first else { continue }
let textUnion = assignedBlocks.dropFirst().reduce(first.boundingBox) {
$0.union($1.boundingBox)
}
guard let candidate = validatedBalloonCandidate(
balloon,
forTextUnion: textUnion
) else {
continue
}
for blockID in blockIDs {
result[blockID] = candidate
}
}
return result
}

private static func provisionalBalloonMatch(
for rawTextRect: CGRect,
balloons: [MangaVisionRegion]
) -> ProvisionalBalloonMatch? {
let textRect = MangaPageCoordinateSpace.clampedNormalizedRect(rawTextRect.standardized)
guard textRect.width > 0, textRect.height > 0 else { return nil }
let toleranceX = max(0.004, textRect.width * 0.10)
let toleranceY = max(0.004, textRect.height * 0.10)
let center = CGPoint(x: textRect.midX, y: textRect.midY)

let matches = balloons.compactMap { balloon -> ProvisionalBalloonMatch? in
let rect = balloon.normalizedRect.standardized
guard rect.width > 0, rect.height > 0 else { return nil }
let containment = MangaPageCoordinateSpace.containment(of: textRect, in: rect)
let centerInside = rect.insetBy(dx: -toleranceX, dy: -toleranceY).contains(center)
guard containment >= 0.55 || (centerInside && containment >= 0.30) else { return nil }

let fitted = MangaPageCoordinateSpace.clampedNormalizedRect(rect.union(textRect))
let distance = hypot(fitted.midX - textRect.midX, fitted.midY - textRect.midY)
let diagonal = max(hypot(fitted.width, fitted.height), 0.001)
let normalizedDistance = distance / diagonal
guard normalizedDistance <= 0.42 else { return nil }

let score = containment * 4.0
- normalizedDistance * 0.9
- MangaPageCoordinateSpace.area(fitted) * 0.8
+ CGFloat(balloon.confidence) * 0.35
return ProvisionalBalloonMatch(region: balloon, score: score)
}.sorted { lhs, rhs in
if abs(lhs.score - rhs.score) > 0.000_1 { return lhs.score > rhs.score }
return MangaPageCoordinateSpace.area(lhs.region.normalizedRect)
< MangaPageCoordinateSpace.area(rhs.region.normalizedRect)
}

guard let best = matches.first else { return nil }
if matches.count > 1 {
let second = matches[1]
let overlap = MangaPageCoordinateSpace.intersectionOverUnion(
best.region.normalizedRect,
second.region.normalizedRect
)
if best.score - second.score < 0.08, overlap < 0.25 {
return nil
}
}
return best
}

private static func validatedBalloonCandidate(
_ balloon: MangaVisionRegion,
forTextUnion rawTextUnion: CGRect
) -> RegionCandidate? {
let textUnion = MangaPageCoordinateSpace.clampedNormalizedRect(rawTextUnion.standardized)
guard textUnion.width > 0, textUnion.height > 0 else { return nil }
let textArea = max(MangaPageCoordinateSpace.area(textUnion), 0.000_001)
let fitted = MangaPageCoordinateSpace.clampedNormalizedRect(
balloon.normalizedRect.standardized.union(textUnion)
)
let fittedArea = MangaPageCoordinateSpace.area(fitted)
let widthExpansion = fitted.width / max(textUnion.width, 0.001)
let heightExpansion = fitted.height / max(textUnion.height, 0.001)
guard fittedArea > 0,
fittedArea <= maximumBalloonPageArea,
fittedArea / textArea <= maximumBalloonToTextAreaRatio,
max(widthExpansion, heightExpansion) <= maximumBalloonAxisExpansion else {
return nil
}

let distance = hypot(fitted.midX - textUnion.midX, fitted.midY - textUnion.midY)
let diagonal = max(hypot(fitted.width, fitted.height), 0.001)
guard distance / diagonal <= 0.42 else { return nil }

return RegionCandidate(
rect: fitted,
score: CGFloat(balloon.confidence),
polygon: balloon.contour?.cgPoints ?? []
)
}

private static func bestBalloon(
for rawTextRect: CGRect,
balloons: [MangaVisionRegion]
Expand Down
Loading
Loading