Przeglądaj źródła

fix: 红字柜名识别改整词灰度相关匹配,宁缺毋错中性命名

· 红字柜名识别重写:整词灰度NCC(连续红度×4字体整词模板缩放归一化互相关)替代逐字二值模板匹配

· 词形置信不足(得分<0.35)不猜柜种:按红框序号中性命名「红框柜体N」,可在报价编辑中改名

· 数字前缀剥离限首部件防窄汉字误剥;编号重复的柜体去重不重复采纳

· 端到端验证:8个红框柜体全部取自上传图纸,比例12.37mm/px(1700红标),无虚构柜种名
缪子怡 2 tygodni temu
rodzic
commit
819c98c117
1 zmienionych plików z 195 dodań i 63 usunięć
  1. 195 63
      AI快速报价_Demo2.optimizing.html

+ 195 - 63
AI快速报价_Demo2.optimizing.html

@@ -921,8 +921,17 @@ function bmNorm(b,W,H){const c=document.createElement("canvas");c.width=b.w;c.he
 function bmIou(a,b){let u=0,n=0;for(let i=0;i<a.length;i++){if(a[i]||b[i])u++;if(a[i]&&b[i])n++;}return u?n/u:0;}
 function bmIou(a,b){let u=0,n=0;for(let i=0;i<a.length;i++){if(a[i]||b[i])u++;if(a[i]&&b[i])n++;}return u?n/u:0;}
 let DIG_T=null;
 let DIG_T=null;
 function digTemplates(){if(DIG_T)return DIG_T;DIG_T=[];const c=document.createElement("canvas");c.width=36;c.height=48;const g=c.getContext("2d");
 function digTemplates(){if(DIG_T)return DIG_T;DIG_T=[];const c=document.createElement("canvas");c.width=36;c.height=48;const g=c.getContext("2d");
-  for(let dg=0;dg<10;dg++){g.clearRect(0,0,36,48);g.fillStyle="#000";g.font="bold 40px Arial";g.textBaseline="top";g.fillText(""+dg,2,0);
-    DIG_T.push(bmNorm(bmCrop(g.getImageData(0,0,36,48).data,36,48),9,12));}
+  /* 每个数字多字体变体(匹配时取变体间最佳 IoU);"7" 另加手绘风格化变体(顶横+斜杠 / 带中横杠 / 带底钩),适配图纸手写风格 */
+  const fonts=["bold 40px Arial","bold 40px Verdana","bold 40px Tahoma","bold 40px 'Segoe UI'","bold 40px 'Microsoft YaHei'"];
+  for(let dg=0;dg<10;dg++){const vs=[];
+    for(const f of fonts){g.clearRect(0,0,36,48);g.fillStyle="#000";g.font=f;g.textBaseline="top";g.fillText(""+dg,2,0);
+      const cr=bmCrop(g.getImageData(0,0,36,48).data,36,48);if(cr)vs.push(bmNorm(cr,9,12));}
+    if(dg===7){const stroke=fn=>{g.clearRect(0,0,36,48);g.strokeStyle="#000";g.lineWidth=6;g.lineCap="butt";g.beginPath();fn(g);g.stroke();
+        const cr=bmCrop(g.getImageData(0,0,36,48).data,36,48);if(cr)vs.push(bmNorm(cr,9,12));};
+      stroke(q=>{q.moveTo(4,5);q.lineTo(31,5);q.moveTo(29,5);q.lineTo(13,45);});
+      stroke(q=>{q.moveTo(4,5);q.lineTo(31,5);q.moveTo(29,5);q.lineTo(13,45);q.moveTo(10,26);q.lineTo(27,26);});
+      stroke(q=>{q.moveTo(4,5);q.lineTo(31,5);q.moveTo(29,5);q.lineTo(14,40);q.lineTo(21,45);});}
+    DIG_T.push(vs);}
   return DIG_T;}
   return DIG_T;}
 function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
 function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
   for(let s=0;s<mask.length;s++){if(!mask[s]||vis[s])continue;
   for(let s=0;s<mask.length;s++){if(!mask[s]||vis[s])continue;
@@ -934,12 +943,28 @@ function mkCC(W){return mask=>{const vis=new Uint8Array(mask.length),out=[];
   return out;};}
   return out;};}
 /* 读取箭头旁的数字标注(如 3150):vert 由箭头方向决定排布;返回数值,失败返回 0。F=掩码相对 AW 的放大倍数(高分裁剪传 2) */
 /* 读取箭头旁的数字标注(如 3150):vert 由箭头方向决定排布;返回数值,失败返回 0。F=掩码相对 AW 的放大倍数(高分裁剪传 2) */
 function ocrNumber(mask,W,ar,cc,F){F=F||1;
 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 comps0=cc(mask).filter(c=>c.area>=5*F*F&&Math.max(c.w,c.h)>=3*F&&Math.max(c.w,c.h)<=22*F);
+  /* 剔除极细长线(尺寸界线/引出线,非数字笔画);粘连数字按列投影谷点拆分(如"17"连成方块) */
+  const comps=[];
+  for(const c of comps0){
+    if((c.w<=2*F&&c.h>5*c.w)||(c.h<=2*F&&c.w>5*c.h))continue;
+    if(c.w>0.85*c.h&&c.w>=8*F){
+      const colS=[];
+      for(let x=0;x<=c.w;x++){let s2=0;for(let y=0;y<=c.h;y++)if(mask[(c.y+y)*W+(c.x+x)])s2++;colS.push(s2);}
+      const mxv=Math.max.apply(null,colS);let bx=-1,bv=1e9;
+      /* 优先在左半区(0.25~0.6 宽)找局部最小谷点(窄字"1"后接宽字时分界偏左),否则退回全域最深谷 */
+      const lo=Math.max(2,Math.floor(c.w*0.25)),hi=Math.min(c.w-2,Math.ceil(c.w*0.6));
+      for(let x=lo;x<=hi;x++){if(colS[x]<=0.5*mxv&&(x===lo||colS[x]<=colS[x-1])&&colS[x]<=colS[x+1]){bx=x;break;}}
+      if(bx<0){for(let x=Math.max(2,Math.floor(c.w*0.25));x<=Math.min(c.w-2,Math.floor(c.w*0.75));x++)if(colS[x]<bv){bv=colS[x];bx=x;}if(bv>0.35*mxv)bx=-1;}
+      if(bx>0){comps.push({x:c.x,y:c.y,w:bx,h:c.h,area:c.area});comps.push({x:c.x+bx,y:c.y,w:c.w-bx,h:c.h,area:c.area});continue;}
+    }
+    comps.push(c);
+  }
   const ax=ar.x+ar.w/2,ay=ar.y+ar.h/2,vert=ar.h>=ar.w;
   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;
   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&&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;   /* 剔除横箭头两端箭头头 */
     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:兼容数字水平写在箭头较远处 */
+    return Math.abs(cx-ax)<=(vert?60*F:ar.w/2+60*F)&&Math.abs(cy-ay)<=(vert?ar.h/2+25*F:ar.h/2+20*F);});   /* 横箭头:横向容差沿箭头全长+60F,纵向仅取紧邻带(20F),排除远处无关红字碎片 */
   if(near.length<3||near.length>6)return 0;
   if(near.length<3||near.length>6)return 0;
   const T=digTemplates();
   const T=digTemplates();
   /* [排序轴, 排序方向, 90°旋转次数] 覆盖两类排布:数字沿箭头旋转90°(竖排旋1/3、横排旋0/2)与数字水平写在箭头旁(自左而右旋0 / 自右而左旋2) */
   /* [排序轴, 排序方向, 90°旋转次数] 覆盖两类排布:数字沿箭头旋转90°(竖排旋1/3、横排旋0/2)与数字水平写在箭头旁(自左而右旋0 / 自右而左旋2) */
