jpg 打印
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ sdkconfig*
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
managed_components
|
||||
|
||||
26
README.md
26
README.md
@@ -29,7 +29,7 @@ ESP32-S3 works as a Wi-Fi REST controller and replaces Android App logic:
|
||||
- `GET /v1/printer/status`
|
||||
- Print:
|
||||
- `POST /v1/print/raster`
|
||||
- `POST /v1/print/image` (`base64_msb_1bpp` or `base64_gray8`)
|
||||
- `POST /v1/print/image` (direct JPEG binary upload, no base64)
|
||||
- `POST /v1/print/qr`
|
||||
- `POST /v1/print/text`
|
||||
- `POST /v1/print/receipt`
|
||||
@@ -101,7 +101,7 @@ The page provides:
|
||||
- health/status check
|
||||
- connect/disconnect printer
|
||||
- submit a simple text print job
|
||||
- upload an image (gray8 payload) and let ESP32-S3 handle threshold/scale/raster conversion
|
||||
- upload a JPG image (binary payload) and let ESP32-S3 decode/threshold/scale/raster conversion
|
||||
- view jobs list
|
||||
|
||||
If API key is enabled, input it in the page before invoking actions.
|
||||
@@ -141,21 +141,17 @@ curl -X POST http://<esp-ip>/v1/print/raster \
|
||||
}'
|
||||
```
|
||||
|
||||
### Image print (gray8 auto-scale to 384)
|
||||
### Image print (direct JPG upload, no base64)
|
||||
```bash
|
||||
curl -X POST http://<esp-ip>/v1/print/image \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"width":800,
|
||||
"height":600,
|
||||
"encoding":"base64_gray8",
|
||||
"data":"<BASE64_GRAY_BYTES>",
|
||||
"scale_to_width":true,
|
||||
"threshold":160,
|
||||
"density":"中等"
|
||||
}'
|
||||
curl -X POST 'http://<esp-ip>/v1/print/image?scale_to_width=1&threshold=160&invert=0&max_height=2200&density=medium' \
|
||||
-H 'Content-Type: image/jpeg' \
|
||||
--data-binary @./sample.jpg
|
||||
```
|
||||
|
||||
Request constraints:
|
||||
- Request body limit: 4 MB
|
||||
- Suggested image size: <= 3 MB when using built-in Web UI uploader
|
||||
|
||||
### QR print
|
||||
```bash
|
||||
curl -X POST http://<esp-ip>/v1/print/qr \
|
||||
@@ -256,6 +252,8 @@ curl -X POST http://<esp-ip>/v1/ota/upgrade \
|
||||
|
||||
- BLE side is central/client role, not printer peripheral role.
|
||||
- `base64_msb_1bpp` uses Android-compatible bit order (MSB first).
|
||||
- `/v1/print/image` accepts raw `image/jpeg` bytes directly (no base64 wrapper).
|
||||
- Large buffers for image upload/decode prefer PSRAM first, then fallback to internal RAM.
|
||||
- `/v1/print/text` and `/v1/print/receipt` now support UTF-8 Chinese via embedded 16x16 GB2312 glyphs.
|
||||
- Characters outside embedded glyph set are rendered as square fallback boxes.
|
||||
- QR encoding uses embedded `qrcodegen` (Project Nayuki C implementation).
|
||||
|
||||
21
dependencies.lock
Normal file
21
dependencies.lock
Normal file
@@ -0,0 +1,21 @@
|
||||
dependencies:
|
||||
espressif/esp_jpeg:
|
||||
component_hash: defb83669293cbf86d0fa86b475ba5517aceed04ed70db435388c151ab37b5d7
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.3.1
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
version: 5.5.2
|
||||
direct_dependencies:
|
||||
- espressif/esp_jpeg
|
||||
- idf
|
||||
manifest_hash: 819afd83349fcea09da843d8b32ebcda85f77da70a3c32f07fb7fb08134db918
|
||||
target: esp32s3
|
||||
version: 2.0.0
|
||||
@@ -64,7 +64,7 @@ static const char *s_web_index =
|
||||
" </div>\n"
|
||||
"\n"
|
||||
" <label>Image Upload</label>\n"
|
||||
" <input id='imageFile' type='file' accept='image/*'>\n"
|
||||
" <input id='imageFile' type='file' accept='.jpg,.jpeg,image/jpeg'>\n"
|
||||
" <div class='row'>\n"
|
||||
" <input id='imgThreshold' type='number' min='0' max='255' value='160' placeholder='Threshold 0-255' style='max-width:160px'>\n"
|
||||
" <input id='imgMaxHeight' type='number' min='64' max='3000' value='2200' placeholder='Max height' style='max-width:160px'>\n"
|
||||
@@ -86,7 +86,7 @@ static const char *s_web_index =
|
||||
" </label>\n"
|
||||
" <button id='btnPrintImage'>Print Image</button>\n"
|
||||
" </div>\n"
|
||||
" <div class='small'>Image bytes are uploaded as gray8; ESP32-S3 handles threshold/scale/raster processing.</div>\n"
|
||||
" <div class='small'>Direct JPG upload: browser sends binary JPEG; ESP32-S3 decodes/scales/thresholds then prints.</div>\n"
|
||||
"\n"
|
||||
" <div class='small'>Tip: connect printer first, then submit print jobs.</div>\n"
|
||||
" <pre id='out'>Ready.</pre>\n"
|
||||
@@ -128,92 +128,41 @@ static const char *s_web_index =
|
||||
" return n;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function uint8ToBase64(data) {\n"
|
||||
" let out = '';\n"
|
||||
" const chunkSize = 0x8000;\n"
|
||||
" for (let i = 0; i < data.length; i += chunkSize) {\n"
|
||||
" const chunk = data.subarray(i, i + chunkSize);\n"
|
||||
" out += String.fromCharCode.apply(null, chunk);\n"
|
||||
" }\n"
|
||||
" return btoa(out);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function loadImage(file) {\n"
|
||||
" if (window.createImageBitmap) {\n"
|
||||
" return window.createImageBitmap(file);\n"
|
||||
" }\n"
|
||||
" return new Promise((resolve, reject) => {\n"
|
||||
" const url = URL.createObjectURL(file);\n"
|
||||
" const img = new Image();\n"
|
||||
" img.onload = () => {\n"
|
||||
" URL.revokeObjectURL(url);\n"
|
||||
" resolve(img);\n"
|
||||
" };\n"
|
||||
" img.onerror = () => {\n"
|
||||
" URL.revokeObjectURL(url);\n"
|
||||
" reject(new Error('image decode failed'));\n"
|
||||
" };\n"
|
||||
" img.src = url;\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" async function buildImagePayload(file) {\n"
|
||||
" function buildImageUploadRequest(file) {\n"
|
||||
" if (!file) {\n"
|
||||
" throw new Error('please select an image first');\n"
|
||||
" throw new Error('please select a jpg image first');\n"
|
||||
" }\n"
|
||||
" const name = (file.name || '').toLowerCase();\n"
|
||||
" const type = (file.type || '').toLowerCase();\n"
|
||||
" const isJpeg = type.includes('jpeg') || name.endsWith('.jpg') || name.endsWith('.jpeg');\n"
|
||||
" if (!isJpeg) {\n"
|
||||
" throw new Error('only .jpg/.jpeg is supported in direct upload mode');\n"
|
||||
" }\n"
|
||||
" if (file.size <= 0) {\n"
|
||||
" throw new Error('empty image file');\n"
|
||||
" }\n"
|
||||
" if (file.size > 3 * 1024 * 1024) {\n"
|
||||
" throw new Error('jpg too large (>3MB), backend limit is 4MB');\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" const src = await loadImage(file);\n"
|
||||
" const srcW = src.width || src.naturalWidth || 0;\n"
|
||||
" const srcH = src.height || src.naturalHeight || 0;\n"
|
||||
" if (srcW <= 0 || srcH <= 0) {\n"
|
||||
" throw new Error('invalid image size');\n"
|
||||
" }\n"
|
||||
" const densityMap = {\n"
|
||||
" '较淡': 'light',\n"
|
||||
" '中等': 'medium',\n"
|
||||
" '较浓': 'dark',\n"
|
||||
" '最深': 'max'\n"
|
||||
" };\n"
|
||||
"\n"
|
||||
" // Keep request body under 1MB limit in read_body().\n"
|
||||
" const maxRawBytes = 680000;\n"
|
||||
" let outW = srcW;\n"
|
||||
" let outH = srcH;\n"
|
||||
" const pixels = srcW * srcH;\n"
|
||||
" if (pixels > maxRawBytes) {\n"
|
||||
" const scale = Math.sqrt(maxRawBytes / pixels);\n"
|
||||
" outW = Math.max(1, Math.floor(srcW * scale));\n"
|
||||
" outH = Math.max(1, Math.floor(srcH * scale));\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" const canvas = document.createElement('canvas');\n"
|
||||
" canvas.width = outW;\n"
|
||||
" canvas.height = outH;\n"
|
||||
" const ctx = canvas.getContext('2d', { willReadFrequently: true });\n"
|
||||
" ctx.drawImage(src, 0, 0, outW, outH);\n"
|
||||
" if (typeof src.close === 'function') {\n"
|
||||
" src.close();\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" const rgba = ctx.getImageData(0, 0, outW, outH).data;\n"
|
||||
" const gray = new Uint8Array(outW * outH);\n"
|
||||
" for (let i = 0, j = 0; i < rgba.length; i += 4, ++j) {\n"
|
||||
" const r = rgba[i];\n"
|
||||
" const g = rgba[i + 1];\n"
|
||||
" const b = rgba[i + 2];\n"
|
||||
" gray[j] = (77 * r + 150 * g + 29 * b) >> 8;\n"
|
||||
" }\n"
|
||||
" const q = new URLSearchParams();\n"
|
||||
" q.set('scale_to_width', imgScaleToWidthEl.checked ? '1' : '0');\n"
|
||||
" q.set('threshold', String(toInt(imgThresholdEl.value, 160, 0, 255)));\n"
|
||||
" q.set('invert', imgInvertEl.checked ? '1' : '0');\n"
|
||||
" q.set('max_height', String(toInt(imgMaxHeightEl.value, 2200, 64, 3000)));\n"
|
||||
" q.set('density', densityMap[imgDensityEl.value] || 'medium');\n"
|
||||
"\n"
|
||||
" return {\n"
|
||||
" payload: {\n"
|
||||
" width: outW,\n"
|
||||
" height: outH,\n"
|
||||
" encoding: 'base64_gray8',\n"
|
||||
" data: uint8ToBase64(gray),\n"
|
||||
" scale_to_width: !!imgScaleToWidthEl.checked,\n"
|
||||
" threshold: toInt(imgThresholdEl.value, 160, 0, 255),\n"
|
||||
" invert: !!imgInvertEl.checked,\n"
|
||||
" max_height: toInt(imgMaxHeightEl.value, 2200, 64, 3000),\n"
|
||||
" density: imgDensityEl.value || '中等'\n"
|
||||
" },\n"
|
||||
" srcW,\n"
|
||||
" srcH,\n"
|
||||
" outW,\n"
|
||||
" outH\n"
|
||||
" path: '/v1/print/image?' + q.toString(),\n"
|
||||
" body: file,\n"
|
||||
" contentType: file.type || 'image/jpeg'\n"
|
||||
" };\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
@@ -230,6 +179,19 @@ static const char *s_web_index =
|
||||
" return data;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" async function callBinaryApi(path, body, contentType) {\n"
|
||||
" const h = headers(false);\n"
|
||||
" h['Content-Type'] = contentType || 'application/octet-stream';\n"
|
||||
" const r = await fetch(path, { method: 'POST', headers: h, body });\n"
|
||||
" const text = await r.text();\n"
|
||||
" let data = text;\n"
|
||||
" try { data = JSON.parse(text); } catch (_) {}\n"
|
||||
" if (!r.ok) {\n"
|
||||
" throw { status: r.status, data };\n"
|
||||
" }\n"
|
||||
" return data;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" async function run(fn) {\n"
|
||||
" try {\n"
|
||||
" const data = await fn();\n"
|
||||
@@ -265,12 +227,9 @@ static const char *s_web_index =
|
||||
"\n"
|
||||
" document.getElementById('btnPrintImage').onclick = () => run(async () => {\n"
|
||||
" const file = imageFileEl.files && imageFileEl.files[0] ? imageFileEl.files[0] : null;\n"
|
||||
" const converted = await buildImagePayload(file);\n"
|
||||
" const result = await callApi('/v1/print/image', 'POST', converted.payload);\n"
|
||||
" if (converted.srcW !== converted.outW || converted.srcH !== converted.outH) {\n"
|
||||
" result.client_note = 'image resized before upload: ' +\n"
|
||||
" converted.srcW + 'x' + converted.srcH + ' -> ' + converted.outW + 'x' + converted.outH;\n"
|
||||
" }\n"
|
||||
" const req = buildImageUploadRequest(file);\n"
|
||||
" const result = await callBinaryApi(req.path, req.body, req.contentType);\n"
|
||||
" result.client_note = 'uploaded jpg bytes: ' + file.size;\n"
|
||||
" return result;\n"
|
||||
" });\n"
|
||||
" </script>\n"
|
||||
|
||||
@@ -6,7 +6,26 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "mbedtls/base64.h"
|
||||
|
||||
#define REST_SERVER_MAX_BODY_BYTES (4 * 1024 * 1024)
|
||||
|
||||
static void *alloc_prefer_psram(size_t size) {
|
||||
void *ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (ptr == NULL) {
|
||||
ptr = malloc(size);
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
|
||||
static void *calloc_prefer_psram(size_t n, size_t size) {
|
||||
void *ptr = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (ptr == NULL) {
|
||||
ptr = calloc(n, size);
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
bool rest_server_auth_ok(httpd_req_t *req) {
|
||||
if (strlen(CONFIG_LYF_API_KEY) == 0) {
|
||||
return true;
|
||||
@@ -49,11 +68,11 @@ esp_err_t rest_server_read_body(httpd_req_t *req, char **out_body) {
|
||||
|
||||
*out_body = NULL;
|
||||
|
||||
if (req->content_len <= 0 || req->content_len > (1024 * 1024)) {
|
||||
if (req->content_len <= 0 || req->content_len > REST_SERVER_MAX_BODY_BYTES) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
char *buf = (char *)calloc(1, req->content_len + 1);
|
||||
char *buf = (char *)calloc_prefer_psram(1, (size_t)req->content_len + 1);
|
||||
if (buf == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
@@ -111,7 +130,7 @@ esp_err_t rest_server_base64_decode_alloc(const char *b64, uint8_t **out_raw, si
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
uint8_t *buf = (uint8_t *)malloc(decoded_len);
|
||||
uint8_t *buf = (uint8_t *)alloc_prefer_psram(decoded_len);
|
||||
if (buf == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,117 @@ static esp_err_t submit_raster_job_and_reply(httpd_req_t *req,
|
||||
return err;
|
||||
}
|
||||
|
||||
static bool parse_bool_text(const char *text, bool *out_value) {
|
||||
if (text == NULL || out_value == NULL) {
|
||||
return false;
|
||||
}
|
||||
if (strcmp(text, "1") == 0 || strcasecmp(text, "true") == 0 || strcasecmp(text, "yes") == 0) {
|
||||
*out_value = true;
|
||||
return true;
|
||||
}
|
||||
if (strcmp(text, "0") == 0 || strcasecmp(text, "false") == 0 || strcasecmp(text, "no") == 0) {
|
||||
*out_value = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static const char *map_density_text(const char *text) {
|
||||
if (text == NULL || text[0] == '\0') {
|
||||
return "中等";
|
||||
}
|
||||
if (strcasecmp(text, "light") == 0 || strcmp(text, "较淡") == 0) {
|
||||
return "较淡";
|
||||
}
|
||||
if (strcasecmp(text, "medium") == 0 || strcmp(text, "中等") == 0) {
|
||||
return "中等";
|
||||
}
|
||||
if (strcasecmp(text, "dark") == 0 || strcmp(text, "较浓") == 0) {
|
||||
return "较浓";
|
||||
}
|
||||
if (strcasecmp(text, "max") == 0 || strcmp(text, "最深") == 0) {
|
||||
return "最深";
|
||||
}
|
||||
return "中等";
|
||||
}
|
||||
|
||||
static void parse_image_upload_options(httpd_req_t *req,
|
||||
bool *out_scale_to_width,
|
||||
uint8_t *out_threshold,
|
||||
bool *out_invert,
|
||||
uint16_t *out_max_height,
|
||||
const char **out_density) {
|
||||
if (out_scale_to_width != NULL) {
|
||||
*out_scale_to_width = true;
|
||||
}
|
||||
if (out_threshold != NULL) {
|
||||
*out_threshold = 160;
|
||||
}
|
||||
if (out_invert != NULL) {
|
||||
*out_invert = false;
|
||||
}
|
||||
if (out_max_height != NULL) {
|
||||
*out_max_height = 2200;
|
||||
}
|
||||
if (out_density != NULL) {
|
||||
*out_density = "中等";
|
||||
}
|
||||
|
||||
if (req == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
int qlen = httpd_req_get_url_query_len(req);
|
||||
if (qlen <= 0 || qlen > 512) {
|
||||
return;
|
||||
}
|
||||
|
||||
char *query = (char *)calloc(1, (size_t)qlen + 1);
|
||||
if (query == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (httpd_req_get_url_query_str(req, query, (size_t)qlen + 1) != ESP_OK) {
|
||||
free(query);
|
||||
return;
|
||||
}
|
||||
|
||||
char value[48] = {0};
|
||||
if (httpd_query_key_value(query, "scale_to_width", value, sizeof(value)) == ESP_OK && out_scale_to_width != NULL) {
|
||||
bool parsed = *out_scale_to_width;
|
||||
if (parse_bool_text(value, &parsed)) {
|
||||
*out_scale_to_width = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (httpd_query_key_value(query, "invert", value, sizeof(value)) == ESP_OK && out_invert != NULL) {
|
||||
bool parsed = *out_invert;
|
||||
if (parse_bool_text(value, &parsed)) {
|
||||
*out_invert = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (httpd_query_key_value(query, "threshold", value, sizeof(value)) == ESP_OK && out_threshold != NULL) {
|
||||
long v = strtol(value, NULL, 10);
|
||||
if (v >= 0 && v <= 255) {
|
||||
*out_threshold = (uint8_t)v;
|
||||
}
|
||||
}
|
||||
|
||||
if (httpd_query_key_value(query, "max_height", value, sizeof(value)) == ESP_OK && out_max_height != NULL) {
|
||||
long v = strtol(value, NULL, 10);
|
||||
if (v >= 64 && v <= 3000) {
|
||||
*out_max_height = (uint16_t)v;
|
||||
}
|
||||
}
|
||||
|
||||
if (httpd_query_key_value(query, "density", value, sizeof(value)) == ESP_OK && out_density != NULL) {
|
||||
*out_density = map_density_text(value);
|
||||
}
|
||||
|
||||
free(query);
|
||||
}
|
||||
|
||||
static esp_err_t decode_image_json_to_raster(cJSON *json,
|
||||
uint16_t *out_width,
|
||||
uint16_t *out_height,
|
||||
@@ -235,40 +346,53 @@ esp_err_t rest_server_print_image_post(httpd_req_t *req) {
|
||||
return rest_server_send_error(req, "401 Unauthorized", "unauthorized");
|
||||
}
|
||||
|
||||
char content_type[96] = {0};
|
||||
if (httpd_req_get_hdr_value_str(req, "Content-Type", content_type, sizeof(content_type)) == ESP_OK) {
|
||||
if (strstr(content_type, "application/json") != NULL) {
|
||||
return rest_server_send_error(req, "400 Bad Request", "use binary jpeg body, not json");
|
||||
}
|
||||
}
|
||||
|
||||
bool scale_to_width = true;
|
||||
uint8_t threshold = 160;
|
||||
bool invert = false;
|
||||
uint16_t max_height = 2200;
|
||||
const char *density = "中等";
|
||||
parse_image_upload_options(req,
|
||||
&scale_to_width,
|
||||
&threshold,
|
||||
&invert,
|
||||
&max_height,
|
||||
&density);
|
||||
|
||||
char *body = NULL;
|
||||
esp_err_t body_err = rest_server_read_body(req, &body);
|
||||
if (body_err != ESP_OK) {
|
||||
return rest_server_send_error(req, "400 Bad Request", "invalid request body");
|
||||
}
|
||||
|
||||
cJSON *json = cJSON_Parse(body);
|
||||
free(body);
|
||||
if (json == NULL) {
|
||||
return rest_server_send_error(req, "400 Bad Request", "invalid json");
|
||||
}
|
||||
|
||||
cJSON *jdensity = cJSON_GetObjectItemCaseSensitive(json, "density");
|
||||
const char *density = (cJSON_IsString(jdensity) && jdensity->valuestring != NULL)
|
||||
? jdensity->valuestring
|
||||
: "中等";
|
||||
|
||||
uint16_t width = 0;
|
||||
uint16_t height = 0;
|
||||
uint8_t *raster = NULL;
|
||||
size_t raster_len = 0;
|
||||
char decode_err[128] = {0};
|
||||
esp_err_t decode_rc = decode_image_json_to_raster(json,
|
||||
&width,
|
||||
&height,
|
||||
&raster,
|
||||
&raster_len,
|
||||
decode_err,
|
||||
sizeof(decode_err));
|
||||
cJSON_Delete(json);
|
||||
esp_err_t decode_rc = raster_tools_convert_jpeg_to_raster_384((const uint8_t *)body,
|
||||
(size_t)req->content_len,
|
||||
scale_to_width,
|
||||
threshold,
|
||||
invert,
|
||||
max_height,
|
||||
&width,
|
||||
&height,
|
||||
&raster,
|
||||
&raster_len,
|
||||
decode_err,
|
||||
sizeof(decode_err));
|
||||
free(body);
|
||||
if (decode_rc != ESP_OK) {
|
||||
return rest_server_send_error(req,
|
||||
"400 Bad Request",
|
||||
decode_err[0] != '\0' ? decode_err : "decode image failed");
|
||||
decode_err[0] != '\0' ? decode_err : "decode jpeg failed");
|
||||
}
|
||||
|
||||
esp_err_t submit_rc = submit_raster_job_and_reply(req,
|
||||
|
||||
@@ -31,6 +31,19 @@ esp_err_t raster_tools_convert_gray8_to_raster_384(const uint8_t *gray,
|
||||
char *err,
|
||||
size_t err_len);
|
||||
|
||||
esp_err_t raster_tools_convert_jpeg_to_raster_384(const uint8_t *jpeg,
|
||||
size_t jpeg_len,
|
||||
bool scale_to_width,
|
||||
uint8_t threshold,
|
||||
bool invert,
|
||||
uint16_t max_height,
|
||||
uint16_t *out_width,
|
||||
uint16_t *out_height,
|
||||
uint8_t **out_raster,
|
||||
size_t *out_len,
|
||||
char *err,
|
||||
size_t err_len);
|
||||
|
||||
esp_err_t raster_tools_render_qr_384(const char *text,
|
||||
uint8_t module_scale,
|
||||
uint8_t margin_modules,
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "jpeg_decoder.h"
|
||||
#include "qrcodegen.h"
|
||||
|
||||
#define RASTER_WIDTH 384
|
||||
#define RASTER_BYTES_PER_ROW (RASTER_WIDTH / 8)
|
||||
#define JPEG_DECODE_MAX_BYTES (2 * 1024 * 1024)
|
||||
#define JPEG_DECODE_MAX_WIDTH 1600
|
||||
|
||||
static void set_black(uint8_t *buf, uint16_t width, uint16_t x, uint16_t y) {
|
||||
if (x >= width) {
|
||||
@@ -20,6 +24,63 @@ static void set_black(uint8_t *buf, uint16_t width, uint16_t x, uint16_t y) {
|
||||
buf[idx] |= (uint8_t)(1u << bit);
|
||||
}
|
||||
|
||||
static void *alloc_prefer_psram(size_t size) {
|
||||
void *ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (ptr == NULL) {
|
||||
ptr = malloc(size);
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
|
||||
static void *calloc_prefer_psram(size_t n, size_t size) {
|
||||
void *ptr = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (ptr == NULL) {
|
||||
ptr = calloc(n, size);
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
|
||||
static uint32_t jpeg_scale_div(esp_jpeg_image_scale_t scale) {
|
||||
switch (scale) {
|
||||
case JPEG_IMAGE_SCALE_1_2:
|
||||
return 2;
|
||||
case JPEG_IMAGE_SCALE_1_4:
|
||||
return 4;
|
||||
case JPEG_IMAGE_SCALE_1_8:
|
||||
return 8;
|
||||
case JPEG_IMAGE_SCALE_0:
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static esp_jpeg_image_scale_t choose_jpeg_scale(uint16_t src_width,
|
||||
uint16_t src_height,
|
||||
bool scale_to_width) {
|
||||
if (!scale_to_width) {
|
||||
return JPEG_IMAGE_SCALE_0;
|
||||
}
|
||||
|
||||
const esp_jpeg_image_scale_t scales[] = {
|
||||
JPEG_IMAGE_SCALE_0,
|
||||
JPEG_IMAGE_SCALE_1_2,
|
||||
JPEG_IMAGE_SCALE_1_4,
|
||||
JPEG_IMAGE_SCALE_1_8
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(scales) / sizeof(scales[0]); ++i) {
|
||||
uint32_t div = jpeg_scale_div(scales[i]);
|
||||
uint32_t w = (uint32_t)src_width / div;
|
||||
uint32_t h = (uint32_t)src_height / div;
|
||||
size_t bytes = (size_t)w * h * 2u; /* RGB565 */
|
||||
if (w > 0 && h > 0 && w <= JPEG_DECODE_MAX_WIDTH && bytes <= JPEG_DECODE_MAX_BYTES) {
|
||||
return scales[i];
|
||||
}
|
||||
}
|
||||
|
||||
return JPEG_IMAGE_SCALE_1_8;
|
||||
}
|
||||
|
||||
esp_err_t raster_tools_convert_gray8_to_raster_384(const uint8_t *gray,
|
||||
uint16_t src_width,
|
||||
uint16_t src_height,
|
||||
@@ -77,7 +138,7 @@ esp_err_t raster_tools_convert_gray8_to_raster_384(const uint8_t *gray,
|
||||
}
|
||||
|
||||
size_t len = (size_t)RASTER_BYTES_PER_ROW * dst_height;
|
||||
uint8_t *raster = (uint8_t *)calloc(1, len);
|
||||
uint8_t *raster = (uint8_t *)calloc_prefer_psram(1, len);
|
||||
if (raster == NULL) {
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "no memory");
|
||||
@@ -116,6 +177,191 @@ esp_err_t raster_tools_convert_gray8_to_raster_384(const uint8_t *gray,
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t raster_tools_convert_jpeg_to_raster_384(const uint8_t *jpeg,
|
||||
size_t jpeg_len,
|
||||
bool scale_to_width,
|
||||
uint8_t threshold,
|
||||
bool invert,
|
||||
uint16_t max_height,
|
||||
uint16_t *out_width,
|
||||
uint16_t *out_height,
|
||||
uint8_t **out_raster,
|
||||
size_t *out_len,
|
||||
char *err,
|
||||
size_t err_len) {
|
||||
if (jpeg == NULL || jpeg_len == 0 ||
|
||||
out_width == NULL || out_height == NULL || out_raster == NULL || out_len == NULL) {
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "invalid args");
|
||||
}
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
*out_width = 0;
|
||||
*out_height = 0;
|
||||
*out_raster = NULL;
|
||||
*out_len = 0;
|
||||
|
||||
if (jpeg_len > UINT32_MAX) {
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "jpeg too large");
|
||||
}
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
if (max_height == 0 || max_height > 3000) {
|
||||
max_height = 3000;
|
||||
}
|
||||
|
||||
esp_jpeg_image_cfg_t info_cfg = {
|
||||
.indata = (uint8_t *)jpeg,
|
||||
.indata_size = (uint32_t)jpeg_len,
|
||||
.out_format = JPEG_IMAGE_FORMAT_RGB565,
|
||||
.out_scale = JPEG_IMAGE_SCALE_0,
|
||||
};
|
||||
esp_jpeg_image_output_t info = {0};
|
||||
esp_err_t info_rc = esp_jpeg_get_image_info(&info_cfg, &info);
|
||||
if (info_rc != ESP_OK || info.width == 0 || info.height == 0) {
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "invalid jpeg");
|
||||
}
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_jpeg_image_scale_t decode_scale = choose_jpeg_scale(info.width, info.height, scale_to_width);
|
||||
uint32_t scale_div = jpeg_scale_div(decode_scale);
|
||||
uint32_t dec_w = (uint32_t)info.width / scale_div;
|
||||
uint32_t dec_h = (uint32_t)info.height / scale_div;
|
||||
size_t decoded_pixels = (size_t)dec_w * dec_h;
|
||||
if (decoded_pixels == 0 || decoded_pixels > (SIZE_MAX / 2u)) {
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "jpeg size overflow");
|
||||
}
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
size_t rgb565_len = decoded_pixels * 2u;
|
||||
if (rgb565_len > UINT32_MAX) {
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "jpeg output too large");
|
||||
}
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
uint8_t *rgb565 = (uint8_t *)alloc_prefer_psram(rgb565_len);
|
||||
if (rgb565 == NULL) {
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "no memory");
|
||||
}
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_jpeg_image_cfg_t decode_cfg = {
|
||||
.indata = (uint8_t *)jpeg,
|
||||
.indata_size = (uint32_t)jpeg_len,
|
||||
.outbuf = rgb565,
|
||||
.outbuf_size = (uint32_t)rgb565_len,
|
||||
.out_format = JPEG_IMAGE_FORMAT_RGB565,
|
||||
.out_scale = decode_scale,
|
||||
};
|
||||
esp_jpeg_image_output_t outimg = {0};
|
||||
esp_err_t decode_rc = esp_jpeg_decode(&decode_cfg, &outimg);
|
||||
if (decode_rc != ESP_OK || outimg.width == 0 || outimg.height == 0) {
|
||||
free(rgb565);
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "jpeg decode failed");
|
||||
}
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
size_t expected_rgb565_len = (size_t)outimg.width * outimg.height * 2u;
|
||||
if (outimg.output_len < expected_rgb565_len || expected_rgb565_len > rgb565_len) {
|
||||
free(rgb565);
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "jpeg decode size mismatch");
|
||||
}
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
uint16_t src_width = outimg.width;
|
||||
uint16_t src_height = outimg.height;
|
||||
uint16_t dst_width = RASTER_WIDTH;
|
||||
uint16_t dst_height = src_height;
|
||||
if (scale_to_width) {
|
||||
uint32_t scaled = ((uint32_t)src_height * RASTER_WIDTH + (src_width / 2u)) / src_width;
|
||||
if (scaled == 0) {
|
||||
scaled = 1;
|
||||
}
|
||||
if (scaled > max_height) {
|
||||
scaled = max_height;
|
||||
}
|
||||
dst_height = (uint16_t)scaled;
|
||||
} else if (src_width != RASTER_WIDTH) {
|
||||
free(rgb565);
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "width must be 384 when scale_to_width=false");
|
||||
}
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
if (dst_height == 0 || dst_height > max_height) {
|
||||
free(rgb565);
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "invalid output height");
|
||||
}
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
size_t raster_len = (size_t)RASTER_BYTES_PER_ROW * dst_height;
|
||||
uint8_t *raster = (uint8_t *)calloc_prefer_psram(1, raster_len);
|
||||
if (raster == NULL) {
|
||||
free(rgb565);
|
||||
if (err != NULL && err_len > 0) {
|
||||
snprintf(err, err_len, "no memory");
|
||||
}
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
const uint16_t *src565 = (const uint16_t *)rgb565;
|
||||
for (uint16_t y = 0; y < dst_height; ++y) {
|
||||
uint16_t src_y = scale_to_width
|
||||
? (uint16_t)(((uint32_t)y * src_height) / dst_height)
|
||||
: y;
|
||||
if (src_y >= src_height) {
|
||||
src_y = (uint16_t)(src_height - 1);
|
||||
}
|
||||
|
||||
for (uint16_t x = 0; x < RASTER_WIDTH; ++x) {
|
||||
uint16_t src_x = scale_to_width
|
||||
? (uint16_t)(((uint32_t)x * src_width) / RASTER_WIDTH)
|
||||
: x;
|
||||
if (src_x >= src_width) {
|
||||
src_x = (uint16_t)(src_width - 1);
|
||||
}
|
||||
|
||||
uint16_t p = src565[(size_t)src_y * src_width + src_x];
|
||||
uint8_t r5 = (uint8_t)((p >> 11) & 0x1Fu);
|
||||
uint8_t g6 = (uint8_t)((p >> 5) & 0x3Fu);
|
||||
uint8_t b5 = (uint8_t)(p & 0x1Fu);
|
||||
uint8_t r8 = (uint8_t)((r5 * 527u + 23u) >> 6);
|
||||
uint8_t g8 = (uint8_t)((g6 * 259u + 33u) >> 6);
|
||||
uint8_t b8 = (uint8_t)((b5 * 527u + 23u) >> 6);
|
||||
uint8_t v = (uint8_t)((77u * r8 + 150u * g8 + 29u * b8) >> 8);
|
||||
bool black = invert ? (v > threshold) : (v < threshold);
|
||||
if (black) {
|
||||
set_black(raster, RASTER_WIDTH, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
free(rgb565);
|
||||
|
||||
*out_width = dst_width;
|
||||
*out_height = dst_height;
|
||||
*out_raster = raster;
|
||||
*out_len = raster_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static enum qrcodegen_Ecc map_qr_ecc(uint8_t ecc_level) {
|
||||
switch (ecc_level) {
|
||||
case 0:
|
||||
@@ -208,7 +454,7 @@ esp_err_t raster_tools_render_qr_384(const char *text,
|
||||
|
||||
uint16_t out_h = (uint16_t)image_size;
|
||||
size_t len = (size_t)RASTER_BYTES_PER_ROW * out_h;
|
||||
uint8_t *raster = (uint8_t *)calloc(1, len);
|
||||
uint8_t *raster = (uint8_t *)calloc_prefer_psram(1, len);
|
||||
if (raster == NULL) {
|
||||
free(temp);
|
||||
free(qr);
|
||||
|
||||
17
main/idf_component.yml
Normal file
17
main/idf_component.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
## IDF Component Manager Manifest File
|
||||
dependencies:
|
||||
## Required IDF version
|
||||
idf:
|
||||
version: '>=4.1.0'
|
||||
# # Put list of dependencies here
|
||||
# # For components maintained by Espressif:
|
||||
# component: "~1.0.0"
|
||||
# # For 3rd party components:
|
||||
# username/component: ">=1.0.0,<2.0.0"
|
||||
# username2/component2:
|
||||
# version: "~1.0.0"
|
||||
# # For transient dependencies `public` flag can be set.
|
||||
# # `public` flag doesn't have an effect dependencies of the `main` component.
|
||||
# # All dependencies of `main` are public by default.
|
||||
# public: true
|
||||
espressif/esp_jpeg: ^1.3.1
|
||||
@@ -11,3 +11,12 @@ CONFIG_LYF_WIFI_PASSWORD="TalkingQ123"
|
||||
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="16MB"
|
||||
|
||||
# ESP32-S3-WROOM-1-N16R8: 16MB Quad Flash + 8MB Octal PSRAM
|
||||
CONFIG_SPIRAM=y
|
||||
CONFIG_SPIRAM_MODE_OCT=y
|
||||
CONFIG_SPIRAM_TYPE_AUTO=y
|
||||
CONFIG_SPIRAM_SPEED_80M=y
|
||||
CONFIG_SPIRAM_BOOT_INIT=y
|
||||
CONFIG_SPIRAM_USE_MALLOC=y
|
||||
CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y
|
||||
|
||||
Reference in New Issue
Block a user