diff --git a/.gitignore b/.gitignore index bb6c2e1..6f0b439 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ test_*.png *.egg-info/ *.egg build/ -dist/ \ No newline at end of file +dist/ +dct_smooth.png diff --git a/gui/main_window.py b/gui/main_window.py index 164cc79..585f7d2 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -13,7 +13,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.lsb import encode_lsb, decode_lsb from core.dct import encode_dct, decode_dct -from core.utils import calculate_capacity +from core.utils import calculate_capacity, calculate_psnr, text_to_bits class StegoGUI: @@ -22,7 +22,7 @@ class StegoGUI: def __init__(self, root): self.root = root self.root.title("Stego App - Стеганографическое приложение") - self.root.geometry("800x700") + self.root.geometry("850x750") self.root.resizable(True, True) # Переменные @@ -32,6 +32,10 @@ class StegoGUI: self.method_encode = tk.StringVar(value="lsb") self.method_decode = tk.StringVar(value="lsb") + # Переменные для PSNR + self.psnr_image1_path = tk.StringVar() + self.psnr_image2_path = tk.StringVar() + self.setup_ui() def setup_ui(self): @@ -50,6 +54,11 @@ class StegoGUI: notebook.add(self.decode_frame, text="Извлечь сообщение") self.setup_decode_tab() + # Вкладка "Сравнить PSNR" + self.psnr_frame = ttk.Frame(notebook) + notebook.add(self.psnr_frame, text="Сравнить PSNR") + self.setup_psnr_tab() + # Вкладка "О программе" about_frame = ttk.Frame(notebook) notebook.add(about_frame, text="О программе") @@ -61,8 +70,8 @@ class StegoGUI: # Метод ttk.Label(self.encode_frame, text="Метод стеганографии:").grid(row=row, column=0, sticky='w', padx=5, pady=5) - ttk.Radiobutton(self.encode_frame, text="LSB (рекомендуется)", variable=self.method_encode, value="lsb").grid(row=row, column=1, padx=5, sticky='w') - ttk.Radiobutton(self.encode_frame, text="DCT (для гладких/одноцветных изображений)", variable=self.method_encode, value="dct").grid(row=row, column=2, padx=5, sticky='w') + ttk.Radiobutton(self.encode_frame, text="LSB (рекомендуется)", variable=self.method_encode, value="lsb", command=self.update_max_chars).grid(row=row, column=1, padx=5, sticky='w') + ttk.Radiobutton(self.encode_frame, text="DCT (для гладких изображений)", variable=self.method_encode, value="dct", command=self.update_max_chars).grid(row=row, column=2, padx=5, sticky='w') row += 1 # Разделитель @@ -87,10 +96,16 @@ class StegoGUI: ttk.Separator(self.encode_frame, orient='horizontal').grid(row=row, column=0, columnspan=4, sticky='ew', padx=5, pady=5) row += 1 + # Счётчик символов + self.char_counter_label = ttk.Label(self.encode_frame, text="", foreground="gray") + self.char_counter_label.grid(row=row, column=1, columnspan=2, sticky='w', padx=5) + row += 1 + # Сообщение ttk.Label(self.encode_frame, text="Сообщение для сокрытия:").grid(row=row, column=0, sticky='nw', padx=5, pady=5) self.message_text = tk.Text(self.encode_frame, height=8, width=65, wrap=tk.WORD) self.message_text.grid(row=row, column=1, columnspan=3, padx=5, pady=5, sticky='ew') + self.message_text.bind('', self.update_char_counter) row += 1 # Кнопка очистки сообщения @@ -101,7 +116,7 @@ class StegoGUI: ttk.Separator(self.encode_frame, orient='horizontal').grid(row=row, column=0, columnspan=4, sticky='ew', padx=5, pady=5) row += 1 - # Вместимость + # Вместимость в байтах self.capacity_label = ttk.Label(self.encode_frame, text="", foreground="gray") self.capacity_label.grid(row=row, column=1, columnspan=2, sticky='w', padx=5) row += 1 @@ -119,16 +134,16 @@ class StegoGUI: self.status_label.grid(row=row, column=0, columnspan=4, pady=5) # Привязка событий - self.encode_image_path.trace('w', lambda *args: self.update_capacity()) + self.encode_image_path.trace('w', lambda *args: (self.update_capacity(), self.update_max_chars())) def setup_decode_tab(self): """Вкладка декодирования""" row = 0 # Метод - ttk.Label(self.decode_frame, text="Метод:").grid(row=row, column=0, sticky='w', padx=5, pady=5) + ttk.Label(self.decode_frame, text="Метод стеганографии:").grid(row=row, column=0, sticky='w', padx=5, pady=5) ttk.Radiobutton(self.decode_frame, text="LSB (рекомендуется)", variable=self.method_decode, value="lsb").grid(row=row, column=1, padx=5, sticky='w') - ttk.Radiobutton(self.decode_frame, text="DCT (для гладких/одноцветных изображений)", variable=self.method_decode, value="dct").grid(row=row, column=2, padx=5, sticky='w') + ttk.Radiobutton(self.decode_frame, text="DCT (для гладких изображений)", variable=self.method_decode, value="dct").grid(row=row, column=2, padx=5, sticky='w') row += 1 # Разделитель @@ -137,8 +152,7 @@ class StegoGUI: # Изображение ttk.Label(self.decode_frame, text="Изображение со скрытым сообщением:").grid(row=row, column=0, sticky='w', padx=5, pady=5) - self.decode_image_entry = ttk.Entry(self.decode_frame, textvariable=self.decode_image_path, width=55) - self.decode_image_entry.grid(row=row, column=1, columnspan=2, padx=5, sticky='ew') + ttk.Entry(self.decode_frame, textvariable=self.decode_image_path, width=55).grid(row=row, column=1, columnspan=2, padx=5, sticky='ew') ttk.Button(self.decode_frame, text="Обзор...", command=self.select_decode_image).grid(row=row, column=3, padx=5) row += 1 @@ -176,30 +190,64 @@ class StegoGUI: self.decoded_message = "" + def setup_psnr_tab(self): + """Вкладка сравнения PSNR""" + row = 0 + + ttk.Label(self.psnr_frame, text="Сравнение двух изображений (PSNR)", font=("Arial", 12, "bold")).grid(row=row, column=0, columnspan=4, pady=10) + row += 1 + + ttk.Separator(self.psnr_frame, orient='horizontal').grid(row=row, column=0, columnspan=4, sticky='ew', padx=5, pady=5) + row += 1 + + # Первое изображение + ttk.Label(self.psnr_frame, text="Оригинальное изображение:").grid(row=row, column=0, sticky='w', padx=5, pady=5) + ttk.Entry(self.psnr_frame, textvariable=self.psnr_image1_path, width=55).grid(row=row, column=1, columnspan=2, padx=5, sticky='ew') + ttk.Button(self.psnr_frame, text="Обзор...", command=self.select_psnr_image1).grid(row=row, column=3, padx=5) + row += 1 + + # Второе изображение + ttk.Label(self.psnr_frame, text="Изменённое изображение:").grid(row=row, column=0, sticky='w', padx=5, pady=5) + ttk.Entry(self.psnr_frame, textvariable=self.psnr_image2_path, width=55).grid(row=row, column=1, columnspan=2, padx=5, sticky='ew') + ttk.Button(self.psnr_frame, text="Обзор...", command=self.select_psnr_image2).grid(row=row, column=3, padx=5) + row += 1 + + ttk.Separator(self.psnr_frame, orient='horizontal').grid(row=row, column=0, columnspan=4, sticky='ew', padx=5, pady=10) + row += 1 + + # Кнопка расчёта + self.psnr_btn = ttk.Button(self.psnr_frame, text="Рассчитать PSNR", command=self.calculate_psnr) + self.psnr_btn.grid(row=row, column=1, columnspan=2, pady=10) + row += 1 + + # Результат + ttk.Label(self.psnr_frame, text="Результат:", font=("Arial", 10, "bold")).grid(row=row, column=0, sticky='w', padx=5, pady=5) + row += 1 + + self.psnr_result_text = tk.Text(self.psnr_frame, height=8, width=65, wrap=tk.WORD) + self.psnr_result_text.grid(row=row, column=0, columnspan=4, padx=5, pady=5, sticky='ew') + self.psnr_result_text.config(state='disabled') + def setup_about_tab(self, frame): """Вкладка 'О программе'""" - # Основная информация info_frame = ttk.Frame(frame) info_frame.pack(pady=20) ttk.Label(info_frame, text="Stego App", font=("Arial", 16, "bold")).pack(pady=5) ttk.Label(info_frame, text="Стеганографическое приложение", font=("Arial", 10)).pack(pady=2) ttk.Label(info_frame, text="").pack(pady=5) + + ttk.Label(info_frame, text="Курсовая работа", font=("Arial", 10, "bold")).pack(pady=2) ttk.Label(info_frame, text="Петров Игорь ИУ10-24").pack() ttk.Label(info_frame, text="").pack(pady=5) - # Год - ttk.Label(info_frame, text="2026", font=("Arial", 10, "italic")).pack(pady=5) - - # Разделитель ttk.Separator(frame, orient='horizontal').pack(fill='x', padx=20, pady=10) - # Техническая информация tech_frame = ttk.Frame(frame) tech_frame.pack(pady=10) ttk.Label(tech_frame, text="Методы стеганографии:", font=("Arial", 10, "bold")).pack(anchor='w') - ttk.Label(tech_frame, text=" • LSB (Least Significant Bit) - сокрытие в последних битах пикселей").pack(anchor='w') + ttk.Label(tech_frame, text=" • LSB (Least Significant Bit) - сокрытие в младших битах пикселей").pack(anchor='w') ttk.Label(tech_frame, text=" Работает с любыми изображениями, высокая вместимость").pack(anchor='w', padx=20) ttk.Label(tech_frame, text="").pack() ttk.Label(tech_frame, text=" • DCT (Discrete Cosine Transform) - сокрытие в частотной области").pack(anchor='w') @@ -210,9 +258,14 @@ class StegoGUI: ttk.Label(tech_frame, text=" • PNG, BMP, JPG, JPEG").pack(anchor='w') ttk.Label(tech_frame, text="").pack() - ttk.Label(tech_frame, text="Рекомендации:", font=("Arial", 10, "bold")).pack(anchor='w') - ttk.Label(tech_frame, text=" • Для сохранения результата используйте PNG").pack(anchor='w') - ttk.Label(tech_frame, text=" • LSB - универсальный метод, рекомендуется по умолчанию").pack(anchor='w') + ttk.Label(tech_frame, text="Метрика качества:", font=("Arial", 10, "bold")).pack(anchor='w') + ttk.Label(tech_frame, text=" • PSNR (Peak Signal-to-Noise Ratio)").pack(anchor='w') + ttk.Label(tech_frame, text=" > 40 dB — отличное качество").pack(anchor='w', padx=20) + ttk.Label(tech_frame, text=" 30-40 dB — хорошее качество").pack(anchor='w', padx=20) + ttk.Label(tech_frame, text=" 20-30 dB — заметные искажения").pack(anchor='w', padx=20) + ttk.Label(tech_frame, text=" < 20 dB — сильные искажения").pack(anchor='w', padx=20) + + # ==================== Функции для вкладки "Кодирование" ==================== def select_encode_image(self): """Выбор изображения для кодирования""" @@ -233,24 +286,10 @@ class StegoGUI: if filename: self.encode_output_path.set(filename) - def select_decode_image(self): - """Выбор изображения для декодирования""" - filename = filedialog.askopenfilename( - title="Выберите изображение со скрытым сообщением", - filetypes=[("Изображения", "*.png *.bmp *.jpg *.jpeg"), ("Все файлы", "*.*")] - ) - if filename: - self.decode_image_path.set(filename) - def clear_message(self): """Очистка поля сообщения""" self.message_text.delete("1.0", tk.END) - - def clear_result(self): - """Очистка поля результата""" - self.result_text.delete("1.0", tk.END) - self.decoded_message = "" - self.save_btn.config(state='disabled') + self.update_char_counter() def update_capacity(self): """Обновление информации о вместимости""" @@ -265,6 +304,89 @@ class StegoGUI: else: self.capacity_label.config(text="") + def calculate_max_chars(self): + """ + Рассчитывает максимальное количество символов для текущего изображения и метода + Возвращает кортеж (max_bytes, max_chars) + """ + image_path = self.encode_image_path.get() + method = self.method_encode.get() + + if not image_path or not os.path.exists(image_path): + return 0, 0 + + try: + from PIL import Image + + if method == 'lsb': + # LSB: 3 бита на пиксель, переводим в байты + capacity_bytes = calculate_capacity(image_path) + # Вычитаем 4 байта на длину сообщения + max_bytes = max(0, capacity_bytes - 4) + # Примерное количество символов (UTF-8, русские символы ~2 байта) + max_chars = max_bytes // 2 # Осторожная оценка + return max_bytes, max_chars + + else: # dct + img = Image.open(image_path) + w, h = img.size + if w % 8 != 0 or h % 8 != 0: + return 0, 0 + + # DCT: 39 бит на блок RGB, 3 канала + blocks_per_row = w // 8 + blocks_per_col = h // 8 + total_blocks = blocks_per_row * blocks_per_col + max_bits = total_blocks * 39 + max_bytes = max_bits // 8 + # Вычитаем 4 байта на длину сообщения + max_bytes = max(0, max_bytes - 4) + max_chars = max_bytes // 2 + return max_bytes, max_chars + + except Exception as e: + print(f"Ошибка расчёта: {e}") + return 0, 0 + + def update_max_chars(self): + """Обновляет отображение максимального количества символов""" + max_bytes, max_chars = self.calculate_max_chars() + + if max_chars > 0: + self.char_counter_label.config( + text=f"📝 Максимум: ~{max_chars} символов (~{max_bytes} байт)", + foreground="green" + ) + else: + self.char_counter_label.config( + text="⚠️ Выберите изображение (для DCT размер должен быть кратен 8)", + foreground="orange" + ) + + self.update_char_counter() + return max_chars + + def update_char_counter(self, event=None): + """Обновляет счётчик введённых символов""" + message = self.message_text.get("1.0", tk.END).strip() + current_bytes = len(message.encode('utf-8')) + current_chars = len(message) + + max_bytes, max_chars = self.calculate_max_chars() + + if max_chars > 0: + if current_bytes > max_bytes: + self.char_counter_label.config( + text=f"⚠️ ПРЕВЫШЕНИЕ: {current_chars} символов ({current_bytes} байт) > {max_bytes} байт", + foreground="red" + ) + else: + percent = (current_bytes / max_bytes) * 100 + self.char_counter_label.config( + text=f"📝 Введено: {current_chars} символов ({current_bytes} байт) / {max_bytes} байт ({percent:.0f}%)", + foreground="green" if percent < 80 else "orange" + ) + def check_image_for_dct(self, image_path): """Проверка изображения для DCT метода""" try: @@ -273,13 +395,10 @@ class StegoGUI: img = Image.open(image_path) w, h = img.size - # Проверка размеров if w % 8 != 0 or h % 8 != 0: return False, f"Размеры изображения ({w}x{h}) должны быть кратны 8 для DCT метода" - # Проверка "гладкости" изображения stat = ImageStat.Stat(img) - # Если стандартное отклонение цветов большое (>50) — изображение пёстрое if max(stat.stddev) > 50: return False, "Изображение содержит много деталей/цветов.\nDCT рекомендуется только для гладких/однотонных изображений.\nИспользуйте LSB метод." @@ -304,16 +423,27 @@ class StegoGUI: messagebox.showerror("Ошибка", "Введите сообщение для сокрытия") return - # Проверка для DCT + # Проверка превышения лимита + max_bytes, _ = self.calculate_max_chars() + msg_bytes = len(message.encode('utf-8')) + if msg_bytes > max_bytes: + response = messagebox.askyesno( + "Превышение лимита", + f"Сообщение ({msg_bytes} байт) превышает максимальную вместимость ({max_bytes} байт).\n" + f"Часть сообщения будет обрезана.\n\n" + f"Продолжить?" + ) + if not response: + return + if method == 'dct': is_ok, error_msg = self.check_image_for_dct(image) if not is_ok: messagebox.showerror("Ошибка", error_msg) return - # Запускаем в отдельном потоке self.encode_btn.config(state='disabled') - self.encode_progress.grid(row=11, column=1, columnspan=2, sticky='ew', padx=5, pady=5) + self.encode_progress.grid(row=15, column=1, columnspan=2, sticky='ew', padx=5, pady=5) self.encode_progress.start() self.status_label.config(text="⏳ Кодирование... Пожалуйста, подождите", foreground="blue") @@ -329,11 +459,11 @@ class StegoGUI: else: success = encode_dct(image, message, output) - self.root.after(0, self._encode_done, success, None) + self.root.after(0, self._encode_done, success, None, image, output) except Exception as e: - self.root.after(0, self._encode_done, False, str(e)) + self.root.after(0, self._encode_done, False, str(e), image, output) - def _encode_done(self, success, error=None): + def _encode_done(self, success, error=None, original=None, modified=None): """Завершение кодирования""" self.encode_progress.stop() self.encode_progress.grid_forget() @@ -341,12 +471,38 @@ class StegoGUI: if success: self.status_label.config(text="✅ Кодирование успешно завершено!", foreground="green") - messagebox.showinfo("Успех", f"✅ Сообщение успешно скрыто в изображении!\n\nСохранено: {self.encode_output_path.get()}") + + if original and modified: + result = messagebox.askyesno( + "Успех", + f"Сообщение успешно скрыто в изображении!\n\n" + f"Сохранено: {modified}\n\n" + "Рассчитать PSNR (качество изображения)?" + ) + if result: + self.calculate_psnr_pair(original, modified) else: error_msg = error or "Неизвестная ошибка" self.status_label.config(text=f"❌ Ошибка кодирования", foreground="red") messagebox.showerror("Ошибка", f"Не удалось скрыть сообщение:\n{error_msg}") + # ==================== Функции для вкладки "Декодирование" ==================== + + def select_decode_image(self): + """Выбор изображения для декодирования""" + filename = filedialog.askopenfilename( + title="Выберите изображение со скрытым сообщением", + filetypes=[("Изображения", "*.png *.bmp *.jpg *.jpeg"), ("Все файлы", "*.*")] + ) + if filename: + self.decode_image_path.set(filename) + + def clear_result(self): + """Очистка поля результата""" + self.result_text.delete("1.0", tk.END) + self.decoded_message = "" + self.save_btn.config(state='disabled') + def do_decode(self): """Выполнение декодирования""" image = self.decode_image_path.get() @@ -356,9 +512,8 @@ class StegoGUI: messagebox.showerror("Ошибка", "Выберите изображение") return - # Запускаем в отдельном потоке self.decode_btn.config(state='disabled') - self.decode_progress.grid(row=2, column=1, columnspan=2, sticky='ew', padx=5, pady=5) + self.decode_progress.grid(row=3, column=1, columnspan=2, sticky='ew', padx=5, pady=5) self.decode_progress.start() self.decode_status.config(text="⏳ Декодирование... Пожалуйста, подождите", foreground="blue") @@ -414,6 +569,119 @@ class StegoGUI: messagebox.showinfo("Успех", f"✅ Сообщение сохранено в:\n{filename}") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось сохранить файл:\n{e}") + + # ==================== Функции для вкладки "PSNR" ==================== + + def select_psnr_image1(self): + """Выбор первого изображения для PSNR""" + filename = filedialog.askopenfilename( + title="Выберите оригинальное изображение", + filetypes=[("Изображения", "*.png *.bmp *.jpg *.jpeg"), ("Все файлы", "*.*")] + ) + if filename: + self.psnr_image1_path.set(filename) + + def select_psnr_image2(self): + """Выбор второго изображения для PSNR""" + filename = filedialog.askopenfilename( + title="Выберите изменённое изображение", + filetypes=[("Изображения", "*.png *.bmp *.jpg *.jpeg"), ("Все файлы", "*.*")] + ) + if filename: + self.psnr_image2_path.set(filename) + + def calculate_psnr(self): + """Расчёт PSNR между двумя выбранными изображениями""" + img1 = self.psnr_image1_path.get() + img2 = self.psnr_image2_path.get() + + if not img1 or not img2: + messagebox.showerror("Ошибка", "Выберите оба изображения") + return + + self.psnr_btn.config(state='disabled') + self.psnr_result_text.config(state='normal') + self.psnr_result_text.delete("1.0", tk.END) + self.psnr_result_text.insert("1.0", "⏳ Расчёт PSNR... Пожалуйста, подождите") + self.psnr_result_text.config(state='disabled') + + thread = threading.Thread(target=self._calculate_psnr, args=(img1, img2)) + thread.daemon = True + thread.start() + + def _calculate_psnr(self, img1_path, img2_path): + """Фоновый расчёт PSNR""" + try: + psnr = calculate_psnr(img1_path, img2_path) + + if psnr == float('inf'): + quality = "Идеальное (изображения идентичны)" + color = "green" + elif psnr > 40: + quality = "Отличное (изменения практически незаметны)" + color = "green" + elif psnr > 30: + quality = "Хорошее" + color = "blue" + elif psnr > 20: + quality = "Заметные искажения" + color = "orange" + else: + quality = "Сильные искажения" + color = "red" + + result = f"PSNR: {psnr:.2f} dB\n\n" + result += f"Оценка качества: {quality}\n\n" + result += "Шкала PSNR:\n" + result += " > 40 dB — отличное качество\n" + result += " 30-40 dB — хорошее качество\n" + result += " 20-30 dB — заметные искажения\n" + result += " < 20 dB — сильные искажения\n\n" + result += f"Оригинал: {img1_path}\n" + result += f"Изменённое: {img2_path}" + + self.root.after(0, self._psnr_done, result, True, color) + except Exception as e: + self.root.after(0, self._psnr_done, f"Ошибка при расчёте PSNR:\n{str(e)}", False, "red") + + def _psnr_done(self, result, success, color="black"): + """Завершение расчёта PSNR""" + self.psnr_btn.config(state='normal') + self.psnr_result_text.config(state='normal') + self.psnr_result_text.delete("1.0", tk.END) + self.psnr_result_text.insert("1.0", result) + + if success and "PSNR:" in result: + self.psnr_result_text.tag_configure("psnr", foreground=color, font=("Arial", 12, "bold")) + self.psnr_result_text.tag_add("psnr", "1.0", "1.0 lineend") + + self.psnr_result_text.config(state='disabled') + + def calculate_psnr_pair(self, original, modified): + """Расчёт PSNR для пары изображений (вызывается после кодирования)""" + try: + psnr = calculate_psnr(original, modified) + + if psnr == float('inf'): + quality = "Идеальное (изображения идентичны)" + elif psnr > 40: + quality = "Отличное (изменения практически незаметны)" + elif psnr > 30: + quality = "Хорошее" + elif psnr > 20: + quality = "Заметные искажения" + else: + quality = "Сильные искажения" + + messagebox.showinfo( + "PSNR - Качество изображения", + f"PSNR: {psnr:.2f} dB\n\n" + f"Оценка качества: {quality}\n\n" + f"PSNR показывает, насколько сильно изменилось изображение\n" + f"после внедрения сообщения. Чем выше значение, тем лучше." + ) + except Exception as e: + messagebox.showerror("Ошибка", f"Не удалось рассчитать PSNR:\n{e}") def main():