最近边学Qt边做了一个号称 “表情包制作器” 的图像处理类软件,结果花费了两个月还是只搭出来一个基本框架,只有破产版的图像处理功能,好在是能跑。

虽说有些功能还有一些问题,但是在那之前,得先做点东西告诉用户这个软件是用来做表情包的,那有什么是表情包特色呢?一拍脑袋,搞个“赛博做旧”的功能吧。
电子包浆的特点
要搞赛博做旧,首先要明确与之息息相关的电子包浆的概念。所谓电子包浆,就是指表情包经过广泛传播、被一个又一个平台反复压缩并刻上水印的现象。这样的表情包极有可能来自于互联网的早期时代,能够流传至今,说明其经过了千锤百炼,受到了人民大众的广泛喜爱,称得上经得起时间考验的true meme👍。
经过我的观察,一个表情包可以被称为电子包浆,不外乎几大特点:
- 低到令人发指的分辨率
- 充满JPEG压缩的噪点
- 堪比陈年污垢厚度的水印
- 不知从何而来的聊天边框、点赞图标
- 奇形怪状的颜色(比如绿色)
- 某位先民留下的涂鸦
- 让人留下时代眼泪的古早梗
以后如果你发现觉得哪个表情包显得很古老,可以套套上面的特点,总会命中一两个。
实现方案
上面提出的几大特点,古早梗方面需要用户发挥主观能动性,其余的都可以让软件来完成。就先从看起来比较简单的开始吧。
对话框设计
窗口不需要太复杂,一个预览部分,一个调整参数的部分和应用、取消键即可。
按计划,参数应该包括以下几个方面:
- 滑动条&微调框
- 分辨率
- 噪点数量
- 色彩失真
- 水印数量
- 勾选/取消
- 扫描线效果
- 意义不明的边框效果
最后实现出来的对话框效果大概是这样:

分辨率压缩
可以直接通过缩放QImage来实现分辨率压缩功能。
qreal scale = (100.0 - m_resolution) / 100;if (scale == 0) // 防止m_resolution=100的情况scale = 0.001;int newWidth = m_image.width() * scale;int newHeight = m_image.height() * scale;if (newWidth > 0 && newHeight > 0){m_baseImage = m_image.scaled(newWidth, newHeight, Qt::KeepAspectRatio, Qt::SmoothTransformation);}
一开始没有加上scale不为0的判断,导致滚动条拉到100的时候程序的内存占用直接干到了95%……加上判断后就没有问题了。

加入噪点
表情包里会出现的噪点现象主要是由多次的JPEG压缩引起的。引用一下百度百科的说法:
由于JPEG格式的图像在缩小图像尺寸后图像仍显得很自然,因此就可以利用特殊的方法来减小图像数据。此时,它就会以上下左右8×8个像素为一个单位进行处理。因此尤其是在8×8个像素边缘的位置就会与下一个8×8个像素单位发生不自然的结合。
由JPEG格式压缩而产生的图像噪音也被称为马赛克噪音(Block Noise),压缩率越高,图像噪音就越明显。
虽然把图像缩小后这种噪音也会变得看不出来,但放大打印后,一进行色彩补偿就表现得非常明显。这种图像噪音可以通过利用尽可能高的画质或者利用JPEG格式以外的方法来记录图像而得以解决。
我们要做的不是解决这个问题,而是制造这个问题。
既然噪点产生的原因找到了,接下来模拟JPEG压缩即可:
QImage workImage = m_filteredImage;int iterations = m_noise / 10 + 3;int quality = qMax(5, 100 - m_noise);for (int iter = 0; iter < iterations; ++iter){QBuffer buffer;buffer.open(QIODevice::WriteOnly);workImage.save(&buffer, "JPEG", quality);QImage compressed;compressed.loadFromData(buffer.buffer(), "JPEG");workImage = compressed.isNull() ? m_filteredImage : compressed;}
在QBuffer里将图片作为JPEG存入,再从中读出,多次重复这个操作,即可制造出以假乱真的噪点(也许就是真的?)。
保存的次数以及保存时的质量都由m_noise决定。效果大致是这样:

