Initial commit

This commit is contained in:
user
2026-07-12 14:06:45 +04:00
commit a75028d68f
148 changed files with 3275 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
import json
import sys
from cli.parse_args import parse_args
def _normalize_channel(ch: str) -> str:
"""Y/Cb/Cr в канонический вид."""
m = {"y": "Y", "cb": "Cb", "cr": "Cr"}
s = (ch or "").strip().lower()
return m.get(s, ch)
def _print_result(result):
if result is None:
return
if isinstance(result, (dict, list)):
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(result)
def main() -> int:
args = parse_args()
try:
if args.cmd == "embed":
from modes.embed_mode import EmbedMode
runner = EmbedMode()
result = runner.run(
input_path=args.input,
label_path=args.label,
output_path=args.output,
channel=_normalize_channel(args.channel),
seq0=args.seq0,
)
elif args.cmd == "extract":
from modes.extract_mode import ExtractMode
runner = ExtractMode()
result = runner.run(
input_path=args.input,
output_path=args.output,
channel=_normalize_channel(args.channel),
seq0=args.seq0
)
elif args.cmd == "analyze":
from modes.analyze_quality import AnalyzeQuality
runner = AnalyzeQuality()
result = runner.run(
original_path=args.original,
stego_path=args.stego,
space=args.space,
metrics=args.metrics,
)
elif args.cmd == "generate":
from modes.generate_mode import GenerateMode
runner = GenerateMode()
# Ветка generate имеет вложенные подкоманды: args.gen in {"gradient","chess"}
result = runner.run(
kind=args.gen,
size=args.size,
channels=args.channels,
output_path=args.output,
tile=getattr(args, "tile", None),
)
else:
raise RuntimeError(f"Неизвестная команда: {args.cmd}")
_print_result(result)
return 0
except Exception as exc:
print(f"Ошибка: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+3
View File
@@ -0,0 +1,3 @@
__all__ = ["parse_args"]
from .parse_args import parse_args
+147
View File
@@ -0,0 +1,147 @@
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
def parse_args(argv=None):
ap = ArgumentParser(
prog="app.py",
description="CLI для лабораторной №6: Стеганография в изображениях",
formatter_class=ArgumentDefaultsHelpFormatter,
)
sub = ap.add_subparsers(dest="cmd", required=True)
# -------- Parent parsers --------
parent_channel_seq = ArgumentParser(add_help=False)
parent_channel_seq.add_argument(
"-c", "--channel",
choices=["Y", "Cb", "Cr", "y", "cb", "cr"],
default="Cb",
help="Канал для встраивания/извлечения: Y, Cb или Cr",
)
parent_channel_seq.add_argument(
"--seq0",
type=str,
default="01010101",
help="8-битная последовательность для бита 0 (seq1 считается инверсией seq0)",
)
parent_gen_common = ArgumentParser(add_help=False)
parent_gen_common.add_argument(
"--size",
type=int,
default=512,
help="Размер стороны квадратного изображения",
)
parent_gen_common.add_argument(
"--channels",
type=int,
choices=[1, 3],
default=3,
help="Количество каналов: 1 (grayscale) или 3 (RGB)",
)
parent_gen_common.add_argument(
"-o", "--output",
required=True,
help="Куда сохранить изображение",
)
# ---------------- EMBED ----------------
sp_embed = sub.add_parser(
"embed",
parents=[parent_channel_seq],
help="Встраивание метки в изображение-контейнер",
formatter_class=ArgumentDefaultsHelpFormatter,
)
sp_embed.add_argument(
"-i", "--input",
required=True,
help="Путь к исходному изображению-контейнеру (512x512, RGB)",
)
sp_embed.add_argument(
"-l", "--label",
required=True,
help="Путь к бинарной метке (64x64)",
)
sp_embed.add_argument(
"-o", "--output",
required=True,
help="Куда сохранить стего-изображение",
)
# ---------------- EXTRACT ----------------
sp_extract = sub.add_parser(
"extract",
parents=[parent_channel_seq],
help="Извлечение метки из стего-изображения",
formatter_class=ArgumentDefaultsHelpFormatter,
)
sp_extract.add_argument(
"-i", "--input",
required=True,
help="Путь к стего-изображению",
)
sp_extract.add_argument(
"-o", "--output",
required=True,
help="Куда сохранить восстановленную бинарную метку (64x64)",
)
# ---------------- ANALYZE ----------------
sp_analyze = sub.add_parser(
"analyze",
help="Сравнение изображений и метрик качества",
formatter_class=ArgumentDefaultsHelpFormatter,
)
sp_analyze.add_argument(
"-a", "--original",
required=True,
help="Путь к исходному изображению-контейнеру",
)
sp_analyze.add_argument(
"-b", "--stego",
required=True,
help="Путь к стего-изображению",
)
sp_analyze.add_argument(
"--space",
choices=["rgb", "ycbcr"],
default="ycbcr",
help="Цветовое пространство для метрик",
)
sp_analyze.add_argument(
"--metrics",
nargs="+",
choices=["psnr"],
default=["psnr"],
help="Набор метрик",
)
# ---------------- GENERATE ----------------
sp_gen = sub.add_parser(
"generate",
help="Генерация изображений",
formatter_class=ArgumentDefaultsHelpFormatter,
)
gen_sub = sp_gen.add_subparsers(dest="gen", required=True)
gen_sub.add_parser(
"gradient",
parents=[parent_gen_common],
help="Сгенерировать градиент",
formatter_class=ArgumentDefaultsHelpFormatter,
)
sp_chess = gen_sub.add_parser(
"chess",
parents=[parent_gen_common],
help="Сгенерировать шахматную доску",
formatter_class=ArgumentDefaultsHelpFormatter,
)
sp_chess.add_argument(
"--tile",
type=int,
default=32,
help="Размер квадрата (должен делить --size)",
)
return ap.parse_args(argv)
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 B

+21
View File
@@ -0,0 +1,21 @@
__all__ = [
"Label64",
"Sequence8",
"ColorSpace",
"BlockGrid8",
"ChannelView",
"RgbImage512",
"PsnrMetric",
"YcbcrImage512",
"LsbRow8",
]
from .label64 import Label64
from .sequence8 import Sequence8
from .color_space import ColorSpace
from .block_grid8 import BlockGrid8
from .channel_view import ChannelView
from .rgb_image512 import RgbImage512
from .psnr_metric import PsnrMetric
from .ycbcr_image512 import YcbcrImage512
from .lsb_row8 import LsbRow8
@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class BlockGrid8:
"""Разбиение и сборка блоков 8×8."""
@staticmethod
def split(component: np.ndarray) -> np.ndarray:
"""Разбить 2D-компоненту на блоки (N,8,8)."""
if component.ndim != 2:
raise ValueError("Ожидается 2D-массив компоненты.")
h, w = component.shape
if h % 8 != 0 or w % 8 != 0:
raise ValueError("Размеры должны быть кратны 8.")
# Перестановка осей даёт (nH, nW, 8, 8) -> (N, 8, 8)
arr = np.asarray(component)
nH, nW = h // 8, w // 8
blocks = arr.reshape(nH, 8, nW, 8).transpose(
0, 2, 1, 3).reshape(nH * nW, 8, 8)
return blocks
@staticmethod
def merge(blocks: np.ndarray, height: int, width: int) -> np.ndarray:
"""Собрать 2D-компоненту из блоков (N,8,8)."""
if blocks.ndim != 3 or blocks.shape[1:] != (8, 8):
raise ValueError("Ожидается массив блоков формы (N,8,8).")
if height % 8 != 0 or width % 8 != 0:
raise ValueError("Размеры должны быть кратны 8.")
nH, nW = height // 8, width // 8
if blocks.shape[0] != nH * nW:
raise ValueError(
"Количество блоков не соответствует размеру выходной матрицы.")
# Обратная перестановка из (N,8,8) -> (nH, nW, 8, 8) -> (H, W)
arr = np.asarray(blocks)
comp = arr.reshape(nH, nW, 8, 8).transpose(
0, 2, 1, 3).reshape(height, width)
return comp
@@ -0,0 +1,39 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class ChannelView:
"""Доступ к компонентам Y, Cb, Cr в массиве YCbCr."""
_MAP = {"y": 0, "cb": 1, "cr": 2}
@staticmethod
def _idx(name: str) -> int:
"""Вернуть индекс канала."""
k = name.strip().lower()
if k not in ChannelView._MAP:
raise ValueError("Канал должен быть Y, Cb или Cr.")
return ChannelView._MAP[k]
@staticmethod
def get(ycbcr: np.ndarray, channel: str) -> np.ndarray:
"""Вернуть 2D-компоненту выбранного канала."""
if ycbcr.ndim != 3 or ycbcr.shape[-1] != 3:
raise ValueError("Ожидается массив HxWx3.")
i = ChannelView._idx(channel)
return ycbcr[..., i]
@staticmethod
def set(ycbcr: np.ndarray, channel: str, component: np.ndarray) -> np.ndarray:
"""Вернуть копию YCbCr с заменённым каналом."""
if ycbcr.ndim != 3 or ycbcr.shape[-1] != 3:
raise ValueError("Ожидается массив HxWx3.")
if component.shape != ycbcr.shape[:2]:
raise ValueError("Размер компоненты не совпадает с HxW.")
out = ycbcr.copy()
i = ChannelView._idx(channel)
# Короткая отсечка и приведение типа
out[..., i] = np.clip(component, 0, 255).astype(np.uint8)
return out
@@ -0,0 +1,44 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class ColorSpace:
"""Конвертация RGB↔YCbCr с отсечкой диапазона."""
# Матрица прямого преобразования (используются коэффициенты из задания)
_M = np.array([
[0.299, 0.587, 0.144],
[-0.168736, -0.331264, 0.5],
[0.5, -0.418688, -0.081312],
], dtype=np.float64)
_M_INV = np.linalg.inv(_M)
@staticmethod
def _clip_u8(arr: np.ndarray) -> np.ndarray:
"""Округлить и отсечь к uint8."""
return np.clip(np.rint(arr), 0, 255).astype(np.uint8)
@classmethod
def rgb_to_ycbcr(cls, rgb: np.ndarray) -> np.ndarray:
"""RGB uint8 -> YCbCr uint8."""
if rgb.ndim != 3 or rgb.shape[-1] != 3:
raise ValueError("Ожидается массив HxWx3.")
x = rgb.astype(np.float64).reshape(-1, 3)
ycbcr = x @ cls._M.T
ycbcr[:, 1:] += 128.0 # смещения Cb/Cr
ycbcr = ycbcr.reshape(rgb.shape)
return cls._clip_u8(ycbcr)
@classmethod
def ycbcr_to_rgb(cls, ycbcr: np.ndarray) -> np.ndarray:
"""YCbCr uint8 -> RGB uint8."""
if ycbcr.ndim != 3 or ycbcr.shape[-1] != 3:
raise ValueError("Ожидается массив HxWx3.")
x = ycbcr.astype(np.float64).reshape(-1, 3)
x[:, 1:] -= 128.0 # убрать смещения Cb/Cr
rgb = x @ cls._M_INV.T
rgb = rgb.reshape(ycbcr.shape)
return cls._clip_u8(rgb)
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
import numpy as np
from PIL import Image
@dataclass(frozen=True)
class Label64:
"""Бинарная метка 64x64."""
data: np.ndarray # uint8, shape (64, 64), значения {0,1}
def __post_init__(self):
arr = np.asarray(self.data, dtype=np.uint8)
if arr.shape != (64, 64):
raise ValueError("Метка должна быть 64x64.")
# Нормализация к {0,1}
arr = (arr > 0).astype(np.uint8)
object.__setattr__(self, "data", arr)
@classmethod
def from_image(cls, path: str, threshold: int = 128) -> "Label64":
"""Создать из изображения 64x64."""
img = Image.open(path).convert("L")
if img.size != (64, 64):
raise ValueError("Изображение метки должно быть 64x64.")
arr = np.array(img, dtype=np.uint8)
arr = (arr >= threshold).astype(np.uint8)
return cls(arr)
@classmethod
def from_vector(cls, vec: Iterable[int]) -> "Label64":
"""Создать из вектора длиной 4096."""
v = np.fromiter(vec, dtype=np.uint8, count=4096)
if v.size != 4096:
raise ValueError("Длина вектора должна быть 4096.")
return cls(v.reshape(64, 64))
def to_vector(self) -> np.ndarray:
"""Вернуть вектор длиной 4096."""
return self.data.reshape(-1).astype(np.uint8)
def to_image(self, path: str) -> None:
"""Сохранить PNG 64x64."""
Image.fromarray(self.data * 255, mode="L").save(path)
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class LsbRow8:
"""Встраивание/извлечение 8 бит в первый ряд блока 8×8 через LSB."""
@staticmethod
def embed(block: np.ndarray, bits: np.ndarray) -> np.ndarray:
"""Вернуть копию блока с вшитыми 8 битами."""
if block.shape != (8, 8):
raise ValueError("Нужен блок 8x8.")
b = np.asarray(block, dtype=np.uint8).copy()
v = np.asarray(bits, dtype=np.uint8).reshape(-1)
if v.size != 8 or not np.isin(v, [0, 1]).all():
raise ValueError("Нужно 8 бит 0/1.")
# Меняем только младшие биты первых 8 пикселей верхней строки
row = b[0, :8]
row = (row & 0xFE) | v
b[0, :8] = row
return b
@staticmethod
def extract(block: np.ndarray) -> np.ndarray:
"""Извлечь 8 бит из LSB первого ряда блока."""
if block.shape != (8, 8):
raise ValueError("Нужен блок 8x8.")
b = np.asarray(block, dtype=np.uint8)
return (b[0, :8] & 1).astype(np.uint8)
@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class PsnrMetric:
"""Расчёт PSNR для двух массивов одинаковой формы."""
max_value: float = 255.0
def psnr(self, a: np.ndarray, b: np.ndarray) -> float:
"""Вычислить PSNR."""
if a.shape != b.shape:
raise ValueError("Массивы должны иметь одинаковую форму.")
x = a.astype(np.float64)
y = b.astype(np.float64)
# MSE по всем элементам (каналы включены)
mse = np.mean((x - y) ** 2)
if mse == 0.0:
return float("inf")
return 20.0 * np.log10(self.max_value) - 10.0 * np.log10(mse)
@@ -0,0 +1,38 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from PIL import Image
@dataclass(frozen=True)
class RgbImage512:
"""RGB-изображение 512x512."""
data: np.ndarray # uint8, shape (512, 512, 3)
def __post_init__(self):
arr = np.asarray(self.data, dtype=np.uint8)
if arr.shape != (512, 512, 3):
raise ValueError("Изображение должно быть 512x512x3 (RGB).")
object.__setattr__(self, "data", arr)
@classmethod
def from_file(cls, path: str) -> "RgbImage512":
"""Загрузить из файла."""
img = Image.open(path).convert("RGB")
if img.size != (512, 512):
raise ValueError("Размер изображения должен быть 512x512.")
return cls(np.array(img, dtype=np.uint8))
@classmethod
def from_array(cls, arr: np.ndarray) -> "RgbImage512":
"""Создать из массива 512x512x3."""
return cls(arr)
def to_array(self) -> np.ndarray:
"""Вернуть массив uint8 512x512x3."""
return self.data
def save(self, path: str) -> None:
"""Сохранить в файл."""
Image.fromarray(self.data, mode="RGB").save(path)
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
import numpy as np
@dataclass(frozen=True)
class Sequence8:
"""Пара 8-битных последовательностей: seq0 и инверсная seq1."""
_seq0: np.ndarray # uint8, shape (8,)
def __post_init__(self):
arr = np.asarray(self._seq0, dtype=np.uint8).reshape(-1)
if arr.size != 8:
raise ValueError("seq0 должна содержать 8 бит.")
if not np.isin(arr, [0, 1]).all():
raise ValueError("seq0 может состоять только из 0 и 1.")
object.__setattr__(self, "_seq0", arr)
@property
def seq0(self) -> np.ndarray:
"""Последовательность для бита 0."""
return self._seq0
@property
def seq1(self) -> np.ndarray:
"""Инверсия seq0 для бита 1."""
return 1 - self._seq0
def for_bit(self, bit: int) -> np.ndarray:
"""Вернуть последовательность по значению бита."""
if bit not in (0, 1):
raise ValueError("bit должен быть 0 или 1.")
return self._seq0 if bit == 0 else (1 - self._seq0)
@classmethod
def from_string(cls, bits: str) -> "Sequence8":
"""Создать из строки вида '01010101'."""
if len(bits) != 8 or any(ch not in "01" for ch in bits):
raise ValueError("Строка должна быть из 8 символов 0/1.")
arr = np.fromiter((int(ch) for ch in bits), dtype=np.uint8, count=8)
return cls(arr)
@classmethod
def from_iterable(cls, bits: Iterable[int]) -> "Sequence8":
"""Создать из итерируемого набора из 8 бит."""
arr = np.fromiter(bits, dtype=np.uint8, count=8)
if arr.size != 8:
raise ValueError("Нужно 8 бит.")
return cls(arr)
def __len__(self) -> int:
"""Длина последовательности."""
return 8
@@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
# Локальные импорты внутри методов уменьшают риск циклов зависимостей
@dataclass(frozen=True)
class YcbcrImage512:
"""YCbCr-изображение 512x512."""
data: np.ndarray # uint8, shape (512, 512, 3)
def __post_init__(self):
arr = np.asarray(self.data, dtype=np.uint8)
if arr.shape != (512, 512, 3):
raise ValueError("Изображение должно быть 512x512x3 (YCbCr).")
object.__setattr__(self, "data", arr)
@classmethod
def from_rgb(cls, rgb_img) -> "YcbcrImage512":
"""Создать из RgbImage512."""
from .color_space import ColorSpace
from .rgb_image512 import RgbImage512
if not isinstance(rgb_img, RgbImage512):
raise TypeError("Ожидается RgbImage512.")
ycbcr = ColorSpace.rgb_to_ycbcr(rgb_img.to_array())
return cls(ycbcr)
def to_rgb(self):
"""Преобразовать в RgbImage512."""
from .color_space import ColorSpace
from .rgb_image512 import RgbImage512
rgb = ColorSpace.ycbcr_to_rgb(self.data)
return RgbImage512.from_array(rgb)
def get_channel(self, name: str) -> np.ndarray:
"""Вернуть 2D-компоненту канала."""
from .channel_view import ChannelView
return ChannelView.get(self.data, name)
def with_channel(self, name: str, component: np.ndarray) -> "YcbcrImage512":
"""Вернуть копию с заменённым каналом."""
from .channel_view import ChannelView
new_ycbcr = ChannelView.set(self.data, name, component)
return YcbcrImage512(new_ycbcr)
def to_array(self) -> np.ndarray:
"""Вернуть массив uint8 512x512x3."""
return self.data
@@ -0,0 +1,5 @@
__all__ = ["TestImageGenerator"]
from .chessboard_generator import ChessboardGenerator
from .gradient_generator import GradientGenerator
@@ -0,0 +1,33 @@
from __future__ import annotations
import numpy as np
class ChessboardGenerator:
"""Шахматная доска произвольного размера и каналов."""
@staticmethod
def make(size: int = 512, channels: int = 3, tile: int = 32) -> np.ndarray:
"""Создать шахматку size×size; для RGB каждый квадрат случайного цвета."""
if size <= 0:
raise ValueError("size должен быть > 0.")
if channels not in (1, 3):
raise ValueError("channels должен быть 1 или 3.")
if tile <= 0 or size % tile != 0:
raise ValueError("tile должен быть > 0 и делить size без остатка.")
n = size // tile # число клеток по стороне
if channels == 1:
base = (np.add.outer(np.arange(n), np.arange(n)) % 2).astype(np.uint8) * 255
return np.kron(base, np.ones((tile, tile), dtype=np.uint8))
# RGB: случайный цвет на каждую клетку (uint8)
rng = np.random.default_rng()
colors = rng.integers(0, 256, size=(n, n, 3), dtype=np.uint8)
# Апсемплинг каждой компоненты по плитке
r = np.kron(colors[:, :, 0], np.ones((tile, tile), dtype=np.uint8))
g = np.kron(colors[:, :, 1], np.ones((tile, tile), dtype=np.uint8))
b = np.kron(colors[:, :, 2], np.ones((tile, tile), dtype=np.uint8))
return np.stack([r, g, b], axis=-1)
@@ -0,0 +1,26 @@
from __future__ import annotations
import numpy as np
class GradientGenerator:
"""Градиентное изображение произвольного размера и каналов."""
@staticmethod
def make(size: int = 512, channels: int = 3) -> np.ndarray:
"""Создать квадратный градиент size×size для 1 или 3 каналов."""
if not isinstance(size, int) or size <= 0:
raise ValueError("size должен быть положительным целым.")
if channels not in (1, 3):
raise ValueError("channels должен быть 1 или 3.")
x = np.linspace(0.0, 255.0, num=size, dtype=np.float64)
h = np.tile(x, (size, 1)) # горизонтальный градиент
v = np.tile(x[:, None], (1, size)) # вертикальный градиент
d = (0.5 * (h + v)) # диагональный градиент
if channels == 1:
return d.astype(np.uint8)
rgb = np.stack([h, v, d], axis=2).astype(np.uint8) # R=горизонталь, G=вертикаль, B=диагональ
return rgb
+6
View File
@@ -0,0 +1,6 @@
__all__ = ["EmbedMode", "ExtractMode", "AnalyzeQuality", "GenerateMode"]
from .embed_mode import EmbedMode
from .extract_mode import ExtractMode
from .analyze_quality import AnalyzeQuality
from .generate_mode import GenerateMode
@@ -0,0 +1,41 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List
import numpy as np
from entities.rgb_image512 import RgbImage512
from entities.color_space import ColorSpace
from entities.psnr_metric import PsnrMetric
@dataclass
class AnalyzeQuality:
"""CLI-режим расчёта метрик качества."""
def run(
self,
original_path: str,
stego_path: str,
space: str = "ycbcr",
metrics: List[str] = None,
) -> Dict[str, float]:
"""Посчитать метрики между изображениями."""
if metrics is None:
metrics = ["psnr"]
metrics = [m.lower() for m in metrics]
if any(m != "psnr" for m in metrics):
raise ValueError("Поддерживается только метрика PSNR.")
orig = RgbImage512.from_file(original_path).to_array()
steg = RgbImage512.from_file(stego_path).to_array()
if space.lower() == "ycbcr":
a = ColorSpace.rgb_to_ycbcr(orig)
b = ColorSpace.rgb_to_ycbcr(steg)
elif space.lower() == "rgb":
a, b = orig, steg
else:
raise ValueError("space должен быть 'rgb' или 'ycbcr'.")
psnr = PsnrMetric().psnr(a, b)
return {"psnr": float(psnr)}
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict
from entities.rgb_image512 import RgbImage512
from entities.label64 import Label64
from entities.sequence8 import Sequence8
from steg.embedder import Embedder
from steg.extractor import Extractor
from steg.component_pipeline import ComponentPipeline
@dataclass
class EmbedMode:
"""CLI-режим встраивания метки в изображение."""
def run(
self,
input_path: str,
label_path: str,
output_path: str,
channel: str,
seq0: str,
) -> Dict[str, str]:
"""Выполнить встраивание и сохранить результат."""
rgb = RgbImage512.from_file(input_path)
label = Label64.from_image(label_path)
seq = Sequence8.from_string(seq0)
pipeline = ComponentPipeline(channel=channel, embedder=Embedder(), extractor=Extractor())
rgb_out = pipeline.embed_rgb(rgb, label, seq)
rgb_out.save(output_path)
return {
"status": "ok",
"output": output_path,
"channel": channel,
"seq0": seq0,
}
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict
from entities.rgb_image512 import RgbImage512
from entities.label64 import Label64
from entities.sequence8 import Sequence8
from steg.embedder import Embedder
from steg.extractor import Extractor
from steg.component_pipeline import ComponentPipeline
@dataclass
class ExtractMode:
"""CLI-режим извлечения метки из изображения."""
def run(
self,
input_path: str,
output_path: str,
channel: str,
seq0: str,
) -> Dict[str, str]:
"""Извлечь метку и сохранить 64x64 PNG."""
rgb = RgbImage512.from_file(input_path)
seq = Sequence8.from_string(seq0)
pipeline = ComponentPipeline(channel=channel, embedder=Embedder(), extractor=Extractor())
label: Label64 = pipeline.extract_from_rgb(rgb, seq)
label.to_image(output_path)
return {
"status": "ok",
"output": output_path,
"channel": channel,
"seq0": seq0,
}
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional
import numpy as np
from PIL import Image
from generators.gradient_generator import GradientGenerator
from generators.chessboard_generator import ChessboardGenerator
@dataclass
class GenerateMode:
"""CLI-режим генерации изображений: gradient/chess."""
def run(
self,
kind: str,
size: int,
channels: int,
output_path: str,
tile: Optional[int] = None,
) -> Dict[str, str]:
"""Сгенерировать изображение и сохранить в файл."""
if kind == "gradient":
arr = GradientGenerator.make(size=size, channels=channels)
elif kind == "chess":
t = 32 if tile is None else int(tile)
arr = ChessboardGenerator.make(size=size, channels=channels, tile=t)
else:
raise ValueError("kind должен быть 'gradient' или 'chess'.")
mode = "L" if channels == 1 else "RGB"
if arr.ndim == 2:
img = Image.fromarray(arr, mode=mode)
else:
img = Image.fromarray(arr.astype(np.uint8), mode=mode)
img.save(output_path)
return {"status": "ok", "kind": kind, "size": str(size), "channels": str(channels), "output": output_path}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

+5
View File
@@ -0,0 +1,5 @@
__all__ = ["Embedder", "Extractor", "ComponentPipeline"]
from .embedder import Embedder
from .extractor import Extractor
from .component_pipeline import ComponentPipeline
@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from entities.rgb_image512 import RgbImage512
from entities.ycbcr_image512 import YcbcrImage512
from entities.label64 import Label64
from entities.sequence8 import Sequence8
from .embedder import Embedder
from .extractor import Extractor
@dataclass(frozen=True)
class ComponentPipeline:
"""Оркестрация встраивания/извлечения в заданный канал Y/Cb/Cr."""
channel: str # "Y", "Cb", "Cr"
embedder: Embedder
extractor: Extractor
def embed_rgb(self, rgb: RgbImage512, label: Label64, seq: Sequence8) -> RgbImage512:
"""Вернуть новый RGB с вшитой меткой в выбранный канал."""
ycbcr = YcbcrImage512.from_rgb(rgb)
comp = ycbcr.get_channel(self.channel)
comp_new = self.embedder.embed_component(comp, label, seq)
ycbcr_new = ycbcr.with_channel(self.channel, comp_new)
return ycbcr_new.to_rgb()
def extract_from_rgb(self, rgb: RgbImage512, seq: Sequence8) -> Label64:
"""Извлечь метку из выбранного канала RGB-изображения."""
ycbcr = YcbcrImage512.from_rgb(rgb)
comp = ycbcr.get_channel(self.channel)
return self.extractor.extract_label(comp, seq)
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from entities.block_grid8 import BlockGrid8
from entities.lsb_row8 import LsbRow8
from entities.label64 import Label64
from entities.sequence8 import Sequence8
@dataclass(frozen=True)
class Embedder:
"""Встраивание метки в 2D-компоненту Y/Cb/Cr через LSB верхней строки блоков 8×8."""
def embed_component(self, component: np.ndarray, label: Label64, seq: Sequence8) -> np.ndarray:
"""Вернуть новую компоненту с вшитой меткой."""
if component.ndim != 2:
raise ValueError("Ожидается 2D-компонента.")
h, w = component.shape
if h % 8 != 0 or w % 8 != 0:
raise ValueError("Размеры компоненты должны быть кратны 8.")
blocks = BlockGrid8.split(component)
bits = label.to_vector() # 4096
if blocks.shape[0] != bits.size:
raise ValueError("Количество блоков должно равняться 4096.")
out_blocks = blocks.copy()
# Для каждого бита берём 8-битную сигнатуру и встраиваем в верхнюю строку блока
for i in range(bits.size):
pattern = seq.for_bit(int(bits[i]))
out_blocks[i] = LsbRow8.embed(out_blocks[i], pattern)
return BlockGrid8.merge(out_blocks, h, w)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from entities.block_grid8 import BlockGrid8
from entities.lsb_row8 import LsbRow8
from entities.label64 import Label64
from entities.sequence8 import Sequence8
@dataclass(frozen=True)
class Extractor:
"""Извлечение метки из 2D-компоненты через LSB верхней строки блоков 8×8."""
def extract_label(self, component: np.ndarray, seq: Sequence8) -> Label64:
"""Вернуть Label64, восстановленную из компоненты."""
if component.ndim != 2:
raise ValueError("Ожидается 2D-компонента.")
h, w = component.shape
if h % 8 != 0 or w % 8 != 0:
raise ValueError("Размеры компоненты должны быть кратны 8.")
blocks = BlockGrid8.split(component)
if blocks.shape[0] != 4096:
raise ValueError("Для метки 64x64 требуется 4096 блоков 8x8.")
bits = np.empty(4096, dtype=np.uint8)
s0 = seq.seq0
s1 = seq.seq1
# Поблочный выбор по минимальной хэмминговой дистанции
for i, blk in enumerate(blocks):
b = LsbRow8.extract(blk)
d0 = np.count_nonzero(b != s0)
d1 = np.count_nonzero(b != s1)
bits[i] = 0 if d0 <= d1 else 1
return Label64.from_vector(bits)