diff --git a/mreader/MangaTextSegmenter.swift b/mreader/MangaTextSegmenter.swift index fe293a8e..595e770c 100644 --- a/mreader/MangaTextSegmenter.swift +++ b/mreader/MangaTextSegmenter.swift @@ -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 @@ -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 } @@ -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) { @@ -326,6 +336,129 @@ nonisolated enum MangaTextSegmenter { ) } + private static func sharedReliableBubbleRegions( + from lines: [TextBlock] + ) -> (regions: [DetectedBubbleRegion], assignedLineIDs: Set) { + 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 @@ -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 ) diff --git a/mreader/MangaVisionOCRIntegration.swift b/mreader/MangaVisionOCRIntegration.swift index 2acc54bf..15804227 100644 --- a/mreader/MangaVisionOCRIntegration.swift +++ b/mreader/MangaVisionOCRIntegration.swift @@ -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 @@ -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 @@ -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] diff --git a/mreader/OCRBubbleLayoutEngine.swift b/mreader/OCRBubbleLayoutEngine.swift index 5e35178f..a8717712 100644 --- a/mreader/OCRBubbleLayoutEngine.swift +++ b/mreader/OCRBubbleLayoutEngine.swift @@ -423,6 +423,67 @@ nonisolated enum OCRBubbleLayoutEngine { : clamped(capped, to: safeAllowed, margin: 0) } + /// Resolve the final text layout bounds without applying fallback-card caps + /// to a physical balloon that has already passed reliability validation. + /// + /// Reliable balloons may use a contour-aware safe region for typography, but + /// they must never be shrunk again by the generic measured-card limits. + static func resolvedTranslationLayoutBounds( + sourceRect: CGRect, + resolvedSafeRegion: CGRect?, + reliableBubbleBounds: CGRect?, + fallbackBounds: CGRect, + imageBounds: CGRect + ) -> CGRect { + if let reliableBubbleBounds { + if let resolvedSafeRegion { + let safe = resolvedSafeRegion.standardized.intersection(imageBounds.standardized) + if !safe.isNull, + safe.width > 0, + safe.height > 0, + safe.insetBy(dx: -1, dy: -1).contains(sourceRect.standardized) { + return safe + } + } + let clippedBubble = reliableBubbleBounds.standardized.intersection(imageBounds.standardized) + if !clippedBubble.isNull, clippedBubble.width > 0, clippedBubble.height > 0 { + return clippedBubble + } + } + + return boundedTranslationBounds( + around: sourceRect, + within: fallbackBounds, + imageBounds: imageBounds + ) + } + + /// A reliable physical bubble owns the visible surface rectangle even when + /// no contour polygon is available. Polygon availability changes only the + /// renderer shape, never the surface extent. + static func translationSurfaceRect( + reliableBubbleBounds: CGRect?, + measuredLayoutRect: CGRect + ) -> CGRect { + reliableBubbleBounds ?? measuredLayoutRect + } + + /// Collision avoidance may relocate measured-text cards. Their background + /// must follow the glyph rectangle by exactly the same delta. A detected + /// physical balloon, however, is page geometry and must stay anchored. + static func presentationSurfaceRect( + surfaceStyle: TranslationSurfaceStyle, + originalSurfaceRect: CGRect, + originalTextRect: CGRect, + presentationTextRect: CGRect + ) -> CGRect { + guard surfaceStyle == .measuredText else { return originalSurfaceRect } + return originalSurfaceRect.offsetBy( + dx: presentationTextRect.minX - originalTextRect.minX, + dy: presentationTextRect.minY - originalTextRect.minY + ) + } + /// Standalone translations may grow only by a small, finite padding around /// the original textBox. A long translation therefore causes the layout /// engine to reduce the font size instead of creating a large card over the diff --git a/mreader/ReaderView.swift b/mreader/ReaderView.swift index bfd61cf4..34f1fe93 100644 --- a/mreader/ReaderView.swift +++ b/mreader/ReaderView.swift @@ -6508,9 +6508,11 @@ struct LocalImageView: View { ) } let allowedBounds = resolvedSafeRegion ?? usableBubbleBounds ?? fallbackBounds - let layoutBounds = OCRBubbleLayoutEngine.boundedTranslationBounds( - around: textRect, - within: allowedBounds, + let layoutBounds = OCRBubbleLayoutEngine.resolvedTranslationLayoutBounds( + sourceRect: textRect, + resolvedSafeRegion: resolvedSafeRegion, + reliableBubbleBounds: usableBubbleBounds, + fallbackBounds: fallbackBounds, imageBounds: imageBounds ) // Geometry and presentation are independent. Both paths normally start from the @@ -6552,7 +6554,10 @@ struct LocalImageView: View { usableBubbleBounds: usableBubbleBounds, using: transform ) - let surfaceRect = bubbleSurface?.rect ?? choice.layout.rect + let surfaceRect = OCRBubbleLayoutEngine.translationSurfaceRect( + reliableBubbleBounds: usableBubbleBounds, + measuredLayoutRect: bubbleSurface?.rect ?? choice.layout.rect + ) let surfacePolygon = bubbleSurface?.polygon ?? [] #if DEBUG @@ -6590,17 +6595,23 @@ struct LocalImageView: View { usableBubbleBounds: CGRect?, using transform: OCRDisplayTransform ) -> (rect: CGRect, polygon: [CGPoint])? { - guard let surfaceRect = usableBubbleBounds, - block.bubblePolygon.count >= 3 else { + guard let surfaceRect = usableBubbleBounds else { return nil } + + // bubbleBox and bubblePolygon are independent evidence. A validated + // physical bubble must keep its real rectangle even when contour points + // are absent or unusable; the renderer then falls back to a rounded + // rectangle instead of collapsing the surface to the translated text box. let mapped = block.bubblePolygon.map { OCRCoordinateMapper.displayPoint( forNormalizedPagePoint: $0, using: transform ) }.filter { $0.x.isFinite && $0.y.isFinite } - guard mapped.count >= 3 else { return nil } + guard mapped.count >= 3 else { + return (surfaceRect, []) + } let local = mapped.map { CGPoint( @@ -6736,26 +6747,42 @@ struct LocalImageView: View { let movementBounds = boundedMovement.isNull || boundedMovement.width <= 0 || boundedMovement.height <= 0 ? transform.imageRect : boundedMovement - let rect = OCRBubbleLayoutEngine.nonOverlappingRect( - original, - anchor: CGPoint(x: mappedSourceRect.midX, y: mappedSourceRect.midY), - occupiedRects: occupiedRects, - // Reliable bubbles are now a hard movement boundary. Measured - // text keeps its local fallback region as the anchor boundary. - bounds: movementBounds, - margin: 0 + + // A detected physical bubble is the placement boundary itself: moving + // its text to another "free" slot detaches the translation from the + // balloon. Only measured-text cards participate in global collision + // avoidance. + let presentationRect: CGRect + if item.surfaceStyle == .detectedBubble { + presentationRect = original + } else { + presentationRect = OCRBubbleLayoutEngine.nonOverlappingRect( + original, + anchor: CGPoint(x: mappedSourceRect.midX, y: mappedSourceRect.midY), + occupiedRects: occupiedRects, + bounds: movementBounds, + margin: 0 + ) + } + let presentationSurfaceRect = OCRBubbleLayoutEngine.presentationSurfaceRect( + surfaceStyle: item.surfaceStyle, + originalSurfaceRect: item.surfaceRect, + originalTextRect: original, + presentationTextRect: presentationRect ) - // The expansion UI was removed. Collision handling must therefore - // never collapse a fitted translation into the old compact preview: - // that preview becomes an opaque material card with clipped/no text. - let presentationRect = rect let layoutStatus = item.layoutStatus - occupiedRects.append(presentationRect.insetBy(dx: -4, dy: -4)) + // Measured cards occupy their translated card. Physical balloons + // occupy their actual surface so later fallback cards do not get + // packed on top of an already translated speech balloon. + let occupied = item.surfaceStyle == .detectedBubble + ? presentationSurfaceRect + : presentationRect + occupiedRects.append(occupied.insetBy(dx: -4, dy: -4)) items.append(TranslationLayoutItem( blocks: item.blocks, rect: presentationRect, - surfaceRect: item.surfaceRect, + surfaceRect: presentationSurfaceRect, surfacePolygon: item.surfacePolygon, allowedBounds: item.allowedBounds, fontSize: item.fontSize, @@ -7648,7 +7675,7 @@ private struct TranslationLayoutItem: Identifiable { /// 字体与布局样式。命中缓存时直接复用上一次的结果。 private final class TranslationLayoutStore { /// 排版算法版本。算法语义变化时必须 +1,避免旧布局被复用。 - static let layoutRevision = 4 + static let layoutRevision = 5 struct Key: Equatable { let scope: String diff --git a/mreaderTests/MangaVisionLayerTests.swift b/mreaderTests/MangaVisionLayerTests.swift index 7a54c147..179b0256 100644 --- a/mreaderTests/MangaVisionLayerTests.swift +++ b/mreaderTests/MangaVisionLayerTests.swift @@ -62,6 +62,74 @@ struct MangaVisionLayerTests { #expect(segmentation.bubbles[0].bubbleBox == balloon.normalizedRect) } + @Test func groupedTinyOCRRowsKeepOnePhysicalBalloon() { + let balloon = region( + .balloon, + x: 0.20, + y: 0.20, + width: 0.50, + height: 0.24, + confidence: 0.93 + ) + let analysis = MangaPageAnalysis( + pageIdentifier: MangaPageIdentifier( + scope: "grouped-tiny-rows", + pageIndex: 0, + sourceFingerprint: "fixture" + ), + imageSize: CGSize(width: 1200, height: 1800), + panels: [], + texts: [], + balloons: [balloon], + faces: [], + bodies: [], + modelIdentifier: "fixture", + modelVersion: 4 + ) + let rows = [ + TextBlock( + text: "MY", + boundingBox: CGRect(x: 0.39, y: 0.235, width: 0.055, height: 0.025), + confidence: 0.96, + ocrSource: "original:en", + estimatedFontScale: 0.025, + textOrientation: .horizontal + ), + TextBlock( + text: "DEAR", + boundingBox: CGRect(x: 0.36, y: 0.315, width: 0.090, height: 0.025), + confidence: 0.95, + ocrSource: "original:en", + estimatedFontScale: 0.025, + textOrientation: .horizontal + ), + TextBlock( + text: "MOTHER", + boundingBox: CGRect(x: 0.35, y: 0.395, width: 0.120, height: 0.025), + confidence: 0.95, + ocrSource: "original:en", + estimatedFontScale: 0.025, + textOrientation: .horizontal + ) + ] + + // Every row is much narrower than the balloon. The former per-row + // expansion guard rejected this geometry before segmentation. + #expect(balloon.normalizedRect.width / rows[0].boundingBox.width > 8) + + let enriched = MangaVisionOCRGeometry.applyingDetectedGeometry( + to: rows, + analysis: analysis + ) + #expect(enriched.allSatisfy { $0.bubbleBox == balloon.normalizedRect }) + + let segmentation = MangaTextSegmenter.segment(enriched, isRightToLeft: false) + #expect(segmentation.lines.count == 3) + #expect(segmentation.bubbles.count == 1) + #expect(segmentation.bubbles[0].sourceLineCount == 3) + #expect(segmentation.bubbles[0].bubbleBox == balloon.normalizedRect) + } + @Test func mangaBalloonContourFlowsIntoTranslationUnitAndSafeRegion() { let contourPoints = [ CGPoint(x: 0.50, y: 0.18), diff --git a/mreaderTests/TranslationSurfacePolicyTests.swift b/mreaderTests/TranslationSurfacePolicyTests.swift index f4719994..9305848b 100644 --- a/mreaderTests/TranslationSurfacePolicyTests.swift +++ b/mreaderTests/TranslationSurfacePolicyTests.swift @@ -159,6 +159,69 @@ struct TranslationSurfacePolicyTests { ) } + @Test func reliableBubbleLayoutBoundsAreNotShrunkByFallbackCardCaps() { + let sourceRect = CGRect(x: 155, y: 300, width: 50, height: 30) + let bubble = CGRect(x: 95, y: 210, width: 200, height: 260) + let fallback = CGRect(x: 125, y: 270, width: 110, height: 90) + + let resolved = OCRBubbleLayoutEngine.resolvedTranslationLayoutBounds( + sourceRect: sourceRect, + resolvedSafeRegion: nil, + reliableBubbleBounds: bubble, + fallbackBounds: fallback, + imageBounds: Self.imageBounds + ) + + #expect(resolved == bubble) + #expect( + resolved.width * resolved.height + > Self.imageBounds.width * Self.imageBounds.height + * TranslationLayoutMetrics.maximumCardAreaFraction + ) + } + + @Test func reliableBubbleRectWinsEvenWithoutContourGeometry() { + let bubble = CGRect(x: 92, y: 180, width: 180, height: 130) + let measuredTextRect = CGRect(x: 130, y: 220, width: 90, height: 48) + + let surface = OCRBubbleLayoutEngine.translationSurfaceRect( + reliableBubbleBounds: bubble, + measuredLayoutRect: measuredTextRect + ) + + #expect(surface == bubble) + } + + @Test func measuredCardSurfaceMovesWithCollisionAdjustedText() { + let originalText = CGRect(x: 100, y: 200, width: 80, height: 44) + let originalSurface = CGRect(x: 96, y: 196, width: 88, height: 52) + let movedText = originalText.offsetBy(dx: 34, dy: -27) + + let movedSurface = OCRBubbleLayoutEngine.presentationSurfaceRect( + surfaceStyle: .measuredText, + originalSurfaceRect: originalSurface, + originalTextRect: originalText, + presentationTextRect: movedText + ) + + #expect(movedSurface == originalSurface.offsetBy(dx: 34, dy: -27)) + } + + @Test func physicalBubbleSurfaceNeverMovesWithCollisionAdjustedText() { + let originalText = CGRect(x: 100, y: 200, width: 80, height: 44) + let bubble = CGRect(x: 70, y: 160, width: 150, height: 120) + let movedText = originalText.offsetBy(dx: 45, dy: 60) + + let surface = OCRBubbleLayoutEngine.presentationSurfaceRect( + surfaceStyle: .detectedBubble, + originalSurfaceRect: bubble, + originalTextRect: originalText, + presentationTextRect: movedText + ) + + #expect(surface == bubble) + } + @Test func noBubblePathologicalTallOCRDoesNotEnlargeCard() { let sourceRect = CGRect(x: 150, y: 200, width: 90, height: 300) let normalSourceRect = CGRect(x: 185, y: 340, width: 30, height: 20) diff --git a/mreaderTests/VisualBubbleGroupingTests.swift b/mreaderTests/VisualBubbleGroupingTests.swift index 04fe6dec..93d7e29b 100644 --- a/mreaderTests/VisualBubbleGroupingTests.swift +++ b/mreaderTests/VisualBubbleGroupingTests.swift @@ -76,6 +76,31 @@ struct VisualBubbleGroupingTests { #expect(segmentation.bubbles[0].bubbleBox != nil) } + @Test func sameLineTinyFragmentsPreserveCollectiveBubbleIdentity() { + let bubble = CGRect(x: 0.20, y: 0.20, width: 0.50, height: 0.20) + let first = Self.block( + text: "MY", + boundingBox: CGRect(x: 0.35, y: 0.25, width: 0.05, height: 0.03), + bubbleBox: bubble, + estimatedFontScale: 0.03 + ) + let second = Self.block( + text: "DEAR", + boundingBox: CGRect(x: 0.41, y: 0.25, width: 0.06, height: 0.03), + bubbleBox: bubble, + estimatedFontScale: 0.03 + ) + + #expect(bubble.width / first.boundingBox.width > 8) + + let segmentation = MangaTextSegmenter.segment([first, second], isRightToLeft: false) + + #expect(segmentation.lines.count == 1) + #expect(segmentation.lines[0].bubbleBox == bubble) + #expect(segmentation.bubbles.count == 1) + #expect(segmentation.bubbles[0].bubbleBox == bubble) + } + // MARK: - 测试 2:三行同气泡(A-B 相邻、B-C 相邻、A-C 距离远) /// complete-link 要求新成员与组内所有成员配对满足关系;A↔C 的纯 OCR 几何 @@ -694,6 +719,40 @@ struct VisualBubbleGroupingTests { #expect(segmentation.bubbles[0].text == "WELL OF COURSE YOU MUST GO ALONE!") } + @Test func overlappingSafeRegionsAllowMeasuredParagraphAcrossSlightlyWiderGap() { + let safeRegion = CGRect(x: 0.20, y: 0.16, width: 0.50, height: 0.24) + let first = TextBlock( + text: "FIRST PART", + boundingBox: CGRect(x: 0.30, y: 0.20, width: 0.26, height: 0.04), + confidence: 0.9, + ocrSource: "vision", + estimatedFontScale: 0.04, + layoutSafeRegion: safeRegion, + textOrientation: .horizontal, + layoutRole: .dialogue + ) + let second = TextBlock( + text: "SECOND PART", + boundingBox: CGRect(x: 0.31, y: 0.30, width: 0.25, height: 0.04), + confidence: 0.9, + ocrSource: "vision", + estimatedFontScale: 0.04, + layoutSafeRegion: safeRegion, + textOrientation: .horizontal, + layoutRole: .dialogue + ) + + let gap = second.boundingBox.minY - first.boundingBox.maxY + #expect(gap > first.estimatedFontScale * 1.20) + #expect(gap < first.estimatedFontScale * 1.65) + + let segmentation = MangaTextSegmenter.segment([first, second], isRightToLeft: false) + + #expect(segmentation.bubbles.count == 1) + #expect(segmentation.bubbles[0].bubbleBox == nil) + #expect(segmentation.bubbles[0].sourceLineCount == 2) + } + /// Conservative measured-text grouping may rebuild a local paragraph, but it /// must never chain rows across a large fraction of the page into one giant /// translation request/surface when no reliable bubble was detected.