Site Logo MySite

PTL - Eindelijk beeld uit de ArduCam

In de vorige artikelen zat ik nog in de fase van voedingen, oude hardware en vooral veel aannames. Inmiddels is de ESP32 binnen en kon ik eindelijk doen waar dit project eigenlijk om draait: de keten end-to-end testen. Dus niet alleen kijken of de camera reageert, maar echt een foto maken, die data uit de ArduCam trekken en vervolgens naar de backend uploaden.

Dat klinkt als een overzichtelijk stappenplan. Camera initialiseren, foto maken, uploaden, klaar. In de praktijk bleek daar toch weer wat meer avontuur in te zitten.

Waarom ik hier PlatformIO gebruik

Voor dit project werk ik bewust met PlatformIO en niet met de klassieke Arduino IDE-workflow. Niet omdat Arduino IDE slecht is, maar omdat ik hier vanaf het begin modulair wilde werken: camera, wifi, config en HTTP elk in een eigen bestand, met build-instellingen op een centrale plek.

Een (gestripte) versie van de setup ziet er zo uit:

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
    ArduCAM
build_flags =
    -D PTL_UPLOAD_PATH="/api/upload"
    -D PTL_CONFIG_PATH="/api/config"

Daardoor blijft de firmwarecode zelf schoner en heb ik minder neiging om alles in een grote monolithische sketch te duwen. Voor een hobbyproject dat stiekem steeds serieuzer wordt, is dat best prettig.

Van “de camera leeft” naar “ik heb een echte JPEG”

De eerste winst was er vrij snel. De ESP32 kreeg netjes contact met de ArduCam via SPI en de OV5642 sensor was ook via I2C te herkennen. Dat zijn van die momenten waarop je denkt: mooi, nu nog even de rest. Dat “nog even” bleek natuurlijk weer iets optimistischer dan nodig.

De camera wilde namelijk best een capture doen, maar wat er vervolgens uit de FIFO kwam was niet meteen een bruikbare foto. Een van de signalen dat er iets mis zat, was een FIFO-lengte van ongeveer 8 bytes. Dat is, zacht uitgedrukt, wat weinig voor een JPEG.

In de backend zag ik dat ook terug. De metadata kwam wel binnen, maar met zulke kleine fifo_len-waardes weet je al dat daar nooit een echte foto uit gaat komen. Er werd dus wel iets vastgelegd, maar bepaald geen plaatje waar je een time-lapse van gaat bouwen.

De eerste echte hobbel

Een belangrijke aanpassing zat uiteindelijk in de initialisatie van de camera. Na de normale InitCAM()-stap bleek het nodig om nog een extra register voor de timing te zetten. Zonder die stap kreeg ik captures die formeel “klaar” waren, maar inhoudelijk vooral op rommel neerkwamen.

Dit is de kern van die fix:

myCAM.set_format(JPEG);
myCAM.InitCAM();
myCAM.write_reg(ARDUCHIP_TIM, VSYNC_LEVEL_MASK);
myCAM.OV5642_set_JPEG_size(OV5642_320x240);

Het is maar een paar regels, maar precies dit soort kleine hardware-specifieke details bepaalt of je een echte foto krijgt of alleen debugfrustratie.

Dat soort bugs zijn altijd leuk. Alles lijkt te werken: geen crash, geen harde fout, capture done staat netjes aan. Alleen heb je vervolgens nog steeds geen foto.

Pas nadat die extra timing-instelling goed stond, begon de FIFO-lengte zich te gedragen als iets waar je een echte JPEG van mag verwachten. Geen 8 bytes meer, maar gewoon data van een paar kilobyte. Nog steeds geen garantie dat alles perfect is, maar in ieder geval een stuk geloofwaardiger.

Niet elke OV5642 leest hetzelfde

Daarna kwam de volgende verrassing. De code om FIFO-data uit een ArduCam te lezen lijkt op papier vrij rechttoe rechtaan, maar er zit een venijnig detail in: niet elke variant wil exact dezelfde burst-read aanpak.

Voor sommige voorbeelden zie je dat er eerst nog een extra dummy byte over SPI wordt verstuurd voordat de echte data wordt gelezen. Prima, zou je zeggen, gewoon overnemen. Alleen voor deze OV5642 Mini 5MP Plus bleek dat juist niet de bedoeling. Als je die stap blind overneemt, verschuift je datastroom net verkeerd en kun je daarna weer vrolijk corrupte output gaan debuggen.

Dat was dus zo’n klassiek embedded moment: de library ondersteunt de sensor, de voorbeelden lijken bekend terrein, maar net deze combinatie vraagt toch weer om een net iets andere behandeling.

Een JPEG is pas een JPEG als de markers kloppen

Zelfs toen er eindelijk serieuze hoeveelheden data uit de FIFO kwamen, was ik er nog niet helemaal. De firmware leest nu eerst de ruwe bytes uit de camera en zoekt daarin vervolgens expliciet naar het begin en einde van de JPEG. Dus naar de bekende start- en eindmarkers.

In code komt dat neer op iets als:

for (uint32_t i = 0; i < fifoLen - 1; i++) {
    if (rawBuf[i] == 0xFF && rawBuf[i + 1] == 0xD8) {
        startIdx = i;
        break;
    }
}

for (uint32_t i = fifoLen - 2; i > startIdx; i--) {
    if (rawBuf[i] == 0xFF && rawBuf[i + 1] == 0xD9) {
        endIdx = i + 2;
        break;
    }
}

