Files
wehub-resource-sync e904b667c6
Build/Publish Develop Docs / deploy (push) Failing after 1s
PaddleOCR Code Style Check / check-code-style (push) Failing after 1s
PaddleOCR PR Tests GPU / detect-changes (push) Failing after 1s
PaddleOCR PR Tests / detect-changes (push) Failing after 1s
PaddleOCR PR Tests GPU / test-pr-gpu (push) Has been cancelled
PaddleOCR PR Tests / test-pr (push) Has been cancelled
PaddleOCR PR Tests GPU / test-pr-gpu-impl (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.13) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.8) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.9) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:59:26 +08:00

251 lines
8.7 KiB
Swift

// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import CoreGraphics
// MARK: - Detection Result
/// A detected text region with its bounding quadrilateral and confidence score.
struct DetectionBox {
/// Four corner points of the bounding quadrilateral, each as [x, y].
/// Order: top-left, top-right, bottom-right, bottom-left (`getMiniBoxes` / min-area rect).
let points: [[Int32]]
/// Confidence score from `boxScoreFast` (mean probability in the box region).
let score: Float
}
// MARK: - DBPostProcessor
/// DB (Differentiable Binarization) text detection postprocessor.
///
/// Maps raw model output probability map to
/// bounding polygons (threshold → contours → min-area quads → score filter → unclip → scale).
///
/// Steps: threshold → contours → min-area quads → box score → polygon offset (unclip) → scale to original size.
///
struct DBPostProcessor {
/// Binary threshold for the probability map (pixels above this are foreground).
let thresh: Float
/// Minimum box confidence score to keep a detection.
let boxThresh: Float
/// Maximum number of contours to evaluate.
let maxCandidates: Int
/// Expansion ratio for polygon offset (unclip step).
let unclipRatio: Float
/// Minimum side length of a bounding box to be kept (pixels in probability map space).
let minSize: Float = 3.0
/// Score mode: only `"fast"` is implemented (`"slow"` is not supported).
let scoreMode: String = "fast"
// MARK: - Initializers
/// Initialize with explicit parameters.
init(thresh: Float = 0.3, boxThresh: Float = 0.6, maxCandidates: Int = 1000, unclipRatio: Float = 1.5) {
self.thresh = thresh
self.boxThresh = boxThresh
self.maxCandidates = maxCandidates
self.unclipRatio = unclipRatio
}
/// Initialize from a parsed ``PostProcessConfig`.
init(config: PostProcessConfig) {
self.thresh = config.thresh
self.boxThresh = config.boxThresh
self.maxCandidates = config.maxCandidates
self.unclipRatio = config.unclipRatio
}
// MARK: - Public API
/// Process raw ORT detection output into bounding polygons.
///
/// - Parameters:
/// - outputTensor: Raw float output from ONNX Runtime, shape [1, 1, H, W].
/// - tensorHeight: Height of the output tensor (H).
/// - tensorWidth: Width of the output tensor (W).
/// - originalWidth: Width of the original input image.
/// - originalHeight: Height of the original input image.
/// - Returns: Array of detected text boxes with confidence scores.
func process(
outputTensor: [Float],
tensorHeight: Int,
tensorWidth: Int,
originalWidth: Int,
originalHeight: Int
) -> [DetectionBox] {
// 1. Extract probability map: pred[0, 0, :, :] from shape [1, 1, H, W]
let mapSize = tensorHeight * tensorWidth
let pred: [Float]
if outputTensor.count >= mapSize {
pred = Array(outputTensor.prefix(mapSize))
} else {
return []
}
// 2. Binary threshold: create mask where prob > thresh
var binaryMask = [UInt8](repeating: 0, count: mapSize)
for i in 0..<mapSize {
binaryMask[i] = pred[i] > thresh ? 1 : 0
}
// 3. Find contours on the binary mask
let contours = findContours(
binaryMask: binaryMask,
width: tensorWidth,
height: tensorHeight
)
// 4. Process each contour
let numContours = min(contours.count, maxCandidates)
var results: [DetectionBox] = []
for i in 0..<numContours {
let contour = contours[i]
// 4a. Get minimum area bounding rectangle
guard let (miniBox, sside) = getMiniBoxes(contour: contour) else {
continue
}
if sside < minSize {
continue
}
// 4b. Score the box using fast mode
let points = miniBox.map { [$0.x, $0.y] }
let score = boxScoreFast(
pred: pred,
predWidth: tensorWidth,
predHeight: tensorHeight,
box: points
)
if boxThresh > score {
continue
}
// 4c. Expand polygon (unclip) via integer-grid offset.
let nsMini = miniBox.map { NSValue(cgPoint: CGPoint(x: CGFloat($0.x), y: CGFloat($0.y))) }
let area = Double(PDBOpenCVDBBridge.contourArea(fromPoints: nsMini))
let length = Double(PDBOpenCVDBBridge.arcLength(fromPoints: nsMini, closed: true))
guard length > 0 else { continue }
let distance = area * Double(unclipRatio) / length
let rings = PDBPolygonOffsetBridge.inflateClosedPolygon(with: nsMini, distance: distance) as [[NSValue]]
guard rings.count == 1 else {
continue
}
let firstRing = rings[0]
// 4d. Second getMiniBoxes on the expanded polygon
let expandedFloats = firstRing.map { FloatPoint(x: Float($0.cgPointValue.x), y: Float($0.cgPointValue.y)) }
guard let (finalBox, finalSside) = getMiniBoxes(contour: expandedFloats) else {
continue
}
if finalSside < minSize + 2 {
continue
}
// 4e. Scale to original image dimensions
let w = Float(tensorWidth)
let h = Float(tensorHeight)
let dw = Float(originalWidth)
let dh = Float(originalHeight)
var scaledPoints: [[Int32]] = []
for pt in finalBox {
let sx = Int32(min(max((pt.x / w * dw).rounded(), 0), dw))
let sy = Int32(min(max((pt.y / h * dh).rounded(), 0), dh))
scaledPoints.append([sx, sy])
}
results.append(DetectionBox(points: scaledPoints, score: score))
}
return results
}
}
// MARK: - Internal Point Type
/// Float 2D point for `DBPostProcessor` geometry helpers.
internal struct FloatPoint {
var x: Float
var y: Float
}
// MARK: - OpenCV (CocoaPods OpenCV 4.3.x)
extension DBPostProcessor {
/// Foreground pixels as 255 on an 8-bit mask; runs `findContours` with `RETR_LIST` and `CHAIN_APPROX_SIMPLE`.
func findContours(binaryMask: [UInt8], width: Int, height: Int) -> [[FloatPoint]] {
let data = Data(binaryMask)
let raw = PDBOpenCVDBBridge.findContours(
binaryMask: data,
width: width,
height: height,
maxCandidates: maxCandidates
)
return raw.map { contour in
contour.map { v in
let p = v.cgPointValue
return FloatPoint(x: Float(p.x), y: Float(p.y))
}
}
}
/// Minimum-area rectangle: `minAreaRect` on contour points, `boxPoints` for corners, then TL/TR/BR/BL ordering.
func getMiniBoxes(contour: [FloatPoint]) -> (box: [FloatPoint], minSide: Float)? {
guard contour.count >= 2 else { return nil }
let values = contour.map { NSValue(cgPoint: CGPoint(x: CGFloat($0.x), y: CGFloat($0.y))) }
guard let result = PDBOpenCVDBBridge.miniBox(fromPoints: values) else { return nil }
let corners = result.corners.map { v in
let p = v.cgPointValue
return FloatPoint(x: Float(p.x), y: Float(p.y))
}
return (corners, result.minSide)
}
}
// MARK: - Box Scoring
extension DBPostProcessor {
/// Compute the mean probability score within a box region of the probability map.
///
/// Mean probability inside the quad: `fillPoly` mask on the score map, then `mean` over the masked region.
func boxScoreFast(
pred: [Float],
predWidth: Int,
predHeight: Int,
box: [[Float]]
) -> Float {
let predData = pred.withUnsafeBufferPointer { Data(buffer: $0) }
let boxNS: [[NSNumber]] = box.map { [NSNumber(value: $0[0]), NSNumber(value: $0[1])] }
return PDBOpenCVImageBridge.meanPredInQuad(
predData,
width: predWidth,
height: predHeight,
box: boxNS
)
}
}