import { Skia, FilterMode, MipmapMode, type SkImage } from '@shopify/react-native-skia'; // FRAME-tab AUTO straighten: the angle, in degrees, of the horizon in the photo. // Null when the picture has no line worth trusting (a portrait, a flat wall), so // AUTO leaves the fine angle alone instead of inventing one. // // Deskew by projection profile, not a gradient-orientation histogram: for every // candidate angle the edge map is sheared and projected onto the rows, and the // angle whose projection is the sharpest single line wins. The histogram looks // cheaper, but a real edge is a staircase one pixel tall — a 7 degree line only // steps every 8 px — so its gradients point at 0 and 45 degrees instead of at // the line, and every small tilt reads as zero. Shearing cancels a staircase // instead of averaging it away. // // ponytail: 1 degree coarse pass, 0.5 degree refine around the winner. The // STRAIGHTEN slider is a 0.5 degree grid, so finer is invisible. Add a third // pass if a residual tilt ever shows up. export function detectTilt(image: SkImage | null, maxAngle = 45): number | null { if (!image) return null; let iw = image.width(); let ih = image.height(); if (!(iw > 8 && ih > 8)) return null; // Scan a thumbnail, never the 12 MP original: the profile only needs the // gross shape of the lines, and this runs on the JS thread. Halve instead of // resampling in one go — a one-shot 3x bilinear step samples 4 taps spread // over 3 source pixels and leaves exactly the staircase the search undoes. let cur = image; while (Math.max(iw, ih) > 256) { const w = Math.max(8, Math.round(iw / 2)); const h = Math.max(8, Math.round(ih / 2)); const surface = Skia.Surface.Make(w, h); if (!surface) break; surface .getCanvas() .drawImageRectOptions( cur, Skia.XYWHRect(0, 0, iw, ih), Skia.XYWHRect(0, 0, w, h), FilterMode.Linear, MipmapMode.Linear ); const next = surface.makeImageSnapshot(); if (!next) break; cur = next; iw = w; ih = h; } const raw = cur.readPixels(); if (!raw) return null; // Bytes for the 8-bit raster surfaces this app builds; the float branch is // there because readPixels() reports whatever the image holds. const unit = raw instanceof Float32Array ? 255 : 1; const w = iw; const h = ih; const gray = new Float32Array(w * h); for (let i = 0; i < w * h; i++) { gray[i] = (0.299 * raw[i * 4] + 0.587 * raw[i * 4 + 1] + 0.114 * raw[i * 4 + 2]) * unit; } // The edge map, kept as a list: the angle search touches every edge once per // candidate, so skipping the flat pixels is most of the saving. const px = new Float64Array(w * h); const py = new Float64Array(w * h); const pw = new Float64Array(w * h); let n = 0; for (let y = 1; y < h - 1; y++) { for (let x = 1; x < w - 1; x++) { const i = y * w + x; const dx = gray[i + 1] - gray[i - 1]; const dy = gray[i + w] - gray[i - w]; const mag = Math.sqrt(dx * dx + dy * dy); if (mag < 20) continue; // flat patch: no line edge here px[n] = x - w / 2; py[n] = y; pw[n] = mag; n++; } } if (n < 32) return null; // too few edges to call anything a horizon // Score an angle by how sharply the sheared edge map projects onto the rows: // a line sheared to its own angle lands in one bin, any other angle smears it // over |delta| * w / 57 bins wide and the squared-sum collapses. const limit = Math.ceil((w / 2) * Math.tan((maxAngle * Math.PI) / 180)) + 2; const size = h + 2 * limit; const acc = new Float64Array(size); const score = (deg: number) => { acc.fill(0); const t = Math.tan((deg * Math.PI) / 180); for (let k = 0; k < n; k++) acc[Math.round(py[k] - px[k] * t) + limit] += pw[k]; let ss = 0; let tot = 0; for (let i = 0; i < size; i++) { ss += acc[i] * acc[i]; tot += acc[i]; } return tot > 0 ? ss / (tot * tot) : 0; }; const coarse: number[] = []; let best = 0; let bestScore = -1; for (let deg = -maxAngle; deg <= maxAngle; deg++) { const s = score(deg); coarse.push(s); if (s > bestScore) { bestScore = s; best = deg; } } // A picture with no line scores about the same at every angle; only a real // one stands clear of the middle of the field. coarse.sort((a, b) => a - b); if (bestScore < 3 * coarse[coarse.length >> 1]) return null; let angle = best; for (let deg = best - 1; deg <= best + 1; deg += 0.5) { const s = score(deg); if (s > bestScore) { bestScore = s; angle = deg; } } return angle; }