ESP32 API Request Fails Due to Missing Base64 Image in Payload

I'm working on an ESP32 project to send a Base64-encoded image to an API using HTTP POST requests. Although I can successfully encode the image and verify its length, the JSON payload being sent does not include the Base64 image data. The API response indicates an error, stating that "astica cognitive services is unable to process the request." I need help ensuring that the Base64 image is correctly attached to the payload before sending it. Any advice or guidance on how to troubleshoot this issue would be greatly appreciated!

The image is stored on the SD card, which is subsequently fetched in chunks, encoded in base64, and attached to a String. We do this until all the chunks of the image are read and encoded. Once that occurs, I attempt to attach the payload to a JSON string payload, which subsequently invokes Astica's API through a POST request. However, the payload does not seem to attach the encoded image and returns an error.

Here is the code for reference:

#include <WiFi.h>
#include <HTTPClient.h>
#include <SD.h>
#include <Base64.h>
#include <Arduino.h>
#include <SD_MMC.h>
#include <SPIFFS.h>
#include <FFat.h>

const char* ssid = "MY_SSID";           
const char* password = "MY_PASSWORD";   
const char* asticaAPIKey = "API_KEY";  
const char* asticaAPIEndpoint = "https://vision.astica.ai/describe";
const int chipSelect = 13;         

// Function to read the image and encode it in Base64
String getImageBase64Encoding(String imagePath) {
    File imageFile = SD.open(imagePath);
    if (!imageFile) {
        Serial.println("Failed to open file");
        return "";
    }

    const size_t CHUNK_SIZE = 256; // Read in smaller chunks
    uint8_t buffer[CHUNK_SIZE];    // Allocate on stack for efficiency
    String encodedImage = "";
    
    while (imageFile.available()) {
        size_t bytesRead = imageFile.read(buffer, CHUNK_SIZE);
        if (bytesRead > 0) {
            String chunk = base64::encode(buffer, bytesRead);
            encodedImage += chunk;
        }
        yield(); // Allow other tasks to run
    }
    
    imageFile.close();
    return encodedImage;
}

// Function to create the JSON payload
// Function to create the JSON payload
// Function to create the JSON payload
String createPayload(String base64Image) {
    String payload = "{";
    payload += "\"tkn\":\"" + String(asticaAPIKey) + "\",";
    payload += "\"modelVersion\":\"2.5_full\",";
    payload += "\"visionParams\":\"gpt,describe,objects,faces\",";
    payload += "\"input\":\"" + base64Image + "\",";
    payload += "\"gpt_prompt\":\"Describe the room and environment, objects, people, and actions in no more than two sentences\",";
    payload += "\"prompt_length\":30,";
    payload += "\"objects_custom_kw\":\"\"";
    payload += "}";

    // Print payload information
    Serial.println("Payload created. Length: " + String(payload.length()));
    return payload;
}



// Function to send an HTTP POST request to the API
void sendAsticaRequest(String payload) {
    if (WiFi.status() == WL_CONNECTED) {
        HTTPClient http;
        http.begin(asticaAPIEndpoint);
        http.addHeader("Content-Type", "application/json");

        // Send the POST request
        int httpResponseCode = http.POST(payload);

        // Check the response
        if (httpResponseCode > 0) {
            String response = http.getString();
            Serial.println("Response: " + response);
        } else {
            Serial.println("Error sending POST request: " + String(httpResponseCode));
        }

        http.end();
    } else {
        Serial.println("WiFi not connected");
    }
}

void setup() {
    Serial.begin(115200);
  
    // Connect to WiFi
    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) {
        delay(1000);
        Serial.println("Connecting to WiFi...");
    }
    Serial.println("Connected to WiFi");

    // Initialize the SD card
    if (!SD.begin(chipSelect)) {
        Serial.println("SD card initialization failed!");
        return;
    }
    Serial.println("SD card initialized.");
}

