浏览代码

fix: 修复红色尺寸标注识别链路,按上传图纸真实标注重新计算

1) 剥离红框笔画后检测尺寸箭头(贴框箭头此前被连通域合并漏检);2) OCR 邻域扩至60px、剔除箭头头干扰、水平容差扩至60F;3) OCR 含糊数字输出候选并以 600mm 锚点交叉复核改判(本图 9400→3400)
缪子怡 2 周之前
父节点
当前提交
3a2ae93bd2
共有 1 个文件被更改,包括 35 次插入14 次删除
  1. 35 14
      AI快速报价_Demo2.optimizing.html

+ 35 - 14
AI快速报价_Demo2.optimizing.html

@@ -771,6 +771,14 @@ function annoInfer(sc600){if(!S.redArrow||!S.redArrow.len)return null;
 function annoScale(){const r=annoInfer(S.scale600||0);return r?r.scale:0;}
 function renderAnnoInfer(){const el=$("q-red-anno-infer");if(!el)return;const inf=S.redArrow?annoInfer(S.scale600||0):null;
   el.textContent=inf?(inf.estM>0?"|推断单位:"+inf.unit+"(标注 "+fmt2(inf.vm)+"m vs 600mm锚点反推 "+fmt2(inf.estM)+"m)":"|600mm锚点未定位,标注默认按 mm"):"";}
+/* OCR 含糊候选复核:存在多个候选值时,取与 600mm 锚点反推箭头实长偏差最小的候选(改善须≥0.05对数距离才改判,避免噪音翻转) */
+function annoPick(sc600){if(!S.redArrow||!S.redAnnoCands||S.redAnnoCands.length<2||!(sc600>0))return;
+  const estM=S.redArrow.len*sc600;if(!(estM>0))return;
+  const dist=v=>{let bd=1e9;for(const c of[v/1000,v/100,v])bd=Math.min(bd,Math.abs(Math.log(c/estM)));return bd;};
+  let bi=0,bd=dist(S.redAnnoCands[0]);
+  for(let i=1;i<S.redAnnoCands.length;i++){const d=dist(S.redAnnoCands[i]);if(d+0.05<bd){bd=d;bi=i;}}
+  if(bi>0){$("q-red-anno").value=S.redAnnoCands[bi];
+    logAI("· OCR 候选含糊("+S.redAnnoCands.join("/")+")→ 按 600mm 锚点反推箭头实长 "+fmt2(estM)+"m 复核,改判为 <b>"+S.redAnnoCands[bi]+"</b>","w");}}
 const sleep=ms=>new Promise(r=>setTimeout(r,ms));
 function precision(){if(S.ft==="cad")return{cls:"exact",txt:"精确值 ±0.1-1%"};if(S.redbox)return{cls:"est",txt:"估算值 ±2-5%(红框+标注)"};return S.scale?{cls:"est",txt:"估算值 ±5-8%(600mm锚点)"}:{cls:"na",txt:"精度:待识别"};}
 function toast(msg,cls){const t=document.createElement("div");t.className="toast "+(cls||"toast-info");t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),2600);}
