Initial commit
@@ -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())
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ["parse_args"]
|
||||
|
||||
from .parse_args import parse_args
|
||||
@@ -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)
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 114 B |
|
After Width: | Height: | Size: 133 B |
|
After Width: | Height: | Size: 959 B |
|
After Width: | Height: | Size: 667 B |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 405 B |
|
After Width: | Height: | Size: 233 B |
|
After Width: | Height: | Size: 122 B |
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 84 B |
|
After Width: | Height: | Size: 152 B |
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 400 B |
|
After Width: | Height: | Size: 144 B |
|
After Width: | Height: | Size: 201 B |
|
After Width: | Height: | Size: 127 B |
|
After Width: | Height: | Size: 144 B |
|
After Width: | Height: | Size: 156 B |
|
After Width: | Height: | Size: 365 B |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 329 B |
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 133 B |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 133 B |
|
After Width: | Height: | Size: 242 B |
|
After Width: | Height: | Size: 342 B |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 339 B |
|
After Width: | Height: | Size: 133 B |
|
After Width: | Height: | Size: 376 B |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 224 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 114 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 114 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 114 B |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||