1b632ba78c
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.
129 lines
4.6 KiB
TypeScript
129 lines
4.6 KiB
TypeScript
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;
|
|
}
|