@@ -911,23 +919,32 @@ function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
 function ocrNumber(mask,W,ar,cc,F){F=F||1;
   const comps=cc(mask).filter(c=>c.area>=5*F*F&&Math.max(c.w,c.h)>=3*F&&Math.max(c.w,c.h)<=16*F);
   const ax=ar.x+ar.w/2,ay=ar.y+ar.h/2,vert=ar.h>=ar.w;
-  const near=comps.filter(c=>{const cx=c.x+c.w/2,cy=c.y+c.h/2;return Math.abs(cx-ax)<=25*F&&Math.abs(cy-ay)<=(vert?ar.h:ar.w)/2+25*F;});
+  const near=comps.filter(c=>{const cx=c.x+c.w/2,cy=c.y+c.h/2;
+    if(vert&&cx>=ar.x-2*F&&cx<=ar.x+ar.w+2*F&&(cy<=ar.y+8*F||cy>=ar.y+ar.h-8*F))return false;   /* 剔除竖箭头两端箭头头 */
+    if(!vert&&cy>=ar.y-2*F&&cy<=ar.y+ar.h+2*F&&(cx<=ar.x+8*F||cx>=ar.x+ar.w-8*F))return false;   /* 剔除横箭头两端箭头头 */
+    return Math.abs(cx-ax)<=60*F&&Math.abs(cy-ay)<=(vert?ar.h:ar.w)/2+25*F;});   /* 水平容差扩至60F:兼容数字水平写在箭头较远处 */
   if(near.length<3||near.length>6)return 0;
   const T=digTemplates();
   /* [排序轴, 排序方向, 90°旋转次数] 覆盖两类排布:数字沿箭头旋转90°(竖排旋1/3、横排旋0/2)与数字水平写在箭头旁(自左而右旋0 / 自右而左旋2) */
   const combos=vert?[["y",-1,1],["y",1,3],["x",1,0],["x",-1,2]]:[["x",1,0],["x",-1,2],["y",-1,1],["y",1,3]];
-  let best=null;
+  let res=[];
   for(const cb of combos){const dir=cb[1],rq=cb[2];
     const list=near.slice().sort((a,b)=>dir*((cb[0]==="y"?a.y:a.x)-(cb[0]==="y"?b.y:b.x)));
-    let s="",sc=0,ok=true;
+    let s="",sc=0,ok=true;const amb=[];
     for(const c of list){let b={w:c.w+1,h:c.h+1,a:new Uint8Array((c.w+1)*(c.h+1))};
       for(let y=0;y<=c.h;y++)for(let x=0;x<=c.w;x++)b.a[y*b.w+x]=mask[(c.y+y)*W+(c.x+x)];
       for(let q=0;q<rq;q++)b=bmRot(b);
-      const nb=bmNorm(b,9,12);let bd=-1,bi=0;
-      for(let dg=0;dg<10;dg++){const v=bmIou(nb,T[dg]);if(v>bd){bd=v;bi=dg;}}
-      if(bd<0.45){ok=false;break;}sc+=bd;s+=bi;}
-    if(ok&&(!best||sc>best.sc))best={s,sc};}
-  if(!best)return 0;const n=+best.s;return best.s.length>=3&&n>=100&&n<=99999?n:0;}
+      const nb=bmNorm(b,9,12);let v1=-1,d1=0,v2=-1,d2=-1;
+      for(let dg=0;dg<10;dg++){const v=bmIou(nb,T[dg]);if(v>v1){v2=v1;d2=d1;v1=v;d1=dg;}else if(v>v2){v2=v;d2=dg;}}
+      if(v1<0.45){ok=false;break;}sc+=v1;s+=d1;
+      if(d2>=0&&d2!==d1&&v1-v2<=0.03)amb.push([s.length-1,d2]);}   /* 数字含糊(前两模板得分差≤0.03)→ 记录备选位 */
+    if(!ok)continue;
+    /* 含糊位枚举变体(最多3位),返回候选数组:首选为最高模板得分,其余交由 600mm 锚点交叉校验复核 */
+    let vars=[{s,sc}];for(const[p,dg]of amb.slice(0,3)){const ex=[];for(const it of vars)ex.push({s:it.s.slice(0,p)+dg+it.s.slice(p+1),sc:it.sc});vars=vars.concat(ex);}
+    for(const it of vars){const n=+it.s;if(it.s.length>=3&&n>=100&&n<=99999)res.push(it);}}
+  if(!res.length)return [];
+  res.sort((a,b)=>b.sc-a.sc);const out=[];for(const it of res){if(out.indexOf(+it.s)<0)out.push(+it.s);}
+  return out;}
 /* ---- 图纸红字柜体名称识别(如"2-电视柜"):柜种词汇表 + 汉字模板匹配 ---- */
 const CAB_NAMES=["餐边柜","电视柜","阳台柜","衣柜","衣帽间","书柜","书桌","储物柜","玄关柜","卫浴柜","榻榻米","鞋柜","酒柜","橱柜","开放柜","吊柜","地柜"];
 let CHR_T=null;