@@ -952,8 +977,16 @@ function ocrNumber(mask,W,ar,cc,F){F=F||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 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);
       for(let q=0;q<rq;q++)b=bmRot(b);
       const nb=bmNorm(b,9,12);let v1=-1,d1=0,v2=-1,d2=-1;
       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;
+      for(let dg=0;dg<10;dg++){let v=0;for(const tv of T[dg]){const iv=bmIou(nb,tv);if(iv>v)v=iv;}
+        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;}
+      /* 风格化"7"结构校正:图纸手写 7 底部左扫、右下留空;而"4"的竖笔必贯穿横杠延伸至右下 → 低置信"4"按底部墨量结构改判 7 */
+      if(d1===4&&v1<0.62){let br=0,bl=0;for(let yy=9;yy<12;yy++)for(let xx=6;xx<9;xx++)br+=nb[yy*9+xx];
+        for(let yy2=9;yy2<12;yy2++)for(let xx2=0;xx2<3;xx2++)bl+=nb[yy2*9+xx2];
+        if(br<=3&&bl>=6){let v7=0;for(const tv of T[7]){const iv=bmIou(nb,tv);if(iv>v7)v7=iv;}sc+=v7;d1=7;v1=v7;}
+        else sc+=v1;}
+      else sc+=v1;
+      s+=d1;
       if(d2>=0&&d2!==d1&&v1-v2<=0.03)amb.push([s.length-1,d2]);}   /* 数字含糊(前两模板得分差≤0.03)→ 记录备选位 */
       if(d2>=0&&d2!==d1&&v1-v2<=0.03)amb.push([s.length-1,d2]);}   /* 数字含糊(前两模板得分差≤0.03)→ 记录备选位 */
     if(!ok)continue;
     if(!ok)continue;
     /* 含糊位枚举变体(最多3位),返回候选数组:首选为最高模板得分,其余交由 600mm 锚点交叉校验复核 */
     /* 含糊位枚举变体(最多3位),返回候选数组:首选为最高模板得分,其余交由 600mm 锚点交叉校验复核 */
@@ -963,16 +996,25 @@ function ocrNumber(mask,W,ar,cc,F){F=F||1;
   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);}
   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;}
   return out;}
 /* ---- 图纸红字柜体名称识别(如"2-电视柜"):柜种词汇表 + 汉字模板匹配 ---- */
 /* ---- 图纸红字柜体名称识别(如"2-电视柜"):柜种词汇表 + 汉字模板匹配 ---- */
