news 2026/9/11 14:21:42

手写DCT与DWT数字水印:从原理到鲁棒性调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写DCT与DWT数字水印:从原理到鲁棒性调优

简介:这套数字水印技术资源以Matlab为运行环境,围绕DCT与DWT两种经典变换以及二者混合的水印嵌入与提取方法展开,适合图像处理、多媒体安全方向的研究者与初学者参考学习,可用于理解频域水印原理并快速开展对比实验。资源共27个文件,压缩包仅1.13MB,其中8个Matlab源码文件实现水印嵌入、常见攻击模拟、水印提取以及归一化相关、峰值信噪比等性能指标计算;12个jpg和3个bmp、2个png等测试图像用于验证算法在不同载体上的表现;docx运行文档与asv自动保存文件可辅助梳理流程。已有679人学习下载。资源内提供可直接运行的DCT/DWT水印Matlab代码、标准Lena等图像素材和说明文档,包含从嵌入、攻击、提取到质量评价的完整流程,便于在此基础上改进嵌入强度与鲁棒性策略,学习者能迅速掌握混合水印的实现思路与评价方法。

1. 数字水印不是玄学:DCT与DWT到底在解决什么问题

给一张图片塞进一段不可见的身份信息,听起来像特工电影里的桥段,但这套东西早就在版权声明、泄露溯源、医疗影像归档里跑了好多年。数字水印做的不是加密,而是把一段信息嵌入到载体数据里,让它在正常使用中不干扰观感,同时在被裁剪、压缩、缩放之后还能被提取出来。这里的关键点在“还能被提取出来”,所以问题从来不是怎么把比特写进像素,而是怎么在图像经过一轮又一轮常规处理之后,仍然能从残留里找回那串比特。

DCT与DWT是两条最常见的嵌入路径。DCT把图像分块后转到频率域,把水印藏在适合人眼感知的中间频段;DWT则把图像拆成不同分辨率下的逼近子带与细节子带,在不同尺度上分别调整系数。两者都能做水印,也都有各自的弱点——DCT对压缩抗性强但对几何攻击敏感,DWT多尺度鲁棒性更好却更容易被低通滤波抹平。所以实践里多是把两者叠起来用,比如DCT嵌入DWT的低频子带,或者反过来在DWT中频系数上再切块做DCT。这样做的收益不是简单1+1,而是让整个嵌入过程同时拿到DWT的结构分解能力和DCT的局部能量分布特征。

这篇文章面向的读者是已经会写Python、调过OpenCV或PyTorch的人。不考虑深度学习端到端方案,只讲DCT、DWT以及DWT-DCT混合水印的手写实现——因为只有手写过一遍系数选择,你才会知道那些所谓“鲁棒性参数”是怎么来的,也才会在真实业务里遇到图像被转码后水印消失时,知道往哪个方向排查。整个过程用Python和NumPy就能跑通,不需要GPU。

2. 数字水印的拆解:从空间域到频域,为什么绕不开DCT与DWT

2.1 把水印系统拆成三个模块,别一上来就调库

一个数字水印系统无论用哪种变换,结构都是固定的三段:嵌入端、传输信道、提取端。嵌入端把水印信息调制到载体图像的某些系数上;传输信道包括正常的图像压缩、尺寸调整、滤波,也包括恶意的裁剪和涂改;提取端则只依赖接收到的图像,在不知道原始载体的条件下尽可能恢复水印比特。

搞清楚这三段之后就会明白一个事实:算法报告的PSNR和鲁棒性都只是实验室条件下的指标,真实管道的瓶颈往往不在算法本身,而在格式转换时引入的量化误差。比如把PNG换成JPEG,DCT块边界被压缩算法进一步量化,高频系数先被丢弃,这个时候水印嵌在哪些频带上就直接决定了它还能不能活着出来。所以下一节先讲清楚频域系数在常规图像处理里到底损失得有多快。

2.1.1 等价的数学直觉:变换不是特征提取,是换坐标

DCT和DWT做的事情在数学上都属于正交变换,本质是把一个二维图像矩阵从空间坐标换到频率坐标。空间域里的一个像素值跟它周围像素的关系是局部的,但在变换域里,一个系数代表的是这个局部区域在整个频段上的能量贡献。水印要藏进这些系数里,就要找一个区域——这个区域的系数值在人眼感知上是次要的,但在各种信号处理下衰减又是可控的。