Daarna wordt alleen het stuk tussen die markers als JPEG behandeld en geüpload.

Dat is misschien niet de meest elegante oplossing ter wereld, maar wel een heel praktische. In plaats van te hopen dat de complete buffer precies netjes begint en eindigt waar je wilt, controleer ik nu gewoon waar de echte JPEG begint en ophoudt. Pas dat stuk wordt daarna gebruikt voor de upload.

Dat gaf voor mij ook meteen wat meer vertrouwen in de keten. Niet alleen “er komt data uit de camera”, maar ook: het lijkt echt op een fotobestand waar een backend iets zinnigs mee kan.

Het YES-moment

En toen kwam het YES-moment: de eerste foto die volledig via ESP32 -> ArduCam -> backend is gegaan.

Eerste geslaagde PTL testfoto - YES moment

De eerste geslaagde PTL-testfoto uit de complete keten: capture, extractie en upload.

Uploaden was ook nog geen gelopen race

Aan backend-kant stond al een eenvoudige Flask-opzet klaar. In eerste instantie was het uploadpad vooral gericht op metadata. Handig om snel te testen of de route werkt, maar uiteindelijk heb je daar weinig aan als je doel een time-lapse camera is en geen time-lapse JSON-generator.

De firmware bouwt de upload nu daarom handmatig op als multipart/form-data. In dat request gaan twee delen mee: JSON metadata en de JPEG zelf. Dat is wat lager niveau dan even een mooie helperfunctie aanroepen, maar op een ESP32 is “even een library erbij” niet altijd de verstandigste route.

De relevante kern:

String bodyStart =
    "--" + boundary + "\r\n"
    "Content-Disposition: form-data; name=\"metadata\"\r\n"
    "Content-Type: application/json\r\n\r\n" + meta + "\r\n"
    "--" + boundary + "\r\n"
    "Content-Disposition: form-data; name=\"image\"; filename=\"capture.jpg\"\r\n"
    "Content-Type: image/jpeg\r\n\r\n";

client.print(bodyStart);
client.write(jpegBuf, jpegLen);
client.print(bodyEnd);

Aan backend-kant is het ontvangstdeel dan weer verrassend simpel:

metadata = request.form.get('metadata')
image = request.files.get('image')

Aan de serverkant pakt Flask vervolgens zowel het metadata-deel als het afbeeldingsbestand uit dezelfde request. De metadata wordt opgeslagen, en de foto wordt op dit moment nog vrij simpel weggeschreven naar disk. Niet chic, wel bruikbaar. Precies goed genoeg voor deze fase van het project.

Wat ik daar wel prettig aan vind, is dat de rollen nu duidelijk beginnen te worden. De firmware maakt een foto en levert die samen met wat context aan. De backend bepaalt vervolgens wat ermee gebeurt. Dat is ook hoe ik het verder wil houden: de firmware zo klein en robuust mogelijk, de backend verantwoordelijk voor opslag, beheer en later ook security.

Config van de backend ophalen

Een ander stuk dat nu staat, is het ophalen van config vanaf de backend. De ESP32 kan inmiddels een config ophalen met daarin onder andere het interval, de resolutie en het uploadpad. Dat betekent dat niet elke wijziging meteen een firmwareflash hoeft te zijn.

Voor nu gebruikt de firmware die config nog in een vrij eenvoudige flow: opstarten, config ophalen, foto maken, uploaden. De echte periodieke timelapse-loop moet nog volgen. Maar architectonisch voelt dit al wel als de juiste richting, omdat de camera zich straks meer als een dom maar nuttig veldapparaat kan gedragen.

Wat staat er nu echt

De stand van zaken is inmiddels best aardig:

  • camera-initialisatie op de ESP32 werkt stabiel
  • captures leveren nu bruikbare JPEG-data op
  • de firmware kan metadata en foto samen uploaden
  • de Flask-backend ontvangt die upload en slaat beide op
  • de backend levert ook runtime-config terug aan de firmware

Dat is voor mij een belangrijk omslagpunt. PTL voelt niet meer als een verzameling losse experimenten, maar als een systeem waarvan de hoofdlijnen echt staan.

Wat nog niet af is

Natuurlijk is het project daarmee nog lang niet klaar. De firmware maakt nu nog niet periodiek foto’s in de loop(), de backend gebruikt voor opslag nog een vrij simpele aanpak en op securitygebied staat er nog nauwelijks iets. Ook het uploadpad in de firmware kan nog slimmer: op dit moment gaat de JPEG eerst volledig in het geheugen voordat hij wordt verstuurd. Dat werkt, maar het is niet per se hoe ik het op langere termijn wil houden.

Met andere woorden: het YES-moment is binnen, maar er is nog genoeg te doen voordat dit echt een nette en langdurig betrouwbare time-lapse camera is.

Conclusie

De afgelopen stap was vooral een mooie reminder dat “een camera aangesloten hebben” nog iets heel anders is dan “een bruikbare foto uit de hardware krijgen”. De echte winst zat niet in een grote architectuurwijziging, maar in een paar venijnige details: een timing-register, een variant-specifieke FIFO-read en het fatsoenlijk afbakenen van de JPEG-data.

Nu die keten eenmaal werkt, wordt de volgende stap ook een stuk leuker: van een eenmalige testopname naar een camera die zelfstandig blijft draaien, periodiek beelden schiet en aan backend-kant steeds netter wordt afgehandeld.

Wordt vervolgd dus. Maar deze keer wel met beeld.