void loop() {
    // Path to the image on the SD card
    String imagePath = "/test.jpg"; 
  
    // Read and encode the image
    String base64Image = getImageBase64Encoding(imagePath);
    if (base64Image == "") {
        Serial.println("Failed to read image");
        return;
    }

    // Output the length of the base64 string
    Serial.println("Base64 image length: " + String(base64Image.length()));

    // Create payload with base64-encoded image
    String payload = createPayload(base64Image);
    Serial.println("Payload size: " + String(payload.length()));
    
    // Log the complete payload for debugging
    Serial.println("Full payload: " + payload);

    // Send the payload to the API
    sendAsticaRequest(payload);
    Serial.println("____________________________________");

    delay(6000);  // Wait for 6 seconds between requests
}

Any help will be appreciated.

It might help to post the error log in code tags, and at least a link to any non-arduino part specs.

Base64 encodes three bytes -- 24 bits -- into four bytes, 6 bits per byte; hence the name. If the payload is not an exact multiple of three, then one or two padding bytes, usually '=', can be added at the very end. The chunks read must be a multiple of three.

The size of the encoded payload can be calculated in advance. Don't forget to round up. Verify the results.

String allocation will cause less fragmentation and be more likely to work if you reserve the amount of space in advance. It returns bool; check if it actually worked.

A String has a hard-coded maximum length

#ifdef BOARD_HAS_PSRAM
        enum { CAPACITY_MAX = 3145728 }; 
#else
        enum { CAPACITY_MAX = 65535 }; 
#endif

If you exceed this size while concatenating, there is no direct indication of failure. It just doesn't work.

If the image might be too large to encode and then add to JSON, since you are hand-coding the JSON anyway, you can send

  • the part of the JSON before the Base64, up to the opening double-quote, then
  • the Base64, in its own chunks, under the max String length
  • the part of the JSON after the Base64, starting with the end-quote

Thank you for your response. Are you implying that sending the data in chunks will work?

Yes. First though: how big are the images?

When talking about "chunks", there's the notion of parts in the general sense. If you, as I call it, "perform HTTP manually", where you do println directly to the WiFiClient and hand-write the request-line and headers, as you see in many examples

    client.println("POST /something HTTP/1.1");
    client.println("Host: example.com");
    client.println("Content-Type: application/json");
    client.println("Content-Length: 86753");
    client.println();

Each statement happens in quick succession, and there's buffering in various places anyway, so it's hard for the server to detect that it's not done all at once. You can send the body in multiple statements as well, in a loop, etc.

Also note that when sending a payload body, the server may require a Content-Length to know when you are done. In your case, you're hand-writing JSON, so you can get the length of that; and you know the image file size and how big it will be after it is Base64-encoded; so add those together.

The other way is using actual HTTP Transfer-Encoding: chunked. This means that the body is sent in parts, preceded by the size of each part. And the body is completed by sending a part of "zero size", which actually takes five bytes. Here, you don't have to know or send the total length in advance.

HTTPClient knows how to read chunked server responses, but does not help to write them. But this seems to work, although not particularly memory-efficient

#include <WiFi.h>
#include <HTTPClient.h>
#include "arduino_secrets.h"

WiFiClient wifi;
HTTPClient http;

class HTTPChunks : public Stream {
  std::function<String()> chunker;
  String chunk;
  size_t index = 0;
  bool done = false;
  static String encode(String const &c) {
    String r(c.length(), HEX);
    r.reserve(c.length() + 8);  // max chunk length is 65527
    r += "\r\n";
    r += c;
    r += "\r\n";
    return r;
  }
public:
  HTTPChunks(std::function<String()> c)
    : chunker(c),
      chunk(encode(chunker())) {
  }
  int available() override {
    if (done) {
      return -1;
    }
    return chunk.length() - index;
  }
  int read() override {
    if (done) {
      return -1;
    }
    char ret = chunk.charAt(index++);
    if (index >= chunk.length()) {
      if (chunk == "0\r\n\r\n") {
        done = true;
      } else {
        chunk = encode(chunker());
        index = 0;
      }
    }
    return ret;
  }
  int peek() override {
    if (done) {
      return -1;
    }
    return chunk.charAt(index);
  }
  size_t write(uint8_t) override {
    return 0;
  }
};


