Spaces:
Running
Running
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)))
|