选择图像 - 从相机或存储卡中选择照片





5.00/5 (3投票s)
“选择图像”是一款面向摄影师的程序,旨在帮助他们浏览和选择相机或存储卡中的照片,以便将它们移动到计算机上进行处理和/或存档。
引言
“选择图像”是“照片选择器”的更新版本。早在2016年,我就向Code Project提交了一篇关于“照片选择器”的文章。我一直使用该程序直到现在。然后,我开始了一个项目,以增加对RAW文件的支持。由于增加了更多功能并对处理RAW文件有了更多了解,这最终导致了全面的重写。我还将名称更改为“选择图像”,以避免混淆。
“选择图像”是一款图像处理程序,旨在简化将JPG和RAW照片从外部媒体选择并复制到硬盘驱动器上的工作文件夹的过程。它还可以用于硬盘驱动器上的文件,用于诸如查看旧文件和文件夹中可删除或存档的图像等任务。您的处理工作流程将从您已选择并复制到硬盘驱动器上的文件夹中的图像开始。“选择图像”通过轻松预览照片并决定哪些值得保留和处理,帮助您达到这一点。通常,您会检查构图、美学和清晰度。
在这个时代,相机每秒可以拍摄数十张6000万像素的图像,仅仅在一次外出摄影后挑选出想要保留的照片可能是一项艰巨的任务。“选择图像”的一种帮助方式是允许您放大图像的特定区域。然后,当您导航到下一张图像时,它会放大同一区域。例如,如果您拍摄了一系列足球运动员接球或鱼鹰捉鱼的照片,那么快速找到序列中的最佳镜头非常有帮助。
“选择图像”可以显示每张图像的EXIF信息。您可以配置其显示的EXIF标签,甚至可以为不同的相机品牌选择不同的配置,即使您处理的是JPG文件。
致谢
Phil Harvey的ExifTool
ExifTool 提供了从几乎所有RAW图像中提取EXIF信息和JPG预览文件的能力。这意味着“选择图像”可以被仅使用RAW格式的摄影师使用,并且在处理高像素相机时也提供了性能优势。
James Newton-King的NewtonSoft.JSON
NewtonSoft.JSON 提供了一种从Settings
中存储和检索EXIF标签设置的简便方法
Drew Noakes的MetadataExtractor
MetadataExtractor 允许“选择图像”从JPG文件中提取所有可能的EXIF标签和值,包括ExifTool从RAW文件中提取的JPG文件。
OpenAI的ChatGPT
ChatGPT 成为了我宝贵的编码助手。它为我节省了数小时的搜索时间,并帮助我找到了更优的复杂任务处理方法。
Visual Studio 2022 用于构建“选择图像”。
Paint.Net 用于创建按钮和屏幕截图。
性能考量
1998年,在数码相机时代之初,索尼推出了索尼 Mavica MVC-FD71数码相机。它能生成640x480像素的图像(307KB),并需要8秒钟将一张照片保存到其1.44MB软盘中。2024年,我的索尼 A7CR 能生成9568x6376像素的图像(61,000KB),并能以足够快的速度将其保存到128,000MB的SD卡上,使我能够以1秒连拍10张照片。我之所以能够连拍,仅仅是因为这些照片被缓冲在相机的内存中,并在更长的时间内写入SD卡。连拍足够多,缓冲区就会填满,然后直到缓冲区清空,我就无法再次连拍了。
Mavica 的瓶颈正是今天仍然制约数码相机的因素——相机存储介质相对于相机本身以及我们处理和存储数码图像的计算机的相对较慢的性能。
如果数码图像仍然是640x480像素,那不成问题,但现在它们是原来的100到200倍大。有个好消息是,您可以将相机配置为生成小得多的JPG图像。在我的相机上,低质量的JPG图像平均为2.7MB(全画幅)和1.5MB(APS-C)。您还可以从比标准160px x 120px缩略图大的RAW相机文件中提取较小的JPG图像。
如果您是休闲摄影师,每次外出拍摄几十张照片,度假时可能拍几百张照片,那么您就可以轻松地处理和分享这些图像中的最佳作品,而无需改变现有工作流程。但是,如果您拍摄野生动物、体育赛事和其他活动,您可能会面对成百上千张照片需要筛选。摄影师之所以拍摄如此多的照片,部分原因是因为他们现在可以这样做了。在胶片时代,如果他们试图连拍照片,他们会花所有时间装卸36张胶卷。如果他们希望捕捉到球棒击中球、老鹰抓鱼、跑者冲过终点线或新郎亲吻新娘的决定性时刻,他们就必须依靠自己的技巧和判断来捕捉这些镜头。如今,他们可以专注于击球手,当他开始挥杆时,他们可以按住快门按钮,得到十多张照片,其中很可能有一张是球从他球棒上飞出、汗珠从他额头上滑落的镜头。
“选择图像”的创建是为了让在大量照片充斥您的硬盘驱动器之前,更容易快速筛选大量照片。通常,您可以相当快地判断一张照片是否能入选并允许其进入您的硬盘进行进一步处理和评估。您实际上并不需要照片的大尺寸版本来做到这一点。虽然索尼Mavica生成的640x480像素图像可能足够了,但您可能希望图像尺寸接近您的显示器大小——比如1920x1200像素。
让我们测试一下预览图像需要多大。我使用索尼 A7CR 上提供的所有JPG尺寸和质量组合,在光线充足的情况下拍摄了同一场景。以下是我获得的图片文件大小。
我使用了一个我编写的程序来并排比较图像,以比较最高质量的大JPG图像和最低质量的小JPG图像。这是原始图像以及每张图像裁剪区域的并排比较。如果您想知道,左边的裁剪来自最高质量的原图。
从这个比较中,我们可以看到,4MB(约2400x1600px)的图像足以用于预览目的。现在我们可以找出最快的方法将类似大小的图像显示在计算机屏幕上,以便我们进行查看。这取决于您喜欢的拍摄图像类型。
JPG拍摄者
如果您只拍摄JPG,您需要权衡尺寸、质量以及照片的使用方式。如果您在Instagram上分享照片,您会选择可用设置中最小、质量最低的JPG。如果您正在分享屏幕大小的照片版本,人们会在大屏幕上欣赏,例如电脑显示器、高清电视和平板电脑,那么中等尺寸和标准质量的JPG文件将是不错的选择。如果您要制作挂在墙上的大幅打印品,那么全尺寸、高质量的图像将是最佳选择。实际上,大多数JPG拍摄者使用相机的默认JPG尺寸和质量,并且可能会调整质量设置“以获得更好的照片”。
RAW拍摄者
如果您只拍摄RAW,您在尺寸方面有一些选择。某些相机型号提供RAW和压缩RAW。一些相机即使在APS-C模式下也只会提供较小的RAW文件。通常没有意识到的是,RAW文件通常包含各种尺寸的嵌入式JPG文件,从缩略图(160px x 120 px)到全尺寸。这因制造商而异,但我检查过的每一个制造商都有一个比缩略图大、比全尺寸小的嵌入式JPG。如果我们能提取这些文件,它们将是图像预览的绝佳选择。
下表展示了一些流行相机品牌的典型预览文件大小。
幸运的是,我发现了Phil Harvey的ExifTool。它提供了从几乎所有已知RAW文件格式中提取缩略图和预览JPG文件的能力。如果您想使用“选择图像”来预览RAW文件,您必须从ExifTool网站下载ExifTool.exe和ExifTool.config,并将其安装在与“选择图像”的可执行文件相同的文件夹中。请注意,您还需要将exiftool_files文件夹安装在同一个文件夹中。
最初,我使用ExifTool的C#包装器直接从RAW文件提取EXIF数据。这可行,但这意味着两次读取大型RAW文件,一次获取预览文件,另一次获取EXIF数据。我建议Phil Harvey,ExifTool应该将EXIF数据复制到它从RAW文件中提取的预览文件中的这个功能会很棒。他欣然同意,ExifTool 12.84 版本也实现了这一点。
“选择图像”使用ExifTool读取RAW文件并提取嵌入的JPG文件以及原始EXIF信息。这速度相当快,生成的预览文件可以像任何其他JPG图像一样处理。一旦您选择了要处理的图像,“选择图像”就可以将匹配的RAW图像复制到您的计算机。然后,您可以使用您喜欢的RAW处理软件将其保存用于任何目的,从社交媒体帖子到大幅打印。您可以通过使用Settings
对话框设置从RAW文件中提取的预览文件的最大尺寸,来减少提取预览文件和创建缩略图所需的时间。表1显示了您可以做出的选择。
在测试一个包含我索尼 A6700 的 308 个 RAW 文件的文件夹时,我有两个选择——预览文件尺寸为 6192px x 4128px,或者预览文件尺寸为 1616px x 1080px。通过将上限设置为 2000px,我强制“选择图像”使用较小的文件。ExifTool 提取预览文件所需的时间从 20 秒减少到 7 秒,加载缩略图并显示它们所需的时间从 93 秒减少到 21 秒。
相比之下,我拍摄了从 RAW 文件创建的最终 JPG 图像,并将该文件夹作为源文件夹。创建和显示缩略图花费了 90 秒。换句话说,如果您不介意处理较小的预览文件,可以通过打开Settings对话框并选择Preview以及较小的预览尺寸限制来更快地开始工作。您可以选择的实际尺寸将取决于您的相机品牌和型号。
RAW 加 JPG 拍摄者
这种组合为您提供了最多的选项。
您可以保存所有 JPG 图像,然后返回并保存 RAW 版本以供需要修复的图像使用。当您拍摄大型活动并希望非常快速地发布照片时,此选项很有吸引力。如果您可以选择,拍摄小型 JPG 图像可以将处理速度提高 2 到 3 倍。
您可以拍摄小型 JPG 图像,并保存您想要保留和进一步处理的图像的 RAW 版本。
您可以忽略 JPG 图像,选择可用的最小 RAW 预览尺寸,并保存您想要保留和进一步处理的图像的 RAW 版本。
我个人通常拍摄小型 JPG 和 RAW 格式。当我开始用单反相机拍摄时,我只拍 JPG。有一天,我和一个朋友去 Magee Marsh 拍摄莺鸟。她一直劝我拍摄 RAW 格式,但我一直没有采纳。我带回了几百张照片。在 LCD 屏幕上看起来还不错。当我将它们复制到电脑上时,我惊恐地发现每张照片都是一个俗气的大杂烩。我意外地将相机设置成了漫画模式,图像被转换成了卡通风格。它们是很糟糕的卡通,更是糟糕的鸟类照片,而且我无法修复它们。如果我当时拍摄的是 RAW 格式…… 经过测试,我得出结论,在我的相机上,拍摄 RAW 并选择小型预览图像对我来说是最好的选择。
处理 RAW 文件
嵌入的 JPG 预览文件,即使是像旧款索尼型号那样尺寸为 1616px x 1080px 的小文件,在预览文件方面也完全足够。近期相机型号提供的更大型预览文件也绰绰有余。嵌入式缩略图(160px x 120px)对于预览目的来说不太有用。
在“选择图像”的Settings对话框中,用户可以指定预览文件中允许的最长尺寸,以及是创建来自源文件夹中的 JPG 文件的缩略图和预览图像,还是创建来自 ExifTool 提取的预览 JPG 文件的缩略图和预览图像。如果您限制预览 JPG 文件的大小并使用它们而不是原始 JPG 图像,那么在加载和显示缩略图方面可以获得更好的性能。
“选择图像”不知道特定图像集中提供了哪种尺寸的预览图像。我曾考虑按品牌配置,但这不是一个好的选择。原因是不同型号保存的预览图像集和尺寸不同。您可以看到表 1。因此,“选择图像”尝试通过检查源文件夹中的第一张图像来确定使用哪种图像。以下方法就是这样做的。
private string GetPreviewType(string sampleRawFile, string tempFolder) {
// Look for the preview type that does not exceed the configurable upper limit on the longest dimension
List<string> previewTypes = new List<string>() { "PreviewImage", "JpgFromRaw", "OtherImage", "ThumbnailImage" };
List<int> previewSizes = new List<int>() { 0, 0, 0, 0 };
int bestSize = 0;
string bestType = null;
string args = "-preview:All -a -b -W " + tempFolder + _PathSeparator + "/%f_%t%-c.%s \"" + sampleRawFile + "\"";
ExecuteExifTool(args, 500);
for (int i = 0; i < previewTypes.Count; i++) {
string previewType = previewTypes[i];
string previewFileName = Path.GetFileNameWithoutExtension(sampleRawFile) + "_" + previewType;
string previewPath = Path.Combine(_TempFolderPath, previewFileName + ".JPG");
if (File.Exists(previewPath)) {
var dimensions = GetImageDimensions(previewPath);
int maxDim = Math.Max((int)dimensions.Width, (int)dimensions.Height);
previewSizes[i] = maxDim;
// Check maximum dimension against the maximum
if (maxDim <= _MaxPreviewDimension && maxDim > bestSize) {
bestSize = maxDim;
bestType = previewType;
}
}
}
// It may happen that your folder on a hard drive contains photos from multiple cameras with different
// maximum preview sizes. If the largest preview size is selected, signal ExifTool to get the largest
// Preview image it finds in each image.
if (_SDCardDrive != null) {
int maxIndex = previewSizes.IndexOf(previewSizes.Max());
if (previewTypes[maxIndex] == bestType) {
return "All";
}
}
return bestType;
}
private (int Width, int Height) GetImageDimensions(string imagePath) {
using (Image image = Image.FromFile(imagePath)) {
return (image.Width, image.Height);
}
}
这是用于从选定的 RAW 文件文件夹中提取所有 JPG 预览文件的 ExifTool 命令示例。
-preview:All -a -b -W C:\Users\patjd\AppData\Local\Temp\ZZZTmp10040622\/%f_%t%-c.%s "L:\DCIM\10040622\PJD06937.ARW"\
“选择图像”调用 ExifTool 并传递一个命令,该命令从 RAW 文件文件夹中提取预览文件并将它们复制到临时目录。EXIF 数据嵌入在文件中,因此无需从原始 RAW 图像中读取 EXIF 数据。从那时起,“选择图像”就可以像处理直接从相机拍摄的 JPG 文件文件夹一样处理这些文件。以下是 ExifTool 在确定应提取PreviewImage
JPG 文件后传递给它的命令示例。
-preview:PreviewImage -a -b -w C:\Users\patjd\AppData\Local\Temp\ZZZTmp11340601/%f.JPG "L:\DCIM\11340601\*.ARW"
如果选定的预览类型也是最大的,“选择图像”将使用一个 ExifTool 命令,该命令将从每个文件中提取最大类型的预览文件。这种情况在相机媒体上“不可能发生”,但如果您查看包含来自多个相机的图像的存档文件夹,则可能会发生。
-config .ExifTool.config -p "${BigImage;SetTags('all:all#')}" -b -w C:\Users\patjd\AppData\Local\Temp\AAATmp10240427/%f.jpg "L:\DCIM\10240427\*.ARW"
ExifTool.config
参数引用 ExifTool 使用的文件。在这种情况下,它包含BigImage
的定义,该定义告诉 ExifTool 选择 RAW 文件中找到的最大预览图像。第一个文件夹是写入预览文件的位置。第二个是 RAW 文件的来源——在本例中是索尼 RAW 文件。您可能会注意到两个目录名称包含相同的数字。这是因为“选择图像”将缩略图和预览图像存储在名为源文件夹的临时文件夹中,该文件夹位于 Windows 指定的 Temp 文件夹内。
private void ExecuteExifTool(string arguments, int timeOut) {
string path = AppDomain.CurrentDomain.BaseDirectory;
string command = Path.Combine(path, "ExifTool.exe");
// Create a ProcessStartInfo object
ProcessStartInfo processStartInfo = new ProcessStartInfo(command, arguments)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
// Create and start the process
using (Process process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true }) {
process.Exited += Process_Exited; // Subscribe to the Exited event
process.Start();
// Read the output streams asynchronously
string output = process.StandardOutput.ReadToEnd();
string error = process.StandardError.ReadToEnd();
// Wait for the process to exit with a timeout
if (!process.WaitForExit(timeOut)) {
process.Kill();
Log("ExifTool process killed due to timeout.", LogType.Error);
}
// Close the process and read output/errors
process.Close();
if (!string.IsNullOrEmpty(error)) {
Log("exifttol error:" + error, LogType.Error);
}
}
}
打开源文件夹
“选择图像”旨在将选定的文件从外部介质(如 SD 卡)复制到您的硬盘驱动器。
使用“选择图像”的第一步是选择源文件的位置。您可以使用“文件”菜单打开源文件存储的文件夹。您还可以使用“最近的源文件夹”菜单项打开以前使用过的文件夹。“选择图像”不会递归遍历子文件夹。然后,“选择图像”确定所选文件夹中文件的最早和最晚日期。如果日期范围超过一天,它会提示您选择一个日期范围。
确定日期范围的代码主要由 ChatGPT 生成。这是它给我的。
private Dictionary<string, DateTime> GetFileDateRange(string sourceFolder) {
Dictionary<string, DateTime> fileDateRange = null;
string[] extensions = { ".JPG", "." + _RAWExtension };
ConcurrentDictionary<string, DateTime> fileLastWriteTimes = new ConcurrentDictionary<string, DateTime>();
Parallel.ForEach(Directory.EnumerateFiles(sourceFolder, "*.*", SearchOption.TopDirectoryOnly)
.Where(file => extensions.Contains(Path.GetExtension(file).ToUpper())),
file =>
{
DateTime lastWriteTime = File.GetLastWriteTime(file);
fileLastWriteTimes.TryAdd(file, lastWriteTime);
});
// Convert to list for further processing
List<KeyValuePair<string, DateTime>> fileList = fileLastWriteTimes.ToList();
// Sort by creation time
fileList.Sort((x, y) => DateTime.Compare(x.Value, y.Value));
// Get the last write time of the first and last photow
if (fileList.Count > 0) {
fileDateRange = new Dictionary<string, DateTime>();
fileDateRange.Add(fileList.First().Key, fileList.First().Value);
fileDateRange.Add(fileList.Last().Key, fileList.Last().Value);
}
return fileDateRange;
}
“选择图像”扫描您选择的文件夹,以发现其中包含 RAW 或 JPG 图像,或者两者都包含。它将文件夹分类为仅 JPG、仅 RAW 或两者的组合。在大多数情况下,包含 RAW 和 JPG 文件夹的文件夹将具有相同的名称但扩展名不同。这是执行此分类的第一部分代码。请注意,有一个设置告诉“选择图像”是加载 JPG 文件的缩略图和 EXIF 信息,还是加载 ExifTool 从 RAW 文件中提取的预览 JPG 文件。如果您对查看大约相当于典型显示器大小的文件感到满意,那么选择后一种选项并设置预览文件的最大尺寸为 2000px 将比直接使用 JPG 文件提供更好的性能。一如既往,您的体验可能有所不同。
private SourceFolderType GetSourceFolderInfo(string sourceFolder) {
// Create a DirectoryInfo object for the folder
DirectoryInfo directoryInfo = new DirectoryInfo(sourceFolder);
// Check if the folder exists
if (directoryInfo.Exists) {
// Get all the JPG files in the folder
FileInfo[] jpgFiles = PruneByDateRange(directoryInfo.GetFiles("*.JPG", _SearchOption));
// Get all the RAW files in the folder
FileInfo[] rawFiles = PruneByDateRange(directoryInfo.GetFiles("*." + _RAWExtension, _SearchOption));
if (rawFiles.Length > 0) {
_RAWSourceFolder = sourceFolder;
}
if (jpgFiles.Length == 0 && rawFiles.Length > 0) {
_AllFilesInfo = rawFiles;
return SourceFolderType.RAW;
}
if (jpgFiles.Length > 0 && rawFiles.Length == 0) {
_AllFilesInfo = jpgFiles;
return SourceFolderType.JPG;
}
jpgFiles = jpgFiles.OrderBy(file => file.Name).ToArray();
rawFiles = rawFiles.OrderBy(file => file.Name).ToArray();
if (jpgFiles.Length == rawFiles.Length) {
bool match = true;
for (int i = 0; i < jpgFiles.Length; i++) {
if (Path.GetFileNameWithoutExtension(jpgFiles[i].Name) != Path.GetFileNameWithoutExtension(rawFiles[i].Name)) {
match = false;
break;
}
}
if (match) {
if (_JPGorPreview == "JPG") {
_AllFilesInfo = jpgFiles;
return SourceFolderType.JPG;
}
else {
_AllFilesInfo = rawFiles;
return SourceFolderType.RAW;
}
}
}
...
如果 JPG 文件和 RAW 文件不匹配(无论是在文件名还是数量上),事情会变得有点复杂。如果您开始只拍摄 JPG,然后切换到拍摄 RAW 或 RAW 加 JPG,则可能会发生这种情况。在这种情况下,“选择图像”会决定是使用 RAW 还是 JPG,并将无法匹配的文件列在“未匹配文件”窗体上。然后您有三个选项:忽略这些文件、将它们移动到另一个文件夹或将它们复制到另一个文件夹。如果文件在 SD 卡上,最好将其复制,以避免相机内部出现文件被删除的问题。至少,对于索尼相机是这样的。代码继续解析我们正在处理的文件类型。
...
else if (jpgFiles.Length > rawFiles.Length) {
// Extract the base file names (without extensions) from the rawFiles array
var rawFileBaseNames = new HashSet<string>(rawFiles.Select(file => Path.GetFileNameWithoutExtension(file.Name)));
// Filter the jpgFiles array to exclude files with matching base names in rawFiles
FileInfo[] prunedJpgFiles = jpgFiles
.Where(file => !rawFileBaseNames.Contains(Path.GetFileNameWithoutExtension(file.Name)))
.ToArray();
if (prunedJpgFiles.Length > 0) {
frmProcessUnmatchedFiles frmPU = new frmProcessUnmatchedFiles() { Files = prunedJpgFiles, Folder = _DestinationJPGFolder };
if (frmPU.ShowDialog() == DialogResult.OK) {
_AllFilesInfo = rawFiles;
return SourceFolderType.RAW;
}
}
}
else if (jpgFiles.Length < rawFiles.Length) {
// Extract the base file names (without extensions) from the rawFiles array
var jpgFileBaseNames = new HashSet<string>(jpgFiles.Select(file => Path.GetFileNameWithoutExtension(file.Name)));
// Filter the jpgFiles array to exclude files with matching base names in rawFiles
FileInfo[] prunedRAWFiles = rawFiles
.Where(file => !jpgFileBaseNames.Contains(Path.GetFileNameWithoutExtension(file.Name)))
.ToArray();
if (prunedRAWFiles.Length > 0) {
frmProcessUnmatchedFiles frmPU = new frmProcessUnmatchedFiles() { Files = prunedRAWFiles, Folder = _DestinationRAWFolder };
if (frmPU.ShowDialog() == DialogResult.OK) {
_AllFilesInfo = jpgFiles;
return SourceFolderType.JPG;
}
}
}
}
return SourceFolderType.Undefined;
}
选择缩略图
大多数相机都会在生成的 JPG 和 RAW 图像中嵌入一个 160px x 120px 的缩略图。这些缩略图太小,不适合预览。例如,您无法区分主体清晰对焦的照片与相机对焦在主体前方或后方的照片。因此,“选择图像”会从 JPG 图像创建更大的缩略图。这些将是相机生成的 JPG 文件或 ExifTool 从 RAW 图像中提取的 JPG 图像的缩放副本。这些缩略图的大小可以配置,最长尺寸限制在 50px 到 360px 之间。您可以随时更改大小,缩略图显示将立即更新。“选择图像”将每个缩略图的 360x240px 副本保存在与源文件夹关联的临时文件夹中。如果您重新打开源文件夹,它将使用这些缩略图文件来匹配每个 JPG 图像。它还将每个文件的Date Taken
EXIF 值保存在一个简单的 CSV 文件中,以节省再次检索的时间。使用保存的缩略图和“拍摄日期”列表可以大大加快加载缩略图的速度,如果您回到先前选择的文件夹。
private ThumbnailInfo GetThumbnailInfo(string path) {
ThumbnailInfo thumbnailInfo = new ThumbnailInfo();
string tempThumbNailPath = string.Empty;
string pathName = Path.GetFileNameWithoutExtension(path);
string fileDateCSV = Path.Combine(_TempFolderPath, "FileDate.csv");
if (Directory.Exists(_TempFolderPath)) {
tempThumbNailPath = Path.Combine(_TempFolderPath, pathName) + ".THM.JPG";
}
try {
if (File.Exists(tempThumbNailPath)) {
using (FileStream fs = new FileStream(tempThumbNailPath, FileMode.Open, FileAccess.Read)) {
// Load the image from the FileStream
using (Image thumbnail = Image.FromStream(fs)) {
_BytesRead += thumbnail.Width * thumbnail.Height;
thumbnailInfo.Thumbnail = new Bitmap(thumbnail, _MaxThumbnailSize);
}
thumbnailInfo.DateTaken = _TempFileDates[pathName];
}
}
else if (File.Exists(path)) {
using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read)) {
// Load the image from the FileStream
using (Image thumbnail = Image.FromStream(fs)) {
_BytesRead += thumbnail.Width * thumbnail.Height;
// Resize the thumbnail to the maximum thumbnail size
thumbnailInfo.Thumbnail = new Bitmap(thumbnail, _MaxThumbnailSize);
}
thumbnailInfo.DateTaken = GetDateTakenFromJPGImage(fs);
if (tempThumbNailPath != string.Empty) {
using (StreamWriter sw = File.AppendText(fileDateCSV)) {
sw.WriteLine(pathName + "," + thumbnailInfo.DateTaken.ToString());
}
SaveImageToFile(thumbnailInfo.Thumbnail, tempThumbNailPath, ImageFormat.Jpeg);
}
}
}
return thumbnailInfo;
}
catch (Exception ex) {
// Handle the exception as needed (e.g., log the error, continue with the next image)
MessageBox.Show(_Messages[15].Replace("<filename>", path) + ex.Message, "Error");
}
return null;
如果您单击缩略图,图像将以当前的缩放级别显示。默认是“适合窗口”。您可以从原始图像大小的 10% 到 100% 以 10% 的步长进行缩放,可以使用键盘快捷键或工具栏上的组合框。这些预览图像可以帮助您检查焦点和其他考虑因素,例如构图、背景虚化、干扰和吸引力。如果您对图像满意,可以将其复制到指定的目的地文件夹进行进一步处理。您还可以将其标记为已选择,然后在查看更多照片后决定是否复制。
一旦创建了FileInfo
对象的_AllFileInfo
数组,您就可以删除任何重复项。SD卡上不太可能出现重复项,但在大型RAW文件存档文件夹中可能会出现。“选择图像”仅从图像文件中需要足够大的图像副本(例如 1500px x 1000px)以及 EXIF 信息。如果源文件是 RAW 文件,它会使用 ExifTool 提取包含原始 EXIF 数据的预览图像。这些预览图像存储在临时文件夹中,并代替实际的 RAW 文件使用。要查看的文件路径存储在List<string> _FileList</string>
中。
“选择图像”需要一种存储每个文件信息的方式,以便于创建和操作一组缩略图。它使用以下对象来实现这一点。
// Thumbnail Dictionary Declarations
private enum ImageFlag {
NotInDateRange,
None,
Checked,
Deleted,
Copied,
CheckPending,
UncheckPending
}
private class ThumbnailInfo : IDisposable {
public int Index { get; set; }
public Image Thumbnail { get; set; }
public DateTime DateTaken { get; set; }
public ImageFlag Flag { get; set; }
public void Dispose() {
Dispose();
GC.SuppressFinalize(this);
}
}
private readonly Dictionary<string, ThumbnailInfo> _Thumbnails = new Dictionary<string, ThumbnailInfo>();
Thumbnails
由文件路径索引。对于 RAW 源图像,这是预览 JPG 的路径。
Thumbnail.Index
给出了对应图像在_FileList
中的位置,这是一个包含所有选定要处理的图像文件的列表。
Thumbnail.Image
是缩放到 360px x 240px 的缩略图图像,这是“选择图像”显示的最大缩略图尺寸。
Thumbnail.ImageFlag
给出了根据enum imageFlag
定义的缩略图的当前状态。
由于ThumbnailInfo
对象包含图像,我们将其声明为可处置的。缩略图字典通过读取_FileList
中引用的所有 JPG 图像或 JPG 预览图像来填充,并从每个文件中提取 360px x 240px 的缩略图图像。它还提取“拍摄日期”EXIF 标签值,以便可以忽略选定日期范围之外的图像。这可能是因为您的 SD 卡文件夹中有数月的照片,但您只想查看您在黄石公园度过的那一周的照片。
此方法用于填充_ThumbnailInfo
字典中的条目。
private ThumbnailInfo GetThumbnailInfo(string path) {
ThumbnailInfo thumbnailInfo = new ThumbnailInfo();
try {
if (File.Exists(path)) {
using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read)) {
// Load the image from the FileStream
using (Image thumbnail = Image.FromStream(fs)) {
_BytesRead += thumbnail.Width * thumbnail.Height;
// Resize the thumbnail to the maximum thumbnail size
thumbnailInfo.Thumbnail = new Bitmap(thumbnail, _MaxThumbnailSize);
}
thumbnailInfo.DateTaken = GetDateTakenFromJPGImage(fs);
}
return thumbnailInfo;
}
}
catch (Exception ex) {
// Handle the exception as needed (e.g., log the error, continue with the next image)
MessageBox.Show(_Messages[15].Replace("<filename>", path) + ex.Message, "Error");
}
return null;
}
填充字典需要遍历所有选为源文件的文件。这在时间上成本很高,因为“选择图像”可能正在从相对较慢的介质中读取成百上千张大型 JPG 文件。当我向妻子展示“选择图像”并选择 SD 卡上的一个大型源文件夹进行预览时,她问道:“我为什么看到一个空白屏幕?”显然,我的小型进度条提供的反馈不足。我更新了“选择图像”,使其在读取文件的同时显示缩略图。尽管更新了每个读取文件的相对较大的图像,但性能影响可以忽略不计。如果thumbnailInfo.DateTaken
日期的分布超过一天,那么“选择图像”会显示一个窗体,允许您限制日期范围,从而限制缩略图图像的数量。
图像流和临时文件夹
显示图像
“选择图像”使用嵌套在面板pnlMainFrame
中的图片框picMainImage
来显示图像和缩略图。关键是pnlMainframe
的AutoScroll
属性设置为true
。此属性意味着如果分配给picMainImage
的Image
属性的图像超出了pnlMainFrame
的边界,则面板控件将提供滚动条。它还允许我们通过操作面板的AutoScrollPosition
来滚动和平移图像。
您可以通过单击缩略图来选择要查看的图像。选择图像后,您可以使用工具栏上的箭头按钮或按住 Ctrl 键的键盘左右箭头键导航到下一张或上一张图像。如果显示的是最后一张图像,则单击右按钮或箭头键将回到第一张图像,反之亦然。
选择图像
选择由工具栏右侧的切换按钮指示。单击切换按钮会改变其状态。屏幕截图显示其切换为关闭和打开状态。当您单击“复制”或“删除”按钮而图像正在显示时,“选择图像”会检查图像是否已被选中。如果未被选中,则仅复制或删除当前图像。否则,所有选定的图像都会被复制或删除,然后图像会被取消选中。
这是从外部媒体读取图像的方法。它会检查文件是否是有效的 JPG 并且不是太大。该尺寸是可配置的,因为相机正朝着越来越高的像素计数发展,这可能是有必要的。它还会从图像文件中加载嵌入的 EXIF 数据。如果文件不是有效的 JPG 图像,则会显示一个错误图像。错误文件名会写入该图像。
private Image GetImageFromMedia(string sourceImagePath) {
Image jpgImage = null;
bool validJPG = true;
Dictionary<string, string> exifDictionary = new Dictionary<string, string>();
// Load the image from the FileStream
using (FileStream fs = new FileStream(sourceImagePath, FileMode.Open, FileAccess.Read)) {
// Get the file size
long fileSize = new FileInfo(sourceImagePath).Length;
// Check if the file size exceeds the maximum allowed size
if (fileSize > _MaxFileSize) {
validJPG = false;
}
else {
byte[] buffer = new byte[4];
int bytesRead = fs.Read(buffer, 0, buffer.Length);
// Check if the file has a valid JPG signature
if (!(bytesRead >= 4 && IsJpgFileSignature(buffer))) {
validJPG = false;
}
}
if (validJPG) {
// Reset the file position to the beginning
fs.Seek(0, SeekOrigin.Begin);
// Load the image from the FileStream
jpgImage = Image.FromStream(fs);
exifDictionary = LoadAllEXIFTagsFromJPGImage(fs);
ExtractJPGExifInformation(exifDictionary);
chooseEXIFTagsToolStripMenuItem.Enabled = true;
}
else {
jpgImage = global::ChooseImages.Properties.Resources.InvalidJPG;
using (Graphics g = Graphics.FromImage(jpgImage)) {
AddFileNameToImage(sourceImagePath, 64, g, jpgImage);
}
}
}
return jpgImage;
}
...
bool IsJpgFileSignature(byte[] buffer) {
// Check if the first bytes match the JPG file signature
return buffer.Length >= 2 && buffer[0] == 0xFF && buffer[1] == 0xD8;
}
下面的屏幕截图显示了“选择图像”如何显示图像。
缩放和平移图像
工具栏上有一个ComboBox
,用于设置显示图像的缩放因子。默认设置为“适合窗口”。在此缩放设置下,如果“选择图像”窗口大小发生变化,“选择图像”将调整图像大小。其他缩放设置是从 10% 到 100%。它们将图像调整为原始图像全分辨率的百分比。如果图像是通过 RAW 图像的预览加载的,那么不同相机和型号的全分辨率可能差异很大。您可以使用Zoom
下拉菜单或使用键盘组合键ctrl+1
(10%)到ctrl+0
(100%)来更改缩放。您可以使用ctrl+=
返回到默认的“适合窗口”。
“选择图像”的主要任务是帮助您选择您认为值得保留和/或使用您喜欢的图像处理程序(如 Lightroom、PhotoShop、DXO Photolab、Luminar 等)进行处理的照片。摄影师想要近距离查看的一件事是照片的主体。“选择图像”允许您双击主体,它将以全分辨率缩放到该点。这可以节省大量的滚动和/或平移。通常,您可能会拍摄一系列照片来捕捉动作。如果您在双击后导航到下一张图像,那么该图像将被缩放到图像中的同一点。当您拍摄了一系列快节奏的体育或野生动物动作镜头时,这一点非常有用。
如果图像太大以至于无法适应包含宿主picturebox
控件的panel
控件的当前尺寸,则会显示所需的滚动条。您可以使用这些滚动条滚动图像。您还可以使用鼠标滚轮垂直滚动。理论上,您可以按住 Shift 键并使用鼠标滚轮水平滚动,但这似乎不起作用。您可以在按住左键的情况下使用鼠标平移图像。您可以使用箭头键和Home
(滚动到图像顶部)、End
(滚动到图像底部)、PgUp
(向上滚动当前窗口大小的 90%)和PgDn
(向下滚动当前窗口大小的 90%)。
实现鼠标平移的代码如下。
private void picMainImage_MouseDown(object sender, MouseEventArgs e) {
if ((string)btnThumbnail.Tag == "View") {
// Check if the left mouse button is pressed for panning
if (e.Button == MouseButtons.Left) {
// Record the starting position for panning
_LastMousePos = e.Location;
_PanScrollPosition = pnlMainFrame.AutoScrollPosition;
// Start timer
_DeltaX = 0;
_DeltaY = 0;
_Timer.Start();
}
return;
}
// Irrelevant code omitted
}
private void picMainImage_MouseMove(object sender, MouseEventArgs e) {
// Irrelevant code omitted
// Check if the left mouse button is pressed for panning
if (e.Button == MouseButtons.Left) {
// Calculate the panning offset
int deltaX = mousePos.X - _LastMousePos.X;
int deltaY = mousePos.Y - _LastMousePos.Y;
// Accumulate the offset
_DeltaX += deltaX;
_DeltaY += deltaY;
// Update last mouse position
_LastMousePos = new Point(mousePos.X, mousePos.Y);
}
// Irrelevant code omitted
}
private void Timer_Tick(object sender, EventArgs e) {
// Update the scroll position
pnlMainFrame.AutoScrollPosition = new Point(-_PanScrollPosition.X - _DeltaX, -_PanScrollPosition.Y - _DeltaY);
_PanScrollPosition = pnlMainFrame.AutoScrollPosition;
}
private void picMainImage_MouseUp(object sender, MouseEventArgs e) {
if ((string)btnThumbnail.Tag == "View") {
// Check if the left mouse button is pressed for panning
if (e.Button == MouseButtons.Left) {
_Timer.Stop();
_ImageScrollPosition = pnlMainFrame.AutoScrollPosition;
}
}
}
支持滚动操作的代码如下。
private void ScrollImage(ScrollType scrollType) {
int deltaX = 0;
int deltaY = 0;
int overlapX = (int)(pnlMainFrame.Width * 0.1);
int overlapY = (int)(pnlMainFrame.Height * 0.1);
// Calculate the new AutoScrollPosition
switch (scrollType) {
case ScrollType.ScrollUp:
deltaY = pnlMainFrame.Height - overlapY;
break;
case ScrollType.ScrollDown:
deltaY = -pnlMainFrame.Height + overlapY;
break;
case ScrollType.ScrollLeft:
deltaX = pnlMainFrame.Width - overlapX;
break;
case ScrollType.ScrollRight:
deltaX = -pnlMainFrame.Width + overlapY;
break;
case ScrollType.PageUp:
deltaY = picMainImage.Height;
break;
case ScrollType.PageDown:
deltaY = -picMainImage.Height;
break;
case ScrollType.PageLeft:
deltaY = picMainImage.Width;
break;
case ScrollType.PageRight:
deltaY = -picMainImage.Width;
break;
default:
deltaX = 0;
deltaY = 0;
break;
}
// Delay setting the AutoScrollPosition until after the layout has been updated
BeginInvoke(new Action(() =>
{
// Set the new AutoScrollPosition
pnlMainFrame.AutoScrollPosition = new Point(-_ImageScrollPosition.X - deltaX, -_ImageScrollPosition.Y - deltaY);
_ImageScrollPosition = pnlMainFrame.AutoScrollPosition;
}));
}
我非常感谢 ChatGPT 建议使用计时器来在鼠标移动时更新滚动位置。这消除了一个糟糕的闪烁问题。如果平移不灵敏或过于灵敏,可以在设置中调整 Timer Tick 间隔。这取决于您的硬件。
键盘快捷键
开发人员有时会开发依赖于指针设备的应用程序,而忽略了键盘(除了作为输入设备)。然而,键盘支持可以使应用程序更易于使用并节省时间。例如,通过鼠标拖动来平移图像是很方便的。但是,如果图像很大,如果您想滚动到图像底部,就会变得很累。您可以使用滚动条,但用鼠标抓住并拖动它们可能会有点棘手。这时您就会体会到使用键盘导航键(如PgDn
和End
)的好处。“选择图像”提供了一套完整的键盘命令。您可以通过从菜单中选择Help/Keyboard Shortcuts
或在键盘上输入Alt+HK
来查看支持的快捷键。这是一个显示键盘快捷键的屏幕截图。
处理所有键组合的代码使用了ProcessCmdKey
方法。我过去在使用常规的KeyUp
、KeyDown
和KeyPress
事件来捕获所有我想要的组合键时遇到麻烦。我向 Chat GTP 寻求帮助,它建议使用ProcessCmdKey
,因为它会在窗体上的任何控件捕获击键之前拦截它们。以下是处理键组合的代码,省略了显而易见的代码。
protected override bool ProcessCmdKey(ref Message msg, Keys keyData) {
// Intercepts all key strokes and we process those we have assigned functionality to.
if ((string)btnThumbnail.Tag == _View) {
switch (keyData) {
// Vanilla key selected
case Keys.Up:
ScrollImage(ScrollType.ScrollUp);
return true; // Indicate that the key has been handled
case Keys.Down:
ScrollImage(ScrollType.ScrollDown);
return true;
// Obvious code omitted
// Ctrl + Key selected
case Keys.Control | Keys.Left:
MoveTo(FileListIndex.Previous);
return true;
case Keys.Control | Keys.Right:
MoveTo(FileListIndex.Next);
return true;
case Keys.Control | Keys.Home:
MoveTo(FileListIndex.First);
return true;
case Keys.Control | Keys.End:
MoveTo(FileListIndex.Last);
return true;
// Zoom control
case Keys.Control | Keys.Oemplus:
// case Keys.Control | Keys.NumPad0:
ProcessZoom(0);
return true;
case Keys.Control | Keys.D1:
case Keys.Control | Keys.NumPad1:
ProcessZoom(1);
return true;
// Obvious code omitted
case Keys.Control | Keys.D9:
case Keys.Control | Keys.NumPad9:
ProcessZoom(9);
return true;
case Keys.Control | Keys.D0:
case Keys.Control | Keys.NumPad0:
ProcessZoom(10);
return true;
}
}
else if ((string)btnThumbnail.Tag == _Thumbnail) {
switch (keyData) {
// Vanilla key selected
case Keys.Home:
ScrollImage(ScrollType.PageUp);
return true;
case Keys.End:
ScrollImage(ScrollType.PageDown);
return true;
case Keys.PageUp:
ScrollImage(ScrollType.ScrollUp);
return true;
case Keys.PageDown:
ScrollImage(ScrollType.ScrollDown);
return true;
}
}
// If not handled, pass the key to the base class
return base.ProcessCmdKey(ref msg, keyData);
}
显示缩略图
“选择图像”从Thumbnails
字典中存储的每个单独的缩略图图像创建一个复合图像。这是创建该图像的方法代码。
private Image CreateCompositeImage() {
// Calculate the number of rows required
int numRows = Math.Max(1, (int)Math.Ceiling((double)_Thumbnails.Count / _ThumbnailsPerRow));
// Create a blank composite image
Bitmap compositeImage = new Bitmap(_ThumbnailsPerRow * (_ThumbnailSize.Width + _BorderWidth * 2),
numRows * (_ThumbnailSize.Height + _BorderWidth * 2));
using (Graphics g = Graphics.FromImage(compositeImage)) {
foreach (KeyValuePair<string, ThumbnailInfo> kvp in _Thumbnails) {
string fileName = kvp.Key;
ThumbnailInfo thumbnailInfo = kvp.Value;
Size thumbPos = ThumbPos(thumbnailInfo.Index);
using (Image resizedThumbnail = new Bitmap(thumbnailInfo.Thumbnail, _ThumbnailSize)) {
g.DrawImage(resizedThumbnail, thumbPos.Width, thumbPos.Height);
}
// Calculate the position for the current thumbnail inside the composite image
Point point = GetThumbNailPosition(thumbnailInfo.Index);
int x = point.X;
int y = point.Y;
if (thumbnailInfo.Flag == ImageFlag.Checked) {
DrawCheckmark(g, x, y);
}
else if (thumbnailInfo.Flag == ImageFlag.Deleted) {
DrawCross(g, x, y);
}
}
}
return compositeImage;
}
private Point GetThumbNailPosition(int fileIndex) {
Point point = new Point();
// Find the position of the clicked thumbnail inside the composite image
int row = fileIndex / _ThumbnailsPerRow;
int col = fileIndex % _ThumbnailsPerRow;
// Calculate the position for the current thumbnail inside the composite image
point.X = col * (_ThumbnailSize.Width + _BorderWidth + 1);
point.Y = row * (_ThumbnailSize.Height + _BorderWidth + 1);
return point;
}
GetThumbNailPosition
方法也用于确定缩略图在复合图像中的X
和Y
坐标。在这种情况下,它用于在缩略图的角落绘制一个复选标记或一个叉号,以反映其状态。请注意,代码不会从磁盘读取图像文件。如果您决定更改缩略图大小,则使用CreateCompositeImage
方法几乎可以立即重新创建缩略图。这是缩略图在最大缩略图分辨率和最小分辨率下的样子。
选择图像
如果您单击缩略图,则会从介质中读取相应的图像并在当前的缩放设置下显示。如果您 Ctrl+单击缩略图,则该缩略图将被选中,并在右下角绘制一个黄色复选标记。如果您 Shift+单击缩略图,则选择范围将扩展到最近的已选缩略图,并且选定范围外的缩略图将被取消选择。以下一系列屏幕截图说明了选择过程。
Ctrl+单击了第二行中的前三个缩略图和第三行中的后两个缩略图。
Shift+单击了第三行中的第一个缩略图。
当鼠标悬停在缩略图上时,文件名会作为工具提示显示。这是实现此功能的函数。
private void picMainImage_MouseMove(object sender, MouseEventArgs e) {
if (_FileList == null) {
return;
}
Point mousePos = picMainImage.PointToClient(MousePosition);
if ((string)btnThumbnail.Tag == "Thumbnail") {
// Handle the MouseMove event to display a tooltip
int clickedIndex = MouseEventFileIndex(mousePos);
if (_FileList.Count > 0 && clickedIndex < _FileList.Count && clickedIndex >= 0) {
toolTip.SetToolTip(picMainImage, Path.GetFileName(_FileList[clickedIndex]));
}
}
...
在测试大量图像时,我发现缩略图图像末尾的缩略图没有被识别。事实证明,通过MouseEventArgs e
访问的 X 和 Y 位置被声明为int
(32 位),但被限制为<short>短值(16 位)。我担心如果垂直尺寸超过 32,767 像素,我将不得不分页缩略图。我向 ChatGPT 询问了这个问题。这是对话。
我
MouseEventArgs e.X 和 e.Y 被定义为 int,但行为像 16 位整数。当 e.y 超过 32767 时,它会变成负值,例如 -32749。有没有解决办法?
ChatGPT
是的,您说得对。MouseEventArgs
类中的 e.X 和 e.Y 属性确实是 int 类型。但是,它们受底层 Win32 API 的限制,该 API 使用 16 位有符号整数进行鼠标坐标。为了正确处理大的坐标,您可以使用控件的PointToClient
方法将屏幕坐标转换为客户端坐标。方法如下:
private void YourControl_MouseMove(object sender, MouseEventArgs e)
{
// Convert screen coordinates to client coordinates
Point clientPoint = YourControl.PointToClient(MousePosition);
// Now you can use clientPoint.X and clientPoint.Y which are of type int
}
通过使用PointToClient
,您可以确保您的鼠标坐标得到正确解释,无论屏幕分辨率或您的控件在窗体中的位置如何。
我
MousePosition
在哪里定义/声明的?
ChatGPT
MousePosition
是 C# 中Control
类的一个静态属性。它表示鼠标指针在屏幕坐标中的当前位置。您可以在任何能够访问Control
类的地方访问它,例如在窗体或控件中。访问MousePosition
的方法如下:
Point mousePosition = Control.MousePosition;
这会将当前鼠标位置检索为一个Point
对象,其 X 和 Y 坐标表示屏幕上鼠标指针的位置。
显示 EXIF 信息
摄影师总是对照片的曝光三角感兴趣。这些信息包含在嵌入图像的 EXIF(可交换图像文件格式)标签和值中。EXIF 标签中还有许多其他有用的信息,例如相机品牌和型号、镜头规格等。“选择图像”允许您配置要查看的 EXIF 标签以及您喜欢的顺序。不同的相机制造商在其生产的图像中提供不同的 EXIF 标签集。如果您使用不同的相机拍摄,则需要一种方法来选择品牌。在“选择图像”中,您通过在Settings
菜单中指定 RAW 扩展名来实现这一点。然后,当您选择要查看的 EXIF 标签时,您可以将其与特定的 RAW 扩展名关联。如果您切换到不同的相机品牌,您可以更改 RAW 扩展名并为该扩展名创建一组新的 EXIF 标签。某些软件会从 JPG 图像中删除 EXIF 标签,因此这些信息并不总是可用。但是,直接从相机介质拍摄的照片始终包含 EXIF 信息。“选择图像”使用MetadataExtractor 库来提取 EXIF 标签和值。这是实现此目的的代码。
private Dictionary<string, string> LoadAllEXIFTagsFromJPGImage(FileStream fs) {
Image jpgImage = null;
Dictionary<string, string> d = new Dictionary<string, string>();
// Reset the file position to the beginning
fs.Seek(0, SeekOrigin.Begin);
// Load the image from the FileStream
jpgImage = Image.FromStream(fs);
using (MemoryStream stream = new MemoryStream()) {
jpgImage.Save(stream, System.Drawing.Imaging.ImageFormat.Jpeg);
// Rewind the stream to the beginning
stream.Seek(0, SeekOrigin.Begin);
// Extract EXIF information from the MemoryStream
var directories = ImageMetadataReader.ReadMetadata(stream);
foreach (var directory in directories) {
foreach (var tag in directory.Tags) {
if (d.ContainsKey(tag.Name)) {
continue;
}
d.Add(tag.Name, tag.Description);
}
}
}
return d;
}
请注意,当忽略目录时,某些 EXIF 标签是重复的,因此“选择图像”仅使用找到的第一个。设置菜单包括选项Choose EXIF Tags to display for this RAW extension。选择此项后,将显示以下对话框。它允许用户选择要显示的 EXIF 选项。所选标签可以使用右侧的列表框重新排列到您喜欢的顺序。您可以使用鼠标突出显示一个标签并将其拖动到相应位置,或使用键盘上的上下箭头键突出显示一个标签,然后按住 Ctrl 键使用相同的键将选定的项目移到相应位置。
管理 EXIF 标签顺序的代码基于 ChatGPT 生成的代码,该代码允许用户重新排列ListBox
(名为lstOrdered
)中显示的 EXIF 标签。我的贡献是要求用户在移动项目时按住 Ctrl 键,否则您必须使用鼠标移动到您想要移动的标签。
private void lstOrdered_MouseDown(object sender, MouseEventArgs e) {
_dragIndex = lstOrdered.IndexFromPoint(e.X, e.Y);
if (_dragIndex != ListBox.NoMatches) {
_dragging = true;
}
}
private void lstOrdered_MouseMove(object sender, MouseEventArgs e) {
if (_dragging && e.Button == MouseButtons.Left) {
lstOrdered.DoDragDrop(lstOrdered.Items[_dragIndex], DragDropEffects.Move);
}
}
private void lstOrdered_DragOver(object sender, DragEventArgs e) {
Point point = lstOrdered.PointToClient(new Point(e.X, e.Y));
int hoverIndex = lstOrdered.IndexFromPoint(point);
if (hoverIndex != ListBox.NoMatches && hoverIndex != _dragIndex) {
lstOrdered.SelectedIndex = hoverIndex;
}
e.Effect = DragDropEffects.Move;
}
private void lstOrdered_DragDrop(object sender, DragEventArgs e) {
Point point = lstOrdered.PointToClient(new Point(e.X, e.Y));
_dropIndex = lstOrdered.IndexFromPoint(point);
if (_dropIndex != ListBox.NoMatches && _dropIndex != _dragIndex) {
object dragItem = lstOrdered.Items[_dragIndex];
lstOrdered.Items.RemoveAt(_dragIndex);
lstOrdered.Items.Insert(_dropIndex, dragItem);
}
_dragging = false;
lstOrdered.SelectedIndex = _dropIndex;
}
private void lstOrdered_KeyDown(object sender, KeyEventArgs e) {
int index = lstOrdered.SelectedIndex;
if (index < 0)
return;
// Check if Ctrl key is pressed
bool ctrlPressed = (Control.ModifierKeys & Keys.Control) == Keys.Control;
if (e.KeyCode == Keys.Up && index > 0) {
if (ctrlPressed) {
MoveItem(-1);
e.Handled = true;
}
}
else if (e.KeyCode == Keys.Down && index < lstOrdered.Items.Count - 1) {
if (ctrlPressed) {
MoveItem(1);
e.Handled = true;
}
}
else if (e.KeyCode == Keys.Enter) {
ConfirmMove();
e.Handled = true;
}
}
private void MoveItem(int direction) {
// Calculate new index using move direction
int newIndex = lstOrdered.SelectedIndex + direction;
// Ensure new index is within bounds
if (newIndex < 0 || newIndex >= lstOrdered.Items.Count)
return;
// Swap items
object selected = lstOrdered.SelectedItem;
lstOrdered.Items.Remove(selected);
lstOrdered.Items.Insert(newIndex, selected);
// Select the moved item
lstOrdered.SetSelected(newIndex, true);
}
private void ConfirmMove() {
_dragIndex = lstOrdered.SelectedIndex;
}
您可以通过选择工具栏上的“信息”按钮来查看图像的 EXIF 数据。您可以再次单击该按钮或单击显示底部“关闭”来关闭它。如果您不关闭 EXIF 显示,您仍然可以使用工具栏箭头键导航到其他图像,并且 EXIF 信息会随着您的操作而更新。
如果您设置了 RAW 扩展名,那么“选择图像”将使用您与该 RAW 扩展名关联的 EXIF 标签。由于 JPG 预览文件中的 EXIF 标签是从原始 RAW 图像复制过来的,因此您可以确保您选择的 EXIF 标签是合适的。
打开目标文件夹
如果您拍摄并处理 RAW 文件,那么您需要将 RAW 图像文件从相机介质复制到 PC 上用于暂存 RAW 照片的文件夹。如果您只拍摄 JPG,那么您需要将 JPG 文件复制到 PC 上用于 JPG 图像的文件夹。文件菜单中有打开其中一个的选项。如果您同时打开两者,但源文件夹包含 RAW 文件,则只会复制 RAW 文件。
调整图像文件大小
当“选择图像”从全尺寸 JPG 图像文件(源文件是 JPG 图像)或中等到大型 JPG 文件(源文件是 ExifTool 从 RAW 文件提取的预览 JPG 文件)创建缩略图时,需要调整图像大小。当用户更改缩放比例或当用户调整“选择图像”窗口大小时(缩放设置为适合窗口),也需要调整图像大小。幸运的是,从质量的角度来看,这些调整大小的操作从不需要将图像放大到原始尺寸。缩略图是通过将大型 JPG 图像缩小到小得多的图像来创建的。用于此目的的单行代码位于上面“显示缩略图”下的创建复合图像的方法中。在缩放或表单调整大小时使用以下方法。
private void UpdateZoomedImage() {
if (pnlMainFrame.Width <= 0) {
return;
}
Size size;
switch (_ZoomFactor) {
case 0.0f:
int width = Math.Min(pnlMainFrame.Width - 4, _OriginalImage.Photo.Width);
int height = (int)((float)width / _ImageAspectRatio);
if (height > pnlMainFrame.Height) {
height = pnlMainFrame.Height;
width = (int)((float)height * _ImageAspectRatio);
}
size = new Size(width - 5, height - 5);
break;
default:
size = new Size((int)(_OriginalImage.Photo.Width * _ZoomFactor), (int)(_OriginalImage.Photo.Height * _ZoomFactor));
break;
}
if (size.Width < 80) {
return;
}
picMainImage.Size = new Size(pnlMainFrame.Width - 5, pnlMainFrame.Height - 5);
Bitmap zoomedBitmap = null;
try {
// Create a new bitmap to draw the zoomed image
zoomedBitmap = new Bitmap((int)(size.Width), (int)(size.Height));
// Create a graphics object from the bitmap
using (Graphics g = Graphics.FromImage(zoomedBitmap)) {
// Set the interpolation mode to high quality
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
g.CompositingMode = CompositingMode.SourceCopy;
g.CompositingQuality = CompositingQuality.HighQuality;
g.SmoothingMode = SmoothingMode.HighQuality;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
// Draw the image onto the bitmap
g.DrawImage(_OriginalImage, new Rectangle(0, 0, zoomedBitmap.Width, zoomedBitmap.Height));
}
// Set the zoomed bitmap as the image of the PictureBox
_DisplayedImage = zoomedBitmap;
picMainImage.Image = _DisplayedImage;
// Irrelevant code omitted
}
catch (Exception ex) {
MessageBox.Show(_Messages[2] + ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Exclamation);
zoomedBitmap.Dispose();
return;
}
RotateImage(_Rotation);
}
private void RotateImage(RotateFlipType rotation) {
picMainImage.Image = RotateBitMap((Bitmap)picMainImage.Image, rotation);
}
当放大大型图像文件时,我使用了比缩小图像文件到缩略图尺寸更复杂的调整大小方法。
日志记录
“选择图像”可以将信息记录到日志文件中。它在您每次启动程序时创建一个新的日志文件。它记录的信息包括:
- 重复文件名 {DSC9999.XXX} 已跳过。目标文件夹中不能有重复名称(错误)
- 显示图像读取 {DSC9999.XXX} ({99} Mbytes)
- 显示图像花费 {999} 毫秒
- 获取 EXIF 标签花费 {999} 毫秒
- ExifTool - 从 {FolderName} 中的 RAW 文件提取预览图像花费 {9.99} 秒
- 从 RAW 文件中提取了 {999} 个 JPG 预览文件
- 加载了 {999} 个 {RAW} 缩略图。
- 从媒体读取 {999} MB
- 内存使用量 (GB) {99.99)
- 复制图像失败,文件为 {PathName} (错误)
- {999} {extension} 缩略图加载中止
- 将新的 RAW 文件从 {SourceFolderName} 复制到临时文件夹花费 {9.99} 秒
花括号中的项目将在日志文件中替换为实际值。日志记录由“Logging”菜单项控制,该菜单项允许您启用或禁用日志记录、查看日志文件和删除日志文件。查看仅仅是在记事本或您为txt
扩展名配置的任何文本编辑器中显示日志文件。
使用 ChatGPT 作为编码助手
作为一名退休的软件开发者,我没有全职工作,也无法及时了解最新的技术。但我确实将编码作为一种爱好,并且在我需要了解如何做某事时会使用搜索引擎。是的,您可以获得所需的答案,但需要时间来筛选最合适的解决方案并根据您的需求进行调整。
我开始问 ChatGPT 关于如何完成特定任务的问题。我的第一个问题之一是关于在 Picture Box 中平移图像。ChatGPT 创建了一个可用的 C# 示例,作为初始模型。我经历了多次迭代,试图完美地实现平移。通过使用我的变量和对象名称来表达我的问题,我能够让 ChatGPT 创建我立即可以使用的代码。
有时,我可以找到一些困扰我数小时的问题的答案。有几个例子很突出。我就是无法让 DataGrid 按我想要的方式显示数据。ChatGPT 的答案也不奏效。我向 ChatGPT 发送了这个问题,包括代码。
我的问题出在这里
// Styling the header columns
DataGridViewCellStyle headerStyle = new DataGridViewCellStyle();
headerStyle.BackColor = Color.DarkBlue;
headerStyle.ForeColor = Color.White;
headerStyle.Font = new Font("Tahoma", 13, FontStyle.Bold, GraphicsUnit.Pixel);
dataGridView1.ColumnHeadersDefaultCellStyle = headerStyle;
唯一有效的样式是字体。颜色被忽略。为什么?
ChatGPT 给出了答案。
很抱歉造成困惑。这个问题可能与应用于 DataGridView 的视觉样式有关,默认的视觉样式可能会覆盖一些指定的样式。要强制应用指定的样式,您可以尝试将 DataGridView 的 EnableHeadersVisualStyles 属性设置为 false。这可以确保 DataGridView 不会使用操作系统对标题单元格的视觉样式。
这是 ChatGPT 为我找到解决方案的另一个例子。我在使用鼠标平移操作结束时遇到了一个闪烁问题。ChatGPT 建议使用计时器更新平移图像,而不是对每一次鼠标抖动做出反应。当我决定在 Composite Image 加载到 Picture Box 中时显示缩略图时,我也遇到了另一个闪烁问题。ChatGPT 告诉我如何使用双缓冲。
ChatGPT 的回应
为您的 Panel 和 PictureBox 控件启用双缓冲以减少闪烁。
public ChooseImages()
{
InitializeComponent();
// Enable double buffering for the panel and picture box
pnlMainFrame.DoubleBuffered(true);
picMainImage.DoubleBuffered(true);
}
public static class ControlExtensions
{
public static void DoubleBuffered(this Control control, bool enable)
{
var doubleBufferPropertyInfo = control.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
doubleBufferPropertyInfo.SetValue(control, enable, null);
}
}
ChatGPT 并不总是能正确或完整地回答您的问题,但您可以与它进行对话并朝着解决方案努力。例如,经过多次交流,我才弄清楚为什么我的 DataGrid 标题设置没有被接受。我将 ChatGPT 视为一个智能的编码助手,它非常了解 C#。
结论
“选择图像”被设计成摄影师工作流程的前端。其主要任务是方便地将您想要处理的图像从相机介质传输过来,并跳过那些不必要或不可接受的图像。当您需要处理大量照片时,它非常有用。这些照片可能来自体育赛事、观鸟活动、野生动物探险、假期或其他您拍摄大量照片的情况。它取代了早期功能和灵活性都差的版本。
如果您想编写处理 RAW 文件的程序,那么 ExifTool 是无价的。它允许“选择图像”从您可能遇到的几乎任何 RAW 文件中提取预览图像和 EXIF 信息。此外,它还可以帮助您比处理相机默认提供的 JPG 文件更快地处理照片。这是因为大多数制造商会在其 RAW 图像中嵌入不同尺寸的多个预览 JPG。读取和显示 1616px x 1080px 的 JPG 图像比使用 8000px x 6000px 的图像要快得多。
开发“选择图像”让我学会了如何使用 C# 操作图像文件。显示用户可以缩放和平移的图像的需求帮助我掌握了使用标准 .Net 控件的技术,而无需深入研究底层图像处理。这些技术可以被需要处理图像和图像文件的程序员采用。简化平移和滚动技巧是将PictureBox
放置在panel
内,并将其Autoscroll
属性设置为true
。然后,您可以根据需要通过设置Autoscroll
属性来操作图像,同时记住在重置Autoscroll
位置时对坐标取负。
开发过程暴露了一些 Windows 的底层弱点。鼠标坐标限制为 16 位整数的事实令人惊讶。用于照片编辑的显示器已经提供高达 6144x3456px 的分辨率。我相机拍摄的图像尺寸为 9568x6376px。因此,可以预见图像的某个维度会超过 32,767 像素。当我创建了一个高度超过 32,767 像素的缩略图复合图像时,我遇到了这个问题。ChatGPT 向我展示了解决方法,但它仅仅是一个解决方法。我也发现很容易耗尽内存。这不是由于内存泄漏;只是 Windows 在同时处理大量大型图像时遇到了麻烦。是转向生产 60MB RAW 图像的相机暴露了这些问题。人们可能会认为 64 位架构和 64GB RAM 不会耗尽内存,但事实并非如此。
我发现 ChatGPT 是一个非常有价值的编码助手。通过询问 ChatGPT 而不是使用搜索引擎来查找完成任务的方法要容易得多。ChatGPT 接受了海量代码的训练,并能以惊人的速度产生解决方案。它并不总是正确的,并且可能会被迷惑。它显然对 ExifTool 的了解有限,我放弃了使用 ChatGPT 来构建 ExifTool 包装器的尝试。我从与 ChatGPT 的合作中获得了大量的帮助和有用的代码。
源代码是免费提供的。如果您发现错误或进行了您愿意分享的增强功能,请告知我。