android: 统一APP工程骨架 + 官方渲染管线Kotlin移植(与C参考逐字节一致)

This commit is contained in:
ZXCLI
2026-09-06 18:44:02 +08:00
parent dd52469377
commit 5b41a6aa9b
27 changed files with 2184 additions and 0 deletions
+115
View File
@@ -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")
+69
View File
@@ -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;
}
+60
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
captures/
+52
View File
@@ -0,0 +1,52 @@
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)
debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit)
}
+23
View File
@@ -0,0 +1,23 @@
<?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" />
<application
android:label="MAG160C"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:supportsRtl="true"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true"
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>
</activity>
</application>
</manifest>
@@ -0,0 +1,21 @@
package com.mag160c.thermal
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { App() }
}
}
@Composable
private fun App() {
Text("MAG160C build check")
}
@@ -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,686 @@
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 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()]
}
}
/** 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 = OfficialTables.PALETTE256_ARGB
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,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
+5
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
android.useAndroidX=true
kotlin.code.style=official
+26
View File
@@ -0,0 +1,26 @@
[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" }
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" }
Binary file not shown.
+7
View File
@@ -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
+251
View File
@@ -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" "$@"
+94
View File
@@ -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
+16
View File
@@ -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")
+105
View File
@@ -0,0 +1,105 @@
# MAG160C 官方 APP 逆向功能总表(2026-09-06
> 用 jadx 1.5.1 反编译 4 个 APKIDAMCP)分析 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 25Android 7.1 时代);专业版/普通版 MainActivity 锁定 landscape。
- USBVID 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=1ImageViewer.java
- PIP 可见光小窗(手机 Camera API 预览,可拖动/三档尺寸)ImageViewerVisible.java;无像素级融合
- 数码变倍 1/2/4/8/16x160C 仅 1/2/4 循环);双击=手动 FFCFling 切拍照/录像
- 录像 MP4FFmpeg avcodec-57 + libcxsdk doRecording640×4802048kbps/20fps
- 拍照 JPEG q100 640×480 含 OSD(十字/最值/水印/激光);仅 C3P 另存 .ddt 伴随文件
- 横竖屏 OrientationEventListener 实时切 native 旋转角;来电自动断 USB;退后台 500ms deinit
**USB/协议**UsbCommunication.java,端点 0x03/0x82/0x81/0x8428B 帧头尾协议同 csdk
- 连接握手:GetParameter1/2 → GetCaliInfo → 无本地标定则 GetCaliFile 下载(others\{产品}.{SN}.{日期}
- 启动:native startProcess(...) + StartTransferImgThreadImgRecv/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
**双设备模型**CXDeviceModelUSB coresdkPID1+ DeviceModel(网络/云/USB-Mx thermogroupPID2;命令口 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 上传队列、单张浏览、"设为参考图"、"分析"= 调起 ThermoScopeintent thermoscope://路径)、GPS 跳地图
**云平台**Retrofit RESTcloudapi.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 256Btype/最值/均值/位置/坐标/色/发射率/报警上下限/名字)→TXT(编码 GB18030/GBK/UTF-8)→AUD;段间 4 字节对齐;支持裸 DDT
- 媒体库:扫 DCIM/Magnity/*.jpg(内容实为 MDT),同目录打开模式,按文件名内嵌时间排序
- 单张分析(SingleMediaActivity + ScopeSurfaceView 软件渲染):
- 11 调色板切换(回写 DDT);参数滑杆:环境温度 -40~80℃ / 发射率 0~1 / 窗口透过率 0~1native 重算+回写)
- 点测温 Pt*(3x3 探针+发射率修正)、框测温 Rt*min/max/ave+minPos/maxPos)、垂直滑动切红外/可见光
- 双指缩放 1~4x、平移;max/min 追踪标记;色标条+6 刻度;保存 640×480 PNG 快照+水印
- 相机信息(SN/分辨率/版本/时间/海拔/经纬度);文字备注;语音段透传
- PDF 报告(PdfCreatortejpratapsingh PDFUtil):标题、7×4 基本信息、热像图、9 级色标、整体+ROI 分析表、结果/建议、测试员;输出 DCIM/Magnity/reports/ReportSettingActivity 15 项报告参数
- RelayActivity:外部打开 .mdt/.ddt/.jpg/pdf 分发
## 5. SDKcn/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_8888IDA 证实 GetOutputImage |0xFF000000 直写、行倒序)
- nativeCopyBitmap:格式/宽高/stride 校验后 qmemcpy
- libcxsdk.so(普通版图像核心)导出 C++ APICFunctions/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 → unlockCanvasAndPostSurfaceHolder 格式 -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
```
+89
View File
@@ -0,0 +1,89 @@
# MAG160C 安卓统一 APP — 会话状态与心跳锚点
> 本文件是本任务("整合 4 个官方 APP 重写为 Android 16 现代化 APP")的心跳锚点。
> 每完成一个里程碑更新勾选;会话中断先读本文件 + `reverse_apk_features.md`
> 再按"下一步"继续。约定:不弹询问窗,按计划自主推进,重大分歧点才停下来问。
## 任务目标
1. 逆向 C:\Project\MAG160C\app 4 个官方 APP(普通版/专业版/ThermoScope/demo)→ 已完成
2. 整合全部功能开发一款安卓 APP:适配 Android 16API 36)、现代 UI、横竖屏、
无流氓文件夹、干爽简洁 → **进行中**
3. 背景问题:专业版在用户 Android 16 手机上花屏(根因见 reverse_apk_features.md §6
## 已完成的里程碑
- [x] 工具链:jadx 1.5.1C:\Tools\jadx)、Ghidra 12.1.2C:\Tools)、IDA MCP 可用
- [x] 4 APK 解包 + jadx 全量反编译(%TEMP%\opencode\apkwork\jadx_*
- [x] 普通版/专业版/ThermoScope/demo 功能清单 → docs/android_app/reverse_apk_features.md
- [x] IDApro libcoresdk.so GetOutputImage/copyBitmap 位图格式证实(ARGB_8888
- [x] Ghidralibcxsdk.so 导出 C++ API 清单(CFunctions 族,与 ARM64 coresdk 同源)
- [x] 花屏根因分析(32 位专属库 + targetSdk25 legacy 渲染栈)
## 关键事实速查(开发时直接用)
- USBVID 0x833C PID 1EP 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.ddt1.8MBSN 160043865
- MDT 文件格式:reverse_apk_features.md §4152B Tail + 5 段布局 + ROI 256B 记录)
- 温度:毫度 intMDT 解码走 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 移植为 KotlinGradle 8.14.3 + SDK 已有组件
- [x] 阶段2:项目骨架(android/AGP 8.11.1 + Kotlin 2.2.0 + Compose BOM
compileSdk/targetSdk 36minSdk 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] 阶段2PC端参考工具 analysis/render_offline.cgcc 编译)+ 差分测试:
**Kotlin 输出与C参考逐字节一致**(60帧序列,首渲染帧32,NUC/LUT/灰度/输出全同)
- [x] 阶段2FrameStream 分块重组测试 + 温度换算单调性测试全过
## 移植陷阱记录(教训)
- C 无符号32位回绕:乘积/减法必须 u32() 掩码(cdf*denom、0xffc0000-iv7*0x40000、
(v-win_lo)*S 等)
- lutRebuild 的 u12 基址是常量 iv7*0x10+0x10**不是**链式 u21vu21v 只用于曲线钳制比较
- Kotlin ByteArray 存 255 = -1lut[bfmax] != 0xFF 判断必须 (b.toInt() and 0xFF)
- 暖机窗口内 FFC 状态机暂停(warm check 在 ffc_step 之前)——移植时保持顺序
## 下一步
阶段3USB层(UsbManager + Kotlin bulk 传输 + 命令协议)+ 实时画面 UI + 拍照/录像。
参考:analysis/protocol_spec.md;文件存储 → MediaStore(DCIM/MAG160C)。
## 开发计划(待确认后逐阶段执行)
- 阶段 1:环境(cmdline-tools/NDK r27+/gradle 8.x + 16KB 对齐配置 + AGP 8.x
- 阶段 2:新仓库 app-androidKotlin + Compose M3 + NDK 移植 csdk 渲染/温度 C 代码 + Kotlin USB 层)
- 阶段 3:实时画面(USB 流+官方管线+OSD+ROI+调色板+FFC+增强+PIP 可见光+拍照 MDT+MP4 录像)
- 阶段 4:媒体库(MediaStoreDCIM/MAG160C,无流氓文件夹)
- 阶段 5:MDT 离线分析(缩放/ROI/参数/PDF 报告/备注)
- 阶段 6:设置/任务巡检/可选云(Retrofit opt-in
- 阶段 7Android 16 专项适配验收(16KB 对齐/横竖屏/深色/预测性返回)
- 阶段 8:打包安装到用户手机实测
## 心跳约定
- 每完成一个阶段:更新本文件勾选 + git commit(不 push)。
- 中断恢复:读本文件 → 按"下一步"继续 → 环境检查命令:
```powershell
Test-Path C:\Users\ZXC\AppData\Local\Android\Sdk\ndk\<ver>
Get-Command java; Get-Command gradle
```