yccstego 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yccstego-0.1.0/PKG-INFO +40 -0
- yccstego-0.1.0/README.md +31 -0
- yccstego-0.1.0/pyproject.toml +17 -0
- yccstego-0.1.0/setup.cfg +4 -0
- yccstego-0.1.0/tests/test_codec.py +120 -0
- yccstego-0.1.0/tests/test_nsf5.py +164 -0
- yccstego-0.1.0/yccstego/__init__.py +7 -0
- yccstego-0.1.0/yccstego/api.py +66 -0
- yccstego-0.1.0/yccstego/cli.py +132 -0
- yccstego-0.1.0/yccstego/color.py +44 -0
- yccstego-0.1.0/yccstego/cpp_features.py +59 -0
- yccstego-0.1.0/yccstego/dct.py +147 -0
- yccstego-0.1.0/yccstego/huffman.py +225 -0
- yccstego-0.1.0/yccstego/jpeg_codec.py +339 -0
- yccstego-0.1.0/yccstego/nsf5.py +373 -0
- yccstego-0.1.0/yccstego/steganalysis.py +290 -0
- yccstego-0.1.0/yccstego.egg-info/PKG-INFO +40 -0
- yccstego-0.1.0/yccstego.egg-info/SOURCES.txt +20 -0
- yccstego-0.1.0/yccstego.egg-info/dependency_links.txt +1 -0
- yccstego-0.1.0/yccstego.egg-info/entry_points.txt +2 -0
- yccstego-0.1.0/yccstego.egg-info/requires.txt +2 -0
- yccstego-0.1.0/yccstego.egg-info/top_level.txt +1 -0
yccstego-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yccstego
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JPEG compression-domain nsF5 steganography on the Y (luma) channel of YCbCr with self-contained DCT+Huffman codec
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: numpy
|
|
8
|
+
Requires-Dist: Pillow
|
|
9
|
+
|
|
10
|
+
# yccstego —— YCC(YCbCr) 亮度通道 nsF5 隐写工具
|
|
11
|
+
|
|
12
|
+
在 JPEG 压缩域中对 YCbCr 的 **Y(亮度)通道量化 DCT 系数** 实施 nsF5 伴随式矩阵编码隐写。
|
|
13
|
+
自实现标准 JPEG(DCT+量化+Huffman) 编解码,保证量化系数在“保存→解析”后逐位一致,
|
|
14
|
+
从而实现压缩域无失真往返嵌入。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
```bash
|
|
18
|
+
pip install -e .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 用法(CLI)
|
|
22
|
+
```bash
|
|
23
|
+
# 嵌入(消息 UTF-8,中英文均可)
|
|
24
|
+
yccstego embed in.png out.jpg -m "你好,ycc stego" -p 3 -k 口令
|
|
25
|
+
# 消息超容量时按 UTF-8 安全截断(默认则报错)
|
|
26
|
+
yccstego embed small.png out.jpg -m "很长很长的中文…" -p 3 -k 口令 --truncate
|
|
27
|
+
# 解码
|
|
28
|
+
yccstego extract out.jpg -p 3 -k 口令
|
|
29
|
+
# 隐写分析
|
|
30
|
+
yccstego analyze out.jpg
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 结构
|
|
34
|
+
- `yccstego/color.py` RGB↔YCbCr(BT.601) 与 4:2:0 子采样
|
|
35
|
+
- `yccstego/dct.py` 8×8 分块 DCT/IDCT、量化表、之字扫描
|
|
36
|
+
- `yccstego/huffman.py` 标准 JPEG DC/AC Huffman 编解码
|
|
37
|
+
- `yccstego/jpeg_codec.py` 图像↔量化系数↔.jpg 位流
|
|
38
|
+
- `yccstego/nsf5.py` Y 亮度量化 DCT 系数上的 nsF5 嵌入/提取(伴随式+湿纸+块置乱+图像哈希自同步)
|
|
39
|
+
- `yccstego/steganalysis.py` YCC 域盲隐写分析
|
|
40
|
+
- `yccstego/cli.py` 命令行入口
|
yccstego-0.1.0/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# yccstego —— YCC(YCbCr) 亮度通道 nsF5 隐写工具
|
|
2
|
+
|
|
3
|
+
在 JPEG 压缩域中对 YCbCr 的 **Y(亮度)通道量化 DCT 系数** 实施 nsF5 伴随式矩阵编码隐写。
|
|
4
|
+
自实现标准 JPEG(DCT+量化+Huffman) 编解码,保证量化系数在“保存→解析”后逐位一致,
|
|
5
|
+
从而实现压缩域无失真往返嵌入。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
```bash
|
|
9
|
+
pip install -e .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 用法(CLI)
|
|
13
|
+
```bash
|
|
14
|
+
# 嵌入(消息 UTF-8,中英文均可)
|
|
15
|
+
yccstego embed in.png out.jpg -m "你好,ycc stego" -p 3 -k 口令
|
|
16
|
+
# 消息超容量时按 UTF-8 安全截断(默认则报错)
|
|
17
|
+
yccstego embed small.png out.jpg -m "很长很长的中文…" -p 3 -k 口令 --truncate
|
|
18
|
+
# 解码
|
|
19
|
+
yccstego extract out.jpg -p 3 -k 口令
|
|
20
|
+
# 隐写分析
|
|
21
|
+
yccstego analyze out.jpg
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 结构
|
|
25
|
+
- `yccstego/color.py` RGB↔YCbCr(BT.601) 与 4:2:0 子采样
|
|
26
|
+
- `yccstego/dct.py` 8×8 分块 DCT/IDCT、量化表、之字扫描
|
|
27
|
+
- `yccstego/huffman.py` 标准 JPEG DC/AC Huffman 编解码
|
|
28
|
+
- `yccstego/jpeg_codec.py` 图像↔量化系数↔.jpg 位流
|
|
29
|
+
- `yccstego/nsf5.py` Y 亮度量化 DCT 系数上的 nsF5 嵌入/提取(伴随式+湿纸+块置乱+图像哈希自同步)
|
|
30
|
+
- `yccstego/steganalysis.py` YCC 域盲隐写分析
|
|
31
|
+
- `yccstego/cli.py` 命令行入口
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yccstego"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "JPEG compression-domain nsF5 steganography on the Y (luma) channel of YCbCr with self-contained DCT+Huffman codec"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["numpy", "Pillow"]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
yccstego = "yccstego.cli:main"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools]
|
|
17
|
+
packages = ["yccstego"]
|
yccstego-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""JPEG-DCT 编解码(BT.601 YCbCr / 量化 / 之字 / Huffman 熵编码)单元测试。
|
|
2
|
+
|
|
3
|
+
覆盖:
|
|
4
|
+
- YCC 全流程往返: 量化系数逐位一致
|
|
5
|
+
- 熵编码跨实现兼容: 自产 .jpg 能被 Pillow/libjpeg 解码
|
|
6
|
+
- ZRL(≥16连续零)场景
|
|
7
|
+
- DC 类别的合法边界(标准 DC 亮度表最大类别 11, |DC|<=2047)
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
16
|
+
from yccstego import jpeg_codec as J
|
|
17
|
+
from yccstego import color, dct, huffman as H
|
|
18
|
+
from yccstego.jpeg_codec import _encode_blocks, _decode_n, _cat_mag
|
|
19
|
+
from yccstego.huffman import BitReader
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_rgb(h, w, seed=1, rng_np=np.random.RandomState):
|
|
23
|
+
rng = rng_np(seed)
|
|
24
|
+
x = np.linspace(0, 1, w)[None, :]
|
|
25
|
+
y = np.linspace(0, 1, h)[:, None]
|
|
26
|
+
base = np.sin(3 * x) * np.cos(5 * y) * 60 + 120
|
|
27
|
+
R = np.clip(base + 3 * x * 255 + rng.normal(0, 8, (h, w)), 0, 255).astype(np.uint8)
|
|
28
|
+
G = np.clip(120 + 60 * np.sin(8 * x) * np.exp(-(y - 0.5) ** 2 * 20)
|
|
29
|
+
+ rng.normal(0, 8, (h, w)), 0, 255).astype(np.uint8)
|
|
30
|
+
B = np.clip(base - 40 * x + rng.normal(0, 8, (h, w)), 0, 255).astype(np.uint8)
|
|
31
|
+
return np.stack([R, G, B], axis=-1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _entropy_roundtrip(blocks):
|
|
35
|
+
"""编码->解码后量化块逐位一致。blocks:(nb,8,8) int16。"""
|
|
36
|
+
nb = blocks.shape[0]
|
|
37
|
+
data = _encode_blocks(blocks)
|
|
38
|
+
back = _decode_n(BitReader(data), nb, H.DC_TABLE, H.AC_TABLE)
|
|
39
|
+
expect = dct.zigzag(blocks).reshape(nb, 64)
|
|
40
|
+
return np.array_equal(expect, back), expect, back
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestJPEGRoundtrip(unittest.TestCase):
|
|
44
|
+
def test_ycc_roundtrip_bitwise(self):
|
|
45
|
+
for q in (70, 85, 95):
|
|
46
|
+
rgb = make_rgb(96, 128, seed=q)
|
|
47
|
+
ycc = J.YCC(rgb, quality=q)
|
|
48
|
+
back = J.YCC.from_bytes(ycc.to_bytes())
|
|
49
|
+
self.assertTrue(np.array_equal(ycc.Y, back.Y), f"Y mismatch q={q}")
|
|
50
|
+
self.assertTrue(np.array_equal(ycc.Cb, back.Cb), f"Cb mismatch q={q}")
|
|
51
|
+
self.assertTrue(np.array_equal(ycc.Cr, back.Cr), f"Cr mismatch q={q}")
|
|
52
|
+
|
|
53
|
+
def test_pillow_decodes_our_jpg(self):
|
|
54
|
+
from PIL import Image
|
|
55
|
+
import tempfile
|
|
56
|
+
rgb = make_rgb(48, 64, seed=5)
|
|
57
|
+
ycc = J.YCC(rgb, quality=85)
|
|
58
|
+
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
|
59
|
+
f.write(ycc.to_bytes())
|
|
60
|
+
path = f.name
|
|
61
|
+
try:
|
|
62
|
+
im = Image.open(path)
|
|
63
|
+
im.load()
|
|
64
|
+
self.assertEqual(im.size, (64, 48))
|
|
65
|
+
finally:
|
|
66
|
+
os.unlink(path)
|
|
67
|
+
|
|
68
|
+
def test_reconstruct_shape(self):
|
|
69
|
+
rgb = make_rgb(80, 96, seed=7)
|
|
70
|
+
ycc = J.YCC(rgb, quality=85)
|
|
71
|
+
pre = ycc.reconstruct()
|
|
72
|
+
self.assertEqual(pre.shape, rgb.shape)
|
|
73
|
+
|
|
74
|
+
def test_quality_scales_capacity(self):
|
|
75
|
+
rgb = make_rgb(64, 64, seed=9)
|
|
76
|
+
nz70 = np.count_nonzero(J.YCC(rgb, 70).Y)
|
|
77
|
+
nz95 = np.count_nonzero(J.YCC(rgb, 95).Y)
|
|
78
|
+
self.assertGreater(nz95, nz70)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestEntropyCoding(unittest.TestCase):
|
|
82
|
+
def test_random_dc_valid_range(self):
|
|
83
|
+
# 差分编码: 每步 DC 差值须落在标准 DC 亮度表类别范围内(|diff|<=2047 => 类别<=11)。
|
|
84
|
+
# 相邻块的 DC 差即 Huffman 差分编码量, 直接以合法差值随机游走生成。
|
|
85
|
+
rng = np.random.RandomState(0)
|
|
86
|
+
for trial in range(5):
|
|
87
|
+
nb = 3 * (trial + 1)
|
|
88
|
+
blk = np.zeros((nb, 8, 8), dtype=np.int16)
|
|
89
|
+
prev = 0
|
|
90
|
+
for i in range(nb):
|
|
91
|
+
step = int(rng.randint(-1500, 1501))
|
|
92
|
+
dc = prev + step
|
|
93
|
+
dc = int(np.clip(dc, -2047, 2047))
|
|
94
|
+
blk[i, 0, 0] = dc
|
|
95
|
+
prev = dc
|
|
96
|
+
for _ in range(rng.randint(0, 8)):
|
|
97
|
+
k = rng.randint(1, 64)
|
|
98
|
+
blk[i, k // 8, k % 8] = rng.choice([-7, -5, -3, -1, 1, 2, 3, 5])
|
|
99
|
+
ok, _, _ = _entropy_roundtrip(blk)
|
|
100
|
+
self.assertTrue(ok, f"entropy roundtrip mismatch trial {trial}")
|
|
101
|
+
|
|
102
|
+
def test_zrl_long_runs(self):
|
|
103
|
+
for zeros in (16, 17, 20, 31, 40):
|
|
104
|
+
blk = np.zeros((1, 8, 8), dtype=np.int16)
|
|
105
|
+
blk[0, 0, 0] = 100
|
|
106
|
+
idx = 1 + zeros
|
|
107
|
+
blk[0, idx // 8, idx % 8] = 3
|
|
108
|
+
ok, _, _ = _entropy_roundtrip(blk)
|
|
109
|
+
self.assertTrue(ok, f"ZRL={zeros} roundtrip mismatch")
|
|
110
|
+
|
|
111
|
+
def test_cat_mag_boundaries(self):
|
|
112
|
+
# DC 类别边界: 1024..2047 → 类别11; 2048 不合法(无类别12)
|
|
113
|
+
self.assertEqual(_cat_mag(1024)[0], 11)
|
|
114
|
+
self.assertEqual(_cat_mag(2047)[0], 11)
|
|
115
|
+
self.assertEqual(_cat_mag(1)[0], 1)
|
|
116
|
+
self.assertEqual(_cat_mag(0)[0], 0)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Y 域 nsF5 往返/篡改检测测试。独立可运行(python tests/test_nsf5.py),也兼容 pytest。"""
|
|
2
|
+
import os, sys, numpy as np
|
|
3
|
+
import contextlib
|
|
4
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
5
|
+
|
|
6
|
+
from yccstego import jpeg_codec as J
|
|
7
|
+
from yccstego import nsf5
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import pytest
|
|
11
|
+
parametrize = pytest.mark.parametrize
|
|
12
|
+
raises = pytest.raises
|
|
13
|
+
_RUNNER = "pytest"
|
|
14
|
+
except Exception: # 非 pytest 环境:自行驱动
|
|
15
|
+
def _parametrize(argnames, argvalues):
|
|
16
|
+
def deco(fn):
|
|
17
|
+
fn._params = (argnames, argvalues); return fn
|
|
18
|
+
return deco
|
|
19
|
+
def _raises(exc):
|
|
20
|
+
return contextlib.nullcontext() if exc is None else pytest_raises(exc)
|
|
21
|
+
class _Raises:
|
|
22
|
+
def __init__(self, exc): self.exc = exc; self.value = None
|
|
23
|
+
def __enter__(self):
|
|
24
|
+
class C:
|
|
25
|
+
def __exit__(self_, et, ev, tb):
|
|
26
|
+
assert et and issubclass(et, self.exc), f"expected {self.exc}, got {et}"
|
|
27
|
+
self.value = ev; return True
|
|
28
|
+
return C()
|
|
29
|
+
def __exit__(self, *a): pass
|
|
30
|
+
def pytest_raises(exc): return _Raises(exc)
|
|
31
|
+
parametrize = _parametrize
|
|
32
|
+
raises = _raises
|
|
33
|
+
_RUNNER = "manual"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_rgb(h=96, w=128, seed=1):
|
|
37
|
+
rng = np.random.default_rng(seed)
|
|
38
|
+
x = np.linspace(0, 1, w)[None, :]
|
|
39
|
+
yy = np.linspace(0, 1, h)[:, None]
|
|
40
|
+
base = np.sin(3 * x) * np.cos(5 * yy) * 60 + 120
|
|
41
|
+
R = np.clip(base + 3 * x * 255 * np.random.uniform(0.5, 1.5, (h, w)), 0, 255)
|
|
42
|
+
G = np.clip(120 + 60 * np.sin(8 * x) * np.exp(-(yy - 0.5) ** 2 * 20) + rng.normal(0, 8, (h, w)), 0, 255)
|
|
43
|
+
B = np.clip(base - 40 * x + rng.normal(0, 8, (h, w)), 0, 255)
|
|
44
|
+
return np.stack([R, G, B], -1).astype(np.uint8)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def embed_roundtrip(msg, p, password, rgb=None):
|
|
48
|
+
rgb = rgb if rgb is not None else make_rgb(128, 160)
|
|
49
|
+
c0 = J.YCC(rgb, 85)
|
|
50
|
+
y_new, rep = nsf5.embed_into_y(c0.Y, c0.Cb, c0.Cr, msg, p=p, password=password)
|
|
51
|
+
c1 = J.YCC.__new__(J.YCC)
|
|
52
|
+
c1.h, c1.w, c1.orig_shape, c1.quality = c0.h, c0.w, c0.orig_shape, 85
|
|
53
|
+
c1.qlum, c1.qchr = c0.qlum, c0.qchr
|
|
54
|
+
c1.Y, c1.Cb, c1.Cr = y_new, c0.Cb, c0.Cr
|
|
55
|
+
return J.YCC.from_bytes(c1.to_bytes()), rep # 压缩域无失真往返
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
P_LIST = [1, 2, 3, 4]
|
|
59
|
+
MSG = ("Hello YCC nsF5 roundtrip 0123456789 abcdef ") * 2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_roundtrip_ascii(p=3):
|
|
63
|
+
back, rep = embed_roundtrip(MSG, p, "pw")
|
|
64
|
+
out, cov, tampered, hm = nsf5.extract_from_y(back.Y, back.Cb, back.Cr, p=p, password="pw")
|
|
65
|
+
assert out == MSG, f"p={p} message mismatch"
|
|
66
|
+
assert tampered is False
|
|
67
|
+
assert hm is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
CHN = ("中文隐写测试 YCC 亮度通道 携秘密 123456 " * 2).strip()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_roundtrip_chinese():
|
|
74
|
+
for p in (1, 2, 3, 4):
|
|
75
|
+
back, rep = embed_roundtrip(CHN, p, "pw")
|
|
76
|
+
out, cov, tampered, hm = nsf5.extract_from_y(back.Y, back.Cb, back.Cr, p=p, password="pw")
|
|
77
|
+
assert out == CHN, f"p={p} Chinese message mismatch"
|
|
78
|
+
assert hm is True and tampered is False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_wrong_password_fails():
|
|
82
|
+
back, _ = embed_roundtrip("secret-message", 3, "pw-a")
|
|
83
|
+
out, *_ = nsf5.extract_from_y(back.Y, back.Cb, back.Cr, p=3, password="pw-b")
|
|
84
|
+
assert out is None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_chroma_tamper_detected():
|
|
88
|
+
c0 = J.YCC(make_rgb(128, 160), 85)
|
|
89
|
+
y_new, _ = nsf5.embed_into_y(c0.Y, c0.Cb, c0.Cr, "hello", p=3, password="pw")
|
|
90
|
+
bad_cb = c0.Cb.copy(); bad_cb.reshape(-1)[7] += 1
|
|
91
|
+
_, _, tampered, hm = nsf5.extract_from_y(y_new, bad_cb, c0.Cr, p=3, password="pw")
|
|
92
|
+
assert hm is False and tampered is True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_y_body_tamper_detected():
|
|
96
|
+
c0 = J.YCC(make_rgb(128, 160), 85)
|
|
97
|
+
y_new, _ = nsf5.embed_into_y(c0.Y, c0.Cb, c0.Cr, "hello", p=3, password="pw")
|
|
98
|
+
bad = y_new.copy(); bad.reshape(-1)[80] += 1
|
|
99
|
+
out, *_ = nsf5.extract_from_y(bad, c0.Cb, c0.Cr, p=3, password="pw")
|
|
100
|
+
assert out is None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_capacity_error():
|
|
104
|
+
rgb = make_rgb(16, 16)
|
|
105
|
+
c0 = J.YCC(rgb, 85)
|
|
106
|
+
try:
|
|
107
|
+
nsf5.embed_into_y(c0.Y, c0.Cb, c0.Cr, "x" * 5000, p=3, password="pw")
|
|
108
|
+
raise AssertionError("expected CapacityError")
|
|
109
|
+
except nsf5.CapacityError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_truncate_over_capacity():
|
|
114
|
+
rgb = make_rgb(64, 64) # 有头部池与少量正文池,但放不下超长中文
|
|
115
|
+
c0 = J.YCC(rgb, 85)
|
|
116
|
+
long_msg = ("中文隐写消息,长度远超这张图能容纳的载荷容量,测试 UTF-8 安全截断行为,"
|
|
117
|
+
"确保不会产生半个汉字," * 10)
|
|
118
|
+
y, rep = nsf5.embed_into_y(c0.Y, c0.Cb, c0.Cr, long_msg, p=3, password="pw",
|
|
119
|
+
truncate=True)
|
|
120
|
+
assert rep["truncated"] is True
|
|
121
|
+
assert 0 < rep["embedded_chars"] < len(long_msg)
|
|
122
|
+
# 截断结果必须能原样往返提取,且是原消息的合法 UTF-8 前缀
|
|
123
|
+
out, cov, tampered, hm = nsf5.extract_from_y(y, c0.Cb, c0.Cr, p=3, password="pw")
|
|
124
|
+
assert hm is True and tampered is False
|
|
125
|
+
assert long_msg.startswith(out)
|
|
126
|
+
|
|
127
|
+
# 超容量但未开 truncate 仍然抛错
|
|
128
|
+
try:
|
|
129
|
+
nsf5.embed_into_y(c0.Y, c0.Cb, c0.Cr, long_msg, p=3, password="pw", truncate=False)
|
|
130
|
+
raise AssertionError("expected CapacityError when truncate=False")
|
|
131
|
+
except nsf5.CapacityError:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_truncate_noop_within_capacity():
|
|
136
|
+
c0 = J.YCC(make_rgb(128, 160), 85)
|
|
137
|
+
y, rep = nsf5.embed_into_y(c0.Y, c0.Cb, c0.Cr, CHN, p=3, password="pw", truncate=True)
|
|
138
|
+
assert rep["truncated"] is False # 容量足够,不应截断
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _main():
|
|
142
|
+
failures = 0
|
|
143
|
+
for p in P_LIST:
|
|
144
|
+
try:
|
|
145
|
+
test_roundtrip_ascii(p); print(f"PASS test_roundtrip_ascii p={p}")
|
|
146
|
+
except AssertionError as e:
|
|
147
|
+
failures += 1; print(f"FAIL test_roundtrip_ascii p={p}: {e}")
|
|
148
|
+
try:
|
|
149
|
+
test_roundtrip_chinese(); print("PASS test_roundtrip_chinese")
|
|
150
|
+
except Exception as e:
|
|
151
|
+
failures += 1; print(f"FAIL test_roundtrip_chinese: {e!r}")
|
|
152
|
+
for name in ["test_wrong_password_fails", "test_chroma_tamper_detected",
|
|
153
|
+
"test_y_body_tamper_detected", "test_capacity_error",
|
|
154
|
+
"test_truncate_over_capacity", "test_truncate_noop_within_capacity"]:
|
|
155
|
+
try:
|
|
156
|
+
globals()[name](); print(f"PASS {name}")
|
|
157
|
+
except Exception as e:
|
|
158
|
+
failures += 1; print(f"FAIL {name}: {e!r}")
|
|
159
|
+
print("FAILURES:", failures)
|
|
160
|
+
sys.exit(1 if failures else 0)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
_main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""yccstego —— JPEG 压缩域(YCbCr 亮度) nsF5 隐写工具。"""
|
|
2
|
+
from .jpeg_codec import YCC
|
|
3
|
+
from . import color, dct, huffman, jpeg_codec, nsf5, steganalysis, api
|
|
4
|
+
|
|
5
|
+
__all__ = ["YCC", "color", "dct", "huffman", "jpeg_codec", "nsf5",
|
|
6
|
+
"steganalysis", "api"]
|
|
7
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""面向文件/数组的高层 API:嵌入、提取、分析。
|
|
2
|
+
|
|
3
|
+
流程:RGB → YCC(DCT+量化+Huffman 压缩到 .jpg 位流)→ 在量化 Y 的
|
|
4
|
+
非零 AC 系数上做 nsF5 伴随式嵌入 → 写回位流;提取端解析位流回到量化系数再取消息。
|
|
5
|
+
由于压缩域往返是"逐位一致"的(见 jpeg_codec),嵌入系数在文件往返后保持不变。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from . import jpeg_codec as J
|
|
12
|
+
from . import nsf5
|
|
13
|
+
from . import steganalysis as S
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _as_rgb(image) -> np.ndarray:
|
|
17
|
+
if isinstance(image, np.ndarray):
|
|
18
|
+
a = image
|
|
19
|
+
elif isinstance(image, (bytes, bytearray)):
|
|
20
|
+
from PIL import Image
|
|
21
|
+
import io
|
|
22
|
+
a = np.asarray(Image.open(io.BytesIO(bytes(image))).convert("RGB"))
|
|
23
|
+
else: # 假定是路径 / 兼容 Pillow 对象
|
|
24
|
+
from PIL import Image
|
|
25
|
+
a = np.asarray(Image.open(image).convert("RGB"))
|
|
26
|
+
return np.ascontiguousarray(a.astype(np.uint8))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def embed_bytes(rgb_image, text: str, p: int = 3, password: str = "", quality: int = 85,
|
|
30
|
+
truncate: bool = False):
|
|
31
|
+
"""在图像上嵌入任意文本(UTF-8,中英文均可),返回 (.jpg bytes, report)。
|
|
32
|
+
|
|
33
|
+
rgb_image 支持 ndarray / bytes / 路径 / Pillow 对象。返回的字节是带隐写载荷的
|
|
34
|
+
标准 JPEG(libjpeg/Pillow 可打开),从位流本身也可无损还原嵌入系数。
|
|
35
|
+
消息超出容量时:truncate=False 抛 CapacityError;truncate=True 安全截断。
|
|
36
|
+
"""
|
|
37
|
+
rgb = _as_rgb(rgb_image)
|
|
38
|
+
c0 = J.YCC(rgb, quality)
|
|
39
|
+
y_new, rep = nsf5.embed_into_y(c0.Y, c0.Cb, c0.Cr, text, p=p, password=password,
|
|
40
|
+
truncate=truncate)
|
|
41
|
+
c1 = J.YCC.__new__(J.YCC)
|
|
42
|
+
c1.h, c1.w, c1.orig_shape, c1.quality = c0.h, c0.w, c0.orig_shape, quality
|
|
43
|
+
c1.qlum, c1.qchr = c0.qlum, c0.qchr
|
|
44
|
+
c1.Y, c1.Cb, c1.Cr = y_new, c0.Cb, c0.Cr
|
|
45
|
+
return c1.to_bytes(), rep
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_bytes(jpg_bytes, p: int = 3, password: str = ""):
|
|
49
|
+
"""从 .jpg 位流提取消息。返回 (message|None, cover_hash_hex, tampered, head_match)。"""
|
|
50
|
+
y = J.YCC.from_bytes(bytes(jpg_bytes))
|
|
51
|
+
return nsf5.extract_from_y(y.Y, y.Cb, y.Cr, p=p, password=password)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def analyze_bytes(jpg_bytes, sensitivity="均衡", unit_baseline=None):
|
|
55
|
+
"""对 .jpg 位流做盲隐写分析。返回像素域与 DCT 域两套结论的合并字典。"""
|
|
56
|
+
y = J.YCC.from_bytes(bytes(jpg_bytes))
|
|
57
|
+
dct = S.analyze_y(y.Y, sensitivity=sensitivity, unit_baseline=unit_baseline)
|
|
58
|
+
try:
|
|
59
|
+
pix = S.analyze(y.reconstruct(), sensitivity=sensitivity)
|
|
60
|
+
except Exception:
|
|
61
|
+
pix = None
|
|
62
|
+
out = dict(dct)
|
|
63
|
+
if pix:
|
|
64
|
+
out.update(pix_probability=pix["stego_probability"],
|
|
65
|
+
pix_verdict=pix["verdict"], pix=pix)
|
|
66
|
+
return out
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""yccstego 命令行入口:embed / extract / analyze。
|
|
2
|
+
|
|
3
|
+
例:
|
|
4
|
+
yccstego embed cover.png out.jpg -m "hello" -p 3 -k 口令
|
|
5
|
+
yccstego extract out.jpg -p 3 -k 口令
|
|
6
|
+
yccstego analyze out.jpg
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .steganalysis import _SENS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _out_jpg(path, data: bytes):
|
|
18
|
+
with open(path, "wb") as f:
|
|
19
|
+
f.write(data)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_embed(args):
|
|
23
|
+
try:
|
|
24
|
+
jpg_bytes, rep = _embed_impl(args.infile, args.message, args.p, args.password,
|
|
25
|
+
args.quality, args.truncate)
|
|
26
|
+
except Exception as e:
|
|
27
|
+
print(f"[错误] {e}", file=sys.stderr); sys.exit(2)
|
|
28
|
+
_out_jpg(args.outfile, jpg_bytes)
|
|
29
|
+
print(f"已嵌入 → {args.outfile} ({len(jpg_bytes)} 字节)")
|
|
30
|
+
print(f" 正文载体数:{rep['body_pool']} 画像哈希:{rep['cover_hash']}")
|
|
31
|
+
if rep.get("truncated"):
|
|
32
|
+
print(f" 提示:消息超容量,已按 UTF-8 安全截断为 {rep['embedded_chars']} 字符")
|
|
33
|
+
if args.json:
|
|
34
|
+
print(json.dumps({**rep, "outfile": args.outfile}, ensure_ascii=False))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _embed_impl(infile, message, p, password, quality, truncate=False):
|
|
38
|
+
from .api import embed_bytes
|
|
39
|
+
return embed_bytes(infile, message, p=p, password=password, quality=quality,
|
|
40
|
+
truncate=truncate)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_extract(args):
|
|
44
|
+
try:
|
|
45
|
+
msg, cov, tampered, head_match = _extract_impl(args.infile, args.p, args.password)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
print(f"[错误] {e}", file=sys.stderr); sys.exit(2)
|
|
48
|
+
if msg is None:
|
|
49
|
+
print(f"提取失败:{'发现篡改' if tampered else '口令错误或非本工具生成'} "
|
|
50
|
+
f"(header哈希匹配={head_match})")
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
print(f"解密消息:{msg}")
|
|
53
|
+
print(f"覆盖图像哈希:{cov} 篡改标记:{tampered}")
|
|
54
|
+
if args.json:
|
|
55
|
+
print(json.dumps({"message": msg, "cover_hash": cov, "tampered": tampered,
|
|
56
|
+
"header_match": head_match}, ensure_ascii=False))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_impl(infile, p, password):
|
|
60
|
+
from .api import extract_bytes
|
|
61
|
+
data = _read(infile)
|
|
62
|
+
return extract_bytes(data, p=p, password=password)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_analyze(args):
|
|
66
|
+
try:
|
|
67
|
+
res = _analyze_impl(args.infile, args.sensitivity)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
print(f"[错误] {e}", file=sys.stderr); sys.exit(2)
|
|
70
|
+
print(f"文件:{args.infile} AC系数数:{res.get('n_ac')}")
|
|
71
|
+
print(f" DCT 卡方 p 值 : {res.get('dct_chi2_pvalue')}")
|
|
72
|
+
print(f" 奇数幅值占比 : {res.get('parity_odd')} (基线 {res.get('base_parity')})")
|
|
73
|
+
print(f" DCT 隐写概率 : {res.get('stego_probability_dct'):.3f}")
|
|
74
|
+
print(f" DCT 判定 : {res.get('verdict')}")
|
|
75
|
+
if res.get("pix_probability") is not None:
|
|
76
|
+
print(f" [像素域佐证] 概率 : {res.get('pix_probability'):.3f} 判定:{res.get('pix_verdict')}")
|
|
77
|
+
if args.json:
|
|
78
|
+
print(json.dumps(res, ensure_ascii=False, default=str))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _analyze_impl(infile, sensitivity):
|
|
82
|
+
from .api import analyze_bytes
|
|
83
|
+
data = _read(infile)
|
|
84
|
+
return analyze_bytes(data, sensitivity=sensitivity)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read(path) -> bytes:
|
|
88
|
+
with open(path, "rb") as f:
|
|
89
|
+
return f.read()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_parser():
|
|
93
|
+
ap = argparse.ArgumentParser(prog="yccstego",
|
|
94
|
+
description="JPEG 压缩域(Y 亮度 DCT) nsF5 隐写工具")
|
|
95
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
96
|
+
|
|
97
|
+
e = sub.add_parser("embed", help="向图像嵌入文本")
|
|
98
|
+
e.add_argument("infile", help="输入图像(png/jpg/...)")
|
|
99
|
+
e.add_argument("outfile", help="输出 .jpg")
|
|
100
|
+
e.add_argument("-m", "--message", required=True, help="要嵌入的文本(UTF-8,中英文均可)")
|
|
101
|
+
e.add_argument("-p", "--p", type=int, default=3, help="矩阵编码参数 p(1..8,默认3)")
|
|
102
|
+
e.add_argument("-k", "--password", default="", help="口令(可选)")
|
|
103
|
+
e.add_argument("-q", "--quality", type=int, default=85, help="JPEG 质量(1..100)")
|
|
104
|
+
e.add_argument("-t", "--truncate", action="store_true",
|
|
105
|
+
help="消息超容量时按 UTF-8 安全截断(默认超容量则报错)")
|
|
106
|
+
e.add_argument("--json", action="store_true")
|
|
107
|
+
e.set_defaults(func=cmd_embed)
|
|
108
|
+
|
|
109
|
+
x = sub.add_parser("extract", help="从 .jpg 提取消息")
|
|
110
|
+
x.add_argument("infile", help="输入 .jpg")
|
|
111
|
+
x.add_argument("-p", "--p", type=int, default=3)
|
|
112
|
+
x.add_argument("-k", "--password", default="")
|
|
113
|
+
x.add_argument("--json", action="store_true")
|
|
114
|
+
x.set_defaults(func=cmd_extract)
|
|
115
|
+
|
|
116
|
+
a = sub.add_parser("analyze", help="盲隐写分析")
|
|
117
|
+
a.add_argument("infile", help="输入 .jpg")
|
|
118
|
+
a.add_argument("-s", "--sensitivity", choices=list(_SENS), default="均衡",
|
|
119
|
+
help="判定灵敏度")
|
|
120
|
+
a.add_argument("--json", action="store_true")
|
|
121
|
+
a.set_defaults(func=cmd_analyze)
|
|
122
|
+
return ap
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv=None):
|
|
126
|
+
ap = build_parser()
|
|
127
|
+
args = ap.parse_args(argv)
|
|
128
|
+
args.func(args)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""RGB <-> YCbCr(BT.601) 转换与 4:2:0 子采样。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
# BT.601 full-swing 系数 (0..255)
|
|
7
|
+
KR, KG, KB = 0.114, 0.587, 0.299
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def rgb2ycbcr(rgb: np.ndarray) -> np.ndarray:
|
|
11
|
+
"""rgb: (H,W,3) uint8 -> ycbr: (H,W,3) float32 [0,255]。"""
|
|
12
|
+
R = rgb[..., 0].astype(np.float32)
|
|
13
|
+
G = rgb[..., 1].astype(np.float32)
|
|
14
|
+
B = rgb[..., 2].astype(np.float32)
|
|
15
|
+
Y = KR * R + KG * G + KB * B
|
|
16
|
+
Cb = 128.0 + (B - Y) * 0.564
|
|
17
|
+
Cr = 128.0 + (R - Y) * 0.713
|
|
18
|
+
return np.stack([Y, Cb, Cr], axis=-1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ycbcr2rgb(ycc: np.ndarray) -> np.ndarray:
|
|
22
|
+
"""ycc: (H,W,3) float -> rgb uint8 Clamped。"""
|
|
23
|
+
Y = ycc[..., 0].astype(np.float32)
|
|
24
|
+
Cb = ycc[..., 1].astype(np.float32) - 128.0
|
|
25
|
+
Cr = ycc[..., 2].astype(np.float32) - 128.0
|
|
26
|
+
R = Y + 1.402 * Cr
|
|
27
|
+
G = Y - 0.344136 * Cb - 0.714136 * Cr
|
|
28
|
+
B = Y + 1.772 * Cb
|
|
29
|
+
out = np.stack([R, G, B], axis=-1)
|
|
30
|
+
return np.clip(out, 0, 255).round().astype(np.uint8)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def subsample(ch: np.ndarray, factor: int = 2) -> np.ndarray:
|
|
34
|
+
"""4:2:0: 对单通道做 2x2 均值下采样。ch: (H,W) float。"""
|
|
35
|
+
H, W = ch.shape
|
|
36
|
+
Hs, Ws = H // factor, W // factor
|
|
37
|
+
ch = ch[: Hs * factor, : Ws * factor]
|
|
38
|
+
return 0.25 * (ch[::2, ::2] + ch[1::2, ::2] + ch[::2, 1::2] + ch[1::2, 1::2])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def upsample(ch: np.ndarray, target: tuple, factor: int = 2) -> np.ndarray:
|
|
42
|
+
"""最近邻放大回原尺寸。chn: (Hs,Ws)。"""
|
|
43
|
+
H, W = target
|
|
44
|
+
return np.repeat(np.repeat(ch, factor, axis=0), factor, axis=1)[:H, :W]
|