Procházet zdrojové kódy

feat: 移除蓝色箭头规则,尺寸标注统一为红色(红箭头+红色数字 OCR 自动读数)

柜体一律取红框边界;ocrNumber 覆盖数字水平排布组合;UI 文案同步更新
缪子怡 před 2 týdny
rodič
revize
46bf60866e
1 změnil soubory, kde provedl 26 přidání a 37 odebrání
  1. 26 37
      AI快速报价_Demo2.optimizing.html

+ 26 - 37
AI快速报价_Demo2.optimizing.html

@@ -403,11 +403,11 @@
           <div style="margin-bottom:12px;">
             <div style="font-size:14px;font-weight:600;color:var(--primary);">🎨 图纸识别标准规则设置(户型图 / CAD 通用)</div>
           </div>
-          <div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">平面户型图与CAD图纸可统一用红色框标注范围,AI抓取红框边界做完整测量;无红框时 AI 全自动识别;尺寸标注箭头支持红/蓝两色(蓝色自动 OCR 读数)。</div>
-          <div style="font-size:12px;font-weight:600;color:#2D3748;margin-bottom:8px;">标注颜色(红框圈注 / 色尺寸标注):</div>
+          <div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;">平面户型图与CAD图纸可统一用红色框标注范围,AI抓取红框边界做完整测量;无红框时 AI 全自动识别;尺寸标注统一为红色(红箭头+红色数字自动 OCR 读数)。</div>
+          <div style="font-size:12px;font-weight:600;color:#2D3748;margin-bottom:8px;">标注颜色(红框圈注 / 色尺寸标注):</div>
           <div style="margin-bottom:14px;">
-            <select style="width:100%;" disabled title="红框=柜体圈注;蓝色箭头=尺寸标注(OCR 自动读数)">
-              <option value="red" selected>红色框(柜体圈注)+蓝色箭头(尺寸标注)· 隐式参照物为厨房台面深度600mm</option>
+            <select style="width:100%;" disabled title="红框=柜体圈注;红色箭头/红色数字=尺寸标注(OCR 自动读数)">
+              <option value="red" selected>红色框(柜体圈注)+红色尺寸标注(箭头+数字 OCR 自动读数)· 隐式参照物为厨房台面深度600mm</option>
             </select>
           </div>
           <div style="padding:10px 12px;background:#F0FFF4;border:1px solid #9AE6B4;border-radius:8px;font-size:12px;color:#276749;line-height:1.8;">
@@ -473,7 +473,7 @@
       </div>
 
       <div id="q-red-anno-row" style="display:none;margin-top:14px;background:#FFF5F5;border:1px solid #FED7D7;border-radius:8px;padding:8px 12px;font-size:12px;color:#C53030;">
-        🎯 标注箭头(红/蓝):长度已检测,标注值 = <input type="number" id="q-red-anno" value="2400" style="width:80px;padding:2px 6px;">(色标注会先尝试 OCR 自动读数;单位自动推断:与 600mm 台面深度锚点交叉校验 mm/cm/m,锚点未定位默认 mm)→ 据此建立全图比例(±2-5%)<span id="q-red-anno-infer" style="color:#97266D;font-weight:600;"></span>
+        🎯 红色标注箭头:长度已检测,标注值 = <input type="number" id="q-red-anno" value="2400" style="width:80px;padding:2px 6px;">(色标注会先尝试 OCR 自动读数;单位自动推断:与 600mm 台面深度锚点交叉校验 mm/cm/m,锚点未定位默认 mm)→ 据此建立全图比例(±2-5%)<span id="q-red-anno-infer" style="color:#97266D;font-weight:600;"></span>
       </div>
       <div id="q-ai-results" style="display:none;gap:16px;flex-wrap:wrap;margin-top:16px;align-items:stretch;">
         <div style="flex:3;min-width:380px;border:1px solid var(--border);border-radius:8px;background:#fff;overflow:hidden;">
@@ -883,7 +883,7 @@ function dilateMask(m,w,h,it){let cur=m;for(let t=0;t<it;t++){const o=new Uint8A
   for(let y=1;y<h-1;y++)for(let x=1;x<w-1;x++){const i=y*w+x;
     if(cur[i]){o[i-1]=1;o[i+1]=1;o[i-w]=1;o[i+w]=1;}}
   cur=o;}return cur;}
