using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEWorkflow.Abstractions;
namespace XFEWorkflow.Plugins.Vision;
public sealed class VisionPlugin : IWorkflowPlugin
{
public static PluginManifest Manifest
{
get { using var stream = typeof(VisionPlugin).Assembly.GetManifestResourceStream("XFEWorkflow.Plugins.Vision.plugin.json")!; return JsonSerializer.Deserialize<PluginManifest>(stream, WorkflowJson.Options)!; }
}
public void Register(IPluginRegistry registry)
{ foreach (var descriptor in Manifest.Capabilities) registry.RegisterAction(descriptor, () => new VisionAction(descriptor.Id)); }
}
public sealed class VisionAction(string capabilityId) : IWorkflowAction
{
public async ValueTask<JsonObject> ExecuteAsync(JsonObject input, IWorkflowContext context, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var operation = capabilityId["xfe.vision.".Length..];
if (operation == "selectRegion")
{
await context.InvokeHostAsync("desktop.acquire", [], ct);
return await context.InvokeHostAsync("screen.selectRegion", input, ct);
}
if (operation is "clipboardRead" or "clipboardWrite")
{
await context.InvokeHostAsync("desktop.acquire", [], ct);
return await context.InvokeHostAsync("screen." + operation, input, ct);
}
if (operation is "click" or "move" or "drag" or "scroll")
{
await context.InvokeHostAsync("desktop.acquire", [], ct);
return await context.InvokeHostAsync("desktop.input", VisionParameters.Input(operation, input), ct);
}
if (input.ContainsKey("imagePath") && input["imagePath"] is null) throw new ArgumentException("图像路径绑定为空,未执行屏幕识别。");
_ = Region(input);
if (input.String("imagePath").Length == 0) await context.InvokeHostAsync("desktop.acquire", [], ct);
if (operation == "capture")
{
var path = input.String("path"); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("请选择截图保存路径。");
if (!Path.GetExtension(path).Equals(".png", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("截图保存路径必须使用 .png 扩展名。");
return await Task.Run(() =>
{
ct.ThrowIfCancellationRequested(); using var frame = ScreenCapture.Capture(Region(input));
var destination = Path.GetFullPath(path); Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
frame.Bitmap.Save(destination, ImageFormat.Png);
return new JsonObject { ["path"] = destination, ["region"] = frame.Region.ToJson() };
}, ct);
}
if (operation is "pixel" or "waitColor")
{
var (x, y) = VisionParameters.ReadPoint(input, "point");
var color = operation == "waitColor" ? VisionParameters.Color(input.String("color", "#FFFFFF")) : default;
var tolerance = VisionParameters.Integer(input, "tolerance", 12, 0, 255);
async Task<JsonObject> Probe()
{
var value = await Task.Run(() => ScreenCapture.Pixel(x, y), ct);
return new() { ["color"] = $"#{value.R:X2}{value.G:X2}{value.B:X2}", ["rgb"] = new JsonObject { ["r"] = value.R, ["g"] = value.G, ["b"] = value.B },
["found"] = Math.Abs(value.R - color.R) <= tolerance && Math.Abs(value.G - color.G) <= tolerance && Math.Abs(value.B - color.B) <= tolerance };
}
return operation == "pixel" ? await Probe() : await VisionPolling.WaitAsync(Probe, input, ct);
}
var isText = operation is "ocr" or "findText" or "waitText" or "clickText";
if (!isText && operation is not ("findImage" or "waitImage" or "clickImage")) throw new NotSupportedException(capabilityId);
using var recognizer = isText ? new OcrRecognizer(input.String("language", "chi_sim+eng")) : null;
using var template = !isText ? TemplateImage.Load(input.String("templatePath")) : null;
Task<JsonObject> Recognize() => Task.Run(() =>
{
ct.ThrowIfCancellationRequested(); using var frame = LoadFrame(input);
if (recognizer is not null)
{
var result = recognizer.Recognize(frame, input, ct);
return operation == "ocr" ? result : OcrTextSearch.Find(result, input.String("text"), input.String("matchMode", "contains"), input.Boolean("ignoreCase", true));
}
var maxMatches = VisionParameters.Integer(input, "maxMatches", 5, 1, 100);
var matches = input["minimumSimilarity"] is not null
? ImageMatcher.FindSimilar(frame.Bitmap, template!.Bitmap, frame.Region, VisionParameters.Number(input, "minimumSimilarity", .9, .01, 1), maxMatches, ct)
: ImageMatcher.Find(frame.Bitmap, template!.Bitmap, frame.Region, VisionParameters.Integer(input, "tolerance", 12, 0, 255), maxMatches, ct);
return VisionParameters.Matches(matches, frame.CoordinateSpace);
}, ct);
var output = operation is "waitText" or "waitImage" ? await VisionPolling.WaitAsync(Recognize, input, ct) : await Recognize();
if (operation is "clickText" or "clickImage")
{
if (!output.Boolean("found")) throw new InvalidOperationException("未找到要点击的目标,请检查识别区域、文字或模板。");
if (output.String("coordinateSpace") != "screen") throw new InvalidOperationException("只允许点击屏幕识别结果。");
var click = WorkflowJson.Copy(input); click["point"] = output["point"]!.DeepClone();
await context.InvokeHostAsync("desktop.input", VisionParameters.Input("click", click), ct);
}
return output;
}
private static ScreenRegion? Region(JsonObject input) => !input.ContainsKey("region") ? null : input["region"] is { } value ? ScreenRegion.Parse(value) : throw new ArgumentException("区域绑定为空。请提供有效区域,或清除区域参数后识别整个桌面。");
private static ScreenFrame LoadFrame(JsonObject input)
{
if (input.ContainsKey("imagePath") && input["imagePath"] is null) throw new ArgumentException("图像路径绑定为空,未执行屏幕识别。");
return input.String("imagePath") is { Length: > 0 } path ? ScreenCapture.Load(path, Region(input)) : ScreenCapture.Capture(Region(input));
}
}
public static class VisionParameters
{
public static double Number(JsonObject input, string name, double fallback, double minimum, double maximum)
{
if (input[name] is null) return fallback;
if (input[name] is not JsonValue scalar) throw new ArgumentException(name + " 必须是数值。");
var value = scalar.TryGetValue<double>(out var number) ? number : scalar.TryGetValue<int>(out var integer) ? integer : double.NaN;
if (!double.IsFinite(value) || value < minimum || value > maximum) throw new ArgumentException($"{name} 范围为 {minimum}~{maximum}。");
return value;
}
public static int Integer(JsonObject input, string name, int fallback, int minimum, int maximum)
{
if (input[name] is null) return fallback;
var value = ScreenRegion.ReadInteger(input, name);
if (value < minimum || value > maximum) throw new ArgumentException($"{name} 范围为 {minimum}~{maximum}。");
return value;
}
public static JsonObject Point(int x, int y) => new() { ["x"] = x, ["y"] = y };
public static (int X, int Y) ReadPoint(JsonObject input, string name)
{
var obj = input[name] as JsonObject ?? throw new ArgumentException("请选择" + name + "屏幕坐标。");
return (ScreenRegion.ReadInteger(obj, "x"), ScreenRegion.ReadInteger(obj, "y"));
}
public static Color Color(string value)
{
if (value.Length != 7 || value[0] != '#' || !int.TryParse(value.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var rgb)) throw new ArgumentException("颜色应为 #RRGGBB。");
return System.Drawing.Color.FromArgb((rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255);
}
public static JsonObject Matches(JsonArray matches, string coordinateSpace, string text = "") => new()
{
["found"] = matches.Count > 0, ["matches"] = matches, ["region"] = matches.FirstOrDefault()?.DeepClone(),
["point"] = matches.FirstOrDefault()?["point"]?.DeepClone(), ["coordinateSpace"] = coordinateSpace, ["text"] = text
};
public static JsonObject Input(string operation, JsonObject input)
{
var result = WorkflowJson.Copy(input); result["operation"] = operation == "move" ? "locate" : operation;
if (operation is "click" or "move") { var (x, y) = ReadPoint(input, "point"); result["x"] = x; result["y"] = y; }
if (operation is "click" or "drag")
{
var button = input.String("button", "left"); if (button is not ("left" or "right" or "middle")) throw new ArgumentException("鼠标按键无效。"); result["button"] = button;
}
if (operation == "click") result["clicks"] = Integer(input, "clicks", 1, 1, 2);
if (operation == "drag") { var from = ReadPoint(input, "from"); var to = ReadPoint(input, "to"); result["fromX"] = from.X; result["fromY"] = from.Y; result["toX"] = to.X; result["toY"] = to.Y; result["durationMilliseconds"] = Integer(input, "durationMilliseconds", 500, 0, 60000); }
if (operation == "scroll") result["delta"] = Integer(input, "delta", -120, -120000, 120000);
return result;
}
}
public static class VisionPolling
{
public static async Task<JsonObject> WaitAsync(Func<Task<JsonObject>> probe, JsonObject input, CancellationToken ct)
{
var timeout = VisionParameters.Integer(input, "timeoutMilliseconds", 30000, 0, 3_600_000);
var interval = VisionParameters.Integer(input, "intervalMilliseconds", 500, 100, 60_000);
var watch = Stopwatch.StartNew();
while (true)
{
ct.ThrowIfCancellationRequested(); var result = await probe(); ct.ThrowIfCancellationRequested();
if (result.Boolean("found")) { result["timedOut"] = false; return result; }
if (watch.ElapsedMilliseconds >= timeout) { result["timedOut"] = true; return result; }
await Task.Delay((int)Math.Max(1, Math.Min(interval, timeout - watch.ElapsedMilliseconds)), ct);
}
}
}
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEWorkflow.Abstractions;
namespace XFEWorkflow.Plugins.Vision;
public sealed class VisionPlugin : IWorkflowPlugin
{
public static PluginManifest Manifest
{
get { using var stream = typeof(VisionPlugin).Assembly.GetManifestResourceStream("XFEWorkflow.Plugins.Vision.plugin.json")!; return JsonSerializer.Deserialize<PluginManifest>(stream, WorkflowJson.Options)!; }
}
public void Register(IPluginRegistry registry)
{ foreach (var descriptor in Manifest.Capabilities) registry.RegisterAction(descriptor, () => new VisionAction(descriptor.Id)); }
}
public sealed class VisionAction(string capabilityId) : IWorkflowAction
{
public async ValueTask<JsonObject> ExecuteAsync(JsonObject input, IWorkflowContext context, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var operation = capabilityId["xfe.vision.".Length..];
if (operation == "selectRegion")
{
await context.InvokeHostAsync("desktop.acquire", [], ct);
return await context.InvokeHostAsync("screen.selectRegion", input, ct);
}
if (operation is "clipboardRead" or "clipboardWrite")
{
await context.InvokeHostAsync("desktop.acquire", [], ct);
return await context.InvokeHostAsync("screen." + operation, input, ct);
}
if (operation is "click" or "move" or "drag" or "scroll")
{
await context.InvokeHostAsync("desktop.acquire", [], ct);
return await context.InvokeHostAsync("desktop.input", VisionParameters.Input(operation, input), ct);
}
if (input.ContainsKey("imagePath") && input["imagePath"] is null) throw new ArgumentException("图像路径绑定为空,未执行屏幕识别。");
_ = Region(input);
if (input.String("imagePath").Length == 0) await context.InvokeHostAsync("desktop.acquire", [], ct);
if (operation == "capture")
{
var path = input.String("path"); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("请选择截图保存路径。");
if (!Path.GetExtension(path).Equals(".png", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("截图保存路径必须使用 .png 扩展名。");
return await Task.Run(() =>
{
ct.ThrowIfCancellationRequested(); using var frame = ScreenCapture.Capture(Region(input));
var destination = Path.GetFullPath(path); Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
frame.Bitmap.Save(destination, ImageFormat.Png);
return new JsonObject { ["path"] = destination, ["region"] = frame.Region.ToJson() };
}, ct);
}
if (operation is "pixel" or "waitColor")
{
var (x, y) = VisionParameters.ReadPoint(input, "point");
var color = operation == "waitColor" ? VisionParameters.Color(input.String("color", "#FFFFFF")) : default;
var tolerance = VisionParameters.Integer(input, "tolerance", 12, 0, 255);
async Task<JsonObject> Probe()
{
var value = await Task.Run(() => ScreenCapture.Pixel(x, y), ct);
return new() { ["color"] = $"#{value.R:X2}{value.G:X2}{value.B:X2}", ["rgb"] = new JsonObject { ["r"] = value.R, ["g"] = value.G, ["b"] = value.B },
["found"] = Math.Abs(value.R - color.R) <= tolerance && Math.Abs(value.G - color.G) <= tolerance && Math.Abs(value.B - color.B) <= tolerance };
}
return operation == "pixel" ? await Probe() : await VisionPolling.WaitAsync(Probe, input, ct);
}
var isText = operation is "ocr" or "findText" or "waitText" or "clickText";
if (!isText && operation is not ("findImage" or "waitImage" or "clickImage")) throw new NotSupportedException(capabilityId);
using var recognizer = isText ? new OcrRecognizer(input.String("language", "chi_sim+eng")) : null;
using var template = !isText ? TemplateImage.Load(input.String("templatePath")) : null;
Task<JsonObject> Recognize() => Task.Run(() =>
{
ct.ThrowIfCancellationRequested(); using var frame = LoadFrame(input);
if (recognizer is not null)
{
var result = recognizer.Recognize(frame, input, ct);
return operation == "ocr" ? result : OcrTextSearch.Find(result, input.String("text"), input.String("matchMode", "contains"), input.Boolean("ignoreCase", true));
}
var maxMatches = VisionParameters.Integer(input, "maxMatches", 5, 1, 100);
var matches = input["minimumSimilarity"] is not null
? ImageMatcher.FindSimilar(frame.Bitmap, template!.Bitmap, frame.Region, VisionParameters.Number(input, "minimumSimilarity", .9, .01, 1), maxMatches, ct)
: ImageMatcher.Find(frame.Bitmap, template!.Bitmap, frame.Region, VisionParameters.Integer(input, "tolerance", 12, 0, 255), maxMatches, ct);
return VisionParameters.Matches(matches, frame.CoordinateSpace);
}, ct);
var output = operation is "waitText" or "waitImage" ? await VisionPolling.WaitAsync(Recognize, input, ct) : await Recognize();
if (operation is "clickText" or "clickImage")
{
if (!output.Boolean("found")) throw new InvalidOperationException("未找到要点击的目标,请检查识别区域、文字或模板。");
if (output.String("coordinateSpace") != "screen") throw new InvalidOperationException("只允许点击屏幕识别结果。");
var click = WorkflowJson.Copy(input); click["point"] = output["point"]!.DeepClone();
await context.InvokeHostAsync("desktop.input", VisionParameters.Input("click", click), ct);
}
return output;
}
private static ScreenRegion? Region(JsonObject input) => !input.ContainsKey("region") ? null : input["region"] is { } value ? ScreenRegion.Parse(value) : throw new ArgumentException("区域绑定为空。请提供有效区域,或清除区域参数后识别整个桌面。");
private static ScreenFrame LoadFrame(JsonObject input)
{
if (input.ContainsKey("imagePath") && input["imagePath"] is null) throw new ArgumentException("图像路径绑定为空,未执行屏幕识别。");
return input.String("imagePath") is { Length: > 0 } path ? ScreenCapture.Load(path, Region(input)) : ScreenCapture.Capture(Region(input));
}
}
public static class VisionParameters
{
public static double Number(JsonObject input, string name, double fallback, double minimum, double maximum)
{
if (input[name] is null) return fallback;
if (input[name] is not JsonValue scalar) throw new ArgumentException(name + " 必须是数值。");
var value = scalar.TryGetValue<double>(out var number) ? number : scalar.TryGetValue<int>(out var integer) ? integer : double.NaN;
if (!double.IsFinite(value) || value < minimum || value > maximum) throw new ArgumentException($"{name} 范围为 {minimum}~{maximum}。");
return value;
}
public static int Integer(JsonObject input, string name, int fallback, int minimum, int maximum)
{
if (input[name] is null) return fallback;
var value = ScreenRegion.ReadInteger(input, name);
if (value < minimum || value > maximum) throw new ArgumentException($"{name} 范围为 {minimum}~{maximum}。");
return value;
}
public static JsonObject Point(int x, int y) => new() { ["x"] = x, ["y"] = y };
public static (int X, int Y) ReadPoint(JsonObject input, string name)
{
var obj = input[name] as JsonObject ?? throw new ArgumentException("请选择" + name + "屏幕坐标。");
return (ScreenRegion.ReadInteger(obj, "x"), ScreenRegion.ReadInteger(obj, "y"));
}
public static Color Color(string value)
{
if (value.Length != 7 || value[0] != '#' || !int.TryParse(value.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var rgb)) throw new ArgumentException("颜色应为 #RRGGBB。");
return System.Drawing.Color.FromArgb((rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255);
}
public static JsonObject Matches(JsonArray matches, string coordinateSpace, string text = "") => new()
{
["found"] = matches.Count > 0, ["matches"] = matches, ["region"] = matches.FirstOrDefault()?.DeepClone(),
["point"] = matches.FirstOrDefault()?["point"]?.DeepClone(), ["coordinateSpace"] = coordinateSpace, ["text"] = text
};
public static JsonObject Input(string operation, JsonObject input)
{
var result = WorkflowJson.Copy(input); result["operation"] = operation == "move" ? "locate" : operation;
if (operation is "click" or "move") { var (x, y) = ReadPoint(input, "point"); result["x"] = x; result["y"] = y; }
if (operation is "click" or "drag")
{
var button = input.String("button", "left"); if (button is not ("left" or "right" or "middle")) throw new ArgumentException("鼠标按键无效。"); result["button"] = button;
}
if (operation == "click") result["clicks"] = Integer(input, "clicks", 1, 1, 2);
if (operation == "drag") { var from = ReadPoint(input, "from"); var to = ReadPoint(input, "to"); result["fromX"] = from.X; result["fromY"] = from.Y; result["toX"] = to.X; result["toY"] = to.Y; result["durationMilliseconds"] = Integer(input, "durationMilliseconds", 500, 0, 60000); }
if (operation == "scroll") result["delta"] = Integer(input, "delta", -120, -120000, 120000);
return result;
}
}
public static class VisionPolling
{
public static async Task<JsonObject> WaitAsync(Func<Task<JsonObject>> probe, JsonObject input, CancellationToken ct)
{
var timeout = VisionParameters.Integer(input, "timeoutMilliseconds", 30000, 0, 3_600_000);
var interval = VisionParameters.Integer(input, "intervalMilliseconds", 500, 100, 60_000);
var watch = Stopwatch.StartNew();
while (true)
{
ct.ThrowIfCancellationRequested(); var result = await probe(); ct.ThrowIfCancellationRequested();
if (result.Boolean("found")) { result["timedOut"] = false; return result; }
if (watch.ElapsedMilliseconds >= timeout) { result["timedOut"] = true; return result; }
await Task.Delay((int)Math.Max(1, Math.Min(interval, timeout - watch.ElapsedMilliseconds)), ct);
}
}
}