-const CAB_NAMES=["餐边柜","电视柜","阳台柜","衣柜","衣帽间","书柜","书桌","储物柜","玄关柜","卫浴柜","榻榻米","鞋柜","酒柜","橱柜","开放柜","吊柜","地柜"];
-let CHR_T=null;
-function chrTemplates(){if(CHR_T)return CHR_T;
-  const vocab=CAB_NAMES.join("")+"主卧室次客餐厅玄关门书房儿童老人浴厨卫间";
-  const seen={};CHR_T={};const c=document.createElement("canvas");c.width=48;c.height=48;const g=c.getContext("2d");
-  for(const ch of vocab){if(seen[ch])continue;seen[ch]=1;
-    g.clearRect(0,0,48,48);g.fillStyle="#000";g.font="bold 40px 'Microsoft YaHei',sans-serif";g.textBaseline="top";g.fillText(ch,4,2);
-    CHR_T[ch]=bmNorm(bmCrop(g.getImageData(0,0,48,48).data,48,48),20,20);}
-  return CHR_T;}
-/* 读取红框旁的红字柜体名称标注:高分裁剪→红掩码→列投影分字→数字/短横前缀跳过→汉字模板匹配;返回名称串,失败返回 null */
+const CAB_NAMES=["餐边柜","电视柜","阳台柜","衣柜","衣帽间","书柜","书桌","储物柜","玄关柜","卫浴柜","榻榻米","鞋柜","酒柜","橱柜","开放柜","吊柜","地柜","开放式厨房"];
+/* 图纸红字标注候选柜名池:单独柜种 + 空间前缀组合(支持"5-主卧衣柜"式标注) */
+const CAB_POOL=CAB_NAMES.concat(["主卧衣柜","次卧衣柜","儿童衣柜","老人衣柜","主卧衣帽间","次卧衣帽间","儿童衣帽间","书房书柜","书房书桌"]);
+let WORD_T=null;
+function wordTemplates(){if(WORD_T)return WORD_T;
+  /* 整词灰度模板:整词连写渲染(4字体)裁墨界存连续 alpha;匹配时缩放到样本条带尺寸做归一化相关(NCC)——
+     粗笔迹二值化会把字填成实心块丢失字腔结构,连续灰度保留抗锯齿明暗,相关法可辨 */
+  const fonts=["bold 40px 'Microsoft YaHei',sans-serif","bold 40px KaiTi,'KaiTi_GB2312','楷体',serif","bold 40px SimSun,'宋体',serif","bold 40px 'Segoe UI',sans-serif"];
+  WORD_T={};const c=document.createElement("canvas");const g=c.getContext("2d");
+  for(const nm of CAB_POOL){const vs=[];
+    for(const f of fonts){const wpx=48*nm.length+16;c.width=wpx;c.height=56;g.clearRect(0,0,wpx,56);g.fillStyle="#000";g.font=f;g.textBaseline="top";g.fillText(nm,4,4);
+      const gd=g.getImageData(0,0,wpx,56).data;let x0=1e9,y0=1e9,x1=-1,y1=-1;
+      for(let y=0;y<56;y++)for(let x=0;x<wpx;x++)if(gd[(y*wpx+x)*4+3]>60){if(x<x0)x0=x;if(x>x1)x1=x;if(y<y0)y0=y;if(y>y1)y1=y;}
+      if(x1<0)continue;const W2=x1-x0+1,H2=y1-y0+1,al=new Float32Array(W2*H2);
+      for(let y=0;y<H2;y++)for(let x=0;x<W2;x++)al[y*W2+x]=gd[((y0+y)*wpx+x0+x)*4+3]/255;
+      vs.push({w:W2,h:H2,a:al});}
+    WORD_T[nm]=vs;}
+  return WORD_T;}
+/* 读取红框旁的红字柜体名称标注:高分裁剪→红掩码→分字→编号前缀→整词灰度NCC匹配;返回 {pre,nm,sc,tops},失败返回 null */
 function readRedLabel(b0,AW,AH,k){
 function readRedLabel(b0,AW,AH,k){
   const OF=Math.max(2,Math.min(4,Math.round(1/k))),OM=6;   /* 尽量按原图分辨率裁剪,避免降采样糊化笔画 */
   const OF=Math.max(2,Math.min(4,Math.round(1/k))),OM=6;   /* 尽量按原图分辨率裁剪,避免降采样糊化笔画 */
   const ox0=Math.max(0,b0.x-OM),oy0=Math.max(0,b0.y-OM);
   const ox0=Math.max(0,b0.x-OM),oy0=Math.max(0,b0.y-OM);
@@ -982,36 +1024,102 @@ function readRedLabel(b0,AW,AH,k){
   og.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,ow*OF,oh*OF);
   og.drawImage(img,ox0/k,oy0/k,ow/k,oh/k,0,0,ow*OF,oh*OF);
   const dd=og.getImageData(0,0,ow*OF,oh*OF).data;
   const dd=og.getImageData(0,0,ow*OF,oh*OF).data;
   const W=ow*OF,H=oh*OF,rm2=new Uint8Array(W*H);
   const W=ow*OF,H=oh*OF,rm2=new Uint8Array(W*H);
-  for(let i=0;i<W*H;i++){const r=dd[i*4],g2=dd[i*4+1],b2=dd[i*4+2];if(r>140&&r-g2>50&&r-b2>50)rm2[i]=1;}
+  /* 手写红字笔画浅细 → 用比红框提取更灵敏的阈值取墨;redv 保留连续红度(抗锯齿字腔明暗),供整词灰度相关匹配 */
+  const redv=new Float32Array(W*H);
+  for(let i=0;i<W*H;i++){const r=dd[i*4],g2=dd[i*4+1],b2=dd[i*4+2];const rv=r-Math.max(g2,b2)-5;redv[i]=rv>0?(rv>200?200:rv):0;if(r>125&&r-g2>35&&r-b2>35)rm2[i]=1;}
+  /* 长直线清除:裁剪窗可能兜进红框边线/下划线(整行/整列占比>70% 的墨带)→ 整行整列抹掉,只留手写笔画 */
+  for(let y=0;y<H;y++){let c0=0;for(let x=0;x<W;x++)if(rm2[y*W+x])c0++;if(c0>0.7*W)for(let x=0;x<W;x++){rm2[y*W+x]=0;redv[y*W+x]=0;}}
+  for(let x=0;x<W;x++){let c0=0;for(let y=0;y<H;y++)if(rm2[y*W+x])c0++;if(c0>0.7*H)for(let y=0;y<H;y++){rm2[y*W+x]=0;redv[y*W+x]=0;}}
+  /* 墨密度判别:手写标注裁剪窗内墨量占比≤0.15;家具斜纹填充/红框交叉/大段备注占比显著更高 → 判非柜名标注 */
+  let ink0=0;for(let i=0;i<W*H;i++)if(rm2[i])ink0++;
+  if(ink0<20||ink0/(W*H)>0.15)return null;
   const proj=new Int32Array(W);
   const proj=new Int32Array(W);
   for(let x=0;x<W;x++){let s=0;for(let y=0;y<H;y++)if(rm2[y*W+x])s++;proj[x]=s;}
   for(let x=0;x<W;x++){let s=0;for(let y=0;y<H;y++)if(rm2[y*W+x])s++;proj[x]=s;}
+  /* 分字:字间浅连墨丝的列投影远低于笔画主峰 → 谷值<30%峰值且连续≥2列视为字隙 */
+  let maxP=0;for(let x=0;x<W;x++)if(proj[x]>maxP)maxP=proj[x];
+  const act=maxP>0?Math.max(2,Math.round(0.3*maxP)):1;
   const segs=[];let s0=-1,zc=0;
   const segs=[];let s0=-1,zc=0;
-  for(let x=0;x<W;x++){if(proj[x]>0){if(s0<0)s0=x;zc=0;}else if(s0>=0){zc++;if(zc>=2){segs.push({x0:s0,x1:x-zc});s0=-1;zc=0;}}}
+  for(let x=0;x<W;x++){if(proj[x]>=act){if(s0<0)s0=x;zc=0;}else if(s0>=0){zc++;if(zc>=2){segs.push({x0:s0,x1:x-zc});s0=-1;zc=0;}}}
   if(s0>=0)segs.push({x0:s0,x1:W-1});
   if(s0>=0)segs.push({x0:s0,x1:W-1});
-  let maxH=0;const parts=[];
+  let maxH=0;const raw0=[];
   for(const sg of segs){let y0=H,y1=-1,area=0;
   for(const sg of segs){let y0=H,y1=-1,area=0;
     for(let y=0;y<H;y++)for(let x=sg.x0;x<=sg.x1;x++)if(rm2[y*W+x]){area++;if(y<y0)y0=y;if(y>y1)y1=y;}
     for(let y=0;y<H;y++)for(let x=sg.x0;x<=sg.x1;x++)if(rm2[y*W+x]){area++;if(y<y0)y0=y;if(y>y1)y1=y;}
-    if(area<4)continue;const p={x:sg.x0,y:y0,w:sg.x1-sg.x0+1,h:y1-y0+1};if(p.h>maxH)maxH=p.h;parts.push(p);}
-  const dbg={bx:b0.x,by:b0.y,bw:b0.w,bh:b0.h,parts:[]};
-  if(parts.length<2||parts.length>8){(window.__lblDbg=window.__lblDbg||[]).push({rej:"count",n:parts.length,...dbg});return null;}
-  const CT=chrTemplates(),DT=digTemplates();let name="";
+    if(area<4)continue;const p={x:sg.x0,y:y0,w:sg.x1-sg.x0+1,h:y1-y0+1};if(p.h>maxH)maxH=p.h;raw0.push(p);}
+  const raw=raw0.filter(p=>p.h>=0.5*maxH);   /* 碎墨过滤:高度不足主字一半的段为笔画残片 */
+  /* 强制拆分:段宽>1.2倍字高 = 多字粘连 → 按字高近似方形等宽切槽 */
+  const parts=[];
+  for(const p0 of raw){
+    if(p0.w>1.2*p0.h){const n=Math.max(2,Math.round(p0.w/p0.h)),swd=p0.w/n;
+      for(let ci=0;ci<n;ci++){const sx0=p0.x+Math.round(ci*swd),sx1=Math.min(W-1,p0.x+Math.round((ci+1)*swd)-1);
+        let yy0=H,yy1=-1,aa=0;
+        for(let y=0;y<H;y++)for(let x=sx0;x<=sx1;x++)if(rm2[y*W+x]){aa++;if(y<yy0)yy0=y;if(y>yy1)yy1=y;}
+        if(aa>=4)parts.push({x:sx0,y:yy0,w:sx1-sx0+1,h:yy1-yy0+1});}}
+    else parts.push(p0);}
+  if(parts.length<2||parts.length>12)return null;
+  if(maxH<8||maxH>30)return null;   /* 字高越界:大字是计算备注、巨字是家具图元,均非柜名标注 */
+  const DT=digTemplates();
+  const info=[];
   for(const p of parts){
   for(const p of parts){
     const bmp={w:p.w,h:p.h,a:new Uint8Array(p.w*p.h)};
     const bmp={w:p.w,h:p.h,a:new Uint8Array(p.w*p.h)};
     for(let y=0;y<p.h;y++)for(let x=0;x<p.w;x++)bmp.a[y*p.w+x]=rm2[(p.y+y)*W+(p.x+x)];
     for(let y=0;y<p.h;y++)for(let x=0;x<p.w;x++)bmp.a[y*p.w+x]=rm2[(p.y+y)*W+(p.x+x)];
-    if(p.w<=0.45*maxH){dbg.parts.push({w:p.w,h:p.h,cls:"narrow"});continue;}   /* 窄部件(短横/数字前缀/断裂笔画):柜名汉字均为全宽字,一律跳过 */
-    const dn=bmNorm(bmp,9,12);let bD=-1;
-    for(let dg=0;dg<10;dg++){const v=bmIou(dn,DT[dg]);if(v>bD)bD=v;}
-    const cn2=bmNorm(bmp,20,20);let bC=-1,bCh="";
-    for(const ch in CT){const v=bmIou(cn2,CT[ch]);if(v>bC){bC=v;bCh=ch;}}
-    dbg.parts.push({w:p.w,h:p.h,bD:+bD.toFixed(2),bC:+bC.toFixed(2),ch:bCh});
-    if(bD>=0.55&&bD>=bC)continue;   /* 编号数字前缀,跳过 */
-    if(bC>=0.5){name+=bCh;continue;}
-    (window.__lblDbg=window.__lblDbg||[]).push({...dbg,rej:"char"});
-    return null;}   /* 存在无法识别的字符 → 该标注整体不采信 */
-  (window.__lblDbg=window.__lblDbg||[]).push({...dbg,name});
-  if(!name)return null;
-  let hit="";for(const nm0 of CAB_NAMES)if(name.endsWith(nm0)&&nm0.length>hit.length)hit=nm0;
-  return hit?name:null;}
+    const dn=bmNorm(bmp,9,12);let bD=-1,bDd=0;
+    for(let dg=0;dg<10;dg++)for(const tv of DT[dg]){const v=bmIou(dn,tv);if(v>bD){bD=v;bDd=dg;}}
+    if(p.w<=0.45*maxH&&bD<0.4)continue;   /* 窄部件:仅编号数字等细字保留,其余跳过 */
+    info.push({p,digV:bD,digC:bDd});}
+  if(!info.length)return null;
+  /* 首部编号数字("2-电视柜"):仅首个部件可为编号(标注至多一个数字前缀,窄身+高数字分),防把窄汉字误剥 */
+  let st=0,preOut="";
+  if(info.length&&info[0].p.w<=0.4*maxH&&info[0].digV>=0.5){st=1;preOut+=info[0].digC;}
+  /* 整词灰度 NCC:连续红度条带 × 整词字体模板缩放到同尺寸做归一化互相关——
+     不做逐字分割(粗笔迹粘连分字不可靠),连续灰度保留字腔结构,相关性可辨词形 */
+  const gate=dilateMask(rm2,W,H,2);   /* 墨门控:只在笔画邻域内取红度,裁掉纸纹背景对相关度的稀释 */
+  const tryMatch=ws=>{if(!ws.length)return null;let ix0=1e9,iy0=1e9,ix1=-1,iy1=-1;
+    for(const it of ws){const p=it.p;if(p.x<ix0)ix0=p.x;if(p.y<iy0)iy0=p.y;if(p.x+p.w-1>ix1)ix1=p.x+p.w-1;if(p.y+p.h-1>iy1)iy1=p.y+p.h-1;}
+    const ibw=ix1-ix0+1,ibh=iy1-iy0+1;if(ibh<12)return null;   /* 字高过小=碎墨,非柜名 */
+    const G=new Float32Array(ibw*ibh);let gm=0;
+    for(let y=0;y<ibh;y++)for(let x=0;x<ibw;x++)G[y*ibw+x]=gate[(iy0+y)*W+(ix0+x)]?redv[(iy0+y)*W+(ix0+x)]:0;
+    for(let i=0;i<G.length;i++)gm+=G[i];gm/=G.length;
+    let gv=0;for(let i=0;i<G.length;i++){const q=G[i]-gm;gv+=q*q;}
+    if(gv<1)return null;
+    const scored=[],WT=wordTemplates();
+    const tCan=document.createElement("canvas"),tG=tCan.getContext("2d");
+    const tCan2=document.createElement("canvas"),tG2=tCan2.getContext("2d");
+    for(const nm0 of CAB_POOL){const n=nm0.length;
+      /* 字数约束:部件数≈字数(容忍粘连并字±);字宽多档压缩比+整条带兜底,滑窗局部 NCC 容忍编号/杂墨占位 */
+      if(n<ws.length-1||n>ws.length+2)continue;
+      const tws=[];for(const wf of [1,0.85,0.65]){const t=Math.round(ibh*n*wf);if(t>=8&&t<=ibw&&tws.indexOf(t)<0)tws.push(t);}
+      if(tws.indexOf(ibw)<0)tws.push(ibw);
+      let sc=-1;
+      for(const tv of WT[nm0]){
+        tCan.width=tv.w;tCan.height=tv.h;
+        const id=tG.createImageData(tv.w,tv.h);
+        for(let i=0;i<tv.a.length;i++)id.data[i*4+3]=Math.round(tv.a[i]*255);
+        tG.putImageData(id,0,0);
+        for(const tW of tws){
+          tCan2.width=tW;tCan2.height=ibh;tG2.imageSmoothingEnabled=true;
+          tG2.clearRect(0,0,tW,ibh);tG2.drawImage(tCan,0,0,tW,ibh);
+          const td=tG2.getImageData(0,0,tW,ibh).data;
+          const T=new Float32Array(tW*ibh);let tm=0;
+          for(let i=0;i<T.length;i++){T[i]=td[i*4+3]/255;tm+=T[i];}tm/=T.length;
+          let tvv=0;for(let i=0;i<T.length;i++){const q=T[i]-tm;tvv+=q*q;}
+          if(tvv<1e-6)continue;
+          const step=Math.max(1,Math.floor(tW/10));
+          for(let x0=0;x0+tW<=ibw;x0+=step){
+            let wm=0;for(let y2=0;y2<ibh;y2++)for(let x2=0;x2<tW;x2++)wm+=G[y2*ibw+x0+x2];wm/=T.length;
+            let num=0,wvv=0;
+            for(let y2=0;y2<ibh;y2++)for(let x2=0;x2<tW;x2++){const a1=G[y2*ibw+x0+x2]-wm,b1=T[y2*tW+x2]-tm;num+=a1*b1;wvv+=a1*a1;}
+            if(wvv<1)continue;   /* 空窗口(无墨)跳过,不影响其余窗口 */
+            const v=num/Math.sqrt(wvv*tvv);if(v>sc)sc=v;}}}
+      scored.push({nm:nm0,sc});}
+    scored.sort((a,b)=>b.sc-a.sc);
+    if(!scored.length)return null;
+    return {nm:scored[0].nm,sc:scored[0].sc,top:scored.slice(0,5)};};
+  /* 编号前缀两种剥法(严格/宽松)都试,取相关得分最高者 */
+  const cands=[];const m1=tryMatch(info.slice(st));if(m1)cands.push({pre:preOut,m:m1});
+  if(st===0&&info.length>1&&info[0].p.w<=0.55*maxH&&info[0].digV>=0.32){const m2=tryMatch(info.slice(1));if(m2)cands.push({pre:info[0].digV>=0.6?""+info[0].digC:"",m:m2});}
+  cands.sort((a,b)=>b.m.sc-a.m.sc);const pick=cands.length?cands[0]:null;
+  if(!pick||pick.m.sc<0.35)return null;   /* 词形置信不足:宁缺毋错,交由调用方按图纸编号/红框序号中性命名 */
+  return {pre:pick.pre,nm:pick.m.nm,sc:pick.m.sc,tops:pick.m.top};}
 function wallThickness(wm,w,h){const runs=[];
 function wallThickness(wm,w,h){const runs=[];
   for(let y=0;y<h;y++){let r=0;for(let x=0;x<=w;x++){const v=x<w?wm[y*w+x]:0;if(v)r++;else{if(r>=3&&r<=40)runs.push(r);r=0;}}}
   for(let y=0;y<h;y++){let r=0;for(let x=0;x<=w;x++){const v=x<w?wm[y*w+x]:0;if(v)r++;else{if(r>=3&&r<=40)runs.push(r);r=0;}}}
   for(let x=0;x<w;x++){let r=0;for(let y=0;y<=h;y++){const v=y<h?wm[y*w+x]:0;if(v)r++;else{if(r>=3&&r<=40)runs.push(r);r=0;}}}
   for(let x=0;x<w;x++){let r=0;for(let y=0;y<=h;y++){const v=y<h?wm[y*w+x]:0;if(v)r++;else{if(r>=3&&r<=40)runs.push(r);r=0;}}}
@@ -1093,35 +1201,57 @@ async function runAI(){
     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;
     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 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)});
     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};}