@@ -1039,7 +1056,6 @@ async function runAI(){
     for(let x=0;x<AW;x++){let run=0,st=0;for(let y2=0;y2<=AH;y2++){const v=y2<AH?mk[y2*AW+x]:0;if(v){if(!run)st=y2;run++;}else{if(run>=12)for(let y3=st;y3<y2;y3++)s[y3*AW+x]=1;run=0;}}}
     return s;};
   if(S.redbox){
-    const solid=runMask(redm);
     /* 闭合红圈/红框 → 内部"孔洞"即柜体边界(兼容粗笔圈注与细线矩形) */
     const ext=new Uint8Array(AW*AH),stk=[];
     for(let x=0;x<AW;x++){if(!redm[x])stk.push(x);if(!redm[(AH-1)*AW+x])stk.push((AH-1)*AW+x);}
@@ -1054,9 +1070,11 @@ async function runAI(){
         S.redRects.push({x:Math.max(0,h.x-2),y:Math.max(0,h.y-2),w:Math.min(AW-h.x-2,h.w+4),h:Math.min(AH-h.y-2,h.h+4)});}
     S.redRects.sort((a,b)=>b.w*b.h-a.w*a.h);
     logAI(ph("· 红框矩形提取:"+S.redRects.length+" 个柜体边界框(直接取图纸红框为柜体边界,不二次收紧;已过滤虚线箭头/标注文字)"),"w");await S2(200);
-    /* 红色尺寸标注检测:共线实线段合并为双头箭头,单长条亦视为箭头;记录包围盒供数字 OCR 裁剪 */
+    /* 红色尺寸标注检测:先剥离红框笔画(避免贴着红框的尺寸箭头因连通域合并被过滤),共线实线段合并为双头箭头,单长条亦视为箭头 */
     S.redArrow=null;
-    const arrows=cc(solid).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=6);
+    const redm2=redm.slice();
+    for(const rc of S.redRects)for(let yy=Math.max(0,rc.y-3);yy<=Math.min(AH-1,rc.y+rc.h+3);yy++)for(let xx=Math.max(0,rc.x-3);xx<=Math.min(AW-1,rc.x+rc.w+3);xx++)redm2[yy*AW+xx]=0;
+    const arrows=cc(runMask(redm2)).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=6);
     const abb=(a1,a2)=>({x:Math.min(a1.x,a2.x),y:Math.min(a1.y,a2.y),w:Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x),h:Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y)});
     for(const c of arrows){const len=Math.max(c.w,c.h)/k;if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,x:c.x,y:c.y,w:c.w,h:c.h};}
     for(let i1=0;i1<arrows.length;i1++)for(let j=i1+1;j<arrows.length;j++){const a1=arrows[i1],a2=arrows[j];
@@ -1068,7 +1086,7 @@ async function runAI(){
           if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,...abb(a1,a2)};}}}
     if(S.redArrow){logAI("· 红色尺寸标注检测:箭头长 "+Math.round(S.redArrow.len)+"px → 尝试 OCR 读取邻近红色数字","w");
       /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域,用红色掩码识别数字 */
-      const OF=2,OM=40,ar=S.redArrow;
+      const OF=2,OM=60,ar=S.redArrow;   /* 邻域扩至60:兼容数字水平写在箭头较远处的排布 */
       const ox0=Math.max(0,ar.x-OM),oy0=Math.max(0,ar.y-OM);
       const ow=Math.min(AW-ox0,ar.w+2*OM),oh=Math.min(AH-oy0,ar.h+2*OM);
       const oc2=document.createElement("canvas");oc2.width=ow*OF;oc2.height=oh*OF;
@@ -1077,8 +1095,9 @@ async function runAI(){
       const d2=og2.getImageData(0,0,ow*OF,oh*OF).data;
       const rm2=new Uint8Array(ow*OF*oh*OF);
       for(let i2=0;i2<rm2.length;i2++){const r2=d2[i2*4],g2=d2[i2*4+1],b3=d2[i2*4+2];if(r2>140&&r2-g2>50&&r2-b3>50)rm2[i2]=1;}
-      const num=ocrNumber(rm2,ow*OF,{x:(ar.x-ox0)*OF,y:(ar.y-oy0)*OF,w:ar.w*OF,h:ar.h*OF},mkCC(ow*OF),OF);
-      if(num){$("q-red-anno").value=num;logAI("· 红色尺寸标注 OCR 读出:<b>"+num+"</b>(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
+      const cands=ocrNumber(rm2,ow*OF,{x:(ar.x-ox0)*OF,y:(ar.y-oy0)*OF,w:ar.w*OF,h:ar.h*OF},mkCC(ow*OF),OF);
+      if(cands.length){$("q-red-anno").value=cands[0];S.redAnnoCands=cands;
+        logAI("· 红色尺寸标注 OCR 读出:<b>"+cands[0]+"</b>"+(cands.length>1?"(存在含糊候选 "+cands.join("/")+",将以 600mm 锚点交叉复核)":"")+"(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
       else logAI("· OCR 未能置信读出红色标注值 → 请在下方输入框确认标注数值(默认 2400,单位 mm)","e");}
   }
   /* 图纸已用红字标注柜体名称(如"2-电视柜")→ 识别之,对应红框柜体直接采用图纸名称,系统不再二次命名 */
@@ -1156,6 +1175,7 @@ async function runAI(){
     if(kitchen){
       /* 600mm 隐式参照物先建立图像比例;场景B 有红箭头标注时,标注值与锚点交叉校验推断尺寸单位后再校准 */
       const sc=0.6/(kitchen.dd/k);S.scale600=sc;S.scale=sc;S.scaleSrc="AI定位厨房台面深度=600mm锚点(全图通用)";
+      annoPick(sc);
       if(S.redArrow){const inf=annoInfer(sc);if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
         logAI("· 尺寸单位推断(场景B):标注值 "+(+$("q-red-anno").value)+" → 推断单位 "+inf.unit+"("+fmt2(inf.vm)+"m) vs 600mm锚点反推箭头长 "+fmt2(inf.estM)+"m → 1px="+(inf.scale*1000).toFixed(2)+"mm","w");}}
       overlays.push({t:"kitchen",x:kitchen.x/k,y:kitchen.y/k,w:kitchen.w/k,h:kitchen.h/k,label:"厨房"});
@@ -1344,6 +1364,7 @@ async function runAI(){
       else dNat=Math.min(b0.w,b0.h)/k;
       /* 600mm 隐式参照物应用于场景A/B:先建锚点比例;场景B 标注值与锚点交叉校验推断尺寸单位(mm/cm/m)后校准 */
       S.scale600=0.6/dNat;
+      annoPick(S.scale600);
       const inf=S.redArrow?annoInfer(S.scale600):null;
       if(inf){S.scale=inf.scale;S.scaleSrc="红箭头标注校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";
         logAI("· 尺寸单位推断(场景B):标注值 "+(+$("q-red-anno").value)+" → 推断单位 "+inf.unit+"("+fmt2(inf.vm)+"m) vs 600mm锚点反推箭头长 "+fmt2(inf.estM)+"m,偏差 "+Math.round(Math.abs(inf.vm-inf.estM)/inf.estM*100)+"% → 1px="+(inf.scale*1000).toFixed(2)+"mm","w");}