tomngdev commited on
Commit
c62fdad
·
verified ·
1 Parent(s): 1a4c0ff

Upload index.html with huggingface_hub

Browse files
Files changed (1) hide show
  1. index.html +8 -118
index.html CHANGED
@@ -5,9 +5,11 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <title>indra-ocr — OCR in your browser (ONNX Runtime: WebGPU / wasm)</title>
7
  <!--
8
- The importmap pins onnxruntime-web's ESM build WITH WebGPU (the plain
9
- bundle is wasm-only). The wasm files resolve next to it on the CDN. In a
10
- bundler/npm app there is no importmap: just `npm i onnxruntime-web`.
 
 
11
  -->
12
  <script type="importmap">
13
  {
@@ -51,9 +53,8 @@
51
 
52
  <label>Try an example page</label>
53
  <div id="examples">
54
- <button data-ex="multi_column">Multi-column</button>
55
- <button data-ex="rotated_letter">Rotated</button>
56
- <button data-ex="chinese_essay">中文 essay</button>
57
  </div>
58
 
59
  <label>Page image</label>
@@ -94,120 +95,9 @@
94
  <div id="canvasWrap"></div>
95
  <pre id="text">lines appear here — pick an example or drop an image, then Run OCR</pre>
96
  </div>
97
-
98
  <script type="module">
99
- import { loadIndra, readGrayBrowser } from "./indra-ocr-web.mjs";
100
-
101
- const $ = (id) => document.getElementById(id);
102
- const status = (s) => { $("status").textContent = s; };
103
- let ocr = null, gray = null, page = null;
104
-
105
- async function ensureModel() {
106
- const base = $("base").value.trim();
107
- const opts = {
108
- device: $("ep").value,
109
- cjk: $("cjk").checked,
110
- orient: $("orient").checked,
111
- };
112
- const sig = JSON.stringify([base, opts.device, opts.cjk, opts.orient]);
113
- if (ensureModel.sig !== sig) {
114
- status("loading models…");
115
- const t0 = performance.now();
116
- ocr = await loadIndra(base, opts);
117
- ensureModel.sig = sig;
118
- window.__device = ocr.device;
119
- status(`models loaded in ${Math.round(performance.now() - t0)} ms (${ocr.device})`);
120
- }
121
- return ocr;
122
- }
123
-
124
- async function showImage(src) {
125
- gray = await readGrayBrowser(src);
126
- page = null;
127
- $("run").disabled = false;
128
- const wrap = $("canvasWrap");
129
- wrap.innerHTML = "";
130
- const c = document.createElement("canvas");
131
- c.width = gray.width;
132
- c.height = gray.height;
133
- const ctx = c.getContext("2d");
134
- const img = ctx.createImageData(gray.width, gray.height);
135
- for (let i = 0, p = 0; i < gray.data.length; i++, p += 4) {
136
- img.data[p] = img.data[p + 1] = img.data[p + 2] = gray.data[i];
137
- img.data[p + 3] = 255;
138
- }
139
- ctx.putImageData(img, 0, 0);
140
- wrap.appendChild(c);
141
- window.__canvas = c;
142
- }
143
-
144
- async function run() {
145
- if (!gray) return;
146
- $("run").disabled = true;
147
- $("drop").classList.add("busy");
148
- status("running…");
149
- await new Promise((r) => setTimeout(r, 30)); // let the UI paint
150
- try {
151
- const t0 = performance.now();
152
- page = await (await ensureModel()).__call__(gray);
153
- const ms = Math.round(performance.now() - t0);
154
- $("text").textContent = page.text;
155
- status(`${page.lines.length} lines in ${ms} ms (rotation ${page.rotation}°, ${window.__device ?? "?"})`);
156
- drawBoxes();
157
- } catch (e) {
158
- status("failed: " + (e?.message ?? e));
159
- console.error(e);
160
- }
161
- $("run").disabled = false;
162
- $("drop").classList.remove("busy");
163
- }
164
-
165
- function drawBoxes() {
166
- const c = window.__canvas;
167
- const ctx = c.getContext("2d");
168
- const s = c.width / gray.width; // canvas may be drawn at native res; CSS scales it
169
- ctx.lineWidth = Math.max(1.5, 1.5 / s);
170
- ctx.strokeStyle = "rgba(30, 170, 90, 0.9)";
171
- for (const l of page.lines) {
172
- const [x0, y0, x1, y1] = l.box;
173
- ctx.strokeRect(x0 * s, y0 * s, (x1 - x0) * s, (y1 - y0) * s);
174
- }
175
- }
176
-
177
- // example pages (Apache-2.0, from datalab-to/indra-ocr)
178
- for (const b of document.querySelectorAll("#examples button")) {
179
- b.onclick = async () => {
180
- try {
181
- status(`loading example: ${b.textContent}…`);
182
- await showImage(`./examples/${b.dataset.ex}.png`);
183
- status("example loaded — press Run OCR");
184
- } catch (e) {
185
- status("failed to load example: " + (e?.message ?? e));
186
- }
187
- };
188
- }
189
-
190
- $("run").onclick = run;
191
- $("file").onchange = () => $("file").files[0] && showImage($("file").files[0]);
192
- const drop = $("drop");
193
- drop.ondragover = (e) => { e.preventDefault(); };
194
- drop.ondrop = (e) => {
195
- e.preventDefault();
196
- const f = e.dataTransfer.files[0];
197
- if (f) showImage(f);
198
- };
199
 
200
- // Hook for headless tests: window.__test(imageUrl, base, device)
201
- window.__test = async (imageUrl, base, device) => {
202
- if (base) $("base").value = base;
203
- if (device) $("ep").value = device;
204
- await showImage(imageUrl);
205
- await run();
206
- return {
207
- lines: page?.lines ?? [], rotation: page?.rotation, text: page?.text ?? "",
208
- device: window.__device ?? null,
209
- };
210
- };
211
  </script>
212
  </body>
213
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <title>indra-ocr — OCR in your browser (ONNX Runtime: WebGPU / wasm)</title>
7
  <!--
8
+ Self-contained demo: the app + the indra-ocr-web library are bundled inline
9
+ below (space/build.mjs). The only external loads are the onnxruntime-web
10
+ module (pinned via the importmap, absolute CDN URL) and the ONNX models,
11
+ fetched from their Hub repo — all absolute URLs, so the page works no matter
12
+ what URL it is served from. In a bundler/npm app: `npm i onnxruntime-web`.
13
  -->
14
  <script type="importmap">
15
  {
 
53
 
54
  <label>Try an example page</label>
55
  <div id="examples">
56
+ <button data-ex="english">English</button>
57
+ <button data-ex="chinese">中文</button>
 
58
  </div>
59
 
60
  <label>Page image</label>
 
95
  <div id="canvasWrap"></div>
96
  <pre id="text">lines appear here — pick an example or drop an image, then Run OCR</pre>
97
  </div>
 
98
  <script type="module">
99
+ import{InferenceSession,Tensor,env as ortEnv}from"onnxruntime-web";function roundPy(x){const f=Math.floor(x);const d=x-f;if(d>.5)return f+1;if(d<.5)return f;return f%2===0?f:f+1}var clamp=(v,lo,hi)=>v<lo?lo:v>hi?hi:v;var median=xs=>{if(!xs.length)return NaN;const s=[...xs].sort((a,b)=>a-b);const m=s.length>>1;return s.length%2?s[m]:(s[m-1]+s[m])/2};function taps(srcN,dstN,mode){const out=[];if(mode==="area"&&dstN<srcN){const inv=srcN/dstN;for(let d=0;d<dstN;d++){const x0=d*inv;const x1=(d+1)*inv;const t=[];for(let i=Math.floor(x0);i<Math.ceil(x1);i++){const w=(Math.min(x1,i+1)-Math.max(x0,i))/inv;if(w>0)t.push([i,w])}out.push(t)}return out}const scale=srcN/dstN;for(let d=0;d<dstN;d++){const s=(d+.5)*scale-.5;let i0=Math.floor(s);const f=s-i0;let i1=i0+1;if(i0<0)i0=0;if(i1>srcN-1)i1=srcN-1;out.push(i0===i1||f<=0?[[i0,1]]:[[i0,1-f],[i1,f]])}return out}function resizeGray(src,sw,sh,dw,dh,mode="linear"){if(dw===sw&&dh===sh)return src;const tx=taps(sw,dw,mode);const ty=taps(sh,dh,mode);const tmp=new Float64Array(dw*sh);for(let y=0;y<sh;y++){const row=y*sw;for(let d=0;d<dw;d++){let a=0;for(const[i,w]of tx[d])a+=src[row+i]*w;tmp[y*dw+d]=a}}const out=new Uint8Array(dw*dh);for(let x=0;x<dw;x++){for(let d=0;d<dh;d++){let a=0;for(const[i,w]of ty[d])a+=tmp[i*dw+x]*w;out[d*dw+x]=clamp(Math.round(a),0,255)}}return out}function rotQuarterCW(gray2,k){let{data,width:w,height:h}=gray2;for(let n=0;n<k;n++){const out=new Uint8Array(w*h);for(let y=0;y<w;y++)for(let x=0;x<h;x++)out[y*h+x]=data[(h-1-x)*w+y];data=out;[w,h]=[h,w]}return{data,width:w,height:h}}var INPUT_MULTIPLE=32;var OUT_STRIDE=2;var CORE_X_SHRINK=.15;function pageToInput(gray2,longSide){const{data,width:w,height:h}=gray2;const scale=longSide/Math.max(h,w);const nh=Math.max(1,roundPy(h*scale));const nw=Math.max(1,roundPy(w*scale));const small=nh===h&&nw===w?data:resizeGray(data,w,h,nw,nh,scale<1?"area":"linear");const ph=Math.ceil(nh/INPUT_MULTIPLE)*INPUT_MULTIPLE;const pw=Math.ceil(nw/INPUT_MULTIPLE)*INPUT_MULTIPLE;const x=new Float32Array(ph*pw);for(let r=0;r<nh;r++){for(let c=0;c<nw;c++)x[r*pw+c]=1-small[r*nw+c]/255}return{x,ph,pw,scale}}function decode(out,h,w,thresh=.5,minArea=2){const n=h*w;const core=out.subarray(0,n);const dTop=out.subarray(n,2*n);const dBot=out.subarray(2*n,3*n);const labels=new Int32Array(n);const prob=new Float32Array(n);for(let i=0;i<n;i++)prob[i]=1/(1+Math.exp(-core[i]));const stats=[{area:0}];const stack=new Int32Array(n);for(let i=0;i<n;i++){if(labels[i]!==0||prob[i]<=thresh)continue;const lab=stats.length;let sp=0;stack[sp++]=i;labels[i]=lab;const st={area:0,x0:w,x1:-1,sumTop:0,sumBot:0,sumSc:0};while(sp>0){const j=stack[--sp];const r=j/w|0;const c=j-r*w;st.area++;if(c<st.x0)st.x0=c;if(c>st.x1)st.x1=c;const ys=(r+.5)*OUT_STRIDE;st.sumTop+=ys-dTop[j];st.sumBot+=ys+dBot[j];st.sumSc+=prob[j];if(c>0&&labels[j-1]===0&&prob[j-1]>thresh){labels[j-1]=lab;stack[sp++]=j-1}if(c<w-1&&labels[j+1]===0&&prob[j+1]>thresh){labels[j+1]=lab;stack[sp++]=j+1}if(r>0&&labels[j-w]===0&&prob[j-w]>thresh){labels[j-w]=lab;stack[sp++]=j-w}if(r<h-1&&labels[j+w]===0&&prob[j+w]>thresh){labels[j+w]=lab;stack[sp++]=j+w}}stats.push(st)}const boxes=[];for(let lab=1;lab<stats.length;lab++){const st=stats[lab];if(st.area<minArea)continue;const y0=st.sumTop/st.area;const y1=st.sumBot/st.area;const lh=Math.max(1,y1-y0);const ex=Math.min(CORE_X_SHRINK*lh,.5*lh);boxes.push([st.x0*OUT_STRIDE-ex,y0,(st.x1+1)*OUT_STRIDE+ex,y1,st.sumSc/st.area])}return boxes}var LINE_HEIGHT=32;var PAD_Y=.2;var PAD_X=.3;var WIDTH_MULTIPLE=8;function cropLine(gray2,box,height=LINE_HEIGHT,maxW=4096){const[x0,y0,x1,y1]=box;const h=Math.max(1,y1-y0);const X0=x0-PAD_X*h;const X1=x1+PAD_X*h;const Y0=y0-PAD_Y*h;const Y1=y1+PAD_Y*h;const H=gray2.height;const W=gray2.width;const ix0=Math.floor(X0);const iy0=Math.floor(Y0);const ix1=Math.ceil(X1);const iy1=Math.ceil(Y1);const cw=Math.max(1,ix1-ix0);const ch=Math.max(1,iy1-iy0);const crop=new Uint8Array(ch*cw).fill(255);const sx0=Math.max(0,ix0);const sy0=Math.max(0,iy0);const sx1=Math.min(W,ix1);const sy1=Math.min(H,iy1);if(sx1>sx0&&sy1>sy0){for(let r=sy0;r<sy1;r++){const srcRow=r*W;const dstRow=(r-iy0)*cw;for(let c=sx0;c<sx1;c++)crop[dstRow+(c-ix0)]=gray2.data[srcRow+c]}}const outW=Math.max(WIDTH_MULTIPLE,Math.min(maxW,roundPy(cw*height/ch)));const resized=resizeGray(crop,cw,ch,outW,height,ch>height?"area":"linear");const ink=new Float32Array(resized.length);for(let i=0;i<resized.length;i++)ink[i]=1-resized[i]/255;return{ink,w:outW}}function padBatch(lines){const H=lines[0].ink.length/lines[0].w;let Wm=Math.max(...lines.map(l=>l.w));Wm=Math.ceil(Wm/WIDTH_MULTIPLE)*WIDTH_MULTIPLE;const arr=new Float32Array(lines.length*H*Wm);const widths=new BigInt64Array(lines.length);lines.forEach((l,i)=>{for(let r=0;r<H;r++){const src=r*l.w;const dst=i*H*Wm+r*Wm;for(let c=0;c<l.w;c++)arr[dst+c]=l.ink[src+c]}widths[i]=BigInt(l.w)});return{arr,w:Wm,widths}}function widthBatches(widths,maxBatch=64,maxRatio=1.3,minWidth=64,maxArea=64*640){const order=widths.map((w,i)=>i).sort((a,b)=>widths[a]-widths[b]);const batches=[];for(const i of order){const last=batches[batches.length-1];if(last&&last.length<maxBatch&&Math.max(widths[i],minWidth)<=maxRatio*Math.max(widths[last[0]],minWidth)&&(last.length+1)*Math.max(widths[i],minWidth)<=maxArea){last.push(i)}else{batches.push([i])}}return batches}var N_FEATS=16;var BLOCK_CLASSES=["Text","Section-Header","Page-Header","Page-Footer","List-Group","Table","Caption","Footnote","Table-Of-Contents","Form"];var GRID_LABELS=new Set(["Table","Form","Table-Of-Contents"].map(c=>BLOCK_CLASSES.indexOf(c)));var isDigit=c=>c>="0"&&c<="9";var isUpper=c=>c!==c.toLowerCase()&&c===c.toUpperCase();function lineFeatures(boxes,pageW,pageH,texts){const n=boxes.length;const out=new Float32Array(n*N_FEATS);if(!n)return out;const medH=median(boxes.map(b=>b[3]-b[1]));for(let i=0;i<n;i++){const[x0,y0,x1,y1]=boxes[i];const w=Math.max(x1-x0,.001);const h=Math.max(y1-y0,.001);const f=i*N_FEATS;out[f+0]=x0/pageW;out[f+1]=y0/pageH;out[f+2]=x1/pageW;out[f+3]=y1/pageH;out[f+4]=(x0+x1)/(2*pageW);out[f+5]=(y0+y1)/(2*pageH);out[f+6]=w/pageW;out[f+7]=Math.log(h/medH);out[f+8]=Math.log(w/h)/4;out[f+9]=medH/pageH*20;const t=(texts?.[i]??"").trim();if(!t)continue;let digits=0;for(const c of t)if(isDigit(c))digits++;let head=t.slice(0,3);while(head.endsWith(".")||head.endsWith(")"))head=head.slice(0,-1);out[f+10]=Math.log1p(t.length)/5;out[f+11]=digits/t.length;out[f+12]=Number("•-–▪◦*·→".includes(t[0])||/^[0-9]+$/.test(head)&&t.length>3);out[f+13]=Number(t.endsWith("-"));out[f+14]=Number(".!?:;".includes(t[t.length-1]));out[f+15]=Number(isUpper(t[0]))}return out}var lpOf=a=>a>=0?-Math.log1p(Math.exp(-a)):a-Math.log1p(Math.exp(a));function decodeOrder(a,n,sweeps=4){if(n<=1)return Array.from({length:n},(_,i)=>i);const score=new Float64Array(n);for(let i=0;i<n;i++)for(let j=0;j<n;j++)score[i]+=lpOf(a[j*n+i])-lpOf(a[i*n+j]);let order=Array.from({length:n},(_,i)=>i).sort((p,q)=>score[p]-score[q]);for(let s=0;s<sweeps;s++){let improved=false;for(const x of[...order]){const rest=order.filter(v=>v!==x);const before=rest.map(r=>lpOf(a[r*n+x]));const after=rest.map(r=>lpOf(a[x*n+r]));const gains=new Float64Array(n);let run2=0;for(let k=1;k<n;k++)gains[k]=run2+=before[k-1];run2=0;for(let k=n-2;k>=0;k--)gains[k]+=run2+=after[k];let best=0;for(let k=1;k<=n;k++)if(gains[k]>gains[best])best=k;const cur=order.indexOf(x);if(gains[best]>gains[cur]+1e-6){rest.splice(best,0,x);order=rest;improved=true}}if(!improved)break}return order}function cluster(same,n){if(!n)return[];const M=new Float64Array(n*n);for(let i=0;i<n*n;i++)M[i]=same[i];for(let i=0;i<n;i++)M[i*n+i]=-Infinity;const alive=new Uint8Array(n).fill(1);const best=new Int32Array(n);const bestV=new Float64Array(n);for(let i=0;i<n;i++){let b=0;for(let j=1;j<n;j++)if(M[i*n+j]>M[i*n+b])b=j;best[i]=b;bestV[i]=M[i*n+b]}const members=Array.from({length:n},(_,i)=>[i]);const S=new Float64Array(M);const size=new Float64Array(n).fill(1);for(let it=0;it<n-1;it++){let a=0;for(let i=1;i<n;i++)if(bestV[i]>bestV[a])a=i;let b=best[a];if(bestV[a]<=0)break;if(b<a)[a,b]=[b,a];for(let j=0;j<n;j++)S[a*n+j]+=S[b*n+j];for(let j=0;j<n;j++)S[j*n+a]+=S[j*n+b];S[a*n+a]=0;size[a]+=size[b];alive[b]=0;members[a].push(...members[b]);const row=new Float64Array(n);for(let j=0;j<n;j++)row[j]=alive[j]&&j!==a?S[a*n+j]/(size[a]*size[j]):-Infinity;for(let j=0;j<n;j++){M[a*n+j]=M[j*n+a]=row[j];M[b*n+j]=M[j*n+b]=-Infinity}bestV[b]=-Infinity;let rb=0;for(let j=1;j<n;j++)if(row[j]>row[rb])rb=j;best[a]=rb;bestV[a]=row[rb];for(let j=0;j<n;j++){if(!alive[j]||j===a)continue;if(best[j]===a||best[j]===b){let nb=0;for(let k=1;k<n;k++)if(M[j*n+k]>M[j*n+nb])nb=k;best[j]=nb;bestV[j]=M[j*n+nb]}if(row[j]>bestV[j]){best[j]=a;bestV[j]=row[j]}}}const out=[];for(let i=0;i<n;i++)if(alive[i])out.push(members[i].sort((p,q)=>p-q));return out}function topDown(boxes){if(!boxes.length)return[];const medH=median(boxes.map(b=>b[3]-b[1]));return boxes.map((b,i)=>[roundPy((b[1]+b[3])/2/Math.max(medH*.5,.001)),b[0],i]).sort((p,q)=>p[0]-q[0]||p[1]-q[1]).map(t=>t[2])}function xyCut(boxes){const bestCut=(b,axis)=>{const lo=b.map(bb=>axis?bb[1]:bb[0]);const hi=b.map(bb=>axis?bb[3]:bb[2]);const o=lo.map((_,i)=>i).sort((p,q)=>lo[p]-lo[q]);let end=hi[o[0]];let bestGap=0;let bestK=-1;for(let k=1;k<o.length;k++){const gap=lo[o[k]]-end;if(gap>bestGap){bestGap=gap;bestK=k}end=Math.max(end,hi[o[k]])}return[bestGap,bestK,o]};const rec=ids=>{if(ids.length<=1)return[...ids];const b=ids.map(i=>boxes[i]);const[gy,ky,oy]=bestCut(b,1);const[gx,kx,ox]=bestCut(b,0);if(ky<0&&kx<0)return topDown(b).map(k2=>ids[k2]);const[k,o]=gy>=gx?[ky,oy]:[kx,ox];return rec(o.slice(0,k).map(i=>ids[i])).concat(rec(o.slice(k).map(i=>ids[i])))};return rec(boxes.map((_,i)=>i))}function decodeBlocksOrder(a,same,boxes,cls){const n=boxes.length;if(n<=1)return Array.from({length:n},(_,i)=>i);const blocks=cluster(same,n);const inner=blocks.map(b=>{let isGrid=false;if(cls){const sum=new Float64Array(BLOCK_CLASSES.length);for(const i of b)for(let c=0;c<BLOCK_CLASSES.length;c++)sum[c]+=cls[i*BLOCK_CLASSES.length+c];let am=0;for(let c=1;c<BLOCK_CLASSES.length;c++)if(sum[c]>sum[am])am=c;isGrid=GRID_LABELS.has(am)}const bx=b.map(i=>boxes[i]);const local=isGrid?topDown(bx):xyCut(bx);return local.map(k=>b[k])});const m=blocks.length;const ab=new Float64Array(m*m);for(let p=0;p<m;p++)for(let q=p+1;q<m;q++){let s=0;for(const i of blocks[p])for(const j of blocks[q])s+=a[i*n+j];const v=s/(blocks[p].length*blocks[q].length);ab[p*m+q]=v;ab[q*m+p]=-v}return decodeOrder(ab,m).flatMap(k=>inner[k])}var Vocab=class _Vocab{constructor(chars){this.chars=chars;this.index=new Map(chars.map((c,i)=>[c,i+1]));this.hasCommands=chars.some(c=>c.length>1)}static fromMeta(obj){if(typeof obj==="string")obj=JSON.parse(obj);if(obj.blank!==0)throw new Error("only blank=0 vocabularies are supported");return new _Vocab(obj.chars)}decode(ids){const toks=[];for(const i of ids)if(i>0&&i<=this.chars.length)toks.push(this.chars[i-1]);if(!this.hasCommands)return toks.join("");let out="";for(let k=0;k<toks.length;k++){const t=toks[k];const nxt=toks[k+1]??"";out+=t;if(t.length>2&&t[0]==="\\"&&/^[A-Za-z]+$/.test(t.slice(1))&&/^[A-Za-z]/.test(nxt))out+=" "}return out}};var ADAPTIVE_MIN_H=9;var ADAPTIVE_TARGET_H=12;var ADAPTIVE_MAX_SIDE=2048;var ORIENT_SIDE=768;var ORIENT_MIN=.95;var MIN_CONF=.5;var SPLIT_GAP=3;var GUTTER_GAP=1.6;var GUTTER_EDGES=3;var round1=v=>Math.round(v*10)/10;var round4=v=>Math.round(v*1e4)/1e4;var IndraOCR=class _IndraOCR{static async create(backend,opts={}){const o=new _IndraOCR;o.Tensor=backend.Tensor;const meta=await backend.meta();o.vocab=Vocab.fromMeta(meta.vocab);o.longSide=meta.det_long_side;o.height=meta.rec_height;o.det=await backend.session("det.onnx");o.rec=await backend.session("rec.onnx");o.order=o.orient=null;if(opts.order!==false){try{o.order=await backend.session("order.onnx")}catch{}}if(opts.orient!==false){try{o.orient=await backend.session("orient.onnx")}catch{}}o.script=o.recCjk=o.vocabCjk=null;if(opts.cjk!==false){try{o.script=await backend.session("script.onnx");o.recCjk=await backend.session("rec_cjk.onnx");o.vocabCjk=Vocab.fromMeta(meta.vocab_cjk)}catch{}}o.adaptive=opts.adaptive??true;return o}async#detectAt(gray2,longSide){const{x,ph,pw,scale}=pageToInput(gray2,longSide);const{out}=await this.det.run({x:new this.Tensor("float32",x,[1,1,ph,pw])});const[,,h,w]=out.dims;return{boxes:decode(out.data,h,w),scale}}async detect(gray2){let{boxes,scale}=await this.#detectAt(gray2,this.longSide);if(this.adaptive&&boxes.length>=8){const medH=median(boxes.map(b=>b[3]-b[1]));if(medH<ADAPTIVE_MIN_H){let ls=Math.floor(Math.min(ADAPTIVE_MAX_SIDE,this.longSide*ADAPTIVE_TARGET_H/Math.max(medH,1)));ls=Math.floor(ls/32)*32;if(ls>this.longSide)({boxes,scale}=await this.#detectAt(gray2,ls))}}for(const b of boxes){b[0]=clamp(b[0]/scale,0,gray2.width);b[2]=clamp(b[2]/scale,0,gray2.width);b[1]=clamp(b[1]/scale,0,gray2.height);b[3]=clamp(b[3]/scale,0,gray2.height)}return boxes}async#hanziLines(crops){const out=new Array(crops.length).fill(false);for(const part of widthBatches(crops.map(c=>c.w))){const{arr,w,widths}=padBatch(part.map(i=>crops[i]));const{p}=await this.script.run({x:new this.Tensor("float32",arr,[part.length,1,this.height,w]),widths:new this.Tensor("int64",widths,[part.length])});part.forEach((i,k)=>out[i]=p.data[k]>.5)}return out}async recognize(gray2,boxes,batch=64){const crops=boxes.map(b=>cropLine(gray2,b,this.height));const out=crops.map(()=>({text:"",conf:0,toks:[]}));let groups=[[this.rec,this.vocab,crops.map((_,i)=>i),false]];if(this.script){const hanzi=await this.#hanziLines(crops);groups=[[this.rec,this.vocab,hanzi.flatMap((h,i)=>h?[]:[i]),false],[this.recCjk,this.vocabCjk,hanzi.flatMap((h,i)=>h?[i]:[]),true]]}for(const[sess,vocab,sel,asChars]of groups){if(!sel.length)continue;for(const part of widthBatches(sel.map(j=>crops[j].w),batch)){const{arr,w:Wm,widths}=padBatch(part.map(j=>crops[j]));const{ids,conf,lengths}=await sess.run({x:new this.Tensor("float32",arr,[part.length,1,this.height,Wm]),widths:new this.Tensor("int64",widths,[part.length])});const T=ids.dims[1];const stride=Wm/Math.max(T,1);part.forEach((j,k)=>{const n=Number(lengths.data[k]);const seq=Array.from({length:n},(_,t)=>Number(ids.data[k*T+t]));const keep=[];for(let t=0;t<n;t++)if(seq[t]!==0&&(t===0||seq[t]!==seq[t-1]))keep.push(t);const[x0,y0,x1,y1]=boxes[j];const h=Math.max(1,y1-y0);const ix0=Math.floor(x0-PAD_X*h);const cw=Math.ceil(x1+PAD_X*h)-ix0;const f=cw/crops[j].w;const xs=keep.map(t=>ix0+(t+.5)*stride*f);const kept=keep.map(t=>seq[t]);const toks=asChars?kept.map((id,i)=>[vocab.chars[id-1],xs[i]]):kept.map((id,i)=>[id,xs[i]]);const c=keep.reduce((s,t)=>s+conf.data[k*T+t],0)/Math.max(keep.length,1);out[j]=c>=MIN_CONF?{text:vocab.decode(kept),conf:c,toks}:{text:"",conf:c,toks:[]}})}}return out}async readingOrder(boxes,texts,wh){if(boxes.length<2||!this.order){return{order:boxes.length?xyCut(boxes):[],layout:null}}const feats=lineFeatures(boxes,wh[0],wh[1],texts);const n=boxes.length;const outs=await this.order.run({feats:new this.Tensor("float32",feats,[1,n,16]),mask:new this.Tensor("bool",new Uint8Array(n).fill(1),[1,n])});const a=outs.a.data;const cls=outs.cls?outs.cls.data:null;if(outs.same){return{order:decodeBlocksOrder(a,outs.same.data,boxes,cls),layout:{cls,same:outs.same.data,classes:BLOCK_CLASSES}}}return{order:decodeOrder(a,n),layout:null}}#isSpace(tok){return tok===" "||tok===this.vocab.index.get(" ")}#decodeToks(toks){if(!toks.length)return"";if(typeof toks[0][0]==="string")return toks.map(tk=>tk[0]).join("").split(/\s+/).filter(Boolean).join(" ");return this.vocab.decode(toks.map(tk=>tk[0]))}#gutterCuts(boxes,rec){const lefts=boxes.map(b=>b[0]);const rights=boxes.map(b=>b[2]);return boxes.map((b,i)=>{const h=Math.max(1,b[3]-b[1]);const xs=rec[i].toks.filter(tk=>!this.#isSpace(tk[0])).map(tk=>tk[1]);const mine=[];for(let k=1;k<xs.length;k++){const a=xs[k-1];const c=xs[k];if(c-a<=GUTTER_GAP*h)continue;const gl=a+.35*h;const gr=c-.35*h;let near=0;for(let m=0;m<boxes.length;m++){if(m===i)continue;if(Math.abs(rights[m]-gl)<.5*h||Math.abs(lefts[m]-gr)<.5*h)near++}if(near>=GUTTER_EDGES)mine.push((a+c)/2)}return mine})}#splitLine(box,toks,gap,at=[]){const h=Math.max(1,box[3]-box[1]);const segs=[[]];let prev=null;const ats=[...at].sort((p,q)=>p-q);for(const tk of toks){const x=tk[1];if(!this.#isSpace(tk[0])){const cut=prev!==null&&(x-prev>gap*h||ats.some(c=>prev<c&&c<x));if(cut)segs.push([]);prev=x}segs[segs.length-1].push(tk)}if(segs.length===1)return[[box.slice(0,4),this.#decodeToks(toks),toks]];const out=[];for(const sg of segs){const text=this.#decodeToks(sg).trim();const xs=sg.filter(tk=>!this.#isSpace(tk[0])).map(tk=>tk[1]);if(text&&xs.length){out.push([[Math.max(box[0],Math.min(...xs)-.35*h),box[1],Math.min(box[2],Math.max(...xs)+.35*h),box[3]],text,sg])}}return out}async#assemble(wh,boxes,rec){const cuts=this.#gutterCuts(boxes,rec);let nb=[];let nrec=[];boxes.forEach((b,i)=>{for(const[bx,tx,tk]of this.#splitLine(b,rec[i].toks,SPLIT_GAP,cuts[i])){nb.push(bx);nrec.push({text:tx,conf:rec[i].conf,toks:tk})}});const texts=nrec.map(r=>r.text);const{order}=await this.readingOrder(nb,texts,wh);return order.map(i=>({box:nb[i].slice(0,4).map(round1),text:nrec[i].text,conf:round4(nrec[i].conf)}))}async orientation(gray2){if(!this.orient)return 0;const side=ORIENT_SIDE;const h=gray2.height;const w=gray2.width;const s=side/Math.max(h,w);const nh=Math.max(1,roundPy(h*s));const nw=Math.max(1,roundPy(w*s));const small=nh===h&&nw===w?gray2.data:resizeGray(gray2.data,w,h,nw,nh,"area");const x=new Float32Array(side*side);for(let r=0;r<nh;r++)for(let c=0;c<nw;c++)x[r*side+c]=1-small[r*nw+c]/255;const{p}=await this.orient.run({x:new this.Tensor("float32",x,[1,1,side,side])});let k=0;for(let i=1;i<p.data.length;i++)if(p.data[i]>p.data[k])k=i;return p.data[k]>ORIENT_MIN?k:0}async __call__(gray2,turn=true){const k=turn?await this.orientation(gray2):0;if(k)gray2=rotQuarterCW(gray2,k);const boxes=await this.detect(gray2);const rec=await this.recognize(gray2,boxes);const lines=await this.#assemble([gray2.width,gray2.height],boxes,rec);return{lines,rotation:90*k,text:lines.map(l=>l.text).join("\n")}}};async function fetchBuf(url){const res=await fetch(url);if(!res.ok)throw new Error(`${res.status} ${res.statusText} for ${url}`);return new Uint8Array(await res.arrayBuffer())}async function makeSession(base,name,eps){const buf=await fetchBuf(base+name);const create=providers=>InferenceSession.create(buf,{executionProviders:providers,graphOptimizationLevel:"all"});let gpu=eps.includes("webgpu");let sess;try{sess=await create(eps)}catch(e){if(!gpu)throw e;console.warn(`indra-ocr: ${name}: WebGPU init failed (${e?.message??e}); using wasm`);gpu=false;sess=await create(["wasm"])}return{async run(inputs){try{return await sess.run(inputs)}catch(e){if(!gpu)throw e;console.warn(`indra-ocr: ${name}: WebGPU run failed (${e?.message??e}); switching to wasm`);gpu=false;sess=await create(["wasm"]);return await sess.run(inputs)}}}}async function loadIndra(base,opts={}){if(!/\/$/.test(base))base+="/";if(opts.wasmPaths)ortEnv.wasm.wasmPaths=opts.wasmPaths;let useGpu=false;const want=opts.device??"auto";if(want==="webgpu"){useGpu=true}else if(want==="auto"&&typeof navigator!=="undefined"&&navigator.gpu){try{useGpu=!!await navigator.gpu.requestAdapter()}catch{}}const eps=useGpu?["webgpu","wasm"]:["wasm"];const backend={Tensor,meta:async()=>await(await fetch(base+"meta.json")).json(),session:name=>makeSession(base,name,eps)};const ocr2=await IndraOCR.create(backend,opts);ocr2.device=useGpu?"webgpu":"wasm";return ocr2}async function readGrayBrowser(source){const blob=source instanceof Blob?source:await(await fetch(source)).blob();const bmp=await createImageBitmap(blob);const c=document.createElement("canvas");c.width=bmp.width;c.height=bmp.height;const ctx=c.getContext("2d",{willReadFrequently:true});ctx.drawImage(bmp,0,0);const{data,width,height}=ctx.getImageData(0,0,bmp.width,bmp.height);const out=new Uint8Array(width*height);for(let i=0,p=0;i<out.length;i++,p+=4){out[i]=Math.round(.299*data[p]+.587*data[p+1]+.114*data[p+2])}bmp.close();return{data:out,width,height}}var $=id=>document.getElementById(id);var status=s=>{$("status").textContent=s};var ocr=null;var gray=null;var page=null;async function ensureModel(){const base=$("base").value.trim();const opts={device:$("ep").value,cjk:$("cjk").checked,orient:$("orient").checked};const sig=JSON.stringify([base,opts.device,opts.cjk,opts.orient]);if(ensureModel.sig!==sig){status("loading models…");const t0=performance.now();ocr=await loadIndra(base,opts);ensureModel.sig=sig;window.__device=ocr.device;status(`models loaded in ${Math.round(performance.now()-t0)} ms (${ocr.device})`)}return ocr}async function showImage(source){gray=await readGrayBrowser(source);page=null;$("run").disabled=false;const wrap=$("canvasWrap");wrap.innerHTML="";const c=document.createElement("canvas");c.width=gray.width;c.height=gray.height;const ctx=c.getContext("2d");const img=ctx.createImageData(gray.width,gray.height);for(let i=0,p=0;i<gray.data.length;i++,p+=4){img.data[p]=img.data[p+1]=img.data[p+2]=gray.data[i];img.data[p+3]=255}ctx.putImageData(img,0,0);wrap.appendChild(c);window.__canvas=c}async function run(){if(!gray)return;$("run").disabled=true;$("drop").classList.add("busy");status("running…");await new Promise(r=>setTimeout(r,30));try{const t0=performance.now();page=await(await ensureModel()).__call__(gray);const ms=Math.round(performance.now()-t0);$("text").textContent=page.text;status(`${page.lines.length} lines in ${ms} ms (rotation ${page.rotation}°, ${window.__device??"?"})`);drawBoxes()}catch(e){status("failed: "+(e?.message??e));console.error(e)}$("run").disabled=false;$("drop").classList.remove("busy")}function drawBoxes(){const c=window.__canvas;const ctx=c.getContext("2d");const s=c.width/gray.width;ctx.lineWidth=Math.max(1.5,1.5/s);ctx.strokeStyle="rgba(30, 170, 90, 0.9)";for(const l of page.lines){const[x0,y0,x1,y1]=l.box;ctx.strokeRect(x0*s,y0*s,(x1-x0)*s,(y1-y0)*s)}}function drawPage(title,lines,{W=1240,H=640,angle=0}={}){const c=document.createElement("canvas");c.width=W;c.height=H;const x=c.getContext("2d");x.fillStyle="#fff";x.fillRect(0,0,W,H);x.save();if(angle){x.translate(W/2,H/2);x.rotate(angle);x.translate(-W/2,-H/2)}x.fillStyle="#111";x.font="bold 36px Georgia, 'Times New Roman', serif";x.fillText(title,70,110);x.font="26px Georgia, 'Times New Roman', serif";lines.forEach((t,i)=>x.fillText(t,70,190+i*60));x.restore();return c}var PAGES={english:{title:"OCR, entirely in your browser.",lines:["Detection finds the text regions, a CTC decoder reads each line,","and a small model sorts them back into reading order.","Numbers and punctuation survive: 0123456789, $42.00, (3 + 4) = 7.","Nothing is uploaded — the models run where this page runs."]},chinese:{title:"光学字符识别(浏览器版)",lines:["OCR 可以把图片里的文字变成可编辑的文本。","它被广泛用于文档数字化、票据识别与翻译工具。","随着深度学习的发展,识别准确率有了显著提升。","全部推理都在浏览器中完成,图片不会上传到服务器。"]}};async function loadExample(name){const p=PAGES[name]??PAGES.english;const c=drawPage(p.title,p.lines,p);const blob=await new Promise(r=>c.toBlob(r,"image/png"));await showImage(blob)}for(const b of document.querySelectorAll("#examples button")){b.onclick=async()=>{try{status(`drawing example: ${b.textContent}…`);await loadExample(b.dataset.ex);status("example ready — press Run OCR")}catch(e){status("failed: "+(e?.message??e));console.error(e)}}}$("run").onclick=run;$("file").onchange=()=>$("file").files[0]&&showImage($("file").files[0]);var drop=$("drop");drop.ondragover=e=>{e.preventDefault()};drop.ondrop=e=>{e.preventDefault();const f=e.dataTransfer.files[0];if(f)showImage(f)};window.__test=async(source,base,device)=>{if(base)$("base").value=base;if(device)$("ep").value=device;await(/^(data|https?):/.test(source)?showImage(source):loadExample(source));await run();return{lines:page?.lines??[],rotation:page?.rotation,text:page?.text??"",device:window.__device??null}};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
 
 
 
 
 
 
 
 
 
 
 
101
  </script>
102
  </body>
103
  </html>