feat(ui-lvgl): hiking trails overlay on the map

Waymarked Trails' transparent hiking layer, kept in /sdcard/maps-trails and
blended over the base tile at render time (Map tools > Hiking trails, NVS).
An area download fetches each tile's trails after its base tile, so
re-running an area on the card adds just the trails; with live tiles on, a
shown tile missing its trails has them fetched. Tiles without a trail are
stored as 0-byte files (no cluster on FAT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-27 09:32:00 +02:00
co-authored by Claude Opus 5.5
parent 8b231935ba
commit ad890a49f6
10 changed files with 188 additions and 32 deletions
+9 -1
View File
@@ -241,7 +241,15 @@ Ideas to come back to (2026-09-26):
Live map tiles: 16 / 64 / 256 MB / 1 GB, default 64 MB; Delete).
Live tiles saved by earlier builds sit in /sdcard/maps and can't be
told apart from downloaded ones.
- [ ] Vector maps and other tile sources (to think through).
- [x] Hiking trails: Waymarked Trails overlay in /sdcard/maps-trails, drawn
over the base map (Map tools > Hiking trails); fetched with an area
download (re-running one adds just the trails) and live; empty tiles
as 0-byte files.
- [ ] Area manager: named list of downloaded areas (regenerate, delete),
a draggable frame to pick an area instead of the visible screen.
- [ ] A grid (scale) under the map where there are no tiles.
- [ ] Vector maps: spike first (own compact format from OSM, roads / trails
/ water / forest, no labels; measure render time).
User list, second batch (2026-09-26):
@@ -397,6 +397,7 @@ void UITask::buildHomeMap(lv_obj_t* box) {
lv_obj_set_style_pad_ver(s_caption, 2, 0);
lv_obj_align(s_caption, LV_ALIGN_BOTTOM_LEFT, 6, -6);
mapview::s_available = lvport::mountStorage() && mapview::s_provider->available();
mapview::s_trails_on = lvport::trailsOn();
lv_obj_update_layout(s_area);
homeMapFit();
homeMapLayout();
@@ -185,6 +185,9 @@ static void setWifiAllowed(bool on) { s_wifi_allowed = on; nvs::putBool("mc_wifi
// Map tools > Live tiles: missing tiles fetched over WiFi while the map is open.
static bool liveTiles() { return nvs::getBool("mc_wifi", "live", true); }
static void setLiveTiles(bool on) { nvs::putBool("mc_wifi", "live", on); }
// Map tools > Hiking trails: the Waymarked Trails overlay over the map.
static bool trailsOn() { return nvs::getBool("mc_ui", "trails", false); }
static void setTrailsOn(bool on) { nvs::putBool("mc_ui", "trails", on); }
// Screen-lock PIN (Settings > Display & power > Screen PIN): digits, "" = none.
static void loadPin(char* out, size_t n) { nvs::getStr("mc_lock", "pin", out, n); }
@@ -516,6 +519,9 @@ static void setWifiAllowed(bool on) { s_wifi_on = on; }
static bool s_live_tiles = true;
static bool liveTiles() { return s_live_tiles; }
static void setLiveTiles(bool on) { s_live_tiles = on; }
static bool s_trails = false;
static bool trailsOn() { return s_trails; }
static void setTrailsOn(bool on) { s_trails = on; }
static bool s_net_on = false;
static void netBegin(const char*, const char*) { s_net_on = true; }
static int netState() { return NET_UP; }
+24 -4
View File
@@ -28,6 +28,14 @@ static TileCache& s_cache = *new (psramBuf<TileCache>(1)) TileCache();
static TileDownloader s_dl("/sdcard/maps");
static const uint32_t AVG_TILE_BYTES = 22 * 1024; // OpenTopoMap-ish, for the size estimate
// Decode a tile into the cache; with live tiles on, a base tile shown without
// its trails overlay has the overlay fetched.
static TileCache::Slot* loadTile(int z, int x, int y) {
TileCache::Slot* s = s_cache.load(*s_provider, z, x, y);
if (s_trails_on && s_provider == &s_raster && s_raster.overlayMissed()) s_dl.liveRequest(z, x, y, true);
return s;
}
static const int MIN_Z = 3, MAX_Z = 18;
static const int GRID_COLS = 3, GRID_ROWS = 2; // covers 320x218 at any offset
@@ -110,7 +118,7 @@ static bool prefetchOne(double left, double top, int w, int h, int z) {
}
}
if (best >= 1e18) return false;
s_cache.load(*s_provider, z, bx, by);
loadTile(z, bx, by);
return true;
}
@@ -198,6 +206,7 @@ void UITask::openMap(bool nav) {
void UITask::showMap() {
_screen = SCR_MAP;
mapview::s_trails_on = lvport::trailsOn();
mapLiveBegin();
mapview::s_available = lvport::mountStorage() && mapview::s_provider->available();
if (_map_z == 0) { // first open: own position, else the node's own advert position, else Poland
@@ -436,7 +445,7 @@ void UITask::mapLoop() {
}
}
if (best_d < 1e18) {
mapview::s_cache.load(*mapview::s_provider, _map_z, best_x, best_y);
mapview::loadTile(_map_z, best_x, best_y);
} else { // every tile at this zoom looked up: decode a coarser one for a missing tile
best_d = 1e18;
int bz = -1;
@@ -455,7 +464,7 @@ void UITask::mapLoop() {
if (d < best_d) { best_d = d; bz = wz; best_x = ax; best_y = ay; }
}
}
if (bz >= 0) mapview::s_cache.load(*mapview::s_provider, bz, best_x, best_y);
if (bz >= 0) mapview::loadTile(bz, best_x, best_y);
}
layoutMap();
}
@@ -797,6 +806,17 @@ void UITask::mapLiveBegin() {
mapview::s_dl.liveBegin(ssid, pass);
}
// Map tools > Hiking trails: the overlay on / off, every decoded tile redrawn.
void UITask::setTrails(bool on) {
lvport::setTrailsOn(on);
mapview::s_trails_on = on;
mapview::s_cache.invalidate();
if (_screen == SCR_MAP) layoutMap();
if (!on) showToast("Hiking trails off");
else if (lvport::liveTiles() && lvport::wifiAllowed()) showToast("Trails load with the map over WiFi", 3000);
else showToast("Download an area again to add its trails", 3000);
}
void UITask::setLiveTiles(bool on) {
lvport::setLiveTiles(on);
if (on) {
@@ -820,7 +840,7 @@ void UITask::mapDownloadTick() {
if (dl.liveOn()) {
int z, x, y;
bool got = false;
while (dl.liveTake(z, x, y)) { mapview::s_cache.forgetMissing(z, x, y); got = true; }
while (dl.liveTake(z, x, y)) { mapview::s_cache.drop(z, x, y); got = true; } // a new tile, or its trails
if (got && _screen == SCR_MAP) layoutMap();
// The WiFi goes 30 s after the map was left or the screen went off (a
// quick look elsewhere keeps it).
@@ -873,6 +873,9 @@ static void scanTrails() {
static void onSavedTrail(lv_event_t* e) { s_ui->savedTrailPopup((int)(intptr_t)lv_event_get_user_data(e)); }
static void onNavTools(lv_event_t* e) { (void)e; s_ui->navToolsPopup(); }
static void onTrails(lv_event_t* e) {
s_ui->setTrails(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
static void onLiveTiles(lv_event_t* e) {
s_ui->setLiveTiles(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
@@ -967,6 +970,9 @@ void UITask::navToolsPopup() {
lv_obj_t* lsw = switchRow(g, "Live tiles", "Load missing tiles over WiFi", nullptr);
if (lvport::liveTiles()) lv_obj_add_state(lsw, LV_STATE_CHECKED);
lv_obj_add_event_cb(lsw, onLiveTiles, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* tsw = switchRow(g, "Hiking trails", "Marked routes in their colours", nullptr);
if (lvport::trailsOn()) lv_obj_add_state(tsw, LV_STATE_CHECKED);
lv_obj_add_event_cb(tsw, onTrails, LV_EVENT_VALUE_CHANGED, NULL);
listRow(g, "Download this area", "For use offline", onNavTool, (void*)(uintptr_t)navmap::TL_DOWNLOAD);
listRow(g, "Map options", "Trail, live sharing, arrival alert", onNavTool, (void*)(uintptr_t)navmap::TL_OPTIONS);
@@ -71,7 +71,7 @@ static void startWalk() {
// What a top-level folder counts as.
static uint8_t topCat(const char* name) {
if (!strcmp(name, "maps")) return C_MAPS;
if (!strcmp(name, "maps") || !strcmp(name, "maps-trails")) return C_MAPS; // trails: the maps' overlay
if (!strcmp(name, "maps-live")) return C_LIVE;
if (!strcmp(name, "meshcore")) return C_MSGS;
if (!strcmp(name, "trails")) return C_TRAILS;
+2 -1
View File
@@ -73,7 +73,8 @@ public:
void mapDownloadResume();
void mapDownloadDiscard();
void mapDownloadZmax(int delta);
void setLiveTiles(bool on); // Map tools > Live tiles
void setLiveTiles(bool on);
void setTrails(bool on); // Map tools > Live tiles
// Navigation map (NavMap.h)
void navTargetsPopup();
void navClosePopup();
@@ -78,6 +78,12 @@ public:
for (Missing& m : _missing) if (m.z == z && m.x == x && m.y == y) m.z = -1;
}
// Forget one tile, decoded or missing (it changed on the card: redrawn next time).
void drop(int z, int x, int y) {
for (Slot& s : _slots) if (s.z == z && s.x == x && s.y == y) { s.z = -1; s.used = 0; }
forgetMissing(z, x, y);
}
// Forget everything (e.g. the card was swapped); keeps the buffers.
void invalidate() { for (Slot& s : _slots) { s.z = -1; s.used = 0; } forgetMissing(); }
@@ -17,6 +17,11 @@
// default 19 -- or 17 for OpenTopoMap); without it, OpenTopoMap. Whatever the source,
// its attribution is written to <root>/attribution.txt for the map to show.
//
// Hiking trails (s_trails_on): each tile's trails overlay is fetched right
// after its base tile, into TRAILS_ROOT (TileProvider.h) -- empty ones as
// 0-byte files, so re-running an area already on the card adds just the
// trails.
//
// Live tiles: while the map is open, single tiles it is missing are fetched
// the same way (one at a time, between the job's steps when there is no job),
// the WiFi connected on the first miss and dropped by the caller (liveEnd)
@@ -113,12 +118,19 @@ public:
_area = a;
if (_area.zmax > _src_zmax) _area.zmax = _src_zmax; // the server has nothing finer
if (_area.zmin > _area.zmax) { fail("The server has no finer zoom: zoom out"); return false; }
_trails = s_trails_on;
_layer = 0;
_total = countTiles(_area);
if (_trails && _area.zmin <= TRAILS_MAX_Z) {
TileArea t = _area;
if (t.zmax > TRAILS_MAX_Z) t.zmax = TRAILS_MAX_Z;
_total += countTiles(t);
}
_done = _skipped = _failed = _consec_fail = 0;
_retried = false;
_placeholder_hash = 0; _placeholder_hits = 0;
_msg[0] = '\0';
if (_total == 0 || _total > MAX_TILES) { fail("Area too large: use the PC tool"); return false; }
if (_total == 0 || _total > (_trails ? 2 : 1) * MAX_TILES) { fail("Area too large: use the PC tool"); return false; }
writeAttribution();
writeJob(_area);
_z = _area.zmin;
@@ -157,14 +169,17 @@ public:
bool liveConnecting() const { return _live && _lv_state == LV_CONNECTING; }
int liveQueued() const { return _live ? _lv_n + (_lv_fetching ? 1 : 0) : 0; }
// A tile the map is missing (duplicates, recent failures and a full queue ignored).
void liveRequest(int z, int x, int y) {
if (!_live || active() || z > _src_zmax) return; // past the server's zoom: the map magnifies
if (_lv_fetching && _lv_cur.z == z && _lv_cur.x == x && _lv_cur.y == y) return;
for (int i = 0; i < _lv_n; i++) if (_lv_q[i].z == z && _lv_q[i].x == x && _lv_q[i].y == y) return;
for (const LiveTile& f : _lv_failed) if (f.z == z && f.x == x && f.y == y) return;
// `trails`: the tile's trails overlay (its base is on the card already).
void liveRequest(int z, int x, int y, bool trails = false) {
if (!_live || active() || z > (trails ? TRAILS_MAX_Z : _src_zmax)) return; // past the server's zoom: the map magnifies
uint8_t layer = trails ? 1 : 0;
auto same = [&](const LiveTile& t) { return t.z == z && t.x == x && t.y == y && t.layer == layer; };
if (_lv_fetching && same(_lv_cur)) return;
for (int i = 0; i < _lv_n; i++) if (same(_lv_q[i])) return;
for (const LiveTile& f : _lv_failed) if (same(f)) return;
if (_lv_n >= LV_QUEUE) return;
LiveTile& t = _lv_q[_lv_n++];
t.z = (int16_t)z; t.x = x; t.y = y;
t.z = (int16_t)z; t.x = x; t.y = y; t.layer = layer;
}
// Next tile written since the last call (the map forgets it was missing).
bool liveTake(int& z, int& x, int& y) {
@@ -199,6 +214,10 @@ public:
size_t len = 0;
const uint8_t* data = lvport::fetchData(len);
if (!looksLikeImage(data, len)) onFailure("Server didn't send a PNG tile");
else if (_layer == 1) { // trails: the overlay, or an empty marker
if (!writeTrails(_z, _x, _y, data, len)) { lvport::fetchRelease(); finish(FAILED, "Can't write to the SD card"); return; }
_done++; _consec_fail = 0;
}
else if (isNoTilePicture(data, len)) { _skipped++; _consec_fail = 0; } // "no tile here": nothing to keep
else if (isPlaceholder(data, len)) { lvport::fetchRelease(); finish(FAILED, "Server sends a placeholder (blocked / key?)"); return; }
else if (!writeTile(_z, _x, _y, data, len)) { lvport::fetchRelease(); finish(FAILED, "Can't write to the SD card"); return; }
@@ -224,12 +243,12 @@ public:
for (int i = 0; i < 16 && _state == RUNNING; i++) {
if (_z > _area.zmax) { finish(DONE, "Done"); return; }
char path[96];
tilePath(path, sizeof(path), _z, _x, _y);
tilePath(path, sizeof(path), _z, _x, _y, _layer);
struct stat st;
if (stat(path, &st) == 0 && st.st_size > 0) { _skipped++; advance(); continue; }
if (stat(path, &st) == 0 && (st.st_size > 0 || _layer == 1)) { _skipped++; advance(); continue; } // an empty trails file counts
if ((int32_t)(millis() - _last_start) < 120) return; // be gentle with the server (a retry waits longer)
char url[200];
buildUrl(url, sizeof(url), _z, _x, _y);
buildUrl(url, sizeof(url), _z, _x, _y, _layer);
int r = lvport::fetchStart(url);
if (r > 0) { _fetching = true; _last_start = millis(); }
else if (r < 0) finish(FAILED, "Out of memory (download task)");
@@ -245,6 +264,8 @@ private:
uint8_t _consec_fail = 0;
int _z = 0, _x = 0, _y = 0, _x0 = 0, _y0 = 0, _x1 = 0, _y1 = 0;
bool _fetching = false;
bool _trails = false; // this job fetches the trails overlay too
uint8_t _layer = 0; // of the current tile: 0 the base map, 1 its trails
bool _retried = false; // the current tile already failed once
uint32_t _last_start = 0, _connect_started = 0;
uint32_t _placeholder_hash = 0;
@@ -258,7 +279,7 @@ private:
bool _has_job = false, _job_checked = false;
// Live tiles
struct LiveTile { int16_t z = -1; int32_t x = 0, y = 0; };
struct LiveTile { int16_t z = -1; uint8_t layer = 0; int32_t x = 0, y = 0; }; // layer: 0 base, 1 trails
enum LiveState : uint8_t { LV_IDLE, LV_CONNECTING, LV_UP, LV_BACKOFF };
static const int LV_QUEUE = 8, LV_FAILED = 16, LV_DONE = 8;
bool _live = false;
@@ -320,7 +341,14 @@ private:
const uint8_t* data = r > 0 ? lvport::fetchData(len) : nullptr;
char lpath[64];
LiveCache::tilePath(lpath, sizeof(lpath), _lv_cur.z, _lv_cur.x, _lv_cur.y);
if (r > 0 && looksLikeImage(data, len) && !isNoTilePicture(data, len) && writeFile(lpath, data, len)) {
if (_lv_cur.layer == 1) { // trails over a tile already shown
if (r > 0 && looksLikeImage(data, len) && writeTrails(_lv_cur.z, _lv_cur.x, _lv_cur.y, data, len)) {
_lv_consec_fail = 0;
if (_lv_done_n < LV_DONE) _lv_done[_lv_done_n++] = _lv_cur;
} else {
liveFailed(_lv_cur);
}
} else if (r > 0 && looksLikeImage(data, len) && !isNoTilePicture(data, len) && writeFile(lpath, data, len)) {
s_live_cache.add(_lv_cur.z, _lv_cur.x, _lv_cur.y, (uint32_t)len); // kept apart, within the Storage limit
_lv_consec_fail = 0;
if (_lv_done_n < LV_DONE) _lv_done[_lv_done_n++] = _lv_cur;
@@ -335,7 +363,7 @@ private:
for (int i = 1; i < _lv_n; i++) _lv_q[i - 1] = _lv_q[i];
_lv_n--;
char url[200];
buildUrl(url, sizeof(url), _lv_cur.z, _lv_cur.x, _lv_cur.y);
buildUrl(url, sizeof(url), _lv_cur.z, _lv_cur.x, _lv_cur.y, _lv_cur.layer);
if (lvport::fetchStart(url) > 0) { _lv_fetching = true; _last_start = millis(); }
else _lv_q[_lv_n++] = _lv_cur; // fetcher busy: back in the queue
}
@@ -401,6 +429,8 @@ private:
void advance() {
_retried = false;
if (_layer == 0 && _trails && _z <= TRAILS_MAX_Z) { _layer = 1; return; } // this tile's trails next
_layer = 0;
if (++_y <= _y1) return;
_y = _y0;
if (++_x <= _x1) return;
@@ -409,13 +439,13 @@ private:
_x = _x0; _y = _y0;
}
void tilePath(char* out, size_t n, int z, int x, int y) const {
snprintf(out, n, "%s/%d/%d/%d.png", _root, z, x, y);
void tilePath(char* out, size_t n, int z, int x, int y, uint8_t layer = 0) const {
snprintf(out, n, "%s/%d/%d/%d.png", layer ? TRAILS_ROOT : _root, z, x, y);
}
void buildUrl(char* out, size_t n, int z, int x, int y) const {
void buildUrl(char* out, size_t n, int z, int x, int y, uint8_t layer = 0) const {
size_t o = 0;
for (const char* p = _url_tpl; *p && o + 12 < n; p++) {
for (const char* p = layer ? TRAILS_URL : _url_tpl; *p && o + 12 < n; p++) {
if (p[0] == '{' && p[1] && p[2] == '}') {
int v = p[1] == 'z' ? z : p[1] == 'x' ? x : p[1] == 'y' ? y : -1;
if (v >= 0) { o += snprintf(out + o, n - o, "%d", v); p += 2; continue; }
@@ -479,6 +509,13 @@ private:
return false;
}
// A trails tile: as fetched when something is drawn on it, else 0 bytes.
bool writeTrails(int z, int x, int y, const uint8_t* d, size_t n) {
char path[96];
tilePath(path, sizeof(path), z, x, y, 1);
return pngHasInk(d, n) ? writeFile(path, d, n) : writeFile(path, d, 0);
}
bool writeTile(int z, int x, int y, const uint8_t* d, size_t n) {
char path[96];
tilePath(path, sizeof(path), z, x, y);
@@ -29,9 +29,42 @@ static bool isNoTilePicture(const uint8_t* d, size_t n) {
return h == OTM_NOTILE_FNV;
}
static const int TILE_PX = 256;
// Hiking trails: Waymarked Trails' transparent overlay (marked routes in their
// waymark colours), kept apart from the base map under TRAILS_ROOT in the same
// {z}/{x}/{y}.png layout, drawn over whatever base tile is shown while
// s_trails_on. A 0-byte file there means "fetched, no trail on this tile" --
// most tiles, and an empty file takes no cluster on a FAT card.
static const char* const TRAILS_ROOT = "/sdcard/maps-trails";
static const char* const TRAILS_URL = "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png";
static const char* const TRAILS_ATTR = "Trails \xC2\xA9 waymarkedtrails.org (CC-BY-SA)";
static const int TRAILS_MAX_Z = 18;
static bool s_trails_on = false; // Map tools > Hiking trails (NVS), set when the map opens
// PNG -> RGBA (LVGL's lodepng: the result is an lv_draw_buf_t*, destroy it).
static lv_draw_buf_t* decodePng(const uint8_t* png, size_t len) {
unsigned char* res = nullptr;
unsigned w = 0, h = 0;
unsigned err = lodepng_decode32(&res, &w, &h, png, len);
lv_draw_buf_t* db = (lv_draw_buf_t*)res;
if (err || !db || w != TILE_PX || h != TILE_PX) { if (db) lv_draw_buf_destroy(db); return nullptr; }
return db;
}
// An overlay tile with anything drawn on it (not fully transparent).
static bool pngHasInk(const uint8_t* png, size_t len) {
lv_draw_buf_t* db = decodePng(png, len);
if (!db) return false;
bool ink = false;
for (int row = 0; row < TILE_PX && !ink; row++) {
const uint8_t* p = db->data + row * db->header.stride + 3;
for (int i = 0; i < TILE_PX; i++, p += 4) if (*p) { ink = true; break; }
}
lv_draw_buf_destroy(db);
return ink;
}
class TileProvider {
public:
virtual ~TileProvider() {}
@@ -69,19 +102,16 @@ public:
}
bool renderTile(int z, int x, int y, uint16_t* out) override {
_overlay_missed = false;
uint32_t len = 0;
uint8_t* png = readLoose(_root, z, x, y, len);
if (!png) png = readPacked(z, x, y, len);
if (!png && _live) png = readLoose(_live, z, x, y, len);
if (!png) return false;
unsigned char* res = nullptr;
unsigned w = 0, h = 0;
unsigned err = lodepng_decode32(&res, &w, &h, png, len);
lv_draw_buf_t* db = decodePng(png, len);
lv_free(png);
lv_draw_buf_t* db = (lv_draw_buf_t*)res;
if (err || !db) { if (db) lv_draw_buf_destroy(db); return false; }
if (w != TILE_PX || h != TILE_PX) { lv_draw_buf_destroy(db); return false; }
if (!db) return false;
for (int row = 0; row < TILE_PX; row++) {
const uint8_t* p = db->data + row * db->header.stride; // R, G, B, A
@@ -90,15 +120,56 @@ public:
o[i] = (uint16_t)(((p[0] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[2] >> 3));
}
lv_draw_buf_destroy(db);
if (s_trails_on && z <= TRAILS_MAX_Z) drawOverlay(z, x, y, out);
return true;
}
const char* attribution() const override { return _attr[0] ? _attr : "\xC2\xA9 OpenStreetMap contributors"; }
// The last renderTile() found no trails file for its tile (not fetched yet).
bool overlayMissed() const { return _overlay_missed; }
const char* attribution() const override {
const char* base = _attr[0] ? _attr : "\xC2\xA9 OpenStreetMap contributors";
if (!s_trails_on) return base;
snprintf(_attr_full, sizeof(_attr_full), "%s | %s", base, TRAILS_ATTR);
return _attr_full;
}
private:
const char* _root;
const char* _live;
char _attr[96] = "";
mutable char _attr_full[160] = "";
bool _overlay_missed = false;
// The trails tile over `out`, blended by its alpha.
void drawOverlay(int z, int x, int y, uint16_t* out) {
char path[64];
snprintf(path, sizeof(path), "%s/%d/%d/%d.png", TRAILS_ROOT, z, x, y);
FILE* f = fopen(path, "rb");
if (!f) { _overlay_missed = true; return; }
fseek(f, 0, SEEK_END);
long sz = ftell(f);
uint8_t* png = sz > 0 ? readRange(f, 0, (uint32_t)sz) : nullptr; // 0 bytes: no trail here
fclose(f);
if (!png) return;
lv_draw_buf_t* db = decodePng(png, (size_t)sz);
lv_free(png);
if (!db) return;
for (int row = 0; row < TILE_PX; row++) {
const uint8_t* p = db->data + row * db->header.stride;
uint16_t* o = out + row * TILE_PX;
for (int i = 0; i < TILE_PX; i++, p += 4) {
unsigned a = p[3];
if (!a) continue;
unsigned r = (o[i] >> 8) & 0xF8, g = (o[i] >> 3) & 0xFC, b = (o[i] << 3) & 0xF8;
r = (p[0] * a + r * (255 - a)) / 255;
g = (p[1] * a + g * (255 - a)) / 255;
b = (p[2] * a + b * (255 - a)) / 255;
o[i] = (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3));
}
}
lv_draw_buf_destroy(db);
}
static uint8_t* readRange(FILE* f, long off, uint32_t len) {
if (len == 0 || len > 512 * 1024) return nullptr;