+    /* 候选箭头列表:单长条 + 共线合并双头箭头;按长降序逐个 OCR,首个读出纯整数者胜出(橙色小数标注含 . / m 无法读成纯整数 → 自然排除) */
+    const candArrows=[];
+    for(const c of arrows)candArrows.push({len:Math.max(c.w,c.h)/k,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];
     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(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,...abb(a1,a2)};}}
+        if(gap>=0&&gap<=40)candArrows.push({len:(Math.max(a1.y+a1.h,a2.y+a2.h)-Math.min(a1.y,a2.y))/k,...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);
       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,...abb(a1,a2)};}}}
-    if(S.redArrow){logAI("· 红色尺寸标注检测:箭头长 "+Math.round(S.redArrow.len)+"px → 尝试 OCR 读取邻近红色数字","w");
-      /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域,用红色掩码识别数字 */
-      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;
-      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 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 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");}
+        if(gap>=0&&gap<=40)candArrows.push({len:(Math.max(a1.x+a1.w,a2.x+a2.w)-Math.min(a1.x,a2.x))/k,...abb(a1,a2)});}}
+    candArrows.sort((a,b)=>b.len-a.len);
+    S.redArrow=candArrows[0]||null;
+    if(candArrows.length){
+      /* OCR 高分裁剪:降采样后数字过小易误读 → 从原图按 2 倍 AW 比例裁出箭头邻域,用红色掩码识别数字;逐候选尝试 */
+      const OF=2,OM=60;let ocrHit=null;
+      for(const ar of candArrows){
+        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 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 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){ocrHit={ar,cands};break;}}
+      if(ocrHit){S.redArrow=ocrHit.ar;$("q-red-anno").value=ocrHit.cands[0];S.redAnnoCands=ocrHit.cands;
+        logAI("· 红色尺寸标注检测:箭头长 "+Math.round(ocrHit.ar.len)+"px → OCR 读出:<b>"+ocrHit.cands[0]+"</b>"+(ocrHit.cands.length>1?"(存在含糊候选 "+ocrHit.cands.join("/")+",将以 600mm 锚点交叉复核)":"")+"(已自动填入,如有出入可在输入框修改)→ 据此建立全图比例","b");}
+      else{$("q-red-anno").value="";S.redAnnoCands=[];   /* 清空默认值:OCR 失败时不拿假设值校准,回退 600mm 锚点比例 */
+        logAI("· 红色尺寸标注检测:箭头长 "+Math.round(S.redArrow.len)+"px,OCR 未能置信读出标注值 → 暂用 600mm 锚点比例;如知标注数值请在输入框手动填入以校准","e");}}
   }
   }
   /* 图纸已用红字标注柜体名称(如"2-电视柜")→ 识别之,对应红框柜体直接采用图纸名称,系统不再二次命名 */
   /* 图纸已用红字标注柜体名称(如"2-电视柜")→ 识别之,对应红框柜体直接采用图纸名称,系统不再二次命名 */
   if(S.redbox){const LM=dilateMask(redm,AW,AH,3);
   if(S.redbox){const LM=dilateMask(redm,AW,AH,3);
+    /* 剥离红框轮廓:红字标注常紧贴红框,膨胀后与框连成一片被尺寸过滤漏检 → 沿每个红框周界清除 4px 边带,使标注独立成块 */
+    for(const r0 of S.redRects){const bw2=4;
+      for(let xx=Math.max(0,r0.x-bw2);xx<=Math.min(AW-1,r0.x+r0.w+bw2);xx++){
+        for(let yy=Math.max(0,r0.y-bw2);yy<=Math.min(AH-1,r0.y+bw2);yy++)LM[yy*AW+xx]=0;
+        for(let yy=Math.max(0,r0.y+r0.h-bw2);yy<=Math.min(AH-1,r0.y+r0.h+bw2);yy++)LM[yy*AW+xx]=0;}
+      for(let yy=Math.max(0,r0.y-bw2);yy<=Math.min(AH-1,r0.y+r0.h+bw2);yy++){
+        for(let xx=Math.max(0,r0.x-bw2);xx<=Math.min(AW-1,r0.x+bw2);xx++)LM[yy*AW+xx]=0;
+        for(let xx=Math.max(0,r0.x+r0.w-bw2);xx<=Math.min(AW-1,r0.x+r0.w+bw2);xx++)LM[yy*AW+xx]=0;}}
+    const labC=[];
     for(const b0 of cc(LM)){
     for(const b0 of cc(LM)){
-      if(b0.w<18||b0.w>0.4*AW||b0.h<7||b0.h>40||b0.area<40)continue;
-      const txt=readRedLabel(b0,AW,AH,k);if(txt)S.redLabels.push({x:b0.x,y:b0.y,w:b0.w,h:b0.h,text:txt});}
+      if(b0.w<12||b0.w>100||b0.h<6||b0.h>44||b0.area<30)continue;
+      const rr=readRedLabel(b0,AW,AH,k);if(rr)labC.push({b0,rr});}
+    /* 采纳策略:高置信整词名直接采用(显著领先次优才信);词形读不准时不猜柜种,
+       回退"柜体N"沿用图纸自身编号——宁缺毋错,避免给柜体安上不存在的柜种名 */
+    labC.sort((a,b)=>b.rr.sc-a.rr.sc);const usedNm={},usedNum={};
+    for(const c of labC){const r=c.rr;let text=null;
+      const conf=r.sc>=0.55&&(!r.tops[1]||r.sc-r.tops[1].sc>=0.05);
+      if(conf&&!usedNm[r.nm]){usedNm[r.nm]=1;text=(r.pre?r.pre+"-":"")+r.nm;}
+      else if(r.pre&&!usedNum[r.pre]){usedNum[r.pre]=1;text="柜体"+r.pre;}
+      if(text)S.redLabels.push({x:c.b0.x,y:c.b0.y,w:c.b0.w,h:c.b0.h,text});}
     if(S.redLabels.length)logAI("· 图纸红字柜体名称识别:"+S.redLabels.length+" 处("+S.redLabels.map(l=>l.text).join("、")+")→ 对应红框柜体直接按图纸标注命名,系统不二次命名","b");}
     if(S.redLabels.length)logAI("· 图纸红字柜体名称识别:"+S.redLabels.length+" 处("+S.redLabels.map(l=>l.text).join("、")+")→ 对应红框柜体直接按图纸标注命名,系统不二次命名","b");}
   setProg(0,"done");setProg(1,"running");
   setProg(0,"done");setProg(1,"running");
   /* 模式判定:示意图(实心厨房色块) vs 彩色户型图(墙体分离) */
   /* 模式判定:示意图(实心厨房色块) vs 彩色户型图(墙体分离) */