JPEG的本质就是在DCT域里做量化。对8x8块逐块做DCT,然后按量化表把系数除以一个步长再取整。这个过程直接决定了基于DCT的水印的生死:水印嵌入到与JPEG量化步长相近的系数幅度上,压缩后被量化到一个整数格子里,就会产生不可逆的偏差。DWT虽然不直接对应JPEG标准,但大多数图像处理库在缩放和滤波时,会改变小波子带系数的统计分布,尤其会影响高频细节子带。这两个现象是所有后续工程决策的出发点。

2.2 空间域的直接替换有硬伤,但它不是没用

最朴素的空间域方案是LSB替换,直接把最低比特位写成水印信息。优点是容量极大,嵌入速度快,提取时只要取余数就能恢复。缺点是极脆弱,任何有损压缩、颜色抖动甚至是一次格式转换,都会把最低比特位抹掉。

所以空间域做法只适合那些对鲁棒性要求不高的场景,比如图片在内部系统里以无损格式流转、且只需要标记归属方的场景。一旦内容需要对外发布或会被平台二次压缩,空间域方案就基本退出竞争了。

那为什么不干脆把所有水印都丢给频域?因为频域嵌入有一个代价——它没有办法在嵌入过程中精确定位水印的空间位置。这对某些需要“在图像内容里隐藏一段空间上集中的信息”(比如二维码形式的可见水印)的需求来说是不适用的。归根结底,空间域和频域不是替代关系,而是两种坐标系统下的取舍。

2.3 DCT与DWT的角色分工:一个管局部纹理,一个管多尺度结构

DCT的强项在于局部能量集中。对自然图像的分块DCT系数做统计,会发现能量集中在左上角的低频系数上,而右下角的高频系数普遍接近零值。嵌入算法通常会选择中频系数,因为低频系数变化会引起明显块效应,高频系数又容易在JPEG压缩时被量化掉。中频带的典型位置在一张8x8块的坐标(4,1)到(6,3)这一片区域。

DWT的强项在于多尺度分解。它通过低通和高通滤波器把图像拆成长宽各一半的四个子带:LL低频逼近子带、LH水平细节、HL垂直细节、HH对角细节。对LL带继续做分解,就形成多级塔式结构。低频子带对一般信号处理有更强的抵抗能力,但直接修改LL子带也最容易被察觉,因为LL决定了图像整体亮度分布。因此经典做法是把水印嵌到二级或三级分解的中等尺度细节子带,或者对LL子带再做一次DCT,把水印嵌进DCT中频系数里——这个思路就是后文要展开的DWT-DCT混合数字水印。

3. 用DCT做数字水印:系数选择、嵌入步骤与完整进程

3.1 对图像做分块DCT前的三个前置决策

动手前先明确三件事。第一,灰度图还是彩色图。灰度图直接做二维DCT,彩色图一般是先转YUV,只在Y通道(亮度)上嵌入水印,这样能减少颜色失真。第二,块尺寸。块尺寸直接影响嵌入容量与鲁棒性的取舍。8x8是常见的选择,与JPEG块对齐,在压缩后保留率上会更好;16x16块频率分辨率更高,但块数量减少,嵌入容量随之下降。第三,嵌入强度。强度越高,肉眼可见性越强,但抗攻击能力也会上升,所以这是一个工程上的权衡。

这三个决策决定了后面所有参数表的起点。建议先固定在8x8、嵌入强度适中、灰度图跑通全流程,再逐步调整变量看效果。

3.1.1 读取图像并分块的边缘情况处理

图像长宽不一定是块尺寸的整数倍,这是最容易踩的坑。一个简单做法是在分块前用常量填充把图像补齐到8的倍数。OpenCV的copyMakeBorder函数或NumPy的pad都能做。另一方面要考虑灰度图和彩色图的通道数差异,cv2.imread(path, cv2.IMREAD_GRAYSCALE)是常用的方式。

3.2 DCT系数标定:哪些位置适合嵌入水印

