o2o-infinith-demo/supporters/scripts/ocr/vision_ocr.swift
Haewon Kam aa5b05227f feat(supporters): 회복 일정 플래너를 템플릿·워커에 통합 (/plan·/en/plan), /recovery·/stay 를 /plan 으로 정리
- 템플릿: Planner.astro, lib/plan.ts·tour.ts, styles/plan.css, planStrings, pages plan·en/plan·404. Base 내비 회복 일정 → /plan, 언어 짝 /plan↔/en/plan, 옛 주소 리다이렉트(vercel.json), 사이트맵
- 워커 planner 단계(recovery 다음): scripts/build_planner_data.mjs 가 업종별 기본 규칙표(scripts/template/planner/procedures.plastic|derm.json)에 병원 시술 페이지 원문(recoveryNotes)을 matchKeywords 로 붙이고, 장소는 briefs/<clinic>/planner.places.json(큐레이션) 또는 범용 기본표(관광공사 기준 좌표)로 만든다
- 브리프: viewclinic·oracle 큐레이션 장소. 빈 템플릿(관광 데이터 없음)도 빌드·검증 통과(plan.test 14건)
- 이전 세션의 미커밋 작업(피부과 수집·OCR·게이트·언어 스위치, stay 페이지 제거)도 이 커밋에 함께 들어감. docs/prd 변경은 제외

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 11:27:59 +09:00

130 lines
6.7 KiB
Swift

// 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] <image> [<image> ...]
//
// ( 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] <image> ...\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"])
}