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.
@@ -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` 命令行入口
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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]