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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user