XPrintServer/XPrint.Image/ColorTrace.cs
2025-11-16 19:33:01 +08:00

829 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml.Linq;
namespace XPrint.Image
{
/// <summary>
/// 使用potrace将位图转换为彩色SVG的工具类透明背景版本
/// </summary>
public class ColorPotraceConverter
{
#region
private string _pngquantPath = "pngquant";
private string _pngnqPath = "pngnq";
private string _convertPath = "magick convert";
private string _identifyPath = "magick identify";
private string _potracePath = "potrace";
private string _potraceOptions = "";
#endregion
#region
public int MaxCommandLength { get; set; } = 1900;
public int VerboseLevel { get; set; } = 0;
public int Despeckle { get; set; } = 2;
public float SmoothCorners { get; set; } = 1.0f;
public float OptimizePaths { get; set; } = 0.2f;
public bool Stack { get; set; } = false;
public float Prescale { get; set; } = 1.0f;
public bool Background { get; set; } = false; // 不再用于设置实体背景
public string Width { get; set; } = null;
public string Height { get; set; } = null;
public string Dither { get; set; } = null;
public int? Colors { get; set; } = null;
public string RemapPalette { get; set; } = null;
public string QuantizationAlgorithm { get; set; } = "mc";
#endregion
/// <summary>
/// 初始化彩色SVG转换器
/// </summary>
public ColorPotraceConverter() { }
/// <summary>
/// 设置外部程序路径
/// </summary>
public void SetExternalProgramPaths(string potracePath, string convertPath = null,
string identifyPath = null, string pngquantPath = null, string pngnqPath = null)
{
if (!string.IsNullOrEmpty(potracePath))
_potracePath = potracePath;
if (!string.IsNullOrEmpty(convertPath))
_convertPath = convertPath;
if (!string.IsNullOrEmpty(identifyPath))
_identifyPath = identifyPath;
if (!string.IsNullOrEmpty(pngquantPath))
_pngquantPath = pngquantPath;
if (!string.IsNullOrEmpty(pngnqPath))
_pngnqPath = pngnqPath;
}
/// <summary>
/// 转换多个图像文件为彩色SVG透明背景
/// </summary>
public void ConvertFiles(List<string> inputFiles, string outputPattern, int? coreCount = null)
{
// 解析输入输出文件对
var filePairs = GetInputOutputPairs(inputFiles, outputPattern);
if (!filePairs.Any())
{
Report("没有找到有效的输入文件");
return;
}
// 确定进程数
int processCount = coreCount ?? Environment.ProcessorCount;
processCount = Math.Max(1, processCount);
Report($"开始处理 {filePairs.Count} 个文件,使用 {processCount} 个进程");
// 创建临时目录
string tempDir = Path.Combine(Path.GetTempPath(), "ColorPotrace_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
// 并行处理文件
Parallel.ForEach(filePairs, new ParallelOptions { MaxDegreeOfParallelism = processCount },
pair => ConvertSingleFile(pair.Input, pair.Output, tempDir));
}
finally
{
// 清理临时文件
if (Directory.Exists(tempDir))
{
try
{
Directory.Delete(tempDir, true);
}
catch (Exception ex)
{
Report($"清理临时文件时出错: {ex.Message}", 1);
}
}
}
Report("所有文件处理完成");
}
/// <summary>
/// 转换单个文件为彩色SVG透明背景
/// </summary>
private void ConvertSingleFile(string inputPath, string outputPath, string tempDir)
{
try
{
Report($"处理文件: {inputPath} -> {outputPath}", 1);
// 确保输出目录存在
string outputDir = Path.GetDirectoryName(outputPath);
if (!Directory.Exists(outputDir))
Directory.CreateDirectory(outputDir);
// 生成临时文件名
string baseName = Path.GetFileNameWithoutExtension(inputPath);
string scaledFile = Path.Combine(tempDir, $"{baseName}~scaled.png");
string reducedFile = Path.Combine(tempDir, $"{baseName}~reduced.png");
// 步骤1: 重缩放图像 - 保留透明度
string filter = (Colors == 0) ? "point" : "lanczos";
RescaleImageWithTransparency(inputPath, scaledFile, Prescale, filter);
// 步骤2: 缩减颜色或重映射颜色 - 保留透明度
if (Colors.HasValue && Colors.Value > 0)
{
QuantizeImageWithTransparency(scaledFile, reducedFile, Colors.Value, QuantizationAlgorithm, Dither);
}
else if (!string.IsNullOrEmpty(RemapPalette))
{
RemapImageColorsWithTransparency(scaledFile, reducedFile, RemapPalette, Dither);
}
else
{
File.Copy(scaledFile, reducedFile, true);
}
// 步骤3: 提取颜色表(排除透明色)
List<string> colorTable = GetColorTableExcludingTransparent(reducedFile);
if (colorTable.Count == 0)
{
Report("无法从图像中提取颜色表(可能全是透明)", 1);
return;
}
Report($"发现 {colorTable.Count} 种颜色(已排除透明色)", 1);
// 步骤4: 为每种颜色创建孤立图层并描摹
List<string> traceFiles = new List<string>();
for (int i = 0; i < colorTable.Count; i++)
{
string color = colorTable[i];
string isolatedFile = Path.Combine(tempDir, $"{baseName}~isolated_{i}.png");
string traceFile = Path.Combine(tempDir, $"{baseName}~trace_{i}.svg");
// 孤立颜色 - 保留透明区域
IsolateColorWithTransparency(reducedFile, isolatedFile, color, colorTable, Stack, tempDir);
// 描摹 - 确保透明区域不被填充
TraceImage(isolatedFile, traceFile, color);
traceFiles.Add(traceFile);
}
// 步骤5: 合并所有SVG图层确保背景透明
MergeSvgLayersWithTransparentBackground(traceFiles, outputPath);
// 清理临时文件
foreach (var file in traceFiles)
DeleteFile(file);
DeleteFile(scaledFile);
DeleteFile(reducedFile);
DeleteFile(Path.Combine(tempDir, $"{baseName}~temp.png"));
}
catch (Exception ex)
{
Report($"处理文件时出错: {ex.Message}", 1);
throw;
}
}
#region
/// <summary>
/// 重缩放图像并保留透明度
/// </summary>
private void RescaleImageWithTransparency(string source, string destination, float scale, string filter)
{
if (scale == 1.0f)
{
// 不缩放直接复制或转换为PNG并保留透明度
if (Path.GetExtension(source).Equals(".png", StringComparison.OrdinalIgnoreCase))
{
File.Copy(source, destination, true);
}
else
{
string command = $"{_convertPath} \"{source}\" -background none -alpha on \"{destination}\"";
RunCommand(command);
}
}
else
{
int scalePercent = (int)(scale * 100);
string command = $"{_convertPath} \"{source}\" -filter {filter} -resize {scalePercent}% " +
$"-background none -alpha on \"{destination}\"";
RunCommand(command);
}
}
/// <summary>
/// 量化缩减图像颜色并强制保留透明度
/// </summary>
/// <param name="source">源图像路径</param>
/// <param name="destination">目标图像路径</param>
/// <param name="colorCount">目标颜色数量</param>
/// <param name="algorithm">量化算法</param>
/// <param name="dither">拟色算法</param>
private void QuantizeImageWithTransparency(
string source,
string destination,
int colorCount,
string algorithm,
string dither)
{
// 验证源文件是否包含alpha通道
if (!HasAlphaChannel(source))
{
Report($"警告: 源文件 {Path.GetFileName(source)} 不包含透明度信息", 1);
}
// 无需量化,直接复制文件并确保保留透明度
if (colorCount <= 1)
{
PreserveTransparencyCopy(source, destination);
return;
}
// 根据选择的算法执行不同的量化处理
switch (algorithm.ToLower())
{
// 中切算法(增强透明度保护)
case "mc":
ProcessMedianCutAlgorithm(source, destination, colorCount, dither);
break;
// 自适应空间细分算法
case "as":
ProcessAdaptiveSubdivisionAlgorithm(source, destination, colorCount, dither);
break;
// 神经量化算法
case "nq":
ProcessNeuralQuantizationAlgorithm(source, destination, colorCount, dither);
break;
// 未知算法处理
default:
throw new ArgumentException($"未知的量化算法: {algorithm}");
}
// 验证处理后的文件是否保留了透明度
if (!HasAlphaChannel(destination))
{
Report($"警告: 处理后文件 {Path.GetFileName(destination)} 丢失了透明度信息", 1);
// 尝试恢复透明度
RestoreTransparency(source, destination);
}
}
/// <summary>
/// 检查图像是否包含alpha通道
/// </summary>
private bool HasAlphaChannel(string imagePath)
{
try
{
string command = $"{_identifyPath} -format \"%[channels]\" \"{imagePath}\"";
string output = RunCommand(command, true).ToLower();
return output.Contains("a") || output.Contains("alpha");
}
catch
{
// 命令执行失败时假设包含alpha通道
return true;
}
}
/// <summary>
/// 复制文件并确保保留透明度
/// </summary>
private void PreserveTransparencyCopy(string source, string destination)
{
// 使用convert命令而非直接复制确保透明度保留
string command = $"{_convertPath} \"{source}\" " +
"-alpha on " +
"-background none " +
"-define png:color-type=6 " + // 明确指定带alpha通道的PNG格式
"\"{destination}\"";
RunCommand(command);
}
/// <summary>
/// 尝试恢复丢失的透明度
/// </summary>
private void RestoreTransparency(string originalImage, string processedImage)
{
string tempFile = Path.ChangeExtension(processedImage, ".tmp.png");
// 使用原始图像的alpha通道修复处理后的图像
string command = $"{_convertPath} \"{processedImage}\" " +
"-alpha off " +
"( \"{originalImage}\" -alpha extract ) " +
"-alpha off -compose CopyOpacity -composite " +
"-define png:color-type=6 " +
"\"{tempFile}\"";
try
{
RunCommand(command);
if (File.Exists(tempFile))
{
File.Delete(processedImage);
File.Move(tempFile, processedImage);
Report("已成功恢复透明度信息", 1);
}
}
catch (Exception ex)
{
Report($"恢复透明度失败: {ex.Message}", 1);
if (File.Exists(tempFile))
File.Delete(tempFile);
}
}
/// <summary>
/// 使用中切算法处理图像量化(增强透明度保护)
/// </summary>
private void ProcessMedianCutAlgorithm(string source, string destination, int colorCount, string dither)
{
// 构建拟色选项
string ditherOption = dither == "floydsteinberg" ? "" : "--nofs";
// 构建pngquant命令明确保留alpha通道
string command = $"{_pngquantPath} " +
$"--force " +
$"{ditherOption} " +
$"{colorCount} " +
$"--skip-if-larger " +
$"--ext .png " +
$"--quality 0-100 " + // 确保质量设置不会影响透明度
$"-- " +
$"\"{source}\"";
// 执行量化命令
RunCommand(command);
// 处理pngquant的输出文件默认会在原文件名后添加-fs8后缀
string tempOutput = Path.ChangeExtension(source, $"-fs8.png");
File.Copy(source, tempOutput);
// 确保目标目录存在
string destinationDir = Path.GetDirectoryName(destination);
if (!Directory.Exists(destinationDir))
{
Directory.CreateDirectory(destinationDir);
}
// 移动处理后的文件到目标位置
if (File.Exists(tempOutput))
{
// 使用convert命令转移文件以确保透明度保留
string convertCommand = $"{_convertPath} \"{tempOutput}\" " +
"-alpha on " +
"-background none " +
"-define png:color-type=6 " +
$"\"{destination}\"";
RunCommand(convertCommand);
// 删除临时文件
File.Delete(tempOutput);
}
else
{
// 量化失败时使用带透明度保护的复制
PreserveTransparencyCopy(source, destination);
}
}
/// <summary>
/// 使用自适应空间细分算法处理图像量化(增强透明度保护)
/// </summary>
private void ProcessAdaptiveSubdivisionAlgorithm(string source, string destination, int colorCount, string dither)
{
string ditherParam = dither ?? "None";
string command = $"{_convertPath} \"{source}\" " +
$"-dither {ditherParam} " +
$"-colors {colorCount} " +
$"-alpha on " + // 明确保留alpha通道
$"-background none " + // 背景设为透明
$"+repage " + // 移除虚拟画布信息
$"-define png:color-type=6 " + // 强制使用带alpha的PNG格式
$"\"{destination}\"";
RunCommand(command);
}
/// <summary>
/// 使用神经量化算法处理图像量化(增强透明度保护)
/// </summary>
private void ProcessNeuralQuantizationAlgorithm(string source, string destination, int colorCount, string dither)
{
string ditherOption = dither == "floydsteinberg" ? "-Q f " : "";
string tempOutputNq = Path.Combine(
Path.GetDirectoryName(source),
$"{Path.GetFileNameWithoutExtension(source)}~quant.png"
);
string command = $"{_pngnqPath} " +
$"-f " +
$"{ditherOption}" +
$"-d \"{Path.GetDirectoryName(source)}\" " +
$"-n {colorCount} " +
$"-e ~quant.png " +
$"\"{source}\"";
RunCommand(command);
// 移动生成的文件到目标位置使用convert确保透明度
if (File.Exists(tempOutputNq))
{
string convertCommand = $"{_convertPath} \"{tempOutputNq}\" " +
"-alpha on " +
"-background none " +
"-define png:color-type=6 " +
"\"{destination}\"";
RunCommand(convertCommand);
File.Delete(tempOutputNq);
}
else
{
// 量化失败时使用带透明度保护的复制
PreserveTransparencyCopy(source, destination);
throw new Exception("pngnq 未能生成输出文件");
}
}
/// <summary>
/// 使用调色板重映射图像颜色并保留透明度
/// </summary>
private void RemapImageColorsWithTransparency(string source, string destination, string paletteImage, string dither)
{
if (!File.Exists(paletteImage))
throw new FileNotFoundException("未找到调色板图像", paletteImage);
string ditherParam = dither ?? "None";
string command = $"{_convertPath} \"{source}\" -dither {ditherParam} -remap \"{paletteImage}\" " +
$"-background none -alpha on \"{destination}\"";
RunCommand(command);
}
/// <summary>
/// 从图像中提取颜色表(排除透明色)
/// </summary>
private List<string> GetColorTableExcludingTransparent(string imagePath)
{
string command = $"{_convertPath} \"{imagePath}\" -unique-colors txt:-";
string output = RunCommand(command, true, false);
// 提取十六进制颜色并排除透明色
MatchCollection matches = Regex.Matches(output, "#[0-9A-Fa-f]{6}");
var colors = matches.Cast<Match>()
.Select(m => m.Value.ToUpper())
.Distinct()
.ToList();
// 排除可能的透明色表示(白色在某些情况下可能代表透明)
// 更精确的做法是检查alpha通道但这里简化处理
colors.Remove("#FFFFFF"); // 移除白色(如果它代表透明背景)
return colors;
}
/// <summary>
/// 孤立指定颜色并保留透明区域
/// </summary>
private void IsolateColorWithTransparency(string source, string destination, string targetColor,
List<string> palette, bool stack, string tempDir)
{
int colorIndex = palette.IndexOf(targetColor);
if (colorIndex == -1)
throw new ArgumentException("目标颜色不在调色板中");
// 获取不在调色板中的颜色作为临时背景和前景
string bgTransparent = "#00000000"; // 透明色
string fgBlack = "#000000";
string bgAlmostWhite = GetNonPaletteColor(palette, false, new[] { bgTransparent, fgBlack });
string fgAlmostBlack = GetNonPaletteColor(palette, true, new[] { bgAlmostWhite, bgTransparent, fgBlack });
// 创建临时文件
string tempFile = Path.Combine(tempDir, Path.GetFileNameWithoutExtension(source) + "~temp.png");
// 构建颜色替换命令 - 保留透明区域
string commandPrefix = $"{_convertPath} \"{source}\" -alpha on";
string commandSuffix = $" -alpha on \"{tempFile}\"";
string commandMiddle = "";
for (int i = 0; i < palette.Count; i++)
{
string fillColor = (i == colorIndex || (i > colorIndex && stack)) ? fgAlmostBlack : bgAlmostWhite;
commandMiddle += $" -fill \"{fillColor}\" -opaque \"{palette[i]}\"";
// 如果命令太长,执行并重置
if (commandMiddle.Length >= MaxCommandLength || i == palette.Count - 1)
{
string fullCommand = commandPrefix + commandMiddle + commandSuffix;
RunCommand(fullCommand);
commandMiddle = "";
}
}
// 转换为黑白并保留透明
string finalCommand = $"{_convertPath} \"{tempFile}\" -fill white -opaque \"{bgAlmostWhite}\" " +
$"-fill black -opaque \"{fgAlmostBlack}\" -alpha on \"{destination}\"";
RunCommand(finalCommand);
}
/// <summary>
/// 描摹图像为SVG确保透明区域不填充
/// </summary>
private void TraceImage(string source, string destination, string color)
{
//if (!string.IsNullOrEmpty(source) && File.Exists(source))
//{
// if (!source.EndsWith(".bmp") && !source.EndsWith(".BMP"))
// {
// string oldSource = source;
// using (var bmp = Bitmap.FromFile(source))
// {
// source = Path.Combine(Path.GetDirectoryName(source), Guid.NewGuid().ToString("N") + ".bmp");
// bmp.Save(source, ImageFormat.Bmp);
// }
// File.Delete(oldSource);
// }
//}
string widthParam = !string.IsNullOrEmpty(Width) ? $"--width {Width}" : "";
string heightParam = !string.IsNullOrEmpty(Height) ? $"--height {Height}" : "";
// 添加参数确保只填充前景,背景保持透明
string command = $"{_potracePath} \"{source}\" {_potraceOptions} --svg -o \"{destination}\" " +
$"-C \"{color}\" -t {Despeckle} -a {SmoothCorners} -O {OptimizePaths} " +
$"{widthParam} {heightParam} --fillcolor \"{color}\"";
RunCommand(command);
}
/// <summary>
/// 合并多个SVG图层并确保背景透明
/// </summary>
private void MergeSvgLayersWithTransparentBackground(List<string> svgFiles, string outputPath)
{
// 创建一个新的SVG文档只声明一次默认命名空间
XNamespace svgNs = "http://www.w3.org/2000/svg";
XDocument mergedDoc = new XDocument(
new XElement(svgNs + "svg",
new XAttribute(XNamespace.Xmlns + "svg", svgNs), // 正确声明命名空间
new XAttribute("version", "1.1"),
// 不设置背景色,保持透明
new XAttribute("style", "background: transparent;")
)
);
XElement root = mergedDoc.Root;
if (root == null)
throw new InvalidOperationException("无法创建SVG根元素");
// 记录是否已经设置了尺寸属性
bool hasSizeAttributes = false;
// 合并所有SVG内容
foreach (string file in svgFiles)
{
XDocument doc = XDocument.Load(file);
XElement docRoot = doc.Root;
if (docRoot == null)
continue;
// 处理第一个SVG文件的尺寸属性只设置一次
if (!hasSizeAttributes)
{
// 复制宽度、高度和视图框属性(排除命名空间属性)
foreach (XAttribute attr in docRoot.Attributes())
{
if (attr.Name.LocalName == "width" ||
attr.Name.LocalName == "height" ||
attr.Name.LocalName == "viewBox")
{
root.SetAttributeValue(attr.Name, attr.Value);
}
}
hasSizeAttributes = true;
}
// 复制所有子元素,并确保它们使用正确的命名空间
foreach (XElement child in docRoot.Elements())
{
// 移除子元素可能携带的命名空间声明,避免冲突
var cleanedChild = CleanElementNamespace(child, svgNs);
// 确保没有设置全局背景
if (cleanedChild.Name.LocalName == "rect" &&
cleanedChild.Attribute("width") != null &&
cleanedChild.Attribute("height") != null &&
cleanedChild.Attribute("fill") != null)
{
// 过滤掉可能的背景矩形
continue;
}
root.Add(cleanedChild);
}
}
mergedDoc.Save(outputPath);
}
#endregion
#region
/// <summary>
/// 获取输入输出文件对
/// </summary>
private List<(string Input, string Output)> GetInputOutputPairs(List<string> inputPatterns, string outputPattern)
{
List<(string, string)> result = new List<(string, string)>();
HashSet<string> processedInputs = new HashSet<string>();
foreach (string pattern in inputPatterns)
{
// 处理通配符
string directory = Path.GetDirectoryName(pattern);
string fileName = Path.GetFileName(pattern);
if (string.IsNullOrEmpty(directory))
directory = Environment.CurrentDirectory;
if (!Directory.Exists(directory))
continue;
// 获取匹配的文件
foreach (string file in Directory.EnumerateFiles(directory, fileName))
{
if (processedInputs.Contains(file))
continue;
processedInputs.Add(file);
// 生成输出文件名
string baseName = Path.GetFileNameWithoutExtension(file);
string outputFile = outputPattern.Replace("{0}", baseName);
result.Add((file, outputFile));
}
}
return result;
}
/// <summary>
/// 运行外部命令
/// </summary>
private string RunCommand(string command, bool captureOutput = false, bool captureError = true)
{
Report($"执行命令: {command}", 2);
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c {command}",
UseShellExecute = false,
RedirectStandardOutput = captureOutput,
RedirectStandardError = captureError,
CreateNoWindow = true
};
using (Process process = Process.Start(startInfo))
{
if (process == null)
throw new InvalidOperationException("无法启动进程");
string output = captureOutput ? process.StandardOutput.ReadToEnd() : null;
string error = captureError ? process.StandardError.ReadToEnd() : null;
process.WaitForExit();
if (process.ExitCode != 0)
throw new Exception($"命令执行失败 (代码 {process.ExitCode}): {error}");
return output;
}
}
/// <summary>
/// 获取不在调色板中的颜色
/// </summary>
private string GetNonPaletteColor(List<string> palette, bool startFromBlack, IEnumerable<string> avoidColors = null)
{
HashSet<string> allAvoid = new HashSet<string>(palette.Select(c => c.ToUpper()));
if (avoidColors != null)
foreach (string color in avoidColors)
allAvoid.Add(color.ToUpper());
if (startFromBlack)
{
// 从黑色开始查找
for (int i = 0; i <= 0xFFFFFF; i++)
{
string color = $"#{i:X6}";
if (!allAvoid.Contains(color))
return color;
}
}
else
{
// 从白色开始查找
for (int i = 0xFFFFFF; i >= 0; i--)
{
string color = $"#{i:X6}";
if (!allAvoid.Contains(color))
return color;
}
}
throw new Exception("无法找到不在调色板中的颜色");
}
/// <summary>
/// 清理元素的命名空间声明,确保使用指定的命名空间
/// </summary>
private XElement CleanElementNamespace(XElement element, XNamespace targetNs)
{
// 创建一个使用目标命名空间的新元素
XElement cleaned = new XElement(targetNs + element.Name.LocalName);
// 复制属性,但排除命名空间声明
foreach (XAttribute attr in element.Attributes())
{
if (!attr.IsNamespaceDeclaration)
{
cleaned.Add(new XAttribute(attr.Name, attr.Value));
}
}
// 递归清理子元素
foreach (XElement child in element.Elements())
{
cleaned.Add(CleanElementNamespace(child, targetNs));
}
// 复制文本内容
if (!string.IsNullOrEmpty(element.Value))
{
cleaned.Value = element.Value;
}
return cleaned;
}
/// <summary>
/// 删除文件(如果存在)
/// </summary>
private void DeleteFile(string path)
{
if (File.Exists(path))
{
try
{
File.Delete(path);
}
catch (Exception ex)
{
Report($"无法删除临时文件 {path}: {ex.Message}", 1);
}
}
}
/// <summary>
/// 报告信息
/// </summary>
private void Report(string message, int level = 1)
{
if (VerboseLevel >= level)
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {message}");
}
/// <summary>
/// 获取图像宽度
/// </summary>
private int GetImageWidth(string imagePath)
{
string command = $"{_identifyPath} -ping -format \"%w\" \"{imagePath}\"";
string output = RunCommand(command, true);
if (int.TryParse(output, out int width))
return width;
throw new Exception("无法获取图像宽度");
}
#endregion
}
}