对一张典型自然图像的8x8分块DCT系数做统计,会得到一种稳定的分布:左上角DC系数数值最大,代表了整块的平均亮度;右下角高频系数几乎都逼近零。嵌入到DC系数上抗压缩能力最强,但图像会出现明显的块间亮度跳变;嵌入到高频系数上则几乎不可见,但JPEG一压缩就没了;所以折中区间在中间频带上。

这张表说明了嵌入位置与攻击损耗的对照关系,实际工程里需要根据载体内容做出选择。图像纹理越丰富,系数方差越大,能容纳的嵌入强度就越高。低频系数幅值大,相同嵌入强度下的相对扰动比例小,因此不易察觉但压缩后保留度好。

3.2.1 用Zigzag扫描选择中频候选块

JPEG标准用Zigzag顺序把8x8系数矩阵重排成一维数组,前面的低频,后面的高频。嵌入时只要确定一个起始索引范围,比如从索引3到索引10(对应中低频位置),就可以对每个候选位置重复嵌入同一个水印比特。重复嵌入是提升提取成功率最直接的手段。

3.3 Python实现DCT水印嵌入:代码每一步在干什么

下面是一段可以直接运行的嵌入脚本,负责把一个二进制字符串扩展到整幅图的中频系数里。

import cv2 import numpy as np from scipy.fftpack import dct, idct block_size = 8 alpha = 30 def embed_watermark_dct(img, watermark_bits): h, w = img.shape # 填充到8的倍数 pad_h = (block_size - h % block_size) % block_size pad_w = (block_size - w % block_size) % block_size img_padded = cv2.copyMakeBorder(img, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=0) H, W = img_padded.shape embed_pos = [4, 5, 6, 7, 8] n_blocks = (H // block_size) * (W // block_size) bit_len = len(watermark_bits) # 反复重复水印,保证每个块都嵌到 repeated_bits = (watermark_bits * (n_blocks // bit_len + 1))[:n_blocks] idx = 0 for i in range(0, H, block_size): for j in range(0, W, block_size): block = img_padded[i:i+block_size, j:j+block_size] coeff = dct(dct(block, axis=0, norm='ortho'), axis=1, norm='ortho') bit = repeated_bits[idx] for p in embed_pos: # 按水印比特调整系数,量化为特定步长 coeff.ravel()[p] = (coeff.ravel()[p] // alpha) * alpha + alpha // 2 + bit * alpha // 2 block_watermarked = idct(idct(coeff, axis=0, norm='ortho'), axis=1, norm='ortho') img_padded[i:i+block_size, j:j+block_size] = block_watermarked idx += 1 # 去掉填充部分 return img_padded[:h, :w]

这段代码的逻辑并不复杂。对每个8x8块做二维DCT之后,coeff.ravel()[p]把二维系数矩阵按行展平,然后取索引4到8的位置。alpha是量化步长,它控制水印的一个比特在系数上产生的偏移大小。coeff // alpha * alpha + alpha // 2 + bit * alpha // 2的本质是把系数往一个特定量化格子上靠拢:比特为0时靠拢到格子中间偏下,比特为1时靠拢到格子中间偏上,这样提取时只要对alpha取模就能判断。norm='ortho'参数是正交归一化选项,缺少它会导致变换后的系数尺度不一致,进而影响嵌入强度的物理含义。

3.4 提取流程设计与代码实现

提取端不知道原始图像,所以只能通过量化取模来判断每个块承载的比特。

def extract_watermark_dct(img_watermarked, orig_shape, expected_len): h, w = img_watermarked.shape pad_h = (block_size - h % block_size) % block_size pad_w = (block_size - w % block_size) % block_size img_padded = cv2.copyMakeBorder(img_watermarked, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=0) H, W = img_padded.shape embed_pos = [4, 5, 6, 7, 8] extracted = [] idx = 0 for i in range(0, H, block_size): for j in range(0, W, block_size): block = img_padded[i:i+block_size, j:j+block_size] coeff = dct(dct(block, axis=0, norm='ortho'), axis=1, norm='ortho') # 多个位置的系数取平均后做量化判断 vals = [coeff.ravel()[p] for p in embed_pos] mean_val = np.mean(vals) bit = 1 if mean_val % alpha > alpha / 2 else 0 extracted.append(bit) idx += 1 return extracted[:expected_len]

