- 템플릿: 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>
130 lines
6.7 KiB
Swift
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"])
|
|
}
|