From e1ecd9840c463e2fc0153ecc5811055d787fc59c Mon Sep 17 00:00:00 2001 From: kkt Date: Sat, 20 Jun 2026 12:52:26 +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 | 1207 ++++++++--------------------------------------------- 1 file changed, 178 insertions(+), 1029 deletions(-) diff --git a/README.md b/README.md index dc10c32..fcfe562 100644 --- a/README.md +++ b/README.md @@ -1,1086 +1,235 @@ -import csv -import logging -import os -import random -import shutil -import sys -from datetime import datetime, timedelta -from pathlib import Path +# 한세로 Git 저장소 관리 규칙 -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, -) +한세로 Git은 프로그램 소스 코드와 수정 이력을 관리하기 위한 저장소입니다. +기존 NAS 폴더는 납품본, 설치파일, 이미지, 백업 보관용으로 유지하고, Git에는 소스 코드만 관리합니다. +--- -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") +## 1. 저장소 이름 규칙 -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 +```text +hmc-지역-공장구분-프로그램명 +``` +예: -class LogEmitter(QObject): - log_signal = pyqtSignal(str) +```text +hmc-ulsan-assembly1-bolt-inspection +hmc-jeonju-body1-underbody-inspection +``` +### 기아자동차 -class UiLogHandler(logging.Handler): - def __init__(self, emitter): - super().__init__() - self.emitter = emitter +```text +kmc-지역-공장구분-프로그램명 +``` - def emit(self, record): - msg = self.format(record) - self.emitter.log_signal.emit(msg) +예: +```text +kmc-gwangju-assembly1-misassembly-inspection +``` -def setup_logging(): - LOG_DIR.mkdir(parents=True, exist_ok=True) - formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") +### 한세로 - if logger.handlers: - return +```text +hansero-프로그램명 +``` - 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) +```text +hansero-hai-studio +hansero-groupware +hansero-chat +``` - logger.info("로그 초기화 완료: %s", session_log) +### 기타 고객사 +```text +etc-고객사-프로그램명 +``` -setup_logging() +예: +```text +etc-mobis-vision-inspection +etc-sampyo-silo-level-inspection +``` -TestMode = DEFAULT_TEST_MODE -Prablem_No = DEFAULT_PROBLEM_NO +--- +## 2. 코드 기준 -def ensure_directory(path: Path): - path.mkdir(parents=True, exist_ok=True) +### 대분류 +| 코드 | 의미 | +| ------- | --------- | +| hmc | 현대자동차 | +| kmc | 기아자동차 | +| hansero | 한세로 사내/공통 | +| etc | 기타 고객사 | -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) +### 지역 +| 코드 | 의미 | +| --------------- | ------ | +| ulsan | 울산 | +| jeonju | 전주 | +| gwangju | 광주 | +| namyang | 남양 | +| uiwang | 의왕 | +| anyang | 안양 | +| cheongju | 청주 | +| china-guangzhou | 중국 광저우 | +| georgia | 미국 조지아 | -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) +### 공장구분 +| 코드 | 의미 | +| ------------ | ----- | +| assembly | 의장 | +| body | 차체 | +| paint | 도장 | +| press | 프레스 | +| engine | 엔진 | +| transmission | 변속기 | +| battery | 배터리 | +| quality | 품질 | +| research | 연구소 | +| office | 사무/관리 | -def resolve_existing_dir(*candidates: Path): - for candidate in candidates: - if candidate.exists() and candidate.is_dir(): - return candidate - return candidates[0] +공장 번호가 있으면 뒤에 숫자를 붙입니다. +```text +assembly1 +assembly2 +body1 +body2 +``` -def resolve_existing_file(*candidates: Path): - for candidate in candidates: - if candidate.exists() and candidate.is_file(): - return candidate - return candidates[0] +--- +## 3. 저장소 설명 -def get_audio_dir() -> Path: - return resolve_existing_dir(BASE_DIR / "audios", BASE_DIR / "Audios") +저장소 설명은 한글로 작성합니다. + +예: +| 저장소명 | 설명 | +| -------------------------------------------- | -------------------------- | +| hmc-ulsan-assembly1-bolt-inspection | 현대자동차 울산 의장1공장 볼트 검사 프로그램 | +| kmc-gwangju-assembly1-misassembly-inspection | 기아자동차 광주 의장1공장 오장착 검사 프로그램 | +| hansero-groupware | 한세로 그룹웨어 | +| etc-mobis-vision-inspection | 현대모비스 비전 검사 프로그램 | -def get_videos_dir() -> Path: - return resolve_existing_dir(BASE_DIR / "videos", BASE_DIR / "Videos") +--- +## 4. Git에 올릴 것 -def get_base_video_dir() -> Path: - return resolve_existing_dir(BASE_DIR / "long" / "base", BASE_DIR / "Long" / "base", BASE_DIR / "base") +```text +소스 코드 +프로젝트 파일 +솔루션 파일 +README.md +설정 샘플 +DB 스크립트 +간단한 문서 +``` +--- -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(), "배경 영상 폴더"), - ] +## 5. Git에 올리지 않을 것 - if params["test_mode"]: - checks.append((Path(DEFAULT_TEST_WORD_FILE), "테스트 모드 단어 파일")) +```text +bin +obj +.vs +exe +dll +zip +msi +설치파일 +빌드 결과물 +런타임 폴더 +학습 데이터 +학습 모델 +대량 이미지 +로그 파일 +백업 파일 +비밀번호 +API Key +인증서 +토큰 +``` + +--- + +## 6. 직원 토큰 생성 방법 + +저장소 생성 도구를 사용하려면 직원별 Gitea 토큰이 필요합니다. + +1. `https://git.hansero.co.kr` 접속 +2. 본인 계정으로 로그인 +3. 우측 상단 프로필 클릭 +4. `설정` → `애플리케이션` +5. `액세스 토큰 생성` +6. 토큰 이름 입력 + +```text +Hansero Git Repo Creator +``` - for path, label in checks: - if not path.exists(): - problems.append(f"- {label} 없음: {path}") +7. 권한 선택 화면이 있으면 아래 권한 부여 - 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 +repository: read / write +organization: read / write +user: read +``` - 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}") +8. 생성된 토큰을 복사하여 저장소 생성 도구에 입력 - return problems +토큰은 생성 직후 한 번만 표시됩니다. +토큰은 비밀번호와 같으므로 다른 사람과 공유하지 않습니다. +--- -class QuizMakerThread(QThread): - progress_signal = pyqtSignal(int, str) - finished_signal = pyqtSignal() - error_signal = pyqtSignal(str) +## 7. 저장소 생성 도구 사용법 - 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) +실행 파일: - 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"] +```text +HanseroGitRepoCreator.exe +``` - 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 +1. 실행 파일 실행 +2. `토큰 설정`에서 본인 토큰 저장 +3. 대분류 선택 +4. 지역, 공장구분, 공장번호 입력 +5. 프로그램명 입력 +6. 로컬 소스 폴더 선택 +7. `저장소 생성 및 업로드` 클릭 - 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)) +## 8. 권한 기준 - 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("사용 가능한 단어가 없습니다. 단어 리스트 또는 이력 제외 규칙을 확인하세요.") +```text +본인이 생성한 저장소만 접근 가능 +다른 직원이 생성한 저장소는 접근 불가 +``` - 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() +관리자가 필요한 경우 특정 저장소에 별도 권한을 부여합니다.