提取时用5个位置系数幅值的平均值来减少单点误差,这是一个低成本的降噪方式。mean_val % alpha得到的余数如果大于alpha的一半,就认为是1,否则是0。这个方案的优点在于不需要原始图像的无参考提取,缺点在于如果载体本身系数分布极不均匀——比如纯色块区域,所有DCT中频系数都趋近于0——嵌入后的量化偏移也可能被噪声干扰,导致某些块提取错误。

3.5 JPEG压缩后水印还能提取吗:一个验证实验步骤

把嵌入后的图像用cv2.imwrite('out.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 85])保存为质量系数85的JPEG,再读回来执行提取,统计误码率。这里有一个常见误区:如果嵌入位置与JPEG量化表的中频系数步长兼容性不够,质量系数一旦低于70,提取误码率会急剧升高。想验证算法极限,就做一组不同质量系数的对比实验,从90、80、70逐级下降。90以上应该接近零误码,低于70出现明显跳崖是正常的。如果从一开始就高误码,先检查alpha是否过小,或者嵌入位置是否处于JPEG量化表极限截止位置附近。

4. 用DWT提升鲁棒性:DWT数字水印与DWT-DCT数字水印的关键差异

4.1 为什么单靠DCT不够:几何攻击与压缩残留的边界

纯DCT分块水印的核心问题是分块独立性。每8x8块各自嵌入、各自提取,块与块之间没有协同关系。图像一旦发生少许平移、旋转或者缩放,块的边界就与原始位置错位,提取时你根本不知道应该对齐到哪里。这种对几何攻击的脆弱性是DCT方案的结构性缺陷,单纯调嵌入强度解决不了。

DWT的做法是从整体上分解,产生金字塔式的多分辨率表示。即使图像尺寸有轻微变化,LL子带的整体结构仍然能保留主要的能量分布,只要通过后期同步或对嵌入位置做一定的范围冗余,就可以在一定程度上抵抗这种错位。当然,DWT也不是万能的,它在纯高频细节上的鲁棒性不如DCT在中频局部区域的控制力,所以两者结合才具备更好的实用价值。

4.2 小波子带的性格:哪个子带能扛攻击、哪个会留痕迹

对图像做一层小波分解后,四个子带的特征差异非常明显。LL子带保留了图像大部分能量,肉眼看上去就是个缩小的原图,修改这里最容易引起视觉退化,但任何攻击只要不摧毁图像的主体亮度结构,水印就会留在里面。LH、HL子带分别对应水平边缘和垂直边缘,人眼对这些方向性的高频噪声有一定容忍度,是中庸的嵌入位置。HH子带包含的是对角方向细节,能量低,嵌入式不易察觉,但往往是图像压缩首先丢弃的对象。

实际项目里最常见的处理是对LL子带做一次DCT,然后继续嵌DCT中频系数。这种DWT-DCT数字水印方案既拿到LL子带较强的抗并行攻击能力,又能通过DCT把比特放在LL子带的局部中频系数上,从而保留较高的嵌入质量。需要注意,如果直接在一级DWT的LL子带上再嵌水印,可嵌入的容量会降低到原始的1/4,但单位比特的鲁棒性提高了不少。

4.3 结合实现:DWT-DCT混合水印的具体流程与代码

4.3.1 DWT-DCT嵌入代码
import pywt from scipy.fftpack import dct, idct def embed_dwt_dct(img, watermark_bits, level=1): # 使用haar小波做一级分解 coeffs = pywt.wavedec2(img, 'haar', level=level) LL, (LH, HL, HH) = coeffs[0], coeffs[1:] h, w = LL.shape block_size_dct = 4 alpha_dwt = 20 n_blocks = (h // block_size_dct) * (w // block_size_dct) bit_len = len(watermark_bits) repeated = (watermark_bits * (n_blocks // bit_len + 1))[:n_blocks] LL_watermarked = LL.copy() idx = 0 for i in range(0, h, block_size_dct): for j in range(0, w, block_size_dct): block = LL_watermarked[i:i+block_size_dct, j:j+block_size_dct] coeff = dct(dct(block, axis=0, norm='ortho'), axis=1, norm='ortho') bit = repeated[idx] # 只修改(1,1)和(1,2)这种低频位置,避开DC coeff[1, 1] = (coeff[1, 1] // alpha_dwt) * alpha_dwt + alpha_dwt // 2 + bit * alpha_dwt // 2 coeff[1, 2] = (coeff[1, 2] // alpha_dwt) * alpha_dwt + alpha_dwt // 2 + bit * alpha_dwt // 2 block_back = idct(idct(coeff, axis=0, norm='ortho'), axis=1, norm='ortho') LL_watermarked[i:i+block_size_dct, j:j+block_size_dct] = block_back idx += 1 # 重构图像 coeffs_new = [LL_watermarked, (LH, HL, HH)] return pywt.waverec2(coeffs_new, 'haar')

这里有几个值得解释的细节。第一,pywt.wavedec2返回的是顶层LL加上每一层的三个细节子带元组,代码里对LL做4x4分块DCT,比8x8更细粒度,嵌入容量更大,但每个块承载的信息量也小。第二,修改位置选(1,1)和(1,2)是在4x4块的DCT系数里避开DC后最靠前的位置,因为4x4的系数空间本来就小,选太靠后的高频位置意义不大。第三,alpha_dwt比纯DCT的alpha小,是因为LL子带本身就是低频逼近,同样的绝对偏移在重构后会作用到更大的空间区域上,视觉影响更明显。

4.3.2 提取端的同步问题

DWT-DCT的提取端同样做一级小波分解,取LL子带,再分块DCT,然后对(1,1)和(1,2)位置的系数取模判比特。这里有一个比纯DCT更麻烦的事情:如果用cv2.resize或某些库缩放图像,再做DWT,LL子带的尺寸会变化,直接拿原嵌入时的块坐标去读就会错位。

实际做法是在嵌入时就把水印比特重复很多遍,并且每个比特分布在多个空间位置上。提取时不需要精确知道块的边界,只要在候选区域内滑动窗口取多个可能的偏移位置,对每个偏移位置分别提取并做投票,取通过率最高的那组作为结果。这个思路叫自同步,虽然朴素,但实用。

4.4 DCT水印、DWT水印、DWT-DCT水印的参数对照与选型

方案嵌入位置视觉影响抗JPEG压缩抗缩放抗旋转典型嵌入容量
空间域LSB最低位平面极差
DCT中频8x8块中频系数较好
DWT LL子带最低频逼近系数较大较好较低
DWT-DCTLL子带+DCT中频较好较好

选型不是看哪个算法名字厉害,而是看内容管道。如果图片最终是JPEG对外分发,DCT方案天然与JPEG的变换域一致,有优势;如果图片会在系统内部做缩放处理,DWT-DCT的更粗粒度结构抗缩放能力更强。实际中我一般先用DWT-DCT做基线,再根据具体攻击测试结果往纯DCT方向调。

5. 数字水印的验证、攻击测试与可落地的参数调优经验

5.1 如何科学地判断水印“成功”了:PSNR与误码率的关系

很多初学者只盯着提取结果能不能看到字符串,这是不够的。嵌入水印后的图像需要计算峰值信噪比(PSNR),一般要求在30dB以上才认为视觉不可见。提取端的评估指标是误比特率(BER),也就是错误的比特数除以总比特数。两者需要同时汇报,因为一味降低嵌入强度可以让PSNR好看,但误码率必然升高。

一个简单可复现的完整评估函数如下:

def evaluate_watermark(original, watermarked, extracted_bits, original_bits): mse = np.mean((original.astype(float) - watermarked.astype(float)) ** 2) psnr = 10 * np.log10(255 * 255 / mse) ber = np.mean(np.array(extracted_bits) != np.array(original_bits)) return psnr, ber

这里的PSNR使用灰度图的逐像素差计算,mse越小说明视觉失真越小。BER如果超过15%,实际应用里就得考虑纠错码或重复投票来挽救,超过30%基本宣告算法参数失效。观察指标的方式也要讲逻辑:先固定攻击条件(比如固定JPEG质量=80),然后画一条横轴为嵌入强度、纵轴为误码率的曲线,找到拐点,再定参数。

5.2 必做的5类鲁棒性测试:代码与阈值参考

5.2.1 JPEG压缩测试
cv2.imwrite('tmp.jpg', img_watermarked, [cv2.IMWRITE_JPEG_QUALITY, Q]) img_attacked = cv2.imread('tmp.jpg', cv2.IMREAD_GRAYSCALE)

质量系数Q的梯度用90、85、75、60、40来测。质量系数低于50以后,中频系数被大幅量化,这时算法退化是正常的,关键是看质量系数在75左右能不能保持BER低于8%。

5.2.2 缩放与旋转攻击

缩放攻击的常见做法是缩小到原本的1/2或1/4再放大回原尺寸。旋转攻击在OpenCV里用仿射变换做,但如果只测算法不测同步,直接对全图旋转45度后提取,意义不大。更贴近实际的做法是旋转后先做中心裁剪,裁掉旋转产生的黑边,再提取。中心裁剪本身也是一种攻击,能同时测出裁剪鲁棒性。

5.2.3 高斯噪声与中值滤波
noise = np.random.normal(0, sigma, img_watermarked.shape) img_noisy = np.clip(img_watermarked + noise, 0, 255).astype(np.uint8) img_filtered = cv2.medianBlur(img_watermarked, kernel_size)

高斯噪声的sigma从5到20逐步增加。中值滤波kernel从3x3增加到5x5时,如果水印BER上升过于明显,说明嵌入位置偏高频,需要向DC方向调整。

5.3 参数调整的实操顺序:先定鲁棒性目标,再调alpha,最后换位置

调参有个反直觉的规律——一上来就同时改alpha、块大小、嵌入位置,永远调不出好看的结果。正确顺序是先固定所有参数,单测嵌入强度alpha。alpha每翻一倍,PSNR大约下降2-3dB,但BER会有显著改善。找到alpha的合理区间后,再调嵌入位置。只移动嵌入位置,把系数从中频移到偏高一段,BER会在JPEG攻击下大幅升高,而PSNR几乎不变。

块大小的调整要放在最后。因为块大小改变直接改变容量上限,如果水印长度固定,块越大冗余越高,但每块嵌入的比特数变少。如果水印过长,就要优先考虑升级成DWT方案,而不是硬改块大小。

5.4 一个容易忽略的坑:颜色空间转换带来的偏差

灰度图跑通后迁移到彩色图,很多人直接在RGB三个通道都嵌一遍,提取时取三个通道的结果投票。这个做法的问题在于RGB通道之间存在强相关性,转JPEG时YCbCr空间经过色度下采样,嵌入在RGB高频系数上的信息会被大幅削弱。正确做法是先转YUV,只在Y通道嵌入,色度通道保持原样。这样既不损失太多容量,也能规避色度下采样的影响。

5.5 进阶技巧:用格雷码把相邻比特的误判率降下来

如果提取误码率稳定在5%到10%之间,又不想牺牲视觉质量,可以考虑映射层优化。把原始水印比特通过格雷码编码后再嵌入,提取后解码恢复。格雷码的物理意义在于:两个相邻数值在二进制表示中只差一个比特,这样由一个数值的偏移误判引起的比特错误最多也只有一位。这比直接二进制编码在抗单点噪声上的表现更稳定,实现成本极低,效果却非常明显。真正上线的时候,还可以在格雷码之外再加一层BCH或卷积码做前向纠错,但这已经是编码层的工作,和DCT/DWT的嵌入位置无关了。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 14:18:28

6μA静态电流升降压DC-DC:低功耗电源设计的关键突破

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 14:16:46

Spark+K-Means社交媒体用户行为聚类实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 14:12:46

Simple Live 跨端直播聚合指南:一个免费 App 看全四大直播平台

Simple Live 跨端直播聚合指南:一个免费 App 看全四大直播平台 【免费下载链接】dart_simple_live 简简单单的看直播 项目地址: https://gitcode.com/GitHub_Trending/da/dart_simple_live 追直播的人多半都有这个习惯:在几个 App 之间来回切换&a…

作者头像 李华