“虞渠成” 15c502c959 feat(scanQRcode): add center crop zoom and bilinear sampling for preview
新增扫码预览的中心裁切放大功能,支持自定义裁切比例,同时使用双线性插值提升画面缩放质量
2026-07-23 16:05:02 +08:00

830 lines
30 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;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Kill.UI.Components;
using UnityEngine;
using UnityEngine.UI;
namespace Kill.UI.Pages
{
/// <summary>
/// 二维码扫描器 - 调用摄像头扫描二维码
/// </summary>
public class ScanQRcode : MonoBehaviour
{
[Serializable]
public enum ScanType
{
ConnectDevice, // 连接设备
Other // 其他用途
}
[Header("UI组件")]
public RawImage cameraPreview; // 摄像头预览
[Header("扫描设置")]
float scanInterval = 0.2f; // 扫描间隔(秒)
public ScanType scanType = ScanType.ConnectDevice; // 扫描类型
[Header("小二维码优化")]
[Tooltip("启用图像放大(推荐用于小二维码)")]
public bool enableUpscale = true;
[Tooltip("放大倍数")]
public float upscaleFactor = 2f;
[Header("性能优化")]
[Tooltip("降低扫描分辨率推荐值640或480")]
public int scanResolutionWidth = 1080;
[Tooltip("扫描处理线程数")]
public bool useAsyncScan = true;
[Header("中心裁切放大")]
[Tooltip("截取画面中心区域的比例0-1值越小放越大推荐0.55~0.7")]
[Range(0.3f, 1.0f)]
public float centerCropRatio = 0.65f;
[Header("自动对焦")]
[Tooltip("启用自动对焦")]
public bool enableAutoFocus = true;
[Tooltip("对焦间隔(秒)")]
public float focusInterval = 1f;
#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")]
private static extern void _EnableContinuousAutoFocus();
[DllImport("__Internal")]
private static extern void _TriggerAutoFocus(float normalizedX, float normalizedY);
#endif
// 摄像头相关
private WebCamTexture webCamTexture;
private WebCamDevice currentDevice;
private bool isScanning = false;
private bool isProcessing = false;
// 扫描结果回调
public event Action<string> OnQRCodeScanned;
void OnEnable()
{
StartScan();
}
void OnDisable()
{
StopScan();
}
void Update()
{
#if UNITY_IOS && !UNITY_EDITOR
if (isScanning && Input.touchCount > 0)
{
Touch touch = Input.GetTouch(0);
if (touch.phase == TouchPhase.Began)
{
float normX = touch.position.x / Screen.width;
float normY = touch.position.y / Screen.height;
_TriggerAutoFocus(normX, normY);
}
}
#endif
}
/// <summary>
/// 开始扫描
/// </summary>
public void StartScan()
{
if (isScanning) return;
StartCoroutine(InitializeCamera());
}
/// <summary>
/// 停止扫描
/// </summary>
public void StopScan()
{
isScanning = false;
StopAllCoroutines();
if (webCamTexture != null && webCamTexture.isPlaying)
{
webCamTexture.Stop();
webCamTexture = null;
}
// 清理预览纹理
if (previewTexture != null)
{
Destroy(previewTexture);
previewTexture = null;
}
}
/// <summary>
/// 关闭扫码界面
/// </summary>
public void Close()
{
StopScan();
gameObject.SetActive(false);
}
/// <summary>
/// 初始化摄像头
/// </summary>
private IEnumerator InitializeCamera()
{
// Android 使用 Permission API 请求权限
#if UNITY_ANDROID && !UNITY_EDITOR
if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.Camera))
{
UnityEngine.Android.Permission.RequestUserPermission(UnityEngine.Android.Permission.Camera);
// 等待权限请求结果
yield return new WaitUntil(() => UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.Camera));
}
if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.Camera))
{
UpdateStatus("100085");
yield break;
}
#else
// 其他平台使用传统方式
yield return Application.RequestUserAuthorization(UserAuthorization.WebCam);
if (!Application.HasUserAuthorization(UserAuthorization.WebCam))
{
UpdateStatus("100085");
yield break;
}
#endif
// 获取后置摄像头
WebCamDevice[] devices = WebCamTexture.devices;
if (devices.Length == 0)
{
UpdateStatus("100086");
yield break;
}
// 优先使用后置摄像头,并选择支持自动对焦的摄像头
string deviceName = devices[0].name;
for (int i = 0; i < devices.Length; i++)
{
if (!devices[i].isFrontFacing)
{
deviceName = devices[i].name;
currentDevice = devices[i];
break;
}
}
RectTransform cameraPreviewRect=cameraPreview.GetComponent<RectTransform>();
// 使用 1080p 分辨率
webCamTexture = new WebCamTexture(deviceName, 1920, 1080, 30);
// 开始播放
webCamTexture.Play();
// 等待摄像头启动
yield return new WaitUntil(() => webCamTexture.width > 100);
#if UNITY_IOS && !UNITY_EDITOR
// iOS: 通过原生插件开启连续自动对焦autoFocusPoint 在 iOS 无效)
_EnableContinuousAutoFocus();
// 定期维护对焦状态,防止被系统重置
StartCoroutine(IOsFocusMaintainCoroutine());
#endif
webCamTexture.autoFocusPoint = new Vector2(0.5f, 0.5f);
// 调整预览画面比例
AdjustPreviewAspect();
isScanning = true;
// 开始预览更新和扫描
StartCoroutine(PreviewUpdateCoroutine());
StartCoroutine(ScanCoroutine());
}
/// <summary>
/// 调整预览画面比例 - 从相机画面中心截取最大适合预览画面比例的画面
/// 手机竖着拿,摄像头默认横向输出,需要旋转并截取中间区域
/// 预览框是横向的1080x680显示旋转后的竖向画面
/// </summary>
private void AdjustPreviewAspect()
{
if (webCamTexture == null || cameraPreview == null) return;
// 获取相机分辨率(摄像头默认横向,如 1920x1080
int cameraWidth = webCamTexture.width; // 1920
int cameraHeight = webCamTexture.height; // 1080
// 获取预览区域尺寸(横向预览框,比如 1080x680
RectTransform previewRect = cameraPreview.GetComponent<RectTransform>();
int previewWidth = (int)previewRect.rect.width; // 1080
int previewHeight = (int)previewRect.rect.height; // 680
// 创建预览纹理(与预览框相同尺寸)
if (previewTexture == null || previewTexture.width != previewWidth || previewTexture.height != previewHeight)
{
if (previewTexture != null)
{
Destroy(previewTexture);
}
previewTexture = new Texture2D(previewWidth, previewHeight, TextureFormat.RGB24, false);
}
// 从相机画面中裁切并旋转
// 手机竖着拿相机横向输出需要顺时针旋转90度
// 从相机画面中间截取适合预览比例的竖向区域
CutAndRotateCameraFrame(cameraWidth, cameraHeight, previewWidth, previewHeight);
// 设置预览纹理
cameraPreview.texture = previewTexture;
cameraPreview.uvRect = new Rect(0, 0, 1, 1);
// 重置旋转(因为已经在像素层面旋转了)
previewRect.localEulerAngles = Vector3.zero;
Debug.Log($"[ScanQRcode] 预览调整 - 相机: {cameraWidth}x{cameraHeight}, 预览: {previewWidth}x{previewHeight}");
}
#if UNITY_IOS && !UNITY_EDITOR
/// <summary>
/// iOS 对焦维护协程 — 定期重新设置连续自动对焦,防止被系统重置
/// </summary>
private IEnumerator IOsFocusMaintainCoroutine()
{
while (isScanning)
{
yield return new WaitForSeconds(3f);
_EnableContinuousAutoFocus();
}
}
#endif
private Texture2D previewTexture;
/// <summary>
/// 从相机画面裁切并旋转,生成预览画面
/// 支持中心裁切放大:只截取画面中心区域,放大到预览尺寸
/// </summary>
private void CutAndRotateCameraFrame(int camW, int camH, int targetW, int targetH)
{
// 相机横向 1920x1080需要顺时针旋转90度变成 1080x1920
// 预览框横向 1080x680
// 从旋转后的 1080x1920 中截取中间区域并放大
// 计算中心裁切区域大小(基于 centerCropRatio
int sourceCropH = Mathf.Max(1, Mathf.RoundToInt(camW * centerCropRatio));
int sourceCropW = Mathf.Max(1, Mathf.RoundToInt(camH * centerCropRatio));
int cropY = (camW - sourceCropH) / 2;
int cropX = (camH - sourceCropW) / 2;
// 计算缩放比例
float scaleY = (float)sourceCropH / targetH;
float scaleX = (float)sourceCropW / targetW;
// 获取相机像素数据
Color32[] cameraPixels = webCamTexture.GetPixels32();
Color32[] targetPixels = new Color32[targetW * targetH];
// 顺时针旋转90度 + 中心裁切 + 缩放
// 目标像素 (x, y) 对应源坐标:
// srcX = cropY + (targetH-1-y) * scaleY (裁切区域内纵向缩放)
// srcY = cropX + x * scaleX (裁切区域内横向缩放)
for (int y = 0; y < targetH; y++)
{
for (int x = 0; x < targetW; x++)
{
float srcX = cropY + (targetH - 1 - y) * scaleY;
float srcY = cropX + x * scaleX;
// 双线性插值采样
int sx = (int)srcX;
int sy = (int)srcY;
float fx = srcX - sx;
float fy = srcY - sy;
int sx1 = Mathf.Min(sx + 1, camW - 1);
int sy1 = Mathf.Min(sy + 1, camH - 1);
int idx00 = sy * camW + sx;
int idx10 = sy * camW + sx1;
int idx01 = sy1 * camW + sx;
int idx11 = sy1 * camW + sx1;
Color32 c00 = cameraPixels[idx00];
Color32 c10 = cameraPixels[idx10];
Color32 c01 = cameraPixels[idx01];
Color32 c11 = cameraPixels[idx11];
float r = (1-fx)*(1-fy)*c00.r + fx*(1-fy)*c10.r + (1-fx)*fy*c01.r + fx*fy*c11.r;
float g = (1-fx)*(1-fy)*c00.g + fx*(1-fy)*c10.g + (1-fx)*fy*c01.g + fx*fy*c11.g;
float b = (1-fx)*(1-fy)*c00.b + fx*(1-fy)*c10.b + (1-fx)*fy*c01.b + fx*fy*c11.b;
targetPixels[y * targetW + x] = new Color32(
(byte)Mathf.Clamp(r, 0, 255),
(byte)Mathf.Clamp(g, 0, 255),
(byte)Mathf.Clamp(b, 0, 255),
255
);
}
}
// 设置像素到预览纹理
previewTexture.SetPixels32(targetPixels);
previewTexture.Apply();
}
[SerializeField] private float previewUpdateInterval = 0.03f; // 预览更新间隔20fps
/// <summary>
/// 预览更新协程 - 降低更新频率避免卡顿
/// </summary>
private IEnumerator PreviewUpdateCoroutine()
{
while (isScanning && webCamTexture != null && webCamTexture.isPlaying)
{
// 更新预览画面(限制频率)
if (previewTexture != null)
{
CutAndRotateCameraFrame(webCamTexture.width, webCamTexture.height, previewTexture.width, previewTexture.height);
}
yield return new WaitForSeconds(previewUpdateInterval);
}
}
/// <summary>
/// 扫描协程
/// </summary>
private IEnumerator ScanCoroutine()
{
// 等待一帧确保摄像头已准备好
yield return null;
while (isScanning && webCamTexture != null && webCamTexture.isPlaying)
{
// 避免同时处理多帧
if (isProcessing)
{
yield return null;
continue;
}
isProcessing = true;
// 使用预览纹理进行扫描
if (previewTexture != null)
{
// 异步解析二维码以避免卡顿
if (useAsyncScan)
{
// 在主线程提取像素数据Unity API 必须在主线程调用)
Color32[] pixels = previewTexture.GetPixels32();
int texWidth = previewTexture.width;
int texHeight = previewTexture.height;
byte[] rgbBytes = new byte[pixels.Length * 3];
for (int i = 0; i < pixels.Length; i++)
{
rgbBytes[i * 3] = pixels[i].r;
rgbBytes[i * 3 + 1] = pixels[i].g;
rgbBytes[i * 3 + 2] = pixels[i].b;
}
string result = null;
bool scanComplete = false;
// 在后台线程执行 ZXing 解码(纯计算,不涉及 Unity API
System.Threading.ThreadPool.QueueUserWorkItem(_ =>
{
try
{
result = DecodeQRCodeFromBytes(rgbBytes, texWidth, texHeight);
}
catch (Exception e)
{
Debug.LogError($"[ScanQRcode] 解码异常: {e.Message}");
}
finally
{
scanComplete = true;
}
});
// 等待解码完成
yield return new WaitUntil(() => scanComplete);
// 处理结果
if (!string.IsNullOrEmpty(result))
{
ProcessScanResult(result);
}
}
else
{
// 同步解码
string result = DecodeQRCode(previewTexture);
if (!string.IsNullOrEmpty(result))
{
ProcessScanResult(result);
}
}
}
isProcessing = false;
yield return new WaitForSeconds(scanInterval);
}
}
/// <summary>
/// 处理扫描结果
/// </summary>
private void ProcessScanResult(string result)
{
Debug.Log($"扫描结果: {result}");
// 根据扫描类型处理结果
if (scanType == ScanType.ConnectDevice)
{
// 从结果中提取 MAC 地址
string macAddress = ExtractMacAddress(result);
if (!string.IsNullOrEmpty(macAddress))
{
// 扫描成功,仅回传 MAC 地址
OnQRCodeScanned?.Invoke(macAddress);
OnQRCodeScanned-=OnQRCodeScanned;
}
else
{
// 二维码不符合要求,显示提示
UpdateStatus("100084");
}
}
else
{
if (!string.IsNullOrEmpty(result))
{
// 其他用途,直接回传结果
OnQRCodeScanned?.Invoke(result);
}
else
{
// 二维码不符合要求,显示提示
UpdateStatus("100084");
}
}
}
/// <summary>
/// 获取摄像头当前帧
/// </summary>
private Texture2D GetSnapshot()
{
if (webCamTexture == null || !webCamTexture.isPlaying) return null;
try
{
Texture2D snapshot = new Texture2D(webCamTexture.width, webCamTexture.height);
snapshot.SetPixels(webCamTexture.GetPixels());
snapshot.Apply();
// // 如果启用放大,对图像进行放大处理
// if (enableUpscale && upscaleFactor > 1f)
// {
// snapshot = UpscaleTexture(snapshot, upscaleFactor);
// }
return snapshot;
}
catch
{
return null;
}
}
/// <summary>
/// 放大纹理(使用双线性插值)- 优化版本
/// </summary>
private Texture2D UpscaleTexture(Texture2D source, float factor)
{
// 限制放大倍数,避免过度消耗性能
factor = Mathf.Clamp(factor, 1f, 3f);
int newWidth = Mathf.RoundToInt(source.width * factor);
int newHeight = Mathf.RoundToInt(source.height * factor);
// 限制最大尺寸,避免内存问题
newWidth = Mathf.Min(newWidth, 1280);
newHeight = Mathf.Min(newHeight, 720);
// 如果尺寸没有变化,直接返回原图
if (newWidth <= source.width || newHeight <= source.height)
{
return source;
}
Texture2D result = new Texture2D(newWidth, newHeight, TextureFormat.RGB24, false);
// 使用 RenderTexture 进行高质量缩放
RenderTexture rt = RenderTexture.GetTemporary(newWidth, newHeight, 0, RenderTextureFormat.RGB565);
RenderTexture.active = rt;
Graphics.Blit(source, rt);
result.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0);
result.Apply();
RenderTexture.active = null;
RenderTexture.ReleaseTemporary(rt);
// 销毁原始纹理
Destroy(source);
return result;
}
/// <summary>
/// 解析二维码
/// </summary>
private string DecodeQRCode(Texture2D texture)
{
try
{
// 全图扫描
string result = TryDecodeRegion(texture, 0, 0, texture.width, texture.height);
if (!string.IsNullOrEmpty(result))
return result;
return null;
}
catch (Exception ex)
{
Debug.LogError($"二维码解析失败: {ex.Message}");
return null;
}
}
/// <summary>
/// 从原始字节数据解析二维码(不依赖 Unity API可在后台线程调用
/// </summary>
private string DecodeQRCodeFromBytes(byte[] rgbBytes, int width, int height)
{
try
{
var luminanceSource = new ZXing.RGBLuminanceSource(rgbBytes, width, height);
string result = TryDecodeWithBinarizer(luminanceSource);
if (!string.IsNullOrEmpty(result))
return result;
return null;
}
catch (Exception ex)
{
Debug.LogError($"二维码解析失败: {ex.Message}");
return null;
}
}
/// <summary>
/// 尝试解码指定区域
/// </summary>
private string TryDecodeRegion(Texture2D texture, int startX, int startY, int width, int height)
{
try
{
// 确保区域在有效范围内
startX = Mathf.Max(0, startX);
startY = Mathf.Max(0, startY);
width = Mathf.Min(width, texture.width - startX);
height = Mathf.Min(height, texture.height - startY);
// 获取全图像素数据
Color32[] allPixels = texture.GetPixels32();
// 提取指定区域的像素
Color32[] regionPixels = new Color32[width * height];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int srcIndex = (startY + y) * texture.width + (startX + x);
int dstIndex = y * width + x;
if (srcIndex < allPixels.Length)
{
regionPixels[dstIndex] = allPixels[srcIndex];
}
}
}
// 转换为 RGB 字节数组ZXing 内部会自动处理灰度转换)
byte[] rgbBytes = new byte[regionPixels.Length * 3];
for (int i = 0; i < regionPixels.Length; i++)
{
rgbBytes[i * 3] = regionPixels[i].r;
rgbBytes[i * 3 + 1] = regionPixels[i].g;
rgbBytes[i * 3 + 2] = regionPixels[i].b;
}
// 使用 RGBLuminanceSource让 ZXing 内部处理灰度转换
var luminanceSource = new ZXing.RGBLuminanceSource(rgbBytes, width, height);
// 尝试解码
string result = TryDecodeWithBinarizer(luminanceSource);
if (!string.IsNullOrEmpty(result))
return result;
return null;
}
catch
{
return null;
}
}
/// <summary>
/// 尝试使用不同的二值化算法解码
/// </summary>
private string TryDecodeWithBinarizer(ZXing.LuminanceSource luminanceSource)
{
var hints = new Dictionary<ZXing.DecodeHintType, object>
{
{ ZXing.DecodeHintType.TRY_HARDER, true },
{ ZXing.DecodeHintType.POSSIBLE_FORMATS, new List<ZXing.BarcodeFormat> { ZXing.BarcodeFormat.QR_CODE } }
};
var reader = new ZXing.QrCode.QRCodeReader();
// 尝试 HybridBinarizer
try
{
var binarizer = new ZXing.Common.HybridBinarizer(luminanceSource);
var binaryBitmap = new ZXing.BinaryBitmap(binarizer);
var result = reader.decode(binaryBitmap, hints);
if (result != null)
return result.Text;
}
catch { }
// 尝试 GlobalHistogramBinarizer
try
{
var binarizer = new ZXing.Common.GlobalHistogramBinarizer(luminanceSource);
var binaryBitmap = new ZXing.BinaryBitmap(binarizer);
var result = reader.decode(binaryBitmap, hints);
if (result != null)
return result.Text;
}
catch { }
return null;
}
/// <summary>
/// 应用锐化滤镜(拉普拉斯算子)
/// 增强图像边缘,提高模糊二维码识别率
/// </summary>
private byte[] ApplySharpenFilter(byte[] input, int width, int height)
{
byte[] output = new byte[input.Length];
// 拉普拉斯锐化核
// 0 -1 0
// -1 5 -1
// 0 -1 0
int[] kernel = { 0, -1, 0, -1, 5, -1, 0, -1, 0 };
int kernelSize = 3;
int halfKernel = kernelSize / 2;
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int sum = 0;
// 应用卷积核
for (int ky = -halfKernel; ky <= halfKernel; ky++)
{
for (int kx = -halfKernel; kx <= halfKernel; kx++)
{
int py = Mathf.Clamp(y + ky, 0, height - 1);
int px = Mathf.Clamp(x + kx, 0, width - 1);
int pixelIndex = py * width + px;
int kernelIndex = (ky + halfKernel) * kernelSize + (kx + halfKernel);
sum += input[pixelIndex] * kernel[kernelIndex];
}
}
// 限制在0-255范围内
output[y * width + x] = (byte)Mathf.Clamp(sum, 0, 255);
}
}
return output;
}
/// <summary>
/// 从字符串中提取 MAC 地址
/// 支持格式:
/// 1. 带冒号格式98:EA:A0:02:4E:06
/// 2. 不带冒号格式98eaa002658e
/// 3. 从键值对格式提取Name:xxx;MAC:98eaa002658e
/// </summary>
private string ExtractMacAddress(string input)
{
if (string.IsNullOrEmpty(input))
return null;
string macAddress = null;
// 尝试从键值对格式提取 MAC:xxx
// 匹配 MAC: 后面跟着 12 位十六进制字符(可能带冒号或不带)
System.Text.RegularExpressions.Regex macKeyValueRegex =
new System.Text.RegularExpressions.Regex(@"MAC:([0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2}:?[0-9A-Fa-f]{2})");
System.Text.RegularExpressions.Match kvMatch = macKeyValueRegex.Match(input);
if (kvMatch.Success)
{
macAddress = kvMatch.Groups[1].Value;
}
else
{
// 尝试匹配带冒号的标准 MAC 格式
System.Text.RegularExpressions.Regex macColonRegex =
new System.Text.RegularExpressions.Regex(@"[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}");
System.Text.RegularExpressions.Match colonMatch = macColonRegex.Match(input);
if (colonMatch.Success)
{
macAddress = colonMatch.Value;
}
else
{
// 尝试匹配不带冒号的 12 位十六进制字符
System.Text.RegularExpressions.Regex macNoColonRegex =
new System.Text.RegularExpressions.Regex(@"[0-9A-Fa-f]{12}");
System.Text.RegularExpressions.Match noColonMatch = macNoColonRegex.Match(input);
if (noColonMatch.Success)
{
macAddress = noColonMatch.Value;
}
}
}
if (!string.IsNullOrEmpty(macAddress))
{
// 统一转换为带冒号的大写格式
return FormatMacAddress(macAddress);
}
return null;
}
/// <summary>
/// 将 MAC 地址统一格式化为带冒号的大写格式
/// 输入: 98eaa002658e 或 98:EA:A0:02:65:8E
/// 输出: 98:EA:A0:02:65:8E
/// </summary>
private string FormatMacAddress(string mac)
{
if (string.IsNullOrEmpty(mac))
return null;
// 移除所有冒号
string cleanMac = mac.Replace(":", "").Replace("-", "").ToUpper();
// 检查长度是否为 12
if (cleanMac.Length != 12)
return null;
// 格式化为 XX:XX:XX:XX:XX:XX
return string.Format("{0}:{1}:{2}:{3}:{4}:{5}",
cleanMac.Substring(0, 2),
cleanMac.Substring(2, 2),
cleanMac.Substring(4, 2),
cleanMac.Substring(6, 2),
cleanMac.Substring(8, 2),
cleanMac.Substring(10, 2));
}
/// <summary>
/// 更新状态文字
/// </summary>
private void UpdateStatus(string code)
{
ToastUI.Show(code);
}
void OnDestroy()
{
StopScan();
gameObject.SetActive(false);
}
}
}