VOC_Monitor/app/utils/chart_generator.py

495 lines
16 KiB
Python

"""
차트 생성 유틸리티
matplotlib 및 wordcloud를 사용하여 VOC 통계 차트를 생성합니다.
한글 폰트 설정, 메모리 관리, 파일 저장 등을 처리합니다.
주요 기능:
- 막대 그래프 (부서별, 상태별)
- 선 그래프 (시계열 추이)
- 파이 차트 (비율)
- 워드 클라우드 (키워드)
작성자: KH.Choi
최종 수정: 2026-02-18
버전: 1.0
"""
import os
from pathlib import Path
from typing import Dict, List, Optional
import matplotlib
matplotlib.use('Agg') # GUI 없이 백그라운드에서 실행
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
from collections import Counter
from utils.logger import get_logger
from utils.path_utils import get_data_dir
class ChartGenerator:
"""
차트 생성 유틸리티 클래스
matplotlib을 사용하여 다양한 형태의 차트를 생성합니다.
한글 폰트 설정, 메모리 관리, 파일 저장을 자동으로 처리합니다.
Attributes:
logger: 로거 인스턴스
output_dir (Path): 차트 저장 디렉토리
font_name (str): 사용할 한글 폰트명
주요 메서드:
create_bar_chart: 막대 그래프 생성
create_line_chart: 선 그래프 생성
create_pie_chart: 파이 차트 생성
create_wordcloud: 워드 클라우드 생성
사용 예시:
>>> generator = ChartGenerator()
>>> data = {"차량": 80, "시설": 50, "역무": 20}
>>> path = generator.create_bar_chart(data, "부서별 VOC 현황")
"""
def __init__(self, output_dir: Optional[Path] = None):
"""
차트 생성기 초기화
Args:
output_dir (Path, optional): 차트 저장 디렉토리. 기본값은 data/charts
"""
self.logger = get_logger("ChartGenerator")
self.output_dir = output_dir or (get_data_dir() / "charts")
self.output_dir.mkdir(parents=True, exist_ok=True)
# 한글 폰트 설정
self._setup_korean_font()
def _setup_korean_font(self):
"""
한글 폰트 설정
시스템에 설치된 한글 폰트를 찾아 matplotlib에 설정합니다.
폰트를 찾지 못하면 기본 폰트를 사용합니다.
"""
try:
# Windows 기본 한글 폰트 시도
font_candidates = [
'Malgun Gothic', # 맑은 고딕
'NanumGothic', # 나눔고딕
'NanumBarunGothic',
'Gulim', # 굴림
'Dotum' # 돋움
]
available_fonts = [f.name for f in fm.fontManager.ttflist]
for font in font_candidates:
if font in available_fonts:
plt.rcParams['font.family'] = font
self.font_name = font
self.logger.info(f"한글 폰트 설정: {font}")
break
else:
# 한글 폰트를 찾지 못한 경우
self.logger.warning("한글 폰트를 찾을 수 없습니다. 기본 폰트를 사용합니다.")
self.font_name = plt.rcParams['font.family']
# 마이너스 기호 깨짐 방지
plt.rcParams['axes.unicode_minus'] = False
except Exception as e:
self.logger.error(f"폰트 설정 실패: {e}")
self.font_name = None
def create_bar_chart(
self,
data: Dict[str, int],
title: str,
xlabel: str = "항목",
ylabel: str = "건수",
filename: Optional[str] = None
) -> str:
"""
막대 그래프 생성
Args:
data: 데이터 딕셔너리 {항목명: 값}
title: 차트 제목
xlabel: X축 레이블
ylabel: Y축 레이블
filename: 저장 파일명 (선택, 기본값은 자동 생성)
Returns:
str: 저장된 파일 경로
Raises:
ValueError: 데이터가 비어있는 경우
"""
if not data:
raise ValueError("차트 생성을 위한 데이터가 비어있습니다.")
try:
# 그래프 생성
fig, ax = plt.subplots(figsize=(10, 6))
items = list(data.keys())
values = list(data.values())
bars = ax.bar(items, values, color='steelblue', alpha=0.8)
# 막대 위에 값 표시
for bar in bars:
height = bar.get_height()
ax.text(
bar.get_x() + bar.get_width() / 2.,
height,
f'{int(height)}',
ha='center',
va='bottom',
fontsize=10
)
ax.set_xlabel(xlabel, fontsize=12)
ax.set_ylabel(ylabel, fontsize=12)
ax.set_title(title, fontsize=14, fontweight='bold')
ax.grid(axis='y', alpha=0.3)
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
# 파일 저장
if not filename:
filename = f"bar_chart_{title.replace(' ', '_')}.png"
filepath = self.output_dir / filename
plt.savefig(filepath, dpi=150, bbox_inches='tight')
plt.close(fig) # 메모리 해제
self.logger.info(f"막대 그래프 생성 완료: {filepath}")
return str(filepath)
except Exception as e:
self.logger.error(f"막대 그래프 생성 실패: {e}")
plt.close('all') # 에러 시에도 메모리 해제
raise
def create_line_chart(
self,
data: Dict[str, int],
title: str,
xlabel: str = "날짜",
ylabel: str = "건수",
filename: Optional[str] = None
) -> str:
"""
선 그래프 생성 (시계열 데이터)
Args:
data: 데이터 딕셔너리 {날짜: 값}
title: 차트 제목
xlabel: X축 레이블
ylabel: Y축 레이블
filename: 저장 파일명 (선택)
Returns:
str: 저장된 파일 경로
"""
if not data:
raise ValueError("차트 생성을 위한 데이터가 비어있습니다.")
try:
fig, ax = plt.subplots(figsize=(12, 6))
dates = list(data.keys())
values = list(data.values())
ax.plot(dates, values, marker='o', linewidth=2, markersize=6, color='steelblue')
# 데이터 포인트에 값 표시 (데이터가 많지 않을 때만)
if len(dates) <= 31:
for i, (date, value) in enumerate(zip(dates, values)):
ax.text(i, value, str(value), ha='center', va='bottom', fontsize=9)
ax.set_xlabel(xlabel, fontsize=12)
ax.set_ylabel(ylabel, fontsize=12)
ax.set_title(title, fontsize=14, fontweight='bold')
ax.grid(True, alpha=0.3)
# X축 레이블 회전 (날짜가 많을 때)
if len(dates) > 10:
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
if not filename:
filename = f"line_chart_{title.replace(' ', '_')}.png"
filepath = self.output_dir / filename
plt.savefig(filepath, dpi=150, bbox_inches='tight')
plt.close(fig)
self.logger.info(f"선 그래프 생성 완료: {filepath}")
return str(filepath)
except Exception as e:
self.logger.error(f"선 그래프 생성 실패: {e}")
plt.close('all')
raise
def create_pie_chart(
self,
data: Dict[str, int],
title: str,
filename: Optional[str] = None
) -> str:
"""
파이 차트 생성
Args:
data: 데이터 딕셔너리 {항목명: 값}
title: 차트 제목
filename: 저장 파일명 (선택)
Returns:
str: 저장된 파일 경로
"""
if not data:
raise ValueError("차트 생성을 위한 데이터가 비어있습니다.")
try:
fig, ax = plt.subplots(figsize=(10, 8))
labels = list(data.keys())
sizes = list(data.values())
# 색상 팔레트
colors = plt.cm.Set3(range(len(labels)))
# 파이 차트 생성
wedges, texts, autotexts = ax.pie(
sizes,
labels=labels,
autopct='%1.1f%%',
startangle=90,
colors=colors,
textprops={'fontsize': 11}
)
# 퍼센트 텍스트 스타일
for autotext in autotexts:
autotext.set_color('white')
autotext.set_fontweight('bold')
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
plt.tight_layout()
if not filename:
filename = f"pie_chart_{title.replace(' ', '_')}.png"
filepath = self.output_dir / filename
plt.savefig(filepath, dpi=150, bbox_inches='tight')
plt.close(fig)
self.logger.info(f"파이 차트 생성 완료: {filepath}")
return str(filepath)
except Exception as e:
self.logger.error(f"파이 차트 생성 실패: {e}")
plt.close('all')
raise
def create_wordcloud(
self,
texts: List[str],
title: str,
max_words: int = 50,
filename: Optional[str] = None
) -> str:
"""
워드 클라우드 생성
Args:
texts: 텍스트 리스트
title: 차트 제목
max_words: 최대 단어 수
filename: 저장 파일명 (선택)
Returns:
str: 저장된 파일 경로
Note:
wordcloud 라이브러리가 설치되어 있어야 합니다.
한글 불용어 제거 및 빈도 기반 생성을 지원합니다.
"""
if not texts:
raise ValueError("워드 클라우드 생성을 위한 텍스트가 비어있습니다.")
try:
# wordcloud 라이브러리 import (선택적 의존성)
try:
from wordcloud import WordCloud
except ImportError:
self.logger.warning("wordcloud 라이브러리가 설치되지 않았습니다. 간단한 빈도 차트를 생성합니다.")
return self._create_keyword_bar_chart(texts, title, max_words, filename)
# 텍스트 전처리 및 단어 추출
word_freq = self._extract_word_frequencies(texts)
if not word_freq:
self.logger.warning("추출된 키워드가 없습니다. 빈 차트를 생성합니다.")
raise ValueError("유효한 키워드가 없습니다.")
# 워드 클라우드 생성 (빈도 딕셔너리 사용)
wordcloud = WordCloud(
font_path=self._get_font_path(),
width=1200,
height=800,
background_color='white',
max_words=max_words,
relative_scaling=0.3,
min_font_size=10,
colormap='viridis',
prefer_horizontal=0.7,
collocations=False # 단어 조합 비활성화
).generate_from_frequencies(word_freq)
# 그래프 생성
fig, ax = plt.subplots(figsize=(14, 10))
ax.imshow(wordcloud, interpolation='bilinear')
ax.axis('off')
ax.set_title(title, fontsize=16, fontweight='bold', pad=20)
plt.tight_layout(pad=0)
if not filename:
filename = f"wordcloud_{title.replace(' ', '_')}.png"
filepath = self.output_dir / filename
plt.savefig(filepath, dpi=200, bbox_inches='tight', facecolor='white')
plt.close(fig)
self.logger.info(f"워드 클라우드 생성 완료: {filepath} (단어 수: {len(word_freq)})")
return str(filepath)
except Exception as e:
self.logger.error(f"워드 클라우드 생성 실패: {e}")
plt.close('all')
raise
def _extract_word_frequencies(self, texts: List[str]) -> dict:
"""
텍스트에서 단어 빈도 추출 (내부 메서드)
Args:
texts: 텍스트 리스트
Returns:
dict: {단어: 빈도}
"""
# 한글 불용어 (조사, 접속사 등)
stopwords = {
# 조사
"", "", "", "", "", "", "", "에서", "으로", "", "", "",
"", "", "까지", "부터", "께서", "에게", "한테", "보다", "처럼",
# 접속사/부사
"그리고", "그러나", "하지만", "또한", "", "", "", "",
# 기타
"", "", "등등", "때문", "위해", "대한", "통해", "관련", "경우",
"있습니다", "없습니다", "합니다", "됩니다", "입니다",
# VOC 관련 일반 단어 (너무 흔한 것들)
"고객", "민원", "문의", "요청", "답변", "처리", "확인"
}
all_words = []
for text in texts:
if not text:
continue
# 공백 기준 단어 분리
words = text.split()
for word in words:
# 길이 체크 (2글자 이상, 15글자 이하)
if len(word) < 2 or len(word) > 15:
continue
# 불용어 제거
if word in stopwords:
continue
# 숫자만 있는 단어 제거
if word.isdigit():
continue
# 특수문자만 있는 단어 제거
if not any(c.isalnum() for c in word):
continue
all_words.append(word)
# 빈도 계산
word_counts = Counter(all_words)
# 빈도가 2 이상인 단어만 (너무 드문 단어 제거)
filtered_counts = {word: count for word, count in word_counts.items() if count >= 2}
return filtered_counts
def _create_keyword_bar_chart(
self,
texts: List[str],
title: str,
top_n: int = 10,
filename: Optional[str] = None
) -> str:
"""
키워드 빈도 막대 그래프 생성 (워드 클라우드 대체)
Args:
texts: 텍스트 리스트
title: 차트 제목
top_n: 상위 N개 키워드
filename: 저장 파일명
Returns:
str: 저장된 파일 경로
"""
# 간단한 단어 추출 (공백 기준)
all_words = []
for text in texts:
words = text.split()
# 2글자 이상만 추출
all_words.extend([w for w in words if len(w) >= 2])
# 빈도 계산
word_counts = Counter(all_words)
top_words = dict(word_counts.most_common(top_n))
# 막대 그래프 생성
return self.create_bar_chart(
top_words,
f"{title} (TOP {top_n})",
xlabel="키워드",
ylabel="빈도",
filename=filename or f"keyword_bar_{title.replace(' ', '_')}.png"
)
def _get_font_path(self) -> Optional[str]:
"""
한글 폰트 경로 반환
Returns:
str: 폰트 파일 경로 (없으면 None)
"""
if not self.font_name:
return None
for font in fm.fontManager.ttflist:
if font.name == self.font_name:
return font.fname
return None