基线:全量源码首提(D1 版本控制落地,含第一轮优化 B1-B4/A1/A4/缩放修复)

This commit is contained in:
dqb-dev
2026-10-01 22:15:22 +08:00
commit 1e65792b09
499 changed files with 204579 additions and 0 deletions
+447
View File
@@ -0,0 +1,447 @@
// ============================================================
// OcrClient:OCR 引擎路由(baidu_ocr.json 的 engine 字段选择)
// · engine=baidu(默认):百度 OCR 高精度含位置版(HTTP 直连)
// · engine=glm:智谱 GLM-OCR 版面解析(手写字强,GlmOcrClient)
// · Key 都在 OcrPaths.ConfigPath 的 baidu_ocr.json(OCR设置窗口写):
// api_key/secret_key=百度;glm_api_key=智谱
// 百度未保存过/Key 为空 → 内置默认 Key 直接可用
// · 百度 access_token 缓存同目录 baidu_token.json(29 天,提前1天过期)
// · 未配 Key / 网络失败直接抛异常(界面提示),无本地回退
// ============================================================
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
namespace 成套报价软件
{
/// <summary>
/// OCR 配置/缓存/本体的路径解析(兼容独立版 pdfbt 的旧布局):
/// 主程序 exe 旁优先;装过独立版 pdfbt 的机器回退 pdfbt\ 子目录旧文件。
/// </summary>
public static class OcrPaths
{
/// <summary>
/// baidu_ocr.json:主 exe 旁已存在→直接用;否则看 pdfbt\ 子目录(独立版
/// pdfbt.exe 保存过的配置);都没有→主 exe 旁(首次保存的新文件)。
/// </summary>
public static string ConfigPath
{
get
{
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
string p = Path.Combine(baseDir, "baidu_ocr.json");
if (File.Exists(p)) return p;
string legacy = Path.Combine(baseDir, "pdfbt", "baidu_ocr.json");
if (File.Exists(legacy)) return legacy;
return p;
}
}
/// <summary>百度 token 缓存,与配置文件同目录(换 Key 后删它重换)。</summary>
public static string TokenCachePath
{
get { return Path.Combine(Path.GetDirectoryName(ConfigPath), "baidu_token.json"); }
}
}
public class OcrItem
{
public string Text;
public PointF[] Box; // 裁剪图像素坐标,4 点(左上起顺时针)
public float Score;
public RectangleF Bounds
{
get
{
if (Box == null || Box.Length == 0) return RectangleF.Empty;
float l = Box[0].X, t = Box[0].Y, r = Box[0].X, b = Box[0].Y;
foreach (var p in Box)
{
if (p.X < l) l = p.X; if (p.X > r) r = p.X;
if (p.Y < t) t = p.Y; if (p.Y > b) b = p.Y;
}
return RectangleF.FromLTRB(l, t, r, b);
}
}
}
public class OcrClient : IDisposable
{
public event Action<string> StatusChanged;
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
private string _ak = "", _sk = "";
private string _token;
private DateTime _tokenExpire = DateTime.MinValue;
// ★ 引擎路由:baidu(默认)/glm(智谱 GLM-OCR)/qianfan(百度 PaddleOCR-VL)/uapis(UApiPro)/umi(Umi-OCR 本地)
private string _engine = "baidu";
private readonly GlmOcrClient _glm = new GlmOcrClient();
private readonly QianfanOcrClient _qianfan = new QianfanOcrClient();
private readonly UapisOcrClient _uapis = new UapisOcrClient();
private readonly UmiOcrClient _umi = new UmiOcrClient();
/// <summary>当前 OCR 引擎(baidu/glm/qianfan/uapis/umi)。</summary>
public string Engine
{
get { return _engine; }
}
// 内置默认 Key(Base64 混淆,exe 内非明文):未保存过配置/Key 为空时直接使用
private static readonly string _defAk = Encoding.UTF8.GetString(Convert.FromBase64String("R0hjbjVxRmJLcG5TY3NyZHlIV1M4dzBB"));
private static readonly string _defSk = Encoding.UTF8.GetString(Convert.FromBase64String("VkMxVVU2RXlxN28wcGRLNElCZ1A4ZnNhWjIyQ3RlMWM="));
public OcrClient()
{
_glm.StatusChanged += msg =>
{
var h = StatusChanged;
if (h != null) h(msg);
};
_qianfan.StatusChanged += msg =>
{
var h = StatusChanged;
if (h != null) h(msg);
};
_uapis.StatusChanged += msg =>
{
var h = StatusChanged;
if (h != null) h(msg);
};
_umi.StatusChanged += msg =>
{
var h = StatusChanged;
if (h != null) h(msg);
};
LoadConfig();
}
/// <summary>读 baidu_ocr.json(OCR设置窗口保存后调用可热更新 Key/引擎)。</summary>
public void LoadConfig()
{
try
{
string p = OcrPaths.ConfigPath;
if (File.Exists(p))
{
var jsv = _json.Deserialize<Dictionary<string, string>>(File.ReadAllText(p));
if (jsv != null)
{
_ak = jsv.ContainsKey("api_key") ? (jsv["api_key"] ?? "") : "";
_sk = jsv.ContainsKey("secret_key") ? (jsv["secret_key"] ?? "") : "";
_engine = jsv.ContainsKey("engine") ? (jsv["engine"] ?? "baidu") : "baidu";
_glm.SetApiKey(jsv.ContainsKey("glm_api_key") ? (jsv["glm_api_key"] ?? "") : "");
_qianfan.SetApiKey(jsv.ContainsKey("qianfan_api_key") ? (jsv["qianfan_api_key"] ?? "") : "");
_uapis.SetApiKey(jsv.ContainsKey("uapis_api_key") ? (jsv["uapis_api_key"] ?? "") : "");
_umi.SetBaseUrl(jsv.ContainsKey("umi_url") ? (jsv["umi_url"] ?? "") : "");
}
}
}
catch { }
// 缺省回退内置默认 Key:装完即用,无需打开设置点保存;用户保存过则文件值优先
if (string.IsNullOrEmpty(_ak)) _ak = _defAk;
if (string.IsNullOrEmpty(_sk)) _sk = _defSk;
if (_engine != "baidu" && _engine != "glm" && _engine != "qianfan" && _engine != "uapis" && _engine != "umi")
_engine = "baidu";
}
public bool HasKey
{
get { return !string.IsNullOrEmpty(_ak) && !string.IsNullOrEmpty(_sk); }
}
private void Notify(string msg)
{
var h = StatusChanged;
if (h != null) h(msg);
}
/// <summary>获取 access_token(内存→文件缓存→在线换取)。失败抛异常。</summary>
private string GetToken()
{
if (_token != null && DateTime.Now < _tokenExpire) return _token;
string cachePath = OcrPaths.TokenCachePath;
try
{
if (File.Exists(cachePath))
{
var c = _json.Deserialize<Dictionary<string, object>>(File.ReadAllText(cachePath));
if (c != null && c.ContainsKey("token") && c.ContainsKey("expire"))
{
double exp;
double.TryParse(Convert.ToString(c["expire"]), out exp);
string tok = Convert.ToString(c["token"]);
if (tok.Length > 0 && DateTime.Now < new DateTime(1970, 1, 1).AddSeconds(exp))
{
_token = tok;
_tokenExpire = new DateTime(1970, 1, 1).AddSeconds(exp);
return _token;
}
}
}
}
catch { }
if (!HasKey)
throw new Exception("未配置百度 OCR Key(工具栏 OCR设置 填写 API Key / Secret Key)");
string url = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials"
+ "&client_id=" + Uri.EscapeDataString(_ak)
+ "&client_secret=" + Uri.EscapeDataString(_sk);
using (var wc = new WebClient())
{
wc.Encoding = Encoding.UTF8;
wc.Headers["Content-Type"] = "application/x-www-form-urlencoded";
string body = wc.UploadString(url, "POST", "");
var resp = _json.Deserialize<Dictionary<string, object>>(body);
if (resp == null || !resp.ContainsKey("access_token"))
throw new Exception("百度 token 获取失败: " +
(resp != null && resp.ContainsKey("error_description")
? Convert.ToString(resp["error_description"])
: body.Substring(0, Math.Min(120, body.Length))));
_token = Convert.ToString(resp["access_token"]);
double expires;
double.TryParse(Convert.ToString(resp.ContainsKey("expires_in") ? resp["expires_in"] : "2592000"), out expires);
_tokenExpire = DateTime.Now.AddSeconds(Math.Max(3600, expires - 86400));
try
{
var cache = new Dictionary<string, object>
{
{ "token", _token },
{ "expire", (_tokenExpire - new DateTime(1970, 1, 1)).TotalSeconds }
};
File.WriteAllText(cachePath, _json.Serialize(cache));
}
catch { }
return _token;
}
}
/// <summary>识别位图(300DPI 裁剪)。失败抛异常;成功通知引擎来源。
/// 竖排文字(实为旋转 90° 的横排)直接识别必乱码:细高框或平均置信度偏低时,
/// 旋转 ±90° 重识别并按平均置信度选优,坐标逆向还原回原图。</summary>
public Task<List<OcrItem>> RecognizeAsync(Bitmap crop, int timeoutMs)
{
// ★ 引擎路由:智谱 GLM-OCR / 千帆 PaddleOCR-VL(版面解析,不走旋转重试)
if (_engine == "glm")
return _glm.RecognizeAsync(crop, timeoutMs);
if (_engine == "qianfan")
return _qianfan.RecognizeAsync(crop, timeoutMs);
if (_engine == "uapis")
return _uapis.RecognizeAsync(crop, timeoutMs);
if (_engine == "umi")
return _umi.RecognizeAsync(crop, timeoutMs);
var tcs = new TaskCompletionSource<List<OcrItem>>();
Task.Factory.StartNew(delegate
{
try
{
var items = RecognizeOnce(crop, timeoutMs);
try
{
if (crop.Height >= crop.Width * 1.15f || AvgScore(items) < 0.88f)
{
float best = AvgScore(items);
foreach (bool cw in new bool[] { true, false })
{
using (var turned = new Bitmap(crop))
{
turned.RotateFlip(cw ? RotateFlipType.Rotate90FlipNone : RotateFlipType.Rotate270FlipNone);
var rr = RecognizeOnce(turned, timeoutMs);
UnrotateItems(rr, cw, crop.Width, crop.Height);
float s = AvgScore(rr);
if (s > best + 0.01f) { best = s; items = rr; }
}
}
}
}
catch { /* 旋转重试失败:保留原方向结果 */ }
Notify("百度OCR·高精度含位置版");
tcs.TrySetResult(items);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
}
/// <summary>单方向识别一次(编码→上传→解析);Box 为入参图像的像素坐标。</summary>
private List<OcrItem> RecognizeOnce(Bitmap img, int timeoutMs)
{
// 编码为百度限额内(高精度版 base64 后 ≤4MB)的 JPEG;返回缩放比用于还原识别坐标
float sendScale;
string b64 = EncodeFit(img, out sendScale);
string tok = GetToken();
string url = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate?access_token="
+ Uri.EscapeDataString(tok);
// Base64 的表单转义不能用 Uri.EscapeDataString:.NET Framework 对超长字符串
// (约 32K 字符)抛"无效的 URI: URI 字符串太长",大图 base64 必中
string form = "image=" + EscapeBase64(b64)
+ "&language_type=CHN_ENG&probability=true";
using (var wc = new WebClient())
{
wc.Encoding = Encoding.UTF8;
wc.Headers["Content-Type"] = "application/x-www-form-urlencoded";
string body = wc.UploadString(url, "POST", form);
var resp = _json.Deserialize<Dictionary<string, object>>(body);
if (resp == null)
throw new Exception("百度OCR返回解析失败");
if (resp.ContainsKey("error_code") && Convert.ToInt64(resp["error_code"]) != 0)
throw new Exception("百度OCR错误 " + Convert.ToString(resp["error_code"]) + ": "
+ Convert.ToString(resp.ContainsKey("error_msg") ? resp["error_msg"] : ""));
var items = new List<OcrItem>();
var arr = resp.ContainsKey("words_result") ? resp["words_result"] as System.Collections.ArrayList : null;
if (arr != null)
{
foreach (Dictionary<string, object> w in arr)
{
var loc = (w != null && w.ContainsKey("location")) ? w["location"] as Dictionary<string, object> : null;
if (loc == null) continue;
float l = LocF(loc, "left"), t = LocF(loc, "top");
float w2 = LocF(loc, "width"), h = LocF(loc, "height");
float score = 0.95f;
var prob = w.ContainsKey("probability") ? w["probability"] as Dictionary<string, object> : null;
if (prob != null && prob.ContainsKey("average"))
{
float sv;
if (float.TryParse(Convert.ToString(prob["average"]), out sv)) score = sv;
}
items.Add(new OcrItem
{
Text = Convert.ToString(w.ContainsKey("words") ? w["words"] : ""),
// location 是发送图的像素坐标,除以缩放比还原到原始裁剪图坐标
Box = new PointF[]
{
new PointF(l / sendScale, t / sendScale),
new PointF((l + w2) / sendScale, t / sendScale),
new PointF((l + w2) / sendScale, (t + h) / sendScale),
new PointF(l / sendScale, (t + h) / sendScale)
},
Score = score
});
}
}
return items;
}
}
/// <summary>平均置信度(无结果时 -1,便于选优时落败)。</summary>
private static float AvgScore(List<OcrItem> items)
{
if (items == null || items.Count == 0) return -1f;
float sum = 0;
foreach (var it in items) sum += it.Score;
return sum / items.Count;
}
/// <summary>把顺/逆时针旋转 90° 后识别出的 Box 坐标逆向还原到原图坐标系
/// (还原后重排为 左上/右上/右下/左下,Bounds 语义不变)。</summary>
private static void UnrotateItems(List<OcrItem> items, bool clockwise, int w, int h)
{
foreach (var it in items)
{
if (it.Box == null) continue;
float l = float.MaxValue, t = float.MaxValue, r = float.MinValue, b = float.MinValue;
foreach (var p in it.Box)
{
float x, y;
if (clockwise) { x = p.Y; y = h - 1f - p.X; }
else { x = w - 1f - p.Y; y = p.X; }
if (x < l) l = x; if (x > r) r = x;
if (y < t) t = y; if (y > b) b = y;
}
it.Box = new PointF[]
{
new PointF(l, t), new PointF(r, t),
new PointF(r, b), new PointF(l, b)
};
}
}
private static float LocF(Dictionary<string, object> loc, string key)
{
float v;
return (loc != null && loc.ContainsKey(key) &&
float.TryParse(Convert.ToString(loc[key]), out v)) ? v : 0f;
}
/// <summary>编码为百度限额内的 JPEG(高精度版要求 base64 后 ≤4MB):
/// 质量 90/75/60 递降试编码,均超限则按 0.8 倍降采样重试(最多 8 轮,必然收敛)。
/// scale=发送图/原图(≤1),调用方用它把返回坐标还原到原图。</summary>
private static string EncodeFit(Bitmap img, out float scale)
{
scale = 1f;
string last = null;
for (int round = 0; round < 8; round++)
{
int w = Math.Max(1, (int)Math.Round(img.Width * scale));
int h = Math.Max(1, (int)Math.Round(img.Height * scale));
using (Bitmap cur = (w == img.Width && h == img.Height) ? new Bitmap(img) : new Bitmap(img, w, h))
{
foreach (long q in new long[] { 90, 75, 60 })
{
last = EncodeJpeg(cur, q);
// base64 字符数≈UTF-8 字节数;3.9M 留余量防 4M 硬限被卡
if (last.Length <= 3900000) return last;
}
}
scale *= 0.8f;
}
return last;
}
private static string EncodeJpeg(Bitmap bmp, long quality)
{
using (var ms = new MemoryStream())
{
var codec = GetJpegEncoder();
if (codec != null)
{
using (var ep = new EncoderParameters(1))
{
ep.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality);
bmp.Save(ms, codec, ep);
}
}
else bmp.Save(ms, ImageFormat.Jpeg);
return Convert.ToBase64String(ms.ToArray());
}
}
/// <summary>Base64 的 URI 表单转义:仅 + / = 三个字符转 %XX,字母数字无需转义。
/// 不能用 Uri.EscapeDataString:.NET Framework 对约 32K 以上字符串抛"URI 字符串太长"。</summary>
private static string EscapeBase64(string s)
{
var sb = new StringBuilder(s.Length + s.Length / 2);
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (c == '+') sb.Append("%2B");
else if (c == '/') sb.Append("%2F");
else if (c == '=') sb.Append("%3D");
else sb.Append(c);
}
return sb.ToString();
}
private static ImageCodecInfo GetJpegEncoder()
{
foreach (var c in ImageCodecInfo.GetImageEncoders())
if (c.FormatID == ImageFormat.Jpeg.Guid) return c;
return null;
}
public void Dispose() { }
}
}