yongyeol commited on
Commit
6fe2a61
·
verified ·
1 Parent(s): 1cf3365

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +206 -0
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)))