今年双十一的时候我买了一部微单相机,拍完照片后给照片添加“边框水印”变得更好看了。网上有一些这类程序,比如开源跨平台的 “壹印”,Semi-Utils,其中壹印采用的是Electron,Semi-Utils用的是Python。在iOS的AppStore里面还有一些例如“边框水印大师”、“边框水印相机”和“光影边框App”等程序也提供相似功能。
我最喜欢的是壹印,不论是软件的界面设计,还是生成的水印风格。于是在Gemini的辅助下,我准备使用C++和Qt复刻一款类似壹印水印效果的程序。我有C#经验和一定的C++基础,但从没有使用C++和Qt开发过窗体程序,所以这次准备试试。
技术选型
- 开发语言:C++ 17
- UI框架:Qt 6.10
- EXIF解析:采用的是exiftool-13.42
- 光影背景:初期采用的是ffmpeg的boxblur,这也是壹印采用的方案,后来直接采用内存计算高斯模糊替换。
- 系统构建:采用了CMake
程序最后展现的界面如下:





模块设计
软件功能模块主要分为三个层面:
1:UI层,主界面最初是参考壹印,后来略有调整。整体的布局是左侧为样式风格选择,详细参数调整,操作区包括添加图片,清理,运行等;右侧为添加的文件列表,右侧下部为进度条。
2:处理引擎,包括生成边框的处理逻辑;照片元数据的处理,包括读取照片信息,以及将原图片的exif信息复制到最终生成的带边框的图片里。
3:资源管理,包括相机品牌Logo(svg和png图片),字体文件,样式预览图,第三方应用程序如exiftool,ffmpeg等。
环境搭建
我是在Windows上开发的,开发工具是Visual Studio,需要安装Qt,CMake以及Qt Visual Studio Tools。
Qt的安装
第一步:下载 Qt 在线安装程序 (Online Installer)。
- 目前 Qt 官方推荐使用开源版 (Open Source)。访问下载页: 请直接点击这个链接(这是开源版本的官方入口,比较难找)。
- 滚动到底部: 页面很长,一直拉到底部,点击绿色的 "Download the Qt Online Installer" 按钮。
- 检测系统: 网页会自动检测你的系统,点击 "Download for Windows" 下载 qt-unified-windows-x64-online.exe。
- 提示:你需要注册一个 Qt Account (免费) 才能运行安装程序。在等待下载时可以先去注册一下。

第二步:运行安装与组件选择,不要一路点 Next,也不要全选。
- 登录:运行下载的 exe,输入刚才注册的 Qt 账号。
- 开源义务:勾选 "I am an individual user..." (我是个人用户) 并确认不将其用于非法目的。
- 选择安装路径:建议安装在 C:\Qt 或 D:\Qt(路径中不要包含中文或空格)。
- 选择安装类型:必须选择 "Custom Installation" (自定义安装)。

-
组件勾选 (Select Components) : 在列表中找到 Qt 6.10.0,展开它,勾选以下核心项:
-
MSVC 2022 64-bit):这是 Visual Studio 的编译器接口。如果你用的是 VS2022,选 MSVC 2019 兼容版本也可以,通常 Qt 6 会直接显示 MSVC 2019 或 MSVC 2022。
-
Qt Shader Tools (通常默认勾选)。
-
Qt 5 Compatibility Module (可选,建议勾选,方便兼容旧代码)。
-
Additional Libraries (可选,如果我们的代码用到了 SVG,这里通常包含在基础包里,如果不放心可以展开看看有没有 Qt SVG,通常 Qt6 是默认包含核心库的)。
-
不要勾选:Android, iOS, WebAssembly, Sources (源码,除非你要研究内核),这些会占用几十 GB 空间。

-
- 工具 (Developer and Designer Tools):
- Qt Creator (默认勾选,建议留着用来查看文档或调试)。
- CMake (建议勾选,虽然 VS 自带,但 Qt 自带的版本有时更稳定)。

