C# WinForm部署YOLOv8实例分割模型:ONNX Runtime工业质检实战
2026/9/3 10:01:54 网站建设 项目流程

简介:本资源是一套基于C# WinForm平台部署YOLOv8实例分割模型的完整工程源码,面向具备.NET开发基础与计算机视觉入门经验的开发者,解决在Windows桌面端高效集成ONNX格式实例分割模型的实际问题,适用于工业质检、智能标注工具开发等轻量级AI应用落地场景。压缩包共47个文件,包含14个核心DLL(含OpenCvSharp与ONNXRuntime运行时)、10个C#业务逻辑文件(涵盖模型加载、推理调度、结果可视化等模块)、7个XML配置与文档文件,以及ONNX模型、测试图像、可执行程序等关键资产,整体大小为101.94MB。已有1749人学习下载。读者可直接运行exe体验实时分割效果,源码结构清晰——含Yolov8SegManager模型管理器、SegmentationResult结果封装类、Form1主界面及配套设计器与资源文件,同时提供VS2019+Net Framework 4.7.2环境下的完整解决方案(sln/csproj),适配OpenCvSharp 4.8.0与ONNXRuntime 1.16.3,显著降低部署门槛。

1. 项目概述:从模型到桌面应用的全链路实现

最近在做一个工业质检的桌面端工具,核心需求是把训练好的YOLOv8实例分割模型集成到WinForm程序里,让产线操作员能直接在电脑上打开摄像头或加载图片进行实时检测和分割。网上关于Python部署的教程一大堆,但真正用C# WinForm做端到端集成,并且是处理实例分割这种复杂输出的完整案例并不多。踩了不少坑之后,我把整个流程和核心代码梳理了出来,形成了这套“C# Winform yolov8-onnx实例分割模型部署源码”。

这套方案的核心价值在于,它打通了从PyTorch训练模型到C#桌面应用落地的“最后一公里”。你不需要依赖Python环境,最终用户拿到的是一个独立的.exe可执行文件,双击就能运行。这对于需要交付给客户、部署在离线环境或者对执行效率有要求的场景来说,是刚需。整个过程涉及几个关键环节:模型导出为ONNX格式、在C#中加载并运行ONNX模型、解析复杂的实例分割输出(包括掩码),最后在WinForm界面上流畅地绘制检测框和分割蒙版。下面,我就把这套方案的实现思路、核心代码和避坑经验详细拆解一遍。

2. 核心思路与方案选型:为什么是ONNX + WinForm?

在决定技术栈时,我主要权衡了性能、易用性和部署便利性。为什么不直接用Python写界面?或者用TensorFlow.NET?

2.1 模型推理引擎的选择:ONNX Runtime的压倒性优势

对于C#环境下的模型推理,常见选项有:

  1. TensorFlow.NET:直接加载TensorFlow SavedModel或冻结图。缺点是生态相对小众,对OP支持可能滞后,且YOLOv8原生是PyTorch的,转换链条长。
  2. LibTorch (TorchSharp):通过C++ LibTorch的C#绑定来直接运行PyTorch模型。性能好,但需要管理额外的Native DLL依赖,部署打包麻烦。
  3. ONNX Runtime这是我们的最终选择。它是一个高性能的推理引擎,专门为ONNX模型优化,对C#的支持非常完善(通过Microsoft.ML.OnnxRuntimeNuGet包)。其优势非常明显:
    • 标准化:ONNX是开放的模型格式,成为PyTorch、TensorFlow等框架模型交换的事实标准。
    • 高性能:ONNX Runtime针对不同硬件(CPU/GPU)有深度优化,推理速度有保障。
    • 部署简单:只需一个NuGet包和对应的模型文件,无需复杂的环境配置或一堆Native库。
    • 跨平台:虽然我们目前是WinForm,但ONNX Runtime同样支持.NET Core/.NET 5+在其他平台运行,为未来留有余地。

因此,我们的技术路径非常清晰:PyTorch训练的YOLOv8实例分割模型 -> 导出为ONNX格式 -> 在C# WinForm中使用ONNX Runtime加载和推理。

2.2 界面框架选择:WinForm的务实之选

