From 50d9a845401532c0e01546e8e1640863073df794 Mon Sep 17 00:00:00 2001 From: kkt Date: Sat, 20 Jun 2026 12:46:39 +0900 Subject: [PATCH] =?UTF-8?q?README.md=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1419 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 1065 insertions(+), 354 deletions(-) diff --git a/README.md b/README.md index 36f50ab..dc10c32 100644 --- a/README.md +++ b/README.md @@ -1,375 +1,1086 @@ -# 한세로 Git 저장소 관리 규칙 +import csv +import logging +import os +import random +import shutil +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import cv2 +from PIL import Image, ImageDraw, ImageFont +from moviepy.editor import ( + AudioFileClip, + CompositeVideoClip, + TextClip, + VideoFileClip, + concatenate_videoclips, +) +from PyQt5.QtCore import QObject, QThread, pyqtSignal +from PyQt5.QtWidgets import ( + QApplication, + QCheckBox, + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QProgressBar, + QPushButton, + QSpinBox, + QTextEdit, + QVBoxLayout, + QWidget, +) + + +BASE_DIR = Path(__file__).resolve().parent +OUTPUT_DIR = BASE_DIR / "output" +IMAGES_DIR = OUTPUT_DIR / "images" +QUIZ_DIR = OUTPUT_DIR / "quiz" +FINAL_DIR = OUTPUT_DIR / "final" +WITH_AUDIO_DIR = OUTPUT_DIR / "with_audio" +WITH_HINT_DIR = OUTPUT_DIR / "with_hint" +LOG_DIR = OUTPUT_DIR / "logs" +FINAL_MERGED_DIR = OUTPUT_DIR / "merged" +WORD_HISTORY_DIR = BASE_DIR / "Word_list" +DEFAULT_WORD_LIST_FILE = str(BASE_DIR / "word_list.txt") +DEFAULT_DUMMY_WORD_FILE = str(BASE_DIR / "dumy_word.csv") +DEFAULT_FONT_PATH = str(BASE_DIR / "KCC-Ganpan.ttf") +DEFAULT_TEST_WORD_FILE = str(WORD_HISTORY_DIR / "2024-05-13-1.txt") + +DEFAULT_TEST_MODE = False +DEFAULT_PROBLEM_NO = 8 +DEFAULT_GRID_WIDTH = 6 +DEFAULT_GRID_HEIGHT = 4 +MAX_WORD_PLACEMENT_ATTEMPTS = 200 +MAX_GRID_RETRY = 30 + +DIRECTIONS_BY_DIFFICULTY = { + 1: ["horizontal", "vertical", "diagonal", "diagonal_reverse"], + 2: [ + "horizontal", + "vertical", + "diagonal", + "diagonal_reverse", + "horizontal_reverse", + "vertical_reverse", + ], + 3: [ + "horizontal", + "vertical", + "diagonal", + "diagonal_reverse", + "horizontal_reverse", + "vertical_reverse", + "diagonal_up_right", + "diagonal_up_left", + ], +} + +logger = logging.getLogger("quiz_maker") +logger.setLevel(logging.INFO) +logger.propagate = False + + +class LogEmitter(QObject): + log_signal = pyqtSignal(str) + + +class UiLogHandler(logging.Handler): + def __init__(self, emitter): + super().__init__() + self.emitter = emitter + + def emit(self, record): + msg = self.format(record) + self.emitter.log_signal.emit(msg) + + +def setup_logging(): + LOG_DIR.mkdir(parents=True, exist_ok=True) + formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + + if logger.handlers: + return + + session_log = LOG_DIR / f"quiz_maker_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" + file_handler = logging.FileHandler(session_log, encoding="utf-8") + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + + logger.info("로그 초기화 완료: %s", session_log) + + +setup_logging() + + +TestMode = DEFAULT_TEST_MODE +Prablem_No = DEFAULT_PROBLEM_NO + + +def ensure_directory(path: Path): + path.mkdir(parents=True, exist_ok=True) + + +def ensure_output_dirs(): + for path in [OUTPUT_DIR, IMAGES_DIR, QUIZ_DIR, FINAL_DIR, WITH_AUDIO_DIR, WITH_HINT_DIR, LOG_DIR, FINAL_MERGED_DIR]: + ensure_directory(path) + -한세로 Git 저장소는 프로그램 소스 코드와 수정 이력을 관리하기 위한 공간입니다. -기존 NAS 폴더 구조는 그대로 유지하되, Git 저장소는 프로그램 단위로 생성합니다. +def clear_directory(path: Path): + ensure_directory(path) + removed = 0 + for entry in path.iterdir(): + if entry.is_file(): + entry.unlink() + removed += 1 + logger.info("출력 폴더 정리: %s (%s개 파일 삭제)", path, removed) + + +def resolve_existing_dir(*candidates: Path): + for candidate in candidates: + if candidate.exists() and candidate.is_dir(): + return candidate + return candidates[0] ---- -## 1. 기본 원칙 +def resolve_existing_file(*candidates: Path): + for candidate in candidates: + if candidate.exists() and candidate.is_file(): + return candidate + return candidates[0] -* 저장소는 프로그램 단위로 생성한다. -* 저장소 이름은 영어 소문자, 숫자, 하이픈만 사용한다. -* 저장소 설명은 한글로 작성한다. -* 설치파일, 실행파일, 빌드 결과물, 대용량 데이터는 Git에 올리지 않는다. -* 실제 현장 설정값, 비밀번호, 인증서, API Key는 Git에 올리지 않는다. ---- +def get_audio_dir() -> Path: + return resolve_existing_dir(BASE_DIR / "audios", BASE_DIR / "Audios") -## 2. 대분류 규칙 -저장소 이름의 첫 번째 항목은 대분류 코드로 작성한다. +def get_videos_dir() -> Path: + return resolve_existing_dir(BASE_DIR / "videos", BASE_DIR / "Videos") -| 대분류 | 의미 | -| ------- | ------------------ | -| hmc | 현대자동차 | -| kmc | 기아자동차 | -| hansero | 한세로 사내 | -| etc | 기타 고객사 | -기타 고객사는 `etc` 뒤에 고객사명을 소분류로 작성한다. +def get_base_video_dir() -> Path: + return resolve_existing_dir(BASE_DIR / "long" / "base", BASE_DIR / "Long" / "base", BASE_DIR / "base") -예: -```text -etc-mobis-anyang-assembly1-vision-inspection -etc-sampyo-cheongju-silo-level-inspection -etc-kmmg-georgia-assembly1-vision-inspection -``` +def validate_resources(params): + problems = [] + checks = [ + (Path(params["word_list_file"]), "단어 리스트 파일"), + (Path(DEFAULT_DUMMY_WORD_FILE), "더미 글자 파일"), + (Path(DEFAULT_FONT_PATH), "폰트 파일"), + (WORD_HISTORY_DIR, "단어 이력 폴더"), + (get_audio_dir(), "오디오 폴더"), + (get_videos_dir(), "인트로/엔딩 영상 폴더"), + (get_base_video_dir(), "배경 영상 폴더"), + ] ---- + if params["test_mode"]: + checks.append((Path(DEFAULT_TEST_WORD_FILE), "테스트 모드 단어 파일")) -## 3. 저장소 이름 형식 + for path, label in checks: + if not path.exists(): + problems.append(f"- {label} 없음: {path}") -### 현대자동차 / 기아자동차 + intro_file = get_videos_dir() / "인트로.mp4" + ending_file = get_videos_dir() / "엔딩.mp4" + for path, label in [(intro_file, "인트로 영상"), (ending_file, "엔딩 영상")]: + if not path.exists(): + problems.append(f"- {label} 없음: {path}") -```text -대분류-지역-공장구분-프로그램명 -``` + for i in range(1, params["problem_no"] + 1): + base_video = get_base_video_dir() / f"{i}.mp4" + if not base_video.exists(): + problems.append(f"- 문제 {i} 배경 영상 없음: {base_video}") -예: + return problems -```text -hmc-ulsan-assembly2-bolt-inspection -hmc-jeonju-body1-underbody-inspection -kmc-gwangju-assembly1-misassembly-inspection -``` -### 한세로 사내 / 공통 개발 +class QuizMakerThread(QThread): + progress_signal = pyqtSignal(int, str) + finished_signal = pyqtSignal() + error_signal = pyqtSignal(str) -```text -hansero-프로그램명 -``` + def __init__(self, params): + super().__init__() + self.params = params -예: + def emit_progress(self, value, message): + logger.info("진행률 %s%% - %s", value, message) + self.progress_signal.emit(value, message) -```text -hansero-hai-studio -hansero-groupware -hansero-chat -hansero-visioncore-sdk -hansero-ai-runtime -``` - -### 기타 고객사 - -```text -etc-고객사-지역-공장구분-프로그램명 -``` - -예: - -```text -etc-mobis-anyang-assembly1-vision-inspection -etc-sampyo-cheongju-silo-level-inspection -etc-kmmg-georgia-assembly1-vision-inspection -``` - ---- - -## 4. 지역 코드 - -| 지역 | 코드 | -| ------ | --------------- | -| 울산 | ulsan | -| 전주 | jeonju | -| 광주 | gwangju | -| 남양 | namyang | -| 의왕 | uiwang | -| 안양 | anyang | -| 청주 | cheongju | -| 중국 광저우 | china-guangzhou | -| 체코 | czech | -| 미국 조지아 | georgia | -| 해외 | overseas | - -해외 공장은 별도 대분류를 만들지 않고 지역 코드에 국가 또는 도시명을 포함한다. - -예: - -```text -hmc-china-guangzhou-assembly1-vision-inspection -kmc-georgia-assembly1-vision-inspection -etc-mobis-czech-assembly1-vision-inspection -``` - ---- - -## 5. 공장구분 코드 - -| 공장 구분 | 코드 | -| ----- | ------------ | -| 의장 | assembly | -| 차체 | body | -| 도장 | paint | -| 프레스 | press | -| 엔진 | engine | -| 변속기 | transmission | -| 배터리 | battery | -| 품질 | quality | -| 연구소 | research | -| 사무/관리 | office | - -공장 번호가 있으면 공장구분 뒤에 숫자를 붙인다. - -예: - -```text -assembly1 -assembly2 -body1 -body2 -paint1 -engine1 -research -office -``` - ---- - -## 6. 프로그램명 작성 규칙 - -프로그램명은 기능을 알 수 있게 작성한다. - -예: - -```text -bolt-inspection -pin-inspection -label-inspection -underbody-inspection -misassembly-inspection -bead-inspection -silo-level-inspection -vision-system -mes-interface -groupware -chat -hai-studio -visioncore-sdk -ai-runtime -``` - ---- - -## 7. 저장소 이름 예시 - -### 현대자동차 - -```text -hmc-ulsan-assembly2-bolt-inspection -hmc-ulsan-body2-pin-inspection -hmc-jeonju-body1-underbody-inspection -hmc-namyang-research-vision-system -``` - -### 기아자동차 - -```text -kmc-gwangju-assembly1-misassembly-inspection -kmc-gwangju-body1-vision-inspection -``` - -### 한세로 - -```text -hansero-hai-studio -hansero-groupware -hansero-chat -hansero-visioncore-sdk -hansero-ai-runtime -``` - -### 기타 고객사 - -```text -etc-mobis-anyang-assembly1-vision-inspection -etc-sampyo-cheongju-silo-level-inspection -etc-kmmg-georgia-assembly1-vision-inspection -``` - ---- - -## 8. 저장소 설명 작성 규칙 - -저장소 설명은 한글로 작성한다. -설명에는 고객사, 지역, 공장, 프로그램 기능을 사람이 알아보기 쉽게 적는다. - -예: - -| 저장소명 | 설명 | -| -------------------------------------------- | ------------------------------- | -| hmc-ulsan-assembly2-bolt-inspection | 현대자동차 울산 의장2공장 볼트 검사 프로그램 | -| hmc-jeonju-body1-underbody-inspection | 현대자동차 전주 차체1공장 하부 검사 프로그램 | -| kmc-gwangju-assembly1-misassembly-inspection | 기아자동차 광주 의장1공장 오장착 검사 프로그램 | -| etc-mobis-anyang-assembly1-vision-inspection | 현대모비스 안양 의장1공장 비전 검사 프로그램 | -| hansero-hai-studio | 한세로 AI 트레이너 / VisionCore Studio | -| hansero-groupware | 한세로 그룹웨어 | - ---- - -## 9. 검색 기준 - -Gitea에서 저장소를 찾을 때는 저장소 이름을 우선 기준으로 검색한다. -따라서 저장소 이름에는 검색에 필요한 핵심 키워드를 포함한다. - -검색 가능한 키워드 예: - -```text -hmc -kmc -hansero -etc -ulsan -jeonju -gwangju -assembly2 -body1 -bolt -pin -inspection -groupware -hai-studio -``` - -한글 검색을 위해 저장소 설명에도 고객사, 지역, 공장, 프로그램명을 한글로 작성한다. - -예: - -```text -저장소명: hmc-ulsan-assembly2-bolt-inspection -설명: 현대자동차 울산 의장2공장 볼트 검사 프로그램 -``` - ---- - -## 10. Git에 올릴 것 - -아래 항목은 Git에 올린다. - -```text -소스 코드 -프로젝트 파일 -솔루션 파일 -README.md -설정 샘플 파일 -DB 스크립트 -간단한 문서 -간단한 샘플 데이터 -``` - ---- - -## 11. Git에 올리지 않을 것 - -아래 항목은 Git에 올리지 않는다. - -```text -bin -obj -.vs -exe -dll -zip -msi -설치파일 -빌드 결과물 -런타임 폴더 -학습 데이터 -학습 모델 -대량 이미지 -로그 파일 -현장 백업 파일 -비밀번호 -API Key -인증서 -``` - ---- - -## 12. .gitignore 기본 예시 - -각 저장소에는 반드시 `.gitignore`를 만든다. - -```gitignore -.vs/ -bin/ -obj/ -artifacts/ -*.user -*.suo -*.log - -*.zip -*.exe -*.dll -*.msi - -runs/ -*.pt -*.onnx -*.engine - -PortableRuntime/ -OfflineRuntime/ - -.env -appsettings.Production.json -config.local.json -``` - ---- - -## 13. 기존 NAS 폴더와 Git 역할 - -기존 NAS 폴더는 고객사, 공장, 납품본, 설치파일, 백업 자료를 보관하는 용도로 유지한다. - -Git 저장소는 소스 코드와 수정 이력을 관리하는 용도로 사용한다. - -```text -NAS 폴더 -→ 납품본, 설치파일, 현장 백업, 이미지, 문서 보관 - -Git 저장소 -→ 소스 코드, 수정 이력, 협업 관리 -``` - ---- - -## 14. 권한 관리 - -| 권한 | 용도 | -| ----- | ------------------- | -| Read | 조회, clone, pull | -| Write | 소스 수정, commit, push | -| Admin | 저장소 설정 변경 | - -일반 개발자는 기본적으로 Write 권한을 사용한다. - ---- - -## 15. 주의사항 - -저장소 이름은 한 번 정하면 계속 사용하므로 신중하게 정한다. -한글은 저장소 설명, README, Wiki, Issue에서 사용한다. -저장소 이름에는 영어 소문자, 숫자, 하이픈만 사용한다. -대용량 파일은 Git에 올리지 않고 NAS 공유 폴더 또는 Gitea Release에 별도 보관한다. + def run(self): + global TestMode, Prablem_No + try: + TestMode = self.params["test_mode"] + Prablem_No = self.params["problem_no"] + width = self.params["grid_width"] + height = self.params["grid_height"] + word_list_file = self.params["word_list_file"] + + logger.info( + "작업 시작 | test_mode=%s, problem_no=%s, grid=%sx%s, word_list=%s", + TestMode, + Prablem_No, + width, + height, + word_list_file, + ) + + self.emit_progress(3, "리소스 검증 중...") + ensure_output_dirs() + validation_errors = validate_resources(self.params) + if validation_errors: + error_message = "필수 리소스가 부족합니다.\n" + "\n".join(validation_errors) + logger.error(error_message) + self.error_signal.emit(error_message) + return + + self.emit_progress(8, "출력 폴더 정리 중...") + for path in [IMAGES_DIR, QUIZ_DIR, FINAL_DIR, WITH_AUDIO_DIR, WITH_HINT_DIR]: + clear_directory(path) + + self.emit_progress(12, "더미 글자 로드 중...") + dummy_chars = make_dumy_wordlist(DEFAULT_DUMMY_WORD_FILE) + logger.info("더미 글자 수: %s", len(dummy_chars)) + + self.emit_progress(18, "단어 리스트 로드 중...") + word_list = load_word_list(word_list_file) + logger.info("원본 단어 수: %s", len(word_list)) + + self.emit_progress(24, "최근 사용 단어 이력 로드 중...") + previous_data = load_previous_data(WORD_HISTORY_DIR, 15) + logger.info("최근 사용 단어 수: %s", len(previous_data)) + + available_words = list(set(word_list) - set(previous_data)) + logger.info("사용 가능한 단어 수: %s", len(available_words)) + if not available_words: + raise RuntimeError("사용 가능한 단어가 없습니다. 단어 리스트 또는 이력 제외 규칙을 확인하세요.") + + self.emit_progress(30, "퀴즈 조합 생성 중...") + if not TestMode: + quiz = generate_quiz(available_words) + else: + quiz = generate_quiz_form_file(DEFAULT_TEST_WORD_FILE) + + logger.info("퀴즈 세트 생성 완료: %s문제", len(quiz)) + for idx, words in enumerate(quiz, start=1): + logger.info("문제 %s 단어: %s", idx, ", ".join(words)) + + self.emit_progress(36, "오디오 파일 검사 중...") + verify_audio_files(quiz) + + history_file_path = get_next_history_file_path(WORD_HISTORY_DIR) + logger.info("이번 실행 단어 저장 파일: %s", history_file_path) + + self.emit_progress(42, "초성 힌트 생성 중...") + chosung_list = build_chosung_list(quiz) + write_quiz_summary(quiz, chosung_list) + + self.emit_progress(48, "문제/정답 이미지 생성 중...") + for i, words in enumerate(quiz): + problem_no = i + 1 + difficulty = 1 if i < 4 else 2 if i < 6 else 3 + logger.info("문제 %s 이미지 생성 시작 | difficulty=%s | words=%s", problem_no, difficulty, words) + grid, solution_list = create_grid(width, height, words, difficulty, dummy_chars, problem_no) + items_string = "(" + ", ".join(words) + ")" + solution_image = create_solution_image(problem_no, IMAGES_DIR, items_string, grid, solution_list) + solution_image.save(IMAGES_DIR / f"{problem_no}.{items_string}_solution.png") + problem_image = create_image(grid) + problem_image.save(IMAGES_DIR / f"{problem_no}.problem.png") + logger.info("문제 %s 이미지 생성 완료 | 해설 경로 수=%s", problem_no, len(solution_list)) + self.emit_progress(48 + int(problem_no * 18 / max(Prablem_No, 1)), f"문제 {problem_no} 이미지 생성 완료") + + self.emit_progress(68, "문제 영상 생성 중...") + Create_video() + + self.emit_progress(80, "배경 합성 영상 생성 중...") + create_Final_Video() + + self.emit_progress(88, "오디오 합성 중...") + Create_Videos_With_Sound(quiz) + + self.emit_progress(95, "최종 영상 병합 중...") + videos_Sum() + + save_data(history_file_path, quiz) + logger.info("사용 단어 이력 저장 완료: %s", history_file_path) + + self.emit_progress(100, "완료") + logger.info("전체 작업 완료") + self.finished_signal.emit() + except Exception as e: + logger.exception("퀴즈 생성 중 오류 발생") + self.error_signal.emit(f"오류 발생: {str(e)}") + + +class QuizMakerUI(QMainWindow): + def __init__(self): + super().__init__() + self.initUI() + + def initUI(self): + self.setWindowTitle("퀴즈 메이커 V1.3 UI") + self.setGeometry(100, 100, 800, 600) + + central_widget = QWidget() + self.setCentralWidget(central_widget) + main_layout = QVBoxLayout(central_widget) + + settings_group = QGroupBox("설정") + settings_layout = QVBoxLayout() + + test_mode_layout = QHBoxLayout() + self.test_mode_checkbox = QCheckBox("테스트 모드") + self.test_mode_checkbox.setChecked(DEFAULT_TEST_MODE) + test_mode_layout.addWidget(self.test_mode_checkbox) + settings_layout.addLayout(test_mode_layout) + + problem_layout = QHBoxLayout() + problem_layout.addWidget(QLabel("문제 수:")) + self.problem_spinbox = QSpinBox() + self.problem_spinbox.setRange(1, 20) + self.problem_spinbox.setValue(DEFAULT_PROBLEM_NO) + problem_layout.addWidget(self.problem_spinbox) + settings_layout.addLayout(problem_layout) + + grid_layout = QHBoxLayout() + grid_layout.addWidget(QLabel("그리드 크기:")) + self.width_spinbox = QSpinBox() + self.width_spinbox.setRange(3, 10) + self.width_spinbox.setValue(DEFAULT_GRID_WIDTH) + grid_layout.addWidget(self.width_spinbox) + grid_layout.addWidget(QLabel("x")) + self.height_spinbox = QSpinBox() + self.height_spinbox.setRange(3, 10) + self.height_spinbox.setValue(DEFAULT_GRID_HEIGHT) + grid_layout.addWidget(self.height_spinbox) + settings_layout.addLayout(grid_layout) + + file_layout = QHBoxLayout() + file_layout.addWidget(QLabel("단어 리스트 파일:")) + self.word_list_edit = QLineEdit(DEFAULT_WORD_LIST_FILE) + file_layout.addWidget(self.word_list_edit) + browse_button = QPushButton("찾아보기") + browse_button.clicked.connect(self.browse_word_list) + file_layout.addWidget(browse_button) + settings_layout.addLayout(file_layout) + + settings_group.setLayout(settings_layout) + main_layout.addWidget(settings_group) + + progress_group = QGroupBox("진행 상황") + progress_layout = QVBoxLayout() + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + progress_layout.addWidget(self.progress_bar) + self.status_label = QLabel("준비됨") + progress_layout.addWidget(self.status_label) + progress_group.setLayout(progress_layout) + main_layout.addWidget(progress_group) + + log_group = QGroupBox("로그") + log_layout = QVBoxLayout() + self.log_text = QTextEdit() + self.log_text.setReadOnly(True) + log_layout.addWidget(self.log_text) + log_group.setLayout(log_layout) + main_layout.addWidget(log_group) + + button_layout = QHBoxLayout() + self.start_button = QPushButton("시작") + self.start_button.clicked.connect(self.start_quiz_maker) + button_layout.addWidget(self.start_button) + self.cancel_button = QPushButton("취소") + self.cancel_button.clicked.connect(self.close) + button_layout.addWidget(self.cancel_button) + main_layout.addLayout(button_layout) + + self.setup_logging() + + def setup_logging(self): + self.log_emitter = LogEmitter() + self.log_emitter.log_signal.connect(self.append_log) + handler = UiLogHandler(self.log_emitter) + handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) + logger.addHandler(handler) + + def append_log(self, message): + self.log_text.append(message) + + def browse_word_list(self): + file_path, _ = QFileDialog.getOpenFileName(self, "단어 리스트 파일 선택", str(BASE_DIR), "텍스트 파일 (*.txt)") + if file_path: + self.word_list_edit.setText(file_path) + + def start_quiz_maker(self): + params = { + "test_mode": self.test_mode_checkbox.isChecked(), + "problem_no": self.problem_spinbox.value(), + "grid_width": self.width_spinbox.value(), + "grid_height": self.height_spinbox.value(), + "word_list_file": self.word_list_edit.text().strip(), + } + + validation_errors = validate_resources(params) + if validation_errors: + QMessageBox.warning(self, "리소스 확인", "필수 리소스를 확인하세요.\n\n" + "\n".join(validation_errors)) + return + + logger.info("UI에서 작업 시작 요청") + self.thread = QuizMakerThread(params) + self.thread.progress_signal.connect(self.update_progress) + self.thread.finished_signal.connect(self.on_finished) + self.thread.error_signal.connect(self.on_error) + + self.start_button.setEnabled(False) + self.progress_bar.setValue(0) + self.status_label.setText("시작 중...") + self.thread.start() + + def update_progress(self, value, message): + self.progress_bar.setValue(value) + self.status_label.setText(message) + + def on_finished(self): + self.start_button.setEnabled(True) + self.status_label.setText("완료") + QMessageBox.information(self, "완료", "퀴즈 생성이 완료되었습니다.") + + def on_error(self, message): + self.start_button.setEnabled(True) + self.status_label.setText("오류 발생") + QMessageBox.critical(self, "오류", message) + + +def build_chosung_list(quiz): + chosungs = ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"] + chosung_list = [] + for lists in quiz: + temp = "" + for word in lists: + for char in word: + uni_base = ord(char) - 44032 + chosung = (uni_base // 28) // 21 + temp += chosungs[chosung] + temp += "," + chosung_list.append(temp[:-1]) + return chosung_list + + +def write_quiz_summary(quiz, chosung_list): + output_file = IMAGES_DIR / "Quiz.txt" + with open(output_file, "w", encoding="utf-8") as f_output: + for i, lists in enumerate(quiz, 1): + words = ", ".join(lists) + f_output.write(f"문제 {i}:\t{words}\t{chosung_list[i-1]}\n") + logger.info("퀴즈 요약 저장 완료: %s", output_file) + + +def get_next_history_file_path(directory: Path): + ensure_directory(directory) + file_prefix = datetime.today().strftime("%Y-%m-%d") + existing_numbers = [] + for file in directory.iterdir(): + if file.is_file() and file.name.startswith(file_prefix): + try: + existing_numbers.append(int(file.stem[len(file_prefix) + 1 :])) + except ValueError: + continue + next_number = max(existing_numbers) + 1 if existing_numbers else 1 + return directory / f"{file_prefix}-{next_number}.txt" + + +def verify_audio_files(quiz): + audio_dir = get_audio_dir() + missing_audio = [] + for words in quiz: + for word in words: + audio_file = audio_dir / f"{word}.mp3" + if not audio_file.exists(): + missing_audio.append(word) + if missing_audio: + logger.error("누락 오디오: %s", ", ".join(missing_audio)) + raise FileNotFoundError(f"다음 단어의 오디오 파일이 없습니다: {', '.join(missing_audio)}") + logger.info("오디오 파일 검사 완료") + + +# 기존 함수 보강 + +def create_grid(width, height, words, difficulty, dummy_chars=None, problem_no=None): + if dummy_chars is None: + dummy_chars = make_dumy_wordlist(DEFAULT_DUMMY_WORD_FILE) + + directions_master = DIRECTIONS_BY_DIFFICULTY.get(difficulty, DIRECTIONS_BY_DIFFICULTY[3]) + + for grid_retry in range(1, MAX_GRID_RETRY + 1): + grid = [[" " for _ in range(width)] for _ in range(height)] + solution_list = [] + available_directions = directions_master.copy() + logger.info( + "문제 %s 퍼즐 배치 시도 %s/%s | width=%s height=%s difficulty=%s words=%s", + problem_no, + grid_retry, + MAX_GRID_RETRY, + width, + height, + difficulty, + words, + ) + + failed = False + for word in words: + placed, solution, selected_direction = place_word_in_grid( + grid=grid, + width=width, + height=height, + word=word, + candidate_directions=available_directions, + problem_no=problem_no, + ) + if not placed: + logger.warning("문제 %s 단어 배치 실패 | word=%s | grid_retry=%s", problem_no, word, grid_retry) + failed = True + break + + solution_list.append(solution) + if selected_direction in available_directions: + available_directions.remove(selected_direction) + logger.info("문제 %s 단어 배치 성공 | word=%s | direction=%s | coords=%s", problem_no, word, selected_direction, solution) + + if failed: + continue + + fill_empty_cells(grid, dummy_chars) + logger.info("문제 %s 퍼즐 배치 완료", problem_no) + return grid, solution_list + + raise RuntimeError(f"문제 {problem_no} 퍼즐 생성 실패: 단어 배치를 {MAX_GRID_RETRY}회 시도했지만 완료하지 못했습니다.") + + +def place_word_in_grid(grid, width, height, word, candidate_directions, problem_no=None): + directions = candidate_directions or DIRECTIONS_BY_DIFFICULTY[3] + if not directions: + directions = DIRECTIONS_BY_DIFFICULTY[3] + + word_length = len(word) + for attempt in range(1, MAX_WORD_PLACEMENT_ATTEMPTS + 1): + direction = random.choice(directions) + coords = get_candidate_coordinates(width, height, word_length, direction) + if coords is None: + logger.debug("문제 %s 좌표 계산 불가 | word=%s | direction=%s", problem_no, word, direction) + continue + + x, y = coords + result = try_place_word(grid, word, x, y, direction) + if result is not None: + return True, result, direction + + if attempt in (1, 10, 50, 100, MAX_WORD_PLACEMENT_ATTEMPTS): + logger.info( + "문제 %s 단어 재시도 | word=%s | attempt=%s/%s | direction=%s", + problem_no, + word, + attempt, + MAX_WORD_PLACEMENT_ATTEMPTS, + direction, + ) + return False, [], None + + +def get_candidate_coordinates(width, height, word_length, direction): + if direction in ("horizontal", "horizontal_reverse"): + if width < word_length: + return None + return random.randint(0, width - word_length), random.randint(0, height - 1) + if direction in ("vertical", "vertical_reverse"): + if height < word_length: + return None + return random.randint(0, width - 1), random.randint(0, height - word_length) + if direction in ("diagonal", "diagonal_reverse", "diagonal_up_left"): + if width < word_length or height < word_length: + return None + return random.randint(0, width - word_length), random.randint(0, height - word_length) + if direction == "diagonal_up_right": + if width < word_length or height < word_length: + return None + return random.randint(0, width - word_length), random.randint(word_length - 1, height - 1) + return None + + +def try_place_word(grid, word, x, y, direction): + word_length = len(word) + coords = [] + + if direction == "horizontal": + cells = [(y, x + i, word[i]) for i in range(word_length)] + elif direction == "vertical": + cells = [(y + i, x, word[i]) for i in range(word_length)] + elif direction == "diagonal": + cells = [(y + i, x + i, word[i]) for i in range(word_length)] + elif direction == "diagonal_reverse": + cells = [(y + i, x + word_length - i - 1, word[i]) for i in range(word_length)] + elif direction == "horizontal_reverse": + cells = [(y, x + i, word[word_length - i - 1]) for i in range(word_length)] + elif direction == "vertical_reverse": + cells = [(y + i, x, word[word_length - i - 1]) for i in range(word_length)] + elif direction == "diagonal_up_right": + cells = [(y - i, x + i, word[i]) for i in range(word_length)] + elif direction == "diagonal_up_left": + cells = [(y + i, x + i, word[word_length - i - 1]) for i in range(word_length)] + else: + return None + + if not all(grid[row][col] == " " for row, col, _ in cells): + return None + + for row, col, char in cells: + grid[row][col] = char + + if direction == "horizontal_reverse": + coords = [[y, x + word_length - i - 1] for i in range(word_length)] + elif direction == "vertical_reverse": + coords = [[y + word_length - i - 1, x] for i in range(word_length)] + elif direction == "diagonal_up_left": + coords = [[y + word_length - i - 1, x + word_length - i - 1] for i in range(word_length)] + else: + coords = [[row, col] for row, col, _ in cells] + + return coords + + +def fill_empty_cells(grid, dummy_chars): + for i in range(len(grid)): + for j in range(len(grid[0])): + if grid[i][j] == " ": + while True: + c = random.choice(dummy_chars) + if c != " ": + grid[i][j] = c + break + + +def make_dumy_wordlist(file_name): + dummy_word_list = "" + with open(file_name, "r", encoding="utf-8") as file: + reader = csv.reader(file) + for row in reader: + for word in row: + dummy_word_list += word + return dummy_word_list + + +def display_grid(grid): + for row in grid: + print(" ".join(row)) + + +def find_word(grid, word): + word_length = len(word) + height = len(grid) + width = len(grid[0]) + for i in range(height): + for j in range(width): + if grid[i][j] == word[0]: + if j + word_length <= width and "".join(grid[i][j:j + word_length]) == word: + return (i, j), (i, j + word_length - 1), False + if j - word_length >= -1 and "".join(grid[i][j:j - word_length - 1:-1]) == word: + return (i, j), (i, j - word_length + 1), True + if i + word_length <= height and "".join(grid[k][j] for k in range(i, i + word_length)) == word: + return (i, j), (i + word_length - 1, j), False + if i - word_length >= -1 and "".join(grid[k][j] for k in range(i, i - word_length - 1, -1)) == word: + return (i, j), (i - word_length + 1, j), True + if j + word_length <= width and i + word_length <= height and "".join(grid[i + k][j + k] for k in range(word_length)) == word: + return (i, j), (i + word_length - 1, j + word_length - 1), False + if j - word_length >= -1 and i + word_length <= height and "".join(grid[i + k][j - k] for k in range(word_length)) == word: + return (i, j), (i + word_length - 1, j - word_length + 1), True + if j + word_length <= width and i - word_length >= -1 and "".join(grid[i - k][j + k] for k in range(word_length)) == word: + return (i, j), (i - word_length + 1, j + word_length - 1), True + return None + + +def create_image(grid, cell_size=500, border=10): + width = len(grid[0]) + height = len(grid) + image_width = width * cell_size + border * 9 + image_height = height * cell_size + border * 7 + image = Image.new("RGBA", (image_width, image_height), (255, 255, 255, 255)) + draw = ImageDraw.Draw(image) + draw.rectangle([(border, border), (image_width - border, image_height - border)], outline="black", width=border) + + for i in range(height): + y = i * cell_size + border * (i + 2) + draw.line([(border, y), (image_width - border, y)], fill="black", width=border) + + for j in range(width): + x = j * cell_size + border * (j + 2) + draw.line([(x, border), (x, image_height - border)], fill="black", width=border) + + font = ImageFont.truetype(DEFAULT_FONT_PATH, size=250) + + for i in range(height): + for j in range(width): + char = grid[i][j] + x = j * cell_size + cell_size // 2 + border * (j + 1) + y = i * cell_size + cell_size // 2 + border * (i + 1) + draw.text((x, y), char, fill="black", font=font, anchor="mm") + + return image + + +def draw_rounded_rectangle(draw, xy, radius, fill=None, outline=None): + x1, y1, x2, y2 = xy + draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill, outline=outline) + draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill, outline=outline) + draw.pieslice([x1, y1, x1 + radius * 2, y1 + radius * 2], start=180, end=270, fill=fill, outline=outline) + draw.pieslice([x2 - radius * 2, y1, x2, y1 + radius * 2], start=270, end=360, fill=fill, outline=outline) + draw.pieslice([x1, y2 - radius * 2, x1 + radius * 2, y2], start=90, end=180, fill=fill, outline=outline) + draw.pieslice([x2 - radius * 2, y2 - radius * 2, x2, y2], start=0, end=90, fill=fill, outline=outline) + + +def create_solution_image(index, folder_path, items_string, grid, solution_list, cell_size=500, border=10, Alpa=100): + width = len(grid[0]) + height = len(grid) + image_width = width * cell_size + border * 9 + image_height = height * cell_size + border * 7 + image = Image.new("RGBA", (image_width, image_height), (255, 255, 255, 255)) + draw = ImageDraw.Draw(image) + draw.rectangle([(border, border), (image_width - border, image_height - border)], outline="black", width=border) + + for i in range(height): + y = i * cell_size + border * (i + 2) + draw.line([(border, y), (image_width - border, y)], fill="black", width=border) + + for j in range(width): + x = j * cell_size + border * (j + 2) + draw.line([(x, border), (x, image_height - border)], fill="black", width=border) + + font = ImageFont.truetype(DEFAULT_FONT_PATH, size=250) + + k = 0 + for solution in solution_list: + if k == 0: + fill_color = (255, 31, 31, Alpa) + elif k == 1: + fill_color = (0x5E, 0x17, 0xEB, Alpa) + elif k == 2: + fill_color = (0x7E, 0xD9, 0x57, Alpa) + else: + fill_color = (0x5C, 0xE1, 0xE6, Alpa) + + k += 1 + a = 1 + for g in solution: + i = g[0] + j = g[1] + x = j * cell_size + border * (j + 2) + cell_size / 10 + y = i * cell_size + border * (i + 2) + cell_size / 10 + xy = (x, y, x + cell_size * 8 / 10, y + cell_size * 8 / 10) + draw_rounded_rectangle(draw, xy, 100, fill=fill_color) + + for row in range(height): + for col in range(width): + char = grid[row][col] + tx = col * cell_size + cell_size // 2 + border * (col + 1) + ty = row * cell_size + cell_size // 2 + border * (row + 1) + draw.text((tx, ty), char, fill="black", font=font, anchor="mm") + + image.save(Path(folder_path) / f"{index}.solution{k}_{a}.png") + a += 1 + + return image + + +word_list_file = DEFAULT_WORD_LIST_FILE + + +def select_words(word_list, length, count): + candidates = [word for word in word_list if len(word) == length] + if len(candidates) < count: + raise ValueError(f"길이 {length} 단어가 부족합니다. 필요={count}, 현재={len(candidates)}") + selected_words = random.sample(candidates, count) + logger.info("단어 선택 | len=%s count=%s selected=%s", length, count, selected_words) + return selected_words + + +def generate_quiz_form_file(file_name): + quiz = [] + words = [] + with open(file_name, "r", encoding="utf-8") as file: + lines = file.readlines() + i = 0 + for word in lines: + words.append(word.strip()) + i += 1 + if i in (3, 6, 9, 12, 15, 18, 21, 25): + quiz.append(words) + words = [] + logger.info("테스트 파일 기반 퀴즈 생성 완료: %s", file_name) + return quiz + + +def generate_quiz(word_list): + quiz = [] + logger.info("퀴즈 조합 생성 시작 | target_problem_count=%s", Prablem_No) + for i in range(Prablem_No): + if i < 4: + words = select_words(word_list, 3, 3) + word_list = list(set(word_list) - set(words)) + quiz.append(words) + elif i < 7: + words_3 = select_words(word_list, 3, 2) + words_4 = select_words(word_list, 4, 1) + word_list = list(set(word_list) - set(words_3) - set(words_4)) + quiz.append(words_3 + words_4) + else: + words_3 = select_words(word_list, 3, 2) + words_4 = select_words(word_list, 4, 2) + word_list = list(set(word_list) - set(words_3) - set(words_4)) + quiz.append(words_3 + words_4) + logger.info("문제 %s 조합 확정: %s", i + 1, quiz[-1]) + return quiz + + +def load_word_list(filename): + with open(filename, "r", encoding="utf-8") as file: + words = [line.strip() for line in file if line.strip()] + logger.info("단어 파일 로드 완료: %s (%s개)", filename, len(words)) + return words + + +def split_words(data): + words = [] + for item in data: + words.extend(item[0].split(", ")) + return words + + +def load_previous_data(folder_path, file_limit): + previous_data = [] + files = [] + folder_path = Path(folder_path) + if not folder_path.exists(): + logger.warning("이력 폴더가 없습니다: %s", folder_path) + return previous_data + + for file_name in folder_path.iterdir(): + if file_name.suffix == ".txt": + files.append(file_name) + + files.sort(reverse=True) + for file_name in files[:file_limit]: + try: + with open(file_name, "r", encoding="utf-8") as file: + data = [line.strip() for line in file if line.strip()] + previous_data.extend(data) + except FileNotFoundError: + logger.warning("이력 파일 없음: %s", file_name) + logger.info("최근 이력 로드 완료 | files=%s words=%s", min(len(files), file_limit), len(previous_data)) + return previous_data + + +def save_data(filename, data): + with open(filename, "a", encoding="utf-8") as file: + for item in data: + for word in item: + file.write(word + "\n") + + +def Overlay_Hint(chosung_list): + text_color = "white" + stroke_color = "rgb(110, 110, 110)" + position = (543, 954) + text_start_time = 39 + text_duration_per_char = 0.15 + text_diapper_per_char = 0.1 + font_path = str(Path(DEFAULT_FONT_PATH)) + + for i in range(Prablem_No): + video_path = WITH_AUDIO_DIR / f"{i + 1}.mp4" + video_clip = VideoFileClip(str(video_path)) + text = "Hint: " + chosung_list[i] + text_clips = [] + + for j in range(1, len(text) + 1): + char_clip = ( + TextClip(text[:j], fontsize=80, color=text_color, stroke_color=stroke_color, stroke_width=3, font=font_path) + .set_position(position) + .set_start(text_start_time + (j - 1) * text_duration_per_char) + .set_duration(video_clip.duration - (text_start_time - (j - 1) * text_diapper_per_char) - 5.5) + .crossfadeout(text_diapper_per_char) + ) + text_clips.append(char_clip) + + typing_clip = CompositeVideoClip([video_clip] + text_clips) + output_path = WITH_HINT_DIR / f"{i + 1}.mp4" + typing_clip.write_videofile(str(output_path), codec="libx264", fps=video_clip.fps) + logger.info("힌트 오버레이 완료: %s", output_path) + + +def Create_video(): + first_image = IMAGES_DIR / "1.problem.png" + frame = cv2.imread(str(first_image)) + if frame is None: + raise FileNotFoundError(f"첫 문제 이미지 로드 실패: {first_image}") + + fps = 30 + problem_time = 71.5 + solution_view_delay = 0.3 + solution_delay = 1 + height, width, layers = frame.shape + + ensure_directory(QUIZ_DIR) + logger.info("문제 영상 생성 시작 | total=%s | fps=%s | size=%sx%s", Prablem_No, fps, width, height) + + for i in range(Prablem_No): + quiz_video_path = QUIZ_DIR / f"{i + 1}.mp4" + video = cv2.VideoWriter(str(quiz_video_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) + problem_image = IMAGES_DIR / f"{i + 1}.problem.png" + + if not problem_image.exists(): + logger.warning("문제 이미지 없음: %s", problem_image) + video.release() + continue + + frame = cv2.imread(str(problem_image)) + logger.info("문제 %s 영상 시작 | source=%s", i + 1, problem_image) + for _ in range(int(problem_time * fps)): + video.write(frame) + + max_j = 3 if i < 4 else 4 + for j in range(max_j): + solution_image = IMAGES_DIR / f"{i + 1}.solution{j + 1}_1.png" + if not solution_image.exists(): + logger.info("문제 %s 솔루션 %s 첫 이미지 없음", i + 1, j + 1) + continue + + for k in range(4): + solution_image = IMAGES_DIR / f"{i + 1}.solution{j + 1}_{k + 1}.png" + if not solution_image.exists(): + logger.info("문제 %s 솔루션 프레임 없음: %s", i + 1, solution_image) + continue + frame = cv2.imread(str(solution_image)) + for _ in range(int(solution_view_delay * fps)): + video.write(frame) + + for _ in range(int(solution_delay * fps)): + video.write(frame) + + for _ in range(int(2 * fps)): + video.write(frame) + + video.release() + logger.info("문제 %s 영상 생성 완료: %s", i + 1, quiz_video_path) + + +def Create_Videos_With_Sound(quiz): + word_time = 79 + word_dealy = 2 + audio_dir = get_audio_dir() + ensure_directory(WITH_AUDIO_DIR) + + for i in range(Prablem_No): + video_path = FINAL_DIR / f"{i + 1}.mp4" + video_clip = VideoFileClip(str(video_path)) + words = quiz[i] + audio_clips = [video_clip.audio] + for word in words: + audio_file = audio_dir / f"{word}.mp3" + audio_clips.append(AudioFileClip(str(audio_file))) + logger.info("문제 %s 오디오 추가 예약 | word=%s | file=%s", i + 1, word, audio_file) + + if len(audio_clips) == 4: + video_clip = CompositeVideoClip([ + video_clip.set_audio(audio_clips[0].set_start(0)), + video_clip.set_audio(audio_clips[1].set_start(word_time + 0 * word_dealy)), + video_clip.set_audio(audio_clips[2].set_start(word_time + 1 * word_dealy)), + video_clip.set_audio(audio_clips[3].set_start(word_time + 2 * word_dealy)), + ]) + else: + video_clip = CompositeVideoClip([ + video_clip.set_audio(audio_clips[0].set_start(0)), + video_clip.set_audio(audio_clips[1].set_start(word_time + 0 * word_dealy)), + video_clip.set_audio(audio_clips[2].set_start(word_time + 1 * word_dealy)), + video_clip.set_audio(audio_clips[3].set_start(word_time + 2 * word_dealy)), + video_clip.set_audio(audio_clips[4].set_start(word_time + 3 * word_dealy)), + ]) + + output_path = WITH_AUDIO_DIR / f"{i + 1}.mp4" + video_clip.write_videofile(str(output_path), codec="libx264", audio_codec="aac") + logger.info("문제 %s 오디오 합성 완료: %s", i + 1, output_path) + + +def create_Final_Video(): + base_folder = get_base_video_dir() + ensure_directory(FINAL_DIR) + + for i in range(Prablem_No): + quiz_clip_path = QUIZ_DIR / f"{i + 1}.mp4" + base_clip_path = base_folder / f"{i + 1}.mp4" + logger.info("문제 %s 배경 합성 시작 | quiz=%s | base=%s", i + 1, quiz_clip_path, base_clip_path) + video_clip = VideoFileClip(str(quiz_clip_path)) + source_clip = VideoFileClip(str(base_clip_path)) + + overlay_start_time = 7.5 + overlay_clip = ( + video_clip.set_start(overlay_start_time) + .set_position((540, 50)) + .resize(width=1350, height=904.4) + .set_duration(video_clip.duration) + ) + blurred_clip = overlay_clip.fadein(duration=1).fadeout(duration=1) + final_clip = CompositeVideoClip([source_clip, blurred_clip.set_duration(source_clip.duration - overlay_start_time - 1)]) + + output_path = FINAL_DIR / f"{i + 1}.mp4" + final_clip.write_videofile(str(output_path)) + logger.info("문제 %s 배경 합성 완료: %s", i + 1, output_path) + + +def videos_Sum(): + ensure_directory(FINAL_MERGED_DIR) + output_directory = FINAL_MERGED_DIR + today = datetime.today() + base_filename = today.strftime("%Y-%m-%d") + output_file = output_directory / f"{base_filename}-1.mp4" + + count = 1 + while output_file.exists(): + count += 1 + output_file = output_directory / f"{base_filename}-{count}.mp4" + + video_clips = [] + intro_file = get_videos_dir() / "인트로.mp4" + ending_file = get_videos_dir() / "엔딩.mp4" + video_clips.append(VideoFileClip(str(intro_file))) + logger.info("인트로 추가: %s", intro_file) + + for i in range(Prablem_No): + quiz_file = WITH_AUDIO_DIR / f"{i + 1}.mp4" + video_clips.append(VideoFileClip(str(quiz_file))) + logger.info("최종 병합에 문제 추가: %s", quiz_file) + + video_clips.append(VideoFileClip(str(ending_file))) + logger.info("엔딩 추가: %s", ending_file) + + final_clip = concatenate_videoclips(video_clips) + final_clip.write_videofile(str(output_file)) + logger.info("최종 병합 영상 생성 완료: %s", output_file) + + +def main(): + app = QApplication(sys.argv) + ui = QuizMakerUI() + ui.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main()