// macOS Vision 프레임워크(VNRecognizeTextRequest)로 이미지 글자를 읽는 단일 파일 도구. // tesseract·pyobjc 가 없는 환경에서 결정론적 OCR 을 쓰기 위한 것. 유료 API 를 쓰지 않는다. // // swiftc -O -o vision_ocr vision_ocr.swift (ocr_evidence.mjs 가 소스가 바뀌면 다시 빌드한다) // ./vision_ocr [--min-width 400] [--langs ko-KR,en-US] [--tile-height 2000] [--upscale-below 1400] [ ...] // // 세로로 긴 이미지(의료진 소개 794x12240 같은 스크롤 이미지)는 Vision 이 통째로 축소해 한 줄도 못 읽는다. // --tile-height 픽셀 단위 띠로 잘라(겹침 10%) 띠마다 인식하고, 겹침 구간의 줄은 한 번만 남긴다. // 폭이 --upscale-below 미만이면 2배로 키워 인식한다(작은 글자 대응). box 좌표는 원본 기준 0~1 정규화. // // 출력: 이미지 한 장에 JSON 한 줄(stdout) // {"path":"...","width":W,"height":H,"lines":[{"text":"...","confidence":0.93,"box":[x,y,w,h]}],"engine":"apple-vision"} // 폭이 --min-width 미만이면 {"path":"...","width":W,"height":H,"skipped":"narrow"}. 읽기 실패는 {"path":"...","error":"..."}. // 줄 순서는 위에서 아래, 같은 줄 안에서는 왼쪽에서 오른쪽(Vision 의 정규화 좌표 y 는 아래가 0 이라 뒤집는다). // 글자는 Vision 이 준 것만 내보낸다. 후처리(병합·교정)는 하지 않는다. 그것은 호출 측(ocr_evidence.mjs) 몫이다. import Foundation import Vision import ImageIO import CoreGraphics var minWidth = 0 var tileHeight = 2000 var upscaleBelow = 1400 var langs = ["ko-KR", "en-US"] var paths: [String] = [] var it = CommandLine.arguments.dropFirst().makeIterator() while let a = it.next() { switch a { case "--min-width": if let v = it.next(), let n = Int(v) { minWidth = n } case "--langs": if let v = it.next() { langs = v.split(separator: ",").map { String($0) } } case "--tile-height": if let v = it.next(), let n = Int(v) { tileHeight = max(n, 400) } case "--upscale-below": if let v = it.next(), let n = Int(v) { upscaleBelow = n } default: paths.append(a) } } if paths.isEmpty { FileHandle.standardError.write("사용법: vision_ocr [--min-width N] [--langs ko-KR,en-US] ...\n".data(using: .utf8)!) exit(2) } func jsonLine(_ obj: [String: Any]) { if let d = try? JSONSerialization.data(withJSONObject: obj, options: [.withoutEscapingSlashes]), let s = String(data: d, encoding: .utf8) { print(s) fflush(stdout) } } func loadImage(_ path: String) -> CGImage? { let url = URL(fileURLWithPath: path) guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } return CGImageSourceCreateImageAtIndex(src, 0, nil) } struct Line { var text: String; var confidence: Double; var x: Double; var y: Double; var w: Double; var h: Double } // 픽셀 좌표(원본 기준) // 이미지 한 장(또는 띠 하나)을 인식해 원본 픽셀 좌표의 줄 목록으로. scale 은 확대 배율 func recognize(_ img: CGImage, offsetY: Int, scale: Int) throws -> [Line] { var target = img if scale > 1 { let w = img.width * scale, h = img.height * scale if let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) { ctx.interpolationQuality = .high ctx.draw(img, in: CGRect(x: 0, y: 0, width: w, height: h)) if let up = ctx.makeImage() { target = up } } } let request = VNRecognizeTextRequest() request.recognitionLevel = .accurate request.recognitionLanguages = langs request.usesLanguageCorrection = true let handler = VNImageRequestHandler(cgImage: target, options: [:]) try handler.perform([request]) let obs = (request.results ?? []) as [VNRecognizedTextObservation] let W = Double(img.width), H = Double(img.height) var out: [Line] = [] for o in obs { guard let top = o.topCandidates(1).first else { continue } let bb = o.boundingBox // 정규화, 원점 왼쪽 아래 out.append(Line(text: top.string, confidence: Double(top.confidence), x: bb.minX * W, y: (1 - bb.maxY) * H + Double(offsetY), w: bb.width * W, h: bb.height * H)) } return out } for path in paths { guard let img = loadImage(path) else { jsonLine(["path": path, "error": "이미지를 열 수 없음"]); continue } let w = img.width, h = img.height if w < minWidth { jsonLine(["path": path, "width": w, "height": h, "skipped": "narrow"]); continue } let scale = w < upscaleBelow ? 2 : 1 var lines: [Line] = [] var tiles = 0 do { if h <= tileHeight { lines = try recognize(img, offsetY: 0, scale: scale); tiles = 1 } else { let overlap = tileHeight / 10 var top = 0 while top < h { let th = min(tileHeight, h - top) guard let tile = img.cropping(to: CGRect(x: 0, y: top, width: w, height: th)) else { break } let got = try recognize(tile, offsetY: top, scale: scale) // 겹침 구간은 한가운데서 나눠 앞 띠는 그 앞까지, 뒷 띠는 그 뒤부터 받는다(같은 줄이 두 띠에 들어가지 않게). // 띠 가장자리에 걸려 반만 읽힌 줄은 중심이 경계 밖이라 버려지고, 다른 띠에서 온전히 읽힌 것만 남는다. 첫 띠는 처음부터, 마지막 띠는 끝까지 let isLast = top + th >= h let lo = top == 0 ? -1.0 : Double(top + overlap / 2) let hi = isLast ? Double.infinity : Double(top + th - overlap / 2) for l in got { let cy = l.y + l.h / 2; if cy >= lo && cy < hi { lines.append(l) } } tiles += 1 if top + th >= h { break } top += tileHeight - overlap } } } catch { jsonLine(["path": path, "width": w, "height": h, "error": "인식 실패: \(error.localizedDescription)"]); continue } // 위→아래, 같은 줄 안에서는 왼→오른쪽 lines.sort { a, b in let ay = a.y + a.h / 2, by = b.y + b.h / 2 if abs(ay - by) > max(a.h, b.h) * 0.6 { return ay < by } return a.x < b.x } let W = Double(w), H = Double(h) let r3 = { (v: Double) -> Double in (v * 1000).rounded() / 1000 } let out: [[String: Any]] = lines.map { l in [ "text": l.text, "confidence": r3(l.confidence), "box": [r3(l.x / W), r3(l.y / H), r3(l.w / W), r3(l.h / H)], ] } jsonLine(["path": path, "width": w, "height": h, "tiles": tiles, "scale": scale, "lines": out, "engine": "apple-vision"]) }