{ "cells": [ { "cell_type": "markdown", "id": "b8e61c5c-4f3e-4980-be22-4010a247babd", "metadata": {}, "source": [ "## Streaming Video: Live video streams made easy\n", "\n", "In this example we will demonstrate how to develop a general tool to transform live image streams from the users web cam. We will be using Panels\n", "[`VideoStream`](https://panel.holoviz.org/reference/widgets/VideoStream.html) widget to record and stream the images.\n", "\n", "![VideoStreamInterfaceSobel](../../assets/VideoStreamInterfaceSobel.jpg)\n", "\n", "We will also show how to apply *blur*, *grayscale*, *sobel* and *face recognition* models to the video stream." ] }, { "cell_type": "markdown", "id": "5a3760b6-f8a7-46ad-8831-620a131c1512", "metadata": {}, "source": [ "## Imports and Settings\n", "\n", "Among other things we will be using [numpy](https://numpy.org/), [PIL](https://pillow.readthedocs.io/en/stable/) and [scikit-image](https://scikit-image.org/) to work with the images." ] }, { "cell_type": "code", "execution_count": null, "id": "02b862bc-f710-456b-9a34-f1b74a76df2c", "metadata": {}, "outputs": [], "source": [ "import base64\n", "import io\n", "import time\n", "\n", "import numpy as np\n", "import param\n", "import PIL\n", "import skimage\n", "\n", "from PIL import Image, ImageFilter\n", "from skimage import data, filters\n", "from skimage.color.adapt_rgb import adapt_rgb, each_channel\n", "from skimage.draw import rectangle\n", "from skimage.exposure import rescale_intensity\n", "from skimage.feature import Cascade\n", "\n", "import panel as pn\n", "\n", "pn.extension(sizing_mode=\"stretch_width\")" ] }, { "cell_type": "markdown", "id": "43e68289-6c33-435f-b279-cf89327266ac", "metadata": {}, "source": [ "We define the *height* and *width* of the images to transform. Smaller is faster.\n", "We also define the *timeout*, i.e. how often the videostream takes and streams a new image." ] }, { "cell_type": "code", "execution_count": null, "id": "7b46542f-fa1a-4dde-a8c6-f023f3358a6c", "metadata": {}, "outputs": [], "source": [ "HEIGHT = 500 # pixels\n", "WIDTH = 500 # pixels\n", "TIMEOUT = 500 # miliseconds" ] }, { "cell_type": "markdown", "id": "bb8b1066-c799-4d6a-94d0-361dd3cc6007", "metadata": {}, "source": [ "## Base Image Models\n", "\n", "We will need to define some *base image models* components. The base models are custom Panel components that inherit from Panels [`Viewer`](https://panel.holoviz.org/user_guide/Custom_Components.html#viewer-components) class.\n", "\n", "The *base models* makes it easy to later turn *image to image* algorithms into interactive UIs like the `FaceDetectionModel` shown in the image just below.\n", "\n", "![VideoStreamInterfaceTimer](../../assets/VideoStreamInterfaceFaceDetectionViewer.jpg)\n", "\n", "Please note we restrict our selves to working with `.jpg` images. The `VideoStream` widget also support `.png` images. But `.png` images are much bigger and slower to work with." ] }, { "cell_type": "code", "execution_count": null, "id": "a507eb66-b242-441c-8d89-dba504ec79a3", "metadata": {}, "outputs": [], "source": [ "class ImageModel(pn.viewable.Viewer):\n", " \"\"\"Base class for image models.\"\"\"\n", "\n", " def __init__(self, **params):\n", " super().__init__(**params)\n", "\n", " with param.edit_constant(self):\n", " self.name = self.__class__.name.replace(\"Model\", \"\")\n", " self.view = self.create_view()\n", "\n", " def __panel__(self):\n", " return self.view\n", "\n", " def apply(self, image: str, height: int = HEIGHT, width: int = WIDTH) -> str:\n", " \"\"\"Transforms a base64 encoded jpg image to a base64 encoded jpg BytesIO object\"\"\"\n", " raise NotImplementedError()\n", "\n", " def create_view(self):\n", " \"\"\"Creates a view of the parameters of the transform to enable the user to configure them\"\"\"\n", " return pn.Param(self, name=self.name)\n", "\n", " def transform(self, image):\n", " \"\"\"Transforms the image\"\"\"\n", " raise NotImplementedError()" ] }, { "cell_type": "markdown", "id": "be8256eb-f2bb-4f24-84dc-8b6b47d64958", "metadata": {}, "source": [ "Lets define a base model for working with **`PIL`** images" ] }, { "cell_type": "code", "execution_count": null, "id": "5ebf5560-29f6-4b3f-815e-a70010998ba1", "metadata": {}, "outputs": [], "source": [ "class PILImageModel(ImageModel):\n", " \"\"\"Base class for PIL image models\"\"\"\n", "\n", " @staticmethod\n", " def to_pil_img(value: str, height=HEIGHT, width=WIDTH):\n", " \"\"\"Converts a base64 jpeg image string to a PIL.Image\"\"\"\n", " encoded_data = value.split(\",\")[1]\n", " base64_decoded = base64.b64decode(encoded_data)\n", " image = Image.open(io.BytesIO(base64_decoded))\n", " image.draft(\"RGB\", (height, width))\n", " return image\n", "\n", " @staticmethod\n", " def from_pil_img(image: Image):\n", " \"\"\"Converts a PIL.Image to a base64 encoded JPG BytesIO object\"\"\"\n", " buff = io.BytesIO()\n", " image.save(buff, format=\"JPEG\")\n", " return buff\n", "\n", " def apply(self, image: str, height: int = HEIGHT, width: int = WIDTH) -> io.BytesIO:\n", " pil_img = self.to_pil_img(image, height=height, width=width)\n", "\n", " transformed_image = self.transform(pil_img)\n", "\n", " return self.from_pil_img(transformed_image)\n", "\n", " def transform(self, image: PIL.Image) -> PIL.Image:\n", " \"\"\"Transforms the PIL.Image image\"\"\"\n", " raise NotImplementedError()" ] }, { "cell_type": "markdown", "id": "63cff6f1-f652-4413-b230-b4602e115560", "metadata": {}, "source": [ "Lets define a base model for working with **`Numpy`** images." ] }, { "cell_type": "code", "execution_count": null, "id": "128c8e1e-8695-4710-a7c1-40476ced300a", "metadata": {}, "outputs": [], "source": [ "class NumpyImageModel(ImageModel):\n", " \"\"\"Base class for np.ndarray image models\"\"\"\n", "\n", " @staticmethod\n", " def to_np_ndarray(image: str, height=HEIGHT, width=WIDTH) -> np.ndarray:\n", " \"\"\"Converts a base64 encoded jpeg string to a np.ndarray\"\"\"\n", " pil_img = PILImageModel.to_pil_img(image, height=height, width=width)\n", " return np.array(pil_img)\n", "\n", " @staticmethod\n", " def from_np_ndarray(image: np.ndarray) -> io.BytesIO:\n", " \"\"\"Converts np.ndarray jpeg image to a jpeg BytesIO instance\"\"\"\n", " if image.dtype == np.dtype(\"float64\"):\n", " image = (image * 255).astype(np.uint8)\n", " pil_img = PIL.Image.fromarray(image)\n", " return PILImageModel.from_pil_img(pil_img)\n", "\n", " def apply(self, image: str, height: int = HEIGHT, width: int = WIDTH) -> io.BytesIO:\n", " np_array = self.to_np_ndarray(image, height=height, width=width)\n", "\n", " transformed_image = self.transform(np_array)\n", "\n", " return self.from_np_ndarray(transformed_image)\n", "\n", " def transform(self, image: np.ndarray) -> np.ndarray:\n", " \"\"\"Transforms the np.array image\"\"\"\n", " raise NotImplementedError()" ] }, { "cell_type": "markdown", "id": "77bf1e99-701e-4dad-bd8a-faba4bdd446a", "metadata": {}, "source": [ "## Timer\n", "\n", "Lets define a timer component to visualize the stats of the live videostream and the image transformations\n", "\n", "![VideoStreamInterfaceTimer](../../assets/VideoStreamInterfaceTimer.jpg)" ] }, { "cell_type": "code", "execution_count": null, "id": "92a4ec91-3a6d-43be-8337-690614bb10cb", "metadata": {}, "outputs": [], "source": [ "class Timer(pn.viewable.Viewer):\n", " \"\"\"Helper Component used to show duration trends\"\"\"\n", "\n", " _trends = param.Dict()\n", "\n", " def __init__(self, **params):\n", " super().__init__()\n", "\n", " self.last_updates = {}\n", " self._trends = {}\n", "\n", " self._layout = pn.Row(**params)\n", "\n", " def time_it(self, name, func, *args, **kwargs):\n", " \"\"\"Measures the duration of the execution of the func function and reports it under the\n", " name specified\"\"\"\n", " start = time.time()\n", " result = func(*args, **kwargs)\n", " end = time.time()\n", " duration = round(end - start, 2)\n", " self._report(name=name, duration=duration)\n", " return result\n", "\n", " def inc_it(self, name):\n", " \"\"\"Measures the duration since the last time `inc_it` was called and reports it under the\n", " specified name\"\"\"\n", " start = self.last_updates.get(name, time.time())\n", " end = time.time()\n", " duration = round(end - start, 2)\n", " self._report(name=name, duration=duration)\n", " self.last_updates[name] = end\n", "\n", " def _report(self, name, duration):\n", " if not name in self._trends:\n", " self._trends[name] = pn.indicators.Trend(\n", " title=name,\n", " data={\"x\": [1], \"y\": [duration]},\n", " height=100,\n", " width=150,\n", " sizing_mode=\"fixed\",\n", " )\n", " self.param.trigger(\"_trends\")\n", " else:\n", " trend = self._trends[name]\n", " next_x = max(trend.data[\"x\"]) + 1\n", " trend.stream({\"x\": [next_x], \"y\": [duration]}, rollover=10)\n", "\n", " @pn.depends(\"_trends\")\n", " def _panel(self):\n", " self._layout[:] = list(self._trends.values())\n", " return self._layout\n", "\n", " def __panel__(self):\n", " return pn.panel(self._panel)" ] }, { "cell_type": "markdown", "id": "44851482-f580-49a7-ac53-2132d3f29bc0", "metadata": {}, "source": [ "## VideoStreamInterface\n", "\n", "The `VideoStreamInterface` will be putting things together in a nice UI.\n", "\n", "![VideoStreamInterfaceSobel](../../assets/VideoStreamInterfaceSobel.jpg)\n", "\n", "Lets define a helper function first" ] }, { "cell_type": "code", "execution_count": null, "id": "b4de82da-81e6-4799-a1d3-a54bf8e017b1", "metadata": {}, "outputs": [], "source": [ "def to_instance(value, **params):\n", " \"\"\"Converts the value to an instance\n", "\n", " Args:\n", " value: A param.Parameterized class or instance\n", "\n", " Returns:\n", " An instance of the param.Parameterized class\n", " \"\"\"\n", " if isinstance(value, param.Parameterized):\n", " value.param.update(**params)\n", " return value\n", " return value(**params)" ] }, { "cell_type": "markdown", "id": "d74ba396-7972-4711-8ff5-b4d27b31120c", "metadata": {}, "source": [ "The `VideoStreamInterface` will take a list of `ImageModel`s. The user can the select and apply the models to the images from the `VideoStream`." ] }, { "cell_type": "code", "execution_count": null, "id": "c6dc71b7-34b2-4171-8a72-46ab9edeba91", "metadata": {}, "outputs": [], "source": [ "class VideoStreamInterface(pn.viewable.Viewer):\n", " \"\"\"An easy to use interface for a VideoStream and a set of transforms\"\"\"\n", "\n", " video_stream = param.ClassSelector(\n", " class_=pn.widgets.VideoStream, constant=True, doc=\"The source VideoStream\"\n", " )\n", "\n", " height = param.Integer(\n", " HEIGHT,\n", " bounds=(10, 2000),\n", " step=10,\n", " doc=\"\"\"The height of the image converted and shown\"\"\",\n", " )\n", " width = param.Integer(\n", " WIDTH,\n", " bounds=(10, 2000),\n", " step=10,\n", " doc=\"\"\"The width of the image converted and shown\"\"\",\n", " )\n", "\n", " model = param.Selector(doc=\"The currently selected model\")\n", "\n", " def __init__(\n", " self,\n", " models,\n", " timeout=TIMEOUT,\n", " paused=False,\n", " **params,\n", " ):\n", " super().__init__(\n", " video_stream=pn.widgets.VideoStream(\n", " name=\"Video Stream\",\n", " timeout=timeout,\n", " paused=paused,\n", " height=0,\n", " width=0,\n", " visible=False,\n", " format=\"jpeg\",\n", " ),\n", " **params,\n", " )\n", " self.image = pn.pane.JPG(\n", " height=self.height, width=self.width, sizing_mode=\"fixed\"\n", " )\n", " self._updating = False\n", " models = [to_instance(model) for model in models]\n", " self.param.model.objects = models\n", " self.model = models[0]\n", " self.timer = Timer(sizing_mode=\"stretch_width\")\n", " self.settings = self._create_settings()\n", " self._panel = self._create_panel()\n", "\n", " def _create_settings(self):\n", " return pn.Column(\n", " pn.Param(\n", " self.video_stream,\n", " parameters=[\"timeout\", \"paused\"],\n", " widgets={\n", " \"timeout\": {\n", " \"widget_type\": pn.widgets.IntSlider,\n", " \"start\": 10,\n", " \"end\": 2000,\n", " \"step\": 10,\n", " }\n", " },\n", " ),\n", " self.timer,\n", " pn.Param(self, parameters=[\"height\", \"width\"], name=\"Image\"),\n", " pn.Param(\n", " self,\n", " parameters=[\"model\"],\n", " expand_button=False,\n", " expand=False,\n", " widgets={\n", " \"model\": {\n", " \"widget_type\": pn.widgets.RadioButtonGroup,\n", " \"orientation\": \"vertical\",\n", " \"button_type\": \"success\",\n", " }\n", " },\n", " name=\"Model\",\n", " ),\n", " self._get_transform,\n", " )\n", "\n", " def _create_panel(self):\n", " return pn.Row(\n", " self.video_stream,\n", " pn.layout.HSpacer(),\n", " self.image,\n", " pn.layout.HSpacer(),\n", " sizing_mode=\"stretch_width\",\n", " align=\"center\",\n", " )\n", "\n", " @pn.depends(\"height\", \"width\", watch=True)\n", " def _update_height_width(self):\n", " self.image.height = self.height\n", " self.image.width = self.width\n", "\n", " @pn.depends(\"model\")\n", " def _get_transform(self):\n", " # Hack: returning self.transform stops working after browsing the transforms for a while\n", " return self.model.view\n", "\n", " def __panel__(self):\n", " return self._panel\n", "\n", " @pn.depends(\"video_stream.value\", watch=True)\n", " def _handle_stream(self):\n", " if self._updating:\n", " return\n", "\n", " self._updating = True\n", " if self.model and self.video_stream.value:\n", " value = self.video_stream.value\n", " try:\n", " image = self.timer.time_it(\n", " name=\"Model\",\n", " func=self.model.apply,\n", " image=value,\n", " height=self.height,\n", " width=self.width,\n", " )\n", " self.image.object = image\n", " except PIL.UnidentifiedImageError:\n", " print(\"unidentified image\")\n", "\n", " self.timer.inc_it(\"Last Update\")\n", " self._updating = False" ] }, { "cell_type": "markdown", "id": "9afa4c9b-a541-4a77-bb38-68d85f0a82d4", "metadata": {}, "source": [ "## Custom Image Models\n", "\n", "We will now make specific image to image algorithms interactive.\n", "\n", "Let us start with the [Gaussian Blur](https://pillow.readthedocs.io/en/stable/reference/ImageFilter.html#PIL.ImageFilter.GaussianBlur) algorithm." ] }, { "cell_type": "code", "execution_count": null, "id": "f389b022-28b0-4ce2-a629-0df728a235bb", "metadata": {}, "outputs": [], "source": [ "class GaussianBlurModel(PILImageModel):\n", " \"\"\"Gaussian Blur Model\n", "\n", " https://pillow.readthedocs.io/en/stable/reference/ImageFilter.html#PIL.ImageFilter.GaussianBlur\n", " \"\"\"\n", "\n", " radius = param.Integer(default=2, bounds=(0, 10))\n", "\n", " def transform(self, image: Image):\n", " return image.filter(ImageFilter.GaussianBlur(radius=self.radius))" ] }, { "cell_type": "markdown", "id": "42d93547-bfce-4deb-a7e5-387cab9fac8e", "metadata": {}, "source": [ "Lets implement a [Grayscale](https://scikit-image.org/docs/0.15.x/auto_examples/color_exposure/plot_rgb_to_gray.html) algorithm." ] }, { "cell_type": "code", "execution_count": null, "id": "13f7f769-af74-4027-be8d-a22208e31800", "metadata": {}, "outputs": [], "source": [ "class GrayscaleModel(NumpyImageModel):\n", " \"\"\"GrayScale Model\n", "\n", " https://scikit-image.org/docs/0.15.x/auto_examples/color_exposure/plot_rgb_to_gray.html\n", " \"\"\"\n", "\n", " def transform(self, image: np.ndarray):\n", " grayscale = skimage.color.rgb2gray(image[:, :, :3])\n", " return skimage.color.gray2rgb(grayscale)" ] }, { "cell_type": "markdown", "id": "b3461593-e63d-424a-b402-cbd6fcc75ffe", "metadata": {}, "source": [ "Lets implement the [Sobel](https://scikit-image.org/docs/0.15.x/auto_examples/color_exposure/plot_adapt_rgb.html) algorithm." ] }, { "cell_type": "code", "execution_count": null, "id": "0991dd1c-0557-4dfa-b940-7918b0363d6c", "metadata": {}, "outputs": [], "source": [ "class SobelModel(NumpyImageModel):\n", " \"\"\"Sobel Model\n", "\n", " https://scikit-image.org/docs/0.15.x/auto_examples/color_exposure/plot_adapt_rgb.html\n", " \"\"\"\n", " def transform(self, image):\n", "\n", "\n", " @adapt_rgb(each_channel)\n", " def sobel_each(image):\n", " return filters.sobel(image)\n", "\n", " return rescale_intensity(1 - sobel_each(image))" ] }, { "cell_type": "markdown", "id": "cadae0c9-0191-4ca4-a770-c3be0b0987b7", "metadata": {}, "source": [ "Lets implement the [face detection model](https://scikit-image.org/docs/0.15.x/auto_examples/applications/plot_face_detection.html) of scikit-image." ] }, { "cell_type": "code", "execution_count": null, "id": "57423cdc-e23e-4236-ace8-452e8068970c", "metadata": {}, "outputs": [], "source": [ "@pn.cache()\n", "def get_detector():\n", " \"\"\"Returns the Cascade detector\"\"\"\n", " trained_file = data.lbp_frontal_face_cascade_filename()\n", " return Cascade(trained_file)" ] }, { "cell_type": "code", "execution_count": null, "id": "5baa7f38-6368-4ed1-883c-7a0247d1c08a", "metadata": {}, "outputs": [], "source": [ "class FaceDetectionModel(NumpyImageModel):\n", " \"\"\"Face detection using a cascade classifier.\n", "\n", " https://scikit-image.org/docs/0.15.x/auto_examples/applications/plot_face_detection.html\n", " \"\"\"\n", "\n", " scale_factor = param.Number(1.4, bounds=(1.0, 2.0), step=0.1)\n", " step_ratio = param.Integer(1, bounds=(1, 10))\n", " size_x = param.Range(default=(60, 322), bounds=(10, 500))\n", " size_y = param.Range(default=(60, 322), bounds=(10, 500))\n", "\n", " def transform(self, image):\n", " detector = get_detector()\n", " detected = detector.detect_multi_scale(\n", " img=image,\n", " scale_factor=self.scale_factor,\n", " step_ratio=self.step_ratio,\n", " min_size=(self.size_x[0], self.size_y[0]),\n", " max_size=(self.size_x[1], self.size_y[1]),\n", " )\n", "\n", " for patch in detected:\n", " rrr, ccc = rectangle(\n", " start=(patch[\"r\"], patch[\"c\"]),\n", " extent=(patch[\"height\"], patch[\"width\"]),\n", " shape=image.shape[:2],\n", " )\n", " image[rrr, ccc, 0] = 200\n", "\n", " return image" ] }, { "cell_type": "markdown", "id": "253e15c1-0097-456f-b979-fbf42de8d270", "metadata": {}, "source": [ "Please note that these models are just examples. You can also implement your own models using Scikit-Image, Pytorch, Tensorflow etc and use the `VideoStreamInterface` to work interactively with them." ] }, { "cell_type": "markdown", "id": "1e6ffe9c-7885-4cb8-9793-97c00862e8ba", "metadata": {}, "source": [ "## Its alive!\n", "\n", "Lets define an instance of the `VideoStreamInterface`" ] }, { "cell_type": "code", "execution_count": null, "id": "47d8118f-77b0-4edf-a733-2b9ba877e25e", "metadata": {}, "outputs": [], "source": [ "component = VideoStreamInterface(\n", " models=[\n", " GaussianBlurModel,\n", " GrayscaleModel,\n", " SobelModel,\n", " FaceDetectionModel,\n", " ]\n", ")\n", "pn.Row(pn.Row(component.settings, max_width=400), component)" ] }, { "cell_type": "markdown", "id": "651ea40a-4d91-4a17-8761-c8b5cc446197", "metadata": {}, "source": [ "## Wrap it in a template\n", "\n", "What makes Panel unique is that our components work very well in both the notebook and as standalone data apps.\n", "\n", "We can wrap the component in the nicely styled [`FastListTemplate`](https://panel.holoviz.org/reference/templates/FastListTemplate.html) to make it *ready for production*." ] }, { "cell_type": "code", "execution_count": null, "id": "dab6006d-001d-4217-ade7-ff2d0d77752f", "metadata": {}, "outputs": [], "source": [ "pn.template.FastListTemplate(\n", " site=\"Panel\",\n", " title=\"VideoStream Interface\",\n", " sidebar=[component.settings],\n", " main=[component],\n", ").servable(); # We add ; to not show the template in the notebook as it does not display well." ] }, { "cell_type": "markdown", "id": "9b00da3e-87fd-4e3e-bc81-e8bf827e2818", "metadata": {}, "source": [ "## Serve it as a server side app" ] }, { "cell_type": "markdown", "id": "7191262b-f610-494f-a797-9186a496c633", "metadata": {}, "source": [ "It is now possible to serve the live app via the command `panel serve streaming_videostream.ipynb`. The app is the available at http://localhost:5006/streaming_videostream.\n", "\n", "" ] }, { "cell_type": "markdown", "id": "01fb5b28-6e49-4aaa-a0ca-2eaa301106c2", "metadata": {}, "source": [ "## Serve it as a client side app\n", "\n", "You can also [`panel convert`](https://panel.holoviz.org/user_guide/Running_in_Webassembly.html) this app to web assembly for even better performance.\n", "\n", "First you will need to create a `requirements.txt` file with the following content\n", "\n", "```bash\n", "panel\n", "numpy\n", "scikit-image\n", "```\n", "\n", "Then you can\n", "\n", "- Run `panel convert streaming_videostream.ipynb --to pyodide-worker --out pyodide --requirements requirements.txt`\n", "- Run `python3 -m http.server` to start a web server locally\n", "- Open http://localhost:8000/pyodide/streaming_videostream.html to try out the app." ] } ], "metadata": { "language_info": { "name": "python", "pygments_lexer": "ipython3" } }, "nbformat": 4, "nbformat_minor": 5 }