From 9ef764ffa1a5cfccc9872660eb1a97f4b332cd72 Mon Sep 17 00:00:00 2001
From: CJKmkp <2564608840@qq.com>
Date: Thu, 19 Feb 2026 18:12:19 +0800
Subject: [PATCH] =?UTF-8?q?add:=E5=B1=95=E5=8F=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Ink Canvas/InkCanvasForClass.csproj | 6 +-
Ink Canvas/MainWindow.xaml | 174 +++++++
Ink Canvas/MainWindow_cs/MW_VideoPresenter.cs | 472 ++++++++++++++++++
Ink Canvas/Models/CapturedImage.cs | 96 ++++
Ink Canvas/Resources/Settings.cs | 3 +
Ink Canvas/packages.lock.json | 19 +
6 files changed, 768 insertions(+), 2 deletions(-)
create mode 100644 Ink Canvas/MainWindow_cs/MW_VideoPresenter.cs
create mode 100644 Ink Canvas/Models/CapturedImage.cs
diff --git a/Ink Canvas/InkCanvasForClass.csproj b/Ink Canvas/InkCanvasForClass.csproj
index 0326b2a3..bfd3c32b 100644
--- a/Ink Canvas/InkCanvasForClass.csproj
+++ b/Ink Canvas/InkCanvasForClass.csproj
@@ -162,8 +162,10 @@
-
-
+
+
+
+
diff --git a/Ink Canvas/MainWindow.xaml b/Ink Canvas/MainWindow.xaml
index f3555254..a37ed2b8 100644
--- a/Ink Canvas/MainWindow.xaml
+++ b/Ink Canvas/MainWindow.xaml
@@ -10314,6 +10314,25 @@
RenderOptions.BitmapScalingMode="HighQuality" Height="17" Margin="0,3,0,0" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
_capturedPhotos = new List();
+
+ private DateTime _lastCaptureTime = DateTime.MinValue;
+ private const int VideoPresenterCaptureCooldownMs = 1000;
+
+ private const int CorrectedPaperHeight = 600;
+
+ private void BtnToggleVideoPresenter_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ ToggleVideoPresenterSidebar();
+ }
+
+ private void ToggleVideoPresenterSidebar()
+ {
+ if (VideoPresenterSidebar == null) return;
+
+ if (VideoPresenterSidebar.Visibility == Visibility.Visible)
+ {
+ VideoPresenterSidebar.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ VideoPresenterSidebar.Visibility = Visibility.Visible;
+ EnsureCameraService();
+ RefreshVideoPresenterDeviceList();
+
+ if (CheckBoxEnablePhotoCorrection != null)
+ {
+ CheckBoxEnablePhotoCorrection.IsChecked = Settings?.Automation?.IsEnablePhotoCorrection ?? false;
+ }
+ }
+
+ private void BtnCloseVideoPresenter_Click(object sender, RoutedEventArgs e)
+ {
+ if (VideoPresenterSidebar != null)
+ {
+ VideoPresenterSidebar.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ private void EnsureCameraService()
+ {
+ if (_cameraService != null) return;
+
+ _cameraService = new CameraService();
+ _cameraService.FrameReceived += CameraService_FrameReceived;
+ _cameraService.ErrorOccurred += CameraService_ErrorOccurred;
+ }
+
+ private void CameraService_ErrorOccurred(object sender, string e)
+ {
+ try
+ {
+ LogHelper.WriteLogToFile($"视频展台摄像头错误: {e}", LogHelper.LogType.Error);
+ }
+ catch { }
+ }
+
+ private void CameraService_FrameReceived(object sender, Bitmap frame)
+ {
+ if (frame == null) return;
+
+ try
+ {
+ Bitmap copy;
+ lock (_videoPresenterFrameLock)
+ {
+ _lastFrame?.Dispose();
+ _lastFrame = (Bitmap)frame.Clone();
+ copy = (Bitmap)_lastFrame.Clone();
+ }
+
+ var preview = ConvertBitmapToBitmapImage(copy);
+ copy.Dispose();
+ if (preview == null) return;
+
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (VideoPresenterPreviewImage != null)
+ {
+ VideoPresenterPreviewImage.Source = preview;
+ }
+
+ if (BtnCapturePhoto != null)
+ {
+ BtnCapturePhoto.IsEnabled = true;
+ }
+ }));
+ }
+ catch
+ {
+ // 忽略预览刷新异常
+ }
+ }
+
+ private void RefreshVideoPresenterDeviceList()
+ {
+ if (_cameraService == null) return;
+ if (CameraDevicesStackPanel == null) return;
+
+ _cameraService.RefreshCameraList();
+ CameraDevicesStackPanel.Children.Clear();
+
+ if (_cameraService.AvailableCameras == null || _cameraService.AvailableCameras.Count == 0)
+ {
+ var tb = new TextBlock
+ {
+ Text = "未检测到摄像头设备",
+ FontSize = 12,
+ Margin = new Thickness(5),
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+ tb.SetResourceReference(TextBlock.ForegroundProperty, "FloatBarForeground");
+ CameraDevicesStackPanel.Children.Add(tb);
+ return;
+ }
+
+ for (int i = 0; i < _cameraService.AvailableCameras.Count; i++)
+ {
+ int idx = i;
+ var dev = _cameraService.AvailableCameras[i];
+ var rb = new RadioButton
+ {
+ Content = dev.Name,
+ Margin = new Thickness(0, 2, 0, 2),
+ FontSize = 12,
+ Tag = idx,
+ };
+ rb.SetResourceReference(Control.ForegroundProperty, "FloatBarForeground");
+ rb.Checked += (s, e) => StartVideoPresenterPreview(idx);
+ CameraDevicesStackPanel.Children.Add(rb);
+ }
+ }
+
+ private void StartVideoPresenterPreview(int cameraIndex)
+ {
+ try
+ {
+ EnsureCameraService();
+ if (_cameraService.StartPreview(cameraIndex))
+ {
+ if (BtnCapturePhoto != null) BtnCapturePhoto.IsEnabled = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ LogHelper.WriteLogToFile($"启动视频展台预览失败: {ex.Message}", LogHelper.LogType.Error);
+ }
+ }
+
+ private void BtnCapturePhoto_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ if ((DateTime.Now - _lastCaptureTime).TotalMilliseconds < VideoPresenterCaptureCooldownMs) return;
+ _lastCaptureTime = DateTime.Now;
+
+ Bitmap frame;
+ lock (_videoPresenterFrameLock)
+ {
+ if (_lastFrame == null) return;
+ frame = (Bitmap)_lastFrame.Clone();
+ }
+
+ Task.Run(() =>
+ {
+ try
+ {
+ using (frame)
+ {
+ Bitmap toSave = frame;
+
+ if (Settings?.Automation?.IsEnablePhotoCorrection == true
+ && TryDetectPaperCorners(toSave, out List corners))
+ {
+ var corrected = ApplyPerspectiveCorrection(toSave, corners);
+ if (corrected != null) toSave = corrected;
+ }
+
+ var bmpImage = ConvertBitmapToBitmapImage(toSave);
+ if (!ReferenceEquals(toSave, frame))
+ {
+ toSave.Dispose();
+ }
+
+ if (bmpImage == null) return;
+
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ var ci = new CapturedImage(bmpImage);
+ _capturedPhotos.Insert(0, ci);
+ UpdateCapturedPhotosDisplay();
+ }));
+ }
+ }
+ catch (Exception ex)
+ {
+ LogHelper.WriteLogToFile($"视频展台拍照失败: {ex.Message}", LogHelper.LogType.Error);
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ LogHelper.WriteLogToFile($"视频展台拍照失败: {ex.Message}", LogHelper.LogType.Error);
+ }
+ }
+
+ private void BtnRotateImage_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ EnsureCameraService();
+ _cameraService.RotationAngle = (_cameraService.RotationAngle + 1) % 4;
+ }
+ catch (Exception ex)
+ {
+ LogHelper.WriteLogToFile($"视频展台旋转失败: {ex.Message}", LogHelper.LogType.Error);
+ }
+ }
+
+ private void CheckBoxEnablePhotoCorrection_Checked(object sender, RoutedEventArgs e)
+ {
+ if (Settings?.Automation == null) return;
+ Settings.Automation.IsEnablePhotoCorrection = true;
+ SaveSettingsToFile();
+ }
+
+ private void CheckBoxEnablePhotoCorrection_Unchecked(object sender, RoutedEventArgs e)
+ {
+ if (Settings?.Automation == null) return;
+ Settings.Automation.IsEnablePhotoCorrection = false;
+ SaveSettingsToFile();
+ }
+
+ private void UpdateCapturedPhotosDisplay()
+ {
+ if (CapturedPhotosStackPanel == null) return;
+
+ CapturedPhotosStackPanel.Children.Clear();
+
+ foreach (var photo in _capturedPhotos.Take(30))
+ {
+ var btn = new Button
+ {
+ Margin = new Thickness(0, 0, 0, 6),
+ Padding = new Thickness(0),
+ BorderThickness = new Thickness(0),
+ Background = System.Windows.Media.Brushes.Transparent,
+ Tag = photo
+ };
+ btn.Click += (s, e) =>
+ {
+ if (btn.Tag is CapturedImage p) InsertPhotoToCanvas(p);
+ };
+
+ var img = new System.Windows.Controls.Image
+ {
+ Source = photo.Thumbnail,
+ Stretch = System.Windows.Media.Stretch.UniformToFill,
+ Height = 90
+ };
+ btn.Content = img;
+ CapturedPhotosStackPanel.Children.Add(btn);
+ }
+ }
+
+ private void InsertPhotoToCanvas(CapturedImage photo)
+ {
+ if (photo?.Image == null) return;
+
+ try
+ {
+ var img = new System.Windows.Controls.Image
+ {
+ Source = photo.Image,
+ Stretch = System.Windows.Media.Stretch.Uniform,
+ Width = 500
+ };
+
+ double x = (inkCanvas?.ActualWidth ?? 0) / 2 - img.Width / 2;
+ double y = (inkCanvas?.ActualHeight ?? 0) / 2 - 200;
+ if (double.IsNaN(x) || double.IsInfinity(x)) x = 100;
+ if (double.IsNaN(y) || double.IsInfinity(y)) y = 100;
+
+ InkCanvas.SetLeft(img, Math.Max(0, x));
+ InkCanvas.SetTop(img, Math.Max(0, y));
+ inkCanvas?.Children.Add(img);
+ }
+ catch (Exception ex)
+ {
+ LogHelper.WriteLogToFile($"插入展台照片失败: {ex.Message}", LogHelper.LogType.Error);
+ }
+ }
+
+ private static BitmapImage ConvertBitmapToBitmapImage(Bitmap bitmap)
+ {
+ try
+ {
+ if (bitmap == null) return null;
+
+ using (var ms = new MemoryStream())
+ {
+ bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
+ ms.Position = 0;
+ var bi = new BitmapImage();
+ bi.BeginInit();
+ bi.CacheOption = BitmapCacheOption.OnLoad;
+ bi.StreamSource = ms;
+ bi.EndInit();
+ bi.Freeze();
+ return bi;
+ }
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static bool TryDetectPaperCorners(Bitmap frame, out List cornersOut)
+ {
+ cornersOut = null;
+ try
+ {
+ if (frame == null) return false;
+
+ int targetWidth = 640;
+ int ow = frame.Width;
+ int oh = frame.Height;
+ double scale = 1.0;
+ Bitmap work = frame;
+ if (ow > targetWidth)
+ {
+ int nh = (int)Math.Round(oh * (targetWidth / (double)ow));
+ var resize = new ResizeBilinear(targetWidth, nh);
+ work = resize.Apply(frame);
+ scale = (double)ow / targetWidth;
+ }
+
+ var gray = Grayscale.CommonAlgorithms.BT709.Apply(work);
+ var blur = new GaussianBlur(3, 3);
+ blur.ApplyInPlace(gray);
+ var canny = new CannyEdgeDetector();
+ canny.ApplyInPlace(gray);
+ var dilate = new Dilatation3x3();
+ dilate.ApplyInPlace(gray);
+
+ var bc = new BlobCounter
+ {
+ FilterBlobs = true,
+ MinHeight = 50,
+ MinWidth = 50,
+ ObjectsOrder = ObjectsOrder.Size
+ };
+ bc.ProcessImage(gray);
+ var blobs = bc.GetObjectsInformation();
+ var sc = new SimpleShapeChecker();
+ List best = null;
+ double bestArea = 0;
+
+ foreach (var blob in blobs)
+ {
+ var edgePoints = bc.GetBlobsEdgePoints(blob);
+ if (edgePoints == null || edgePoints.Count < 4) continue;
+ if (sc.IsQuadrilateral(edgePoints, out List crn))
+ {
+ double area = Math.Abs(PolygonArea(crn));
+ if (area > bestArea)
+ {
+ bestArea = area;
+ best = crn;
+ }
+ }
+ }
+
+ if (best != null)
+ {
+ var pts = best
+ .Select(p => new AForge.IntPoint((int)Math.Round(p.X * scale), (int)Math.Round(p.Y * scale)))
+ .ToList();
+ pts.Sort((a, b) => a.Y.CompareTo(b.Y));
+ if (pts[0].X > pts[1].X) (pts[0], pts[1]) = (pts[1], pts[0]);
+ if (pts[2].X > pts[3].X) (pts[2], pts[3]) = (pts[3], pts[2]);
+ cornersOut = pts;
+ if (!ReferenceEquals(work, frame)) work.Dispose();
+ gray.Dispose();
+ return true;
+ }
+
+ if (!ReferenceEquals(work, frame)) work.Dispose();
+ gray.Dispose();
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static Bitmap ApplyPerspectiveCorrection(Bitmap frame, List corners)
+ {
+ try
+ {
+ if (frame == null || corners == null || corners.Count != 4) return null;
+ var tl = corners[0];
+ var tr = corners[1];
+ var bl = corners[2];
+ var br = corners[3];
+
+ double topW = Math.Sqrt((tr.X - tl.X) * (tr.X - tl.X) + (tr.Y - tl.Y) * (tr.Y - tl.Y));
+ double bottomW = Math.Sqrt((br.X - bl.X) * (br.X - bl.X) + (br.Y - bl.Y) * (br.Y - bl.Y));
+ double leftH = Math.Sqrt((bl.X - tl.X) * (bl.X - tl.X) + (bl.Y - tl.Y) * (bl.Y - tl.Y));
+ double rightH = Math.Sqrt((br.X - tr.X) * (br.X - tr.X) + (br.Y - tr.Y) * (br.Y - tr.Y));
+
+ double avgW = (topW + bottomW) / 2.0;
+ double avgH = (leftH + rightH) / 2.0;
+ if (avgH <= 0) avgH = 1;
+ double ratio = avgW / avgH;
+
+ int targetH = CorrectedPaperHeight;
+ int targetW = Math.Max(1, (int)Math.Round(targetH * ratio));
+
+ var orderedCorners = new List { tl, tr, br, bl };
+ var qtf = new QuadrilateralTransformation(orderedCorners, targetW, targetH);
+ return qtf.Apply(frame);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static double PolygonArea(List pts)
+ {
+ int n = pts.Count;
+ if (n < 3) return 0;
+ long sum = 0;
+ for (int i = 0; i < n; i++)
+ {
+ var p = pts[i];
+ var q = pts[(i + 1) % n];
+ sum += (long)p.X * q.Y - (long)p.Y * q.X;
+ }
+ return 0.5 * sum;
+ }
+ }
+}
+
diff --git a/Ink Canvas/Models/CapturedImage.cs b/Ink Canvas/Models/CapturedImage.cs
new file mode 100644
index 00000000..60205b87
--- /dev/null
+++ b/Ink Canvas/Models/CapturedImage.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Windows.Ink;
+using System.Windows.Media.Imaging;
+
+namespace Ink_Canvas.Models
+{
+ public class CapturedImage
+ {
+ public BitmapImage Image { get; }
+ public BitmapImage Thumbnail { get; }
+ public StrokeCollection Strokes { get; }
+ public string Timestamp { get; }
+ public string FilePath { get; }
+
+ public CapturedImage(BitmapImage image)
+ {
+ Image = image;
+ Thumbnail = CreateThumbnail(image);
+ Strokes = new StrokeCollection();
+ Timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
+ FilePath = null;
+ }
+
+ public CapturedImage(BitmapImage image, string filePath)
+ {
+ Image = image;
+ Thumbnail = CreateThumbnail(image);
+ Strokes = new StrokeCollection();
+ FilePath = filePath;
+ Timestamp = TryExtractTimestampFromFilePath(filePath) ?? DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
+ }
+
+ private static string TryExtractTimestampFromFilePath(string filePath)
+ {
+ try
+ {
+ if (string.IsNullOrEmpty(filePath)) return null;
+ var name = System.IO.Path.GetFileNameWithoutExtension(filePath);
+ if (DateTime.TryParseExact(
+ name,
+ "yyyy-MM-dd HH-mm-ss-fff",
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None,
+ out var dt))
+ {
+ return dt.ToString("yyyy-MM-dd HH:mm:ss");
+ }
+ if (name.Length >= 23)
+ {
+ var tail = name.Substring(name.Length - 23);
+ if (DateTime.TryParseExact(
+ tail,
+ "yyyy-MM-dd HH-mm-ss-fff",
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None,
+ out var dt2))
+ {
+ return dt2.ToString("yyyy-MM-dd HH:mm:ss");
+ }
+ }
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private BitmapImage CreateThumbnail(BitmapImage original)
+ {
+ double targetWidth = 290.0;
+ double targetHeight = 180.0;
+ double scale = Math.Min(targetWidth / original.PixelWidth, targetHeight / original.PixelHeight);
+ var thumbnail = new TransformedBitmap(original, new System.Windows.Media.ScaleTransform(scale, scale));
+
+ var bmp = new JpegBitmapEncoder { QualityLevel = 85 };
+ bmp.Frames.Add(BitmapFrame.Create(thumbnail));
+
+ using (var stream = new System.IO.MemoryStream())
+ {
+ bmp.Save(stream);
+ stream.Seek(0, System.IO.SeekOrigin.Begin);
+
+ var result = new BitmapImage();
+ result.BeginInit();
+ result.CacheOption = BitmapCacheOption.OnLoad;
+ result.StreamSource = stream;
+ result.EndInit();
+ result.Freeze();
+
+ return result;
+ }
+ }
+ }
+}
+
diff --git a/Ink Canvas/Resources/Settings.cs b/Ink Canvas/Resources/Settings.cs
index 04be989e..4019d2f1 100644
--- a/Ink Canvas/Resources/Settings.cs
+++ b/Ink Canvas/Resources/Settings.cs
@@ -522,6 +522,9 @@ namespace Ink_Canvas
[JsonProperty("isAutoSaveStrokesAtClear")]
public bool IsAutoSaveStrokesAtClear { get; set; }
+ [JsonProperty("isEnablePhotoCorrection")]
+ public bool IsEnablePhotoCorrection { get; set; } = false;
+
[JsonProperty("isAutoClearWhenExitingWritingMode")]
public bool IsAutoClearWhenExitingWritingMode { get; set; }
diff --git a/Ink Canvas/packages.lock.json b/Ink Canvas/packages.lock.json
index 2cf4c681..7d2e7578 100644
--- a/Ink Canvas/packages.lock.json
+++ b/Ink Canvas/packages.lock.json
@@ -2,6 +2,25 @@
"version": 1,
"dependencies": {
".NETFramework,Version=v4.7.2": {
+ "AForge.Imaging": {
+ "type": "Direct",
+ "requested": "[2.2.5, )",
+ "resolved": "2.2.5",
+ "contentHash": "n8l7Zdsm89EFW9Uz4uCO3D428Gu4Q56YxixSMJPruen1c30R8R9Mm8AreTdoOi2XjN5id04JwFFlXYTk8pIIfw==",
+ "dependencies": {
+ "AForge": "2.2.5",
+ "AForge.Math": "2.2.5"
+ }
+ },
+ "AForge.Math": {
+ "type": "Direct",
+ "requested": "[2.2.5, )",
+ "resolved": "2.2.5",
+ "contentHash": "pmT1WobVv13vMxwXhCu8sRunxjiUHyURWS0+dsW/aBPi06xiyYIdCLHI6jph9Axb4QoLIlb85eBI6zd2EAbGIg==",
+ "dependencies": {
+ "AForge": "2.2.5"
+ }
+ },
"AForge.Video": {
"type": "Direct",
"requested": "[2.2.5, )",