项目 4 任务 2 网络摄像机:PictureBox、Image / Bitmap、照片列表
- 摄像头:自绘东围墙场景(Camera/scene.svg),按云台位置切成 640×480 帧(build-frames.ts); Pan 每次转一格,红外对射被遮挡时画面出现翻墙的人;类名、委托、事件参数与教材 dll 一致, ImageRead 每 250 ms 在后台线程触发 - Image / Bitmap 保存真实图片数据:Save、FromFile、FromStream,文件夹不存在时报 GDI+ 一般性错误; 图片每张只发给浏览器一次(按内容哈希缓存) - 程序所在目录(AppDomain.CurrentDomain.BaseDirectory,编译检查单独放行)和当前目录指向可写的 bin/Debug, 沙箱里在沙箱的临时目录 - PictureBox(SizeMode、BorderStyle);设计器工具箱加"图片框";运行区显示程序保存的图片,点开看大图; 实训台摄像头图标显示开启状态 - 评分规则加图片框显示图片、摄像头开关与转向、保存图片三种检查;示例 p4-t2-网络摄像机 - 测试:摄像头端到端、图片读写与程序目录、AppDomain 编译检查、评分出错情况 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ed9450c02
commit
6b088240da
76 files changed
+1290
-14
No files matched your search
@@ -0,0 +1,105 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Drawing;
|
||||
|
||||
/// <summary>
|
||||
/// 图片。System.Drawing 在 Linux 上不受支持,这里只保存图片文件的原始数据(JPEG、PNG、GIF、BMP)和尺寸:
|
||||
/// 能显示在 PictureBox 上、保存成文件、从文件或流读回,不能逐像素绘制。
|
||||
/// </summary>
|
||||
public abstract class Image : IDisposable, ICloneable
|
||||
{
|
||||
private string? _simKey;
|
||||
|
||||
private protected Image(byte[] data)
|
||||
{
|
||||
Data = data;
|
||||
(RawFormat, Width, Height) = ReadHeader(data) ?? throw new ArgumentException("参数无效:不是 JPEG、PNG、GIF 或 BMP 格式的图片。");
|
||||
}
|
||||
|
||||
internal byte[] Data { get; }
|
||||
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
public Size Size => new(Width, Height);
|
||||
public ImageFormat RawFormat { get; }
|
||||
public object? Tag { get; set; }
|
||||
|
||||
/// <summary>平台内部使用:图片内容的哈希,浏览器按它缓存图片,同一张图只传一次</summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public string SimKey => _simKey ??= Convert.ToHexString(SHA1.HashData(Data)).ToLowerInvariant();
|
||||
|
||||
internal string MimeType => RawFormat.MimeType;
|
||||
|
||||
public static Image FromFile(string filename) => new Bitmap(filename);
|
||||
|
||||
public static Image FromStream(Stream stream) => new Bitmap(stream);
|
||||
|
||||
public void Save(string filename) => Save(filename, RawFormat);
|
||||
|
||||
/// <summary>按原格式写出图片数据(不做格式转换)。目录不存在时和 GDI+ 一样报"一般性错误"</summary>
|
||||
public void Save(string filename, ImageFormat format)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(filename, Data);
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
throw new ExternalException("GDI+ 中发生一般性错误。(保存图片的文件夹不存在,先用 Directory.CreateDirectory 创建)");
|
||||
}
|
||||
SimHost.ImageSaved(Path.GetFullPath(filename), this);
|
||||
}
|
||||
|
||||
public void Save(Stream stream, ImageFormat format) => stream.Write(Data, 0, Data.Length);
|
||||
|
||||
public object Clone() => new Bitmap(this);
|
||||
|
||||
public void Dispose() => GC.SuppressFinalize(this);
|
||||
|
||||
private static (ImageFormat, int, int)? ReadHeader(byte[] d)
|
||||
{
|
||||
if (d.Length >= 24 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G')
|
||||
return (ImageFormat.Png, BigEndian(d, 16), BigEndian(d, 20));
|
||||
if (d.Length >= 10 && d[0] == 'G' && d[1] == 'I' && d[2] == 'F')
|
||||
return (ImageFormat.Gif, d[6] | d[7] << 8, d[8] | d[9] << 8);
|
||||
if (d.Length >= 26 && d[0] == 'B' && d[1] == 'M')
|
||||
return (ImageFormat.Bmp, BitConverter.ToInt32(d, 18), Math.Abs(BitConverter.ToInt32(d, 22)));
|
||||
if (d.Length >= 4 && d[0] == 0xFF && d[1] == 0xD8)
|
||||
{
|
||||
// 依次跳过各段,找到记录尺寸的 SOF 段
|
||||
var i = 2;
|
||||
while (i + 9 < d.Length && d[i] == 0xFF)
|
||||
{
|
||||
var marker = d[i + 1];
|
||||
var length = d[i + 2] << 8 | d[i + 3];
|
||||
if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC)
|
||||
return (ImageFormat.Jpeg, d[i + 7] << 8 | d[i + 8], d[i + 5] << 8 | d[i + 6]);
|
||||
i += 2 + length;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int BigEndian(byte[] d, int offset) => d[offset] << 24 | d[offset + 1] << 16 | d[offset + 2] << 8 | d[offset + 3];
|
||||
}
|
||||
|
||||
public sealed class Bitmap : Image
|
||||
{
|
||||
public Bitmap(string filename) : base(File.ReadAllBytes(filename)) { }
|
||||
|
||||
public Bitmap(Stream stream) : base(ReadAll(stream)) { }
|
||||
|
||||
public Bitmap(Image original) : base(original.Data) { }
|
||||
|
||||
private static byte[] ReadAll(Stream stream)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
stream.CopyTo(buffer);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace System.Drawing.Imaging;
|
||||
|
||||
/// <summary>图片格式。Image.Save 时只用来对应文件类型,不做格式转换</summary>
|
||||
public sealed class ImageFormat
|
||||
{
|
||||
private readonly string _name;
|
||||
|
||||
private ImageFormat(string name, string mimeType)
|
||||
{
|
||||
_name = name;
|
||||
MimeType = mimeType;
|
||||
}
|
||||
|
||||
internal string MimeType { get; }
|
||||
|
||||
public static ImageFormat Jpeg { get; } = new("Jpeg", "image/jpeg");
|
||||
public static ImageFormat Png { get; } = new("Png", "image/png");
|
||||
public static ImageFormat Gif { get; } = new("Gif", "image/gif");
|
||||
public static ImageFormat Bmp { get; } = new("Bmp", "image/bmp");
|
||||
public static ImageFormat Icon { get; } = new("Icon", "image/x-icon");
|
||||
|
||||
public override string ToString() => _name;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Text.Json.Nodes;
|
||||
using Sim.Host;
|
||||
|
||||
namespace System.Windows.Forms;
|
||||
|
||||
public enum PictureBoxSizeMode { Normal, StretchImage, AutoSize, CenterImage, Zoom }
|
||||
|
||||
/// <summary>图片框(教材项目 4 显示摄像头画面)</summary>
|
||||
public class PictureBox : Control, ISupportInitialize
|
||||
{
|
||||
private Image? _image;
|
||||
private PictureBoxSizeMode _sizeMode;
|
||||
private BorderStyle _borderStyle;
|
||||
|
||||
protected override Size DefaultSize => new(100, 50);
|
||||
|
||||
/// <summary>
|
||||
/// 要显示的图片。不检查调用线程:教材在摄像头回调(后台线程)里直接给 Image 赋值,
|
||||
/// 真实 WinForms 的 PictureBox 也不会因此报错。
|
||||
/// </summary>
|
||||
public Image? Image
|
||||
{
|
||||
get => _image;
|
||||
set
|
||||
{
|
||||
LegacyThreads.CheckPoint();
|
||||
if (ReferenceEquals(_image, value)) return;
|
||||
_image = value;
|
||||
NotifyChanged(nameof(Image), value);
|
||||
if (_sizeMode == PictureBoxSizeMode.AutoSize) FitToImage();
|
||||
}
|
||||
}
|
||||
|
||||
public PictureBoxSizeMode SizeMode
|
||||
{
|
||||
get => _sizeMode;
|
||||
set
|
||||
{
|
||||
if (SetProp(ref _sizeMode, value, nameof(SizeMode)) && value == PictureBoxSizeMode.AutoSize) FitToImage();
|
||||
}
|
||||
}
|
||||
|
||||
public BorderStyle BorderStyle
|
||||
{
|
||||
get => _borderStyle;
|
||||
set => SetProp(ref _borderStyle, value, nameof(BorderStyle));
|
||||
}
|
||||
|
||||
public string? ImageLocation { get; set; }
|
||||
public Image? ErrorImage { get; set; }
|
||||
public Image? InitialImage { get; set; }
|
||||
public bool WaitOnLoad { get; set; }
|
||||
|
||||
/// <summary>从文件加载图片(只支持本地文件)</summary>
|
||||
public void Load(string url)
|
||||
{
|
||||
ImageLocation = url;
|
||||
Image = Image.FromFile(url);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ImageLocation)) Load(ImageLocation);
|
||||
}
|
||||
|
||||
// Designer.cs 里 VS 生成 ((ISupportInitialize)pictureBox1).BeginInit() / EndInit()
|
||||
public void BeginInit() { }
|
||||
public void EndInit() { }
|
||||
|
||||
private void FitToImage()
|
||||
{
|
||||
if (_image != null) Size = _image.Size;
|
||||
}
|
||||
|
||||
protected override void WriteProps(JsonObject props)
|
||||
{
|
||||
base.WriteProps(props);
|
||||
props["Image"] = PropValue.From(_image);
|
||||
props["SizeMode"] = _sizeMode.ToString();
|
||||
props["BorderStyle"] = _borderStyle.ToString();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public static class PropValue
|
||||
["underline"] = f.Underline,
|
||||
["strikeout"] = f.Strikeout,
|
||||
},
|
||||
Image image => new JsonObject { ["key"] = SimHost.ImageKey(image), ["width"] = image.Width, ["height"] = image.Height },
|
||||
Padding p => new JsonObject { ["left"] = p.Left, ["top"] = p.Top, ["right"] = p.Right, ["bottom"] = p.Bottom },
|
||||
IEnumerable list => ListOf(list),
|
||||
_ => JsonValueOf(value.ToString() ?? ""),
|
||||
|
||||
@@ -75,6 +75,42 @@ public static class SimHost
|
||||
_output.WriteLine(new JsonObject { ["type"] = "patch", ["changes"] = changes }.ToJsonString(JsonOptions));
|
||||
}
|
||||
|
||||
// ---------- 图片 ----------
|
||||
|
||||
private static readonly HashSet<string> SentImages = new();
|
||||
|
||||
/// <summary>
|
||||
/// 学生程序的"程序所在目录"(AppDomain.CurrentDomain.BaseDirectory),可写,程序结束后清掉。
|
||||
/// 运行器启动时设定;保存的图片按相对这里的路径显示在网页的照片列表里。
|
||||
/// </summary>
|
||||
public static string AppBaseDirectory { get; set; } = AppContext.BaseDirectory;
|
||||
|
||||
/// <summary>图片在浏览器端的编号;第一次用到时先把图片数据发过去,浏览器缓存后同一张图不再重发</summary>
|
||||
public static string ImageKey(System.Drawing.Image image)
|
||||
{
|
||||
var key = image.SimKey;
|
||||
bool isNew;
|
||||
lock (SentImages) isNew = SentImages.Add(key);
|
||||
if (isNew)
|
||||
{
|
||||
Send(new JsonObject
|
||||
{
|
||||
["type"] = "image",
|
||||
["key"] = key,
|
||||
["mime"] = image.MimeType,
|
||||
["data"] = Convert.ToBase64String(image.Data),
|
||||
});
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>学生程序用 Image.Save 保存了图片:网页的照片列表里显示出来</summary>
|
||||
public static void ImageSaved(string fullPath, System.Drawing.Image image)
|
||||
{
|
||||
var path = fullPath.StartsWith(AppBaseDirectory, StringComparison.Ordinal) ? fullPath[AppBaseDirectory.Length..] : fullPath;
|
||||
Send(new JsonObject { ["type"] = "imageSaved", ["path"] = path, ["image"] = ImageKey(image) });
|
||||
}
|
||||
|
||||
public static void SendConsole(string text) => Send(new JsonObject { ["type"] = "console", ["text"] = text });
|
||||
|
||||
public static void SendDevice(string device, string action, object? data)
|
||||
|
||||
Reference in new issue
Block a user