void setup() {
  Serial.begin(115200);

  WiFi.begin(SECRET_SSID, SECRET_PASS);
  for (int i = 0; WiFi.status() != WL_CONNECTED; i++) {
    Serial.print(i % 10 ? "." : "\n.");
    delay(100);
  }
  Serial.println();
  Serial.println(WiFi.localIP());

  if (!http.begin("http://httpbin.org/anything")) {
    Serial.println("! begin");
    return;
  }
  http.addHeader("Content-Type", "application/json");
  http.addHeader("Transfer-Encoding", "chunked");

  int part = 0;
  HTTPChunks body([&part]() -> String {
    switch (part++) {
      case 0: return "{";
      case 1: return R""""(
  "one_is_the_loneliest_number":)"""";
      case 2: return String(millis());
      case 3: return "\n}";
    }
    return "";
  });

  int status = http.sendRequest("POST", &body, 0);  // zero means "don't know" the size
  Serial.println(status);
  if (status > 0) {
    Serial.println(http.getString());
  }
}

void loop() {}

This example sends to httpbin.org/anything, which will echo what was sent. It uses a lambda to return String chunks, using a captured-by-reference-variable to keep track of the current part. You can adapt this to read and Base64-encode parts of the image file, inside the hand-coded JSON.

I think before I attempted "manual" or "HTTP" chunking, I'd try to build the entire POST payload in PSRAM as a c-string (not a String object) and then use this overload of the post() function from the HTTPClient library:

int POST(uint8_t *payload, size_t size);

Yes, that's an important question. If it's a few hundred KB up to say 1 or 2 MB, you can use PSRAM. If it's tens of MB, then I'd use PC software to resample it to a reasonable size. Here's part of a code I used to Base64 encode an image file (from LittleFS) into a PSRAM buffer. It does "chunk" in the sense that only reads in a small part of the file on every pass so the entire raw image doesn't need to be all in memory at once. But, it does hold the entire Base64 result in PSRAM:

#include "Arduino.h"
#include "FS.h"
#include <LittleFS.h>
#include "libb64/cencode.h"

void setup() {
  const size_t maxChunckSize = 100UL * 3;
  size_t curentChunkSize;
  size_t numBase64Bytes;
  char chunkBuffer[maxChunckSize];
  base64_encodestate base64State;
  base64_init_encodestate(&base64State);

  Serial.begin(115200);
  delay(4000);
  Serial.println("Starting");

  if (!psramFound()) {
    log_e("- psram not found");
    return;
  }

  if (!LittleFS.begin(true)) {
    log_e("- LittleFS Mount Failed");
    return;
  }

  File file = LittleFS.open("/photo.jpg");
  if (!file || file.isDirectory()) {
    log_e("- failed to open file for reading");
    return;
  } else {
    log_i("- file opened");
  }

  size_t bytesToConvert = file.size();
  size_t base64Size = base64_encode_expected_len(bytesToConvert) + 1;
  log_i("bytesToConvert = %d, base64Size = %d", bytesToConvert, base64Size);

  char *base64Buffer = reinterpret_cast<char*>(ps_malloc(base64Size));
  char *currentBase64BufferPosition = base64Buffer;
  if (base64Buffer == nullptr) {
    log_e("- failed to allocate base64Buffer");
    return;
  } else {
    log_i("- base64Buffer allocated");
  }

  while (bytesToConvert > 0) {
    if (bytesToConvert >= maxChunckSize) {
      curentChunkSize = maxChunckSize;
    } else {
      curentChunkSize = bytesToConvert;
    }
    file.readBytes(chunkBuffer, curentChunkSize);
    numBase64Bytes = base64_encode_block(chunkBuffer, curentChunkSize, currentBase64BufferPosition, &base64State);
    currentBase64BufferPosition += numBase64Bytes;
    bytesToConvert -= curentChunkSize;
  }

  base64_encode_blockend(currentBase64BufferPosition, &base64State);
  file.close();

  log_i("Base64 encode result:\n%s", base64Buffer);
  free(base64Buffer);
}

void loop() {
}