Spaces:
Running
Running
import gradio as gr | |
import google.generativeai as genai | |
import base64 | |
import io | |
from PIL import Image, ImageEnhance | |
import difflib | |
import os | |
import time | |
from datetime import datetime | |
# 전역 변수 | |
api_key = None | |
model = None | |
processing_cache = {} | |
# 환경 변수에서 API 키 확인 | |
GEMINI_API_KEY = os.getenv('GEMINI_API_KEY') | |
def initialize_api(): | |
"""환경 변수에서 API 키를 자동으로 설정""" | |
global api_key, model | |
if GEMINI_API_KEY: | |
try: | |
api_key = GEMINI_API_KEY | |
genai.configure(api_key=api_key) | |
model = genai.GenerativeModel('gemini-2.5-flash') | |
return True, "✅ 환경 변수에서 API 키가 자동으로 설정되었습니다!" | |
except Exception as e: | |
return False, f"❌ 환경 변수 API 키 설정 실패: {str(e)}" | |
return False, "" | |
def configure_api(api_key_input): | |
"""Gemini API 설정""" | |
global api_key, model | |
# 환경 변수 우선 확인 | |
if GEMINI_API_KEY and not api_key_input.strip(): | |
is_initialized, message = initialize_api() | |
if is_initialized: | |
return message | |
# 수동 입력 API 키 처리 | |
try: | |
if not api_key_input.strip(): | |
if not GEMINI_API_KEY: | |
return "❌ API 키를 입력하거나 환경 변수 'GEMINI_API_KEY'를 설정해주세요." | |
else: | |
return "❌ 환경 변수의 API 키가 유효하지 않습니다. 직접 입력해주세요." | |
api_key = api_key_input.strip() | |
genai.configure(api_key=api_key) | |
model = genai.GenerativeModel('gemini-2.5-flash') | |
return "✅ 수동 입력된 API 키로 설정이 완료되었습니다!" | |
except Exception as e: | |
return f"❌ API 설정 실패: {str(e)}" | |
def preprocess_image(image): | |
"""이미지 전처리로 OCR 정확도 향상""" | |
try: | |
if image is None: | |
return None | |
# 이미지 크기 최적화 | |
width, height = image.size | |
if width * height > 4000000: # 4MP 이상이면 축소 | |
ratio = (4000000 / (width * height)) ** 0.5 | |
new_width = int(width * ratio) | |
new_height = int(height * ratio) | |
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) | |
elif width < 800 or height < 600: # 너무 작으면 확대 | |
scale = max(800/width, 600/height) | |
new_width = int(width * scale) | |
new_height = int(height * scale) | |
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) | |
# 이미지 품질 향상 | |
if image.mode != 'RGB': | |
image = image.convert('RGB') | |
# 대비 향상 | |
enhancer = ImageEnhance.Contrast(image) | |
image = enhancer.enhance(1.2) | |
# 선명도 향상 | |
enhancer = ImageEnhance.Sharpness(image) | |
image = enhancer.enhance(1.1) | |
return image | |
except Exception as e: | |
print(f"이미지 전처리 오류: {e}") | |
return image | |
def extract_text_from_image(image): | |
"""이미지에서 한국어 텍스트 추출""" | |
global model, processing_cache | |
if model is None: | |
return "❌ 먼저 API 키를 설정해주세요." | |
if image is None: | |
return "❌ 이미지를 업로드해주세요." | |
try: | |
# 이미지 해시로 캐시 확인 | |
image_hash = hash(str(image.tobytes()) if hasattr(image, 'tobytes') else str(image)) | |
if image_hash in processing_cache: | |
return processing_cache[image_hash] | |
# 이미지 전처리 | |
processed_image = preprocess_image(image) | |
# PIL Image를 bytes로 변환 | |
buffer = io.BytesIO() | |
processed_image.save(buffer, format='PNG', optimize=True, quality=95) | |
image_data = buffer.getvalue() | |
# OCR 프롬프트 | |
prompt = """ | |
이 이미지에 있는 모든 한국어 텍스트를 정확하게 추출해주세요. | |
규칙: | |
1. 텍스트의 읽기 순서와 공간적 배치를 정확히 유지하세요 | |
2. 줄바꿈, 들여쓰기, 공백을 원본과 동일하게 보존하세요 | |
3. 한글, 영어, 숫자, 특수문자를 모두 포함하세요 | |
4. 표나 목록의 구조를 유지하세요 | |
5. 흐릿하거나 불분명한 글자는 [?]로 표시하세요 | |
6. 텍스트만 출력하고 추가 설명은 하지 마세요 | |
""" | |
# 이미지를 base64로 인코딩 | |
image_base64 = base64.b64encode(image_data).decode('utf-8') | |
# 재시도 메커니즘 | |
max_retries = 3 | |
for attempt in range(max_retries): | |
try: | |
response = model.generate_content([ | |
prompt, | |
{ | |
'mime_type': 'image/png', | |
'data': image_base64 | |
} | |
]) | |
extracted_text = response.text.strip() | |
# 캐시에 저장 | |
processing_cache[image_hash] = extracted_text | |
return extracted_text | |
except Exception as e: | |
if attempt == max_retries - 1: | |
raise e | |
time.sleep(1) | |
except Exception as e: | |
return f"❌ 텍스트 추출 실패: {str(e)}" | |
def compare_texts(text1, text2): | |
"""두 텍스트를 비교하고 차이점을 HTML로 표시""" | |
if not text1 or not text2: | |
return "비교할 텍스트가 없습니다." | |
# 줄 단위로 비교 | |
lines1 = text1.splitlines() | |
lines2 = text2.splitlines() | |
# 통계 정보 계산 | |
total_lines1 = len(lines1) | |
total_lines2 = len(lines2) | |
# 유사도 계산 | |
similarity = difflib.SequenceMatcher(None, text1, text2).ratio() | |
similarity_percent = round(similarity * 100, 1) | |
# 현재 시간 | |
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
html_result = f""" | |
<div style="font-family: 'Noto Sans KR', sans-serif; line-height: 1.6;"> | |
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 12px; margin-bottom: 20px;"> | |
<h2 style="margin: 0 0 10px 0;">📊 텍스트 비교 분석 결과</h2> | |
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-top: 15px;"> | |
<div style="background: rgba(255,255,255,0.2); padding: 10px; border-radius: 8px;"> | |
<div style="font-size: 0.9em;">유사도</div> | |
<div style="font-size: 1.5em; font-weight: bold;">{similarity_percent}%</div> | |
</div> | |
<div style="background: rgba(255,255,255,0.2); padding: 10px; border-radius: 8px;"> | |
<div style="font-size: 0.9em;">원본 라인 수</div> | |
<div style="font-size: 1.5em; font-weight: bold;">{total_lines1}</div> | |
</div> | |
<div style="background: rgba(255,255,255,0.2); padding: 10px; border-radius: 8px;"> | |
<div style="font-size: 0.9em;">수정된 라인 수</div> | |
<div style="font-size: 1.5em; font-weight: bold;">{total_lines2}</div> | |
</div> | |
<div style="background: rgba(255,255,255,0.2); padding: 10px; border-radius: 8px;"> | |
<div style="font-size: 0.9em;">분석 시간</div> | |
<div style="font-size: 1em; font-weight: bold;">{timestamp}</div> | |
</div> | |
</div> | |
</div> | |
<div style="display: flex; gap: 20px;"> | |
<div style="flex: 1;"> | |
<div style="background: #ef4444; color: white; padding: 12px; border-radius: 8px 8px 0 0; font-weight: 600;"> | |
📷 원본 텍스트 ({total_lines1}줄) | |
</div> | |
<div style="background-color: #fff5f5; border: 1px solid #fed7d7; padding: 15px; border-radius: 0 0 8px 8px; white-space: pre-wrap; font-family: monospace; max-height: 400px; overflow-y: auto;"> | |
""" | |
# 차이점이 있는 라인 찾기 | |
diff_lines = set() | |
for line in difflib.unified_diff(lines1, lines2, lineterm='', n=0): | |
if line.startswith('-') and not line.startswith('---'): | |
diff_lines.add(line[1:]) | |
elif line.startswith('+') and not line.startswith('+++'): | |
diff_lines.add(line[1:]) | |
# 원본 텍스트 표시 (차이점 강조) | |
for line in lines1: | |
if line in diff_lines: | |
html_result += f'<span style="background-color: #fecaca; color: #dc2626; padding: 2px 4px; border-radius: 3px;">{line}</span>\n' | |
else: | |
html_result += f'{line}\n' | |
html_result += f""" | |
</div> | |
</div> | |
<div style="flex: 1;"> | |
<div style="background: #22c55e; color: white; padding: 12px; border-radius: 8px 8px 0 0; font-weight: 600;"> | |
🔄 수정된 텍스트 ({total_lines2}줄) | |
</div> | |
<div style="background-color: #f0fdf4; border: 1px solid #bbf7d0; padding: 15px; border-radius: 0 0 8px 8px; white-space: pre-wrap; font-family: monospace; max-height: 400px; overflow-y: auto;"> | |
""" | |
# 수정된 텍스트 표시 (차이점 강조) | |
for line in lines2: | |
if line in diff_lines: | |
html_result += f'<span style="background-color: #dcfce7; color: #059669; padding: 2px 4px; border-radius: 3px;">{line}</span>\n' | |
else: | |
html_result += f'{line}\n' | |
html_result += """ | |
</div> | |
</div> | |
</div> | |
</div> | |
""" | |
return html_result | |
def process_images(image1, image2, api_key_input): | |
"""두 이미지를 처리하고 비교 결과 반환""" | |
# 환경 변수 우선 확인 및 API 설정 | |
if not model: | |
config_result = configure_api(api_key_input) | |
if "❌" in config_result: | |
return config_result, "", "" | |
# 첫 번째 이미지 텍스트 추출 | |
text1 = extract_text_from_image(image1) if image1 else "" | |
# 두 번째 이미지 텍스트 추출 | |
text2 = extract_text_from_image(image2) if image2 else "" | |
# 비교 결과 생성 | |
if text1 and text2: | |
comparison = compare_texts(text1, text2) | |
else: | |
comparison = "두 이미지 모두 업로드하고 텍스트가 추출되어야 비교가 가능합니다." | |
return comparison, text1, text2 | |
def extract_text_only(image, api_key_input): | |
"""이미지에서 텍스트만 추출""" | |
# 환경 변수 우선 확인 및 API 설정 | |
if not model: | |
config_result = configure_api(api_key_input) | |
if "❌" in config_result: | |
return config_result | |
# 이미지 텍스트 추출 | |
extracted_text = extract_text_from_image(image) if image else "" | |
return extracted_text | |
def apply_changes_only(original_text, change_request_text, api_key_input): | |
"""원본 텍스트에 변경사항만 적용""" | |
# 환경 변수 우선 확인 및 API 설정 | |
if not model: | |
config_result = configure_api(api_key_input) | |
if "❌" in config_result: | |
return config_result, "" | |
if not original_text.strip(): | |
return "먼저 이미지에서 텍스트를 추출해주세요.", "" | |
if not change_request_text.strip(): | |
return "변경사항 요청을 입력해주세요.", "" | |
try: | |
# 변경사항 적용을 위한 프롬프트 | |
prompt = f""" | |
다음 원본 텍스트에서 사용자가 요청한 변경사항을 정확하게 적용해주세요. | |
원본 텍스트: | |
{original_text} | |
변경사항 요청: | |
{change_request_text} | |
규칙: | |
1. 요청된 변경사항만 정확히 적용하세요 | |
2. 나머지 텍스트는 원본과 완전히 동일하게 유지하세요 | |
3. 텍스트의 형식, 구조, 줄바꿈을 원본과 동일하게 보존하세요 | |
4. 한국어 맞춤법과 띄어쓰기를 정확히 적용하세요 | |
5. 변경된 텍스트만 출력하고 설명이나 주석은 포함하지 마세요 | |
""" | |
response = model.generate_content(prompt) | |
modified_text = response.text.strip() | |
# 비교 결과 생성 | |
if original_text and modified_text: | |
comparison = compare_texts(original_text, modified_text) | |
else: | |
comparison = "텍스트 비교를 위해 원본 텍스트와 변경된 텍스트가 모두 필요합니다." | |
return modified_text, comparison | |
except Exception as e: | |
return f"❌ 변경사항 적용 실패: {str(e)}", "" | |
def clear_all(): | |
"""모든 입력 초기화""" | |
return None, None, "", "", "" | |
def compare_image_and_text(image_text, input_text, api_key_input): | |
"""이미지에서 추출된 텍스트와 입력된 텍스트 비교""" | |
# 환경 변수 우선 확인 및 API 설정 | |
if not model: | |
config_result = configure_api(api_key_input) | |
if "❌" in config_result: | |
return input_text, config_result | |
if not image_text.strip(): | |
return input_text, "먼저 이미지에서 텍스트를 추출해주세요." | |
if not input_text.strip(): | |
return input_text, "비교할 텍스트를 입력해주세요." | |
# 비교 결과 생성 | |
comparison = compare_texts(image_text, input_text) | |
return input_text, comparison | |
def clear_all_tab2(): | |
"""탭2의 모든 입력 초기화""" | |
return None, "", "", "", "" | |
def clear_all_tab3(): | |
"""탭3의 모든 입력 초기화""" | |
return None, "", "", "", "" | |
# Gradio 인터페이스 생성 | |
def create_interface(): | |
# 앱 시작 시 환경 변수 확인 | |
env_initialized, env_message = initialize_api() | |
with gr.Blocks( | |
theme=gr.themes.Soft(), | |
title="한국어 OCR 비교 분석 도구", | |
css=""" | |
.gradio-container { | |
font-family: 'Noto Sans KR', sans-serif !important; | |
} | |
""" | |
) as demo: | |
gr.Markdown(""" | |
# 🔍 한국어 OCR 이미지 비교 분석 도구 Pro | |
**AI 기반 고정밀 한국어 텍스트 인식 및 지능형 편집 솔루션** | |
### 🚀 Pro 기능: | |
- **🎯 AI 이미지 전처리**: 자동 품질 향상으로 인식률 극대화 | |
- **🧠 스마트 OCR**: Gemini 2.5 Flash + 고도화된 프롬프트 엔지니어링 | |
- **⚡ 캐싱 시스템**: 동일 이미지 재처리 방지로 속도 향상 | |
- **🔍 정밀 비교 분석**: 라인별, 단어별 상세 차이점 분석 | |
- **📊 실시간 통계**: 유사도, 변경사항 수치화 | |
""") | |
# 환경 변수 상태 표시 | |
if env_initialized: | |
gr.HTML(f""" | |
<div style="background: linear-gradient(135deg, #10b981, #059669); color: white; padding: 15px; border-radius: 12px; margin: 15px 0;"> | |
<div style="display: flex; align-items: center; font-weight: 600; font-size: 1.1em;"> | |
<span style="font-size: 1.5em; margin-right: 10px;">✅</span> | |
{env_message} | |
</div> | |
<div style="margin-top: 8px; opacity: 0.9; font-size: 0.9em;"> | |
API 연결 완료 • 최적화된 성능 • 안전한 처리 | |
</div> | |
</div> | |
""") | |
api_key_input = gr.Textbox(visible=False) | |
else: | |
gr.HTML(""" | |
<div style="background: linear-gradient(135deg, #f59e0b, #d97706); color: white; padding: 20px; border-radius: 12px; margin: 15px 0;"> | |
<div style="font-weight: 600; font-size: 1.1em; margin-bottom: 10px;"> | |
⚠️ 환경 변수 설정 필요 | |
</div> | |
<div style="background: rgba(255,255,255,0.2); padding: 15px; border-radius: 8px; margin: 10px 0;"> | |
<div style="font-weight: 600; margin-bottom: 8px;">🔧 허깅페이스 설정 방법:</div> | |
<div style="font-size: 0.9em; line-height: 1.6;"> | |
<div>1️⃣ 스페이스 설정(Settings) → Variables 탭</div> | |
<div>2️⃣ Name: GEMINI_API_KEY</div> | |
<div>3️⃣ Value: 여러분의 Google Gemini API 키</div> | |
<div>4️⃣ 스페이스 재시작으로 적용</div> | |
</div> | |
</div> | |
</div> | |
""") | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 🔑 임시 API 설정") | |
api_key_input = gr.Textbox( | |
label="Google Gemini API Key", | |
type="password", | |
placeholder="임시 사용을 위해 API 키를 입력하세요...", | |
info="⚠️ 보안을 위해 환경 변수 설정을 권장합니다." | |
) | |
with gr.Tabs(): | |
with gr.TabItem("📷 이미지 비교"): | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 📷 이미지 업로드") | |
with gr.Row(): | |
image1 = gr.Image( | |
label="이미지 1 (원본)", | |
type="pil", | |
height=300 | |
) | |
image2 = gr.Image( | |
label="이미지 2 (비교대상)", | |
type="pil", | |
height=300 | |
) | |
with gr.Row(): | |
analyze_btn = gr.Button( | |
"🚀 분석 시작", | |
variant="primary", | |
size="lg" | |
) | |
clear_btn = gr.Button( | |
"🗑️ 초기화", | |
variant="secondary", | |
size="lg" | |
) | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 📄 추출된 텍스트") | |
with gr.Row(): | |
text1_output = gr.Textbox( | |
label="이미지 1 텍스트", | |
lines=10, | |
max_lines=15, | |
show_copy_button=True | |
) | |
text2_output = gr.Textbox( | |
label="이미지 2 텍스트", | |
lines=10, | |
max_lines=15, | |
show_copy_button=True | |
) | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 📊 텍스트 차이점 분석") | |
comparison_output = gr.HTML( | |
label="텍스트 비교 결과", | |
show_label=False | |
) | |
with gr.TabItem("📷 이미지+텍스트"): | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 📷 원본 이미지 업로드") | |
image_tab3 = gr.Image( | |
label="원본 이미지", | |
type="pil", | |
height=300 | |
) | |
extract_text_btn_tab3 = gr.Button( | |
"📝 텍스트 추출", | |
variant="secondary", | |
size="lg" | |
) | |
with gr.Column(): | |
gr.Markdown("### ✏️ 텍스트 입력") | |
text_input_tab3 = gr.Textbox( | |
label="비교할 텍스트를 입력하세요", | |
placeholder="여기에 비교하고 싶은 텍스트를 입력하세요...", | |
lines=10, | |
max_lines=15 | |
) | |
with gr.Row(): | |
compare_text_btn = gr.Button( | |
"🔍 텍스트 비교", | |
variant="primary", | |
size="lg" | |
) | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 📄 추출된 텍스트") | |
with gr.Row(): | |
extracted_text_tab3 = gr.Textbox( | |
label="원본 이미지 텍스트", | |
lines=10, | |
max_lines=15, | |
show_copy_button=True | |
) | |
input_text_display = gr.Textbox( | |
label="입력된 텍스트", | |
lines=10, | |
max_lines=15, | |
show_copy_button=True, | |
interactive=False | |
) | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 📊 텍스트 차이점 분석") | |
comparison_output_tab3 = gr.HTML( | |
label="텍스트 비교 결과", | |
show_label=False | |
) | |
with gr.Row(): | |
clear_btn_tab3 = gr.Button( | |
"🗑️ 초기화", | |
variant="secondary", | |
size="lg" | |
) | |
with gr.TabItem("✏️ 변경사항 요청"): | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 📷 원본 이미지 업로드") | |
image1_tab2 = gr.Image( | |
label="원본 이미지", | |
type="pil", | |
height=300 | |
) | |
extract_text_btn = gr.Button( | |
"📝 텍스트 추출", | |
variant="secondary", | |
size="lg" | |
) | |
with gr.Column(): | |
gr.Markdown("### ✏️ 변경사항 요청") | |
change_request = gr.Textbox( | |
label="변경하고 싶은 내용을 입력하세요", | |
placeholder="예: '홍길동'을 '김철수'로 변경해주세요", | |
lines=5, | |
max_lines=10 | |
) | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 📄 추출된 텍스트") | |
with gr.Row(): | |
text1_output_tab2 = gr.Textbox( | |
label="원본 이미지 텍스트", | |
lines=10, | |
max_lines=15, | |
show_copy_button=True | |
) | |
text2_output_tab2 = gr.Textbox( | |
label="변경사항 적용된 텍스트", | |
lines=10, | |
max_lines=15, | |
show_copy_button=True | |
) | |
apply_change_btn = gr.Button( | |
"✏️ 변경사항 적용", | |
variant="primary", | |
size="lg" | |
) | |
with gr.Row(): | |
with gr.Column(): | |
gr.Markdown("### 📊 텍스트 차이점 분석") | |
comparison_output_tab2 = gr.HTML( | |
label="텍스트 비교 결과", | |
show_label=False | |
) | |
with gr.Row(): | |
clear_btn_tab2 = gr.Button( | |
"🗑️ 초기화", | |
variant="secondary", | |
size="lg" | |
) | |
# 이벤트 연결 - 탭1 (이미지 비교) | |
analyze_btn.click( | |
fn=process_images, | |
inputs=[image1, image2, api_key_input], | |
outputs=[comparison_output, text1_output, text2_output] | |
) | |
clear_btn.click( | |
fn=clear_all, | |
outputs=[image1, image2, comparison_output, text1_output, text2_output] | |
) | |
# 이벤트 연결 - 탭3 (이미지+텍스트) | |
extract_text_btn_tab3.click( | |
fn=extract_text_only, | |
inputs=[image_tab3, api_key_input], | |
outputs=[extracted_text_tab3] | |
) | |
compare_text_btn.click( | |
fn=compare_image_and_text, | |
inputs=[extracted_text_tab3, text_input_tab3, api_key_input], | |
outputs=[input_text_display, comparison_output_tab3] | |
) | |
clear_btn_tab3.click( | |
fn=clear_all_tab3, | |
outputs=[image_tab3, text_input_tab3, extracted_text_tab3, input_text_display, comparison_output_tab3] | |
) | |
# 이벤트 연결 - 탭2 (변경사항 요청) | |
extract_text_btn.click( | |
fn=extract_text_only, | |
inputs=[image1_tab2, api_key_input], | |
outputs=[text1_output_tab2] | |
) | |
apply_change_btn.click( | |
fn=apply_changes_only, | |
inputs=[text1_output_tab2, change_request, api_key_input], | |
outputs=[text2_output_tab2, comparison_output_tab2] | |
) | |
clear_btn_tab2.click( | |
fn=clear_all_tab2, | |
outputs=[image1_tab2, change_request, comparison_output_tab2, text1_output_tab2, text2_output_tab2] | |
) | |
gr.Markdown(""" | |
### 💡 Pro 팁: | |
- **🖼️ 이미지 최적화**: 자동 전처리로 800x600 이상 해상도 권장 | |
- **🔍 정확도 향상**: 선명하고 대비가 높은 이미지 사용 | |
- **⚡ 성능 최적화**: 캐싱 시스템으로 동일 이미지 재처리 방지 | |
- **🔒 보안 강화**: 환경 변수 사용으로 API 키 보안 강화 | |
""") | |
return demo | |
if __name__ == "__main__": | |
demo = create_interface() | |
demo.launch( | |
server_name="0.0.0.0", | |
server_port=7860, | |
share=True, | |
show_error=True | |
) |