From cccc2f44880f6df2f6bdef685a24b999dee359c0 Mon Sep 17 00:00:00 2001 From: Alan <60433566+alanesq@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:27:28 +0100 Subject: [PATCH] Update motionDetect.pde --- Misc/motionDetect/motionDetect.pde | 385 ++++++++++++++++++++--------- 1 file changed, 273 insertions(+), 112 deletions(-) diff --git a/Misc/motionDetect/motionDetect.pde b/Misc/motionDetect/motionDetect.pde index 46cc6bd..d6c0034 100644 --- a/Misc/motionDetect/motionDetect.pde +++ b/Misc/motionDetect/motionDetect.pde @@ -1,7 +1,7 @@ /* ---------------------------------------------------- - ESP32cam motion detection using Processing - 02Jan25 + ESP32cam motion detection using Processing - 02Apr25 uses libraries OpenCV and Minim @@ -9,6 +9,7 @@ Notes: Convert jpg files to amovie: convert -delay 10 *.jpg output.mp4 + Set up individual camera settings in 'setCam()' */ @@ -17,20 +18,28 @@ // ---------------------------------------------------- String myParam = "ESPCam"; // camera title - String imgUrl = "http://192.168.1.2/jpg" + "?image.jpg"; // url of camera + String imgUrl = "http://192.168.1.2/jpg" + "?image.jpg"; // url of the esp32cam String imageFolder = sketchPath() + "/images/"; // folder to store images - - boolean soundEnabled = false; // if sound when motion detected - int triggerLevel = 20; // movement trigger level above baseline level - int enableAdaptiveTriggerLevel = 1;// if trigger level adapts to previous movement levels (1 or 0) - int windowSize = 50; // Sliding window size for average calculation - long imageAgeLimit = 7; // days to keep stored images - int resizeX = 160; // size of image created to motion detect + + boolean showDiags = true; // show extra diagnostic information on screen + int lineSpace = 20; // line spacing for text/buttons on screen + boolean soundEnabled = false; // if sound when motion detected + boolean UDPenabled = false; // if sending UDP broadcasts is enabled + int minTriggerTime = 15; // Minimum time between repeat triggers of sound or UDP (seconds) + int maxLineTriggers = 40; // If trigger level of a horizontal line excedes this it is ignored (percentage 0-100) + int triggerLevel = 65; // movement trigger level above baseline level + int enableAdaptiveTriggerLevel = 1; // if trigger level adapts to previous movement levels (1 or 0) + int windowSize = 50; // Sliding window size for average calculation + long imageAgeLimit = 7; // days to keep stored images + int resizeX = 160; // size of image created to motion detect int resizeY = 120; - int changeThreshold = 25; // Pixel intensity change threshold - int delay = 800; // Delay in milliseconds between draw calls (ms) - float alpha = 0.1; // Smoothing factor (0.0 < alpha <= 1.0) - int graphHeight = 80; // Height of the graph area + int changeThreshold = 20; // Pixel intensity change threshold + int delay = 800; // Delay in milliseconds between draw calls (ms) + float alpha = 0.1; // Smoothing factor (0.0 < alpha <= 1.0) + int graphHeight = 80; // Height of the graph area + int attemptsToTry = 5; // Max retries when capturing image + int overlayTint = 80; // how srong the overlaed movement/mask is on the image (0-255) + // A 4x4 movement detection mask (true = ignore area) - masked area is shown as yellow boolean[][] mask = { @@ -43,17 +52,64 @@ // ---------------------------------------------------- +// required to broadcast UDP + import java.net.*; + import java.io.*; + DatagramSocket socket; + InetAddress broadcastAddress; + int port = 12345; // required to delete old image files import java.io.File; import java.util.Date; + +// diag variables + int lineErrorCounter = 0; // for monitoring how many horizontal line rejections are occuring + int maxHLTriggers = 0; // max triggers on a horizontal line + long fameCompareTime = 0; // timing the frame compare procedure + long fameCaptureTime = 0; // timing the frame capture from URL + int TriggerCounter = 0; // trigger counter + +// control buttons + int buttonWidth = 70; + int buttonHeight = 17; + int buttonSpacing = 80; // horizontal spacing of the buttons + int buttonY = 3 * lineSpace - buttonHeight + 4; // Y position of the buttons + int soundButtonX = 5; // X position of the sound button + int UDPbuttonX = soundButtonX + buttonSpacing; // X position of the sound button + int imageButtonX = soundButtonX + buttonSpacing * 2; // X position of the save image button - -// sound toggle button - int buttonX = 5; // X position of the button - int buttonY = 55; // Y position of the button - int buttonWidth = 75; - int buttonHeight = 25; + +// ---------------------------------------------------- +// camera settings +// ---------------------------------------------------- +// camera selected from command line parameter + +void setCam(String param) { + + if (param.equals("front")) { + imgUrl = "http://192.168.1.144/jpg" + "?image.jpg"; + imageFolder = sketchPath() + "/../images/front/"; + // change mask + mask[3][0] = true; mask[3][1] = true; mask[3][2] = true; mask[3][3] = true; + mask[0][3] = true; mask[1][3] = true; mask[2][3] = true; + } + + if (param.equals("side")) { + imgUrl = "http://192.168.1.192/jpg" + "?image.jpg"; + imageFolder = sketchPath() + "/../images/side/"; + } + + if (param.equals("back")) { + imgUrl = "http://192.168.1.222/jpg" + "?image.jpg"; + imageFolder = sketchPath() + "/../images/back/"; + // change mask + //mask[0][0] = true; mask[0][1] = true; mask[0][2] = true; mask[0][3] = true; + //mask[1][3] = true; + } + + deleteOldFiles(imageFolder); // delete any image files older than 2 weeks +} PImage currentImg, previousImg, motionOverlay; @@ -65,6 +121,8 @@ int movementLevel = 0; // Accumulated movement level ArrayList recentReadings = new ArrayList(); // Store past readings int maxReading = 0; // Maximum reading for bar graph normalization String lastDownloadTime = ""; // Store last image fetch time +long lastUDPtrigger = 0; // Last time a UDP broadcast was sent +long lastSoundtrigger = 0; // Last time a sound was triggered // sound import ddf.minim.*; @@ -77,29 +135,45 @@ AudioPlayer beep; // ---------------------------------------------------- void setup() { - frameRate(refreshRate); - size(640, 480); - surface.setResizable(true); // audio minim = new Minim(this); - beep = minim.loadFile("beep.wav"); + beep = minim.loadFile("../beep.wav"); - surface.setTitle("Motion: " + myParam); + // get camera from parameter + myParam = System.getenv("motionCAM"); + if (myParam == null) myParam = "side"; // default camera + println(getCurrentDateTime() + "[camera] motionCAM parameter: " + myParam); + setCam(myParam); // set camera parameters + + // setup display + frameRate(refreshRate); + size(640, 480); + surface.setResizable(true); + surface.setTitle("Motion: " + myParam); + + println(getCurrentDateTime() + "[camera] '" + myParam + "' starting"); // request image from camera - currentImg = requestImg(); // load first image - normalizeBrightness(currentImg); - if (currentImg != null) previousImg = currentImg.get(); + currentImg = requestImg(); // load first image + normalizeBrightness(currentImg); // adjust brigtness to compensate for sudden changes in sunlight + if (currentImg != null) previousImg = currentImg.get(); // store this image as the reference for comparison - if (soundEnabled == true) { - beep.rewind(); - beep.play(); - } + if (soundEnabled == true) makeSound(); if (enableAdaptiveTriggerLevel == 0) saveThreshold = 0; // if adaptive trigger is disabled - println(getCurrentDateTime() + " camera '" + myParam + "' starting"); +// setup for UDP broadcasting + try { + socket = new DatagramSocket(null); + socket.setReuseAddress(true); // Enable reuse + socket.setBroadcast(true); // Enable broadcast + socket.bind(new InetSocketAddress(port)); + broadcastAddress = InetAddress.getByName("192.168.1.255"); + } catch (Exception e) { + e.printStackTrace(); + } + sendUDPmessage("GM:Camera " + myParam + " starting"); // send a UDP broadcast } @@ -116,12 +190,11 @@ void draw() { // Refresh image if (currentImg != null) previousImg = currentImg.get(); currentImg = requestImg(); - normalizeBrightness(currentImg); // compensate for changes in brightness + normalizeBrightness(currentImg); // adjust brigtness to compensate for sudden changes in sunlight if (previousImg != null && currentImg != null) { - MovementResult diff = compareImages(previousImg, currentImg, mask); - movementLevel = diff.movementLevel; + int movementLevel = compareImages(previousImg, currentImg, mask); // Update graph data recentReadings.add(movementLevel); @@ -141,28 +214,37 @@ void draw() { // display text textSize(18); textAlign(LEFT); - text("Movement:" + movementLevel + " Trigger:" + (saveThreshold + triggerLevel), 10, 40); - text(myParam + ": " + lastDownloadTime, 10, 20); + text(myParam + ": " + lastDownloadTime, 10, 1 * lineSpace); + text("Movement: " + movementLevel + " Trigger: " + (saveThreshold + triggerLevel), 10, 2 * lineSpace); + + // if extra diagnostic info display is enabled + if (showDiags == true) { + text("Line rejections: " + lineErrorCounter + " (" + maxHLTriggers + "/" + resizeX + ")", width / 2, 1 * lineSpace); + text("Triggers: " + TriggerCounter, width / 2, 2 * lineSpace); + text("Time to capture image: " + fameCaptureTime + "ms", width / 2, 3 * lineSpace); + text("Time to compare images: " + fameCompareTime + "ms", width / 2, 4 * lineSpace); + } // if movement threshold exceeded if (enableAdaptiveTriggerLevel == 1) updateThreshold(); // adapt threshold level if (movementLevel > saveThreshold + triggerLevel) { - println(getCurrentDateTime() + " Movement detected (" + myParam +")"); + println(getCurrentDateTime() + "[camera] Movement detected (" + myParam +")"); //currentImg.save(imageFolder + lastDownloadTime + ".jpg"); save(imageFolder + lastDownloadTime + ".jpg"); - if (soundEnabled == true) { - beep.rewind(); - beep.play(); + if (soundEnabled == true) makeSound(); + if (UDPenabled == true) { + sendUDPmessage("IN:Movement detected"); // send UDP broadcast } + TriggerCounter++; // trigger counter for extra diag display } // Draw movement graph - tint(255, 96); + tint(255, 80); drawGraph(); // display movement detection image on top of camera image - tint(255, 96); - image(motionOverlay, 0, 0, width, height); + tint(255, 255); + image(motionOverlay, 0, 0, width, height); // delete older images once per day if ((millis() - lastRun) % (24 * 60 * 60 * 1000) == 0) { @@ -178,24 +260,41 @@ void draw() { // ---------------------------------------------------- -// sound on/off toggle button +// sound and UDP on/off toggle buttons // ---------------------------------------------------- void togButton() { - // Draw the toggle button + // Draw the sound toggle button if (soundEnabled) { fill(0, 255, 0); // Green when ON } else { fill(255, 0, 0); // Red when OFF } tint(255, 64); - rect(buttonX, buttonY, buttonWidth, buttonHeight); - // Draw button label + rect(soundButtonX, buttonY, buttonWidth, buttonHeight); + + // Draw the UDP toggle button + if (UDPenabled) { + fill(0, 255, 0); // Green when ON + } else { + fill(255, 0, 0); // Red when OFF + } + tint(255, 64); + rect(UDPbuttonX, buttonY, buttonWidth, buttonHeight); + + // Draw sound button label fill(0, 0, 255); // blue tint(255, 64); textSize(12); textAlign(CENTER, CENTER); - text(soundEnabled ? "Sound ON" : "Sound OFF", buttonX + buttonWidth / 2, buttonY + buttonHeight / 2); + text(soundEnabled ? "Sound ON" : "Sound OFF", soundButtonX + buttonWidth / 2, buttonY + buttonHeight / 2); + + // Draw UDP button label + fill(0, 0, 255); // blue + tint(255, 64); + textSize(12); + textAlign(CENTER, CENTER); + text(UDPenabled ? "UDP ON" : "UDP OFF", UDPbuttonX + buttonWidth / 2, buttonY + buttonHeight / 2); } @@ -207,20 +306,29 @@ void saveButton() { // Draw the save button fill(0, 255, 0); // Green when ON tint(255, 64); - rect(buttonX + 5 + buttonWidth, buttonY, buttonWidth, buttonHeight); + rect(imageButtonX, buttonY, buttonWidth, buttonHeight); // Draw button label fill(0, 0, 255); // blue tint(255, 64); textSize(12); textAlign(CENTER, CENTER); - text("Save", 5 + buttonWidth + buttonWidth / 2 , buttonY + buttonHeight / 2); + text("Save", imageButtonX + buttonWidth / 2 , buttonY + buttonHeight / 2); } + + +// ---------------------------------------------------- +// if mouse was clicked action buttons +// ---------------------------------------------------- + void mousePressed() { - if (mouseX > buttonX && mouseX < buttonX + buttonWidth && mouseY > buttonY && mouseY < buttonY + buttonHeight) { + if (mouseX > soundButtonX && mouseX < soundButtonX + buttonWidth && mouseY > buttonY && mouseY < buttonY + buttonHeight) { soundEnabled = !soundEnabled; // Toggle the flag } - if (mouseX > buttonX + buttonWidth + 5 && mouseX < buttonX + (2 * buttonWidth) + 5 && mouseY > buttonY && mouseY < buttonY + buttonHeight) { + if (mouseX > UDPbuttonX && mouseX < UDPbuttonX + buttonWidth && mouseY > buttonY && mouseY < buttonY + buttonHeight) { + UDPenabled = !UDPenabled; // Toggle the UDP broadcasts flag + } + if (mouseX > imageButtonX && mouseX < imageButtonX + buttonWidth && mouseY > buttonY && mouseY < buttonY + buttonHeight) { save(imageFolder + lastDownloadTime + ".jpg"); // save image } } @@ -231,46 +339,64 @@ void mousePressed() { // ---------------------------------------------------- PImage requestImg() { - int attemptsToTry = 3; - int retries = attemptsToTry; - - while (retries-- > 0) { - PImage img = null; // Always start fresh - try { - img = requestImage(imgUrl); // Attempt to load the image - int startTime = millis(); - while (img != null && img.width < 1) { // Wait for a valid image - if (millis() - startTime >= 5000) { // Timeout - println(getCurrentDateTime() + " Image load timed out (" + myParam + ")"); - img = null; // Clear invalid image - break; - } - delay(50); - } - if (img != null && img.width > 1 && img.height > 1) { // Successfully loaded - lastDownloadTime = day() + "-" + month() + "-" + year() + "--" + hour() + ":" + nf(minute(), 2) + ":" + nf(second(), 2); - if (retries != attemptsToTry - 1) println(getCurrentDateTime() + " camera '" + myParam + "' image captured ok"); - return img; - } else { - println(getCurrentDateTime() + " camera '" + myParam + "' image capture failed"); - } - } catch (Exception e) { - println(getCurrentDateTime() + " camera '" + myParam + "' Exception during image request: " + e.getMessage()); - } - println(myParam + " Retrying... (" + retries + " attempts left)"); - delay(800); // Allow cooldown between retries + int startTime3 = millis(); // used to time this procedure + + for (int attempt = 1; attempt <= attemptsToTry; attempt++) { + PImage img = requestImage(imgUrl); // Start loading the image asynchronously + int startTime2 = millis(); + boolean loaded = false; + + // Wait (up to 5 seconds) for the image to load without blocking the main thread + while (millis() - startTime2 < 5000) { + if (img.width > 1 && img.height > 1) { + loaded = true; + break; + } + try { + Thread.sleep(50); + } catch (InterruptedException e) { + println(getCurrentDateTime() + "[camera] '" + myParam + "' interrupted: " + e.getMessage()); + return null; + } } - println(getCurrentDateTime() + myParam + " - Failed to fetch image after multiple attempts (" + myParam + ")"); - return null; + + if (loaded) { + lastDownloadTime = day() + "-" + month() + "-" + year() + "--" + + hour() + ":" + nf(minute(), 2) + ":" + nf(second(), 2); + if (attempt > 1) + println(getCurrentDateTime() + "[camera] '" + myParam + "' image captured ok on attempt " + attempt); + fameCaptureTime = millis() - startTime3; // store time to compare images + return img; + } + else { + println(getCurrentDateTime() + "[camera] '" + myParam + "' image capture timed out on attempt " + attempt); + } + + // Cooldown before retrying + try { + Thread.sleep(800); + } catch (InterruptedException e) { + println(getCurrentDateTime() + "[camera] '" + myParam + "' interrupted during cooldown: " + e.getMessage()); + return null; + } + } + + println(getCurrentDateTime() + "[camera '" + myParam + "' - Failed to fetch image after " + attemptsToTry + " attempts"); + exit(); // close app + return null; } + // ---------------------------------------------------- // compare two images // ---------------------------------------------------- // a mask in the form of a 4x4 grid can be supplied (true = ignore area) -MovementResult compareImages(PImage img1, PImage img2, boolean[][] mask) { +int compareImages(PImage img1, PImage img2, boolean[][] mask) { + long startTime4 = millis(); // used to time this procedure + maxHLTriggers = 0; // maximum triggers on a horizontal line + // Resize images for faster computation PImage smallImg1 = img1.get(); // Make a copy of img1 PImage smallImg2 = img2.get(); // Make a copy of img2 @@ -280,15 +406,17 @@ MovementResult compareImages(PImage img1, PImage img2, boolean[][] mask) { smallImg1.loadPixels(); smallImg2.loadPixels(); - motionOverlay = createImage(smallImg1.width, smallImg1.height, RGB); + motionOverlay = createImage(smallImg1.width, smallImg1.height, ARGB); motionOverlay.loadPixels(); int movementLevel = 0; int gridWidth = smallImg1.width / 4; int gridHeight = smallImg1.height / 4; + int startOfLineTriggers = 0; for (int y = 0; y < smallImg1.height; y++) { + startOfLineTriggers = movementLevel; // store how many triggers at start of this horizontal line for (int x = 0; x < smallImg1.width; x++) { int i = x + y * smallImg1.width; @@ -298,33 +426,42 @@ MovementResult compareImages(PImage img1, PImage img2, boolean[][] mask) { // Check if the mask excludes this cell if (mask != null && mask[gridY][gridX]) { - //motionOverlay.pixels[i] = smallImg1.pixels[i]; // Keep original pixel - motionOverlay.pixels[i] = color(255, 255, 0); // yellow - continue; + motionOverlay.pixels[i] = color(128, 128, 0, overlayTint); // highlight mask in yellow + continue; // skip detection for this pixel } float diff = brightness(smallImg1.pixels[i]) - brightness(smallImg2.pixels[i]); - if (abs(diff) > changeThreshold) { - movementLevel++; - // Highlight motion in green - motionOverlay.pixels[i] = color(0, 255, 0); + if (abs(diff) > changeThreshold) { // if motion detected + movementLevel++; // increment movement level detected value + motionOverlay.pixels[i] = color(0, 128, 0, overlayTint); // Highlight motion in green } else { - // Keep original pixel - motionOverlay.pixels[i] = smallImg1.pixels[i]; + motionOverlay.pixels[i] = color(0, 0, 0, 0); // clear pixel + //motionOverlay.pixels[i] = smallImg1.pixels[i]; // Keep original pixel } - } - } + } // x + + // if whole line is triggered then assune it is error (interference in image or whole image has changed) + int Triggers = movementLevel - startOfLineTriggers; // triggers on this line + if (Triggers > (smallImg1.width * maxLineTriggers) / 100) { + movementLevel = startOfLineTriggers; // discard this line as error + lineErrorCounter++; // diag variable for monitoring line rejections + } + if (Triggers > maxHLTriggers) maxHLTriggers = Triggers; // update diag variable maximum triggers per line + + } // y motionOverlay.updatePixels(); // Display the image with motion highlighted image(motionOverlay, 0, 0, width, height); + + // store time to compare images + fameCompareTime = millis() - startTime4; - return new MovementResult(movementLevel); + return movementLevel; } - // ---------------------------------------------------- // normalise image brightness (to compensate for sun brightness changes) // ---------------------------------------------------- @@ -332,7 +469,6 @@ MovementResult compareImages(PImage img1, PImage img2, boolean[][] mask) { void normalizeBrightness(PImage img) { if (img == null) return; - img.loadPixels(); float totalBrightness = 0; int numPixels = img.pixels.length; @@ -441,12 +577,13 @@ void updateThreshold() { // ---------------------------------------------------- // delete older image files // ---------------------------------------------------- + void deleteOldFiles(String folderPath) { - println(getCurrentDateTime() + " Deleting older image files (" + myParam + ")"); + println(getCurrentDateTime() + "[camera] Deleting older image files (" + myParam + ")"); File folder = new File(folderPath); if (!folder.exists() || !folder.isDirectory()) { - println(myParam + " - Invalid folder path: " + folderPath); + println("[camera]" + myParam + " - Invalid folder path: " + folderPath); return; } @@ -454,7 +591,7 @@ void deleteOldFiles(String folderPath) { File[] files = folder.listFiles(); if (files == null || files.length == 0) { - println(myParam + " - No files to check in: " + folderPath); + println("[camera]" + myParam + " - No files to check in: " + folderPath); return; } @@ -471,12 +608,12 @@ void deleteOldFiles(String folderPath) { // If the file is older than 2 weeks, delete it if (lastModified < cutoffDate) { if (file.delete()) { - println(myParam + " - Deleted: " + file.getName()); + println("[camera]" + myParam + " - Deleted: " + file.getName()); } else { - println(myParam + " - Failed to delete: " + file.getName()); + println("[camera]" + myParam + " - Failed to delete: " + file.getName()); } } else { - //println(myParam +" - Retained: " + file.getName()); + //println("[camera]" + myParam +" - Retained: " + file.getName()); } } } @@ -497,22 +634,46 @@ String getCurrentDateTime() { int second = second(); // Construct the date and time string - String dateTime = nf(month, 2) + "/" + nf(day, 2) + "/" + year + " " + nf(hour, 2) + ":" + nf(minute, 2) + ":" + nf(second, 2); + String dateTime = nf(day, 2) + "/" + nf(month, 2) + "/" + year + " " + nf(hour, 2) + ":" + nf(minute, 2) + ":" + nf(second, 2); return dateTime; } // ---------------------------------------------------- -// used for comparing images +// send a UDP broadcast // ---------------------------------------------------- -class MovementResult { - int movementLevel; - MovementResult(int movementLevel) { - this.movementLevel = movementLevel; - } + +void sendUDPmessage(String message) { + if (UDPenabled == false) return; + long currentTime = millis(); + if (currentTime - lastUDPtrigger >= (minTriggerTime * 1000) || lastUDPtrigger == 0) { + lastUDPtrigger = currentTime; + byte[] buffer = message.getBytes(); + DatagramPacket packet = new DatagramPacket(buffer, buffer.length, broadcastAddress, port); + try { + socket.send(packet); + println("Message sent!"); + } catch (IOException e) { + e.printStackTrace(); + } + } } +// ---------------------------------------------------- +// Make a sound +// ---------------------------------------------------- + +void makeSound() { + long currentTime = millis(); + if (currentTime - lastSoundtrigger >= (minTriggerTime * 1000) || lastSoundtrigger == 0) { + beep.rewind(); + beep.play(); + lastSoundtrigger = currentTime; + } +} + + // ---------------------------------------------------- // end