Spaces:
Sleeping
Sleeping
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import os
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import unicodedata
|
| 7 |
+
import tempfile
|
| 8 |
+
from difflib import SequenceMatcher
|
| 9 |
+
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
| 10 |
+
|
| 11 |
+
import cv2
|
| 12 |
+
import numpy as np
|
| 13 |
+
import gradio as gr
|
| 14 |
+
from google.cloud import vision
|
| 15 |
+
from google.oauth2 import service_account
|
| 16 |
+
from kospellpy import spell_init
|
| 17 |
+
|
| 18 |
+
# ──────────────────────────────── 환경 설정 ────────────────────────────────
|
| 19 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s')
|
| 20 |
+
FONT_PATH = "/usr/share/fonts/truetype/nanum/NanumGothic.ttf"
|
| 21 |
+
MIN_FONT_SIZE = 8
|
| 22 |
+
|
| 23 |
+
# GCP 서비스 계정 키 base64 → JSON 디코딩
|
| 24 |
+
def get_vision_client():
|
| 25 |
+
b64 = os.getenv("GCP_SERVICE_ACCOUNT_JSON")
|
| 26 |
+
if not b64:
|
| 27 |
+
logging.warning("GCP_SERVICE_ACCOUNT_JSON 환경변수가 설정되지 않았습니다. 기본 인증을 사용합니다.")
|
| 28 |
+
return vision.ImageAnnotatorClient()
|
| 29 |
+
try:
|
| 30 |
+
info = json.loads(base64.b64decode(b64).decode())
|
| 31 |
+
creds = service_account.Credentials.from_service_account_info(info)
|
| 32 |
+
return vision.ImageAnnotatorClient(credentials=creds)
|
| 33 |
+
except Exception as e:
|
| 34 |
+
logging.error(f"Vision API 인증 실패: {e}")
|
| 35 |
+
raise
|
| 36 |
+
|
| 37 |
+
vision_client = get_vision_client()
|
| 38 |
+
checker = spell_init()
|
| 39 |
+
|
| 40 |
+
# ──────────────────────────────── 유틸 함수 ────────────────────────────────
|
| 41 |
+
def normalize_text(text: str) -> str:
|
| 42 |
+
return unicodedata.normalize('NFC', text)
|
| 43 |
+
|
| 44 |
+
def compute_font_for_word(vertices):
|
| 45 |
+
ys = [v.y for v in vertices]
|
| 46 |
+
bbox_h = max(ys) - min(ys)
|
| 47 |
+
size = max(MIN_FONT_SIZE, int(bbox_h * 0.4))
|
| 48 |
+
try:
|
| 49 |
+
return ImageFont.truetype(FONT_PATH, size)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
print(f"[WARNING] 폰트 로딩 실패: {e}")
|
| 52 |
+
return ImageFont.load_default()
|
| 53 |
+
|
| 54 |
+
def preprocess_with_adaptive_threshold(img: Image.Image) -> Image.Image:
|
| 55 |
+
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
| 56 |
+
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
| 57 |
+
adap = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 25, 10)
|
| 58 |
+
bgr = cv2.cvtColor(adap, cv2.COLOR_GRAY2BGR)
|
| 59 |
+
return Image.fromarray(cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB))
|
| 60 |
+
|
| 61 |
+
# ──────────────────────────────── OCR 및 교정 ────────────────────────────────
|
| 62 |
+
def ocr_overlay_and_correct_text(img: Image.Image):
|
| 63 |
+
corrected_text = ""
|
| 64 |
+
overlay = None
|
| 65 |
+
if img is not None:
|
| 66 |
+
img = ImageOps.exif_transpose(img)
|
| 67 |
+
proc = preprocess_with_adaptive_threshold(img)
|
| 68 |
+
buf = io.BytesIO(); proc.save(buf, format='PNG')
|
| 69 |
+
|
| 70 |
+
res = vision_client.document_text_detection(
|
| 71 |
+
image=vision.Image(content=buf.getvalue()),
|
| 72 |
+
image_context={'language_hints': ['ko']}
|
| 73 |
+
)
|
| 74 |
+
ann = res.full_text_annotation
|
| 75 |
+
raw = ann.text.replace('\n', ' ').strip()
|
| 76 |
+
logging.info(f"[OCR] Raw: {raw}")
|
| 77 |
+
corrected_text = checker(raw)
|
| 78 |
+
logging.info(f"[Spell] Corrected: {corrected_text}")
|
| 79 |
+
|
| 80 |
+
syms = []
|
| 81 |
+
for pg in ann.pages:
|
| 82 |
+
for bl in pg.blocks:
|
| 83 |
+
for para in bl.paragraphs:
|
| 84 |
+
for w in para.words:
|
| 85 |
+
for s in w.symbols:
|
| 86 |
+
syms.append({'text': normalize_text(s.text), 'bbox': s.bounding_box.vertices})
|
| 87 |
+
|
| 88 |
+
raw_c, corr_c, mapping = list(raw), list(corrected_text), {}
|
| 89 |
+
idx = 0
|
| 90 |
+
for i, ch in enumerate(raw_c):
|
| 91 |
+
if ch.strip():
|
| 92 |
+
mapping[i] = idx
|
| 93 |
+
idx += 1
|
| 94 |
+
|
| 95 |
+
sm = SequenceMatcher(None, raw_c, corr_c)
|
| 96 |
+
overlay = img.copy()
|
| 97 |
+
draw = ImageDraw.Draw(overlay)
|
| 98 |
+
col = "#FF3333"
|
| 99 |
+
|
| 100 |
+
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
| 101 |
+
if tag not in ('replace', 'insert'):
|
| 102 |
+
continue
|
| 103 |
+
repl = ''.join(corr_c[j1:j2])
|
| 104 |
+
if tag == 'insert' and repl == ' ':
|
| 105 |
+
repl = 'V'
|
| 106 |
+
valid = (
|
| 107 |
+
[k for k in range(i1, i2) if k in mapping]
|
| 108 |
+
if tag == 'replace'
|
| 109 |
+
else ([max(i1-1, 0)] if max(i1-1, 0) in mapping else [])
|
| 110 |
+
)
|
| 111 |
+
for k in valid:
|
| 112 |
+
sd = mapping[k]
|
| 113 |
+
verts = syms[sd]['bbox']
|
| 114 |
+
xs, ys = [v.x for v in verts], [v.y for v in verts]
|
| 115 |
+
x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys)
|
| 116 |
+
ul = y0 + int((y1 - y0) * 0.9)
|
| 117 |
+
draw.line([(x0, ul), (x1, ul)], fill=col, width=3)
|
| 118 |
+
if valid:
|
| 119 |
+
sd = mapping[valid[0]]
|
| 120 |
+
verts = syms[sd]['bbox']
|
| 121 |
+
xs, ys = [v.x for v in verts], [v.y for v in verts]
|
| 122 |
+
x0, x1, y0 = min(xs), max(xs), min(ys)
|
| 123 |
+
if tag == 'insert' and len(repl) == 1 and not repl.isalnum():
|
| 124 |
+
prev_k = max(i1 - 1, 0)
|
| 125 |
+
if prev_k in mapping:
|
| 126 |
+
prev_sd = mapping[prev_k]
|
| 127 |
+
prev_verts = syms[prev_sd]['bbox']
|
| 128 |
+
prev_xs = [v.x for v in prev_verts]
|
| 129 |
+
fx = max(prev_xs + xs)
|
| 130 |
+
overlay_str = raw_c[prev_k] + repl
|
| 131 |
+
else:
|
| 132 |
+
overlay_str, fx = repl, x1
|
| 133 |
+
elif repl == 'V':
|
| 134 |
+
overlay_str, fx = 'V', x1
|
| 135 |
+
elif not repl.isalnum():
|
| 136 |
+
overlay_str, fx = repl, x1
|
| 137 |
+
else:
|
| 138 |
+
overlay_str, fx = repl, x0
|
| 139 |
+
fy = y0
|
| 140 |
+
font = compute_font_for_word(verts)
|
| 141 |
+
draw.text((fx, fy), overlay_str, font=font, fill=col)
|
| 142 |
+
|
| 143 |
+
return overlay, corrected_text
|
| 144 |
+
|
| 145 |
+
# ──────────────────────────────── Gradio 핸들러 ────────────────────────────────
|
| 146 |
+
def text_correct_fn(text):
|
| 147 |
+
raw = normalize_text(text.strip())
|
| 148 |
+
corrected = checker(raw)
|
| 149 |
+
return None, corrected
|
| 150 |
+
|
| 151 |
+
def img_correct_fn(blob):
|
| 152 |
+
img = None
|
| 153 |
+
if blob:
|
| 154 |
+
img = Image.open(io.BytesIO(blob)).convert('RGB')
|
| 155 |
+
return ocr_overlay_and_correct_text(img)
|
| 156 |
+
|
| 157 |
+
# ──────────────────────────────── Gradio UI ────────────────────────────────
|
| 158 |
+
with gr.Blocks(
|
| 159 |
+
css="""
|
| 160 |
+
.gradio-container {background-color: #fafaf5}
|
| 161 |
+
footer {display: none !important;}
|
| 162 |
+
.gr-box {border: 2px solid black !important;}
|
| 163 |
+
* { font-family: 'Quicksand', ui-sans-serif, sans-serif !important; }
|
| 164 |
+
""",
|
| 165 |
+
theme="dark"
|
| 166 |
+
) as demo:
|
| 167 |
+
state = gr.State()
|
| 168 |
+
gr.Markdown("## 📷찰칵! 맞춤법 검사기")
|
| 169 |
+
with gr.Row():
|
| 170 |
+
with gr.Column():
|
| 171 |
+
upload = gr.UploadButton(label='사진 촬영 및 업로드', file_types=['image'], type='binary')
|
| 172 |
+
img_check_btn = gr.Button('✔️검사하기', interactive=False)
|
| 173 |
+
with gr.Column():
|
| 174 |
+
text_in = gr.Textbox(lines=3, placeholder='텍스트를 직접 입력하세요 (선택)', label='💻직접 입력 텍스트')
|
| 175 |
+
text_check_btn = gr.Button('텍스트 검사', interactive=False)
|
| 176 |
+
|
| 177 |
+
img_out = gr.Image(type='pil', label='교정 결과')
|
| 178 |
+
txt_out = gr.Textbox(label='교정된 텍스트')
|
| 179 |
+
clear_btn = gr.Button('초기화')
|
| 180 |
+
|
| 181 |
+
def on_upload_start():
|
| 182 |
+
return gr.update(label="업로드 중...", interactive=False), gr.update(interactive=False)
|
| 183 |
+
upload.upload(on_upload_start, None, [upload, img_check_btn], queue=False, preprocess=False)
|
| 184 |
+
|
| 185 |
+
def on_upload_complete(blob):
|
| 186 |
+
return blob, gr.update(label="업로드 완료", interactive=False), gr.update(interactive=True)
|
| 187 |
+
upload.upload(on_upload_complete, inputs=[upload], outputs=[state, upload, img_check_btn])
|
| 188 |
+
|
| 189 |
+
def on_img_check(blob):
|
| 190 |
+
result = img_correct_fn(blob)
|
| 191 |
+
return gr.update(label="사진 촬영 및 업로드", interactive=True, value=None), gr.update(interactive=False), result[0], result[1]
|
| 192 |
+
img_check_btn.click(on_img_check, inputs=[state], outputs=[upload, img_check_btn, img_out, txt_out])
|
| 193 |
+
|
| 194 |
+
def enable_text_check(text):
|
| 195 |
+
return gr.update(interactive=bool(text.strip()))
|
| 196 |
+
text_in.change(enable_text_check, inputs=[text_in], outputs=[text_check_btn])
|
| 197 |
+
|
| 198 |
+
text_check_btn.click(text_correct_fn, inputs=[text_in], outputs=[img_out, txt_out])
|
| 199 |
+
|
| 200 |
+
def on_clear():
|
| 201 |
+
return None, gr.update(label="사진 촬영 및 업로드", interactive=True, value=None), '', gr.update(interactive=False), None, ''
|
| 202 |
+
clear_btn.click(on_clear, None, [state, upload, text_in, img_check_btn, img_out, txt_out])
|
| 203 |
+
|
| 204 |
+
# Hugging Face Spaces 전용 launch
|
| 205 |
+
if __name__ == '__main__':
|
| 206 |
+
demo.launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", 7860)))
|