-/* ========== 标注数字 OCR(模板匹配 0-9,支持 90° 旋转,用于色尺寸标注自动读数) ========== */
+/* ========== 标注数字 OCR(模板匹配 0-9,支持 90° 旋转,用于色尺寸标注自动读数) ========== */
 function bmCrop(data,w,h){let x0=1e9,y0=1e9,x1=-1,y1=-1;for(let y=0;y<h;y++)for(let x=0;x<w;x++){if(data[(y*w+x)*4+3]>120){if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;}}
   if(x1<0)return null;const W=x1-x0+1,H=y1-y0+1,a=new Uint8Array(W*H);
   for(let y=0;y<H;y++)for(let x=0;x<W;x++)a[y*W+x]=data[((y0+y)*w+x0+x)*4+3]>120?1:0;return {w:W,h:H,a};}
@@ -914,10 +914,11 @@ function ocrNumber(mask,W,ar,cc,F){F=F||1;
   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;});
   if(near.length<3||near.length>6)return 0;
   const T=digTemplates();
-  const combos=vert?[[-1,1],[1,3]]:[[1,0],[-1,2]];   /* [排序方向, 90°旋转次数] 仅几何自洽组合:逆时针90°标注=自下而上+旋1;顺时针90°=自上而下+旋3;水平=自左而右+旋0 / 倒置=自右而左+旋2 */
+  /* [排序轴, 排序方向, 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;
-  for(const cb of combos){const dir=cb[0],rq=cb[1];
-    const list=near.slice().sort((a,b)=>dir*((vert?a.y:a.x)-(vert?b.y:b.x)));
+  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;
     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)];
@@ -1053,44 +1054,32 @@ 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 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];
       if(a1.h>a1.w&&a2.h>a2.w&&Math.abs(a1.x-a2.x)<=4){const gap=Math.max(a1.y,a2.y)-Math.min(a1.y+a1.h,a2.y+a2.h);
         if(gap>=0&&gap<=40){const len=(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k;
-          if(!S.redArrow||len>S.redArrow.len)S.redArrow={len};}}
+          if(!S.redArrow||len>S.redArrow.len)S.redArrow={len,...abb(a1,a2)};}}
       else if(a1.w>a1.h&&a2.w>a2.h&&Math.abs(a1.y-a2.y)<=4){const gap=Math.max(a1.x,a2.x)-Math.min(a1.x+a1.w,a2.x+a2.w);
         if(gap>=0&&gap<=40){const len=(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k;
-          if(!S.redArrow||len>S.redArrow.len)S.redArrow={len};}}}
-    if(S.redArrow)logAI("· 红箭头标注检测:长 "+Math.round(S.redArrow.len)+"px → 请在结果面板确认标注值,尺寸单位由 600mm 台面深度锚点交叉推断(场景B ±2-5%)","w");
-  }
-  /* 蓝色尺寸标注箭头(部分图纸标注为蓝色双头箭头,如 3150):红箭头优先;单长条亦视为箭头 */
-  if(!S.redArrow){
-    const bm=new Uint8Array(AW*AH);for(let i=0;i<AW*AH;i++){const r=d[i*4],g=d[i*4+1],b2=d[i*4+2];if(r<150&&b2>140&&b2-r>60&&b2-g>25)bm[i]=1;}
-    const bcomps=cc(runMask(bm)).filter(c=>Math.max(c.w,c.h)>=18&&Math.min(c.w,c.h)<=8);
-    let bl=0,bcomp=null;
-    for(const c of bcomps){const len=Math.max(c.w,c.h)/k;if(len>bl){bl=len;bcomp=c;}}
-    for(let i1=0;i1<bcomps.length;i1++)for(let j=i1+1;j<bcomps.length;j++){const a1=bcomps[i1],a2=bcomps[j];
-      if(a1.h>a1.w&&a2.h>a2.w&&Math.abs(a1.x-a2.x)<=4){const gap=Math.max(a1.y,a2.y)-Math.min(a1.y+a1.h,a2.y+a2.h);
-        if(gap>=0&&gap<=40){const len=(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k;if(len>bl){bl=len;bcomp=a1;}}}
-      else if(a1.w>a1.h&&a2.w>a2.h&&Math.abs(a1.y-a2.y)<=4){const gap=Math.max(a1.x,a2.x)-Math.min(a1.x+a1.w,a2.x+a2.w);
-        if(gap>=0&&gap<=40){const len=(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k;if(len>bl){bl=len;bcomp=a1;}}}}
-    if(bcomp){S.redArrow={len:bl,blue:true};$("q-red-anno").value="";   /* 蓝色标注值未知:清空默认值,避免误套 2400 */
-      logAI("· 蓝色尺寸标注箭头检测:长 "+Math.round(bl)+"px(本图标注为蓝色双头箭头)→ 尝试 OCR 读取邻近数字","w");
-      /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域再识别 */
-      const OF=2,OM=30;
-      const ox0=Math.max(0,bcomp.x-OM),oy0=Math.max(0,bcomp.y-OM);
-      const ow=Math.min(AW-ox0,bcomp.w+2*OM),oh=Math.min(AH-oy0,bcomp.h+2*OM);
+          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 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;
       const og2=oc2.getContext("2d");og2.imageSmoothingEnabled=true;
       og2.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,ow*OF,oh*OF);
       const d2=og2.getImageData(0,0,ow*OF,oh*OF).data;
-      const bm2=new Uint8Array(ow*OF*oh*OF);
-      for(let i2=0;i2<bm2.length;i2++){const r2=d2[i2*4],g2=d2[i2*4+1],b3=d2[i2*4+2];if(r2<150&&b3>140&&b3-r2>60&&b3-g2>25)bm2[i2]=1;}
-      const num=ocrNumber(bm2,ow*OF,{x:(bcomp.x-ox0)*OF,y:(bcomp.y-oy0)*OF,w:bcomp.w*OF,h:bcomp.h*OF},mkCC(ow*OF),OF);
-      if(num){$("q-red-anno").value=num;logAI("· 蓝色标注 OCR 读出:<b>"+num+"</b>(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
-      else logAI("· OCR 未能置信读出蓝色标注值 → 请在下方输入框手动填写标注数值(默认按 mm)","e");}
+      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");}
+      else logAI("· OCR 未能置信读出红色标注值 → 请在下方输入框确认标注数值(默认 2400,单位 mm)","e");}
   }
   /* 图纸已用红字标注柜体名称(如"2-电视柜")→ 识别之,对应红框柜体直接采用图纸名称,系统不再二次命名 */
   if(S.redbox){const LM=dilateMask(redm,AW,AH,3);
@@ -1843,7 +1832,7 @@ function renderRules(){
 }
 
 /* ========== 事件与初始化 ========== */
-$("q-red-anno").oninput=()=>{if(S.redArrow){const old=dimScale();const inf=annoInfer(S.scale600||0);if(!inf)return;S.scale=inf.scale;S.scaleSrc=(S.redArrow.blue?"蓝":"红")+"标注箭头校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";const nw=dimScale();if(S.kitBox&&old&&nw)S.kitBox={wM:S.kitBox.wM/old*nw,hM:S.kitBox.hM/old*nw};renderAnnoInfer();recalc();renderAIResults();}};
+$("q-red-anno").oninput=()=>{if(S.redArrow){const old=dimScale();const inf=annoInfer(S.scale600||0);if(!inf)return;S.scale=inf.scale;S.scaleSrc="红标注箭头校准(推断单位"+inf.unit+" · 与600mm锚点交叉校验)";const nw=dimScale();if(S.kitBox&&old&&nw)S.kitBox={wM:S.kitBox.wM/old*nw,hM:S.kitBox.hM/old*nw};renderAnnoInfer();recalc();renderAIResults();}};
 $("btnFb").onclick=()=>{const px=+$("fbPx").value||0,mm=+$("fbMm").value||0;if(px>0&&mm>0){S.scale=(mm/1000)/px;S.scaleSrc="用户补充已知尺寸";recalc();toast("比例已建立");}else toast("请填写像素数与毫米数","toast-error");};
 
 renderTiers();renderRules();renderQuoteList();rebuildObjs();recalc();