- 点击安装,它就会在线下载完成安装。如果下载速度只有几 KB,可以在命令行运行安装程序并指定国内镜像:
.\qt-unified-windows-x64-online.exe --mirror https://mirrors.tuna.tsinghua.edu.cn/qt
配置 Visual Studio (Qt VS Tools)
为了在 VS 中直接创建和管理 Qt 项目,需要安装官方插件。
- 安装插件:
- 打开 Visual Studio 2022。
- 点击菜单栏 扩展 (Extensions) -> 管理扩展 (Manage Extensions)。
- 在搜索框输入 "Qt"。
- 找到 Qt Visual Studio Tools,点击下载。
- 关闭 Visual Studio,它会自动弹出安装界面,点击 Modify 开始安装。
- 配置 Qt 版本:
- 重新打开 Visual Studio。
- 点击菜单栏 扩展 (Extensions) -> Qt VS Tools -> Qt Versions。
- 点击文件夹图标(Add New Qt Version)。
- 浏览路径,找到你刚才安装 Qt 的位置。
- VS 会自动识别版本号,点击 OK 保存。
项目结构
Visual Studio 处理 CMake项目的方式,和处理传统的 .sln (Solution) 项目完全不同。它有点类似入VS Code的方式,把一个文件夹作为一个项目管理,整个项目通过CMakeLists.txt这个CMake文件进行管理和编译。
所以第一步就是建立一个文件夹,这里简单创建一个名为ImageFrame的文件夹,然后在里面创建一个CMakeLists.txt的文件,内容如下:
cmake_minimum_required(VERSION 3.16)
project(ProBatchFrame LANGUAGES CXX)
list(APPEND CMAKE_PREFIX_PATH "C:/Qt/6.10.0/msvc2022_64")
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Svg Concurrent)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
include_directories(${CMAKE_SOURCE_DIR})
SET(SOURCES
main.cpp
MainWindow.cpp
MainWindow.h
FrameEngine.cpp
FrameEngine.h
YiyinProcessor.h
YiyinProcessor.cpp
SwitchButton.h
YiyinInsideProcessor.h
YiyinInsideProcessor.cpp
BaseYiyinProcessor.h
BaseYiyinProcessor.cpp
StyleManager.h
StyleSelectionDialog.h
StyleSelectionDialog.cpp
StylePreviewDialog.h
StylePreviewDialog.cpp
SemiUtilsProcessor.h
SemiUtilsProcessor.cpp
MinimalistProcessor.h
MinimalistProcessor.cpp
ImagePreViewDialog.h
BlurUtils.h
Logger.h
Logger.cpp
resources.qrc
)
#add_executable(ProBatchFrame ${SOURCES})
# =============================================================
# 使用 qt_add_executable 代替 add_executable
# 这会自动处理 Windows 入口点冲突 (main vs WinMain)
# =============================================================
qt_add_executable(ProBatchFrame MANUAL_FINALIZATION ${SOURCES})
target_link_libraries(ProBatchFrame PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Widgets
Qt6::Svg
Qt6::Concurrent
)
if(WIN32)
set_target_properties(ProBatchFrame PROPERTIES WIN32_EXECUTABLE ON)
endif()
# =============================================================
# 构建后操作:自动复制 ExifTool 及其依赖文件夹
# =============================================================
# 1. 定义源路径和目标路径
set(ASSETS_DIR "${CMAKE_SOURCE_DIR}/assets")
if(WIN32)
# Windows: 复制到 exe 旁边的 assets 文件夹
set(TARGET_ASSET_DIR "$<TARGET_FILE_DIR:ProBatchFrame>/assets")
add_custom_command(TARGET ProBatchFrame POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${ASSETS_DIR}"
"${TARGET_ASSET_DIR}"
COMMENT "Deploying assets to Windows output..."
)
elseif(APPLE)
# macOS: 这里的逻辑是将 assets 文件夹里的内容,复制到 .app/Contents/Resources/ 下
# CMake 的 install(DIRECTORY ...) 在构建 Bundle 时非常有用,但调试时我们需要 add_custom_command
# 获取 .app 包内的 Resources 路径
set(TARGET_ASSET_DIR "$<TARGET_FILE_DIR:ProBatchFrame>/../Resources")
# 这一步会在编译后自动把项目根目录的 assets 里的东西拷进去
add_custom_command(TARGET ProBatchFrame POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "${TARGET_ASSET_DIR}"
COMMAND ${CMAKE_COMMAND} -E copy_directory "${ASSETS_DIR}" "${TARGET_ASSET_DIR}"
COMMENT "Deploying assets to macOS Bundle Resources..."
)
endif()
# =============================================================
# macOS Bundle 设置 (图标等)
# =============================================================
if(APPLE)
# 定义资源路径变量
set(MACOSX_BUNDLE_BUNDLE_NAME "ProBatchFrame")
set(MACOSX_BUNDLE_GUI_IDENTIFIER "com.yycoding.probatchframe")
set(MACOSX_BUNDLE_BUNDLE_VERSION "1.0.0")
set(MACOSX_BUNDLE_SHORT_VERSION_STRING "1.0")
endif()
我这里列出来了所有的整个项目所有的源文件,以及资源文件。
现在启动 Visual Studio 2022,在启动界面右侧,点击底部的选项 "打开本地文件夹" (Open a local folder),或点击菜单栏 文件(File) -> 打开(Open) -> 文件夹(Folder)。浏览并选中刚才建立的 ImageFrame 文件夹,点击“选择文件夹”。Visual Studio 会检测到文件夹里有一个 CMakeLists.txt,它会自动识别这是一个 CMake 项目,而不是传统的 .sln 项目。它会立即启动 CMake 配置工具。
实现
UI界面
UI界面是通过代码的方式来创建的,主要代码如下,这是MainWindow.h:
#pragma once
#include <QMainWindow>
#include <QSettings> // 引入 QSettings
#include <QTableWidget>
#include <QProgressBar>
#include <QPushButton>
#include <QComboBox>
#include <QCheckBox>
#include <QLineEdit>
#include <QFutureWatcher>
#include <QFontComboBox>
#include <QSpinBox>
#include <QRadioButton>
#include <QButtonGroup>
#include <QLabel>
#include "FrameEngine.h"
#include "StyleManager.h" // 确保包含 FrameStyle 定义
#include "SwitchButton.h"
#include "StyleSelectionDialog.h"
#include "ImagePreviewDialog.h"
// 任务包:传递给线程的数据
struct BatchTask {
int rowId;
QString inputPath;
QString outputPath;
PhotoMeta meta;
FrameOptions options; // 包含字体、布局设置
};
class MainWindow : public QMainWindow {
Q_OBJECT
public:
MainWindow();
~MainWindow();
private slots:
void onAddFiles();
void onClearList(); //清空列表槽函数
void onStartBatch();
void onBrowseOutDir();
void onOpenStyleDialog(); //打开模板选择器
void onChooseColor(); // 颜色选择槽函数
void onPresetInfoClicked();
void onPreviewBtnClicked(); //列表里点击预览按钮
void updatePreview(bool force = false);//更新预览(核心联动函数)
protected:
//重写关闭事件,在窗口关闭时自动保存配置
void closeEvent(QCloseEvent* event) override;
private:
// UI 初始化与辅助函数
void setupUI();
void updateStatus(int row, const QString& msg, const QColor& bg);
FrameOptions getOptions(); // 从界面获取设置
QWidget* createSettingRow(QString title, QString helpTxt, QWidget* control); // 创建带标题的设置行
void applyStyle(const FrameStyle& style);
//配置管理函数
void loadConfig();
void saveConfig();
// 辅助函数:更新颜色按钮的样式(显示颜色块)
void updateColorBtnStyle(const QColor& color);
// --- UI 控件成员变量 ---
// 左侧列表区域
QTableWidget* table;
QProgressBar* progress;
// 顶部
QComboBox* comboFont;
// 开关组 (使用自定义 SwitchButton)
SwitchButton* swSolidBg; // 纯色背景
SwitchButton* swLandscape; // 横屏输出
SwitchButton* swMake; // 机型显示 比如尼康
SwitchButton* swModel; // 型号显示 比如Z52
SwitchButton* swParams; // 参数显示比如焦距,光圈,ISO之类
SwitchButton* swLens;//显示镜头
SwitchButton* swTextOverlay;//文字位置开关 (默认关闭=图外,开启=图内)
SwitchButton* swShadowShift; //移影开关
QPushButton* btnStyleSelect; //大按钮
// 数值显示与输入
QLabel* lblCount; // 选中数量
QLineEdit* edtOutDir;
QPushButton* btnBrowseOut;
QDoubleSpinBox* spinMainRatio; //主图占比
QDoubleSpinBox* spinTextMargin;//文本间距
QSpinBox* spinRatioW; // 背景比例 (宽)
QSpinBox* spinRatioH; // 背景比例 (高) - 预留
QDoubleSpinBox* spinRadius; // 圆角大小
QDoubleSpinBox* spinShadow; // 阴影大小
QSpinBox* spinQuality; // 图片质量
// 底部按钮
QPushButton* btnSelect;
QPushButton* btnClear; //清空按钮
QPushButton* btnRun;
QPushButton* btnInfo;
QPushButton* btnDir;
// 状态追踪
int processedCount = 0;
int totalCount = 0;
//用于存储每一行任务的当前进度
QVector<int> m_taskProgress;
//缓存样式选择窗口指针
StyleSelectionDialog* m_styleDlg = nullptr;
StyleType m_currentStyleType = StyleType::YiyinDefault;
bool m_currentClassicBorder = false;
//记录当前样式ID,用于下次启动恢复
QString m_currentStyleId;
// 建议增加一个成员变量来记录上次保存的输出目录(可选)
QString m_lastOutputDir;
//颜色选择按钮
QPushButton* m_btnColor;
//预览显示标签
QLabel* lblPreview;
// 预览缓存
ImagePreviewDialog* m_previewDlg = nullptr; // 预览窗口指针
QImage m_previewImage; // 缓存未处理的缩略图
PhotoMeta m_presetMeta; // 缓存元数据
// 数据与状态
FrameEngine m_engine;
//当前选中的背景色
QColor m_customSolidColor;
//合并预设信息的辅助函数
PhotoMeta mergeMeta(const PhotoMeta& original, const PhotoMeta& preset);
};
MainWindow.cpp实现如下,这里只列出了UI界面的创建逻辑:
void MainWindow::setupUI() {
QWidget* cen = new QWidget; setCentralWidget(cen);
cen->setStyleSheet("background-color: #F2F3F8;");
QHBoxLayout* mainLay = new QHBoxLayout(cen);
mainLay->setContentsMargins(0, 0, 0, 0); mainLay->setSpacing(0);
// === 左侧设置面板 ===
QWidget* leftPanel = new QWidget;
leftPanel->setObjectName("leftPanel");
leftPanel->setStyleSheet("#leftPanel { background-color: #F5F7FA; border-right: 1px solid #E1E4E8; }");
QVBoxLayout* leftLay = new QVBoxLayout(leftPanel);
leftLay->setContentsMargins(25, 25, 25, 25);
leftLay->setSpacing(15);
// 1. 输出目录
QLabel* lblDir = new QLabel("输出目录:");
lblDir->setStyleSheet("font-weight: bold; color: #555; border:none;");
leftLay->addWidget(lblDir);
QHBoxLayout* dirLay = new QHBoxLayout;
edtOutDir = new QLineEdit;
edtOutDir->setPlaceholderText("默认: ./Output (原图同级)");
edtOutDir->setStyleSheet("background: white; border: 1px solid #ccc; border-radius: 4px; padding: 6px;");
btnBrowseOut = new QPushButton("...");
btnBrowseOut->setFixedSize(30, 30);
btnBrowseOut->setStyleSheet("background: #ddd; border-radius: 4px; border:none;");
connect(btnBrowseOut, &QPushButton::clicked, this, &MainWindow::onBrowseOutDir);
dirLay->addWidget(edtOutDir);
dirLay->addWidget(btnBrowseOut);
leftLay->addLayout(dirLay);
// 2. 核心入口:风格模板选择按钮
btnStyleSelect = new QPushButton("✨ 选择样式模板");
btnStyleSelect->setCursor(Qt::PointingHandCursor);
btnStyleSelect->setFixedHeight(50);
btnStyleSelect->setStyleSheet(
"QPushButton { "
" background-color: #333; "
" color: white; "
" border-radius: 8px; "
" font-size: 16px; "
" font-weight: bold; "
" text-align: left; "
" padding-left: 20px; "
"}"
"QPushButton:hover { background-color: #444; }"
);
connect(btnStyleSelect, &QPushButton::clicked, this, &MainWindow::onOpenStyleDialog);
leftLay->addWidget(btnStyleSelect);
leftLay->addSpacing(10);
// 3. 参数微调区 (Group Box)
QGroupBox* grpParams = new QGroupBox("参数自定义");
grpParams->setStyleSheet("QGroupBox { font-weight: bold; color: #555; border: 1px solid #ddd; border-radius: 6px; margin-top: 10px; padding-top: 15px; }");
QVBoxLayout* paramsBoxLay = new QVBoxLayout(grpParams);
QGridLayout* grid = new QGridLayout;
grid->setHorizontalSpacing(15); grid->setVerticalSpacing(12);
comboFont = new QComboBox;
comboFont->setEditable(false);
comboFont->setFixedHeight(38);
comboFont->setFixedSize(160, 38);
// 只负责画背景和边框,把自带的箭头隐藏掉
comboFont->setStyleSheet(R"(
QComboBox {
background: white;
border: 1px solid #DCDFE6;
border-radius: 6px;
padding-left: 10px;
padding-right: 30px; /* 给我们的“假”箭头留出位置 */
color: #333;
font-weight: bold;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 30px; /* 按钮宽度 */
border-left: 1px solid #E4E7ED;
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
background: #FAFAFA;
}
/* 彻底隐藏系统原生箭头,防止干扰 */
QComboBox::down-arrow {
image: none;
border: none;
width: 0px;
height: 0px;
}
QComboBox::drop-down:hover {
background: #F2F6FC;
}
QComboBox QAbstractItemView {
background-color: white; /* 强制下拉框背景为白*/
border: 1px solid #dcdcdc; /* 下拉框边框*/
selection-background-color: #007AFF; /* 选中项背景色 (iOS蓝)*/
selection-color: white; /* 选中项文字颜色*/
outline: none; /* 去掉选中时的虚线框*/
padding: 4px;
}
)");
// 直接创建一个 Label 贴在 Combo 上面,绝对可靠
QLabel* arrow = new QLabel("▼", comboFont);
arrow->setStyleSheet("background: transparent; color: #606266; font-size: 10px; border: none;");
arrow->setAlignment(Qt::AlignCenter);
arrow->setFixedSize(30, 38); // 大小填满右侧按钮区
arrow->move(130, 0);// 定位到最右侧:总宽 160 - 按钮宽 30 = 130
arrow->setAttribute(Qt::WA_TransparentForMouseEvents);// 关键:让鼠标点击穿透这个 Label,传给底下的 ComboBox,否则点箭头没反应
QStringList fontList;
QString fontDir = QCoreApplication::applicationDirPath() + "/assets/fonts";
QDir dir(fontDir);
QStringList filters; filters << "*.ttf" << "*.otf";
for (const QFileInfo& info : dir.entryInfoList(filters, QDir::Files)) {
int id = QFontDatabase::addApplicationFont(info.absoluteFilePath());
if (id != -1) fontList << QFontDatabase::applicationFontFamilies(id).first();
}
comboFont->addItems(fontList);
comboFont->setCurrentText("PingFang SC");
grid->addWidget(createSettingRow("字体", "", comboFont), 0, 0, 1, 2);
QString spinStyle = "QDoubleSpinBox, QSpinBox { background: #E8EBED; border: none; border-radius: 4px; padding: 4px; color: #333; font-weight: bold; }";
// Row 1
spinMainRatio = new QDoubleSpinBox; spinMainRatio->setRange(50, 99); spinMainRatio->setValue(90.0); spinMainRatio->setSuffix("%");
spinMainRatio->setStyleSheet(spinStyle); spinMainRatio->setButtonSymbols(QAbstractSpinBox::NoButtons); spinMainRatio->setAlignment(Qt::AlignCenter); spinMainRatio->setFixedSize(80, 28);
grid->addWidget(createSettingRow("主图占比", "主图宽度占输出图片的比例", spinMainRatio), 1, 0);
// 1. 创建一个容器 Widget
QWidget* colorContainer = new QWidget;
// 2. 给容器设置水平布局
QHBoxLayout* hLayout = new QHBoxLayout(colorContainer);
hLayout->setContentsMargins(0, 0, 0, 0); // 【关键】零边距
hLayout->setSpacing(5);
m_btnColor = new QPushButton;
m_btnColor->setText("背景颜色");
m_btnColor->setFixedSize(60, 24);
m_btnColor->setCursor(Qt::PointingHandCursor);
updateColorBtnStyle(m_customSolidColor); // 初始化颜色显示
swSolidBg = new SwitchButton;
hLayout->addWidget(swSolidBg);
hLayout->addWidget(m_btnColor);
hLayout->addStretch(); // 靠左
grid->addWidget(createSettingRow("纯色背景", "背景是纯色,还是使用原图动态模糊", colorContainer), 1, 1);
connect(swSolidBg, &SwitchButton::toggled, this, [this](bool checked) {
// 1. 设置按钮是否可用
m_btnColor->setEnabled(checked);
// 2. 如果变为不可用(关闭纯色),可能需要把按钮样式变灰或者保持原样
// 这里其实不需要额外操作,Qt 的 setEnabled(false) 会自动让按钮看起来变灰不可点
if (checked) {
// 【开启时】:恢复显示用户上次选中的颜色 (m_customSolidColor)
updateColorBtnStyle(m_customSolidColor);
}
else {
// 【关闭时】:按钮视觉上变为白色 (表示默认/无),但保留 m_customSolidColor 的值不变
updateColorBtnStyle(Qt::white);
}
// 3. 刷新预览
updatePreview();
});
connect(m_btnColor, &QPushButton::clicked, this, &MainWindow::onChooseColor);
// 初始化状态 (根据当前开关状态决定显示什么颜色)
m_btnColor->setEnabled(swSolidBg->isChecked());
if (swSolidBg->isChecked()) {
updateColorBtnStyle(m_customSolidColor);
}
else {
updateColorBtnStyle(Qt::white);
}
// Row 2
spinTextMargin = new QDoubleSpinBox; spinTextMargin->setRange(0, 5.0); spinTextMargin->setValue(0.4); spinTextMargin->setSingleStep(0.1);
spinTextMargin->setStyleSheet(spinStyle); spinTextMargin->setButtonSymbols(QAbstractSpinBox::NoButtons); spinTextMargin->setAlignment(Qt::AlignCenter); spinTextMargin->setFixedSize(80, 28);
grid->addWidget(createSettingRow("文本间距", "下方的文本间距", spinTextMargin), 2, 0);
swLandscape = new SwitchButton;
grid->addWidget(createSettingRow("强制横屏", "如果是竖图,则调整为横图", swLandscape), 2, 1);
// Row 3
spinRadius = new QDoubleSpinBox; spinRadius->setRange(0, 50); spinRadius->setValue(2.1);
spinRadius->setStyleSheet(spinStyle); spinRadius->setButtonSymbols(QAbstractSpinBox::NoButtons); spinRadius->setAlignment(Qt::AlignCenter); spinRadius->setFixedSize(80, 28);
grid->addWidget(createSettingRow("圆角大小", "圆角的大小", spinRadius), 3, 0);
//文字位置开关 (默认关闭=图外,开启=图内)
swTextOverlay = new SwitchButton; swTextOverlay->setChecked(false);
grid->addWidget(createSettingRow("文字入图", "文字在图中底部,还是在图外底部", swTextOverlay), 3, 1);
// Row 4
spinShadow = new QDoubleSpinBox; spinShadow->setRange(0, 50); spinShadow->setValue(6.0); spinShadow->setSuffix("%");
spinShadow->setStyleSheet(spinStyle); spinShadow->setButtonSymbols(QAbstractSpinBox::NoButtons); spinShadow->setAlignment(Qt::AlignCenter); spinShadow->setFixedSize(80, 28);
grid->addWidget(createSettingRow("阴影大小 (高)", "阴影的高度占原图高度的百分比", spinShadow), 4, 0);
swShadowShift = new SwitchButton;
grid->addWidget(createSettingRow("移影效果", "阴影时四周悬浮还是仅右、下方有", swShadowShift), 4, 1);
// Row 5
QWidget* ratioBox = new QWidget; ratioBox->setStyleSheet("background:transparent; border:none;");
QHBoxLayout* rl = new QHBoxLayout(ratioBox); rl->setContentsMargins(0, 0, 0, 0);
spinRatioW = new QSpinBox; spinRatioW->setRange(0, 100); spinRatioW->setValue(0); spinRatioW->setStyleSheet(spinStyle); spinRatioW->setButtonSymbols(QAbstractSpinBox::NoButtons); spinRatioW->setFixedSize(35, 28); spinRatioW->setAlignment(Qt::AlignCenter);
spinRatioH = new QSpinBox; spinRatioH->setRange(0, 100); spinRatioH->setValue(0); spinRatioH->setStyleSheet(spinStyle); spinRatioH->setButtonSymbols(QAbstractSpinBox::NoButtons); spinRatioH->setFixedSize(35, 28); spinRatioH->setAlignment(Qt::AlignCenter);
rl->addStretch(); rl->addWidget(spinRatioW); rl->addWidget(new QLabel(":")); rl->addWidget(spinRatioH);
grid->addWidget(createSettingRow("输出宽高比", "输出背景图片的宽高比", ratioBox), 5, 0);
swMake = new SwitchButton; swMake->setChecked(true);
grid->addWidget(createSettingRow("显示品牌", "比如Nikon", swMake), 5, 1);
// Row 6
swModel = new SwitchButton; swModel->setChecked(true);
grid->addWidget(createSettingRow("显示型号", "比如Z8", swModel), 6, 0);
swParams = new SwitchButton; swParams->setChecked(true);
grid->addWidget(createSettingRow("显示参数", "快门、焦距、ISO信息", swParams), 6, 1);
// Row 7
swLens = new SwitchButton; swLens->setChecked(false);
grid->addWidget(createSettingRow("显示镜头", "镜头信息", swLens), 7, 0);
spinQuality = new QSpinBox; spinQuality->setRange(80, 100); spinQuality->setValue(100); spinQuality->setSingleStep(1);
spinQuality->setStyleSheet(spinStyle); spinQuality->setButtonSymbols(QAbstractSpinBox::NoButtons); spinQuality->setAlignment(Qt::AlignCenter); spinQuality->setFixedSize(80, 28);
grid->addWidget(createSettingRow("图片质量", "图片质量", spinQuality), 7, 1);
connect(swLandscape, &SwitchButton::toggled, this, &MainWindow::updatePreview);
connect(swTextOverlay, &SwitchButton::toggled, this, &MainWindow::updatePreview);
connect(swShadowShift, &SwitchButton::toggled, this, &MainWindow::updatePreview);
connect(swModel, &SwitchButton::toggled, this, &MainWindow::updatePreview);
connect(swParams, &SwitchButton::toggled, this, &MainWindow::updatePreview);
connect(swLens, &SwitchButton::toggled, this, &MainWindow::updatePreview);
paramsBoxLay->addLayout(grid);
//leftLay->addLayout(grid);
leftLay->addWidget(grpParams);
leftLay->addStretch();
// 底部按钮
QHBoxLayout* btnLay = new QHBoxLayout; btnLay->setSpacing(10);
QString btnCss = "QPushButton { background: white; border: 1px solid #ccc; border-radius: 8px; font-weight: bold; padding: 10px 0; color: #333; } QPushButton:hover { background: #f0f0f0; }";
auto setupBtn = [&](QPushButton* b) { b->setStyleSheet(btnCss); b->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); b->setCursor(Qt::PointingHandCursor); };
btnSelect = new QPushButton("选择图片"); setupBtn(btnSelect);
connect(btnSelect, &QPushButton::clicked, this, &MainWindow::onAddFiles);
btnClear = new QPushButton("清空"); setupBtn(btnClear);
connect(btnClear, &QPushButton::clicked, this, &MainWindow::onClearList);
btnInfo = new QPushButton("信息预设"); setupBtn(btnInfo);
connect(btnInfo, &QPushButton::clicked, this, &MainWindow::onPresetInfoClicked);
btnDir = new QPushButton("打开输出"); setupBtn(btnDir);
connect(btnDir, &QPushButton::clicked, this, [this]() {
QString path = edtOutDir->text();
// 智能判断路径
if (path.isEmpty() && table->rowCount() > 0) {
path = QFileInfo(table->item(0, 1)->text()).absolutePath() + "/Output";
}
if (path.isEmpty()) path = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
QDir().mkpath(path);
QDesktopServices::openUrl(QUrl::fromLocalFile(path));
});
btnRun = new QPushButton("开始处理"); setupBtn(btnRun);
btnRun->setStyleSheet("QPushButton { background: #333; border: 1px solid #333; border-radius: 8px; font-weight: bold; padding: 10px 0; color: white; } QPushButton:hover { background: #555; }");
connect(btnRun, &QPushButton::clicked, this, &MainWindow::onStartBatch);
btnLay->addWidget(btnSelect, 1);
btnLay->addWidget(btnClear, 1);
btnLay->addWidget(btnInfo, 1);
btnLay->addWidget(btnDir, 1);
btnLay->addWidget(btnRun, 1);
leftLay->addLayout(btnLay);
QLabel* copy = new QLabel("© 2025 ProBatch Frame Studio");
copy->setAlignment(Qt::AlignCenter); copy->setStyleSheet("color: #999; font-size: 10px; margin-top: 10px; border:none;");
leftLay->addWidget(copy);
mainLay->addWidget(leftPanel, 35);
// 右侧:列表
QWidget* rightPanel = new QWidget;
QVBoxLayout* rightLay = new QVBoxLayout(rightPanel);
rightLay->setContentsMargins(15, 15, 15, 15);
table = new QTableWidget(0, 5);
table->setHorizontalHeaderLabels({ "文件名", "路径", "大小", "操作" , "状态" });
table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Fixed);
table->setColumnWidth(2, 80); // 预览按钮列宽
table->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Fixed);
table->setColumnWidth(3, 80); // 预览按钮列宽
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setStyleSheet("QTableWidget { border: 1px solid #ddd; background: white; }");
table->setAlternatingRowColors(true);
rightLay->addWidget(table);
//进度条样式:扁平化细长条
progress = new QProgressBar;
progress->setTextVisible(true); progress->setAlignment(Qt::AlignCenter);
progress->setFixedHeight(24);
progress->setStyleSheet(R"(
QProgressBar {
border: none;
background: #E5E7EB;
border-radius: 2px; /* 改为极小圆角/直角 */
text-align: center;
color: #333;
}
QProgressBar::chunk {
background-color: #10B981;
border-radius: 0px;
}
)");
rightLay->addWidget(progress);
// 数量标签
lblCount = new QLabel("0"); lblCount->setVisible(false);
mainLay->addWidget(rightPanel, 65);
}
元数据的读取和写入
最开始是使用Qt里面的方法来读取照片的元数据信息:
PhotoMeta FrameEngine::extractMetadata(const QString& filePath, const PhotoMeta& preset) {
PhotoMeta result;
QImageReader reader(filePath);
reader.setAutoTransform(true);
// 尝试读取图片(只读 header,不完全解码,速度较快)
// 注意:在 Qt6 中,有些元数据必须 read() 之后才能拿到,
// 对于大图,为了性能,我们可以只利用 QImageReader 的 text() 接口
if (!reader.canRead()) {
return preset; // 读都读不了,直接用预设
}
// 为了获取最全的 EXIF,通常建议读入图片。
// 商业软件中,因为我们在 process 时本来就要读图,
// 所以这里其实可以通过传入已加载的 QImage 来优化性能。
// 但为了逻辑解耦,这里演示独立读取。
QImage img = reader.read();
// 1. 提取品牌 (Make)
QString make = img.text("Exif.Image.Make");
if (make.isEmpty()) make = preset.make; // 回退机制
else {
// 清洗数据,比如 "NIKON CORPORATION" -> "Nikon"
if (make.contains("Nikon", Qt::CaseInsensitive)) make = "Nikon";
else if (make.contains("Sony", Qt::CaseInsensitive)) make = "Sony";
else if (make.contains("Canon", Qt::CaseInsensitive)) make = "Canon";
}
result.make = make;
// 2. 提取机型 (Model)
QString model = img.text("Exif.Image.Model");
if (model.isEmpty()) model = preset.model;
result.model = model;
// 3. 提取镜头 (Lens)
// 注意:Qt 对 LensModel 支持较弱,很多时候读不到,必须回退
QString lens = img.text("Exif.Photo.LensModel");
if (lens.isEmpty()) lens = preset.lens;
result.lens = lens;
// 4. 提取拍摄时间
QString dateRaw = img.text("Exif.Photo.DateTimeOriginal"); // 格式通常是 "2025:11:20 12:00:00"
if (dateRaw.isEmpty()) {
result.date = preset.date;
} else {
// 格式化为 yyyy.MM.dd
QStringList parts = dateRaw.split(" ");
if (!parts.isEmpty()) {
QString d = parts.first(); // 2025:11:20
result.date = d.replace(":", ".");
} else {
result.date = preset.date;
}
}
// 5. 组合参数 (ISO, 光圈, 快门)
QString iso = img.text("Exif.Photo.ISOSpeedRatings");
QString fNumberStr = img.text("Exif.Photo.FNumber"); // 通常是 "2.8" 或 "28/10"
QString exposureTimeStr = img.text("Exif.Photo.ExposureTime"); // 通常是 "0.004"
// 如果读不到,分别使用预设
QString finalParam;
// ISO
if (iso.isEmpty()) {
// 如果预设里有 ISO,就尝试解析预设,这里简单处理直接拼凑
// 实际商业逻辑可能需要拆解 preset.params 字符串,这里简化:
if (!preset.params.isEmpty()) finalParam = preset.params;
} else {
finalParam += "ISO " + iso + " ";
}
// 光圈 (F-Number)
if (!fNumberStr.isEmpty()) {
double f = fNumberStr.toDouble();
if (f > 0) finalParam += QString("f/%1 ").arg(f);
}
// 快门
if (!exposureTimeStr.isEmpty()) {
double t = exposureTimeStr.toDouble();
finalParam += formatShutterSpeed(t);
}
// 如果所有参数都没读到,且刚才没赋值预设,则整体使用预设
if (iso.isEmpty() && fNumberStr.isEmpty() && exposureTimeStr.isEmpty()) {
result.params = preset.params;
} else {
result.params = finalParam.trimmed();
}
return result;
}
它的优点是不依赖第三方类库,可以使用Qt 6原生支持的QImageReader来读取EXIF 信息。虽然 Qt 对 EXIF 的支持不如专业库全面(特别是“镜头型号”有时读不到),但对于常规的 ISO、快门、时间、机型支持已经足够好。
但是对于一些新的相机型号,这个QImageReader读不出一些信息,于是转而使用EasyExif来读取,EasyExif 是一个极其轻量级的库(只有两个文件exif.h 和 exif.cpp),能完美读取 Nikon、Sony 等相机的详细信息。改为使用EasyExif读取元数据的实现如下:
PhotoMeta FrameEngine::extractMetadata(const QString& filePath, const PhotoMeta& preset) {
PhotoMeta result;
// 1. 使用 Qt 读取文件二进制数据
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) return preset;
QByteArray data = file.readAll();
file.close();
// 2. 使用 EasyExif 解析
easyexif::EXIFInfo exif;
int code = exif.parseFrom((unsigned char *)data.data(), data.size());
if (code != 0) {
// 解析失败,回退到预设
return preset;
}
// 3. 提取数据 (EasyExif 极其强大,能读出 Qt 读不到的)
// 品牌
result.make = QString::fromStdString(exif.Make).trimmed();
if (result.make.isEmpty()) result.make = preset.make;
else {
// 简单清洗
if (result.make.contains("Nikon", Qt::CaseInsensitive)) result.make = "Nikon";
}
// 机型
result.model = QString::fromStdString(exif.Model).trimmed();
if (result.model.isEmpty()) result.model = preset.model;
// 镜头 (EasyExif 对 Nikon Z 镜头的支持通常比 Qt 好)
result.lens = QString::fromStdString(exif.LensModel).trimmed();
if (result.lens.isEmpty()) result.lens = preset.lens;
// 时间
result.date = QString::fromStdString(exif.DateTimeOriginal).trimmed();
if (!result.date.isEmpty()) {
// 格式化 2025:11:20 -> 2025.11.20
result.date.replace(":", ".");
// 只取日期部分,去掉时间
result.date = result.date.split(" ").first();
} else {
result.date = preset.date;
}
// 参数拼装
QString paramStr;
// ISO
if (exif.ISOSpeedRatings > 0) {
paramStr += QString("ISO %1 ").arg(exif.ISOSpeedRatings);
}
// 光圈
if (exif.FNumber > 0) {
paramStr += QString("f/%1 ").arg(exif.FNumber);
}
// 快门
if (exif.ExposureTime > 0) {
paramStr += formatShutterSpeed(exif.ExposureTime);
}
// 焦距 (可选)
if (exif.FocalLength > 0) {
paramStr += QString(" %1mm").arg(static_cast<int>(exif.FocalLength));
}
result.params = paramStr.trimmed();
if (result.params.isEmpty()) result.params = preset.params;
return result;
}
至此,照片元信息的读取没有大的问题。接下来的问题是,如何把原始照片的元数据信息,完整地拷贝到生成相框之后的照片中去。
使用Qt的QImage同样可以将一些简单的信息输出到生成的图片上。但原生的Qt写回会有一些局限性:
- 私有数据丢失:Nikon Z 系列的 RAW/JPG 里面包含大量的 MakerNotes(例如:对焦点位置、人脸识别数据、动态 D-Lighting 等)。Qt 的 save 函数会重写整个文件结构,这些非标准数据一定会丢失。
- 色彩空间 (Color Space):如果原图是 Adobe RGB,Qt save 默认可能会转为 sRGB 或者丢掉 ICC 配置文件。可以在
process里把原图的colorSpace()赋给新图:result.setColorSpace(source.colorSpace());
但还有更好的办法,就是使用exiftool这个工具,它可以通过命令行的方式,直接将原始图片的元数据信息拷贝到目标图片中,命令如下:
QString program = "exiftool.exe";
QStringList arguments;
arguments << "-overwrite_original" << "-tagsFromFile" << task.inputPath << "-all:all" << task.outputPath;
QProcess process;
process.start(program, arguments);
process.waitForFinished();
这个还支持多平台:
#include <QProcess>
#include <QCoreApplication>
#include <QDir>
#include <QDebug>
void processImageTask(BatchTask task) {
// ... (前置步骤:EasyExif读取 -> FrameEngine处理 -> 保存新图到 task.outputPath) ...
// ============================================================
// 跨平台调用 ExifTool 移植元数据
// ============================================================
QString program;
QStringList args;
// 获取应用程序所在的目录
QString appDir = QCoreApplication::applicationDirPath();
#ifdef Q_OS_WIN
// --- Windows 逻辑 ---
// 假设 exiftool.exe 在 exe 同级目录下
program = appDir + "/exiftool.exe";
// Windows 下如果路径有空格,QProcess 通常能处理,但为了保险最好用引号包裹路径的逻辑交给 QProcess
#elif defined(Q_OS_MAC)
// --- macOS 逻辑 ---
// 在 macOS 中,可执行文件在 AppName.app/Contents/MacOS 下
// 资源文件通常放在 AppName.app/Contents/Resources 下
// 我们需要回退到 Resources 目录找到 exiftool 脚本
QDir dir(appDir);
dir.cdUp(); // 回到 Contents
dir.cd("Resources");
dir.cd("exiftool"); // 假设你把解压的文件夹命名为 exiftool 放进去了
QString scriptPath = dir.absoluteFilePath("exiftool"); // 主脚本通常没有扩展名
// macOS 直接运行 Perl 脚本通常需要显式调用 perl 解释器,或者确保脚本有执行权限
// 最稳妥的方法是直接调用系统 perl
program = "/usr/bin/perl";
args << scriptPath;
#endif
// 通用参数:从原图复制所有 Tag 到新图,覆盖原文件,不生成 _original 备份
args << "-overwrite_original"
<< "-tagsFromFile" << task.inputPath
<< "-all:all"
<< task.outputPath;
// 执行命令
QProcess process;
process.start(program, args);
// 等待完成 (设置一个超时,比如 10秒,防止卡死)
if (!process.waitForFinished(10000)) {
qDebug() << "ExifTool timeout or error:" << process.errorString();
}
}
写入时,如果报错,可以先用命令行的方式写入,如果错误,会返回错误信息:
D:\Study\repos\ImageFrame\out\build\debug>exiftool.exe -overwrite_original -tagsFromFile D:\DSC_0042.JPG -all:all D:\Output\DSC_0042.JPG
Could not find D:\Study\repos\ImageFrame\out\build\debug\exiftool_files\perl5*.dll
从出错信息可以看到,exiftool.exe不是一个单独的exe,它还要依赖在同一目录下的exiftool_files下面的一系列dll。
现在问题来了,为了解决元素据的读取,我们依赖了EasyExif;为了解决元数据的写入,我们依赖了exiftool,一个整体的功能依赖了两个模块,这是一个bad smell。 EasyExif 是一个“只读”库 (Parser),它没有任何写入 (Writer) 功能。 它的设计初衷就是轻量级解析,不具备生成二进制 EXIF 块的能力。exiftool既然能够写入元数据信息,那当然可以读取,为何不直接使用exiftool来读取元数据信息,而移除对EasyExif的依赖。
//使用 ExifTool 读取元数据
PhotoMeta FrameEngine::extractMetadata(const QString& filePath, const PhotoMeta& preset) {
qDebug() << "Start extracting metadata for:" << filePath;
// 默认使用预设值,读取成功则覆盖
PhotoMeta res = preset;
QString appDir = QCoreApplication::applicationDirPath();
// 适配 ExifTool 文件名和路径
QString exifToolPath;
#ifdef Q_OS_WIN
exifToolPath = appDir + "/assets/exiftool.exe";
#elif defined(Q_OS_MAC)
// 在 macOS App Bundle 中,可执行文件在 Contents/MacOS,资源在 Contents/Resources
// 路径通常是 appDir/../Resources/exiftool
QDir dir(appDir);
dir.cdUp();
dir.cd("Resources");
exifToolPath = dir.absoluteFilePath("exiftool");
#else
exifToolPath = appDir + "/exiftool"; // Linux
#endif
// 如果没有找到 exiftool,直接返回预设
if (!QFile::exists(exifToolPath)) {
// 尝试系统路径
exifToolPath = "exiftool";
qWarning() << "Built-in exiftool not found, trying system PATH.";
}
QProcess process;
QStringList args;
// -json: 输出 JSON 格式
// -n: 输出数值而不是格式化后的字符串 (方便我们自己格式化)
// 指定需要的 Tag,避免读取大量无用数据加快速度
args << "-json" << "-n"
<< "-Make" << "-Model"
<< "-LensModel" << "-LensID" << "-LensInfo" << "-Lens" << "-FocalLengthIn35mmFormat" // 尝试多种镜头字段
<< "-FocalLength" << "-FNumber" << "-ExposureTime" << "-ISO"
<< "-DateTimeOriginal"
<< filePath;
process.start(exifToolPath, args);
if (!process.waitForFinished(3000)) { // 等待 3秒
qWarning() << "ExifTool timeout or failed to start:" << process.errorString();
return res;
}
QByteArray output = process.readAllStandardOutput();
if (output.isEmpty()) {
qWarning() << "ExifTool returned empty output.";
return res;
}
QJsonDocument doc = QJsonDocument::fromJson(output);
if (doc.isArray() && !doc.array().isEmpty()) {
QJsonObject obj = doc.array().first().toObject();
// 1. Make
if (obj.contains("Make")) {
QString makeStr = obj["Make"].toString();
// 首字母大写处理
if (!makeStr.isEmpty()) {
makeStr = makeStr.toLower();
makeStr[0] = makeStr[0].toUpper();
res.make = makeStr;
}
}
// 2. Model
if (obj.contains("Model")) {
res.model = obj["Model"].toString();
}
// 3. Lens (尝试多个字段,优先级:LensModel > LensID > LensInfo > Lens)
QString lensStr;
if (obj.contains("LensModel")) lensStr = obj["LensModel"].toString();
else if (obj.contains("LensID")) lensStr = obj["LensID"].toString();
else if (obj.contains("LensInfo")) lensStr = obj["LensInfo"].toString();
else if (obj.contains("Lens")) lensStr = obj["Lens"].toString();
if (!lensStr.isEmpty() && lensStr != "Unknown") {
res.lens = lensStr;
}
// 4. Date
if (obj.contains("DateTimeOriginal")) {
// ExifTool JSON 格式通常是 "YYYY:MM:DD HH:MM:SS"
QString dateRaw = obj["DateTimeOriginal"].toString();
res.rawDateFull = dateRaw;
// 提取日期部分并替换冒号
QStringList parts = dateRaw.split(" ");
if (!parts.isEmpty()) {
res.date = parts.first().replace(":", ".");
}
}
// 5. Params (构建参数字符串)
QStringList pList;
// 焦距 (FocalLength 建议从 Composite 标签读取,这里简化直接读取)
// 注意:ExifTool 默认不输出 FocalLengthIn35mmFormat 除非指定
// 这里我们只取基本的
int focallength = 0;
if (obj.contains("FocalLength"))
{
focallength = obj["FocalLength"].toInt();
}
if (focallength > 0)
{
pList << QString("%1mm").arg(focallength);
}
else
{
//apple
if (obj.contains("FocalLengthIn35mmFormat")) {
int focallength = obj["FocalLengthIn35mmFormat"].toInt();
if (focallength > 0) pList << QString("%1mm").arg(focallength);
}
}
// 光圈
if (obj.contains("FNumber")) {
double f = obj["FNumber"].toDouble();
if (f > 0) pList << QString("f/%1").arg(f);
}
// 快门
if (obj.contains("ExposureTime")) {
double t = obj["ExposureTime"].toDouble();
if (t > 0) pList << formatShutter(t);
}
// ISO
if (obj.contains("ISO")) {
int iso = obj["ISO"].toInt();
if (iso > 0) pList << QString("ISO%1").arg(iso);
}
if (!pList.isEmpty()) {
res.params = pList.join(" ");
}
//日志记录解析到的关键信息
qInfo() << "Metadata extracted -> Model:" << res.model << "Lens:" << res.lens << "Params:" << res.params;
}
else
{
qWarning() << "Failed to parse ExifTool JSON output.";
}
return res;
}
是使用exiftool读取元数据,然后使用exiftool写回元数据时,需要进行一定的调整,特别是关于预览图的部分。
//高层全流程接口
bool FrameEngine::processAndSave(const QString& inputPath, const QString& outputPath, const PhotoMeta& presetMeta, const FrameOptions& opts, ProgressCallback callback) {
// 辅助宏
#define UPDATE(pct, msg) if(callback) callback(pct, msg);
qInfo() << ">>> Start Task:" << inputPath;
// 步骤 1: 提取元数据 (5%)
UPDATE(5, "提取元数据...");
PhotoMeta finalMeta = extractMetadata(inputPath, presetMeta);
// 步骤 2: 加载图片 (自动旋转)
QImageReader reader(inputPath);
reader.setAutoTransform(true);
QImage img = reader.read();
if (img.isNull()) {
qCritical() << "Failed to load image:" << inputPath << "Error:" << reader.errorString();
UPDATE(0, "图片加载失败");
return false;
}
// 步骤 3-7: 生成处理 (15% - 85% 由内部 process 汇报)
// 注意:内部 process 会调用 callback,我们需要确保它汇报的进度是相对合理的
// 目前 YiyinProcessor 内部汇报了 15-85 的进度,直接传递即可
QImage res = process(img, finalMeta, opts, callback);
if (res.isNull()) {
qCritical() << "Image processing returned null image.";
UPDATE(0, "处理失败");
return false;
}
// 步骤 8: 保存文件
UPDATE(88, "保存文件...");
QFileInfo outInfo(outputPath);
QDir().mkpath(outInfo.absolutePath()); // 确保目录存在
if (!res.save(outputPath, "JPG", 100)) {
qCritical() << "Failed to save file to:" << outputPath;
UPDATE(0, "保存失败");
return false;
}
qInfo() << "File saved to:" << outputPath;
// 步骤 9: 回写元数据 (90% - 99%)
UPDATE(90, "写入元数据...");
QString program;
QStringList args;
// 通用参数:覆盖原文件,从原图复制所有标签
// 排除原图的缩略图和预览图!否则 Windows 预览时会显示原图的竖向缩略图,导致预览错误
args << "-overwrite_original"
<< "-tagsFromFile"
<< QDir::toNativeSeparators(inputPath)
<< "-all:all"
<< "-unsafe"
<< "-Orientation="
<< "--ThumbnailImage"
<< "--PreviewImage"
<< QDir::toNativeSeparators(outputPath);
// 3. 跨平台调用 ExifTool (Windows & macOS 完整实现)
QString appDir = QCoreApplication::applicationDirPath();
#ifdef Q_OS_WIN
// --- Windows 逻辑 ---
// ExifTool.exe 在 exe 的/assets下面
QString winPath = appDir + "/assets/exiftool.exe";
if (QFile::exists(winPath)) {
program = winPath;
}
#elif defined(Q_OS_MAC)
// --- macOS 逻辑 ---
// 1. 寻找路径: AppDir 是 ".../Contents/MacOS"
// 我们需要去 ".../Contents/Resources"
QDir dir(appDir);
dir.cdUp(); // 退到 Contents
dir.cd("Resources");
// 2. 寻找 exiftool 脚本
// 假设你把下载的文件夹命名为 "exiftool" 放进去了,或者直接放了脚本
QString scriptPath = dir.absoluteFilePath("/assets/exiftool"); // 情况A: 直接放文件
if (!QFile::exists(scriptPath)) {
// 情况B: 放在了子文件夹里 (exiftool/exiftool)
scriptPath = dir.absoluteFilePath("/assets/exiftool/exiftool");
}
if (QFile::exists(scriptPath)) {
// 3. 使用系统 Perl 解释器调用
program = "/usr/bin/perl";
// 将脚本路径作为第一个参数插入到参数列表最前面
args.prepend(scriptPath);
}
else {
qDebug() << "MacOS Error: exiftool script not found in Resources:" << dir.absolutePath();
}
#endif
qDebug() << "Running ExifTool:" << program << args.join(" "); // 记录完整命令
// 执行命令
if (!program.isEmpty())
{
QProcess p;
p.start(program, args);
bool finished = p.waitForFinished(30000); // 30s timeout
if (!finished)
{
qWarning() << "ExifTool timed out or crashed.";
UPDATE(100, "元数据写入超时");
// 非致命错误
}
else
{
if (p.exitCode() != 0)
{
qWarning() << "ExifTool exit code:" << p.exitCode() << "StdErr:" << p.readAllStandardError();
}
else
{
qInfo() << "ExifTool finished successfully.";
}
}
}
UPDATE(100, "完成");
qInfo() << "<<< Task Completed";
return true;
}
有两点需要注意:
- 在使用QImageReader加载图片的时候,需要设置reader.setAutoTransform(true)自动旋转。
- 在 exiftool 回写参数中增加 -Orientation=(删除方向标记),防止“双重旋转”。
- 在 exiftool 回写元数据时,显式排除缩略图和预览图(--ThumbnailImage --PreviewImage)。这样 Windows 发现图片里没有自带缩略图,就会被迫读取实际像素重新生成正确的横向预览图。
光影背景
光影背景,类似全屏毛玻璃背景,这个是壹印的核心功能,最开始是使用QImage来实现的,算法就是对原图线缩小,然后再放大。
//快速模糊算法 (利用缩放插值模拟高斯模糊,性能极高)
QImage FrameProcessor::generateBlurredBackground(const QImage& src, int targetW, int targetH)
{
// 1. 极度缩小 (例如缩小到 1/50),这会自动丢弃高频细节
QImage tiny = src.scaled(targetW / 40, targetH / 40, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
// 2. 放大回目标尺寸,Qt 的双线性插值会自动产生平滑的模糊效果
QImage blurred = tiny.scaled(targetW, targetH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
// 3. 叠加一层半透明蒙版,降低干扰,提升文字可读性
QPainter p(&blurred);
p.fillRect(blurred.rect(), QColor(0, 0, 0, 60)); // 黑色蒙版,透明度 60
p.end();
return blurred;
}
这个效果,和壹印的比起来,还是有不小的差距,通过壹印的源码可以看到,它是通过直接调用ffmpeg的 “ boxblur=200:2” 来产生的光影背景。这里直接模仿壹印,采用ffmpeg同样的方案:
// 2. 背景模糊算法 (使用 FFmpeg BoxBlur 逻辑:Resize -> Blur -> Resize)
// 逻辑:Resize(3025x3025) -> boxblur=200:2 -> Resize(Target)
QImage FrameEngine::generateBgWithFFmpeg(const QImage& src, int w, int h) {
if (src.isNull()) return QImage();
QString tempPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
// 【核心修复】生成唯一文件名,防止多线程冲突
QString id = QUuid::createUuid().toString(QUuid::WithoutBraces);
QString inputFile = tempPath + "/pbf_in_" + id + ".jpg";
QString outputFile = tempPath + "/pbf_out_" + id + ".jpg";
// 1. 预处理:强制缩放到 3025x3025
QImage fixedSizeImg = src.scaled(3025, 3025, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation);
fixedSizeImg.save(inputFile, "JPG", 95);
QString appDir = QCoreApplication::applicationDirPath();
QString ffmpegPath = appDir + "/ffmpeg.exe";
if (!QFile::exists(ffmpegPath)) ffmpegPath = "ffmpeg";
QProcess ffmpeg;
QStringList args;
args << "-y" << "-i" << inputFile << "-vf" << "boxblur=200:2" << outputFile;
ffmpeg.start(ffmpegPath, args);
bool finished = ffmpeg.waitForFinished(30000);
QImage result;
if (finished && QFile::exists(outputFile)) {
QImage blurred;
if (blurred.load(outputFile)) {
// 3. 缩放到目标尺寸
result = blurred.scaled(w, h, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
// 压暗背景
QPainter p(&result);
p.fillRect(result.rect(), QColor(0, 0, 0, 25));
p.end();
}
}
// 失败回退
if (result.isNull()) {
QImage tiny = src.scaled(30, 30, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
result = tiny.scaled(w, h, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
QPainter p(&result);
p.fillRect(result.rect(), QColor(0, 0, 0, 100));
p.end();
}
// 清理临时文件 (非常重要,否则临时文件夹会爆满)
QFile::remove(inputFile);
QFile::remove(outputFile);
return result;
}
优先使用ffmpeg,如果失败,回退Qt的采样模式。引入ffmpeg这个exe之后有个尴尬的问题就是代码又引入了一个额外的超过80多M的依赖。这会导致最终打包或发布的程序会多增加80多M的体积。
但为了效果暂时先这样,后面再仔细研究了ffmpeg的“boxblur=200:2”的参数背后的算法,决定按照它的逻辑“对每个像素取指定半径200范围内的像素算术平均值,且连续执行 2 次模糊迭代。” :
- Box Blur (盒式模糊):是一种线性滤波器。它的核心思想是:目标像素的值 = 以该像素为中心的 N×N 矩形区域内所有像素的平均值。
- Separable (可分离性):这是高效实现的关键。二维的 Box Blur 可以拆分为两个一维的 Box Blur:先对每一行做水平模糊,再对每一列做垂直模糊。
- Sliding Window (滑动窗口):这是进一步优化的关键。在计算一行的平均值时,当窗口向右移动一格,只需要减去最左边移出的像素,加上最右边移入的像素,不需要重新计算整个窗口的和。
- 200:2 的含义是:luma_radius = 200: 模糊半径。窗口直径(Kernel Size)= 2 ×200 + 1 = 401像素。luma_power = 2: 迭代次数。即上述的一维模糊过程执行 2 遍。盒式模糊执行多次会越来越趋近于高斯模糊,效果更柔和。
使用 QtConcurrent 进行多线程加速(利用多核 CPU),并使用 滑动窗口算法 消除半径大小对性能的影响。代码如下:
#pragma once
#include <QImage>
#include <QtConcurrent>
#include <vector>
#include <numeric>
#include <QElapsedTimer>
#include <QDebug>
class BlurUtils {
public:
// 核心接口:模拟 ffmpeg boxblur=radius:power
// radius: 模糊半径
// power: 迭代次数 (次数越多越平滑,越接近高斯模糊,通常 2-3 次足矣)
static void applyBoxBlur(QImage& img, int radius, int power) {
if (img.isNull() || radius < 1) return;
// 确保格式为 ARGB32_Premultiplied (Qt 绘图最高效的格式)
if (img.format() != QImage::Format_ARGB32_Premultiplied) {
img = img.convertToFormat(QImage::Format_ARGB32_Premultiplied);
}
// 迭代执行 (对应 ffmpeg 的 :2 参数)
for (int i = 0; i < power; ++i) {
boxBlurHorizontal(img, radius);
boxBlurVertical(img, radius);
}
}
private:
static inline int clamp(int x, int min, int max) {
return (x < min) ? min : ((x > max) ? max : x);
}
// 水平模糊 (多线程行处理)
static void boxBlurHorizontal(QImage& img, int radius) {
int w = img.width();
int h = img.height();
// 生成行号索引
std::vector<int> rows(h);
std::iota(rows.begin(), rows.end(), 0);
// 并行处理每一行
QtConcurrent::blockingMap(rows, [&](int y) {
QRgb* scanLine = reinterpret_cast<QRgb*>(img.scanLine(y));
std::vector<QRgb> tempRow(w);
long sumA = 0, sumR = 0, sumG = 0, sumB = 0;
int kernelSize = radius * 2 + 1;
// 初始化窗口
for (int i = -radius; i <= radius; ++i) {
int x = clamp(i, 0, w - 1);
QRgb p = scanLine[x];
sumA += qAlpha(p); sumR += qRed(p); sumG += qGreen(p); sumB += qBlue(p);
}
// 滑动窗口
for (int x = 0; x < w; ++x) {
tempRow[x] = qRgba(sumR / kernelSize, sumG / kernelSize, sumB / kernelSize, sumA / kernelSize);
int leftIndex = clamp(x - radius, 0, w - 1);
QRgb pOut = scanLine[leftIndex];
sumA -= qAlpha(pOut); sumR -= qRed(pOut); sumG -= qGreen(pOut); sumB -= qBlue(pOut);
int rightIndex = clamp(x + radius + 1, 0, w - 1);
QRgb pIn = scanLine[rightIndex];
sumA += qAlpha(pIn); sumR += qRed(pIn); sumG += qGreen(pIn); sumB += qBlue(pIn);
}
memcpy(scanLine, tempRow.data(), w * sizeof(QRgb));
});
}
// 垂直模糊 (多线程列处理)
static void boxBlurVertical(QImage& img, int radius) {
int w = img.width();
int h = img.height();
std::vector<int> cols(w);
std::iota(cols.begin(), cols.end(), 0);
QtConcurrent::blockingMap(cols, [&](int x) {
std::vector<QRgb> tempCol(h);
long sumA = 0, sumR = 0, sumG = 0, sumB = 0;
int kernelSize = radius * 2 + 1;
for (int i = -radius; i <= radius; ++i) {
int y = clamp(i, 0, h - 1);
QRgb p = img.pixel(x, y);
sumA += qAlpha(p); sumR += qRed(p); sumG += qGreen(p); sumB += qBlue(p);
}
for (int y = 0; y < h; ++y) {
tempCol[y] = qRgba(sumR / kernelSize, sumG / kernelSize, sumB / kernelSize, sumA / kernelSize);
int topIndex = clamp(y - radius, 0, h - 1);
QRgb pOut = img.pixel(x, topIndex);
sumA -= qAlpha(pOut); sumR -= qRed(pOut); sumG -= qGreen(pOut); sumB -= qBlue(pOut);
int bottomIndex = clamp(y + radius + 1, 0, h - 1);
QRgb pIn = img.pixel(x, bottomIndex);
sumA += qAlpha(pIn); sumR += qRed(pIn); sumG += qGreen(pIn); sumB += qBlue(pIn);
}
for (int y = 0; y < h; ++y) img.setPixel(x, y, tempCol[y]);
});
}
};
这样,核心的生成光影背景的代码修改如下:
// C++ 原生背景生成
QImage BaseYiyinProcessor::generateBg(const QImage& src, int w, int h, bool solid, QColor color) {
if (solid) {
QImage out(w, h, QImage::Format_ARGB32_Premultiplied);
out.fill(color);
return out;
}
// 1. 降采样 (Downsample)
// 策略:为了模拟 FFmpeg boxblur=200 的超柔和效果,我们不需要在 4000px 的图上跑模糊。
// 将原图缩小到长边 400px,然后做半径 26 的模糊,效果等同于原图做 200 的模糊,但快 100 倍。
// 计算缩放比例:假设 FFmpeg 逻辑是在 3025px 上做 radius=200
// 3025 / 400 ≈ 7.5 倍。 Radius 200 / 7.5 ≈ 26。
QImage tiny = src.scaled(400, 400, Qt::KeepAspectRatio, Qt::SmoothTransformation);
// 2. 内存模糊 (In-Memory Blur)
// power=2 对应 ffmpeg 的 :2 (两次迭代,更平滑)
BlurUtils::applyBoxBlur(tiny, 26, 2);
// 3. 上采样 (Upsample) 到目标尺寸
// 使用 SmoothTransformation 插值,进一步柔化像素
QImage bg = tiny.scaled(w, h, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
// 4. 亮度遮罩 (Overlay)
// 计算模糊后的亮度,决定覆盖什么颜色的蒙层
int br = calcAverageBrightness(bg);
QColor overlay;
if (br < 15) overlay = QColor(180, 180, 180, 51);
else if (br < 20) overlay = QColor(158, 158, 158, 51);
else if (br < 40) overlay = QColor(128, 128, 128, 51);
else overlay = QColor(0, 0, 0, 51);
QPainter painter(&bg);
painter.fillRect(bg.rect(), overlay);
return bg;
}
现在,移除对 ffmpeg.exe 的外部进程调用不仅能减小发布包体积,还能避免进程启动的开销(Process Overhead)和磁盘I/O,直接在内存中处理图像,速度会有质的飞跃。
打包发布
默认生成的Qt程序点击运行会报找不到dll的错误,原因在于要使用Qt的发布程序对exe程序执行发布操作,他会将exe依赖的组件全部拷贝到exe所在的目录下:
PS D:\Study\repos\ImageFrame\out\build\debug> C:\Qt\6.10.0\msvc2022_64\bin\windeployqt.exe .\ProBatchFrame.exe
D:\Study\repos\ImageFrame\out\build\debug\ProBatchFrame.exe 64 bit, debug executable
Adding in plugin type generic for module: Qt6Gui
Adding Qt6Network for qtuiotouchplugind.dll from plugin type: generic
Adding in plugin type iconengines for module: Qt6Gui
Adding in plugin type imageformats for module: Qt6Gui
Adding in plugin type networkinformation for module: Qt6Network
Adding in plugin type platforms for module: Qt6Gui
Adding in plugin type styles for module: Qt6Widgets
Adding in plugin type tls for module: Qt6Network
Skipping plugin qopensslbackendd.dll. Use -force-openssl or specify -openssl-root if you want to use it.
Performing additional pass of finding Qt plugins due to updated Qt module list: Qt6Core Qt6Gui Qt6Network Qt6Svg Qt6Widgets
Adding in plugin type generic for module: Qt6Gui
Adding in plugin type iconengines for module: Qt6Gui
Adding in plugin type imageformats for module: Qt6Gui
Adding in plugin type networkinformation for module: Qt6Network
Adding in plugin type platforms for module: Qt6Gui
Adding in plugin type styles for module: Qt6Widgets
Adding in plugin type tls for module: Qt6Network
Skipping plugin qopensslbackendd.dll. Use -force-openssl or specify -openssl-root if you want to use it.
Direct dependencies: Qt6Core Qt6Gui Qt6Svg Qt6Widgets
All dependencies : Qt6Core Qt6Gui Qt6Svg Qt6Widgets
To be deployed : Qt6Core Qt6Gui Qt6Network Qt6Svg Qt6Widgets
Warning: Cannot find any version of the dxcompiler.dll and dxil.dll.
Warning: Cannot find Visual Studio installation directory, VCINSTALLDIR is not set.
Updating icuuc.dll.
Updating Qt6Cored.dll.
Updating Qt6Guid.dll.
Updating Qt6Networkd.dll.
Updating Qt6Svgd.dll.
Updating Qt6Widgetsd.dll.
Updating opengl32sw.dll.
Updating D3Dcompiler_47.dll.
Creating directory D:/Study/repos/ImageFrame/out/build/debug/generic.
Updating qtuiotouchplugind.dll.
Creating directory D:/Study/repos/ImageFrame/out/build/debug/iconengines.
Updating qsvgicond.dll.
Creating directory D:/Study/repos/ImageFrame/out/build/debug/imageformats.
Updating qgifd.dll.
Updating qicod.dll.
Updating qjpegd.dll.
Updating qsvgd.dll.
Creating directory D:/Study/repos/ImageFrame/out/build/debug/networkinformation.
Updating qnetworklistmanagerd.dll.
Creating directory D:/Study/repos/ImageFrame/out/build/debug/platforms.
Updating qwindowsd.dll.
Creating directory D:/Study/repos/ImageFrame/out/build/debug/styles.
Updating qmodernwindowsstyled.dll.
Creating directory D:/Study/repos/ImageFrame/out/build/debug/tls.
Updating qcertonlybackendd.dll.
Updating qschannelbackendd.dll.
Creating D:\Study\repos\ImageFrame\out\build\debug\translations...
Creating qt_ar.qm...
Creating qt_bg.qm...
Creating qt_ca.qm...
Creating qt_cs.qm...
Creating qt_da.qm...
Creating qt_de.qm...
Creating qt_en.qm...
Creating qt_es.qm...
Creating qt_fa.qm...
Creating qt_fi.qm...
Creating qt_fr.qm...
Creating qt_gd.qm...
Creating qt_he.qm...
Creating qt_hr.qm...
Creating qt_hu.qm...
Creating qt_it.qm...
Creating qt_ja.qm...
Creating qt_ka.qm...
Creating qt_ko.qm...
Creating qt_lg.qm...
Creating qt_lv.qm...
Creating qt_nl.qm...
Creating qt_nn.qm...
Creating qt_pl.qm...
Creating qt_pt_BR.qm...
Creating qt_ru.qm...
Creating qt_sk.qm...
Creating qt_sv.qm...
Creating qt_tr.qm...
Creating qt_uk.qm...
Creating qt_zh_CN.qm...
Creating qt_zh_TW.qm...
比如以上命令。
架构改进
最开始的代码部分,只是支持了壹印桌面应用里面的默认风格,在壹印iOS App里面也非常多的其它风格可以选择。另外Semi-Util里面的风格应用也非常广,所以程序扩展为了可以支持多种风格。这就需要对程序的结构进行调整。需要抽象出一个基类或者接口,还需要有一个工程类,根据用户选择的风格生成对应的处理类。
// 定义进度回调
using ProgressCallback = std::function<void(int, const QString&)>;
// 元数据结构体
struct PhotoMeta {
QString make; // 品牌 (Sony)
QString model; // 机型 (A7M4)
QString lens; // 镜头
QString params; // 参数 (ISO 100 f/2.8)
QString date; // 拍摄日期
QString rawDateFull;// 原始数据 (用于 ExifTool 写回)
};
enum StyleType
{
YiyinDefault, //0: 默认壹印风格
YiyinInside, //1: 图内文字风格
SemiUtilClassic, //2: 经典底部白条风格 (Classic / Semi-Utils)
Minimalist //3: 极简风格 (Classic / Minimalist)
};
struct FrameOptions {
//输出与背景
QString customOutputDir; // 自定义输出路径 (空则默认 ./Output)
bool useSolidColor = false; // 纯色背景开关
QColor solidBgColor = Qt::white;
//尺寸与比例
double mainImgRatio = 90.0; // 主图占比 (50-99, 默认90)
int targetRatioW = 0; // 输出比例宽 (0代表原比例)
int targetRatioH = 0; // 输出比例高
bool landscapeMode = false; // 强制横屏模式
//样式细节
double cornerRadius = 2.1; // 圆角大小 (0-50)
double shadowSize = 6.0; // 阴影大小 (图片高度的百分比)
double paddingRatio = 0;
double textMargin = 0.4; // 文本间距系数 (默认0.4)
bool textOverlay = false; // 是否将文字显示在图片内部
bool shadowShift = false; //是否开启移影(右下投影)
//字体与内容
QString fontFamily;
QColor textColor = Qt::black;
double fontSizeScale = 1.0;
bool showModel = true;
bool showMake = true;
bool showParams = true;
bool showLens = true; //显示镜头开关
StyleType styleType = StyleType::YiyinDefault;
bool classicBorder = false; //是否开启经典边框(仅 styleType = 2 时有效)
};
// 抽象处理器接口 (OCP原则)
class IFrameProcessor {
public:
virtual ~IFrameProcessor() = default;
virtual QImage process(const QImage& source, const PhotoMeta& meta, const FrameOptions& opts, ProgressCallback callback = nullptr) = 0;
};
//引擎入口-
class FrameEngine {
public:
static PhotoMeta extractMetadata(const QString& filePath, const PhotoMeta& preset);
// 高层业务接口:封装读取、处理、保存、回写全流程
static bool processAndSave(const QString& inputPath, const QString& outputPath, const PhotoMeta& presetMeta, const FrameOptions& opts, ProgressCallback callback = nullptr);
// 预览专用接口 这个接口明确告诉调用者:我只处理图片,不保存文件,无副作用
static QImage generatePreview(const QImage& source, const PhotoMeta& meta, const FrameOptions& opts, ProgressCallback callback);
private:
static QImage process(const QImage& source, const PhotoMeta& meta, const FrameOptions& opts, ProgressCallback callback);
};
FrameEngine是一个引擎入口,在processAndSave方法中,会根据用户选择的FrameOptions中的设置,查找所有实现了IFrameProcessor接口的类去实现对应的模板功能。
所有新的风格,必须实现IFrameProcessor类,比如壹印风格的类:
#pragma once
#include "FrameEngine.h"
#include <QObject>
// 抽象基类:提取壹印风格的公共逻辑
class BaseYiyinProcessor : public IFrameProcessor {
public:
virtual ~BaseYiyinProcessor() = default;
// 接口保持纯虚,由子类实现具体流程
virtual QImage process(const QImage& source, const PhotoMeta& meta, const FrameOptions& opts, ProgressCallback callback) override = 0;
protected:
// --- 共享算法与绘图方法 ---
// 1. 字符串处理
static QString prettifyModelName(QString model, const QString& make);
// 2. 图像算法 (模糊/亮度)
int calcAverageBrightness(const QImage& img);
int calcRegionBrightness(const QImage& img, const QRect& rect);
// 3. 生成器
QImage generateBg(const QImage& src, int w, int h, bool solid, QColor color);
QImage generateShadow(const QSize& size, int radius, int blur);
// 4. 绘图辅助
void drawLogoSvg(QPainter& p, const QString& make, bool isDark, const QRect& rect, const QColor& color);
};
#include "BaseYiyinProcessor.h"
#include "BlurUtils.h"
#include <QCoreApplication>
#include <QtSvg/QSvgRenderer>
#include <QtMath>
#include <QDebug>
#include <QPainterPath>
#include <QProcess>
#include <QTemporaryFile>
#include <QStandardPaths>
#include <QUuid>
// --------------------------------------------------------------------------
// BaseYiyinProcessor 成员实现
// --------------------------------------------------------------------------
QString BaseYiyinProcessor::prettifyModelName(QString model, const QString& make) {
// 1. 尼康 (Nikon) 专属逻辑
if (make.contains("Nikon", Qt::CaseInsensitive)) {
// 替换 Z 为双线 ℤ
model.replace("Z", "ℤ");
// 替换下划线为 空格
model.replace("_", " ");
// 替换代数数字为罗马数字
// 逻辑:通常代数出现在末尾,或者空格之后
// 先处理 " 2" -> " Ⅱ"
model.replace(" 2", " Ⅱ");
model.replace(" 3", " Ⅲ");
// 处理末尾是数字的情况 (针对 Z5_2 -> Z5 2 -> Z5 Ⅱ)
if (model.endsWith("2")) {
model.replace(model.length() - 1, 1, "Ⅱ");
}
else if (model.endsWith("3")) {
model.replace(model.length() - 1, 1, "Ⅲ");
}
}
// 2. 索尼 (Sony) 专属逻辑
else if (make.contains("Sony", Qt::CaseInsensitive)) {
// ILCE-7M4 -> α7M4
model.replace("ILCE-", "α");
// 有些型号是 ILCE-7RM4,也可以变 α7RM4
}
return model;
}
int BaseYiyinProcessor::calcRegionBrightness(const QImage& img, const QRect& rect) {
// 亮度计算
if (img.isNull()) return 0;
QImage tiny = img.scaled(50, 50, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
long long total = 0; int count = 0;
for (int y = 0; y < tiny.height(); ++y) {
for (int x = 0; x < tiny.width(); ++x) {
QColor c = tiny.pixelColor(x, y);
total += (c.red() * 0.299 + c.green() * 0.587 + c.blue() * 0.114);
count++;
}
}
return count > 0 ? (total / count) : 128;
}
int BaseYiyinProcessor::calcAverageBrightness(const QImage& img) {
return calcRegionBrightness(img, img.rect());
}
// C++ 原生背景生成
QImage BaseYiyinProcessor::generateBg(const QImage& src, int w, int h, bool solid, QColor color) {
if (solid) {
QImage out(w, h, QImage::Format_ARGB32_Premultiplied);
out.fill(color);
return out;
}
// 1. 降采样 (Downsample)
// 策略:为了模拟 FFmpeg boxblur=200 的超柔和效果,我们不需要在 4000px 的图上跑模糊。
// 将原图缩小到长边 400px,然后做半径 26 的模糊,效果等同于原图做 200 的模糊,但快 100 倍。
// 计算缩放比例:假设 FFmpeg 逻辑是在 3025px 上做 radius=200
// 3025 / 400 ≈ 7.5 倍。 Radius 200 / 7.5 ≈ 26。
QImage tiny = src.scaled(400, 400, Qt::KeepAspectRatio, Qt::SmoothTransformation);
// 2. 内存模糊 (In-Memory Blur)
// power=2 对应 ffmpeg 的 :2 (两次迭代,更平滑)
BlurUtils::applyBoxBlur(tiny, 26, 2);
// 3. 上采样 (Upsample) 到目标尺寸
// 使用 SmoothTransformation 插值,进一步柔化像素
QImage bg = tiny.scaled(w, h, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
// 4. 亮度遮罩 (Overlay)
// 计算模糊后的亮度,决定覆盖什么颜色的蒙层
int br = calcAverageBrightness(bg);
QColor overlay;
if (br < 15) overlay = QColor(180, 180, 180, 51);
else if (br < 20) overlay = QColor(158, 158, 158, 51);
else if (br < 40) overlay = QColor(128, 128, 128, 51);
else overlay = QColor(0, 0, 0, 51);
QPainter painter(&bg);
painter.fillRect(bg.rect(), overlay);
return bg;
}
QImage BaseYiyinProcessor::generateShadow(const QSize& size, int radius, int blur) {
if (size.isEmpty()) return QImage();
int padding = blur * 2;
QSize canvasSize = size + QSize(padding * 2, padding * 2);
QImage shadow(canvasSize, QImage::Format_ARGB32_Premultiplied);
shadow.fill(Qt::transparent);
QPainter p(&shadow);
p.setRenderHints(QPainter::Antialiasing);
p.setBrush(Qt::black); p.setPen(Qt::NoPen);
p.drawRoundedRect(QRect(QPoint(padding, padding), size), radius, radius);
p.end();
// 模拟 shadowBlur (Box Blur)
/*int kernel = blur / 2;
QImage temp = shadow.copy();
boxBlurH(shadow, temp, kernel);
boxBlurV(temp, shadow, kernel);*/
// 3. 模糊处理 (核心修复)
// 之前的逻辑为了保护圆角限制了 shrinkFactor,导致竖图阴影生成失败。
// 现在我们优先保证模糊生成。
// 计算缩放因子:目标是让模糊核在小图上大约为 2-3 像素
// 这样既能产生平滑的大模糊,又不会消耗过多内存
// blurRadius 是大图上的模糊半径。
// 假设我们在小图上做半径为 2 的 BoxBlur,那么缩放因子应该是 blurRadius / 2
int shrinkFactor = qMax(2, blur / 2);
// 限制最大缩放,防止图太小变成像素点
if (shrinkFactor > 20) shrinkFactor = 20;
QImage tiny = shadow.scaled(canvasSize / shrinkFactor, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
// 在小图上应用固定的微小模糊,放大后就是大模糊
// 2 是一个经验值,配合 SmoothTransformation 放大,效果极佳
BlurUtils::applyBoxBlur(tiny, 2, 1);
// 放大回原尺寸
QImage finalShadow = tiny.scaled(canvasSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
return finalShadow;
}
void BaseYiyinProcessor::drawLogoSvg(QPainter& p, const QString& make, bool isDark, const QRect& rect, const QColor& color) {
QString name = make.split(" ").first().toLower();
QString path = QCoreApplication::applicationDirPath() + "/assets/logos/" + name + "-b.svg";
if (QFile::exists(path)) {
QSvgRenderer svg(path);
if (!isDark) {
QString wP = QCoreApplication::applicationDirPath() + "/assets/logos/" + name + "-w.svg";
if (QFile::exists(wP)) { QSvgRenderer s(wP); s.render(&p, rect); return; }
}
svg.render(&p, rect);
}
}
壹印下面也有很多风格,这里提取了一个基类。经典的风格就是壹印Windows上的风格,光影背景,文字在底部和背景之间YiyinProcessor:
#pragma once
#include "BaseYiyinProcessor.h"
// --- 壹印风格处理器 ---
class YiyinProcessor : public BaseYiyinProcessor {
public:
QImage process(const QImage& source, const PhotoMeta& meta, const FrameOptions& opts, ProgressCallback callback) override;
private:
// 壹印核心步骤
struct LayoutInfo {
int bgW, bgH;
int topMargin;
int shadowBlur; // 对应 blurRadius
double fontSizeBase; // 计算出的字号
int bottomOffset;
// 【修复】添加缺失成员
int textH; // 文字块总高度
int contentH; // 内容总高度 (图+文+间距)
int visualH; // 内容总高度 (图+文+间距)
};
LayoutInfo calcLayout(int srcW, int srcH, const FrameOptions& opts);
void drawText(QPainter& p, const PhotoMeta& meta, int w, const FrameOptions& opts, QRect area, double fontSize);
};
#include "YiyinProcessor.h"
#include <QCoreApplication>
#include <QtSvg/QSvgRenderer>
#include <QtMath>
#include <QPainterPath>
#include <QProcess>
#include <QTemporaryFile>
#include <QStandardPaths>
#include <QUuid>
#include <QDebug>
// 动态系数配置
struct DynamicParams {
double fontSizeRate;
double topMarginRate;
double bottomMarginRate;
double midMarginScale; // 【修复】加回了这个成员
};
static DynamicParams getDynamicParams(bool isLandscape) {
if (isLandscape) {
// 横图:字小,间距紧
return { 0.010, 0.035, 0.022, 0.4 };
}
else {
// 竖图:字大,间距松
return { 0.022, 0.065, 0.045, 0.8 };
}
}
QImage YiyinProcessor::process(const QImage& source, const PhotoMeta& meta, const FrameOptions& opts, ProgressCallback callback) {
if (source.isNull()) return QImage();
#define REP(p, m) if(callback) callback(p, m);
int srcW = source.width();
int srcH = source.height();
// 1. 计算布局 (15%)
REP(15, "计算布局...");
LayoutInfo layout = calcLayout(srcW, srcH, opts);
if (layout.bgW > 30000 || layout.bgH > 30000) return QImage();
// 2. 创建画布 (20%)
REP(20, "创建画布...");
QImage out(layout.bgW, layout.bgH, QImage::Format_ARGB32_Premultiplied);
if (out.isNull()) return QImage();
QPainter p(&out);
p.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing | QPainter::SmoothPixmapTransform);
// 3. 背景 (40%)
REP(30, "生成背景...");
QImage bgImg = generateBg(source, layout.bgW, layout.bgH, opts.useSolidColor, opts.solidBgColor);
if (!bgImg.isNull()) {
p.drawImage(0, 0, bgImg);
}
REP(50, "背景完成");
// 4. 定位
int imgX = (layout.bgW - srcW) / 2;
int imgY = layout.topMargin;
// 如果背景高度 > 视觉实体高度 + 顶部留白,说明空间充裕,执行居中
if (layout.bgH > layout.visualH + layout.topMargin) {
int centerY = (layout.bgH - layout.visualH) / 2;
imgY = std::max(layout.topMargin, centerY);
}
QRect imgRect(imgX, imgY, srcW, srcH);
int radius = qMin(srcW, srcH) * (opts.cornerRadius / 100.0);
// 5. 阴影 (65%)
REP(60, "绘制阴影...");
if (layout.shadowBlur > 0) {
QImage shadowImg = generateShadow(imgRect.size(), radius, layout.shadowBlur);
if (!shadowImg.isNull()) {
// 【核心优化 1】根据模式调整不透明度
// 移影模式(shadowShift)下,不透明度大幅降低(0.6),模拟自然光照
// 普通悬浮模式下,保持较高不透明度(0.9)以维持立体感
double opacity = opts.shadowShift ? 0.50 : 0.90;
p.save();
p.setCompositionMode(QPainter::CompositionMode_SourceOver);
p.setOpacity(opacity);
// 计算逻辑 padding
int padding = layout.shadowBlur * 2;
// 【核心优化 2】计算偏移量
// 移影模式:向右下偏移 0.5 倍模糊半径。
// 从 0.5 降到 0.25,让阴影紧贴图片
int offset = opts.shadowShift ? (layout.shadowBlur * 0.10) : 0;
//// 目标区域:原图区域向四周扩展 padding
//QRect targetRect = imgRect.adjusted(-padding, -padding, padding, padding);
// 目标区域:原图位置 - padding + offset
QRect targetRect = imgRect.adjusted(-padding + offset, -padding + offset, padding + offset, padding + offset);
// 绘制 (Qt 会自动平滑缩放小图)
p.drawImage(targetRect, shadowImg);
p.restore();
}
}
// 6. 主图 (75%)
REP(70, "绘制主图...");
p.save();
QPainterPath path;
path.addRoundedRect(imgRect, radius, radius);
p.setClipPath(path);
p.drawImage(imgX, imgY, source);
p.restore();
// 7. 文字 (85%)
REP(80, "绘制文本...");
// 计算底部剩余区域用于 drawText 的垂直居中
int bottomAreaTop = imgY + srcH;
int bottomAreaH = layout.bgH - bottomAreaTop;
QRect bottomArea(0, bottomAreaTop, layout.bgW, bottomAreaH);
drawText(p, meta, layout.bgW, opts, bottomArea, layout.fontSizeBase);
p.end();
return out;
}
YiyinProcessor::LayoutInfo YiyinProcessor::calcLayout(int srcW, int srcH, const FrameOptions& opts) {
LayoutInfo info;
bool isLandscape = (srcW >= srcH);
DynamicParams dParams = getDynamicParams(isLandscape);
// A. 比例
double targetRatio = (double)srcW / srcH;
if (opts.landscapeMode && srcH > srcW) {
if (opts.targetRatioW == 0) targetRatio = 4.0 / 3.0;
}
if (opts.targetRatioW > 0 && opts.targetRatioH > 0) {
targetRatio = (double)opts.targetRatioW / opts.targetRatioH;
}
// B. 宽度
double mainImgRate = (opts.paddingRatio > 0.1) ? (1.0 - opts.paddingRatio * 2 / 100.0) : 0.8356;
int baseW = std::ceil(srcW / mainImgRate);
// C. 文字高度
info.fontSizeBase = baseW * dParams.fontSizeRate;
double textH = 0;
double lineGap = info.fontSizeBase * opts.textMargin;
bool hasLine1 = opts.showModel || opts.showMake;
bool hasLine2 = hasLine1&&opts.showParams;
if (hasLine1) textH += (info.fontSizeBase * 1.35);
if (hasLine2) textH += lineGap;
if (opts.showParams) textH += (info.fontSizeBase * 0.9);
info.textH = (int)textH;
// D. 垂直间距
int topMargin = baseW * dParams.topMarginRate;
int blurRadius = 0;
if (opts.shadowSize > 0) {
blurRadius = srcH * (opts.shadowSize / 100.0);
topMargin = std::max(topMargin, blurRadius);
}
info.shadowBlur = blurRadius;
info.topMargin = topMargin;
int bottomOffset = baseW * dParams.bottomMarginRate;
info.bottomOffset = bottomOffset;
//最小高度计算策略
int midMargin = 0;
int minBottomSpace = 0;
if (info.textH > 0)
{
// --- 有文字:需要预留底部空间 ---
midMargin = info.fontSizeBase * 2.0 * dParams.midMarginScale;
info.visualH = srcH + midMargin + info.textH; // 视觉实体 = 图 + 间距 + 字
// 底部最小空间 = 间距 + 字 + 底部安全留白
// 为了平衡,我们希望底部至少和顶部差不多大,或者按内容需求
int spaceForContent = midMargin + info.textH + bottomOffset;
minBottomSpace = std::max(topMargin, spaceForContent);
}
else
{
// --- 无文字:强制完全对称 ---
midMargin = 0;
info.visualH = srcH; // 视觉实体 = 仅图片
minBottomSpace = topMargin; // 底部留白 = 顶部留白
}
info.contentH = srcH + minBottomSpace;
int minBgH = topMargin + info.contentH;
// E. 反向撑大
int h_from_baseW = std::ceil(baseW / targetRatio);
int w_from_minH = std::ceil(minBgH * targetRatio);
if (h_from_baseW >= minBgH) {
info.bgW = baseW;
info.bgH = h_from_baseW;
}
else {
info.bgW = w_from_minH;
info.bgH = minBgH;
}
return info;
}
void YiyinProcessor::drawText(QPainter& p, const PhotoMeta& meta, int w, const FrameOptions& opts, QRect area, double fontSize) {
double fontSizeBase = fontSize;
QString fontName = opts.fontFamily.isEmpty() ? "PingFang SC" : opts.fontFamily;
QStringList families; families << fontName << "Segoe UI Symbol" << "Arial";
QFont fModel; fModel.setFamilies(families); fModel.setPointSizeF(fontSizeBase * 1.35); fModel.setWeight(QFont::Bold);
QFont fSpecs(fontName); fSpecs.setPointSizeF(fontSizeBase); fSpecs.setWeight(QFont::Normal);
QColor cText = opts.useSolidColor? Qt::black : Qt::white;
QFontMetricsF fmModel(fModel);
QFontMetricsF fmSpecs(fSpecs);
double lineGap = fontSizeBase * opts.textMargin;
double totalH = 0;
bool hasLine1 = opts.showModel || opts.showMake;
// Line 2 存在的条件:显示参数 OR (显示镜头且有镜头信息)
bool hasLine2 = opts.showParams || (opts.showLens && !meta.lens.isEmpty());
if (hasLine1) totalH += fmModel.height();
if (hasLine1 && hasLine2) totalH += lineGap;
if (hasLine2) totalH += fmSpecs.height();
double startY = area.top() + (area.height() - totalH) / 2;
double minStartY = area.top() + fontSizeBase; //fontSizeBase* 0.5
if (startY < minStartY)
{
startY = minStartY;
}
double currentY = startY;
// Line 1
if (hasLine1) {
QString modelText = prettifyModelName(meta.model, meta.make);
QString brandName = meta.make.split(" ").first();
bool logoDrawn = false;
int logoH = fmModel.capHeight();
int logoW = 0;
if (opts.showMake) {
QString logoPath = QCoreApplication::applicationDirPath() + "/assets/logos/" + brandName.toLower() + "-b.svg";
if (QFile::exists(logoPath)) {
QSvgRenderer svg(logoPath);
if (svg.isValid()) {
QSize sz = svg.defaultSize();
double r = sz.height() > 0 ? (double)sz.width() / sz.height() : 1.0;
logoW = logoH * r; logoDrawn = true;
if (modelText.startsWith(brandName, Qt::CaseInsensitive)) modelText = modelText.mid(brandName.length()).trimmed();
}
}
}
if (!logoDrawn && opts.showMake) {
if (!modelText.startsWith(meta.make, Qt::CaseInsensitive)) modelText = meta.make + " " + modelText;
modelText = prettifyModelName(modelText, meta.make);
}
int textW = fmModel.horizontalAdvance(modelText);
int totalW = textW; if (logoDrawn) totalW += logoW + (logoH * 0.8);
int startX = (w - totalW) / 2;
int baseLine = currentY + fmModel.ascent();
if (logoDrawn) {
drawLogoSvg(p, meta.make, opts.useSolidColor, QRect(startX, baseLine - logoH, logoW, logoH), cText);
startX += logoW + (logoH * 0.8);
}
if (opts.showModel) {
p.setFont(fModel); p.setPen(cText);
p.drawText(startX, baseLine, modelText);
}
currentY += fmModel.height() + lineGap;
}
// Line 2
if (hasLine2) {
QString bottomText;
// 镜头显示逻辑
if (opts.showLens && !meta.lens.isEmpty()) {
bottomText += meta.lens;
}
// 参数显示逻辑
if (opts.showParams && !meta.params.isEmpty()) {
if (!bottomText.isEmpty()) bottomText += " "; // 加间隔
bottomText += meta.params;
}
p.setFont(fSpecs); p.setPen(cText);
int baseLine = currentY + fmSpecs.ascent();
int txtW = fmSpecs.horizontalAdvance(bottomText);
p.drawText((w - txtW) / 2, baseLine, bottomText);
}
}
在FrameEngine的process私有方法中,就将实现委托给了各个实现了IFrameEngine的风格类。
#include <QFile>
#include <QCoreApplication>
#include <QProcess>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QFileInfo>
#include <QtMath>
#include <QDebug>// 确保包含 qDebug
#include <QImageReader>
#include <QDir>
#include <QDateTime>
#include <QElapsedTimer> // 用于计时
#include "FrameEngine.h"
#include "YiyinProcessor.h" // 引用具体的处理器
#include "YiyinInsideProcessor.h"
#include "SemiUtilsProcessor.h"
#include "MinimalistProcessor.h"
// 格式化快门速度 ---
static QString formatShutter(double sec) {
if (sec <= 0) return "";
if (sec >= 1) return QString::number(sec) + "s"; // 1秒以上
// 计算倒数,例如 0.004 -> 250
int denominator = qRound(1.0 / sec);
return QString("1/%1s").arg(denominator);
}
//使用 ExifTool 读取元数据
PhotoMeta FrameEngine::extractMetadata(const QString& filePath, const PhotoMeta& preset) {
qDebug() << "Start extracting metadata for:" << filePath;
// 默认使用预设值,读取成功则覆盖
PhotoMeta res = preset;
QString appDir = QCoreApplication::applicationDirPath();
// 【修改 1】适配 ExifTool 文件名和路径
QString exifToolPath;
#ifdef Q_OS_WIN
exifToolPath = appDir + "/assets/exiftool.exe";
#elif defined(Q_OS_MAC)
// 在 macOS App Bundle 中,可执行文件在 Contents/MacOS,资源在 Contents/Resources
// 路径通常是 appDir/../Resources/exiftool
QDir dir(appDir);
dir.cdUp();
dir.cd("Resources");
exifToolPath = dir.absoluteFilePath("exiftool");
#else
exifToolPath = appDir + "/exiftool"; // Linux
#endif
// 如果没有找到 exiftool,直接返回预设
if (!QFile::exists(exifToolPath)) {
// 尝试系统路径
exifToolPath = "exiftool";
qWarning() << "Built-in exiftool not found, trying system PATH.";
}
QProcess process;
QStringList args;
// -json: 输出 JSON 格式
// -n: 输出数值而不是格式化后的字符串 (方便我们自己格式化)
// 指定需要的 Tag,避免读取大量无用数据加快速度
args << "-json" << "-n"
<< "-Make" << "-Model"
<< "-LensModel" << "-LensID" << "-LensInfo" << "-Lens" << "-FocalLengthIn35mmFormat" // 尝试多种镜头字段
<< "-FocalLength" << "-FNumber" << "-ExposureTime" << "-ISO"
<< "-DateTimeOriginal"
<< filePath;
process.start(exifToolPath, args);
if (!process.waitForFinished(3000)) { // 等待 3秒
qWarning() << "ExifTool timeout or failed to start:" << process.errorString();
return res;
}
QByteArray output = process.readAllStandardOutput();
if (output.isEmpty()) {
qWarning() << "ExifTool returned empty output.";
return res;
}
QJsonDocument doc = QJsonDocument::fromJson(output);
if (doc.isArray() && !doc.array().isEmpty()) {
QJsonObject obj = doc.array().first().toObject();
// 1. Make
if (obj.contains("Make")) {
QString makeStr = obj["Make"].toString();
// 首字母大写处理
if (!makeStr.isEmpty()) {
makeStr = makeStr.toLower();
makeStr[0] = makeStr[0].toUpper();
res.make = makeStr;
}
}
// 2. Model
if (obj.contains("Model")) {
res.model = obj["Model"].toString();
}
// 3. Lens (尝试多个字段,优先级:LensModel > LensID > LensInfo > Lens)
QString lensStr;
if (obj.contains("LensModel")) lensStr = obj["LensModel"].toString();
else if (obj.contains("LensID")) lensStr = obj["LensID"].toString();
else if (obj.contains("LensInfo")) lensStr = obj["LensInfo"].toString();
else if (obj.contains("Lens")) lensStr = obj["Lens"].toString();
if (!lensStr.isEmpty() && lensStr != "Unknown") {
res.lens = lensStr;
}
// 4. Date
if (obj.contains("DateTimeOriginal")) {
// ExifTool JSON 格式通常是 "YYYY:MM:DD HH:MM:SS"
QString dateRaw = obj["DateTimeOriginal"].toString();
res.rawDateFull = dateRaw;
// 提取日期部分并替换冒号
QStringList parts = dateRaw.split(" ");
if (!parts.isEmpty()) {
res.date = parts.first().replace(":", ".");
}
}
// 5. Params (构建参数字符串)
QStringList pList;
// 焦距 (FocalLength 建议从 Composite 标签读取,这里简化直接读取)
// 注意:ExifTool 默认不输出 FocalLengthIn35mmFormat 除非指定
// 这里我们只取基本的
int focallength = 0;
if (obj.contains("FocalLength"))
{
focallength = obj["FocalLength"].toInt();
}
if (focallength > 0)
{
pList << QString("%1mm").arg(focallength);
}
else
{
//apple
if (obj.contains("FocalLengthIn35mmFormat")) {
int focallength = obj["FocalLengthIn35mmFormat"].toInt();
if (focallength > 0) pList << QString("%1mm").arg(focallength);
}
}
// 光圈
if (obj.contains("FNumber")) {
double f = obj["FNumber"].toDouble();
if (f > 0) pList << QString("f/%1").arg(f);
}
// 快门
if (obj.contains("ExposureTime")) {
double t = obj["ExposureTime"].toDouble();
if (t > 0) pList << formatShutter(t);
}
// ISO
if (obj.contains("ISO")) {
int iso = obj["ISO"].toInt();
if (iso > 0) pList << QString("ISO%1").arg(iso);
}
if (!pList.isEmpty()) {
res.params = pList.join(" ");
}
//日志记录解析到的关键信息
qInfo() << "Metadata extracted -> Model:" << res.model << "Lens:" << res.lens << "Params:" << res.params;
}
else
{
qWarning() << "Failed to parse ExifTool JSON output.";
}
return res;
}
QImage FrameEngine::process(const QImage& source, const PhotoMeta& meta, const FrameOptions& opts, ProgressCallback callback) {
// 策略模式:如果有多种风格,可以在这里 switch 创建不同的 Processor
std::unique_ptr<IFrameProcessor> processor;
QString procName;
if (opts.styleType == StyleType::SemiUtilClassic) {
//经典样式 (Semi-Utils)
processor = std::make_unique<SemiUtilsProcessor>();
procName = "SemiUtilsProcessor";
}
else if (opts.styleType == StyleType::YiyinInside || opts.textOverlay) {
processor = std::make_unique<YiyinInsideProcessor>();
procName = "YiyinInsideProcessor";
}
else if (opts.styleType == StyleType::Minimalist) {
processor = std::make_unique<MinimalistProcessor>();
procName = "MinimalistProcessor";
}
else if (opts.styleType == StyleType::YiyinDefault) {
processor = std::make_unique<YiyinProcessor>();
procName = "YiyinProcessor";
}
else
{
qDebug() << "unknow styleType:" << opts.styleType;
}
qDebug() << "Processing image using:" << procName << "Size:" << source.width() << "x" << source.height();
QElapsedTimer timer;
timer.start();
QImage result = processor->process(source, meta, opts, callback);
qDebug() << procName << "finished in" << timer.elapsed() << "ms";
return result;
}
QImage FrameEngine::generatePreview(const QImage& source, const PhotoMeta& meta, const FrameOptions& opts, ProgressCallback callback)
{
return process(source, meta, opts, callback);
}
//高层全流程接口
bool FrameEngine::processAndSave(const QString& inputPath, const QString& outputPath, const PhotoMeta& presetMeta, const FrameOptions& opts, ProgressCallback callback) {
// 辅助宏
#define UPDATE(pct, msg) if(callback) callback(pct, msg);
qInfo() << ">>> Start Task:" << inputPath;
// 步骤 1: 提取元数据 (5%)
UPDATE(5, "提取元数据...");
PhotoMeta finalMeta = extractMetadata(inputPath, presetMeta);
// 步骤 2: 加载图片 (自动旋转)
QImageReader reader(inputPath);
reader.setAutoTransform(true);
QImage img = reader.read();
if (img.isNull()) {
qCritical() << "Failed to load image:" << inputPath << "Error:" << reader.errorString();
UPDATE(0, "图片加载失败");
return false;
}
// 步骤 3-7: 生成处理 (15% - 85% 由内部 process 汇报)
// 注意:内部 process 会调用 callback,我们需要确保它汇报的进度是相对合理的
// 目前 YiyinProcessor 内部汇报了 15-85 的进度,直接传递即可
QImage res = process(img, finalMeta, opts, callback);
if (res.isNull()) {
qCritical() << "Image processing returned null image.";
UPDATE(0, "处理失败");
return false;
}
// 步骤 8: 保存文件
UPDATE(88, "保存文件...");
QFileInfo outInfo(outputPath);
QDir().mkpath(outInfo.absolutePath()); // 确保目录存在
if (!res.save(outputPath, "JPG", 100)) {
qCritical() << "Failed to save file to:" << outputPath;
UPDATE(0, "保存失败");
return false;
}
qInfo() << "File saved to:" << outputPath;
// 步骤 9: 回写元数据 (90% - 99%)
UPDATE(90, "写入元数据...");
QString program;
QStringList args;
// 通用参数:覆盖原文件,从原图复制所有标签
// 排除原图的缩略图和预览图!否则 Windows 预览时会显示原图的竖向缩略图,导致预览错误
args << "-overwrite_original"
<< "-tagsFromFile"
<< QDir::toNativeSeparators(inputPath)
<< "-all:all"
<< "-unsafe"
<< "-Orientation="
<< "--ThumbnailImage"
<< "--PreviewImage"
<< QDir::toNativeSeparators(outputPath);
// 3. 跨平台调用 ExifTool (Windows & macOS 完整实现)
QString appDir = QCoreApplication::applicationDirPath();
#ifdef Q_OS_WIN
// --- Windows 逻辑 ---
// ExifTool.exe 在 exe 的/assets下面
QString winPath = appDir + "/assets/exiftool.exe";
if (QFile::exists(winPath)) {
program = winPath;
}
#elif defined(Q_OS_MAC)
// --- macOS 逻辑 ---
// 1. 寻找路径: AppDir 是 ".../Contents/MacOS"
// 我们需要去 ".../Contents/Resources"
QDir dir(appDir);
dir.cdUp(); // 退到 Contents
dir.cd("Resources");
// 2. 寻找 exiftool 脚本
// 假设你把下载的文件夹命名为 "exiftool" 放进去了,或者直接放了脚本
QString scriptPath = dir.absoluteFilePath("/assets/exiftool"); // 情况A: 直接放文件
if (!QFile::exists(scriptPath)) {
// 情况B: 放在了子文件夹里 (exiftool/exiftool)
scriptPath = dir.absoluteFilePath("/assets/exiftool/exiftool");
}
if (QFile::exists(scriptPath)) {
// 3. 使用系统 Perl 解释器调用
program = "/usr/bin/perl";
// 将脚本路径作为第一个参数插入到参数列表最前面
args.prepend(scriptPath);
}
else {
qDebug() << "MacOS Error: exiftool script not found in Resources:" << dir.absolutePath();
}
#endif
qDebug() << "Running ExifTool:" << program << args.join(" "); // 记录完整命令
// 执行命令
if (!program.isEmpty())
{
QProcess p;
p.start(program, args);
bool finished = p.waitForFinished(30000); // 30s timeout
if (!finished)
{
qWarning() << "ExifTool timed out or crashed.";
UPDATE(100, "元数据写入超时");
// 非致命错误
}
else
{
if (p.exitCode() != 0)
{
qWarning() << "ExifTool exit code:" << p.exitCode() << "StdErr:" << p.readAllStandardError();
}
else
{
qInfo() << "ExifTool finished successfully.";
}
}
}
UPDATE(100, "完成");
qInfo() << "<<< Task Completed";
return true;
}
可以看到,项目中除了壹印的风格之外,还实现了多种风格:
- MinimalistProcessor:极简风格专用。专注于纸张纹理生成、柔光阴影和画廊布局。
- SemiUtilsProcessor:半框/工具风格
总结
本文是使用C++和Qt复刻一款类似壹印水印效果的一次尝试。我有C#经验和一定的C++基础,但从没有使用C++和Qt开发过窗体程序。从最初依赖 FFmpeg 的臃肿工具,到现在拥有自适应纸张纹理、柔光阴影、且体积轻量的原生 C++ 应用,这个项目不仅是代码量的增加,更是对架构设计(策略模式)、性能优化(算法自研)和工程化思维(工具集成) 的一次完整实践。