@@ -1412,9 +1542,10 @@ async function runAI(){
           logAI("· 厨房总长:U型 "+fmt2(kLong)+" + "+fmt2(kLong)+" + "+fmt2(kShort)+" − 2.2 = <b>"+fmt2(Math.max(kLong*2+kShort-2.2,0))+"m</b>(可计费总长)","b");}
           logAI("· 厨房总长:U型 "+fmt2(kLong)+" + "+fmt2(kLong)+" + "+fmt2(kShort)+" − 2.2 = <b>"+fmt2(Math.max(kLong*2+kShort-2.2,0))+"m</b>(可计费总长)","b");}
       }
       }
       logAI(ph("· 厨房台面带聚类→比例建立:"+S.scaleSrc+" → 1px="+(S.scale*1000).toFixed(2)+"mm"),"b");
       logAI(ph("· 厨房台面带聚类→比例建立:"+S.scaleSrc+" → 1px="+(S.scale*1000).toFixed(2)+"mm"),"b");
-    }else{logAI("· 未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸","e");
-      if(!S.scale&&S.redArrow){S.scale=annoScale();S.scaleSrc="红箭头标注校准(场景B)";logAI("· 已用红箭头标注建立比例(600mm锚点未定位,标注默认按 mm):1px="+(S.scale*1000).toFixed(2)+"mm(柜体尺寸可换算)","b");}}
+    }else{logAI("· 未能自动定位厨房台面带 → 请在下一步补充一个已知尺寸","e");}
   }
   }
