Dark video detection

* Add local detection for users who are broadcasting dark (e.g. mostly
  or completely black) video feeds from their local device.
* Every 5 seconds while the webcam is active, the average RGB color is
  sampled. If the average color value remains below 60 (out of 255) for
  two consecutive samples, the camera is stopped automatically.
This commit is contained in:
Noah 2024-05-18 19:09:11 -07:00
parent fd36d09727
commit a536862a91

View File

@ -38,6 +38,9 @@ const configuration = {
const FileUploadMaxSize = 1024 * 1024 * 8; // 8 MB const FileUploadMaxSize = 1024 * 1024 * 8; // 8 MB
const DebugChannelID = "barertc-debug"; const DebugChannelID = "barertc-debug";
const WebcamWidth = 640,
WebcamHeight = 480;
export default { export default {
name: 'BareRTC', name: 'BareRTC',
components: { components: {
@ -202,6 +205,20 @@ export default {
video: null, video: null,
audio: null, audio: null,
}, },
// Detect dark video streams.
darkVideo: {
canvas: null, // <canvas> element to screenshot video into
ctx: null, // Canvas context2d
interval: null, // interval loop
lastImage: null, // data: uri of last screenshot taken
lastAverage: [], // last average RGB color
lastAverageColor: "rgba(255, 0, 255, 1)",
wasTooDark: false, // previous average was too dark
// Configuration thresholds: how dark is too dark? (0-255)
threshold: 60,
},
}, },
// Video flag constants (sync with values in messages.go) // Video flag constants (sync with values in messages.go)
@ -1052,6 +1069,21 @@ export default {
return; return;
} }
// DEBUGGING: print last dark video screenshot taken
if (this.message.toLowerCase().indexOf("/debug-dark-video") === 0) {
if (this.webcam.darkVideo.lastImage === null) {
this.ChatClient("There is no recent image available.");
} else {
this.ChatClient(
`Last average color of your video: ${JSON.stringify(this.webcam.darkVideo.lastAverage)} ` +
`<span style="background-color: ${this.webcam.darkVideo.lastAverageColor}">${this.webcam.darkVideo.lastAverageColor}</span>` +
`<br><img src="${this.webcam.darkVideo.lastImage}" width="160" height="120">`
);
}
this.message = "";
return;
}
// DEBUGGING: reconnect to the server // DEBUGGING: reconnect to the server
if (this.message.toLowerCase().indexOf("/reconnect") === 0) { if (this.message.toLowerCase().indexOf("/reconnect") === 0) {
this.resetChatClient(); this.resetChatClient();
@ -2057,8 +2089,8 @@ export default {
let mediaParams = { let mediaParams = {
audio: true, audio: true,
video: { video: {
width: { max: 640 }, width: { max: WebcamWidth },
height: { max: 480 }, height: { max: WebcamHeight },
}, },
}; };
@ -2118,6 +2150,9 @@ export default {
if (changeCamera) { if (changeCamera) {
this.updateWebRTCStreams(); this.updateWebRTCStreams();
} }
// Begin dark video detection.
this.initDarkVideoDetection();
}).catch(err => { }).catch(err => {
this.ChatClient(`Webcam error: ${err}`); this.ChatClient(`Webcam error: ${err}`);
}).finally(() => { }).finally(() => {
@ -2605,6 +2640,8 @@ export default {
// Stop broadcasting. // Stop broadcasting.
stopVideo() { stopVideo() {
this.stopDarkVideoDetection();
// Close all WebRTC sessions. // Close all WebRTC sessions.
for (let username of Object.keys(this.WebRTC.pc)) { for (let username of Object.keys(this.WebRTC.pc)) {
this.closeVideo(username, "answerer"); this.closeVideo(username, "answerer");
@ -2771,6 +2808,128 @@ export default {
}) })
}, },
// Dark video detection.
initDarkVideoDetection() {
if (this.webcam.darkVideo.canvas === null) {
let canvas = document.createElement("canvas"),
ctx = canvas.getContext('2d');
canvas.width = WebcamWidth;
canvas.height = WebcamHeight;
this.webcam.darkVideo.canvas = canvas;
this.webcam.darkVideo.ctx = ctx;
}
if (this.webcam.darkVideo.interval !== null) {
clearInterval(this.webcam.darkVideo.interval);
}
this.webcam.darkVideo.interval = setInterval(() => {
this.darkVideoInterval();
}, 5000);
},
stopDarkVideoDetection() {
if (this.webcam.darkVideo.interval !== null) {
clearInterval(this.webcam.darkVideo.interval);
}
},
darkVideoInterval() {
if (!this.webcam.active) { // safety
this.stopDarkVideoDetection();
return;
}
// Take a screenshot from the user's local webcam.
let canvas = this.webcam.darkVideo.canvas,
ctx = this.webcam.darkVideo.ctx;
ctx.drawImage(this.webcam.elem, 0, 0, canvas.width, canvas.height);
// Debugging: export the screenshot to a data URI.
let img = canvas.toDataURL('image/jpeg');
this.webcam.darkVideo.lastImage = img;
// Get average RGB value.
let rgb = this.getAverageRGB(ctx);
if (rgb === null) {
return;
}
this.webcam.darkVideo.lastAverage = rgb;
this.webcam.darkVideo.lastAverageColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 1)`;
// If the average total color is below the threshold (too dark of a video).
let averageBrightness = Math.floor((rgb[0] + rgb[1] + rgb[2]) / 3);
if (averageBrightness < this.webcam.darkVideo.threshold) {
if (this.wasTooDark) {
// Last sample was too dark too, = cut the camera.
this.stopVideo();
this.ChatClient(
"Your webcam was too dark to see anything and has been turned off.",
);
} else {
// Mark that this frame was too dark, if the next sample is too,
// cut their camera.
this.wasTooDark = true;
}
} else {
this.wasTooDark = false;
}
},
getAverageRGB(ctx) {
// Helper function to compute the average color of a <canvas>.
// Ref: https://stackoverflow.com/a/2541680
const blockSize = 16; // only visit every N pixels
let img = null,
rgb = [0, 0, 0];
try {
img = ctx.getImageData(0, 0, WebcamWidth, WebcamHeight);
} catch(e) {
// Not supported.
return null;
}
let length = img.data.length,
i = 0,
count = 0,
firstColor = [],
allSame = true;
while ((i += blockSize * 4) < length) {
count++;
let thisColor = [
img.data[i],
img.data[i+1],
img.data[i+2]
]
rgb[0] += thisColor[0];
rgb[1] += thisColor[1];
rgb[2] += thisColor[2];
// Also check whether every sampled pixel is THE SAME color,
// to detect users broadcasting a solid (bright) color.
if (firstColor.length === 0) {
firstColor = [ rgb[0], rgb[1], rgb[2] ];
} else if (allSame) {
if (firstColor[0] !== thisColor[0] ||
firstColor[1] !== thisColor[1] ||
firstColor[2] !== thisColor[2]
) {
allSame = false;
}
}
}
// If all sampled colors were the same solid image: red flag!
if (allSame) {
return [0, 0, 0];
}
rgb[0] = Math.floor(rgb[0]/count);
rgb[1] = Math.floor(rgb[1]/count);
rgb[2] = Math.floor(rgb[2]/count);
return rgb;
},
initHistory(channel) { initHistory(channel) {
if (this.channels[channel] == undefined) { if (this.channels[channel] == undefined) {
this.channels[channel] = { this.channels[channel] = {
@ -2811,6 +2970,9 @@ export default {
} else if (this.imageDisplaySetting === "collapse") { } else if (this.imageDisplaySetting === "collapse") {
// Put a collapser link. // Put a collapser link.
let collapseID = `collapse-${messageID}`; let collapseID = `collapse-${messageID}`;
if (!messageID) {
collapseID = "collapse-missingno-" + parseInt(Math.random()*100000);
}
message = ` message = `
<a href="#" id="img-show-${collapseID}" <a href="#" id="img-show-${collapseID}"
class="button is-outlined is-small is-info" class="button is-outlined is-small is-info"