From e1e0ac89295dac23130428c63ea17023b780e70a Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 24 Feb 2026 18:51:53 +0800 Subject: [PATCH] =?UTF-8?q?jpg=20=E6=89=93=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + README.md | 26 +- dependencies.lock | 21 ++ main/control_plane/src/rest_server.c | 135 ++++------- main/control_plane/src/rest_server_common.c | 25 +- main/control_plane/src/rest_server_print.c | 164 +++++++++++-- main/domain/include/raster_tools.h | 13 + main/domain/src/raster_tools_image_qr.c | 250 +++++++++++++++++++- main/idf_component.yml | 17 ++ sdkconfig.defaults | 9 + 10 files changed, 535 insertions(+), 127 deletions(-) create mode 100644 dependencies.lock create mode 100644 main/idf_component.yml diff --git a/.gitignore b/.gitignore index 87199a6..776aeec 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ sdkconfig* .DS_Store __pycache__/ .pytest_cache/ + +managed_components diff --git a/README.md b/README.md index fa8d7d7..d2e6067 100644 --- a/README.md +++ b/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:///v1/print/raster \ }' ``` -### Image print (gray8 auto-scale to 384) +### Image print (direct JPG upload, no base64) ```bash -curl -X POST http:///v1/print/image \ - -H 'Content-Type: application/json' \ - -d '{ - "width":800, - "height":600, - "encoding":"base64_gray8", - "data":"", - "scale_to_width":true, - "threshold":160, - "density":"中等" - }' +curl -X POST 'http:///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:///v1/print/qr \ @@ -256,6 +252,8 @@ curl -X POST http:///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). diff --git a/dependencies.lock b/dependencies.lock new file mode 100644 index 0000000..e2d7a5c --- /dev/null +++ b/dependencies.lock @@ -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 diff --git a/main/control_plane/src/rest_server.c b/main/control_plane/src/rest_server.c index bdd92f0..b3f94c1 100644 --- a/main/control_plane/src/rest_server.c +++ b/main/control_plane/src/rest_server.c @@ -64,7 +64,7 @@ static const char *s_web_index = " \n" "\n" " \n" - " \n" + " \n" "
\n" " \n" " \n" @@ -86,7 +86,7 @@ static const char *s_web_index = " \n" " \n" "
\n" - "
Image bytes are uploaded as gray8; ESP32-S3 handles threshold/scale/raster processing.
\n" + "
Direct JPG upload: browser sends binary JPEG; ESP32-S3 decodes/scales/thresholds then prints.
\n" "\n" "
Tip: connect printer first, then submit print jobs.
\n" "
Ready.
\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" " \n" diff --git a/main/control_plane/src/rest_server_common.c b/main/control_plane/src/rest_server_common.c index cbc34ba..b538df1 100644 --- a/main/control_plane/src/rest_server_common.c +++ b/main/control_plane/src/rest_server_common.c @@ -6,7 +6,26 @@ #include #include +#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; } diff --git a/main/control_plane/src/rest_server_print.c b/main/control_plane/src/rest_server_print.c index f860d4c..538c866 100644 --- a/main/control_plane/src/rest_server_print.c +++ b/main/control_plane/src/rest_server_print.c @@ -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, diff --git a/main/domain/include/raster_tools.h b/main/domain/include/raster_tools.h index 93347e5..740c336 100644 --- a/main/domain/include/raster_tools.h +++ b/main/domain/include/raster_tools.h @@ -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, diff --git a/main/domain/src/raster_tools_image_qr.c b/main/domain/src/raster_tools_image_qr.c index a209645..0472f39 100644 --- a/main/domain/src/raster_tools_image_qr.c +++ b/main/domain/src/raster_tools_image_qr.c @@ -5,10 +5,14 @@ #include #include +#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); diff --git a/main/idf_component.yml b/main/idf_component.yml new file mode 100644 index 0000000..80a6b89 --- /dev/null +++ b/main/idf_component.yml @@ -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 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 6fb9896..1719d96 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -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