first commit
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# 환경 변수 설정 파일
|
||||
# 실제 사용 시 이 파일을 .env로 복사하고 실제 값으로 변경하세요
|
||||
|
||||
# Gemini API 키 (필수)
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# 애플리케이션 설정
|
||||
APP_TITLE=PDF 도면 분석기
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=False
|
||||
|
||||
# 파일 업로드 설정
|
||||
MAX_FILE_SIZE_MB=50
|
||||
ALLOWED_EXTENSIONS=pdf
|
||||
UPLOAD_FOLDER=uploads
|
||||
|
||||
# Gemini API 설정
|
||||
GEMINI_MODEL=gemini-2.5-flash
|
||||
DEFAULT_PROMPT=pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.
|
||||
90
.gemini/settings.json
Normal file
90
.gemini/settings.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"MyMCP": {
|
||||
"command": "D:/MYCLAUDE_PROJECT/mcpmaker/OpenXmlMcpServer/bin/Debug/net8.0/OpenXmlMcpServer.exe",
|
||||
"args": []
|
||||
},
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@smithery/cli@latest",
|
||||
"run",
|
||||
"@smithery-ai/github",
|
||||
"--key",
|
||||
"faeea742-0fdc-4de1-bd2b-63d6875186d1",
|
||||
"--profile",
|
||||
"tasty-muskox-jiXD8J"
|
||||
]
|
||||
},
|
||||
"supabase": {
|
||||
"command": "cmd",
|
||||
"args": [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"@supabase/mcp-server-supabase@latest",
|
||||
"--access-token",
|
||||
"sbp_18b8744ff5e37ae58101c29ea552f15382a7d250"
|
||||
]
|
||||
},
|
||||
"taskmaster-ai": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
|
||||
"env": {
|
||||
"GOOGLE_API_KEY": "AIzaSyAUyWPGBkl9fxpAh1O4uIGU87I2dpgSYOg",
|
||||
"MODEL": "gemini-1.5-flash",
|
||||
"MAX_TOKENS": "64000",
|
||||
"TEMPERATURE": "0.2",
|
||||
"DEFAULT_SUBTASKS": "5",
|
||||
"DEFAULT_PRIORITY": "medium"
|
||||
}
|
||||
},
|
||||
"markitdown": {
|
||||
"command": "markitdown-mcp"
|
||||
},
|
||||
"playwright-stealth": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@pvinis/playwright-stealth-mcp-server"]
|
||||
},
|
||||
"blender": {
|
||||
"command": "uvx",
|
||||
"args": ["blender-mcp"]
|
||||
},
|
||||
"terminal": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@dillip285/mcp-terminal"]
|
||||
},
|
||||
"googleSearch": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "g-search-mcp"]
|
||||
},
|
||||
"text-editor": {
|
||||
"command": "npx",
|
||||
"args": ["mcp-server-text-editor"]
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"D:\\MYCLAUDE_PROJECT"
|
||||
]
|
||||
},
|
||||
"context7-mcp": {
|
||||
"command": "cmd",
|
||||
"args": [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"@smithery/cli@latest",
|
||||
"run",
|
||||
"@upstash/context7-mcp",
|
||||
"--key",
|
||||
"faeea742-0fdc-4de1-bd2b-63d6875186d1",
|
||||
"--profile",
|
||||
"tasty-muskox-jiXD8J"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
177
.gitignore
vendored
Normal file
177
.gitignore
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
results/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
121
DXF_INTEGRATION_COMPLETE.md
Normal file
121
DXF_INTEGRATION_COMPLETE.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# PDF/DXF 통합 분석기 - DXF 지원 기능 통합 완료 보고서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
- **프로젝트명**: Flet 기반 PDF/DXF 도면 분석기
|
||||
- **완료일**: 2025-07-09
|
||||
- **진행률**: 100% (DXF 지원 기능 통합 완료)
|
||||
|
||||
## 🎯 구현된 핵심 기능
|
||||
|
||||
### 1. 멀티 파일 포맷 지원
|
||||
- **PDF 분석**: Gemini API를 통한 이미지 기반 도면 분석
|
||||
- **DXF 분석**: ezdxf 라이브러리를 통한 도곽(Title Block) 정보 추출
|
||||
- **자동 파일 타입 감지**: 확장자에 따른 적절한 분석 방법 자동 선택
|
||||
|
||||
### 2. 통합된 사용자 인터페이스
|
||||
- **좌우 분할 레이아웃**: 좌측 설정 패널, 우측 결과 표시
|
||||
- **반응형 디자인**: ResponsiveRow를 활용한 화면 크기별 최적화
|
||||
- **파일 타입별 UI**: PDF 미리보기, DXF 정보 표시 등 차별화된 인터페이스
|
||||
|
||||
### 3. DXF 분석 기능
|
||||
- **블록 참조 추출**: Block Reference와 Attribute Reference 분석
|
||||
- **도곽 정보 추출**: 건설분야, 건설단계, 도면명, 축척, 도면번호 등
|
||||
- **좌표 정보**: Text Bounding Box 및 최외곽 경계 계산
|
||||
- **요약 정보**: 전체 블록 수, 속성 수 등 통계 정보
|
||||
|
||||
## 🔧 기술적 성과
|
||||
|
||||
### 수정된 파일들
|
||||
1. **main.py** - 핵심 통합 작업
|
||||
- `DocumentAnalyzerApp` 클래스로 통일
|
||||
- `on_file_selected` 메서드 PDF/DXF 지원으로 완전 교체
|
||||
- `run_analysis` 메서드 파일 타입별 분석으로 분할
|
||||
- DXF 분석 결과 표시 기능 추가
|
||||
- 변수명 통일 (`current_file_path`, `current_file_type`)
|
||||
|
||||
2. **dxf_support_methods.py** - 지원 메서드 설계
|
||||
- PDF/DXF 파일 선택 처리 로직
|
||||
- DXF 분석 실행 및 결과 표시 메서드
|
||||
- 파일 상태 초기화 및 오류 처리
|
||||
|
||||
3. **project_plan.md** - 진행 상황 업데이트
|
||||
- 단계 11 DXF 지원 기능 100% 완료
|
||||
- 최종 진행률 100% 달성
|
||||
- 상세한 구현 내용 기록
|
||||
|
||||
### 연구 및 검증
|
||||
- **20개 이상 웹사이트 심층 연구**: Flet, ezdxf, CAD 분석 최신 기술
|
||||
- **기술적 검증**: FilePicker 다중 파일 타입, DXF 처리, UI 패턴 연구
|
||||
- **모범 사례 적용**: 최신 Flet 기능 및 ezdxf 라이브러리 활용
|
||||
|
||||
## 🚀 사용법
|
||||
|
||||
### 1. 환경 설정
|
||||
```bash
|
||||
# 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 환경 변수 설정 (.env 파일)
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
```
|
||||
|
||||
### 2. 애플리케이션 실행
|
||||
```bash
|
||||
# 기본 실행
|
||||
python main.py
|
||||
|
||||
# 임포트 테스트 (권장)
|
||||
python test_imports.py
|
||||
```
|
||||
|
||||
### 3. 파일 분석 과정
|
||||
1. **파일 선택**: 좌측 패널에서 PDF 또는 DXF 파일 선택
|
||||
2. **분석 설정**: 조직 스키마, 페이지 선택, 분석 모드 설정
|
||||
3. **분석 실행**: "🚀 분석 시작" 버튼 클릭
|
||||
4. **결과 확인**: 우측 패널에서 분석 결과 확인
|
||||
5. **결과 저장**: 텍스트 또는 JSON 형식으로 저장
|
||||
|
||||
### 4. 지원 파일 형식
|
||||
- **PDF**: `.pdf` (Gemini API 이미지 분석)
|
||||
- **DXF**: `.dxf` (ezdxf 도곽 정보 추출)
|
||||
|
||||
## 📊 분석 결과 예시
|
||||
|
||||
### PDF 분석 결과
|
||||
- 문서 유형 및 주요 내용
|
||||
- 도면/도표 정보
|
||||
- 텍스트 내용 추출
|
||||
- 조직별 스키마 적용 (국토교통부/한국도로공사)
|
||||
|
||||
### DXF 분석 결과
|
||||
- 전체 블록 수 및 속성 정보
|
||||
- 도곽 블록 식별 및 정보 추출
|
||||
- 건설 관련 필드 (도면명, 도면번호, 건설분야 등)
|
||||
- 좌표 및 크기 정보
|
||||
- 블록 참조 목록
|
||||
|
||||
## 🎉 프로젝트 완료 요약
|
||||
|
||||
### 달성된 목표
|
||||
- ✅ PDF와 DXF 파일 통합 분석 시스템 구축
|
||||
- ✅ 사용자 친화적인 Flet 기반 GUI 구현
|
||||
- ✅ 파일 타입별 최적화된 분석 방법 제공
|
||||
- ✅ 결과 저장 및 내보내기 기능
|
||||
- ✅ 모듈화된 코드 구조로 유지보수성 확보
|
||||
|
||||
### 기술적 혁신
|
||||
- **멀티 포맷 지원**: 단일 애플리케이션에서 AI 기반 PDF 분석과 구조적 DXF 분석
|
||||
- **자동화된 워크플로**: 파일 타입 감지부터 결과 표시까지 완전 자동화
|
||||
- **확장 가능한 아키텍처**: 새로운 파일 형식이나 분석 방법 쉽게 추가 가능
|
||||
|
||||
### 향후 활용 방안
|
||||
- 건설/건축 업계 도면 분석 자동화
|
||||
- CAD 파일 메타데이터 추출 및 관리
|
||||
- AI 기반 도면 내용 분석 및 분류
|
||||
- 대용량 도면 파일 일괄 처리 시스템
|
||||
|
||||
---
|
||||
|
||||
**✨ PDF/DXF 통합 분석기 개발 완료! ✨**
|
||||
|
||||
이제 사용자는 하나의 애플리케이션에서 PDF와 DXF 파일을 모두 분석할 수 있으며, 각 파일 형식에 최적화된 분석 결과를 얻을 수 있습니다.
|
||||
405
GEMINI.md
Normal file
405
GEMINI.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Flet 기반 PDF 입력 및 Gemini API 이미지 분석 UI 프로젝트 계획
|
||||
|
||||
## 1. 프로젝트 목표
|
||||
Flet 프레임워크를 사용하여 사용자가 PDF 및 DXF 파일을 업로드하고, 다음과 같은 분석을 수행하는 애플리케이션을 개발합니다:
|
||||
- **PDF 파일**: Gemini API를 통한 이미지 분석
|
||||
- **DXF 파일**: ezdxf 라이브러리를 통한 도곽(Title Block) 정보 추출 및 Block Reference/Attribute Reference 분석
|
||||
|
||||
## 2. 기술 스택
|
||||
- **UI 프레임워크**: Flet v0.25.1+
|
||||
- **API 연동**: Google Gemini API (Python SDK - google-genai v1.0+)
|
||||
- **PDF 처리**: PyMuPDF v1.26.3+ 또는 pdf2image v1.17.0+
|
||||
- **DXF 처리**: ezdxf v1.4.2+ (CAD 도면 파일 처리)
|
||||
- **데이터 인코딩**: base64 (Python 내장 라이브러리)
|
||||
- **환경 변수 관리**: python-dotenv v1.0.0+
|
||||
- **UI 디자인**: Flet Material Library (선택 사항)
|
||||
- **좌표 계산**: numpy v1.24.0+ (Bounding Box 계산)
|
||||
|
||||
## 3. 프로젝트 구조
|
||||
```
|
||||
fletimageanalysis/
|
||||
├── main.py # Flet UI 메인 애플리케이션
|
||||
├── gemini_analyzer.py # Gemini API 연동 모듈
|
||||
├── pdf_processor.py # PDF 처리 모듈
|
||||
├── dxf_processor.py # DXF 처리 모듈 (NEW)
|
||||
├── ui_components.py # UI 컴포넌트 모듈
|
||||
├── config.py # 설정 관리 모듈
|
||||
├── requirements.txt # 프로젝트 의존성 목록
|
||||
├── .env # 환경 변수 파일 (API 키 등)
|
||||
├── uploads/ # 업로드된 파일 저장 폴더
|
||||
├── assets/ # 애플리케이션 자산
|
||||
└── docs/ # 문서화 파일
|
||||
```
|
||||
|
||||
## 4. 주요 기능 및 UI 구성
|
||||
|
||||
### 4.1 메인 UI 구성
|
||||
- **헤더**: 애플리케이션 제목 및 로고
|
||||
- **파일 업로드 영역**: PDF 파일 선택 버튼 및 파일 정보 표시
|
||||
- **분석 설정 영역**: 분석 옵션 설정 (페이지 선택, 분석 모드 등)
|
||||
- **분석 버튼**: 분석 시작 버튼
|
||||
- **결과 표시 영역**: 분석 결과 및 PDF 미리보기
|
||||
- **상태 표시줄**: 진행 상태 및 오류 메시지
|
||||
|
||||
### 4.2 핵심 기능
|
||||
**PDF 분석 기능:**
|
||||
- PDF 파일 업로드 및 검증
|
||||
- PDF 페이지 이미지 변환
|
||||
- Gemini API를 통한 이미지 분석
|
||||
|
||||
**DXF 분석 기능 (NEW):**
|
||||
- DXF 파일 업로드 및 검증
|
||||
- Block Reference 추출 및 분석
|
||||
- Attribute Reference에서 도곽 정보 추출
|
||||
- 도곽 위치, 배치, 크기 정보 추출
|
||||
- Text Bounding Box 좌표 계산
|
||||
|
||||
**공통 기능:**
|
||||
- 분석 결과 실시간 표시
|
||||
- 분석 진행률 표시
|
||||
- 오류 처리 및 사용자 피드백
|
||||
|
||||
## 5. 개발 단계 및 진행 상황
|
||||
|
||||
### 단계 1: 프로젝트 초기 설정 ✅
|
||||
- [x] 프로젝트 폴더 구조 생성
|
||||
- [x] project_plan.md 작성
|
||||
- [x] requirements.txt 작성
|
||||
- [x] 기본 설정 파일 구성 (.env.example, config.py)
|
||||
- [x] 업로드/자산 폴더 생성
|
||||
|
||||
### 단계 2: 핵심 모듈 구현 ✅
|
||||
- [x] PDF 처리 모듈 (pdf_processor.py) 구현
|
||||
- [x] Gemini API 연동 모듈 (gemini_analyzer.py) 구현
|
||||
- [x] UI 컴포넌트 모듈 (ui_components.py) 구현
|
||||
- [x] 메인 애플리케이션 (main.py) 구현
|
||||
|
||||
### 단계 3: 기본 기능 구현 ✅
|
||||
- [x] PDF 파일 읽기 및 검증
|
||||
- [x] PDF 페이지 이미지 변환 (PyMuPDF)
|
||||
- [x] Base64 인코딩 처리
|
||||
- [x] Gemini API 클라이언트 구성
|
||||
- [x] 이미지 분석 요청 처리
|
||||
- [x] API 응답 처리 및 파싱
|
||||
|
||||
### 단계 4: UI 구현 ✅
|
||||
- [x] 메인 애플리케이션 레이아웃 설계
|
||||
- [x] 파일 업로드 UI 구현
|
||||
- [x] 분석 설정 UI 구현
|
||||
- [x] 진행률 표시 UI 구현
|
||||
- [x] 결과 표시 UI 구현
|
||||
- [x] PDF 미리보기 UI 구현
|
||||
|
||||
### 단계 5: 통합 및 이벤트 처리 ✅
|
||||
- [x] 파일 업로드 이벤트 처리
|
||||
- [x] 분석 진행률 표시
|
||||
- [x] 결과 표시 기능
|
||||
- [x] 오류 처리 및 사용자 알림
|
||||
- [x] 백그라운드 스레드 처리
|
||||
|
||||
### 단계 6: 고급 기능 구현 ✅
|
||||
- [x] PDF 미리보기 기능 (advanced_features.py)
|
||||
- [x] 분석 결과 저장 기능 (텍스트/JSON)
|
||||
- [x] 고급 설정 관리 (AdvancedSettings)
|
||||
- [x] 오류 처리 및 로깅 시스템 (ErrorHandler)
|
||||
- [x] 분석 히스토리 관리 (AnalysisHistory)
|
||||
- [x] 사용자 정의 프롬프트 관리 (CustomPromptManager)
|
||||
|
||||
### 단계 7: 문서화 및 테스트 ✅
|
||||
- [x] README.md 작성 (상세한 사용법 및 설치 가이드)
|
||||
- [x] 사용자 가이드 (docs/user_guide.md)
|
||||
- [x] 개발자 가이드 (docs/developer_guide.md)
|
||||
- [x] 테스트 스크립트 (test_project.py)
|
||||
- [x] 설치 스크립트 (setup.py)
|
||||
- [x] 라이선스 파일 (LICENSE - MIT)
|
||||
|
||||
### 단계 8: 고급 기능 확장 ✅ (NEW)
|
||||
- [x] 조직별 스키마 선택 기능 구현
|
||||
- [x] 국토교통부/한국도로공사 전용 스키마 적용
|
||||
- [x] UI 조직 선택 드롭다운 추가
|
||||
- [x] Gemini API 스키마 매개변수 동적 전달
|
||||
- [x] 조직별 분석 결과 차별화
|
||||
|
||||
### 단계 9: 최종 최적화 및 배포 준비 ✅
|
||||
- [x] 코드 정리 및 최적화
|
||||
- [x] 오류 처리 강화
|
||||
- [x] 사용자 경험 개선
|
||||
- [x] 최종 테스트 및 검증
|
||||
|
||||
### 단계 10: UI 레이아웃 개선 ✅
|
||||
- [x] 좌우 분할 레이아웃으로 UI 재구성
|
||||
- [x] ResponsiveRow를 활용한 반응형 디자인 적용
|
||||
- [x] 좌측: 파일 업로드 + 분석 설정 + 진행률 + 분석 시작 버튼
|
||||
- [x] 우측: 분석 결과 표시 영역 확장
|
||||
- [x] PDF 뷰어를 별도 모달 창으로 분리
|
||||
- [x] AlertDialog를 사용한 PDF 미리보기 기능 구현
|
||||
- [x] 페이지 네비게이션 기능 추가 (이전/다음 페이지)
|
||||
- [x] pdf_processor.py에 이미지 바이트 변환 메서드 추가
|
||||
- [x] 기존 UI와 새 UI 백업 및 교체 완료
|
||||
|
||||
### 단계 11: DXF 파일 지원 추가 ✅ (COMPLETED)
|
||||
- [x] DXF 파일 형식 지원 추가 (.dxf 확장자)
|
||||
- [x] ezdxf 라이브러리 설치 및 설정 (requirements.txt 업데이트)
|
||||
- [x] DXF 파일 업로드 및 검증 기능 (dxf_processor.py 완성)
|
||||
- [x] Block Reference 추출 모듈 구현 (dxf_processor.py)
|
||||
- [x] Attribute Reference 분석 모듈 구현 (dxf_processor.py)
|
||||
- [x] 도곽 정보 추출 로직 구현 (dxf_processor.py)
|
||||
- [x] Bounding Box 계산 기능 구현 (dxf_processor.py)
|
||||
- [x] config.py DXF 지원 설정 추가
|
||||
- [x] ui_components.py DXF 지원 텍스트 업데이트
|
||||
- [x] main.py 기본 DXF 지원 구조 수정 (import, 클래스명)
|
||||
- [x] DXF 지원 메서드들 설계 및 구현 (dxf_support_methods.py)
|
||||
- [x] main.py에 DXF 지원 메서드들 완전 통합
|
||||
- [x] 파일 선택 로직 DXF 지원으로 완전 업데이트
|
||||
- [x] DXF 분석 결과 UI 통합
|
||||
- [x] 파일 타입별 분석 방법 자동 선택
|
||||
- [x] DocumentAnalyzerApp 클래스명 통일
|
||||
- [x] 변수명 통일 (current_file_path, current_file_type)
|
||||
- [x] 좌우 분할 레이아웃으로 UI 재구성
|
||||
- [x] ResponsiveRow를 활용한 반응형 디자인 적용
|
||||
- [x] 좌측: 파일 업로드 + 분석 설정 + 진행률 + 분석 시작 버튼
|
||||
- [x] 우측: 분석 결과 표시 영역 확장
|
||||
- [x] PDF 뷰어를 별도 모달 창으로 분리
|
||||
- [x] AlertDialog를 사용한 PDF 미리보기 기능 구현
|
||||
- [x] 페이지 네비게이션 기능 추가 (이전/다음 페이지)
|
||||
- [x] pdf_processor.py에 이미지 바이트 변환 메서드 추가
|
||||
- [x] 기존 UI와 새 UI 백업 및 교체 완료
|
||||
|
||||
## 6. 연구된 웹사이트 (70+개)
|
||||
|
||||
### 6.1 Flet 프레임워크 관련 (12개)
|
||||
1. Flet 공식 문서 - https://flet.dev/docs/
|
||||
2. Flet GitHub - https://github.com/flet-dev/flet
|
||||
3. Flet 드롭다운 컴포넌트 - https://flet.dev/docs/controls/dropdown/
|
||||
4. Flet 컨트롤 참조 - https://flet.dev/docs/controls/
|
||||
5. Flet FilePicker 문서 - https://flet.dev/docs/controls/filepicker/
|
||||
6. Flet 2024 개발 동향 - DEV Community
|
||||
7. Flet 초보자 가이드 - DEV Community
|
||||
8. Flet 예제 코드 - https://github.com/flet-dev/examples
|
||||
9. Flet UI 컴포넌트 라이브러리 - Gumroad
|
||||
10. Flet 개발 토론 - GitHub Discussions
|
||||
11. Flet 소개 및 특징 - Analytics Vidhya
|
||||
12. Talk Python 팟캐스트 Flet 업데이트 - TalkPython.fm
|
||||
|
||||
### 6.2 Gemini API 및 구조화된 출력 (10개)
|
||||
13. Gemini API Structured Output - https://ai.google.dev/gemini-api/docs/structured-output
|
||||
14. Firebase Vertex AI Structured Output - Firebase 문서
|
||||
15. Google Gen AI SDK - https://googleapis.github.io/python-genai/
|
||||
16. Gemini JSON 모드 - Google Cloud 커뮤니티 Medium
|
||||
17. Vertex AI Structured Output - Google Cloud 문서
|
||||
18. Gemini API 퀵스타트 - Google AI 개발자
|
||||
19. Gemini API 참조 - Google AI 개발자
|
||||
20. Google Developers Blog 제어된 생성 - Google 개발자 블로그
|
||||
21. Controlled Generation 매거진 - tanaikech GitHub
|
||||
22. Gemini API JSON 구조화 가이드 - Medium
|
||||
|
||||
### 6.3 PDF 처리 라이브러리 비교 (8개)
|
||||
23. PyMuPDF 성능 비교 - PyMuPDF 문서
|
||||
24. Python PDF 라이브러리 비교 2024 - Pythonify
|
||||
25. PDF 에코시스템 2023/2024 - Medium
|
||||
26. PyMuPDF vs pdf2image - GitHub 토론
|
||||
27. PDF 텍스트 추출 도구 평가 - Unstract
|
||||
28. Python PDF 라이브러리 성능 벤치마크 - GitHub
|
||||
29. PDF 처리 방법 비교 - Medium
|
||||
30. Python PDF 라이브러리 비교 - IronPDF
|
||||
|
||||
### 6.4 한국 건설/교통 표준 (8개)
|
||||
31. 국토교통부 (MOLIT) 공식사이트 - https://www.molit.go.kr/
|
||||
32. 한국 건설표준센터 - KCSC
|
||||
33. 한국 건설 표준 용어집 - SPACE Magazine
|
||||
34. 국제 건설 코드 한국 - ICC
|
||||
35. 한국 건설 안전 규정 - CAPA
|
||||
36. 건설 도면 표준 번호 체계 - Archtoolbox
|
||||
37. 건설 문서 가이드 - Monograph
|
||||
38. 미국 건설 도면 규격 - Acquisition.gov
|
||||
|
||||
### 6.5 DXF 파일 처리 및 ezdxf 라이브러리 (20개)
|
||||
39. ezdxf 공식 문서 - https://ezdxf.readthedocs.io/en/stable/
|
||||
40. ezdxf PyPI 패키지 - https://pypi.org/project/ezdxf/
|
||||
41. ezdxf GitHub 리포지토리 - https://github.com/mozman/ezdxf
|
||||
42. ezdxf 블록 튜토리얼 - Block Management Documentation
|
||||
43. ezdxf 데이터 추출 튜토리얼 - Getting Data Tutorial
|
||||
44. ezdxf DXF 엔티티 문서 - DXF Entities Documentation
|
||||
45. Stack Overflow - ezdxf Block Reference 추출
|
||||
46. Stack Overflow - DXF 텍스트 추출 방법
|
||||
47. Stack Overflow - ezdxf Attribute Reference 처리
|
||||
48. ezdxf Block Management Structures
|
||||
49. ezdxf DXF Tags 문서
|
||||
50. AutoCAD DXF Reference - Autodesk
|
||||
51. FileFormat.com - ezdxf 라이브러리 가이드
|
||||
52. ezdxf Usage for Beginners
|
||||
53. ezdxf Quick-Info 문서
|
||||
54. GitHub - ezdxf 포크 프로젝트들
|
||||
55. PyDigger - ezdxf 패키지 정보
|
||||
56. GDAL AutoCAD DXF 드라이버 문서
|
||||
57. FME Support - AutoCAD DWG Block Attribute 추출
|
||||
58. ezdxf MText 문서
|
||||
|
||||
### 6.6 CAD 도면 분석 및 AI 기반 처리 (12개)
|
||||
59. 엔지니어링 도면 데이터 추출 - Infrrd.ai
|
||||
60. werk24 PyPI - AI 기반 기술 도면 분석
|
||||
61. ResearchGate - 도면 제목 블록 정보 추출 연구
|
||||
62. ScienceDirect - 엔지니어링 도면에서 치수 요구사항 추출
|
||||
63. Medium - TensorFlow, Keras-OCR, OpenCV를 이용한 기술 도면 정보 추출
|
||||
64. GitHub - 엔지니어링 도면 추출기 (Bakkopi)
|
||||
65. Werk24 - 기술 도면 특징 추출 API
|
||||
66. Stack Overflow - PyPDF2 엔지니어링 도면 파싱
|
||||
67. BusinesswareTech - 기술 도면 데이터 추출 AI 솔루션
|
||||
68. Stack Overflow - OCR을 이용한 CAD 기술 도면 특정 데이터 추출
|
||||
69. Autodesk Forums - 제목 블록/텍스트 속성 문제
|
||||
70. AutoCAD DXF 공식 레퍼런스 문서
|
||||
31. 국토교통부 (MOLIT) 공식사이트 - https://www.molit.go.kr/
|
||||
32. 한국 건설표준센터 - KCSC
|
||||
33. 한국 건설 표준 용어집 - SPACE Magazine
|
||||
34. 국제 건설 코드 한국 - ICC
|
||||
35. 한국 건설 안전 규정 - CAPA
|
||||
36. 건설 도면 표준 번호 체계 - Archtoolbox
|
||||
37. 건설 문서 가이드 - Monograph
|
||||
38. 미국 건설 도면 규격 - Acquisition.gov
|
||||
|
||||
## 7. 개발 일정 (예상 8주)
|
||||
- **1-2주차**: 프로젝트 설정 및 기본 UI 구성
|
||||
- **3-4주차**: PDF 처리 및 Gemini API 연동
|
||||
- **5-6주차**: UI/백엔드 연동 및 핵심 기능 구현
|
||||
- **7-8주차**: 고급 기능, 테스트 및 최적화
|
||||
|
||||
## 8. 고려 사항
|
||||
- **보안**: API 키 안전한 관리 (.env 파일 사용)
|
||||
- **성능**: 대용량 PDF 파일 처리 최적화
|
||||
- **사용자 경험**: 직관적인 UI 및 명확한 피드백
|
||||
- **오류 처리**: 포괄적인 예외 처리 및 사용자 알림
|
||||
- **호환성**: 다양한 운영체제에서의 동작 확인
|
||||
|
||||
## 9. 버전 관리
|
||||
- Python: 3.9+
|
||||
- Flet: 0.25.1+
|
||||
- google-genai: 1.0+
|
||||
- PyMuPDF: 1.26.3+ 또는 pdf2image: 1.17.0+
|
||||
- ezdxf: 1.4.2+ (NEW - DXF 파일 처리)
|
||||
- numpy: 1.24.0+ (NEW - 좌표 계산)
|
||||
- python-dotenv: 1.0.0+
|
||||
|
||||
## 10. 다음 단계
|
||||
1. requirements.txt 파일 작성
|
||||
2. 기본 프로젝트 구조 생성
|
||||
3. 메인 애플리케이션 뼈대 구현
|
||||
4. PDF 처리 모듈 구현
|
||||
5. Gemini API 연동 모듈 구현
|
||||
|
||||
---
|
||||
**최종 업데이트**: 2025-07-09
|
||||
**현재 진행률**: 100% (DXF 파일 지원 기능 통합 완료)
|
||||
|
||||
## 12. 최근 업데이트 (2025-07-09)
|
||||
|
||||
### 12.1 새로 구현된 기능
|
||||
1. **조직별 스키마 선택 시스템**
|
||||
- 국토교통부: 일반 토목/건설 도면 표준 스키마
|
||||
- 한국도로공사: 고속도로 전용 도면 스키마
|
||||
- UI에서 드롭다운으로 선택 가능
|
||||
|
||||
2. **gemini_analyzer.py 확장**
|
||||
- `organization_type` 매개변수 추가
|
||||
- 동적 스키마 선택 로직 구현
|
||||
- `schema_transportation`, `schema_expressway` 분리
|
||||
|
||||
3. **UI 컴포넌트 개선**
|
||||
- `create_analysis_settings_section_with_refs()` 함수 추가
|
||||
- 조직별 설명 텍스트 포함
|
||||
- 직관적인 선택 인터페이스 제공
|
||||
|
||||
4. **main.py 통합**
|
||||
- 조직 선택 이벤트 핸들러 추가
|
||||
- 분석 시 선택된 조직 유형 전달
|
||||
- 결과에 조직 정보 포함
|
||||
|
||||
### 12.2 기술적 개선사항
|
||||
- 30개 이상 웹사이트 심층 연구 완료
|
||||
- Flet 최신 드롭다운 API 활용
|
||||
- Gemini API Structured Output 최신 기능 적용
|
||||
- 한국 건설 표준 및 도로공사 규격 조사
|
||||
|
||||
### 12.3 UI 레이아웃 개선 세부사항 (2025-07-09)
|
||||
- 창 크기 조정: 1400x900 기본, 최소 1200x800
|
||||
- ResponsiveRow 반응형 브레이크포인트: sm(12), md(5/7), lg(4/8)
|
||||
- PDF 뷰어 모달: 650x750 크기, 이미지 600x700 컴테이너
|
||||
- 50개 이상의 웹사이트 연구로 Flet 최신 기능 적용
|
||||
|
||||
### 12.4 DXF 파일 지원 구현 완료 (2025-07-09) ✅
|
||||
|
||||
**완성된 기능:**
|
||||
1. **파일 형식 확장**: requirements.txt에 ezdxf v1.4.2+, numpy v1.24.0+ 추가
|
||||
2. **DXF 처리 모듈**: dxf_processor.py 완전 구현
|
||||
- Block Reference 추출 및 분석
|
||||
- Attribute Reference에서 도곽 정보 추출
|
||||
- 도곽 식별 로직 (건설분야, 건설단계, 도면명, 축척, 도면번호)
|
||||
- Text Bounding Box 좌표 계산
|
||||
- 최외곽 Bounding Box 계산
|
||||
3. **설정 업데이트**: config.py에 DXF 파일 지원 추가
|
||||
4. **UI 업데이트**: ui_components.py에 PDF/DXF 지원 텍스트 추가
|
||||
5. **메인 애플리케이션**: main.py 기본 구조 수정 및 DXF 지원 메서드 설계
|
||||
6. **지원 메서드**: dxf_support_methods.py에 다음 기능 구현
|
||||
- DXF 파일 선택 처리
|
||||
- DXF 분석 실행 로직
|
||||
- DXF 결과 표시 UI
|
||||
|
||||
**기술적 세부사항:**
|
||||
- ezdxf 라이브러리를 통한 DXF 파일 파싱
|
||||
- Block Reference 순회 및 Attribute Reference 추출
|
||||
- 도곽 식별을 위한 키워드 매칭 알고리즘
|
||||
- numpy를 이용한 좌표 계산 및 Bounding Box 처리
|
||||
- 데이터클래스를 통한 구조화된 데이터 처리
|
||||
- 포괄적인 오류 처리 및 로깅 시스템
|
||||
|
||||
**다음 단계 (완료):**
|
||||
1. ✅ main.py에 dxf_support_methods.py의 메서드들 통합 완료
|
||||
2. ✅ 파일 선택 로직 DXF 지원으로 완전 업데이트 완료
|
||||
3. ✅ DXF 분석 결과 UI 통합 및 테스트 완료
|
||||
4. ✅ 전체 기능 통합 및 테스트 완료
|
||||
|
||||
### 12.6 DXF 지원 기능 통합 완료 (2025-07-09) ✅
|
||||
|
||||
**완성된 통합 작업:**
|
||||
1. **파일 선택기 확장**: PDF와 DXF 파일 확장자 모두 지원
|
||||
2. **파일 선택 로직 업데이트**:
|
||||
- `on_file_selected` 메서드를 PDF/DXF 파일 타입 자동 감지로 완전 교체
|
||||
- `_handle_pdf_file_selection`과 `_handle_dxf_file_selection` 메서드 추가
|
||||
- `_reset_file_state` 메서드로 파일 상태 초기화
|
||||
3. **분석 실행 로직 확장**:
|
||||
- `run_analysis` 메서드를 PDF/DXF 타입별 분석으로 완전 교체
|
||||
- `_run_pdf_analysis`와 `_run_dxf_analysis` 메서드 분리
|
||||
- `display_dxf_analysis_results` 메서드 추가
|
||||
4. **UI 통합**:
|
||||
- 파일 타입별 미리보기 활성화/비활성화
|
||||
- DXF 분석 결과 전용 UI 표시
|
||||
- 파일 정보 표시 개선 (PDF/DXF 구분)
|
||||
5. **변수명 통일**:
|
||||
- `current_pdf_path` → `current_file_path`
|
||||
- `current_file_type` 변수로 PDF/DXF 구분
|
||||
- `DocumentAnalyzerApp` 클래스명 통일
|
||||
6. **저장 기능 확장**: PDF와 DXF 분석 결과 모두 지원
|
||||
|
||||
**기술적 성과:**
|
||||
- 단일 애플리케이션에서 PDF(Gemini API)와 DXF(ezdxf) 분석 완전 지원
|
||||
- 파일 타입 자동 감지 및 적절한 분석 방법 선택
|
||||
- 20개 이상의 웹사이트 연구를 통한 최신 기술 적용
|
||||
- Flet 프레임워크의 FilePicker 최신 기능 활용
|
||||
- 모듈화된 코드 구조로 유지보수성 향상
|
||||
|
||||
### 12.7 프로젝트 완료
|
||||
- ✅ PDF/DXF 통합 문서 분석기 완전 구현
|
||||
- ✅ 모든 핵심 기능 동작 확인
|
||||
- ✅ 사용자 인터페이스 최종 완성
|
||||
- ✅ 프로젝트 목표 100% 달성
|
||||
|
||||
## 11. 구현 완료된 파일들
|
||||
- ✅ `config.py` - 환경 변수 및 설정 관리
|
||||
- ✅ `pdf_processor.py` - PDF 처리 및 이미지 변환
|
||||
- ✅ `gemini_analyzer.py` - Gemini API 연동 (조직별 스키마 지원)
|
||||
- ✅ `ui_components.py` - UI 컴포넌트 정의 (조직 선택 기능 포함)
|
||||
- ✅ `main.py` - 메인 애플리케이션 (조직별 분석 통합)
|
||||
- ✅ `requirements.txt` - 의존성 목록
|
||||
- ✅ `.env.example` - 환경 변수 템플릿
|
||||
- ✅ `advanced_features.py` - 고급 기능 모듈
|
||||
- ✅ `utils.py` - 유틸리티 함수들
|
||||
- ✅ `test_project.py` - 테스트 스크립트
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 PDF Drawing Analyzer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
266
README.md
Normal file
266
README.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# PDF/DXF 문서 분석기
|
||||
|
||||
Flet 기반의 PDF 및 DXF 파일 업로드 및 분석 애플리케이션입니다. PDF 파일은 Google Gemini AI를 통해 이미지 분석을, DXF 파일은 ezdxf 라이브러리를 통해 도곽 정보 및 Block Reference/Attribute Reference를 추출하여 상세한 정보를 제공합니다.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 🌟 주요 기능
|
||||
|
||||
### PDF 분석 기능
|
||||
|
||||
- 📄 **PDF 파일 업로드**: 간편한 드래그 앤 드롭 인터페이스
|
||||
- 🔍 **AI 이미지 분석**: Google Gemini API를 통한 고급 이미지 분석
|
||||
- 🏢 **조직별 스키마**: 국토교통부/한국도로공사 전용 분석 스키마
|
||||
- 👁️ **PDF 뷰어 모달**: 별도 창에서 PDF 미리보기 및 페이지 네비게이션
|
||||
|
||||
### DXF 분석 기능 (NEW)
|
||||
|
||||
- 🏗️ **DXF 파일 지원**: CAD 도면 파일 (.dxf) 업로드 및 분석
|
||||
- 📐 **도곽 정보 추출**: 도면명, 도면번호, 건설분야, 건설단계, 축척 등
|
||||
- 🔧 **Block Reference 분석**: 블록 참조 및 속성 정보 완전 추출
|
||||
- 📋 **Attribute Reference**: 모든 속성의 tag, text, prompt, position, bounding box 정보
|
||||
- 📏 **바운딩 박스 계산**: 텍스트 및 블록의 정확한 좌표 정보
|
||||
- 🎯 **ATTDEF 정보 수집**: 블록 정의에서 프롬프트 정보 자동 매핑
|
||||
|
||||
### 공통 기능
|
||||
|
||||
- 📊 **실시간 진행률**: 분석 과정을 실시간으로 확인
|
||||
- 🎨 **현대적인 UI**: 좌우 분할 레이아웃 및 Material Design 기반 인터페이스
|
||||
- ⚙️ **다양한 분석 모드**: 기본, 상세, 사용자 정의 분석
|
||||
- 💾 **결과 저장**: 분석 결과를 텍스트/JSON 파일로 저장
|
||||
- 📱 **반응형 디자인**: 다양한 화면 크기에 대응하는 인터페이스
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. 요구 사항
|
||||
|
||||
- Python 3.9 이상
|
||||
- Google Gemini API 키
|
||||
|
||||
### 2. 설치
|
||||
|
||||
```bash
|
||||
# 저장소 클론
|
||||
git clone https://github.com/your-username/pdf-analyzer.git
|
||||
cd pdf-analyzer
|
||||
|
||||
# 가상 환경 생성 (권장)
|
||||
python -m venv venv
|
||||
|
||||
# 가상 환경 활성화
|
||||
# Windows:
|
||||
venv\\Scripts\\activate
|
||||
# macOS/Linux:
|
||||
source venv/bin/activate
|
||||
|
||||
# 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. 환경 설정
|
||||
|
||||
1. `.env.example` 파일을 `.env`로 복사:
|
||||
|
||||
```bash
|
||||
copy .env.example .env # Windows
|
||||
cp .env.example .env # macOS/Linux
|
||||
```
|
||||
|
||||
2. `.env` 파일을 편집하여 Gemini API 키 설정:
|
||||
|
||||
```env
|
||||
GEMINI_API_KEY=your_actual_gemini_api_key_here
|
||||
```
|
||||
|
||||
### 4. 실행
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## 🛠️ 설정
|
||||
|
||||
### 환경 변수
|
||||
|
||||
`.env` 파일에서 다음 설정을 조정할 수 있습니다:
|
||||
|
||||
```env
|
||||
# 필수: Gemini API 키
|
||||
GEMINI_API_KEY=your_gemini_api_key
|
||||
|
||||
# 애플리케이션 설정
|
||||
APP_TITLE=PDF 도면 분석기
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=False
|
||||
|
||||
# 파일 업로드 설정
|
||||
MAX_FILE_SIZE_MB=50
|
||||
ALLOWED_EXTENSIONS=pdf
|
||||
UPLOAD_FOLDER=uploads
|
||||
|
||||
# Gemini API 설정
|
||||
GEMINI_MODEL=gemini-2.5-pro
|
||||
DEFAULT_PROMPT=pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.
|
||||
```
|
||||
|
||||
### Gemini API 키 획득
|
||||
|
||||
1. [Google AI Studio](https://makersuite.google.com/app/apikey)에 접속
|
||||
2. Google 계정으로 로그인
|
||||
3. "Create API Key" 클릭
|
||||
4. 생성된 API 키를 `.env` 파일에 추가
|
||||
|
||||
## 📖 사용법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
1. **PDF 파일 선택**: "PDF 파일 선택" 버튼을 클릭하여 분석할 PDF 파일을 선택합니다.
|
||||
|
||||
2. **분석 설정**:
|
||||
|
||||
- **페이지 선택**: 첫 번째 페이지만 또는 모든 페이지 분석 선택
|
||||
- **분석 모드**: 기본, 상세, 사용자 정의 중 선택
|
||||
|
||||
3. **분석 시작**: "분석 시작" 버튼을 클릭하여 AI 분석을 시작합니다.
|
||||
|
||||
4. **결과 확인**: 분석 완료 후 결과를 확인하고 필요시 저장합니다.
|
||||
|
||||
### 분석 모드
|
||||
|
||||
- **기본 분석**: 문서 유형 및 기본 정보 분석
|
||||
- **상세 분석**: 도면, 도표, 텍스트 등 상세 정보 분석
|
||||
- **사용자 정의**: 원하는 분석 내용을 직접 입력
|
||||
|
||||
## 🏗️ 프로젝트 구조
|
||||
|
||||
```
|
||||
fletimageanalysis/
|
||||
├── main.py # 메인 애플리케이션
|
||||
├── config.py # 설정 관리
|
||||
├── pdf_processor.py # PDF 처리 모듈
|
||||
├── gemini_analyzer.py # Gemini API 연동
|
||||
├── ui_components.py # UI 컴포넌트
|
||||
├── requirements.txt # 의존성 목록
|
||||
├── .env.example # 환경 변수 템플릿
|
||||
├── uploads/ # 업로드 폴더
|
||||
├── assets/ # 자산 폴더
|
||||
└── docs/ # 문서 폴더
|
||||
```
|
||||
|
||||
## 🔧 개발
|
||||
|
||||
### 개발 환경 설정
|
||||
|
||||
```bash
|
||||
# 개발용 의존성 설치
|
||||
pip install black flake8 pytest
|
||||
|
||||
# 코드 포맷팅
|
||||
black .
|
||||
|
||||
# 코드 검사
|
||||
flake8 .
|
||||
|
||||
# 테스트 실행
|
||||
pytest
|
||||
```
|
||||
|
||||
### 모듈 설명
|
||||
|
||||
#### `pdf_processor.py`
|
||||
|
||||
- PDF 파일 검증 및 정보 추출
|
||||
- PDF 페이지를 이미지로 변환
|
||||
- Base64 인코딩 처리
|
||||
|
||||
#### `gemini_analyzer.py`
|
||||
|
||||
- Gemini API 클라이언트 관리
|
||||
- 이미지 분석 요청 및 응답 처리
|
||||
- 스트리밍 분석 지원
|
||||
|
||||
#### `ui_components.py`
|
||||
|
||||
- Flet UI 컴포넌트 정의
|
||||
- 재사용 가능한 UI 요소들
|
||||
- Material Design 스타일 적용
|
||||
|
||||
#### `main.py`
|
||||
|
||||
- 메인 애플리케이션 로직
|
||||
- 이벤트 처리 및 UI 통합
|
||||
- 백그라운드 작업 관리
|
||||
|
||||
## 🐛 문제 해결
|
||||
|
||||
### 일반적인 문제들
|
||||
|
||||
**1. API 키 오류**
|
||||
|
||||
```
|
||||
오류: Gemini API 키가 설정되지 않았습니다.
|
||||
해결: .env 파일에 올바른 GEMINI_API_KEY를 설정하세요.
|
||||
```
|
||||
|
||||
**2. PDF 파일 오류**
|
||||
|
||||
```
|
||||
오류: 유효하지 않은 PDF 파일입니다.
|
||||
해결: 손상되지 않은 PDF 파일을 사용하거나 다른 PDF로 시도하세요.
|
||||
```
|
||||
|
||||
**3. 의존성 설치 오류**
|
||||
|
||||
```bash
|
||||
# PyMuPDF 설치 문제가 있을 경우
|
||||
pip install --upgrade pip
|
||||
pip install PyMuPDF --no-cache-dir
|
||||
```
|
||||
|
||||
**4. 메모리 부족 오류**
|
||||
|
||||
```
|
||||
해결: 큰 PDF 파일의 경우 첫 번째 페이지만 분석하거나
|
||||
zoom 값을 낮춰서 이미지 크기를 줄이세요.
|
||||
```
|
||||
|
||||
### 로그 확인
|
||||
|
||||
애플리케이션 실행 시 콘솔에서 상세한 로그를 확인할 수 있습니다:
|
||||
|
||||
```bash
|
||||
python main.py 2>&1 | tee app.log
|
||||
```
|
||||
|
||||
## 🤝 기여하기
|
||||
|
||||
1. 이 저장소를 포크합니다
|
||||
2. 기능 브랜치를 생성합니다 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 변경사항을 커밋합니다 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 브랜치에 푸시합니다 (`git push origin feature/AmazingFeature`)
|
||||
5. Pull Request를 생성합니다
|
||||
|
||||
## 📝 라이선스
|
||||
|
||||
이 프로젝트는 MIT 라이선스 하에 배포됩니다. 자세한 내용은 [LICENSE](LICENSE) 파일을 참조하세요.
|
||||
|
||||
## 🙏 감사의 말
|
||||
|
||||
- [Flet](https://flet.dev/) - 뛰어난 Python UI 프레임워크
|
||||
- [Google Gemini](https://ai.google.dev/) - 강력한 AI 분석 API
|
||||
- [PyMuPDF](https://pymupdf.readthedocs.io/) - PDF 처리 라이브러리
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 있거나 질문이 있으시면 [Issues](https://github.com/your-username/pdf-analyzer/issues) 페이지에서 이슈를 생성해 주세요.
|
||||
|
||||
---
|
||||
|
||||
**🔗 관련 링크**
|
||||
|
||||
- [Flet 문서](https://flet.dev/docs/)
|
||||
- [Gemini API 문서](https://ai.google.dev/gemini-api/docs)
|
||||
- [PyMuPDF 문서](https://pymupdf.readthedocs.io/)
|
||||
428
advanced_features.py
Normal file
428
advanced_features.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
고급 기능 모듈
|
||||
PDF 미리보기, 고급 설정, 확장 기능들을 제공합니다.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import flet as ft
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PDFPreviewGenerator:
|
||||
"""PDF 미리보기 생성 클래스"""
|
||||
|
||||
def __init__(self, pdf_processor):
|
||||
self.pdf_processor = pdf_processor
|
||||
self.preview_cache = {}
|
||||
|
||||
def generate_preview(
|
||||
self,
|
||||
file_path: str,
|
||||
page_number: int = 0,
|
||||
max_size: tuple = (300, 400)
|
||||
) -> Optional[str]:
|
||||
"""PDF 페이지 미리보기 이미지 생성 (base64 반환)"""
|
||||
try:
|
||||
cache_key = f"{file_path}_{page_number}_{max_size}"
|
||||
|
||||
# 캐시 확인
|
||||
if cache_key in self.preview_cache:
|
||||
return self.preview_cache[cache_key]
|
||||
|
||||
# PDF 페이지를 이미지로 변환
|
||||
image = self.pdf_processor.convert_pdf_page_to_image(
|
||||
file_path, page_number, zoom=1.0
|
||||
)
|
||||
|
||||
if not image:
|
||||
return None
|
||||
|
||||
# 미리보기 크기로 조정
|
||||
preview_image = self._resize_for_preview(image, max_size)
|
||||
|
||||
# base64로 인코딩
|
||||
buffer = io.BytesIO()
|
||||
preview_image.save(buffer, format='PNG')
|
||||
base64_data = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
# 캐시에 저장
|
||||
self.preview_cache[cache_key] = base64_data
|
||||
|
||||
logger.info(f"PDF 미리보기 생성 완료: 페이지 {page_number + 1}")
|
||||
return base64_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"미리보기 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
def _resize_for_preview(self, image: Image.Image, max_size: tuple) -> Image.Image:
|
||||
"""이미지를 미리보기 크기로 조정"""
|
||||
# 비율 유지하면서 크기 조정
|
||||
image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
|
||||
# 캔버스 생성 (회색 배경)
|
||||
canvas = Image.new('RGB', max_size, color='#f0f0f0')
|
||||
|
||||
# 이미지를 중앙에 배치
|
||||
x = (max_size[0] - image.width) // 2
|
||||
y = (max_size[1] - image.height) // 2
|
||||
canvas.paste(image, (x, y))
|
||||
|
||||
return canvas
|
||||
|
||||
def clear_cache(self):
|
||||
"""캐시 정리"""
|
||||
self.preview_cache.clear()
|
||||
logger.info("미리보기 캐시 정리 완료")
|
||||
|
||||
class AdvancedSettings:
|
||||
"""고급 설정 관리 클래스"""
|
||||
|
||||
def __init__(self, settings_file: str = "settings.json"):
|
||||
self.settings_file = Path(settings_file)
|
||||
self.default_settings = {
|
||||
"ui": {
|
||||
"theme_mode": "light",
|
||||
"window_width": 1200,
|
||||
"window_height": 800,
|
||||
"auto_save_results": False,
|
||||
"show_preview": True
|
||||
},
|
||||
"processing": {
|
||||
"default_zoom": 2.0,
|
||||
"image_format": "PNG",
|
||||
"jpeg_quality": 95,
|
||||
"max_pages_batch": 5
|
||||
},
|
||||
"analysis": {
|
||||
"default_mode": "basic",
|
||||
"save_format": "both", # text, json, both
|
||||
"auto_analyze": False,
|
||||
"custom_prompts": []
|
||||
}
|
||||
}
|
||||
self.settings = self.load_settings()
|
||||
|
||||
def load_settings(self) -> Dict[str, Any]:
|
||||
"""설정 파일 로드"""
|
||||
try:
|
||||
if self.settings_file.exists():
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
loaded_settings = json.load(f)
|
||||
# 기본 설정과 병합
|
||||
settings = self.default_settings.copy()
|
||||
self._deep_merge(settings, loaded_settings)
|
||||
return settings
|
||||
else:
|
||||
return self.default_settings.copy()
|
||||
except Exception as e:
|
||||
logger.error(f"설정 로드 실패: {e}")
|
||||
return self.default_settings.copy()
|
||||
|
||||
def save_settings(self) -> bool:
|
||||
"""설정 파일 저장"""
|
||||
try:
|
||||
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.settings, f, indent=2, ensure_ascii=False)
|
||||
logger.info("설정 저장 완료")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"설정 저장 실패: {e}")
|
||||
return False
|
||||
|
||||
def get(self, section: str, key: str, default=None):
|
||||
"""설정 값 조회"""
|
||||
return self.settings.get(section, {}).get(key, default)
|
||||
|
||||
def set(self, section: str, key: str, value):
|
||||
"""설정 값 변경"""
|
||||
if section not in self.settings:
|
||||
self.settings[section] = {}
|
||||
self.settings[section][key] = value
|
||||
|
||||
def _deep_merge(self, base: dict, update: dict):
|
||||
"""딕셔너리 깊은 병합"""
|
||||
for key, value in update.items():
|
||||
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||
self._deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
|
||||
class ErrorHandler:
|
||||
"""고급 오류 처리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.error_log = []
|
||||
self.error_callbacks = []
|
||||
|
||||
def handle_error(
|
||||
self,
|
||||
error: Exception,
|
||||
context: str = "",
|
||||
user_message: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""오류 처리 및 정보 수집"""
|
||||
error_info = {
|
||||
"timestamp": self._get_timestamp(),
|
||||
"error_type": type(error).__name__,
|
||||
"error_message": str(error),
|
||||
"context": context,
|
||||
"user_message": user_message or self._get_user_friendly_message(error)
|
||||
}
|
||||
|
||||
# 오류 로그에 추가
|
||||
self.error_log.append(error_info)
|
||||
|
||||
# 로거에 기록
|
||||
logger.error(f"[{context}] {error_info['error_type']}: {error_info['error_message']}")
|
||||
|
||||
# 콜백 실행
|
||||
for callback in self.error_callbacks:
|
||||
try:
|
||||
callback(error_info)
|
||||
except Exception as e:
|
||||
logger.error(f"오류 콜백 실행 실패: {e}")
|
||||
|
||||
return error_info
|
||||
|
||||
def add_error_callback(self, callback):
|
||||
"""오류 발생 시 실행할 콜백 추가"""
|
||||
self.error_callbacks.append(callback)
|
||||
|
||||
def get_recent_errors(self, count: int = 10) -> List[Dict[str, Any]]:
|
||||
"""최근 오류 목록 반환"""
|
||||
return self.error_log[-count:]
|
||||
|
||||
def clear_error_log(self):
|
||||
"""오류 로그 정리"""
|
||||
self.error_log.clear()
|
||||
|
||||
def _get_timestamp(self) -> str:
|
||||
"""현재 타임스탬프 반환"""
|
||||
from datetime import datetime
|
||||
return datetime.now().isoformat()
|
||||
|
||||
def _get_user_friendly_message(self, error: Exception) -> str:
|
||||
"""사용자 친화적 오류 메시지 생성"""
|
||||
error_type = type(error).__name__
|
||||
|
||||
friendly_messages = {
|
||||
"FileNotFoundError": "파일을 찾을 수 없습니다. 파일 경로를 확인해주세요.",
|
||||
"PermissionError": "파일에 접근할 권한이 없습니다. 관리자 권한으로 실행해보세요.",
|
||||
"ConnectionError": "네트워크 연결에 문제가 있습니다. 인터넷 연결을 확인해주세요.",
|
||||
"TimeoutError": "요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.",
|
||||
"ValueError": "입력 값이 올바르지 않습니다. 설정을 확인해주세요.",
|
||||
"KeyError": "필요한 설정 값이 없습니다. 설정을 다시 확인해주세요.",
|
||||
"ImportError": "필요한 라이브러리가 설치되지 않았습니다. 의존성을 확인해주세요."
|
||||
}
|
||||
|
||||
return friendly_messages.get(error_type, "예상치 못한 오류가 발생했습니다.")
|
||||
|
||||
class AnalysisHistory:
|
||||
"""분석 히스토리 관리 클래스"""
|
||||
|
||||
def __init__(self, history_file: str = "analysis_history.json"):
|
||||
self.history_file = Path(history_file)
|
||||
self.history = self.load_history()
|
||||
|
||||
def load_history(self) -> List[Dict[str, Any]]:
|
||||
"""히스토리 파일 로드"""
|
||||
try:
|
||||
if self.history_file.exists():
|
||||
with open(self.history_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"히스토리 로드 실패: {e}")
|
||||
return []
|
||||
|
||||
def save_history(self):
|
||||
"""히스토리 파일 저장"""
|
||||
try:
|
||||
with open(self.history_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.history, f, indent=2, ensure_ascii=False)
|
||||
logger.info("히스토리 저장 완료")
|
||||
except Exception as e:
|
||||
logger.error(f"히스토리 저장 실패: {e}")
|
||||
|
||||
def add_analysis(
|
||||
self,
|
||||
pdf_filename: str,
|
||||
analysis_mode: str,
|
||||
pages_analyzed: int,
|
||||
analysis_time: float,
|
||||
success: bool = True
|
||||
):
|
||||
"""분석 기록 추가"""
|
||||
record = {
|
||||
"timestamp": self._get_timestamp(),
|
||||
"pdf_filename": pdf_filename,
|
||||
"analysis_mode": analysis_mode,
|
||||
"pages_analyzed": pages_analyzed,
|
||||
"analysis_time": analysis_time,
|
||||
"success": success
|
||||
}
|
||||
|
||||
self.history.append(record)
|
||||
|
||||
# 최대 100개 기록 유지
|
||||
if len(self.history) > 100:
|
||||
self.history = self.history[-100:]
|
||||
|
||||
self.save_history()
|
||||
|
||||
def get_recent_analyses(self, count: int = 10) -> List[Dict[str, Any]]:
|
||||
"""최근 분석 기록 반환"""
|
||||
return self.history[-count:]
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""분석 통계 반환"""
|
||||
if not self.history:
|
||||
return {
|
||||
"total_analyses": 0,
|
||||
"successful_analyses": 0,
|
||||
"total_pages": 0,
|
||||
"average_time": 0,
|
||||
"most_used_mode": None
|
||||
}
|
||||
|
||||
successful = [h for h in self.history if h["success"]]
|
||||
|
||||
mode_counts = {}
|
||||
for h in self.history:
|
||||
mode = h["analysis_mode"]
|
||||
mode_counts[mode] = mode_counts.get(mode, 0) + 1
|
||||
|
||||
return {
|
||||
"total_analyses": len(self.history),
|
||||
"successful_analyses": len(successful),
|
||||
"total_pages": sum(h["pages_analyzed"] for h in self.history),
|
||||
"average_time": sum(h["analysis_time"] for h in successful) / len(successful) if successful else 0,
|
||||
"most_used_mode": max(mode_counts.items(), key=lambda x: x[1])[0] if mode_counts else None
|
||||
}
|
||||
|
||||
def clear_history(self):
|
||||
"""히스토리 정리"""
|
||||
self.history.clear()
|
||||
self.save_history()
|
||||
|
||||
def _get_timestamp(self) -> str:
|
||||
"""현재 타임스탬프 반환"""
|
||||
from datetime import datetime
|
||||
return datetime.now().isoformat()
|
||||
|
||||
class CustomPromptManager:
|
||||
"""사용자 정의 프롬프트 관리 클래스"""
|
||||
|
||||
def __init__(self, prompts_file: str = "custom_prompts.json"):
|
||||
self.prompts_file = Path(prompts_file)
|
||||
self.prompts = self.load_prompts()
|
||||
|
||||
def load_prompts(self) -> List[Dict[str, str]]:
|
||||
"""프롬프트 파일 로드"""
|
||||
try:
|
||||
if self.prompts_file.exists():
|
||||
with open(self.prompts_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
return self._get_default_prompts()
|
||||
except Exception as e:
|
||||
logger.error(f"프롬프트 로드 실패: {e}")
|
||||
return self._get_default_prompts()
|
||||
|
||||
def save_prompts(self):
|
||||
"""프롬프트 파일 저장"""
|
||||
try:
|
||||
with open(self.prompts_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.prompts, f, indent=2, ensure_ascii=False)
|
||||
logger.info("프롬프트 저장 완료")
|
||||
except Exception as e:
|
||||
logger.error(f"프롬프트 저장 실패: {e}")
|
||||
|
||||
def add_prompt(self, name: str, content: str, description: str = ""):
|
||||
"""새 프롬프트 추가"""
|
||||
prompt = {
|
||||
"name": name,
|
||||
"content": content,
|
||||
"description": description,
|
||||
"created_at": self._get_timestamp()
|
||||
}
|
||||
self.prompts.append(prompt)
|
||||
self.save_prompts()
|
||||
|
||||
def get_prompt(self, name: str) -> Optional[str]:
|
||||
"""프롬프트 내용 조회"""
|
||||
for prompt in self.prompts:
|
||||
if prompt["name"] == name:
|
||||
return prompt["content"]
|
||||
return None
|
||||
|
||||
def get_prompt_list(self) -> List[str]:
|
||||
"""프롬프트 이름 목록 반환"""
|
||||
return [prompt["name"] for prompt in self.prompts]
|
||||
|
||||
def delete_prompt(self, name: str) -> bool:
|
||||
"""프롬프트 삭제"""
|
||||
for i, prompt in enumerate(self.prompts):
|
||||
if prompt["name"] == name:
|
||||
del self.prompts[i]
|
||||
self.save_prompts()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_default_prompts(self) -> List[Dict[str, str]]:
|
||||
"""기본 프롬프트 목록 반환"""
|
||||
return [
|
||||
{
|
||||
"name": "기본 도면 분석",
|
||||
"content": "이 도면을 분석하여 문서 유형, 주요 내용, 치수 정보를 알려주세요.",
|
||||
"description": "일반적인 도면 분석용",
|
||||
"created_at": self._get_timestamp()
|
||||
},
|
||||
{
|
||||
"name": "건축 도면 분석",
|
||||
"content": "이 건축 도면을 분석하여 건물 유형, 평면 구조, 주요 치수, 방의 용도를 상세히 설명해주세요.",
|
||||
"description": "건축 도면 전용",
|
||||
"created_at": self._get_timestamp()
|
||||
},
|
||||
{
|
||||
"name": "기계 도면 분석",
|
||||
"content": "이 기계 도면을 분석하여 부품명, 치수, 공차, 재료, 가공 방법을 파악해주세요.",
|
||||
"description": "기계 설계 도면 전용",
|
||||
"created_at": self._get_timestamp()
|
||||
}
|
||||
]
|
||||
|
||||
def _get_timestamp(self) -> str:
|
||||
"""현재 타임스탬프 반환"""
|
||||
from datetime import datetime
|
||||
return datetime.now().isoformat()
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
# 고급 기능 테스트
|
||||
print("고급 기능 모듈 테스트")
|
||||
|
||||
# 설정 관리 테스트
|
||||
settings = AdvancedSettings()
|
||||
print(f"현재 테마: {settings.get('ui', 'theme_mode')}")
|
||||
|
||||
# 오류 처리 테스트
|
||||
error_handler = ErrorHandler()
|
||||
try:
|
||||
raise ValueError("테스트 오류")
|
||||
except Exception as e:
|
||||
error_info = error_handler.handle_error(e, "테스트 컨텍스트")
|
||||
print(f"오류 처리: {error_info['user_message']}")
|
||||
|
||||
# 프롬프트 관리 테스트
|
||||
prompt_manager = CustomPromptManager()
|
||||
prompts = prompt_manager.get_prompt_list()
|
||||
print(f"사용 가능한 프롬프트: {prompts}")
|
||||
109
back_src/SIMPLE_BATCH_GUIDE.md
Normal file
109
back_src/SIMPLE_BATCH_GUIDE.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 간단한 PDF 배치 분석기 사용법
|
||||
|
||||
## 🎯 개요
|
||||
사용자 요구사항: **"복잡하게 하지 말고 기존 모듈 그대로 사용해서 여러 개 처리하고 CSV로 만들기"**
|
||||
|
||||
✅ **완전 구현 완료!** getcode.py와 똑같은 방식으로 여러 PDF를 처리하고 결과를 CSV로 저장하는 시스템이 준비되어 있습니다.
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### 방법 1: 간단한 실행기 사용
|
||||
```bash
|
||||
python run_simple_batch.py
|
||||
```
|
||||
|
||||
### 방법 2: 직접 실행
|
||||
```bash
|
||||
python simple_batch_analyzer_app.py
|
||||
```
|
||||
|
||||
## 📱 UI 사용법
|
||||
|
||||
1. **📂 파일 선택**: "PDF 파일 선택" 버튼 클릭 또는 드래그&드롭
|
||||
2. **✏️ 프롬프트 설정** (선택사항): 기본값은 getcode.py와 동일
|
||||
- 기본: "pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘"
|
||||
3. **▶️ 분석 시작**: "배치 분석 시작" 버튼 클릭
|
||||
4. **📊 진행률 확인**: 실시간 진행률 및 처리 상태 표시
|
||||
5. **💾 결과 확인**: 자동으로 CSV 파일 저장 및 요약 통계 표시
|
||||
|
||||
## 📄 CSV 출력 형식
|
||||
|
||||
생성되는 CSV 파일에는 다음 컬럼들이 포함됩니다:
|
||||
|
||||
| 컬럼명 | 설명 |
|
||||
|--------|------|
|
||||
| file_name | 파일 이름 |
|
||||
| file_size_mb | 파일 크기 (MB) |
|
||||
| processing_time_seconds | 처리 시간 (초) |
|
||||
| success | 성공 여부 (True/False) |
|
||||
| analysis_result | getcode.py 스타일 분석 결과 |
|
||||
| analysis_timestamp | 분석 완료 시간 |
|
||||
| prompt_used | 사용된 프롬프트 |
|
||||
| model_used | 사용된 AI 모델 |
|
||||
| error_message | 오류 메시지 (실패시) |
|
||||
| processed_at | 처리 완료 시간 |
|
||||
|
||||
## 🔧 환경 설정
|
||||
|
||||
### 1. API 키 설정
|
||||
`.env` 파일에 Gemini API 키 설정:
|
||||
```
|
||||
GEMINI_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
### 2. 필요 패키지 설치
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
주요 패키지:
|
||||
- `flet>=0.25.1` - UI 프레임워크
|
||||
- `google-genai>=1.0` - Gemini API
|
||||
- `PyMuPDF>=1.26.3` - PDF 처리
|
||||
- `pandas>=1.5.0` - CSV 출력
|
||||
- `python-dotenv>=1.0.0` - 환경변수
|
||||
|
||||
## ⚡ 핵심 특징
|
||||
|
||||
- ✨ **단순성**: getcode.py와 동일한 방식, 복잡한 기능 제거
|
||||
- 🔄 **배치 처리**: 한 번에 여러 PDF 파일 처리
|
||||
- 📊 **CSV 출력**: JSON 분석 결과를 자동으로 CSV 변환
|
||||
- ⚡ **성능**: 비동기 처리로 빠른 배치 분석
|
||||
- 📱 **사용성**: 직관적이고 간단한 UI
|
||||
|
||||
## 🗂️ 파일 저장 위치
|
||||
|
||||
- CSV 결과: `D:/MYCLAUDE_PROJECT/fletimageanalysis/results/`
|
||||
- 파일명 형식: `batch_analysis_results_YYYY-MM-DD_HH-MM-SS.csv`
|
||||
|
||||
## 🎯 예상 사용 시나리오
|
||||
|
||||
1. **여러 도면 PDF 분석**: 한 번에 10-50개 도면 파일 분석
|
||||
2. **일괄 메타데이터 추출**: 도면 정보, 제목, 축척 등 추출
|
||||
3. **분석 결과 관리**: CSV로 저장하여 Excel에서 관리
|
||||
4. **품질 보증**: 성공률 및 처리 시간 통계로 분석 품질 확인
|
||||
|
||||
## 🔍 문제 해결
|
||||
|
||||
### Q: 분석이 실패하는 경우
|
||||
A:
|
||||
- Gemini API 키가 올바르게 설정되었는지 확인
|
||||
- PDF 파일이 손상되지 않았는지 확인
|
||||
- 인터넷 연결 상태 확인
|
||||
|
||||
### Q: UI가 실행되지 않는 경우
|
||||
A:
|
||||
- Python 3.9+ 버전 확인
|
||||
- Flet 패키지 설치 확인: `pip install flet`
|
||||
- 작업 디렉토리가 올바른지 확인
|
||||
|
||||
### Q: CSV 파일을 찾을 수 없는 경우
|
||||
A:
|
||||
- `results/` 폴더가 자동 생성됩니다
|
||||
- 완료 메시지에서 정확한 파일 경로 확인
|
||||
- 파일 권한 문제 확인
|
||||
|
||||
## 📞 지원
|
||||
|
||||
이 시스템은 사용자 요구사항에 맞춰 **단순하고 직관적**으로 설계되었습니다.
|
||||
getcode.py의 장점을 그대로 유지하면서 배치 처리 기능만 추가했습니다.
|
||||
94
back_src/create_test_dxf.py
Normal file
94
back_src/create_test_dxf.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
테스트용 DXF 파일 생성 스크립트
|
||||
도곽 블록과 속성을 포함한 간단한 DXF 파일 생성
|
||||
"""
|
||||
|
||||
import ezdxf
|
||||
import os
|
||||
|
||||
def create_test_dxf():
|
||||
"""테스트용 DXF 파일 생성"""
|
||||
# 새 문서 생성
|
||||
doc = ezdxf.new('R2010')
|
||||
|
||||
# 모델스페이스 가져오기
|
||||
msp = doc.modelspace()
|
||||
|
||||
# 도곽 블록 생성
|
||||
title_block = doc.blocks.new(name='TITLE_BLOCK')
|
||||
|
||||
# 블록에 기본 도형 추가 (도곽 테두리)
|
||||
title_block.add_lwpolyline([
|
||||
(0, 0), (210, 0), (210, 297), (0, 297), (0, 0)
|
||||
], dxfattribs={'layer': 'BORDER'})
|
||||
|
||||
# 도곽 블록에 속성 정의 추가
|
||||
title_block.add_attdef('DRAWING_NAME', (150, 20),
|
||||
dxfattribs={'height': 5, 'prompt': '도면명'})
|
||||
title_block.add_attdef('DRAWING_NUMBER', (150, 15),
|
||||
dxfattribs={'height': 3, 'prompt': '도면번호'})
|
||||
title_block.add_attdef('SCALE', (150, 10),
|
||||
dxfattribs={'height': 3, 'prompt': '축척'})
|
||||
title_block.add_attdef('DESIGNER', (150, 5),
|
||||
dxfattribs={'height': 3, 'prompt': '설계자'})
|
||||
title_block.add_attdef('DATE', (200, 5),
|
||||
dxfattribs={'height': 3, 'prompt': '날짜'})
|
||||
|
||||
# 블록에 일반 텍스트도 추가
|
||||
title_block.add_text('도면 제목', dxfattribs={'height': 4, 'insert': (10, 280)})
|
||||
title_block.add_text('프로젝트명', dxfattribs={'height': 3, 'insert': (10, 275)})
|
||||
|
||||
# 모델스페이스에 도곽 블록 참조 추가
|
||||
blockref = msp.add_blockref('TITLE_BLOCK', (0, 0))
|
||||
|
||||
# 블록 참조에 속성 값 추가
|
||||
blockref.add_auto_attribs({
|
||||
'DRAWING_NAME': '평면도 및 종단면도',
|
||||
'DRAWING_NUMBER': 'DWG-001',
|
||||
'SCALE': '1:1000',
|
||||
'DESIGNER': '김설계',
|
||||
'DATE': '2025-07-09'
|
||||
})
|
||||
|
||||
# 추가 블록 생성 (일반 블록)
|
||||
detail_block = doc.blocks.new(name='DETAIL_MARK')
|
||||
detail_block.add_circle((0, 0), 5)
|
||||
detail_block.add_attdef('DETAIL_NO', (0, 0),
|
||||
dxfattribs={'height': 3, 'prompt': '상세번호'})
|
||||
|
||||
# 상세 마크 블록 참조 추가
|
||||
detail_ref = msp.add_blockref('DETAIL_MARK', (50, 50))
|
||||
detail_ref.add_auto_attribs({'DETAIL_NO': 'A'})
|
||||
|
||||
detail_ref2 = msp.add_blockref('DETAIL_MARK', (100, 100))
|
||||
detail_ref2.add_auto_attribs({'DETAIL_NO': 'B'})
|
||||
|
||||
# 독립적인 텍스트 엔티티 추가
|
||||
msp.add_text('독립 텍스트 1', dxfattribs={'height': 5, 'insert': (30, 150)})
|
||||
msp.add_mtext('여러줄\n텍스트', dxfattribs={'char_height': 4, 'insert': (30, 130)})
|
||||
|
||||
return doc
|
||||
|
||||
def main():
|
||||
"""메인 함수"""
|
||||
try:
|
||||
# 테스트 DXF 파일 생성
|
||||
doc = create_test_dxf()
|
||||
|
||||
# uploads 폴더 생성
|
||||
os.makedirs('uploads', exist_ok=True)
|
||||
|
||||
# 파일 저장
|
||||
output_path = 'uploads/test_drawing.dxf'
|
||||
doc.saveas(output_path)
|
||||
|
||||
print(f"[SUCCESS] 테스트 DXF 파일 생성 완료: {output_path}")
|
||||
print(" - TITLE_BLOCK: 도곽 블록 (5개 속성)")
|
||||
print(" - DETAIL_MARK: 상세 마크 블록 (2개 인스턴스)")
|
||||
print(" - 독립 텍스트 엔티티 2개")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] DXF 파일 생성 실패: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
313
back_src/dxf_support_methods.py
Normal file
313
back_src/dxf_support_methods.py
Normal file
@@ -0,0 +1,313 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DXF 파일 지원을 위한 추가 메서드들
|
||||
main.py에 추가할 메서드들을 정의합니다.
|
||||
"""
|
||||
|
||||
def handle_file_selection_update(self, e):
|
||||
"""
|
||||
기존 on_file_selected 메서드를 DXF 지원으로 업데이트하는 로직
|
||||
이 메서드들을 main.py에 추가하거나 기존 메서드를 대체해야 합니다.
|
||||
"""
|
||||
|
||||
def on_file_selected_updated(self, e):
|
||||
"""파일 선택 결과 핸들러 - PDF/DXF 지원"""
|
||||
if e.files:
|
||||
file = e.files[0]
|
||||
self.current_file_path = file.path
|
||||
|
||||
# 파일 확장자로 타입 결정
|
||||
file_extension = file.path.lower().split('.')[-1]
|
||||
|
||||
if file_extension == 'pdf':
|
||||
self.current_file_type = 'pdf'
|
||||
self._handle_pdf_file_selection(file)
|
||||
elif file_extension == 'dxf':
|
||||
self.current_file_type = 'dxf'
|
||||
self._handle_dxf_file_selection(file)
|
||||
else:
|
||||
self.selected_file_text.value = f"❌ 지원하지 않는 파일 형식입니다: {file_extension}"
|
||||
self.selected_file_text.color = ft.Colors.RED_600
|
||||
self.upload_button.disabled = True
|
||||
self.pdf_preview_button.disabled = True
|
||||
self._reset_file_state()
|
||||
else:
|
||||
self.selected_file_text.value = "선택된 파일이 없습니다"
|
||||
self.selected_file_text.color = ft.Colors.GREY_600
|
||||
self.upload_button.disabled = True
|
||||
self.pdf_preview_button.disabled = True
|
||||
self._reset_file_state()
|
||||
|
||||
self.page.update()
|
||||
|
||||
def _handle_pdf_file_selection(self, file):
|
||||
"""PDF 파일 선택 처리"""
|
||||
if self.pdf_processor.validate_pdf_file(self.current_file_path):
|
||||
# PDF 정보 조회
|
||||
self.current_pdf_info = self.pdf_processor.get_pdf_info(self.current_file_path)
|
||||
|
||||
# 파일 크기 정보 추가
|
||||
file_size_mb = self.current_pdf_info['file_size'] / (1024 * 1024)
|
||||
file_info = f"✅ {file.name} (PDF)\n📄 {self.current_pdf_info['page_count']}페이지, {file_size_mb:.1f}MB"
|
||||
self.selected_file_text.value = file_info
|
||||
self.selected_file_text.color = ft.Colors.GREEN_600
|
||||
self.upload_button.disabled = False
|
||||
self.pdf_preview_button.disabled = False
|
||||
|
||||
# 페이지 정보 업데이트
|
||||
self.page_info_text.value = f"1 / {self.current_pdf_info['page_count']}"
|
||||
self.current_page_index = 0
|
||||
|
||||
logger.info(f"PDF 파일 선택됨: {file.name}")
|
||||
else:
|
||||
self.selected_file_text.value = "❌ 유효하지 않은 PDF 파일입니다"
|
||||
self.selected_file_text.color = ft.Colors.RED_600
|
||||
self.upload_button.disabled = True
|
||||
self.pdf_preview_button.disabled = True
|
||||
self._reset_file_state()
|
||||
|
||||
def _handle_dxf_file_selection(self, file):
|
||||
"""DXF 파일 선택 처리"""
|
||||
try:
|
||||
if self.dxf_processor.validate_dxf_file(self.current_file_path):
|
||||
# DXF 파일 크기 계산
|
||||
import os
|
||||
file_size_mb = os.path.getsize(self.current_file_path) / (1024 * 1024)
|
||||
|
||||
file_info = f"✅ {file.name} (DXF)\n🏗️ CAD 도면 파일, {file_size_mb:.1f}MB"
|
||||
self.selected_file_text.value = file_info
|
||||
self.selected_file_text.color = ft.Colors.GREEN_600
|
||||
self.upload_button.disabled = False
|
||||
self.pdf_preview_button.disabled = True # DXF는 미리보기 비활성화
|
||||
|
||||
# DXF는 페이지 개념이 없으므로 기본값 설정
|
||||
self.page_info_text.value = "DXF 파일"
|
||||
self.current_page_index = 0
|
||||
self.current_pdf_info = None # DXF는 PDF 정보 없음
|
||||
|
||||
logger.info(f"DXF 파일 선택됨: {file.name}")
|
||||
else:
|
||||
self.selected_file_text.value = "❌ 유효하지 않은 DXF 파일입니다"
|
||||
self.selected_file_text.color = ft.Colors.RED_600
|
||||
self.upload_button.disabled = True
|
||||
self.pdf_preview_button.disabled = True
|
||||
self._reset_file_state()
|
||||
except Exception as e:
|
||||
logger.error(f"DXF 파일 검증 오류: {e}")
|
||||
self.selected_file_text.value = f"❌ DXF 파일 처리 오류: {str(e)}"
|
||||
self.selected_file_text.color = ft.Colors.RED_600
|
||||
self.upload_button.disabled = True
|
||||
self.pdf_preview_button.disabled = True
|
||||
self._reset_file_state()
|
||||
|
||||
def _reset_file_state(self):
|
||||
"""파일 상태 초기화"""
|
||||
self.current_file_path = None
|
||||
self.current_file_type = None
|
||||
self.current_pdf_info = None
|
||||
|
||||
def run_analysis_updated(self):
|
||||
"""분석 실행 (백그라운드 스레드) - PDF/DXF 지원"""
|
||||
try:
|
||||
self.analysis_start_time = time.time()
|
||||
|
||||
if self.current_file_type == 'pdf':
|
||||
self._run_pdf_analysis()
|
||||
elif self.current_file_type == 'dxf':
|
||||
self._run_dxf_analysis()
|
||||
else:
|
||||
raise ValueError(f"지원하지 않는 파일 타입: {self.current_file_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"분석 중 오류 발생: {e}")
|
||||
self.update_progress_ui(False, f"❌ 분석 오류: {str(e)}")
|
||||
self.show_error_dialog("분석 오류", f"분석 중 오류가 발생했습니다:\n{str(e)}")
|
||||
|
||||
def _run_pdf_analysis(self):
|
||||
"""PDF 파일 분석 실행"""
|
||||
self.update_progress_ui(True, "PDF 이미지 변환 중...")
|
||||
|
||||
# 조직 유형 결정
|
||||
organization_type = "transportation"
|
||||
if self.organization_selector and self.organization_selector.value:
|
||||
if self.organization_selector.value == "한국도로공사":
|
||||
organization_type = "expressway"
|
||||
else:
|
||||
organization_type = "transportation"
|
||||
|
||||
logger.info(f"선택된 조직 유형: {organization_type}")
|
||||
|
||||
# 분석할 페이지 결정
|
||||
if self.page_selector.value == "첫 번째 페이지":
|
||||
pages_to_analyze = [0]
|
||||
else:
|
||||
pages_to_analyze = list(range(self.current_pdf_info['page_count']))
|
||||
|
||||
# 분석 프롬프트 결정
|
||||
if self.analysis_mode.value == "custom":
|
||||
prompt = self.custom_prompt.value or Config.DEFAULT_PROMPT
|
||||
elif self.analysis_mode.value == "detailed":
|
||||
prompt = "이 PDF 이미지를 자세히 분석하여 다음 정보를 제공해주세요: 1) 문서 유형, 2) 주요 내용, 3) 도면/도표 정보, 4) 텍스트 내용, 5) 기타 특징"
|
||||
else:
|
||||
prompt = Config.DEFAULT_PROMPT
|
||||
|
||||
# 페이지별 분석 수행
|
||||
total_pages = len(pages_to_analyze)
|
||||
self.analysis_results = {}
|
||||
|
||||
for i, page_num in enumerate(pages_to_analyze):
|
||||
progress = (i + 1) / total_pages
|
||||
self.update_progress_ui(
|
||||
True,
|
||||
f"페이지 {page_num + 1} 분석 중... ({i + 1}/{total_pages})",
|
||||
progress
|
||||
)
|
||||
|
||||
# PDF 페이지를 base64로 변환
|
||||
base64_data = self.pdf_processor.pdf_page_to_base64(
|
||||
self.current_file_path,
|
||||
page_num
|
||||
)
|
||||
|
||||
if base64_data:
|
||||
# Gemini API로 분석
|
||||
result = self.gemini_analyzer.analyze_image_from_base64(
|
||||
base64_data=base64_data,
|
||||
prompt=prompt,
|
||||
organization_type=organization_type
|
||||
)
|
||||
|
||||
if result:
|
||||
self.analysis_results[page_num] = result
|
||||
else:
|
||||
self.analysis_results[page_num] = f"페이지 {page_num + 1} 분석 실패"
|
||||
else:
|
||||
self.analysis_results[page_num] = f"페이지 {page_num + 1} 이미지 변환 실패"
|
||||
|
||||
# 결과 표시
|
||||
self.display_analysis_results()
|
||||
|
||||
# 완료 상태로 업데이트
|
||||
if self.analysis_start_time:
|
||||
duration = time.time() - self.analysis_start_time
|
||||
duration_str = DateTimeUtils.format_duration(duration)
|
||||
self.update_progress_ui(False, f"✅ PDF 분석 완료! (소요시간: {duration_str})", 1.0)
|
||||
else:
|
||||
self.update_progress_ui(False, "✅ PDF 분석 완료!", 1.0)
|
||||
|
||||
def _run_dxf_analysis(self):
|
||||
"""DXF 파일 분석 실행"""
|
||||
self.update_progress_ui(True, "DXF 파일 분석 중...")
|
||||
|
||||
try:
|
||||
# DXF 파일 처리
|
||||
result = self.dxf_processor.process_dxf_file(self.current_file_path)
|
||||
|
||||
if result['success']:
|
||||
# 분석 결과 포맷팅
|
||||
self.analysis_results = {'dxf': result}
|
||||
|
||||
# 결과 표시
|
||||
self.display_dxf_analysis_results(result)
|
||||
|
||||
# 완료 상태로 업데이트
|
||||
if self.analysis_start_time:
|
||||
duration = time.time() - self.analysis_start_time
|
||||
duration_str = DateTimeUtils.format_duration(duration)
|
||||
self.update_progress_ui(False, f"✅ DXF 분석 완료! (소요시간: {duration_str})", 1.0)
|
||||
else:
|
||||
self.update_progress_ui(False, "✅ DXF 분석 완료!", 1.0)
|
||||
else:
|
||||
error_msg = result.get('error', '알 수 없는 오류')
|
||||
self.update_progress_ui(False, f"❌ DXF 분석 실패: {error_msg}")
|
||||
self.show_error_dialog("DXF 분석 오류", f"DXF 파일 분석에 실패했습니다:\n{error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DXF 분석 중 오류: {e}")
|
||||
self.update_progress_ui(False, f"❌ DXF 분석 오류: {str(e)}")
|
||||
self.show_error_dialog("DXF 분석 오류", f"DXF 분석 중 오류가 발생했습니다:\n{str(e)}")
|
||||
|
||||
def display_dxf_analysis_results(self, dxf_result):
|
||||
"""DXF 분석 결과 표시"""
|
||||
def update_results():
|
||||
if dxf_result and dxf_result['success']:
|
||||
# 결과 텍스트 구성
|
||||
result_text = "🎯 DXF 분석 요약\n"
|
||||
result_text += f"📊 파일: {os.path.basename(dxf_result['file_path'])}\n"
|
||||
result_text += f"⏰ 완료 시간: {DateTimeUtils.get_timestamp()}\n"
|
||||
result_text += "=" * 60 + "\n\n"
|
||||
|
||||
# 요약 정보
|
||||
summary = dxf_result.get('summary', {})
|
||||
result_text += "📋 분석 요약\n"
|
||||
result_text += "-" * 40 + "\n"
|
||||
result_text += f"전체 블록 수: {summary.get('total_blocks', 0)}\n"
|
||||
result_text += f"도곽 블록 발견: {'예' if summary.get('title_block_found', False) else '아니오'}\n"
|
||||
result_text += f"속성 수: {summary.get('attributes_count', 0)}\n"
|
||||
|
||||
if summary.get('title_block_name'):
|
||||
result_text += f"도곽 블록명: {summary['title_block_name']}\n"
|
||||
|
||||
result_text += "\n"
|
||||
|
||||
# 도곽 정보
|
||||
title_block = dxf_result.get('title_block')
|
||||
if title_block:
|
||||
result_text += "🏗️ 도곽 정보\n"
|
||||
result_text += "-" * 40 + "\n"
|
||||
|
||||
fields = {
|
||||
'drawing_name': '도면명',
|
||||
'drawing_number': '도면번호',
|
||||
'construction_field': '건설분야',
|
||||
'construction_stage': '건설단계',
|
||||
'scale': '축척',
|
||||
'project_name': '프로젝트명',
|
||||
'designer': '설계자',
|
||||
'date': '날짜',
|
||||
'revision': '리비전',
|
||||
'location': '위치'
|
||||
}
|
||||
|
||||
for field, label in fields.items():
|
||||
value = title_block.get(field)
|
||||
if value:
|
||||
result_text += f"{label}: {value}\n"
|
||||
|
||||
# 바운딩 박스 정보
|
||||
bbox = title_block.get('bounding_box')
|
||||
if bbox:
|
||||
result_text += f"\n📐 도곽 위치 정보\n"
|
||||
result_text += f"좌하단: ({bbox['min_x']:.2f}, {bbox['min_y']:.2f})\n"
|
||||
result_text += f"우상단: ({bbox['max_x']:.2f}, {bbox['max_y']:.2f})\n"
|
||||
result_text += f"크기: {bbox['max_x'] - bbox['min_x']:.2f} × {bbox['max_y'] - bbox['min_y']:.2f}\n"
|
||||
|
||||
# 블록 참조 정보
|
||||
block_refs = dxf_result.get('block_references', [])
|
||||
if block_refs:
|
||||
result_text += f"\n📦 블록 참조 목록 ({len(block_refs)}개)\n"
|
||||
result_text += "-" * 40 + "\n"
|
||||
|
||||
for i, block_ref in enumerate(block_refs[:10]): # 최대 10개까지만 표시
|
||||
result_text += f"{i+1}. {block_ref.get('name', 'Unknown')}"
|
||||
if block_ref.get('attributes'):
|
||||
result_text += f" (속성 {len(block_ref['attributes'])}개)"
|
||||
result_text += "\n"
|
||||
|
||||
if len(block_refs) > 10:
|
||||
result_text += f"... 외 {len(block_refs) - 10}개 블록\n"
|
||||
|
||||
self.results_text.value = result_text.strip()
|
||||
|
||||
# 저장 버튼 활성화
|
||||
self.save_text_button.disabled = False
|
||||
self.save_json_button.disabled = False
|
||||
else:
|
||||
self.results_text.value = "❌ DXF 분석 결과가 없습니다."
|
||||
self.save_text_button.disabled = True
|
||||
self.save_json_button.disabled = True
|
||||
|
||||
self.page.update()
|
||||
|
||||
# 메인 스레드에서 UI 업데이트
|
||||
self.page.run_thread(update_results)
|
||||
408
back_src/gemini_analyzer_backup.py
Normal file
408
back_src/gemini_analyzer_backup.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Gemini API 연동 모듈
|
||||
Google Gemini API를 사용하여 이미지 분석을 수행합니다.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from typing import Optional, Dict, Any
|
||||
from config import Config
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GeminiAnalyzer:
|
||||
"""Gemini API 이미지 분석 클래스"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
|
||||
"""
|
||||
GeminiAnalyzer 초기화
|
||||
|
||||
Args:
|
||||
api_key: Gemini API 키 (None인 경우 환경변수에서 가져옴)
|
||||
model: 사용할 모델명 (기본값: 설정에서 가져옴)
|
||||
"""
|
||||
self.api_key = api_key or Config.GEMINI_API_KEY
|
||||
self.model = model or Config.GEMINI_MODEL
|
||||
self.default_prompt = Config.DEFAULT_PROMPT
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("Gemini API 키가 설정되지 않았습니다.")
|
||||
|
||||
# Gemini 클라이언트 초기화
|
||||
try:
|
||||
self.client = genai.Client(api_key=self.api_key)
|
||||
logger.info(f"Gemini 클라이언트 초기화 완료 (모델: {self.model})")
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 클라이언트 초기화 실패: {e}")
|
||||
raise
|
||||
|
||||
def analyze_image_from_base64(
|
||||
self,
|
||||
base64_data: str,
|
||||
prompt: Optional[str] = None,
|
||||
mime_type: str = "image/png",
|
||||
organization_type: str = "transportation"
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Base64 이미지 데이터를 분석합니다.
|
||||
|
||||
Args:
|
||||
base64_data: Base64로 인코딩된 이미지 데이터
|
||||
prompt: 분석 요청 텍스트 (None인 경우 기본값 사용)
|
||||
mime_type: 이미지 MIME 타입
|
||||
organization_type: 조직 유형 ("transportation" 또는 "expressway")
|
||||
|
||||
Returns:
|
||||
분석 결과 텍스트 또는 None (실패 시)
|
||||
"""
|
||||
try:
|
||||
analysis_prompt = prompt or self.default_prompt
|
||||
|
||||
# 컨텐츠 구성
|
||||
contents = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_bytes(
|
||||
mime_type=mime_type,
|
||||
data=base64.b64decode(base64_data),
|
||||
),
|
||||
types.Part.from_text(text=analysis_prompt),
|
||||
],
|
||||
)
|
||||
]
|
||||
schema_expressway=genai.types.Schema(
|
||||
type = genai.types.Type.OBJECT,
|
||||
required = ["사업명", "시설/공구", "건설분야", "건설단계"],
|
||||
properties = {
|
||||
"사업명": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"노선이정": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"설계사": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"시공사": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"건설분야": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"건설단계": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"계정번호": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"(계정)날짜": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"(개정)내용": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"작성자": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"검토자": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"확인자": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"설계공구": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"시공공구": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"도면번호": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"도면축척": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"도면명": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"편철번호": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"적용표준버전": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"Note": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"Title": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"기타정보": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
},
|
||||
)
|
||||
schema_transportation=genai.types.Schema(
|
||||
type = genai.types.Type.OBJECT,
|
||||
required = ["사업명", "시설/공구", "건설분야", "건설단계"],
|
||||
properties = {
|
||||
"사업명": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"시설/공구": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"건설분야": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"건설단계": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"계정차수": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"계정일자": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"개정내용": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"과업책임자": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"분야별책임자": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"설계자": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"위치정보": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"축척": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"도면번호": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"도면명": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"편철번호": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"적용표준": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"Note": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"Title": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
"기타정보": genai.types.Schema(
|
||||
type = genai.types.Type.STRING,
|
||||
),
|
||||
},
|
||||
)
|
||||
# 조직 유형에 따른 스키마 선택
|
||||
if organization_type == "expressway":
|
||||
selected_schema = schema_expressway
|
||||
else: # transportation (기본값)
|
||||
selected_schema = schema_transportation
|
||||
|
||||
# 생성 설정
|
||||
generate_content_config = types.GenerateContentConfig(
|
||||
temperature=0,
|
||||
top_p=0.05,
|
||||
thinking_config = types.ThinkingConfig(
|
||||
thinking_budget=0,
|
||||
),
|
||||
response_mime_type="application/json",
|
||||
response_schema= selected_schema
|
||||
)
|
||||
|
||||
logger.info("Gemini API 분석 요청 시작...")
|
||||
|
||||
# API 호출
|
||||
response = self.client.models.generate_content(
|
||||
model=self.model,
|
||||
contents=contents,
|
||||
config=generate_content_config,
|
||||
)
|
||||
|
||||
if response and hasattr(response, 'text'):
|
||||
result = response.text
|
||||
logger.info(f"분석 완료: {len(result)} 문자")
|
||||
return result
|
||||
else:
|
||||
logger.error("API 응답에서 텍스트를 찾을 수 없습니다.")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이미지 분석 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
def analyze_image_stream_from_base64(
|
||||
self,
|
||||
base64_data: str,
|
||||
prompt: Optional[str] = None,
|
||||
mime_type: str = "image/png",
|
||||
organization_type: str = "transportation"
|
||||
):
|
||||
"""
|
||||
Base64 이미지 데이터를 스트리밍으로 분석합니다.
|
||||
|
||||
Args:
|
||||
base64_data: Base64로 인코딩된 이미지 데이터
|
||||
prompt: 분석 요청 텍스트
|
||||
mime_type: 이미지 MIME 타입
|
||||
organization_type: 조직 유형 ("transportation" 또는 "expressway")
|
||||
|
||||
Yields:
|
||||
분석 결과 텍스트 청크
|
||||
"""
|
||||
try:
|
||||
analysis_prompt = prompt or self.default_prompt
|
||||
|
||||
# 컨텐츠 구성
|
||||
contents = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_bytes(
|
||||
mime_type=mime_type,
|
||||
data=base64.b64decode(base64_data),
|
||||
),
|
||||
types.Part.from_text(text=analysis_prompt),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
# 조직 유형에 따른 스키마 선택
|
||||
if organization_type == "expressway":
|
||||
selected_schema = schema_expressway
|
||||
else: # transportation (기본값)
|
||||
selected_schema = schema_transportation
|
||||
|
||||
# 생성 설정
|
||||
generate_content_config = types.GenerateContentConfig(
|
||||
temperature=0,
|
||||
top_p=0.05,
|
||||
thinking_config = types.ThinkingConfig(
|
||||
thinking_budget=0,
|
||||
),
|
||||
response_mime_type="application/json",
|
||||
response_schema=selected_schema,
|
||||
)
|
||||
|
||||
logger.info("Gemini API 스트리밍 분석 요청 시작...")
|
||||
|
||||
# 스트리밍 API 호출
|
||||
for chunk in self.client.models.generate_content_stream(
|
||||
model=self.model,
|
||||
contents=contents,
|
||||
config=generate_content_config,
|
||||
):
|
||||
if hasattr(chunk, 'text') and chunk.text:
|
||||
yield chunk.text
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"스트리밍 이미지 분석 중 오류 발생: {e}")
|
||||
yield f"오류: {str(e)}"
|
||||
|
||||
def analyze_pdf_images(
|
||||
self,
|
||||
base64_images: list,
|
||||
prompt: Optional[str] = None,
|
||||
mime_type: str = "image/png",
|
||||
organization_type: str = "transportation"
|
||||
) -> Dict[int, str]:
|
||||
"""
|
||||
여러 PDF 페이지 이미지를 일괄 분석합니다.
|
||||
|
||||
Args:
|
||||
base64_images: Base64 이미지 데이터 리스트
|
||||
prompt: 분석 요청 텍스트
|
||||
mime_type: 이미지 MIME 타입
|
||||
organization_type: 조직 유형 ("transportation" 또는 "expressway")
|
||||
|
||||
Returns:
|
||||
페이지 번호별 분석 결과 딕셔너리
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for i, base64_data in enumerate(base64_images):
|
||||
logger.info(f"페이지 {i + 1}/{len(base64_images)} 분석 중...")
|
||||
|
||||
result = self.analyze_image_from_base64(
|
||||
base64_data=base64_data,
|
||||
prompt=prompt,
|
||||
mime_type=mime_type,
|
||||
organization_type=organization_type
|
||||
)
|
||||
|
||||
if result:
|
||||
results[i] = result
|
||||
else:
|
||||
results[i] = f"페이지 {i + 1} 분석 실패"
|
||||
|
||||
logger.info(f"총 {len(results)}개 페이지 분석 완료")
|
||||
return results
|
||||
|
||||
def validate_api_connection(self) -> bool:
|
||||
"""API 연결 상태를 확인합니다."""
|
||||
try:
|
||||
# 간단한 텍스트 생성으로 연결 테스트
|
||||
test_response = self.client.models.generate_content(
|
||||
model=self.model,
|
||||
contents=[types.Content(
|
||||
role="user",
|
||||
parts=[types.Part.from_text(text="안녕하세요. 연결 테스트입니다.")]
|
||||
)],
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0,
|
||||
max_output_tokens=10,
|
||||
)
|
||||
)
|
||||
|
||||
if test_response and hasattr(test_response, 'text'):
|
||||
logger.info("Gemini API 연결 테스트 성공")
|
||||
return True
|
||||
else:
|
||||
logger.error("Gemini API 연결 테스트 실패")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini API 연결 테스트 중 오류: {e}")
|
||||
return False
|
||||
|
||||
def get_model_info(self) -> Dict[str, Any]:
|
||||
"""현재 사용 중인 모델 정보를 반환합니다."""
|
||||
return {
|
||||
"model": self.model,
|
||||
"api_key_length": len(self.api_key) if self.api_key else 0,
|
||||
"default_prompt": self.default_prompt
|
||||
}
|
||||
|
||||
# 사용 예시 및 테스트
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
# 분석기 초기화
|
||||
analyzer = GeminiAnalyzer()
|
||||
|
||||
# 연결 테스트
|
||||
if analyzer.validate_api_connection():
|
||||
print("Gemini API 연결 성공!")
|
||||
print(f"모델 정보: {analyzer.get_model_info()}")
|
||||
else:
|
||||
print("Gemini API 연결 실패!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"초기화 실패: {e}")
|
||||
print("API 키가 올바르게 설정되었는지 확인하세요.")
|
||||
723
back_src/main_old.py
Normal file
723
back_src/main_old.py
Normal file
@@ -0,0 +1,723 @@
|
||||
"""
|
||||
PDF 도면 분석기 - 메인 애플리케이션 (업데이트됨)
|
||||
Flet 기반의 PDF 업로드 및 Gemini API 이미지 분석 애플리케이션
|
||||
"""
|
||||
|
||||
import flet as ft
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
import time
|
||||
|
||||
# 프로젝트 모듈 임포트
|
||||
from config import Config
|
||||
from pdf_processor import PDFProcessor
|
||||
from gemini_analyzer import GeminiAnalyzer
|
||||
from ui_components import UIComponents
|
||||
from utils import AnalysisResultSaver, DateTimeUtils
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PDFAnalyzerApp:
|
||||
"""PDF 분석기 메인 애플리케이션 클래스"""
|
||||
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self.pdf_processor = PDFProcessor()
|
||||
self.gemini_analyzer = None
|
||||
self.current_pdf_path = None
|
||||
self.current_pdf_info = None
|
||||
self.analysis_results = {}
|
||||
self.result_saver = AnalysisResultSaver("results")
|
||||
self.analysis_start_time = None
|
||||
|
||||
# UI 컴포넌트 참조
|
||||
self.file_picker = None
|
||||
self.selected_file_text = None
|
||||
self.upload_button = None
|
||||
self.progress_bar = None
|
||||
self.progress_ring = None
|
||||
self.status_text = None
|
||||
self.results_text = None
|
||||
self.results_container = None
|
||||
self.save_button = None
|
||||
self.organization_selector = None # 새로 추가
|
||||
self.page_selector = None
|
||||
self.analysis_mode = None
|
||||
self.custom_prompt = None
|
||||
self.pdf_preview_container = None
|
||||
self.page_nav_text = None
|
||||
self.prev_button = None
|
||||
self.next_button = None
|
||||
|
||||
# 초기화
|
||||
self.setup_page()
|
||||
self.init_gemini_analyzer()
|
||||
|
||||
def setup_page(self):
|
||||
"""페이지 기본 설정"""
|
||||
self.page.title = Config.APP_TITLE
|
||||
self.page.theme_mode = ft.ThemeMode.LIGHT
|
||||
self.page.padding = 0
|
||||
self.page.bgcolor = ft.Colors.GREY_100
|
||||
|
||||
# 윈도우 크기 설정
|
||||
self.page.window_width = 1200
|
||||
self.page.window_height = 800
|
||||
self.page.window_min_width = 1000
|
||||
self.page.window_min_height = 700
|
||||
|
||||
logger.info("페이지 설정 완료")
|
||||
|
||||
def init_gemini_analyzer(self):
|
||||
"""Gemini 분석기 초기화"""
|
||||
try:
|
||||
config_errors = Config.validate_config()
|
||||
if config_errors:
|
||||
self.show_error_dialog(
|
||||
"설정 오류",
|
||||
"\\n".join(config_errors) + "\\n\\n.env 파일을 확인하세요."
|
||||
)
|
||||
return
|
||||
|
||||
self.gemini_analyzer = GeminiAnalyzer()
|
||||
logger.info("Gemini 분석기 초기화 완료")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 분석기 초기화 실패: {e}")
|
||||
self.show_error_dialog(
|
||||
"초기화 오류",
|
||||
f"Gemini API 초기화에 실패했습니다:\\n{str(e)}"
|
||||
)
|
||||
|
||||
def build_ui(self):
|
||||
"""UI 구성"""
|
||||
|
||||
# 앱바
|
||||
app_bar = UIComponents.create_app_bar()
|
||||
self.page.appbar = app_bar
|
||||
|
||||
# 파일 업로드 섹션
|
||||
upload_section = self.create_file_upload_section()
|
||||
|
||||
# 분석 설정 섹션
|
||||
settings_section = self.create_analysis_settings_section()
|
||||
|
||||
# 진행률 섹션
|
||||
progress_section = self.create_progress_section()
|
||||
|
||||
# 결과 및 미리보기 섹션
|
||||
content_row = ft.Row([
|
||||
ft.Column([
|
||||
self.create_results_section(),
|
||||
], expand=2),
|
||||
ft.Column([
|
||||
self.create_pdf_preview_section(),
|
||||
], expand=1),
|
||||
])
|
||||
|
||||
# 메인 레이아웃
|
||||
main_content = ft.Column([
|
||||
upload_section,
|
||||
settings_section,
|
||||
progress_section,
|
||||
content_row,
|
||||
], scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
# 페이지에 추가
|
||||
self.page.add(main_content)
|
||||
logger.info("UI 구성 완료")
|
||||
|
||||
def create_file_upload_section(self) -> ft.Container:
|
||||
"""파일 업로드 섹션 생성"""
|
||||
|
||||
# 파일 선택기
|
||||
self.file_picker = ft.FilePicker(on_result=self.on_file_selected)
|
||||
self.page.overlay.append(self.file_picker)
|
||||
|
||||
# 선택된 파일 정보
|
||||
self.selected_file_text = ft.Text(
|
||||
"선택된 파일이 없습니다",
|
||||
size=14,
|
||||
color=ft.Colors.GREY_600
|
||||
)
|
||||
|
||||
# 파일 선택 버튼
|
||||
select_button = ft.ElevatedButton(
|
||||
text="PDF 파일 선택",
|
||||
icon=ft.Icons.UPLOAD_FILE,
|
||||
on_click=self.on_select_file_click,
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.BLUE_100,
|
||||
color=ft.Colors.BLUE_800,
|
||||
)
|
||||
)
|
||||
|
||||
# 분석 시작 버튼
|
||||
self.upload_button = ft.ElevatedButton(
|
||||
text="분석 시작",
|
||||
icon=ft.Icons.ANALYTICS,
|
||||
on_click=self.on_analysis_start_click,
|
||||
disabled=True,
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.GREEN_100,
|
||||
color=ft.Colors.GREEN_800,
|
||||
)
|
||||
)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text(
|
||||
"📄 PDF 파일 업로드",
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.BLUE_800
|
||||
),
|
||||
ft.Divider(),
|
||||
ft.Row([
|
||||
select_button,
|
||||
self.upload_button,
|
||||
], alignment=ft.MainAxisAlignment.START),
|
||||
self.selected_file_text,
|
||||
]),
|
||||
padding=20,
|
||||
margin=10,
|
||||
bgcolor=ft.Colors.WHITE,
|
||||
border_radius=10,
|
||||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||||
)
|
||||
|
||||
def create_analysis_settings_section(self) -> ft.Container:
|
||||
"""분석 설정 섹션 생성"""
|
||||
|
||||
# UI 컴포넌트와 참조를 가져오기
|
||||
container, organization_selector, page_selector, analysis_mode, custom_prompt = \
|
||||
UIComponents.create_analysis_settings_section_with_refs()
|
||||
|
||||
# 인스턴스 변수에 참조 저장
|
||||
self.organization_selector = organization_selector
|
||||
self.page_selector = page_selector
|
||||
self.analysis_mode = analysis_mode
|
||||
self.custom_prompt = custom_prompt
|
||||
|
||||
# 이벤트 핸들러 설정
|
||||
self.analysis_mode.on_change = self.on_analysis_mode_change
|
||||
self.organization_selector.on_change = self.on_organization_change
|
||||
|
||||
return container
|
||||
|
||||
def create_progress_section(self) -> ft.Container:
|
||||
"""진행률 섹션 생성"""
|
||||
|
||||
# 진행률 바
|
||||
self.progress_bar = ft.ProgressBar(
|
||||
width=400,
|
||||
color=ft.Colors.BLUE_600,
|
||||
bgcolor=ft.Colors.GREY_300,
|
||||
visible=False,
|
||||
)
|
||||
|
||||
# 상태 텍스트
|
||||
self.status_text = ft.Text(
|
||||
"대기 중...",
|
||||
size=14,
|
||||
color=ft.Colors.GREY_600
|
||||
)
|
||||
|
||||
# 진행률 링
|
||||
self.progress_ring = ft.ProgressRing(
|
||||
width=50,
|
||||
height=50,
|
||||
stroke_width=4,
|
||||
visible=False,
|
||||
)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text(
|
||||
"📊 분석 진행 상황",
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.PURPLE_800
|
||||
),
|
||||
ft.Divider(),
|
||||
ft.Row([
|
||||
self.progress_ring,
|
||||
ft.Column([
|
||||
self.status_text,
|
||||
self.progress_bar,
|
||||
], expand=1),
|
||||
], alignment=ft.MainAxisAlignment.START),
|
||||
]),
|
||||
padding=20,
|
||||
margin=10,
|
||||
bgcolor=ft.Colors.WHITE,
|
||||
border_radius=10,
|
||||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||||
)
|
||||
|
||||
def create_results_section(self) -> ft.Container:
|
||||
"""결과 섹션 생성"""
|
||||
|
||||
# 결과 텍스트
|
||||
self.results_text = ft.Text(
|
||||
"분석 결과가 여기에 표시됩니다.",
|
||||
size=14,
|
||||
selectable=True,
|
||||
)
|
||||
|
||||
# 결과 컨테이너
|
||||
self.results_container = ft.Container(
|
||||
content=ft.Column([
|
||||
self.results_text,
|
||||
], scroll=ft.ScrollMode.AUTO),
|
||||
padding=15,
|
||||
height=350,
|
||||
bgcolor=ft.Colors.GREY_50,
|
||||
border_radius=8,
|
||||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||||
)
|
||||
|
||||
# 저장 버튼들
|
||||
save_text_button = ft.ElevatedButton(
|
||||
text="텍스트 저장",
|
||||
icon=ft.Icons.SAVE,
|
||||
disabled=True,
|
||||
on_click=self.on_save_text_click,
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.TEAL_100,
|
||||
color=ft.Colors.TEAL_800,
|
||||
)
|
||||
)
|
||||
|
||||
save_json_button = ft.ElevatedButton(
|
||||
text="JSON 저장",
|
||||
icon=ft.Icons.SAVE_ALT,
|
||||
disabled=True,
|
||||
on_click=self.on_save_json_click,
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=ft.Colors.INDIGO_100,
|
||||
color=ft.Colors.INDIGO_800,
|
||||
)
|
||||
)
|
||||
|
||||
# 저장 버튼들을 인스턴스 변수로 저장
|
||||
self.save_text_button = save_text_button
|
||||
self.save_json_button = save_json_button
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Row([
|
||||
ft.Text(
|
||||
"📋 분석 결과",
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.GREEN_800
|
||||
),
|
||||
ft.Row([
|
||||
save_text_button,
|
||||
save_json_button,
|
||||
]),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
ft.Divider(),
|
||||
self.results_container,
|
||||
]),
|
||||
padding=20,
|
||||
margin=10,
|
||||
bgcolor=ft.Colors.WHITE,
|
||||
border_radius=10,
|
||||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||||
)
|
||||
|
||||
def create_pdf_preview_section(self) -> ft.Container:
|
||||
"""PDF 미리보기 섹션 생성"""
|
||||
|
||||
# 미리보기 컨테이너
|
||||
self.pdf_preview_container = ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Icon(
|
||||
ft.Icons.PICTURE_AS_PDF,
|
||||
size=100,
|
||||
color=ft.Colors.GREY_400
|
||||
),
|
||||
ft.Text(
|
||||
"PDF 미리보기",
|
||||
size=14,
|
||||
color=ft.Colors.GREY_600
|
||||
)
|
||||
], alignment=ft.MainAxisAlignment.CENTER),
|
||||
width=300,
|
||||
height=400,
|
||||
bgcolor=ft.Colors.GREY_100,
|
||||
border_radius=8,
|
||||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||||
alignment=ft.alignment.center,
|
||||
)
|
||||
|
||||
# 페이지 네비게이션
|
||||
self.prev_button = ft.IconButton(
|
||||
icon=ft.Icons.ARROW_BACK,
|
||||
disabled=True,
|
||||
)
|
||||
|
||||
self.page_nav_text = ft.Text("1 / 1", size=14)
|
||||
|
||||
self.next_button = ft.IconButton(
|
||||
icon=ft.Icons.ARROW_FORWARD,
|
||||
disabled=True,
|
||||
)
|
||||
|
||||
page_nav = ft.Row([
|
||||
self.prev_button,
|
||||
self.page_nav_text,
|
||||
self.next_button,
|
||||
], alignment=ft.MainAxisAlignment.CENTER)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text(
|
||||
"👁️ PDF 미리보기",
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.INDIGO_800
|
||||
),
|
||||
ft.Divider(),
|
||||
self.pdf_preview_container,
|
||||
page_nav,
|
||||
], alignment=ft.MainAxisAlignment.START),
|
||||
padding=20,
|
||||
margin=10,
|
||||
bgcolor=ft.Colors.WHITE,
|
||||
border_radius=10,
|
||||
border=ft.border.all(1, ft.Colors.GREY_300),
|
||||
)
|
||||
|
||||
# 이벤트 핸들러들
|
||||
|
||||
def on_select_file_click(self, e):
|
||||
"""파일 선택 버튼 클릭 핸들러"""
|
||||
self.file_picker.pick_files(
|
||||
allowed_extensions=["pdf"],
|
||||
allow_multiple=False
|
||||
)
|
||||
|
||||
def on_file_selected(self, e: ft.FilePickerResultEvent):
|
||||
"""파일 선택 결과 핸들러"""
|
||||
if e.files:
|
||||
file = e.files[0]
|
||||
self.current_pdf_path = file.path
|
||||
|
||||
# 파일 검증
|
||||
if self.pdf_processor.validate_pdf_file(self.current_pdf_path):
|
||||
# PDF 정보 조회
|
||||
self.current_pdf_info = self.pdf_processor.get_pdf_info(self.current_pdf_path)
|
||||
|
||||
# 파일 크기 정보 추가
|
||||
file_size_mb = self.current_pdf_info['file_size'] / (1024 * 1024)
|
||||
file_info = f"📄 {file.name} ({self.current_pdf_info['page_count']}페이지, {file_size_mb:.1f}MB)"
|
||||
self.selected_file_text.value = file_info
|
||||
self.selected_file_text.color = ft.Colors.GREEN_600
|
||||
self.upload_button.disabled = False
|
||||
|
||||
# 페이지 네비게이션 업데이트
|
||||
self.page_nav_text.value = f"1 / {self.current_pdf_info['page_count']}"
|
||||
|
||||
logger.info(f"PDF 파일 선택됨: {file.name}")
|
||||
else:
|
||||
self.selected_file_text.value = "❌ 유효하지 않은 PDF 파일입니다"
|
||||
self.selected_file_text.color = ft.Colors.RED_600
|
||||
self.upload_button.disabled = True
|
||||
self.current_pdf_path = None
|
||||
self.current_pdf_info = None
|
||||
else:
|
||||
self.selected_file_text.value = "선택된 파일이 없습니다"
|
||||
self.selected_file_text.color = ft.Colors.GREY_600
|
||||
self.upload_button.disabled = True
|
||||
self.current_pdf_path = None
|
||||
self.current_pdf_info = None
|
||||
|
||||
self.page.update()
|
||||
|
||||
def on_analysis_mode_change(self, e):
|
||||
"""분석 모드 변경 핸들러"""
|
||||
if e.control.value == "custom":
|
||||
self.custom_prompt.visible = True
|
||||
else:
|
||||
self.custom_prompt.visible = False
|
||||
self.page.update()
|
||||
|
||||
def on_organization_change(self, e):
|
||||
"""조직 선택 변경 핸들러"""
|
||||
selected_org = e.control.value
|
||||
logger.info(f"조직 선택 변경: {selected_org}")
|
||||
# 필요한 경우 추가 작업 수행
|
||||
self.page.update()
|
||||
|
||||
def on_analysis_start_click(self, e):
|
||||
"""분석 시작 버튼 클릭 핸들러"""
|
||||
if not self.current_pdf_path or not self.gemini_analyzer:
|
||||
return
|
||||
|
||||
# 분석을 별도 스레드에서 실행
|
||||
threading.Thread(target=self.run_analysis, daemon=True).start()
|
||||
|
||||
def on_save_text_click(self, e):
|
||||
"""텍스트 저장 버튼 클릭 핸들러"""
|
||||
self._save_results("text")
|
||||
|
||||
def on_save_json_click(self, e):
|
||||
"""JSON 저장 버튼 클릭 핸들러"""
|
||||
self._save_results("json")
|
||||
|
||||
def _save_results(self, format_type: str):
|
||||
"""결과 저장 공통 함수"""
|
||||
if not self.analysis_results or not self.current_pdf_info:
|
||||
self.show_error_dialog("저장 오류", "저장할 분석 결과가 없습니다.")
|
||||
return
|
||||
|
||||
try:
|
||||
# 분석 설정 정보 수집
|
||||
analysis_settings = {
|
||||
"페이지_선택": self.page_selector.value,
|
||||
"분석_모드": self.analysis_mode.value,
|
||||
"사용자_정의_프롬프트": self.custom_prompt.value if self.analysis_mode.value == "custom" else None,
|
||||
"분석_시간": DateTimeUtils.get_timestamp()
|
||||
}
|
||||
|
||||
if format_type == "text":
|
||||
# 텍스트 파일로 저장
|
||||
saved_path = self.result_saver.save_analysis_results(
|
||||
pdf_filename=self.current_pdf_info['filename'],
|
||||
analysis_results=self.analysis_results,
|
||||
pdf_info=self.current_pdf_info,
|
||||
analysis_settings=analysis_settings
|
||||
)
|
||||
|
||||
if saved_path:
|
||||
self.show_info_dialog(
|
||||
"저장 완료",
|
||||
f"분석 결과가 텍스트 파일로 저장되었습니다:\\n\\n{saved_path}"
|
||||
)
|
||||
else:
|
||||
self.show_error_dialog("저장 실패", "텍스트 파일 저장 중 오류가 발생했습니다.")
|
||||
|
||||
elif format_type == "json":
|
||||
# JSON 파일로 저장
|
||||
saved_path = self.result_saver.save_analysis_json(
|
||||
pdf_filename=self.current_pdf_info['filename'],
|
||||
analysis_results=self.analysis_results,
|
||||
pdf_info=self.current_pdf_info,
|
||||
analysis_settings=analysis_settings
|
||||
)
|
||||
|
||||
if saved_path:
|
||||
self.show_info_dialog(
|
||||
"저장 완료",
|
||||
f"분석 결과가 JSON 파일로 저장되었습니다:\\n\\n{saved_path}"
|
||||
)
|
||||
else:
|
||||
self.show_error_dialog("저장 실패", "JSON 파일 저장 중 오류가 발생했습니다.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"결과 저장 중 오류: {e}")
|
||||
self.show_error_dialog("저장 오류", f"결과 저장 중 오류가 발생했습니다:\\n{str(e)}")
|
||||
|
||||
def run_analysis(self):
|
||||
"""분석 실행 (백그라운드 스레드)"""
|
||||
try:
|
||||
# 분석 시작 시간 기록
|
||||
self.analysis_start_time = time.time()
|
||||
|
||||
# UI 상태 업데이트
|
||||
self.update_progress_ui(True, "PDF 이미지 변환 중...")
|
||||
|
||||
# 조직 유형 결정
|
||||
organization_type = "transportation" # 기본값
|
||||
if self.organization_selector and self.organization_selector.value:
|
||||
if self.organization_selector.value == "한국도로공사":
|
||||
organization_type = "expressway"
|
||||
else:
|
||||
organization_type = "transportation"
|
||||
|
||||
logger.info(f"선택된 조직 유형: {organization_type}")
|
||||
|
||||
# 분석할 페이지 결정
|
||||
if self.page_selector.value == "첫 번째 페이지":
|
||||
pages_to_analyze = [0]
|
||||
else: # 모든 페이지
|
||||
pages_to_analyze = list(range(self.current_pdf_info['page_count']))
|
||||
|
||||
# 분석 프롬프트 결정
|
||||
if self.analysis_mode.value == "custom":
|
||||
prompt = self.custom_prompt.value or Config.DEFAULT_PROMPT
|
||||
elif self.analysis_mode.value == "detailed":
|
||||
prompt = "이 PDF 이미지를 자세히 분석하여 다음 정보를 제공해주세요: 1) 문서 유형, 2) 주요 내용, 3) 도면/도표 정보, 4) 텍스트 내용, 5) 기타 특징"
|
||||
else: # basic
|
||||
prompt = Config.DEFAULT_PROMPT
|
||||
|
||||
# 페이지별 분석 수행
|
||||
total_pages = len(pages_to_analyze)
|
||||
self.analysis_results = {}
|
||||
|
||||
for i, page_num in enumerate(pages_to_analyze):
|
||||
# 진행률 업데이트
|
||||
progress = (i + 1) / total_pages
|
||||
self.update_progress_ui(
|
||||
True,
|
||||
f"페이지 {page_num + 1} 분석 중... ({i + 1}/{total_pages})",
|
||||
progress
|
||||
)
|
||||
|
||||
# PDF 페이지를 base64로 변환
|
||||
base64_data = self.pdf_processor.pdf_page_to_base64(
|
||||
self.current_pdf_path,
|
||||
page_num
|
||||
)
|
||||
|
||||
if base64_data:
|
||||
# Gemini API로 분석 (조직 유형 전달)
|
||||
result = self.gemini_analyzer.analyze_image_from_base64(
|
||||
base64_data=base64_data,
|
||||
prompt=prompt,
|
||||
organization_type=organization_type
|
||||
)
|
||||
|
||||
if result:
|
||||
self.analysis_results[page_num] = result
|
||||
else:
|
||||
self.analysis_results[page_num] = f"페이지 {page_num + 1} 분석 실패"
|
||||
else:
|
||||
self.analysis_results[page_num] = f"페이지 {page_num + 1} 이미지 변환 실패"
|
||||
|
||||
# 결과 표시
|
||||
self.display_analysis_results()
|
||||
|
||||
# 완료 상태로 업데이트
|
||||
if self.analysis_start_time:
|
||||
duration = time.time() - self.analysis_start_time
|
||||
duration_str = DateTimeUtils.format_duration(duration)
|
||||
self.update_progress_ui(False, f"분석 완료! (소요시간: {duration_str})", 1.0)
|
||||
else:
|
||||
self.update_progress_ui(False, "분석 완료!", 1.0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"분석 중 오류 발생: {e}")
|
||||
self.update_progress_ui(False, f"분석 오류: {str(e)}")
|
||||
self.show_error_dialog("분석 오류", f"분석 중 오류가 발생했습니다:\\n{str(e)}")
|
||||
|
||||
def update_progress_ui(
|
||||
self,
|
||||
is_running: bool,
|
||||
status: str,
|
||||
progress: Optional[float] = None
|
||||
):
|
||||
"""진행률 UI 업데이트"""
|
||||
def update():
|
||||
self.progress_ring.visible = is_running
|
||||
self.status_text.value = status
|
||||
|
||||
if progress is not None:
|
||||
self.progress_bar.value = progress
|
||||
self.progress_bar.visible = True
|
||||
else:
|
||||
self.progress_bar.visible = is_running
|
||||
|
||||
self.page.update()
|
||||
|
||||
# 메인 스레드에서 UI 업데이트
|
||||
self.page.run_thread(update)
|
||||
|
||||
def display_analysis_results(self):
|
||||
"""분석 결과 표시"""
|
||||
def update_results():
|
||||
if self.analysis_results:
|
||||
# 결과 텍스트 구성 (요약 정보 포함)
|
||||
result_text = "📊 분석 요약\\n"
|
||||
result_text += f"- 분석된 페이지: {len(self.analysis_results)}개\\n"
|
||||
result_text += f"- 분석 완료 시간: {DateTimeUtils.get_timestamp()}\\n\\n"
|
||||
|
||||
for page_num, result in self.analysis_results.items():
|
||||
result_text += f"\\n📋 페이지 {page_num + 1} 분석 결과\\n"
|
||||
result_text += "=" * 50 + "\\n"
|
||||
result_text += result
|
||||
result_text += "\\n\\n"
|
||||
|
||||
self.results_text.value = result_text.strip()
|
||||
|
||||
# 저장 버튼 활성화
|
||||
self.save_text_button.disabled = False
|
||||
self.save_json_button.disabled = False
|
||||
else:
|
||||
self.results_text.value = "분석 결과가 없습니다."
|
||||
self.save_text_button.disabled = True
|
||||
self.save_json_button.disabled = True
|
||||
|
||||
self.page.update()
|
||||
|
||||
# 메인 스레드에서 UI 업데이트
|
||||
self.page.run_thread(update_results)
|
||||
|
||||
def show_error_dialog(self, title: str, message: str):
|
||||
"""오류 다이얼로그 표시"""
|
||||
dialog = UIComponents.create_error_dialog(title, message)
|
||||
|
||||
def close_dialog(e):
|
||||
dialog.open = False
|
||||
self.page.update()
|
||||
|
||||
dialog.actions[0].on_click = close_dialog
|
||||
self.page.dialog = dialog
|
||||
dialog.open = True
|
||||
self.page.update()
|
||||
|
||||
def show_info_dialog(self, title: str, message: str):
|
||||
"""정보 다이얼로그 표시"""
|
||||
dialog = UIComponents.create_info_dialog(title, message)
|
||||
|
||||
def close_dialog(e):
|
||||
dialog.open = False
|
||||
self.page.update()
|
||||
|
||||
dialog.actions[0].on_click = close_dialog
|
||||
self.page.dialog = dialog
|
||||
dialog.open = True
|
||||
self.page.update()
|
||||
|
||||
def main(page: ft.Page):
|
||||
"""메인 함수"""
|
||||
try:
|
||||
# 애플리케이션 초기화
|
||||
app = PDFAnalyzerApp(page)
|
||||
|
||||
# UI 구성
|
||||
app.build_ui()
|
||||
|
||||
logger.info("애플리케이션 시작 완료")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"애플리케이션 시작 실패: {e}")
|
||||
# 간단한 오류 페이지 표시
|
||||
page.add(
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text("애플리케이션 초기화 오류", size=24, weight=ft.FontWeight.BOLD),
|
||||
ft.Text(f"오류 내용: {str(e)}", size=16),
|
||||
ft.Text("설정을 확인하고 다시 시도하세요.", size=14),
|
||||
], alignment=ft.MainAxisAlignment.CENTER),
|
||||
alignment=ft.alignment.center,
|
||||
expand=True,
|
||||
)
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 애플리케이션 실행
|
||||
ft.app(
|
||||
target=main,
|
||||
view=ft.AppView.FLET_APP,
|
||||
upload_dir="uploads",
|
||||
)
|
||||
1161
back_src/main_single_file_backup.py
Normal file
1161
back_src/main_single_file_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
56
back_src/run_simple_batch.py
Normal file
56
back_src/run_simple_batch.py
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
간단한 배치 처리 앱 실행기
|
||||
getcode.py 스타일의 간단한 PDF 분석을 여러 파일에 적용
|
||||
|
||||
실행 방법:
|
||||
python run_simple_batch.py
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
def run_simple_batch_app():
|
||||
"""간단한 배치 분석 앱 실행"""
|
||||
try:
|
||||
# 현재 디렉토리로 이동
|
||||
os.chdir("D:/MYCLAUDE_PROJECT/fletimageanalysis")
|
||||
|
||||
print("🚀 간단한 배치 PDF 분석기 시작 중...")
|
||||
print("📂 작업 디렉토리:", os.getcwd())
|
||||
|
||||
# simple_batch_analyzer_app.py 실행
|
||||
result = subprocess.run([
|
||||
sys.executable,
|
||||
"simple_batch_analyzer_app.py"
|
||||
], check=True)
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ 실행 중 오류 발생: {e}")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print("❌ simple_batch_analyzer_app.py 파일을 찾을 수 없습니다.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 예기치 않은 오류: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 50)
|
||||
print("📊 간단한 PDF 배치 분석기")
|
||||
print("🎯 getcode.py 스타일 → 여러 파일 → CSV 출력")
|
||||
print("=" * 50)
|
||||
|
||||
success = run_simple_batch_app()
|
||||
|
||||
if success:
|
||||
print("✅ 애플리케이션이 성공적으로 실행되었습니다!")
|
||||
else:
|
||||
print("❌ 애플리케이션 실행에 실패했습니다.")
|
||||
print("\n🔧 해결 방법:")
|
||||
print("1. GEMINI_API_KEY 환경변수가 설정되어 있는지 확인")
|
||||
print("2. requirements.txt 패키지들이 설치되어 있는지 확인")
|
||||
print("3. D:/MYCLAUDE_PROJECT/fletimageanalysis 폴더에서 실행")
|
||||
429
back_src/simple_batch_analyzer_app.py
Normal file
429
back_src/simple_batch_analyzer_app.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
간단한 다중 파일 PDF 분석 UI
|
||||
getcode.py 스타일의 간단한 분석을 여러 파일에 적용하는 Flet 애플리케이션
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-14
|
||||
Version: 1.0.0
|
||||
Features:
|
||||
- 다중 PDF 파일 선택
|
||||
- getcode.py 프롬프트를 그대로 사용한 간단한 분석
|
||||
- 실시간 진행률 표시
|
||||
- 자동 CSV 저장
|
||||
- 결과 요약 표시
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import flet as ft
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
import threading
|
||||
import logging
|
||||
|
||||
from simple_batch_processor import SimpleBatchProcessor
|
||||
from config import Config
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimpleBatchAnalyzerApp:
|
||||
"""간단한 배치 분석 애플리케이션"""
|
||||
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self.selected_files: List[str] = []
|
||||
self.processor: Optional[SimpleBatchProcessor] = None
|
||||
self.is_processing = False
|
||||
|
||||
# UI 컴포넌트들
|
||||
self.file_picker = None
|
||||
self.selected_files_text = None
|
||||
self.progress_bar = None
|
||||
self.progress_text = None
|
||||
self.analyze_button = None
|
||||
self.results_text = None
|
||||
self.custom_prompt_field = None
|
||||
|
||||
self.setup_page()
|
||||
self.build_ui()
|
||||
|
||||
def setup_page(self):
|
||||
"""페이지 기본 설정"""
|
||||
self.page.title = "간단한 다중 PDF 분석기"
|
||||
self.page.window.width = 900
|
||||
self.page.window.height = 700
|
||||
self.page.window.min_width = 800
|
||||
self.page.window.min_height = 600
|
||||
self.page.theme_mode = ft.ThemeMode.LIGHT
|
||||
self.page.padding = 20
|
||||
|
||||
# API 키 확인
|
||||
api_key = Config.GEMINI_API_KEY
|
||||
if not api_key:
|
||||
self.show_error_dialog("Gemini API 키가 설정되지 않았습니다. .env 파일을 확인해주세요.")
|
||||
return
|
||||
|
||||
self.processor = SimpleBatchProcessor(api_key)
|
||||
logger.info("간단한 배치 분석 앱 초기화 완료")
|
||||
|
||||
def build_ui(self):
|
||||
"""UI 구성 요소 생성"""
|
||||
|
||||
# 제목
|
||||
title = ft.Text(
|
||||
"🔍 간단한 다중 PDF 분석기",
|
||||
size=24,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.BLUE_700
|
||||
)
|
||||
|
||||
subtitle = ft.Text(
|
||||
"getcode.py 스타일의 간단한 프롬프트로 여러 PDF 파일을 분석하고 결과를 CSV로 저장합니다.",
|
||||
size=14,
|
||||
color=ft.Colors.GREY_700
|
||||
)
|
||||
|
||||
# 파일 선택 섹션
|
||||
self.file_picker = ft.FilePicker(
|
||||
on_result=self.on_files_selected
|
||||
)
|
||||
self.page.overlay.append(self.file_picker)
|
||||
|
||||
file_select_button = ft.ElevatedButton(
|
||||
"📁 PDF 파일 선택",
|
||||
icon=ft.icons.FOLDER_OPEN,
|
||||
on_click=self.select_files,
|
||||
style=ft.ButtonStyle(
|
||||
color=ft.Colors.WHITE,
|
||||
bgcolor=ft.Colors.BLUE_600
|
||||
)
|
||||
)
|
||||
|
||||
self.selected_files_text = ft.Text(
|
||||
"선택된 파일이 없습니다",
|
||||
size=12,
|
||||
color=ft.Colors.GREY_600
|
||||
)
|
||||
|
||||
# 사용자 정의 프롬프트 섹션
|
||||
self.custom_prompt_field = ft.TextField(
|
||||
label="사용자 정의 프롬프트 (비워두면 기본 프롬프트 사용)",
|
||||
hint_text="예: PDF 이미지를 분석하여 도면의 주요 정보를 알려주세요",
|
||||
multiline=True,
|
||||
min_lines=2,
|
||||
max_lines=4,
|
||||
width=850
|
||||
)
|
||||
|
||||
default_prompt_text = ft.Text(
|
||||
"🔸 기본 프롬프트: \"pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.\"",
|
||||
size=12,
|
||||
color=ft.Colors.GREY_600,
|
||||
italic=True
|
||||
)
|
||||
|
||||
# 분석 시작 섹션
|
||||
self.analyze_button = ft.ElevatedButton(
|
||||
"🚀 분석 시작",
|
||||
icon=ft.icons.PLAY_ARROW,
|
||||
on_click=self.start_analysis,
|
||||
disabled=True,
|
||||
style=ft.ButtonStyle(
|
||||
color=ft.Colors.WHITE,
|
||||
bgcolor=ft.Colors.GREEN_600
|
||||
)
|
||||
)
|
||||
|
||||
# 진행률 섹션
|
||||
self.progress_bar = ft.ProgressBar(
|
||||
width=850,
|
||||
visible=False,
|
||||
color=ft.Colors.BLUE_600,
|
||||
bgcolor=ft.Colors.BLUE_100
|
||||
)
|
||||
|
||||
self.progress_text = ft.Text(
|
||||
"",
|
||||
size=12,
|
||||
color=ft.Colors.BLUE_700,
|
||||
visible=False
|
||||
)
|
||||
|
||||
# 결과 섹션
|
||||
self.results_text = ft.Text(
|
||||
"",
|
||||
size=12,
|
||||
color=ft.Colors.BLACK,
|
||||
selectable=True
|
||||
)
|
||||
|
||||
# 레이아웃 구성
|
||||
content = ft.Column([
|
||||
# 헤더
|
||||
ft.Container(
|
||||
content=ft.Column([title, subtitle]),
|
||||
margin=ft.margin.only(bottom=20)
|
||||
),
|
||||
|
||||
# 파일 선택
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text("📁 파일 선택", size=16, weight=ft.FontWeight.BOLD),
|
||||
file_select_button,
|
||||
self.selected_files_text
|
||||
]),
|
||||
bgcolor=ft.colors.GREY_50,
|
||||
padding=15,
|
||||
border_radius=10,
|
||||
margin=ft.margin.only(bottom=15)
|
||||
),
|
||||
|
||||
# 프롬프트 설정
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text("✏️ 프롬프트 설정", size=16, weight=ft.FontWeight.BOLD),
|
||||
self.custom_prompt_field,
|
||||
default_prompt_text
|
||||
]),
|
||||
bgcolor=ft.colors.GREY_50,
|
||||
padding=15,
|
||||
border_radius=10,
|
||||
margin=ft.margin.only(bottom=15)
|
||||
),
|
||||
|
||||
# 분석 시작
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text("🔄 분석 실행", size=16, weight=ft.FontWeight.BOLD),
|
||||
self.analyze_button,
|
||||
self.progress_bar,
|
||||
self.progress_text
|
||||
]),
|
||||
bgcolor=ft.colors.GREY_50,
|
||||
padding=15,
|
||||
border_radius=10,
|
||||
margin=ft.margin.only(bottom=15)
|
||||
),
|
||||
|
||||
# 결과 표시
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text("📊 분석 결과", size=16, weight=ft.FontWeight.BOLD),
|
||||
self.results_text
|
||||
]),
|
||||
bgcolor=ft.colors.GREY_50,
|
||||
padding=15,
|
||||
border_radius=10
|
||||
)
|
||||
])
|
||||
|
||||
# 스크롤 가능한 컨테이너로 감싸기
|
||||
scrollable_content = ft.Container(
|
||||
content=content,
|
||||
alignment=ft.alignment.top_center
|
||||
)
|
||||
|
||||
self.page.add(scrollable_content)
|
||||
self.page.update()
|
||||
|
||||
def select_files(self, e):
|
||||
"""파일 선택 대화상자 열기"""
|
||||
self.file_picker.pick_files(
|
||||
allow_multiple=True,
|
||||
allowed_extensions=["pdf"],
|
||||
dialog_title="분석할 PDF 파일들을 선택하세요"
|
||||
)
|
||||
|
||||
def on_files_selected(self, e: ft.FilePickerResultEvent):
|
||||
"""파일 선택 완료 후 처리"""
|
||||
if e.files:
|
||||
self.selected_files = [file.path for file in e.files]
|
||||
file_count = len(self.selected_files)
|
||||
|
||||
if file_count == 1:
|
||||
self.selected_files_text.value = f"✅ {file_count}개 파일 선택됨: {os.path.basename(self.selected_files[0])}"
|
||||
else:
|
||||
self.selected_files_text.value = f"✅ {file_count}개 파일 선택됨"
|
||||
|
||||
self.selected_files_text.color = ft.colors.GREEN_700
|
||||
self.analyze_button.disabled = False
|
||||
|
||||
logger.info(f"{file_count}개 PDF 파일 선택완료")
|
||||
else:
|
||||
self.selected_files = []
|
||||
self.selected_files_text.value = "선택된 파일이 없습니다"
|
||||
self.selected_files_text.color = ft.colors.GREY_600
|
||||
self.analyze_button.disabled = True
|
||||
|
||||
self.page.update()
|
||||
|
||||
def start_analysis(self, e):
|
||||
"""분석 시작"""
|
||||
if self.is_processing or not self.selected_files:
|
||||
return
|
||||
|
||||
self.is_processing = True
|
||||
self.analyze_button.disabled = True
|
||||
self.progress_bar.visible = True
|
||||
self.progress_text.visible = True
|
||||
self.progress_bar.value = 0
|
||||
self.progress_text.value = "분석 준비 중..."
|
||||
self.results_text.value = ""
|
||||
|
||||
self.page.update()
|
||||
|
||||
# 백그라운드에서 비동기 처리 실행
|
||||
threading.Thread(target=self.run_analysis_async, daemon=True).start()
|
||||
|
||||
def run_analysis_async(self):
|
||||
"""비동기 분석 실행"""
|
||||
try:
|
||||
# 새 이벤트 루프 생성 (백그라운드 스레드에서)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# 분석 실행
|
||||
loop.run_until_complete(self.process_files())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"분석 실행 중 오류: {e}")
|
||||
self.update_ui_on_error(str(e))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
async def process_files(self):
|
||||
"""파일 처리 실행"""
|
||||
try:
|
||||
# 사용자 정의 프롬프트 확인
|
||||
custom_prompt = self.custom_prompt_field.value.strip()
|
||||
if not custom_prompt:
|
||||
custom_prompt = None
|
||||
|
||||
# 진행률 콜백 함수
|
||||
def progress_callback(current: int, total: int, status: str):
|
||||
progress_value = current / total
|
||||
self.update_progress(progress_value, f"{current}/{total} - {status}")
|
||||
|
||||
# 배치 처리 실행
|
||||
results = await self.processor.process_multiple_pdf_files(
|
||||
pdf_file_paths=self.selected_files,
|
||||
custom_prompt=custom_prompt,
|
||||
max_concurrent_files=2, # 안정성을 위해 낮게 설정
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
# 결과 요약
|
||||
summary = self.processor.get_processing_summary()
|
||||
self.update_ui_on_completion(summary)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 처리 중 오류: {e}")
|
||||
self.update_ui_on_error(str(e))
|
||||
|
||||
def update_progress(self, value: float, text: str):
|
||||
"""진행률 업데이트 (스레드 안전)"""
|
||||
def update():
|
||||
self.progress_bar.value = value
|
||||
self.progress_text.value = text
|
||||
self.page.update()
|
||||
|
||||
self.page.run_thread_safe(update)
|
||||
|
||||
def update_ui_on_completion(self, summary: dict):
|
||||
"""분석 완료 시 UI 업데이트"""
|
||||
def update():
|
||||
self.progress_bar.visible = False
|
||||
self.progress_text.visible = False
|
||||
self.analyze_button.disabled = False
|
||||
self.is_processing = False
|
||||
|
||||
# 결과 요약 텍스트 생성
|
||||
result_text = "🎉 분석 완료!\n\n"
|
||||
result_text += f"📊 처리 요약:\n"
|
||||
result_text += f"• 전체 파일: {summary.get('total_files', 0)}개\n"
|
||||
result_text += f"• 성공: {summary.get('success_files', 0)}개\n"
|
||||
result_text += f"• 실패: {summary.get('failed_files', 0)}개\n"
|
||||
result_text += f"• 성공률: {summary.get('success_rate', 0)}%\n"
|
||||
result_text += f"• 전체 처리 시간: {summary.get('total_processing_time', 0)}초\n"
|
||||
result_text += f"• 평균 처리 시간: {summary.get('avg_processing_time', 0)}초/파일\n"
|
||||
result_text += f"• 전체 파일 크기: {summary.get('total_file_size_mb', 0)}MB\n\n"
|
||||
result_text += "💾 결과가 CSV 파일로 자동 저장되었습니다.\n"
|
||||
result_text += "파일 위치: D:/MYCLAUDE_PROJECT/fletimageanalysis/results/"
|
||||
|
||||
self.results_text.value = result_text
|
||||
self.results_text.color = ft.colors.GREEN_700
|
||||
self.page.update()
|
||||
|
||||
# 완료 알림
|
||||
self.show_success_dialog("분석이 완료되었습니다!", result_text)
|
||||
|
||||
self.page.run_thread_safe(update)
|
||||
|
||||
def update_ui_on_error(self, error_message: str):
|
||||
"""오류 발생 시 UI 업데이트"""
|
||||
def update():
|
||||
self.progress_bar.visible = False
|
||||
self.progress_text.visible = False
|
||||
self.analyze_button.disabled = False
|
||||
self.is_processing = False
|
||||
|
||||
self.results_text.value = f"❌ 분석 중 오류가 발생했습니다:\n{error_message}"
|
||||
self.results_text.color = ft.colors.RED_700
|
||||
self.page.update()
|
||||
|
||||
self.show_error_dialog("분석 오류", error_message)
|
||||
|
||||
self.page.run_thread_safe(update)
|
||||
|
||||
def show_success_dialog(self, title: str, message: str):
|
||||
"""성공 다이얼로그 표시"""
|
||||
def show():
|
||||
dialog = ft.AlertDialog(
|
||||
title=ft.Text(title),
|
||||
content=ft.Text(message, selectable=True),
|
||||
actions=[
|
||||
ft.TextButton("확인", on_click=lambda e: self.close_dialog())
|
||||
]
|
||||
)
|
||||
self.page.overlay.append(dialog)
|
||||
dialog.open = True
|
||||
self.page.update()
|
||||
|
||||
self.page.run_thread_safe(show)
|
||||
|
||||
def show_error_dialog(self, title: str, message: str = ""):
|
||||
"""오류 다이얼로그 표시"""
|
||||
def show():
|
||||
dialog = ft.AlertDialog(
|
||||
title=ft.Text(title, color=ft.colors.RED_700),
|
||||
content=ft.Text(message if message else title, selectable=True),
|
||||
actions=[
|
||||
ft.TextButton("확인", on_click=lambda e: self.close_dialog())
|
||||
]
|
||||
)
|
||||
self.page.overlay.append(dialog)
|
||||
dialog.open = True
|
||||
self.page.update()
|
||||
|
||||
self.page.run_thread_safe(show)
|
||||
|
||||
def close_dialog(self):
|
||||
"""다이얼로그 닫기"""
|
||||
if self.page.overlay:
|
||||
for overlay in self.page.overlay:
|
||||
if isinstance(overlay, ft.AlertDialog):
|
||||
overlay.open = False
|
||||
self.page.update()
|
||||
|
||||
|
||||
async def main(page: ft.Page):
|
||||
"""메인 함수"""
|
||||
app = SimpleBatchAnalyzerApp(page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Flet 애플리케이션 실행
|
||||
ft.app(target=main, view=ft.AppView.FLET_APP)
|
||||
378
back_src/simple_batch_processor.py
Normal file
378
back_src/simple_batch_processor.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
간단한 다중 파일 배치 처리 모듈
|
||||
getcode.py 스타일의 간단한 분석을 여러 파일에 적용하고 결과를 CSV로 저장합니다.
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-14
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pandas as pd
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from simple_gemini_analyzer import SimpleGeminiAnalyzer
|
||||
from pdf_processor import PDFProcessor
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimpleBatchResult:
|
||||
"""간단한 배치 처리 결과"""
|
||||
file_path: str
|
||||
file_name: str
|
||||
file_size_mb: float
|
||||
processing_time_seconds: float
|
||||
success: bool
|
||||
|
||||
# 분석 결과
|
||||
analysis_result: Optional[str] = None
|
||||
analysis_timestamp: Optional[str] = None
|
||||
prompt_used: Optional[str] = None
|
||||
model_used: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# 메타데이터
|
||||
processed_at: Optional[str] = None
|
||||
|
||||
|
||||
class SimpleBatchProcessor:
|
||||
"""
|
||||
간단한 다중 파일 배치 처리기
|
||||
getcode.py 스타일의 분석을 여러 PDF 파일에 적용합니다.
|
||||
"""
|
||||
|
||||
def __init__(self, gemini_api_key: str):
|
||||
"""
|
||||
배치 처리기 초기화
|
||||
|
||||
Args:
|
||||
gemini_api_key: Gemini API 키
|
||||
"""
|
||||
self.gemini_api_key = gemini_api_key
|
||||
self.analyzer = SimpleGeminiAnalyzer(gemini_api_key)
|
||||
self.pdf_processor = PDFProcessor()
|
||||
|
||||
self.results: List[SimpleBatchResult] = []
|
||||
self.current_progress = 0
|
||||
self.total_files = 0
|
||||
|
||||
logger.info("간단한 배치 처리기 초기화 완료")
|
||||
|
||||
async def process_multiple_pdf_files(
|
||||
self,
|
||||
pdf_file_paths: List[str],
|
||||
output_csv_path: Optional[str] = None,
|
||||
custom_prompt: Optional[str] = None,
|
||||
max_concurrent_files: int = 3,
|
||||
progress_callback: Optional[Callable[[int, int, str], None]] = None
|
||||
) -> List[SimpleBatchResult]:
|
||||
"""
|
||||
여러 PDF 파일을 배치로 처리하고 결과를 CSV로 저장
|
||||
|
||||
Args:
|
||||
pdf_file_paths: 처리할 PDF 파일 경로 리스트
|
||||
output_csv_path: 출력 CSV 파일 경로 (None인 경우 자동 생성)
|
||||
custom_prompt: 사용자 정의 프롬프트 (None인 경우 기본 프롬프트 사용)
|
||||
max_concurrent_files: 동시 처리할 최대 파일 수
|
||||
progress_callback: 진행률 콜백 함수 (current, total, status)
|
||||
|
||||
Returns:
|
||||
처리 결과 리스트
|
||||
"""
|
||||
self.results = []
|
||||
self.total_files = len(pdf_file_paths)
|
||||
self.current_progress = 0
|
||||
|
||||
logger.info(f"간단한 배치 처리 시작: {self.total_files}개 PDF 파일")
|
||||
|
||||
if not pdf_file_paths:
|
||||
logger.warning("처리할 파일이 없습니다.")
|
||||
return []
|
||||
|
||||
# 동시 처리 제한을 위한 세마포어
|
||||
semaphore = asyncio.Semaphore(max_concurrent_files)
|
||||
|
||||
# 각 파일에 대한 처리 태스크 생성
|
||||
tasks = []
|
||||
for i, file_path in enumerate(pdf_file_paths):
|
||||
task = self._process_single_pdf_with_semaphore(
|
||||
semaphore, file_path, custom_prompt, progress_callback, i + 1
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
# 모든 파일 처리 완료까지 대기
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
logger.info(f"배치 처리 완료: {len(self.results)}개 결과")
|
||||
|
||||
# CSV 저장
|
||||
if output_csv_path or self.results:
|
||||
csv_path = output_csv_path or self._generate_default_csv_path()
|
||||
await self.save_results_to_csv(csv_path)
|
||||
|
||||
return self.results
|
||||
|
||||
async def _process_single_pdf_with_semaphore(
|
||||
self,
|
||||
semaphore: asyncio.Semaphore,
|
||||
file_path: str,
|
||||
custom_prompt: Optional[str],
|
||||
progress_callback: Optional[Callable[[int, int, str], None]],
|
||||
file_number: int
|
||||
) -> None:
|
||||
"""세마포어를 사용하여 단일 PDF 파일 처리"""
|
||||
async with semaphore:
|
||||
result = await self._process_single_pdf_file(file_path, custom_prompt)
|
||||
self.results.append(result)
|
||||
|
||||
self.current_progress += 1
|
||||
if progress_callback:
|
||||
status = f"처리 완료: {result.file_name}"
|
||||
if not result.success:
|
||||
status = f"처리 실패: {result.file_name}"
|
||||
progress_callback(self.current_progress, self.total_files, status)
|
||||
|
||||
async def _process_single_pdf_file(
|
||||
self,
|
||||
file_path: str,
|
||||
custom_prompt: Optional[str] = None
|
||||
) -> SimpleBatchResult:
|
||||
"""
|
||||
단일 PDF 파일 처리
|
||||
|
||||
Args:
|
||||
file_path: PDF 파일 경로
|
||||
custom_prompt: 사용자 정의 프롬프트
|
||||
|
||||
Returns:
|
||||
처리 결과
|
||||
"""
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
try:
|
||||
# 파일 정보 수집
|
||||
file_size = os.path.getsize(file_path)
|
||||
file_size_mb = round(file_size / (1024 * 1024), 2)
|
||||
|
||||
logger.info(f"PDF 파일 처리 시작: {file_name} ({file_size_mb}MB)")
|
||||
|
||||
# PDF를 이미지로 변환 (첫 번째 페이지만)
|
||||
images = self.pdf_processor.convert_to_images(file_path, max_pages=1)
|
||||
if not images:
|
||||
raise ValueError("PDF를 이미지로 변환할 수 없습니다")
|
||||
|
||||
# 첫 번째 페이지 이미지를 바이트로 변환
|
||||
first_page_image = images[0]
|
||||
image_bytes = self.pdf_processor.image_to_bytes(first_page_image)
|
||||
|
||||
# Gemini API로 분석 (비동기 처리)
|
||||
loop = asyncio.get_event_loop()
|
||||
analysis_result = await loop.run_in_executor(
|
||||
None,
|
||||
self.analyzer.analyze_image_from_bytes,
|
||||
image_bytes,
|
||||
custom_prompt,
|
||||
"image/png"
|
||||
)
|
||||
|
||||
if analysis_result and analysis_result['success']:
|
||||
result = SimpleBatchResult(
|
||||
file_path=file_path,
|
||||
file_name=file_name,
|
||||
file_size_mb=file_size_mb,
|
||||
processing_time_seconds=0, # 나중에 계산
|
||||
success=True,
|
||||
analysis_result=analysis_result['analysis_result'],
|
||||
analysis_timestamp=analysis_result['timestamp'],
|
||||
prompt_used=analysis_result['prompt_used'],
|
||||
model_used=analysis_result['model'],
|
||||
error_message=None,
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
logger.info(f"분석 성공: {file_name}")
|
||||
else:
|
||||
error_msg = analysis_result['error_message'] if analysis_result else "알 수 없는 오류"
|
||||
result = SimpleBatchResult(
|
||||
file_path=file_path,
|
||||
file_name=file_name,
|
||||
file_size_mb=file_size_mb,
|
||||
processing_time_seconds=0,
|
||||
success=False,
|
||||
analysis_result=None,
|
||||
error_message=error_msg,
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
logger.error(f"분석 실패: {file_name} - {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"파일 처리 오류: {str(e)}"
|
||||
logger.error(f"파일 처리 오류 ({file_name}): {error_msg}")
|
||||
result = SimpleBatchResult(
|
||||
file_path=file_path,
|
||||
file_name=file_name,
|
||||
file_size_mb=0,
|
||||
processing_time_seconds=0,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
finally:
|
||||
# 처리 시간 계산
|
||||
end_time = asyncio.get_event_loop().time()
|
||||
result.processing_time_seconds = round(end_time - start_time, 2)
|
||||
|
||||
return result
|
||||
|
||||
async def save_results_to_csv(self, csv_path: str) -> None:
|
||||
"""
|
||||
처리 결과를 CSV 파일로 저장
|
||||
|
||||
Args:
|
||||
csv_path: 출력 CSV 파일 경로
|
||||
"""
|
||||
try:
|
||||
if not self.results:
|
||||
logger.warning("저장할 결과가 없습니다.")
|
||||
return
|
||||
|
||||
# 결과를 DataFrame으로 변환
|
||||
data_rows = []
|
||||
for result in self.results:
|
||||
row = {
|
||||
'file_name': result.file_name,
|
||||
'file_path': result.file_path,
|
||||
'file_size_mb': result.file_size_mb,
|
||||
'processing_time_seconds': result.processing_time_seconds,
|
||||
'success': result.success,
|
||||
'analysis_result': result.analysis_result or '',
|
||||
'analysis_timestamp': result.analysis_timestamp or '',
|
||||
'prompt_used': result.prompt_used or '',
|
||||
'model_used': result.model_used or '',
|
||||
'error_message': result.error_message or '',
|
||||
'processed_at': result.processed_at or ''
|
||||
}
|
||||
data_rows.append(row)
|
||||
|
||||
# DataFrame 생성
|
||||
df = pd.DataFrame(data_rows)
|
||||
|
||||
# 컬럼 순서 정렬
|
||||
column_order = [
|
||||
'file_name', 'success', 'file_size_mb', 'processing_time_seconds',
|
||||
'analysis_result', 'prompt_used', 'model_used', 'analysis_timestamp',
|
||||
'error_message', 'processed_at', 'file_path'
|
||||
]
|
||||
|
||||
df = df[column_order]
|
||||
|
||||
# 출력 디렉토리 생성
|
||||
os.makedirs(os.path.dirname(csv_path), exist_ok=True)
|
||||
|
||||
# UTF-8 BOM으로 저장 (한글 호환성)
|
||||
df.to_csv(csv_path, index=False, encoding='utf-8-sig')
|
||||
|
||||
logger.info(f"CSV 저장 완료: {csv_path}")
|
||||
logger.info(f"총 {len(data_rows)}개 파일 결과 저장")
|
||||
|
||||
# 처리 요약 로그
|
||||
success_count = sum(1 for r in self.results if r.success)
|
||||
failure_count = len(self.results) - success_count
|
||||
logger.info(f"처리 요약 - 성공: {success_count}개, 실패: {failure_count}개")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CSV 저장 오류: {str(e)}")
|
||||
raise
|
||||
|
||||
def _generate_default_csv_path(self) -> str:
|
||||
"""기본 CSV 파일 경로 생성"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
results_dir = "D:/MYCLAUDE_PROJECT/fletimageanalysis/results"
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
return os.path.join(results_dir, f"simple_batch_analysis_{timestamp}.csv")
|
||||
|
||||
def get_processing_summary(self) -> Dict[str, Any]:
|
||||
"""처리 결과 요약 정보 반환"""
|
||||
if not self.results:
|
||||
return {}
|
||||
|
||||
total_files = len(self.results)
|
||||
success_files = sum(1 for r in self.results if r.success)
|
||||
failed_files = total_files - success_files
|
||||
|
||||
total_processing_time = sum(r.processing_time_seconds for r in self.results)
|
||||
avg_processing_time = total_processing_time / total_files if total_files > 0 else 0
|
||||
|
||||
total_file_size = sum(r.file_size_mb for r in self.results)
|
||||
|
||||
return {
|
||||
'total_files': total_files,
|
||||
'success_files': success_files,
|
||||
'failed_files': failed_files,
|
||||
'total_processing_time': round(total_processing_time, 2),
|
||||
'avg_processing_time': round(avg_processing_time, 2),
|
||||
'total_file_size_mb': round(total_file_size, 2),
|
||||
'success_rate': round((success_files / total_files) * 100, 1) if total_files > 0 else 0
|
||||
}
|
||||
|
||||
|
||||
# 사용 예시
|
||||
async def main():
|
||||
"""사용 예시 함수"""
|
||||
# API 키 설정 (실제 사용 시에는 .env 파일이나 환경변수 사용)
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
print("❌ GEMINI_API_KEY 환경변수를 설정해주세요.")
|
||||
return
|
||||
|
||||
# 배치 처리기 초기화
|
||||
processor = SimpleBatchProcessor(api_key)
|
||||
|
||||
# 진행률 콜백 함수
|
||||
def progress_callback(current: int, total: int, status: str):
|
||||
percentage = (current / total) * 100
|
||||
print(f"진행률: {current}/{total} ({percentage:.1f}%) - {status}")
|
||||
|
||||
# 샘플 PDF 파일 경로 (실제 사용 시에는 실제 파일 경로로 교체)
|
||||
pdf_files = [
|
||||
"D:/MYCLAUDE_PROJECT/fletimageanalysis/testsample/sample1.pdf",
|
||||
"D:/MYCLAUDE_PROJECT/fletimageanalysis/testsample/sample2.pdf",
|
||||
# 더 많은 파일 추가 가능
|
||||
]
|
||||
|
||||
# 실제 존재하는 PDF 파일만 필터링
|
||||
existing_files = [f for f in pdf_files if os.path.exists(f)]
|
||||
|
||||
if not existing_files:
|
||||
print("❌ 처리할 PDF 파일이 없습니다.")
|
||||
return
|
||||
|
||||
# 배치 처리 실행
|
||||
results = await processor.process_multiple_pdf_files(
|
||||
pdf_file_paths=existing_files,
|
||||
custom_prompt=None, # 기본 프롬프트 사용
|
||||
max_concurrent_files=2,
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
# 처리 요약 출력
|
||||
summary = processor.get_processing_summary()
|
||||
print("\n=== 처리 요약 ===")
|
||||
for key, value in summary.items():
|
||||
print(f"{key}: {value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 비동기 메인 함수 실행
|
||||
asyncio.run(main())
|
||||
235
back_src/simple_gemini_analyzer.py
Normal file
235
back_src/simple_gemini_analyzer.py
Normal file
@@ -0,0 +1,235 @@
|
||||
# To run this code you need to install the following dependencies:
|
||||
# pip install google-genai
|
||||
|
||||
"""
|
||||
간단한 Gemini 이미지 분석기
|
||||
getcode.py의 프롬프트를 그대로 사용하여 PDF 이미지를 분석합니다.
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-14
|
||||
Version: 1.0.0
|
||||
Based on: getcode.py (original user code)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import logging
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimpleGeminiAnalyzer:
|
||||
"""
|
||||
getcode.py 스타일의 간단한 Gemini 이미지 분석기
|
||||
구조화된 JSON 스키마 대신 자연어 텍스트 분석을 수행합니다.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""
|
||||
간단한 Gemini 분석기 초기화
|
||||
|
||||
Args:
|
||||
api_key: Gemini API 키 (None인 경우 환경변수에서 로드)
|
||||
"""
|
||||
self.api_key = api_key or os.environ.get("GEMINI_API_KEY")
|
||||
self.model = "gemini-2.5-flash" # getcode.py와 동일한 모델
|
||||
|
||||
# getcode.py와 동일한 프롬프트 사용
|
||||
self.default_prompt = "pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘."
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("Gemini API 키가 설정되지 않았습니다. GEMINI_API_KEY 환경변수를 설정하거나 api_key 매개변수를 제공하세요.")
|
||||
|
||||
try:
|
||||
self.client = genai.Client(api_key=self.api_key)
|
||||
logger.info(f"Simple Gemini 클라이언트 초기화 완료 (모델: {self.model})")
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 클라이언트 초기화 실패: {e}")
|
||||
raise
|
||||
|
||||
def analyze_pdf_image(
|
||||
self,
|
||||
base64_data: str,
|
||||
custom_prompt: Optional[str] = None,
|
||||
mime_type: str = "application/pdf"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Base64로 인코딩된 PDF 데이터를 분석합니다.
|
||||
getcode.py와 동일한 방식으로 동작합니다.
|
||||
|
||||
Args:
|
||||
base64_data: Base64로 인코딩된 PDF 데이터
|
||||
custom_prompt: 사용자 정의 프롬프트 (None인 경우 기본 프롬프트 사용)
|
||||
mime_type: 파일 MIME 타입
|
||||
|
||||
Returns:
|
||||
분석 결과 딕셔너리 또는 None (실패 시)
|
||||
{
|
||||
'analysis_result': str, # 분석 결과 텍스트
|
||||
'success': bool, # 성공 여부
|
||||
'timestamp': str, # 분석 시각
|
||||
'prompt_used': str, # 사용된 프롬프트
|
||||
'model': str, # 사용된 모델
|
||||
'error_message': str # 오류 메시지 (실패 시)
|
||||
}
|
||||
"""
|
||||
try:
|
||||
prompt = custom_prompt or self.default_prompt
|
||||
logger.info(f"이미지 분석 시작 - 프롬프트: {prompt[:50]}...")
|
||||
|
||||
# getcode.py와 동일한 구조로 컨텐츠 생성
|
||||
contents = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_bytes(
|
||||
mime_type=mime_type,
|
||||
data=base64.b64decode(base64_data),
|
||||
),
|
||||
types.Part.from_text(text=prompt),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# getcode.py와 동일한 설정 사용
|
||||
generate_content_config = types.GenerateContentConfig(
|
||||
temperature=0,
|
||||
top_p=0.05,
|
||||
thinking_config=types.ThinkingConfig(
|
||||
thinking_budget=0,
|
||||
),
|
||||
response_mime_type="text/plain", # JSON이 아닌 일반 텍스트
|
||||
)
|
||||
|
||||
# 스트리밍이 아닌 일반 응답으로 수정 (CSV 저장을 위해)
|
||||
response = self.client.models.generate_content(
|
||||
model=self.model,
|
||||
contents=contents,
|
||||
config=generate_content_config,
|
||||
)
|
||||
|
||||
if response and hasattr(response, 'text') and response.text:
|
||||
result = {
|
||||
'analysis_result': response.text.strip(),
|
||||
'success': True,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'prompt_used': prompt,
|
||||
'model': self.model,
|
||||
'error_message': None
|
||||
}
|
||||
logger.info(f"분석 완료: {len(response.text)} 문자")
|
||||
return result
|
||||
else:
|
||||
logger.error("API 응답에서 텍스트를 찾을 수 없습니다.")
|
||||
return {
|
||||
'analysis_result': None,
|
||||
'success': False,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'prompt_used': prompt,
|
||||
'model': self.model,
|
||||
'error_message': "API 응답에서 텍스트를 찾을 수 없습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"이미지 분석 중 오류 발생: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
'analysis_result': None,
|
||||
'success': False,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'prompt_used': custom_prompt or self.default_prompt,
|
||||
'model': self.model,
|
||||
'error_message': error_msg
|
||||
}
|
||||
|
||||
def analyze_image_from_bytes(
|
||||
self,
|
||||
image_bytes: bytes,
|
||||
custom_prompt: Optional[str] = None,
|
||||
mime_type: str = "image/png"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
바이트 형태의 이미지를 직접 분석합니다.
|
||||
|
||||
Args:
|
||||
image_bytes: 이미지 바이트 데이터
|
||||
custom_prompt: 사용자 정의 프롬프트
|
||||
mime_type: 이미지 MIME 타입
|
||||
|
||||
Returns:
|
||||
분석 결과 딕셔너리
|
||||
"""
|
||||
try:
|
||||
# 바이트를 base64로 인코딩
|
||||
base64_data = base64.b64encode(image_bytes).decode('utf-8')
|
||||
return self.analyze_pdf_image(base64_data, custom_prompt, mime_type)
|
||||
except Exception as e:
|
||||
error_msg = f"이미지 바이트 분석 중 오류: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
'analysis_result': None,
|
||||
'success': False,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'prompt_used': custom_prompt or self.default_prompt,
|
||||
'model': self.model,
|
||||
'error_message': error_msg
|
||||
}
|
||||
|
||||
def validate_api_connection(self) -> bool:
|
||||
"""API 연결 상태를 확인합니다."""
|
||||
try:
|
||||
test_content = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[types.Part.from_text(text="안녕하세요")]
|
||||
)
|
||||
]
|
||||
|
||||
config = types.GenerateContentConfig(
|
||||
temperature=0,
|
||||
response_mime_type="text/plain"
|
||||
)
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model=self.model,
|
||||
contents=test_content,
|
||||
config=config
|
||||
)
|
||||
|
||||
if response and hasattr(response, 'text'):
|
||||
logger.info("Simple Gemini API 연결 테스트 성공")
|
||||
return True
|
||||
else:
|
||||
logger.error("Simple Gemini API 연결 테스트 실패")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Simple Gemini API 연결 테스트 중 오류: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
# 테스트 코드
|
||||
analyzer = SimpleGeminiAnalyzer()
|
||||
|
||||
# API 연결 테스트
|
||||
if analyzer.validate_api_connection():
|
||||
print("✅ API 연결 성공")
|
||||
|
||||
# 샘플 이미지 분석 (실제 사용 시에는 PDF 파일에서 추출한 이미지 사용)
|
||||
sample_text = "테스트용 간단한 텍스트 분석"
|
||||
|
||||
# 실제 사용 예시:
|
||||
# with open("sample.pdf", "rb") as f:
|
||||
# pdf_bytes = f.read()
|
||||
# base64_data = base64.b64encode(pdf_bytes).decode('utf-8')
|
||||
# result = analyzer.analyze_pdf_image(base64_data)
|
||||
# print("분석 결과:", result)
|
||||
else:
|
||||
print("❌ API 연결 실패")
|
||||
633
back_src/temp_backup/dxf_processor_backup.py
Normal file
633
back_src/temp_backup/dxf_processor_backup.py
Normal file
@@ -0,0 +1,633 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DXF 파일 처리 모듈
|
||||
ezdxf 라이브러리를 사용하여 DXF 파일에서 도곽 정보 및 Block Reference/Attribute Reference를 추출
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple, Union, Any
|
||||
from dataclasses import dataclass, asdict, field
|
||||
|
||||
try:
|
||||
import ezdxf
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.entities import Insert, Attrib, AttDef, Text, MText
|
||||
from ezdxf.layouts import BlockLayout, Modelspace
|
||||
EZDXF_AVAILABLE = True
|
||||
except ImportError:
|
||||
EZDXF_AVAILABLE = False
|
||||
logging.warning("ezdxf 라이브러리가 설치되지 않았습니다. DXF 기능이 비활성화됩니다.")
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoundingBox:
|
||||
"""바운딩 박스 정보를 담는 데이터 클래스"""
|
||||
min_x: float
|
||||
min_y: float
|
||||
max_x: float
|
||||
max_y: float
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
return self.max_x - self.min_x
|
||||
|
||||
@property
|
||||
def height(self) -> float:
|
||||
return self.max_y - self.min_y
|
||||
|
||||
@property
|
||||
def center(self) -> Tuple[float, float]:
|
||||
return ((self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeInfo:
|
||||
"""속성 정보를 담는 데이터 클래스 - 모든 DXF 속성 포함"""
|
||||
tag: str
|
||||
text: str
|
||||
position: Tuple[float, float, float] # insert point (x, y, z)
|
||||
height: float
|
||||
width: float
|
||||
rotation: float
|
||||
layer: str
|
||||
bounding_box: Optional[BoundingBox] = None
|
||||
|
||||
# 추가 DXF 속성들
|
||||
prompt: Optional[str] = None # 프롬프트 문자열 (ATTDEF에서 가져옴)
|
||||
style: Optional[str] = None # 텍스트 스타일
|
||||
invisible: bool = False # 보이지 않는 속성
|
||||
const: bool = False # 상수 속성
|
||||
verify: bool = False # 검증 필요
|
||||
preset: bool = False # 프롬프트 없이 삽입
|
||||
align_point: Optional[Tuple[float, float, float]] = None # 정렬점
|
||||
halign: int = 0 # 수평 정렬 (0=LEFT, 2=RIGHT, etc.)
|
||||
valign: int = 0 # 수직 정렬 (0=BASELINE, 1=BOTTOM, etc.)
|
||||
text_generation_flag: int = 0 # 텍스트 생성 플래그
|
||||
oblique_angle: float = 0.0 # 기울기 각도
|
||||
width_factor: float = 1.0 # 폭 비율
|
||||
color: Optional[int] = None # 색상 코드
|
||||
linetype: Optional[str] = None # 선 타입
|
||||
lineweight: Optional[int] = None # 선 굵기
|
||||
|
||||
# 좌표 정보
|
||||
insert_x: float = 0.0 # X 좌표
|
||||
insert_y: float = 0.0 # Y 좌표
|
||||
insert_z: float = 0.0 # Z 좌표
|
||||
|
||||
# 계산된 정보
|
||||
estimated_width: float = 0.0 # 추정 텍스트 폭
|
||||
entity_handle: Optional[str] = None # DXF 엔티티 핸들
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockInfo:
|
||||
"""블록 정보를 담는 데이터 클래스"""
|
||||
name: str
|
||||
position: Tuple[float, float, float]
|
||||
scale: Tuple[float, float, float]
|
||||
rotation: float
|
||||
layer: str
|
||||
attributes: List[AttributeInfo]
|
||||
bounding_box: Optional[BoundingBox] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TitleBlockInfo:
|
||||
"""도곽 정보를 담는 데이터 클래스"""
|
||||
drawing_name: Optional[str] = None # 도면명
|
||||
drawing_number: Optional[str] = None # 도면번호
|
||||
construction_field: Optional[str] = None # 건설분야
|
||||
construction_stage: Optional[str] = None # 건설단계
|
||||
scale: Optional[str] = None # 축척
|
||||
project_name: Optional[str] = None # 프로젝트명
|
||||
designer: Optional[str] = None # 설계자
|
||||
date: Optional[str] = None # 날짜
|
||||
revision: Optional[str] = None # 리비전
|
||||
location: Optional[str] = None # 위치
|
||||
bounding_box: Optional[BoundingBox] = None # 도곽 전체 바운딩 박스
|
||||
block_name: Optional[str] = None # 도곽 블록 이름
|
||||
|
||||
# 모든 attributes 정보 저장
|
||||
all_attributes: List[AttributeInfo] = field(default_factory=list) # 도곽의 모든 속성 정보 리스트
|
||||
attributes_count: int = 0 # 속성 개수
|
||||
|
||||
# 추가 메타데이터
|
||||
block_position: Optional[Tuple[float, float, float]] = None # 블록 위치
|
||||
block_scale: Optional[Tuple[float, float, float]] = None # 블록 스케일
|
||||
block_rotation: float = 0.0 # 블록 회전각
|
||||
block_layer: Optional[str] = None # 블록 레이어
|
||||
|
||||
def __post_init__(self):
|
||||
"""초기화 후 처리"""
|
||||
self.attributes_count = len(self.all_attributes)
|
||||
|
||||
|
||||
class DXFProcessor:
|
||||
"""DXF 파일 처리 클래스"""
|
||||
|
||||
# 도곽 식별을 위한 키워드 정의
|
||||
TITLE_BLOCK_KEYWORDS = {
|
||||
'건설분야': ['construction_field', 'field', '분야', '공사', 'category'],
|
||||
'건설단계': ['construction_stage', 'stage', '단계', 'phase'],
|
||||
'도면명': ['drawing_name', 'title', '제목', 'name', '명'],
|
||||
'축척': ['scale', '축척', 'ratio', '비율'],
|
||||
'도면번호': ['drawing_number', 'number', '번호', 'no', 'dwg'],
|
||||
'설계자': ['designer', '설계', 'design', 'drawn'],
|
||||
'프로젝트': ['project', '사업', '공사명'],
|
||||
'날짜': ['date', '일자', '작성일'],
|
||||
'리비전': ['revision', 'rev', '개정'],
|
||||
'위치': ['location', '위치', '지역']
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""DXF 처리기 초기화"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
if not EZDXF_AVAILABLE:
|
||||
raise ImportError("ezdxf 라이브러리가 필요합니다. 'pip install ezdxf'로 설치하세요.")
|
||||
|
||||
def validate_dxf_file(self, file_path: str) -> bool:
|
||||
"""DXF 파일 유효성 검사"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
self.logger.error(f"파일이 존재하지 않습니다: {file_path}")
|
||||
return False
|
||||
|
||||
if not file_path.lower().endswith('.dxf'):
|
||||
self.logger.error(f"DXF 파일이 아닙니다: {file_path}")
|
||||
return False
|
||||
|
||||
# ezdxf로 파일 읽기 시도
|
||||
doc = ezdxf.readfile(file_path)
|
||||
if doc is None:
|
||||
return False
|
||||
|
||||
self.logger.info(f"DXF 파일 유효성 검사 성공: {file_path}")
|
||||
return True
|
||||
|
||||
except ezdxf.DXFStructureError as e:
|
||||
self.logger.error(f"DXF 구조 오류: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 파일 검증 중 오류: {e}")
|
||||
return False
|
||||
|
||||
def load_dxf_document(self, file_path: str) -> Optional[Drawing]:
|
||||
"""DXF 문서 로드"""
|
||||
try:
|
||||
doc = ezdxf.readfile(file_path)
|
||||
self.logger.info(f"DXF 문서 로드 성공: {file_path}")
|
||||
return doc
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 문서 로드 실패: {e}")
|
||||
return None
|
||||
|
||||
def calculate_text_bounding_box(self, entity: Union[Text, MText, Attrib]) -> Optional[BoundingBox]:
|
||||
"""텍스트 엔티티의 바운딩 박스 계산"""
|
||||
try:
|
||||
if hasattr(entity, 'dxf'):
|
||||
# 텍스트 위치 가져오기
|
||||
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
|
||||
height = getattr(entity.dxf, 'height', 1.0)
|
||||
|
||||
# 텍스트 내용 길이 추정 (폰트에 따라 다르지만 대략적으로)
|
||||
text_content = ""
|
||||
if hasattr(entity.dxf, 'text'):
|
||||
text_content = entity.dxf.text
|
||||
elif hasattr(entity, 'plain_text'):
|
||||
text_content = entity.plain_text()
|
||||
|
||||
# 텍스트 너비 추정 (높이의 0.6배 * 글자 수)
|
||||
estimated_width = len(text_content) * height * 0.6
|
||||
|
||||
# 회전 고려 (기본값)
|
||||
rotation = getattr(entity.dxf, 'rotation', 0)
|
||||
|
||||
# 바운딩 박스 계산
|
||||
x, y = insert_point[0], insert_point[1]
|
||||
|
||||
return BoundingBox(
|
||||
min_x=x,
|
||||
min_y=y,
|
||||
max_x=x + estimated_width,
|
||||
max_y=y + height
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"텍스트 바운딩 박스 계산 실패: {e}")
|
||||
return None
|
||||
|
||||
def extract_block_references(self, doc: Drawing) -> List[BlockInfo]:
|
||||
"""문서에서 모든 Block Reference 추출"""
|
||||
block_refs = []
|
||||
|
||||
try:
|
||||
# 모델스페이스에서 INSERT 엔티티 찾기
|
||||
msp = doc.modelspace()
|
||||
|
||||
for insert in msp.query('INSERT'):
|
||||
block_info = self._process_block_reference(doc, insert)
|
||||
if block_info:
|
||||
block_refs.append(block_info)
|
||||
|
||||
# 페이퍼스페이스도 확인
|
||||
for layout_name in doc.layout_names_in_taborder():
|
||||
if layout_name.startswith('*'): # 모델스페이스 제외
|
||||
continue
|
||||
try:
|
||||
layout = doc.paperspace(layout_name)
|
||||
for insert in layout.query('INSERT'):
|
||||
block_info = self._process_block_reference(doc, insert)
|
||||
if block_info:
|
||||
block_refs.append(block_info)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"레이아웃 {layout_name} 처리 중 오류: {e}")
|
||||
|
||||
self.logger.info(f"총 {len(block_refs)}개의 블록 참조를 찾았습니다.")
|
||||
return block_refs
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"블록 참조 추출 중 오류: {e}")
|
||||
return []
|
||||
|
||||
def _process_block_reference(self, doc: Drawing, insert: Insert) -> Optional[BlockInfo]:
|
||||
"""개별 Block Reference 처리 - ATTDEF 정보도 함께 수집"""
|
||||
try:
|
||||
# 블록 정보 추출
|
||||
block_name = insert.dxf.name
|
||||
position = (insert.dxf.insert.x, insert.dxf.insert.y, insert.dxf.insert.z)
|
||||
scale = (
|
||||
getattr(insert.dxf, 'xscale', 1.0),
|
||||
getattr(insert.dxf, 'yscale', 1.0),
|
||||
getattr(insert.dxf, 'zscale', 1.0)
|
||||
)
|
||||
rotation = getattr(insert.dxf, 'rotation', 0.0)
|
||||
layer = getattr(insert.dxf, 'layer', '0')
|
||||
|
||||
# ATTDEF 정보 수집 (프롬프트 정보 포함)
|
||||
attdef_info = {}
|
||||
try:
|
||||
block_layout = doc.blocks.get(block_name)
|
||||
if block_layout:
|
||||
for attdef in block_layout.query('ATTDEF'):
|
||||
tag = getattr(attdef.dxf, 'tag', '')
|
||||
prompt = getattr(attdef.dxf, 'prompt', '')
|
||||
if tag:
|
||||
attdef_info[tag] = {
|
||||
'prompt': prompt,
|
||||
'default_text': getattr(attdef.dxf, 'text', ''),
|
||||
'position': (attdef.dxf.insert.x, attdef.dxf.insert.y, attdef.dxf.insert.z),
|
||||
'height': getattr(attdef.dxf, 'height', 1.0),
|
||||
'style': getattr(attdef.dxf, 'style', 'Standard'),
|
||||
'invisible': getattr(attdef.dxf, 'invisible', False),
|
||||
'const': getattr(attdef.dxf, 'const', False),
|
||||
'verify': getattr(attdef.dxf, 'verify', False),
|
||||
'preset': getattr(attdef.dxf, 'preset', False)
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.debug(f"ATTDEF 정보 수집 실패: {e}")
|
||||
|
||||
# ATTRIB 속성 추출 및 ATTDEF 정보와 결합
|
||||
attributes = []
|
||||
for attrib in insert.attribs:
|
||||
attr_info = self._extract_attribute_info(attrib)
|
||||
if attr_info and attr_info.tag in attdef_info:
|
||||
# ATTDEF에서 프롬프트 정보 추가
|
||||
attr_info.prompt = attdef_info[attr_info.tag]['prompt']
|
||||
|
||||
if attr_info:
|
||||
attributes.append(attr_info)
|
||||
|
||||
return BlockInfo(
|
||||
name=block_name,
|
||||
position=position,
|
||||
scale=scale,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
attributes=attributes
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"블록 참조 처리 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def _extract_attribute_info(self, attrib: Attrib) -> Optional[AttributeInfo]:
|
||||
"""Attribute Reference에서 모든 정보 추출"""
|
||||
try:
|
||||
# 기본 속성
|
||||
tag = getattr(attrib.dxf, 'tag', '')
|
||||
text = getattr(attrib.dxf, 'text', '')
|
||||
|
||||
# 위치 정보
|
||||
insert_point = getattr(attrib.dxf, 'insert', (0, 0, 0))
|
||||
position = (insert_point.x if hasattr(insert_point, 'x') else insert_point[0],
|
||||
insert_point.y if hasattr(insert_point, 'y') else insert_point[1],
|
||||
insert_point.z if hasattr(insert_point, 'z') else insert_point[2])
|
||||
|
||||
# 텍스트 속성
|
||||
height = getattr(attrib.dxf, 'height', 1.0)
|
||||
width = getattr(attrib.dxf, 'width', 1.0)
|
||||
rotation = getattr(attrib.dxf, 'rotation', 0.0)
|
||||
|
||||
# 레이어 및 스타일
|
||||
layer = getattr(attrib.dxf, 'layer', '0')
|
||||
style = getattr(attrib.dxf, 'style', 'Standard')
|
||||
|
||||
# 속성 플래그
|
||||
invisible = getattr(attrib.dxf, 'invisible', False)
|
||||
const = getattr(attrib.dxf, 'const', False)
|
||||
verify = getattr(attrib.dxf, 'verify', False)
|
||||
preset = getattr(attrib.dxf, 'preset', False)
|
||||
|
||||
# 정렬 정보
|
||||
align_point_data = getattr(attrib.dxf, 'align_point', None)
|
||||
align_point = None
|
||||
if align_point_data:
|
||||
align_point = (align_point_data.x if hasattr(align_point_data, 'x') else align_point_data[0],
|
||||
align_point_data.y if hasattr(align_point_data, 'y') else align_point_data[1],
|
||||
align_point_data.z if hasattr(align_point_data, 'z') else align_point_data[2])
|
||||
|
||||
halign = getattr(attrib.dxf, 'halign', 0)
|
||||
valign = getattr(attrib.dxf, 'valign', 0)
|
||||
|
||||
# 텍스트 형식
|
||||
text_generation_flag = getattr(attrib.dxf, 'text_generation_flag', 0)
|
||||
oblique_angle = getattr(attrib.dxf, 'oblique_angle', 0.0)
|
||||
width_factor = getattr(attrib.dxf, 'width_factor', 1.0)
|
||||
|
||||
# 시각적 속성
|
||||
color = getattr(attrib.dxf, 'color', None)
|
||||
linetype = getattr(attrib.dxf, 'linetype', None)
|
||||
lineweight = getattr(attrib.dxf, 'lineweight', None)
|
||||
|
||||
# 엔티티 핸들
|
||||
entity_handle = getattr(attrib.dxf, 'handle', None)
|
||||
|
||||
# 텍스트 폭 추정 (높이의 0.6배 * 글자 수)
|
||||
estimated_width = len(text) * height * 0.6 * width_factor
|
||||
|
||||
# 바운딩 박스 계산
|
||||
bounding_box = self.calculate_text_bounding_box(attrib)
|
||||
|
||||
# 프롬프트 정보는 ATTDEF에서 가져와야 함 (필요시 별도 처리)
|
||||
prompt = None
|
||||
|
||||
return AttributeInfo(
|
||||
tag=tag,
|
||||
text=text,
|
||||
position=position,
|
||||
height=height,
|
||||
width=width,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
bounding_box=bounding_box,
|
||||
prompt=prompt,
|
||||
style=style,
|
||||
invisible=invisible,
|
||||
const=const,
|
||||
verify=verify,
|
||||
preset=preset,
|
||||
align_point=align_point,
|
||||
halign=halign,
|
||||
valign=valign,
|
||||
text_generation_flag=text_generation_flag,
|
||||
oblique_angle=oblique_angle,
|
||||
width_factor=width_factor,
|
||||
color=color,
|
||||
linetype=linetype,
|
||||
lineweight=lineweight,
|
||||
insert_x=position[0],
|
||||
insert_y=position[1],
|
||||
insert_z=position[2],
|
||||
estimated_width=estimated_width,
|
||||
entity_handle=entity_handle
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"속성 정보 추출 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def identify_title_block(self, block_refs: List[BlockInfo]) -> Optional[TitleBlockInfo]:
|
||||
"""블록 참조들 중에서 도곽을 식별하고 정보 추출"""
|
||||
title_block_candidates = []
|
||||
|
||||
for block_ref in block_refs:
|
||||
# 도곽 키워드를 포함한 속성이 있는지 확인
|
||||
keyword_matches = 0
|
||||
|
||||
for attr in block_ref.attributes:
|
||||
for keyword_group in self.TITLE_BLOCK_KEYWORDS.keys():
|
||||
if self._contains_keyword(attr.tag, keyword_group) or \
|
||||
self._contains_keyword(attr.text, keyword_group):
|
||||
keyword_matches += 1
|
||||
break
|
||||
|
||||
# 충분한 키워드가 매칭되면 도곽 후보로 추가
|
||||
if keyword_matches >= 2: # 최소 2개 이상의 키워드 매칭
|
||||
title_block_candidates.append((block_ref, keyword_matches))
|
||||
|
||||
if not title_block_candidates:
|
||||
self.logger.warning("도곽 블록을 찾을 수 없습니다.")
|
||||
return None
|
||||
|
||||
# 가장 많은 키워드를 포함한 블록을 도곽으로 선택
|
||||
title_block_candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
best_candidate = title_block_candidates[0][0]
|
||||
|
||||
self.logger.info(f"도곽 블록 발견: {best_candidate.name} (키워드 매칭: {title_block_candidates[0][1]})")
|
||||
|
||||
return self._extract_title_block_info(best_candidate)
|
||||
|
||||
def _contains_keyword(self, text: str, keyword_group: str) -> bool:
|
||||
"""텍스트에 특정 키워드 그룹의 단어가 포함되어 있는지 확인"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
text_lower = text.lower()
|
||||
keywords = self.TITLE_BLOCK_KEYWORDS.get(keyword_group, [])
|
||||
|
||||
return any(keyword.lower() in text_lower for keyword in keywords)
|
||||
|
||||
def _extract_title_block_info(self, block_ref: BlockInfo) -> TitleBlockInfo:
|
||||
"""도곽 블록에서 상세 정보 추출 - 모든 attributes 정보 포함"""
|
||||
# TitleBlockInfo 객체 생성
|
||||
title_block = TitleBlockInfo(
|
||||
block_name=block_ref.name,
|
||||
all_attributes=block_ref.attributes.copy(), # 모든 attributes 정보 저장
|
||||
block_position=block_ref.position,
|
||||
block_scale=block_ref.scale,
|
||||
block_rotation=block_ref.rotation,
|
||||
block_layer=block_ref.layer
|
||||
)
|
||||
|
||||
# 속성들을 분석하여 도곽 정보 매핑
|
||||
for attr in block_ref.attributes:
|
||||
tag_lower = attr.tag.lower()
|
||||
text_value = attr.text.strip()
|
||||
|
||||
if not text_value:
|
||||
continue
|
||||
|
||||
# 각 키워드 그룹별로 매칭 시도
|
||||
if self._contains_keyword(attr.tag, '도면명') or self._contains_keyword(attr.text, '도면명'):
|
||||
title_block.drawing_name = text_value
|
||||
elif self._contains_keyword(attr.tag, '도면번호') or self._contains_keyword(attr.text, '도면번호'):
|
||||
title_block.drawing_number = text_value
|
||||
elif self._contains_keyword(attr.tag, '건설분야') or self._contains_keyword(attr.text, '건설분야'):
|
||||
title_block.construction_field = text_value
|
||||
elif self._contains_keyword(attr.tag, '건설단계') or self._contains_keyword(attr.text, '건설단계'):
|
||||
title_block.construction_stage = text_value
|
||||
elif self._contains_keyword(attr.tag, '축척') or self._contains_keyword(attr.text, '축척'):
|
||||
title_block.scale = text_value
|
||||
elif self._contains_keyword(attr.tag, '설계자') or self._contains_keyword(attr.text, '설계자'):
|
||||
title_block.designer = text_value
|
||||
elif self._contains_keyword(attr.tag, '프로젝트') or self._contains_keyword(attr.text, '프로젝트'):
|
||||
title_block.project_name = text_value
|
||||
elif self._contains_keyword(attr.tag, '날짜') or self._contains_keyword(attr.text, '날짜'):
|
||||
title_block.date = text_value
|
||||
elif self._contains_keyword(attr.tag, '리비전') or self._contains_keyword(attr.text, '리비전'):
|
||||
title_block.revision = text_value
|
||||
elif self._contains_keyword(attr.tag, '위치') or self._contains_keyword(attr.text, '위치'):
|
||||
title_block.location = text_value
|
||||
|
||||
# 도곽 전체 바운딩 박스 계산
|
||||
title_block.bounding_box = self._calculate_title_block_bounding_box(block_ref)
|
||||
|
||||
# 속성 개수 업데이트
|
||||
title_block.attributes_count = len(title_block.all_attributes)
|
||||
|
||||
# 디버깅 로그 - 모든 attributes 정보 출력
|
||||
self.logger.info(f"도곽 '{block_ref.name}'에서 {title_block.attributes_count}개의 속성 추출:")
|
||||
for i, attr in enumerate(title_block.all_attributes):
|
||||
self.logger.debug(f" [{i+1}] Tag: '{attr.tag}', Text: '{attr.text}', "
|
||||
f"Position: ({attr.insert_x:.2f}, {attr.insert_y:.2f}, {attr.insert_z:.2f}), "
|
||||
f"Height: {attr.height:.2f}, Prompt: '{attr.prompt or 'N/A'}'")
|
||||
|
||||
return title_block
|
||||
|
||||
def _calculate_title_block_bounding_box(self, block_ref: BlockInfo) -> Optional[BoundingBox]:
|
||||
"""도곽의 전체 바운딩 박스 계산"""
|
||||
try:
|
||||
valid_boxes = [attr.bounding_box for attr in block_ref.attributes
|
||||
if attr.bounding_box is not None]
|
||||
|
||||
if not valid_boxes:
|
||||
self.logger.warning("유효한 바운딩 박스가 없습니다.")
|
||||
return None
|
||||
|
||||
# 모든 바운딩 박스를 포함하는 최외곽 박스 계산
|
||||
min_x = min(box.min_x for box in valid_boxes)
|
||||
min_y = min(box.min_y for box in valid_boxes)
|
||||
max_x = max(box.max_x for box in valid_boxes)
|
||||
max_y = max(box.max_y for box in valid_boxes)
|
||||
|
||||
return BoundingBox(min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"도곽 바운딩 박스 계산 실패: {e}")
|
||||
return None
|
||||
|
||||
def process_dxf_file(self, file_path: str) -> Dict[str, Any]:
|
||||
"""DXF 파일 전체 처리"""
|
||||
result = {
|
||||
'success': False,
|
||||
'error': None,
|
||||
'file_path': file_path,
|
||||
'title_block': None,
|
||||
'block_references': [],
|
||||
'summary': {}
|
||||
}
|
||||
|
||||
try:
|
||||
# 파일 유효성 검사
|
||||
if not self.validate_dxf_file(file_path):
|
||||
result['error'] = "유효하지 않은 DXF 파일입니다."
|
||||
return result
|
||||
|
||||
# DXF 문서 로드
|
||||
doc = self.load_dxf_document(file_path)
|
||||
if not doc:
|
||||
result['error'] = "DXF 문서를 로드할 수 없습니다."
|
||||
return result
|
||||
|
||||
# Block Reference 추출
|
||||
block_refs = self.extract_block_references(doc)
|
||||
result['block_references'] = [asdict(block_ref) for block_ref in block_refs]
|
||||
|
||||
# 도곽 정보 추출
|
||||
title_block = self.identify_title_block(block_refs)
|
||||
if title_block:
|
||||
result['title_block'] = asdict(title_block)
|
||||
|
||||
# 요약 정보
|
||||
result['summary'] = {
|
||||
'total_blocks': len(block_refs),
|
||||
'title_block_found': title_block is not None,
|
||||
'title_block_name': title_block.block_name if title_block else None,
|
||||
'attributes_count': sum(len(br.attributes) for br in block_refs)
|
||||
}
|
||||
|
||||
result['success'] = True
|
||||
self.logger.info(f"DXF 파일 처리 완료: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
|
||||
result['error'] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def save_analysis_result(self, result: Dict[str, Any], output_file: str) -> bool:
|
||||
"""분석 결과를 JSON 파일로 저장"""
|
||||
try:
|
||||
os.makedirs(Config.RESULTS_FOLDER, exist_ok=True)
|
||||
output_path = os.path.join(Config.RESULTS_FOLDER, output_file)
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
self.logger.info(f"분석 결과 저장 완료: {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"분석 결과 저장 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""테스트용 메인 함수"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
if not EZDXF_AVAILABLE:
|
||||
print("ezdxf 라이브러리가 설치되지 않았습니다.")
|
||||
return
|
||||
|
||||
processor = DXFProcessor()
|
||||
|
||||
# 테스트 파일 경로 (실제 파일 경로로 변경 필요)
|
||||
test_file = "test_drawing.dxf"
|
||||
|
||||
if os.path.exists(test_file):
|
||||
result = processor.process_dxf_file(test_file)
|
||||
|
||||
if result['success']:
|
||||
print("DXF 파일 처리 성공!")
|
||||
print(f"블록 수: {result['summary']['total_blocks']}")
|
||||
print(f"도곽 발견: {result['summary']['title_block_found']}")
|
||||
|
||||
if result['title_block']:
|
||||
print("\n도곽 정보:")
|
||||
title_block = result['title_block']
|
||||
for key, value in title_block.items():
|
||||
if value and key != 'bounding_box':
|
||||
print(f" {key}: {value}")
|
||||
else:
|
||||
print(f"처리 실패: {result['error']}")
|
||||
else:
|
||||
print(f"테스트 파일을 찾을 수 없습니다: {test_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
76
back_src/test_dxf_processor.py
Normal file
76
back_src/test_dxf_processor.py
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DXF 처리 모듈 테스트 스크립트
|
||||
수정된 _extract_title_block_info 함수와 관련 기능들을 테스트
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(__file__))
|
||||
|
||||
try:
|
||||
from dxf_processor import DXFProcessor, AttributeInfo, TitleBlockInfo, BoundingBox
|
||||
print("[SUCCESS] DXF 처리 모듈 import 성공")
|
||||
|
||||
# DXFProcessor 인스턴스 생성
|
||||
processor = DXFProcessor()
|
||||
print("[SUCCESS] DXFProcessor 인스턴스 생성 성공")
|
||||
|
||||
# AttributeInfo 데이터 클래스 테스트
|
||||
test_attr = AttributeInfo(
|
||||
tag="TEST_TAG",
|
||||
text="테스트 텍스트",
|
||||
position=(100.0, 200.0, 0.0),
|
||||
height=5.0,
|
||||
width=50.0,
|
||||
rotation=0.0,
|
||||
layer="0",
|
||||
prompt="테스트 프롬프트",
|
||||
style="Standard",
|
||||
invisible=False,
|
||||
const=False,
|
||||
verify=False,
|
||||
preset=False,
|
||||
insert_x=100.0,
|
||||
insert_y=200.0,
|
||||
insert_z=0.0,
|
||||
estimated_width=75.0,
|
||||
entity_handle="ABC123"
|
||||
)
|
||||
print("[SUCCESS] AttributeInfo 데이터 클래스 테스트 성공")
|
||||
print(f" Tag: {test_attr.tag}")
|
||||
print(f" Text: {test_attr.text}")
|
||||
print(f" Position: {test_attr.position}")
|
||||
print(f" Prompt: {test_attr.prompt}")
|
||||
print(f" Handle: {test_attr.entity_handle}")
|
||||
|
||||
# TitleBlockInfo 데이터 클래스 테스트
|
||||
test_title_block = TitleBlockInfo(
|
||||
block_name="TEST_TITLE_BLOCK",
|
||||
drawing_name="테스트 도면",
|
||||
drawing_number="TEST-001",
|
||||
all_attributes=[test_attr]
|
||||
)
|
||||
print("[SUCCESS] TitleBlockInfo 데이터 클래스 테스트 성공")
|
||||
print(f" Block Name: {test_title_block.block_name}")
|
||||
print(f" Drawing Name: {test_title_block.drawing_name}")
|
||||
print(f" Drawing Number: {test_title_block.drawing_number}")
|
||||
print(f" Attributes Count: {test_title_block.attributes_count}")
|
||||
|
||||
# BoundingBox 테스트
|
||||
test_bbox = BoundingBox(min_x=0.0, min_y=0.0, max_x=100.0, max_y=50.0)
|
||||
print("[SUCCESS] BoundingBox 데이터 클래스 테스트 성공")
|
||||
print(f" Width: {test_bbox.width}")
|
||||
print(f" Height: {test_bbox.height}")
|
||||
print(f" Center: {test_bbox.center}")
|
||||
|
||||
print("\n[COMPLETE] 모든 테스트 통과! DXF 속성 추출 기능 개선이 성공적으로 완료되었습니다.")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"[ERROR] Import 오류: {e}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 테스트 실행 중 오류: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\nDXF 처리 모듈 테스트 완료")
|
||||
114
back_src/test_imports.py
Normal file
114
back_src/test_imports.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
간단한 임포트 테스트
|
||||
DXF 지원 통합 후 모든 모듈이 정상적으로 임포트되는지 확인
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 현재 경로 추가
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def test_imports():
|
||||
"""모든 주요 모듈 임포트 테스트"""
|
||||
try:
|
||||
print("🔍 모듈 임포트 테스트 시작...")
|
||||
|
||||
# 기본 라이브러리
|
||||
import flet as ft
|
||||
print("✅ Flet 임포트 성공")
|
||||
|
||||
# 프로젝트 모듈들
|
||||
from config import Config
|
||||
print("✅ Config 임포트 성공")
|
||||
|
||||
from pdf_processor import PDFProcessor
|
||||
print("✅ PDFProcessor 임포트 성공")
|
||||
|
||||
from dxf_processor import DXFProcessor
|
||||
print("✅ DXFProcessor 임포트 성공")
|
||||
|
||||
from gemini_analyzer import GeminiAnalyzer
|
||||
print("✅ GeminiAnalyzer 임포트 성공")
|
||||
|
||||
from ui_components import UIComponents
|
||||
print("✅ UIComponents 임포트 성공")
|
||||
|
||||
from utils import AnalysisResultSaver, DateTimeUtils
|
||||
print("✅ Utils 임포트 성공")
|
||||
|
||||
# 메인 애플리케이션
|
||||
from main import DocumentAnalyzerApp
|
||||
print("✅ DocumentAnalyzerApp 임포트 성공")
|
||||
|
||||
print("\n🎉 모든 모듈 임포트 성공!")
|
||||
print(f"📦 Flet 버전: {ft.__version__}")
|
||||
|
||||
# DXF 관련 라이브러리
|
||||
try:
|
||||
import ezdxf
|
||||
print(f"📐 ezdxf 버전: {ezdxf.version}")
|
||||
except ImportError:
|
||||
print("⚠️ ezdxf 라이브러리가 설치되지 않았습니다")
|
||||
|
||||
try:
|
||||
import numpy
|
||||
print(f"🔢 numpy 버전: {numpy.__version__}")
|
||||
except ImportError:
|
||||
print("⚠️ numpy 라이브러리가 설치되지 않았습니다")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 임포트 오류: {e}")
|
||||
return False
|
||||
|
||||
def test_basic_functionality():
|
||||
"""기본 기능 테스트"""
|
||||
try:
|
||||
print("\n🔧 기본 기능 테스트 시작...")
|
||||
|
||||
# Config 테스트
|
||||
config_errors = Config.validate_config()
|
||||
if config_errors:
|
||||
print(f"⚠️ 설정 오류: {config_errors}")
|
||||
else:
|
||||
print("✅ Config 검증 성공")
|
||||
|
||||
# PDF Processor 테스트
|
||||
pdf_processor = PDFProcessor()
|
||||
print("✅ PDFProcessor 인스턴스 생성 성공")
|
||||
|
||||
# DXF Processor 테스트
|
||||
dxf_processor = DXFProcessor()
|
||||
print("✅ DXFProcessor 인스턴스 생성 성공")
|
||||
|
||||
# DateTimeUtils 테스트
|
||||
from utils import DateTimeUtils
|
||||
timestamp = DateTimeUtils.get_timestamp()
|
||||
print(f"✅ 현재 시간: {timestamp}")
|
||||
|
||||
print("\n🎉 기본 기능 테스트 성공!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 기능 테스트 오류: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("📋 PDF/DXF 분석기 통합 테스트")
|
||||
print("=" * 60)
|
||||
|
||||
import_success = test_imports()
|
||||
functionality_success = test_basic_functionality()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if import_success and functionality_success:
|
||||
print("🎉 모든 테스트 통과! 애플리케이션 준비 완료")
|
||||
print("💡 main.py를 실행하여 애플리케이션을 시작할 수 있습니다")
|
||||
else:
|
||||
print("❌ 일부 테스트 실패. 설정을 확인하세요")
|
||||
print("=" * 60)
|
||||
314
back_src/test_project.py
Normal file
314
back_src/test_project.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
테스트 스크립트
|
||||
프로젝트의 핵심 기능들을 테스트합니다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# 프로젝트 루트를 Python 경로에 추가
|
||||
project_root = Path(__file__).parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_config():
|
||||
"""설정 모듈 테스트"""
|
||||
print("=" * 50)
|
||||
print("설정 모듈 테스트")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from config import Config
|
||||
|
||||
print(f"✅ 앱 제목: {Config.APP_TITLE}")
|
||||
print(f"✅ 앱 버전: {Config.APP_VERSION}")
|
||||
print(f"✅ 업로드 폴더: {Config.UPLOAD_FOLDER}")
|
||||
print(f"✅ 최대 파일 크기: {Config.MAX_FILE_SIZE_MB}MB")
|
||||
print(f"✅ 허용 확장자: {Config.ALLOWED_EXTENSIONS}")
|
||||
print(f"✅ Gemini 모델: {Config.GEMINI_MODEL}")
|
||||
|
||||
# 설정 검증
|
||||
errors = Config.validate_config()
|
||||
if errors:
|
||||
print("❌ 설정 오류:")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
return False
|
||||
else:
|
||||
print("✅ 모든 설정이 올바릅니다.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 설정 모듈 테스트 실패: {e}")
|
||||
return False
|
||||
|
||||
def test_pdf_processor():
|
||||
"""PDF 처리 모듈 테스트"""
|
||||
print("\\n" + "=" * 50)
|
||||
print("PDF 처리 모듈 테스트")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from pdf_processor import PDFProcessor
|
||||
|
||||
processor = PDFProcessor()
|
||||
print("✅ PDF 처리기 초기화 성공")
|
||||
|
||||
# 테스트용 임시 PDF 생성 (실제로는 존재하지 않음)
|
||||
test_pdf = "test_sample.pdf"
|
||||
|
||||
# 존재하지 않는 파일 테스트
|
||||
result = processor.validate_pdf_file(test_pdf)
|
||||
if not result:
|
||||
print("✅ 존재하지 않는 파일 검증: 정상 동작")
|
||||
else:
|
||||
print("❌ 존재하지 않는 파일 검증: 비정상 동작")
|
||||
|
||||
# 확장자 검증 테스트
|
||||
if not processor.validate_pdf_file("test.txt"):
|
||||
print("✅ 잘못된 확장자 검증: 정상 동작")
|
||||
else:
|
||||
print("❌ 잘못된 확장자 검증: 비정상 동작")
|
||||
|
||||
# base64 변환 테스트 (PIL Image 사용)
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# 작은 테스트 이미지 생성
|
||||
test_image = Image.new('RGB', (100, 100), color='red')
|
||||
base64_result = processor.image_to_base64(test_image)
|
||||
|
||||
if base64_result and len(base64_result) > 0:
|
||||
print("✅ Base64 변환: 정상 동작")
|
||||
else:
|
||||
print("❌ Base64 변환: 실패")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Base64 변환 테스트 실패: {e}")
|
||||
|
||||
print("✅ PDF 처리 모듈 기본 테스트 완료")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ PDF 처리 모듈 테스트 실패: {e}")
|
||||
return False
|
||||
|
||||
def test_gemini_analyzer():
|
||||
"""Gemini 분석기 테스트"""
|
||||
print("\\n" + "=" * 50)
|
||||
print("Gemini 분석기 테스트")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from config import Config
|
||||
|
||||
# API 키 확인
|
||||
if not Config.GEMINI_API_KEY:
|
||||
print("❌ Gemini API 키가 설정되지 않았습니다.")
|
||||
print(" .env 파일에 GEMINI_API_KEY를 설정하세요.")
|
||||
return False
|
||||
|
||||
from gemini_analyzer import GeminiAnalyzer
|
||||
|
||||
analyzer = GeminiAnalyzer()
|
||||
print("✅ Gemini 분석기 초기화 성공")
|
||||
|
||||
# API 연결 테스트
|
||||
if analyzer.validate_api_connection():
|
||||
print("✅ Gemini API 연결 테스트 성공")
|
||||
else:
|
||||
print("❌ Gemini API 연결 테스트 실패")
|
||||
return False
|
||||
|
||||
# 모델 정보 확인
|
||||
model_info = analyzer.get_model_info()
|
||||
print(f"✅ 사용 모델: {model_info['model']}")
|
||||
print(f"✅ API 키 길이: {model_info['api_key_length']}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Gemini 분석기 테스트 실패: {e}")
|
||||
return False
|
||||
|
||||
def test_utils():
|
||||
"""유틸리티 모듈 테스트"""
|
||||
print("\\n" + "=" * 50)
|
||||
print("유틸리티 모듈 테스트")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from utils import FileUtils, DateTimeUtils, TextUtils, ValidationUtils
|
||||
|
||||
# 파일 유틸리티 테스트
|
||||
safe_name = FileUtils.get_safe_filename("test<file>name?.pdf")
|
||||
print(f"✅ 안전한 파일명 생성: '{safe_name}'")
|
||||
|
||||
# 날짜/시간 유틸리티 테스트
|
||||
timestamp = DateTimeUtils.get_timestamp()
|
||||
filename_timestamp = DateTimeUtils.get_filename_timestamp()
|
||||
print(f"✅ 타임스탬프: {timestamp}")
|
||||
print(f"✅ 파일명 타임스탬프: {filename_timestamp}")
|
||||
|
||||
# 텍스트 유틸리티 테스트
|
||||
long_text = "이것은 긴 텍스트입니다. " * 10
|
||||
truncated = TextUtils.truncate_text(long_text, 50)
|
||||
print(f"✅ 텍스트 축약: '{truncated}'")
|
||||
|
||||
# 검증 유틸리티 테스트
|
||||
is_valid_pdf = ValidationUtils.is_valid_pdf_extension("test.pdf")
|
||||
is_invalid_pdf = ValidationUtils.is_valid_pdf_extension("test.txt")
|
||||
print(f"✅ PDF 확장자 검증: {is_valid_pdf} / {not is_invalid_pdf}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 유틸리티 모듈 테스트 실패: {e}")
|
||||
return False
|
||||
|
||||
def test_file_structure():
|
||||
"""파일 구조 테스트"""
|
||||
print("\\n" + "=" * 50)
|
||||
print("파일 구조 테스트")
|
||||
print("=" * 50)
|
||||
|
||||
required_files = [
|
||||
"main.py",
|
||||
"config.py",
|
||||
"pdf_processor.py",
|
||||
"gemini_analyzer.py",
|
||||
"ui_components.py",
|
||||
"utils.py",
|
||||
"requirements.txt",
|
||||
".env.example",
|
||||
"README.md",
|
||||
"project_plan.md"
|
||||
]
|
||||
|
||||
required_dirs = [
|
||||
"uploads",
|
||||
"assets",
|
||||
"docs"
|
||||
]
|
||||
|
||||
missing_files = []
|
||||
missing_dirs = []
|
||||
|
||||
# 파일 확인
|
||||
for file in required_files:
|
||||
if not (project_root / file).exists():
|
||||
missing_files.append(file)
|
||||
else:
|
||||
print(f"✅ {file}")
|
||||
|
||||
# 디렉토리 확인
|
||||
for dir_name in required_dirs:
|
||||
if not (project_root / dir_name).exists():
|
||||
missing_dirs.append(dir_name)
|
||||
else:
|
||||
print(f"✅ {dir_name}/")
|
||||
|
||||
if missing_files:
|
||||
print("❌ 누락된 파일:")
|
||||
for file in missing_files:
|
||||
print(f" - {file}")
|
||||
|
||||
if missing_dirs:
|
||||
print("❌ 누락된 디렉토리:")
|
||||
for dir_name in missing_dirs:
|
||||
print(f" - {dir_name}/")
|
||||
|
||||
return len(missing_files) == 0 and len(missing_dirs) == 0
|
||||
|
||||
def test_dependencies():
|
||||
"""의존성 테스트"""
|
||||
print("\\n" + "=" * 50)
|
||||
print("의존성 테스트")
|
||||
print("=" * 50)
|
||||
|
||||
required_packages = [
|
||||
"flet",
|
||||
"google.genai",
|
||||
"fitz", # PyMuPDF
|
||||
"PIL", # Pillow
|
||||
"dotenv" # python-dotenv
|
||||
]
|
||||
|
||||
missing_packages = []
|
||||
|
||||
for package in required_packages:
|
||||
try:
|
||||
if package == "fitz":
|
||||
import fitz
|
||||
elif package == "PIL":
|
||||
import PIL
|
||||
elif package == "dotenv":
|
||||
import dotenv
|
||||
elif package == "google.genai":
|
||||
import google.genai
|
||||
else:
|
||||
__import__(package)
|
||||
|
||||
print(f"✅ {package}")
|
||||
|
||||
except ImportError:
|
||||
missing_packages.append(package)
|
||||
print(f"❌ {package} - 설치되지 않음")
|
||||
|
||||
if missing_packages:
|
||||
print("\\n설치가 필요한 패키지:")
|
||||
print("pip install " + " ".join(missing_packages))
|
||||
return False
|
||||
else:
|
||||
print("\\n✅ 모든 의존성이 설치되어 있습니다.")
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""메인 테스트 함수"""
|
||||
print("PDF 도면 분석기 - 테스트 스크립트")
|
||||
print("=" * 80)
|
||||
|
||||
tests = [
|
||||
("파일 구조", test_file_structure),
|
||||
("의존성", test_dependencies),
|
||||
("설정 모듈", test_config),
|
||||
("PDF 처리 모듈", test_pdf_processor),
|
||||
("유틸리티 모듈", test_utils),
|
||||
("Gemini 분석기", test_gemini_analyzer),
|
||||
]
|
||||
|
||||
passed = 0
|
||||
total = len(tests)
|
||||
|
||||
for test_name, test_func in tests:
|
||||
try:
|
||||
if test_func():
|
||||
passed += 1
|
||||
print(f"\\n✅ {test_name} 테스트 통과")
|
||||
else:
|
||||
print(f"\\n❌ {test_name} 테스트 실패")
|
||||
except Exception as e:
|
||||
print(f"\\n❌ {test_name} 테스트 오류: {e}")
|
||||
|
||||
print("\\n" + "=" * 80)
|
||||
print(f"테스트 결과: {passed}/{total} 통과")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 모든 테스트가 통과했습니다!")
|
||||
print("애플리케이션 실행 준비가 완료되었습니다.")
|
||||
print("\\n실행 방법:")
|
||||
print("python main.py")
|
||||
else:
|
||||
print("⚠️ 일부 테스트가 실패했습니다.")
|
||||
print("실패한 테스트를 확인하고 문제를 해결하세요.")
|
||||
|
||||
print("=" * 80)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
back_src/test_run.py
Normal file
5
back_src/test_run.py
Normal file
@@ -0,0 +1,5 @@
|
||||
try:
|
||||
from dxf_processor import DXFProcessor
|
||||
print("Successfully imported DXFProcessor")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
548
comprehensive_text_extractor.py
Normal file
548
comprehensive_text_extractor.py
Normal file
@@ -0,0 +1,548 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
포괄적 텍스트 추출 모듈
|
||||
DXF 파일에서 도곽 블록 외의 모든 텍스트 엔티티를 추출하여 표시 및 저장
|
||||
- 모델스페이스의 독립적인 TEXT/MTEXT 엔티티
|
||||
- 페이퍼스페이스의 독립적인 TEXT/MTEXT 엔티티
|
||||
- 모든 블록 내부의 TEXT/MTEXT 엔티티
|
||||
- 블록 속성(ATTRIB) 중 도곽이 아닌 것들
|
||||
"""
|
||||
|
||||
import os
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
import ezdxf
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.entities import Insert, Attrib, AttDef, Text, MText
|
||||
from ezdxf.layouts import BlockLayout, Modelspace, Paperspace
|
||||
from ezdxf import bbox
|
||||
EZDXF_AVAILABLE = True
|
||||
except ImportError:
|
||||
EZDXF_AVAILABLE = False
|
||||
logging.warning("ezdxf 라이브러리가 설치되지 않았습니다.")
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComprehensiveTextEntity:
|
||||
"""포괄적인 텍스트 엔티티 정보"""
|
||||
entity_type: str # TEXT, MTEXT, ATTRIB
|
||||
text: str
|
||||
position_x: float
|
||||
position_y: float
|
||||
position_z: float
|
||||
height: float
|
||||
rotation: float
|
||||
layer: str
|
||||
color: Optional[int] = None
|
||||
style: Optional[str] = None
|
||||
entity_handle: Optional[str] = None
|
||||
|
||||
# 위치 정보
|
||||
location_type: str = "Unknown" # ModelSpace, PaperSpace, Block
|
||||
parent_block: Optional[str] = None
|
||||
layout_name: Optional[str] = None
|
||||
|
||||
# 블록 속성 정보 (ATTRIB인 경우)
|
||||
attribute_tag: Optional[str] = None
|
||||
is_title_block_attribute: bool = False
|
||||
|
||||
# 바운딩 박스
|
||||
bbox_min_x: Optional[float] = None
|
||||
bbox_min_y: Optional[float] = None
|
||||
bbox_max_x: Optional[float] = None
|
||||
bbox_max_y: Optional[float] = None
|
||||
|
||||
# 추가 속성
|
||||
width_factor: float = 1.0
|
||||
oblique_angle: float = 0.0
|
||||
text_generation_flag: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComprehensiveExtractionResult:
|
||||
"""포괄적인 텍스트 추출 결과"""
|
||||
all_text_entities: List[ComprehensiveTextEntity] = field(default_factory=list)
|
||||
modelspace_texts: List[ComprehensiveTextEntity] = field(default_factory=list)
|
||||
paperspace_texts: List[ComprehensiveTextEntity] = field(default_factory=list)
|
||||
block_texts: List[ComprehensiveTextEntity] = field(default_factory=list)
|
||||
non_title_block_attributes: List[ComprehensiveTextEntity] = field(default_factory=list)
|
||||
|
||||
# 통계 정보
|
||||
total_count: int = 0
|
||||
by_type_count: Dict[str, int] = field(default_factory=dict)
|
||||
by_location_count: Dict[str, int] = field(default_factory=dict)
|
||||
by_layer_count: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ComprehensiveTextExtractor:
|
||||
"""포괄적 텍스트 추출기"""
|
||||
|
||||
# 도곽 블록 식별을 위한 키워드
|
||||
TITLE_BLOCK_KEYWORDS = {
|
||||
'건설분야', '건설단계', '도면명', '축척', '도면번호', '설계자',
|
||||
'프로젝트', '날짜', '리비전', '위치', 'title', 'scale', 'drawing',
|
||||
'project', 'designer', 'date', 'revision', 'dwg', 'construction'
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""텍스트 추출기 초기화"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
if not EZDXF_AVAILABLE:
|
||||
raise ImportError("ezdxf 라이브러리가 필요합니다.")
|
||||
|
||||
def extract_all_texts_comprehensive(self, file_path: str) -> ComprehensiveExtractionResult:
|
||||
"""DXF 파일에서 모든 텍스트를 포괄적으로 추출"""
|
||||
try:
|
||||
self.logger.info(f"포괄적 텍스트 추출 시작: {file_path}")
|
||||
|
||||
# DXF 문서 로드
|
||||
doc = ezdxf.readfile(file_path)
|
||||
result = ComprehensiveExtractionResult()
|
||||
|
||||
# 1. 모델스페이스에서 독립적인 텍스트 추출
|
||||
self._extract_layout_texts(doc.modelspace(), "ModelSpace", "Model", result)
|
||||
|
||||
# 2. 모든 페이퍼스페이스에서 텍스트 추출
|
||||
for layout_name in doc.layout_names_in_taborder():
|
||||
if layout_name != "Model": # 모델스페이스 제외
|
||||
try:
|
||||
layout = doc.paperspace(layout_name)
|
||||
self._extract_layout_texts(layout, "PaperSpace", layout_name, result)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"페이퍼스페이스 {layout_name} 처리 실패: {e}")
|
||||
|
||||
# 3. 모든 블록 정의에서 텍스트 추출
|
||||
for block_layout in doc.blocks:
|
||||
if not block_layout.name.startswith('*'): # 시스템 블록 제외
|
||||
self._extract_block_texts(block_layout, result)
|
||||
|
||||
# 4. 모든 블록 참조의 속성 추출 (도곽 제외)
|
||||
self._extract_block_attributes(doc, result)
|
||||
|
||||
# 5. 결과 분류 및 통계 생성
|
||||
self._classify_and_analyze(result)
|
||||
|
||||
self.logger.info(f"포괄적 텍스트 추출 완료: 총 {result.total_count}개")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"포괄적 텍스트 추출 실패: {e}")
|
||||
raise
|
||||
|
||||
def _extract_layout_texts(self, layout, location_type: str, layout_name: str, result: ComprehensiveExtractionResult):
|
||||
"""레이아웃(모델스페이스/페이퍼스페이스)에서 텍스트 추출"""
|
||||
try:
|
||||
# TEXT 엔티티 추출
|
||||
for text_entity in layout.query('TEXT'):
|
||||
text_info = self._create_text_entity_info(
|
||||
text_entity, 'TEXT', location_type, layout_name
|
||||
)
|
||||
if text_info and text_info.text.strip():
|
||||
result.all_text_entities.append(text_info)
|
||||
if location_type == "ModelSpace":
|
||||
result.modelspace_texts.append(text_info)
|
||||
else:
|
||||
result.paperspace_texts.append(text_info)
|
||||
|
||||
# MTEXT 엔티티 추출
|
||||
for mtext_entity in layout.query('MTEXT'):
|
||||
text_info = self._create_text_entity_info(
|
||||
mtext_entity, 'MTEXT', location_type, layout_name
|
||||
)
|
||||
if text_info and text_info.text.strip():
|
||||
result.all_text_entities.append(text_info)
|
||||
if location_type == "ModelSpace":
|
||||
result.modelspace_texts.append(text_info)
|
||||
else:
|
||||
result.paperspace_texts.append(text_info)
|
||||
|
||||
# 독립적인 ATTRIB 엔티티 추출 (블록 외부)
|
||||
for attrib_entity in layout.query('ATTRIB'):
|
||||
text_info = self._create_text_entity_info(
|
||||
attrib_entity, 'ATTRIB', location_type, layout_name
|
||||
)
|
||||
if text_info and text_info.text.strip():
|
||||
result.all_text_entities.append(text_info)
|
||||
if location_type == "ModelSpace":
|
||||
result.modelspace_texts.append(text_info)
|
||||
else:
|
||||
result.paperspace_texts.append(text_info)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"레이아웃 {layout_name} 텍스트 추출 실패: {e}")
|
||||
|
||||
def _extract_block_texts(self, block_layout: BlockLayout, result: ComprehensiveExtractionResult):
|
||||
"""블록 정의 내부의 TEXT/MTEXT 엔티티 추출"""
|
||||
try:
|
||||
block_name = block_layout.name
|
||||
|
||||
# TEXT 엔티티 추출
|
||||
for text_entity in block_layout.query('TEXT'):
|
||||
text_info = self._create_text_entity_info(
|
||||
text_entity, 'TEXT', "Block", None, block_name
|
||||
)
|
||||
if text_info and text_info.text.strip():
|
||||
result.all_text_entities.append(text_info)
|
||||
result.block_texts.append(text_info)
|
||||
|
||||
# MTEXT 엔티티 추출
|
||||
for mtext_entity in block_layout.query('MTEXT'):
|
||||
text_info = self._create_text_entity_info(
|
||||
mtext_entity, 'MTEXT', "Block", None, block_name
|
||||
)
|
||||
if text_info and text_info.text.strip():
|
||||
result.all_text_entities.append(text_info)
|
||||
result.block_texts.append(text_info)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"블록 {block_layout.name} 텍스트 추출 실패: {e}")
|
||||
|
||||
def _extract_block_attributes(self, doc: Drawing, result: ComprehensiveExtractionResult):
|
||||
"""모든 블록 참조의 속성 추출 (도곽 블록 제외)"""
|
||||
try:
|
||||
# 모델스페이스의 블록 참조
|
||||
self._process_layout_block_references(doc.modelspace(), "ModelSpace", "Model", result)
|
||||
|
||||
# 페이퍼스페이스의 블록 참조
|
||||
for layout_name in doc.layout_names_in_taborder():
|
||||
if layout_name != "Model":
|
||||
try:
|
||||
layout = doc.paperspace(layout_name)
|
||||
self._process_layout_block_references(layout, "PaperSpace", layout_name, result)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"페이퍼스페이스 {layout_name} 블록 참조 처리 실패: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"블록 속성 추출 실패: {e}")
|
||||
|
||||
def _process_layout_block_references(self, layout, location_type: str, layout_name: str, result: ComprehensiveExtractionResult):
|
||||
"""레이아웃의 블록 참조 처리"""
|
||||
for insert in layout.query('INSERT'):
|
||||
block_name = insert.dxf.name
|
||||
|
||||
# 도곽 블록인지 확인
|
||||
is_title_block = self._is_title_block(insert)
|
||||
|
||||
# 블록의 속성들 추출
|
||||
for attrib in insert.attribs:
|
||||
text_info = self._create_attrib_entity_info(
|
||||
attrib, location_type, layout_name, block_name, is_title_block
|
||||
)
|
||||
if text_info and text_info.text.strip():
|
||||
result.all_text_entities.append(text_info)
|
||||
if not is_title_block:
|
||||
result.non_title_block_attributes.append(text_info)
|
||||
|
||||
def _is_title_block(self, insert: Insert) -> bool:
|
||||
"""블록이 도곽 블록인지 판단"""
|
||||
try:
|
||||
# 블록 이름에서 도곽 키워드 확인
|
||||
block_name = insert.dxf.name.lower()
|
||||
if any(keyword in block_name for keyword in ['title', 'border', '도곽', '표제']):
|
||||
return True
|
||||
|
||||
# 속성에서 도곽 키워드 확인
|
||||
title_block_attrs = 0
|
||||
for attrib in insert.attribs:
|
||||
tag = attrib.dxf.tag.lower()
|
||||
text = attrib.dxf.text.lower()
|
||||
|
||||
if any(keyword in tag or keyword in text for keyword in self.TITLE_BLOCK_KEYWORDS):
|
||||
title_block_attrs += 1
|
||||
|
||||
# 2개 이상의 도곽 관련 속성이 있으면 도곽으로 판단
|
||||
return title_block_attrs >= 2
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _create_text_entity_info(self, entity, entity_type: str, location_type: str,
|
||||
layout_name: Optional[str], parent_block: Optional[str] = None) -> Optional[ComprehensiveTextEntity]:
|
||||
"""텍스트 엔티티 정보 생성"""
|
||||
try:
|
||||
# 텍스트 내용 추출
|
||||
if entity_type == 'MTEXT':
|
||||
text_content = getattr(entity, 'text', '') or getattr(entity.dxf, 'text', '')
|
||||
else:
|
||||
text_content = getattr(entity.dxf, 'text', '')
|
||||
|
||||
if not text_content.strip():
|
||||
return None
|
||||
|
||||
# 위치 정보
|
||||
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
|
||||
if hasattr(insert_point, 'x'):
|
||||
position = (insert_point.x, insert_point.y, insert_point.z)
|
||||
else:
|
||||
position = (insert_point[0], insert_point[1], insert_point[2] if len(insert_point) > 2 else 0)
|
||||
|
||||
# 속성 정보
|
||||
height = getattr(entity.dxf, 'height', 1.0)
|
||||
if entity_type == 'MTEXT':
|
||||
height = getattr(entity.dxf, 'char_height', height)
|
||||
|
||||
rotation = getattr(entity.dxf, 'rotation', 0.0)
|
||||
layer = getattr(entity.dxf, 'layer', '0')
|
||||
color = getattr(entity.dxf, 'color', None)
|
||||
style = getattr(entity.dxf, 'style', None)
|
||||
entity_handle = getattr(entity.dxf, 'handle', None)
|
||||
width_factor = getattr(entity.dxf, 'width_factor', 1.0)
|
||||
oblique_angle = getattr(entity.dxf, 'oblique_angle', 0.0)
|
||||
text_generation_flag = getattr(entity.dxf, 'text_generation_flag', 0)
|
||||
|
||||
# 바운딩 박스 계산
|
||||
bbox_info = self._calculate_entity_bbox(entity)
|
||||
|
||||
return ComprehensiveTextEntity(
|
||||
entity_type=entity_type,
|
||||
text=text_content,
|
||||
position_x=position[0],
|
||||
position_y=position[1],
|
||||
position_z=position[2],
|
||||
height=height,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
color=color,
|
||||
style=style,
|
||||
entity_handle=entity_handle,
|
||||
location_type=location_type,
|
||||
parent_block=parent_block,
|
||||
layout_name=layout_name,
|
||||
bbox_min_x=bbox_info[0] if bbox_info else None,
|
||||
bbox_min_y=bbox_info[1] if bbox_info else None,
|
||||
bbox_max_x=bbox_info[2] if bbox_info else None,
|
||||
bbox_max_y=bbox_info[3] if bbox_info else None,
|
||||
width_factor=width_factor,
|
||||
oblique_angle=oblique_angle,
|
||||
text_generation_flag=text_generation_flag
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"텍스트 엔티티 정보 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
def _create_attrib_entity_info(self, attrib: Attrib, location_type: str, layout_name: Optional[str],
|
||||
parent_block: str, is_title_block: bool) -> Optional[ComprehensiveTextEntity]:
|
||||
"""속성 엔티티 정보 생성"""
|
||||
try:
|
||||
text_content = getattr(attrib.dxf, 'text', '')
|
||||
if not text_content.strip():
|
||||
return None
|
||||
|
||||
# 위치 정보
|
||||
insert_point = getattr(attrib.dxf, 'insert', (0, 0, 0))
|
||||
if hasattr(insert_point, 'x'):
|
||||
position = (insert_point.x, insert_point.y, insert_point.z)
|
||||
else:
|
||||
position = (insert_point[0], insert_point[1], insert_point[2] if len(insert_point) > 2 else 0)
|
||||
|
||||
# 속성 정보
|
||||
tag = getattr(attrib.dxf, 'tag', '')
|
||||
height = getattr(attrib.dxf, 'height', 1.0)
|
||||
rotation = getattr(attrib.dxf, 'rotation', 0.0)
|
||||
layer = getattr(attrib.dxf, 'layer', '0')
|
||||
color = getattr(attrib.dxf, 'color', None)
|
||||
style = getattr(attrib.dxf, 'style', None)
|
||||
entity_handle = getattr(attrib.dxf, 'handle', None)
|
||||
width_factor = getattr(attrib.dxf, 'width_factor', 1.0)
|
||||
oblique_angle = getattr(attrib.dxf, 'oblique_angle', 0.0)
|
||||
text_generation_flag = getattr(attrib.dxf, 'text_generation_flag', 0)
|
||||
|
||||
# 바운딩 박스 계산
|
||||
bbox_info = self._calculate_entity_bbox(attrib)
|
||||
|
||||
return ComprehensiveTextEntity(
|
||||
entity_type='ATTRIB',
|
||||
text=text_content,
|
||||
position_x=position[0],
|
||||
position_y=position[1],
|
||||
position_z=position[2],
|
||||
height=height,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
color=color,
|
||||
style=style,
|
||||
entity_handle=entity_handle,
|
||||
location_type=location_type,
|
||||
parent_block=parent_block,
|
||||
layout_name=layout_name,
|
||||
attribute_tag=tag,
|
||||
is_title_block_attribute=is_title_block,
|
||||
bbox_min_x=bbox_info[0] if bbox_info else None,
|
||||
bbox_min_y=bbox_info[1] if bbox_info else None,
|
||||
bbox_max_x=bbox_info[2] if bbox_info else None,
|
||||
bbox_max_y=bbox_info[3] if bbox_info else None,
|
||||
width_factor=width_factor,
|
||||
oblique_angle=oblique_angle,
|
||||
text_generation_flag=text_generation_flag
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"속성 엔티티 정보 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_entity_bbox(self, entity) -> Optional[Tuple[float, float, float, float]]:
|
||||
"""엔티티의 바운딩 박스 계산"""
|
||||
try:
|
||||
entity_bbox = bbox.extents([entity])
|
||||
if entity_bbox:
|
||||
return (entity_bbox.extmin.x, entity_bbox.extmin.y,
|
||||
entity_bbox.extmax.x, entity_bbox.extmax.y)
|
||||
except Exception:
|
||||
# 대안: 추정 계산
|
||||
try:
|
||||
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
|
||||
height = getattr(entity.dxf, 'height', 1.0)
|
||||
|
||||
if hasattr(entity, 'text'):
|
||||
text_content = entity.text
|
||||
elif hasattr(entity.dxf, 'text'):
|
||||
text_content = entity.dxf.text
|
||||
else:
|
||||
text_content = ""
|
||||
|
||||
estimated_width = len(text_content) * height * 0.6
|
||||
x, y = insert_point[0], insert_point[1]
|
||||
|
||||
return (x, y, x + estimated_width, y + height)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _classify_and_analyze(self, result: ComprehensiveExtractionResult):
|
||||
"""결과 분류 및 통계 분석"""
|
||||
result.total_count = len(result.all_text_entities)
|
||||
|
||||
# 타입별 개수
|
||||
for entity in result.all_text_entities:
|
||||
entity_type = entity.entity_type
|
||||
result.by_type_count[entity_type] = result.by_type_count.get(entity_type, 0) + 1
|
||||
|
||||
# 위치별 개수
|
||||
for entity in result.all_text_entities:
|
||||
location = entity.location_type
|
||||
result.by_location_count[location] = result.by_location_count.get(location, 0) + 1
|
||||
|
||||
# 레이어별 개수
|
||||
for entity in result.all_text_entities:
|
||||
layer = entity.layer
|
||||
result.by_layer_count[layer] = result.by_layer_count.get(layer, 0) + 1
|
||||
|
||||
def save_to_csv(self, result: ComprehensiveExtractionResult, output_path: str) -> bool:
|
||||
"""결과를 CSV 파일로 저장"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
||||
fieldnames = [
|
||||
'Entity_Type', 'Text', 'Position_X', 'Position_Y', 'Position_Z',
|
||||
'Height', 'Rotation', 'Layer', 'Color', 'Style', 'Entity_Handle',
|
||||
'Location_Type', 'Parent_Block', 'Layout_Name', 'Attribute_Tag',
|
||||
'Is_Title_Block_Attribute', 'BBox_Min_X', 'BBox_Min_Y',
|
||||
'BBox_Max_X', 'BBox_Max_Y', 'Width_Factor', 'Oblique_Angle'
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for entity in result.all_text_entities:
|
||||
writer.writerow({
|
||||
'Entity_Type': entity.entity_type,
|
||||
'Text': entity.text,
|
||||
'Position_X': entity.position_x,
|
||||
'Position_Y': entity.position_y,
|
||||
'Position_Z': entity.position_z,
|
||||
'Height': entity.height,
|
||||
'Rotation': entity.rotation,
|
||||
'Layer': entity.layer,
|
||||
'Color': entity.color,
|
||||
'Style': entity.style,
|
||||
'Entity_Handle': entity.entity_handle,
|
||||
'Location_Type': entity.location_type,
|
||||
'Parent_Block': entity.parent_block,
|
||||
'Layout_Name': entity.layout_name,
|
||||
'Attribute_Tag': entity.attribute_tag,
|
||||
'Is_Title_Block_Attribute': entity.is_title_block_attribute,
|
||||
'BBox_Min_X': entity.bbox_min_x,
|
||||
'BBox_Min_Y': entity.bbox_min_y,
|
||||
'BBox_Max_X': entity.bbox_max_x,
|
||||
'BBox_Max_Y': entity.bbox_max_y,
|
||||
'Width_Factor': entity.width_factor,
|
||||
'Oblique_Angle': entity.oblique_angle
|
||||
})
|
||||
|
||||
self.logger.info(f"CSV 저장 완료: {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"CSV 저장 실패: {e}")
|
||||
return False
|
||||
|
||||
def save_to_json(self, result: ComprehensiveExtractionResult, output_path: str) -> bool:
|
||||
"""결과를 JSON 파일로 저장"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as jsonfile:
|
||||
json.dump(asdict(result), jsonfile, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
self.logger.info(f"JSON 저장 완료: {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"JSON 저장 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""테스트용 메인 함수"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
if not EZDXF_AVAILABLE:
|
||||
print("ezdxf 라이브러리가 설치되지 않았습니다.")
|
||||
return
|
||||
|
||||
extractor = ComprehensiveTextExtractor()
|
||||
test_file = "test_drawing.dxf"
|
||||
|
||||
if os.path.exists(test_file):
|
||||
try:
|
||||
result = extractor.extract_all_texts_comprehensive(test_file)
|
||||
|
||||
print(f"포괄적 텍스트 추출 결과:")
|
||||
print(f"총 텍스트 엔티티: {result.total_count}")
|
||||
print(f"모델스페이스: {len(result.modelspace_texts)}")
|
||||
print(f"페이퍼스페이스: {len(result.paperspace_texts)}")
|
||||
print(f"블록 내부: {len(result.block_texts)}")
|
||||
print(f"비도곽 속성: {len(result.non_title_block_attributes)}")
|
||||
|
||||
print("\n타입별 개수:")
|
||||
for entity_type, count in result.by_type_count.items():
|
||||
print(f" {entity_type}: {count}")
|
||||
|
||||
print("\n위치별 개수:")
|
||||
for location, count in result.by_location_count.items():
|
||||
print(f" {location}: {count}")
|
||||
|
||||
# CSV 저장 테스트
|
||||
csv_path = "test_comprehensive_texts.csv"
|
||||
if extractor.save_to_csv(result, csv_path):
|
||||
print(f"\nCSV 저장 성공: {csv_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"추출 실패: {e}")
|
||||
else:
|
||||
print(f"테스트 파일을 찾을 수 없습니다: {test_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
74
config.py
Normal file
74
config.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
설정 관리 모듈
|
||||
환경 변수 및 애플리케이션 설정을 관리합니다.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from pathlib import Path
|
||||
|
||||
# .env 파일 로드
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
"""애플리케이션 설정 클래스"""
|
||||
|
||||
# 기본 애플리케이션 설정
|
||||
APP_TITLE = os.getenv("APP_TITLE", "PDF/DXF 도면 분석기")
|
||||
APP_VERSION = os.getenv("APP_VERSION", "1.1.0")
|
||||
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
|
||||
|
||||
# API 설정
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
||||
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-pro")
|
||||
DEFAULT_PROMPT = os.getenv(
|
||||
"DEFAULT_PROMPT",
|
||||
"pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.structured_output 이외에 정보도 기타에 넣어줘."
|
||||
)
|
||||
|
||||
# 파일 업로드 설정
|
||||
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
|
||||
ALLOWED_EXTENSIONS = os.getenv("ALLOWED_EXTENSIONS", "pdf,dxf").split(",")
|
||||
UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER", "uploads")
|
||||
|
||||
# 경로 설정
|
||||
BASE_DIR = Path(__file__).parent
|
||||
UPLOAD_DIR = BASE_DIR / UPLOAD_FOLDER
|
||||
ASSETS_DIR = BASE_DIR / "assets"
|
||||
RESULTS_FOLDER = BASE_DIR / "results"
|
||||
|
||||
@classmethod
|
||||
def validate_config(cls):
|
||||
"""설정 유효성 검사"""
|
||||
errors = []
|
||||
|
||||
if not cls.GEMINI_API_KEY:
|
||||
errors.append("GEMINI_API_KEY가 설정되지 않았습니다.")
|
||||
|
||||
if not cls.UPLOAD_DIR.exists():
|
||||
try:
|
||||
cls.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
errors.append(f"업로드 폴더 생성 실패: {e}")
|
||||
|
||||
return errors
|
||||
|
||||
@classmethod
|
||||
def get_file_size_limit_bytes(cls):
|
||||
"""파일 크기 제한을 바이트로 반환"""
|
||||
return cls.MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
@classmethod
|
||||
def get_gemini_api_key(cls):
|
||||
"""Gemini API 키 반환"""
|
||||
return cls.GEMINI_API_KEY
|
||||
|
||||
# 설정 검증
|
||||
if __name__ == "__main__":
|
||||
config_errors = Config.validate_config()
|
||||
if config_errors:
|
||||
print("설정 오류:")
|
||||
for error in config_errors:
|
||||
print(f" - {error}")
|
||||
else:
|
||||
print("설정이 올바르게 구성되었습니다.")
|
||||
638
cross_tabulated_csv_exporter.py
Normal file
638
cross_tabulated_csv_exporter.py
Normal file
@@ -0,0 +1,638 @@
|
||||
"""
|
||||
Cross-Tabulated CSV 내보내기 모듈 (개선된 통합 버전)
|
||||
JSON 형태의 분석 결과를 key-value 형태의 cross-tabulated CSV로 저장하는 기능을 제공합니다.
|
||||
관련 키들(value, x, y)을 하나의 행으로 통합하여 저장합니다.
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-15
|
||||
Updated: 2025-07-16 (키 통합 개선 버전)
|
||||
Version: 2.0.0
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Union, Tuple
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrossTabulatedCSVExporter:
|
||||
"""Cross-Tabulated CSV 내보내기 클래스 (개선된 통합 버전)"""
|
||||
|
||||
def __init__(self):
|
||||
"""Cross-Tabulated CSV 내보내기 초기화"""
|
||||
self.coordinate_pattern = re.compile(r'\b(\d+)\s*,\s*(\d+)\b') # x,y 좌표 패턴
|
||||
self.debug_mode = True # 디버깅 모드 활성화
|
||||
|
||||
# 키 그룹핑을 위한 패턴들
|
||||
self.value_suffixes = ['_value', '_val', '_text', '_content']
|
||||
self.x_suffixes = ['_x', '_x_coord', '_x_position', '_left']
|
||||
self.y_suffixes = ['_y', '_y_coord', '_y_position', '_top']
|
||||
|
||||
def export_cross_tabulated_csv(
|
||||
self,
|
||||
processing_results: List[Any],
|
||||
output_path: str,
|
||||
include_coordinates: bool = True,
|
||||
coordinate_source: str = "auto" # "auto", "text_blocks", "analysis_result", "none"
|
||||
) -> bool:
|
||||
"""
|
||||
처리 결과를 cross-tabulated CSV 형태로 저장 (키 통합 기능 포함)
|
||||
|
||||
Args:
|
||||
processing_results: 다중 파일 처리 결과 리스트
|
||||
output_path: 출력 CSV 파일 경로
|
||||
include_coordinates: 좌표 정보 포함 여부
|
||||
coordinate_source: 좌표 정보 출처 ("auto", "text_blocks", "analysis_result", "none")
|
||||
|
||||
Returns:
|
||||
저장 성공 여부
|
||||
"""
|
||||
try:
|
||||
if self.debug_mode:
|
||||
logger.info(f"=== Cross-tabulated CSV 저장 시작 (통합 버전) ===")
|
||||
logger.info(f"입력된 결과 수: {len(processing_results)}")
|
||||
logger.info(f"출력 경로: {output_path}")
|
||||
logger.info(f"좌표 포함: {include_coordinates}, 좌표 출처: {coordinate_source}")
|
||||
|
||||
# 입력 데이터 검증
|
||||
if not processing_results:
|
||||
logger.warning("입력된 처리 결과가 비어있습니다.")
|
||||
return False
|
||||
|
||||
# 각 결과 객체의 구조 분석
|
||||
for i, result in enumerate(processing_results):
|
||||
if self.debug_mode:
|
||||
logger.info(f"결과 {i+1}: {self._analyze_result_structure(result)}")
|
||||
|
||||
# 모든 파일의 key-value 쌍을 수집
|
||||
all_grouped_data = []
|
||||
|
||||
for i, result in enumerate(processing_results):
|
||||
try:
|
||||
if not hasattr(result, 'success'):
|
||||
logger.warning(f"결과 {i+1}: 'success' 속성이 없습니다. 스킵합니다.")
|
||||
continue
|
||||
|
||||
if not result.success:
|
||||
if self.debug_mode:
|
||||
logger.info(f"결과 {i+1}: 실패한 파일, 스킵합니다 ({getattr(result, 'error_message', 'Unknown error')})")
|
||||
continue # 실패한 파일은 제외
|
||||
|
||||
# 기본 key-value 쌍 추출
|
||||
file_data = self._extract_key_value_pairs(result, include_coordinates, coordinate_source)
|
||||
|
||||
if file_data:
|
||||
# 관련 키들을 그룹화하여 통합된 데이터 생성
|
||||
grouped_data = self._group_and_merge_keys(file_data, result)
|
||||
|
||||
if grouped_data:
|
||||
all_grouped_data.extend(grouped_data)
|
||||
if self.debug_mode:
|
||||
logger.info(f"결과 {i+1}: {len(file_data)}개 key-value 쌍 → {len(grouped_data)}개 통합 행 생성")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"결과 {i+1}: 그룹화 후 데이터가 없습니다")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"결과 {i+1}: key-value 쌍을 추출할 수 없습니다")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"결과 {i+1} 처리 중 오류: {str(e)}")
|
||||
continue
|
||||
|
||||
if not all_grouped_data:
|
||||
logger.warning("저장할 데이터가 없습니다. 모든 파일에서 유효한 key-value 쌍을 추출할 수 없었습니다.")
|
||||
if self.debug_mode:
|
||||
self._print_debug_summary(processing_results)
|
||||
return False
|
||||
|
||||
# DataFrame 생성
|
||||
df = pd.DataFrame(all_grouped_data)
|
||||
|
||||
# 컬럼 순서 정렬
|
||||
column_order = ['file_name', 'file_type', 'key', 'value']
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
column_order.extend(['x', 'y'])
|
||||
|
||||
# 추가 컬럼들을 뒤에 배치
|
||||
existing_columns = [col for col in column_order if col in df.columns]
|
||||
additional_columns = [col for col in df.columns if col not in existing_columns]
|
||||
df = df[existing_columns + additional_columns]
|
||||
|
||||
# 출력 디렉토리 생성
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
# UTF-8 BOM으로 저장 (한글 호환성)
|
||||
df.to_csv(output_path, index=False, encoding='utf-8-sig')
|
||||
|
||||
logger.info(f"Cross-tabulated CSV 저장 완료: {output_path}")
|
||||
logger.info(f"총 {len(all_grouped_data)}개 통합 행 저장")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cross-tabulated CSV 저장 오류: {str(e)}")
|
||||
return False
|
||||
|
||||
def _group_and_merge_keys(self, raw_data: List[Dict[str, Any]], result: Any) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
관련된 키들을 그룹화하고 하나의 행으로 통합
|
||||
|
||||
Args:
|
||||
raw_data: 원시 key-value 쌍 리스트
|
||||
result: 파일 처리 결과
|
||||
|
||||
Returns:
|
||||
통합된 데이터 리스트
|
||||
"""
|
||||
# 파일 기본 정보
|
||||
file_name = getattr(result, 'file_name', 'Unknown')
|
||||
file_type = getattr(result, 'file_type', 'Unknown')
|
||||
|
||||
# 키별로 데이터 그룹화
|
||||
key_groups = defaultdict(dict)
|
||||
|
||||
for data_row in raw_data:
|
||||
key = data_row.get('key', '')
|
||||
value = data_row.get('value', '')
|
||||
x = data_row.get('x', '')
|
||||
y = data_row.get('y', '')
|
||||
|
||||
# 기본 키 추출 (예: "사업명_value" -> "사업명")
|
||||
base_key = self._extract_base_key(key)
|
||||
|
||||
# 키 타입 결정 (value, x, y 등)
|
||||
key_type = self._determine_key_type(key)
|
||||
|
||||
if self.debug_mode and not key_groups[base_key]:
|
||||
logger.info(f"새 키 그룹 생성: '{base_key}' (원본: '{key}', 타입: '{key_type}')")
|
||||
|
||||
# 그룹에 데이터 추가
|
||||
if key_type == 'value':
|
||||
key_groups[base_key]['value'] = value
|
||||
# value에 좌표가 포함된 경우 사용
|
||||
if not key_groups[base_key].get('x') and x:
|
||||
key_groups[base_key]['x'] = x
|
||||
if not key_groups[base_key].get('y') and y:
|
||||
key_groups[base_key]['y'] = y
|
||||
elif key_type == 'x':
|
||||
key_groups[base_key]['x'] = value # x 값은 value 컬럼에서 가져옴
|
||||
elif key_type == 'y':
|
||||
key_groups[base_key]['y'] = value # y 값은 value 컬럼에서 가져옴
|
||||
else:
|
||||
# 일반적인 키인 경우 (suffix가 없는 경우)
|
||||
if not key_groups[base_key].get('value'):
|
||||
key_groups[base_key]['value'] = value
|
||||
if x and not key_groups[base_key].get('x'):
|
||||
key_groups[base_key]['x'] = x
|
||||
if y and not key_groups[base_key].get('y'):
|
||||
key_groups[base_key]['y'] = y
|
||||
|
||||
# 그룹화된 데이터를 최종 형태로 변환
|
||||
merged_data = []
|
||||
|
||||
for base_key, group_data in key_groups.items():
|
||||
# 빈 값이나 의미없는 데이터 제외
|
||||
if not group_data.get('value') or str(group_data.get('value')).strip() == '':
|
||||
continue
|
||||
|
||||
merged_row = {
|
||||
'file_name': file_name,
|
||||
'file_type': file_type,
|
||||
'key': base_key,
|
||||
'value': str(group_data.get('value', '')),
|
||||
'x': str(group_data.get('x', '')) if group_data.get('x') else '',
|
||||
'y': str(group_data.get('y', '')) if group_data.get('y') else '',
|
||||
}
|
||||
|
||||
merged_data.append(merged_row)
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"통합 행 생성: {base_key} = '{merged_row['value']}' ({merged_row['x']}, {merged_row['y']})")
|
||||
|
||||
return merged_data
|
||||
|
||||
def _extract_base_key(self, key: str) -> str:
|
||||
"""
|
||||
키에서 기본 이름 추출 (suffix 제거)
|
||||
|
||||
Args:
|
||||
key: 원본 키 (예: "사업명_value", "사업명_x")
|
||||
|
||||
Returns:
|
||||
기본 키 이름 (예: "사업명")
|
||||
"""
|
||||
if not key:
|
||||
return key
|
||||
|
||||
# 모든 가능한 suffix 확인
|
||||
all_suffixes = self.value_suffixes + self.x_suffixes + self.y_suffixes
|
||||
|
||||
for suffix in all_suffixes:
|
||||
if key.endswith(suffix):
|
||||
return key[:-len(suffix)]
|
||||
|
||||
# suffix가 없는 경우 원본 반환
|
||||
return key
|
||||
|
||||
def _determine_key_type(self, key: str) -> str:
|
||||
"""
|
||||
키의 타입 결정 (value, x, y, other)
|
||||
|
||||
Args:
|
||||
key: 키 이름
|
||||
|
||||
Returns:
|
||||
키 타입 ("value", "x", "y", "other")
|
||||
"""
|
||||
if not key:
|
||||
return "other"
|
||||
|
||||
key_lower = key.lower()
|
||||
|
||||
# value 타입 확인
|
||||
for suffix in self.value_suffixes:
|
||||
if key_lower.endswith(suffix.lower()):
|
||||
return "value"
|
||||
|
||||
# x 타입 확인
|
||||
for suffix in self.x_suffixes:
|
||||
if key_lower.endswith(suffix.lower()):
|
||||
return "x"
|
||||
|
||||
# y 타입 확인
|
||||
for suffix in self.y_suffixes:
|
||||
if key_lower.endswith(suffix.lower()):
|
||||
return "y"
|
||||
|
||||
return "other"
|
||||
|
||||
def _analyze_result_structure(self, result: Any) -> str:
|
||||
"""결과 객체의 구조를 분석하여 문자열로 반환"""
|
||||
try:
|
||||
info = []
|
||||
|
||||
# 기본 속성들 확인
|
||||
if hasattr(result, 'file_name'):
|
||||
info.append(f"file_name='{result.file_name}'")
|
||||
if hasattr(result, 'file_type'):
|
||||
info.append(f"file_type='{result.file_type}'")
|
||||
if hasattr(result, 'success'):
|
||||
info.append(f"success={result.success}")
|
||||
|
||||
# PDF 관련 속성
|
||||
if hasattr(result, 'pdf_analysis_result'):
|
||||
pdf_result = result.pdf_analysis_result
|
||||
if pdf_result:
|
||||
if isinstance(pdf_result, str):
|
||||
info.append(f"pdf_analysis_result=str({len(pdf_result)} chars)")
|
||||
else:
|
||||
info.append(f"pdf_analysis_result={type(pdf_result).__name__}")
|
||||
else:
|
||||
info.append("pdf_analysis_result=None")
|
||||
|
||||
# DXF 관련 속성
|
||||
if hasattr(result, 'dxf_title_blocks'):
|
||||
dxf_blocks = result.dxf_title_blocks
|
||||
if dxf_blocks:
|
||||
info.append(f"dxf_title_blocks=list({len(dxf_blocks)} blocks)")
|
||||
else:
|
||||
info.append("dxf_title_blocks=None")
|
||||
|
||||
return " | ".join(info) if info else "구조 분석 실패"
|
||||
|
||||
except Exception as e:
|
||||
return f"분석 오류: {str(e)}"
|
||||
|
||||
def _print_debug_summary(self, processing_results: List[Any]):
|
||||
"""디버깅을 위한 요약 정보 출력"""
|
||||
logger.info("=== 디버깅 요약 ===")
|
||||
|
||||
success_count = 0
|
||||
pdf_count = 0
|
||||
dxf_count = 0
|
||||
has_pdf_data = 0
|
||||
has_dxf_data = 0
|
||||
|
||||
for i, result in enumerate(processing_results):
|
||||
try:
|
||||
if hasattr(result, 'success') and result.success:
|
||||
success_count += 1
|
||||
|
||||
file_type = getattr(result, 'file_type', 'unknown').lower()
|
||||
if file_type == 'pdf':
|
||||
pdf_count += 1
|
||||
if getattr(result, 'pdf_analysis_result', None):
|
||||
has_pdf_data += 1
|
||||
elif file_type == 'dxf':
|
||||
dxf_count += 1
|
||||
if getattr(result, 'dxf_title_blocks', None):
|
||||
has_dxf_data += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"결과 {i+1} 분석 중 오류: {str(e)}")
|
||||
|
||||
logger.info(f"총 결과: {len(processing_results)}개")
|
||||
logger.info(f"성공한 결과: {success_count}개")
|
||||
logger.info(f"PDF 파일: {pdf_count}개 (분석 데이터 있음: {has_pdf_data}개)")
|
||||
logger.info(f"DXF 파일: {dxf_count}개 (타이틀블록 데이터 있음: {has_dxf_data}개)")
|
||||
|
||||
def _extract_key_value_pairs(
|
||||
self,
|
||||
result: Any,
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
단일 파일 결과에서 key-value 쌍 추출
|
||||
|
||||
Args:
|
||||
result: 파일 처리 결과
|
||||
include_coordinates: 좌표 정보 포함 여부
|
||||
coordinate_source: 좌표 정보 출처
|
||||
|
||||
Returns:
|
||||
key-value 쌍 리스트
|
||||
"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
# 기본 정보 확인
|
||||
file_name = getattr(result, 'file_name', 'Unknown')
|
||||
file_type = getattr(result, 'file_type', 'Unknown')
|
||||
|
||||
base_info = {
|
||||
'file_name': file_name,
|
||||
'file_type': file_type,
|
||||
}
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"처리 중: {file_name} ({file_type})")
|
||||
|
||||
# PDF 분석 결과 처리
|
||||
if file_type.lower() == 'pdf':
|
||||
pdf_result = getattr(result, 'pdf_analysis_result', None)
|
||||
if pdf_result:
|
||||
pdf_rows = self._extract_pdf_key_values(result, base_info, include_coordinates, coordinate_source)
|
||||
data_rows.extend(pdf_rows)
|
||||
if self.debug_mode:
|
||||
logger.info(f"PDF에서 {len(pdf_rows)}개 key-value 쌍 추출")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"PDF 분석 결과가 없습니다: {file_name}")
|
||||
|
||||
# DXF 분석 결과 처리
|
||||
elif file_type.lower() == 'dxf':
|
||||
dxf_blocks = getattr(result, 'dxf_title_blocks', None)
|
||||
if dxf_blocks:
|
||||
dxf_rows = self._extract_dxf_key_values(result, base_info, include_coordinates, coordinate_source)
|
||||
data_rows.extend(dxf_rows)
|
||||
if self.debug_mode:
|
||||
logger.info(f"DXF에서 {len(dxf_rows)}개 key-value 쌍 추출")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"DXF 타이틀블록 데이터가 없습니다: {file_name}")
|
||||
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"지원하지 않는 파일 형식: {file_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Key-value 추출 오류 ({getattr(result, 'file_name', 'Unknown')}): {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _extract_pdf_key_values(
|
||||
self,
|
||||
result: Any,
|
||||
base_info: Dict[str, str],
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""PDF 분석 결과에서 key-value 쌍 추출"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
# PDF 분석 결과를 JSON으로 파싱
|
||||
analysis_result = getattr(result, 'pdf_analysis_result', None)
|
||||
|
||||
if not analysis_result:
|
||||
return data_rows
|
||||
|
||||
if isinstance(analysis_result, str):
|
||||
try:
|
||||
analysis_data = json.loads(analysis_result)
|
||||
except json.JSONDecodeError:
|
||||
# JSON이 아닌 경우 텍스트로 처리
|
||||
analysis_data = {"분석결과": analysis_result}
|
||||
else:
|
||||
analysis_data = analysis_result
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"PDF 분석 데이터 구조: {type(analysis_data).__name__}")
|
||||
if isinstance(analysis_data, dict):
|
||||
logger.info(f"PDF 분석 데이터 키: {list(analysis_data.keys())}")
|
||||
|
||||
# 중첩된 구조를 평탄화하여 key-value 쌍 생성
|
||||
flattened_data = self._flatten_dict(analysis_data)
|
||||
|
||||
for key, value in flattened_data.items():
|
||||
if value is None or str(value).strip() == "":
|
||||
continue # 빈 값 제외
|
||||
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': key,
|
||||
'value': str(value),
|
||||
})
|
||||
|
||||
# 좌표 정보 추가
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
coordinates = self._extract_coordinates(key, value, coordinate_source)
|
||||
row_data.update(coordinates)
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF key-value 추출 오류: {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _extract_dxf_key_values(
|
||||
self,
|
||||
result: Any,
|
||||
base_info: Dict[str, str],
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""DXF 분석 결과에서 key-value 쌍 추출"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
title_blocks = getattr(result, 'dxf_title_blocks', None)
|
||||
|
||||
if not title_blocks:
|
||||
return data_rows
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"DXF 타이틀블록 수: {len(title_blocks)}")
|
||||
|
||||
for block_idx, title_block in enumerate(title_blocks):
|
||||
if not isinstance(title_block, dict):
|
||||
continue
|
||||
|
||||
block_name = title_block.get('block_name', 'Unknown')
|
||||
|
||||
# 블록 정보
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': f"{block_name}_블록명",
|
||||
'value': block_name,
|
||||
})
|
||||
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
coordinates = self._extract_coordinates('블록명', block_name, coordinate_source)
|
||||
row_data.update(coordinates)
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
# 속성 정보
|
||||
attributes = title_block.get('attributes', [])
|
||||
if self.debug_mode:
|
||||
logger.info(f"블록 {block_idx+1} ({block_name}): {len(attributes)}개 속성")
|
||||
|
||||
for attr_idx, attr in enumerate(attributes):
|
||||
if not isinstance(attr, dict):
|
||||
continue
|
||||
|
||||
attr_text = attr.get('text', '')
|
||||
if not attr_text or str(attr_text).strip() == "":
|
||||
continue # 빈 속성 제외
|
||||
|
||||
# 속성별 key-value 쌍 생성
|
||||
attr_key = attr.get('tag', attr.get('prompt', f'Unknown_Attr_{attr_idx}'))
|
||||
attr_value = str(attr_text)
|
||||
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': attr_key,
|
||||
'value': attr_value,
|
||||
})
|
||||
|
||||
# DXF 속성의 경우 insert 좌표 사용
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
x_coord = attr.get('insert_x', '')
|
||||
y_coord = attr.get('insert_y', '')
|
||||
|
||||
if x_coord and y_coord:
|
||||
row_data.update({
|
||||
'x': round(float(x_coord), 2) if isinstance(x_coord, (int, float)) else x_coord,
|
||||
'y': round(float(y_coord), 2) if isinstance(y_coord, (int, float)) else y_coord,
|
||||
})
|
||||
else:
|
||||
row_data.update({'x': '', 'y': ''})
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DXF key-value 추출 오류: {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _flatten_dict(self, data: Dict[str, Any], parent_key: str = '', sep: str = '_') -> Dict[str, Any]:
|
||||
"""
|
||||
중첩된 딕셔너리를 평탄화
|
||||
|
||||
Args:
|
||||
data: 평탄화할 딕셔너리
|
||||
parent_key: 부모 키
|
||||
sep: 구분자
|
||||
|
||||
Returns:
|
||||
평탄화된 딕셔너리
|
||||
"""
|
||||
items = []
|
||||
|
||||
for k, v in data.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
|
||||
if isinstance(v, dict):
|
||||
# 중첩된 딕셔너리인 경우 재귀 호출
|
||||
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
|
||||
elif isinstance(v, list):
|
||||
# 리스트인 경우 인덱스와 함께 처리
|
||||
for i, item in enumerate(v):
|
||||
if isinstance(item, dict):
|
||||
items.extend(self._flatten_dict(item, f"{new_key}_{i}", sep=sep).items())
|
||||
else:
|
||||
items.append((f"{new_key}_{i}", item))
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
|
||||
return dict(items)
|
||||
|
||||
def _extract_coordinates(self, key: str, value: str, coordinate_source: str) -> Dict[str, str]:
|
||||
"""
|
||||
텍스트에서 좌표 정보 추출
|
||||
|
||||
Args:
|
||||
key: 키
|
||||
value: 값
|
||||
coordinate_source: 좌표 정보 출처
|
||||
|
||||
Returns:
|
||||
좌표 딕셔너리
|
||||
"""
|
||||
coordinates = {'x': '', 'y': ''}
|
||||
|
||||
try:
|
||||
# 값에서 좌표 패턴 찾기
|
||||
matches = self.coordinate_pattern.findall(str(value))
|
||||
|
||||
if matches:
|
||||
# 첫 번째 매치 사용
|
||||
x, y = matches[0]
|
||||
coordinates = {'x': x, 'y': y}
|
||||
else:
|
||||
# 키에서 좌표 정보 찾기
|
||||
key_matches = self.coordinate_pattern.findall(str(key))
|
||||
if key_matches:
|
||||
x, y = key_matches[0]
|
||||
coordinates = {'x': x, 'y': y}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"좌표 추출 오류: {str(e)}")
|
||||
|
||||
return coordinates
|
||||
|
||||
|
||||
def generate_cross_tabulated_csv_filename(base_name: str = "cross_tabulated_analysis") -> str:
|
||||
"""기본 Cross-tabulated CSV 파일명 생성"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"{base_name}_results_{timestamp}.csv"
|
||||
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
# 테스트용 예시
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 샘플 처리 결과 (실제 데이터 구조에 맞게 수정)
|
||||
sample_results = []
|
||||
|
||||
# 실제 사용 시에는 processing_results를 전달
|
||||
# success = exporter.export_cross_tabulated_csv(
|
||||
# sample_results,
|
||||
# "test_cross_tabulated.csv",
|
||||
# include_coordinates=True
|
||||
# )
|
||||
|
||||
print("Cross-tabulated CSV 내보내기 모듈 (통합 버전) 테스트 완료")
|
||||
331
cross_tabulated_csv_exporter_backup.py
Normal file
331
cross_tabulated_csv_exporter_backup.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Cross-Tabulated CSV 내보내기 모듈
|
||||
JSON 형태의 분석 결과를 key-value 형태의 cross-tabulated CSV로 저장하는 기능을 제공합니다.
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-15
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import os
|
||||
import re
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrossTabulatedCSVExporter:
|
||||
"""Cross-Tabulated CSV 내보내기 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
"""Cross-Tabulated CSV 내보내기 초기화"""
|
||||
self.coordinate_pattern = re.compile(r'\b(\d+)\s*,\s*(\d+)\b') # x,y 좌표 패턴
|
||||
|
||||
def export_cross_tabulated_csv(
|
||||
self,
|
||||
processing_results: List[Any],
|
||||
output_path: str,
|
||||
include_coordinates: bool = True,
|
||||
coordinate_source: str = "auto" # "auto", "text_blocks", "analysis_result", "none"
|
||||
) -> bool:
|
||||
"""
|
||||
처리 결과를 cross-tabulated CSV 형태로 저장
|
||||
|
||||
Args:
|
||||
processing_results: 다중 파일 처리 결과 리스트
|
||||
output_path: 출력 CSV 파일 경로
|
||||
include_coordinates: 좌표 정보 포함 여부
|
||||
coordinate_source: 좌표 정보 출처 ("auto", "text_blocks", "analysis_result", "none")
|
||||
|
||||
Returns:
|
||||
저장 성공 여부
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Cross-tabulated CSV 저장 시작: {len(processing_results)}개 파일")
|
||||
|
||||
# 모든 파일의 key-value 쌍을 수집
|
||||
all_data_rows = []
|
||||
|
||||
for result in processing_results:
|
||||
if not result.success:
|
||||
continue # 실패한 파일은 제외
|
||||
|
||||
file_data = self._extract_key_value_pairs(result, include_coordinates, coordinate_source)
|
||||
all_data_rows.extend(file_data)
|
||||
|
||||
if not all_data_rows:
|
||||
logger.warning("저장할 데이터가 없습니다")
|
||||
return False
|
||||
|
||||
# DataFrame 생성
|
||||
df = pd.DataFrame(all_data_rows)
|
||||
|
||||
# 컬럼 순서 정렬
|
||||
column_order = ['file_name', 'file_type', 'key', 'value']
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
column_order.extend(['x', 'y'])
|
||||
|
||||
# 추가 컬럼들을 뒤에 배치
|
||||
existing_columns = [col for col in column_order if col in df.columns]
|
||||
additional_columns = [col for col in df.columns if col not in existing_columns]
|
||||
df = df[existing_columns + additional_columns]
|
||||
|
||||
# UTF-8 BOM으로 저장 (한글 호환성)
|
||||
df.to_csv(output_path, index=False, encoding='utf-8-sig')
|
||||
|
||||
logger.info(f"Cross-tabulated CSV 저장 완료: {output_path}")
|
||||
logger.info(f"총 {len(all_data_rows)}개 key-value 쌍 저장")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cross-tabulated CSV 저장 오류: {str(e)}")
|
||||
return False
|
||||
|
||||
def _extract_key_value_pairs(
|
||||
self,
|
||||
result: Any,
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
단일 파일 결과에서 key-value 쌍 추출
|
||||
|
||||
Args:
|
||||
result: 파일 처리 결과
|
||||
include_coordinates: 좌표 정보 포함 여부
|
||||
coordinate_source: 좌표 정보 출처
|
||||
|
||||
Returns:
|
||||
key-value 쌍 리스트
|
||||
"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
# 기본 정보
|
||||
base_info = {
|
||||
'file_name': result.file_name,
|
||||
'file_type': result.file_type,
|
||||
}
|
||||
|
||||
# PDF 분석 결과 처리
|
||||
if result.file_type.lower() == 'pdf' and result.pdf_analysis_result:
|
||||
data_rows.extend(
|
||||
self._extract_pdf_key_values(result, base_info, include_coordinates, coordinate_source)
|
||||
)
|
||||
|
||||
# DXF 분석 결과 처리
|
||||
elif result.file_type.lower() == 'dxf' and result.dxf_title_blocks:
|
||||
data_rows.extend(
|
||||
self._extract_dxf_key_values(result, base_info, include_coordinates, coordinate_source)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Key-value 추출 오류 ({result.file_name}): {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _extract_pdf_key_values(
|
||||
self,
|
||||
result: Any,
|
||||
base_info: Dict[str, str],
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""PDF 분석 결과에서 key-value 쌍 추출"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
# PDF 분석 결과를 JSON으로 파싱
|
||||
analysis_result = result.pdf_analysis_result
|
||||
if isinstance(analysis_result, str):
|
||||
try:
|
||||
analysis_data = json.loads(analysis_result)
|
||||
except json.JSONDecodeError:
|
||||
# JSON이 아닌 경우 텍스트로 처리
|
||||
analysis_data = {"분석결과": analysis_result}
|
||||
else:
|
||||
analysis_data = analysis_result
|
||||
|
||||
# 중첩된 구조를 평탄화하여 key-value 쌍 생성
|
||||
flattened_data = self._flatten_dict(analysis_data)
|
||||
|
||||
for key, value in flattened_data.items():
|
||||
if value is None or str(value).strip() == "":
|
||||
continue # 빈 값 제외
|
||||
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': key,
|
||||
'value': str(value),
|
||||
})
|
||||
|
||||
# 좌표 정보 추가
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
coordinates = self._extract_coordinates(key, value, coordinate_source)
|
||||
row_data.update(coordinates)
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF key-value 추출 오류: {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _extract_dxf_key_values(
|
||||
self,
|
||||
result: Any,
|
||||
base_info: Dict[str, str],
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""DXF 분석 결과에서 key-value 쌍 추출"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
for title_block in result.dxf_title_blocks:
|
||||
block_name = title_block.get('block_name', 'Unknown')
|
||||
|
||||
# 블록 정보
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': f"{block_name}_블록명",
|
||||
'value': block_name,
|
||||
})
|
||||
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
coordinates = self._extract_coordinates('블록명', block_name, coordinate_source)
|
||||
row_data.update(coordinates)
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
# 속성 정보
|
||||
for attr in title_block.get('attributes', []):
|
||||
if not attr.get('text') or str(attr.get('text')).strip() == "":
|
||||
continue # 빈 속성 제외
|
||||
|
||||
# 속성별 key-value 쌍 생성
|
||||
attr_key = attr.get('tag', attr.get('prompt', 'Unknown'))
|
||||
attr_value = attr.get('text', '')
|
||||
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': attr_key,
|
||||
'value': str(attr_value),
|
||||
})
|
||||
|
||||
# DXF 속성의 경우 insert 좌표 사용
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
x_coord = attr.get('insert_x', '')
|
||||
y_coord = attr.get('insert_y', '')
|
||||
|
||||
if x_coord and y_coord:
|
||||
row_data.update({
|
||||
'x': round(float(x_coord), 2) if isinstance(x_coord, (int, float)) else x_coord,
|
||||
'y': round(float(y_coord), 2) if isinstance(y_coord, (int, float)) else y_coord,
|
||||
})
|
||||
else:
|
||||
row_data.update({'x': '', 'y': ''})
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DXF key-value 추출 오류: {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _flatten_dict(self, data: Dict[str, Any], parent_key: str = '', sep: str = '_') -> Dict[str, Any]:
|
||||
"""
|
||||
중첩된 딕셔너리를 평탄화
|
||||
|
||||
Args:
|
||||
data: 평탄화할 딕셔너리
|
||||
parent_key: 부모 키
|
||||
sep: 구분자
|
||||
|
||||
Returns:
|
||||
평탄화된 딕셔너리
|
||||
"""
|
||||
items = []
|
||||
|
||||
for k, v in data.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
|
||||
if isinstance(v, dict):
|
||||
# 중첩된 딕셔너리인 경우 재귀 호출
|
||||
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
|
||||
elif isinstance(v, list):
|
||||
# 리스트인 경우 인덱스와 함께 처리
|
||||
for i, item in enumerate(v):
|
||||
if isinstance(item, dict):
|
||||
items.extend(self._flatten_dict(item, f"{new_key}_{i}", sep=sep).items())
|
||||
else:
|
||||
items.append((f"{new_key}_{i}", item))
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
|
||||
return dict(items)
|
||||
|
||||
def _extract_coordinates(self, key: str, value: str, coordinate_source: str) -> Dict[str, str]:
|
||||
"""
|
||||
텍스트에서 좌표 정보 추출
|
||||
|
||||
Args:
|
||||
key: 키
|
||||
value: 값
|
||||
coordinate_source: 좌표 정보 출처
|
||||
|
||||
Returns:
|
||||
좌표 딕셔너리
|
||||
"""
|
||||
coordinates = {'x': '', 'y': ''}
|
||||
|
||||
try:
|
||||
# 값에서 좌표 패턴 찾기
|
||||
matches = self.coordinate_pattern.findall(str(value))
|
||||
|
||||
if matches:
|
||||
# 첫 번째 매치 사용
|
||||
x, y = matches[0]
|
||||
coordinates = {'x': x, 'y': y}
|
||||
else:
|
||||
# 키에서 좌표 정보 찾기
|
||||
key_matches = self.coordinate_pattern.findall(str(key))
|
||||
if key_matches:
|
||||
x, y = key_matches[0]
|
||||
coordinates = {'x': x, 'y': y}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"좌표 추출 오류: {str(e)}")
|
||||
|
||||
return coordinates
|
||||
|
||||
|
||||
def generate_cross_tabulated_csv_filename(base_name: str = "cross_tabulated_analysis") -> str:
|
||||
"""기본 Cross-tabulated CSV 파일명 생성"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"{base_name}_results_{timestamp}.csv"
|
||||
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
# 테스트용 예시
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 샘플 처리 결과 (실제 데이터 구조에 맞게 수정)
|
||||
sample_results = []
|
||||
|
||||
# 실제 사용 시에는 processing_results를 전달
|
||||
# success = exporter.export_cross_tabulated_csv(
|
||||
# sample_results,
|
||||
# "test_cross_tabulated.csv",
|
||||
# include_coordinates=True
|
||||
# )
|
||||
|
||||
print("Cross-tabulated CSV 내보내기 모듈 테스트 완료")
|
||||
489
cross_tabulated_csv_exporter_fixed.py
Normal file
489
cross_tabulated_csv_exporter_fixed.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
Cross-Tabulated CSV 내보내기 모듈 (수정 버전)
|
||||
JSON 형태의 분석 결과를 key-value 형태의 cross-tabulated CSV로 저장하는 기능을 제공합니다.
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-15
|
||||
Updated: 2025-07-16 (디버깅 개선 버전)
|
||||
Version: 1.1.0
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import os
|
||||
import re
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrossTabulatedCSVExporter:
|
||||
"""Cross-Tabulated CSV 내보내기 클래스 (수정 버전)"""
|
||||
|
||||
def __init__(self):
|
||||
"""Cross-Tabulated CSV 내보내기 초기화"""
|
||||
self.coordinate_pattern = re.compile(r'\b(\d+)\s*,\s*(\d+)\b') # x,y 좌표 패턴
|
||||
self.debug_mode = True # 디버깅 모드 활성화
|
||||
|
||||
def export_cross_tabulated_csv(
|
||||
self,
|
||||
processing_results: List[Any],
|
||||
output_path: str,
|
||||
include_coordinates: bool = True,
|
||||
coordinate_source: str = "auto" # "auto", "text_blocks", "analysis_result", "none"
|
||||
) -> bool:
|
||||
"""
|
||||
처리 결과를 cross-tabulated CSV 형태로 저장
|
||||
|
||||
Args:
|
||||
processing_results: 다중 파일 처리 결과 리스트
|
||||
output_path: 출력 CSV 파일 경로
|
||||
include_coordinates: 좌표 정보 포함 여부
|
||||
coordinate_source: 좌표 정보 출처 ("auto", "text_blocks", "analysis_result", "none")
|
||||
|
||||
Returns:
|
||||
저장 성공 여부
|
||||
"""
|
||||
try:
|
||||
if self.debug_mode:
|
||||
logger.info(f"=== Cross-tabulated CSV 저장 시작 ===")
|
||||
logger.info(f"입력된 결과 수: {len(processing_results)}")
|
||||
logger.info(f"출력 경로: {output_path}")
|
||||
logger.info(f"좌표 포함: {include_coordinates}, 좌표 출처: {coordinate_source}")
|
||||
|
||||
# 입력 데이터 검증
|
||||
if not processing_results:
|
||||
logger.warning("입력된 처리 결과가 비어있습니다.")
|
||||
return False
|
||||
|
||||
# 각 결과 객체의 구조 분석
|
||||
for i, result in enumerate(processing_results):
|
||||
if self.debug_mode:
|
||||
logger.info(f"결과 {i+1}: {self._analyze_result_structure(result)}")
|
||||
|
||||
# 모든 파일의 key-value 쌍을 수집
|
||||
all_data_rows = []
|
||||
|
||||
for i, result in enumerate(processing_results):
|
||||
try:
|
||||
if not hasattr(result, 'success'):
|
||||
logger.warning(f"결과 {i+1}: 'success' 속성이 없습니다. 스킵합니다.")
|
||||
continue
|
||||
|
||||
if not result.success:
|
||||
if self.debug_mode:
|
||||
logger.info(f"결과 {i+1}: 실패한 파일, 스킵합니다 ({getattr(result, 'error_message', 'Unknown error')})")
|
||||
continue # 실패한 파일은 제외
|
||||
|
||||
file_data = self._extract_key_value_pairs(result, include_coordinates, coordinate_source)
|
||||
if file_data:
|
||||
all_data_rows.extend(file_data)
|
||||
if self.debug_mode:
|
||||
logger.info(f"결과 {i+1}: {len(file_data)}개 key-value 쌍 추출")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"결과 {i+1}: key-value 쌍을 추출할 수 없습니다")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"결과 {i+1} 처리 중 오류: {str(e)}")
|
||||
continue
|
||||
|
||||
if not all_data_rows:
|
||||
logger.warning("저장할 데이터가 없습니다. 모든 파일에서 유효한 key-value 쌍을 추출할 수 없었습니다.")
|
||||
if self.debug_mode:
|
||||
self._print_debug_summary(processing_results)
|
||||
return False
|
||||
|
||||
# DataFrame 생성
|
||||
df = pd.DataFrame(all_data_rows)
|
||||
|
||||
# 컬럼 순서 정렬
|
||||
column_order = ['file_name', 'file_type', 'key', 'value']
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
column_order.extend(['x', 'y'])
|
||||
|
||||
# 추가 컬럼들을 뒤에 배치
|
||||
existing_columns = [col for col in column_order if col in df.columns]
|
||||
additional_columns = [col for col in df.columns if col not in existing_columns]
|
||||
df = df[existing_columns + additional_columns]
|
||||
|
||||
# 출력 디렉토리 생성
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
# UTF-8 BOM으로 저장 (한글 호환성)
|
||||
df.to_csv(output_path, index=False, encoding='utf-8-sig')
|
||||
|
||||
logger.info(f"Cross-tabulated CSV 저장 완료: {output_path}")
|
||||
logger.info(f"총 {len(all_data_rows)}개 key-value 쌍 저장")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cross-tabulated CSV 저장 오류: {str(e)}")
|
||||
return False
|
||||
|
||||
def _analyze_result_structure(self, result: Any) -> str:
|
||||
"""결과 객체의 구조를 분석하여 문자열로 반환"""
|
||||
try:
|
||||
info = []
|
||||
|
||||
# 기본 속성들 확인
|
||||
if hasattr(result, 'file_name'):
|
||||
info.append(f"file_name='{result.file_name}'")
|
||||
if hasattr(result, 'file_type'):
|
||||
info.append(f"file_type='{result.file_type}'")
|
||||
if hasattr(result, 'success'):
|
||||
info.append(f"success={result.success}")
|
||||
|
||||
# PDF 관련 속성
|
||||
if hasattr(result, 'pdf_analysis_result'):
|
||||
pdf_result = result.pdf_analysis_result
|
||||
if pdf_result:
|
||||
if isinstance(pdf_result, str):
|
||||
info.append(f"pdf_analysis_result=str({len(pdf_result)} chars)")
|
||||
else:
|
||||
info.append(f"pdf_analysis_result={type(pdf_result).__name__}")
|
||||
else:
|
||||
info.append("pdf_analysis_result=None")
|
||||
|
||||
# DXF 관련 속성
|
||||
if hasattr(result, 'dxf_title_blocks'):
|
||||
dxf_blocks = result.dxf_title_blocks
|
||||
if dxf_blocks:
|
||||
info.append(f"dxf_title_blocks=list({len(dxf_blocks)} blocks)")
|
||||
else:
|
||||
info.append("dxf_title_blocks=None")
|
||||
|
||||
return " | ".join(info) if info else "구조 분석 실패"
|
||||
|
||||
except Exception as e:
|
||||
return f"분석 오류: {str(e)}"
|
||||
|
||||
def _print_debug_summary(self, processing_results: List[Any]):
|
||||
"""디버깅을 위한 요약 정보 출력"""
|
||||
logger.info("=== 디버깅 요약 ===")
|
||||
|
||||
success_count = 0
|
||||
pdf_count = 0
|
||||
dxf_count = 0
|
||||
has_pdf_data = 0
|
||||
has_dxf_data = 0
|
||||
|
||||
for i, result in enumerate(processing_results):
|
||||
try:
|
||||
if hasattr(result, 'success') and result.success:
|
||||
success_count += 1
|
||||
|
||||
file_type = getattr(result, 'file_type', 'unknown').lower()
|
||||
if file_type == 'pdf':
|
||||
pdf_count += 1
|
||||
if getattr(result, 'pdf_analysis_result', None):
|
||||
has_pdf_data += 1
|
||||
elif file_type == 'dxf':
|
||||
dxf_count += 1
|
||||
if getattr(result, 'dxf_title_blocks', None):
|
||||
has_dxf_data += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"결과 {i+1} 분석 중 오류: {str(e)}")
|
||||
|
||||
logger.info(f"총 결과: {len(processing_results)}개")
|
||||
logger.info(f"성공한 결과: {success_count}개")
|
||||
logger.info(f"PDF 파일: {pdf_count}개 (분석 데이터 있음: {has_pdf_data}개)")
|
||||
logger.info(f"DXF 파일: {dxf_count}개 (타이틀블록 데이터 있음: {has_dxf_data}개)")
|
||||
|
||||
def _extract_key_value_pairs(
|
||||
self,
|
||||
result: Any,
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
단일 파일 결과에서 key-value 쌍 추출
|
||||
|
||||
Args:
|
||||
result: 파일 처리 결과
|
||||
include_coordinates: 좌표 정보 포함 여부
|
||||
coordinate_source: 좌표 정보 출처
|
||||
|
||||
Returns:
|
||||
key-value 쌍 리스트
|
||||
"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
# 기본 정보 확인
|
||||
file_name = getattr(result, 'file_name', 'Unknown')
|
||||
file_type = getattr(result, 'file_type', 'Unknown')
|
||||
|
||||
base_info = {
|
||||
'file_name': file_name,
|
||||
'file_type': file_type,
|
||||
}
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"처리 중: {file_name} ({file_type})")
|
||||
|
||||
# PDF 분석 결과 처리
|
||||
if file_type.lower() == 'pdf':
|
||||
pdf_result = getattr(result, 'pdf_analysis_result', None)
|
||||
if pdf_result:
|
||||
pdf_rows = self._extract_pdf_key_values(result, base_info, include_coordinates, coordinate_source)
|
||||
data_rows.extend(pdf_rows)
|
||||
if self.debug_mode:
|
||||
logger.info(f"PDF에서 {len(pdf_rows)}개 key-value 쌍 추출")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"PDF 분석 결과가 없습니다: {file_name}")
|
||||
|
||||
# DXF 분석 결과 처리
|
||||
elif file_type.lower() == 'dxf':
|
||||
dxf_blocks = getattr(result, 'dxf_title_blocks', None)
|
||||
if dxf_blocks:
|
||||
dxf_rows = self._extract_dxf_key_values(result, base_info, include_coordinates, coordinate_source)
|
||||
data_rows.extend(dxf_rows)
|
||||
if self.debug_mode:
|
||||
logger.info(f"DXF에서 {len(dxf_rows)}개 key-value 쌍 추출")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"DXF 타이틀블록 데이터가 없습니다: {file_name}")
|
||||
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"지원하지 않는 파일 형식: {file_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Key-value 추출 오류 ({getattr(result, 'file_name', 'Unknown')}): {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _extract_pdf_key_values(
|
||||
self,
|
||||
result: Any,
|
||||
base_info: Dict[str, str],
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""PDF 분석 결과에서 key-value 쌍 추출"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
# PDF 분석 결과를 JSON으로 파싱
|
||||
analysis_result = getattr(result, 'pdf_analysis_result', None)
|
||||
|
||||
if not analysis_result:
|
||||
return data_rows
|
||||
|
||||
if isinstance(analysis_result, str):
|
||||
try:
|
||||
analysis_data = json.loads(analysis_result)
|
||||
except json.JSONDecodeError:
|
||||
# JSON이 아닌 경우 텍스트로 처리
|
||||
analysis_data = {"분석결과": analysis_result}
|
||||
else:
|
||||
analysis_data = analysis_result
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"PDF 분석 데이터 구조: {type(analysis_data).__name__}")
|
||||
if isinstance(analysis_data, dict):
|
||||
logger.info(f"PDF 분석 데이터 키: {list(analysis_data.keys())}")
|
||||
|
||||
# 중첩된 구조를 평탄화하여 key-value 쌍 생성
|
||||
flattened_data = self._flatten_dict(analysis_data)
|
||||
|
||||
for key, value in flattened_data.items():
|
||||
if value is None or str(value).strip() == "":
|
||||
continue # 빈 값 제외
|
||||
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': key,
|
||||
'value': str(value),
|
||||
})
|
||||
|
||||
# 좌표 정보 추가
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
coordinates = self._extract_coordinates(key, value, coordinate_source)
|
||||
row_data.update(coordinates)
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF key-value 추출 오류: {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _extract_dxf_key_values(
|
||||
self,
|
||||
result: Any,
|
||||
base_info: Dict[str, str],
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""DXF 분석 결과에서 key-value 쌍 추출"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
title_blocks = getattr(result, 'dxf_title_blocks', None)
|
||||
|
||||
if not title_blocks:
|
||||
return data_rows
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"DXF 타이틀블록 수: {len(title_blocks)}")
|
||||
|
||||
for block_idx, title_block in enumerate(title_blocks):
|
||||
if not isinstance(title_block, dict):
|
||||
continue
|
||||
|
||||
block_name = title_block.get('block_name', 'Unknown')
|
||||
|
||||
# 블록 정보
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': f"{block_name}_블록명",
|
||||
'value': block_name,
|
||||
})
|
||||
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
coordinates = self._extract_coordinates('블록명', block_name, coordinate_source)
|
||||
row_data.update(coordinates)
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
# 속성 정보
|
||||
attributes = title_block.get('attributes', [])
|
||||
if self.debug_mode:
|
||||
logger.info(f"블록 {block_idx+1} ({block_name}): {len(attributes)}개 속성")
|
||||
|
||||
for attr_idx, attr in enumerate(attributes):
|
||||
if not isinstance(attr, dict):
|
||||
continue
|
||||
|
||||
attr_text = attr.get('text', '')
|
||||
if not attr_text or str(attr_text).strip() == "":
|
||||
continue # 빈 속성 제외
|
||||
|
||||
# 속성별 key-value 쌍 생성
|
||||
attr_key = attr.get('tag', attr.get('prompt', f'Unknown_Attr_{attr_idx}'))
|
||||
attr_value = str(attr_text)
|
||||
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': attr_key,
|
||||
'value': attr_value,
|
||||
})
|
||||
|
||||
# DXF 속성의 경우 insert 좌표 사용
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
x_coord = attr.get('insert_x', '')
|
||||
y_coord = attr.get('insert_y', '')
|
||||
|
||||
if x_coord and y_coord:
|
||||
row_data.update({
|
||||
'x': round(float(x_coord), 2) if isinstance(x_coord, (int, float)) else x_coord,
|
||||
'y': round(float(y_coord), 2) if isinstance(y_coord, (int, float)) else y_coord,
|
||||
})
|
||||
else:
|
||||
row_data.update({'x': '', 'y': ''})
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DXF key-value 추출 오류: {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _flatten_dict(self, data: Dict[str, Any], parent_key: str = '', sep: str = '_') -> Dict[str, Any]:
|
||||
"""
|
||||
중첩된 딕셔너리를 평탄화
|
||||
|
||||
Args:
|
||||
data: 평탄화할 딕셔너리
|
||||
parent_key: 부모 키
|
||||
sep: 구분자
|
||||
|
||||
Returns:
|
||||
평탄화된 딕셔너리
|
||||
"""
|
||||
items = []
|
||||
|
||||
for k, v in data.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
|
||||
if isinstance(v, dict):
|
||||
# 중첩된 딕셔너리인 경우 재귀 호출
|
||||
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
|
||||
elif isinstance(v, list):
|
||||
# 리스트인 경우 인덱스와 함께 처리
|
||||
for i, item in enumerate(v):
|
||||
if isinstance(item, dict):
|
||||
items.extend(self._flatten_dict(item, f"{new_key}_{i}", sep=sep).items())
|
||||
else:
|
||||
items.append((f"{new_key}_{i}", item))
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
|
||||
return dict(items)
|
||||
|
||||
def _extract_coordinates(self, key: str, value: str, coordinate_source: str) -> Dict[str, str]:
|
||||
"""
|
||||
텍스트에서 좌표 정보 추출
|
||||
|
||||
Args:
|
||||
key: 키
|
||||
value: 값
|
||||
coordinate_source: 좌표 정보 출처
|
||||
|
||||
Returns:
|
||||
좌표 딕셔너리
|
||||
"""
|
||||
coordinates = {'x': '', 'y': ''}
|
||||
|
||||
try:
|
||||
# 값에서 좌표 패턴 찾기
|
||||
matches = self.coordinate_pattern.findall(str(value))
|
||||
|
||||
if matches:
|
||||
# 첫 번째 매치 사용
|
||||
x, y = matches[0]
|
||||
coordinates = {'x': x, 'y': y}
|
||||
else:
|
||||
# 키에서 좌표 정보 찾기
|
||||
key_matches = self.coordinate_pattern.findall(str(key))
|
||||
if key_matches:
|
||||
x, y = key_matches[0]
|
||||
coordinates = {'x': x, 'y': y}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"좌표 추출 오류: {str(e)}")
|
||||
|
||||
return coordinates
|
||||
|
||||
|
||||
def generate_cross_tabulated_csv_filename(base_name: str = "cross_tabulated_analysis") -> str:
|
||||
"""기본 Cross-tabulated CSV 파일명 생성"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"{base_name}_results_{timestamp}.csv"
|
||||
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
# 테스트용 예시
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 샘플 처리 결과 (실제 데이터 구조에 맞게 수정)
|
||||
sample_results = []
|
||||
|
||||
# 실제 사용 시에는 processing_results를 전달
|
||||
# success = exporter.export_cross_tabulated_csv(
|
||||
# sample_results,
|
||||
# "test_cross_tabulated.csv",
|
||||
# include_coordinates=True
|
||||
# )
|
||||
|
||||
print("Cross-tabulated CSV 내보내기 모듈 (수정 버전) 테스트 완료")
|
||||
489
cross_tabulated_csv_exporter_previous.py
Normal file
489
cross_tabulated_csv_exporter_previous.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
Cross-Tabulated CSV 내보내기 모듈 (수정 버전)
|
||||
JSON 형태의 분석 결과를 key-value 형태의 cross-tabulated CSV로 저장하는 기능을 제공합니다.
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-15
|
||||
Updated: 2025-07-16 (디버깅 개선 버전)
|
||||
Version: 1.1.0
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import os
|
||||
import re
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrossTabulatedCSVExporter:
|
||||
"""Cross-Tabulated CSV 내보내기 클래스 (수정 버전)"""
|
||||
|
||||
def __init__(self):
|
||||
"""Cross-Tabulated CSV 내보내기 초기화"""
|
||||
self.coordinate_pattern = re.compile(r'\b(\d+)\s*,\s*(\d+)\b') # x,y 좌표 패턴
|
||||
self.debug_mode = True # 디버깅 모드 활성화
|
||||
|
||||
def export_cross_tabulated_csv(
|
||||
self,
|
||||
processing_results: List[Any],
|
||||
output_path: str,
|
||||
include_coordinates: bool = True,
|
||||
coordinate_source: str = "auto" # "auto", "text_blocks", "analysis_result", "none"
|
||||
) -> bool:
|
||||
"""
|
||||
처리 결과를 cross-tabulated CSV 형태로 저장
|
||||
|
||||
Args:
|
||||
processing_results: 다중 파일 처리 결과 리스트
|
||||
output_path: 출력 CSV 파일 경로
|
||||
include_coordinates: 좌표 정보 포함 여부
|
||||
coordinate_source: 좌표 정보 출처 ("auto", "text_blocks", "analysis_result", "none")
|
||||
|
||||
Returns:
|
||||
저장 성공 여부
|
||||
"""
|
||||
try:
|
||||
if self.debug_mode:
|
||||
logger.info(f"=== Cross-tabulated CSV 저장 시작 ===")
|
||||
logger.info(f"입력된 결과 수: {len(processing_results)}")
|
||||
logger.info(f"출력 경로: {output_path}")
|
||||
logger.info(f"좌표 포함: {include_coordinates}, 좌표 출처: {coordinate_source}")
|
||||
|
||||
# 입력 데이터 검증
|
||||
if not processing_results:
|
||||
logger.warning("입력된 처리 결과가 비어있습니다.")
|
||||
return False
|
||||
|
||||
# 각 결과 객체의 구조 분석
|
||||
for i, result in enumerate(processing_results):
|
||||
if self.debug_mode:
|
||||
logger.info(f"결과 {i+1}: {self._analyze_result_structure(result)}")
|
||||
|
||||
# 모든 파일의 key-value 쌍을 수집
|
||||
all_data_rows = []
|
||||
|
||||
for i, result in enumerate(processing_results):
|
||||
try:
|
||||
if not hasattr(result, 'success'):
|
||||
logger.warning(f"결과 {i+1}: 'success' 속성이 없습니다. 스킵합니다.")
|
||||
continue
|
||||
|
||||
if not result.success:
|
||||
if self.debug_mode:
|
||||
logger.info(f"결과 {i+1}: 실패한 파일, 스킵합니다 ({getattr(result, 'error_message', 'Unknown error')})")
|
||||
continue # 실패한 파일은 제외
|
||||
|
||||
file_data = self._extract_key_value_pairs(result, include_coordinates, coordinate_source)
|
||||
if file_data:
|
||||
all_data_rows.extend(file_data)
|
||||
if self.debug_mode:
|
||||
logger.info(f"결과 {i+1}: {len(file_data)}개 key-value 쌍 추출")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"결과 {i+1}: key-value 쌍을 추출할 수 없습니다")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"결과 {i+1} 처리 중 오류: {str(e)}")
|
||||
continue
|
||||
|
||||
if not all_data_rows:
|
||||
logger.warning("저장할 데이터가 없습니다. 모든 파일에서 유효한 key-value 쌍을 추출할 수 없었습니다.")
|
||||
if self.debug_mode:
|
||||
self._print_debug_summary(processing_results)
|
||||
return False
|
||||
|
||||
# DataFrame 생성
|
||||
df = pd.DataFrame(all_data_rows)
|
||||
|
||||
# 컬럼 순서 정렬
|
||||
column_order = ['file_name', 'file_type', 'key', 'value']
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
column_order.extend(['x', 'y'])
|
||||
|
||||
# 추가 컬럼들을 뒤에 배치
|
||||
existing_columns = [col for col in column_order if col in df.columns]
|
||||
additional_columns = [col for col in df.columns if col not in existing_columns]
|
||||
df = df[existing_columns + additional_columns]
|
||||
|
||||
# 출력 디렉토리 생성
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
# UTF-8 BOM으로 저장 (한글 호환성)
|
||||
df.to_csv(output_path, index=False, encoding='utf-8-sig')
|
||||
|
||||
logger.info(f"Cross-tabulated CSV 저장 완료: {output_path}")
|
||||
logger.info(f"총 {len(all_data_rows)}개 key-value 쌍 저장")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cross-tabulated CSV 저장 오류: {str(e)}")
|
||||
return False
|
||||
|
||||
def _analyze_result_structure(self, result: Any) -> str:
|
||||
"""결과 객체의 구조를 분석하여 문자열로 반환"""
|
||||
try:
|
||||
info = []
|
||||
|
||||
# 기본 속성들 확인
|
||||
if hasattr(result, 'file_name'):
|
||||
info.append(f"file_name='{result.file_name}'")
|
||||
if hasattr(result, 'file_type'):
|
||||
info.append(f"file_type='{result.file_type}'")
|
||||
if hasattr(result, 'success'):
|
||||
info.append(f"success={result.success}")
|
||||
|
||||
# PDF 관련 속성
|
||||
if hasattr(result, 'pdf_analysis_result'):
|
||||
pdf_result = result.pdf_analysis_result
|
||||
if pdf_result:
|
||||
if isinstance(pdf_result, str):
|
||||
info.append(f"pdf_analysis_result=str({len(pdf_result)} chars)")
|
||||
else:
|
||||
info.append(f"pdf_analysis_result={type(pdf_result).__name__}")
|
||||
else:
|
||||
info.append("pdf_analysis_result=None")
|
||||
|
||||
# DXF 관련 속성
|
||||
if hasattr(result, 'dxf_title_blocks'):
|
||||
dxf_blocks = result.dxf_title_blocks
|
||||
if dxf_blocks:
|
||||
info.append(f"dxf_title_blocks=list({len(dxf_blocks)} blocks)")
|
||||
else:
|
||||
info.append("dxf_title_blocks=None")
|
||||
|
||||
return " | ".join(info) if info else "구조 분석 실패"
|
||||
|
||||
except Exception as e:
|
||||
return f"분석 오류: {str(e)}"
|
||||
|
||||
def _print_debug_summary(self, processing_results: List[Any]):
|
||||
"""디버깅을 위한 요약 정보 출력"""
|
||||
logger.info("=== 디버깅 요약 ===")
|
||||
|
||||
success_count = 0
|
||||
pdf_count = 0
|
||||
dxf_count = 0
|
||||
has_pdf_data = 0
|
||||
has_dxf_data = 0
|
||||
|
||||
for i, result in enumerate(processing_results):
|
||||
try:
|
||||
if hasattr(result, 'success') and result.success:
|
||||
success_count += 1
|
||||
|
||||
file_type = getattr(result, 'file_type', 'unknown').lower()
|
||||
if file_type == 'pdf':
|
||||
pdf_count += 1
|
||||
if getattr(result, 'pdf_analysis_result', None):
|
||||
has_pdf_data += 1
|
||||
elif file_type == 'dxf':
|
||||
dxf_count += 1
|
||||
if getattr(result, 'dxf_title_blocks', None):
|
||||
has_dxf_data += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"결과 {i+1} 분석 중 오류: {str(e)}")
|
||||
|
||||
logger.info(f"총 결과: {len(processing_results)}개")
|
||||
logger.info(f"성공한 결과: {success_count}개")
|
||||
logger.info(f"PDF 파일: {pdf_count}개 (분석 데이터 있음: {has_pdf_data}개)")
|
||||
logger.info(f"DXF 파일: {dxf_count}개 (타이틀블록 데이터 있음: {has_dxf_data}개)")
|
||||
|
||||
def _extract_key_value_pairs(
|
||||
self,
|
||||
result: Any,
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
단일 파일 결과에서 key-value 쌍 추출
|
||||
|
||||
Args:
|
||||
result: 파일 처리 결과
|
||||
include_coordinates: 좌표 정보 포함 여부
|
||||
coordinate_source: 좌표 정보 출처
|
||||
|
||||
Returns:
|
||||
key-value 쌍 리스트
|
||||
"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
# 기본 정보 확인
|
||||
file_name = getattr(result, 'file_name', 'Unknown')
|
||||
file_type = getattr(result, 'file_type', 'Unknown')
|
||||
|
||||
base_info = {
|
||||
'file_name': file_name,
|
||||
'file_type': file_type,
|
||||
}
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"처리 중: {file_name} ({file_type})")
|
||||
|
||||
# PDF 분석 결과 처리
|
||||
if file_type.lower() == 'pdf':
|
||||
pdf_result = getattr(result, 'pdf_analysis_result', None)
|
||||
if pdf_result:
|
||||
pdf_rows = self._extract_pdf_key_values(result, base_info, include_coordinates, coordinate_source)
|
||||
data_rows.extend(pdf_rows)
|
||||
if self.debug_mode:
|
||||
logger.info(f"PDF에서 {len(pdf_rows)}개 key-value 쌍 추출")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"PDF 분석 결과가 없습니다: {file_name}")
|
||||
|
||||
# DXF 분석 결과 처리
|
||||
elif file_type.lower() == 'dxf':
|
||||
dxf_blocks = getattr(result, 'dxf_title_blocks', None)
|
||||
if dxf_blocks:
|
||||
dxf_rows = self._extract_dxf_key_values(result, base_info, include_coordinates, coordinate_source)
|
||||
data_rows.extend(dxf_rows)
|
||||
if self.debug_mode:
|
||||
logger.info(f"DXF에서 {len(dxf_rows)}개 key-value 쌍 추출")
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"DXF 타이틀블록 데이터가 없습니다: {file_name}")
|
||||
|
||||
else:
|
||||
if self.debug_mode:
|
||||
logger.warning(f"지원하지 않는 파일 형식: {file_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Key-value 추출 오류 ({getattr(result, 'file_name', 'Unknown')}): {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _extract_pdf_key_values(
|
||||
self,
|
||||
result: Any,
|
||||
base_info: Dict[str, str],
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""PDF 분석 결과에서 key-value 쌍 추출"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
# PDF 분석 결과를 JSON으로 파싱
|
||||
analysis_result = getattr(result, 'pdf_analysis_result', None)
|
||||
|
||||
if not analysis_result:
|
||||
return data_rows
|
||||
|
||||
if isinstance(analysis_result, str):
|
||||
try:
|
||||
analysis_data = json.loads(analysis_result)
|
||||
except json.JSONDecodeError:
|
||||
# JSON이 아닌 경우 텍스트로 처리
|
||||
analysis_data = {"분석결과": analysis_result}
|
||||
else:
|
||||
analysis_data = analysis_result
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"PDF 분석 데이터 구조: {type(analysis_data).__name__}")
|
||||
if isinstance(analysis_data, dict):
|
||||
logger.info(f"PDF 분석 데이터 키: {list(analysis_data.keys())}")
|
||||
|
||||
# 중첩된 구조를 평탄화하여 key-value 쌍 생성
|
||||
flattened_data = self._flatten_dict(analysis_data)
|
||||
|
||||
for key, value in flattened_data.items():
|
||||
if value is None or str(value).strip() == "":
|
||||
continue # 빈 값 제외
|
||||
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': key,
|
||||
'value': str(value),
|
||||
})
|
||||
|
||||
# 좌표 정보 추가
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
coordinates = self._extract_coordinates(key, value, coordinate_source)
|
||||
row_data.update(coordinates)
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF key-value 추출 오류: {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _extract_dxf_key_values(
|
||||
self,
|
||||
result: Any,
|
||||
base_info: Dict[str, str],
|
||||
include_coordinates: bool,
|
||||
coordinate_source: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""DXF 분석 결과에서 key-value 쌍 추출"""
|
||||
data_rows = []
|
||||
|
||||
try:
|
||||
title_blocks = getattr(result, 'dxf_title_blocks', None)
|
||||
|
||||
if not title_blocks:
|
||||
return data_rows
|
||||
|
||||
if self.debug_mode:
|
||||
logger.info(f"DXF 타이틀블록 수: {len(title_blocks)}")
|
||||
|
||||
for block_idx, title_block in enumerate(title_blocks):
|
||||
if not isinstance(title_block, dict):
|
||||
continue
|
||||
|
||||
block_name = title_block.get('block_name', 'Unknown')
|
||||
|
||||
# 블록 정보
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': f"{block_name}_블록명",
|
||||
'value': block_name,
|
||||
})
|
||||
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
coordinates = self._extract_coordinates('블록명', block_name, coordinate_source)
|
||||
row_data.update(coordinates)
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
# 속성 정보
|
||||
attributes = title_block.get('attributes', [])
|
||||
if self.debug_mode:
|
||||
logger.info(f"블록 {block_idx+1} ({block_name}): {len(attributes)}개 속성")
|
||||
|
||||
for attr_idx, attr in enumerate(attributes):
|
||||
if not isinstance(attr, dict):
|
||||
continue
|
||||
|
||||
attr_text = attr.get('text', '')
|
||||
if not attr_text or str(attr_text).strip() == "":
|
||||
continue # 빈 속성 제외
|
||||
|
||||
# 속성별 key-value 쌍 생성
|
||||
attr_key = attr.get('tag', attr.get('prompt', f'Unknown_Attr_{attr_idx}'))
|
||||
attr_value = str(attr_text)
|
||||
|
||||
row_data = base_info.copy()
|
||||
row_data.update({
|
||||
'key': attr_key,
|
||||
'value': attr_value,
|
||||
})
|
||||
|
||||
# DXF 속성의 경우 insert 좌표 사용
|
||||
if include_coordinates and coordinate_source != "none":
|
||||
x_coord = attr.get('insert_x', '')
|
||||
y_coord = attr.get('insert_y', '')
|
||||
|
||||
if x_coord and y_coord:
|
||||
row_data.update({
|
||||
'x': round(float(x_coord), 2) if isinstance(x_coord, (int, float)) else x_coord,
|
||||
'y': round(float(y_coord), 2) if isinstance(y_coord, (int, float)) else y_coord,
|
||||
})
|
||||
else:
|
||||
row_data.update({'x': '', 'y': ''})
|
||||
|
||||
data_rows.append(row_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DXF key-value 추출 오류: {str(e)}")
|
||||
|
||||
return data_rows
|
||||
|
||||
def _flatten_dict(self, data: Dict[str, Any], parent_key: str = '', sep: str = '_') -> Dict[str, Any]:
|
||||
"""
|
||||
중첩된 딕셔너리를 평탄화
|
||||
|
||||
Args:
|
||||
data: 평탄화할 딕셔너리
|
||||
parent_key: 부모 키
|
||||
sep: 구분자
|
||||
|
||||
Returns:
|
||||
평탄화된 딕셔너리
|
||||
"""
|
||||
items = []
|
||||
|
||||
for k, v in data.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
|
||||
if isinstance(v, dict):
|
||||
# 중첩된 딕셔너리인 경우 재귀 호출
|
||||
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
|
||||
elif isinstance(v, list):
|
||||
# 리스트인 경우 인덱스와 함께 처리
|
||||
for i, item in enumerate(v):
|
||||
if isinstance(item, dict):
|
||||
items.extend(self._flatten_dict(item, f"{new_key}_{i}", sep=sep).items())
|
||||
else:
|
||||
items.append((f"{new_key}_{i}", item))
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
|
||||
return dict(items)
|
||||
|
||||
def _extract_coordinates(self, key: str, value: str, coordinate_source: str) -> Dict[str, str]:
|
||||
"""
|
||||
텍스트에서 좌표 정보 추출
|
||||
|
||||
Args:
|
||||
key: 키
|
||||
value: 값
|
||||
coordinate_source: 좌표 정보 출처
|
||||
|
||||
Returns:
|
||||
좌표 딕셔너리
|
||||
"""
|
||||
coordinates = {'x': '', 'y': ''}
|
||||
|
||||
try:
|
||||
# 값에서 좌표 패턴 찾기
|
||||
matches = self.coordinate_pattern.findall(str(value))
|
||||
|
||||
if matches:
|
||||
# 첫 번째 매치 사용
|
||||
x, y = matches[0]
|
||||
coordinates = {'x': x, 'y': y}
|
||||
else:
|
||||
# 키에서 좌표 정보 찾기
|
||||
key_matches = self.coordinate_pattern.findall(str(key))
|
||||
if key_matches:
|
||||
x, y = key_matches[0]
|
||||
coordinates = {'x': x, 'y': y}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"좌표 추출 오류: {str(e)}")
|
||||
|
||||
return coordinates
|
||||
|
||||
|
||||
def generate_cross_tabulated_csv_filename(base_name: str = "cross_tabulated_analysis") -> str:
|
||||
"""기본 Cross-tabulated CSV 파일명 생성"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"{base_name}_results_{timestamp}.csv"
|
||||
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
# 테스트용 예시
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 샘플 처리 결과 (실제 데이터 구조에 맞게 수정)
|
||||
sample_results = []
|
||||
|
||||
# 실제 사용 시에는 processing_results를 전달
|
||||
# success = exporter.export_cross_tabulated_csv(
|
||||
# sample_results,
|
||||
# "test_cross_tabulated.csv",
|
||||
# include_coordinates=True
|
||||
# )
|
||||
|
||||
print("Cross-tabulated CSV 내보내기 모듈 (수정 버전) 테스트 완료")
|
||||
306
csv_exporter.py
Normal file
306
csv_exporter.py
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CSV 저장 유틸리티 모듈
|
||||
DXF 타이틀블럭 Attribute 정보를 CSV 형식으로 저장
|
||||
"""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TitleBlockCSVExporter:
|
||||
"""타이틀블럭 속성 정보 CSV 저장 클래스"""
|
||||
|
||||
def __init__(self, output_dir: str = None):
|
||||
"""CSV 저장기 초기화"""
|
||||
self.output_dir = output_dir or Config.RESULTS_FOLDER
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
|
||||
def export_title_block_attributes(
|
||||
self,
|
||||
title_block_info: Dict[str, Any],
|
||||
filename: str = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
타이틀블럭 속성 정보를 CSV 파일로 저장
|
||||
|
||||
Args:
|
||||
title_block_info: 타이틀블럭 정보 딕셔너리
|
||||
filename: 저장할 파일명 (없으면 자동 생성)
|
||||
|
||||
Returns:
|
||||
저장된 파일 경로 또는 None (실패시)
|
||||
"""
|
||||
try:
|
||||
if not title_block_info or not title_block_info.get('all_attributes'):
|
||||
logger.warning("타이틀블럭 속성 정보가 없습니다.")
|
||||
return None
|
||||
|
||||
# 파일명 생성
|
||||
if not filename:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
block_name = title_block_info.get('block_name', 'Unknown_Block')
|
||||
filename = f"title_block_attributes_{block_name}_{timestamp}.csv"
|
||||
|
||||
# 확장자 확인
|
||||
if not filename.endswith('.csv'):
|
||||
filename += '.csv'
|
||||
|
||||
filepath = os.path.join(self.output_dir, filename)
|
||||
|
||||
# CSV 헤더 정의
|
||||
headers = [
|
||||
'block_name', # block_ref.name
|
||||
'attr_prompt', # attr.prompt
|
||||
'attr_text', # attr.text
|
||||
'attr_tag', # attr.tag
|
||||
'attr_insert_x', # attr.insert_x
|
||||
'attr_insert_y', # attr.insert_y
|
||||
'bounding_box_min_x', # attr.bounding_box.min_x
|
||||
'bounding_box_min_y', # attr.bounding_box.min_y
|
||||
'bounding_box_max_x', # attr.bounding_box.max_x
|
||||
'bounding_box_max_y', # attr.bounding_box.max_y
|
||||
'bounding_box_width', # attr.bounding_box.width
|
||||
'bounding_box_height', # attr.bounding_box.height
|
||||
'attr_height', # 추가: 텍스트 높이
|
||||
'attr_rotation', # 추가: 회전각
|
||||
'attr_layer', # 추가: 레이어
|
||||
'attr_style', # 추가: 스타일
|
||||
'entity_handle' # 추가: 엔티티 핸들
|
||||
]
|
||||
|
||||
# CSV 데이터 준비
|
||||
csv_rows = []
|
||||
block_name = title_block_info.get('block_name', '')
|
||||
|
||||
for attr in title_block_info.get('all_attributes', []):
|
||||
row = {
|
||||
'block_name': block_name,
|
||||
'attr_prompt': attr.get('prompt', '') or '',
|
||||
'attr_text': attr.get('text', '') or '',
|
||||
'attr_tag': attr.get('tag', '') or '',
|
||||
'attr_insert_x': attr.get('insert_x', '') or '',
|
||||
'attr_insert_y': attr.get('insert_y', '') or '',
|
||||
'attr_height': attr.get('height', '') or '',
|
||||
'attr_rotation': attr.get('rotation', '') or '',
|
||||
'attr_layer': attr.get('layer', '') or '',
|
||||
'attr_style': attr.get('style', '') or '',
|
||||
'entity_handle': attr.get('entity_handle', '') or '',
|
||||
}
|
||||
|
||||
# 바운딩 박스 정보 추가
|
||||
bbox = attr.get('bounding_box')
|
||||
if bbox:
|
||||
row.update({
|
||||
'bounding_box_min_x': bbox.get('min_x', ''),
|
||||
'bounding_box_min_y': bbox.get('min_y', ''),
|
||||
'bounding_box_max_x': bbox.get('max_x', ''),
|
||||
'bounding_box_max_y': bbox.get('max_y', ''),
|
||||
'bounding_box_width': bbox.get('max_x', 0) - bbox.get('min_x', 0) if bbox.get('max_x') and bbox.get('min_x') else '',
|
||||
'bounding_box_height': bbox.get('max_y', 0) - bbox.get('min_y', 0) if bbox.get('max_y') and bbox.get('min_y') else '',
|
||||
})
|
||||
else:
|
||||
row.update({
|
||||
'bounding_box_min_x': '',
|
||||
'bounding_box_min_y': '',
|
||||
'bounding_box_max_x': '',
|
||||
'bounding_box_max_y': '',
|
||||
'bounding_box_width': '',
|
||||
'bounding_box_height': '',
|
||||
})
|
||||
|
||||
csv_rows.append(row)
|
||||
|
||||
# CSV 파일 저장
|
||||
with open(filepath, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
||||
writer = csv.DictWriter(csvfile, fieldnames=headers)
|
||||
|
||||
# 헤더 작성
|
||||
writer.writeheader()
|
||||
|
||||
# 데이터 작성
|
||||
writer.writerows(csv_rows)
|
||||
|
||||
logger.info(f"타이틀블럭 속성 CSV 저장 완료: {filepath}")
|
||||
return filepath
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CSV 저장 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def create_attribute_table_data(
|
||||
self,
|
||||
title_block_info: Dict[str, Any]
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
UI 테이블 표시용 데이터 생성
|
||||
|
||||
Args:
|
||||
title_block_info: 타이틀블럭 정보 딕셔너리
|
||||
|
||||
Returns:
|
||||
테이블 표시용 데이터 리스트
|
||||
"""
|
||||
try:
|
||||
if not title_block_info or not title_block_info.get('all_attributes'):
|
||||
return []
|
||||
|
||||
table_data = []
|
||||
block_name = title_block_info.get('block_name', '')
|
||||
|
||||
for i, attr in enumerate(title_block_info.get('all_attributes', [])):
|
||||
# 바운딩 박스 정보 포맷팅
|
||||
bbox_str = ""
|
||||
bbox = attr.get('bounding_box')
|
||||
if bbox:
|
||||
bbox_str = f"({bbox.get('min_x', 0):.1f}, {bbox.get('min_y', 0):.1f}) - ({bbox.get('max_x', 0):.1f}, {bbox.get('max_y', 0):.1f})"
|
||||
|
||||
row = {
|
||||
'No.': str(i + 1),
|
||||
'Block Name': block_name,
|
||||
'Tag': attr.get('tag', ''),
|
||||
'Text': attr.get('text', '')[:30] + ('...' if len(attr.get('text', '')) > 30 else ''), # 텍스트 길이 제한
|
||||
'Prompt': attr.get('prompt', '') or 'N/A',
|
||||
'X': f"{attr.get('insert_x', 0):.1f}",
|
||||
'Y': f"{attr.get('insert_y', 0):.1f}",
|
||||
'Bounding Box': bbox_str or 'N/A',
|
||||
'Height': f"{attr.get('height', 0):.1f}",
|
||||
'Layer': attr.get('layer', ''),
|
||||
}
|
||||
|
||||
table_data.append(row)
|
||||
|
||||
return table_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"테이블 데이터 생성 중 오류: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
"""테스트용 메인 함수"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# 테스트 데이터
|
||||
test_title_block = {
|
||||
'block_name': 'TEST_TITLE_BLOCK',
|
||||
'all_attributes': [
|
||||
{
|
||||
'tag': 'DRAWING_NAME',
|
||||
'text': '테스트 도면',
|
||||
'prompt': '도면명을 입력하세요',
|
||||
'insert_x': 100.0,
|
||||
'insert_y': 200.0,
|
||||
'height': 5.0,
|
||||
'rotation': 0.0,
|
||||
'layer': '0',
|
||||
'style': 'Standard',
|
||||
'entity_handle': 'ABC123',
|
||||
'bounding_box': {
|
||||
'min_x': 100.0,
|
||||
'min_y': 200.0,
|
||||
'max_x': 180.0,
|
||||
'max_y': 210.0
|
||||
}
|
||||
},
|
||||
{
|
||||
'tag': 'DRAWING_NUMBER',
|
||||
'text': 'TEST-001',
|
||||
'prompt': '도면번호를 입력하세요',
|
||||
'insert_x': 100.0,
|
||||
'insert_y': 190.0,
|
||||
'height': 4.0,
|
||||
'rotation': 0.0,
|
||||
'layer': '0',
|
||||
'style': 'Standard',
|
||||
'entity_handle': 'DEF456',
|
||||
'bounding_box': {
|
||||
'min_x': 100.0,
|
||||
'min_y': 190.0,
|
||||
'max_x': 150.0,
|
||||
'max_y': 198.0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# CSV 저장 테스트
|
||||
exporter = TitleBlockCSVExporter()
|
||||
|
||||
# 테이블 데이터 생성 테스트
|
||||
table_data = exporter.create_attribute_table_data(test_title_block)
|
||||
print("테이블 데이터:")
|
||||
for row in table_data:
|
||||
print(row)
|
||||
|
||||
# CSV 저장 테스트
|
||||
saved_path = exporter.export_title_block_attributes(test_title_block, "test_export.csv")
|
||||
if saved_path:
|
||||
print(f"\nCSV 저장 성공: {saved_path}")
|
||||
else:
|
||||
print("\nCSV 저장 실패")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
import json
|
||||
|
||||
def export_analysis_results_to_csv(data: List[Dict[str, Any]], file_path: str):
|
||||
"""
|
||||
분석 결과를 CSV 파일로 저장합니다. pdf_analysis_result 컬럼의 JSON 데이터를 평탄화합니다.
|
||||
Args:
|
||||
data: 분석 결과 딕셔너리 리스트
|
||||
file_path: 저장할 CSV 파일 경로
|
||||
"""
|
||||
if not data:
|
||||
logger.warning("내보낼 데이터가 없습니다.")
|
||||
return
|
||||
|
||||
all_keys = set()
|
||||
processed_data = []
|
||||
|
||||
for row in data:
|
||||
new_row = row.copy()
|
||||
if 'pdf_analysis_result' in new_row and new_row['pdf_analysis_result']:
|
||||
try:
|
||||
json_data = new_row['pdf_analysis_result']
|
||||
if isinstance(json_data, str):
|
||||
json_data = json.loads(json_data)
|
||||
|
||||
if isinstance(json_data, dict):
|
||||
for k, v in json_data.items():
|
||||
new_row[f"pdf_analysis_result_{k}"] = v
|
||||
del new_row['pdf_analysis_result']
|
||||
else:
|
||||
new_row['pdf_analysis_result'] = str(json_data)
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning(f"pdf_analysis_result 파싱 오류: {e}, 원본 데이터 유지: {new_row['pdf_analysis_result']}")
|
||||
new_row['pdf_analysis_result'] = str(new_row['pdf_analysis_result'])
|
||||
|
||||
processed_data.append(new_row)
|
||||
all_keys.update(new_row.keys())
|
||||
|
||||
# 'pdf_analysis_result'가 평탄화된 경우 최종 키에서 제거
|
||||
if 'pdf_analysis_result' in all_keys:
|
||||
all_keys.remove('pdf_analysis_result')
|
||||
|
||||
sorted_keys = sorted(list(all_keys))
|
||||
|
||||
try:
|
||||
with open(file_path, 'w', newline='', encoding='utf-8-sig') as output_file:
|
||||
dict_writer = csv.DictWriter(output_file, sorted_keys)
|
||||
dict_writer.writeheader()
|
||||
dict_writer.writerows(processed_data)
|
||||
logger.info(f"분석 결과 CSV 저장 완료: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"분석 결과 CSV 저장 중 오류: {e}")
|
||||
|
||||
484
docs/developer_guide.md
Normal file
484
docs/developer_guide.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# 개발자 가이드
|
||||
|
||||
PDF 도면 분석기의 개발 및 확장을 위한 가이드입니다.
|
||||
|
||||
## 목차
|
||||
|
||||
1. [프로젝트 구조](#프로젝트-구조)
|
||||
2. [개발 환경 설정](#개발-환경-설정)
|
||||
3. [모듈 구조](#모듈-구조)
|
||||
4. [API 참조](#api-참조)
|
||||
5. [확장 가이드](#확장-가이드)
|
||||
6. [기여하기](#기여하기)
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
fletimageanalysis/
|
||||
├── main.py # 메인 애플리케이션 진입점
|
||||
├── config.py # 설정 관리 모듈
|
||||
├── pdf_processor.py # PDF 처리 및 이미지 변환
|
||||
├── gemini_analyzer.py # Gemini API 연동
|
||||
├── ui_components.py # UI 컴포넌트 정의
|
||||
├── utils.py # 유틸리티 함수들
|
||||
├── setup.py # 설치 스크립트
|
||||
├── test_project.py # 테스트 스크립트
|
||||
├── requirements.txt # Python 의존성
|
||||
├── .env.example # 환경 변수 템플릿
|
||||
├── README.md # 프로젝트 개요
|
||||
├── LICENSE # 라이선스
|
||||
├── project_plan.md # 프로젝트 계획
|
||||
├── uploads/ # 업로드된 파일 저장
|
||||
├── results/ # 분석 결과 저장
|
||||
├── assets/ # 정적 자산
|
||||
└── docs/ # 문서
|
||||
├── user_guide.md # 사용자 가이드
|
||||
└── developer_guide.md # 개발자 가이드
|
||||
```
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
### 필수 요구사항
|
||||
|
||||
- Python 3.9+
|
||||
- pip 최신 버전
|
||||
- Google Gemini API 키
|
||||
|
||||
### 개발 환경 구성
|
||||
|
||||
1. **저장소 클론**
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd fletimageanalysis
|
||||
```
|
||||
|
||||
2. **가상 환경 생성**
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. **개발 의존성 설치**
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install black flake8 pytest # 개발 도구
|
||||
```
|
||||
|
||||
4. **환경 설정**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# .env 파일에서 GEMINI_API_KEY 설정
|
||||
```
|
||||
|
||||
### 코드 스타일
|
||||
|
||||
프로젝트는 다음 코딩 스타일을 따릅니다:
|
||||
|
||||
- **포맷터**: Black
|
||||
- **린터**: Flake8
|
||||
- **라인 길이**: 88자
|
||||
- **문서화**: Google 스타일 docstring
|
||||
|
||||
```bash
|
||||
# 코드 포맷팅
|
||||
black .
|
||||
|
||||
# 코드 검사
|
||||
flake8 .
|
||||
|
||||
# 테스트 실행
|
||||
python test_project.py
|
||||
```
|
||||
|
||||
## 모듈 구조
|
||||
|
||||
### 1. config.py - 설정 관리
|
||||
|
||||
```python
|
||||
class Config:
|
||||
"""애플리케이션 설정 클래스"""
|
||||
|
||||
# 기본 설정
|
||||
APP_TITLE = "PDF 도면 분석기"
|
||||
APP_VERSION = "1.0.0"
|
||||
|
||||
# API 설정
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
||||
GEMINI_MODEL = "gemini-2.5-flash"
|
||||
|
||||
# 파일 설정
|
||||
MAX_FILE_SIZE_MB = 50
|
||||
ALLOWED_EXTENSIONS = ["pdf"]
|
||||
```
|
||||
|
||||
**주요 기능:**
|
||||
|
||||
- 환경 변수 로드
|
||||
- 설정 유효성 검사
|
||||
- 경로 관리
|
||||
|
||||
### 2. pdf_processor.py - PDF 처리
|
||||
|
||||
```python
|
||||
class PDFProcessor:
|
||||
"""PDF 파일 처리 클래스"""
|
||||
|
||||
def validate_pdf_file(self, file_path: str) -> bool:
|
||||
"""PDF 파일 유효성 검사"""
|
||||
|
||||
def convert_pdf_page_to_image(self, file_path: str, page_number: int) -> Image:
|
||||
"""PDF 페이지를 PIL Image로 변환"""
|
||||
|
||||
def pdf_page_to_base64(self, file_path: str, page_number: int) -> str:
|
||||
"""PDF 페이지를 base64 문자열로 변환"""
|
||||
```
|
||||
|
||||
**주요 기능:**
|
||||
|
||||
- PDF 파일 검증
|
||||
- 페이지별 이미지 변환
|
||||
- Base64 인코딩
|
||||
- 메타데이터 추출
|
||||
|
||||
### 3. gemini_analyzer.py - API 연동
|
||||
|
||||
```python
|
||||
class GeminiAnalyzer:
|
||||
"""Gemini API 이미지 분석 클래스"""
|
||||
|
||||
def analyze_image_from_base64(self, base64_data: str, prompt: str) -> str:
|
||||
"""Base64 이미지 데이터 분석"""
|
||||
|
||||
def analyze_pdf_images(self, base64_images: list, prompt: str) -> dict:
|
||||
"""여러 PDF 페이지 일괄 분석"""
|
||||
```
|
||||
|
||||
**주요 기능:**
|
||||
|
||||
- API 클라이언트 관리
|
||||
- 이미지 분석 요청
|
||||
- 응답 처리
|
||||
- 오류 처리
|
||||
|
||||
### 4. ui_components.py - UI 컴포넌트
|
||||
|
||||
```python
|
||||
class UIComponents:
|
||||
"""UI 컴포넌트 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def create_app_bar() -> ft.AppBar:
|
||||
"""애플리케이션 상단 바 생성"""
|
||||
|
||||
@staticmethod
|
||||
def create_file_upload_section() -> ft.Container:
|
||||
"""파일 업로드 섹션 생성"""
|
||||
```
|
||||
|
||||
**주요 기능:**
|
||||
|
||||
- 재사용 가능한 UI 컴포넌트
|
||||
- Material Design 스타일
|
||||
- 이벤트 핸들러 정의
|
||||
|
||||
### 5. utils.py - 유틸리티
|
||||
|
||||
```python
|
||||
class FileUtils:
|
||||
"""파일 관련 유틸리티"""
|
||||
|
||||
class AnalysisResultSaver:
|
||||
"""분석 결과 저장"""
|
||||
|
||||
class DateTimeUtils:
|
||||
"""날짜/시간 유틸리티"""
|
||||
```
|
||||
|
||||
**주요 기능:**
|
||||
|
||||
- 파일 조작
|
||||
- 결과 저장
|
||||
- 텍스트 처리
|
||||
- 검증 함수
|
||||
|
||||
## API 참조
|
||||
|
||||
### PDFProcessor
|
||||
|
||||
#### `validate_pdf_file(file_path: str) -> bool`
|
||||
|
||||
PDF 파일의 유효성을 검사합니다.
|
||||
|
||||
**매개변수:**
|
||||
|
||||
- `file_path`: PDF 파일 경로
|
||||
|
||||
**반환값:**
|
||||
|
||||
- `bool`: 유효한 PDF인지 여부
|
||||
|
||||
#### `get_pdf_info(file_path: str) -> dict`
|
||||
|
||||
PDF 파일의 메타데이터를 조회합니다.
|
||||
|
||||
**반환값:**
|
||||
|
||||
```python
|
||||
{
|
||||
'page_count': int,
|
||||
'metadata': dict,
|
||||
'file_size': int,
|
||||
'filename': str
|
||||
}
|
||||
```
|
||||
|
||||
### GeminiAnalyzer
|
||||
|
||||
#### `analyze_image_from_base64(base64_data: str, prompt: str) -> str`
|
||||
|
||||
Base64 이미지를 분석합니다.
|
||||
|
||||
**매개변수:**
|
||||
|
||||
- `base64_data`: Base64로 인코딩된 이미지
|
||||
- `prompt`: 분석 요청 텍스트
|
||||
|
||||
**반환값:**
|
||||
|
||||
- `str`: 분석 결과 텍스트
|
||||
|
||||
### AnalysisResultSaver
|
||||
|
||||
#### `save_analysis_results(pdf_filename, analysis_results, pdf_info, analysis_settings) -> str`
|
||||
|
||||
분석 결과를 텍스트 파일로 저장합니다.
|
||||
|
||||
**반환값:**
|
||||
|
||||
- `str`: 저장된 파일 경로
|
||||
|
||||
## 확장 가이드
|
||||
|
||||
### 새로운 분석 모드 추가
|
||||
|
||||
1. **UI 업데이트**
|
||||
|
||||
```python
|
||||
# ui_components.py에서 새 라디오 버튼 추가
|
||||
ft.Radio(value="new_mode", label="새로운 모드")
|
||||
```
|
||||
|
||||
2. **분석 로직 추가**
|
||||
|
||||
```python
|
||||
# main.py의 run_analysis()에서 프롬프트 설정
|
||||
elif self.analysis_mode.value == "new_mode":
|
||||
prompt = "새로운 분석 모드의 프롬프트"
|
||||
```
|
||||
|
||||
### 새로운 파일 형식 지원
|
||||
|
||||
1. **설정 업데이트**
|
||||
|
||||
```python
|
||||
# config.py
|
||||
ALLOWED_EXTENSIONS = ["pdf", "docx"] # 새 형식 추가
|
||||
```
|
||||
|
||||
2. **처리기 확장**
|
||||
|
||||
```python
|
||||
# 새로운 처리 클래스 구현
|
||||
class DOCXProcessor:
|
||||
def validate_docx_file(self, file_path: str) -> bool:
|
||||
# DOCX 검증 로직
|
||||
pass
|
||||
```
|
||||
|
||||
### 새로운 AI 모델 지원
|
||||
|
||||
1. **설정 추가**
|
||||
|
||||
```python
|
||||
# config.py
|
||||
ALTERNATIVE_MODEL = "claude-3-5-sonnet"
|
||||
```
|
||||
|
||||
2. **분석기 확장**
|
||||
|
||||
```python
|
||||
class ClaudeAnalyzer:
|
||||
def analyze_image(self, image_data: str) -> str:
|
||||
# Claude API 연동 로직
|
||||
pass
|
||||
```
|
||||
|
||||
### UI 컴포넌트 확장
|
||||
|
||||
1. **새 컴포넌트 추가**
|
||||
|
||||
```python
|
||||
# ui_components.py
|
||||
@staticmethod
|
||||
def create_advanced_settings_section() -> ft.Container:
|
||||
"""고급 설정 섹션"""
|
||||
return ft.Container(...)
|
||||
```
|
||||
|
||||
2. **메인 UI에 통합**
|
||||
|
||||
```python
|
||||
# main.py의 build_ui()에서 새 컴포넌트 추가
|
||||
```
|
||||
|
||||
## 기여하기
|
||||
|
||||
### 기여 프로세스
|
||||
|
||||
1. **이슈 생성**
|
||||
|
||||
- 새 기능이나 버그 리포트
|
||||
- 명확한 설명과 예시 제공
|
||||
|
||||
2. **브랜치 생성**
|
||||
|
||||
```bash
|
||||
git checkout -b feature/new-feature
|
||||
git checkout -b bugfix/fix-issue
|
||||
```
|
||||
|
||||
3. **개발 및 테스트**
|
||||
|
||||
```bash
|
||||
# 개발 후 테스트 실행
|
||||
python test_project.py
|
||||
black .
|
||||
flake8 .
|
||||
```
|
||||
|
||||
4. **커밋 및 푸시**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin feature/new-feature
|
||||
```
|
||||
|
||||
5. **Pull Request 생성**
|
||||
- 명확한 제목과 설명
|
||||
- 변경사항 설명
|
||||
- 테스트 결과 포함
|
||||
|
||||
### 커밋 메시지 규칙
|
||||
|
||||
- `feat:` 새로운 기능
|
||||
- `fix:` 버그 수정
|
||||
- `docs:` 문서 업데이트
|
||||
- `style:` 코드 스타일 변경
|
||||
- `refactor:` 코드 리팩토링
|
||||
- `test:` 테스트 추가/수정
|
||||
- `chore:` 기타 작업
|
||||
|
||||
### 코드 리뷰 체크리스트
|
||||
|
||||
- [ ] 코드 스타일 준수 (Black, Flake8)
|
||||
- [ ] 테스트 통과
|
||||
- [ ] 문서화 완료
|
||||
- [ ] 타입 힌트 추가
|
||||
- [ ] 에러 처리 적절
|
||||
- [ ] 성능 고려
|
||||
- [ ] 보안 검토
|
||||
|
||||
## 디버깅
|
||||
|
||||
### 로깅 설정
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
# 개발 시 상세 로깅
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
```
|
||||
|
||||
### 일반적인 디버깅 시나리오
|
||||
|
||||
1. **API 연결 문제**
|
||||
|
||||
```python
|
||||
# gemini_analyzer.py에서 연결 테스트
|
||||
if not analyzer.validate_api_connection():
|
||||
logger.error("API 연결 실패")
|
||||
```
|
||||
|
||||
2. **파일 처리 오류**
|
||||
|
||||
```python
|
||||
# pdf_processor.py에서 상세 오류 정보
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"PDF 열기 실패: {e}")
|
||||
```
|
||||
|
||||
3. **UI 업데이트 문제**
|
||||
|
||||
```python
|
||||
# main.py에서 스레드 안전 업데이트
|
||||
def safe_ui_update():
|
||||
def update():
|
||||
# UI 업데이트 코드
|
||||
self.page.update()
|
||||
|
||||
self.page.run_thread(update)
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 메모리 관리
|
||||
|
||||
1. **대용량 PDF 처리**
|
||||
|
||||
```python
|
||||
# 페이지별 순차 처리
|
||||
for page_num in range(total_pages):
|
||||
# 메모리 해제
|
||||
del previous_image
|
||||
gc.collect()
|
||||
```
|
||||
|
||||
2. **이미지 크기 최적화**
|
||||
|
||||
```python
|
||||
# 적절한 줌 레벨 선택
|
||||
zoom = min(2.0, target_width / pdf_width)
|
||||
```
|
||||
|
||||
### API 호출 최적화
|
||||
|
||||
1. **요청 배치 처리**
|
||||
|
||||
```python
|
||||
# 여러 페이지를 하나의 요청으로 처리
|
||||
combined_prompt = f"다음 {len(images)}개 이미지를 분석..."
|
||||
```
|
||||
|
||||
2. **캐싱 구현**
|
||||
|
||||
```python
|
||||
# 분석 결과 캐시
|
||||
@lru_cache(maxsize=100)
|
||||
def cached_analysis(image_hash: str, prompt: str) -> str:
|
||||
return analyzer.analyze_image(image_data, prompt)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
더 자세한 정보나 질문이 있으시면 GitHub Issues에서 문의해 주세요.
|
||||
222
docs/user_guide.md
Normal file
222
docs/user_guide.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 사용자 가이드
|
||||
|
||||
PDF 도면 분석기의 상세한 사용법을 안내합니다.
|
||||
|
||||
## 목차
|
||||
1. [설치 후 첫 실행](#설치-후-첫-실행)
|
||||
2. [기본 사용법](#기본-사용법)
|
||||
3. [고급 기능](#고급-기능)
|
||||
4. [문제 해결](#문제-해결)
|
||||
5. [팁과 요령](#팁과-요령)
|
||||
|
||||
## 설치 후 첫 실행
|
||||
|
||||
### 1. API 키 설정 확인
|
||||
애플리케이션을 처음 실행하기 전에 Gemini API 키가 올바르게 설정되었는지 확인하세요.
|
||||
|
||||
```bash
|
||||
# .env 파일 확인
|
||||
GEMINI_API_KEY=your_actual_api_key_here
|
||||
```
|
||||
|
||||
### 2. 테스트 실행
|
||||
설치가 올바르게 되었는지 확인:
|
||||
```bash
|
||||
python test_project.py
|
||||
```
|
||||
|
||||
### 3. 애플리케이션 실행
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## 기본 사용법
|
||||
|
||||
### 1. PDF 파일 업로드
|
||||
|
||||
1. **파일 선택**: "PDF 파일 선택" 버튼을 클릭합니다.
|
||||
2. **파일 확인**: 선택된 파일의 정보(이름, 페이지 수, 크기)를 확인합니다.
|
||||
3. **유효성 검사**: 시스템이 자동으로 PDF 파일의 유효성을 검사합니다.
|
||||
|
||||
**지원되는 파일:**
|
||||
- ✅ PDF 형식 파일
|
||||
- ✅ 최대 50MB 크기
|
||||
- ✅ 모든 페이지 수
|
||||
|
||||
**지원되지 않는 파일:**
|
||||
- ❌ 암호로 보호된 PDF
|
||||
- ❌ 손상된 PDF 파일
|
||||
- ❌ 이미지 파일 (JPG, PNG 등)
|
||||
|
||||
### 2. 분석 설정
|
||||
|
||||
#### 페이지 선택
|
||||
- **첫 번째 페이지**: 첫 페이지만 분석 (빠름, 비용 절약)
|
||||
- **모든 페이지**: 전체 페이지 분석 (상세함, 시간 소요)
|
||||
|
||||
#### 분석 모드
|
||||
- **기본 분석**: 문서 유형과 기본 정보 분석
|
||||
- **상세 분석**: 도면, 도표, 텍스트 등 상세 분석
|
||||
- **사용자 정의**: 원하는 분석 내용을 직접 입력
|
||||
|
||||
### 3. 분석 실행
|
||||
|
||||
1. **분석 시작**: "분석 시작" 버튼을 클릭합니다.
|
||||
2. **진행 상황 확인**: 진행률 바와 상태 메시지를 확인합니다.
|
||||
3. **결과 확인**: 분석 완료 후 결과를 검토합니다.
|
||||
|
||||
### 4. 결과 저장
|
||||
|
||||
분석 완료 후 두 가지 형식으로 저장할 수 있습니다:
|
||||
- **텍스트 저장**: 읽기 쉬운 텍스트 형식
|
||||
- **JSON 저장**: 구조화된 데이터 형식
|
||||
|
||||
## 고급 기능
|
||||
|
||||
### 사용자 정의 분석
|
||||
|
||||
분석 모드에서 "사용자 정의"를 선택하면 원하는 분석 내용을 직접 지정할 수 있습니다.
|
||||
|
||||
**예시 프롬프트:**
|
||||
```
|
||||
이 도면에서 다음 정보를 추출해주세요:
|
||||
1. 도면 제목과 도면 번호
|
||||
2. 주요 치수 정보
|
||||
3. 사용된 재료 정보
|
||||
4. 특별한 주의사항
|
||||
```
|
||||
|
||||
### 대용량 PDF 처리
|
||||
|
||||
큰 PDF 파일을 처리할 때 팁:
|
||||
1. **첫 페이지만 분석**: 전체 분석 전에 테스트
|
||||
2. **인터넷 연결 확인**: 안정적인 연결 필요
|
||||
3. **충분한 시간 확보**: 페이지당 1-2분 소요
|
||||
|
||||
### 배치 처리
|
||||
|
||||
여러 PDF를 순차적으로 처리하는 방법:
|
||||
1. 첫 번째 PDF 분석 완료
|
||||
2. 결과 저장
|
||||
3. 다음 PDF 업로드
|
||||
4. 반복
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 일반적인 오류들
|
||||
|
||||
#### 1. API 키 오류
|
||||
```
|
||||
오류: Gemini API 키가 설정되지 않았습니다.
|
||||
```
|
||||
**해결책:**
|
||||
- `.env` 파일의 `GEMINI_API_KEY` 확인
|
||||
- API 키가 올바른지 Google AI Studio에서 확인
|
||||
|
||||
#### 2. PDF 파일 오류
|
||||
```
|
||||
오류: 유효하지 않은 PDF 파일입니다.
|
||||
```
|
||||
**해결책:**
|
||||
- 다른 PDF 뷰어에서 파일 열어보기
|
||||
- 파일 손상 여부 확인
|
||||
- 파일 크기 제한 확인 (50MB 이하)
|
||||
|
||||
#### 3. 네트워크 오류
|
||||
```
|
||||
오류: 분석 중 오류가 발생했습니다.
|
||||
```
|
||||
**해결책:**
|
||||
- 인터넷 연결 상태 확인
|
||||
- 방화벽 설정 확인
|
||||
- 잠시 후 다시 시도
|
||||
|
||||
#### 4. 메모리 부족
|
||||
```
|
||||
오류: 메모리가 부족합니다.
|
||||
```
|
||||
**해결책:**
|
||||
- 다른 프로그램 종료
|
||||
- 첫 번째 페이지만 분석
|
||||
- 시스템 재시작
|
||||
|
||||
### 로그 확인
|
||||
|
||||
문제 발생 시 콘솔 출력을 확인하세요:
|
||||
```bash
|
||||
python main.py > app.log 2>&1
|
||||
```
|
||||
|
||||
## 팁과 요령
|
||||
|
||||
### 1. 효율적인 분석
|
||||
|
||||
**빠른 분석을 위해:**
|
||||
- 첫 번째 페이지만 선택
|
||||
- 기본 분석 모드 사용
|
||||
- 작은 크기의 PDF 사용
|
||||
|
||||
**정확한 분석을 위해:**
|
||||
- 모든 페이지 선택
|
||||
- 상세 분석 모드 사용
|
||||
- 구체적인 사용자 정의 프롬프트 작성
|
||||
|
||||
### 2. 프롬프트 작성 요령
|
||||
|
||||
**좋은 프롬프트 예시:**
|
||||
```
|
||||
이 건축 도면을 분석하여 다음을 알려주세요:
|
||||
- 건물 유형과 규모
|
||||
- 주요 치수 (길이, 폭, 높이)
|
||||
- 방의 개수와 용도
|
||||
- 특별한 설계 요소
|
||||
```
|
||||
|
||||
**피해야 할 프롬프트:**
|
||||
```
|
||||
분석해줘 (너무 일반적)
|
||||
모든 것을 알려줘 (너무 광범위)
|
||||
```
|
||||
|
||||
### 3. 결과 활용
|
||||
|
||||
**텍스트 결과**:
|
||||
- 보고서 작성에 적합
|
||||
- 직접 복사/붙여넣기 가능
|
||||
|
||||
**JSON 결과**:
|
||||
- 다른 시스템과 연동
|
||||
- 추가 데이터 처리 가능
|
||||
|
||||
### 4. 성능 최적화
|
||||
|
||||
**시스템 성능 향상:**
|
||||
- 충분한 RAM 확보 (8GB 이상 권장)
|
||||
- SSD 사용 시 더 빠른 처리
|
||||
- 안정적인 인터넷 연결
|
||||
|
||||
**비용 최적화:**
|
||||
- 필요한 페이지만 분석
|
||||
- 기본 분석 모드 우선 사용
|
||||
- 중복 분석 방지
|
||||
|
||||
## 자주 묻는 질문 (FAQ)
|
||||
|
||||
### Q: 분석 시간이 얼마나 걸리나요?
|
||||
A: 페이지당 1-2분 정도 소요됩니다. 네트워크 상태와 이미지 복잡도에 따라 달라집니다.
|
||||
|
||||
### Q: 어떤 종류의 도면을 분석할 수 있나요?
|
||||
A: 건축 도면, 기계 도면, 전기 회로도, 지도, 차트 등 모든 종류의 이미지가 포함된 PDF를 분석할 수 있습니다.
|
||||
|
||||
### Q: 분석 결과의 정확도는 어느 정도인가요?
|
||||
A: Google Gemini AI의 최신 기술을 사용하여 높은 정확도를 제공하지만, 복잡한 도면이나 불분명한 이미지의 경우 제한이 있을 수 있습니다.
|
||||
|
||||
### Q: 개인정보나 민감한 문서도 안전한가요?
|
||||
A: 업로드된 파일은 로컬에서만 처리되며, Google API로는 이미지 데이터만 전송됩니다. 원본 파일은 로컬에 보관됩니다.
|
||||
|
||||
### Q: 오프라인에서도 사용할 수 있나요?
|
||||
A: 아니요. Gemini API 호출을 위해 인터넷 연결이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
추가 질문이나 문제가 있으시면 GitHub Issues에서 문의해 주세요.
|
||||
871
dxf_processor.py
Normal file
871
dxf_processor.py
Normal file
@@ -0,0 +1,871 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
향상된 DXF 파일 처리 모듈
|
||||
ezdxf 라이브러리를 사용하여 DXF 파일에서 도곽 정보, 텍스트 엔티티 및 모든 Block Reference/Attribute Reference를 추출
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from dataclasses import dataclass, asdict, field
|
||||
|
||||
try:
|
||||
import ezdxf
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.entities import Insert, Attrib, AttDef, Text, MText
|
||||
from ezdxf.layouts import BlockLayout, Modelspace
|
||||
from ezdxf import bbox, disassemble
|
||||
EZDXF_AVAILABLE = True
|
||||
except ImportError:
|
||||
EZDXF_AVAILABLE = False
|
||||
logging.warning("ezdxf 라이브러리가 설치되지 않았습니다. DXF 기능이 비활성화됩니다.")
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoundingBox:
|
||||
"""바운딩 박스 정보를 담는 데이터 클래스"""
|
||||
min_x: float
|
||||
min_y: float
|
||||
max_x: float
|
||||
max_y: float
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
return self.max_x - self.min_x
|
||||
|
||||
@property
|
||||
def height(self) -> float:
|
||||
return self.max_y - self.min_y
|
||||
|
||||
@property
|
||||
def center(self) -> Tuple[float, float]:
|
||||
return ((self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2)
|
||||
|
||||
def merge(self, other: 'BoundingBox') -> 'BoundingBox':
|
||||
"""다른 바운딩 박스와 병합하여 가장 큰 외곽 박스 반환"""
|
||||
return BoundingBox(
|
||||
min_x=min(self.min_x, other.min_x),
|
||||
min_y=min(self.min_y, other.min_y),
|
||||
max_x=max(self.max_x, other.max_x),
|
||||
max_y=max(self.max_y, other.max_y)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextInfo:
|
||||
"""텍스트 엔티티 정보를 담는 데이터 클래스"""
|
||||
entity_type: str # TEXT, MTEXT, ATTRIB
|
||||
text: str
|
||||
position: Tuple[float, float, float]
|
||||
height: float
|
||||
rotation: float
|
||||
layer: str
|
||||
bounding_box: Optional[BoundingBox] = None
|
||||
entity_handle: Optional[str] = None
|
||||
style: Optional[str] = None
|
||||
color: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeInfo:
|
||||
"""속성 정보를 담는 데이터 클래스 - 모든 DXF 속성 포함"""
|
||||
tag: str
|
||||
text: str
|
||||
position: Tuple[float, float, float] # insert point (x, y, z)
|
||||
height: float
|
||||
width: float
|
||||
rotation: float
|
||||
layer: str
|
||||
bounding_box: Optional[BoundingBox] = None
|
||||
|
||||
# 추가 DXF 속성들
|
||||
prompt: Optional[str] = None
|
||||
style: Optional[str] = None
|
||||
invisible: bool = False
|
||||
const: bool = False
|
||||
verify: bool = False
|
||||
preset: bool = False
|
||||
align_point: Optional[Tuple[float, float, float]] = None
|
||||
halign: int = 0
|
||||
valign: int = 0
|
||||
text_generation_flag: int = 0
|
||||
oblique_angle: float = 0.0
|
||||
width_factor: float = 1.0
|
||||
color: Optional[int] = None
|
||||
linetype: Optional[str] = None
|
||||
lineweight: Optional[int] = None
|
||||
|
||||
# 좌표 정보
|
||||
insert_x: float = 0.0
|
||||
insert_y: float = 0.0
|
||||
insert_z: float = 0.0
|
||||
|
||||
# 계산된 정보
|
||||
estimated_width: float = 0.0
|
||||
entity_handle: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockInfo:
|
||||
"""블록 정보를 담는 데이터 클래스"""
|
||||
name: str
|
||||
position: Tuple[float, float, float]
|
||||
scale: Tuple[float, float, float]
|
||||
rotation: float
|
||||
layer: str
|
||||
attributes: List[AttributeInfo]
|
||||
bounding_box: Optional[BoundingBox] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TitleBlockInfo:
|
||||
"""도곽 정보를 담는 데이터 클래스"""
|
||||
drawing_name: Optional[str] = None
|
||||
drawing_number: Optional[str] = None
|
||||
construction_field: Optional[str] = None
|
||||
construction_stage: Optional[str] = None
|
||||
scale: Optional[str] = None
|
||||
project_name: Optional[str] = None
|
||||
designer: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
revision: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
bounding_box: Optional[BoundingBox] = None
|
||||
block_name: Optional[str] = None
|
||||
|
||||
# 모든 attributes 정보 저장
|
||||
all_attributes: List[AttributeInfo] = field(default_factory=list)
|
||||
attributes_count: int = 0
|
||||
|
||||
# 추가 메타데이터
|
||||
block_position: Optional[Tuple[float, float, float]] = None
|
||||
block_scale: Optional[Tuple[float, float, float]] = None
|
||||
block_rotation: float = 0.0
|
||||
block_layer: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""초기화 후 처리"""
|
||||
self.attributes_count = len(self.all_attributes)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComprehensiveExtractionResult:
|
||||
"""종합적인 추출 결과를 담는 데이터 클래스"""
|
||||
text_entities: List[TextInfo] = field(default_factory=list)
|
||||
all_block_references: List[BlockInfo] = field(default_factory=list)
|
||||
title_block: Optional[TitleBlockInfo] = None
|
||||
overall_bounding_box: Optional[BoundingBox] = None
|
||||
summary: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class EnhancedDXFProcessor:
|
||||
"""향상된 DXF 파일 처리 클래스"""
|
||||
|
||||
# 도곽 식별을 위한 키워드 정의
|
||||
TITLE_BLOCK_KEYWORDS = {
|
||||
'건설분야': ['construction_field', 'field', '분야', '공사', 'category'],
|
||||
'건설단계': ['construction_stage', 'stage', '단계', 'phase'],
|
||||
'도면명': ['drawing_name', 'title', '제목', 'name', '명'],
|
||||
'축척': ['scale', '축척', 'ratio', '비율'],
|
||||
'도면번호': ['drawing_number', 'number', '번호', 'no', 'dwg'],
|
||||
'설계자': ['designer', '설계', 'design', 'drawn'],
|
||||
'프로젝트': ['project', '사업', '공사명'],
|
||||
'날짜': ['date', '일자', '작성일'],
|
||||
'리비전': ['revision', 'rev', '개정'],
|
||||
'위치': ['location', '위치', '지역']
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""DXF 처리기 초기화"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
if not EZDXF_AVAILABLE:
|
||||
raise ImportError("ezdxf 라이브러리가 필요합니다. 'pip install ezdxf'로 설치하세요.")
|
||||
|
||||
def validate_dxf_file(self, file_path: str) -> bool:
|
||||
"""DXF 파일 유효성 검사"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
self.logger.error(f"파일이 존재하지 않습니다: {file_path}")
|
||||
return False
|
||||
|
||||
if not file_path.lower().endswith('.dxf'):
|
||||
self.logger.error(f"DXF 파일이 아닙니다: {file_path}")
|
||||
return False
|
||||
|
||||
# ezdxf로 파일 읽기 시도
|
||||
doc = ezdxf.readfile(file_path)
|
||||
if doc is None:
|
||||
return False
|
||||
|
||||
self.logger.info(f"DXF 파일 유효성 검사 성공: {file_path}")
|
||||
return True
|
||||
|
||||
except ezdxf.DXFStructureError as e:
|
||||
self.logger.error(f"DXF 구조 오류: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 파일 검증 중 오류: {e}")
|
||||
return False
|
||||
|
||||
def load_dxf_document(self, file_path: str) -> Optional[Drawing]:
|
||||
"""DXF 문서 로드"""
|
||||
try:
|
||||
doc = ezdxf.readfile(file_path)
|
||||
self.logger.info(f"DXF 문서 로드 성공: {file_path}")
|
||||
return doc
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 문서 로드 실패: {e}")
|
||||
return None
|
||||
|
||||
def _is_empty_text(self, text: str) -> bool:
|
||||
"""텍스트가 비어있는지 확인 (공백 문자만 있거나 완전히 비어있는 경우)"""
|
||||
return not text or text.strip() == ""
|
||||
|
||||
def calculate_comprehensive_bounding_box(self, doc: Drawing) -> Optional[BoundingBox]:
|
||||
"""전체 문서의 종합적인 바운딩 박스 계산 (ezdxf.bbox 사용)"""
|
||||
try:
|
||||
msp = doc.modelspace()
|
||||
|
||||
# ezdxf의 bbox 모듈을 사용하여 전체 바운딩 박스 계산
|
||||
cache = bbox.Cache()
|
||||
overall_bbox = bbox.extents(msp, cache=cache)
|
||||
|
||||
if overall_bbox:
|
||||
self.logger.info(f"전체 바운딩 박스: {overall_bbox}")
|
||||
return BoundingBox(
|
||||
min_x=overall_bbox.extmin.x,
|
||||
min_y=overall_bbox.extmin.y,
|
||||
max_x=overall_bbox.extmax.x,
|
||||
max_y=overall_bbox.extmax.y
|
||||
)
|
||||
else:
|
||||
self.logger.warning("바운딩 박스 계산 실패")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"바운딩 박스 계산 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def extract_all_text_entities(self, doc: Drawing) -> List[TextInfo]:
|
||||
"""모든 텍스트 엔티티 추출 (TEXT, MTEXT, DBTEXT)"""
|
||||
text_entities = []
|
||||
|
||||
try:
|
||||
msp = doc.modelspace()
|
||||
|
||||
# TEXT 엔티티 추출
|
||||
for text_entity in msp.query('TEXT'):
|
||||
text_content = getattr(text_entity.dxf, 'text', '')
|
||||
if not self._is_empty_text(text_content):
|
||||
text_info = self._extract_text_info(text_entity, 'TEXT')
|
||||
if text_info:
|
||||
text_entities.append(text_info)
|
||||
|
||||
# MTEXT 엔티티 추출
|
||||
for mtext_entity in msp.query('MTEXT'):
|
||||
# MTEXT는 .text 속성 사용
|
||||
text_content = getattr(mtext_entity, 'text', '') or getattr(mtext_entity.dxf, 'text', '')
|
||||
if not self._is_empty_text(text_content):
|
||||
text_info = self._extract_text_info(mtext_entity, 'MTEXT')
|
||||
if text_info:
|
||||
text_entities.append(text_info)
|
||||
|
||||
# ATTRIB 엔티티 추출 (블록 외부의 독립적인 속성)
|
||||
for attrib_entity in msp.query('ATTRIB'):
|
||||
text_content = getattr(attrib_entity.dxf, 'text', '')
|
||||
if not self._is_empty_text(text_content):
|
||||
text_info = self._extract_text_info(attrib_entity, 'ATTRIB')
|
||||
if text_info:
|
||||
text_entities.append(text_info)
|
||||
|
||||
# 페이퍼스페이스도 확인
|
||||
for layout_name in doc.layout_names_in_taborder():
|
||||
if layout_name.startswith('*'): # 모델스페이스 제외
|
||||
continue
|
||||
try:
|
||||
layout = doc.paperspace(layout_name)
|
||||
|
||||
# TEXT, MTEXT, ATTRIB 추출
|
||||
for entity_type in ['TEXT', 'MTEXT', 'ATTRIB']:
|
||||
for entity in layout.query(entity_type):
|
||||
if entity_type == 'MTEXT':
|
||||
text_content = getattr(entity, 'text', '') or getattr(entity.dxf, 'text', '')
|
||||
else:
|
||||
text_content = getattr(entity.dxf, 'text', '')
|
||||
|
||||
if not self._is_empty_text(text_content):
|
||||
text_info = self._extract_text_info(entity, entity_type)
|
||||
if text_info:
|
||||
text_entities.append(text_info)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"레이아웃 {layout_name} 처리 중 오류: {e}")
|
||||
|
||||
self.logger.info(f"총 {len(text_entities)}개의 텍스트 엔티티를 찾았습니다.")
|
||||
return text_entities
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"텍스트 엔티티 추출 중 오류: {e}")
|
||||
return []
|
||||
|
||||
def _extract_text_info(self, entity, entity_type: str) -> Optional[TextInfo]:
|
||||
"""텍스트 엔티티에서 정보 추출"""
|
||||
try:
|
||||
# 텍스트 내용 추출
|
||||
if entity_type == 'MTEXT':
|
||||
text_content = getattr(entity, 'text', '') or getattr(entity.dxf, 'text', '')
|
||||
else:
|
||||
text_content = getattr(entity.dxf, 'text', '')
|
||||
|
||||
# 위치 정보
|
||||
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
|
||||
position = (
|
||||
insert_point.x if hasattr(insert_point, 'x') else insert_point[0],
|
||||
insert_point.y if hasattr(insert_point, 'y') else insert_point[1],
|
||||
insert_point.z if hasattr(insert_point, 'z') else insert_point[2]
|
||||
)
|
||||
|
||||
# 기본 속성
|
||||
height = getattr(entity.dxf, 'height', 1.0)
|
||||
rotation = getattr(entity.dxf, 'rotation', 0.0)
|
||||
layer = getattr(entity.dxf, 'layer', '0')
|
||||
entity_handle = getattr(entity.dxf, 'handle', None)
|
||||
style = getattr(entity.dxf, 'style', None)
|
||||
color = getattr(entity.dxf, 'color', None)
|
||||
|
||||
# 바운딩 박스 계산
|
||||
bounding_box = self._calculate_text_bounding_box(entity)
|
||||
|
||||
return TextInfo(
|
||||
entity_type=entity_type,
|
||||
text=text_content,
|
||||
position=position,
|
||||
height=height,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
bounding_box=bounding_box,
|
||||
entity_handle=entity_handle,
|
||||
style=style,
|
||||
color=color
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"텍스트 정보 추출 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_text_bounding_box(self, entity) -> Optional[BoundingBox]:
|
||||
"""텍스트 엔티티의 바운딩 박스 계산"""
|
||||
try:
|
||||
# ezdxf bbox 모듈 사용
|
||||
entity_bbox = bbox.extents([entity])
|
||||
if entity_bbox:
|
||||
return BoundingBox(
|
||||
min_x=entity_bbox.extmin.x,
|
||||
min_y=entity_bbox.extmin.y,
|
||||
max_x=entity_bbox.extmax.x,
|
||||
max_y=entity_bbox.extmax.y
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"바운딩 박스 계산 실패, 추정값 사용: {e}")
|
||||
|
||||
# 대안: 추정 계산
|
||||
try:
|
||||
if hasattr(entity, 'dxf'):
|
||||
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
|
||||
height = getattr(entity.dxf, 'height', 1.0)
|
||||
|
||||
# 텍스트 내용 길이 추정
|
||||
if hasattr(entity, 'text'):
|
||||
text_content = entity.text
|
||||
elif hasattr(entity.dxf, 'text'):
|
||||
text_content = entity.dxf.text
|
||||
else:
|
||||
text_content = ""
|
||||
|
||||
# 텍스트 너비 추정 (높이의 0.6배 * 글자 수)
|
||||
estimated_width = len(text_content) * height * 0.6
|
||||
|
||||
x, y = insert_point[0], insert_point[1]
|
||||
|
||||
return BoundingBox(
|
||||
min_x=x,
|
||||
min_y=y,
|
||||
max_x=x + estimated_width,
|
||||
max_y=y + height
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"텍스트 바운딩 박스 계산 실패: {e}")
|
||||
return None
|
||||
|
||||
def extract_all_block_references(self, doc: Drawing) -> List[BlockInfo]:
|
||||
"""모든 Block Reference 추출 (재귀적으로 중첩된 블록도 포함)"""
|
||||
block_refs = []
|
||||
|
||||
try:
|
||||
# 모델스페이스에서 INSERT 엔티티 찾기
|
||||
msp = doc.modelspace()
|
||||
|
||||
for insert in msp.query('INSERT'):
|
||||
block_info = self._process_block_reference(doc, insert)
|
||||
if block_info:
|
||||
block_refs.append(block_info)
|
||||
|
||||
# 페이퍼스페이스도 확인
|
||||
for layout_name in doc.layout_names_in_taborder():
|
||||
if layout_name.startswith('*'): # 모델스페이스 제외
|
||||
continue
|
||||
try:
|
||||
layout = doc.paperspace(layout_name)
|
||||
for insert in layout.query('INSERT'):
|
||||
block_info = self._process_block_reference(doc, insert)
|
||||
if block_info:
|
||||
block_refs.append(block_info)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"레이아웃 {layout_name} 처리 중 오류: {e}")
|
||||
|
||||
# 블록 정의 내부도 재귀적으로 검사
|
||||
for block_layout in doc.blocks:
|
||||
if not block_layout.name.startswith('*'): # 시스템 블록 제외
|
||||
for insert in block_layout.query('INSERT'):
|
||||
block_info = self._process_block_reference(doc, insert)
|
||||
if block_info:
|
||||
block_refs.append(block_info)
|
||||
|
||||
self.logger.info(f"총 {len(block_refs)}개의 블록 참조를 찾았습니다.")
|
||||
return block_refs
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"블록 참조 추출 중 오류: {e}")
|
||||
return []
|
||||
|
||||
def _process_block_reference(self, doc: Drawing, insert: Insert) -> Optional[BlockInfo]:
|
||||
"""개별 Block Reference 처리 - ATTDEF 정보도 함께 수집"""
|
||||
try:
|
||||
# 블록 정보 추출
|
||||
block_name = insert.dxf.name
|
||||
position = (insert.dxf.insert.x, insert.dxf.insert.y, insert.dxf.insert.z)
|
||||
scale = (
|
||||
getattr(insert.dxf, 'xscale', 1.0),
|
||||
getattr(insert.dxf, 'yscale', 1.0),
|
||||
getattr(insert.dxf, 'zscale', 1.0)
|
||||
)
|
||||
rotation = getattr(insert.dxf, 'rotation', 0.0)
|
||||
layer = getattr(insert.dxf, 'layer', '0')
|
||||
|
||||
# ATTDEF 정보 수집 (프롬프트 정보 포함)
|
||||
attdef_info = {}
|
||||
try:
|
||||
block_layout = doc.blocks.get(block_name)
|
||||
if block_layout:
|
||||
for attdef in block_layout.query('ATTDEF'):
|
||||
tag = getattr(attdef.dxf, 'tag', '')
|
||||
prompt = getattr(attdef.dxf, 'prompt', '')
|
||||
if tag:
|
||||
attdef_info[tag] = {
|
||||
'prompt': prompt,
|
||||
'default_text': getattr(attdef.dxf, 'text', ''),
|
||||
'position': (attdef.dxf.insert.x, attdef.dxf.insert.y, attdef.dxf.insert.z),
|
||||
'height': getattr(attdef.dxf, 'height', 1.0),
|
||||
'style': getattr(attdef.dxf, 'style', 'Standard'),
|
||||
'invisible': getattr(attdef.dxf, 'invisible', False),
|
||||
'const': getattr(attdef.dxf, 'const', False),
|
||||
'verify': getattr(attdef.dxf, 'verify', False),
|
||||
'preset': getattr(attdef.dxf, 'preset', False)
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.debug(f"ATTDEF 정보 수집 실패: {e}")
|
||||
|
||||
# ATTRIB 속성 추출 및 ATTDEF 정보와 결합 (빈 텍스트 제외)
|
||||
attributes = []
|
||||
for attrib in insert.attribs:
|
||||
attr_info = self._extract_attribute_info(attrib)
|
||||
if attr_info and not self._is_empty_text(attr_info.text):
|
||||
# ATTDEF에서 프롬프트 정보 추가
|
||||
if attr_info.tag in attdef_info:
|
||||
attr_info.prompt = attdef_info[attr_info.tag]['prompt']
|
||||
attributes.append(attr_info)
|
||||
|
||||
# 블록 바운딩 박스 계산
|
||||
block_bbox = self._calculate_block_bounding_box(insert)
|
||||
|
||||
return BlockInfo(
|
||||
name=block_name,
|
||||
position=position,
|
||||
scale=scale,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
attributes=attributes,
|
||||
bounding_box=block_bbox
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"블록 참조 처리 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_block_bounding_box(self, insert: Insert) -> Optional[BoundingBox]:
|
||||
"""블록의 바운딩 박스 계산"""
|
||||
try:
|
||||
# ezdxf bbox 모듈 사용
|
||||
block_bbox = bbox.extents([insert])
|
||||
if block_bbox:
|
||||
return BoundingBox(
|
||||
min_x=block_bbox.extmin.x,
|
||||
min_y=block_bbox.extmin.y,
|
||||
max_x=block_bbox.extmax.x,
|
||||
max_y=block_bbox.extmax.y
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"블록 바운딩 박스 계산 실패: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _extract_attribute_info(self, attrib: Attrib) -> Optional[AttributeInfo]:
|
||||
"""Attribute Reference에서 모든 정보 추출 (빈 텍스트 포함)"""
|
||||
try:
|
||||
# 기본 속성
|
||||
tag = getattr(attrib.dxf, 'tag', '')
|
||||
text = getattr(attrib.dxf, 'text', '')
|
||||
|
||||
# 위치 정보
|
||||
insert_point = getattr(attrib.dxf, 'insert', (0, 0, 0))
|
||||
position = (insert_point.x if hasattr(insert_point, 'x') else insert_point[0],
|
||||
insert_point.y if hasattr(insert_point, 'y') else insert_point[1],
|
||||
insert_point.z if hasattr(insert_point, 'z') else insert_point[2])
|
||||
|
||||
# 텍스트 속성
|
||||
height = getattr(attrib.dxf, 'height', 1.0)
|
||||
width = getattr(attrib.dxf, 'width', 1.0)
|
||||
rotation = getattr(attrib.dxf, 'rotation', 0.0)
|
||||
|
||||
# 레이어 및 스타일
|
||||
layer = getattr(attrib.dxf, 'layer', '0')
|
||||
style = getattr(attrib.dxf, 'style', 'Standard')
|
||||
|
||||
# 속성 플래그
|
||||
invisible = getattr(attrib.dxf, 'invisible', False)
|
||||
const = getattr(attrib.dxf, 'const', False)
|
||||
verify = getattr(attrib.dxf, 'verify', False)
|
||||
preset = getattr(attrib.dxf, 'preset', False)
|
||||
|
||||
# 정렬 정보
|
||||
align_point_data = getattr(attrib.dxf, 'align_point', None)
|
||||
align_point = None
|
||||
if align_point_data:
|
||||
align_point = (align_point_data.x if hasattr(align_point_data, 'x') else align_point_data[0],
|
||||
align_point_data.y if hasattr(align_point_data, 'y') else align_point_data[1],
|
||||
align_point_data.z if hasattr(align_point_data, 'z') else align_point_data[2])
|
||||
|
||||
halign = getattr(attrib.dxf, 'halign', 0)
|
||||
valign = getattr(attrib.dxf, 'valign', 0)
|
||||
|
||||
# 텍스트 형식
|
||||
text_generation_flag = getattr(attrib.dxf, 'text_generation_flag', 0)
|
||||
oblique_angle = getattr(attrib.dxf, 'oblique_angle', 0.0)
|
||||
width_factor = getattr(attrib.dxf, 'width_factor', 1.0)
|
||||
|
||||
# 시각적 속성
|
||||
color = getattr(attrib.dxf, 'color', None)
|
||||
linetype = getattr(attrib.dxf, 'linetype', None)
|
||||
lineweight = getattr(attrib.dxf, 'lineweight', None)
|
||||
|
||||
# 엔티티 핸들
|
||||
entity_handle = getattr(attrib.dxf, 'handle', None)
|
||||
|
||||
# 텍스트 폭 추정
|
||||
estimated_width = len(text) * height * 0.6 * width_factor
|
||||
|
||||
# 바운딩 박스 계산
|
||||
bounding_box = self._calculate_text_bounding_box(attrib)
|
||||
|
||||
return AttributeInfo(
|
||||
tag=tag,
|
||||
text=text,
|
||||
position=position,
|
||||
height=height,
|
||||
width=width,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
bounding_box=bounding_box,
|
||||
prompt=None, # 나중에 ATTDEF에서 설정
|
||||
style=style,
|
||||
invisible=invisible,
|
||||
const=const,
|
||||
verify=verify,
|
||||
preset=preset,
|
||||
align_point=align_point,
|
||||
halign=halign,
|
||||
valign=valign,
|
||||
text_generation_flag=text_generation_flag,
|
||||
oblique_angle=oblique_angle,
|
||||
width_factor=width_factor,
|
||||
color=color,
|
||||
linetype=linetype,
|
||||
lineweight=lineweight,
|
||||
insert_x=position[0],
|
||||
insert_y=position[1],
|
||||
insert_z=position[2],
|
||||
estimated_width=estimated_width,
|
||||
entity_handle=entity_handle
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"속성 정보 추출 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def identify_title_block(self, block_refs: List[BlockInfo]) -> Optional[TitleBlockInfo]:
|
||||
"""블록 참조들 중에서 도곽을 식별하고 정보 추출"""
|
||||
title_block_candidates = []
|
||||
|
||||
for block_ref in block_refs:
|
||||
# 도곽 키워드를 포함한 속성이 있는지 확인
|
||||
keyword_matches = 0
|
||||
|
||||
for attr in block_ref.attributes:
|
||||
for keyword_group in self.TITLE_BLOCK_KEYWORDS.keys():
|
||||
if self._contains_keyword(attr.tag, keyword_group) or \
|
||||
self._contains_keyword(attr.text, keyword_group):
|
||||
keyword_matches += 1
|
||||
break
|
||||
|
||||
# 충분한 키워드가 매칭되면 도곽 후보로 추가
|
||||
if keyword_matches >= 2: # 최소 2개 이상의 키워드 매칭
|
||||
title_block_candidates.append((block_ref, keyword_matches))
|
||||
|
||||
if not title_block_candidates:
|
||||
self.logger.warning("도곽 블록을 찾을 수 없습니다.")
|
||||
return None
|
||||
|
||||
# 가장 많은 키워드를 포함한 블록을 도곽으로 선택
|
||||
title_block_candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
best_candidate = title_block_candidates[0][0]
|
||||
|
||||
self.logger.info(f"도곽 블록 발견: {best_candidate.name} (키워드 매칭: {title_block_candidates[0][1]})")
|
||||
|
||||
return self._extract_title_block_info(best_candidate)
|
||||
|
||||
def _contains_keyword(self, text: str, keyword_group: str) -> bool:
|
||||
"""텍스트에 특정 키워드 그룹의 단어가 포함되어 있는지 확인"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
text_lower = text.lower()
|
||||
keywords = self.TITLE_BLOCK_KEYWORDS.get(keyword_group, [])
|
||||
|
||||
return any(keyword.lower() in text_lower for keyword in keywords)
|
||||
|
||||
def _extract_title_block_info(self, block_ref: BlockInfo) -> TitleBlockInfo:
|
||||
"""도곽 블록에서 상세 정보 추출"""
|
||||
# TitleBlockInfo 객체 생성
|
||||
title_block = TitleBlockInfo(
|
||||
block_name=block_ref.name,
|
||||
all_attributes=block_ref.attributes.copy(),
|
||||
block_position=block_ref.position,
|
||||
block_scale=block_ref.scale,
|
||||
block_rotation=block_ref.rotation,
|
||||
block_layer=block_ref.layer
|
||||
)
|
||||
|
||||
# 속성들을 분석하여 도곽 정보 매핑
|
||||
for attr in block_ref.attributes:
|
||||
text_value = attr.text.strip()
|
||||
|
||||
if not text_value:
|
||||
continue
|
||||
|
||||
# 각 키워드 그룹별로 매칭 시도
|
||||
if self._contains_keyword(attr.tag, '도면명') or self._contains_keyword(attr.text, '도면명'):
|
||||
title_block.drawing_name = text_value
|
||||
elif self._contains_keyword(attr.tag, '도면번호') or self._contains_keyword(attr.text, '도면번호'):
|
||||
title_block.drawing_number = text_value
|
||||
elif self._contains_keyword(attr.tag, '건설분야') or self._contains_keyword(attr.text, '건설분야'):
|
||||
title_block.construction_field = text_value
|
||||
elif self._contains_keyword(attr.tag, '건설단계') or self._contains_keyword(attr.text, '건설단계'):
|
||||
title_block.construction_stage = text_value
|
||||
elif self._contains_keyword(attr.tag, '축척') or self._contains_keyword(attr.text, '축척'):
|
||||
title_block.scale = text_value
|
||||
elif self._contains_keyword(attr.tag, '설계자') or self._contains_keyword(attr.text, '설계자'):
|
||||
title_block.designer = text_value
|
||||
elif self._contains_keyword(attr.tag, '프로젝트') or self._contains_keyword(attr.text, '프로젝트'):
|
||||
title_block.project_name = text_value
|
||||
elif self._contains_keyword(attr.tag, '날짜') or self._contains_keyword(attr.text, '날짜'):
|
||||
title_block.date = text_value
|
||||
elif self._contains_keyword(attr.tag, '리비전') or self._contains_keyword(attr.text, '리비전'):
|
||||
title_block.revision = text_value
|
||||
elif self._contains_keyword(attr.tag, '위치') or self._contains_keyword(attr.text, '위치'):
|
||||
title_block.location = text_value
|
||||
|
||||
# 도곽 바운딩 박스는 블록의 바운딩 박스 사용
|
||||
title_block.bounding_box = block_ref.bounding_box
|
||||
|
||||
# 속성 개수 업데이트
|
||||
title_block.attributes_count = len(title_block.all_attributes)
|
||||
|
||||
self.logger.info(f"도곽 '{block_ref.name}'에서 {title_block.attributes_count}개의 속성 추출 완료")
|
||||
|
||||
return title_block
|
||||
|
||||
def process_dxf_file_comprehensive(self, file_path: str) -> Dict[str, Any]:
|
||||
"""DXF 파일 종합적인 처리"""
|
||||
result = {
|
||||
'success': False,
|
||||
'error': None,
|
||||
'file_path': file_path,
|
||||
'comprehensive_result': None,
|
||||
'summary': {}
|
||||
}
|
||||
|
||||
try:
|
||||
# 파일 유효성 검사
|
||||
if not self.validate_dxf_file(file_path):
|
||||
result['error'] = "유효하지 않은 DXF 파일입니다."
|
||||
return result
|
||||
|
||||
# DXF 문서 로드
|
||||
doc = self.load_dxf_document(file_path)
|
||||
if not doc:
|
||||
result['error'] = "DXF 문서를 로드할 수 없습니다."
|
||||
return result
|
||||
|
||||
# 종합적인 추출 시작
|
||||
comprehensive_result = ComprehensiveExtractionResult()
|
||||
|
||||
# 1. 모든 텍스트 엔티티 추출
|
||||
self.logger.info("텍스트 엔티티 추출 중...")
|
||||
comprehensive_result.text_entities = self.extract_all_text_entities(doc)
|
||||
|
||||
# 2. 모든 블록 참조 추출
|
||||
self.logger.info("블록 참조 추출 중...")
|
||||
comprehensive_result.all_block_references = self.extract_all_block_references(doc)
|
||||
|
||||
# 3. 도곽 정보 추출
|
||||
self.logger.info("도곽 정보 추출 중...")
|
||||
comprehensive_result.title_block = self.identify_title_block(comprehensive_result.all_block_references)
|
||||
|
||||
# 4. 전체 바운딩 박스 계산
|
||||
self.logger.info("전체 바운딩 박스 계산 중...")
|
||||
comprehensive_result.overall_bounding_box = self.calculate_comprehensive_bounding_box(doc)
|
||||
|
||||
# 5. 요약 정보 생성
|
||||
comprehensive_result.summary = {
|
||||
'total_text_entities': len(comprehensive_result.text_entities),
|
||||
'total_block_references': len(comprehensive_result.all_block_references),
|
||||
'title_block_found': comprehensive_result.title_block is not None,
|
||||
'title_block_name': comprehensive_result.title_block.block_name if comprehensive_result.title_block else None,
|
||||
'total_attributes': sum(len(br.attributes) for br in comprehensive_result.all_block_references),
|
||||
'non_empty_attributes': sum(len([attr for attr in br.attributes if not self._is_empty_text(attr.text)])
|
||||
for br in comprehensive_result.all_block_references),
|
||||
'overall_bounding_box': comprehensive_result.overall_bounding_box.__dict__ if comprehensive_result.overall_bounding_box else None
|
||||
}
|
||||
|
||||
# 결과 저장
|
||||
result['comprehensive_result'] = asdict(comprehensive_result)
|
||||
result['summary'] = comprehensive_result.summary
|
||||
result['success'] = True
|
||||
|
||||
self.logger.info(f"DXF 파일 종합 처리 완료: {file_path}")
|
||||
self.logger.info(f"추출 요약: 텍스트 엔티티 {comprehensive_result.summary['total_text_entities']}개, "
|
||||
f"블록 참조 {comprehensive_result.summary['total_block_references']}개, "
|
||||
f"비어있지 않은 속성 {comprehensive_result.summary['non_empty_attributes']}개")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
|
||||
result['error'] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def save_analysis_result(self, result: Dict[str, Any], output_file: str) -> bool:
|
||||
"""분석 결과를 JSON 파일로 저장"""
|
||||
try:
|
||||
os.makedirs(Config.RESULTS_FOLDER, exist_ok=True)
|
||||
output_path = os.path.join(Config.RESULTS_FOLDER, output_file)
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
self.logger.info(f"분석 결과 저장 완료: {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"분석 결과 저장 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 기존 클래스명과의 호환성을 위한 별칭
|
||||
DXFProcessor = EnhancedDXFProcessor
|
||||
|
||||
|
||||
def main():
|
||||
"""테스트용 메인 함수"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
if not EZDXF_AVAILABLE:
|
||||
print("ezdxf 라이브러리가 설치되지 않았습니다.")
|
||||
return
|
||||
|
||||
processor = EnhancedDXFProcessor()
|
||||
|
||||
# 테스트 파일 경로 (실제 파일 경로로 변경 필요)
|
||||
test_file = "test_drawing.dxf"
|
||||
|
||||
if os.path.exists(test_file):
|
||||
result = processor.process_dxf_file_comprehensive(test_file)
|
||||
|
||||
if result['success']:
|
||||
print("DXF 파일 종합 처리 성공!")
|
||||
summary = result['summary']
|
||||
print(f"텍스트 엔티티: {summary['total_text_entities']}")
|
||||
print(f"블록 참조: {summary['total_block_references']}")
|
||||
print(f"도곽 발견: {summary['title_block_found']}")
|
||||
print(f"비어있지 않은 속성: {summary['non_empty_attributes']}")
|
||||
|
||||
if summary['overall_bounding_box']:
|
||||
bbox_info = summary['overall_bounding_box']
|
||||
print(f"전체 바운딩 박스: ({bbox_info['min_x']:.2f}, {bbox_info['min_y']:.2f}) ~ "
|
||||
f"({bbox_info['max_x']:.2f}, {bbox_info['max_y']:.2f})")
|
||||
else:
|
||||
print(f"처리 실패: {result['error']}")
|
||||
else:
|
||||
print(f"테스트 파일을 찾을 수 없습니다: {test_file}")
|
||||
|
||||
|
||||
def process_dxf_file(self, file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
기존 코드와의 호환성을 위한 메서드
|
||||
process_dxf_file_comprehensive를 호출하고 기존 형식으로 변환
|
||||
"""
|
||||
try:
|
||||
# 새로운 종합 처리 메서드 호출
|
||||
comprehensive_result = self.process_dxf_file_comprehensive(file_path)
|
||||
|
||||
if not comprehensive_result['success']:
|
||||
return comprehensive_result
|
||||
|
||||
# 기존 형식으로 변환
|
||||
comp_data = comprehensive_result['comprehensive_result']
|
||||
|
||||
# 기존 형식으로 데이터 재구성
|
||||
result = {
|
||||
'success': True,
|
||||
'error': None,
|
||||
'file_path': file_path,
|
||||
'title_block': comp_data.get('title_block'),
|
||||
'block_references': comp_data.get('all_block_references', []),
|
||||
'summary': comp_data.get('summary', {})
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'file_path': file_path,
|
||||
'title_block': None,
|
||||
'block_references': [],
|
||||
'summary': {}
|
||||
}
|
||||
637
dxf_processor_fixed.py
Normal file
637
dxf_processor_fixed.py
Normal file
@@ -0,0 +1,637 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
수정된 DXF 파일 처리 모듈 - 속성 추출 문제 해결
|
||||
ezdxf 라이브러리를 사용하여 DXF 파일에서 모든 속성을 정확히 추출
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from dataclasses import dataclass, asdict, field
|
||||
|
||||
try:
|
||||
import ezdxf
|
||||
from ezdxf.document import Drawing
|
||||
from ezdxf.entities import Insert, Attrib, AttDef, Text, MText
|
||||
from ezdxf.layouts import BlockLayout, Modelspace
|
||||
from ezdxf import bbox, disassemble
|
||||
EZDXF_AVAILABLE = True
|
||||
except ImportError:
|
||||
EZDXF_AVAILABLE = False
|
||||
logging.warning("ezdxf 라이브러리가 설치되지 않았습니다. DXF 기능이 비활성화됩니다.")
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoundingBox:
|
||||
"""바운딩 박스 정보를 담는 데이터 클래스"""
|
||||
min_x: float
|
||||
min_y: float
|
||||
max_x: float
|
||||
max_y: float
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
return self.max_x - self.min_x
|
||||
|
||||
@property
|
||||
def height(self) -> float:
|
||||
return self.max_y - self.min_y
|
||||
|
||||
@property
|
||||
def center(self) -> Tuple[float, float]:
|
||||
return ((self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2)
|
||||
|
||||
def merge(self, other: 'BoundingBox') -> 'BoundingBox':
|
||||
"""다른 바운딩 박스와 병합하여 가장 큰 외곽 박스 반환"""
|
||||
return BoundingBox(
|
||||
min_x=min(self.min_x, other.min_x),
|
||||
min_y=min(self.min_y, other.min_y),
|
||||
max_x=max(self.max_x, other.max_x),
|
||||
max_y=max(self.max_y, other.max_y)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeInfo:
|
||||
"""속성 정보를 담는 데이터 클래스"""
|
||||
tag: str
|
||||
text: str
|
||||
position: Tuple[float, float, float]
|
||||
height: float
|
||||
rotation: float
|
||||
layer: str
|
||||
prompt: Optional[str] = None
|
||||
style: Optional[str] = None
|
||||
invisible: bool = False
|
||||
const: bool = False
|
||||
bounding_box: Optional[BoundingBox] = None
|
||||
entity_handle: Optional[str] = None
|
||||
|
||||
# 좌표 정보
|
||||
insert_x: float = 0.0
|
||||
insert_y: float = 0.0
|
||||
insert_z: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockInfo:
|
||||
"""블록 정보를 담는 데이터 클래스"""
|
||||
name: str
|
||||
position: Tuple[float, float, float]
|
||||
scale: Tuple[float, float, float]
|
||||
rotation: float
|
||||
layer: str
|
||||
attributes: List[AttributeInfo]
|
||||
bounding_box: Optional[BoundingBox] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TitleBlockInfo:
|
||||
"""도곽 정보를 담는 데이터 클래스"""
|
||||
drawing_name: Optional[str] = None
|
||||
drawing_number: Optional[str] = None
|
||||
construction_field: Optional[str] = None
|
||||
construction_stage: Optional[str] = None
|
||||
scale: Optional[str] = None
|
||||
project_name: Optional[str] = None
|
||||
designer: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
revision: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
bounding_box: Optional[BoundingBox] = None
|
||||
block_name: Optional[str] = None
|
||||
|
||||
# 모든 attributes 정보 저장
|
||||
all_attributes: List[AttributeInfo] = field(default_factory=list)
|
||||
attributes_count: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
"""초기화 후 처리"""
|
||||
self.attributes_count = len(self.all_attributes)
|
||||
|
||||
|
||||
class FixedDXFProcessor:
|
||||
"""수정된 DXF 파일 처리기 - 속성 추출 문제 해결"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
# 도곽 식별을 위한 키워드 (한국어/영어)
|
||||
self.title_block_keywords = {
|
||||
'도면명': ['도면명', '도면', 'drawing', 'title', 'name', 'dwg'],
|
||||
'도면번호': ['도면번호', '번호', 'number', 'no', 'dwg_no'],
|
||||
'건설분야': ['건설분야', '분야', 'field', 'construction', 'civil'],
|
||||
'건설단계': ['건설단계', '단계', 'stage', 'phase', 'step'],
|
||||
'축척': ['축척', 'scale', 'ratio'],
|
||||
'설계자': ['설계자', '설계', 'designer', 'design', 'engineer'],
|
||||
'프로젝트': ['프로젝트', '사업', 'project', 'work'],
|
||||
'날짜': ['날짜', '일자', 'date', 'time'],
|
||||
'리비전': ['리비전', '개정', 'revision', 'rev'],
|
||||
'위치': ['위치', '장소', 'location', 'place', 'site']
|
||||
}
|
||||
|
||||
if not EZDXF_AVAILABLE:
|
||||
self.logger.warning("ezdxf 라이브러리가 설치되지 않았습니다.")
|
||||
|
||||
def validate_dxf_file(self, file_path: str) -> bool:
|
||||
"""DXF 파일 유효성 검사"""
|
||||
if not EZDXF_AVAILABLE:
|
||||
self.logger.error("ezdxf 라이브러리가 설치되지 않음")
|
||||
return False
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
self.logger.error(f"DXF 파일이 존재하지 않음: {file_path}")
|
||||
return False
|
||||
|
||||
if not file_path.lower().endswith('.dxf'):
|
||||
self.logger.error(f"DXF 파일이 아님: {file_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# 파일 열기 시도
|
||||
doc = ezdxf.readfile(file_path)
|
||||
self.logger.info(f"DXF 파일 유효성 검사 통과: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 파일 유효성 검사 실패: {e}")
|
||||
return False
|
||||
|
||||
def load_dxf_document(self, file_path: str) -> Optional[Drawing]:
|
||||
"""DXF 문서 로드"""
|
||||
try:
|
||||
doc = ezdxf.readfile(file_path)
|
||||
self.logger.info(f"DXF 문서 로드 성공: {file_path}")
|
||||
return doc
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 문서 로드 실패: {e}")
|
||||
return None
|
||||
|
||||
def extract_all_insert_attributes(self, doc: Drawing) -> List[BlockInfo]:
|
||||
"""모든 INSERT 엔티티에서 속성 추출 - 수정된 로직"""
|
||||
block_refs = []
|
||||
|
||||
try:
|
||||
# 모델스페이스에서 INSERT 엔티티 검색
|
||||
msp = doc.modelspace()
|
||||
inserts = msp.query('INSERT')
|
||||
|
||||
self.logger.info(f"모델스페이스에서 {len(inserts)}개의 INSERT 엔티티 발견")
|
||||
|
||||
for insert in inserts:
|
||||
block_info = self._process_insert_entity(insert, doc)
|
||||
if block_info:
|
||||
block_refs.append(block_info)
|
||||
|
||||
# 페이퍼스페이스에서도 검색
|
||||
for layout in doc.layouts:
|
||||
if layout.is_any_paperspace:
|
||||
inserts = layout.query('INSERT')
|
||||
self.logger.info(f"페이퍼스페이스 '{layout.name}'에서 {len(inserts)}개의 INSERT 엔티티 발견")
|
||||
|
||||
for insert in inserts:
|
||||
block_info = self._process_insert_entity(insert, doc)
|
||||
if block_info:
|
||||
block_refs.append(block_info)
|
||||
|
||||
self.logger.info(f"총 {len(block_refs)}개의 블록 참조 처리 완료")
|
||||
return block_refs
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"INSERT 속성 추출 중 오류: {e}")
|
||||
return []
|
||||
|
||||
def _process_insert_entity(self, insert: Insert, doc: Drawing) -> Optional[BlockInfo]:
|
||||
"""개별 INSERT 엔티티 처리 - 향상된 속성 추출"""
|
||||
try:
|
||||
attributes = []
|
||||
|
||||
# 방법 1: INSERT에 연결된 ATTRIB 엔티티들 추출
|
||||
self.logger.debug(f"INSERT '{insert.dxf.name}'의 연결된 속성 추출 중...")
|
||||
|
||||
# insert.attribs는 리스트를 반환
|
||||
insert_attribs = insert.attribs
|
||||
self.logger.debug(f"INSERT.attribs: {len(insert_attribs)}개 발견")
|
||||
|
||||
for attrib in insert_attribs:
|
||||
attr_info = self._extract_attrib_info(attrib)
|
||||
if attr_info:
|
||||
attributes.append(attr_info)
|
||||
self.logger.debug(f"ATTRIB 추출: tag='{attr_info.tag}', text='{attr_info.text}'")
|
||||
|
||||
# 방법 2: 블록 정의에서 ATTDEF 정보 추출 (search_const=True와 유사한 효과)
|
||||
try:
|
||||
block_layout = doc.blocks.get(insert.dxf.name)
|
||||
if block_layout:
|
||||
# 블록 정의에서 ATTDEF 엔티티 검색
|
||||
attdefs = block_layout.query('ATTDEF')
|
||||
self.logger.debug(f"블록 정의에서 {len(attdefs)}개의 ATTDEF 발견")
|
||||
|
||||
for attdef in attdefs:
|
||||
# ATTDEF에서 기본값이나 상수 값 추출
|
||||
if hasattr(attdef.dxf, 'text') and attdef.dxf.text.strip():
|
||||
attr_info = self._extract_attdef_info(attdef, insert)
|
||||
if attr_info:
|
||||
# 중복 체크 (같은 tag가 이미 있으면 건너뛰기)
|
||||
existing_tags = [attr.tag for attr in attributes]
|
||||
if attr_info.tag not in existing_tags:
|
||||
attributes.append(attr_info)
|
||||
self.logger.debug(f"ATTDEF 추출: tag='{attr_info.tag}', text='{attr_info.text}'")
|
||||
|
||||
# 방법 3: 블록 내부의 TEXT/MTEXT 엔티티도 추출
|
||||
text_entities = block_layout.query('TEXT MTEXT')
|
||||
self.logger.debug(f"블록 정의에서 {len(text_entities)}개의 TEXT/MTEXT 발견")
|
||||
|
||||
for text_entity in text_entities:
|
||||
attr_info = self._extract_text_as_attribute(text_entity, insert)
|
||||
if attr_info:
|
||||
attributes.append(attr_info)
|
||||
self.logger.debug(f"TEXT 추출: text='{attr_info.text}'")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"블록 정의 처리 중 오류: {e}")
|
||||
|
||||
# 방법 4: get_attrib_text 메서드를 사용한 확인
|
||||
try:
|
||||
# 일반적인 속성 태그들로 시도
|
||||
common_tags = ['TITLE', 'NAME', 'NUMBER', 'DATE', 'SCALE', 'DESIGNER',
|
||||
'PROJECT', 'DRAWING', 'DWG_NO', 'REV', 'REVISION']
|
||||
|
||||
for tag in common_tags:
|
||||
try:
|
||||
# search_const=True로 ATTDEF도 검색
|
||||
text_value = insert.get_attrib_text(tag, search_const=True)
|
||||
if text_value and text_value.strip():
|
||||
# 이미 있는 태그인지 확인
|
||||
existing_tags = [attr.tag for attr in attributes]
|
||||
if tag not in existing_tags:
|
||||
attr_info = AttributeInfo(
|
||||
tag=tag,
|
||||
text=text_value.strip(),
|
||||
position=insert.dxf.insert,
|
||||
height=12.0, # 기본값
|
||||
rotation=insert.dxf.rotation,
|
||||
layer=insert.dxf.layer,
|
||||
insert_x=insert.dxf.insert[0],
|
||||
insert_y=insert.dxf.insert[1],
|
||||
insert_z=insert.dxf.insert[2] if len(insert.dxf.insert) > 2 else 0.0
|
||||
)
|
||||
attributes.append(attr_info)
|
||||
self.logger.debug(f"get_attrib_text 추출: tag='{tag}', text='{text_value.strip()}'")
|
||||
except:
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"get_attrib_text 처리 중 오류: {e}")
|
||||
|
||||
# BlockInfo 생성
|
||||
if attributes or True: # 속성이 없어도 블록 정보는 수집
|
||||
block_info = BlockInfo(
|
||||
name=insert.dxf.name,
|
||||
position=insert.dxf.insert,
|
||||
scale=(insert.dxf.xscale, insert.dxf.yscale, insert.dxf.zscale),
|
||||
rotation=insert.dxf.rotation,
|
||||
layer=insert.dxf.layer,
|
||||
attributes=attributes
|
||||
)
|
||||
|
||||
self.logger.info(f"INSERT '{insert.dxf.name}' 처리 완료: {len(attributes)}개 속성")
|
||||
return block_info
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"INSERT 엔티티 처리 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def _extract_attrib_info(self, attrib: Attrib) -> Optional[AttributeInfo]:
|
||||
"""ATTRIB 엔티티에서 속성 정보 추출"""
|
||||
try:
|
||||
# 텍스트가 비어있으면 건너뛰기
|
||||
text_value = attrib.dxf.text.strip()
|
||||
if not text_value:
|
||||
return None
|
||||
|
||||
attr_info = AttributeInfo(
|
||||
tag=attrib.dxf.tag,
|
||||
text=text_value,
|
||||
position=attrib.dxf.insert,
|
||||
height=getattr(attrib.dxf, 'height', 12.0),
|
||||
rotation=getattr(attrib.dxf, 'rotation', 0.0),
|
||||
layer=getattr(attrib.dxf, 'layer', '0'),
|
||||
style=getattr(attrib.dxf, 'style', None),
|
||||
invisible=getattr(attrib, 'is_invisible', False),
|
||||
const=getattr(attrib, 'is_const', False),
|
||||
entity_handle=attrib.dxf.handle,
|
||||
insert_x=attrib.dxf.insert[0],
|
||||
insert_y=attrib.dxf.insert[1],
|
||||
insert_z=attrib.dxf.insert[2] if len(attrib.dxf.insert) > 2 else 0.0
|
||||
)
|
||||
|
||||
return attr_info
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"ATTRIB 정보 추출 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def _extract_attdef_info(self, attdef: AttDef, insert: Insert) -> Optional[AttributeInfo]:
|
||||
"""ATTDEF 엔티티에서 속성 정보 추출"""
|
||||
try:
|
||||
# 텍스트가 비어있으면 건너뛰기
|
||||
text_value = attdef.dxf.text.strip()
|
||||
if not text_value:
|
||||
return None
|
||||
|
||||
# INSERT의 위치를 기준으로 실제 위치 계산
|
||||
actual_position = (
|
||||
insert.dxf.insert[0] + attdef.dxf.insert[0] * insert.dxf.xscale,
|
||||
insert.dxf.insert[1] + attdef.dxf.insert[1] * insert.dxf.yscale,
|
||||
insert.dxf.insert[2] + (attdef.dxf.insert[2] if len(attdef.dxf.insert) > 2 else 0.0)
|
||||
)
|
||||
|
||||
attr_info = AttributeInfo(
|
||||
tag=attdef.dxf.tag,
|
||||
text=text_value,
|
||||
position=actual_position,
|
||||
height=getattr(attdef.dxf, 'height', 12.0),
|
||||
rotation=getattr(attdef.dxf, 'rotation', 0.0) + insert.dxf.rotation,
|
||||
layer=getattr(attdef.dxf, 'layer', insert.dxf.layer),
|
||||
prompt=getattr(attdef.dxf, 'prompt', None),
|
||||
style=getattr(attdef.dxf, 'style', None),
|
||||
invisible=getattr(attdef, 'is_invisible', False),
|
||||
const=getattr(attdef, 'is_const', False),
|
||||
entity_handle=attdef.dxf.handle,
|
||||
insert_x=actual_position[0],
|
||||
insert_y=actual_position[1],
|
||||
insert_z=actual_position[2]
|
||||
)
|
||||
|
||||
return attr_info
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"ATTDEF 정보 추출 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def _extract_text_as_attribute(self, text_entity, insert: Insert) -> Optional[AttributeInfo]:
|
||||
"""TEXT/MTEXT 엔티티를 속성으로 추출"""
|
||||
try:
|
||||
# 텍스트 내용 추출
|
||||
if hasattr(text_entity, 'text'):
|
||||
text_value = text_entity.text.strip()
|
||||
elif hasattr(text_entity.dxf, 'text'):
|
||||
text_value = text_entity.dxf.text.strip()
|
||||
else:
|
||||
return None
|
||||
|
||||
if not text_value:
|
||||
return None
|
||||
|
||||
# INSERT의 위치를 기준으로 실제 위치 계산
|
||||
text_pos = text_entity.dxf.insert
|
||||
actual_position = (
|
||||
insert.dxf.insert[0] + text_pos[0] * insert.dxf.xscale,
|
||||
insert.dxf.insert[1] + text_pos[1] * insert.dxf.yscale,
|
||||
insert.dxf.insert[2] + (text_pos[2] if len(text_pos) > 2 else 0.0)
|
||||
)
|
||||
|
||||
# 태그는 텍스트 내용의 첫 단어나 전체 내용으로 설정
|
||||
tag = text_value.split()[0] if ' ' in text_value else text_value[:20]
|
||||
|
||||
attr_info = AttributeInfo(
|
||||
tag=f"TEXT_{tag}",
|
||||
text=text_value,
|
||||
position=actual_position,
|
||||
height=getattr(text_entity.dxf, 'height', 12.0),
|
||||
rotation=getattr(text_entity.dxf, 'rotation', 0.0) + insert.dxf.rotation,
|
||||
layer=getattr(text_entity.dxf, 'layer', insert.dxf.layer),
|
||||
style=getattr(text_entity.dxf, 'style', None),
|
||||
entity_handle=text_entity.dxf.handle,
|
||||
insert_x=actual_position[0],
|
||||
insert_y=actual_position[1],
|
||||
insert_z=actual_position[2]
|
||||
)
|
||||
|
||||
return attr_info
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"TEXT 엔티티 추출 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def identify_title_block(self, block_refs: List[BlockInfo]) -> Optional[TitleBlockInfo]:
|
||||
"""도곽 블록 식별 및 정보 추출"""
|
||||
if not block_refs:
|
||||
return None
|
||||
|
||||
title_block_candidates = []
|
||||
|
||||
# 각 블록 참조에 대해 도곽 가능성 점수 계산
|
||||
for block_ref in block_refs:
|
||||
score = self._calculate_title_block_score(block_ref)
|
||||
if score > 0:
|
||||
title_block_candidates.append((block_ref, score))
|
||||
|
||||
if not title_block_candidates:
|
||||
self.logger.warning("도곽 블록을 찾을 수 없습니다.")
|
||||
return None
|
||||
|
||||
# 가장 높은 점수의 블록을 도곽으로 선택
|
||||
title_block_candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
best_candidate = title_block_candidates[0][0]
|
||||
|
||||
self.logger.info(f"도곽 블록 식별: '{best_candidate.name}' (점수: {title_block_candidates[0][1]})")
|
||||
|
||||
# TitleBlockInfo 생성
|
||||
title_block = TitleBlockInfo(
|
||||
block_name=best_candidate.name,
|
||||
all_attributes=best_candidate.attributes
|
||||
)
|
||||
|
||||
# 속성들을 분류하여 해당 필드에 할당
|
||||
for attr in best_candidate.attributes:
|
||||
self._assign_attribute_to_field(title_block, attr)
|
||||
|
||||
title_block.attributes_count = len(best_candidate.attributes)
|
||||
|
||||
return title_block
|
||||
|
||||
def _calculate_title_block_score(self, block_ref: BlockInfo) -> int:
|
||||
"""블록이 도곽일 가능성 점수 계산"""
|
||||
score = 0
|
||||
|
||||
# 블록 이름에 도곽 관련 키워드가 있는지 확인
|
||||
name_lower = block_ref.name.lower()
|
||||
title_keywords = ['title', 'titleblock', 'title_block', '도곽', '타이틀', 'border', 'frame']
|
||||
|
||||
for keyword in title_keywords:
|
||||
if keyword in name_lower:
|
||||
score += 10
|
||||
break
|
||||
|
||||
# 속성 개수 (도곽은 보통 많은 속성을 가짐)
|
||||
if len(block_ref.attributes) >= 5:
|
||||
score += 5
|
||||
elif len(block_ref.attributes) >= 3:
|
||||
score += 3
|
||||
elif len(block_ref.attributes) >= 1:
|
||||
score += 1
|
||||
|
||||
# 도곽 관련 속성이 있는지 확인
|
||||
for attr in block_ref.attributes:
|
||||
for field_name, keywords in self.title_block_keywords.items():
|
||||
if self._contains_any_keyword(attr.tag.lower(), keywords) or \
|
||||
self._contains_any_keyword(attr.text.lower(), keywords):
|
||||
score += 2
|
||||
|
||||
return score
|
||||
|
||||
def _contains_any_keyword(self, text: str, keywords: List[str]) -> bool:
|
||||
"""텍스트에 키워드 중 하나라도 포함되어 있는지 확인"""
|
||||
text_lower = text.lower()
|
||||
return any(keyword.lower() in text_lower for keyword in keywords)
|
||||
|
||||
def _assign_attribute_to_field(self, title_block: TitleBlockInfo, attr: AttributeInfo):
|
||||
"""속성을 도곽 정보의 해당 필드에 할당"""
|
||||
attr_text = attr.text.strip()
|
||||
|
||||
if not attr_text:
|
||||
return
|
||||
|
||||
# 태그나 텍스트에서 키워드 매칭
|
||||
attr_tag_lower = attr.tag.lower()
|
||||
attr_text_lower = attr_text.lower()
|
||||
|
||||
# 각 필드별로 키워드 매칭
|
||||
for field_name, keywords in self.title_block_keywords.items():
|
||||
if self._contains_any_keyword(attr_tag_lower, keywords) or \
|
||||
self._contains_any_keyword(attr_text_lower, keywords):
|
||||
|
||||
if field_name == '도면명' and not title_block.drawing_name:
|
||||
title_block.drawing_name = attr_text
|
||||
elif field_name == '도면번호' and not title_block.drawing_number:
|
||||
title_block.drawing_number = attr_text
|
||||
elif field_name == '건설분야' and not title_block.construction_field:
|
||||
title_block.construction_field = attr_text
|
||||
elif field_name == '건설단계' and not title_block.construction_stage:
|
||||
title_block.construction_stage = attr_text
|
||||
elif field_name == '축척' and not title_block.scale:
|
||||
title_block.scale = attr_text
|
||||
elif field_name == '설계자' and not title_block.designer:
|
||||
title_block.designer = attr_text
|
||||
elif field_name == '프로젝트' and not title_block.project_name:
|
||||
title_block.project_name = attr_text
|
||||
elif field_name == '날짜' and not title_block.date:
|
||||
title_block.date = attr_text
|
||||
elif field_name == '리비전' and not title_block.revision:
|
||||
title_block.revision = attr_text
|
||||
elif field_name == '위치' and not title_block.location:
|
||||
title_block.location = attr_text
|
||||
break
|
||||
|
||||
def process_dxf_file_comprehensive(self, file_path: str) -> Dict[str, Any]:
|
||||
"""DXF 파일 종합적인 처리 - 수정된 버전"""
|
||||
result = {
|
||||
'success': False,
|
||||
'error': None,
|
||||
'file_path': file_path,
|
||||
'title_block': None,
|
||||
'block_references': [],
|
||||
'summary': {}
|
||||
}
|
||||
|
||||
try:
|
||||
# 파일 유효성 검사
|
||||
if not self.validate_dxf_file(file_path):
|
||||
result['error'] = "유효하지 않은 DXF 파일입니다."
|
||||
return result
|
||||
|
||||
# DXF 문서 로드
|
||||
doc = self.load_dxf_document(file_path)
|
||||
if not doc:
|
||||
result['error'] = "DXF 문서를 로드할 수 없습니다."
|
||||
return result
|
||||
|
||||
# 모든 INSERT 엔티티에서 속성 추출
|
||||
self.logger.info("INSERT 엔티티 속성 추출 중...")
|
||||
block_references = self.extract_all_insert_attributes(doc)
|
||||
|
||||
# 도곽 정보 추출
|
||||
self.logger.info("도곽 정보 추출 중...")
|
||||
title_block = self.identify_title_block(block_references)
|
||||
|
||||
# 요약 정보 생성
|
||||
total_attributes = sum(len(br.attributes) for br in block_references)
|
||||
non_empty_attributes = sum(len([attr for attr in br.attributes if attr.text.strip()])
|
||||
for br in block_references)
|
||||
|
||||
summary = {
|
||||
'total_blocks': len(block_references),
|
||||
'title_block_found': title_block is not None,
|
||||
'title_block_name': title_block.block_name if title_block else None,
|
||||
'attributes_count': title_block.attributes_count if title_block else 0,
|
||||
'total_attributes': total_attributes,
|
||||
'non_empty_attributes': non_empty_attributes
|
||||
}
|
||||
|
||||
# 결과 설정
|
||||
result['title_block'] = asdict(title_block) if title_block else None
|
||||
result['block_references'] = [asdict(br) for br in block_references]
|
||||
result['summary'] = summary
|
||||
result['success'] = True
|
||||
|
||||
self.logger.info(f"DXF 파일 처리 완료: {file_path}")
|
||||
self.logger.info(f"처리 요약: 블록 {len(block_references)}개, 속성 {total_attributes}개 (비어있지 않은 속성: {non_empty_attributes}개)")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
|
||||
result['error'] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 기존 클래스명과의 호환성을 위한 별칭
|
||||
DXFProcessor = FixedDXFProcessor
|
||||
|
||||
|
||||
def main():
|
||||
"""테스트용 메인 함수"""
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
if not EZDXF_AVAILABLE:
|
||||
print("ezdxf 라이브러리가 설치되지 않았습니다.")
|
||||
return
|
||||
|
||||
processor = FixedDXFProcessor()
|
||||
|
||||
# 업로드 폴더에서 DXF 파일 찾기
|
||||
upload_dir = "uploads"
|
||||
if os.path.exists(upload_dir):
|
||||
for file in os.listdir(upload_dir):
|
||||
if file.lower().endswith('.dxf'):
|
||||
test_file = os.path.join(upload_dir, file)
|
||||
print(f"\n테스트 파일: {test_file}")
|
||||
|
||||
result = processor.process_dxf_file_comprehensive(test_file)
|
||||
|
||||
if result['success']:
|
||||
print("✅ DXF 파일 처리 성공!")
|
||||
summary = result['summary']
|
||||
print(f" 블록 수: {summary['total_blocks']}")
|
||||
print(f" 도곽 발견: {summary['title_block_found']}")
|
||||
print(f" 도곽 속성 수: {summary['attributes_count']}")
|
||||
print(f" 전체 속성 수: {summary['total_attributes']}")
|
||||
print(f" 비어있지 않은 속성 수: {summary['non_empty_attributes']}")
|
||||
|
||||
if summary['title_block_name']:
|
||||
print(f" 도곽 블록명: {summary['title_block_name']}")
|
||||
else:
|
||||
print(f"❌ 처리 실패: {result['error']}")
|
||||
|
||||
break
|
||||
else:
|
||||
print("uploads 폴더를 찾을 수 없습니다.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
209
gemini_analyzer.py
Normal file
209
gemini_analyzer.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Gemini API 연동 모듈 (좌표 추출 기능 추가)
|
||||
Google Gemini API를 사용하여 이미지와 텍스트 좌표를 함께 분석합니다.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import json
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from config import Config
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- 새로운 스키마 정의 ---
|
||||
|
||||
# 좌표를 포함하는 값을 위한 재사용 가능한 스키마
|
||||
ValueWithCoords = types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"value": types.Schema(type=types.Type.STRING, description="추출된 텍스트 값"),
|
||||
"x": types.Schema(type=types.Type.NUMBER, description="텍스트의 시작 x 좌표"),
|
||||
"y": types.Schema(type=types.Type.NUMBER, description="텍스트의 시작 y 좌표"),
|
||||
},
|
||||
required=["value", "x", "y"]
|
||||
)
|
||||
|
||||
# 모든 필드가 ValueWithCoords를 사용하도록 스키마 업데이트
|
||||
SCHEMA_EXPRESSWAY = types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"사업명": ValueWithCoords,
|
||||
"시설_공구": ValueWithCoords,
|
||||
"노선이정": ValueWithCoords,
|
||||
"설계사": ValueWithCoords,
|
||||
"시공사": ValueWithCoords,
|
||||
"건설분야": ValueWithCoords,
|
||||
"건설단계": ValueWithCoords,
|
||||
"계정번호": ValueWithCoords,
|
||||
"계정날짜": ValueWithCoords,
|
||||
"개정내용": ValueWithCoords,
|
||||
"작성자": ValueWithCoords,
|
||||
"검토자": ValueWithCoords,
|
||||
"확인자": ValueWithCoords,
|
||||
"설계공구_Station": ValueWithCoords,
|
||||
"시공공구_Station": ValueWithCoords,
|
||||
"도면번호": ValueWithCoords,
|
||||
"도면축척": ValueWithCoords,
|
||||
"도면명": ValueWithCoords,
|
||||
"편철번호": ValueWithCoords,
|
||||
"적용표준버전": ValueWithCoords,
|
||||
"Note": ValueWithCoords,
|
||||
"Title": ValueWithCoords,
|
||||
"기타정보": ValueWithCoords,
|
||||
},
|
||||
)
|
||||
|
||||
SCHEMA_TRANSPORTATION = types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"사업명": ValueWithCoords,
|
||||
"시설_공구": ValueWithCoords,
|
||||
"건설분야": ValueWithCoords,
|
||||
"건설단계": ValueWithCoords,
|
||||
"계정차수": ValueWithCoords,
|
||||
"계정일자": ValueWithCoords,
|
||||
"개정내용": ValueWithCoords,
|
||||
"과업책임자": ValueWithCoords,
|
||||
"분야별책임자": ValueWithCoords,
|
||||
"설계자": ValueWithCoords,
|
||||
"위치정보": ValueWithCoords,
|
||||
"축척": ValueWithCoords,
|
||||
"도면번호": ValueWithCoords,
|
||||
"도면명": ValueWithCoords,
|
||||
"편철번호": ValueWithCoords,
|
||||
"적용표준": ValueWithCoords,
|
||||
"Note": ValueWithCoords,
|
||||
"Title": ValueWithCoords,
|
||||
"기타정보": ValueWithCoords,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class GeminiAnalyzer:
|
||||
"""Gemini API 이미지 및 텍스트 분석 클래스"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
|
||||
self.api_key = api_key or Config.GEMINI_API_KEY
|
||||
self.model = model or Config.GEMINI_MODEL
|
||||
self.default_prompt = Config.DEFAULT_PROMPT
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("Gemini API 키가 설정되지 않았습니다.")
|
||||
|
||||
try:
|
||||
self.client = genai.Client(api_key=self.api_key)
|
||||
logger.info(f"Gemini 클라이언트 초기화 완료 (모델: {self.model})")
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 클라이언트 초기화 실패: {e}")
|
||||
raise
|
||||
|
||||
def _get_schema(self, organization_type: str) -> types.Schema:
|
||||
"""조직 유형에 따른 스키마를 반환합니다."""
|
||||
return SCHEMA_EXPRESSWAY if organization_type == "한국도로공사" else SCHEMA_TRANSPORTATION
|
||||
|
||||
def analyze_pdf_page(
|
||||
self,
|
||||
base64_data: str,
|
||||
text_blocks: List[Dict[str, Any]],
|
||||
prompt: Optional[str] = None,
|
||||
mime_type: str = "image/png",
|
||||
organization_type: str = "transportation"
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Base64 이미지와 추출된 텍스트 좌표를 함께 분석합니다.
|
||||
|
||||
Args:
|
||||
base64_data: Base64로 인코딩된 이미지 데이터.
|
||||
text_blocks: PDF에서 추출된 텍스트와 좌표 정보 리스트.
|
||||
prompt: 분석 요청 텍스트 (None인 경우 기본값 사용).
|
||||
mime_type: 이미지 MIME 타입.
|
||||
organization_type: 조직 유형 ("transportation" 또는 "expressway").
|
||||
|
||||
Returns:
|
||||
분석 결과 JSON 문자열 또는 None (실패 시).
|
||||
"""
|
||||
try:
|
||||
# 텍스트 블록 정보를 JSON 문자열로 변환하여 프롬프트에 추가
|
||||
text_context = "\n".join([
|
||||
f"- text: '{block['text']}', bbox: ({block['bbox'][0]:.0f}, {block['bbox'][1]:.0f})"
|
||||
for block in text_blocks
|
||||
])
|
||||
|
||||
analysis_prompt = (
|
||||
(prompt or self.default_prompt) +
|
||||
"\n\n--- 추출된 텍스트와 좌표 정보 ---\n" +
|
||||
text_context +
|
||||
"\n\n--- 지시사항 ---\n"
|
||||
"위 텍스트와 좌표 정보를 바탕으로, 이미지의 내용을 분석하여 JSON 스키마를 채워주세요."
|
||||
"각 필드에 해당하는 텍스트를 찾고, 해당 텍스트의 'value'와 시작 'x', 'y' 좌표를 JSON에 기입하세요."
|
||||
)
|
||||
|
||||
contents = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_bytes(
|
||||
mime_type=mime_type,
|
||||
data=base64.b64decode(base64_data),
|
||||
),
|
||||
types.Part.from_text(text=analysis_prompt),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
selected_schema = self._get_schema(organization_type)
|
||||
|
||||
generate_content_config = types.GenerateContentConfig(
|
||||
temperature=0,
|
||||
top_p=0.05,
|
||||
response_mime_type="application/json",
|
||||
response_schema=selected_schema
|
||||
)
|
||||
|
||||
logger.info("Gemini API 분석 요청 시작 (텍스트 좌표 포함)...")
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model=self.model,
|
||||
contents=contents,
|
||||
config=generate_content_config,
|
||||
)
|
||||
|
||||
if response and hasattr(response, 'text'):
|
||||
result = response.text
|
||||
# JSON 응답을 파싱하여 다시 직렬화 (일관된 포맷팅)
|
||||
parsed_json = json.loads(result)
|
||||
pretty_result = json.dumps(parsed_json, ensure_ascii=False, indent=2)
|
||||
logger.info(f"분석 완료: {len(pretty_result)} 문자")
|
||||
return pretty_result
|
||||
else:
|
||||
logger.error("API 응답에서 텍스트를 찾을 수 없습니다.")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이미지 및 텍스트 분석 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
# --- 기존 다른 메서드들은 필요에 따라 수정 또는 유지 ---
|
||||
# analyze_image_stream_from_base64, analyze_pdf_images 등은
|
||||
# 새로운 analyze_pdf_page 메서드와 호환되도록 수정 필요.
|
||||
# 지금은 핵심 기능에 집중.
|
||||
|
||||
def validate_api_connection(self) -> bool:
|
||||
"""API 연결 상태를 확인합니다."""
|
||||
try:
|
||||
test_response = self.client.models.generate_content("안녕하세요")
|
||||
if test_response and hasattr(test_response, 'text'):
|
||||
logger.info("Gemini API 연결 테스트 성공")
|
||||
return True
|
||||
else:
|
||||
logger.error("Gemini API 연결 테스트 실패")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini API 연결 테스트 중 오류: {e}")
|
||||
return False
|
||||
653
multi_file_main.py
Normal file
653
multi_file_main.py
Normal file
@@ -0,0 +1,653 @@
|
||||
"""
|
||||
다중 파일 처리 애플리케이션 클래스
|
||||
여러 PDF/DXF 파일을 배치로 처리하고 결과를 CSV로 저장하는 기능을 제공합니다.
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-14
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import flet as ft
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
import time
|
||||
|
||||
# 프로젝트 모듈 임포트
|
||||
from config import Config
|
||||
from multi_file_processor import MultiFileProcessor, BatchProcessingConfig, generate_default_csv_filename
|
||||
from ui_components import MultiFileUIComponents
|
||||
from utils import DateTimeUtils
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MultiFileApp:
|
||||
"""다중 파일 처리 애플리케이션 클래스"""
|
||||
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self.selected_files = []
|
||||
self.processing_results = []
|
||||
self.is_processing = False
|
||||
self.is_cancelled = False
|
||||
self.is_paused = False
|
||||
self.processor = None
|
||||
|
||||
# UI 컴포넌트 참조
|
||||
self.file_picker = None
|
||||
self.files_container = None
|
||||
self.clear_files_button = None
|
||||
self.batch_analysis_button = None
|
||||
|
||||
# 배치 설정 컴포넌트
|
||||
self.organization_selector = None
|
||||
self.concurrent_files_slider = None
|
||||
self.enable_batch_mode = None
|
||||
self.save_intermediate_results = None
|
||||
self.include_error_files = None
|
||||
self.csv_output_path = None
|
||||
self.browse_button = None
|
||||
|
||||
# 진행률 컴포넌트
|
||||
self.overall_progress_bar = None
|
||||
self.progress_text = None
|
||||
self.current_status_text = None
|
||||
self.timing_info = None
|
||||
self.log_container = None
|
||||
self.cancel_button = None
|
||||
self.pause_resume_button = None
|
||||
|
||||
# 결과 컴포넌트
|
||||
self.summary_stats = None
|
||||
self.results_table = None
|
||||
self.save_csv_button = None
|
||||
self.save_cross_csv_button = None # 새로 추가
|
||||
self.save_excel_button = None
|
||||
self.clear_results_button = None
|
||||
|
||||
# 시간 추적
|
||||
self.start_time = None
|
||||
|
||||
# Gemini API 키 확인
|
||||
self.init_processor()
|
||||
|
||||
def init_processor(self):
|
||||
"""다중 파일 처리기 초기화"""
|
||||
try:
|
||||
config_errors = Config.validate_config()
|
||||
if config_errors:
|
||||
logger.error(f"설정 오류: {config_errors}")
|
||||
return
|
||||
|
||||
# Gemini API 키가 있는지 확인
|
||||
gemini_api_key = Config.get_gemini_api_key()
|
||||
if not gemini_api_key:
|
||||
logger.error("Gemini API 키가 설정되지 않았습니다")
|
||||
return
|
||||
|
||||
self.processor = MultiFileProcessor(gemini_api_key)
|
||||
logger.info("다중 파일 처리기 초기화 완료")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"다중 파일 처리기 초기화 실패: {e}")
|
||||
|
||||
def build_ui(self) -> ft.Column:
|
||||
"""다중 파일 처리 UI 구성"""
|
||||
|
||||
# 파일 업로드 섹션
|
||||
upload_section = self.create_file_upload_section()
|
||||
|
||||
# 배치 설정 섹션
|
||||
settings_section = self.create_batch_settings_section()
|
||||
|
||||
# 진행률 섹션
|
||||
progress_section = self.create_progress_section()
|
||||
|
||||
# 결과 섹션
|
||||
results_section = self.create_results_section()
|
||||
|
||||
# 좌측 컨트롤 패널
|
||||
left_panel = ft.Container(
|
||||
content=ft.Column([
|
||||
upload_section,
|
||||
ft.Divider(height=10),
|
||||
settings_section,
|
||||
ft.Divider(height=10),
|
||||
progress_section,
|
||||
], scroll=ft.ScrollMode.AUTO),
|
||||
col={"sm": 12, "md": 5, "lg": 4},
|
||||
padding=10,
|
||||
)
|
||||
|
||||
# 우측 결과 패널
|
||||
right_panel = ft.Container(
|
||||
content=results_section,
|
||||
col={"sm": 12, "md": 7, "lg": 8},
|
||||
padding=10,
|
||||
)
|
||||
|
||||
# ResponsiveRow를 사용한 좌우 분할 레이아웃
|
||||
main_layout = ft.ResponsiveRow([left_panel, right_panel])
|
||||
|
||||
return ft.Column([
|
||||
main_layout
|
||||
], expand=True, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def create_file_upload_section(self) -> ft.Container:
|
||||
"""파일 업로드 섹션 생성"""
|
||||
upload_container = MultiFileUIComponents.create_multi_file_upload_section(
|
||||
on_files_selected=self.on_files_selected,
|
||||
on_batch_analysis_click=self.on_batch_analysis_click,
|
||||
on_clear_files_click=self.on_clear_files_click
|
||||
)
|
||||
|
||||
# 참조 저장
|
||||
self.file_picker = upload_container.content.controls[-1] # 마지막 요소가 file_picker
|
||||
self.files_container = upload_container.content.controls[4] # files_container
|
||||
self.clear_files_button = upload_container.content.controls[2].controls[1]
|
||||
self.batch_analysis_button = upload_container.content.controls[2].controls[2]
|
||||
|
||||
# overlay에 추가
|
||||
self.page.overlay.append(self.file_picker)
|
||||
|
||||
return upload_container
|
||||
|
||||
def create_batch_settings_section(self) -> ft.Container:
|
||||
"""배치 설정 섹션 생성"""
|
||||
(
|
||||
container,
|
||||
self.organization_selector,
|
||||
self.concurrent_files_slider,
|
||||
self.enable_batch_mode,
|
||||
self.save_intermediate_results,
|
||||
self.include_error_files,
|
||||
self.csv_output_path,
|
||||
self.browse_button
|
||||
) = MultiFileUIComponents.create_batch_settings_section()
|
||||
|
||||
# 슬라이더 변경 이벤트 처리
|
||||
self.concurrent_files_slider.on_change = self.on_concurrent_files_change
|
||||
|
||||
# 경로 선택 버튼 이벤트 처리
|
||||
self.browse_button.on_click = self.on_browse_csv_path_click
|
||||
|
||||
return container
|
||||
|
||||
def create_progress_section(self) -> ft.Container:
|
||||
"""진행률 섹션 생성"""
|
||||
(
|
||||
container,
|
||||
self.overall_progress_bar,
|
||||
self.progress_text,
|
||||
self.current_status_text,
|
||||
self.timing_info,
|
||||
self.log_container,
|
||||
self.cancel_button,
|
||||
self.pause_resume_button
|
||||
) = MultiFileUIComponents.create_batch_progress_section()
|
||||
|
||||
# 버튼 이벤트 처리
|
||||
self.cancel_button.on_click = self.on_cancel_click
|
||||
self.pause_resume_button.on_click = self.on_pause_resume_click
|
||||
|
||||
return container
|
||||
|
||||
def create_results_section(self) -> ft.Container:
|
||||
"""결과 섹션 생성"""
|
||||
(
|
||||
container,
|
||||
self.summary_stats,
|
||||
self.results_table,
|
||||
self.save_csv_button,
|
||||
self.save_cross_csv_button, # 새로 추가
|
||||
self.save_excel_button,
|
||||
self.clear_results_button
|
||||
) = MultiFileUIComponents.create_batch_results_section()
|
||||
|
||||
# 버튼 이벤트 처리
|
||||
self.save_csv_button.on_click = self.on_save_csv_click
|
||||
self.save_cross_csv_button.on_click = self.on_save_cross_csv_click # 새로 추가
|
||||
self.save_excel_button.on_click = self.on_save_excel_click
|
||||
self.clear_results_button.on_click = self.on_clear_results_click
|
||||
|
||||
return container
|
||||
|
||||
# 이벤트 핸들러들
|
||||
|
||||
def on_files_selected(self, e: ft.FilePickerResultEvent):
|
||||
"""파일 선택 이벤트 핸들러"""
|
||||
if e.files:
|
||||
# 선택된 파일들을 기존 목록에 추가 (중복 제거)
|
||||
existing_paths = {f.path for f in self.selected_files}
|
||||
new_files = [f for f in e.files if f.path not in existing_paths]
|
||||
|
||||
if new_files:
|
||||
self.selected_files.extend(new_files)
|
||||
MultiFileUIComponents.update_selected_files_list(
|
||||
self.files_container,
|
||||
self.selected_files,
|
||||
self.clear_files_button,
|
||||
self.batch_analysis_button
|
||||
)
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"{len(new_files)}개 파일 추가됨 (총 {len(self.selected_files)}개)",
|
||||
"info"
|
||||
)
|
||||
else:
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
"선택된 파일들이 이미 목록에 있습니다",
|
||||
"warning"
|
||||
)
|
||||
else:
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
"파일 선택이 취소되었습니다",
|
||||
"info"
|
||||
)
|
||||
|
||||
self.page.update()
|
||||
|
||||
def on_clear_files_click(self, e):
|
||||
"""파일 목록 지우기 이벤트 핸들러"""
|
||||
file_count = len(self.selected_files)
|
||||
self.selected_files.clear()
|
||||
|
||||
MultiFileUIComponents.update_selected_files_list(
|
||||
self.files_container,
|
||||
self.selected_files,
|
||||
self.clear_files_button,
|
||||
self.batch_analysis_button
|
||||
)
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"{file_count}개 파일 목록 초기화",
|
||||
"info"
|
||||
)
|
||||
|
||||
self.page.update()
|
||||
|
||||
def on_batch_analysis_click(self, e):
|
||||
"""배치 분석 시작 이벤트 핸들러"""
|
||||
if not self.selected_files:
|
||||
return
|
||||
|
||||
if self.is_processing:
|
||||
return
|
||||
|
||||
if not self.processor:
|
||||
self.show_error_dialog("처리기 오류", "다중 파일 처리기가 초기화되지 않았습니다.")
|
||||
return
|
||||
|
||||
# 비동기 처리 시작
|
||||
self.page.run_task(self.start_batch_processing)
|
||||
|
||||
def on_concurrent_files_change(self, e):
|
||||
"""동시 처리 수 변경 이벤트 핸들러"""
|
||||
value = int(e.control.value)
|
||||
# 슬라이더 레이블 업데이트
|
||||
# 상위 Container에서 텍스트 찾아서 업데이트
|
||||
try:
|
||||
settings_container = e.control.parent.parent # Column -> Container
|
||||
text_control = settings_container.content.controls[2].controls[1].controls[0] # 해당 텍스트
|
||||
text_control.value = f"동시 처리 수: {value}개"
|
||||
self.page.update()
|
||||
except:
|
||||
logger.warning("슬라이더 레이블 업데이트 실패")
|
||||
|
||||
def on_browse_csv_path_click(self, e):
|
||||
"""CSV 저장 경로 선택 이벤트 핸들러"""
|
||||
# 간단한 구현 - 현재 디렉토리 + 자동 생성 파일명 설정
|
||||
default_filename = generate_default_csv_filename()
|
||||
default_path = os.path.join(os.getcwd(), "results", default_filename)
|
||||
|
||||
self.csv_output_path.value = default_path
|
||||
self.page.update()
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"CSV 저장 경로 설정: {default_filename}",
|
||||
"info"
|
||||
)
|
||||
|
||||
def on_cancel_click(self, e):
|
||||
"""처리 취소 이벤트 핸들러"""
|
||||
if self.is_processing:
|
||||
self.is_cancelled = True
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
"사용자가 처리를 취소했습니다",
|
||||
"warning"
|
||||
)
|
||||
self.page.update()
|
||||
|
||||
def on_pause_resume_click(self, e):
|
||||
"""일시정지/재개 이벤트 핸들러"""
|
||||
# 현재 구현에서는 단순한 토글 기능만 제공
|
||||
if self.is_processing:
|
||||
self.is_paused = not self.is_paused
|
||||
if self.is_paused:
|
||||
self.pause_resume_button.text = "재개"
|
||||
self.pause_resume_button.icon = ft.Icons.PLAY_ARROW
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
"처리가 일시정지되었습니다",
|
||||
"warning"
|
||||
)
|
||||
else:
|
||||
self.pause_resume_button.text = "일시정지"
|
||||
self.pause_resume_button.icon = ft.Icons.PAUSE
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
"처리가 재개되었습니다",
|
||||
"success"
|
||||
)
|
||||
self.page.update()
|
||||
|
||||
def on_save_csv_click(self, e):
|
||||
"""CSV 저장 이벤트 핸들러"""
|
||||
if not self.processing_results:
|
||||
self.show_error_dialog("저장 오류", "저장할 결과가 없습니다.")
|
||||
return
|
||||
|
||||
self.page.run_task(self.save_results_to_csv)
|
||||
|
||||
def on_save_cross_csv_click(self, e):
|
||||
"""Cross-Tabulated CSV 저장 이벤트 핸들러 (새로 추가)"""
|
||||
if not self.processing_results:
|
||||
self.show_error_dialog("저장 오류", "저장할 결과가 없습니다.")
|
||||
return
|
||||
|
||||
self.page.run_task(self.save_cross_tabulated_csv)
|
||||
|
||||
def on_save_excel_click(self, e):
|
||||
"""Excel 저장 이벤트 핸들러"""
|
||||
if not self.processing_results:
|
||||
self.show_error_dialog("저장 오류", "저장할 결과가 없습니다.")
|
||||
return
|
||||
|
||||
# Excel 저장 기능 (향후 구현)
|
||||
self.show_info_dialog("개발 중", "Excel 저장 기능은 곧 추가될 예정입니다.")
|
||||
|
||||
def on_clear_results_click(self, e):
|
||||
"""결과 초기화 이벤트 핸들러"""
|
||||
self.processing_results.clear()
|
||||
|
||||
MultiFileUIComponents.update_batch_results(
|
||||
self.summary_stats,
|
||||
self.results_table,
|
||||
self.processing_results,
|
||||
self.save_csv_button,
|
||||
self.save_cross_csv_button, # 새로 추가
|
||||
self.save_excel_button,
|
||||
self.clear_results_button
|
||||
)
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
"결과가 초기화되었습니다",
|
||||
"info"
|
||||
)
|
||||
|
||||
self.page.update()
|
||||
|
||||
# 비동기 처리 함수들
|
||||
|
||||
async def start_batch_processing(self):
|
||||
"""배치 처리 시작"""
|
||||
try:
|
||||
self.is_processing = True
|
||||
self.is_cancelled = False
|
||||
self.is_paused = False
|
||||
self.start_time = time.time()
|
||||
|
||||
# UI 상태 업데이트
|
||||
self.batch_analysis_button.disabled = True
|
||||
self.cancel_button.disabled = False
|
||||
self.pause_resume_button.disabled = False
|
||||
|
||||
# 처리 설정 구성
|
||||
config = BatchProcessingConfig(
|
||||
organization_type=self.organization_selector.value,
|
||||
enable_gemini_batch_mode=self.enable_batch_mode.value,
|
||||
max_concurrent_files=int(self.concurrent_files_slider.value),
|
||||
save_intermediate_results=self.save_intermediate_results.value,
|
||||
output_csv_path=self.csv_output_path.value or None,
|
||||
include_error_files=self.include_error_files.value
|
||||
)
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"배치 처리 시작: {len(self.selected_files)}개 파일",
|
||||
"info"
|
||||
)
|
||||
|
||||
# 파일 경로 추출
|
||||
file_paths = [f.path for f in self.selected_files]
|
||||
|
||||
# 진행률 콜백 함수
|
||||
def progress_callback(current: int, total: int, status: str):
|
||||
elapsed_time = time.time() - self.start_time if self.start_time else 0
|
||||
estimated_remaining = (elapsed_time / current * (total - current)) if current > 0 else 0
|
||||
|
||||
MultiFileUIComponents.update_batch_progress(
|
||||
self.overall_progress_bar,
|
||||
self.progress_text,
|
||||
self.current_status_text,
|
||||
self.timing_info,
|
||||
current,
|
||||
total,
|
||||
status,
|
||||
elapsed_time,
|
||||
estimated_remaining
|
||||
)
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
status,
|
||||
"success" if "완료" in status else "info"
|
||||
)
|
||||
|
||||
self.page.update()
|
||||
|
||||
# 처리 실행
|
||||
self.processing_results = await self.processor.process_multiple_files(
|
||||
file_paths, config, progress_callback
|
||||
)
|
||||
|
||||
# 결과 표시
|
||||
MultiFileUIComponents.update_batch_results(
|
||||
self.summary_stats,
|
||||
self.results_table,
|
||||
self.processing_results,
|
||||
self.save_csv_button,
|
||||
self.save_cross_csv_button, # 새로 추가
|
||||
self.save_excel_button,
|
||||
self.clear_results_button
|
||||
)
|
||||
|
||||
# 요약 정보
|
||||
summary = self.processor.get_processing_summary()
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"처리 완료! 성공: {summary['success_files']}, 실패: {summary['failed_files']}, 성공률: {summary['success_rate']}%",
|
||||
"success"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"배치 처리 오류: {e}")
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"처리 오류: {str(e)}",
|
||||
"error"
|
||||
)
|
||||
self.show_error_dialog("처리 오류", f"배치 처리 중 오류가 발생했습니다:\n{str(e)}")
|
||||
|
||||
finally:
|
||||
# UI 상태 복원
|
||||
self.is_processing = False
|
||||
self.batch_analysis_button.disabled = False
|
||||
self.cancel_button.disabled = True
|
||||
self.pause_resume_button.disabled = True
|
||||
self.pause_resume_button.text = "일시정지"
|
||||
self.pause_resume_button.icon = ft.Icons.PAUSE
|
||||
self.page.update()
|
||||
|
||||
async def save_results_to_csv(self):
|
||||
"""결과를 CSV로 저장"""
|
||||
try:
|
||||
if not self.processor:
|
||||
self.show_error_dialog("오류", "처리기가 초기화되지 않았습니다.")
|
||||
return
|
||||
|
||||
# CSV 경로 설정
|
||||
output_path = self.csv_output_path.value
|
||||
if not output_path:
|
||||
# 자동 생성
|
||||
results_dir = os.path.join(os.getcwd(), "results")
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
output_path = os.path.join(results_dir, generate_default_csv_filename())
|
||||
|
||||
# CSV 저장
|
||||
await self.processor.save_results_to_csv(output_path)
|
||||
|
||||
self.show_info_dialog(
|
||||
"저장 완료",
|
||||
f"배치 처리 결과가 CSV 파일로 저장되었습니다:\n\n{output_path}"
|
||||
)
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"CSV 저장 완료: {os.path.basename(output_path)}",
|
||||
"success"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CSV 저장 오류: {e}")
|
||||
self.show_error_dialog("저장 오류", f"CSV 저장 중 오류가 발생했습니다:\n{str(e)}")
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"CSV 저장 실패: {str(e)}",
|
||||
"error"
|
||||
)
|
||||
|
||||
async def save_cross_tabulated_csv(self):
|
||||
"""Cross-tabulated CSV로 저장 (새로 추가)"""
|
||||
try:
|
||||
# Cross-tabulated CSV 내보내기 모듈 임포트
|
||||
from cross_tabulated_csv_exporter import CrossTabulatedCSVExporter, generate_cross_tabulated_csv_filename
|
||||
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# CSV 경로 설정
|
||||
results_dir = os.path.join(os.getcwd(), "results")
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
|
||||
# Cross-tabulated CSV 파일명 생성
|
||||
cross_csv_filename = generate_cross_tabulated_csv_filename("batch_key_value_analysis")
|
||||
output_path = os.path.join(results_dir, cross_csv_filename)
|
||||
|
||||
# Cross-tabulated CSV 저장
|
||||
success = exporter.export_cross_tabulated_csv(
|
||||
self.processing_results,
|
||||
output_path,
|
||||
include_coordinates=True,
|
||||
coordinate_source="auto"
|
||||
)
|
||||
|
||||
if success:
|
||||
self.show_info_dialog(
|
||||
"Key-Value CSV 저장 완료",
|
||||
f"분석 결과가 Key-Value 형태의 CSV 파일로 저장되었습니다:\n\n{output_path}\n\n" +
|
||||
"이 CSV는 다음과 같은 형태로 구성됩니다:\n" +
|
||||
"- file_name: 파일명\n" +
|
||||
"- file_type: 파일 형식\n" +
|
||||
"- key: 속성 키\n" +
|
||||
"- value: 속성 값\n" +
|
||||
"- x, y: 좌표 정보 (가능한 경우)"
|
||||
)
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"Key-Value CSV 저장 완료: {os.path.basename(output_path)}",
|
||||
"success"
|
||||
)
|
||||
else:
|
||||
self.show_error_dialog(
|
||||
"저장 실패",
|
||||
"Key-Value CSV 저장에 실패했습니다. 로그를 확인해주세요."
|
||||
)
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
"Key-Value CSV 저장 실패",
|
||||
"error"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cross-tabulated CSV 저장 오류: {e}")
|
||||
self.show_error_dialog(
|
||||
"저장 오류",
|
||||
f"Key-Value CSV 저장 중 오류가 발생했습니다:\n{str(e)}"
|
||||
)
|
||||
|
||||
MultiFileUIComponents.add_log_message(
|
||||
self.log_container,
|
||||
f"Key-Value CSV 저장 실패: {str(e)}",
|
||||
"error"
|
||||
)
|
||||
|
||||
# 유틸리티 함수들
|
||||
|
||||
def show_error_dialog(self, title: str, message: str):
|
||||
"""오류 다이얼로그 표시"""
|
||||
from ui_components import UIComponents
|
||||
|
||||
dialog = UIComponents.create_error_dialog(title, message)
|
||||
|
||||
def close_dialog(e):
|
||||
dialog.open = False
|
||||
self.page.update()
|
||||
|
||||
dialog.actions[0].on_click = close_dialog
|
||||
self.page.dialog = dialog
|
||||
dialog.open = True
|
||||
self.page.update()
|
||||
|
||||
def show_info_dialog(self, title: str, message: str):
|
||||
"""정보 다이얼로그 표시"""
|
||||
from ui_components import UIComponents
|
||||
|
||||
dialog = UIComponents.create_info_dialog(title, message)
|
||||
|
||||
def close_dialog(e):
|
||||
dialog.open = False
|
||||
self.page.update()
|
||||
|
||||
dialog.actions[0].on_click = close_dialog
|
||||
self.page.dialog = dialog
|
||||
dialog.open = True
|
||||
self.page.update()
|
||||
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
def main_multi_file(page: ft.Page):
|
||||
"""다중 파일 처리 앱 테스트 실행"""
|
||||
page.title = "다중 파일 처리 테스트"
|
||||
page.theme_mode = ft.ThemeMode.LIGHT
|
||||
|
||||
app = MultiFileApp(page)
|
||||
ui = app.build_ui()
|
||||
page.add(ui)
|
||||
|
||||
ft.app(target=main_multi_file)
|
||||
510
multi_file_processor.py
Normal file
510
multi_file_processor.py
Normal file
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
다중 파일 처리 모듈
|
||||
여러 PDF/DXF 파일을 배치로 처리하고 결과를 CSV로 저장하는 기능을 제공합니다.
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-14
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from pdf_processor import PDFProcessor
|
||||
from dxf_processor import EnhancedDXFProcessor
|
||||
from gemini_analyzer import GeminiAnalyzer
|
||||
from csv_exporter import TitleBlockCSVExporter
|
||||
import json # Added import
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileProcessingResult:
|
||||
"""단일 파일 처리 결과"""
|
||||
file_path: str
|
||||
file_name: str
|
||||
file_type: str
|
||||
file_size: int
|
||||
processing_time: float
|
||||
success: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# PDF 분석 결과
|
||||
pdf_analysis_result: Optional[str] = None
|
||||
|
||||
# DXF 분석 결과
|
||||
dxf_title_blocks: Optional[List[Dict]] = None
|
||||
dxf_total_attributes: Optional[int] = None
|
||||
dxf_total_text_entities: Optional[int] = None
|
||||
|
||||
# 공통 메타데이터
|
||||
processed_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchProcessingConfig:
|
||||
"""배치 처리 설정"""
|
||||
organization_type: str = "국토교통부"
|
||||
enable_gemini_batch_mode: bool = False
|
||||
max_concurrent_files: int = 3
|
||||
save_intermediate_results: bool = True
|
||||
output_csv_path: Optional[str] = None
|
||||
include_error_files: bool = True
|
||||
|
||||
|
||||
class MultiFileProcessor:
|
||||
"""다중 파일 처리기"""
|
||||
|
||||
def __init__(self, gemini_api_key: str):
|
||||
"""
|
||||
다중 파일 처리기 초기화
|
||||
|
||||
Args:
|
||||
gemini_api_key: Gemini API 키
|
||||
"""
|
||||
self.gemini_api_key = gemini_api_key
|
||||
self.pdf_processor = PDFProcessor()
|
||||
self.dxf_processor = EnhancedDXFProcessor()
|
||||
self.gemini_analyzer = GeminiAnalyzer(gemini_api_key)
|
||||
self.csv_exporter = TitleBlockCSVExporter() # CSV 내보내기 추가
|
||||
|
||||
self.processing_results: List[FileProcessingResult] = []
|
||||
self.current_progress = 0
|
||||
self.total_files = 0
|
||||
|
||||
async def process_multiple_files(
|
||||
self,
|
||||
file_paths: List[str],
|
||||
config: BatchProcessingConfig,
|
||||
progress_callback: Optional[Callable[[int, int, str], None]] = None
|
||||
) -> List[FileProcessingResult]:
|
||||
"""
|
||||
여러 파일을 배치로 처리
|
||||
|
||||
Args:
|
||||
file_paths: 처리할 파일 경로 리스트
|
||||
config: 배치 처리 설정
|
||||
progress_callback: 진행률 콜백 함수 (current, total, status)
|
||||
|
||||
Returns:
|
||||
처리 결과 리스트
|
||||
"""
|
||||
self.processing_results = []
|
||||
self.total_files = len(file_paths)
|
||||
self.current_progress = 0
|
||||
|
||||
logger.info(f"배치 처리 시작: {self.total_files}개 파일")
|
||||
|
||||
# 동시 처리 제한을 위한 세마포어
|
||||
semaphore = asyncio.Semaphore(config.max_concurrent_files)
|
||||
|
||||
# 각 파일에 대한 처리 태스크 생성
|
||||
tasks = []
|
||||
for i, file_path in enumerate(file_paths):
|
||||
task = self._process_single_file_with_semaphore(
|
||||
semaphore, file_path, config, progress_callback, i + 1
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
# 모든 파일 처리 완료까지 대기
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# 예외 발생한 결과 처리
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
error_result = FileProcessingResult(
|
||||
file_path=file_paths[i],
|
||||
file_name=os.path.basename(file_paths[i]),
|
||||
file_type="unknown",
|
||||
file_size=0,
|
||||
processing_time=0,
|
||||
success=False,
|
||||
error_message=str(result),
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
self.processing_results.append(error_result)
|
||||
|
||||
logger.info(f"배치 처리 완료: {len(self.processing_results)}개 결과")
|
||||
|
||||
# CSV 저장
|
||||
if config.output_csv_path:
|
||||
await self.save_results_to_csv(config.output_csv_path)
|
||||
|
||||
return self.processing_results
|
||||
|
||||
async def _process_single_file_with_semaphore(
|
||||
self,
|
||||
semaphore: asyncio.Semaphore,
|
||||
file_path: str,
|
||||
config: BatchProcessingConfig,
|
||||
progress_callback: Optional[Callable[[int, int, str], None]],
|
||||
file_number: int
|
||||
) -> None:
|
||||
"""세마포어를 사용하여 단일 파일 처리"""
|
||||
async with semaphore:
|
||||
result = await self._process_single_file(file_path, config)
|
||||
self.processing_results.append(result)
|
||||
|
||||
self.current_progress += 1
|
||||
if progress_callback:
|
||||
status = f"처리 완료: {result.file_name}"
|
||||
if not result.success:
|
||||
status = f"처리 실패: {result.file_name} - {result.error_message}"
|
||||
progress_callback(self.current_progress, self.total_files, status)
|
||||
|
||||
async def _process_single_file(
|
||||
self,
|
||||
file_path: str,
|
||||
config: BatchProcessingConfig
|
||||
) -> FileProcessingResult:
|
||||
"""
|
||||
단일 파일 처리
|
||||
|
||||
Args:
|
||||
file_path: 파일 경로
|
||||
config: 처리 설정
|
||||
|
||||
Returns:
|
||||
처리 결과
|
||||
"""
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
try:
|
||||
# 파일 정보 수집
|
||||
file_size = os.path.getsize(file_path)
|
||||
file_type = self._detect_file_type(file_path)
|
||||
|
||||
logger.info(f"파일 처리 시작: {file_name} ({file_type})")
|
||||
|
||||
result = FileProcessingResult(
|
||||
file_path=file_path,
|
||||
file_name=file_name,
|
||||
file_type=file_type,
|
||||
file_size=file_size,
|
||||
processing_time=0,
|
||||
success=False,
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
# 파일 유형에 따른 처리
|
||||
if file_type.lower() == 'pdf':
|
||||
await self._process_pdf_file(file_path, result, config)
|
||||
elif file_type.lower() == 'dxf':
|
||||
await self._process_dxf_file(file_path, result, config)
|
||||
else:
|
||||
raise ValueError(f"지원하지 않는 파일 형식: {file_type}")
|
||||
|
||||
result.success = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 처리 오류 ({file_name}): {str(e)}")
|
||||
result.success = False
|
||||
result.error_message = str(e)
|
||||
|
||||
finally:
|
||||
# 처리 시간 계산
|
||||
end_time = asyncio.get_event_loop().time()
|
||||
result.processing_time = round(end_time - start_time, 2)
|
||||
|
||||
return result
|
||||
|
||||
async def _process_pdf_file(
|
||||
self,
|
||||
file_path: str,
|
||||
result: FileProcessingResult,
|
||||
config: BatchProcessingConfig
|
||||
) -> None:
|
||||
"""PDF 파일 처리"""
|
||||
# PDF 이미지 변환
|
||||
images = self.pdf_processor.convert_to_images(file_path)
|
||||
if not images:
|
||||
raise ValueError("PDF를 이미지로 변환할 수 없습니다")
|
||||
|
||||
# 첫 번째 페이지만 분석 (다중 페이지 처리는 향후 개선)
|
||||
first_page = images[0]
|
||||
base64_image = self.pdf_processor.image_to_base64(first_page)
|
||||
|
||||
# PDF에서 텍스트 블록 추출
|
||||
text_blocks = self.pdf_processor.extract_text_with_coordinates(file_path, 0)
|
||||
|
||||
# Gemini API로 분석
|
||||
# 실제 구현에서는 batch mode 사용 가능
|
||||
analysis_result = await self._analyze_with_gemini(
|
||||
base64_image, text_blocks, config.organization_type
|
||||
)
|
||||
|
||||
result.pdf_analysis_result = analysis_result
|
||||
|
||||
async def _process_dxf_file(
|
||||
self,
|
||||
file_path: str,
|
||||
result: FileProcessingResult,
|
||||
config: BatchProcessingConfig
|
||||
) -> None:
|
||||
"""DXF 파일 처리"""
|
||||
# DXF 파일 분석
|
||||
extraction_result = self.dxf_processor.extract_comprehensive_data(file_path)
|
||||
|
||||
# 타이틀 블록 정보를 딕셔너리 리스트로 변환
|
||||
title_blocks = []
|
||||
for tb_info in extraction_result.title_blocks:
|
||||
tb_dict = {
|
||||
'block_name': tb_info.block_name,
|
||||
'block_position': f"{tb_info.block_position[0]:.2f}, {tb_info.block_position[1]:.2f}",
|
||||
'attributes_count': tb_info.attributes_count,
|
||||
'attributes': [
|
||||
{
|
||||
'tag': attr.tag,
|
||||
'text': attr.text,
|
||||
'prompt': attr.prompt,
|
||||
'insert_x': attr.insert_x,
|
||||
'insert_y': attr.insert_y
|
||||
}
|
||||
for attr in tb_info.all_attributes
|
||||
]
|
||||
}
|
||||
title_blocks.append(tb_dict)
|
||||
|
||||
result.dxf_title_blocks = title_blocks
|
||||
result.dxf_total_attributes = sum(tb['attributes_count'] for tb in title_blocks)
|
||||
result.dxf_total_text_entities = len(extraction_result.text_entities)
|
||||
|
||||
# 상세한 title block attributes CSV 생성
|
||||
if extraction_result.title_blocks:
|
||||
await self._save_detailed_dxf_csv(file_path, extraction_result)
|
||||
|
||||
async def _analyze_with_gemini(
|
||||
self,
|
||||
base64_image: str,
|
||||
text_blocks: list,
|
||||
organization_type: str
|
||||
) -> str:
|
||||
"""Gemini API로 이미지 분석"""
|
||||
try:
|
||||
# 비동기 처리를 위해 동기 함수를 태스크로 실행
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
self.gemini_analyzer.analyze_pdf_page,
|
||||
base64_image,
|
||||
text_blocks,
|
||||
None, # prompt (default 사용)
|
||||
"image/png", # mime_type
|
||||
organization_type
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 분석 오류: {str(e)}")
|
||||
return f"분석 실패: {str(e)}"
|
||||
|
||||
async def _save_detailed_dxf_csv(
|
||||
self,
|
||||
file_path: str,
|
||||
extraction_result
|
||||
) -> None:
|
||||
"""상세한 DXF title block attributes CSV 저장"""
|
||||
try:
|
||||
# 파일명에서 확장자 제거
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
|
||||
# 출력 디렉토리 확인 및 생성
|
||||
output_dir = os.path.join(os.path.dirname(file_path), '..', 'results')
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# CSV 파일명 생성
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
csv_filename = f"detailed_title_blocks_{file_name}_{timestamp}.csv"
|
||||
csv_path = os.path.join(output_dir, csv_filename)
|
||||
|
||||
# TitleBlockCSVExporter를 사용하여 CSV 생성
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self.csv_exporter.save_title_block_info_to_csv,
|
||||
extraction_result.title_blocks,
|
||||
csv_path
|
||||
)
|
||||
|
||||
logger.info(f"상세 DXF CSV 저장 완료: {csv_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"상세 DXF CSV 저장 오류: {str(e)}")
|
||||
|
||||
def _detect_file_type(self, file_path: str) -> str:
|
||||
"""파일 확장자로 파일 유형 검출"""
|
||||
_, ext = os.path.splitext(file_path.lower())
|
||||
if ext == '.pdf':
|
||||
return 'PDF'
|
||||
elif ext == '.dxf':
|
||||
return 'DXF'
|
||||
else:
|
||||
return ext.upper().lstrip('.')
|
||||
|
||||
async def save_results_to_csv(self, output_path: str) -> None:
|
||||
"""
|
||||
처리 결과를 CSV 파일로 저장
|
||||
|
||||
Args:
|
||||
output_path: 출력 CSV 파일 경로
|
||||
"""
|
||||
try:
|
||||
# 결과를 DataFrame으로 변환
|
||||
data_rows = []
|
||||
|
||||
for result in self.processing_results:
|
||||
# 기본 정보
|
||||
row = {
|
||||
'file_name': result.file_name,
|
||||
'file_path': result.file_path,
|
||||
'file_type': result.file_type,
|
||||
'file_size_bytes': result.file_size,
|
||||
'file_size_mb': round(result.file_size / (1024 * 1024), 2),
|
||||
'processing_time_seconds': result.processing_time,
|
||||
'success': result.success,
|
||||
'error_message': result.error_message or '',
|
||||
'processed_at': result.processed_at
|
||||
}
|
||||
|
||||
# PDF 분석 결과
|
||||
if result.file_type.lower() == 'pdf':
|
||||
row['pdf_analysis_result'] = result.pdf_analysis_result or ''
|
||||
row['dxf_total_attributes'] = ''
|
||||
row['dxf_total_text_entities'] = ''
|
||||
row['dxf_title_blocks_summary'] = ''
|
||||
|
||||
# DXF 분석 결과
|
||||
elif result.file_type.lower() == 'dxf':
|
||||
row['pdf_analysis_result'] = ''
|
||||
row['dxf_total_attributes'] = result.dxf_total_attributes or 0
|
||||
row['dxf_total_text_entities'] = result.dxf_total_text_entities or 0
|
||||
|
||||
# 타이틀 블록 요약
|
||||
if result.dxf_title_blocks:
|
||||
summary = f"{len(result.dxf_title_blocks)}개 타이틀블록"
|
||||
for tb in result.dxf_title_blocks[:3]: # 처음 3개만 표시
|
||||
summary += f" | {tb['block_name']}({tb['attributes_count']}속성)"
|
||||
if len(result.dxf_title_blocks) > 3:
|
||||
summary += f" | ...외 {len(result.dxf_title_blocks)-3}개"
|
||||
row['dxf_title_blocks_summary'] = summary
|
||||
else:
|
||||
row['dxf_title_blocks_summary'] = '타이틀블록 없음'
|
||||
|
||||
data_rows.append(row)
|
||||
|
||||
# DataFrame 생성 및 CSV 저장
|
||||
df = pd.DataFrame(data_rows)
|
||||
|
||||
# pdf_analysis_result 컬럼 평탄화
|
||||
if 'pdf_analysis_result' in df.columns:
|
||||
# JSON 문자열을 딕셔너리로 변환 (이미 딕셔너리인 경우도 처리)
|
||||
df['pdf_analysis_result'] = df['pdf_analysis_result'].apply(lambda x: json.loads(x) if isinstance(x, str) and x.strip() else {}).fillna({})
|
||||
|
||||
# 평탄화된 데이터를 새로운 DataFrame으로 생성
|
||||
# errors='ignore'를 사용하여 JSON이 아닌 값은 무시
|
||||
# record_prefix를 사용하여 컬럼 이름에 접두사 추가
|
||||
pdf_analysis_df = pd.json_normalize(df['pdf_analysis_result'], errors='ignore', record_prefix='pdf_analysis_result_')
|
||||
|
||||
# 원본 df에서 pdf_analysis_result 컬럼 제거
|
||||
df = df.drop(columns=['pdf_analysis_result'])
|
||||
|
||||
# 원본 df와 평탄화된 DataFrame을 병합
|
||||
df = pd.concat([df, pdf_analysis_df], axis=1)
|
||||
|
||||
# 컬럼 순서 정렬을 위한 기본 순서 정의
|
||||
column_order = [
|
||||
'file_name', 'file_type', 'file_size_mb', 'processing_time_seconds',
|
||||
'success', 'error_message', 'processed_at', 'file_path', 'file_size_bytes',
|
||||
'dxf_total_attributes', 'dxf_total_text_entities', 'dxf_title_blocks_summary'
|
||||
]
|
||||
|
||||
# 기존 컬럼 순서를 유지하면서 새로운 컬럼을 추가
|
||||
existing_columns = [col for col in column_order if col in df.columns]
|
||||
new_columns = [col for col in df.columns if col not in existing_columns]
|
||||
df = df[existing_columns + sorted(new_columns)]
|
||||
|
||||
# UTF-8 BOM으로 저장 (한글 호환성)
|
||||
df.to_csv(output_path, index=False, encoding='utf-8-sig')
|
||||
|
||||
logger.info(f"CSV 저장 완료: {output_path}")
|
||||
logger.info(f"총 {len(data_rows)}개 파일 결과 저장")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CSV 저장 오류: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_processing_summary(self) -> Dict[str, Any]:
|
||||
"""처리 결과 요약 정보 반환"""
|
||||
if not self.processing_results:
|
||||
return {}
|
||||
|
||||
total_files = len(self.processing_results)
|
||||
success_files = sum(1 for r in self.processing_results if r.success)
|
||||
failed_files = total_files - success_files
|
||||
|
||||
pdf_files = sum(1 for r in self.processing_results if r.file_type.lower() == 'pdf')
|
||||
dxf_files = sum(1 for r in self.processing_results if r.file_type.lower() == 'dxf')
|
||||
|
||||
total_processing_time = sum(r.processing_time for r in self.processing_results)
|
||||
avg_processing_time = total_processing_time / total_files if total_files > 0 else 0
|
||||
|
||||
total_file_size = sum(r.file_size for r in self.processing_results)
|
||||
|
||||
return {
|
||||
'total_files': total_files,
|
||||
'success_files': success_files,
|
||||
'failed_files': failed_files,
|
||||
'pdf_files': pdf_files,
|
||||
'dxf_files': dxf_files,
|
||||
'total_processing_time': round(total_processing_time, 2),
|
||||
'avg_processing_time': round(avg_processing_time, 2),
|
||||
'total_file_size_mb': round(total_file_size / (1024 * 1024), 2),
|
||||
'success_rate': round((success_files / total_files) * 100, 1) if total_files > 0 else 0
|
||||
}
|
||||
|
||||
|
||||
def generate_default_csv_filename() -> str:
|
||||
"""기본 CSV 파일명 생성"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"batch_analysis_results_{timestamp}.csv"
|
||||
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
async def main():
|
||||
# 테스트용 예시
|
||||
processor = MultiFileProcessor("your-gemini-api-key")
|
||||
|
||||
config = BatchProcessingConfig(
|
||||
organization_type="국토교통부",
|
||||
max_concurrent_files=2,
|
||||
output_csv_path="test_results.csv"
|
||||
)
|
||||
|
||||
# 진행률 콜백 함수
|
||||
def progress_callback(current: int, total: int, status: str):
|
||||
print(f"진행률: {current}/{total} ({current/total*100:.1f}%) - {status}")
|
||||
|
||||
# 파일 경로 리스트 (실제 파일 경로로 교체 필요)
|
||||
file_paths = [
|
||||
"sample1.pdf",
|
||||
"sample2.dxf",
|
||||
"sample3.pdf"
|
||||
]
|
||||
|
||||
results = await processor.process_multiple_files(
|
||||
file_paths, config, progress_callback
|
||||
)
|
||||
|
||||
summary = processor.get_processing_summary()
|
||||
print("처리 요약:", summary)
|
||||
|
||||
# asyncio.run(main())
|
||||
322
pdf_processor.py
Normal file
322
pdf_processor.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
PDF 처리 모듈
|
||||
PDF 파일을 이미지로 변환하고 base64로 인코딩하는 기능을 제공합니다.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import fitz # PyMuPDF
|
||||
from PIL import Image
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PDFProcessor:
|
||||
"""PDF 파일 처리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.supported_formats = ['pdf']
|
||||
|
||||
def validate_pdf_file(self, file_path: str) -> bool:
|
||||
"""PDF 파일 유효성 검사"""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
|
||||
# 파일 존재 여부 확인
|
||||
if not path.exists():
|
||||
logger.error(f"파일이 존재하지 않습니다: {file_path}")
|
||||
return False
|
||||
|
||||
# 파일 확장자 확인
|
||||
if path.suffix.lower() != '.pdf':
|
||||
logger.error(f"지원하지 않는 파일 형식입니다: {path.suffix}")
|
||||
return False
|
||||
|
||||
# PDF 파일 열기 테스트
|
||||
doc = fitz.open(file_path)
|
||||
page_count = len(doc)
|
||||
doc.close()
|
||||
|
||||
if page_count == 0:
|
||||
logger.error("PDF 파일에 페이지가 없습니다.")
|
||||
return False
|
||||
|
||||
logger.info(f"PDF 검증 완료: {page_count}페이지")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF 파일 검증 중 오류 발생: {e}")
|
||||
return False
|
||||
|
||||
def get_pdf_info(self, file_path: str) -> Optional[dict]:
|
||||
"""PDF 파일 정보 조회"""
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
info = {
|
||||
'page_count': len(doc),
|
||||
'metadata': doc.metadata,
|
||||
'file_size': Path(file_path).stat().st_size,
|
||||
'filename': Path(file_path).name
|
||||
}
|
||||
doc.close()
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF 정보 조회 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
def convert_pdf_page_to_image(
|
||||
self,
|
||||
file_path: str,
|
||||
page_number: int = 0,
|
||||
zoom: float = 2.0,
|
||||
image_format: str = "PNG"
|
||||
) -> Optional[Image.Image]:
|
||||
"""PDF 페이지를 PIL Image로 변환"""
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
|
||||
if page_number >= len(doc):
|
||||
logger.error(f"페이지 번호가 범위를 벗어남: {page_number}")
|
||||
doc.close()
|
||||
return None
|
||||
|
||||
# 페이지 로드
|
||||
page = doc.load_page(page_number)
|
||||
|
||||
# 이미지 변환을 위한 매트릭스 설정 (확대/축소)
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
|
||||
# PIL Image로 변환
|
||||
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
||||
|
||||
doc.close()
|
||||
logger.info(f"페이지 {page_number + 1} 이미지 변환 완료: {img.size}")
|
||||
return img
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF 페이지 이미지 변환 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
def convert_pdf_to_images(
|
||||
self,
|
||||
file_path: str,
|
||||
max_pages: Optional[int] = None,
|
||||
zoom: float = 2.0
|
||||
) -> List[Image.Image]:
|
||||
"""PDF의 모든 페이지를 이미지로 변환"""
|
||||
images = []
|
||||
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
total_pages = len(doc)
|
||||
|
||||
# 최대 페이지 수 제한
|
||||
if max_pages:
|
||||
total_pages = min(total_pages, max_pages)
|
||||
|
||||
for page_num in range(total_pages):
|
||||
img = self.convert_pdf_page_to_image(file_path, page_num, zoom)
|
||||
if img:
|
||||
images.append(img)
|
||||
|
||||
doc.close()
|
||||
logger.info(f"총 {len(images)}개 페이지 이미지 변환 완료")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF 전체 페이지 변환 중 오류 발생: {e}")
|
||||
|
||||
return images
|
||||
|
||||
def image_to_base64(
|
||||
self,
|
||||
image: Image.Image,
|
||||
format: str = "PNG",
|
||||
quality: int = 95
|
||||
) -> Optional[str]:
|
||||
"""PIL Image를 base64 문자열로 변환"""
|
||||
try:
|
||||
buffer = io.BytesIO()
|
||||
|
||||
# JPEG 형식인 경우 품질 설정
|
||||
if format.upper() == "JPEG":
|
||||
image.save(buffer, format=format, quality=quality)
|
||||
else:
|
||||
image.save(buffer, format=format)
|
||||
|
||||
buffer.seek(0)
|
||||
base64_string = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
logger.info(f"이미지를 base64로 변환 완료 (크기: {len(base64_string)} 문자)")
|
||||
return base64_string
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이미지 base64 변환 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
def pdf_page_to_base64(
|
||||
self,
|
||||
file_path: str,
|
||||
page_number: int = 0,
|
||||
zoom: float = 2.0,
|
||||
format: str = "PNG"
|
||||
) -> Optional[str]:
|
||||
"""PDF 페이지를 직접 base64로 변환"""
|
||||
img = self.convert_pdf_page_to_image(file_path, page_number, zoom)
|
||||
if img:
|
||||
return self.image_to_base64(img, format)
|
||||
return None
|
||||
|
||||
def pdf_page_to_image_bytes(
|
||||
self,
|
||||
file_path: str,
|
||||
page_number: int = 0,
|
||||
zoom: float = 2.0,
|
||||
format: str = "PNG"
|
||||
) -> Optional[bytes]:
|
||||
"""PDF 페이지를 이미지 바이트로 변환 (Flet 이미지 표시용)"""
|
||||
try:
|
||||
img = self.convert_pdf_page_to_image(file_path, page_number, zoom)
|
||||
if img:
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format=format)
|
||||
buffer.seek(0)
|
||||
image_bytes = buffer.getvalue()
|
||||
|
||||
logger.info(f"페이지 {page_number + 1} 이미지 바이트 변환 완료 (크기: {len(image_bytes)} 바이트)")
|
||||
return image_bytes
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF 페이지 이미지 바이트 변환 중 오류 발생: {e}")
|
||||
return None
|
||||
|
||||
def get_optimal_zoom_for_size(self, target_size: Tuple[int, int]) -> float:
|
||||
"""목표 크기에 맞는 최적 줌 비율 계산"""
|
||||
# 기본 PDF 페이지 크기 (A4: 595x842 points)
|
||||
default_width, default_height = 595, 842
|
||||
target_width, target_height = target_size
|
||||
|
||||
# 비율 계산
|
||||
width_ratio = target_width / default_width
|
||||
height_ratio = target_height / default_height
|
||||
|
||||
# 작은 비율을 선택하여 전체 페이지가 들어가도록 함
|
||||
zoom = min(width_ratio, height_ratio)
|
||||
|
||||
logger.info(f"최적 줌 비율 계산: {zoom:.2f}")
|
||||
return zoom
|
||||
|
||||
def extract_text_with_coordinates(self, file_path: str, page_number: int = 0) -> List[Dict[str, Any]]:
|
||||
"""PDF 페이지에서 텍스트와 좌표를 추출합니다."""
|
||||
text_blocks = []
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
if page_number >= len(doc):
|
||||
logger.error(f"페이지 번호가 범위를 벗어남: {page_number}")
|
||||
doc.close()
|
||||
return []
|
||||
|
||||
page = doc.load_page(page_number)
|
||||
# 'dict' 옵션은 블록, 라인, 스팬에 대한 상세 정보를 제공합니다.
|
||||
blocks = page.get_text("dict")["blocks"]
|
||||
for b in blocks: # 블록 반복
|
||||
if b['type'] == 0: # 텍스트 블록
|
||||
for l in b["lines"]: # 라인 반복
|
||||
for s in l["spans"]: # 스팬(텍스트 조각) 반복
|
||||
text_blocks.append({
|
||||
"text": s["text"],
|
||||
"bbox": s["bbox"], # (x0, y0, x1, y1)
|
||||
"font": s["font"],
|
||||
"size": s["size"]
|
||||
})
|
||||
|
||||
doc.close()
|
||||
logger.info(f"페이지 {page_number + 1}에서 {len(text_blocks)}개의 텍스트 블록 추출 완료")
|
||||
return text_blocks
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF 텍스트 및 좌표 추출 중 오류 발생: {e}")
|
||||
return []
|
||||
|
||||
def convert_to_images(
|
||||
self,
|
||||
file_path: str,
|
||||
zoom: float = 2.0,
|
||||
max_pages: int = 10
|
||||
) -> List[Image.Image]:
|
||||
"""PDF의 모든 페이지(또는 지정된 수까지)를 PIL Image 리스트로 변환"""
|
||||
images = []
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
page_count = min(len(doc), max_pages) # 최대 페이지 수 제한
|
||||
|
||||
logger.info(f"PDF 변환 시작: {page_count}페이지")
|
||||
|
||||
for page_num in range(page_count):
|
||||
page = doc.load_page(page_num)
|
||||
|
||||
# 이미지 변환을 위한 매트릭스 설정
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
|
||||
# PIL Image로 변환
|
||||
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
||||
images.append(img)
|
||||
|
||||
logger.info(f"페이지 {page_num + 1}/{page_count} 변환 완료: {img.size}")
|
||||
|
||||
doc.close()
|
||||
logger.info(f"PDF 전체 변환 완료: {len(images)}개 이미지")
|
||||
return images
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF 다중 페이지 변환 중 오류 발생: {e}")
|
||||
return []
|
||||
|
||||
def image_to_bytes(self, image: Image.Image, format: str = 'PNG') -> bytes:
|
||||
"""
|
||||
PIL Image를 바이트 데이터로 변환합니다.
|
||||
|
||||
Args:
|
||||
image: PIL Image 객체
|
||||
format: 이미지 포맷 ('PNG', 'JPEG' 등)
|
||||
|
||||
Returns:
|
||||
이미지 바이트 데이터
|
||||
"""
|
||||
try:
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format=format)
|
||||
image_bytes = buffer.getvalue()
|
||||
buffer.close()
|
||||
|
||||
logger.info(f"이미지를 {format} 바이트로 변환: {len(image_bytes)} bytes")
|
||||
return image_bytes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이미지 바이트 변환 중 오류 발생: {e}")
|
||||
return b''
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
processor = PDFProcessor()
|
||||
|
||||
# 테스트용 코드 (실제 PDF 파일 경로로 변경 필요)
|
||||
test_pdf = "test.pdf"
|
||||
|
||||
if processor.validate_pdf_file(test_pdf):
|
||||
info = processor.get_pdf_info(test_pdf)
|
||||
print(f"PDF 정보: {info}")
|
||||
|
||||
# 첫 번째 페이지를 base64로 변환
|
||||
base64_data = processor.pdf_page_to_base64(test_pdf, 0)
|
||||
if base64_data:
|
||||
print(f"Base64 변환 성공: {len(base64_data)} 문자")
|
||||
else:
|
||||
print("PDF 파일 검증 실패")
|
||||
1022
project_plan.md
Normal file
1022
project_plan.md
Normal file
File diff suppressed because it is too large
Load Diff
38
requirements.txt
Normal file
38
requirements.txt
Normal file
@@ -0,0 +1,38 @@
|
||||
# Flet 기반 PDF 이미지 분석기 - 필수 라이브러리
|
||||
|
||||
# UI 프레임워크
|
||||
flet>=0.25.1
|
||||
|
||||
# Google Generative AI SDK
|
||||
google-genai>=1.0.0
|
||||
|
||||
# PDF 처리 라이브러리 (둘 중 하나 선택)
|
||||
PyMuPDF>=1.26.3
|
||||
pdf2image>=1.17.0
|
||||
|
||||
# 이미지 처리
|
||||
Pillow>=10.0.0
|
||||
|
||||
# DXF 파일 처리 (NEW)
|
||||
ezdxf>=1.4.2
|
||||
|
||||
# 수치 계산 (NEW)
|
||||
numpy>=1.24.0
|
||||
|
||||
# 환경 변수 관리
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# 추가 유틸리티
|
||||
requests>=2.31.0
|
||||
|
||||
# 데이터 처리 (NEW - 다중 파일 CSV 출력용)
|
||||
pandas>=2.0.0
|
||||
|
||||
# Flet Material Design (선택 사항)
|
||||
flet-material>=0.3.3
|
||||
|
||||
# 개발 도구 (선택 사항)
|
||||
# black>=23.0.0
|
||||
# flake8>=6.0.0
|
||||
# pytest>=7.0.0
|
||||
# mypy>=1.0.0
|
||||
264
setup.py
Normal file
264
setup.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
설치 및 설정 가이드 스크립트
|
||||
프로젝트 설치와 초기 설정을 도와주는 스크립트입니다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
def print_header(title):
|
||||
"""헤더 출력"""
|
||||
print("\n" + "=" * 60)
|
||||
print(f" {title}")
|
||||
print("=" * 60)
|
||||
|
||||
def print_step(step_num, title):
|
||||
"""단계 출력"""
|
||||
print(f"\n📋 단계 {step_num}: {title}")
|
||||
print("-" * 40)
|
||||
|
||||
def check_python_version():
|
||||
"""Python 버전 확인"""
|
||||
print_step(1, "Python 버전 확인")
|
||||
|
||||
version = sys.version_info
|
||||
print(f"현재 Python 버전: {version.major}.{version.minor}.{version.micro}")
|
||||
|
||||
if version.major < 3 or (version.major == 3 and version.minor < 9):
|
||||
print("❌ Python 3.9 이상이 필요합니다.")
|
||||
print("https://www.python.org/downloads/ 에서 최신 버전을 다운로드하세요.")
|
||||
return False
|
||||
else:
|
||||
print("✅ Python 버전이 요구사항을 만족합니다.")
|
||||
return True
|
||||
|
||||
def check_pip():
|
||||
"""pip 확인 및 업그레이드"""
|
||||
print_step(2, "pip 확인 및 업그레이드")
|
||||
|
||||
try:
|
||||
# pip 버전 확인
|
||||
result = subprocess.run([sys.executable, "-m", "pip", "--version"],
|
||||
capture_output=True, text=True)
|
||||
print(f"pip 버전: {result.stdout.strip()}")
|
||||
|
||||
# pip 업그레이드
|
||||
print("pip를 최신 버전으로 업그레이드 중...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "pip"],
|
||||
check=True)
|
||||
print("✅ pip 업그레이드 완료")
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ pip 업그레이드 실패: {e}")
|
||||
return False
|
||||
|
||||
def create_virtual_environment():
|
||||
"""가상 환경 생성 (선택 사항)"""
|
||||
print_step(3, "가상 환경 생성 (권장)")
|
||||
|
||||
venv_path = Path("venv")
|
||||
|
||||
if venv_path.exists():
|
||||
print("⚠️ 가상 환경이 이미 존재합니다.")
|
||||
response = input("기존 가상 환경을 삭제하고 새로 만드시겠습니까? (y/N): ")
|
||||
if response.lower() == 'y':
|
||||
shutil.rmtree(venv_path)
|
||||
else:
|
||||
print("기존 가상 환경을 사용합니다.")
|
||||
return True
|
||||
|
||||
try:
|
||||
print("가상 환경 생성 중...")
|
||||
subprocess.run([sys.executable, "-m", "venv", "venv"], check=True)
|
||||
print("✅ 가상 환경 생성 완료")
|
||||
|
||||
# 활성화 안내
|
||||
if os.name == 'nt': # Windows
|
||||
print("\n가상 환경 활성화 방법:")
|
||||
print(" venv\\Scripts\\activate")
|
||||
else: # macOS/Linux
|
||||
print("\n가상 환경 활성화 방법:")
|
||||
print(" source venv/bin/activate")
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ 가상 환경 생성 실패: {e}")
|
||||
return False
|
||||
|
||||
def install_dependencies():
|
||||
"""의존성 설치"""
|
||||
print_step(4, "의존성 설치")
|
||||
|
||||
requirements_file = Path("requirements.txt")
|
||||
|
||||
if not requirements_file.exists():
|
||||
print("❌ requirements.txt 파일을 찾을 수 없습니다.")
|
||||
return False
|
||||
|
||||
try:
|
||||
print("의존성 패키지 설치 중...")
|
||||
print("(이 과정은 몇 분 정도 걸릴 수 있습니다)")
|
||||
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
|
||||
check=True)
|
||||
print("✅ 모든 의존성 설치 완료")
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ 의존성 설치 실패: {e}")
|
||||
return False
|
||||
|
||||
def setup_environment_file():
|
||||
"""환경 변수 파일 설정"""
|
||||
print_step(5, "환경 변수 설정")
|
||||
|
||||
env_example = Path(".env.example")
|
||||
env_file = Path(".env")
|
||||
|
||||
if not env_example.exists():
|
||||
print("❌ .env.example 파일을 찾을 수 없습니다.")
|
||||
return False
|
||||
|
||||
if env_file.exists():
|
||||
print("⚠️ .env 파일이 이미 존재합니다.")
|
||||
response = input("기존 .env 파일을 덮어쓰시겠습니까? (y/N): ")
|
||||
if response.lower() != 'y':
|
||||
print("기존 .env 파일을 사용합니다.")
|
||||
return True
|
||||
|
||||
try:
|
||||
# .env.example을 .env로 복사
|
||||
shutil.copy2(env_example, env_file)
|
||||
print("✅ .env 파일 생성 완료")
|
||||
|
||||
print("\n⚠️ 중요: Gemini API 키를 설정해야 합니다!")
|
||||
print("1. Google AI Studio에서 API 키를 발급받으세요:")
|
||||
print(" https://makersuite.google.com/app/apikey")
|
||||
print("2. .env 파일을 열어 GEMINI_API_KEY를 설정하세요:")
|
||||
print(" GEMINI_API_KEY=your_actual_api_key_here")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ .env 파일 설정 실패: {e}")
|
||||
return False
|
||||
|
||||
def create_directories():
|
||||
"""필요한 디렉토리 생성"""
|
||||
print_step(6, "필요한 디렉토리 생성")
|
||||
|
||||
directories = ["uploads", "results", "assets", "docs"]
|
||||
|
||||
for dir_name in directories:
|
||||
dir_path = Path(dir_name)
|
||||
if not dir_path.exists():
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
print(f"✅ {dir_name}/ 디렉토리 생성")
|
||||
else:
|
||||
print(f"✅ {dir_name}/ 디렉토리 이미 존재")
|
||||
|
||||
return True
|
||||
|
||||
def run_tests():
|
||||
"""테스트 실행"""
|
||||
print_step(7, "설치 확인 테스트")
|
||||
|
||||
test_script = Path("test_project.py")
|
||||
|
||||
if not test_script.exists():
|
||||
print("❌ 테스트 스크립트를 찾을 수 없습니다.")
|
||||
return False
|
||||
|
||||
try:
|
||||
print("설치 상태를 확인하는 테스트를 실행합니다...")
|
||||
result = subprocess.run([sys.executable, "test_project.py"],
|
||||
capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print("오류:", result.stderr)
|
||||
|
||||
return result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 테스트 실행 실패: {e}")
|
||||
return False
|
||||
|
||||
def print_final_instructions():
|
||||
"""최종 안내사항 출력"""
|
||||
print_header("설치 완료!")
|
||||
|
||||
print("🎉 PDF 도면 분석기 설치가 완료되었습니다!")
|
||||
print("\n다음 단계:")
|
||||
print("1. .env 파일을 열어 Gemini API 키를 설정하세요")
|
||||
print("2. 가상 환경을 활성화하세요 (사용하는 경우)")
|
||||
print("3. 애플리케이션을 실행하세요:")
|
||||
print(" python main.py")
|
||||
|
||||
print("\n📚 추가 정보:")
|
||||
print("- README.md: 사용법 및 문제 해결")
|
||||
print("- project_plan.md: 프로젝트 계획 및 진행 상황")
|
||||
print("- test_project.py: 테스트 스크립트")
|
||||
|
||||
print("\n🔧 문제가 발생하면:")
|
||||
print("1. python test_project.py 를 실행하여 문제를 확인하세요")
|
||||
print("2. .env 파일의 API 키 설정을 확인하세요")
|
||||
print("3. 가상 환경이 활성화되어 있는지 확인하세요")
|
||||
|
||||
def main():
|
||||
"""메인 설치 함수"""
|
||||
print_header("PDF 도면 분석기 설치 스크립트")
|
||||
print("이 스크립트는 PDF 도면 분석기 설치를 도와드립니다.")
|
||||
print("각 단계를 순차적으로 진행합니다.")
|
||||
|
||||
response = input("\n설치를 시작하시겠습니까? (Y/n): ")
|
||||
if response.lower() == 'n':
|
||||
print("설치가 취소되었습니다.")
|
||||
return
|
||||
|
||||
# 설치 단계들
|
||||
steps = [
|
||||
("Python 버전 확인", check_python_version),
|
||||
("pip 확인 및 업그레이드", check_pip),
|
||||
("가상 환경 생성", create_virtual_environment),
|
||||
("의존성 설치", install_dependencies),
|
||||
("환경 변수 설정", setup_environment_file),
|
||||
("디렉토리 생성", create_directories),
|
||||
("설치 확인 테스트", run_tests)
|
||||
]
|
||||
|
||||
completed = 0
|
||||
|
||||
for step_name, step_func in steps:
|
||||
try:
|
||||
if step_func():
|
||||
completed += 1
|
||||
else:
|
||||
print(f"\n❌ {step_name} 단계에서 문제가 발생했습니다.")
|
||||
response = input("계속 진행하시겠습니까? (y/N): ")
|
||||
if response.lower() != 'y':
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n설치가 중단되었습니다.")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"\n❌ {step_name} 단계에서 예상치 못한 오류가 발생했습니다: {e}")
|
||||
response = input("계속 진행하시겠습니까? (y/N): ")
|
||||
if response.lower() != 'y':
|
||||
break
|
||||
|
||||
print(f"\n설치 진행률: {completed}/{len(steps)} 단계 완료")
|
||||
|
||||
if completed == len(steps):
|
||||
print_final_instructions()
|
||||
else:
|
||||
print("\n⚠️ 설치가 완전히 완료되지 않았습니다.")
|
||||
print("문제를 해결한 후 다시 실행하거나 수동으로 남은 단계를 진행하세요.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
271
test_cross_tabulated_csv.py
Normal file
271
test_cross_tabulated_csv.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Cross-Tabulated CSV 내보내기 기능 테스트 스크립트
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-15
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List, Dict, Any
|
||||
import json
|
||||
|
||||
# 프로젝트 모듈 임포트
|
||||
from cross_tabulated_csv_exporter import CrossTabulatedCSVExporter, generate_cross_tabulated_csv_filename
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockFileProcessingResult:
|
||||
"""테스트용 파일 처리 결과 모의 객체"""
|
||||
file_path: str
|
||||
file_name: str
|
||||
file_type: str
|
||||
file_size: int
|
||||
processing_time: float
|
||||
success: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# PDF 분석 결과
|
||||
pdf_analysis_result: Optional[str] = None
|
||||
|
||||
# DXF 분석 결과
|
||||
dxf_title_blocks: Optional[List[Dict]] = None
|
||||
dxf_total_attributes: Optional[int] = None
|
||||
dxf_total_text_entities: Optional[int] = None
|
||||
|
||||
# 공통 메타데이터
|
||||
processed_at: Optional[str] = None
|
||||
|
||||
|
||||
def create_test_pdf_result() -> MockFileProcessingResult:
|
||||
"""테스트용 PDF 분석 결과 생성"""
|
||||
|
||||
# 복잡한 중첩 구조의 JSON 분석 결과 시뮬레이션
|
||||
analysis_result = {
|
||||
"도면_정보": {
|
||||
"제목": "황단면도",
|
||||
"도면번호": "A-001",
|
||||
"축척": "1:1000",
|
||||
"위치": "533, 48" # 좌표 정보 포함
|
||||
},
|
||||
"건설_정보": {
|
||||
"건설단계": "실시설계",
|
||||
"건설분야": "토목",
|
||||
"사업명": "봉담~송산 고속도로",
|
||||
"좌표": "372, 802" # 좌표 정보 포함
|
||||
},
|
||||
"도면_속성": [
|
||||
{"속성명": "작성자", "값": "김상균", "위치": "150, 200"},
|
||||
{"속성명": "작성일", "값": "2016.10", "위치": "250, 200"},
|
||||
{"속성명": "승인", "값": "실시설계 승인신청", "위치": "350, 200"}
|
||||
],
|
||||
"메타데이터": {
|
||||
"분석_시간": datetime.now().isoformat(),
|
||||
"신뢰도": 0.95,
|
||||
"처리_상태": "완료"
|
||||
}
|
||||
}
|
||||
|
||||
return MockFileProcessingResult(
|
||||
file_path="/test/황단면도.pdf",
|
||||
file_name="황단면도.pdf",
|
||||
file_type="PDF",
|
||||
file_size=2048000, # 2MB
|
||||
processing_time=5.2,
|
||||
success=True,
|
||||
pdf_analysis_result=json.dumps(analysis_result, ensure_ascii=False),
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
def create_test_dxf_result() -> MockFileProcessingResult:
|
||||
"""테스트용 DXF 분석 결과 생성"""
|
||||
|
||||
title_blocks = [
|
||||
{
|
||||
'block_name': 'TITLE_BLOCK',
|
||||
'block_position': '0.00, 0.00',
|
||||
'attributes_count': 5,
|
||||
'attributes': [
|
||||
{
|
||||
'tag': 'DWG_TITLE',
|
||||
'text': '평면도',
|
||||
'prompt': '도면제목',
|
||||
'insert_x': 100.5,
|
||||
'insert_y': 50.25
|
||||
},
|
||||
{
|
||||
'tag': 'DWG_NO',
|
||||
'text': 'B-002',
|
||||
'prompt': '도면번호',
|
||||
'insert_x': 200.75,
|
||||
'insert_y': 50.25
|
||||
},
|
||||
{
|
||||
'tag': 'SCALE',
|
||||
'text': '1:500',
|
||||
'prompt': '축척',
|
||||
'insert_x': 300.0,
|
||||
'insert_y': 50.25
|
||||
},
|
||||
{
|
||||
'tag': 'DESIGNER',
|
||||
'text': '이동훈',
|
||||
'prompt': '설계자',
|
||||
'insert_x': 400.25,
|
||||
'insert_y': 50.25
|
||||
},
|
||||
{
|
||||
'tag': 'DATE',
|
||||
'text': '2025.07.15',
|
||||
'prompt': '작성일',
|
||||
'insert_x': 500.5,
|
||||
'insert_y': 50.25
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return MockFileProcessingResult(
|
||||
file_path="/test/평면도.dxf",
|
||||
file_name="평면도.dxf",
|
||||
file_type="DXF",
|
||||
file_size=1536000, # 1.5MB
|
||||
processing_time=3.8,
|
||||
success=True,
|
||||
dxf_title_blocks=title_blocks,
|
||||
dxf_total_attributes=5,
|
||||
dxf_total_text_entities=25,
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
def create_test_failed_result() -> MockFileProcessingResult:
|
||||
"""테스트용 실패 결과 생성"""
|
||||
|
||||
return MockFileProcessingResult(
|
||||
file_path="/test/손상된파일.pdf",
|
||||
file_name="손상된파일.pdf",
|
||||
file_type="PDF",
|
||||
file_size=512000, # 0.5MB
|
||||
processing_time=1.0,
|
||||
success=False,
|
||||
error_message="파일이 손상되어 분석할 수 없습니다",
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
def test_cross_tabulated_csv_export():
|
||||
"""Cross-tabulated CSV 내보내기 기능 테스트"""
|
||||
|
||||
print("Cross-Tabulated CSV 내보내기 기능 테스트 시작")
|
||||
print("=" * 60)
|
||||
|
||||
# 테스트 데이터 생성
|
||||
test_results = [
|
||||
create_test_pdf_result(),
|
||||
create_test_dxf_result(),
|
||||
create_test_failed_result() # 실패 케이스도 포함
|
||||
]
|
||||
|
||||
print(f"테스트 데이터: {len(test_results)}개 파일")
|
||||
for i, result in enumerate(test_results, 1):
|
||||
status = "성공" if result.success else "실패"
|
||||
print(f" {i}. {result.file_name} ({result.file_type}) - {status}")
|
||||
|
||||
# 출력 디렉토리 생성
|
||||
output_dir = os.path.join(os.getcwd(), "test_results")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# CrossTabulatedCSVExporter 인스턴스 생성
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 테스트 1: 기본 내보내기 (좌표 포함)
|
||||
print("\n테스트 1: 기본 내보내기 (좌표 포함)")
|
||||
output_file_1 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_basic"))
|
||||
|
||||
success_1 = exporter.export_cross_tabulated_csv(
|
||||
test_results,
|
||||
output_file_1,
|
||||
include_coordinates=True,
|
||||
coordinate_source="auto"
|
||||
)
|
||||
|
||||
if success_1:
|
||||
print(f"성공: {output_file_1}")
|
||||
print(f"파일 크기: {os.path.getsize(output_file_1)} bytes")
|
||||
else:
|
||||
print("실패")
|
||||
|
||||
# 테스트 2: 좌표 제외 내보내기
|
||||
print("\n테스트 2: 좌표 제외 내보내기")
|
||||
output_file_2 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_no_coords"))
|
||||
|
||||
success_2 = exporter.export_cross_tabulated_csv(
|
||||
test_results,
|
||||
output_file_2,
|
||||
include_coordinates=False,
|
||||
coordinate_source="none"
|
||||
)
|
||||
|
||||
if success_2:
|
||||
print(f"성공: {output_file_2}")
|
||||
print(f"파일 크기: {os.path.getsize(output_file_2)} bytes")
|
||||
else:
|
||||
print("실패")
|
||||
|
||||
# 테스트 3: 성공한 파일만 내보내기
|
||||
print("\n테스트 3: 성공한 파일만 내보내기")
|
||||
success_only_results = [r for r in test_results if r.success]
|
||||
output_file_3 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_success_only"))
|
||||
|
||||
success_3 = exporter.export_cross_tabulated_csv(
|
||||
success_only_results,
|
||||
output_file_3,
|
||||
include_coordinates=True,
|
||||
coordinate_source="auto"
|
||||
)
|
||||
|
||||
if success_3:
|
||||
print(f"성공: {output_file_3}")
|
||||
print(f"파일 크기: {os.path.getsize(output_file_3)} bytes")
|
||||
else:
|
||||
print("실패")
|
||||
|
||||
# 결과 요약
|
||||
print("\n" + "=" * 60)
|
||||
print("테스트 결과 요약:")
|
||||
test_results_summary = [
|
||||
("기본 내보내기 (좌표 포함)", success_1),
|
||||
("좌표 제외 내보내기", success_2),
|
||||
("성공한 파일만 내보내기", success_3)
|
||||
]
|
||||
|
||||
for test_name, success in test_results_summary:
|
||||
status = "통과" if success else "실패"
|
||||
print(f" - {test_name}: {status}")
|
||||
|
||||
total_success = sum(1 for _, success in test_results_summary if success)
|
||||
print(f"\n전체 테스트 결과: {total_success}/{len(test_results_summary)} 통과")
|
||||
|
||||
if total_success == len(test_results_summary):
|
||||
print("모든 테스트가 성공적으로 완료되었습니다!")
|
||||
else:
|
||||
print("일부 테스트가 실패했습니다. 로그를 확인해주세요.")
|
||||
|
||||
# 생성된 파일 목록 표시
|
||||
print(f"\n생성된 테스트 파일들 ({output_dir}):")
|
||||
if os.path.exists(output_dir):
|
||||
for file in os.listdir(output_dir):
|
||||
if file.endswith('.csv'):
|
||||
file_path = os.path.join(output_dir, file)
|
||||
file_size = os.path.getsize(file_path)
|
||||
print(f" - {file} ({file_size} bytes)")
|
||||
|
||||
print("\n생성된 CSV 파일을 열어서 key-value 형태로 데이터가 올바르게 저장되었는지 확인해보세요.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_cross_tabulated_csv_export()
|
||||
352
test_cross_tabulated_csv_fixed.py
Normal file
352
test_cross_tabulated_csv_fixed.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Cross-Tabulated CSV 내보내기 기능 테스트 스크립트 (수정 버전)
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-15
|
||||
Updated: 2025-07-16 (디버깅 개선 버전)
|
||||
Version: 1.1.0
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List, Dict, Any
|
||||
import json
|
||||
|
||||
# 수정된 프로젝트 모듈 임포트
|
||||
from cross_tabulated_csv_exporter_fixed import CrossTabulatedCSVExporter, generate_cross_tabulated_csv_filename
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockFileProcessingResult:
|
||||
"""테스트용 파일 처리 결과 모의 객체 (real structure)"""
|
||||
file_path: str
|
||||
file_name: str
|
||||
file_type: str
|
||||
file_size: int
|
||||
processing_time: float
|
||||
success: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# PDF 분석 결과
|
||||
pdf_analysis_result: Optional[str] = None
|
||||
|
||||
# DXF 분석 결과
|
||||
dxf_title_blocks: Optional[List[Dict]] = None
|
||||
dxf_total_attributes: Optional[int] = None
|
||||
dxf_total_text_entities: Optional[int] = None
|
||||
|
||||
# 공통 메타데이터
|
||||
processed_at: Optional[str] = None
|
||||
|
||||
|
||||
def create_test_pdf_result() -> MockFileProcessingResult:
|
||||
"""테스트용 PDF 분석 결과 생성"""
|
||||
|
||||
# 복잡한 중첩 구조의 JSON 분석 결과 시뮬레이션
|
||||
analysis_result = {
|
||||
"도면_정보": {
|
||||
"제목": "황단면도",
|
||||
"도면번호": "A-001",
|
||||
"축척": "1:1000",
|
||||
"위치": "533, 48" # 좌표 정보 포함
|
||||
},
|
||||
"건설_정보": {
|
||||
"건설단계": "실시설계",
|
||||
"건설분야": "토목",
|
||||
"사업명": "봉담~송산 고속도로",
|
||||
"좌표": "372, 802" # 좌표 정보 포함
|
||||
},
|
||||
"도면_속성": [
|
||||
{"속성명": "작성자", "값": "김상균", "위치": "150, 200"},
|
||||
{"속성명": "작성일", "값": "2016.10", "위치": "250, 200"},
|
||||
{"속성명": "승인", "값": "실시설계 승인신청", "위치": "350, 200"}
|
||||
],
|
||||
"메타데이터": {
|
||||
"분석_시간": datetime.now().isoformat(),
|
||||
"신뢰도": 0.95,
|
||||
"처리_상태": "완료"
|
||||
}
|
||||
}
|
||||
|
||||
return MockFileProcessingResult(
|
||||
file_path="/test/황단면도.pdf",
|
||||
file_name="황단면도.pdf",
|
||||
file_type="PDF",
|
||||
file_size=2048000, # 2MB
|
||||
processing_time=5.2,
|
||||
success=True,
|
||||
pdf_analysis_result=json.dumps(analysis_result, ensure_ascii=False),
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
def create_test_dxf_result() -> MockFileProcessingResult:
|
||||
"""테스트용 DXF 분석 결과 생성"""
|
||||
|
||||
title_blocks = [
|
||||
{
|
||||
'block_name': 'TITLE_BLOCK',
|
||||
'block_position': '0.00, 0.00',
|
||||
'attributes_count': 5,
|
||||
'attributes': [
|
||||
{
|
||||
'tag': 'DWG_TITLE',
|
||||
'text': '평면도',
|
||||
'prompt': '도면제목',
|
||||
'insert_x': 100.5,
|
||||
'insert_y': 50.25
|
||||
},
|
||||
{
|
||||
'tag': 'DWG_NO',
|
||||
'text': 'B-002',
|
||||
'prompt': '도면번호',
|
||||
'insert_x': 200.75,
|
||||
'insert_y': 50.25
|
||||
},
|
||||
{
|
||||
'tag': 'SCALE',
|
||||
'text': '1:500',
|
||||
'prompt': '축척',
|
||||
'insert_x': 300.0,
|
||||
'insert_y': 50.25
|
||||
},
|
||||
{
|
||||
'tag': 'DESIGNER',
|
||||
'text': '이동훈',
|
||||
'prompt': '설계자',
|
||||
'insert_x': 400.25,
|
||||
'insert_y': 50.25
|
||||
},
|
||||
{
|
||||
'tag': 'DATE',
|
||||
'text': '2025.07.16',
|
||||
'prompt': '작성일',
|
||||
'insert_x': 500.5,
|
||||
'insert_y': 50.25
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return MockFileProcessingResult(
|
||||
file_path="/test/평면도.dxf",
|
||||
file_name="평면도.dxf",
|
||||
file_type="DXF",
|
||||
file_size=1536000, # 1.5MB
|
||||
processing_time=3.8,
|
||||
success=True,
|
||||
dxf_title_blocks=title_blocks,
|
||||
dxf_total_attributes=5,
|
||||
dxf_total_text_entities=25,
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
def create_test_failed_result() -> MockFileProcessingResult:
|
||||
"""테스트용 실패 결과 생성"""
|
||||
|
||||
return MockFileProcessingResult(
|
||||
file_path="/test/손상된파일.pdf",
|
||||
file_name="손상된파일.pdf",
|
||||
file_type="PDF",
|
||||
file_size=512000, # 0.5MB
|
||||
processing_time=1.0,
|
||||
success=False,
|
||||
error_message="파일이 손상되어 분석할 수 없습니다",
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
def create_test_empty_pdf_result() -> MockFileProcessingResult:
|
||||
"""테스트용 빈 PDF 분석 결과 생성"""
|
||||
|
||||
return MockFileProcessingResult(
|
||||
file_path="/test/빈PDF.pdf",
|
||||
file_name="빈PDF.pdf",
|
||||
file_type="PDF",
|
||||
file_size=100000, # 0.1MB
|
||||
processing_time=2.0,
|
||||
success=True,
|
||||
pdf_analysis_result=None, # 빈 분석 결과
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
def create_test_empty_dxf_result() -> MockFileProcessingResult:
|
||||
"""테스트용 빈 DXF 분석 결과 생성"""
|
||||
|
||||
return MockFileProcessingResult(
|
||||
file_path="/test/빈DXF.dxf",
|
||||
file_name="빈DXF.dxf",
|
||||
file_type="DXF",
|
||||
file_size=50000, # 0.05MB
|
||||
processing_time=1.5,
|
||||
success=True,
|
||||
dxf_title_blocks=None, # 빈 타이틀블록
|
||||
dxf_total_attributes=0,
|
||||
dxf_total_text_entities=0,
|
||||
processed_at=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
def test_cross_tabulated_csv_export_fixed():
|
||||
"""Cross-tabulated CSV 내보내기 기능 테스트 (수정 버전)"""
|
||||
|
||||
print("Cross-Tabulated CSV 내보내기 기능 테스트 시작 (수정 버전)")
|
||||
print("=" * 70)
|
||||
|
||||
# 테스트 데이터 생성 (다양한 케이스 포함)
|
||||
test_results = [
|
||||
create_test_pdf_result(), # 정상 PDF
|
||||
create_test_dxf_result(), # 정상 DXF
|
||||
create_test_failed_result(), # 실패 케이스
|
||||
create_test_empty_pdf_result(), # 빈 PDF 분석 결과
|
||||
create_test_empty_dxf_result(), # 빈 DXF 분석 결과
|
||||
]
|
||||
|
||||
print(f"테스트 데이터: {len(test_results)}개 파일")
|
||||
for i, result in enumerate(test_results, 1):
|
||||
status = "성공" if result.success else "실패"
|
||||
has_data = ""
|
||||
if result.success:
|
||||
if result.file_type.lower() == 'pdf':
|
||||
has_data = f" (분석결과: {'있음' if result.pdf_analysis_result else '없음'})"
|
||||
elif result.file_type.lower() == 'dxf':
|
||||
has_data = f" (타이틀블록: {'있음' if result.dxf_title_blocks else '없음'})"
|
||||
print(f" {i}. {result.file_name} ({result.file_type}) - {status}{has_data}")
|
||||
|
||||
# 출력 디렉토리 생성
|
||||
output_dir = os.path.join(os.getcwd(), "test_results_fixed")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# CrossTabulatedCSVExporter 인스턴스 생성 (수정 버전)
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 테스트 1: 모든 데이터 (빈 데이터 포함) 내보내기
|
||||
print("\n테스트 1: 모든 데이터 내보내기 (빈 데이터 포함)")
|
||||
output_file_1 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_all_data"))
|
||||
|
||||
success_1 = exporter.export_cross_tabulated_csv(
|
||||
test_results,
|
||||
output_file_1,
|
||||
include_coordinates=True,
|
||||
coordinate_source="auto"
|
||||
)
|
||||
|
||||
if success_1:
|
||||
print(f"성공: {output_file_1}")
|
||||
print(f"파일 크기: {os.path.getsize(output_file_1)} bytes")
|
||||
else:
|
||||
print("실패 - 이것은 예상된 결과일 수 있습니다 (빈 데이터가 많음)")
|
||||
|
||||
# 테스트 2: 유효한 데이터만 내보내기 (성공하고 데이터가 있는 것만)
|
||||
print("\n테스트 2: 유효한 데이터만 내보내기")
|
||||
valid_results = []
|
||||
for result in test_results:
|
||||
if result.success:
|
||||
if (result.file_type.lower() == 'pdf' and result.pdf_analysis_result) or \
|
||||
(result.file_type.lower() == 'dxf' and result.dxf_title_blocks):
|
||||
valid_results.append(result)
|
||||
|
||||
print(f"유효한 결과: {len(valid_results)}개")
|
||||
|
||||
output_file_2 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_valid_only"))
|
||||
|
||||
success_2 = exporter.export_cross_tabulated_csv(
|
||||
valid_results,
|
||||
output_file_2,
|
||||
include_coordinates=True,
|
||||
coordinate_source="auto"
|
||||
)
|
||||
|
||||
if success_2:
|
||||
print(f"성공: {output_file_2}")
|
||||
print(f"파일 크기: {os.path.getsize(output_file_2)} bytes")
|
||||
else:
|
||||
print("실패")
|
||||
|
||||
# 테스트 3: 좌표 제외 내보내기
|
||||
print("\n테스트 3: 좌표 제외 내보내기")
|
||||
output_file_3 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_no_coords"))
|
||||
|
||||
success_3 = exporter.export_cross_tabulated_csv(
|
||||
valid_results,
|
||||
output_file_3,
|
||||
include_coordinates=False,
|
||||
coordinate_source="none"
|
||||
)
|
||||
|
||||
if success_3:
|
||||
print(f"성공: {output_file_3}")
|
||||
print(f"파일 크기: {os.path.getsize(output_file_3)} bytes")
|
||||
else:
|
||||
print("실패")
|
||||
|
||||
# 테스트 4: 빈 리스트 테스트
|
||||
print("\n테스트 4: 빈 리스트 테스트")
|
||||
output_file_4 = os.path.join(output_dir, generate_cross_tabulated_csv_filename("test_empty_list"))
|
||||
|
||||
success_4 = exporter.export_cross_tabulated_csv(
|
||||
[], # 빈 리스트
|
||||
output_file_4,
|
||||
include_coordinates=True,
|
||||
coordinate_source="auto"
|
||||
)
|
||||
|
||||
if success_4:
|
||||
print(f"성공: {output_file_4}")
|
||||
else:
|
||||
print("실패 - 예상된 결과 (빈 리스트)")
|
||||
|
||||
# 결과 요약
|
||||
print("\n" + "=" * 70)
|
||||
print("테스트 결과 요약:")
|
||||
test_results_summary = [
|
||||
("모든 데이터 내보내기", success_1),
|
||||
("유효한 데이터만 내보내기", success_2),
|
||||
("좌표 제외 내보내기", success_3),
|
||||
("빈 리스트 테스트", success_4)
|
||||
]
|
||||
|
||||
for test_name, success in test_results_summary:
|
||||
status = "통과" if success else "실패"
|
||||
print(f" - {test_name}: {status}")
|
||||
|
||||
total_success = sum(1 for _, success in test_results_summary if success)
|
||||
print(f"\n전체 테스트 결과: {total_success}/{len(test_results_summary)} 통과")
|
||||
|
||||
# 생성된 파일 목록 표시
|
||||
print(f"\n생성된 테스트 파일들 ({output_dir}):")
|
||||
if os.path.exists(output_dir):
|
||||
for file in os.listdir(output_dir):
|
||||
if file.endswith('.csv'):
|
||||
file_path = os.path.join(output_dir, file)
|
||||
file_size = os.path.getsize(file_path)
|
||||
print(f" - {file} ({file_size} bytes)")
|
||||
|
||||
# 성공한 CSV 파일 중 하나 미리보기
|
||||
successful_files = [f for f in [output_file_1, output_file_2, output_file_3]
|
||||
if os.path.exists(f) and os.path.getsize(f) > 0]
|
||||
|
||||
if successful_files:
|
||||
preview_file = successful_files[0]
|
||||
print(f"\n미리보기 ({os.path.basename(preview_file)}):")
|
||||
print("-" * 50)
|
||||
|
||||
try:
|
||||
with open(preview_file, 'r', encoding='utf-8-sig') as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines[:10]): # 처음 10줄만 표시
|
||||
print(f"{i+1:2d}: {line.rstrip()}")
|
||||
if len(lines) > 10:
|
||||
print(f"... (총 {len(lines)}줄)")
|
||||
except Exception as e:
|
||||
print(f"미리보기 오류: {e}")
|
||||
|
||||
print("\n생성된 CSV 파일을 열어서 key-value 형태로 데이터가 올바르게 저장되었는지 확인해보세요.")
|
||||
print("수정된 버전에서는 더 자세한 디버깅 정보가 로그에 출력됩니다.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_cross_tabulated_csv_export_fixed()
|
||||
243
test_key_integration.py
Normal file
243
test_key_integration.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Cross-Tabulated CSV 내보내기 개선 테스트 스크립트
|
||||
키 통합 기능 테스트
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-16
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
# 프로젝트 루트 디렉토리를 Python 경로에 추가
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cross_tabulated_csv_exporter import CrossTabulatedCSVExporter
|
||||
|
||||
class MockResult:
|
||||
"""테스트용 모의 결과 객체"""
|
||||
|
||||
def __init__(self, file_name, file_type, success=True, **kwargs):
|
||||
self.file_name = file_name
|
||||
self.file_type = file_type
|
||||
self.success = success
|
||||
|
||||
# PDF 관련 속성
|
||||
if 'pdf_analysis_result' in kwargs:
|
||||
self.pdf_analysis_result = kwargs['pdf_analysis_result']
|
||||
|
||||
# DXF 관련 속성
|
||||
if 'dxf_title_blocks' in kwargs:
|
||||
self.dxf_title_blocks = kwargs['dxf_title_blocks']
|
||||
|
||||
# 오류 메시지
|
||||
if 'error_message' in kwargs:
|
||||
self.error_message = kwargs['error_message']
|
||||
|
||||
|
||||
def test_key_integration():
|
||||
"""키 통합 기능 테스트"""
|
||||
print("=== Cross-Tabulated CSV 키 통합 기능 테스트 ===\n")
|
||||
|
||||
# 테스트용 CSV 내보내기 객체 생성
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 테스트 케이스 1: PDF 분석 결과 (키 분리 형태)
|
||||
print("테스트 케이스 1: PDF 분석 결과 (키 분리 → 통합)")
|
||||
|
||||
pdf_analysis_result = {
|
||||
"사업명_value": "고속국도 제30호선 대산~당진 고속도로 건설공사",
|
||||
"사업명_x": "40",
|
||||
"사업명_y": "130",
|
||||
"시설_공구_value": "제2공구 : 온산~사성",
|
||||
"시설_공구_x": "41",
|
||||
"시설_공구_y": "139",
|
||||
"건설분야_value": "토목",
|
||||
"건설분야_x": "199",
|
||||
"건설분야_y": "1069",
|
||||
"건설단계_value": "실시설계",
|
||||
"건설단계_x": "263",
|
||||
"건설단계_y": "1069",
|
||||
"계절자수_value": "1",
|
||||
"계절자수_x": "309",
|
||||
"계절자수_y": "1066",
|
||||
"작성일자_value": "2023. 03",
|
||||
"작성일자_x": "332",
|
||||
"작성일자_y": "1069"
|
||||
}
|
||||
|
||||
mock_pdf_result = MockResult(
|
||||
"황단면도.pdf",
|
||||
"PDF",
|
||||
success=True,
|
||||
pdf_analysis_result=json.dumps(pdf_analysis_result)
|
||||
)
|
||||
|
||||
# 테스트 케이스 2: DXF 분석 결과
|
||||
print("테스트 케이스 2: DXF 분석 결과")
|
||||
|
||||
dxf_title_blocks = [
|
||||
{
|
||||
"block_name": "TITLE_BLOCK",
|
||||
"attributes": [
|
||||
{
|
||||
"tag": "DWG_NO",
|
||||
"text": "A-001",
|
||||
"insert_x": 150,
|
||||
"insert_y": 25
|
||||
},
|
||||
{
|
||||
"tag": "TITLE",
|
||||
"text": "도면제목",
|
||||
"insert_x": 200,
|
||||
"insert_y": 50
|
||||
},
|
||||
{
|
||||
"tag": "SCALE",
|
||||
"text": "1:100",
|
||||
"insert_x": 250,
|
||||
"insert_y": 75
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
mock_dxf_result = MockResult(
|
||||
"도면001.dxf",
|
||||
"DXF",
|
||||
success=True,
|
||||
dxf_title_blocks=dxf_title_blocks
|
||||
)
|
||||
|
||||
# 테스트 케이스 3: 실패한 결과
|
||||
mock_failed_result = MockResult(
|
||||
"오류파일.pdf",
|
||||
"PDF",
|
||||
success=False,
|
||||
error_message="분석 실패"
|
||||
)
|
||||
|
||||
# 모든 테스트 결과를 리스트로 구성
|
||||
test_results = [mock_pdf_result, mock_dxf_result, mock_failed_result]
|
||||
|
||||
# 출력 디렉토리 생성
|
||||
output_dir = "test_results_integrated"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# 테스트 실행: 통합된 CSV 저장
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = os.path.join(output_dir, f"integrated_test_results_{timestamp}.csv")
|
||||
|
||||
print(f"CSV 저장 위치: {output_path}")
|
||||
|
||||
success = exporter.export_cross_tabulated_csv(
|
||||
test_results,
|
||||
output_path,
|
||||
include_coordinates=True,
|
||||
coordinate_source="auto"
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"✅ 테스트 성공: 통합된 CSV 파일이 저장되었습니다.")
|
||||
print(f"📁 파일 위치: {output_path}")
|
||||
|
||||
# 저장된 CSV 파일 내용 미리보기
|
||||
try:
|
||||
with open(output_path, 'r', encoding='utf-8-sig') as f:
|
||||
lines = f.readlines()
|
||||
print(f"\n📋 CSV 파일 미리보기 (상위 10행):")
|
||||
for i, line in enumerate(lines[:10]):
|
||||
print(f"{i+1:2d}: {line.rstrip()}")
|
||||
|
||||
if len(lines) > 10:
|
||||
print(f" ... 총 {len(lines)}행")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ 파일 읽기 오류: {e}")
|
||||
|
||||
else:
|
||||
print("❌ 테스트 실패: CSV 저장에 실패했습니다.")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def test_key_extraction():
|
||||
"""키 추출 및 그룹화 기능 단위 테스트"""
|
||||
print("\n=== 키 추출 및 그룹화 단위 테스트 ===\n")
|
||||
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 테스트 케이스: 다양한 키 형태
|
||||
test_keys = [
|
||||
"사업명_value",
|
||||
"사업명_x",
|
||||
"사업명_y",
|
||||
"시설_공구_val",
|
||||
"시설_공구_x_coord",
|
||||
"시설_공구_y_coord",
|
||||
"건설분야_text",
|
||||
"건설분야_left",
|
||||
"건설분야_top",
|
||||
"일반키",
|
||||
"축척_content",
|
||||
"축척_x_position",
|
||||
"축척_y_position"
|
||||
]
|
||||
|
||||
print("🔍 키 분석 결과:")
|
||||
for key in test_keys:
|
||||
base_key = exporter._extract_base_key(key)
|
||||
key_type = exporter._determine_key_type(key)
|
||||
print(f" {key:20s} → 기본키: '{base_key:12s}' 타입: {key_type}")
|
||||
|
||||
print("\n✅ 키 추출 및 그룹화 단위 테스트 완료")
|
||||
|
||||
|
||||
def test_expected_output():
|
||||
"""예상 출력 형태 테스트"""
|
||||
print("\n=== 예상 출력 형태 확인 ===\n")
|
||||
|
||||
print("📊 기대하는 CSV 출력 형태:")
|
||||
print("file_name,file_type,key,value,x,y")
|
||||
print("황단면도.pdf,PDF,사업명,고속국도 제30호선 대산~당진 고속도로 건설공사,40,130")
|
||||
print("황단면도.pdf,PDF,시설_공구,제2공구 : 온산~사성,41,139")
|
||||
print("황단면도.pdf,PDF,건설분야,토목,199,1069")
|
||||
print("황단면도.pdf,PDF,건설단계,실시설계,263,1069")
|
||||
print("도면001.dxf,DXF,DWG_NO,A-001,150,25")
|
||||
print("도면001.dxf,DXF,TITLE,도면제목,200,50")
|
||||
print("도면001.dxf,DXF,SCALE,1:100,250,75")
|
||||
|
||||
print("\n🎯 개선사항:")
|
||||
print("- 기존: 사업명_value, 사업명_x, 사업명_y가 별도 행으로 분리")
|
||||
print("- 개선: 사업명 하나의 행에 value, x, y 정보 통합")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print("Cross-Tabulated CSV 키 통합 기능 테스트 시작\n")
|
||||
|
||||
# 1. 키 추출 및 그룹화 단위 테스트
|
||||
test_key_extraction()
|
||||
|
||||
# 2. 예상 출력 형태 확인
|
||||
test_expected_output()
|
||||
|
||||
# 3. 실제 통합 기능 테스트
|
||||
success = test_key_integration()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
if success:
|
||||
print("🎉 모든 테스트가 성공적으로 완료되었습니다!")
|
||||
print("✨ 키 통합 기능이 정상적으로 동작합니다.")
|
||||
else:
|
||||
print("😞 테스트 중 일부가 실패했습니다.")
|
||||
print("🔧 코드를 점검해주세요.")
|
||||
print(f"{'='*60}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 테스트 실행 중 오류 발생: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
218
test_key_integration_simple.py
Normal file
218
test_key_integration_simple.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Cross-Tabulated CSV 내보내기 개선 테스트 스크립트 (Simple 버전)
|
||||
키 통합 기능 테스트
|
||||
|
||||
Author: Claude Assistant
|
||||
Created: 2025-07-16
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
# 프로젝트 루트 디렉토리를 Python 경로에 추가
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cross_tabulated_csv_exporter import CrossTabulatedCSVExporter
|
||||
|
||||
class MockResult:
|
||||
"""테스트용 모의 결과 객체"""
|
||||
|
||||
def __init__(self, file_name, file_type, success=True, **kwargs):
|
||||
self.file_name = file_name
|
||||
self.file_type = file_type
|
||||
self.success = success
|
||||
|
||||
# PDF 관련 속성
|
||||
if 'pdf_analysis_result' in kwargs:
|
||||
self.pdf_analysis_result = kwargs['pdf_analysis_result']
|
||||
|
||||
# DXF 관련 속성
|
||||
if 'dxf_title_blocks' in kwargs:
|
||||
self.dxf_title_blocks = kwargs['dxf_title_blocks']
|
||||
|
||||
# 오류 메시지
|
||||
if 'error_message' in kwargs:
|
||||
self.error_message = kwargs['error_message']
|
||||
|
||||
|
||||
def test_key_integration():
|
||||
"""키 통합 기능 테스트"""
|
||||
print("=== Cross-Tabulated CSV 키 통합 기능 테스트 ===")
|
||||
|
||||
# 테스트용 CSV 내보내기 객체 생성
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 테스트 케이스 1: PDF 분석 결과 (키 분리 형태)
|
||||
print("\n테스트 케이스 1: PDF 분석 결과 (키 분리 -> 통합)")
|
||||
|
||||
pdf_analysis_result = {
|
||||
"사업명_value": "고속국도 제30호선 대산~당진 고속도로 건설공사",
|
||||
"사업명_x": "40",
|
||||
"사업명_y": "130",
|
||||
"시설_공구_value": "제2공구 : 온산~사성",
|
||||
"시설_공구_x": "41",
|
||||
"시설_공구_y": "139",
|
||||
"건설분야_value": "토목",
|
||||
"건설분야_x": "199",
|
||||
"건설분야_y": "1069",
|
||||
"건설단계_value": "실시설계",
|
||||
"건설단계_x": "263",
|
||||
"건설단계_y": "1069"
|
||||
}
|
||||
|
||||
mock_pdf_result = MockResult(
|
||||
"황단면도.pdf",
|
||||
"PDF",
|
||||
success=True,
|
||||
pdf_analysis_result=json.dumps(pdf_analysis_result)
|
||||
)
|
||||
|
||||
# 테스트 케이스 2: DXF 분석 결과
|
||||
print("테스트 케이스 2: DXF 분석 결과")
|
||||
|
||||
dxf_title_blocks = [
|
||||
{
|
||||
"block_name": "TITLE_BLOCK",
|
||||
"attributes": [
|
||||
{
|
||||
"tag": "DWG_NO",
|
||||
"text": "A-001",
|
||||
"insert_x": 150,
|
||||
"insert_y": 25
|
||||
},
|
||||
{
|
||||
"tag": "TITLE",
|
||||
"text": "도면제목",
|
||||
"insert_x": 200,
|
||||
"insert_y": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
mock_dxf_result = MockResult(
|
||||
"도면001.dxf",
|
||||
"DXF",
|
||||
success=True,
|
||||
dxf_title_blocks=dxf_title_blocks
|
||||
)
|
||||
|
||||
# 모든 테스트 결과를 리스트로 구성
|
||||
test_results = [mock_pdf_result, mock_dxf_result]
|
||||
|
||||
# 출력 디렉토리 생성
|
||||
output_dir = "test_results_integrated"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# 테스트 실행: 통합된 CSV 저장
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = os.path.join(output_dir, f"integrated_test_results_{timestamp}.csv")
|
||||
|
||||
print(f"\nCSV 저장 위치: {output_path}")
|
||||
|
||||
success = exporter.export_cross_tabulated_csv(
|
||||
test_results,
|
||||
output_path,
|
||||
include_coordinates=True,
|
||||
coordinate_source="auto"
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"SUCCESS: 통합된 CSV 파일이 저장되었습니다.")
|
||||
print(f"파일 위치: {output_path}")
|
||||
|
||||
# 저장된 CSV 파일 내용 미리보기
|
||||
try:
|
||||
with open(output_path, 'r', encoding='utf-8-sig') as f:
|
||||
lines = f.readlines()
|
||||
print(f"\nCSV 파일 미리보기 (상위 10행):")
|
||||
for i, line in enumerate(lines[:10]):
|
||||
print(f"{i+1:2d}: {line.rstrip()}")
|
||||
|
||||
if len(lines) > 10:
|
||||
print(f" ... 총 {len(lines)}행")
|
||||
|
||||
except Exception as e:
|
||||
print(f"WARNING: 파일 읽기 오류: {e}")
|
||||
|
||||
else:
|
||||
print("ERROR: CSV 저장에 실패했습니다.")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def test_key_extraction():
|
||||
"""키 추출 및 그룹화 기능 단위 테스트"""
|
||||
print("\n=== 키 추출 및 그룹화 단위 테스트 ===")
|
||||
|
||||
exporter = CrossTabulatedCSVExporter()
|
||||
|
||||
# 테스트 케이스: 다양한 키 형태
|
||||
test_keys = [
|
||||
"사업명_value",
|
||||
"사업명_x",
|
||||
"사업명_y",
|
||||
"시설_공구_val",
|
||||
"시설_공구_x_coord",
|
||||
"시설_공구_y_coord",
|
||||
"건설분야_text",
|
||||
"건설분야_left",
|
||||
"건설분야_top",
|
||||
"일반키"
|
||||
]
|
||||
|
||||
print("\n키 분석 결과:")
|
||||
for key in test_keys:
|
||||
base_key = exporter._extract_base_key(key)
|
||||
key_type = exporter._determine_key_type(key)
|
||||
print(f" {key:20s} -> 기본키: '{base_key:12s}' 타입: {key_type}")
|
||||
|
||||
print("\n키 추출 및 그룹화 단위 테스트 완료")
|
||||
|
||||
|
||||
def test_expected_output():
|
||||
"""예상 출력 형태 테스트"""
|
||||
print("\n=== 예상 출력 형태 확인 ===")
|
||||
|
||||
print("\n기대하는 CSV 출력 형태:")
|
||||
print("file_name,file_type,key,value,x,y")
|
||||
print("황단면도.pdf,PDF,사업명,고속국도 제30호선 대산~당진 고속도로 건설공사,40,130")
|
||||
print("황단면도.pdf,PDF,시설_공구,제2공구 : 온산~사성,41,139")
|
||||
print("황단면도.pdf,PDF,건설분야,토목,199,1069")
|
||||
print("도면001.dxf,DXF,DWG_NO,A-001,150,25")
|
||||
print("도면001.dxf,DXF,TITLE,도면제목,200,50")
|
||||
|
||||
print("\n개선사항:")
|
||||
print("- 기존: 사업명_value, 사업명_x, 사업명_y가 별도 행으로 분리")
|
||||
print("- 개선: 사업명 하나의 행에 value, x, y 정보 통합")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print("Cross-Tabulated CSV 키 통합 기능 테스트 시작")
|
||||
|
||||
# 1. 키 추출 및 그룹화 단위 테스트
|
||||
test_key_extraction()
|
||||
|
||||
# 2. 예상 출력 형태 확인
|
||||
test_expected_output()
|
||||
|
||||
# 3. 실제 통합 기능 테스트
|
||||
success = test_key_integration()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
if success:
|
||||
print("SUCCESS: 모든 테스트가 성공적으로 완료되었습니다!")
|
||||
print("키 통합 기능이 정상적으로 동작합니다.")
|
||||
else:
|
||||
print("ERROR: 테스트 중 일부가 실패했습니다.")
|
||||
print("코드를 점검해주세요.")
|
||||
print(f"{'='*60}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: 테스트 실행 중 오류 발생: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
BIN
testsample/1.pdf
Normal file
BIN
testsample/1.pdf
Normal file
Binary file not shown.
BIN
testsample/2.pdf
Normal file
BIN
testsample/2.pdf
Normal file
Binary file not shown.
3826
testsample/test_drawing.dxf
Normal file
3826
testsample/test_drawing.dxf
Normal file
File diff suppressed because it is too large
Load Diff
2398
ui_components.py
Normal file
2398
ui_components.py
Normal file
File diff suppressed because it is too large
Load Diff
288
utils.py
Normal file
288
utils.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
유틸리티 함수 모듈
|
||||
공통으로 사용되는 유틸리티 함수들을 제공합니다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class FileUtils:
|
||||
"""파일 관련 유틸리티 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def ensure_directory_exists(directory_path: str) -> bool:
|
||||
"""디렉토리가 존재하지 않으면 생성"""
|
||||
try:
|
||||
Path(directory_path).mkdir(parents=True, exist_ok=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"디렉토리 생성 실패 {directory_path}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_safe_filename(filename: str) -> str:
|
||||
"""안전한 파일명 생성 (특수문자 제거)"""
|
||||
import re
|
||||
# 특수문자를 언더스코어로 치환
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
# 연속된 언더스코어를 하나로 축약
|
||||
safe_name = re.sub(r'_+', '_', safe_name)
|
||||
# 앞뒤 언더스코어 제거
|
||||
return safe_name.strip('_')
|
||||
|
||||
@staticmethod
|
||||
def get_file_size_mb(file_path: str) -> float:
|
||||
"""파일 크기를 MB 단위로 반환"""
|
||||
try:
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
return round(size_bytes / (1024 * 1024), 2)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def save_text_file(file_path: str, content: str, encoding: str = 'utf-8') -> bool:
|
||||
"""텍스트 파일 저장"""
|
||||
try:
|
||||
with open(file_path, 'w', encoding=encoding) as f:
|
||||
f.write(content)
|
||||
logger.info(f"텍스트 파일 저장 완료: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"텍스트 파일 저장 실패 {file_path}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def save_json_file(file_path: str, data: Dict[str, Any], indent: int = 2) -> bool:
|
||||
"""JSON 파일 저장"""
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=indent)
|
||||
logger.info(f"JSON 파일 저장 완료: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"JSON 파일 저장 실패 {file_path}: {e}")
|
||||
return False
|
||||
|
||||
class AnalysisResultSaver:
|
||||
"""분석 결과 저장 클래스"""
|
||||
|
||||
def __init__(self, output_dir: str = "results"):
|
||||
self.output_dir = Path(output_dir)
|
||||
FileUtils.ensure_directory_exists(str(self.output_dir))
|
||||
|
||||
def save_analysis_results(
|
||||
self,
|
||||
pdf_filename: str,
|
||||
analysis_results: Dict[int, str],
|
||||
pdf_info: Dict[str, Any],
|
||||
analysis_settings: Dict[str, Any]
|
||||
) -> Optional[str]:
|
||||
"""분석 결과를 파일로 저장"""
|
||||
try:
|
||||
# 안전한 파일명 생성
|
||||
safe_filename = FileUtils.get_safe_filename(
|
||||
Path(pdf_filename).stem
|
||||
)
|
||||
|
||||
# 타임스탬프 추가
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
result_filename = f"{safe_filename}_analysis_{timestamp}.txt"
|
||||
result_path = self.output_dir / result_filename
|
||||
|
||||
# 결과 텍스트 구성
|
||||
content = self._format_analysis_content(
|
||||
pdf_filename, analysis_results, pdf_info, analysis_settings
|
||||
)
|
||||
|
||||
# 파일 저장
|
||||
if FileUtils.save_text_file(str(result_path), content):
|
||||
return str(result_path)
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"분석 결과 저장 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def save_analysis_json(
|
||||
self,
|
||||
pdf_filename: str,
|
||||
analysis_results: Dict[int, str],
|
||||
pdf_info: Dict[str, Any],
|
||||
analysis_settings: Dict[str, Any]
|
||||
) -> Optional[str]:
|
||||
"""분석 결과를 JSON으로 저장"""
|
||||
try:
|
||||
safe_filename = FileUtils.get_safe_filename(
|
||||
Path(pdf_filename).stem
|
||||
)
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
json_filename = f"{safe_filename}_analysis_{timestamp}.json"
|
||||
json_path = self.output_dir / json_filename
|
||||
|
||||
# JSON 데이터 구성
|
||||
json_data = {
|
||||
"analysis_info": {
|
||||
"pdf_filename": pdf_filename,
|
||||
"analysis_date": datetime.datetime.now().isoformat(),
|
||||
"total_pages_analyzed": len(analysis_results)
|
||||
},
|
||||
"pdf_info": pdf_info,
|
||||
"analysis_settings": analysis_settings,
|
||||
"results": {
|
||||
f"page_{page_num + 1}": result
|
||||
for page_num, result in analysis_results.items()
|
||||
}
|
||||
}
|
||||
|
||||
if FileUtils.save_json_file(str(json_path), json_data):
|
||||
return str(json_path)
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"JSON 결과 저장 중 오류: {e}")
|
||||
return None
|
||||
|
||||
def _format_analysis_content(
|
||||
self,
|
||||
pdf_filename: str,
|
||||
analysis_results: Dict[int, str],
|
||||
pdf_info: Dict[str, Any],
|
||||
analysis_settings: Dict[str, Any]
|
||||
) -> str:
|
||||
"""분석 결과를 텍스트 형식으로 포맷팅"""
|
||||
|
||||
content = []
|
||||
|
||||
# 헤더
|
||||
content.append("=" * 80)
|
||||
content.append("PDF 도면 분석 결과 보고서")
|
||||
content.append("=" * 80)
|
||||
content.append("")
|
||||
|
||||
# 기본 정보
|
||||
content.append("📄 파일 정보")
|
||||
content.append("-" * 40)
|
||||
content.append(f"파일명: {pdf_filename}")
|
||||
content.append(f"총 페이지 수: {pdf_info.get('page_count', 'N/A')}")
|
||||
content.append(f"파일 크기: {pdf_info.get('file_size', 'N/A')} bytes")
|
||||
content.append(f"분석 일시: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
content.append("")
|
||||
|
||||
# 분석 설정
|
||||
content.append("⚙️ 분석 설정")
|
||||
content.append("-" * 40)
|
||||
for key, value in analysis_settings.items():
|
||||
content.append(f"{key}: {value}")
|
||||
content.append("")
|
||||
|
||||
# 분석 결과
|
||||
content.append("🔍 분석 결과")
|
||||
content.append("-" * 40)
|
||||
|
||||
for page_num, result in analysis_results.items():
|
||||
content.append(f"\n📋 페이지 {page_num + 1} 분석 결과:")
|
||||
content.append("=" * 50)
|
||||
content.append(result)
|
||||
content.append("")
|
||||
|
||||
# 푸터
|
||||
content.append("=" * 80)
|
||||
content.append("분석 완료")
|
||||
content.append("=" * 80)
|
||||
|
||||
return "\n".join(content)
|
||||
|
||||
class DateTimeUtils:
|
||||
"""날짜/시간 관련 유틸리티 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def get_timestamp() -> str:
|
||||
"""현재 타임스탬프 반환 (YYYY-MM-DD HH:MM:SS 형식)"""
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@staticmethod
|
||||
def get_filename_timestamp() -> str:
|
||||
"""파일명용 타임스탬프 반환 (YYYYMMDD_HHMMSS 형식)"""
|
||||
return datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
@staticmethod
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""초를 읽기 쉬운 형식으로 변환"""
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}초"
|
||||
elif seconds < 3600:
|
||||
minutes = seconds / 60
|
||||
return f"{minutes:.1f}분"
|
||||
else:
|
||||
hours = seconds / 3600
|
||||
return f"{hours:.1f}시간"
|
||||
|
||||
class TextUtils:
|
||||
"""텍스트 처리 유틸리티 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
|
||||
"""텍스트를 지정된 길이로 자르기"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
@staticmethod
|
||||
def clean_text(text: str) -> str:
|
||||
"""텍스트 정리 (불필요한 공백 제거 등)"""
|
||||
import re
|
||||
# 연속된 공백을 하나로 축약
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
# 앞뒤 공백 제거
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def word_count(text: str) -> int:
|
||||
"""단어 수 계산"""
|
||||
return len(text.split())
|
||||
|
||||
@staticmethod
|
||||
def char_count(text: str, exclude_spaces: bool = False) -> int:
|
||||
"""문자 수 계산"""
|
||||
if exclude_spaces:
|
||||
return len(text.replace(' ', ''))
|
||||
return len(text)
|
||||
|
||||
class ValidationUtils:
|
||||
"""검증 관련 유틸리티 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def is_valid_api_key(api_key: str) -> bool:
|
||||
"""API 키 형식 검증"""
|
||||
if not api_key or not isinstance(api_key, str):
|
||||
return False
|
||||
# 기본적인 형식 검증 (실제 검증은 API 호출 시 수행)
|
||||
return len(api_key.strip()) > 10
|
||||
|
||||
@staticmethod
|
||||
def is_valid_file_size(file_size_bytes: int, max_size_mb: int) -> bool:
|
||||
"""파일 크기 검증"""
|
||||
max_size_bytes = max_size_mb * 1024 * 1024
|
||||
return file_size_bytes <= max_size_bytes
|
||||
|
||||
@staticmethod
|
||||
def is_valid_pdf_extension(filename: str) -> bool:
|
||||
"""PDF 파일 확장자 검증"""
|
||||
return Path(filename).suffix.lower() == '.pdf'
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
# 파일 유틸리티 테스트
|
||||
print("유틸리티 함수 테스트:")
|
||||
print(f"타임스탬프: {DateTimeUtils.get_timestamp()}")
|
||||
print(f"파일명 타임스탬프: {DateTimeUtils.get_filename_timestamp()}")
|
||||
print(f"안전한 파일명: {FileUtils.get_safe_filename('test<file>name.pdf')}")
|
||||
print(f"텍스트 축약: {TextUtils.truncate_text('이것은 긴 텍스트입니다' * 10, 50)}")
|
||||
Reference in New Issue
Block a user