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:
@@ -6,7 +6,6 @@ import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import * as MediaLibraryLegacy from 'expo-media-library/legacy';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Accelerometer } from 'expo-sensors';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import TopBar from './src/components/TopBar';
|
||||
@@ -72,35 +71,6 @@ export default function App() {
|
||||
// export alike, the straighten angle applied on top of the turn.
|
||||
const [photoRotation, setPhotoRotation] = useState<0 | 90 | 180 | 270>(0);
|
||||
const [photoStraighten, setPhotoStraighten] = useState(0);
|
||||
// FRAME-tab AUTO: level the photo to the horizon. The accelerometer feeds the
|
||||
// very same straighten angle the hand slider does (as an offset on top of
|
||||
// it), so preview and export share one number. Library only — a live camera
|
||||
// frame has no horizon to bake in.
|
||||
const [autoHorizon, setAutoHorizon] = useState(false);
|
||||
const [horizonRoll, setHorizonRoll] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!autoHorizon || mode !== 'library') return;
|
||||
Accelerometer.setUpdateInterval(100);
|
||||
const sub = Accelerometer.addListener(({ x, y }) => {
|
||||
// atan2(x, y) is the lean about the screen normal: 0 upright in portrait.
|
||||
// Skip a near-flat hold — x/y are noise there and no horizon is readable.
|
||||
// Whole degrees only: a 12 MP re-rotation per sensor tick would burn the
|
||||
// battery for a sub-pixel move.
|
||||
if (Math.hypot(x, y) < 0.2) return;
|
||||
const deg = (Math.atan2(x, y) * 180) / Math.PI;
|
||||
// Past 45 the phone is not being held to shoot: the level line and the
|
||||
// angle both stop meaning anything, so keep the last good reading.
|
||||
if (Math.abs(deg) > 45) return;
|
||||
setHorizonRoll(Math.round(deg));
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [autoHorizon, mode]);
|
||||
// Lean the phone and everything painted on its screen leans with it, so the
|
||||
// photo gains exactly the lean the STRAIGHTEN slider would have to undo: the
|
||||
// measured roll is added, never negated. An emulator tilt of +10° puts the
|
||||
// export 10° clockwise of the untilted one.
|
||||
const effectiveStraighten =
|
||||
photoStraighten + (autoHorizon && mode === 'library' ? horizonRoll : 0);
|
||||
useEffect(() => {
|
||||
const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height));
|
||||
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0));
|
||||
@@ -223,9 +193,6 @@ export default function App() {
|
||||
const [openParamKey, setOpenParamKey] = useState<string | null>(null);
|
||||
// FRAME → WATERMARK sub-panel is open (the rail's WATERMARK tab is gone).
|
||||
const [wmPanelOpen, setWmPanelOpen] = useState(false);
|
||||
// FRAME -> ROTATE strip is open: gates the bubble-level line on the image.
|
||||
const [rotatePanelOpen, setRotatePanelOpen] = useState(false);
|
||||
|
||||
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
|
||||
// Session photo history (camera captures + library exports). The camera
|
||||
// thumbnail/preview button shows the newest entry; the swipe viewer walks the
|
||||
@@ -571,8 +538,6 @@ export default function App() {
|
||||
setWmRotation(0);
|
||||
setPhotoRotation(0);
|
||||
setPhotoStraighten(0);
|
||||
setAutoHorizon(false);
|
||||
setHorizonRoll(0);
|
||||
setWallLandscape(false);
|
||||
setCropRatio('none');
|
||||
setCropRect(DEFAULT_CROP_RECT);
|
||||
@@ -593,7 +558,6 @@ export default function App() {
|
||||
wmRotation !== 0 ||
|
||||
photoRotation !== 0 ||
|
||||
photoStraighten !== 0 ||
|
||||
autoHorizon ||
|
||||
wallLandscape ||
|
||||
cropRatio !== 'none' ||
|
||||
differs(cropRect, DEFAULT_CROP_RECT);
|
||||
@@ -825,6 +789,18 @@ export default function App() {
|
||||
setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e));
|
||||
};
|
||||
|
||||
// FRAME-tab AUTO: the photo is levelled from its OWN pixels, not from the
|
||||
// phone. A library still carries no record of how the camera was held when
|
||||
// it was shot (EXIF Orientation only knows 0/90/180/270), so the sensor had
|
||||
// nothing to offer; the dominant line in the picture is the only horizon
|
||||
// there is. A one-shot: it replaces whatever the slider or a previous AUTO
|
||||
// left, and does nothing at all when the picture has no line worth trusting.
|
||||
const handleAutoStraighten = useCallback(() => {
|
||||
const tilt = viewfinderRef.current?.detectHorizon?.() ?? null;
|
||||
if (tilt == null) return;
|
||||
setPhotoStraighten(-tilt);
|
||||
}, []);
|
||||
|
||||
const handlePickImage = async () => {
|
||||
if (isProcessing) return;
|
||||
|
||||
@@ -859,12 +835,10 @@ export default function App() {
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
const asset = result.assets[0];
|
||||
setLibraryImageUri(asset.uri);
|
||||
// A new photo starts level: the previous photo's turn, fine straighten
|
||||
// and AUTO horizon must not carry over.
|
||||
// A new photo starts level: the previous photo's turn and fine
|
||||
// straighten must not carry over.
|
||||
setPhotoRotation(0);
|
||||
setPhotoStraighten(0);
|
||||
setAutoHorizon(false);
|
||||
setHorizonRoll(0);
|
||||
setMode('library');
|
||||
// Picking from the camera is the other half of that switch (the camera
|
||||
// button is the way back), so the camera's edits must not land on the
|
||||
@@ -1126,7 +1100,7 @@ export default function App() {
|
||||
// save the same crop the preview showed (null = centered 1x).
|
||||
frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null,
|
||||
photoRotation,
|
||||
photoStraighten: effectiveStraighten,
|
||||
photoStraighten,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1304,12 +1278,7 @@ export default function App() {
|
||||
libraryImageUri={libraryImageUri}
|
||||
availableHeight={deckTop}
|
||||
photoRotation={photoRotation}
|
||||
photoStraighten={effectiveStraighten}
|
||||
autoRoll={
|
||||
autoHorizon && mode === 'library' && openTab === 'frame' && rotatePanelOpen
|
||||
? horizonRoll
|
||||
: null
|
||||
}
|
||||
photoStraighten={photoStraighten}
|
||||
cameraPermissionGranted={cameraPermissionGranted}
|
||||
onRequestCameraPermission={requestCameraPermission}
|
||||
photoOutput={photoOutput}
|
||||
@@ -1390,8 +1359,7 @@ export default function App() {
|
||||
photoStraighten={photoStraighten}
|
||||
onPickRotation={setPhotoRotation}
|
||||
onStraighten={setPhotoStraighten}
|
||||
autoHorizon={autoHorizon}
|
||||
onAutoHorizon={setAutoHorizon}
|
||||
onAutoStraighten={handleAutoStraighten}
|
||||
wallLandscape={wallLandscape}
|
||||
onToggleWallLandscape={() => setWallLandscape((v) => !v)}
|
||||
useGeotag={useGeotag}
|
||||
@@ -1420,7 +1388,6 @@ export default function App() {
|
||||
wmRotation={wmRotation}
|
||||
onRotateWm={() => setWmRotation((r) => (r + 90) % 360)}
|
||||
onWmPanelChange={setWmPanelOpen}
|
||||
onRotatePanelChange={setRotatePanelOpen}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user