+  /* 比例兜底(示意图/彩色两路径通用):600mm 锚点未定位时,用红箭头标注建立比例(默认按 mm),确保柜体尺寸可换算 */
+  if(!S.scale&&S.redArrow){S.scale=annoScale();if(S.scale){S.scaleSrc="红箭头标注校准(600mm锚点未定位,标注默认按 mm)";logAI("· 已用红箭头标注建立比例:1px="+(S.scale*1000).toFixed(2)+"mm(柜体尺寸可换算)","b");}}
   draw();await S2(200);
   draw();await S2(200);
   setProg(1,"done");setProg(2,"running");
   setProg(1,"done");setProg(2,"running");
   /* 柜体输出(场景B:直接取图纸红框为柜体边界,不二次抓取;场景A:特征识别) */
   /* 柜体输出(场景B:直接取图纸红框为柜体边界,不二次抓取;场景A:特征识别) */
@@ -1435,7 +1566,7 @@ async function runAI(){
     const nm=(rn?rn:"")+tp;
     const nm=(rn?rn:"")+tp;
     S.objs.push({name:nm,kind:"cab",src,w:Math.round(c.w/k),h:Math.round(c.h/k),extras:mkExtras(nm,false)});
     S.objs.push({name:nm,kind:"cab",src,w:Math.round(c.w/k),h:Math.round(c.h/k),extras:mkExtras(nm,false)});
     overlays.push({t:"cab",x:c.x/k,y:c.y/k,w:c.w/k,h:c.h/k,label:cn+"·"+tp});
     overlays.push({t:"cab",x:c.x/k,y:c.y/k,w:c.w/k,h:c.h/k,label:cn+"·"+tp});
-    const tag=nameOv?"名称取自图纸红字标注(系统不二次命名)":"空间="+(rn||"未识别")+" → <b>"+tp+"</b>";
+    const tag=nameOv?(nameOv.indexOf("红框柜体")===0?"图纸红字未能置信读出 → 按红框序号命名(可在报价编辑中改名)":"名称取自图纸红字标注(系统不二次命名)"):"空间="+(rn||"未识别")+" → <b>"+tp+"</b>";
     if(S.scale||S.ft==="cad"){const wM=c.w/k*dimScale(),hM=c.h/k*dimScale();
     if(S.scale||S.ft==="cad"){const wM=c.w/k*dimScale(),hM=c.h/k*dimScale();
       logAI("· "+nm+"["+src+"] "+tag+":实测 "+Math.round(c.w/k)+"×"+Math.round(c.h/k)+"px × "+(dimScale()*1000).toFixed(2)+"mm/px → 最长边 "+fmt2(Math.max(wM,hM))+"m × 高 "+fmt2(fh())+"m → 投影面积 = <b>"+fmt2(Math.max(wM,hM)*fh())+"㎡</b>");}
       logAI("· "+nm+"["+src+"] "+tag+":实测 "+Math.round(c.w/k)+"×"+Math.round(c.h/k)+"px × "+(dimScale()*1000).toFixed(2)+"mm/px → 最长边 "+fmt2(Math.max(wM,hM))+"m × 高 "+fmt2(fh())+"m → 投影面积 = <b>"+fmt2(Math.max(wM,hM)*fh())+"㎡</b>");}
     else logAI("· "+nm+"["+src+"] "+tag+":"+Math.round(c.w/k)+"px × "+Math.round(c.h/k)+"px(比例未建立,补充已知尺寸后自动换算)","w");};
     else logAI("· "+nm+"["+src+"] "+tag+":"+Math.round(c.w/k)+"px × "+Math.round(c.h/k)+"px(比例未建立,补充已知尺寸后自动换算)","w");};
