Compare commits
21
Commits
dd52469377
...
456285c71b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
456285c71b | ||
|
|
add3d06713 | ||
|
|
7437ce5e85 | ||
|
|
fabea4c664 | ||
|
|
b1a3c38a69 | ||
|
|
d0b133a895 | ||
|
|
2c38537303 | ||
|
|
798e180683 | ||
|
|
96b92a8d26 | ||
|
|
8d22969f6d | ||
|
|
17fcb7b94c | ||
|
|
f8eab28857 | ||
|
|
430bf373e4 | ||
|
|
6d0805af90 | ||
|
|
4fd2e5bf79 | ||
|
|
a07a49b437 | ||
|
|
3bca426c99 | ||
|
|
29a9b85cbe | ||
|
|
ed0a0882d8 | ||
|
|
6297a06b97 | ||
|
|
5b41a6aa9b |
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate Kotlin tables from csdk C headers (palette256, t2e)."""
|
||||||
|
import re, sys, io
|
||||||
|
|
||||||
|
CSDK = r"C:\Project\MAG160C\csdk\src"
|
||||||
|
OUT = r"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\OfficialTables.kt"
|
||||||
|
|
||||||
|
def read(fn):
|
||||||
|
with open(fn, encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def parse_matrix(text, rows=256):
|
||||||
|
vals = []
|
||||||
|
for m in re.finditer(r"\{\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\}", text):
|
||||||
|
vals.append((int(m.group(3)), int(m.group(2)), int(m.group(1)))) # R,G,B from (B,G,R,0)
|
||||||
|
if len(vals) == rows:
|
||||||
|
break
|
||||||
|
return vals
|
||||||
|
|
||||||
|
def parse_int32(text):
|
||||||
|
body = text[text.index("{"):]
|
||||||
|
return [int(x) for x in re.findall(r"-?\d+", body)]
|
||||||
|
|
||||||
|
pal_txt = read(rf"{CSDK}\mag160c_official_palette256.h")
|
||||||
|
t2e_txt = read(rf"{CSDK}\mag160c_official_t2e.h")
|
||||||
|
tables_txt = read(rf"{CSDK}\mag160c_tables.h")
|
||||||
|
|
||||||
|
pal = parse_matrix(pal_txt)
|
||||||
|
assert len(pal) == 256, f"palette entries: {len(pal)}"
|
||||||
|
t2e = parse_int32(t2e_txt)
|
||||||
|
assert len(t2e) == 646, f"t2e entries: {len(t2e)}"
|
||||||
|
|
||||||
|
# mag160c_t2e (274 entries, hex) + mag160c_e2t_acc_q10 (274 entries)
|
||||||
|
def parse_hex_array(text, name):
|
||||||
|
m = re.search(rf"static const uint32_t {name}\[MAG160C_TEMP_CURVE_ENTRIES\] = \{{(.*?)\}};", text, re.S)
|
||||||
|
assert m, name
|
||||||
|
return [int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", m.group(1))]
|
||||||
|
|
||||||
|
t2e274 = parse_hex_array(tables_txt, "mag160c_t2e")
|
||||||
|
assert len(t2e274) == 274, f"t2e274: {len(t2e274)}"
|
||||||
|
e2t = parse_hex_array(tables_txt, "mag160c_e2t_acc_q10")
|
||||||
|
assert len(e2t) == 274, f"e2t: {len(e2t)}"
|
||||||
|
|
||||||
|
# palette ARGB int per gray index
|
||||||
|
lines = []
|
||||||
|
lines.append("package com.mag160c.thermal.core")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("/** Generated from csdk C headers by analysis/gen_kotlin_tables.py. DO NOT EDIT. */")
|
||||||
|
lines.append("object OfficialTables {")
|
||||||
|
lines.append(" /** Official default palette (256 gray levels -> ARGB int), source: CoreSDKLib dev+0xb18. */")
|
||||||
|
lines.append(" val PALETTE256_ARGB = intArrayBuilder {")
|
||||||
|
for i, (r, g, b) in enumerate(pal):
|
||||||
|
argb = (0xFF << 24) | (r << 16) | (g << 8) | b
|
||||||
|
lines.append(f" add({argb}) // {i}")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" /** Official T2E table (646 int32), temp = slope*diff>>12 + (i<<12) - 0x249f0. */")
|
||||||
|
lines.append(" val T2E = intArrayBuilder {")
|
||||||
|
for i in range(0, len(t2e), 8):
|
||||||
|
chunk = t2e[i:i+8]
|
||||||
|
lines.append(" add(" + ", ".join(str(v) for v in chunk) + (")" if i + 8 >= len(t2e) else ""))
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append("}")
|
||||||
|
kt = "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
# intArrayBuilder trick is overkill; use direct arrays with chunked lines instead.
|
||||||
|
argb_lines = []
|
||||||
|
def signed(v):
|
||||||
|
return v if v < 2**31 else v - 2**32
|
||||||
|
for i in range(0, len(pal), 6):
|
||||||
|
row = ", ".join(str(signed((0xFF << 24) | (r << 16) | (g << 8) | b)) for (r, g, b) in pal[i:i+6])
|
||||||
|
argb_lines.append(" " + row + ",")
|
||||||
|
t2e_lines = []
|
||||||
|
for i in range(0, len(t2e), 8):
|
||||||
|
row = ", ".join(str(v) for v in t2e[i:i+8])
|
||||||
|
t2e_lines.append(" " + row + ("," if i + 8 < len(t2e) else ""))
|
||||||
|
|
||||||
|
t2e274_lines = []
|
||||||
|
for i in range(0, len(t2e274), 8):
|
||||||
|
row = ", ".join(f"0x{v:08x}" for v in t2e274[i:i+8])
|
||||||
|
t2e274_lines.append(" " + row + ("," if i + 8 < len(t2e274) else ""))
|
||||||
|
|
||||||
|
e2t_lines = []
|
||||||
|
for i in range(0, len(e2t), 8):
|
||||||
|
row = ", ".join(f"0x{v:08x}" for v in e2t[i:i+8])
|
||||||
|
e2t_lines.append(" " + row + ("," if i + 8 < len(e2t) else ""))
|
||||||
|
|
||||||
|
kt = f"""package com.mag160c.thermal.core
|
||||||
|
|
||||||
|
/** Generated from csdk C headers by analysis/gen_kotlin_tables.py. DO NOT EDIT. */
|
||||||
|
object OfficialTables {{
|
||||||
|
/** Official default palette: 256 gray levels -> ARGB int (from CoreSDKLib dev+0xb18, B,G,R order swapped). */
|
||||||
|
val PALETTE256_ARGB = intArrayOf(
|
||||||
|
{chr(10).join(argb_lines)}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Official T2E table (646 int32): temp = slope*diff>>12 + (i<<12) - 0x249f0. */
|
||||||
|
val T2E = intArrayOf(
|
||||||
|
{chr(10).join(t2e_lines)}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Vendor T2E curve from libcoresdk.so ARM64 @0x402010 (274 uint32 entries). */
|
||||||
|
val T2E274 = intArrayOf(
|
||||||
|
{chr(10).join(t2e274_lines)}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Vendor E2TAccQ10 curve from libcoresdk.so ARM64 @0x40245c (274 uint32 entries). */
|
||||||
|
val E2T_ACC_Q10 = intArrayOf(
|
||||||
|
{chr(10).join(e2t_lines)}
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
with open(OUT, "w", encoding="utf-8", newline="\n") as f:
|
||||||
|
f.write(kt)
|
||||||
|
print(f"written {OUT}: palette {len(pal)} entries, t2e {len(t2e)} entries")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,69 @@
|
|||||||
|
/* render_offline.c - PC reference runner for the official mag160c pipeline.
|
||||||
|
* Usage: render_offline.exe <ddt> <frames.bin> <out.rgb> <stats.txt>
|
||||||
|
* frames.bin = sequence of complete USB frames (0x38 + 38400 bytes each).
|
||||||
|
* Writes per-frame 320x240x3 RGB24 + one stats line per frame.
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "mag160c/mag160c_render.h"
|
||||||
|
|
||||||
|
static unsigned rd32(const unsigned char *p) {
|
||||||
|
return p[0] | (p[1] << 8) | (p[2] << 16) | ((unsigned)p[3] << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ffc_cb(void *user, int param) { (void)user; (void)param; }
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
if (argc < 5) { fprintf(stderr, "usage: %s ddt frames out.rgb stats\n", argv[0]); return 1; }
|
||||||
|
FILE *ff = fopen(argv[2], "rb");
|
||||||
|
if (!ff) { fprintf(stderr, "open frames fail\n"); return 1; }
|
||||||
|
fseek(ff, 0, SEEK_END);
|
||||||
|
long fsz = ftell(ff);
|
||||||
|
fseek(ff, 0, SEEK_SET);
|
||||||
|
unsigned char *buf = malloc(fsz);
|
||||||
|
if (fread(buf, 1, fsz, ff) != (size_t)fsz) { fclose(ff); return 1; }
|
||||||
|
fclose(ff);
|
||||||
|
|
||||||
|
const unsigned frame_len = 38400;
|
||||||
|
const unsigned total = 0x38 + frame_len;
|
||||||
|
if (fsz < (long)total) { fprintf(stderr, "frames too small\n"); return 1; }
|
||||||
|
|
||||||
|
FILE *out = fopen(argv[3], "wb");
|
||||||
|
FILE *st = fopen(argv[4], "w");
|
||||||
|
if (!out || !st) { fprintf(stderr, "open out fail\n"); return 1; }
|
||||||
|
|
||||||
|
mag160c_render_t *r = NULL;
|
||||||
|
mag160c_render_cfg_t cfg;
|
||||||
|
memset(&cfg, 0, sizeof(cfg));
|
||||||
|
cfg.width = 160; cfg.height = 120;
|
||||||
|
cfg.ddt_path = argv[1];
|
||||||
|
cfg.ffc_cb = ffc_cb;
|
||||||
|
cfg.startup_force_ffc = 1;
|
||||||
|
cfg.cdf_pivot_75 = 1;
|
||||||
|
if (mag160c_render_init(&r, &cfg) != MAG160C_OK) {
|
||||||
|
fprintf(stderr, "render init fail\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned char *rgb = malloc(320 * 240 * 3);
|
||||||
|
int nframes = fsz / total;
|
||||||
|
for (int f = 0; f < nframes; ++f) {
|
||||||
|
unsigned char *fr = buf + (long)f * total;
|
||||||
|
/* sanity: marker + len */
|
||||||
|
if (rd32(fr) != 0x1bb1b11b) { fprintf(stderr, "bad marker at frame %d\n", f); return 1; }
|
||||||
|
if (rd32(fr + 8) != frame_len) { fprintf(stderr, "bad len at frame %d\n", f); return 1; }
|
||||||
|
if (rd32(fr + 0x1c + frame_len) != 0x1bb1b11c) { fprintf(stderr, "bad trailer at frame %d\n", f); return 1; }
|
||||||
|
mag160c_error_t rc = mag160c_render_frame(r, fr, 1, rgb);
|
||||||
|
if (rc == MAG160C_OK) {
|
||||||
|
fwrite(rgb, 1, 320 * 240 * 3, out);
|
||||||
|
int lo, hi, mn, mx, mean, sd;
|
||||||
|
mag160c_render_get_stats(r, &lo, &hi, &mn, &mx, &mean, &sd);
|
||||||
|
fprintf(st, "%d %d %d %d %d %d %d\n", f, lo, hi, mn, mx, mean, sd);
|
||||||
|
} else {
|
||||||
|
fprintf(st, "%d HIDDEN\n", f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose(out); fclose(st);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
0 HIDDEN
|
||||||
|
1 HIDDEN
|
||||||
|
2 HIDDEN
|
||||||
|
3 HIDDEN
|
||||||
|
4 HIDDEN
|
||||||
|
5 HIDDEN
|
||||||
|
6 HIDDEN
|
||||||
|
7 HIDDEN
|
||||||
|
8 HIDDEN
|
||||||
|
9 HIDDEN
|
||||||
|
10 HIDDEN
|
||||||
|
11 HIDDEN
|
||||||
|
12 HIDDEN
|
||||||
|
13 HIDDEN
|
||||||
|
14 HIDDEN
|
||||||
|
15 HIDDEN
|
||||||
|
16 HIDDEN
|
||||||
|
17 HIDDEN
|
||||||
|
18 HIDDEN
|
||||||
|
19 HIDDEN
|
||||||
|
20 HIDDEN
|
||||||
|
21 HIDDEN
|
||||||
|
22 HIDDEN
|
||||||
|
23 HIDDEN
|
||||||
|
24 HIDDEN
|
||||||
|
25 HIDDEN
|
||||||
|
26 HIDDEN
|
||||||
|
27 HIDDEN
|
||||||
|
28 HIDDEN
|
||||||
|
29 HIDDEN
|
||||||
|
30 HIDDEN
|
||||||
|
31 HIDDEN
|
||||||
|
32 6961 7961 7118 7668 7461 103
|
||||||
|
33 6962 7962 7098 7663 7462 103
|
||||||
|
34 6962 7962 7116 7675 7462 103
|
||||||
|
35 6961 7961 7106 7672 7461 103
|
||||||
|
36 6961 7961 7106 7659 7461 103
|
||||||
|
37 6961 7961 7116 7664 7461 103
|
||||||
|
38 6961 7961 7121 7671 7461 103
|
||||||
|
39 6962 7962 7117 7673 7462 103
|
||||||
|
40 6962 7962 7132 7667 7462 103
|
||||||
|
41 6962 7962 7105 7664 7462 103
|
||||||
|
42 6961 7961 7118 7671 7461 103
|
||||||
|
43 6961 7961 7130 7671 7461 103
|
||||||
|
44 6962 7962 7122 7661 7462 103
|
||||||
|
45 6961 7961 7096 7669 7461 103
|
||||||
|
46 6961 7961 7096 7671 7461 103
|
||||||
|
47 6962 7962 7122 7675 7462 103
|
||||||
|
48 6961 7961 7094 7667 7461 103
|
||||||
|
49 6962 7962 7099 7664 7462 103
|
||||||
|
50 6962 7962 7134 7669 7462 103
|
||||||
|
51 6962 7962 7133 7662 7462 103
|
||||||
|
52 6961 7961 7129 7671 7461 103
|
||||||
|
53 6962 7962 7126 7671 7462 103
|
||||||
|
54 6961 7961 7119 7669 7461 103
|
||||||
|
55 6961 7961 7096 7673 7461 103
|
||||||
|
56 6961 7961 7135 7661 7461 103
|
||||||
|
57 6962 7962 7099 7662 7462 103
|
||||||
|
58 6961 7961 7122 7659 7461 103
|
||||||
|
59 6962 7962 7116 7669 7462 103
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
captures/
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.mag160c.thermal"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.mag160c.thermal"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
implementation(libs.compose.ui)
|
||||||
|
implementation(libs.compose.ui.graphics)
|
||||||
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.compose.material3)
|
||||||
|
implementation(libs.activity.compose)
|
||||||
|
implementation(libs.core.ktx)
|
||||||
|
implementation(libs.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="MAG160C"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||||
|
android:resizeableActivity="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Auto-launch / system permission dialog when the camera is plugged in -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
|
||||||
|
android:resource="@xml/device_filter" />
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
Binary file not shown.
@@ -0,0 +1,26 @@
|
|||||||
|
package com.mag160c.thermal
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import com.mag160c.thermal.ui.AppRoot
|
||||||
|
import com.mag160c.thermal.ui.theme.Mag160cTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
// immersive: hide the status bar (swipe to reveal)
|
||||||
|
window.insetsController?.let { c ->
|
||||||
|
c.hide(android.view.WindowInsets.Type.statusBars())
|
||||||
|
c.systemBarsBehavior =
|
||||||
|
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
}
|
||||||
|
setContent {
|
||||||
|
Mag160cTheme {
|
||||||
|
AppRoot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.mag160c.thermal.core
|
||||||
|
|
||||||
|
/** Little-endian byte helpers used by the recovered vendor parsers. */
|
||||||
|
object ByteReader {
|
||||||
|
fun rd32(b: ByteArray, off: Int): Int =
|
||||||
|
(b[off].toInt() and 0xFF) or
|
||||||
|
((b[off + 1].toInt() and 0xFF) shl 8) or
|
||||||
|
((b[off + 2].toInt() and 0xFF) shl 16) or
|
||||||
|
((b[off + 3].toInt() and 0xFF) shl 24)
|
||||||
|
|
||||||
|
fun rd16(b: ByteArray, off: Int): Int =
|
||||||
|
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
||||||
|
|
||||||
|
/** Decode `count` little-endian uint16 values into an IntArray. */
|
||||||
|
fun decodeU16(b: ByteArray, off: Int, count: Int, out: IntArray, outOff: Int = 0) {
|
||||||
|
for (i in 0 until count) {
|
||||||
|
out[outOff + i] = rd16(b, off + i * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.mag160c.thermal.core
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming frame assembler, ported from csdk/src/mag160c_frame.c (vendor
|
||||||
|
* libmagcore reader thread). Feed every bulk read from EP 0x81 into
|
||||||
|
* [push]; when a complete valid frame is buffered it is copied to
|
||||||
|
* [outFrame] and the returned length is > 0.
|
||||||
|
*/
|
||||||
|
class FrameStream(maxFrameLen: Int) {
|
||||||
|
private val cap = maxFrameLen * 2 + 0x470
|
||||||
|
private val buf = ByteArray(cap)
|
||||||
|
private var len = 0
|
||||||
|
private var aligned = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append bulk-read data. Returns the total frame size when a complete
|
||||||
|
* frame has been copied into [outFrame] (capacity must be >= 0x38 + len),
|
||||||
|
* otherwise 0.
|
||||||
|
*/
|
||||||
|
fun push(data: ByteArray, size: Int, outFrame: ByteArray): Int {
|
||||||
|
if (cap == 0 || size > cap - len) {
|
||||||
|
len = 0
|
||||||
|
aligned = false
|
||||||
|
}
|
||||||
|
if (size > cap - len) {
|
||||||
|
len = 0
|
||||||
|
}
|
||||||
|
System.arraycopy(data, 0, buf, len, size)
|
||||||
|
len += size
|
||||||
|
|
||||||
|
if (!aligned) {
|
||||||
|
alignToMarker()
|
||||||
|
if (!aligned) return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len < FRAME_OVERHEAD) return 0
|
||||||
|
val dataLen = ByteReader.rd32(buf, 8)
|
||||||
|
if (dataLen < 0 || dataLen > cap - FRAME_OVERHEAD) {
|
||||||
|
len = 0 // bogus length; resync
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (len < FRAME_OVERHEAD + dataLen) return 0
|
||||||
|
|
||||||
|
if (!isValidFrame(buf, len)) {
|
||||||
|
len = 0
|
||||||
|
aligned = false
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val total = FRAME_OVERHEAD + dataLen
|
||||||
|
System.arraycopy(buf, 0, outFrame, 0, total)
|
||||||
|
|
||||||
|
// consume frame
|
||||||
|
System.arraycopy(buf, total, buf, 0, len - total)
|
||||||
|
len -= total
|
||||||
|
if (len == 0) aligned = false
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alignToMarker() {
|
||||||
|
var i = 0
|
||||||
|
while (i + 4 <= len) {
|
||||||
|
if (ByteReader.rd32(buf, i) == FRAME_MARKER) break
|
||||||
|
i += 4
|
||||||
|
}
|
||||||
|
if (i + 4 > len) {
|
||||||
|
len = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (i != 0) {
|
||||||
|
System.arraycopy(buf, i, buf, 0, len - i)
|
||||||
|
len -= i
|
||||||
|
}
|
||||||
|
aligned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val FRAME_MARKER = 0x1BB1B11B
|
||||||
|
const val FRAME_TRAILING_MARKER = 0x1BB1B11C
|
||||||
|
const val DATA_OFFSET = 0x1C
|
||||||
|
const val FRAME_OVERHEAD = 0x38
|
||||||
|
|
||||||
|
/** Validate a buffered frame: marker, type <= 1, length and trailer. */
|
||||||
|
fun isValidFrame(buf: ByteArray, size: Int): Boolean {
|
||||||
|
if (size < DATA_OFFSET + 4) return false
|
||||||
|
if (ByteReader.rd32(buf, 0) != FRAME_MARKER) return false
|
||||||
|
if (ByteReader.rd32(buf, 12) > 1) return false
|
||||||
|
val dataLen = ByteReader.rd32(buf, 8)
|
||||||
|
if (size < FRAME_OVERHEAD + dataLen) return false
|
||||||
|
return ByteReader.rd32(buf, DATA_OFFSET + dataLen) == FRAME_TRAILING_MARKER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package com.mag160c.thermal.core
|
||||||
|
|
||||||
|
/** Generated from csdk C headers by analysis/gen_kotlin_tables.py. DO NOT EDIT. */
|
||||||
|
object OfficialTables {
|
||||||
|
/** Official default palette: 256 gray levels -> ARGB int (from CoreSDKLib dev+0xb18, B,G,R order swapped). */
|
||||||
|
val PALETTE256_ARGB = intArrayOf(
|
||||||
|
-16777216, -16777211, -16777206, -16777201, -16777195, -16777190,
|
||||||
|
-16777185, -16777179, -16777174, -16777169, -16777163, -16777158,
|
||||||
|
-16777153, -16777147, -16777142, -16777137, -16777131, -16777126,
|
||||||
|
-16777121, -16777115, -16777110, -16777105, -16777099, -16646027,
|
||||||
|
-16449418, -16318346, -16121737, -15925129, -15794056, -15597448,
|
||||||
|
-15400839, -15269767, -15073158, -14876550, -14745477, -14548869,
|
||||||
|
-14352260, -14221188, -14024579, -13827971, -13696898, -13500290,
|
||||||
|
-13369217, -13172609, -12976000, -12844928, -12648319, -12451710,
|
||||||
|
-12320638, -12124029, -11927421, -11796348, -11599740, -11403131,
|
||||||
|
-11272059, -11075450, -10878842, -10747769, -10551161, -10420088,
|
||||||
|
-10223480, -10026871, -9895799, -9699190, -9502582, -9371509,
|
||||||
|
-9174901, -8978292, -8847220, -8650611, -8454002, -8322930,
|
||||||
|
-8126321, -7929713, -7798640, -7602032, -7470959, -7274351,
|
||||||
|
-7077742, -6946670, -6750061, -6553453, -6422380, -6225772,
|
||||||
|
-6029163, -5898091, -5701482, -5504874, -5373801, -5177193,
|
||||||
|
-4980584, -4849512, -4652903, -4456294, -4390504, -4324458,
|
||||||
|
-4192876, -4127086, -4061040, -3929458, -3863412, -3797622,
|
||||||
|
-3666040, -3599994, -3534204, -3402622, -3336576, -3270530,
|
||||||
|
-3139204, -3073158, -3007112, -2875530, -2809740, -2743694,
|
||||||
|
-2612112, -2546322, -2480276, -2348694, -2282648, -2216858,
|
||||||
|
-2085276, -2019230, -1887905, -1887397, -1821353, -1755310,
|
||||||
|
-1689522, -1623479, -1557435, -1491392, -1425604, -1359561,
|
||||||
|
-1293517, -1227729, -1161686, -1095642, -1029599, -963811,
|
||||||
|
-963304, -897260, -831473, -765425, -699377, -633329,
|
||||||
|
-567537, -501489, -435441, -369393, -303601, -237553,
|
||||||
|
-171505, -105713, -39665, -39153, -38641, -38385,
|
||||||
|
-37873, -37361, -37105, -36593, -36081, -35569,
|
||||||
|
-35313, -34801, -34289, -33777, -33521, -33009,
|
||||||
|
-32497, -32241, -31729, -31217, -30705, -30449,
|
||||||
|
-29937, -29425, -28913, -28657, -28145, -27633,
|
||||||
|
-27377, -26865, -26353, -25841, -25585, -25073,
|
||||||
|
-24561, -24305, -23793, -23281, -22769, -22513,
|
||||||
|
-22001, -21489, -20977, -20721, -20209, -19697,
|
||||||
|
-19441, -18929, -18417, -17905, -17649, -17137,
|
||||||
|
-16625, -16369, -15857, -15345, -14833, -14577,
|
||||||
|
-14065, -13553, -13041, -12785, -12273, -11761,
|
||||||
|
-11505, -10993, -10481, -9969, -9713, -9201,
|
||||||
|
-8689, -8433, -7914, -7394, -6875, -6611,
|
||||||
|
-6092, -5572, -5053, -4789, -4270, -3750,
|
||||||
|
-3487, -2967, -2448, -1928, -1665, -1145,
|
||||||
|
-626, -106, -99, -91, -84, -76,
|
||||||
|
-69, -61, -54, -46, -39, -31,
|
||||||
|
-24, -16, -9, -1,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Official T2E table (646 int32): temp = slope*diff>>12 + (i<<12) - 0x249f0. */
|
||||||
|
val T2E = intArrayOf(
|
||||||
|
51, 70, 94, 125, 162, 208, 264, 331,
|
||||||
|
410, 503, 612, 737, 881, 1045, 1231, 1440,
|
||||||
|
1675, 1937, 2227, 2547, 2899, 3285, 3705, 4162,
|
||||||
|
4658, 5192, 5768, 6386, 7047, 7754, 8506, 9305,
|
||||||
|
10153, 11050, 11997, 12995, 14045, 15147, 16303, 17513,
|
||||||
|
18778, 20098, 21473, 22905, 24393, 25938, 27540, 29199,
|
||||||
|
30916, 32690, 34522, 36412, 38360, 40366, 42430, 44552,
|
||||||
|
46731, 48968, 51262, 53613, 56022, 58487, 61008, 63586,
|
||||||
|
66220, 68910, 71654, 74454, 77308, 80217, 83179, 86195,
|
||||||
|
89263, 92385, 95558, 98783, 102059, 105387, 108764, 112191,
|
||||||
|
115668, 119194, 122768, 126391, 130060, 133777, 137541, 141350,
|
||||||
|
145205, 149105, 153050, 157039, 161072, 165147, 169266, 173426,
|
||||||
|
177629, 181873, 186157, 190482, 194847, 199251, 203694, 208175,
|
||||||
|
212695, 217252, 221847, 226478, 231145, 235849, 240587, 245361,
|
||||||
|
250169, 255012, 259888, 264797, 269740, 274715, 279722, 284761,
|
||||||
|
289831, 294933, 300064, 305227, 310419, 315640, 320891, 326170,
|
||||||
|
331478, 336814, 342178, 347569, 352988, 358433, 363904, 369402,
|
||||||
|
374925, 380474, 386048, 391647, 397271, 402919, 408591, 414286,
|
||||||
|
420005, 425747, 431512, 437300, 443109, 448941, 454795, 460670,
|
||||||
|
466566, 472484, 478422, 484381, 490360, 496359, 502378, 508416,
|
||||||
|
514474, 520551, 526647, 532761, 538894, 545045, 551215, 557402,
|
||||||
|
563606, 569828, 576068, 582324, 588597, 594887, 601193, 607516,
|
||||||
|
613854, 620209, 626579, 632965, 639366, 645782, 652213, 658660,
|
||||||
|
665120, 671596, 678086, 684589, 691108, 697639, 704185, 710744,
|
||||||
|
717317, 723903, 730502, 737114, 743740, 750377, 757028, 763691,
|
||||||
|
770366, 777053, 783753, 790464, 797187, 803922, 810669, 817427,
|
||||||
|
824196, 830977, 837768, 844571, 851384, 858209, 865043, 871889,
|
||||||
|
878745, 885611, 892487, 899374, 906270, 913177, 920093, 927019,
|
||||||
|
933954, 940899, 947854, 954818, 961791, 968773, 975764, 982764,
|
||||||
|
989773, 996791, 1003818, 1010853, 1017897, 1024949, 1032009, 1039078,
|
||||||
|
1046155, 1053240, 1060333, 1067435, 1074544, 1081661, 1088785, 1095918,
|
||||||
|
1103058, 1110205, 1117360, 1124523, 1131692, 1138869, 1146053, 1153245,
|
||||||
|
1160443, 1167648, 1174860, 1182080, 1189305, 1196538, 1203777, 1211023,
|
||||||
|
1218276, 1225535, 1232800, 1240072, 1247350, 1254635, 1261925, 1269222,
|
||||||
|
1276525, 1283834, 1291149, 1298470, 1305797, 1313129, 1320468, 1327812,
|
||||||
|
1335162, 1342517, 1349878, 1357245, 1364617, 1371995, 1379378, 1386766,
|
||||||
|
1394160, 1401559, 1408963, 1416372, 1423786, 1431206, 1438631, 1446060,
|
||||||
|
1453495, 1460934, 1468379, 1475828, 1483282, 1490741, 1498204, 1505672,
|
||||||
|
1513145, 1520622, 1528104, 1535591, 1543082, 1550578, 1558077, 1565582,
|
||||||
|
1573090, 1580603, 1588121, 1595642, 1603168, 1610698, 1618232, 1625770,
|
||||||
|
1633312, 1640859, 1648409, 1655963, 1663522, 1671084, 1678650, 1686220,
|
||||||
|
1693794, 1701371, 1708953, 1716538, 1724127, 1731719, 1739315, 1746915,
|
||||||
|
1754519, 1762126, 1769736, 1777350, 1784968, 1792589, 1800214, 1807842,
|
||||||
|
1815473, 1823108, 1830746, 1838387, 1846032, 1853680, 1861331, 1868986,
|
||||||
|
1876643, 1884304, 1891968, 1899635, 1907306, 1914979, 1922655, 1930335,
|
||||||
|
1938017, 1945703, 1953391, 1961082, 1968777, 1976474, 1984174, 1991877,
|
||||||
|
1999583, 2007292, 2015003, 2022718, 2030435, 2038155, 2045877, 2053602,
|
||||||
|
2061330, 2069061, 2076794, 2084530, 2092269, 2100010, 2107754, 2115500,
|
||||||
|
2123249, 2131001, 2138755, 2146511, 2154270, 2162031, 2169795, 2177562,
|
||||||
|
2185330, 2193101, 2200875, 2208651, 2216429, 2224210, 2231993, 2239778,
|
||||||
|
2247565, 2255355, 2263147, 2270941, 2278738, 2286537, 2294338, 2302141,
|
||||||
|
2309946, 2317754, 2325563, 2333375, 2341189, 2349005, 2356823, 2364643,
|
||||||
|
2372465, 2380290, 2388116, 2395944, 2403775, 2411607, 2419441, 2427278,
|
||||||
|
2435116, 2442956, 2450798, 2458642, 2466488, 2474336, 2482186, 2490038,
|
||||||
|
2497891, 2505747, 2513604, 2521463, 2529324, 2537187, 2545051, 2552918,
|
||||||
|
2560786, 2568656, 2576527, 2584401, 2592276, 2600153, 2608031, 2615911,
|
||||||
|
2623793, 2631677, 2639562, 2647449, 2655338, 2663228, 2671120, 2679014,
|
||||||
|
2686909, 2694806, 2702704, 2710604, 2718505, 2726408, 2734313, 2742219,
|
||||||
|
2750127, 2758036, 2765947, 2773859, 2781773, 2789688, 2797605, 2805524,
|
||||||
|
2813443, 2821364, 2829287, 2837211, 2845137, 2853064, 2860992, 2868922,
|
||||||
|
2876853, 2884786, 2892720, 2900655, 2908592, 2916530, 2924470, 2932411,
|
||||||
|
2940353, 2948296, 2956241, 2964187, 2972135, 2980084, 2988034, 2995985,
|
||||||
|
3003938, 3011892, 3019847, 3027803, 3035761, 3043720, 3051680, 3059642,
|
||||||
|
3067605, 3075569, 3083534, 3091500, 3099468, 3107436, 3115406, 3123378,
|
||||||
|
3131350, 3139323, 3147298, 3155274, 3163251, 3171229, 3179208, 3187189,
|
||||||
|
3195170, 3203153, 3211137, 3219121, 3227107, 3235095, 3243083, 3251072,
|
||||||
|
3259062, 3267054, 3275046, 3283040, 3291035, 3299030, 3307027, 3315025,
|
||||||
|
3323024, 3331023, 3339024, 3347026, 3355029, 3363033, 3371038, 3379044,
|
||||||
|
3387051, 3395059, 3403068, 3411078, 3419089, 3427101, 3435113, 3443127,
|
||||||
|
3451142, 3459158, 3467174, 3475192, 3483211, 3491230, 3499250, 3507272,
|
||||||
|
3515294, 3523317, 3531341, 3539366, 3547392, 3555419, 3563447, 3571475,
|
||||||
|
3579505, 3587535, 3595566, 3603598, 3611631, 3619665, 3627700, 3635735,
|
||||||
|
3643772, 3651809, 3659847, 3667886, 3675926, 3683966, 3692008, 3700050,
|
||||||
|
3708093, 3716137, 3724182, 3732227, 3740273, 3748321, 3756368, 3764417,
|
||||||
|
3772467, 3780517, 3788568, 3796620, 3804672, 3812726, 3820780, 3828835,
|
||||||
|
3836891, 3844947, 3853004, 3861062, 3869121, 3877180, 3885240, 3893301,
|
||||||
|
3901363, 3909425, 3917488, 3925552, 3933617, 3941682, 3949748, 3957814,
|
||||||
|
3965882, 3973950, 3982019, 3990088, 3998158, 4006229, 4014301, 4022373,
|
||||||
|
4030446, 4038519, 4046594, 4054669, 4062744, 4070820, 4078897, 4086975,
|
||||||
|
4095053, 4103132, 4111212, 4119292, 4127373, 4135454, 4143536, 4151619,
|
||||||
|
4159703, 4167787, 4175871, 4183957, 4192042, 4200129
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Vendor T2E curve from libcoresdk.so ARM64 @0x402010 (274 uint32 entries). */
|
||||||
|
val T2E274 = intArrayOf(
|
||||||
|
0x000003e8, 0x000004d3, 0x000005e1, 0x00000714, 0x0000086e, 0x000009f1, 0x00000b9e, 0x00000d76,
|
||||||
|
0x00000f7b, 0x000011ae, 0x0000140e, 0x0000169d, 0x0000195c, 0x00001c49, 0x00001f67, 0x000022b3,
|
||||||
|
0x0000262f, 0x000029db, 0x00002db5, 0x000031bd, 0x000035f4, 0x00003a58, 0x00003ee9, 0x000043a7,
|
||||||
|
0x00004890, 0x00004da4, 0x000052e2, 0x0000584a, 0x00005ddb, 0x00006394, 0x00006974, 0x00006f7a,
|
||||||
|
0x000075a6, 0x00007bf7, 0x0000826c, 0x00008904, 0x00008fbf, 0x0000969b, 0x00009d98, 0x0000a4b6,
|
||||||
|
0x0000abf3, 0x0000b34e, 0x0000bac8, 0x0000c25f, 0x0000ca12, 0x0000d1e2, 0x0000d9cc, 0x0000e1d1,
|
||||||
|
0x0000e9f0, 0x0000f229, 0x0000fa7a, 0x000102e3, 0x00010b64, 0x000113fb, 0x00011ca9, 0x0001256d,
|
||||||
|
0x00012e46, 0x00013734, 0x00014036, 0x0001494c, 0x00015276, 0x00015bb2, 0x00016501, 0x00016e62,
|
||||||
|
0x000177d4, 0x00018158, 0x00018aec, 0x00019490, 0x00019e45, 0x0001a809, 0x0001b1dd, 0x0001bbbf,
|
||||||
|
0x0001c5b0, 0x0001cfaf, 0x0001d9bc, 0x0001e3d7, 0x0001edff, 0x0001f833, 0x00020275, 0x00020cc3,
|
||||||
|
0x0002171d, 0x00022183, 0x00022bf5, 0x00023672, 0x000240fa, 0x00024b8c, 0x0002562a, 0x000260d2,
|
||||||
|
0x00026b84, 0x00027640, 0x00028106, 0x00028bd5, 0x000296ae, 0x0002a190, 0x0002ac7b, 0x0002b76f,
|
||||||
|
0x0002c26b, 0x0002cd70, 0x0002d87d, 0x0002e392, 0x0002eeaf, 0x0002f9d4, 0x00030500, 0x00031034,
|
||||||
|
0x00031b6f, 0x000326b1, 0x000331fb, 0x00033d4b, 0x000348a2, 0x00035400, 0x00035f64, 0x00036acf,
|
||||||
|
0x00037640, 0x000381b7, 0x00038d34, 0x000398b7, 0x0003a440, 0x0003afcf, 0x0003bb63, 0x0003c6fd,
|
||||||
|
0x0003d29c, 0x0003de40, 0x0003e9ea, 0x0003f599, 0x0004014d, 0x00040d05, 0x000418c3, 0x00042486,
|
||||||
|
0x0004304d, 0x00043c18, 0x000447e9, 0x000453bd, 0x00045f96, 0x00046b74, 0x00047756, 0x0004833b,
|
||||||
|
0x00048f25, 0x00049b13, 0x0004a705, 0x0004b2fb, 0x0004bef4, 0x0004caf2, 0x0004d6f3, 0x0004e2f8,
|
||||||
|
0x0004ef00, 0x0004fb0c, 0x0005071b, 0x0005132e, 0x00051f44, 0x00052b5d, 0x0005377a, 0x0005439a,
|
||||||
|
0x00054fbd, 0x00055be3, 0x0005680c, 0x00057439, 0x00058068, 0x00058c9a, 0x000598cf, 0x0005a507,
|
||||||
|
0x0005b142, 0x0005bd7f, 0x0005c9bf, 0x0005d602, 0x0005e247, 0x0005ee90, 0x0005fada, 0x00060727,
|
||||||
|
0x00061377, 0x00061fc9, 0x00062c1e, 0x00063875, 0x000644ce, 0x00065129, 0x00065d87, 0x000669e7,
|
||||||
|
0x0006764a, 0x000682ae, 0x00068f15, 0x00069b7e, 0x0006a7e9, 0x0006b456, 0x0006c0c5, 0x0006cd36,
|
||||||
|
0x0006d9a9, 0x0006e61e, 0x0006f295, 0x0006ff0e, 0x00070b89, 0x00071805, 0x00072484, 0x00073104,
|
||||||
|
0x00073d86, 0x00074a0a, 0x0007568f, 0x00076317, 0x00076fa0, 0x00077c2a, 0x000788b7, 0x00079545,
|
||||||
|
0x0007a1d4, 0x0007ae66, 0x0007baf8, 0x0007c78d, 0x0007d423, 0x0007e0ba, 0x0007ed53, 0x0007f9ed,
|
||||||
|
0x00080689, 0x00081327, 0x00081fc5, 0x00082c65, 0x00083907, 0x000845aa, 0x0008524e, 0x00085ef4,
|
||||||
|
0x00086b9b, 0x00087843, 0x000884ed, 0x00089197, 0x00089e44, 0x0008aaf1, 0x0008b7a0, 0x0008c44f,
|
||||||
|
0x0008d100, 0x0008ddb3, 0x0008ea66, 0x0008f71b, 0x000903d0, 0x00091087, 0x00091d3f, 0x000929f9,
|
||||||
|
0x000936b3, 0x0009436e, 0x0009502b, 0x00095ce8, 0x000969a7, 0x00097666, 0x00098327, 0x00098fe9,
|
||||||
|
0x00099cab, 0x0009a96f, 0x0009b634, 0x0009c2f9, 0x0009cfc0, 0x0009dc88, 0x0009e950, 0x0009f61a,
|
||||||
|
0x000a02e4, 0x000a0faf, 0x000a1c7c, 0x000a2949, 0x000a3617, 0x000a42e6, 0x000a4fb5, 0x000a5c86,
|
||||||
|
0x000a6958, 0x000a762a, 0x000a82fd, 0x000a8fd1, 0x000a9ca6, 0x000aa97c, 0x000ab652, 0x000ac329,
|
||||||
|
0x000ad001, 0x000adcda, 0x000ae9b4, 0x000af68e, 0x000b0369, 0x000b1045, 0x000b1d22, 0x000b29ff,
|
||||||
|
0x000b36dd, 0x000b43bc
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Vendor E2TAccQ10 curve from libcoresdk.so ARM64 @0x40245c (274 uint32 entries). */
|
||||||
|
val E2T_ACC_Q10 = intArrayOf(
|
||||||
|
0x00008b70, 0x0000795d, 0x00006abc, 0x00005eb5, 0x000054ac, 0x00004c62, 0x0000456c, 0x00003f62,
|
||||||
|
0x00003a34, 0x000035e5, 0x00003207, 0x00002e9d, 0x00002bc0, 0x00002910, 0x000026d3, 0x000024bc,
|
||||||
|
0x000022dc, 0x0000213c, 0x00001fc0, 0x00001e5e, 0x00001d27, 0x00001c08, 0x00001afe, 0x00001a12,
|
||||||
|
0x00001935, 0x0000186b, 0x000017ad, 0x000016ff, 0x0000165e, 0x000015ca, 0x00001540, 0x000014bd,
|
||||||
|
0x00001444, 0x000013d3, 0x0000136a, 0x00001305, 0x000012a9, 0x00001251, 0x000011fc, 0x000011af,
|
||||||
|
0x00001167, 0x0000111f, 0x000010dd, 0x000010a0, 0x00001062, 0x0000102c, 0x00000ff6, 0x00000fc3,
|
||||||
|
0x00000f91, 0x00000f64, 0x00000f38, 0x00000f0d, 0x00000ee7, 0x00000ebf, 0x00000e9a, 0x00000e78,
|
||||||
|
0x00000e56, 0x00000e36, 0x00000e16, 0x00000df8, 0x00000ddc, 0x00000dc0, 0x00000da6, 0x00000d8d,
|
||||||
|
0x00000d74, 0x00000d5d, 0x00000d47, 0x00000d30, 0x00000d1b, 0x00000d06, 0x00000cf4, 0x00000ce0,
|
||||||
|
0x00000cce, 0x00000cbc, 0x00000cab, 0x00000c9a, 0x00000c8c, 0x00000c7a, 0x00000c6c, 0x00000c5e,
|
||||||
|
0x00000c4f, 0x00000c41, 0x00000c34, 0x00000c28, 0x00000c1c, 0x00000c0e, 0x00000c03, 0x00000bf8,
|
||||||
|
0x00000bed, 0x00000be2, 0x00000bd8, 0x00000bcd, 0x00000bc3, 0x00000bb9, 0x00000bb0, 0x00000ba7,
|
||||||
|
0x00000b9e, 0x00000b95, 0x00000b8d, 0x00000b85, 0x00000b7c, 0x00000b75, 0x00000b6d, 0x00000b66,
|
||||||
|
0x00000b5f, 0x00000b57, 0x00000b51, 0x00000b4a, 0x00000b43, 0x00000b3d, 0x00000b36, 0x00000b30,
|
||||||
|
0x00000b2a, 0x00000b24, 0x00000b1e, 0x00000b19, 0x00000b13, 0x00000b0e, 0x00000b08, 0x00000b04,
|
||||||
|
0x00000aff, 0x00000af9, 0x00000af5, 0x00000af0, 0x00000aec, 0x00000ae7, 0x00000ae2, 0x00000ade,
|
||||||
|
0x00000adb, 0x00000ad5, 0x00000ad2, 0x00000ace, 0x00000ac9, 0x00000ac6, 0x00000ac3, 0x00000abe,
|
||||||
|
0x00000abb, 0x00000ab7, 0x00000ab4, 0x00000ab1, 0x00000aac, 0x00000aaa, 0x00000aa6, 0x00000aa4,
|
||||||
|
0x00000aa0, 0x00000a9d, 0x00000a9a, 0x00000a97, 0x00000a95, 0x00000a91, 0x00000a8f, 0x00000a8c,
|
||||||
|
0x00000a89, 0x00000a87, 0x00000a83, 0x00000a82, 0x00000a7f, 0x00000a7c, 0x00000a7a, 0x00000a77,
|
||||||
|
0x00000a76, 0x00000a73, 0x00000a70, 0x00000a6f, 0x00000a6b, 0x00000a6a, 0x00000a68, 0x00000a65,
|
||||||
|
0x00000a64, 0x00000a61, 0x00000a5f, 0x00000a5e, 0x00000a5c, 0x00000a5a, 0x00000a58, 0x00000a55,
|
||||||
|
0x00000a55, 0x00000a52, 0x00000a50, 0x00000a4f, 0x00000a4d, 0x00000a4b, 0x00000a4a, 0x00000a48,
|
||||||
|
0x00000a46, 0x00000a45, 0x00000a43, 0x00000a42, 0x00000a41, 0x00000a3e, 0x00000a3d, 0x00000a3c,
|
||||||
|
0x00000a3a, 0x00000a39, 0x00000a37, 0x00000a36, 0x00000a35, 0x00000a33, 0x00000a32, 0x00000a31,
|
||||||
|
0x00000a2f, 0x00000a2f, 0x00000a2c, 0x00000a2c, 0x00000a2b, 0x00000a29, 0x00000a28, 0x00000a27,
|
||||||
|
0x00000a25, 0x00000a25, 0x00000a23, 0x00000a22, 0x00000a21, 0x00000a20, 0x00000a1f, 0x00000a1e,
|
||||||
|
0x00000a1d, 0x00000a1b, 0x00000a1b, 0x00000a19, 0x00000a19, 0x00000a17, 0x00000a17, 0x00000a16,
|
||||||
|
0x00000a14, 0x00000a14, 0x00000a13, 0x00000a13, 0x00000a11, 0x00000a10, 0x00000a0f, 0x00000a0f,
|
||||||
|
0x00000a0e, 0x00000a0c, 0x00000a0c, 0x00000a0b, 0x00000a0b, 0x00000a09, 0x00000a08, 0x00000a08,
|
||||||
|
0x00000a07, 0x00000a06, 0x00000a06, 0x00000a05, 0x00000a04, 0x00000a04, 0x00000a02, 0x00000a02,
|
||||||
|
0x00000a01, 0x00000a00, 0x00000a00, 0x000009ff, 0x000009fe, 0x000009fe, 0x000009fd, 0x000009fc,
|
||||||
|
0x000009fc, 0x000009fb, 0x000009fa, 0x000009fa, 0x000009f9, 0x000009f9, 0x000009f8, 0x000009f7,
|
||||||
|
0x000009f7, 0x000009f6, 0x000009f6, 0x000009f5, 0x000009f4, 0x000009f3, 0x000009f3, 0x000009f3,
|
||||||
|
0x000009f2, 0x000009f1
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.mag160c.thermal.core
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.exp
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 12 display palettes of the official apps. Index 2 (ironbow) uses the
|
||||||
|
* palette extracted from CoreSDKLib; the rest are standard thermal curves.
|
||||||
|
*/
|
||||||
|
object Palettes {
|
||||||
|
val NAMES = listOf(
|
||||||
|
"白热", "黑热", "铁虹", "彩虹", "琥珀", "金秋",
|
||||||
|
"寒冬", "热金属", "喷射", "红饱和", "高对比度", "红热",
|
||||||
|
)
|
||||||
|
|
||||||
|
val NAMES_EN = listOf(
|
||||||
|
"White hot", "Black hot", "Ironbow", "Rainbow", "Amber", "Autumn",
|
||||||
|
"Winter", "Hot metal", "Jet", "Red saturation", "High contrast", "Red hot",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Build all palettes as ARGB int arrays (256 entries each). */
|
||||||
|
fun buildAll(): List<IntArray> = listOf(
|
||||||
|
ramp(255, 255, 255, 0, 0, 0), // 0 white hot
|
||||||
|
ramp(0, 0, 0, 255, 255, 255), // 1 black hot
|
||||||
|
officialIronbow(), // 2 ironbow (vendor)
|
||||||
|
rainbow(), // 3 rainbow
|
||||||
|
ramp(0, 0, 0, 255, 183, 74), // 4 amber
|
||||||
|
ramp(0, 0, 0, 255, 220, 120), // 5 autumn
|
||||||
|
ramp(0, 0, 0, 200, 230, 255), // 6 winter
|
||||||
|
hotMetal(), // 7 hot metal
|
||||||
|
jet(), // 8 jet
|
||||||
|
ramp(0, 0, 0, 255, 0, 0), // 9 red saturation
|
||||||
|
highContrast(), // 10 high contrast
|
||||||
|
ramp(0, 0, 0, 128, 0, 0), // 11 red hot
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun argb(r: Int, g: Int, b: Int): Int =
|
||||||
|
(0xFF shl 24) or (r.coerceIn(0, 255) shl 16) or (g.coerceIn(0, 255) shl 8) or b.coerceIn(0, 255)
|
||||||
|
|
||||||
|
private fun ramp(r0: Int, g0: Int, b0: Int, r1: Int, g1: Int, b1: Int): IntArray {
|
||||||
|
val out = IntArray(256)
|
||||||
|
for (i in 0 until 256) {
|
||||||
|
val t = i / 255f
|
||||||
|
out[i] = 0xFF000000.toInt() or
|
||||||
|
((r0 + (r1 - r0) * t).toInt() shl 16) or
|
||||||
|
((g0 + (g1 - g0) * t).toInt() shl 8) or
|
||||||
|
(b0 + (b1 - b0) * t).toInt()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vendor ironbow: reuse the generated official table. */
|
||||||
|
private fun officialIronbow(): IntArray = OfficialTables.PALETTE256_ARGB
|
||||||
|
|
||||||
|
/** Classic ironbow-style fallback used by index 3 rainbow curve. */
|
||||||
|
private fun rainbow(): IntArray {
|
||||||
|
val out = IntArray(256)
|
||||||
|
for (i in 0 until 256) {
|
||||||
|
val t = i / 255.0
|
||||||
|
val r = (255 * clamp(1.5 - t.coerceIn(0.0, 1.0) * 4)).toInt()
|
||||||
|
val g = (255 * clamp(1.5 - kotlin.math.abs(t - 0.5) * 4)).toInt()
|
||||||
|
val b = (255 * clamp(t * 2.2)).toInt()
|
||||||
|
out[i] = 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun highContrast(): IntArray {
|
||||||
|
val out = IntArray(256)
|
||||||
|
for (i in 0 until 256) {
|
||||||
|
val v = if (i < 128) (i * 2) else 255
|
||||||
|
out[i] = 0xFF000000.toInt() or (v shl 16) or (v shl 8) or v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hotMetal(): IntArray {
|
||||||
|
val out = IntArray(256)
|
||||||
|
for (i in 0 until 256) {
|
||||||
|
val t = i / 255.0
|
||||||
|
val r = (255 * clamp(t * 1.6)).toInt()
|
||||||
|
val g = (255 * clamp(t * t * 1.9 - 0.25)).toInt()
|
||||||
|
val b = (255 * clamp(t * t * t * 1.6 - 0.6)).toInt()
|
||||||
|
out[i] = 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jet(): IntArray {
|
||||||
|
val out = IntArray(256)
|
||||||
|
for (i in 0 until 256) {
|
||||||
|
val t = i / 255.0
|
||||||
|
val r = (255 * clamp(1.5 - kotlin.math.abs(4.0 * t - 3.0))).toInt()
|
||||||
|
val g = (255 * clamp(1.5 - kotlin.math.abs(4.0 * t - 2.0))).toInt()
|
||||||
|
val b = (255 * clamp(1.5 - kotlin.math.abs(4.0 * t - 1.0))).toInt()
|
||||||
|
out[i] = 0xFF000000.toInt() or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clamp(v: Double): Double = if (v < 0) 0.0 else if (v > 1) 1.0 else v
|
||||||
|
}
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
package com.mag160c.thermal.core
|
||||||
|
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Official MAG160C rendering pipeline, ported from csdk/src/mag160c_render.c
|
||||||
|
* (pixel-verified against CoreSDKLib.dll).
|
||||||
|
*
|
||||||
|
* raw frame -> shutter extract -> FFC state machine -> endpoint select
|
||||||
|
* + Q12 table interp -> ref (4x type-1 mean) -> NUC lookup -> blind
|
||||||
|
* compensation -> stats/window -> LUT1024 rebuild -> gray -> 2x upscale
|
||||||
|
* -> palette -> ARGB output.
|
||||||
|
*
|
||||||
|
* Feed USB frames; get a 320x240 ARGB IntArray ready for Bitmap.setPixels.
|
||||||
|
* FFC commands are emitted through [onFfc] so the caller owns the transport.
|
||||||
|
*/
|
||||||
|
class RenderPipeline(
|
||||||
|
private val w: Int = 160,
|
||||||
|
private val h: Int = 120,
|
||||||
|
private val ffcPeriod: Int = 1800,
|
||||||
|
private val ffcDrift: Int = 250,
|
||||||
|
private val warmFrames: Int = 20,
|
||||||
|
private val cdfPivot75: Boolean = true,
|
||||||
|
private val force75: Boolean = true,
|
||||||
|
/** FFC command callback: param 0 = FFC(0), 1 = FFC(1). */
|
||||||
|
private val onFfc: ((param: Int) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
private val npix = w * h
|
||||||
|
|
||||||
|
// ---- DDT state ----
|
||||||
|
private var epCount = 0
|
||||||
|
private var nsegs = 0
|
||||||
|
private var c5c = 0
|
||||||
|
private var c60 = 0
|
||||||
|
private var dev24 = 5
|
||||||
|
private var dev4cShift = 3
|
||||||
|
private var dev48 = 0
|
||||||
|
private val epTemps = IntArray(64)
|
||||||
|
private val blindCnt = IntArray(64)
|
||||||
|
private val epThr = arrayOfNulls<ShortArray>(64)
|
||||||
|
private val epGain = arrayOfNulls<IntArray>(64)
|
||||||
|
private val epBlind = arrayOfNulls<IntArray>(64)
|
||||||
|
private var sel = 0
|
||||||
|
private var thrWork = ShortArray(0)
|
||||||
|
private var gainWork = IntArray(0)
|
||||||
|
private var blindWork: IntArray? = null
|
||||||
|
private var blindCount = 0
|
||||||
|
|
||||||
|
// ---- frame/FFC state ----
|
||||||
|
private var ffcIdx = -1
|
||||||
|
private var globalFrames = 0
|
||||||
|
private var shutter = 0
|
||||||
|
private var shutterAtFfc = 0
|
||||||
|
private var manualFfc = false
|
||||||
|
private var hasRef = false
|
||||||
|
private var refCnt = 0
|
||||||
|
private var startupFfcDone = false
|
||||||
|
|
||||||
|
private val ref = IntArray(npix)
|
||||||
|
private val refAcc = IntArray(npix)
|
||||||
|
private val nuc = IntArray(npix)
|
||||||
|
private val gray160 = ByteArray(npix)
|
||||||
|
private val gray320 = ByteArray(npix * 4)
|
||||||
|
|
||||||
|
// ---- render state ----
|
||||||
|
private val hist1024 = IntArray(1024)
|
||||||
|
private val cdf = IntArray(1024)
|
||||||
|
private val lut = ByteArray(1024)
|
||||||
|
private val curve = IntArray(1024)
|
||||||
|
private var pal: IntArray = OfficialTables.PALETTE256_ARGB
|
||||||
|
private var winLo = 0
|
||||||
|
private var winHi = 0
|
||||||
|
private var statMin = 0
|
||||||
|
private var statMax = 0
|
||||||
|
private var statMean = 0
|
||||||
|
private var statStd = 0
|
||||||
|
private var centerPrev = 341
|
||||||
|
private var meanPrev = 0
|
||||||
|
|
||||||
|
// ---- output ----
|
||||||
|
val outWidth: Int = w * 2
|
||||||
|
val outHeight: Int = h * 2
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
/** Load the official DDT calibration data (raw file bytes). */
|
||||||
|
fun loadDdt(bytes: ByteArray): Boolean = synchronized(lock) {
|
||||||
|
if (bytes.size < 0x98) return false
|
||||||
|
val magic = ByteReader.rd32(bytes, 0)
|
||||||
|
if (magic != 0x5AA50003 && magic != 0x5AA50004) return false
|
||||||
|
if (ByteReader.rd32(bytes, 4) != w || ByteReader.rd32(bytes, 8) != h) return false
|
||||||
|
epCount = ByteReader.rd32(bytes, 12)
|
||||||
|
nsegs = ByteReader.rd32(bytes, 16)
|
||||||
|
if (epCount < 2 || epCount > 64 || nsegs < 1 || nsegs > 8) return false
|
||||||
|
val v4 = magic == 0x5AA50004
|
||||||
|
dev24 = ByteReader.rd32(bytes, 20)
|
||||||
|
dev4cShift = 3
|
||||||
|
if (!v4) {
|
||||||
|
c5c = ByteReader.rd32(bytes, 28)
|
||||||
|
c60 = ByteReader.rd32(bytes, 32)
|
||||||
|
if (c5c > 0x10000) c5c = 0
|
||||||
|
} else {
|
||||||
|
c5c = ByteReader.rd32(bytes, 0x2C + 0x120)
|
||||||
|
c60 = ByteReader.rd32(bytes, 0x30 + 0x120)
|
||||||
|
}
|
||||||
|
if (c5c and 1 == 1) c5c = c5c and 1.inv()
|
||||||
|
val arro = if (v4) 0x120 + 36 else 36
|
||||||
|
var off = arro
|
||||||
|
if (off + epCount * 12 > bytes.size) return false
|
||||||
|
for (e in 0 until epCount) {
|
||||||
|
epTemps[e] = ByteReader.rd32(bytes, off + e * 4)
|
||||||
|
}
|
||||||
|
off += epCount * 4
|
||||||
|
off += epCount * 4 // skip array 2
|
||||||
|
off += epCount * 4 // skip array 3
|
||||||
|
if (off + (epCount - 1) * 4 > bytes.size) return false
|
||||||
|
for (e in 0 until epCount - 1) {
|
||||||
|
blindCnt[e] = ByteReader.rd32(bytes, off + e * 4)
|
||||||
|
if (blindCnt[e] > 4096) blindCnt[e] = 4096
|
||||||
|
}
|
||||||
|
blindCnt[epCount - 1] = 0
|
||||||
|
off += (epCount - 1) * 4
|
||||||
|
|
||||||
|
val thrSz = (nsegs - 1) * npix + c5c / 2
|
||||||
|
val gainSz = nsegs * npix * 2
|
||||||
|
for (e in 0 until epCount) {
|
||||||
|
if (off + thrSz * 2 + gainSz * 2 > bytes.size) return false
|
||||||
|
val thr = ShortArray(thrSz)
|
||||||
|
for (i in 0 until thrSz) {
|
||||||
|
thr[i] = ByteReader.rd16(bytes, off + i * 2).toShort()
|
||||||
|
}
|
||||||
|
off += thrSz * 2
|
||||||
|
val gain = IntArray(gainSz)
|
||||||
|
ByteReader.decodeU16(bytes, off, gainSz, gain)
|
||||||
|
off += gainSz * 2
|
||||||
|
epThr[e] = thr
|
||||||
|
epGain[e] = gain
|
||||||
|
}
|
||||||
|
for (e in 0 until epCount - 1) {
|
||||||
|
val n = blindCnt[e]
|
||||||
|
if (n > 0) {
|
||||||
|
if (off + n * 40 > bytes.size) return false
|
||||||
|
val rec = IntArray(n * 10)
|
||||||
|
for (i in 0 until n * 10) {
|
||||||
|
rec[i] = ByteReader.rd32(bytes, off + i * 4)
|
||||||
|
}
|
||||||
|
off += n * 40
|
||||||
|
epBlind[e] = rec
|
||||||
|
} else {
|
||||||
|
epBlind[e] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thrWork = ShortArray(thrSz)
|
||||||
|
gainWork = IntArray(gainSz)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectEndpoint(shutter: Int): Int {
|
||||||
|
var s = 0
|
||||||
|
if (epCount != 2) {
|
||||||
|
while (s < epCount - 2 && shutter > epTemps[s + 1]) s++
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildTables(shutter: Int) {
|
||||||
|
sel = selectEndpoint(shutter)
|
||||||
|
val t = (((shutter - epTemps[sel]) shl 12) / (epTemps[sel + 1] - epTemps[sel]))
|
||||||
|
.coerceIn(-0x3FFF, 0x3FFF)
|
||||||
|
val a = epThr[sel]!!
|
||||||
|
val b = epThr[sel + 1]!!
|
||||||
|
val nThr = (nsegs - 1) * npix + c5c / 2
|
||||||
|
for (i in 0 until nThr) {
|
||||||
|
val av = a[i].toInt()
|
||||||
|
thrWork[i] = (av + (((b[i].toInt() - av) * t) shr 12)).toShort()
|
||||||
|
}
|
||||||
|
val ga = epGain[sel]!!
|
||||||
|
val gb = epGain[sel + 1]!!
|
||||||
|
val nGain = nsegs * npix * 2
|
||||||
|
for (i in 0 until nGain) {
|
||||||
|
gainWork[i] = ga[i] + (((gb[i] - ga[i]) * t) shr 12)
|
||||||
|
}
|
||||||
|
blindWork = epBlind[sel]
|
||||||
|
blindCount = blindCnt[sel]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refPush(frame: IntArray) {
|
||||||
|
if (refCnt == 0) {
|
||||||
|
for (i in 0 until npix) refAcc[i] = frame[i]
|
||||||
|
} else {
|
||||||
|
for (i in 0 until npix) refAcc[i] += frame[i]
|
||||||
|
}
|
||||||
|
refCnt++
|
||||||
|
if (refCnt >= 4) {
|
||||||
|
for (i in 0 until npix) ref[i] = refAcc[i] shr 2
|
||||||
|
refCnt = 0
|
||||||
|
hasRef = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nucAndBlind(f20: IntArray, out: IntArray) {
|
||||||
|
val thr = thrWork
|
||||||
|
val gg = gainWork
|
||||||
|
for (i in 0 until npix) {
|
||||||
|
val d2 = (f20[i] - ref[i]) shr 1
|
||||||
|
var seg = 0
|
||||||
|
if (d2 > thr[i * 2].toInt()) {
|
||||||
|
seg = 1
|
||||||
|
if (d2 > thr[i * 2 + 1].toInt()) seg = 2
|
||||||
|
}
|
||||||
|
val base = (seg * npix + i) * 2
|
||||||
|
var v = gg[base + 1] + ((gg[base] * d2) shr 12)
|
||||||
|
if (v < 0) v = 0 else if (v > 65535) v = 65535
|
||||||
|
out[i] = v
|
||||||
|
}
|
||||||
|
val bw = blindWork
|
||||||
|
if (bw != null && blindCount > 0) {
|
||||||
|
for (k in 0 until blindCount) {
|
||||||
|
val base = k * 10
|
||||||
|
val target = bw[base]
|
||||||
|
val typ = bw[base + 1]
|
||||||
|
if (target >= npix || typ < 3 || typ > 8) continue
|
||||||
|
var sum = 0L
|
||||||
|
for (j in 0 until typ) sum += out[bw[base + 2 + j]]
|
||||||
|
when (typ) {
|
||||||
|
8 -> out[target] = (sum shr 3).toInt()
|
||||||
|
4 -> out[target] = (sum shr 2).toInt()
|
||||||
|
else -> out[target] = (sum / typ).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statsWindow() {
|
||||||
|
var ssum = 0L
|
||||||
|
var s2sum = 0L
|
||||||
|
var mn = 0xFFFF
|
||||||
|
var mx = 0
|
||||||
|
for (i in 0 until npix) {
|
||||||
|
val v = nuc[i]
|
||||||
|
ssum += v
|
||||||
|
s2sum += v.toLong() * v
|
||||||
|
if (v < mn) mn = v
|
||||||
|
if (v > mx) mx = v
|
||||||
|
}
|
||||||
|
statMin = mn
|
||||||
|
statMax = mx
|
||||||
|
statMean = (ssum / npix).toInt()
|
||||||
|
val dmean = ssum.toDouble() / npix
|
||||||
|
val variance = s2sum.toDouble() / npix - dmean * dmean
|
||||||
|
statStd = sqrt(if (variance > 0) variance else 0.0).toInt()
|
||||||
|
if (mn == mx) {
|
||||||
|
winLo = mn
|
||||||
|
winHi = mn
|
||||||
|
lut.fill(0x80.toByte())
|
||||||
|
gray160.fill(0x80.toByte())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var base = (dev24 * 1000) shr dev4cShift
|
||||||
|
if (base < 0x80) base = 0x80
|
||||||
|
val half = base shr 1
|
||||||
|
var lo = statMean - half
|
||||||
|
if (lo > statMin) lo = statMin
|
||||||
|
if (lo < 0) lo = 0
|
||||||
|
var hi = statMean + half
|
||||||
|
if (hi < statMax) hi = statMax
|
||||||
|
if (hi > 65535) hi = 65535
|
||||||
|
winLo = lo
|
||||||
|
winHi = hi
|
||||||
|
if (winHi <= winLo) winHi = winLo + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lutRebuild() {
|
||||||
|
// The vendor code is 32-bit unsigned arithmetic; products/subtractions
|
||||||
|
// wrap mod 2^32. u32() replicates that so results match bit-for-bit.
|
||||||
|
val span = (winHi - winLo).coerceAtLeast(1)
|
||||||
|
val s = 0xFFC00000L / span
|
||||||
|
hist1024.fill(0)
|
||||||
|
for (i in 0 until npix) {
|
||||||
|
val idx = u32((nuc[i] - winLo).toLong() * s) shr 0x16
|
||||||
|
if (idx < 1024) hist1024[idx.toInt()]++
|
||||||
|
}
|
||||||
|
var i = 0
|
||||||
|
while (i < 1023) {
|
||||||
|
hist1024[i] = (hist1024[i + 1] + hist1024[i]) shr 1
|
||||||
|
hist1024[i + 1] = (hist1024[i + 2] + hist1024[i + 1]) shr 1
|
||||||
|
hist1024[i + 2] = (hist1024[i + 3] + hist1024[i + 2]) shr 1
|
||||||
|
i += 3
|
||||||
|
}
|
||||||
|
var total = 0L
|
||||||
|
for (k in 0 until 1024) total += hist1024[k]
|
||||||
|
var u21 = total shr 12
|
||||||
|
if (u21 == 0L) u21 = 1
|
||||||
|
var lres = ((statMean - winLo).toLong() * 0x3FF / span).toInt()
|
||||||
|
if (lres < 0) lres = 0
|
||||||
|
if (lres > 1023) lres = 1023
|
||||||
|
if (cdfPivot75) {
|
||||||
|
var acc = 0L
|
||||||
|
var k = 0
|
||||||
|
while (k < 1024) {
|
||||||
|
acc += hist1024[k]
|
||||||
|
if (acc >= (total * 3 shr 2)) break
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
if (k < 1024 && k > lres) lres = k
|
||||||
|
}
|
||||||
|
var u11 = 0L
|
||||||
|
var u13 = 0L
|
||||||
|
var k = lres
|
||||||
|
while (k >= 0) {
|
||||||
|
u11 += hist1024[k]
|
||||||
|
if (u21 <= u11) {
|
||||||
|
u13 += 0x100
|
||||||
|
u11 = 0
|
||||||
|
}
|
||||||
|
cdf[k] = (((0x10000L / u21) * u11 shr 8) + u13).toInt()
|
||||||
|
k--
|
||||||
|
}
|
||||||
|
u11 = 0
|
||||||
|
u13 = 0
|
||||||
|
k = lres + 1
|
||||||
|
while (k < 1024) {
|
||||||
|
u11 += hist1024[k]
|
||||||
|
if (u11 < u21 * 8) {
|
||||||
|
if (u21 <= u11) {
|
||||||
|
u13 += 0x100
|
||||||
|
u11 = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
u13 += 0x200
|
||||||
|
u11 = 0
|
||||||
|
}
|
||||||
|
cdf[k] = (((0x10000L / u21) * u11 shr 8) + u13).toInt()
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
for (j in 0 until 1024) {
|
||||||
|
val v = ((0x300000L + j * 0x800L) shr 0xD).toInt()
|
||||||
|
curve[j] = if (v != 0) v else 1
|
||||||
|
}
|
||||||
|
var iv6 = dev48 * 0x155 / 200
|
||||||
|
var iv7 = iv6 + 0x155
|
||||||
|
var iv26 = iv7
|
||||||
|
if (iv6 + 0x156 > 0x331) iv26 = 0x330
|
||||||
|
var iv10 = 0x332
|
||||||
|
var b3 = true
|
||||||
|
val iv22 = 0xFFC00000L / span
|
||||||
|
while (iv26 + 1 < iv10) {
|
||||||
|
iv7 = (iv10 + iv26) / 2
|
||||||
|
if (b3) {
|
||||||
|
b3 = false
|
||||||
|
iv7 = iv26
|
||||||
|
}
|
||||||
|
var u5 = cdf[0].toLong()
|
||||||
|
if (u5 != 0L) {
|
||||||
|
val denom = (iv7 * 0x40000L + 0x40000L) / u5
|
||||||
|
val base = iv7 * 0x10L + 0x10L
|
||||||
|
var u21v = base
|
||||||
|
var j = lres
|
||||||
|
while (j >= 0) {
|
||||||
|
val shifted = u32(cdf[j].toLong() * denom) shr 0xE
|
||||||
|
var u12 = u32(base - shifted)
|
||||||
|
val over = u32(u21v - u12)
|
||||||
|
if (curve[j] < over) u12 = u32(u21v - curve[j])
|
||||||
|
lut[j] = (u12 shr 6).toByte()
|
||||||
|
u21v = u12
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u5 = cdf[1023].toLong()
|
||||||
|
if (u5 != 0L) {
|
||||||
|
val denom = u32(0xFFC0000L - iv7 * 0x40000L) / u5
|
||||||
|
val base = iv7 * 0x10L
|
||||||
|
var u21v = base
|
||||||
|
var j = lres + 1
|
||||||
|
while (j < 1024) {
|
||||||
|
val shifted = u32(cdf[j].toLong() * denom) shr 0xE
|
||||||
|
var u12 = u32(shifted + base)
|
||||||
|
val over = u32(u12 - u21v)
|
||||||
|
if (curve[j] < over) u12 = u32(curve[j] + u21v)
|
||||||
|
lut[j] = (u12 shr 6).toByte()
|
||||||
|
u21v = u12
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var iv4 = iv7
|
||||||
|
val bfmin = u32(u32((statMin - winLo).toLong()) * iv22) shr 0x16
|
||||||
|
val bfmax = u32(u32((statMax - winLo).toLong()) * iv22) shr 0x16
|
||||||
|
if (bfmin < 1024 && bfmax < 1024 &&
|
||||||
|
(lut[bfmin.toInt()].toInt() and 0xFF) == 0 && (lut[bfmax.toInt()].toInt() and 0xFF) != 0xFF
|
||||||
|
) {
|
||||||
|
iv4 = iv10
|
||||||
|
iv26 = iv7
|
||||||
|
}
|
||||||
|
iv10 = iv4
|
||||||
|
}
|
||||||
|
var dm = statMean - meanPrev
|
||||||
|
if (dm < 0) dm = -dm
|
||||||
|
meanPrev = statMean
|
||||||
|
var iv18 = iv10 + ((dm and 0xFFFF) shl dev4cShift) * 2 + statMean
|
||||||
|
if (iv18 < 0x9C4) {
|
||||||
|
if (iv18 < 100) iv18 = 100
|
||||||
|
iv7 = (iv18 * iv7 + centerPrev * 500) / (iv18 + 500)
|
||||||
|
centerPrev = iv7
|
||||||
|
var u5 = cdf[0].toLong()
|
||||||
|
if (u5 != 0L) {
|
||||||
|
val denom = (iv7 * 0x40000L + 0x40000L) / u5
|
||||||
|
val base = iv7 * 0x10L + 0x10L
|
||||||
|
var u21v = base
|
||||||
|
var j = lres
|
||||||
|
while (j >= 0) {
|
||||||
|
val shifted = u32(cdf[j].toLong() * denom) shr 0xE
|
||||||
|
var u12 = u32(base - shifted)
|
||||||
|
val over = u32(u21v - u12)
|
||||||
|
if (curve[j] < over) u12 = u32(u21v - curve[j])
|
||||||
|
lut[j] = (u12 shr 6).toByte()
|
||||||
|
u21v = u12
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u5 = cdf[1023].toLong()
|
||||||
|
if (u5 != 0L) {
|
||||||
|
val denom = u32(0xFFC0000L - iv7 * 0x40000L) / u5
|
||||||
|
val base = iv7 * 0x10L
|
||||||
|
var u21v = base
|
||||||
|
var j = lres + 1
|
||||||
|
while (j < 1024) {
|
||||||
|
val shifted = u32(cdf[j].toLong() * denom) shr 0xE
|
||||||
|
var u12 = u32(shifted + base)
|
||||||
|
val over = u32(u12 - u21v)
|
||||||
|
if (curve[j] < over) u12 = u32(curve[j] + u21v)
|
||||||
|
lut[j] = (u12 shr 6).toByte()
|
||||||
|
u21v = u12
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun grayMap() {
|
||||||
|
val s = 0xFFC00000L / (winHi - winLo).coerceAtLeast(1)
|
||||||
|
for (i in 0 until npix) {
|
||||||
|
val v = nuc[i]
|
||||||
|
val idx = when {
|
||||||
|
v <= winLo -> 0L
|
||||||
|
v >= winHi -> 1023L
|
||||||
|
else -> u32((v - winLo).toLong() * s) shr 0x16
|
||||||
|
}
|
||||||
|
gray160[i] = lut[idx.coerceAtMost(1023L).toInt()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the display palette (0..11, see [Palettes]); default = official ironbow. */
|
||||||
|
fun setPalette(index: Int) = synchronized(lock) {
|
||||||
|
pal = Palettes.buildAll()[index.coerceIn(0, Palettes.NAMES.size - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 32-bit unsigned wrap (C unsigned int semantics). */
|
||||||
|
private fun u32(x: Long): Long = x and 0xFFFFFFFFL
|
||||||
|
|
||||||
|
private fun upscale2x() {
|
||||||
|
val W = w
|
||||||
|
val H = h
|
||||||
|
val W2 = W * 2
|
||||||
|
val s = gray160
|
||||||
|
val o = gray320
|
||||||
|
for (y in 0 until H - 1) {
|
||||||
|
val r0 = y * W
|
||||||
|
val r1 = r0 + W
|
||||||
|
val o0 = y * W2 * 2
|
||||||
|
val o1 = o0 + W2
|
||||||
|
var x = 0
|
||||||
|
while (x < W - 2) {
|
||||||
|
val a0 = s[r0 + x].toInt() and 0xFF
|
||||||
|
val a1 = s[r0 + x + 1].toInt() and 0xFF
|
||||||
|
val a2 = s[r0 + x + 2].toInt() and 0xFF
|
||||||
|
val b0 = s[r1 + x].toInt() and 0xFF
|
||||||
|
val b1 = s[r1 + x + 1].toInt() and 0xFF
|
||||||
|
val b2 = s[r1 + x + 2].toInt() and 0xFF
|
||||||
|
val c = x * 2
|
||||||
|
o[o0 + c] = a0.toByte()
|
||||||
|
o[o0 + c + 1] = ((a0 + a1) shr 1).toByte()
|
||||||
|
o[o0 + c + 2] = a1.toByte()
|
||||||
|
o[o0 + c + 3] = ((a2 + a1) shr 1).toByte()
|
||||||
|
o[o1 + c] = ((b0 + a0) shr 1).toByte()
|
||||||
|
o[o1 + c + 1] = ((b0 + b1 + a0 + a1) shr 2).toByte()
|
||||||
|
o[o1 + c + 2] = ((b1 + a1) shr 1).toByte()
|
||||||
|
o[o1 + c + 3] = ((b2 + b1 + a2 + a1) shr 2).toByte()
|
||||||
|
x += 2
|
||||||
|
}
|
||||||
|
val a0 = s[r0 + W - 2].toInt() and 0xFF
|
||||||
|
val a1 = s[r0 + W - 1].toInt() and 0xFF
|
||||||
|
val b0 = s[r1 + W - 2].toInt() and 0xFF
|
||||||
|
val b1 = s[r1 + W - 1].toInt() and 0xFF
|
||||||
|
var c = (W - 2) * 2
|
||||||
|
o[o0 + c] = a0.toByte()
|
||||||
|
o[o0 + c + 1] = ((a1 + a0) shr 1).toByte()
|
||||||
|
o[o1 + c] = ((b0 + a0) shr 1).toByte()
|
||||||
|
o[o1 + c + 1] = ((a1 + b1 + b0 + a0) shr 2).toByte()
|
||||||
|
c = (W - 1) * 2
|
||||||
|
o[o0 + c] = a1.toByte()
|
||||||
|
o[o0 + c + 1] = (((3 * a1) shr 2) + (a0 shr 2)).toByte()
|
||||||
|
o[o1 + c] = ((b1 + a1) shr 1).toByte()
|
||||||
|
o[o1 + c + 1] = ((3 * (b1 + a1) + b0 + a0) shr 3).toByte()
|
||||||
|
}
|
||||||
|
val y = H - 1
|
||||||
|
val r0 = y * W
|
||||||
|
val r1 = r0 - W
|
||||||
|
val o0 = y * W2 * 2
|
||||||
|
val o1 = o0 + W2
|
||||||
|
var x = 0
|
||||||
|
while (x < W - 2) {
|
||||||
|
val a0 = s[r0 + x].toInt() and 0xFF
|
||||||
|
val a1 = s[r0 + x + 1].toInt() and 0xFF
|
||||||
|
val a2 = s[r0 + x + 2].toInt() and 0xFF
|
||||||
|
val u0 = s[r1 + x].toInt() and 0xFF
|
||||||
|
val u1 = s[r1 + x + 1].toInt() and 0xFF
|
||||||
|
val u2 = s[r1 + x + 2].toInt() and 0xFF
|
||||||
|
val c = x * 2
|
||||||
|
o[o0 + c] = a0.toByte()
|
||||||
|
o[o0 + c + 1] = ((a0 + a1) shr 1).toByte()
|
||||||
|
o[o0 + c + 2] = a1.toByte()
|
||||||
|
o[o0 + c + 3] = ((a2 + a1) shr 1).toByte()
|
||||||
|
o[o1 + c] = (((3 * a0) shr 2) + (u0 shr 2)).toByte()
|
||||||
|
o[o1 + c + 1] = ((3 * (a0 + a1) + u0 + u1) shr 3).toByte()
|
||||||
|
o[o1 + c + 2] = (((3 * a1) shr 2) + (u1 shr 2)).toByte()
|
||||||
|
o[o1 + c + 3] = ((3 * (a2 + a1) + u2 + u1) shr 3).toByte()
|
||||||
|
x += 2
|
||||||
|
}
|
||||||
|
val a0 = s[r0 + W - 2].toInt() and 0xFF
|
||||||
|
val a1 = s[r0 + W - 1].toInt() and 0xFF
|
||||||
|
val u0 = s[r1 + W - 2].toInt() and 0xFF
|
||||||
|
val u1 = s[r1 + W - 1].toInt() and 0xFF
|
||||||
|
var c = (W - 2) * 2
|
||||||
|
o[o0 + c] = a0.toByte()
|
||||||
|
o[o0 + c + 1] = ((a1 + a0) shr 1).toByte()
|
||||||
|
o[o1 + c] = (((3 * a0) shr 2) + (u0 shr 2)).toByte()
|
||||||
|
o[o1 + c + 1] = ((3 * (a0 + a1) + u0 + u1) shr 3).toByte()
|
||||||
|
c = (W - 1) * 2
|
||||||
|
o[o0 + c] = a1.toByte()
|
||||||
|
o[o0 + c + 1] = ((3 * a1 + a0) shr 2).toByte()
|
||||||
|
o[o1 + c] = ((3 * a1 + u1) shr 2).toByte()
|
||||||
|
o[o1 + c + 1] = (((o[o0 + c + 1].toInt() and 0xFF) + (o[o1 + c].toInt() and 0xFF) + a1) / 3).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ffcStep(shutterIn: Int): Int {
|
||||||
|
if (ffcIdx < 0) {
|
||||||
|
onFfc?.invoke(0)
|
||||||
|
ffcIdx = 0
|
||||||
|
shutterAtFfc = shutterIn
|
||||||
|
}
|
||||||
|
ffcIdx++
|
||||||
|
val idx = ffcIdx
|
||||||
|
if (idx <= 5) {
|
||||||
|
if (idx == 1) rebuildTables(shutterAtFfc)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if (idx <= 9) {
|
||||||
|
if (idx == 9) onFfc?.invoke(1)
|
||||||
|
if (idx == 6) {
|
||||||
|
refCnt = 0
|
||||||
|
hasRef = false
|
||||||
|
}
|
||||||
|
return 2 // ref collection window
|
||||||
|
}
|
||||||
|
if (idx <= 13) return 1
|
||||||
|
// normal frame: FFC condition
|
||||||
|
var drift = shutterIn - shutterAtFfc
|
||||||
|
if (drift < 0) drift = -drift
|
||||||
|
val cool = 13
|
||||||
|
var doFfc = false
|
||||||
|
if (manualFfc) {
|
||||||
|
manualFfc = false
|
||||||
|
doFfc = true
|
||||||
|
}
|
||||||
|
if (force75 && !startupFfcDone && idx == 75) {
|
||||||
|
startupFfcDone = true
|
||||||
|
doFfc = true
|
||||||
|
}
|
||||||
|
if (idx >= 4 + ffcPeriod) {
|
||||||
|
doFfc = true
|
||||||
|
} else if (idx >= cool + 30 && drift > ffcDrift) {
|
||||||
|
doFfc = true
|
||||||
|
}
|
||||||
|
if (doFfc) {
|
||||||
|
onFfc?.invoke(0)
|
||||||
|
ffcIdx = 0
|
||||||
|
shutterAtFfc = shutterIn
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process one raw frame.
|
||||||
|
* @param frame USB frame buffer (with or without the 0x38-byte header)
|
||||||
|
* @param hasHdr true when the 28-byte header is at frame[0]
|
||||||
|
* @param outArgb 320x240 ARGB IntArray output (outW*outH entries)
|
||||||
|
* @return true when outArgb was written; false while in the startup/FFC window.
|
||||||
|
*/
|
||||||
|
fun frame(frame: ByteArray, hasHdr: Boolean, outArgb: IntArray): Boolean = synchronized(lock) {
|
||||||
|
globalFrames++
|
||||||
|
if (globalFrames < warmFrames) return false
|
||||||
|
|
||||||
|
var shutterValue = shutter
|
||||||
|
if (hasHdr) {
|
||||||
|
val len = ByteReader.rd32(frame, 8)
|
||||||
|
if (len == npix * 2) {
|
||||||
|
shutterValue = ByteReader.rd32(frame, 0x1C + len + 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shutter = shutterValue
|
||||||
|
|
||||||
|
val st = ffcStep(shutterValue)
|
||||||
|
if (st != 0) {
|
||||||
|
if (st == 2) {
|
||||||
|
val off = if (hasHdr) 0x1C else 0
|
||||||
|
ByteReader.decodeU16(frame, off, npix, nuc)
|
||||||
|
refPush(nuc)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val off = if (hasHdr) 0x1C else 0
|
||||||
|
ByteReader.decodeU16(frame, off, npix, nuc)
|
||||||
|
if (!hasRef) return false
|
||||||
|
|
||||||
|
nucAndBlind(nuc, nuc)
|
||||||
|
statsWindow()
|
||||||
|
lutRebuild()
|
||||||
|
grayMap()
|
||||||
|
upscale2x()
|
||||||
|
// palette colorize 320x240 -> ARGB
|
||||||
|
val pal = this.pal
|
||||||
|
var i = 0
|
||||||
|
while (i < npix * 4) {
|
||||||
|
outArgb[i] = pal[gray320[i].toInt() and 0xFF]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShutter(value: Int) {
|
||||||
|
synchronized(lock) { shutter = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual FFC trigger (official FFC button). */
|
||||||
|
fun requestFfc() {
|
||||||
|
synchronized(lock) { manualFfc = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Stats(
|
||||||
|
val winLo: Int, val winHi: Int,
|
||||||
|
val min: Int, val max: Int,
|
||||||
|
val mean: Int, val std: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun stats(): Stats = synchronized(lock) {
|
||||||
|
Stats(winLo, winHi, statMin, statMax, statMean, statStd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasReference(): Boolean = synchronized(lock) { hasRef }
|
||||||
|
fun frameIndex(): Int = synchronized(lock) { ffcIdx }
|
||||||
|
fun endpoint(): Int = synchronized(lock) { sel }
|
||||||
|
|
||||||
|
/** Temperature probe on the current NUC output, in millidegrees C. */
|
||||||
|
fun probeTemp(x: Int, y: Int): Int = synchronized(lock) {
|
||||||
|
TempMath.countsToTempMc(nuc[y * w + x])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current NUC counts buffer (already blind-compensated). */
|
||||||
|
fun copyNuc(out: IntArray) = synchronized(lock) {
|
||||||
|
System.arraycopy(nuc, 0, out, 0, minOf(npix, out.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debug: full intermediate state, same layout as analysis/debug_lut.c. */
|
||||||
|
internal fun debugState(): String = synchronized(lock) {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append("win $winLo $winHi\n")
|
||||||
|
sb.append("hist")
|
||||||
|
for (v in hist1024) sb.append(' ').append(v)
|
||||||
|
sb.append("\ncdf")
|
||||||
|
for (v in cdf) sb.append(' ').append(v)
|
||||||
|
sb.append("\nlut")
|
||||||
|
for (v in lut) sb.append(' ').append(v.toInt() and 0xFF)
|
||||||
|
sb.append("\ngray")
|
||||||
|
for (v in gray160) sb.append(' ').append(v.toInt() and 0xFF)
|
||||||
|
sb.append("\ngray320")
|
||||||
|
for (v in gray320) sb.append(' ').append(v.toInt() and 0xFF)
|
||||||
|
sb.append('\n')
|
||||||
|
sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.mag160c.thermal.core
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temperature math ported from csdk/src/mag160c_render.c counts_to_temp_mc
|
||||||
|
* and csdk/src/mag160c_temp.c t2e_interp. All temperatures are integer
|
||||||
|
* millidegrees Celsius (0.001 C units).
|
||||||
|
*/
|
||||||
|
object TempMath {
|
||||||
|
private const val T2E_OFFSET = 0x249F0
|
||||||
|
private const val TEMP_C = 5797
|
||||||
|
private val T2E = OfficialTables.T2E
|
||||||
|
private val T2E274 = OfficialTables.T2E274
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert NUC counts to millidegrees C via the official T2E curve
|
||||||
|
* (646-entry inverse map, Q12 interpolation).
|
||||||
|
*/
|
||||||
|
fun countsToTempMc(counts: Int): Int {
|
||||||
|
var x = counts.toLong() * 3 - TEMP_C
|
||||||
|
if (x < 0) x = 0
|
||||||
|
var lo = 0
|
||||||
|
var hi = 645
|
||||||
|
while (lo < hi) {
|
||||||
|
val mid = (lo + hi + 1) shr 1
|
||||||
|
if (T2E[mid] <= x) lo = mid else hi = mid - 1
|
||||||
|
}
|
||||||
|
var i = lo
|
||||||
|
if (i > 644) i = 644
|
||||||
|
val diff = x - T2E[i]
|
||||||
|
val t2 = (T2E[i + 1] - T2E[i]).toLong()
|
||||||
|
val slope = if (t2 != 0L) (0x1000000L + t2 / 2) / t2 else 0L
|
||||||
|
val temp = ((slope * diff) shr 12) + (i.toLong() shl 12) - T2E_OFFSET
|
||||||
|
return temp.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T2E piecewise-linear evaluation with Q13 band selection
|
||||||
|
* (vendor ReviseTemperature/CorrectTemperature core, 274-entry curve).
|
||||||
|
*/
|
||||||
|
fun t2eInterp(value: Int): Int {
|
||||||
|
val v = value + 0xC350
|
||||||
|
var idx = v shr 13
|
||||||
|
if (idx < 0) idx = 0 else if (idx > T2E274.size - 2) idx = T2E274.size - 2
|
||||||
|
val t0 = T2E274[idx]
|
||||||
|
val t1 = T2E274[idx + 1]
|
||||||
|
val d = v - (idx shl 13)
|
||||||
|
val prod = d * (t1 - t0)
|
||||||
|
var interp = prod shr 13
|
||||||
|
if (interp < 0 && (prod and 0x1FFF) != 0) interp += 1
|
||||||
|
return t0 + interp
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.mag160c.thermal.media
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MDT thermal file container, mirroring the vendor ThermoScope layout
|
||||||
|
* (docs/android_app/reverse_apk_features.md §4, all little-endian):
|
||||||
|
*
|
||||||
|
* [JPEG section: raw jpg bytes at offset 0, padded to 4]
|
||||||
|
* [DDT section: 136B header (code 0x5BB5B55B + size + reserved 128) + body]
|
||||||
|
* [Tail: 152B — code 0x5BB5B57B + ddtOffset + reserved]
|
||||||
|
*
|
||||||
|
* DDT body = typed blocks {u32 magic, u32 len, data padded to 4}:
|
||||||
|
* 0x5BB5B55B camera info (0x38B from command 66b)
|
||||||
|
* 0x5BB5B55C second info block (0x38B from 66c, optional)
|
||||||
|
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE)
|
||||||
|
* 0x5BB5B55E text note (UTF-8, optional)
|
||||||
|
*/
|
||||||
|
object Mdt {
|
||||||
|
const val SECTION_DDT = 0x5BB5B55B
|
||||||
|
const val SECTION_TAIL = 0x5BB5B57B
|
||||||
|
|
||||||
|
const val BLOCK_INFO0 = 0x5BB5B55B
|
||||||
|
const val BLOCK_INFO1 = 0x5BB5B55C
|
||||||
|
const val BLOCK_FRAME = 0x5BB5B55D
|
||||||
|
const val BLOCK_TXT = 0x5BB5B55E
|
||||||
|
|
||||||
|
private fun align4(n: Int): Int = (n + 3) / 4 * 4
|
||||||
|
|
||||||
|
fun u32(b: ByteArray, off: Int): Int =
|
||||||
|
(b[off].toInt() and 0xFF) or
|
||||||
|
((b[off + 1].toInt() and 0xFF) shl 8) or
|
||||||
|
((b[off + 2].toInt() and 0xFF) shl 16) or
|
||||||
|
((b[off + 3].toInt() and 0xFF) shl 24)
|
||||||
|
|
||||||
|
fun put32(dst: ByteArray, off: Int, v: Int) {
|
||||||
|
dst[off] = (v and 0xFF).toByte()
|
||||||
|
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
|
||||||
|
dst[off + 2] = ((v shr 16) and 0xFF).toByte()
|
||||||
|
dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose an MDT file.
|
||||||
|
* @param jpg rendered image JPEG (offset 0, used as thumbnail + analysis base)
|
||||||
|
* @param info0 camera info block from command 0x6BB6B66B (0x38B)
|
||||||
|
* @param info1 second cached info block (0x38B) or null
|
||||||
|
* @param framePixels raw measurement frame (38400 bytes, 19200 x uint16 LE) or null
|
||||||
|
* @param text UTF-8 note bytes or null
|
||||||
|
*/
|
||||||
|
fun compose(
|
||||||
|
jpg: ByteArray,
|
||||||
|
info0: ByteArray?,
|
||||||
|
info1: ByteArray?,
|
||||||
|
framePixels: ByteArray?,
|
||||||
|
text: ByteArray? = null,
|
||||||
|
): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
|
||||||
|
out.write(jpg, 0, jpg.size)
|
||||||
|
repeat(align4(jpg.size) - jpg.size) { out.write(0) }
|
||||||
|
|
||||||
|
// --- DDT section ---
|
||||||
|
val ddtOffset = out.size()
|
||||||
|
val body = ByteArrayOutputStream()
|
||||||
|
fun emit(magic: Int, data: ByteArray) {
|
||||||
|
val n = align4(data.size)
|
||||||
|
val b = ByteArray(8 + n)
|
||||||
|
put32(b, 0, magic)
|
||||||
|
put32(b, 4, n)
|
||||||
|
System.arraycopy(data, 0, b, 8, data.size)
|
||||||
|
body.write(b, 0, b.size)
|
||||||
|
}
|
||||||
|
info0?.let { emit(BLOCK_INFO0, it) }
|
||||||
|
info1?.let { emit(BLOCK_INFO1, it) }
|
||||||
|
if (framePixels != null && framePixels.size == 38400) {
|
||||||
|
emit(BLOCK_FRAME, framePixels)
|
||||||
|
}
|
||||||
|
text?.let { emit(BLOCK_TXT, it) }
|
||||||
|
|
||||||
|
val bodyBytes = body.toByteArray()
|
||||||
|
val header = ByteArray(0x88)
|
||||||
|
put32(header, 0, SECTION_DDT)
|
||||||
|
put32(header, 4, bodyBytes.size)
|
||||||
|
out.write(header, 0, 0x88)
|
||||||
|
out.write(bodyBytes, 0, bodyBytes.size)
|
||||||
|
|
||||||
|
// --- Tail ---
|
||||||
|
val tail = ByteArray(152)
|
||||||
|
put32(tail, 0, SECTION_TAIL)
|
||||||
|
put32(tail, 4, ddtOffset)
|
||||||
|
out.write(tail, 0, 152)
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an MDT file produced by [compose] (or any file whose last 152
|
||||||
|
* bytes carry a valid tail). Returns the sections or null. */
|
||||||
|
fun parse(bytes: ByteArray): Parsed? {
|
||||||
|
if (bytes.size < 152) return null
|
||||||
|
val tail = bytes.copyOfRange(bytes.size - 152, bytes.size)
|
||||||
|
if (u32(tail, 0) != SECTION_TAIL) return null
|
||||||
|
val ddtOffset = u32(tail, 4)
|
||||||
|
if (ddtOffset <= 0 || ddtOffset + 0x88 > bytes.size - 152) return null
|
||||||
|
if (u32(bytes, ddtOffset) != SECTION_DDT) return null
|
||||||
|
val bodySize = u32(bytes, ddtOffset + 4)
|
||||||
|
val bodyStart = ddtOffset + 0x88
|
||||||
|
if (bodyStart + bodySize > bytes.size - 152) return null
|
||||||
|
val blocks = HashMap<Int, ByteArray>()
|
||||||
|
var p = bodyStart
|
||||||
|
val end = bodyStart + bodySize
|
||||||
|
while (p + 8 <= end) {
|
||||||
|
val magic = u32(bytes, p)
|
||||||
|
val len = u32(bytes, p + 4)
|
||||||
|
if (len < 0 || p + 8 + len > end) break
|
||||||
|
blocks[magic] = bytes.copyOfRange(p + 8, p + 8 + len)
|
||||||
|
p += 8 + len
|
||||||
|
}
|
||||||
|
return Parsed(
|
||||||
|
jpg = bytes.copyOfRange(0, ddtOffset),
|
||||||
|
info0 = blocks[BLOCK_INFO0],
|
||||||
|
info1 = blocks[BLOCK_INFO1],
|
||||||
|
frame = blocks[BLOCK_FRAME],
|
||||||
|
text = blocks[BLOCK_TXT],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parsed(
|
||||||
|
val jpg: ByteArray,
|
||||||
|
val info0: ByteArray?,
|
||||||
|
val info1: ByteArray?,
|
||||||
|
val frame: ByteArray?,
|
||||||
|
val text: ByteArray?,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.mag160c.thermal.media
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.MediaMuxer
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor
|
||||||
|
* .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec
|
||||||
|
* handles color conversion; frames arrive as ARGB bitmaps.
|
||||||
|
*/
|
||||||
|
class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
||||||
|
private val fps = 15
|
||||||
|
private val bitRate = 2_000_000
|
||||||
|
|
||||||
|
private var encoder: MediaCodec? = null
|
||||||
|
private var inputSurface: android.view.Surface? = null
|
||||||
|
private var muxer: MediaMuxer? = null
|
||||||
|
private var trackIndex = -1
|
||||||
|
private var muxerStarted = false
|
||||||
|
private val active = AtomicBoolean(false)
|
||||||
|
private val canvas = Canvas()
|
||||||
|
private val paint = Paint()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var outPath: File? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (active.get()) return true
|
||||||
|
return try {
|
||||||
|
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
|
||||||
|
setInteger(
|
||||||
|
MediaFormat.KEY_COLOR_FORMAT,
|
||||||
|
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface,
|
||||||
|
)
|
||||||
|
setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
||||||
|
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
|
||||||
|
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5)
|
||||||
|
}
|
||||||
|
val enc = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||||
|
enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
val surface = enc.createInputSurface()
|
||||||
|
enc.start()
|
||||||
|
encoder = enc
|
||||||
|
inputSurface = surface
|
||||||
|
val tmp = File.createTempFile("mag160c", ".mp4")
|
||||||
|
muxer = MediaMuxer(tmp.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||||
|
outPath = tmp
|
||||||
|
active.set(true)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
releaseAll()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isRecording(): Boolean = active.get()
|
||||||
|
|
||||||
|
/** Push one frame bitmap (called from the frame callback thread). */
|
||||||
|
fun offerFrame(bmp: Bitmap) {
|
||||||
|
val surface = inputSurface ?: return
|
||||||
|
if (!active.get()) return
|
||||||
|
val c = surface.lockCanvas(null) ?: return
|
||||||
|
try {
|
||||||
|
c.drawBitmap(bmp, null, android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), paint)
|
||||||
|
} finally {
|
||||||
|
surface.unlockCanvasAndPost(c)
|
||||||
|
}
|
||||||
|
drain(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop recording and finalize. Returns the output file. */
|
||||||
|
fun stop(): File? {
|
||||||
|
if (!active.getAndSet(false)) return null
|
||||||
|
val enc = encoder ?: return null
|
||||||
|
// drain with EOS
|
||||||
|
val idx = enc.dequeueInputBuffer(10_000)
|
||||||
|
if (idx >= 0) enc.queueInputBuffer(idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||||
|
drain(true)
|
||||||
|
val f = outPath
|
||||||
|
runCatching { muxer?.stop() }
|
||||||
|
runCatching { muxer?.release() }
|
||||||
|
muxer = null
|
||||||
|
runCatching { enc.stop() }
|
||||||
|
runCatching { enc.release() }
|
||||||
|
encoder = null
|
||||||
|
inputSurface?.release()
|
||||||
|
inputSurface = null
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseAll() {
|
||||||
|
runCatching { encoder?.stop() }
|
||||||
|
runCatching { encoder?.release() }
|
||||||
|
encoder = null
|
||||||
|
runCatching { inputSurface?.release() }
|
||||||
|
inputSurface = null
|
||||||
|
runCatching { muxer?.stop() }
|
||||||
|
runCatching { muxer?.release() }
|
||||||
|
muxer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drain(end: Boolean) {
|
||||||
|
val enc = encoder ?: return
|
||||||
|
val mux = muxer ?: return
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
while (true) {
|
||||||
|
val outIdx = enc.dequeueOutputBuffer(info, if (end) 10_000 else 0)
|
||||||
|
when {
|
||||||
|
outIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> if (!end) return
|
||||||
|
outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||||
|
trackIndex = mux.addTrack(enc.outputFormat)
|
||||||
|
mux.start()
|
||||||
|
muxerStarted = true
|
||||||
|
}
|
||||||
|
outIdx >= 0 -> {
|
||||||
|
if (info.size > 0 && muxerStarted &&
|
||||||
|
info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0
|
||||||
|
) {
|
||||||
|
val ob = enc.getOutputBuffer(outIdx)
|
||||||
|
if (ob != null) mux.writeSampleData(trackIndex, ob, info)
|
||||||
|
}
|
||||||
|
enc.releaseOutputBuffer(outIdx, false)
|
||||||
|
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return
|
||||||
|
}
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.mag160c.thermal.media
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.pdf.PdfDocument
|
||||||
|
import android.os.Environment
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF inspection report (port of the vendor ThermoScope report):
|
||||||
|
* header title, 7x4 basic-info table, thermal image with 9-step color
|
||||||
|
* scale, overall + ROI analysis tables, result/advice, tester line.
|
||||||
|
*/
|
||||||
|
object PdfReport {
|
||||||
|
private val PAGE_W = 595
|
||||||
|
private val PAGE_H = 842
|
||||||
|
|
||||||
|
data class ReportData(
|
||||||
|
val companyName: String = "",
|
||||||
|
val deviceName: String = "",
|
||||||
|
val installSite: String = "",
|
||||||
|
val deviceModel: String = "",
|
||||||
|
val loadCurrent: String = "",
|
||||||
|
val phase: String = "",
|
||||||
|
val envTemp: String = "",
|
||||||
|
val envHumidity: String = "",
|
||||||
|
val probeDistance: String = "",
|
||||||
|
val weather: String = "",
|
||||||
|
val instrumentModel: String = "MAG160C",
|
||||||
|
val serial: String = "",
|
||||||
|
val date: String = "",
|
||||||
|
val time: String = "",
|
||||||
|
val results: String = "",
|
||||||
|
val advice: String = "",
|
||||||
|
val tester: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Render the report and save it as PDF. Returns the saved file name. */
|
||||||
|
fun generate(context: Context, image: Bitmap, data: ReportData): String? {
|
||||||
|
val doc = android.graphics.pdf.PdfDocument()
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.BLACK
|
||||||
|
typeface = Typeface.SANS_SERIF
|
||||||
|
}
|
||||||
|
val title = Paint(paint).apply {
|
||||||
|
textSize = 20f
|
||||||
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||||
|
}
|
||||||
|
val label = Paint(paint).apply { textSize = 11f }
|
||||||
|
val value = Paint(paint).apply { textSize = 11f }
|
||||||
|
|
||||||
|
val page = doc.startPage(
|
||||||
|
android.graphics.pdf.PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, 1).create(),
|
||||||
|
)
|
||||||
|
val canvas = page.canvas
|
||||||
|
var y = 60f
|
||||||
|
|
||||||
|
canvas.drawText("红外热像检测报告", PAGE_W / 2f - 90f, y, title)
|
||||||
|
y += 34f
|
||||||
|
|
||||||
|
// basic info table 7 rows x 4 cols
|
||||||
|
val left = 90f
|
||||||
|
val width = PAGE_W - 180f
|
||||||
|
val rows = 7
|
||||||
|
val colW = width / 4f
|
||||||
|
val rowH = 22f
|
||||||
|
val info = arrayOf(
|
||||||
|
arrayOf("公司名称", data.companyName, "设备名称", data.deviceName),
|
||||||
|
arrayOf("安装地点", data.installSite, "设备型号", data.deviceModel),
|
||||||
|
arrayOf("负载电流", data.loadCurrent + " A", "相序", data.phase),
|
||||||
|
arrayOf("环境温度", data.envTemp + " ℃", "环境湿度", data.envHumidity + "%"),
|
||||||
|
arrayOf("探测距离", data.probeDistance + " m", "天气", data.weather),
|
||||||
|
arrayOf("仪器型号", data.instrumentModel, "序列号", data.serial),
|
||||||
|
arrayOf("探测日期", data.date, "探测时间", data.time),
|
||||||
|
)
|
||||||
|
val box = Paint().apply { style = Paint.Style.STROKE; color = Color.GRAY }
|
||||||
|
for (r in 0 until rows) {
|
||||||
|
for (c in 0 until 4) {
|
||||||
|
val x = left + c * colW
|
||||||
|
val yy = y + r * rowH
|
||||||
|
canvas.drawRect(x, yy, x + colW, yy + rowH, box)
|
||||||
|
if (c % 2 == 0) {
|
||||||
|
canvas.drawText(info[r][c], x + 4, yy + 15, label)
|
||||||
|
} else {
|
||||||
|
canvas.drawText(info[r][c], x + 4, yy + 15, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y += rows * rowH + 24f
|
||||||
|
|
||||||
|
canvas.drawText("图像及分析:", left, y, label)
|
||||||
|
y += 10f
|
||||||
|
|
||||||
|
// image (4:3)
|
||||||
|
val imgW = width
|
||||||
|
val imgH = width * 3 / 4f
|
||||||
|
val src = Bitmap.createBitmap(image)
|
||||||
|
canvas.drawBitmap(src, null, android.graphics.RectF(left, y, left + imgW, y + imgH), null)
|
||||||
|
y += imgH + 14f
|
||||||
|
if (src != image) src.recycle()
|
||||||
|
|
||||||
|
canvas.drawText("最高温:${data.envTemp} ℃(分析温度解码待硬件验证)", left, y, value)
|
||||||
|
canvas.drawText("结果及建议:${data.results} ${data.advice}", left, y + 20f, value)
|
||||||
|
canvas.drawText("测试员:${data.tester}", left, y + 44f, value)
|
||||||
|
|
||||||
|
doc.finishPage(page)
|
||||||
|
|
||||||
|
// save under DCIM/MAG160C/reports
|
||||||
|
val dir = File(
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM),
|
||||||
|
"MAG160C/reports",
|
||||||
|
)
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val name = "report_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH).format(Date())}.pdf"
|
||||||
|
val out = File(dir, name)
|
||||||
|
runCatching {
|
||||||
|
doc.writeTo(out.outputStream())
|
||||||
|
}.onFailure { doc.close(); return null }
|
||||||
|
doc.close()
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.mag160c.thermal.media
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save captured photos into MediaStore under DCIM/MAG160C (system gallery
|
||||||
|
* visible, no rogue folders). The stored file is a self-contained MDT
|
||||||
|
* container (JPG + temperature frame + note) named by capture time.
|
||||||
|
*/
|
||||||
|
object PhotoSaver {
|
||||||
|
private val TIME_FMT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH)
|
||||||
|
|
||||||
|
fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg"
|
||||||
|
|
||||||
|
/** Encode a rendered ARGB frame to JPEG bytes. */
|
||||||
|
fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray {
|
||||||
|
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
bmp.setPixels(frame, 0, w, 0, 0, w, h)
|
||||||
|
return encodeJpeg(bmp, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode an existing bitmap to JPEG bytes. */
|
||||||
|
fun encodeJpeg(bmp: Bitmap, quality: Int = 92): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream(bmp.width * bmp.height / 4)
|
||||||
|
bmp.compress(Bitmap.CompressFormat.JPEG, quality, out)
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save an MDT container into MediaStore. Returns the media uri string. */
|
||||||
|
fun saveMdt(
|
||||||
|
context: Context,
|
||||||
|
mdt: ByteArray,
|
||||||
|
displayName: String,
|
||||||
|
): String? {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C")
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values,
|
||||||
|
) ?: return null
|
||||||
|
try {
|
||||||
|
resolver.openOutputStream(uri)?.use { it.write(mdt) }
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
values.clear()
|
||||||
|
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||||
|
resolver.update(uri, values, null, null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return uri.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save a plain JPEG (no MDT wrapper). */
|
||||||
|
fun saveJpeg(context: Context, jpg: ByteArray, displayName: String): String? {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C")
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val uri = context.contentResolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values,
|
||||||
|
) ?: return null
|
||||||
|
try {
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { it.write(jpg) }
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
|
||||||
|
context.contentResolver.update(uri, done, null, null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
context.contentResolver.delete(uri, null, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return uri.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.mag160c.thermal.task
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class TaskItem(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val order: Int,
|
||||||
|
val captured: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TaskLibrary(
|
||||||
|
val fileName: String,
|
||||||
|
val title: String,
|
||||||
|
val items: List<TaskItem>,
|
||||||
|
) {
|
||||||
|
fun toTree(): Map<String, List<TaskItem>> =
|
||||||
|
items.groupBy { it.name.substringBefore(" - ", it.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspection task library parser (port of the vendor SqliteTaskParser /
|
||||||
|
* XmlTaskParser). All queries are guarded; a missing table just yields an
|
||||||
|
* empty item list.
|
||||||
|
*/
|
||||||
|
object TaskParser {
|
||||||
|
fun parseSqlite(file: File): TaskLibrary = runCatching {
|
||||||
|
val db = SQLiteDatabase.openDatabase(
|
||||||
|
file.absolutePath, null, SQLiteDatabase.OPEN_READONLY, null,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
var title = file.nameWithoutExtension
|
||||||
|
val regionNames = LinkedHashMap<Long, String>()
|
||||||
|
try {
|
||||||
|
db.rawQuery("SELECT id, name FROM region", null).use { c ->
|
||||||
|
while (c.moveToNext()) regionNames[c.getLong(0)] = c.getString(1) ?: ""
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
title = regionNames.values.firstOrNull() ?: title
|
||||||
|
|
||||||
|
val items = ArrayList<TaskItem>()
|
||||||
|
try {
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT t.id, t.capture_status FROM task t",
|
||||||
|
null,
|
||||||
|
).use { c ->
|
||||||
|
var ord = 0
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val id = c.getLong(0)
|
||||||
|
val captured = c.getInt(1) == 1
|
||||||
|
items.add(TaskItem(id, "任务#$id", ord++, captured))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// join names when the columns exist
|
||||||
|
try {
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT t.id, d.name, p.name, ph.name FROM task t " +
|
||||||
|
"LEFT JOIN device d ON t.device_id = d.id " +
|
||||||
|
"LEFT JOIN part p ON t.part_id = p.id " +
|
||||||
|
"LEFT JOIN phase ph ON t.phase_id = ph.id",
|
||||||
|
null,
|
||||||
|
).use { c2 ->
|
||||||
|
for ((idx, it) in items.withIndex()) {
|
||||||
|
if (c2.moveToNext()) {
|
||||||
|
val d = c2.getString(1) ?: ""
|
||||||
|
val p = c2.getString(2) ?: ""
|
||||||
|
val ph = c2.getString(3) ?: ""
|
||||||
|
val joined = listOf(ph, d, p).filter { it.isNotBlank() }
|
||||||
|
.joinToString("-")
|
||||||
|
if (joined.isNotBlank()) items[idx] = items[idx].copy(name = joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
TaskLibrary(file.name, title, items)
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}.getOrDefault(TaskLibrary(file.name, file.nameWithoutExtension, emptyList()))
|
||||||
|
|
||||||
|
/** Parse the legacy XML task format. */
|
||||||
|
fun parseXml(text: String): TaskLibrary = runCatching {
|
||||||
|
val parser = org.xmlpull.v1.XmlPullParserFactory.newInstance().newPullParser()
|
||||||
|
parser.setInput(text.reader())
|
||||||
|
var ev = parser.eventType
|
||||||
|
var title = ""
|
||||||
|
val items = ArrayList<TaskItem>()
|
||||||
|
var inTarget = false
|
||||||
|
var id = ""
|
||||||
|
var name = ""
|
||||||
|
while (ev != org.xmlpull.v1.XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (ev) {
|
||||||
|
org.xmlpull.v1.XmlPullParser.START_TAG -> {
|
||||||
|
when (parser.name) {
|
||||||
|
"target" -> {
|
||||||
|
inTarget = true
|
||||||
|
id = ""
|
||||||
|
name = ""
|
||||||
|
}
|
||||||
|
"id" -> if (inTarget) {
|
||||||
|
ev = parser.next()
|
||||||
|
id = parser.text?.trim() ?: ""
|
||||||
|
}
|
||||||
|
"name" -> if (inTarget) {
|
||||||
|
ev = parser.next()
|
||||||
|
name = parser.text ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
org.xmlpull.v1.XmlPullParser.END_TAG -> {
|
||||||
|
if (parser.name == "target") {
|
||||||
|
items.add(TaskItem(items.size.toLong(), "$id $name", items.size, false))
|
||||||
|
inTarget = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ev = parser.next()
|
||||||
|
}
|
||||||
|
TaskLibrary(title.ifBlank { "任务" }, title, items)
|
||||||
|
}.getOrDefault(TaskLibrary("任务", "任务", emptyList()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.mag160c.thermal.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
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
|
||||||
|
import com.mag160c.thermal.ui.gallery.GalleryScreen
|
||||||
|
import com.mag160c.thermal.ui.live.LiveScreen
|
||||||
|
import com.mag160c.thermal.ui.settings.SettingsScreen
|
||||||
|
|
||||||
|
private data class Tab(val label: String, val icon: Int)
|
||||||
|
|
||||||
|
private val TABS = listOf(
|
||||||
|
Tab("实时", R.drawable.ic_ffc),
|
||||||
|
Tab("相册", R.drawable.ic_gallery),
|
||||||
|
Tab("分析", R.drawable.ic_analysis),
|
||||||
|
Tab("设置", R.drawable.ic_settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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). 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) {
|
||||||
|
0 -> LiveScreen(onOpenGallery = { tab = 1 })
|
||||||
|
1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
|
||||||
|
GalleryScreen()
|
||||||
|
}
|
||||||
|
2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
|
||||||
|
GalleryScreen()
|
||||||
|
}
|
||||||
|
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
|
||||||
|
SettingsScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bottom navigation overlay (glued to the portrait bottom edge)
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.onSizeChanged { UiInsets.navPx = it.height },
|
||||||
|
) {
|
||||||
|
NavigationBar(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
TABS.forEachIndexed { i, t ->
|
||||||
|
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,67 @@
|
|||||||
|
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, sensor reads -g on X), 180 = upside down
|
||||||
|
* (sensor reads -g on Y), 270 = turned counter-clockwise (sensor reads +g
|
||||||
|
* on X).
|
||||||
|
*
|
||||||
|
* Android accelerometer convention (REAL devices): at rest the reading is
|
||||||
|
* "acceleration minus gravity", i.e. it points to world UP in device coords
|
||||||
|
* — flat on a table screen-up -> z=+9.81, upright portrait -> y=+9.81.
|
||||||
|
* (Note the emulator's virtual sensor uses the opposite, gravity-vector
|
||||||
|
* convention; do not "fix" this mapping to match emulator defaults.)
|
||||||
|
*
|
||||||
|
* 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]
|
||||||
|
// world-up in device coords: (0,+g)=0 (-g,0)=90 (0,-g)=180 (+g,0)=270.
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.mag160c.thermal.ui
|
||||||
|
|
||||||
|
/** Shared UI inset state (px), written by AppRoot's nav overlay. */
|
||||||
|
object UiInsets {
|
||||||
|
/** Bottom navigation overlay height in px (portrait-locked app: constant). */
|
||||||
|
@Volatile
|
||||||
|
var navPx: Int = 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.mag160c.thermal.ui.analyze
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mag160c.thermal.media.Mdt
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline MDT analysis state: loaded container, palette re-render, probes.
|
||||||
|
*/
|
||||||
|
class AnalyzeViewModel(
|
||||||
|
app: Application,
|
||||||
|
private val containerBytes: ByteArray,
|
||||||
|
private val fileUri: android.net.Uri,
|
||||||
|
) : AndroidViewModel(app) {
|
||||||
|
val parsed: Mdt.Parsed? = Mdt.parse(containerBytes)
|
||||||
|
|
||||||
|
/** Raw measurement frame (19200 uint16) if present. */
|
||||||
|
val rawFrame: IntArray? by lazy {
|
||||||
|
parsed?.frame?.let { raw ->
|
||||||
|
val out = IntArray(19200)
|
||||||
|
for (i in out.indices) {
|
||||||
|
out[i] = (raw[i * 2].toInt() and 0xFF) or ((raw[i * 2 + 1].toInt() and 0xFF) shl 8)
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _render = MutableStateFlow<Bitmap?>(null)
|
||||||
|
val render: StateFlow<Bitmap?> = _render
|
||||||
|
|
||||||
|
private val _paletteIndex = MutableStateFlow(2)
|
||||||
|
val paletteIndex: StateFlow<Int> = _paletteIndex
|
||||||
|
|
||||||
|
/** Re-render the raw frame with the given palette + auto window. */
|
||||||
|
fun render(paletteIdx: Int) {
|
||||||
|
val raw = rawFrame ?: return
|
||||||
|
_paletteIndex.value = paletteIdx
|
||||||
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
|
var mn = Int.MAX_VALUE
|
||||||
|
var mx = -1
|
||||||
|
for (v in raw) {
|
||||||
|
if (v < mn) mn = v
|
||||||
|
if (v > mx) mx = v
|
||||||
|
}
|
||||||
|
if (mx <= mn) mx = mn + 1
|
||||||
|
val pal = com.mag160c.thermal.core.Palettes.buildAll()[paletteIdx.coerceIn(0, 11)]
|
||||||
|
val argb = IntArray(19200)
|
||||||
|
val scale = (255 shl 12) / (mx - mn)
|
||||||
|
for (i in argb.indices) {
|
||||||
|
var g = ((raw[i] - mn) * scale) shr 8
|
||||||
|
if (g < 0) g = 0 else if (g > 255) g = 255
|
||||||
|
argb[i] = pal[g]
|
||||||
|
}
|
||||||
|
val bmp = Bitmap.createBitmap(160, 120, Bitmap.Config.ARGB_8888)
|
||||||
|
bmp.setPixels(argb, 0, 160, 0, 0, 160, 120)
|
||||||
|
withContext(Dispatchers.Main) { _render.value = bmp }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeNote(): String? {
|
||||||
|
val t = parsed?.text ?: return null
|
||||||
|
return String(t, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe temperature approximation at a raw pixel (millidegrees C). */
|
||||||
|
fun probeTemp(x: Int, y: Int): Int? {
|
||||||
|
val raw = rawFrame ?: return null
|
||||||
|
if (x < 0 || y < 0 || x >= 160 || y >= 120) return null
|
||||||
|
return com.mag160c.thermal.core.TempMath.countsToTempMc(raw[y * 160 + x])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save note: rewrite the container in place (jpg = current render). */
|
||||||
|
fun saveNote(note: String, onDone: (Boolean) -> Unit) {
|
||||||
|
val bmp = _render.value
|
||||||
|
if (bmp == null) {
|
||||||
|
onDone(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(bmp)
|
||||||
|
val mdt = Mdt.compose(
|
||||||
|
jpg = jpg,
|
||||||
|
info0 = parsed?.info0,
|
||||||
|
info1 = parsed?.info1,
|
||||||
|
framePixels = parsed?.frame,
|
||||||
|
text = note.toByteArray(Charsets.UTF_8),
|
||||||
|
)
|
||||||
|
val ok = runCatching {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
ctx.contentResolver.openOutputStream(fileUri, "w")?.use { it.write(mdt) } != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
withContext(Dispatchers.Main) { onDone(ok) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.mag160c.thermal.ui.analyze
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mag160c.thermal.core.Palettes
|
||||||
|
import com.mag160c.thermal.ui.gallery.GalleryViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-file MDT analysis viewer: pinch zoom/pan, palette re-render,
|
||||||
|
* text note editing.
|
||||||
|
*/
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val vm = remember(item.name) {
|
||||||
|
val bytes = runCatching {
|
||||||
|
context.contentResolver.openInputStream(item.uri)?.use { it.readBytes() }
|
||||||
|
}.getOrNull() ?: ByteArray(0)
|
||||||
|
AnalyzeViewModel(context.applicationContext as android.app.Application, bytes, item.uri)
|
||||||
|
}
|
||||||
|
val render by vm.render.collectAsState()
|
||||||
|
val paletteIdx by vm.paletteIndex.collectAsState()
|
||||||
|
var zoom by remember { mutableStateOf(1f) }
|
||||||
|
var pan by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
var showNote by remember { mutableStateOf(false) }
|
||||||
|
var reportName by remember { mutableStateOf<String?>(null) }
|
||||||
|
var note by remember { mutableStateOf(vm.decodeNote() ?: "") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { vm.render(2) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
) {
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(4f / 3f)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
|
||||||
|
zoom = (zoom * gestureZoom).coerceIn(1f, 4f)
|
||||||
|
pan += gesturePan
|
||||||
|
if (zoom <= 1.01f) {
|
||||||
|
zoom = 1f
|
||||||
|
pan = Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val img = render?.asImageBitmap()
|
||||||
|
if (img != null) {
|
||||||
|
val w = size.width * zoom
|
||||||
|
val h = size.height * zoom
|
||||||
|
val left = (size.width - w) / 2 + pan.x
|
||||||
|
val top = (size.height - h) / 2 + pan.y
|
||||||
|
drawImage(
|
||||||
|
image = img,
|
||||||
|
dstOffset = androidx.compose.ui.unit.IntOffset(left.toInt(), top.toInt()),
|
||||||
|
dstSize = IntSize(w.toInt(), h.toInt()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(Color(0x66000000))
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
Palettes.NAMES.forEachIndexed { idx, name ->
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
color = if (paletteIdx == idx) MaterialTheme.colorScheme.primary else Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { vm.render(idx) }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Button(onClick = { showNote = true }) { Text("备注") }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val bmp = render
|
||||||
|
if (bmp != null) {
|
||||||
|
reportName = com.mag160c.thermal.media.PdfReport.generate(
|
||||||
|
context, bmp,
|
||||||
|
com.mag160c.thermal.media.PdfReport.ReportData(
|
||||||
|
date = java.text.SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())
|
||||||
|
.format(java.util.Date()),
|
||||||
|
time = java.text.SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||||
|
.format(java.util.Date()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
) { Text("报告") }
|
||||||
|
}
|
||||||
|
if (reportName != null) {
|
||||||
|
Text(
|
||||||
|
"已生成: $reportName",
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showNote) {
|
||||||
|
NoteEditor(
|
||||||
|
initial = note,
|
||||||
|
onSave = {
|
||||||
|
note = it
|
||||||
|
vm.saveNote(it) { }
|
||||||
|
showNote = false
|
||||||
|
},
|
||||||
|
onDismiss = { showNote = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntOffsetCompat(x: Float, y: Float): Offset = Offset(x, y)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
var text by remember { mutableStateOf(initial) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("文字备注") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(value = text, onValueChange = { text = it })
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSave(text) }) { Text("保存") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("取消") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.mag160c.thermal.ui.gallery
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mag160c.thermal.ui.analyze.AnalyzeViewer
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
|
||||||
|
val items by vm.items.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showViewer by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE)
|
||||||
|
val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
|
||||||
|
android.Manifest.permission.READ_MEDIA_IMAGES
|
||||||
|
else android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||||
|
val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||||
|
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted -> vm.refresh() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
|
||||||
|
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) vm.refresh() else launcher.launch(perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Button(onClick = { vm.refresh() }) { Text("刷新") }
|
||||||
|
}
|
||||||
|
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
|
||||||
|
items(items.size) { idx ->
|
||||||
|
val item = items[idx]
|
||||||
|
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||||
|
LaunchedEffect(item.name) {
|
||||||
|
vm.thumbnail(item) { b -> bmp = b }
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(4f / 3f)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable {
|
||||||
|
vm.select(item)
|
||||||
|
showViewer = true
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val b = bmp
|
||||||
|
if (b != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = b.asImageBitmap(),
|
||||||
|
contentDescription = item.name,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
item.name.removePrefix("MAG160C_"),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(4.dp)
|
||||||
|
.background(Color(0x88000000)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showViewer) {
|
||||||
|
val sel = vm.selected.value
|
||||||
|
if (sel != null) {
|
||||||
|
AnalyzeViewer(item = sel, galleryVm = vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.mag160c.thermal.ui.gallery
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mag160c.thermal.media.Mdt
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media library: lists MDT thermal files under DCIM/MAG160C via MediaStore
|
||||||
|
* with async embedded-JPEG thumbnails.
|
||||||
|
*/
|
||||||
|
class GalleryViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
data class Item(
|
||||||
|
val id: Long,
|
||||||
|
val uri: android.net.Uri,
|
||||||
|
val name: String,
|
||||||
|
val size: Long,
|
||||||
|
val dateMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _items = MutableStateFlow<List<Item>>(emptyList())
|
||||||
|
val items: StateFlow<List<Item>> = _items
|
||||||
|
|
||||||
|
private val _selected = MutableStateFlow<Item?>(null)
|
||||||
|
val selected: StateFlow<Item?> = _selected
|
||||||
|
|
||||||
|
private val thumbs = ConcurrentHashMap<String, Bitmap>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
val proj = arrayOf(
|
||||||
|
MediaStore.MediaColumns._ID,
|
||||||
|
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||||
|
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||||
|
MediaStore.MediaColumns.SIZE,
|
||||||
|
)
|
||||||
|
val list = ArrayList<Item>()
|
||||||
|
ctx.contentResolver.query(
|
||||||
|
uri, proj,
|
||||||
|
"${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ?",
|
||||||
|
arrayOf("%DCIM/MAG160C%"),
|
||||||
|
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val id = c.getLong(0)
|
||||||
|
val name = c.getString(1) ?: ""
|
||||||
|
val date = c.getLong(2) * 1000
|
||||||
|
val size = c.getLong(3)
|
||||||
|
if (size < 152 + 136) continue
|
||||||
|
list.add(Item(id, ContentUris.withAppendedId(uri, id), name, size, date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// validate MDT containers
|
||||||
|
val valid = list.filter { isMdt(it) }
|
||||||
|
withContext(Dispatchers.Main) { _items.value = valid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isMdt(item: Item): Boolean = runCatching {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
|
||||||
|
val buf = ByteArray(152)
|
||||||
|
if (s.skip(item.size - 152) != item.size - 152) return false
|
||||||
|
var off = 0
|
||||||
|
while (off < 152) {
|
||||||
|
val n = s.read(buf, off, 152 - off)
|
||||||
|
if (n <= 0) return false
|
||||||
|
off += n
|
||||||
|
}
|
||||||
|
Mdt.u32(buf, 0) == Mdt.SECTION_TAIL
|
||||||
|
} ?: false
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
/** Embedded-JPEG thumbnail (async). */
|
||||||
|
fun thumbnail(item: Item, onReady: (Bitmap) -> Unit) {
|
||||||
|
thumbs[item.name]?.let { onReady(it); return }
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
val bmp = runCatching {
|
||||||
|
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
|
||||||
|
val all = s.readBytes()
|
||||||
|
val parsed = Mdt.parse(all)
|
||||||
|
if (parsed != null) {
|
||||||
|
BitmapFactory.decodeByteArray(parsed.jpg, 0, parsed.jpg.size)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
if (bmp != null) {
|
||||||
|
thumbs[item.name] = bmp
|
||||||
|
withContext(Dispatchers.Main) { onReady(bmp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(item: Item?) {
|
||||||
|
_selected.value = item
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(item: Item) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
runCatching { ctx.contentResolver.delete(item.uri, null, null) }
|
||||||
|
thumbs.remove(item.name)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package com.mag160c.thermal.ui.live
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.view.SurfaceHolder
|
||||||
|
import android.view.SurfaceView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Software canvas renderer for the live IR stream.
|
||||||
|
*
|
||||||
|
* The activity is portrait-locked, so the composition below is glued to the
|
||||||
|
* phone's portrait frame: the thermal image is ALWAYS drawn rotated 90 deg
|
||||||
|
* 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 (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,
|
||||||
|
private val vm: LiveViewModel,
|
||||||
|
) : SurfaceHolder.Callback, Runnable {
|
||||||
|
private var thread: Thread? = null
|
||||||
|
private val running = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
private val density = surfaceView.resources.displayMetrics.density
|
||||||
|
private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
|
||||||
|
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||||
|
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
typeface = Typeface.SANS_SERIF
|
||||||
|
textSize = 15f * density
|
||||||
|
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
|
||||||
|
}
|
||||||
|
private val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detach() {
|
||||||
|
running.set(false)
|
||||||
|
surfaceView.holder.removeCallback(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
|
running.set(true)
|
||||||
|
thread = Thread(this, "live-render").also { it.start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||||
|
|
||||||
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
|
running.set(false)
|
||||||
|
thread?.join(200)
|
||||||
|
thread = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
val holder = surfaceView.holder
|
||||||
|
while (running.get()) {
|
||||||
|
val canvas = holder.lockCanvas() ?: continue
|
||||||
|
try {
|
||||||
|
drawFrame(canvas)
|
||||||
|
} finally {
|
||||||
|
holder.unlockCanvasAndPost(canvas)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(33)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawFrame(canvas: Canvas) {
|
||||||
|
val w = canvas.width.toFloat()
|
||||||
|
val h = canvas.height.toFloat()
|
||||||
|
canvas.drawColor(Color.BLACK)
|
||||||
|
val frame = vm.latestFrame ?: return
|
||||||
|
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
|
||||||
|
|
||||||
|
// available area (minus UI overlays)
|
||||||
|
val top = vm.uiTopPx.toFloat()
|
||||||
|
val bottom = h - vm.uiBottomPx.toFloat()
|
||||||
|
val availW = w
|
||||||
|
val availH = bottom - top
|
||||||
|
if (availH <= 0) return
|
||||||
|
|
||||||
|
// fit the 3:4 (rotated) image into the available rect (fixed orientation)
|
||||||
|
var dstW = availW
|
||||||
|
var dstH = availW * 4f / 3f
|
||||||
|
if (dstH > availH) {
|
||||||
|
dstH = availH
|
||||||
|
dstW = availH * 3f / 4f
|
||||||
|
}
|
||||||
|
val left = (availW - dstW) / 2f
|
||||||
|
val vpTop = top + (availH - dstH) / 2f
|
||||||
|
viewport.set(left, vpTop, left + dstW, vpTop + dstH)
|
||||||
|
|
||||||
|
// draw the source bitmap rotated 90 deg CW around the viewport center
|
||||||
|
val cx = viewport.centerX()
|
||||||
|
val cy = viewport.centerY()
|
||||||
|
val zoom = vm.state.value.zoom
|
||||||
|
val srcRect = if (zoom > 1) {
|
||||||
|
val cw = 320 / zoom
|
||||||
|
val ch = 240 / zoom
|
||||||
|
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(90f, cx, cy)
|
||||||
|
// pre-rotation draw rect (source aspect 4:3 inside the rotated 3:4 viewport)
|
||||||
|
val w0 = dstH
|
||||||
|
val h0 = dstW
|
||||||
|
val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
|
||||||
|
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
|
||||||
|
else canvas.drawBitmap(bitmap, null, dst, paint)
|
||||||
|
canvas.restore()
|
||||||
|
|
||||||
|
drawOsd(canvas, vm.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
|
||||||
|
if (sx < 0 || sy < 0 || tempC == null) return
|
||||||
|
val p = vm.probeToScreen(sx, sy)
|
||||||
|
val cx = p[0]
|
||||||
|
val cy = p[1]
|
||||||
|
val dotR = 4.5f * density
|
||||||
|
markerPaint.style = Paint.Style.FILL
|
||||||
|
canvas.drawCircle(cx, cy, dotR, markerPaint)
|
||||||
|
markerPaint.style = Paint.Style.STROKE
|
||||||
|
markerPaint.strokeWidth = 2.5f * density
|
||||||
|
canvas.drawCircle(cx, cy, dotR + 5f * density, markerPaint)
|
||||||
|
markerPaint.style = Paint.Style.FILL
|
||||||
|
|
||||||
|
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
|
||||||
|
val tw = textPaint.measureText(text)
|
||||||
|
val pad = 6f * density
|
||||||
|
var tx = cx + 14f * density
|
||||||
|
var ty = cy + textPaint.textSize
|
||||||
|
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) {
|
||||||
|
if (state.maxTempC == null || state.minTempC == null) return
|
||||||
|
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
|
||||||
|
val barW = 20f * density
|
||||||
|
val barH = viewport.height() * 0.8f
|
||||||
|
val x = viewport.right - barW - 12f * density
|
||||||
|
val y0 = viewport.top + (viewport.height() - barH) / 2f
|
||||||
|
val seg = Paint()
|
||||||
|
val n = 96
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val c = pal[255 - i * 255 / (n - 1)]
|
||||||
|
val sy = y0 + barH * i / n
|
||||||
|
val ey = y0 + barH * (i + 1) / n
|
||||||
|
seg.color = c
|
||||||
|
canvas.drawRect(x, sy, x + barW, ey + 0.5f, seg)
|
||||||
|
}
|
||||||
|
textPaint.color = Color.WHITE
|
||||||
|
val maxT = "%.1f".format(state.maxTempC)
|
||||||
|
val minT = "%.1f".format(state.minTempC)
|
||||||
|
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) {
|
||||||
|
textPaint.color = Color.WHITE
|
||||||
|
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, "高")
|
||||||
|
}
|
||||||
|
drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, null)
|
||||||
|
for (p in state.probes) {
|
||||||
|
drawTempMarker(canvas, p.x, p.y, p.tempC, p.label)
|
||||||
|
}
|
||||||
|
drawColorBar(canvas, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package com.mag160c.thermal.ui.live
|
||||||
|
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.Color
|
||||||
|
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
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mag160c.thermal.R
|
||||||
|
import com.mag160c.thermal.core.Palettes
|
||||||
|
import com.mag160c.thermal.ui.DeviceOrientation
|
||||||
|
import com.mag160c.thermal.ui.UiInsets
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable single-branch live view, camera-app style:
|
||||||
|
* - control TOP bar (glued to the portrait top edge): FFC / zoom / max-temp
|
||||||
|
* trace / palette, each with a tiny label, pre-rotated by the grip angle;
|
||||||
|
* - camera shutter row above the bottom navigation: gallery shortcut, big
|
||||||
|
* photo shutter, record/stop;
|
||||||
|
* - the image region stays glued to the phone's portrait frame (matches the
|
||||||
|
* lens direction) and never moves; OSD text compensates the grip angle.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {}) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val phi by DeviceOrientation.deg.collectAsState()
|
||||||
|
val density = LocalDensity.current.density
|
||||||
|
var showPalette by remember { mutableStateOf(false) }
|
||||||
|
var navPx by remember { mutableStateOf(UiInsets.navPx) }
|
||||||
|
var shutterPx by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
vm.connect()
|
||||||
|
vm.uiBottomPx = navPx + shutterPx
|
||||||
|
while (true) {
|
||||||
|
kotlinx.coroutines.delay(400)
|
||||||
|
navPx = UiInsets.navPx
|
||||||
|
vm.uiBottomPx = navPx + shutterPx
|
||||||
|
vm.refreshTemps()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AndroidSurface(vm)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
vm.tapImage(
|
||||||
|
offset.x, offset.y,
|
||||||
|
size.width.toFloat(), size.height.toFloat(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!state.connected) {
|
||||||
|
Text(
|
||||||
|
text = statusText(state),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.graphicsLayer { rotationZ = -phi.toFloat() }
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- control TOP bar (glued to the portrait top edge) ----
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
// onSizeChanged BEFORE the safe-drawing padding: the reported
|
||||||
|
// height must INCLUDE the cutout/status-bar inset, otherwise
|
||||||
|
// the renderer viewport starts inside the cutout strip and
|
||||||
|
// the image top hides behind the bar on punch-hole phones
|
||||||
|
.onSizeChanged { vm.uiTopPx = it.height }
|
||||||
|
.windowInsetsPadding(
|
||||||
|
WindowInsets.safeDrawing
|
||||||
|
.only(WindowInsetsSides.Top),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable { vm.triggerFfc() }.padding(4.dp),
|
||||||
|
) {
|
||||||
|
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
|
||||||
|
Text("FFC", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
vm.setZoom(vm.state.value.zoom % 4 + 1)
|
||||||
|
}.padding(4.dp),
|
||||||
|
) {
|
||||||
|
Icon(painterResource(R.drawable.ic_zoom), "数码变倍")
|
||||||
|
Text("${state.zoom}×", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(4.dp),
|
||||||
|
) {
|
||||||
|
val on = state.maxTraceOn
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_target), "最高温追踪",
|
||||||
|
tint = if (on) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (on) "追踪·开" else "追踪",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (on) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable { showPalette = true }.padding(4.dp),
|
||||||
|
) {
|
||||||
|
Icon(painterResource(R.drawable.ic_palette), "调色板")
|
||||||
|
Text(
|
||||||
|
Palettes.NAMES[state.paletteIndex],
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- camera shutter row above the bottom navigation (live tab) ----
|
||||||
|
val recording = state.status == "recording"
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.offset(y = -(navPx / density).dp)
|
||||||
|
.onSizeChanged {
|
||||||
|
shutterPx = it.height
|
||||||
|
vm.uiBottomPx = navPx + shutterPx
|
||||||
|
}
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(44.dp, Alignment.CenterHorizontally),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(42.dp)
|
||||||
|
.background(Color(0x59FFFFFF), CircleShape)
|
||||||
|
.clickable { onOpenGallery() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(painterResource(R.drawable.ic_gallery), null, tint = Color.White)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"相册", color = Color.White,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(60.dp)
|
||||||
|
.border(4.dp, Color.White, CircleShape)
|
||||||
|
.padding(5.dp)
|
||||||
|
.background(Color.White, CircleShape)
|
||||||
|
.clickable { vm.capturePhoto(context) },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"拍照", color = Color.White,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(42.dp)
|
||||||
|
.border(3.dp, Color(0xFFFF5252), CircleShape)
|
||||||
|
.clickable { vm.toggleRecording(context) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (recording) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.background(Color(0xFFFF5252), RoundedCornerShape(3.dp)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(19.dp)
|
||||||
|
.background(Color(0xFFFF5252), CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (recording) "停止" else "录像", color = Color.White,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPalette) {
|
||||||
|
PaletteDialog(
|
||||||
|
current = state.paletteIndex,
|
||||||
|
onSelect = { vm.setPalette(it); showPalette = false },
|
||||||
|
onDismiss = { showPalette = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusText(state: LiveViewModel.LiveState): String = when (state.status) {
|
||||||
|
"no_device" -> "未检测到热像仪,请插入MAG160C"
|
||||||
|
"no_permission" -> "USB权限未授予"
|
||||||
|
"ddt_fail" -> "标定文件加载失败"
|
||||||
|
else -> "连接中…"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PaletteDialog(current: Int, onSelect: (Int) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("调色板") },
|
||||||
|
text = {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(3),
|
||||||
|
modifier = Modifier.height(320.dp),
|
||||||
|
) {
|
||||||
|
items((0..11).toList()) { idx ->
|
||||||
|
Text(
|
||||||
|
text = Palettes.NAMES[idx],
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (idx == current) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { onSelect(idx) }
|
||||||
|
.padding(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compose host for the SurfaceView renderer. */
|
||||||
|
@Composable
|
||||||
|
private fun AndroidSurface(vm: LiveViewModel) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
SurfaceView(ctx).also { sv ->
|
||||||
|
val renderer = LiveRenderer(sv, vm)
|
||||||
|
renderer.attach()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onSizeChanged {
|
||||||
|
vm.uiViewW = it.width
|
||||||
|
vm.uiViewH = it.height
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package com.mag160c.thermal.ui.live
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mag160c.thermal.core.TempMath
|
||||||
|
import com.mag160c.thermal.usb.IrSession
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live view state machine: USB permission -> link -> stream -> OSD stats.
|
||||||
|
*/
|
||||||
|
class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
data class ProbePoint(val x: Int, val y: Int, val label: String, val tempC: Float?)
|
||||||
|
|
||||||
|
data class LiveState(
|
||||||
|
val connected: Boolean = false,
|
||||||
|
val streaming: Boolean = false,
|
||||||
|
val status: String = "",
|
||||||
|
val paletteIndex: Int = 2,
|
||||||
|
val zoom: Int = 1,
|
||||||
|
val centerTempC: Float? = null,
|
||||||
|
val maxTempC: Float? = null,
|
||||||
|
val minTempC: Float? = null,
|
||||||
|
val maxPos: Int = -1,
|
||||||
|
val minPos: Int = -1,
|
||||||
|
val identity: IrSession.CameraIdentity? = null,
|
||||||
|
val maxTraceOn: Boolean = true,
|
||||||
|
val probes: List<ProbePoint> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(LiveState())
|
||||||
|
val state: StateFlow<LiveState> = _state
|
||||||
|
|
||||||
|
/** Latest rendered frame pushed by the session (320x240 ARGB). */
|
||||||
|
@Volatile
|
||||||
|
var latestFrame: IntArray? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val session = IrSession(app)
|
||||||
|
|
||||||
|
private var usbReceiver: android.content.BroadcastReceiver? = null
|
||||||
|
|
||||||
|
private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null
|
||||||
|
|
||||||
|
/** UI insets (px) reported from Compose, consumed by the renderer. */
|
||||||
|
@Volatile
|
||||||
|
var uiTopPx: Int = 0
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var uiBottomPx: Int = 0
|
||||||
|
|
||||||
|
private val sessionListener = object : IrSession.Listener {
|
||||||
|
override fun onStateChanged(state: IrSession.State, message: String?) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
connected = state == IrSession.State.STREAMING,
|
||||||
|
streaming = state == IrSession.State.STREAMING,
|
||||||
|
status = message ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFrameReady(argb: IntArray) {
|
||||||
|
latestFrame = argb
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIdentity(identity: IrSession.CameraIdentity) {
|
||||||
|
_state.value = _state.value.copy(identity = identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
session.setListener(sessionListener)
|
||||||
|
// auto permission + start when the camera is plugged in while running
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
usbReceiver = object : android.content.BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: android.content.Context?, i: android.content.Intent?) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.registerReceiver(
|
||||||
|
usbReceiver,
|
||||||
|
android.content.IntentFilter(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED),
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 33) android.content.Context.RECEIVER_NOT_EXPORTED else 0,
|
||||||
|
)
|
||||||
|
// push frames into the MP4 recorder while recording
|
||||||
|
session.recorderHook = { argb ->
|
||||||
|
val rec = recorder
|
||||||
|
if (rec != null && rec.isRecording()) {
|
||||||
|
val bmp = android.graphics.Bitmap.createBitmap(320, 240, android.graphics.Bitmap.Config.ARGB_8888)
|
||||||
|
bmp.setPixels(argb, 0, 320, 0, 0, 320, 240)
|
||||||
|
rec.offerFrame(bmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin USB permission flow, then start streaming. No device -> demo mode. */
|
||||||
|
fun connect() {
|
||||||
|
val context = getApplication<Application>()
|
||||||
|
val transport = com.mag160c.thermal.usb.UsbTransport(context)
|
||||||
|
val dev = transport.findDevice()
|
||||||
|
if (dev == null) {
|
||||||
|
startDemo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.requestPermission { ok ->
|
||||||
|
if (ok) {
|
||||||
|
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
|
||||||
|
.getOrDefault(ByteArray(0))
|
||||||
|
session.start(ddt)
|
||||||
|
} else {
|
||||||
|
_state.value = _state.value.copy(connected = false, status = "no_permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var demoPipeline: com.mag160c.thermal.core.RenderPipeline? = null
|
||||||
|
private var demoJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo mode: feed synthetic frames through the REAL render pipeline
|
||||||
|
* (official DDT bundled) so the UI is fully explorable without hardware.
|
||||||
|
*/
|
||||||
|
private fun startDemo() {
|
||||||
|
if (demoJob?.isActive == true) return
|
||||||
|
_state.value = _state.value.copy(connected = true, streaming = true, status = "demo")
|
||||||
|
val frames = buildDemoFrames()
|
||||||
|
demoJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.Default) {
|
||||||
|
val ddt = runCatching {
|
||||||
|
getApplication<Application>().assets.open("mag160c.ddt").readBytes()
|
||||||
|
}.getOrDefault(ByteArray(0))
|
||||||
|
val pipe = com.mag160c.thermal.core.RenderPipeline(160, 120, onFfc = {})
|
||||||
|
if (!pipe.loadDdt(ddt)) return@launch
|
||||||
|
demoPipeline = pipe
|
||||||
|
val out = IntArray(320 * 240)
|
||||||
|
var count = 0
|
||||||
|
while (demoPipeline != null) {
|
||||||
|
for (f in frames.indices) {
|
||||||
|
val p = demoPipeline ?: return@launch
|
||||||
|
if (p.frame(frames[f], true, out)) {
|
||||||
|
latestFrame = out.copyOf()
|
||||||
|
count++
|
||||||
|
if (count % 8 == 0) refreshTemps()
|
||||||
|
}
|
||||||
|
delay(66)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synthetic 60-frame cycle (gradient scene + slow shutter drift). */
|
||||||
|
private fun buildDemoFrames(): ArrayList<ByteArray> {
|
||||||
|
val frameSize = 0x38 + 38400
|
||||||
|
val frames = ArrayList<ByteArray>(60)
|
||||||
|
var seed = 20260906
|
||||||
|
for (f in 0 until 60) {
|
||||||
|
val buf = ByteArray(frameSize)
|
||||||
|
var acc = seed
|
||||||
|
fun next(): Int {
|
||||||
|
acc = (acc * 1103515245 + 12345) and 0x7FFFFFFF
|
||||||
|
return acc % 97 - 48
|
||||||
|
}
|
||||||
|
seed = acc
|
||||||
|
for (i in 0 until 19200) {
|
||||||
|
val x = i % 160
|
||||||
|
val y = i / 160
|
||||||
|
var base = x * 4 + y * 3 + 14000
|
||||||
|
// warm block (like a hand in frame), upper-right area
|
||||||
|
if (x in 96..144 && y in 28..76) base += 620
|
||||||
|
if (x in 104..136 && y in 36..68) base += 180
|
||||||
|
val v = (base + next()).coerceIn(0, 65535)
|
||||||
|
val off = 0x1C + i * 2
|
||||||
|
buf[off] = (v and 0xFF).toByte()
|
||||||
|
buf[off + 1] = ((v shr 8) and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
frames.add(buf)
|
||||||
|
}
|
||||||
|
// headers + trailers
|
||||||
|
for (f in frames.indices) {
|
||||||
|
val buf = frames[f]
|
||||||
|
put32(buf, 0, 0x1BB1B11B)
|
||||||
|
put32(buf, 4, f)
|
||||||
|
put32(buf, 8, 38400)
|
||||||
|
put32(buf, 12, if (f in 6..9) 1 else 0)
|
||||||
|
put32(buf, 16, 4096 + f / 4)
|
||||||
|
put32(buf, 0x1C + 38400, 0x1BB1B11C)
|
||||||
|
}
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun put32(dst: ByteArray, off: Int, v: Int) {
|
||||||
|
dst[off] = (v and 0xFF).toByte()
|
||||||
|
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
|
||||||
|
dst[off + 2] = ((v shr 16) and 0xFF).toByte()
|
||||||
|
dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() = session.stop()
|
||||||
|
|
||||||
|
fun triggerFfc() = session.triggerFfc()
|
||||||
|
|
||||||
|
fun setPalette(index: Int) {
|
||||||
|
session.setPalette(index)
|
||||||
|
_state.value = _state.value.copy(paletteIndex = index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setZoom(z: Int) {
|
||||||
|
_state.value = _state.value.copy(zoom = z.coerceIn(1, 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle max-temperature trace marker. */
|
||||||
|
fun toggleMaxTrace() {
|
||||||
|
_state.value = _state.value.copy(maxTraceOn = !_state.value.maxTraceOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture: rendered JPEG + raw frame + camera info -> MDT -> MediaStore. */
|
||||||
|
fun capturePhoto(context: android.content.Context) {
|
||||||
|
val frame = latestFrame ?: return
|
||||||
|
val s = session
|
||||||
|
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(frame)
|
||||||
|
val rawFrame = s.lastRawFrame
|
||||||
|
val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) {
|
||||||
|
rawFrame.copyOfRange(0x1C, 0x1C + 38400)
|
||||||
|
} else null
|
||||||
|
val mdt = com.mag160c.thermal.media.Mdt.compose(
|
||||||
|
jpg = jpg,
|
||||||
|
info0 = s.lastInfo0,
|
||||||
|
info1 = s.lastInfo1,
|
||||||
|
framePixels = pixels,
|
||||||
|
)
|
||||||
|
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
|
||||||
|
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
|
||||||
|
)
|
||||||
|
_state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tap on the live image: add a probe point, or delete an existing one when
|
||||||
|
* tapping near it. The image is always displayed rotated 90 deg CW
|
||||||
|
* (3:4 vertical) and does NOT move with the phone; only OSD text follows
|
||||||
|
* the screen. Insets carve the available area.
|
||||||
|
*/
|
||||||
|
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
|
||||||
|
val s = _state.value
|
||||||
|
if (!s.streaming) return
|
||||||
|
val top = uiTopPx.toFloat()
|
||||||
|
val bottom = viewH - uiBottomPx.toFloat()
|
||||||
|
if (screenY < top || screenY > bottom) return
|
||||||
|
// fit the 3:4 (rotated 90 CW) image into the available rect
|
||||||
|
val availW = viewW
|
||||||
|
val availH = bottom - top
|
||||||
|
var dstW = availW
|
||||||
|
var dstH = availW * 4f / 3f
|
||||||
|
if (dstH > availH) {
|
||||||
|
dstH = availH
|
||||||
|
dstW = availH * 3f / 4f
|
||||||
|
}
|
||||||
|
val left = (availW - dstW) / 2f
|
||||||
|
val top2 = top + (availH - dstH) / 2f
|
||||||
|
if (screenX < left || screenX > left + dstW || screenY < top2 || screenY > top2 + dstH) return
|
||||||
|
val fx = (screenX - left) / dstW
|
||||||
|
val fy = (screenY - top2) / dstH
|
||||||
|
// inverse of the fixed 90 CW mapping: fx = 1 - sy/120, fy = sx/160
|
||||||
|
val sy = (1f - fx) * 120f
|
||||||
|
val sx = fy * 160f
|
||||||
|
// near an existing probe (compare in screen space)? delete it instead
|
||||||
|
val thr = dstW * 0.06f
|
||||||
|
val existing = s.probes.firstOrNull { p ->
|
||||||
|
val scr = probeToScreen(p.x, p.y)
|
||||||
|
val dx = screenX - scr[0]
|
||||||
|
val dy = screenY - scr[1]
|
||||||
|
dx * dx + dy * dy < thr * thr
|
||||||
|
}
|
||||||
|
if (existing != null) {
|
||||||
|
_state.value = _state.value.copy(probes = _state.value.probes - existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val label = "Pt${s.probes.size + 1}"
|
||||||
|
val p = ProbePoint(
|
||||||
|
sx.toInt().coerceIn(0, 159),
|
||||||
|
sy.toInt().coerceIn(0, 119),
|
||||||
|
label,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
_state.value = _state.value.copy(probes = _state.value.probes + p)
|
||||||
|
refreshTemps()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screen coords of a sensor point under the fixed 90 CW rotation + insets. */
|
||||||
|
fun probeToScreen(sx: Int, sy: Int): FloatArray {
|
||||||
|
val viewW = uiViewW.toFloat().coerceAtLeast(1f)
|
||||||
|
val availH = (uiViewH - uiTopPx - uiBottomPx).toFloat().coerceAtLeast(1f)
|
||||||
|
var dstW = viewW
|
||||||
|
var dstH = viewW * 4f / 3f
|
||||||
|
if (dstH > availH) {
|
||||||
|
dstH = availH
|
||||||
|
dstW = availH * 3f / 4f
|
||||||
|
}
|
||||||
|
val left = (viewW - dstW) / 2f
|
||||||
|
val top = uiTopPx + (availH - dstH) / 2f
|
||||||
|
val fx = 1f - sy / 120f
|
||||||
|
val fy = sx / 160f
|
||||||
|
return floatArrayOf(left + fx * dstW, top + fy * dstH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last known surface size, for probe screen mapping. */
|
||||||
|
@Volatile
|
||||||
|
var uiViewW: Int = 1080
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var uiViewH: Int = 2280
|
||||||
|
|
||||||
|
/** Toggle MP4 recording of the live stream. */
|
||||||
|
fun toggleRecording(context: android.content.Context) {
|
||||||
|
val rec = recorder
|
||||||
|
if (rec == null) {
|
||||||
|
val r = com.mag160c.thermal.media.Mp4Recorder()
|
||||||
|
if (r.start()) {
|
||||||
|
recorder = r
|
||||||
|
_state.value = _state.value.copy(status = "recording")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val file = rec.stop()
|
||||||
|
recorder = null
|
||||||
|
if (file != null) {
|
||||||
|
val name = "MAG160C_V_${com.mag160c.thermal.media.PhotoSaver.fileName()}"
|
||||||
|
_state.value = _state.value.copy(status = "rec_done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update per-frame temperature stats (called on a slow timer). */
|
||||||
|
fun refreshTemps() {
|
||||||
|
val demo = demoPipeline
|
||||||
|
if (demo != null) {
|
||||||
|
// Demo-only display mapping (true calibration needs hardware).
|
||||||
|
demo.copyNuc(demoNuc)
|
||||||
|
var mean = 0L
|
||||||
|
var mn = Int.MAX_VALUE
|
||||||
|
var mx = -1
|
||||||
|
var mnPos = -1
|
||||||
|
var mxPos = -1
|
||||||
|
for (i in demoNuc.indices) {
|
||||||
|
val v = demoNuc[i]
|
||||||
|
mean += v
|
||||||
|
if (v < mn) { mn = v; mnPos = i }
|
||||||
|
if (v > mx) { mx = v; mxPos = i }
|
||||||
|
}
|
||||||
|
mean /= demoNuc.size
|
||||||
|
val mv = mean.toFloat()
|
||||||
|
val probes = _state.value.probes.map { p ->
|
||||||
|
val v = demoNuc[p.y * 160 + p.x]
|
||||||
|
p.copy(tempC = 24f + (v - mean) * 0.02f)
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
centerTempC = 24f + (demoNuc[60 * 160 + 80] - mean) * 0.02f,
|
||||||
|
maxTempC = 24f + (mx - mean) * 0.02f,
|
||||||
|
minTempC = 24f + (mn - mean) * 0.02f,
|
||||||
|
maxPos = mxPos,
|
||||||
|
minPos = mnPos,
|
||||||
|
probes = probes,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!session.isStreaming()) return
|
||||||
|
val center = session.probeTemp(80, 60)
|
||||||
|
val nuc = IntArray(19200)
|
||||||
|
if (!session.copyNuc(nuc)) return
|
||||||
|
updateTemps(center, nuc)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val demoNuc = IntArray(19200)
|
||||||
|
|
||||||
|
private fun updateTemps(center: Int?, nuc: IntArray) {
|
||||||
|
var mn = Int.MAX_VALUE
|
||||||
|
var mx = -1
|
||||||
|
var mnPos = -1
|
||||||
|
var mxPos = -1
|
||||||
|
for (i in nuc.indices) {
|
||||||
|
val v = nuc[i]
|
||||||
|
if (v < mn) {
|
||||||
|
mn = v
|
||||||
|
mnPos = i
|
||||||
|
}
|
||||||
|
if (v > mx) {
|
||||||
|
mx = v
|
||||||
|
mxPos = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val probes = _state.value.probes.map { p ->
|
||||||
|
p.copy(tempC = TempMath.countsToTempMc(nuc[p.y * 160 + p.x]) / 1000f)
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
centerTempC = center?.let { TempMath.countsToTempMc(it) / 1000f },
|
||||||
|
maxTempC = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null,
|
||||||
|
minTempC = if (mn <= Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null,
|
||||||
|
maxPos = mxPos,
|
||||||
|
minPos = mnPos,
|
||||||
|
probes = probes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
demoPipeline = null
|
||||||
|
session.destroy()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.mag160c.thermal.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AlarmTempDialog(initialC: Int, onDone: (Int) -> Unit) {
|
||||||
|
var value by remember { mutableStateOf(initialC / 10f) }
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { onDone(initialC) },
|
||||||
|
title = { Text("报警温度") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("超过 %.1f℃ 报警".format(value))
|
||||||
|
Slider(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { value = it },
|
||||||
|
valueRange = -20f..500f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = { onDone((value * 10).toInt()) }) {
|
||||||
|
Text("确定")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.mag160c.thermal.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/** App settings backed by SharedPreferences (app-private storage only). */
|
||||||
|
class AppSettings(context: Context) {
|
||||||
|
private val sp = context.getSharedPreferences("mag160c_settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
var defaultPaletteIndex: Int
|
||||||
|
get() = sp.getInt("palette", 2)
|
||||||
|
set(v) = sp.edit().putInt("palette", v).apply()
|
||||||
|
|
||||||
|
var defaultEmissivityPercent: Int
|
||||||
|
get() = sp.getInt("emissivity", 100)
|
||||||
|
set(v) = sp.edit().putInt("emissivity", v).apply()
|
||||||
|
|
||||||
|
var alarmTempC: Int
|
||||||
|
get() = sp.getInt("alarm_temp", 200)
|
||||||
|
set(v) = sp.edit().putInt("alarm_temp", v).apply()
|
||||||
|
|
||||||
|
var language: String
|
||||||
|
get() = sp.getString("locale", "auto") ?: "auto"
|
||||||
|
set(v) = sp.edit().putString("locale", v).apply()
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.mag160c.thermal.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mag160c.thermal.core.Palettes
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen() {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val settings = remember { AppSettings(context) }
|
||||||
|
var dialog by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||||
|
SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" }
|
||||||
|
SettingRow(
|
||||||
|
"默认发射率",
|
||||||
|
"%.2f".format(settings.defaultEmissivityPercent / 100f),
|
||||||
|
) { dialog = "emissivity" }
|
||||||
|
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
|
||||||
|
SettingRow("语言", settings.language) { dialog = "language" }
|
||||||
|
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
when (dialog) {
|
||||||
|
"palette" -> AlertDialog(
|
||||||
|
onDismissRequest = { dialog = null },
|
||||||
|
title = { Text("默认调色板") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Palettes.NAMES.forEachIndexed { idx, name ->
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
settings.defaultPaletteIndex = idx
|
||||||
|
dialog = null
|
||||||
|
}
|
||||||
|
.padding(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
"emissivity" -> AlertDialog(
|
||||||
|
onDismissRequest = { dialog = null },
|
||||||
|
title = { Text("默认发射率") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
listOf(
|
||||||
|
"黑体 1.00" to 100,
|
||||||
|
"暗光 0.90" to 90,
|
||||||
|
"半光 0.80" to 80,
|
||||||
|
"亮光 0.70" to 70,
|
||||||
|
"金属 0.10" to 10,
|
||||||
|
).forEach { (label, v) ->
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
settings.defaultEmissivityPercent = v
|
||||||
|
dialog = null
|
||||||
|
}
|
||||||
|
.padding(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
"alarm" -> AlarmTempDialog(
|
||||||
|
initialC = settings.alarmTempC,
|
||||||
|
onDone = { settings.alarmTempC = it; dialog = null },
|
||||||
|
)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingRow(label: String, value: String, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 18.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.mag160c.thermal.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val DarkScheme = darkColorScheme(
|
||||||
|
primary = Color(0xFFFFB59B),
|
||||||
|
secondary = Color(0xFFB3CAD5),
|
||||||
|
tertiary = Color(0xFFD5C4A1),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightScheme = lightColorScheme(
|
||||||
|
primary = Color(0xFF8F4C38),
|
||||||
|
secondary = Color(0xFF4F6269),
|
||||||
|
tertiary = Color(0xFF6A5B44),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Mag160cTheme(content: @Composable () -> Unit) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val scheme = when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
dark -> DarkScheme
|
||||||
|
else -> LightScheme
|
||||||
|
}
|
||||||
|
MaterialTheme(colorScheme = scheme, content = content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package com.mag160c.thermal.usb
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.usb.UsbDeviceConnection
|
||||||
|
import android.hardware.usb.UsbEndpoint
|
||||||
|
import com.mag160c.thermal.core.FrameStream
|
||||||
|
import com.mag160c.thermal.core.RenderPipeline
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live IR camera session: link -> query info -> start stream -> render.
|
||||||
|
* Ported from csdk/src/mag160c_ir.c + the demo3 FFC cadence:
|
||||||
|
* prepare: 66b / 66c / 66f (4B each)
|
||||||
|
* start: reader thread -> 50 ms -> FFC(0) x2 -> 300 ms -> START(73)
|
||||||
|
* stop: STOP(74)
|
||||||
|
* FFC commands are emitted by [RenderPipeline.onFfc] to keep the type=0
|
||||||
|
* stream alive (official cadence).
|
||||||
|
*/
|
||||||
|
class IrSession(context: Context) {
|
||||||
|
data class CameraIdentity(
|
||||||
|
val pid: Int,
|
||||||
|
val serial: Long,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val fps: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class State { IDLE, LINKING, STREAMING, ERROR }
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
fun onStateChanged(state: State, message: String?)
|
||||||
|
fun onFrameReady(argb: IntArray)
|
||||||
|
fun onIdentity(identity: CameraIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val transport = UsbTransport(context)
|
||||||
|
private var listener: Listener? = null
|
||||||
|
private var pipeline: RenderPipeline? = null
|
||||||
|
private val running = AtomicBoolean(false)
|
||||||
|
private var streaming = false
|
||||||
|
|
||||||
|
private var identity = CameraIdentity(1, 0, 160, 120, 15)
|
||||||
|
|
||||||
|
fun setListener(l: Listener?) {
|
||||||
|
listener = l
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional per-frame hook (MP4 recording), runs on the reader thread. */
|
||||||
|
@Volatile
|
||||||
|
var recorderHook: ((IntArray) -> Unit)? = null
|
||||||
|
|
||||||
|
fun isStreaming(): Boolean = running.get()
|
||||||
|
|
||||||
|
/** Latest raw frame (with 0x38-byte header) for MDT capture. */
|
||||||
|
@Volatile
|
||||||
|
var lastRawFrame: ByteArray? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Cached 66b/66c camera info blocks (0x38B each) for the MDT DDT section. */
|
||||||
|
@Volatile
|
||||||
|
var lastInfo0: ByteArray? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastInfo1: ByteArray? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun identitySnapshot(): CameraIdentity = identity.copy()
|
||||||
|
|
||||||
|
/** Connect + start the live stream. Must be called after USB permission. */
|
||||||
|
fun start(ddtBytes: ByteArray) {
|
||||||
|
if (running.get()) return
|
||||||
|
scope.launch {
|
||||||
|
notify(State.LINKING, null)
|
||||||
|
val dev = transport.findDevice()
|
||||||
|
if (dev == null) {
|
||||||
|
notify(State.ERROR, "no_device")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
transport.useDevice(dev)
|
||||||
|
if (!transport.open()) {
|
||||||
|
notify(State.ERROR, "open_fail")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val (epOut, epResp, epStream) = transport.endpoints()
|
||||||
|
if (epOut == null || epResp == null || epStream == null) {
|
||||||
|
transport.close()
|
||||||
|
notify(State.ERROR, "no_endpoints")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val pipe = RenderPipeline(
|
||||||
|
w = identity.width, h = identity.height,
|
||||||
|
onFfc = { param -> sendCmd(cmd8(MagProtocol.CMD_FFC, param), epOut, epResp) },
|
||||||
|
)
|
||||||
|
if (!pipe.loadDdt(ddtBytes)) {
|
||||||
|
transport.close()
|
||||||
|
notify(State.ERROR, "ddt_fail")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
pipeline = pipe
|
||||||
|
// prepare sequence (verified hardware: 4-byte commands)
|
||||||
|
sendCmd(cmd4(MagProtocol.CMD_PREPARE1), epOut, epResp)
|
||||||
|
sendCmd(cmd4(MagProtocol.CMD_PREPARE2), epOut, epResp)
|
||||||
|
sendCmd(cmd4(MagProtocol.CMD_GET_INFO), epOut, epResp)
|
||||||
|
notify(State.STREAMING, null)
|
||||||
|
notifyIdentity()
|
||||||
|
|
||||||
|
running.set(true)
|
||||||
|
streamLoop(pipe, epStream, epOut, epResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notify(state: State, message: String?) {
|
||||||
|
listener?.onStateChanged(state, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyIdentity() {
|
||||||
|
listener?.onIdentity(identitySnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cmd4(magic: Int) = MagProtocol.cmd4(magic)
|
||||||
|
private fun cmd8(magic: Int, param: Int) = MagProtocol.cmd8(magic, param)
|
||||||
|
|
||||||
|
private fun sendCmd(packet: ByteArray, out: UsbEndpoint, resp: UsbEndpoint) {
|
||||||
|
val conn: UsbDeviceConnection = transport.connection() ?: return
|
||||||
|
val written = conn.bulkTransfer(out, packet, packet.size, 500)
|
||||||
|
if (written != packet.size) return
|
||||||
|
val buf = ByteArray(0x1000)
|
||||||
|
val n = conn.bulkTransfer(resp, buf, buf.size, 2000)
|
||||||
|
if (n <= 3) return
|
||||||
|
val magic = MagProtocol.u32(buf, 0)
|
||||||
|
if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) {
|
||||||
|
lastInfo1 = buf.copyOfRange(4, 4 + 0x38)
|
||||||
|
}
|
||||||
|
if (magic == MagProtocol.RSP_INFO_0 && n >= 0x3C) {
|
||||||
|
val payload = buf.copyOfRange(4, n)
|
||||||
|
lastInfo0 = payload.copyOf(0x38)
|
||||||
|
val newIdentity = CameraIdentity(
|
||||||
|
pid = MagProtocol.u32(payload, 0),
|
||||||
|
serial = (MagProtocol.u32(payload, 8).toLong() and 0xFFFFFFFFL) or
|
||||||
|
((MagProtocol.u32(payload, 12).toLong() and 0xFFFFFFFFL) shl 32),
|
||||||
|
width = MagProtocol.u32(payload, 0x10),
|
||||||
|
height = MagProtocol.u32(payload, 0x14),
|
||||||
|
fps = if (payload.size >= 0x1C) MagProtocol.u32(payload, 0x18) else identity.fps,
|
||||||
|
)
|
||||||
|
identity = newIdentity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun streamLoop(
|
||||||
|
pipe: RenderPipeline,
|
||||||
|
epStream: UsbEndpoint,
|
||||||
|
epOut: UsbEndpoint,
|
||||||
|
epResp: UsbEndpoint,
|
||||||
|
) {
|
||||||
|
val conn: UsbDeviceConnection = transport.connection() ?: return
|
||||||
|
val stream = FrameStream(38400)
|
||||||
|
val frameBuf = ByteArray(0x38 + 38400)
|
||||||
|
val out = IntArray(320 * 240)
|
||||||
|
val tmp = ByteArray(0x8000)
|
||||||
|
val noop = ByteArray(0)
|
||||||
|
while (running.get()) {
|
||||||
|
val n = conn.bulkTransfer(epStream, tmp, tmp.size, 500)
|
||||||
|
if (n <= 0) continue
|
||||||
|
var len = stream.push(tmp, n, frameBuf)
|
||||||
|
while (len > 0 && running.get()) {
|
||||||
|
lastRawFrame = frameBuf.copyOf()
|
||||||
|
val rendered = pipe.frame(frameBuf, true, out)
|
||||||
|
if (rendered) {
|
||||||
|
listener?.onFrameReady(out)
|
||||||
|
recorderHook?.invoke(out)
|
||||||
|
}
|
||||||
|
len = stream.push(noop, 0, frameBuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendCmd(cmd4(MagProtocol.CMD_STOP), epOut, epResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual FFC (official shutter button / double tap). */
|
||||||
|
fun triggerFfc() {
|
||||||
|
pipeline?.requestFfc()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display palette (see [Palettes]); applied to the live pipeline. */
|
||||||
|
fun setPalette(index: Int) {
|
||||||
|
pipeline?.setPalette(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
|
||||||
|
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
|
||||||
|
|
||||||
|
/** Snapshot of the current NUC counts (already blind-compensated). */
|
||||||
|
fun copyNuc(out: IntArray): Boolean {
|
||||||
|
val p = pipeline ?: return false
|
||||||
|
if (!running.get()) return false
|
||||||
|
p.copyNuc(out)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!running.getAndSet(false)) return
|
||||||
|
pipeline = null
|
||||||
|
transport.close()
|
||||||
|
notify(State.IDLE, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
stop()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.mag160c.thermal.usb
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vendor command/response protocol, recovered in analysis/protocol_spec.md.
|
||||||
|
* Plain commands are 4-byte {magic}; FFC carries an 8-byte {magic, param}.
|
||||||
|
* Responses on EP 0x82: 0x5BB5B55B camera info (0x38), 0x5BB5B55C block 2,
|
||||||
|
* 0x5BB5B55E version pair (0x10).
|
||||||
|
*/
|
||||||
|
object MagProtocol {
|
||||||
|
const val CMD_PREPARE1 = 0x6BB6B66B
|
||||||
|
const val CMD_PREPARE2 = 0x6BB6B66C
|
||||||
|
const val CMD_GET_INFO = 0x6BB6B66F
|
||||||
|
const val CMD_GET_VERSION = 0x6BB6B670
|
||||||
|
const val CMD_FFC = 0x6BB6B672
|
||||||
|
const val CMD_START = 0x6BB6B673
|
||||||
|
const val CMD_STOP = 0x6BB6B674
|
||||||
|
|
||||||
|
const val RSP_INFO_0 = 0x5BB5B55B
|
||||||
|
const val RSP_INFO_1 = 0x5BB5B55C
|
||||||
|
const val RSP_PAIR = 0x5BB5B55E
|
||||||
|
|
||||||
|
fun cmd4(magic: Int): ByteArray {
|
||||||
|
val b = ByteBuffer.allocate(4)
|
||||||
|
b.putInt(magic)
|
||||||
|
return b.array()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cmd8(magic: Int, param: Int): ByteArray {
|
||||||
|
val b = ByteBuffer.allocate(8)
|
||||||
|
b.putInt(magic)
|
||||||
|
b.putInt(param)
|
||||||
|
return b.array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camera info block (0x5BB5B55B, 0x38 bytes): +0x00 pid, +0x08 serial,
|
||||||
|
* +0x10 width, +0x14 height, +0x18 fps. */
|
||||||
|
class CameraInfo {
|
||||||
|
var pid = 0
|
||||||
|
var serial = 0L
|
||||||
|
var width = 160
|
||||||
|
var height = 120
|
||||||
|
var fps = 15
|
||||||
|
var raw = ByteArray(0)
|
||||||
|
|
||||||
|
fun parse(payload: ByteArray) {
|
||||||
|
raw = payload.copyOf(minOf(0x38, payload.size))
|
||||||
|
if (payload.size >= 0x1C) {
|
||||||
|
pid = u32(payload, 0)
|
||||||
|
serial = u32(payload, 8).toLong() and 0xFFFFFFFFL or
|
||||||
|
((u32(payload, 12).toLong() and 0xFFFFFFFFL) shl 32)
|
||||||
|
width = u32(payload, 0x10)
|
||||||
|
height = u32(payload, 0x14)
|
||||||
|
if (payload.size >= 0x1C) fps = u32(payload, 0x18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun u32(b: ByteArray, off: Int): Int =
|
||||||
|
(b[off].toInt() and 0xFF) or
|
||||||
|
((b[off + 1].toInt() and 0xFF) shl 8) or
|
||||||
|
((b[off + 2].toInt() and 0xFF) shl 16) or
|
||||||
|
((b[off + 3].toInt() and 0xFF) shl 24)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.mag160c.thermal.usb
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.hardware.usb.UsbConstants
|
||||||
|
import android.hardware.usb.UsbDevice
|
||||||
|
import android.hardware.usb.UsbDeviceConnection
|
||||||
|
import android.hardware.usb.UsbEndpoint
|
||||||
|
import android.hardware.usb.UsbInterface
|
||||||
|
import android.hardware.usb.UsbManager
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/**
|
||||||
|
* USB transport for the MAG160C module (VID 0x833C PID 0x0001), ported from
|
||||||
|
* csdk/src/mag160c_ir.c:
|
||||||
|
* config 2 (fallback 1), interface 0
|
||||||
|
* EP OUT 0x03 commands, EP IN 0x82 responses, EP IN 0x81 stream
|
||||||
|
*/
|
||||||
|
class UsbTransport(private val context: Context) {
|
||||||
|
private val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||||
|
private var connection: UsbDeviceConnection? = null
|
||||||
|
private var claimedInterface: UsbInterface? = null
|
||||||
|
var device: UsbDevice? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Find the first connected MAG160C device. */
|
||||||
|
fun findDevice(): UsbDevice? {
|
||||||
|
for (dev in manager.deviceList.values) {
|
||||||
|
if (dev.vendorId == VID) return dev
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** USB permission callback: true after approval. */
|
||||||
|
fun requestPermission(onDone: (Boolean) -> Unit) {
|
||||||
|
val dev = findDevice() ?: run {
|
||||||
|
onDone(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (manager.hasPermission(dev)) {
|
||||||
|
device = dev
|
||||||
|
onDone(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val action = "com.mag160c.thermal.USB_PERMISSION_ACTION"
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(ctx: Context, intent: Intent) {
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
if (manager.hasPermission(dev)) device = dev
|
||||||
|
onDone(manager.hasPermission(dev))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.registerReceiver(
|
||||||
|
receiver,
|
||||||
|
IntentFilter(action),
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) Context.RECEIVER_NOT_EXPORTED else 0,
|
||||||
|
)
|
||||||
|
val pi = PendingIntent.getBroadcast(
|
||||||
|
context, 0, Intent(action).setPackage(context.packageName),
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0,
|
||||||
|
)
|
||||||
|
manager.requestPermission(dev, pi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select the device to open (called before [open]). */
|
||||||
|
fun useDevice(dev: UsbDevice) {
|
||||||
|
device = dev
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the device: claim interface 0 and expose endpoints. */
|
||||||
|
fun open(): Boolean {
|
||||||
|
val dev = device ?: return false
|
||||||
|
val conn = manager.openDevice(dev) ?: return false
|
||||||
|
connection = conn
|
||||||
|
val intf = dev.getInterface(0) ?: run { conn.close(); return false }
|
||||||
|
if (!conn.claimInterface(intf, true)) {
|
||||||
|
conn.close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
claimedInterface = intf
|
||||||
|
// Prefer configuration 2 when the device exposes it (vendor behavior).
|
||||||
|
if (dev.configurationCount > 1) {
|
||||||
|
val cfg = dev.getConfiguration(1)
|
||||||
|
// USB SET_CONFIGURATION request = 9
|
||||||
|
conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endpoints(): Triple<UsbEndpoint?, UsbEndpoint?, UsbEndpoint?> {
|
||||||
|
val intf = claimedInterface ?: return Triple(null, null, null)
|
||||||
|
var out: UsbEndpoint? = null
|
||||||
|
var resp: UsbEndpoint? = null
|
||||||
|
var stream: UsbEndpoint? = null
|
||||||
|
for (i in 0 until intf.endpointCount) {
|
||||||
|
when (intf.getEndpoint(i).address) {
|
||||||
|
EP_CMD_OUT -> out = intf.getEndpoint(i)
|
||||||
|
EP_CMD_IN -> resp = intf.getEndpoint(i)
|
||||||
|
EP_STREAM_IN -> stream = intf.getEndpoint(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Triple(out, resp, stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isOpen(): Boolean = connection != null
|
||||||
|
|
||||||
|
/** Raw connection handle for bulk transfers. */
|
||||||
|
fun connection(): UsbDeviceConnection? = connection
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
claimedInterface?.let { connection?.releaseInterface(it) }
|
||||||
|
connection?.close()
|
||||||
|
connection = null
|
||||||
|
claimedInterface = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VID = 0x833C
|
||||||
|
const val EP_CMD_OUT = 0x03
|
||||||
|
const val EP_CMD_IN = 0x82
|
||||||
|
const val EP_STREAM_IN = 0x81
|
||||||
|
const val EP_BULK_IN = 0x84
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M5,20 L5,13" android:strokeColor="#FF000000" android:strokeWidth="4" />
|
||||||
|
<path android:pathData="M12,4 L12,20" android:strokeColor="#FF000000" android:strokeWidth="4" />
|
||||||
|
<path android:pathData="M19,9 L19,20" android:strokeColor="#FF000000" android:strokeWidth="4" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M4,7 L4,19 L20,19 L20,7 Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
|
||||||
|
<path android:pathData="M9,7 L9,5 L15,5 L15,7" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
|
||||||
|
<path android:pathData="M12,13m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M5,12a7,7 0 1,0 14,0a7,7 0 1,0 -14,0z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
|
||||||
|
<path android:pathData="M12,3 L12,7 M12,17 L12,21 M3,12 L7,12 M17,12 L21,12" android:strokeColor="#FF000000" android:strokeWidth="2" />
|
||||||
|
<path android:pathData="M12,12m-2.5,0a2.5,2.5 0 1,1 5,0a2.5,2.5 0 1,1 -5,0" android:fillColor="#FF000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M4,5 L4,19 L20,19 L20,7 Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
|
||||||
|
<path android:pathData="M7,16 L11,11 L14,14 L17,12 L20,16" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||||
|
<path android:pathData="M15,10a1.5,1.5 0 1,1 3,0a1.5,1.5 0 1,1 -3,0" android:fillColor="#FF000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:pathData="M0,0h108v108h-108z" android:fillColor="#FF1B2B33" />
|
||||||
|
<path android:pathData="M54,54m-20,0a20,20 0 1,0 40,0a20,20 0 1,0 -40,0" android:fillColor="#FFFFB59B" />
|
||||||
|
<path android:pathData="M54,54m-12,0a12,12 0 1,0 24,0a12,12 0 1,0 -24,0" android:fillColor="#FF8F4C38" />
|
||||||
|
<path android:pathData="M54,54m-6,0a6,6 0 1,0 12,0a6,6 0 1,0 -12,0" android:fillColor="#FFFFE0D2" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M6,4 L6,18" android:strokeColor="#FFB71C1C" android:strokeWidth="3" />
|
||||||
|
<path android:pathData="M12,8 L12,18" android:strokeColor="#FF2E7D32" android:strokeWidth="3" />
|
||||||
|
<path android:pathData="M18,4 L18,18" android:strokeColor="#FF1565C0" android:strokeWidth="3" />
|
||||||
|
<path android:pathData="M3,18 L21,18" android:strokeColor="#FF37474F" android:strokeWidth="2" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M12,8a4,4 0 1,0 8,0a4,4 0 1,0 -8,0z" android:strokeColor="#FFD32F2F" android:strokeWidth="2.5" android:fillColor="#00000000" />
|
||||||
|
<path android:pathData="M12,12m-4,0a4,4 0 1,1 8,0a4,4 0 1,1 -8,0" android:fillColor="#FFD32F2F" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M4,6 L20,6" android:strokeColor="#FF000000" android:strokeWidth="2" />
|
||||||
|
<path android:pathData="M14,6m-2.5,0a2,2 0 1,1 4,0a2,2 0 1,1 -4,0" android:fillColor="#FF000000" />
|
||||||
|
<path android:pathData="M4,12 L20,12" android:strokeColor="#FF000000" android:strokeWidth="2" />
|
||||||
|
<path android:pathData="M8,12m-2,0a2,2 0 1,1 4,0a2,2 0 1,1 -4,0" android:fillColor="#FF000000" />
|
||||||
|
<path android:pathData="M4,18 L20,18" android:strokeColor="#FF000000" android:strokeWidth="2" />
|
||||||
|
<path android:pathData="M16,18m-2,0a2,2 0 1,1 4,0a2,2 0 1,1 -4,0" android:fillColor="#FF000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M5,12a7,7 0 1,0 14,0a7,7 0 1,0 -14,0z" android:strokeColor="#FF000000" android:strokeWidth="2.5" android:fillColor="#00000000" />
|
||||||
|
<path android:pathData="M12,1.5 L12,5.5" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
|
||||||
|
<path android:pathData="M12,18.5 L12,22.5" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
|
||||||
|
<path android:pathData="M1.5,12 L5.5,12" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
|
||||||
|
<path android:pathData="M18.5,12 L22.5,12" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
|
||||||
|
<path android:pathData="M10.9,12a1.1,1.1 0 1,0 2.2,0a1.1,1.1 0 1,0 -2.2,0z" android:fillColor="#FF000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M3,10.5a7.5,7.5 0 1,0 15,0a7.5,7.5 0 1,0 -15,0z" android:strokeColor="#FF000000" android:strokeWidth="2.5" android:fillColor="#00000000" />
|
||||||
|
<path android:pathData="M16,16 L21,21" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- MAG160C thermal module -->
|
||||||
|
<usb-device vendor-id="33596" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.mag160c.thermal.core
|
||||||
|
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Differential test: the Kotlin render pipeline must produce byte-identical
|
||||||
|
* output to the pixel-verified C implementation (csdk/src/mag160c_render.c),
|
||||||
|
* which was validated against CoreSDKLib.dll. Reference files were produced
|
||||||
|
* by analysis/render_offline.c with the official DDT + a synthetic frame
|
||||||
|
* sequence (warm=20, ffc_period=1800, ffc_drift=250, cdf pivot 75, forced
|
||||||
|
* frame-75 startup FFC, FFC callback noop).
|
||||||
|
*/
|
||||||
|
class RenderPipelineTest {
|
||||||
|
private fun res(name: String): ByteArray =
|
||||||
|
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun matchesCReferencePixelExact() {
|
||||||
|
val ddt = res("mag160c_official.ddt")
|
||||||
|
val frames = res("test_frames.bin")
|
||||||
|
val refRgb = res("test_ref.rgb")
|
||||||
|
val refStats = res("test_ref_stats.txt").decodeToString()
|
||||||
|
|
||||||
|
val pipeline = RenderPipeline(
|
||||||
|
w = 160, h = 120,
|
||||||
|
ffcPeriod = 1800, ffcDrift = 250, warmFrames = 20,
|
||||||
|
cdfPivot75 = true, force75 = true,
|
||||||
|
onFfc = {},
|
||||||
|
)
|
||||||
|
assertTrue("DDT must load", pipeline.loadDdt(ddt))
|
||||||
|
|
||||||
|
val frameSize = 0x38 + 38400
|
||||||
|
val nframes = frames.size / frameSize
|
||||||
|
val out = IntArray(320 * 240)
|
||||||
|
val renderedFrames = ArrayList<IntArray>()
|
||||||
|
for (f in 0 until nframes) {
|
||||||
|
val fr = frames.copyOfRange(f * frameSize, (f + 1) * frameSize)
|
||||||
|
if (pipeline.frame(fr, true, out)) {
|
||||||
|
renderedFrames.add(out.copyOf())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reference: hidden frames end with "HIDDEN", rendered frames have 6 ints
|
||||||
|
val refRendered = refStats.trim().lines().count { !it.endsWith("HIDDEN") }
|
||||||
|
assertEquals("rendered frame count", refRendered, renderedFrames.size)
|
||||||
|
|
||||||
|
// per-pixel comparison (C writes RGB24; Kotlin produces the same ARGB ints)
|
||||||
|
val rgb = res("test_ref.rgb")
|
||||||
|
var off = 0
|
||||||
|
for (fi in renderedFrames.indices) {
|
||||||
|
val expect = IntArray(320 * 240)
|
||||||
|
for (i in 0 until 320 * 240) {
|
||||||
|
val r = refRgb[off].toInt() and 0xFF
|
||||||
|
val g = refRgb[off + 1].toInt() and 0xFF
|
||||||
|
val b = refRgb[off + 2].toInt() and 0xFF
|
||||||
|
expect[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
off += 3
|
||||||
|
}
|
||||||
|
assertArrayEquals("frame $fi pixels", expect, renderedFrames[fi])
|
||||||
|
}
|
||||||
|
assertEquals("consumed all reference bytes", refRgb.size, off)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tempConversionIsMonotonicAndSane() {
|
||||||
|
// protocol_spec: hand vs background ~1322 counts delta; 0x1000-ish counts
|
||||||
|
// are ambient-scale. Sanity: monotonic and plausible millidegrees.
|
||||||
|
var prev = Int.MIN_VALUE
|
||||||
|
for (counts in 6000..8000 step 10) {
|
||||||
|
val t = TempMath.countsToTempMc(counts)
|
||||||
|
assertTrue("monotonic at $counts", t > prev)
|
||||||
|
prev = t
|
||||||
|
}
|
||||||
|
// ~20-40 C around the 7000-count region (millidegrees)
|
||||||
|
val t20 = TempMath.countsToTempMc(7000)
|
||||||
|
assertTrue("temp in plausible range: $t20", t20 in 0..60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun frameStreamReassemblesSplitReads() {
|
||||||
|
val frames = res("test_frames.bin")
|
||||||
|
val frameSize = 0x38 + 38400
|
||||||
|
val stream = FrameStream(38400)
|
||||||
|
val out = ByteArray(frameSize)
|
||||||
|
var got = 0
|
||||||
|
var pos = 0
|
||||||
|
var seed = 7
|
||||||
|
while (pos < frames.size) {
|
||||||
|
seed = (seed * 1103515245 + 12345) and 0x7FFFFFFF
|
||||||
|
val n = minOf(1 + (seed % 3000), frames.size - pos)
|
||||||
|
val len = stream.push(frames.copyOfRange(pos, pos + n), n, out)
|
||||||
|
if (len > 0) {
|
||||||
|
for (i in 0 until frameSize) {
|
||||||
|
assertEquals("byte $i at frame $got", frames[got * frameSize + i], out[i])
|
||||||
|
}
|
||||||
|
got++
|
||||||
|
}
|
||||||
|
pos += n
|
||||||
|
}
|
||||||
|
assertEquals("all frames reassembled", frames.size / frameSize, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,60 @@
|
|||||||
|
0 HIDDEN
|
||||||
|
1 HIDDEN
|
||||||
|
2 HIDDEN
|
||||||
|
3 HIDDEN
|
||||||
|
4 HIDDEN
|
||||||
|
5 HIDDEN
|
||||||
|
6 HIDDEN
|
||||||
|
7 HIDDEN
|
||||||
|
8 HIDDEN
|
||||||
|
9 HIDDEN
|
||||||
|
10 HIDDEN
|
||||||
|
11 HIDDEN
|
||||||
|
12 HIDDEN
|
||||||
|
13 HIDDEN
|
||||||
|
14 HIDDEN
|
||||||
|
15 HIDDEN
|
||||||
|
16 HIDDEN
|
||||||
|
17 HIDDEN
|
||||||
|
18 HIDDEN
|
||||||
|
19 HIDDEN
|
||||||
|
20 HIDDEN
|
||||||
|
21 HIDDEN
|
||||||
|
22 HIDDEN
|
||||||
|
23 HIDDEN
|
||||||
|
24 HIDDEN
|
||||||
|
25 HIDDEN
|
||||||
|
26 HIDDEN
|
||||||
|
27 HIDDEN
|
||||||
|
28 HIDDEN
|
||||||
|
29 HIDDEN
|
||||||
|
30 HIDDEN
|
||||||
|
31 HIDDEN
|
||||||
|
32 6961 7961 7118 7668 7461 103
|
||||||
|
33 6962 7962 7098 7663 7462 103
|
||||||
|
34 6962 7962 7116 7675 7462 103
|
||||||
|
35 6961 7961 7106 7672 7461 103
|
||||||
|
36 6961 7961 7106 7659 7461 103
|
||||||
|
37 6961 7961 7116 7664 7461 103
|
||||||
|
38 6961 7961 7121 7671 7461 103
|
||||||
|
39 6962 7962 7117 7673 7462 103
|
||||||
|
40 6962 7962 7132 7667 7462 103
|
||||||
|
41 6962 7962 7105 7664 7462 103
|
||||||
|
42 6961 7961 7118 7671 7461 103
|
||||||
|
43 6961 7961 7130 7671 7461 103
|
||||||
|
44 6962 7962 7122 7661 7462 103
|
||||||
|
45 6961 7961 7096 7669 7461 103
|
||||||
|
46 6961 7961 7096 7671 7461 103
|
||||||
|
47 6962 7962 7122 7675 7462 103
|
||||||
|
48 6961 7961 7094 7667 7461 103
|
||||||
|
49 6962 7962 7099 7664 7462 103
|
||||||
|
50 6962 7962 7134 7669 7462 103
|
||||||
|
51 6962 7962 7133 7662 7462 103
|
||||||
|
52 6961 7961 7129 7671 7461 103
|
||||||
|
53 6962 7962 7126 7671 7462 103
|
||||||
|
54 6961 7961 7119 7669 7461 103
|
||||||
|
55 6961 7961 7096 7673 7461 103
|
||||||
|
56 6961 7961 7135 7661 7461 103
|
||||||
|
57 6962 7962 7099 7662 7462 103
|
||||||
|
58 6961 7961 7122 7659 7461 103
|
||||||
|
59 6962 7962 7116 7669 7462 103
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.caching=true
|
||||||
|
kotlin.daemon.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.11.1"
|
||||||
|
kotlin = "2.2.0"
|
||||||
|
composeBom = "2025.06.01"
|
||||||
|
activityCompose = "1.10.1"
|
||||||
|
coreKtx = "1.16.0"
|
||||||
|
lifecycle = "2.9.1"
|
||||||
|
junit = "4.13.2"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
Vendored
+251
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "MAG160C"
|
||||||
|
include(":app")
|
||||||
Binary file not shown.
@@ -0,0 +1,234 @@
|
|||||||
|
# MAG160C 统一安卓APP — 完整开发交接文档(换模型用)
|
||||||
|
|
||||||
|
> 生成时间:2026-09-07。给接手模型读取本文件即可恢复全部上下文。
|
||||||
|
> 结合 `docs/android_app/session_state.md`(心跳)与 `docs/android_app/reverse_apk_features.md`(逆向总表)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 给新模型的读取提示词
|
||||||
|
|
||||||
|
> 你是本项目的续任开发者。请先完整阅读 `C:\Project\MAG160C\docs\android_app\HANDOFF_DEVELOPMENT.md`
|
||||||
|
> 和 `C:\Project\MAG160C\docs\android_app\session_state.md`、`C:\Project\MAG160C\docs\android_app\reverse_apk_features.md`、
|
||||||
|
> `C:\Project\MAG160C\analysis\protocol_spec.md`,再继续开发。项目根目录 `C:\Project\MAG160C` 是 git 仓库,
|
||||||
|
> 源码在 `android\` 子目录(Gradle 工程,package `com.mag160c.thermal`)。当前会话状态与待办见 session_state.md。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目目标与背景
|
||||||
|
|
||||||
|
- 把 MAG160C 热像仪(160×120, 15fps, USB VID 0x833C)的官方 4 个安卓APP
|
||||||
|
(普通版 MAG-Cx / 专业版 MAG-Mx / ThermoScope 数据分析 / CoreSdkSample)逆向整合
|
||||||
|
为一款现代化安卓APP。
|
||||||
|
- 适配 **Android 16(API 36)**;用户反馈旧专业版在 Android 16 上花屏,重写根治。
|
||||||
|
- 逆向证据:`docs/android_app/reverse_apk_features.md`(4 APP 功能总表)、
|
||||||
|
`analysis/protocol_spec.md`(USB 协议/温度算法全逆向)。
|
||||||
|
- 用户要求:现代 UI、**构图钉死竖屏框架**(竖屏锁定,见 §4.3)、**不建立流氓文件夹**(媒体→MediaStore DCIM/MAG160C)、
|
||||||
|
无 32 位库(纯 Kotlin,无 NDK)。
|
||||||
|
|
||||||
|
## 2. 技术栈与工具链(已部署)
|
||||||
|
|
||||||
|
- **纯 Kotlin + Jetpack Compose (Material 3)**,无 NDK、无 .so、无 native。
|
||||||
|
- Gradle 8.14.3(`C:\Tools\gradle-8.14.3\bin\gradle.bat`),JDK Zulu 21(`C:\Program Files\Zulu\zulu-21`)。
|
||||||
|
- Android SDK:`C:\Users\ZXC\AppData\Local\Android\Sdk`(platforms/android-36、build-tools 36.1.0、
|
||||||
|
platform-tools/adb、模拟器 system-images android-36.1 google_apis_playstore x86_64、AVD `Medium_Phone_API_36.1`)。
|
||||||
|
- 工程:`C:\Project\MAG160C\android\`;版本目录 `gradle\libs.versions.toml`
|
||||||
|
(AGP 8.11.1 / Kotlin 2.2.0 / Compose BOM 2025.06.01 / lifecycle 2.9.1 / junit 4.13.2)。
|
||||||
|
- **磁盘轻量约定**:不装 NDK/CMake/zig(曾占用 2.4GB+,已卸载);全 Kotlin。
|
||||||
|
- 模拟器实测命令(无窗口):
|
||||||
|
```powershell
|
||||||
|
Start-Process "$env:LOCALAPPDATA\Android\Sdk\emulator\emulator.exe" -ArgumentList @('-avd','Medium_Phone_API_36.1','-no-window','-no-audio','-gpu','swiftshader_indirect') -WindowStyle Hidden
|
||||||
|
# 安装/启动/截图:
|
||||||
|
adb -s emulator-5554 install -r build-artifacts\mag160c-app-debug.apk
|
||||||
|
adb -s emulator-5554 shell am force-stop com.mag160c.thermal
|
||||||
|
adb -s emulator-5554 shell am start -n com.mag160c.thermal/.MainActivity
|
||||||
|
adb -s emulator-5554 exec-out screencap -p > analysis\preview\xxx.png # 注意:exec-out 才二进制安全
|
||||||
|
adb -s emulator-5554 emu rotate # 旋转模拟器(每次90°)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 源码结构与核心模块
|
||||||
|
|
||||||
|
```
|
||||||
|
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/ ★实时画面(最核心)
|
||||||
|
│ ├─ LiveViewModel.kt 状态机/演示模式/多点测温/追踪开关/拍照/录像/inset上报
|
||||||
|
│ ├─ LiveScreen.kt 稳定单分支相机布局:顶栏4控制项+底部快门区(相册/拍照/录像)+SurfaceView
|
||||||
|
│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像恒90°旋转(3:4竖)钉在竖屏框架固定区域,
|
||||||
|
│ 文字恒屏幕水平;色标条/圆圈标记/中心温OSD
|
||||||
|
├─ ui/gallery/ 相册页(MediaStore DCIM/MAG160C 扫描 + 内嵌JPEG缩略图 + 运行时媒体权限)
|
||||||
|
├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/备注回写/PDF报告)
|
||||||
|
├─ ui/settings/ 设置页(默认调色板/发射率/报警温度/语言/关于,SharedPreferences私有)
|
||||||
|
├─ media/
|
||||||
|
│ ├─ Mdt.kt MDT 容器格式(JPG + DDT段[info块+原始帧] + 152B Tail)
|
||||||
|
│ ├─ PhotoSaver.kt MediaStore 保存(DCIM/MAG160C,无权限也可写自有文件)
|
||||||
|
│ ├─ Mp4Recorder.kt MP4录像(Surface 输入 H.264,MediaCodec+MediaMuxer)
|
||||||
|
│ └─ PdfReport.kt PDF 巡检报告(PdfDocument)
|
||||||
|
├─ usb/
|
||||||
|
│ ├─ UsbTransport.kt UsbManager 枚举(VID 0x833C)/权限/claim/EP
|
||||||
|
│ ├─ MagProtocol.kt 命令码 0x6BB6B6xx、响应 0x5BB5B5xx
|
||||||
|
│ └─ IrSession.kt 连接/启动流/读线程→FrameStream→RenderPipeline→回调;FFC回调
|
||||||
|
├─ core/
|
||||||
|
│ ├─ OfficialTables.kt 自动生成:PALETTE256_ARGB(官方铁虹)/T2E(646)/T2E274/E2T
|
||||||
|
│ ├─ Palettes.kt 12调色板(铁虹=官方,其余标准曲线近似)
|
||||||
|
│ ├─ RenderPipeline.kt ★官方渲染管线 Kotlin 移植(与C参考逐字节一致)
|
||||||
|
│ ├─ TempMath.kt 温度换算(counts→毫度,T2E)
|
||||||
|
│ └─ FrameStream.kt 流帧重组(0x1BB1B11B 搜索)
|
||||||
|
└─ task/TaskParser.kt 任务巡检 sqlite/xml 解析
|
||||||
|
assets/mag160c.ddt 官方 DDT 标定文件(1.8MB,渲染必需)
|
||||||
|
res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 核心知识(必须知道)
|
||||||
|
|
||||||
|
### 4.1 渲染管线(已逐字节验证)
|
||||||
|
- `core/RenderPipeline.kt` 移植自 `csdk/src/mag160c_render.c`(官方管线,像素级验证)。
|
||||||
|
- 输入:USB 帧(0x38 头 + 38400B u16 小端 + 尾部);输出 320×240 ARGB。
|
||||||
|
- FFC 状态机:暖机20帧内暂停;第75帧强制FFC;FFC(0)→5隐藏→FFC(1)→4参考帧→4隐藏→正常。
|
||||||
|
- **移植陷阱(已解决,改代码时务必保持)**:
|
||||||
|
1. C 的 32 位无符号回绕:所有 `u32()` 掩码必须保留(cdf*denom、0xffc0000-iv7*0x40000、(v-lo)*S 等)。
|
||||||
|
2. lutRebuild 里 `u12` 的基址是常量 `iv7*0x10+0x10`,**不是**链式 u21v。
|
||||||
|
3. ByteArray 存 255=-1:判 0xFF 必须 `(b.toInt() and 0xFF)`。
|
||||||
|
4. 暖机窗口检查在 ffc_step **之前**(顺序不能改)。
|
||||||
|
- 验证方式:`android/app/src/test/.../RenderPipelineTest.kt`(JVM 差分测试,参照 `analysis/render_offline.c` 的 C 输出,60帧序列与官方DDT)。
|
||||||
|
|
||||||
|
### 4.2 USB 协议(analysis/protocol_spec.md 全文)
|
||||||
|
- 命令:普通 4 字节 magic;FFC 8 字节 {magic, param}。
|
||||||
|
- 初始化:66b→66c→66f(4B)→FFC(0)×2→300ms→START(73);停止 STOP(74)。
|
||||||
|
- 帧流:EP 0x81 bulk,0x1BB1B11B 头 + 0x1C 起像素 + 尾部 shutter 于 +0x24。
|
||||||
|
- 相机信息块 0x5BB5B55B:+0x00 pid、+0x08 序列号、+0x10 宽、+0x14 高、+0x18 fps。
|
||||||
|
|
||||||
|
### 4.3 实时画面渲染(最终约定:构图钉死竖屏框架)
|
||||||
|
- **Activity 锁定竖屏**(manifest `screenOrientation="portrait"`):屏幕相对手机框架
|
||||||
|
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
|
||||||
|
中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
|
||||||
|
与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
|
||||||
|
- **图标/文字按物理持机朝向补偿旋转**(第六轮定稿):`ui/DeviceOrientation.kt` 用
|
||||||
|
加速度计得出手机相对竖屏的顺时针物理转角 φ(0/90/180/270,带滞回;竖屏锁定下
|
||||||
|
Display.rotation 恒 0 不可用)。顶栏/底部导航条目 `graphicsLayer rotationZ=-φ`
|
||||||
|
原位预旋转;渲染器 OSD 文字 `canvas.rotate(-φ)` 绕锚点旋转(标记圆点、色标条
|
||||||
|
几何仍钉死在图像上)。对话框与其他页签暂不补偿。
|
||||||
|
- **加速度计符号约定(第七轮教训,勿改回)**:真机 TYPE_ACCELEROMETER 静止读数
|
||||||
|
指向世界上方(竖屏正持 y=+9.81);模拟器虚拟传感器是反的重力约定(y=-9.81)。
|
||||||
|
DeviceOrientation 映射按**真机约定**写,模拟器测试须用反号值驱动
|
||||||
|
(φ=0→`adb emu sensor set acceleration 0:9.81:0`)。
|
||||||
|
- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
|
||||||
|
- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
|
||||||
|
`fx=1-sy/120, fy=sx/160`)。
|
||||||
|
- 顶栏=4 个相机控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称),恒在顶部并带小字
|
||||||
|
标签,`safeDrawing` 顶部inset 适配挖孔屏。
|
||||||
|
- 底部(仅实时页)导航栏上方为**相机快门区**:相册快捷入口 / 大快门拍照 /
|
||||||
|
录像-停止;快门区高度并入 `vm.uiBottomPx`(= 导航高+快门区高)。
|
||||||
|
- 顶栏高度经 `onSizeChanged`→`vm.uiTopPx`(**必须挂在 safeDrawing inset 之前**,
|
||||||
|
即包含挖孔安全区高度,否则真机热像顶端会插进顶栏背后);底导航高度经
|
||||||
|
`UiInsets.navPx`、快门区高度经其 `onSizeChanged`→`vm.uiBottomPx`(= 导航+快门区),
|
||||||
|
渲染器据此留白。
|
||||||
|
- 演示模式:无设备时用真实管线+DDT渲染合成帧(热块场景),demo温度用局部线性显示映射
|
||||||
|
(真机温度走管线数学;绝对温度标定待真机)。
|
||||||
|
|
||||||
|
### 4.4 温度
|
||||||
|
- 温度 = 毫度 int(÷1000 = ℃)。`counts_to_temp_mc`(NUC域→T2E逆映射)用于探针/OSD,
|
||||||
|
在真机上需按 DDT 标定核对绝对值(待办)。
|
||||||
|
- 离线 MDT 温度解码(ConvertResponse2Temperature + 标定参数)未实现(待真机文件对照)。
|
||||||
|
|
||||||
|
## 5. 构建 / 测试 / 提交
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 构建(无 --daemon 单次,约1分钟)
|
||||||
|
cd C:\Project\MAG160C\android
|
||||||
|
$env:JAVA_HOME="C:\Program Files\Zulu\zulu-21"
|
||||||
|
& "C:\Tools\gradle-8.14.3\bin\gradle.bat" :app:assembleDebug --no-daemon
|
||||||
|
# 单元测试(管线差分验证)
|
||||||
|
& "C:\Tools\gradle-8.14.3\bin\gradle.bat" test --no-daemon
|
||||||
|
# 产物
|
||||||
|
Copy-Item app\build\outputs\apk\debug\app-debug.apk C:\Project\MAG160C\build-artifacts\mag160c-app-debug.apk
|
||||||
|
# 提交(每里程碑一个 commit,不 push)
|
||||||
|
git -C C:\Project\MAG160C add -A
|
||||||
|
git -C C:\Project\MAG160C commit -m "android: ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 严重教训(反复踩坑,务必遵守)
|
||||||
|
|
||||||
|
1. **编码**:所有源码必须 UTF-8。本机默认编码是 GBK!
|
||||||
|
- **python 读写文件必须显式 `encoding='utf-8'`**(`open(p,'w')` 裸写会变 GBK→编译出的中文全乱码)。
|
||||||
|
- 检测/修复脚本:`C:\Users\ZXC\AppData\Local\Temp\opencode\fix_encoding.py`、
|
||||||
|
`check_mojibake.py`。改完必须跑一遍确认全 OK,再构建。
|
||||||
|
- gradle.properties 已有 `-Dfile.encoding=UTF-8`(gradle + kotlin daemon)。
|
||||||
|
2. **图标**:手写 pathData 的弧线易坏,一律用双弧圆 `M x,y a r,r 0 1,0 2r,0 a r,r 0 1,0 -2r,0z` + 直线/矩形。
|
||||||
|
3. **SurfaceView 与旋转**:不要在横竖屏间切换不同布局分支(SurfaceView 会被销毁且 surface 不重建)。
|
||||||
|
用"稳定单分支 + 覆盖层"布局(当前实现即如此)。
|
||||||
|
4. **写代码要谨慎**:本会话多次因急于成稿写出语法错误/残留死代码。每写一个文件后立即编译。
|
||||||
|
|
||||||
|
## 7. 当前状态与待办
|
||||||
|
|
||||||
|
**已完成**:核心管线移植(字节级验证)、USB 层、实时画面(相机式布局:
|
||||||
|
顶栏4控制项 FFC/变倍/追踪/调色板 + 底部快门区 相册/拍照/录像 + 底导航)、
|
||||||
|
图标文字按物理持机朝向补偿旋转(加速度计)、演示模式、拍照 MDT 容器→MediaStore、
|
||||||
|
MP4 录像、媒体库、离线分析查看器、PDF 报告、任务解析、设置页、Android16 沉浸
|
||||||
|
(状态栏隐藏+竖屏锁定+挖孔安全区)、乱码多次修复。
|
||||||
|
|
||||||
|
**布局约定(最终,勿回退)**:
|
||||||
|
- Activity **锁定竖屏**(`portrait`),构图钉死手机竖屏框架:顶栏/热像区域/快门区/底导航
|
||||||
|
绝对位置永不随手机物理旋转变化(屏显方向永远对应镜头方向)。
|
||||||
|
- 图标/文字持机朝向补偿:`ui/DeviceOrientation`(加速度计,真机约定 y=+9.81=0°;
|
||||||
|
模拟器是反的重力约定)→ `rotationZ=-φ`(Compose)/`canvas.rotate(-φ)`(Canvas)。
|
||||||
|
- 顶栏 `uiTopPx` 必须挂在 `safeDrawing` inset **之前**(包含挖孔安全区,否则真机热像
|
||||||
|
顶端被顶栏盖住);`uiBottomPx` = 底导航高 + 快门区高。
|
||||||
|
|
||||||
|
**待办**(记录在 session_state.md):
|
||||||
|
1. 真机 USB 实测:温度绝对值标定、FFC/录像/MDT 保存端到端。
|
||||||
|
2. 网络互连远程预览(用户需求:UDP 自动发现 + 手动内网 IP 连另一台插热像仪的手机)。
|
||||||
|
3. 离线 MDT 温度解码(ConvertResponse2Temperature + 标定参数)。
|
||||||
|
4. 厂商 12 调色板精确提取(运行时抓取或逆向)。
|
||||||
|
5. 可见光 PIP 融合、云模块(预留结构)。
|
||||||
|
|
||||||
|
## 8. 文件路径速查
|
||||||
|
|
||||||
|
- 心跳/状态:`docs/android_app/session_state.md`
|
||||||
|
- 逆向功能总表:`docs/android_app/reverse_apk_features.md`
|
||||||
|
- USB 协议/温度算法:`analysis/protocol_spec.md`
|
||||||
|
- C 参考实现(Kotlin 管线的蓝本):`csdk/src/mag160c_render.c`、`mag160c_temp.c`、`mag160c_ir.c`
|
||||||
|
- PC 参考工具:`analysis/render_offline.c`、`analysis/gen_kotlin_tables.py`
|
||||||
|
- 预览截图:`analysis/preview/preview_*.png`
|
||||||
|
- APK 产物:`build-artifacts/mag160c-app-debug.apk`
|
||||||
|
|
||||||
|
## 9. 下一轮提示词(交付新模型直接用)
|
||||||
|
|
||||||
|
> 你是本项目的续任开发者。请先完整阅读 `C:\Project\MAG160C\docs\android_app\HANDOFF_DEVELOPMENT.md`
|
||||||
|
> 和 `C:\Project\MAG160C\docs\android_app\session_state.md`、`docs\android_app\reverse_apk_features.md`、
|
||||||
|
> `C:\Project\MAG160C\analysis\protocol_spec.md`,再继续开发。
|
||||||
|
> 项目根 `C:\Project\MAG160C` 是 git 仓库,源码在 `android\`(Gradle 工程,package
|
||||||
|
> `com.mag160c.thermal`)。Android 16 已适配,目标是一个 MAG160C 热像仪(160×120, 15fps,
|
||||||
|
> USB VID 0x833C PID 1)统一 APP。
|
||||||
|
>
|
||||||
|
> 当前已完成到第 9 轮反馈修复:核心渲染管线 Kotlin 移植(逐字节验证)、USB 层、
|
||||||
|
> 相机式实时布局(顶栏4控制项+底部快门区+底导航)、图标文字持机朝向补偿旋转、
|
||||||
|
> 演示模式(无设备用真实管线+DDT渲染合成帧)、拍照MDT存MediaStore、MP4录像、
|
||||||
|
> 媒体库、离线分析、PDF报告、任务解析、设置页、Android16沉浸(竖屏锁定+挖孔适配)。
|
||||||
|
>
|
||||||
|
> 硬性约定(勿回退):① Activity 竖屏锁定 `portrait`,构图钉死手机竖屏框架,
|
||||||
|
> 屏显方向必须始终对应镜头方向;图标/文字用 `ui/DeviceOrientation`(加速度计,
|
||||||
|
> 真机约定 y=+9.81=0°)补偿旋转。② 源码全 UTF-8;python 读写必须显式
|
||||||
|
> `encoding='utf-8'`(本机默认 GBK,裸写会乱码)。③ 图标用双弧圆+直线/矩形。
|
||||||
|
>
|
||||||
|
> 下一步建议按 session_state.md「待办」推进:
|
||||||
|
> 1. 真机 USB 实测——温度绝对值标定(对 DDT 核对 counts→毫度);
|
||||||
|
> 2. 网络互连远程预览——UDP 广播自动发现 + 手动内网 IP,主机手机插热像仪、
|
||||||
|
> 局域网另一台手机远程实时预览(自定义轻量协议:控制通道 JSON + 图像流,
|
||||||
|
> 不走厂商 33596/33597);
|
||||||
|
> 3. 离线 MDT 温度解码(ConvertResponse2Temperature + 标定参数);
|
||||||
|
> 4. 厂商 12 调色板精确提取;5. 可见光 PIP 融合、云模块(Retrofit opt-in 预留)。
|
||||||
|
>
|
||||||
|
> 构建/提交:
|
||||||
|
> ```powershell
|
||||||
|
> cd C:\Project\MAG160C\android; $env:JAVA_HOME="C:\Program Files\Zulu\zulu-21"
|
||||||
|
> & "C:\Tools\gradle-8.14.3\bin\gradle.bat" :app:assembleDebug --no-daemon
|
||||||
|
> & "C:\Tools\gradle-8.14.3\bin\gradle.bat" test --no-daemon
|
||||||
|
> git -C C:\Project\MAG160C add -A; git -C C:\Project\MAG160C commit -m "android: ..."
|
||||||
|
> ```
|
||||||
|
> 环境:`C:\Tools\gradle-8.14.3`、JDK Zulu 21、SDK `C:\Users\ZXC\AppData\Local\Android\Sdk`、
|
||||||
|
> AVD `Medium_Phone_API_36.1`。注意:git 工作树里有大量与本任务无关的脏文件
|
||||||
|
> (`.tools/`、`IR_Camera_SDK/`、`csdk/`、`analysis/` 等),**不要提交它们**,只提交你的改动。
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# MAG160C 官方 APP 逆向功能总表(2026-09-06)
|
||||||
|
|
||||||
|
> 用 jadx 1.5.1 反编译 4 个 APK,IDA(MCP)分析 pro 版 libcoresdk.so(v7a),
|
||||||
|
> Ghidra 12.1.2 headless 分析普通版 libcxsdk.so。反编译工作副本在
|
||||||
|
> `%TEMP%\opencode\apkwork\jadx_*`(临时目录,已删可重跑 jadx 命令见本文末尾)。
|
||||||
|
|
||||||
|
## 1. 四个 APP 身份
|
||||||
|
|
||||||
|
| 包名 | 版本 | 名称 | 原生库 | 定位 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| cn.com.magnity.magnitycx | 1.1.4 (114) | MAG-Cx 普通版 | libcxsdk + FFmpeg (仅 armeabi) | USB 实时拍摄/录像 |
|
||||||
|
| cn.com.magnity.magnitymx | 2.0.7 (27) | MAG-Mx 专业版 | libcoresdk(新版libusb) + libthermogroupsdk (仅 armeabi-v7a) | USB+网络设备、任务巡检、云 |
|
||||||
|
| cn.com.magnity.thermoscope | 1.0.2 (2) | Thermo Scope | libthermogroupsdk (仅 armeabi-v7a) | MDT 热像文件离线分析+PDF 报告 |
|
||||||
|
| cn.com.magnity.coresdksample | 1.0 | CoreSdkSample | libcoresdk (旧) | SDK 示例(无独立功能) |
|
||||||
|
|
||||||
|
- 全部 targetSdk 25(Android 7.1 时代);专业版/普通版 MainActivity 锁定 landscape。
|
||||||
|
- USB:VID 0x833C。普通版 PID=1;专业版同时支持 PID=1(160Core/coresdk) 与 PID=2(Mx 网络机芯)。
|
||||||
|
|
||||||
|
## 2. 普通版 MAG-Cx 功能(jadx_normal/sources/cn/com/magnity/magnitycx)
|
||||||
|
|
||||||
|
**实时画面**:SurfaceView 软件渲染(lockCanvas→drawBitmap),IR 160×120→640×480 合成画布
|
||||||
|
- 12 调色板(白热/黑热/铁虹/彩虹/琥珀/金秋/寒冬/热金属/喷射/红饱和/高对比/红热)DialogFragmentPalette.java
|
||||||
|
- 中心点测温、全屏最高/最低温追踪(无/最高/最低/全部)、矩形 ROI(最值+均值,MAX_ROI_NUM=1)ImageViewer.java
|
||||||
|
- PIP 可见光小窗(手机 Camera API 预览,可拖动/三档尺寸)ImageViewerVisible.java;无像素级融合
|
||||||
|
- 数码变倍 1/2/4/8/16x(160C 仅 1/2/4 循环);双击=手动 FFC;Fling 切拍照/录像
|
||||||
|
- 录像 MP4(FFmpeg avcodec-57 + libcxsdk doRecording,640×480,2048kbps/20fps)
|
||||||
|
- 拍照 JPEG q100 640×480 含 OSD(十字/最值/水印/激光);仅 C3P 另存 .ddt 伴随文件
|
||||||
|
- 横竖屏 OrientationEventListener 实时切 native 旋转角;来电自动断 USB;退后台 500ms deinit
|
||||||
|
|
||||||
|
**USB/协议**(UsbCommunication.java,端点 0x03/0x82/0x81/0x84,28B 帧头尾协议同 csdk)
|
||||||
|
- 连接握手:GetParameter1/2 → GetCaliInfo → 无本地标定则 GetCaliFile 下载(others\{产品}.{SN}.{日期})
|
||||||
|
- 启动:native startProcess(...) + StartTransferImg;ThreadImgRecv/Process 双线程;16 帧一次查开机时长并按时间表 FFC
|
||||||
|
- 命令集 P2D_*(0x6BB6B6xx)与 D2P_*(0x5BB5B5xx),与 csdk/protocol_spec.md 完全一致
|
||||||
|
|
||||||
|
**设置项**:可见光尺寸、温度追踪目标、矩形测温开关、H/V 翻转、变倍、辐射率 4 档(1.00/0.90/0.80/0.70)、帧率高/低、移动光标
|
||||||
|
**媒体库**:GridView+LRU、多选分享/删除、ViewPager 浏览、视频外部播放器
|
||||||
|
**升级**:http://www.magnity.com.cn/APPs/UpgradeInfo(产品键 MAG-Cx)
|
||||||
|
|
||||||
|
## 3. 专业版 MAG-Mx 功能(jadx_pro/sources/cn/com/magnity/magnitymx)
|
||||||
|
|
||||||
|
**双设备模型**:CXDeviceModel(USB coresdk,PID1)+ DeviceModel(网络/云/USB-Mx thermogroup,PID2;命令口 33596、图像口 33597,云中继 121.43.190.114,账密 magnity/any123、admin/admin123)
|
||||||
|
|
||||||
|
**实时画面**(LiveFragment+MainActivity,竖直 LinearLayout)
|
||||||
|
- 工具栏:任务面板、logo 点击=FFC、设置、调参条(tunebar)、PIP 切换(长按=可见光窗口调整)、最高温追踪、缩略图、ROI、拍照(长按=MGS 录像)、暂停、设备列表
|
||||||
|
- tunebar 8 项:手动拉伸/等温线/发射率/调色板/温度报警/任务/红外基准图/扫码/码流
|
||||||
|
- 12 调色板;发射率 35 种材料表+自定义(DialogFragmentEmissivity)
|
||||||
|
- ROI:点/线/框,区域 ROI 弹名称+报警上下限(DialogFragmentRoiAlarmTempAddEdit);报警声+红色闪烁
|
||||||
|
- 可见光 Camera2 融合(alpha 混合、拖动/双指缩放、拍照取 JPEG 写入 MDT);闪光灯
|
||||||
|
- 图像增强:手动两点拉伸/自动拉伸(range5)/等温线;图像旋转 0/90/180/270
|
||||||
|
- 参考图叠加(基准图 alpha 调整,拍照时 blendBitmap 融合)
|
||||||
|
- 码流类型:温度流2/视频流4/混合流6(网络设备)
|
||||||
|
- 拍照 = 640×480 JPG + saveDDT2Buffer + ROI + 可见光 + GPS(EXIF) → **MDT 容器** saveMDT,文件名 01-yyMMddHHmmss-<任务名/扫码结果>.jpg
|
||||||
|
- **录像 = SDK .mgs 专有格式**(仅长按触发,视频流禁用);VideoEncoder MP4 通道是死代码
|
||||||
|
- 拼接全景:仅 CX/USB 通道实现(startStitching)
|
||||||
|
- 云台:WiFi SSID 含 HERO-RC → UDP 8484 协议遥控拍照/模式切换(UDPHelper)
|
||||||
|
- 扫码:ZXing,结果作文件名标记
|
||||||
|
- 音量键拍照;GPS 写 EXIF;云设备 10 分钟闲置断流
|
||||||
|
|
||||||
|
**任务巡检系统**(task/ + assets/tasks)
|
||||||
|
- SqliteTaskParser:.sqlite 库(省-市-工区-电压-变电站五级 + task 树 + capture_status/order)
|
||||||
|
- XmlTaskParser:旧 XML 任务;选库后侧滑两级树,点选→参考图叠加→拍照自动跳下一项
|
||||||
|
- 任务库放 /sdcard/magnity/mx/tasks(首拷自 assets;云下载 v1/account/taskfiles/{taskId})
|
||||||
|
|
||||||
|
**媒体库**(media/):宫格、多选(上传/分享/删除/全选)、NetworkManager 上传队列、单张浏览、"设为参考图"、"分析"= 调起 ThermoScope(intent thermoscope://路径)、GPS 跳地图
|
||||||
|
**云平台**:Retrofit REST(cloudapi.magnity.com.cn):login/logout/getDevices/upload(task_id)/taskfiles;本地 magnity_mx_files.db 记 MD5 上传状态;CloudSyncTask 云同步;MagnityNetworkService 前台服务上传
|
||||||
|
**设置**:账户/相机(仅图像旋转)/语言(自动/简/繁/EN)/主题(占位)/检查更新(magnity.com.cn/APPs/mx)/关于
|
||||||
|
**数据库**:Room cloud_device/local_device + SQLite magnity_mx_files.db
|
||||||
|
|
||||||
|
## 4. ThermoScope 功能(jadx_thermoscope/sources/cn/com/magnity/thermoscope)
|
||||||
|
|
||||||
|
- **MDT 容器格式**(types/):尾部 152B Tail{0x5BB5B57B + 5 段绝对偏移};段序 JPG 缩略图→DDT(136B头+38400B 原始 u16 帧)→VIS 可见光→LAB(每 ROI 256B:type/最值/均值/位置/坐标/色/发射率/报警上下限/名字)→TXT(编码 GB18030/GBK/UTF-8)→AUD;段间 4 字节对齐;支持裸 DDT
|
||||||
|
- 媒体库:扫 DCIM/Magnity/*.jpg(内容实为 MDT),同目录打开模式,按文件名内嵌时间排序
|
||||||
|
- 单张分析(SingleMediaActivity + ScopeSurfaceView 软件渲染):
|
||||||
|
- 11 调色板切换(回写 DDT);参数滑杆:环境温度 -40~80℃ / 发射率 0~1 / 窗口透过率 0~1(native 重算+回写)
|
||||||
|
- 点测温 Pt*(3x3 探针+发射率修正)、框测温 Rt*(min/max/ave+minPos/maxPos)、垂直滑动切红外/可见光
|
||||||
|
- 双指缩放 1~4x、平移;max/min 追踪标记;色标条+6 刻度;保存 640×480 PNG 快照+水印
|
||||||
|
- 相机信息(SN/分辨率/版本/时间/海拔/经纬度);文字备注;语音段透传
|
||||||
|
- PDF 报告(PdfCreator,tejpratapsingh PDFUtil):标题、7×4 基本信息、热像图、9 级色标、整体+ROI 分析表、结果/建议、测试员;输出 DCIM/Magnity/reports/;ReportSettingActivity 15 项报告参数
|
||||||
|
- RelayActivity:外部打开 .mdt/.ddt/.jpg/pdf 分发
|
||||||
|
|
||||||
|
## 5. SDK(cn/com/magnity/coresdk + sdk)
|
||||||
|
|
||||||
|
三代演进:coresdk(USB-only, PID1) → thermogroupsdk 基础版(thermoscope: +网络/云/PTZ/人脸) → 完整版(pro: +GetCameraInfoEx/ObjReco/visPlay_no_decode H.264)
|
||||||
|
- 帧回调只发节拍(newFrame(camState,streamType)),数据用 getOutputImage()/getOutputTempImage(int[])/getTemperatureData(int[]) 拉取
|
||||||
|
- 温度单位:毫度 int(×0.001℃);位图 ARGB_8888(IDA 证实 GetOutputImage |0xFF000000 直写、行倒序)
|
||||||
|
- nativeCopyBitmap:格式/宽高/stride 校验后 qmemcpy
|
||||||
|
- libcxsdk.so(普通版图像核心)导出 C++ API:CFunctions/CAccumulator/CSmoother(与 ARM64 libcoresdk 同族,JNI_OnLoad 动态注册)
|
||||||
|
|
||||||
|
## 6. 花屏(Android 16 上专业版)根因分析
|
||||||
|
|
||||||
|
可确认的事实:
|
||||||
|
1. 专业版仅带 **armeabi-v7a 32 位库**(libcoresdk/libthermogroupsdk),无 arm64。
|
||||||
|
2. targetSdk 25 + MainActivity 锁 landscape + 全部旧版 support/自定义 SurfaceView 渲染。
|
||||||
|
3. 渲染链:native 位图(ARGB_8888,行倒序)→ Bitmap.createBitmap 拷贝 → lockCanvas/drawBitmap → unlockCanvasAndPost;SurfaceHolder 格式 -2(TRANSPARENT),与格式无关的 Canvas 软渲染。
|
||||||
|
4. 现役 2025~2026 旗舰(多数已停 32 位支持或启用 16KB 页内核)上,32 位 v7a 库+4KB 对齐 ELF+legacy 渲染路径极易出现兼容层渲染异常(花屏/缩放模糊/偶发撕裂)。
|
||||||
|
|
||||||
|
结论:不是单一 bug,而是"32 位专属库 + targetSdk25 时代渲染栈"在 Android 16 设备上的整体不兼容。重写为 targetSdk 36 + arm64(16KB 对齐) + 现代渲染即可根治,无需逐条修旧代码。
|
||||||
|
|
||||||
|
## 7. 反编译复现命令
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$w='C:\Users\ZXC\AppData\Local\Temp\opencode\apkwork'
|
||||||
|
& 'C:\Tools\jadx\bin\jadx.bat' -d "$w\jadx_pro" --show-bad-code -j 8 "$w\orig\pro.apk"
|
||||||
|
# Ghidra: & 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' C:\Tools\ghidra_proj Mag160C -import <lib> -scriptPath C:\Tools\ghidra_scripts_user -postScript DumpJni.java
|
||||||
|
```
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# MAG160C 安卓统一 APP — 会话状态与心跳锚点
|
||||||
|
|
||||||
|
> 本文件是本任务("整合 4 个官方 APP 重写为 Android 16 现代化 APP")的心跳锚点。
|
||||||
|
> 每完成一个里程碑更新勾选;会话中断先读本文件 + `reverse_apk_features.md`,
|
||||||
|
> 再按"下一步"继续。约定:不弹询问窗,按计划自主推进,重大分歧点才停下来问。
|
||||||
|
|
||||||
|
## 任务目标
|
||||||
|
|
||||||
|
1. 逆向 C:\Project\MAG160C\app 4 个官方 APP(普通版/专业版/ThermoScope/demo)→ 已完成
|
||||||
|
2. 整合全部功能开发一款安卓 APP:适配 Android 16(API 36)、现代 UI、横竖屏、
|
||||||
|
无流氓文件夹、干爽简洁 → **进行中**
|
||||||
|
3. 背景问题:专业版在用户 Android 16 手机上花屏(根因见 reverse_apk_features.md §6)
|
||||||
|
|
||||||
|
## 已完成的里程碑
|
||||||
|
|
||||||
|
- [x] 工具链:jadx 1.5.1(C:\Tools\jadx)、Ghidra 12.1.2(C:\Tools)、IDA MCP 可用
|
||||||
|
- [x] 4 APK 解包 + jadx 全量反编译(%TEMP%\opencode\apkwork\jadx_*)
|
||||||
|
- [x] 普通版/专业版/ThermoScope/demo 功能清单 → docs/android_app/reverse_apk_features.md
|
||||||
|
- [x] IDA:pro libcoresdk.so GetOutputImage/copyBitmap 位图格式证实(ARGB_8888)
|
||||||
|
- [x] Ghidra:libcxsdk.so 导出 C++ API 清单(CFunctions 族,与 ARM64 coresdk 同源)
|
||||||
|
- [x] 花屏根因分析(32 位专属库 + targetSdk25 legacy 渲染栈)
|
||||||
|
|
||||||
|
## 关键事实速查(开发时直接用)
|
||||||
|
|
||||||
|
- USB:VID 0x833C PID 1,EP 0x03/0x82/0x81/0x84,帧 28B 头+38400B+尾,15fps
|
||||||
|
- 协议/渲染/温度算法全部已逆向:analysis/protocol_spec.md + csdk/(逐像素验证过的官方管线)
|
||||||
|
- csdk 的 mag160c_render 无平台依赖:USB 帧 → 320×240 RGB24 + 探针测温(毫度)
|
||||||
|
- DDT 标定文件:可从设备经 0x6BB6B66D/E 下载(官方 prepareProcessImage 同款),仓库有
|
||||||
|
build-artifacts/mag160c_official.ddt(1.8MB,SN 160043865)
|
||||||
|
- MDT 文件格式:reverse_apk_features.md §4(152B Tail + 5 段布局 + ROI 256B 记录)
|
||||||
|
- 温度:毫度 int;MDT 解码走 ConvertResponse2Temperature + T2E(Revise) + CorrectTemperature
|
||||||
|
|
||||||
|
## 用户已确认的范围(2026-09-06)
|
||||||
|
|
||||||
|
- 云功能:预留 Retrofit 接口模块,默认关闭
|
||||||
|
- 扫码打标 + 任务巡检(sqlite/xml) + 红外基准图叠加:v1 全部实现
|
||||||
|
- **网络互连(用户新增需求)**:主机手机插 USB 热像仪,局域网内另一台手机
|
||||||
|
远程实时预览——UDP 广播自动发现 + 手动添加内网 IP;自定义轻量协议
|
||||||
|
(控制通道 JSON + 图像流),不走厂商 33596/33597 协议
|
||||||
|
|
||||||
|
## 当前阶段
|
||||||
|
|
||||||
|
**阶段 2/8 完成:纯Kotlin核心管线移植并逐字节验证(2026-09-06)**
|
||||||
|
|
||||||
|
## 已完成里程碑(本任务)
|
||||||
|
|
||||||
|
- [x] 阶段1:轻量工具链定案——NDK/CMake/zig 全部卸载(-2.4GB),纯 Kotlin 方案:
|
||||||
|
渲染管线+温度算法+帧解析从 C 移植为 Kotlin,Gradle 8.14.3 + SDK 已有组件
|
||||||
|
- [x] 阶段2:项目骨架(android/,AGP 8.11.1 + Kotlin 2.2.0 + Compose BOM,
|
||||||
|
compileSdk/targetSdk 36,minSdk 26),最小APK可构建
|
||||||
|
- [x] 阶段2:表数据自动生成 analysis/gen_kotlin_tables.py → OfficialTables.kt
|
||||||
|
(palette256 ARGB / T2E 646 / T2E274 / E2T_ACC_Q10)
|
||||||
|
- [x] 阶段2:核心移植 FrameStream / RenderPipeline / TempMath(毫度测温)
|
||||||
|
- [x] 阶段2:PC端参考工具 analysis/render_offline.c(gcc 编译)+ 差分测试:
|
||||||
|
**Kotlin 输出与C参考逐字节一致**(60帧序列,首渲染帧32,NUC/LUT/灰度/输出全同)
|
||||||
|
- [x] 阶段2:FrameStream 分块重组测试 + 温度换算单调性测试全过
|
||||||
|
|
||||||
|
## 移植陷阱记录(教训)
|
||||||
|
|
||||||
|
- C 无符号32位回绕:乘积/减法必须 u32() 掩码(cdf*denom、0xffc0000-iv7*0x40000、
|
||||||
|
(v-win_lo)*S 等)
|
||||||
|
- lutRebuild 的 u12 基址是常量 iv7*0x10+0x10,**不是**链式 u21v;u21v 只用于曲线钳制比较
|
||||||
|
- Kotlin ByteArray 存 255 = -1:lut[bfmax] != 0xFF 判断必须 (b.toInt() and 0xFF)
|
||||||
|
- 暖机窗口内 FFC 状态机暂停(warm check 在 ffc_step 之前)——移植时保持顺序
|
||||||
|
|
||||||
|
## 用户反馈修复(2026-09-07)
|
||||||
|
|
||||||
|
- [x] **中文乱码根因**:部分源码曾被python以GBK重写(AppRoot.kt),编译出的字符串
|
||||||
|
mojibake。已写 fix_encoding.py 全量转UTF-8 + kotlin.daemon.jvmargs 锁UTF-8 +
|
||||||
|
dex字节级验证(classes5/9.dex 中"媒体库"UTF-8正确)
|
||||||
|
- [x] **权限弹窗**:Manifest 加 USB_DEVICE_ATTACHED intent-filter + device_filter
|
||||||
|
(插热像仪系统自动弹权限/启动);相册页运行时申请 READ_MEDIA_IMAGES
|
||||||
|
(模拟器实测弹窗正常)
|
||||||
|
- [x] **图标**:全部重绘为可验证几何图形(双弧圆/直线/矩形),横竖屏截图确认渲染正确
|
||||||
|
- [x] **专业版三大功能补齐**:多点测温(点击加Pt1..PtN、再点删除)、右侧色标条
|
||||||
|
(渐变+最高/最低温标注)、最高温追踪开关(追踪·开/关)
|
||||||
|
- [x] **演示模式**:无设备时用真实管线+DDT渲染合成帧(热块场景),全UI可探索;
|
||||||
|
demo温度用局部线性显示映射(真机温度走管线数学)
|
||||||
|
- [x] Android 16.1 模拟器(Medium_Phone AVD)实机验证:中文/图标/布局/横竖屏正常,
|
||||||
|
预览图在 analysis/preview/preview_*.png
|
||||||
|
|
||||||
|
## 用户反馈修复 第二轮(2026-09-07)
|
||||||
|
|
||||||
|
- [x] 竖屏时热像图旋转90°竖着显示(填满屏宽);横屏时保持传感器原生4:3方向
|
||||||
|
填满屏高——两个方向下显示区域都最大化(用户要求的官方行为)
|
||||||
|
- [x] 横屏:控制栏移到屏幕右侧(竖列),竖屏保持底部横排6项等分——切换时
|
||||||
|
不再跳位
|
||||||
|
- [x] 追踪/测温点标记改为大号"圆环+实心点"图标,字高随密度缩放(更大)
|
||||||
|
- [x] 色标条放大(宽20dp、高0.8视口)+更大刻度字号
|
||||||
|
- [x] ScreenOrientation=fullSensor:跟随传感器旋转(系统旋转锁定时也转,
|
||||||
|
与官方普通版一致)
|
||||||
|
- [x] **SurfaceView旋转存活**:AppRoot/LiveScreen改为稳定单分支布局(导航/控制
|
||||||
|
栏做成overlay),SurfaceView不再因横竖屏切换被销毁重建(此前横屏花屏空
|
||||||
|
白的根因)
|
||||||
|
- [x] 模拟器实测:竖屏/横屏截图确认全部生效(analysis/preview/)
|
||||||
|
|
||||||
|
## 用户反馈修复 第三轮(2026-09-07)
|
||||||
|
|
||||||
|
- [x] **布局重构**:控制项(FFC/变倍/追踪/调色板/拍照/录像/调色板名)移到**顶部横栏**,
|
||||||
|
横竖屏都显示;底栏导航(实时/相册/分析/设置)保持原位置(竖屏底部栏/横屏左侧栏)
|
||||||
|
- [x] **图像内容世界固定**:绘制角度 = (90 - 显示器rotation×90) mod 360,
|
||||||
|
四向旋转(竖屏/左横/右横/倒竖屏)图像内容都不随手机转,只转图标文字
|
||||||
|
(修复"向右横屏图像颠倒"问题)
|
||||||
|
- [x] **状态栏沉浸**:insetsController 隐藏状态栏(下滑临时显示),底栏导航仍可用
|
||||||
|
- [x] **inset感知渲染**:渲染器 viewport = 全屏减顶栏/底栏inset(AppRoot横屏时
|
||||||
|
导航栏宽度不再误算为底部inset)
|
||||||
|
- [x] 顶栏通过 onSizeChanged 上报 vm.uiTopPx;导航 overlay 高度经 UiInsets 单例
|
||||||
|
同步 vm.uiBottomPx
|
||||||
|
- [x] **(重要教训)python 裸写文件会用 GBK**:所有 python 编辑必须显式
|
||||||
|
encoding='utf-8';本次 LiveScreen.kt 双重乱码已重写恢复,dex 验证 CJK 正常
|
||||||
|
- [x] 模拟器验证:顶栏/左导航/图像/色标/标记布局正确,中文正常,状态栏隐藏生效
|
||||||
|
|
||||||
|
## 用户反馈修复 第四轮(2026-09-07,最终布局约定)
|
||||||
|
|
||||||
|
- [x] **顶栏/底栏位置固定**:顶栏=控制项(FFC/变倍/追踪/调色板/拍照/录像)恒在顶部;
|
||||||
|
底栏=导航恒在底部。横竖屏都不移动(**取消横屏左栏**),图标文字跟随屏幕方向(恒可读)
|
||||||
|
- [x] **图像区域永不旋转**:图像恒 90°CW 绘制为 3:4 竖向,填满顶栏~底栏之间可用区,
|
||||||
|
手机怎么转都不动;只转标记文字(文字恒屏幕水平,位置经 probeToScreen 固定90°映射)
|
||||||
|
- [x] **顶栏适配挖孔屏**:`WindowInsets.safeDrawing.only(Top)` 顶部安全区 padding
|
||||||
|
- [x] 去掉 display-rotation 依赖(uiRotation/drawRotationDeg 已删,tapImage/probeToScreen
|
||||||
|
用固定 90° 映射)
|
||||||
|
- [x] 完整交接文档:docs/android_app/HANDOFF_DEVELOPMENT.md(含给新模型的读取提示词)
|
||||||
|
|
||||||
|
## 用户反馈修复 第五轮(2026-09-07,布局约定最终定稿:构图钉死竖屏框架)
|
||||||
|
|
||||||
|
- [x] **用户澄清**:"顶栏底栏位置不变"指**绝对位置**——永远贴着手机竖屏的物理顶边
|
||||||
|
(挖孔侧)与物理底边;热像区域也**完全不动**。不是"横屏后显示区域跟着屏幕转":
|
||||||
|
那样屏显方向会和镜头实际方向对不上。
|
||||||
|
- [x] **根因与修复**:此前 fullSensor 下渲染"相对当前屏幕固定 90°CW",手机横过来时
|
||||||
|
屏幕本身转 90°,整个构图相对手机框架也转 90° → 与镜头方向脱节。修复 =
|
||||||
|
**Activity 锁定竖屏**(manifest `screenOrientation="portrait"`):屏幕相对手机框架
|
||||||
|
永不旋转,构图(顶栏/热像区/底栏)永远钉在竖屏框架上;手机怎么物理旋转都一样。
|
||||||
|
- [x] 顺带清理:GalleryScreen 去掉 isLandscape() 死逻辑(恒 3 列);
|
||||||
|
AppRoot/LiveScreen/LiveRenderer/UiInsets 注释同步更新。
|
||||||
|
- [x] 模拟器四方向实测(0/90/180/270°,`adb emu rotate`):**四个方向截图逐字节一致**
|
||||||
|
(MD5 相同)——构图绝对固定;演示画面/顶栏/底栏/色标/标记/中文均正常
|
||||||
|
(analysis/preview/preview_rot*_v5.png)。
|
||||||
|
- [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 已更新。
|
||||||
|
- 注:对话框(调色板等)与其他页签内容未做补偿旋转(保持竖屏可读),如需再加。
|
||||||
|
|
||||||
|
## 用户反馈修复 第七轮(2026-09-07,真机朝向全反修复)
|
||||||
|
|
||||||
|
- [x] **用户反馈**:真机正持竖屏时屏幕内容倒立,其他方向也全反。
|
||||||
|
- [x] **根因**:第六轮把加速度计符号约定写反。Android 真机 TYPE_ACCELEROMETER
|
||||||
|
静止读数 = "加速度减重力",指向世界上方向(平放屏幕朝上 z=+9.81、竖屏正持
|
||||||
|
y=+9.81);模拟器虚拟传感器却是重力向量约定(正持 y=-9.81),故模拟器测试
|
||||||
|
"通过"而真机全反(所有姿态差 180°)。
|
||||||
|
- [x] **修复**:DeviceOrientation 映射改为 (0,+g)=0、(-g,0)=90、(0,-g)=180、(+g,0)=270;
|
||||||
|
代码注释明确标注"勿按模拟器默认值改回"。模拟器用反号值复测:姿态 0 恢复正常
|
||||||
|
正持显示,90/180/270 补偿几何不变(preview_fix_pose*_v7.png)。
|
||||||
|
- [x] 单元测试全过;APK 已更新。
|
||||||
|
|
||||||
|
## 用户反馈修复 第八轮(2026-09-07,相机式按键布局)
|
||||||
|
|
||||||
|
- [x] **顶栏精简为 4 个相机控制项**(左→右,带小字标签,随持机朝向补偿旋转):
|
||||||
|
FFC 校正 / 数码变倍(显示当前 1×/2×/4×,点击循环)/ 最高温追踪开关(追踪·开,
|
||||||
|
高亮+新 ic_target 准星图标)/ 调色板(显示当前调色板名,点击弹出选择)。
|
||||||
|
- [x] **底部新增相机快门区**(仅实时页,位于底导航上方,白/红色调):
|
||||||
|
相册快捷入口(跳转媒体库页签)/ 大圆形快门=拍照 / 录像-停止(红圈,录像中
|
||||||
|
红圈内容变红色方块)。快门区高度并入渲染器底部 inset(vm.uiBottomPx =
|
||||||
|
导航高+快门区高),测温点点击映射同步排除该区域。
|
||||||
|
- [x] 底部导航(实时/相册/分析/设置)不变;快门区内容同样按持机朝向补偿旋转。
|
||||||
|
- [x] 模拟器实测:0°/90° 姿态布局、旋转补偿、图像区域均正确
|
||||||
|
(preview_ui_v8.png / preview_ui90_v8.png);单元测试全过;APK 已更新。
|
||||||
|
|
||||||
|
## 用户反馈修复 第九轮(2026-09-07,真机热像顶端被顶栏遮挡)
|
||||||
|
|
||||||
|
- [x] **用户反馈**:真机上快门区把热像显示区顶满后,热像最顶端被顶栏盖住看不见。
|
||||||
|
- [x] **根因**:顶栏 `onSizeChanged` 挂在 `safeDrawing` 挖孔 inset 内侧,`uiTopPx`
|
||||||
|
不含挖孔安全区高度;模拟器无挖孔看不出来。快门区加入后剩余高度变小、热像
|
||||||
|
进入"高度受限"铺满视口状态,顶端误差直接表现为热像顶端插入顶栏背后。
|
||||||
|
- [x] **修复**:`onSizeChanged` 移到 `windowInsetsPadding` 之前(`uiTopPx` = 挖孔
|
||||||
|
安全区 + 顶栏全高,渲染器视口完整跳过顶栏);同时按要求缩小快门区
|
||||||
|
(相册/录像 42dp、快门 60dp、纵向 padding 6dp、间距 44dp)。
|
||||||
|
- [x] 模拟器复测布局正常,单元测试全过;APK 已更新。
|
||||||
|
|
||||||
|
## 待办
|
||||||
|
|
||||||
|
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)
|
||||||
|
- 网络互连远程预览(用户需求:UDP自动发现+手动IP)
|
||||||
|
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
|
||||||
|
- 厂商12调色板精确提取(需运行时抓取)
|
||||||
|
- 可见光PIP融合、云模块(预留结构)
|
||||||
|
|
||||||
|
## 里程碑日志
|
||||||
|
|
||||||
|
- 阶段3b 完成(2026-09-06 晚): 拍照 MDT 容器(jpg+info块+raw帧+备注)存入
|
||||||
|
MediaStore DCIM/MAG160C;MP4 录像 = Surface 编码 H.264 @320×240 15fps 2Mbps
|
||||||
|
- 阶段4 完成: 媒体库(MediaStore 扫描 + 内嵌 JPEG 缩略图 + MDT 尾部校验)
|
||||||
|
- 阶段5a 完成: 分析查看器(detectTransformGestures 缩放1-4x/平移、12 调色板
|
||||||
|
重渲染原始帧、自动窗口、备注编辑回写容器、温度探针近似)
|
||||||
|
- 阶段5b 完成: PDF 巡检报告(PdfDocument:标题/7×4信息表/热像图/结果/建议/测试员)
|
||||||
|
- 阶段6a 完成: 设置页(默认调色板/发射率/报警温度/关于,SharedPreferences 私有)
|
||||||
|
+ 任务巡检解析器 TaskParser(sqlite task/region/device/part/phase + XML title/target)
|
||||||
|
|
||||||
|
## 阶段3a 完成(2026-09-06 晚)
|
||||||
|
|
||||||
|
- [x] USB传输层 usb/UsbTransport.kt(VID 0x833C 枚举/权限/claim/EP查找)
|
||||||
|
- [x] 命令协议 usb/MagProtocol.kt(4B命令+FFC 8B;0x5BB5B55B 信息块解析)
|
||||||
|
- [x] 直播会话 usb/IrSession.kt(66b/66c/66f → FFC(0)x2 → 300ms → START(73),
|
||||||
|
读线程→FrameStream→RenderPipeline→帧回调;STOP(74);手动FFC)
|
||||||
|
- [x] 官方12调色板 core/Palettes.kt(铁虹=官方提取表,其余为标准曲线近似)
|
||||||
|
- [x] UI骨架:底部导航4区(实时/相册/分析/设置,横屏自动切侧栏 NavigationRail)
|
||||||
|
- [x] 实时画面 ui/live/:SurfaceView+软件Canvas渲染(letterbox+数码变倍+OSD
|
||||||
|
中心温/最高最低温标记),ViewModel驱动,慢速测温定时器
|
||||||
|
- [x] DDT标定文件打包进 assets/mag160c.ddt
|
||||||
|
- [x] 全APK assembleDebug 成功(11.7MB,无material-icons膨胀)
|
||||||
|
- [ ] 待实机验证USB流(需插设备)
|
||||||
|
|
||||||
|
## 开发计划(待确认后逐阶段执行)
|
||||||
|
|
||||||
|
- 阶段 1:环境(cmdline-tools/NDK r27+/gradle 8.x + 16KB 对齐配置 + AGP 8.x)
|
||||||
|
- 阶段 2:新仓库 app-android(Kotlin + Compose M3 + NDK 移植 csdk 渲染/温度 C 代码 + Kotlin USB 层)
|
||||||
|
- 阶段 3:实时画面(USB 流+官方管线+OSD+ROI+调色板+FFC+增强+PIP 可见光+拍照 MDT+MP4 录像)
|
||||||
|
- 阶段 4:媒体库(MediaStore,DCIM/MAG160C,无流氓文件夹)
|
||||||
|
- 阶段 5:MDT 离线分析(缩放/ROI/参数/PDF 报告/备注)
|
||||||
|
- 阶段 6:设置/任务巡检/可选云(Retrofit opt-in)
|
||||||
|
- 阶段 7:Android 16 专项适配验收(16KB 对齐/横竖屏/深色/预测性返回)
|
||||||
|
- 阶段 8:打包安装到用户手机实测
|
||||||
|
|
||||||
|
## 心跳约定
|
||||||
|
|
||||||
|
- 每完成一个阶段:更新本文件勾选 + git commit(不 push)。
|
||||||
|
- 中断恢复:读本文件 → 按"下一步"继续 → 环境检查命令:
|
||||||
|
```powershell
|
||||||
|
Test-Path C:\Users\ZXC\AppData\Local\Android\Sdk\ndk\<ver>
|
||||||
|
Get-Command java; Get-Command gradle
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# MAG160C 安卓APP会话恢复脚本
|
||||||
|
# 用法: powershell -ExecutionPolicy Bypass -File tools\resume_android.ps1
|
||||||
|
# 作用: 检查环境/构建/设备状态,重新构建APK,列出实机验证清单。
|
||||||
|
$proj = "C:\Project\MAG160C"
|
||||||
|
$andr = "$proj\android"
|
||||||
|
Write-Host "=== 1. 工具链 ==="
|
||||||
|
Write-Host "Java: $env:JAVA_HOME"
|
||||||
|
$gradle = "C:\Tools\gradle-8.14.3\bin\gradle.bat"
|
||||||
|
Write-Host "Gradle: $(Test-Path $gradle)"
|
||||||
|
Write-Host "SDK: $(Test-Path "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe")"
|
||||||
|
|
||||||
|
Write-Host "=== 2. 设备 ==="
|
||||||
|
cmd /c "C:\Users\ZXC\AppData\Local\Android\Sdk\platform-tools\adb.exe devices"
|
||||||
|
|
||||||
|
Write-Host "=== 3. 构建 ==="
|
||||||
|
Push-Location $andr
|
||||||
|
$env:JAVA_HOME = "C:\Program Files\Zulu\zulu-21"
|
||||||
|
& $gradle :app:assembleDebug --no-daemon -q 2>&1 | Out-Null
|
||||||
|
Pop-Location
|
||||||
|
$apk = "$andr\app\build\outputs\apk\debug\app-debug.apk"
|
||||||
|
if (Test-Path $apk) {
|
||||||
|
Write-Host "构建 OK: $apk ($((Get-Item $apk).Length) 字节)"
|
||||||
|
Copy-Item $apk "$proj\build-artifacts\mag160c-app-debug.apk" -Force
|
||||||
|
} else { Write-Host "!!! 构建失败,运行: cd android; gradle :app:assembleDebug" }
|
||||||
|
|
||||||
|
Write-Host "=== 4. 实机验证清单 ==="
|
||||||
|
Write-Host "1. adb install mag160c-app-debug.apk"
|
||||||
|
Write-Host "2. 插入MAG160C -> 允许USB权限 -> 实时画面出图"
|
||||||
|
Write-Host "3. FFC/调色板/变倍/拍照/录像/媒体库/分析/PDF报告"
|
||||||
|
Write-Host "4. 横竖屏+深色模式"
|
||||||
|
Write-Host "详细状态: docs\android_app\session_state.md"
|
||||||
Reference in New Issue
Block a user