kopia lustrzana https://github.com/alanesq/esp32cam-demo
Update multiCameraViewer.htm
rodzic
426bd8ceb5
commit
b0937eae7c
|
@ -11,8 +11,10 @@
|
||||||
Add camera IPs under "ADD YOUR CAMERA IPs HERE"
|
Add camera IPs under "ADD YOUR CAMERA IPs HERE"
|
||||||
|
|
||||||
Modify load settings keys under 'load settings with keypress'
|
Modify load settings keys under 'load settings with keypress'
|
||||||
|
|
||||||
|
Specify config on url: http://x.x.x.x?config=xxx
|
||||||
|
|
||||||
26May25
|
03Jun25
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
@ -20,7 +22,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Camera Viewer with Motion Detection</title>
|
<title>Camera Viewer</title>
|
||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
<meta http-equiv="Expires" content="0">
|
<meta http-equiv="Expires" content="0">
|
||||||
|
@ -89,8 +91,8 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
background: #333;
|
background: rgba(0,0,0,0);
|
||||||
color: white;
|
color: #00A;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -101,11 +103,11 @@
|
||||||
.cam-title {
|
.cam-title {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
left: 8px;
|
left: 5px;
|
||||||
color: #fff;
|
color: #00A;
|
||||||
background: rgba(0,0,0,0.5);
|
background: rgba(0,0,0,0);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
@ -113,31 +115,31 @@
|
||||||
/* Styles for motion detection controls */
|
/* Styles for motion detection controls */
|
||||||
.motion-controls {
|
.motion-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4px;
|
top: 26px;
|
||||||
left: 8px;
|
left: 5px;
|
||||||
background: rgba(0,0,0,0.5);
|
background: rgba(0,0,0,0);
|
||||||
padding: 3px 6px;
|
padding: 0px 0px;
|
||||||
border-radius: 3px;
|
border-radius: 0px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
color: white;
|
color: #00A;
|
||||||
font-size: 12px;
|
font-size: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.motion-controls label {
|
.motion-controls label {
|
||||||
margin-right: 5px;
|
margin-right: 0px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.motion-controls input[type="checkbox"] {
|
.motion-controls input[type="checkbox"] {
|
||||||
margin-right: 5px;
|
margin-right: 0px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.motion-controls input[type="range"] {
|
.motion-controls input[type="range"] {
|
||||||
width: 80px;
|
width: 120px;
|
||||||
height: 10px;
|
height: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: none; /* Hidden by default */
|
display: none; /* Hidden by default */
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
|
@ -188,7 +190,7 @@
|
||||||
}
|
}
|
||||||
// --- END DEBUGGING ---
|
// --- END DEBUGGING ---
|
||||||
|
|
||||||
|
// load settings with keypress ('r' and 'b' unavailable)
|
||||||
const keyConfigMap = [
|
const keyConfigMap = [
|
||||||
{ key: 'm', config: 'main' },
|
{ key: 'm', config: 'main' },
|
||||||
{ key: 's', config: 'small' },
|
{ key: 's', config: 'small' },
|
||||||
|
@ -203,7 +205,7 @@
|
||||||
const currentDisplay = window.getComputedStyle(tb).display;
|
const currentDisplay = window.getComputedStyle(tb).display;
|
||||||
tb.style.display = (currentDisplay === 'none' || tb.style.display === 'none') ? 'block' : 'none';
|
tb.style.display = (currentDisplay === 'none' || tb.style.display === 'none') ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
if (e.key === 'c') resetCameras();
|
if (e.key === 'r') resetCameras();
|
||||||
|
|
||||||
const match = keyConfigMap.find(item => e.key === item.key);
|
const match = keyConfigMap.find(item => e.key === item.key);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
@ -223,7 +225,7 @@
|
||||||
const cameras = [
|
const cameras = [
|
||||||
{ src: "http://192.168.1.100/jpg", id: "Front" },
|
{ src: "http://192.168.1.100/jpg", id: "Front" },
|
||||||
{ src: "http://192.168.1.101/jpg", id: "Side" },
|
{ src: "http://192.168.1.101/jpg", id: "Side" },
|
||||||
{ src: "http://192.168.1.102/jpg", id: "Back" },
|
{ src: "http://192.168.1.102/jpg", id: "Back" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const toolbar = document.getElementById('toolbar');
|
const toolbar = document.getElementById('toolbar');
|
||||||
|
@ -310,15 +312,17 @@
|
||||||
|
|
||||||
function showInstructions() {
|
function showInstructions() {
|
||||||
alert(`CCTV Camera Viewer with Motion Detection
|
alert(`CCTV Camera Viewer with Motion Detection
|
||||||
- Click a camera button to open its feed.
|
- Click camera button to open its feed.
|
||||||
- Save/load/import/export configurations.
|
- Save/Load/Import/Export configuration.
|
||||||
- Drag/resize windows.
|
- Drag/Resize windows.
|
||||||
- SHIFT + Scroll to zoom, SHIFT + drag to pan.
|
- SHIFT + Scroll to zoom, SHIFT + drag to pan.
|
||||||
- Motion Detection: Check box to enable,
|
- Motion Detection: Check box to enable;
|
||||||
use slider for sensitivity.
|
use slider for sensitivity.
|
||||||
- Press 'b' to hide/show the toolbar.
|
- 'b' hide/show the toolbar.
|
||||||
- Quick load configs: 'm', 's', 'f'.
|
- Quick load configs:
|
||||||
- Reset to close all feeds (or press 'C').
|
'm' Main, 's' Side, 'f' Front.
|
||||||
|
- 'r'/Reset close all feeds.
|
||||||
|
- Specify config on URL: ?config=
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -370,20 +374,27 @@
|
||||||
debugLog('State saved.');
|
debugLog('State saved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadState() {
|
function getQueryParam(name) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadState(configName) {
|
||||||
debugLog('Loading state...');
|
debugLog('Loading state...');
|
||||||
// First, clear all existing cameras and their loops properly
|
|
||||||
|
// Clear existing cameras
|
||||||
document.querySelectorAll('.img-container').forEach(el => {
|
document.querySelectorAll('.img-container').forEach(el => {
|
||||||
if (el._zoomPanState) {
|
if (el._zoomPanState) el._zoomPanState.isActive = false;
|
||||||
debugLog(`Load: Setting isActive=false for old ${el.id}`);
|
|
||||||
el._zoomPanState.isActive = false;
|
|
||||||
}
|
|
||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
const stateString = localStorage.getItem('cameraState');
|
// Get state from URL config or fallback to default
|
||||||
|
const stateString = configName
|
||||||
|
? localStorage.getItem('cameraState_' + configName)
|
||||||
|
: localStorage.getItem('cameraState');
|
||||||
|
|
||||||
if (!stateString) {
|
if (!stateString) {
|
||||||
debugLog('No state found in localStorage to load.');
|
debugLog('No state found to load.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -392,28 +403,21 @@
|
||||||
stateToLoad = JSON.parse(stateString);
|
stateToLoad = JSON.parse(stateString);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error parsing saved state:", e);
|
console.error("Error parsing saved state:", e);
|
||||||
alert("Error loading saved state. It might be corrupted.");
|
alert("Error loading saved state.");
|
||||||
localStorage.removeItem('cameraState'); // Clear corrupted state
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(stateToLoad).length === 0) {
|
for (const camDef of cameras) {
|
||||||
debugLog('Saved state is empty. Nothing to load.');
|
(stateToLoad[camDef.id] || []).forEach(cfg => {
|
||||||
return;
|
createCameraWindow(camDef, cfg);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const camDef of cameras) { // Iterate over defined cameras
|
|
||||||
if (stateToLoad[camDef.id]) { // If this camera type has saved instances
|
|
||||||
(stateToLoad[camDef.id] || []).forEach(cfg => {
|
|
||||||
debugLog(`Loading config for ${camDef.id} (unique: ${cfg.uniqueId})`);
|
|
||||||
createCameraWindow(camDef, cfg);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debugLog('State loaded.');
|
debugLog('State loaded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function initAudio() {
|
function initAudio() {
|
||||||
if (!audioContext) {
|
if (!audioContext) {
|
||||||
try {
|
try {
|
||||||
|
@ -491,7 +495,7 @@
|
||||||
<div class="img-inner"><img crossorigin="anonymous" /></div>
|
<div class="img-inner"><img crossorigin="anonymous" /></div>
|
||||||
<div class="motion-indicator"></div>
|
<div class="motion-indicator"></div>
|
||||||
<div class="motion-controls">
|
<div class="motion-controls">
|
||||||
<label for="motion_check_${uniqueId}">Motion:</label>
|
<label for="motion_check_${uniqueId}"></label>
|
||||||
<input type="checkbox" id="motion_check_${uniqueId}" class="motion-checkbox">
|
<input type="checkbox" id="motion_check_${uniqueId}" class="motion-checkbox">
|
||||||
<input type="range" id="motion_slider_${uniqueId}" class="motion-slider" min="1" max="100" value="50">
|
<input type="range" id="motion_slider_${uniqueId}" class="motion-slider" min="1" max="100" value="50">
|
||||||
</div>
|
</div>
|
||||||
|
@ -500,12 +504,36 @@
|
||||||
initContainer(div, cam.src, saved);
|
initContainer(div, cam.src, saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareFrames(ctx1, ctx2, width, height, sensitivity) {
|
function compareFrames(ctx1, ctx2, width, height, sensitivity, containerId = "unknown_cam") { // Added containerId for logging
|
||||||
const imgData1 = ctx1.getImageData(0, 0, width, height).data;
|
const imgData1 = ctx1.getImageData(0, 0, width, height).data;
|
||||||
const imgData2 = ctx2.getImageData(0, 0, width, height).data;
|
const imgData2 = ctx2.getImageData(0, 0, width, height).data;
|
||||||
let diffCount = 0;
|
let diffCount = 0;
|
||||||
const threshold = 25;
|
|
||||||
const requiredDiffPixels = (101 - sensitivity) * (width * height / 400);
|
// --- MODIFIED PARAMETERS ---
|
||||||
|
// Original: const threshold = 10;
|
||||||
|
const threshold = 8; // Lowered from 10. Defines how much a single pixel's grayscale value needs to change.
|
||||||
|
// This makes individual pixel changes easier to detect.
|
||||||
|
|
||||||
|
// Original calculation was: (101 - sensitivity) * (width * height / 400)
|
||||||
|
// New calculation aims for a less steep sensitivity curve and generally more sensitivity.
|
||||||
|
|
||||||
|
// N_scaler: Compresses the effect of the sensitivity slider.
|
||||||
|
// A higher N_scaler means the slider has a less dramatic effect (curve is flatter).
|
||||||
|
// Original formula implicitly had N_scaler around 1.0 relative to its 1-100 range.
|
||||||
|
const N_scaler = 2.0;
|
||||||
|
|
||||||
|
// baseDivisor: Affects overall sensitivity. Higher baseDivisor = more sensitive (fewer pixels needed).
|
||||||
|
// Original was 400.0.
|
||||||
|
const baseDivisor = 800.0;
|
||||||
|
|
||||||
|
// 'sensitivity' is the slider value from 1 (low sensitivity) to 100 (high sensitivity).
|
||||||
|
// We want 'effectiveSensitivityFactor' to be low for high slider sensitivity, and high for low slider sensitivity.
|
||||||
|
// When slider sensitivity = 100 (max), effectiveSensitivityFactor = ((100-100)/2.0) + 1.0 = 1.0 (most sensitive factor part).
|
||||||
|
// When slider sensitivity = 1 (min), effectiveSensitivityFactor = ((100-1)/2.0) + 1.0 = 49.5 + 1.0 = 50.5 (least sensitive factor part).
|
||||||
|
const effectiveSensitivityFactor = ((100.0 - parseFloat(sensitivity)) / N_scaler) + 1.0;
|
||||||
|
|
||||||
|
const requiredDiffPixels = effectiveSensitivityFactor * (width * height / baseDivisor);
|
||||||
|
// --- END MODIFIED PARAMETERS ---
|
||||||
|
|
||||||
for (let i = 0; i < imgData1.length; i += 4) {
|
for (let i = 0; i < imgData1.length; i += 4) {
|
||||||
const gray1 = (imgData1[i] + imgData1[i + 1] + imgData1[i + 2]) / 3;
|
const gray1 = (imgData1[i] + imgData1[i + 1] + imgData1[i + 2]) / 3;
|
||||||
|
@ -513,7 +541,12 @@
|
||||||
if (Math.abs(gray1 - gray2) > threshold) diffCount++;
|
if (Math.abs(gray1 - gray2) > threshold) diffCount++;
|
||||||
}
|
}
|
||||||
const motionDetected = diffCount > requiredDiffPixels;
|
const motionDetected = diffCount > requiredDiffPixels;
|
||||||
// debugLog(`compareFrames: diffCount=${diffCount}, required=${requiredDiffPixels.toFixed(2)}, detected=${motionDetected}`); // Very spammy
|
|
||||||
|
// For debugging the new values:
|
||||||
|
// Only log ~10% of the calls to reduce console spam, if DEBUG_MOTION is true.
|
||||||
|
if (DEBUG_MOTION && Math.random() < 0.1) { // Adjust 0.1 (10%) as needed for logging frequency
|
||||||
|
debugLog(`[${containerId}] compareFrames (slider: ${sensitivity}): diffCount=${diffCount}, reqDiffPixels=${requiredDiffPixels.toFixed(2)}, effectiveSensFactor=${effectiveSensitivityFactor.toFixed(2)}, threshold=${threshold}, detected=${motionDetected}`);
|
||||||
|
}
|
||||||
return motionDetected;
|
return motionDetected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -743,7 +776,7 @@
|
||||||
|
|
||||||
if (prevImageLoaded) {
|
if (prevImageLoaded) {
|
||||||
// debugLog(`[${container.id}] Comparing frames.`); // Spammy
|
// debugLog(`[${container.id}] Comparing frames.`); // Spammy
|
||||||
if (compareFrames(ctx1, ctx2, motionWidth, motionHeight, state.motionSensitivity)) {
|
if (compareFrames(ctx1, ctx2, motionWidth, motionHeight, state.motionSensitivity, container.id)) {
|
||||||
debugLog(`[${container.id}] Motion DETECTED! Calling playBeep.`);
|
debugLog(`[${container.id}] Motion DETECTED! Calling playBeep.`);
|
||||||
playBeep(container.id);
|
playBeep(container.id);
|
||||||
motionIndicator.style.display = 'block';
|
motionIndicator.style.display = 'block';
|
||||||
|
@ -805,7 +838,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load of state from localStorage when the page loads
|
// Initial load of state from localStorage when the page loads
|
||||||
loadState();
|
const startupConfig = getQueryParam('config');
|
||||||
|
loadState(startupConfig);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
Ładowanie…
Reference in New Issue