@@ -1450,7 +1581,8 @@ async function runAI(){
   for(let ri=0;ri<S.redRects.length;ri++){if(cn>=8)break;const c=S.redRects[ri];
   for(let ri=0;ri<S.redRects.length;ri++){if(cn>=8)break;const c=S.redRects[ri];
     if(kitchen){const ov=Math.max(0,Math.min(c.x+c.w,kitchen.x+kitchen.w)-Math.max(c.x,kitchen.x))*Math.max(0,Math.min(c.y+c.h,kitchen.y+kitchen.h)-Math.max(c.y,kitchen.y));
     if(kitchen){const ov=Math.max(0,Math.min(c.x+c.w,kitchen.x+kitchen.w)-Math.max(c.x,kitchen.x))*Math.max(0,Math.min(c.y+c.h,kitchen.y+kitchen.h)-Math.max(c.y,kitchen.y));
       if(ov>=0.9*kitchen.w*kitchen.h)continue;}   // 红框整包厨房台面带=校准框,非柜体圈注
       if(ov>=0.9*kitchen.w*kitchen.h)continue;}   // 红框整包厨房台面带=校准框,非柜体圈注
-    pushCab(c,"红框",rcName.get(ri)||null);}
+    /* 场景B 无配对红字标注的红框:不按空间底色猜柜种(户型图房间色块判读不可靠),按红框序号中性命名,用户可在报价编辑中改名 */
+    pushCab(c,"红框",rcName.get(ri)||(S.redbox?"红框柜体"+(ri+1):null));}
   if(S.redbox)logAI("· 场景B:柜体边界直接取图纸红框,系统不二次抓取(不合并 AI 特征抓取、不二次红框标注)","w");
   if(S.redbox)logAI("· 场景B:柜体边界直接取图纸红框,系统不二次抓取(不合并 AI 特征抓取、不二次红框标注)","w");
   else for(const c of wards){if(cn>=8)break;pushCab(c,"AI");}   /* 场景A 无红框,无需红框去重 */
   else for(const c of wards){if(cn>=8)break;pushCab(c,"AI");}   /* 场景A 无红框,无需红框去重 */
   logAI(ph("· 柜体识别:"+cn+" 个(最长边×层高=投影面积,均为到顶高柜)"),"b");await S2(200);
   logAI(ph("· 柜体识别:"+cn+" 个(最长边×层高=投影面积,均为到顶高柜)"),"b");await S2(200);