feat(frame): read AUTO straighten from the photo, not the accelerometer

EXIF Orientation only knows 0/90/180/270, so a library still carries no
record of how the camera was held. The sensor had nothing to offer.

AUTO now measures the dominant line in the picture itself:
src/utils/horizon.ts runs a shear-projection search (coarse 1 deg over
-45..45, then a 0.5 deg refine) on a <=256px thumbnail, bails when no
line's score beats 3x the median, and returns the tilt in degrees.
App applies photoStraighten = -tilt, so preview and export share one
number exactly as the slider did.

Removes expo-sensors wiring, the horizonRoll state, effectiveStraighten
and the bubble-level overlay (autoRoll prop) from App/AdjustmentPanel/
Viewfinder. expo-sensors stays in package.json.
This commit is contained in:
2026-09-11 14:46:28 +07:00
parent d6b3ebda40
commit 1b632ba78c
4 changed files with 179 additions and 104 deletions
+128
View File
@@ -0,0 +1,128 @@
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;
}