android: rotate bar icons/text + OSD labels by physical grip angle (accelerometer), image region stays glued

This commit is contained in:
ZXCLI
2026-09-07 13:37:34 +08:00
parent d0b133a895
commit b1a3c38a69
12 changed files with 155 additions and 17 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,13 +12,17 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.mag160c.thermal.R
@@ -38,12 +42,20 @@ private val TABS = listOf(
/**
* App shell. The activity is portrait-locked, so the bottom navigation bar
* is glued to the phone's portrait bottom edge at all times (its absolute
* position never moves, however the phone is physically held). Tab content
* lives in a stable slot.
* position never moves, however the phone is physically held). Bar content
* (icons/text) is pre-rotated by the physical device orientation so it stays
* readable in any grip. Tab content lives in a stable slot.
*/
@Composable
fun AppRoot() {
var tab by rememberSaveable { mutableStateOf(0) }
val phi by DeviceOrientation.deg.collectAsState()
val ctx = LocalContext.current
DisposableEffect(Unit) {
DeviceOrientation.start(ctx)
onDispose { DeviceOrientation.stop() }
}
Box(modifier = Modifier.fillMaxSize()) {
when (tab) {
@@ -71,6 +83,8 @@ fun AppRoot() {
NavigationBarItem(
selected = tab == i,
onClick = { tab = i },
// pre-rotate so the item is upright in the current grip
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
icon = { Icon(painterResource(t.icon), null) },
label = { Text(t.label) },
)
@@ -0,0 +1,59 @@
package com.mag160c.thermal.ui
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlin.math.abs
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Physical orientation of the phone relative to its portrait grip, from the
* accelerometer. [deg] is the CLOCKWISE rotation of the device as seen by
* the user: 0 = upright portrait, 90 = turned clockwise (portrait top edge
* points to the user's right, gravity along +X device), 180 = upside down,
* 270 = turned counter-clockwise.
*
* The activity is portrait-locked (composition glued to the phone frame, so
* the thermal image region always matches the lens direction). UI layers use
* [deg] to pre-rotate their icons/text by -deg so labels stay readable in
* whatever grip the phone is currently held.
*/
object DeviceOrientation : SensorEventListener {
private val _deg = MutableStateFlow(0)
/** Physical CW rotation of the phone relative to the portrait grip: 0/90/180/270. */
val deg: StateFlow<Int> = _deg
private var sm: SensorManager? = null
fun start(context: Context) {
if (sm != null) return
val m = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = m.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) ?: return
sm = m
m.registerListener(this, sensor, SensorManager.SENSOR_DELAY_UI)
}
fun stop() {
sm?.unregisterListener(this)
sm = null
}
override fun onSensorChanged(event: SensorEvent) {
val gx = event.values[0]
val gy = event.values[1]
// hysteresis: only switch pose when the dominant axis clearly wins,
// so ~45 deg in-between holds keep the previous reading
val next = when {
abs(gx) > abs(gy) + 2.5f -> if (gx > 0) 90 else 270
abs(gy) > abs(gx) + 2.5f -> if (gy < 0) 0 else 180
else -> _deg.value
}
if (next != _deg.value) _deg.value = next
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
@@ -16,8 +16,10 @@ import android.view.SurfaceView
* CW (3:4 vertical) into a fixed fitted rect inside the available area
* (full screen minus the control top bar and the bottom navigation bar).
* The image region does NOT move or rotate however the phone is physically
* held, so the on-screen area always matches the thermal lens direction;
* OSD text stays screen-horizontal so the labels are always readable.
* held, so the on-screen area always matches the thermal lens direction.
* OSD text (center temp / probe labels / color-bar numbers) is pre-rotated
* by the accelerometer-derived grip angle so labels stay readable in any
* grip; marker dots and the color-bar strip stay glued to the image.
*/
class LiveRenderer(
private val surfaceView: SurfaceView,
@@ -40,6 +42,10 @@ class LiveRenderer(
}
private val viewport = android.graphics.RectF()
/** OSD text compensation: pre-rotation so labels are upright in the current grip. */
private val textRot: Float
get() = -com.mag160c.thermal.ui.DeviceOrientation.deg.value.toFloat()
fun attach() {
surfaceView.holder.addCallback(this)
}
@@ -147,7 +153,11 @@ class LiveRenderer(
if (tx + tw + pad > viewport.right) tx = cx - 14f * density - tw
if (ty > viewport.bottom - 4f * density) ty = cy - 10f * density
if (ty < viewport.top + textPaint.textSize) ty = cy + textPaint.textSize + 4f * density
// pivot around the marker anchor: the label stays attached while upright
canvas.save()
canvas.rotate(textRot, cx, cy)
canvas.drawText(text, tx, ty, textPaint)
canvas.restore()
}
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
@@ -169,8 +179,16 @@ class LiveRenderer(
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
canvas.drawText(maxT, x + barW / 2f - textPaint.measureText(maxT) / 2, y0 - 8f * density, textPaint)
canvas.drawText(minT, x + barW / 2f - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint)
val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
val labelMaxX = x + barW / 2f - textPaint.measureText(minT) / 2
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density)
canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint)
canvas.restore()
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize)
canvas.drawText(minT, labelMaxX, y0 + barH + textPaint.textSize, textPaint)
canvas.restore()
}
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
@@ -178,7 +196,10 @@ class LiveRenderer(
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
canvas.save()
canvas.rotate(textRot, ox, oy)
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
canvas.restore()
}
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "")
@@ -31,6 +31,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
@@ -48,12 +49,15 @@ import android.view.SurfaceView
* The activity is portrait-locked, so the whole composition (top bar /
* image region / bottom bar) is glued to the phone's portrait frame and
* never moves or rotates, however the phone is physically held — the on-
* screen area always matches the thermal lens direction.
* screen area always matches the thermal lens direction. The bars' icons
* and text are pre-rotated by the physical grip angle (DeviceOrientation)
* so they stay readable in any grip.
*/
@Composable
fun LiveScreen(vm: LiveViewModel = viewModel()) {
val state by vm.state.collectAsState()
val context = LocalContext.current
val phi by com.mag160c.thermal.ui.DeviceOrientation.deg.collectAsState()
var showPalette by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -84,7 +88,10 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
if (!state.connected) {
Text(
text = statusText(state),
modifier = Modifier.align(Alignment.Center).padding(16.dp),
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer { rotationZ = -phi.toFloat() }
.padding(16.dp),
)
}
@@ -107,17 +114,17 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.triggerFfc() }) {
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
Text(
text = if (state.maxTraceOn) "追踪·开" else "追踪",
style = MaterialTheme.typography.labelMedium,
@@ -126,22 +133,22 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
modifier = Modifier.clickable { vm.toggleMaxTrace() },
)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { showPalette = true }) {
Icon(painterResource(R.drawable.ic_palette), "调色板")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.capturePhoto(context) }) {
Icon(painterResource(R.drawable.ic_camera), "拍照")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.toggleRecording(context) }) {
Icon(painterResource(R.drawable.ic_record), "录像")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
Text(
text = Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelMedium,
Binary file not shown.
+6
View File
@@ -51,6 +51,7 @@
android/app/src/main/kotlin/com/mag160c/thermal/
├─ MainActivity.kt 启动,enableEdgeToEdge + 隐藏状态栏(沉浸)
├─ ui/AppRoot.kt App壳:底部导航(实时/相册/分析/设置)恒贴竖屏底边(Activity 竖屏锁定)
├─ ui/DeviceOrientation.kt 加速度计→手机物理朝向(条栏/OSD 文字补偿旋转用)
├─ ui/UiInsets.kt 单例:底部导航高度 px(渲染器用它留白)
├─ ui/theme/Theme.kt M3 动态取色(Android12+
├─ ui/live/ ★实时画面(最核心)
@@ -105,6 +106,11 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
- **图标/文字按物理持机朝向补偿旋转**(第六轮定稿):`ui/DeviceOrientation.kt` 用
加速度计得出手机相对竖屏的顺时针物理转角 φ(0/90/180/270,带滞回;竖屏锁定下
Display.rotation 恒 0 不可用)。顶栏/底部导航条目 `graphicsLayer rotationZ=-φ`
原位预旋转;渲染器 OSD 文字 `canvas.rotate(-φ)` 绕锚点旋转(标记圆点、色标条
几何仍钉死在图像上)。对话框与其他页签暂不补偿。
- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
`fx=1-sy/120, fy=sx/160`)。
+16
View File
@@ -138,6 +138,22 @@
- [x] 单元测试全过;APK 已更新 build-artifacts/mag160c-app-debug.apk。
- 注:不要再改回 fullSensor/sensor 横屏;横竖屏适配类需求一律以"竖屏构图恒定"为准。
## 用户反馈修复 第六轮(2026-09-07,横屏持机图标文字可读)
- [x] **用户反馈**:竖屏锁定后,横过来拿手机时顶栏/底栏里的图标和文字不旋转(侧着)。
约定补全:**构图钉死竖屏框架不变**(条栏绝对位置+热像区域不动),但条栏内容
(图标+文字)与 OSD 文字要按**物理持机朝向**补偿旋转,保持可读。
- [x] 新增 `ui/DeviceOrientation.kt`:加速度计→手机相对竖屏的顺时针物理转角
φ∈{0,90,180,270}(主轴判定+2.5m/s² 滞回;竖屏锁定下 Display.rotation 恒 0 不可用)。
- [x] 顶栏 7 控制项/底部导航 4 项:`graphicsLayer rotationZ=-φ` 原位预旋转(布局不动)。
- [x] 渲染器 OSD 文字(中心温/探针标注/色标最高最低数字)`canvas.rotate(-φ)` 绕锚点
旋转;标记圆点、色标条几何仍钉死在图像上。
- [x] 模拟器实测(`adb emu sensor set acceleration` 驱动 4 姿态):四姿态下热像区域
完全一致,图标/文字按姿态正确补偿(analysis/preview/preview_pose*_v6.png);
姿态复位 0° 后输出与第五轮逐字节一致(MD5 相同)。
- [x] 单元测试全过;APK 已更新。
- 注:对话框(调色板等)与其他页签内容未做补偿旋转(保持竖屏可读),如需再加。
## 待办
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)