# On-device content moderation with Zoom Video SDK Accessing real-time raw media data is now easy with Zoom Video SDK media processors. Media processors run inside web workers, receiving raw audio and video data frames directly from the SDK. Developers can modify the frame data before it's rendered and sent to other users in a session. We can use this feature with WebGL and an AI model in Tensorflow.js to build on device video moderation. Let's implement a video processor using Tensorflow.js for object detection. We'll create a WebGL shader to blur out the selected objects in the video frame. ## Prerequisites - Node & NPM LTS - Zoom Video SDK Account We've explaining how video processors work in a [previous blog](/blog/customize-video-stream-with-watermarks) by building a simpler processor for video watermarks. You can read through it learn more. Like in the previous blog we'll build on top of the [Zoom Video SDK quickstart guide](/blog/build-a-video-conferencing-app-with-the-zoom-video-sdk). If you're new to the SDK, we recommend checking out the quickstart guide first. You can clone that repo and follow the steps to get started: ```bash git clone https://github.com/zoom/videosdk-web-helloworld ``` The completed code for this app can be found on [GitHub](https://github.com/zoom/videosdk-web-videoprocessor-contentmoderation). ## Configuring the video processor To start, we need to create a javascript file that will implement our `VideoProcessor` class. We'll call it `od-quad.js` and store it in the `public` folder. The processor will access and process the raw media data. ```bash ├── index.html ├── node_modules ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── coi-serviceworker.js │ ├── favicon.svg │ └── od-quad.js ├── src │ ├── main.ts │ ├── style.css │ ├── utils.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts ``` Next, we'll implement the skeleton for our Video Processor: ```js class BlurProcessor extends VideoProcessor { constructor(port, options) {} async onInit() {} onUninit() {} async processFrame(input, output) {} } registerProcessor("gl-processor", BlurProcessor); ``` The `BlurProcessor` class extends the `VideoProcessor` class. The class comes equipped with `onInit` and `onUninit` methods that are lifecycle functions triggered when the processor initializes or shuts down. The `processFrame` function defines how to process each video frame. The SDK calls this method for every video frame, taking a `VideoFrame` as input and modifying the `OffscreenCanvas` output. The method's return value determines whether the SDK applies the effect to the frame sent remotely. We then register this processor by calling `registerProcessor` function. In `main.ts` we can instantiate this processor with the following: ```js const processor = await mediaStream.createProcessor({ name: "gl-processor", type: "video", url: window.location.origin + "/od-quad.js", }); // Add the processor await mediaStream.addProcessor(processor); ``` Now that our processor has been added, we can implement the object detection and blurring logic in the processor. ## Tensorflow for object detection [Tensorflow.js](https://www.tensorflow.org/js) is an open-source JavaScript library developed by Google for machine learning. It allows developers to define, train, and run machine learning models directly in the web browser. We'll use this as the driver for our AI model. We'll use a pre-trained version of the [MobileNetV2 COCO-SSD]([https://github.com/tensorflow/tfjs-models/tree/master/coco-ssd]) model since it's a lightweight, efficient, and fast objection detection model. In `od-quad.js` import Tensorflow.js and the coco-ssd model using the `importScripts` function ```js importScripts("https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"); importScripts("https://cdn.jsdelivr.net/npm/@tensorflow-models/coco-ssd"); ``` Now we'll implement the model within the body of the class: ```js class BlurProcessor extends VideoProcessor { model = null; ctx = null; constructor(port, options) { super(port, options); } async onInit() { const output = this.getOutput(); this.model = await cocoSsd.load(); if (!output) return; this.ctx = output.getContext("2d"); } onUninit() {} async processFrame(input, output) { const bitmap = await createImageBitmap(input); const prediction = await this.model.detect( tf.browser.fromPixels(bitmap), ); this.ctx.drawImage(input, 0, 0, output.width, output.height); this.ctx.strokeStyle = "blue"; this.ctx.lineWidth = 2; prediction.forEach((obj) => { const [x, y, width, height, label, score] = [ obj.bbox[0], obj.bbox[1], obj.bbox[2], obj.bbox[3], obj.class, obj.score, ]; this.ctx.fillText( `${label} - ${Math.round(score * 100)}%`, x + 5, y - 3, ); this.ctx.strokeRect(x, y, width, height); }); input.close(); bitmap.close(); return true; } } registerProcessor("gl-processor", BlurProcessor); ``` In the `onInit` function, we initialize the `model` attribute with the COCO SSD pre-trained model and retrieve the `CanvasRenderingContext2D` value. We'll use this to draw to the video canvas. In `processFrame`, each video frame sent to the processor is accessible through the input parameter. This function is called for every video frame. Our `model` takes in a `ImageBitmap` object and returns a `tf.Tensor` object that is given to model as a parameter to run predictions on. We use the `createImageBitmap` function to create a bitmap of our video frame and pass it to the `tf.browser.fromPixels` function to create the `Tensor`. The `Tensor` is then sent to the `detect` method that makes a prediction, returning an array of all detected objects with their `x` & `y` coordinates, a `label` for classification, and a confidence `score`. With this information we can use the `ctx` object to draw a box around the detected objects in the canvas along with a tag name and score. Testing this processor yields the below result: ![](/img/blog/ticorrianheard/objectdetection.png) ## WebGL shaders for Gaussian blur Now that we have bject detection implemented, we can modify our processor to use WebGL. WebGL allows for graphics processing in the browser by using the GPU for hardware acceleration. We'll keep the explanation of Gaussian blur concise as the implementation for this is beyond the scope of this blog. The Gaussian blur implementation will take in the video frame and makes two mathematical passes, horizontal and then vertical, modifying the pixel values of the image. This achieves the blur effect. We take that modified blurred image and the original pre-blurred image as inputs to create a final image where each of the pixels is either from the blurred image or the original image based on the detected objects x and y coordinates. To achieve this, we implement a vertex shader that will use three fragment shaders. The vertex shader sets the coordinates of the video canvas we provide in a buffer and the coordinates of a texture we obtain from the video frame image. It then passes the texture coordinates to the fragment shaders. The 3 fragment shaders handle the horizontal region blur, the vertical region blur, and the creation of the composite image with the region blurs included. They each use the coordinates from the video frame texture to modify each pixel in that given area. Here are the shaders implementation: ```js // Vertex shader: fullscreen quad const vertSrc = `#version 300 es in vec2 a_position; in vec2 a_texCoord; out vec2 v_uv; void main() { v_uv = a_texCoord; gl_Position = vec4(a_position, 0.0, 1.0); }`; // Fragment shader: horizontal Gaussian blur const blurFragSrcH = `#version 300 es precision mediump float; in vec2 v_uv; uniform sampler2D u_texture; uniform float u_texelOffset; uniform float u_weights[9]; out vec4 outColor; void main() { vec2 off = vec2(u_texelOffset, 0.0); vec4 sum = texture(u_texture, v_uv) * u_weights[0]; for (int i = 1; i < 9; ++i) { sum += texture(u_texture, v_uv + off * float(i)) * u_weights[i]; sum += texture(u_texture, v_uv - off * float(i)) * u_weights[i]; } outColor = sum; }`; // Fragment shader: vertical Gaussian blur const blurFragSrcV = `#version 300 es precision mediump float; in vec2 v_uv; uniform sampler2D u_texture; uniform float u_texelOffset; uniform float u_weights[9]; out vec4 outColor; void main() { vec2 off = vec2(0.0, u_texelOffset); vec4 sum = texture(u_texture, v_uv) * u_weights[0]; for (int i = 1; i < 9; ++i) { sum += texture(u_texture, v_uv + off * float(i)) * u_weights[i]; sum += texture(u_texture, v_uv - off * float(i)) * u_weights[i]; } outColor = sum; }`; // Fragment shader: composite original and blurred based on multiple regions const compositeFragSrc = `#version 300 es precision mediump float; in vec2 v_uv; uniform sampler2D u_orig; uniform sampler2D u_blur; uniform vec4 u_regions[10]; // Support up to 10 regions uniform int u_regionCount; out vec4 outColor; void main() { bool shouldBlur = false; // Check if current pixel is in any of the blur regions for (int i = 0; i < u_regionCount; i++) { if (v_uv.x >= u_regions[i].x && v_uv.x <= u_regions[i].x + u_regions[i].z && v_uv.y >= u_regions[i].y && v_uv.y <= u_regions[i].y + u_regions[i].w) { shouldBlur = true; break; } } if (shouldBlur) { outColor = texture(u_blur, v_uv); } else { outColor = texture(u_orig, v_uv); } }`; ``` We've omitted the initialization of WebGL2 and execution of the shaders but that can be referenced [here](https://github.com/zoom/videosdk-web-videoprocessor-contentmoderation/blob/main/public/od-quad.js). We combine this with our Tensorflow.js model by collecting the x and y coordinates of the detected objects and providing this data to the vertex shader. The composite shader runs a conditional that will either use a texture obtained from the vertical and horizontal shaders to apply a blur effect to the pixel or use the original image texture to apply a unblurred effect to the pixel. ```js try { tensor = tf.browser.fromPixels(bitmap); prediction = await this.model.detect(tensor); // Vertical Blur Logic // Horizontal Blur Logic // Composite gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.useProgram(this.progC); this.regions = []; prediction.forEach((obj) => { const [x, y, width, height, label, score] = [ obj.bbox[0], obj.bbox[1], obj.bbox[2], obj.bbox[3], obj.class, obj.score, ]; if (moderationLabels.includes(label)) { this.regions.push([ x / this.width, y / this.height, width / this.width, height / this.height, ]); } }); // console.log(`Total regions to blur: ${this.regions.length}`); const maxRegions = Math.min(this.regions.length, 10); if (maxRegions > 0) { const regionData = new Float32Array(maxRegions * 4); for (let i = 0; i < maxRegions; i++) { regionData[i * 4] = this.regions[i][0]; // x regionData[i * 4 + 1] = this.regions[i][1]; // y regionData[i * 4 + 2] = this.regions[i][2]; // width regionData[i * 4 + 3] = this.regions[i][3]; // height } try { gl.uniform4fv(this.uRegions, regionData); gl.uniform1i(this.uRegionCount, maxRegions); } catch (error) { console.error("Error setting WebGL uniforms:", error); } } else { // console.log("No regions to blur"); gl.uniform1i(this.uRegionCount, 0); } gl.uniform1i(this.uOrig, 0); gl.uniform1i(this.uBlur, 1); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.texOrig); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.tex2); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); gl.bindVertexArray(null); // console.log("Prediction", prediction) } finally { if (tensor) { tensor.dispose(); tensor = null; } if (bitmap) { bitmap.close(); } if (input) { input.close(); } prediction = null; } ``` ## Testing our content moderation video processor With the Gaussian blur and Tensorflow model implemented, we can expect that certain detected objects, specified in our content filter array, should be blurred out in real-time. Other objects will be ignored and displayed as normal. Here's how that looks: ![](/img/blog/ticorrianheard/blurredphone.png) ![](/img/blog/ticorrianheard/blurredplant.png) As you can see, Zoom Video SDK video processors combined with the right tools can take your meeting experience to the next level. This feature offers fully unique and customizable use cases only limited to the creativity of the developer. To dive deeper, check out our [raw-data documentation](/docs/video-sdk/web/raw-data) and explore the [sample processor repo](https://github.com/zoom/videosdk-web-processor-sample/tree/main) for more inspiration.