色彩失真
图像的色彩偏移效果大概可以分为四层:
- $8 \times 8$ 块级偏移,均匀偏移
- $4 \times 4$ 块级偏移,分通道偏移
- 单像素级偏移,高对比区域
- 对角插值偏移,像素级微调
其中的偏移量利用QRandomGenerator随机生成,m_color决定随机的范围。
// 8*8
int offset = QRandomGenerator::global()->bounded(-intensity / 3, intensity / 3 + 1);
// 4*4
int rOffset = QRandomGenerator::global()->bounded(-intensity / 2, intensity / 2 + 1);
int gOffset = QRandomGenerator::global()->bounded(-intensity / 2, intensity / 2 + 1);
int bOffset = QRandomGenerator::global()->bounded(-intensity / 2, intensity / 2 + 1);
分层、分通道地模拟色彩偏移效果。
for (int y = 0; y < height; y += 4){for (int x = 0; x < width; x += 4){if (QRandomGenerator::global()->bounded(100) < 15){int rOffset = QRandomGenerator::global()->bounded(-intensity / 2, intensity / 2 + 1);int gOffset = QRandomGenerator::global()->bounded(-intensity / 2, intensity / 2 + 1);int bOffset = QRandomGenerator::global()->bounded(-intensity / 2, intensity / 2 + 1);for (int by = 0; by < 4 && y + by < height; ++by){for (int bx = 0; bx < 4 && x + bx < width; ++bx){QRgb pixel = result.pixel(x + bx, y + by);int r = qBound(0, qRed(pixel) + rOffset, 255);int g = qBound(0, qGreen(pixel) + gOffset, 255);int b = qBound(0, qBlue(pixel) + bOffset, 255);result.setPixel(x + bx, y + by, qRgb(r, g, b));}}}}}

扫描线效果
扫描线的效果相对来说比较好实现,主要就是有间隔地在图片上绘制黑色横线。
QPainter painter(&m_filteredImage);
painter.setPen(Qt::NoPen);int height = m_filteredImage.height();
for (int y = 0; y < height; y += m_OutputSpacing) {painter.setOpacity(0.15);painter.setBrush(QColor(0, 0, 0));painter.drawRect(0, y, m_filteredImage.width(), m_ScanLineHeight);
}
painter.end();
这样的实现采用了固定的扫描线间距和扫描线宽度,这样会导致扫描线在预览中呈现的效果随图片大小的增大而越来越不明显,最后看起来好像只是让图片变灰了一点。

考虑到一般表情包都不会以较大的形式呈现,过于密集的扫描线难以让人肉眼可见地看到变化,我们需要加入一个动态的调整机制,让扫描线的宽度和间距随着图片大小的变化而变化,也许不太符合实际扫描线的情况,但是对于本项目来说,视觉上的冲击力比拟真更重要。
QPainter painter(&m_filteredImage);
painter.setPen(Qt::NoPen);const int scanLineSpacingOnScreen = 3;
QSize fixedSize(400, 300);
QSize scaledSize = fixedSize.boundedTo(m_filteredImage.size());
scaledSize.scale(fixedSize, Qt::KeepAspectRatio);double scaleX = static_cast<double>(m_filteredImage.width()) / scaledSize.width();
double scaleY = static_cast<double>(m_filteredImage.height()) / scaledSize.height();
double scale = qMax(scaleX, scaleY);int spacing = qMax(1, static_cast<int>(scanLineSpacingOnScreen * scale));int height = m_filteredImage.height();
for (int y = 0; y < height; y += spacing) {painter.setOpacity(0.15);painter.setBrush(QColor(0, 0, 0));painter.drawRect(0, y, m_filteredImage.width(), qMax(1, spacing / 2));
}painter.end();
最终达到的效果大概是这样:

添加水印
首先要解决的问题就是水印里写什么,虽说只是气氛组,万一有人注意到水印里写的全是随机生成的汉字,未免有些破坏气氛。
所以随便找了些用户名存在UserName.json中,方便后续随时更改,并且在水印添加函数里随机读取:
void CyberDistressingDialog::applyWaterMark()
{// 打开配置文档QFile file(":/config/UserName.json");if (!file.open(QIODevice::ReadOnly)){qWarning() << "无法打开用户名文件";return;}QByteArray data = file.readAll();file.close();QJsonDocument doc = QJsonDocument::fromJson(data);if (!doc.isArray()){qWarning() << "文件不是数组形式";return;}QJsonArray array = doc.array();if (array.empty()){qWarning() << "数组为空";return;}// 随机选择用户名for (int i = 0; i <= m_watermark / 4; ++i){int index = QRandomGenerator::global()->bounded(array.size());drawWaterMark(array.at(index).toString());}need_to_change_mark = false;
}
之后,再把这些名字绘制在图中,这里的策略是在四个位置随机生成,并且水印的大小是随着图片大小的变化动态变化的:
switch (position){case Center:// 中央位置drawPosition = QPoint((imageWidth - textWidth) / 2,(imageHeight - textHeight) / 2);break;case BottomLeft:// 左下位置drawPosition = QPoint(0,imageHeight - textHeight / 2);break;case BottomRight:// 右下位置drawPosition = QPoint(imageWidth - textWidth,imageHeight - textHeight / 2);break;case BottomCenter:// 中间最下位置drawPosition = QPoint((imageWidth - textWidth) / 2,imageHeight - textHeight / 2);break;}
最终效果如下:

最终效果图


原文链接