虽然WPF在UI表现力和MVVM架构上更现代,但WinForm在开发效率、控件成熟度、尤其是与传统工业上位机软件的兼容性上仍有巨大优势。我们的工具需要频繁操作图像(显示、绘制)、处理摄像头视频流,WinForm的PictureBoxTimer等控件简单直接,性能开销小。对于需要复杂属性设置的场景(如摄像头参数),完全可以通过自定义窗体或第三方UI库(如文章中提到的AntDesign的WinForm版本)来美化,而不是重构整个架构。WinForm项目编译后是单个exe(或附带少量dll),分发极其简单,这对客户现场部署至关重要。

2.3 整体架构设计

整个应用的架构可以概括为以下流程:

[YOLOv8 PyTorch模型] --(export)--> [.onnx模型文件] | v [C# WinForm Application] | | [ONNX Runtime推理引擎] [WinForm UI界面] | | [图像预处理] -> [推理] -> [输出解析] -> [结果渲染]

核心模块包括:

  1. 模型管理器:负责加载ONNX模型,创建推理会话(InferenceSession)。
  2. 图像预处理模块:将输入的System.Drawing.Bitmap或摄像头帧,转换为模型需要的张量(Tensor),包括缩放、归一化、通道转换(BGR->RGB)、维度调整等。
  3. 推理模块:调用ONNX Runtime进行前向传播。
  4. 后处理模块:这是难点和重点。YOLOv8实例分割的ONNX输出不同于目标检测,它包含多个输出节点(如boxes, scores, masks),需要对其进行解码、非极大值抑制(NMS)以及掩码(mask)的原图尺寸还原。
  5. UI渲染模块:将解析后的边界框、类别标签、置信度以及分割掩码,绘制到WinForm的PictureBox或自定义控件上。
  6. 摄像头/图像源管理模块:处理摄像头采集(如使用AForge.NET/OpenCvSharp等库)或本地图片加载。

3. 实操详解:从模型导出到C#集成

3.1 第一步:正确导出YOLOv8实例分割ONNX模型

这是所有工作的基础,导出错误会导致后续步骤全部失败。我使用的是Ultralytics官方库。

pip install ultralytics

导出命令的关键参数必须设置正确:

from ultralytics import YOLO # 加载训练好的模型 model = YOLO('path/to/your/yolov8n-seg.pt') # 例如 yolov8n-seg.pt # 导出ONNX模型 model.export(format='onnx', imgsz=640, # 训练/推理的图片尺寸,必须与训练时一致 opset=12, # ONNX算子集版本,建议12或以上 simplify=True, # 至关重要!启用ONNX简化,优化模型结构 dynamic=False) # 对于WinForm固定尺寸输入,建议设为False以优化性能

注意simplify=True参数极其重要。YOLOv8原始的ONNX导出可能包含一些复杂的算子组合,简化后模型结构更清晰,有时能避免在ONNX Runtime中遇到不支持的算子。可以使用onnxsim工具进行简化。

导出成功后,你会得到一个.onnx文件。用Netron(一个可视化工具)打开它,了解其输入输出结构。通常,输入是一个名为images的节点,形状为[1, 3, 640, 640](批大小,通道,高,宽)。输出通常包含:

  • output0:检测输出,形状为[1, 116, 8400](以640输入为例)。其中116 = 4(框坐标)+ 80(COCO类别数)+ 32(掩码系数)。
  • output1:或名为/model.22/mask_proto/Conv_output_0等,是原型掩码(prototype masks),形状如[1, 32, 160, 160]。这是生成实例分割掩码的关键。

3.2 第二步:创建C# WinForm项目并引入依赖

  1. 在Visual Studio中创建一个新的Windows窗体应用(.NET Framework 4.7.2+ 或 .NET 6/8)。
  2. 通过NuGet包管理器安装必须的库:
    • Microsoft.ML.OnnxRuntime:核心推理库。如果需要GPU加速,可以安装Microsoft.ML.OnnxRuntime.Gpu(确保CUDA/cuDNN环境正确)。
    • System.Drawing.Common:用于图像处理(.NET Core/5+项目可能需要单独安装)。
    • OpenCvSharp4OpenCvSharp4.runtime.win强烈推荐。虽然System.Drawing也能处理图像,但OpenCV在图像缩放、颜色空间转换、矩阵运算上更专业、高效,且其Mat对象与ONNX Runtime的Tensor转换更方便。AForge.NET较老,OpenCvSharp是更现代的选择。
    • (可选)Emgu.CV:OpenCV的另一个.NET封装,API风格不同,按喜好选择。

3.3 第三步:构建核心推理与后处理类

这是源码的核心部分。我们创建一个名为Yolov8SegPredictor的类。

3.3.1 初始化与模型加载
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; using System.Drawing; public class Yolov8SegPredictor { private InferenceSession _session; private int _inputWidth = 640; private int _inputHeight = 640; private float[] _mean = new float[] { 0.485f, 0.456f, 0.406f }; // ImageNet均值 private float[] _std = new float[] { 0.229f, 0.224f, 0.225f }; // ImageNet标准差 private string[] _classNames = { /* 你的类别名称数组,例如 ["person", "bicycle", ...] */ }; public Yolov8SegPredictor(string modelPath) { // 设置Session选项,例如使用GPU var sessionOptions = new SessionOptions(); // sessionOptions.AppendExecutionProvider_CUDA(0); // 如果使用GPU _session = new InferenceSession(modelPath, sessionOptions); // 也可以从输入节点信息获取尺寸,这里假设已知 // var inputMeta = _session.InputMetadata; // var inputName = _session.InputNames.First(); // var shape = inputMeta[inputName].Dimensions; } }
3.3.2 图像预处理:速度与精度的平衡

预处理的目标是将任意尺寸的输入图像,转换为模型需要的[1, 3, H, W]形状且归一化的张量。这里使用OpenCvSharp,效率远高于直接操作Bitmap。

private DenseTensor<float> Preprocess(Mat src) { // 1. 转换颜色通道 BGR -> RGB Mat rgb = new Mat(); Cv2.CvtColor(src, rgb, ColorConversionCodes.BGR2RGB); // 2. 调整尺寸并保持长宽比填充(LetterBox) Mat resized = new Mat(_inputHeight, _inputWidth, MatType.CV_8UC3, new Scalar(114, 114, 114)); float scale = Math.Min((float)_inputWidth / src.Width, (float)_inputHeight / src.Height); int newWidth = (int)(src.Width * scale); int newHeight = (int)(src.Height * scale); Mat roi = resized[new Rect((_inputWidth - newWidth) / 2, (_inputHeight - newHeight) / 2, newWidth, newHeight)]; Cv2.Resize(rgb, roi, new Size(newWidth, newHeight)); // 3. 转换为float32并归一化 [0,255] -> [0,1] Mat floatMat = new Mat(); resized.ConvertTo(floatMat, MatType.CV_32FC3, 1.0 / 255.0); // 4. 应用标准化 (x - mean) / std // 注意:OpenCV的Split返回的是单通道Mat数组 Mat[] channels = new Mat[3]; Cv2.Split(floatMat, out channels); for (int i = 0; i < 3; i++) { Cv2.Subtract(channels[i], new Scalar(_mean[i]), channels[i]); Cv2.Divide(channels[i], new Scalar(_std[i]), channels[i]); } Cv2.Merge(channels, floatMat); // 5. 转换为DenseTensor,并调整维度顺序 HWC -> CHW var tensorData = new float[_inputHeight * _inputWidth * 3]; unsafe { float* ptr = (float*)floatMat.Data; int channelStride = _inputHeight * _inputWidth; for (int c = 0; c < 3; c++) { for (int h = 0; h < _inputHeight; h++) { for (int w = 0; w < _inputWidth; w++) { // 内存布局转换 tensorData[c * channelStride + h * _inputWidth + w] = ptr[(h * _inputWidth + w) * 3 + c]; } } } } // 6. 创建Tensor,注意维度是 [1, 3, height, width] return new DenseTensor<float>(tensorData, new[] { 1, 3, _inputHeight, _inputWidth }); }

实操心得:预处理中的LetterBox(保持长宽比的填充)非常重要,它避免了图像变形,使模型推理更准确。填充的颜色(这里是114)需要与模型训练时使用的保持一致(YOLO系列常用114)。归一化和标准化的参数也必须与训练时一致,通常使用ImageNet的均值和标准差。

3.3.3 执行推理与后处理(核心难点)

推理本身很简单,难点在于解析输出。

public List<SegmentationResult> Predict(Mat srcImage) { // 1. 预处理 var inputTensor = Preprocess(srcImage); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", inputTensor) }; // 2. 推理 using (var results = _session.Run(inputs)) { var output0 = results.FirstOrDefault(r => r.Name == "output0")?.AsTensor<float>(); var output1 = results.FirstOrDefault(r => r.Name == "output1")?.AsTensor<float>(); // 原型掩码 if (output0 == null || output1 == null) return new List<SegmentationResult>(); // 3. 解析检测输出 (output0) // output0 形状: [1, 116, 8400] int numClasses = _classNames.Length; int maskDim = 32; // 掩码系数维度,根据模型确定 int numAnchors = output0.Dimensions[2]; // 8400 var proposals = new List<SegmentationResult>(); for (int i = 0; i < numAnchors; i++) { // 获取第i个anchor的116维向量 float confidence = 0; int classId = -1; // 找到最大类别置信度 for (int c = 0; c < numClasses; c++) { float clsScore = output0[0, 4 + c, i]; if (clsScore > confidence) { confidence = clsScore; classId = c; } } // 获取框的置信度(objectness) float boxScore = output0[0, 4, i]; // 综合得分 = 框置信度 * 类别置信度 float totalScore = boxScore * confidence; if (totalScore > 0.5f) // 设置一个较低的初始阈值 { // 解析边界框 (cx, cy, w, h) 格式,需要转换为 (x1, y1, x2, y2) float cx = output0[0, 0, i]; float cy = output0[0, 1, i]; float w = output0[0, 2, i]; float h = output0[0, 3, i]; float x1 = cx - w / 2; float y1 = cy - h / 2; float x2 = cx + w / 2; float y2 = cy + h / 2; // 提取32维掩码系数 float[] maskCoefficients = new float[maskDim]; for (int m = 0; m < maskDim; m++) { maskCoefficients[m] = output0[0, 4 + numClasses + m, i]; } proposals.Add(new SegmentationResult { BoundingBox = new RectangleF(x1, y1, w, h), Score = totalScore, ClassId = classId, MaskCoefficients = maskCoefficients }); } } // 4. 非极大值抑制 (NMS) proposals = NonMaxSuppression(proposals, 0.45f); // 5. 生成分割掩码 foreach (var proposal in proposals) { proposal.Mask = GenerateMask(proposal.MaskCoefficients, output1, proposal.BoundingBox, srcImage.Size()); } // 6. 将坐标映射回原图尺寸 // 因为预处理时做了LetterBox,需要将归一化坐标(0~1)转换回原图坐标,并去除填充偏移 // ... (坐标映射代码,需根据LetterBox参数计算) return proposals; } }

后处理关键点解析:

  1. 输出结构理解output0116维向量是[cx, cy, w, h, obj_score, class_scores..., mask_coefficients...]。你需要清楚你的模型类别数,才能正确分割出掩码系数的起始位置。
  2. 掩码生成GenerateMask函数是核心。它利用output1(原型掩码,形状[1, 32, 160, 160])和每个检测框对应的32个掩码系数,通过矩阵乘法生成160x160的掩码,然后通过sigmoid激活、阈值化、缩放到边界框大小,再根据原图与输入图像的变换关系,映射到原图位置。
  3. 坐标映射:由于预处理进行了缩放和填充,模型输出的坐标是相对于640x640输入图像的。必须记录下缩放比例scale和填充偏移(dx, dy),将检测框和掩码坐标正确映射回原始图像尺寸。
3.3.4 非极大值抑制(NMS)实现
private List<SegmentationResult> NonMaxSuppression(List<SegmentationResult> proposals, float iouThreshold) { // 按置信度降序排序 proposals = proposals.OrderByDescending(p => p.Score).ToList(); var selected = new List<SegmentationResult>(); while (proposals.Count > 0) { var current = proposals[0]; selected.Add(current); proposals.RemoveAt(0); for (int i = proposals.Count - 1; i >= 0; i--) { if (current.ClassId != proposals[i].ClassId) continue; float iou = CalculateIoU(current.BoundingBox, proposals[i].BoundingBox); if (iou > iouThreshold) { proposals.RemoveAt(i); } } } return selected; } private float CalculateIoU(RectangleF a, RectangleF b) { float interX1 = Math.Max(a.Left, b.Left); float interY1 = Math.Max(a.Top, b.Top); float interX2 = Math.Min(a.Right, b.Right); float interY2 = Math.Min(a.Bottom, b.Bottom); float interArea = Math.Max(0, interX2 - interX1) * Math.Max(0, interY2 - interY1); float unionArea = a.Width * a.Height + b.Width * b.Height - interArea; return interArea / unionArea; }

3.4 第四步:WinForm界面集成与渲染

在WinForm的主窗体中,我们集成上述预测器。

  1. 界面设计:放置PictureBox(用于显示图像)、Button(打开文件、开始/停止摄像头)、ComboBox(选择摄像头)、Label(显示FPS和结果信息)。
  2. 摄像头处理:可以使用OpenCvSharpVideoCaptureAForge.NETVideoCaptureDevice。这里以OpenCvSharp为例,使用一个Timer来循环抓帧。
private VideoCapture _capture; private Timer _timer; private Yolov8SegPredictor _predictor; private void btnStartCamera_Click(object sender, EventArgs e) { _capture = new VideoCapture(0); // 打开默认摄像头 if (!_capture.IsOpened()) return; _timer = new Timer { Interval = 30 }; // ~33 FPS _timer.Tick += ProcessFrame; _timer.Start(); } private void ProcessFrame(object sender, EventArgs e) { using (var frameMat = new Mat()) { if (!_capture.Read(frameMat) || frameMat.Empty()) return; // 执行预测 var results = _predictor.Predict(frameMat); // 在Mat上绘制结果 DrawResults(frameMat, results); // 将Mat转换为Bitmap并显示到PictureBox pictureBox1.Image?.Dispose(); pictureBox1.Image = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(frameMat); } } private void DrawResults(Mat image, List<SegmentationResult> results) { foreach (var result in results) { // 1. 绘制边界框 var rect = result.BoundingBox; Cv2.Rectangle(image, new Point((int)rect.X, (int)rect.Y), new Point((int)(rect.X + rect.Width), (int)(rect.Y + rect.Height)), new Scalar(0, 255, 0), 2); // 2. 绘制标签和置信度 string label = $"{_classNames[result.ClassId]} {result.Score:F2}"; Cv2.PutText(image, label, new Point((int)rect.X, (int)rect.Y - 5), HersheyFonts.HersheySimplex, 0.5, new Scalar(0, 255, 0), 1); // 3. 绘制分割掩码 (半透明覆盖) if (result.Mask != null) { // result.Mask 是一个二值化的Mat,大小与原图一致 // 创建一个彩色掩码层 using (var colorMask = new Mat(image.Size(), MatType.CV_8UC3, new Scalar(0, 255, 0))) // 绿色掩码 { colorMask.CopyTo(image, result.Mask); // 仅在有掩码的位置覆盖颜色 } } } }

注意事项:在Timer事件或摄像头回调中,一定要处理好资源释放。MatBitmap都是非托管资源,必须及时Dispose(),否则会导致内存泄漏。可以使用using语句确保释放。

4. 性能优化与常见问题排查

4.1 性能优化技巧

  1. 启用GPU推理:如果机器有NVIDIA GPU,安装Microsoft.ML.OnnxRuntime.Gpu包,并在创建SessionOptions时指定GPU执行提供程序。这通常能带来数倍至数十倍的性能提升。
    var sessionOptions = new SessionOptions(); sessionOptions.AppendExecutionProvider_CUDA(0); // 使用第一个GPU // 或者使用更通用的方式,优先尝试GPU try { sessionOptions.AppendExecutionProvider_CUDA(0); } catch { // 回退到CPU sessionOptions.AppendExecutionProvider_CPU(); }
  2. 固定输入尺寸:导出模型时使用dynamic=False,并确保推理时输入尺寸固定。这允许ONNX Runtime进行图优化。
  3. 复用输入输出容器:在循环推理中(如处理视频流),避免在每次推理时都创建新的DenseTensorList<NamedOnnxValue>。可以复用这些对象,只更新其中的数据。
  4. 图像预处理优化:使用OpenCV的Mat操作远比System.DrawingBitmap操作快。LetterBox和归一化等操作尽量在Mat层面完成。
  5. 异步处理:如果UI在推理时卡顿,可以将推理操作放到Task.Run中异步执行,但要注意线程安全,通过Invoke更新UI。

4.2 常见问题与解决方案实录

问题1:加载ONNX模型时抛出异常,提示“Failed to load model from ...”。

  • 排查:首先检查模型文件路径是否正确。其次,可能是ONNX Runtime版本与模型不兼容(尤其是opset版本)。尝试使用与模型导出时匹配或更新的ONNX Runtime版本。
  • 解决:使用Netron打开模型,查看其要求的opset版本。确保安装的Microsoft.ML.OnnxRuntime支持该opset。

问题2:推理时出错,提示“不支持的算子:XXX”。

  • 排查:YOLOv8导出的原始ONNX可能包含一些非常规算子。
  • 解决:导出模型时务必加上simplify=True参数。如果还不行,可以尝试使用onnx-simplifier工具对导出的模型进行二次简化:python -m onnxsim input.onnx output.onnx

问题3:检测框位置错乱或掩码对不上。

  • 排查:99%的问题出在坐标映射上。模型输出的坐标是相对于预处理后640x640图像的,并且是归一化的(cx, cy, w, h)格式。
  • 解决:仔细核对预处理LetterBox的缩放比例scale和填充偏移(dx, dy)。后处理时,先将模型输出的归一化坐标乘以640得到在输入图像上的坐标,然后减去填充偏移,最后除以缩放比例得到在原图上的坐标。务必为每个检测框单独记录其对应的变换参数

问题4:内存泄漏,程序运行一段时间后崩溃。

  • 排查:WinForm中常见的泄漏点是BitmapMat没有释放,以及InferenceSession的输入输出IDisposable对象没有释放。
  • 解决
    • 所有MatBitmap对象使用using语句或在finally块中Dispose
    • _session.Run()返回的IDisposableReadOnlyCollection<DisposableNamedOnnxValue>必须被Dispose,或者用using包裹。
    • 在窗体的FormClosing事件中,确保释放_capture,_timer,_predictor等资源。

问题5:FPS很低,无法达到实时。

  • 排查:首先用性能分析工具(如Visual Studio Diagnostic Tools)查看是CPU瓶颈还是GPU瓶颈。
  • 解决
    • CPU瓶颈:检查是否启用了GPU推理。优化预处理和后处理代码,避免不必要的循环和内存拷贝。考虑将预处理和后处理移至GPU(但这需要更复杂的CUDA编程,通常不推荐在C#中做)。
    • GPU瓶颈:模型可能太大。考虑使用更小的YOLOv8模型(如yolov8n-seg.pt),或者对模型进行INT8量化。ONNX Runtime支持静态和动态量化,可以显著减少模型大小并提升推理速度,但可能会轻微损失精度。
    • I/O瓶颈:如果从摄像头读取帧慢,尝试降低分辨率或使用更高效的摄像头驱动。

问题6:在GTX 1660 Ti等显卡上运行GPU版本报错。

  • 排查:错误信息可能类似“Cuda error: no kernel image is available for execution on the device”。
  • 解决:这是因为ONNX Runtime GPU包预编译的CUDA内核可能与你的显卡算力不兼容。GTX 1660 Ti是图灵架构(算力7.5)。你需要确保安装了与你的CUDA版本和显卡算力匹配的ONNX Runtime包,或者从源码编译ONNX Runtime。一个更简单的方法是尝试使用TensorRT EP(Execution Provider)。先将ONNX模型转换为TensorRT引擎,然后使用ONNX Runtime的TensorRT EP来加载,这通常能获得最佳性能,并且兼容性更好。但这需要安装TensorRT,步骤更复杂。

这套源码的价值在于提供了一个完整、可工作的起点。你可以在此基础上,根据具体业务需求增加功能,如多模型切换、结果保存、报警联动、模型热更新等。桌面端AI应用开发,核心在于稳定、高效和易用,而这套基于C# WinForm和ONNX Runtime的方案,经过实践验证,是能够满足这些要求的可靠选择。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询