Changes between Version 16 and Version 17 of expansion/gw16168


Ignore:
Timestamp:
08/04/2026 11:02:10 PM (31 hours ago)
Author:
Tim Harvey
Comment:

update for NXP Ara 2.1.1 runtime release

Legend:

Unmodified
Added
Removed
Modified
  • expansion/gw16168

    v16 v17  
    7575= NXP Ara240 DNPU AI Accelerator Quick Start
    7676
    77 == Using NXP deb distribution packages
    78 Currently NXP is distributing the Ara2 runtime in binary form. They have released the kernel driver as opensource which resolves kernel compatibility issues which is a huge step but the userspace apps and libraries remain dynamic linked binary objects.
    79 
    80 The current deb packages have some shortcomings:
    81  - packages are not very consistent; some have a systemd service in the data, others create one via postinst
    82  - they were intended to install on top of the NXP Embedded Linux Firmware (version L6.12.34-2.1.0) and intended to support only NXP dev kit boards so the dependencies are incomplete and don't match what would be on other Linux based root filesystems (Ubuntu system for example)
    83 
    84 If you extract the deb's and examine the DEBIAN directory you can see how to install them on other boards and root filesystems.
    85 
    86 It is fairly common for AI models to make use of python and NXP is using that here. The rt-sdk-ara2 includes a couple of Python Wheels that are used in the examples. A Python Wheel is a standard built-package format for distributing Python libraries. It is essentially a ZIP-format archive with a .whl extension that contains all the files needed for a package to run immediately after being. It's also standard when using Python to run into package version incompatibilities which is why user based Python virtual environments are used.
    87 
    88 Note the deb files require an NXP account to download (from [https://www.nxp.com/design/design-center/software/embedded-software/ara-software-development-kit:ARA-SDK NXP ARA SDK Landing page]) so the instructions below assume you have them already in the current directory.
    89 
    90 [=#rt-sdk-ara2]
    91 === rt-sdk-ara2
    92 The ara2 runtime should not really be considered an 'SDK' - it has nothing to do with software development, its simply the set of utils and libs needed to use the Ara2.
    93 
    94 The rt-sdk-ara2 provides a complete runtime environment for AI/ML acceleration using the Ara240 NPU on for aarch64. This package includes:
     77[=#ara2-runtime]
     78== Ara Runtime
     79NXP has a runtime library for the Ara240 which consists of some statically built libraries as well as a dynamic linked GStreamer plugin.
     80
     81The Ara runtime includes a couple of Python Wheels. A Python Wheel is a standard built-package format for distributing Python libraries. It is essentially a ZIP-format archive with a .whl extension that contains all the files needed for a package to run immediately after being. It's fairly standard when using Python to run into package version incompatibilities which is why user based Python virtual environments are used.
     82
     83The Ara runtime provides a complete runtime environment for AI/ML acceleration using the Ara240 NPU on for aarch64. This package includes:
    9584 * Runtime libraries for Ara240 NPU integration
    9685 * Python bindings (DVAPI) for custom inference applications
    9786 * Optimum-Ara framework for LLMs and VLMs
    98  * GStreamer plugins for Real-Time Detection Object Applications
     87 * GStreamer plugin for Real-Time Detection Object Applications
    9988 * Helper scripts for monitoring, benchmarking, and model management
    10089 * Systemd service for automatic hardware initialization
    10190
    10291Installation on a Gateworks board with Ubuntu based OS:
    103  - extract the debian 'data' (do not install the package!)
    104 {{{#!bash
    105 # extract data (but don't install)
    106 dpkg-deb --vextract rt-sdk-ara2_2.0.4.deb /
     92 - Download and extract the self-extracting binary from NXP:
     93{{{#!bash
     94VER=imx-nxp-ara2-2.1.1-063d56c
     95wget https://www.nxp.com/lgfiles/NMG/MAD/YOCTO/$VER.bin
     96sh $VER.bin
    10797}}}
    10898 - take care of postinst steps
    109   - miscelaneous
    110 {{{#!bash
    111 # create app dirs (used for models)
     99  - miscellaneous
     100{{{#!bash
     101# create dirs (used for models)
    112102mkdir -pv /usr/share/{cnn,llm}
    113 # get rid of circular symlink
    114 rm /usr/share/rt-sdk-ara240_2.0.4/rt-sdk-ara240_2.0.4
     103}}}
     104  - configure swap (necessary if using VLM)
     105{{{#!bash
     106/usr/bin/enable_swap 2
    115107}}}
    116108  - install uv package manager for Python virtualization and packaging for local user (which is installed to ~/.local/bin so we create symlinks to /usr/bin)
     
    121113ln -s /root/.local/bin/uvx /usr/bin/uvx
    122114}}}
    123   - build driver (the one in the deb is specific to the IMX BSP kernel)
     115  - build driver
    124116{{{#!bash
    125117apt update && apt install -y build-essential git bc file flex bison
     
    153145  - libara_vision_inference.so - inference lib that builds on libaraclient
    154146 - /usr/lib/gstreamer-1.0
    155   - libgstdvPre.so
    156   - libgstdvInfo.so
    157   - libgstdvPost.so
     147  - libgstdvInf.so - GStreamer plugin
    158148 - /usr/share/rt-sdk-ara240 (symlink to a version independent dir at same location)
    159149  - hw_utils/boot_img - firmware files
     
    176166
    177167Notes:
    178  - This will not program flash - that is a manual step only required if there is an update
    179168 - The 'uv' package manager is a fast all-in-one Python package and project manager written in Rust which makes it easy to work with virtual env's to avoid Python package version clashing which is essential
    180169 - on bootup make sure you wait for the console messages indicating the Proxy is launched before using it as it can take a couple of minutes
    181  - the binary tools and libs are all currently dynamic linked against stdlibc
    182  - the GStreamer libs require GStreamer 1.26 or newer
     170 - the binary tools and libs are all static linked for compatibility
     171 - the GStreamer libs require GStreamer 1.26 or newer and is dynamic linked
    183172
    184173Verification steps:
     
    221210[=#gstreamer]
    222211=== GStreamer plugins
    223 The rt-sdk-ara2 provides a set of gstreamer plugins for inference:
    224  - dvPre
    225  - dvInf
    226  - dvPost
    227 
    228 Without more documentation or source for these its likely best to think of them as: dvPre prepares buffers, dvInf hands them off to the NPU and dvPost processes the response.
    229 
    230 The dvPre element must have 32bit pixel samples (ie format=BGRA using 4 bytes per pixel, blue, green, red, alpha; alpha byte is completely empty padding data not used for transparency just as a structural spacer), not 24-bit format=RGB (3 bytes one for red, green, blue).
    231 
    232 All three elements require the model specified via the 'model' property. If using yolov8x for example you would specify the path to the yolov8x.dvm
    233 
    234 For detection models the dvPost element frame data will contain a buffer with number of bytes (32bit) followed by a series of detection structures containing the bounding box, confidence level, and COCO class ID of the object detected.
     212The Ara runtime provides an OpenSource GStreamer plugin for detection models:
     213 - [https://github.com/nxp-imx-support/gstreamer-plugins-ara240 dvInf]
     214
     215The plugin can sink 32bit pixel samples (ie format=BGRx using 4 bytes per pixel, blue, green, red, and a pading byte as a structural spacer)
     216
     217The model is specified via the 'model' property. If using yolov8x for example you would specify the path to the yolov8x.dvm
     218
     219For detection models the element frame data will contain a buffer with number of bytes (32bit) followed by a series of detection structures containing the bounding box, confidence level, and COCO class ID of the object detected.
    235220
    236221The units for the bounding box are relative to the models size and will need to be scaled back to your original image size. For example the YOLO models operate on 640x640 pixel data. You can pass something larger in and it will essentially tile but its unclear if there is an advantage of doing that.
    237222
    238 The gstreamer plugins are currently provided as binary only shared objects. They are linked against stdlibc (libc.so.6) and libgstreamer-1.0.so.0 and compatible with GStreamer 1.26 or newer.
    239 
    240 If you are using a rootfs that does not have GStreamer 1.26 you will need to build it or provide it via virtualization. For example Ubuntu 24.x Noble has GStreamer 1.24, Ubuntu 25.x has GStreamer 1.26 and Ubuntu 26.x Ocelot has GStreamer 1.28. So if you were running Ubuntu Noble you could use distrobox/docker to install GStreamer 1.26 and its dependencies using Ubuntu 25.x.
    241 
    242 Examples:
    243  * Ubuntu noble (24.04):
    244   - Ubuntu noble has GStreamer 1.24 which is not compatible with the 1.26 plugins
    245   - one solution could be a GStreamer 1.26 PPA backport but we have not found any
    246   - one solution is a containerized Ubuntu 25.04 container on Ubuntu 24.04 rootfs:
    247 {{{#!bash
    248 apt update && apt install -y distrobox docker.io
    249 # Create a 25.04 container that can see your hardware
    250 distrobox create --image ubuntu:25.04 --name gst126 \  --volume /usr/lib/gstreamer-1.0:/opt/ara2/plugins:ro \
    251   --volume /usr/lib:/opt/ara2/libs:ro \
    252   --volume /usr/share/cnn:/usr/share/cnn \
    253   --volume /usr/share/llm:/usr/share/llm \
    254   --volume /dev/bus/usb:/dev/bus/usb
    255 # enter the container to use it
    256 distrobox enter gst126
    257 # export vars via ~/.bashrc (exit and enter the distrobox to take effect)
    258 echo "export GST_PLUGIN_PATH=/opt/ara2/plugins" >> ~/.bashrc
    259 echo "export LD_LIBRARY_PATH=/opt/ara2/libs:\$LD_LIBRARY_PATH" >> ~/.bashrc
    260 }}}
    261    - whenever using the ARA plugins you will need to make sure you do so in the gst126 environment
    262    - the volume param creates bind mounts between the host and the virtual target
    263    - you can also always access the host rootfs via /run/host
    264    - also make sure you install gstreamer and anything that uses it within that virtual environment
    265    - this uses virtualization, not emulation - there is no performance hit or latency added, its just a different set of executables
    266    - disk space for the ubuntu 25.04 base above is about 1.54GB
    267  * Ubuntu 26.04 resolute
    268   - Ubuntu resolute (26.04) has GStreamer 1.28 which the 1.26 plugins are backwards compatible with
    269   - gstreamer 1.28 decodebin is picking hardware-accelerated v4l2jpegdec (on Venice) instead of the standard software decoder jpegdec and v4l2jpegdec does not support YUV3 (typical for standard JPEG images) so if using it you will need to take steps to disable it or prefer jpegdec over it. For example you can use GST_PLUGIN_FEATURE_RANK="v4l2jpegdec:NONE" or set the rank at runtime such is done in the detection examples below
     223While the gstreamer plugin source provided is provided [https://github.com/nxp-imx-support/gstreamer-plugins-ara240 here] it is included in the Ara runtime pre-compiled for convenience linked against stdlibc (libc.so.6) and libgstreamer-1.0.so.0 and compatible with GStreamer 1.26 or newer.
    270224
    271225Install GStreamer:
     
    292246 - this tells GStreamer to look for plugins in the non-standard location of the ARA gstreamer plugins
    293247
    294 At this point you should be able to inspect the dvPre, dvInf, and dvPost elements:
    295 {{{#!bash
    296 gst-inspect-1.0 dvPre
     248At this point you can inspect the dvInf element:
     249{{{#!bash
    297250gst-inspect-1.0 dvInf
    298 gst-inspect-1.0 dvPost
    299 }}}
    300 
    301 [=#detection]
    302 === Detection Examples
     251}}}
     252
    303253Examples:
    304254 * gst-launch pipeline prototyping:
     
    306256   * perform detection on a v4l2 video device like a webcam:
    307257{{{#!bash
    308 DEV=/dev/video2
     258DEV=/dev/video_webcam
    309259MODEL=/usr/share/cnn/detection/yolov8n/model.dvm
    310 GST_DEBUG="dvPost:6" \
     260GST_DEBUG="dvInf:6" \
    311261gst-launch-1.0 -v \
    312262  v4l2src device=$DEV ! \
    313263  video/x-raw,width=640,height=480,framerate=30/1 ! \
    314   videoconvert ! video/x-raw,format=BGRA ! \
    315   dvPre model=$MODEL ! \
    316   dvInf model=$MODEL sock=/var/run/proxy.sock use-shm=false ! \
    317   dvPost model=$MODEL ! \
     264  videoconvert ! video/x-raw,format=BGRx ! \
     265  dvInf model=$MODEL orig-width=640 orig-height=480 stream=0 \
     266        sock=/var/run/proxy.sock use-shm=true shm-path=/dev/shm/ara_shm ! \
    318267  fakesink sync=false | grep Detected
    319268}}}
     
    323272URI=file:///$PWD/traffic.png
    324273MODEL=/usr/share/cnn/detection/yolov8n/model.dvm
    325 GST_DEBUG="dvPost:6" \
    326 GST_PLUGIN_FEATURE_RANK="v4l2jpegdec:NONE" \
     274GST_DEBUG="dvInf:6" \
    327275gst-launch-1.0 -v \
    328   urisourcebin uri=$URI ! decodebin ! \
    329   videoconvert ! video/x-raw,format=BGRA ! \
    330   dvPre model=$MODEL ! \
    331   dvInf model=$MODEL sock=/var/run/proxy.sock use-shm=false ! \
    332   dvPost model=$MODEL ! \
    333   fakesink sync=false | grep Detected
    334 }}}
    335     - the GST_PLUGIN_FEATURE_RANK is to disable the use of the v4l2jpegdec hardware decode on GStreamer 1.28 as it does not support a compatible format needed by dvPre (jet jpegdec does)
    336  * Image detection with boxing via Python
    337   - Python is incredibly useful for accessing GStreamer and handling the ARA detection frame data and imagemagick provides excellent tools for converting and drawing on images:
    338   - (optional) install lighttpd so that we can easily see our resulting images via a browser
    339 {{{#!bash
    340 apt-get install -y lighttpd
    341 # add configuration for directory listing and mapping of /root to /
    342 cat << EOF >> /etc/lighttpd/lighttpd.conf
    343 dir-listing.encoding    = "utf-8"
    344 server.dir-listing      = "enable"
    345 
    346 # directory access
    347 alias.url += (
    348         "/root" => "/root",
    349 )
    350 EOF
    351 # make the dir executable
    352 chmod ugo+x .
    353 # restart the web server
    354 /etc/init.d/lighttpd restart
    355 }}}
    356   - install imagemagick which we will use to draw named boxes for detections
    357 {{{#!bash
    358 apt-get install -y imagemagick
    359 }}}
    360   - create a dir for us to work in and create the script
    361 {{{#!bash
    362 mkdir image-detect; cd image-detect
    363 # create python script
    364 cat <<\EOF > image_detect.py
    365 #!/usr/bin/env python3
    366 """
    367 Ara NPU Multi-Format Universal Image Decoder
    368 ============================================
    369 """
    370 
    371 import ctypes
    372 import os
    373 import sys
    374 import subprocess
    375 import gi
    376 
    377 gi.require_version('Gst', '1.0')
    378 from gi.repository import Gst
    379 
    380 Gst.init(None)
    381 
    382 # Standard COCO Class Mapping for printing human-readable labels
    383 COCO_CLASSES = {
    384     0: "person", 1: "bicycle", 2: "car", 3: "motorcycle", 4: "airplane", 5: "bus",
    385     6: "train", 7: "truck", 8: "boat", 9: "traffic light", 10: "fire hydrant",
    386     11: "stop sign", 12: "parking meter", 13: "bench", 14: "bird", 15: "cat",
    387     16: "dog", 17: "horse", 18: "sheep", 19: "cow", 20: "elephant", 21: "bear",
    388     22: "zebra", 23: "giraffe", 24: "backpack", 25: "umbrella", 26: "handbag",
    389     27: "tie", 28: "suitcase", 29: "frisbee", 30: "skis", 31: "snowboard",
    390     32: "sports ball", 33: "kite", 34: "baseball bat", 35: "baseball glove",
    391     36: "skateboard", 37: "surfboard", 38: "tennis racket", 39: "bottle",
    392     40: "wine glass", 41: "cup", 42: "fork", 43: "knife", 44: "spoon", 45: "bowl",
    393     46: "banana", 47: "apple", 48: "sandwich", 49: "orange", 50: "broccoli",
    394     51: "carrot", 52: "hot dog", 53: "pizza", 54: "donut", 55: "cake",
    395     56: "chair", 57: "couch", 58: "potted plant", 59: "bed", 60: "dining table",
    396     61: "toilet", 62: "tv", 63: "laptop", 64: "mouse", 65: "remote", 66: "keyboard",
    397     67: "cell phone", 68: "microwave", 69: "oven", 70: "toaster", 71: "sink",
    398     72: "refrigerator", 73: "book", 74: "clock", 75: "vase", 76: "scissors",
    399     77: "teddy bear", 78: "hair drier", 79: "toothbrush"
    400 }
    401 
    402 class AraDetection(ctypes.Structure):
    403     _layout_ = "ms"
    404     _pack_ = 1
    405     _fields_ = [
    406         ("xmin", ctypes.c_float), ("ymin", ctypes.c_float),
    407         ("xmax", ctypes.c_float), ("ymax", ctypes.c_float),
    408         ("confidence", ctypes.c_float), ("class_id", ctypes.c_int32),
    409         ("class_name_ptr", ctypes.c_void_p)
    410     ]
    411 
    412 def main():
    413     if len(sys.argv) < 3:
    414         print(f"Usage: {sys.argv[0]} <input_image> <output_image> [model]")
    415         sys.exit(1)
    416 
    417     input_image = sys.argv[1]
    418     output_image = sys.argv[2]
    419     model = "/usr/share/cnn/detection/yolov8n/model.dvm"
    420     if len(sys.argv) > 3:
    421         model = sys.argv[3]
    422 
    423     if not os.path.exists(input_image):
    424         print(f"ERROR: File '{input_image}' could not be located.")
    425         sys.exit(1)
    426 
    427     # Fetch native dimensions using ImageMagick
    428     try:
    429         dimensions = subprocess.check_output(f"identify -format '%w %h' {input_image}", shell=True).decode().split()
    430         w_native, h_native = int(dimensions[0]), int(dimensions[1])
    431     except Exception as e:
    432         print(f"ERROR: Failed to read image properties using ImageMagick: {e}")
    433         sys.exit(1)
    434    
    435     # Print target properties cleanly
    436     print(f"\nmodel: {model}")
    437     print(f"image: {os.path.basename(input_image)} {w_native}x{h_native}")
    438 
    439     MODEL_W, MODEL_H = 640, 640
    440 
    441     pipe_str = (
    442         f"multifilesrc location={input_image} loop=false num-buffers=2 ! decodebin name=d ! "
    443         f"videoconvert ! videoscale ! video/x-raw,width={MODEL_W},height={MODEL_H} ! "
    444         f"videoconvert ! video/x-raw,format=BGRA ! "
    445         f"dvPre model={model} ! "
    446         f"dvInf model={model} sock=/var/run/proxy.sock use-shm=true shm-path=/dev/shm/ara_inf_ ! "
    447         f"dvPost model={model} orig-width={MODEL_W} orig-height={MODEL_H} ! "
    448         f"appsink name=mysink sync=false async=false emit-signals=true"
    449     )
    450 
    451     # Before creating the launcher, adjust the system plugin registry ranking
    452     # so GStreamer ignores v4l2jpegdec element (as it doesn't support BGRA output)
    453     registry = Gst.Registry.get()
    454     feature = registry.lookup_feature("v4l2jpegdec")
    455     if feature:
    456         # Lower its rank to ZERO so decodebin skips over it permanently
    457         feature.set_rank(0)
    458 
    459     pipeline = Gst.parse_launch(pipe_str)
    460     sink = pipeline.get_by_name("mysink")
    461     pipeline.set_state(Gst.State.PLAYING)
    462 
    463     last_valid_raw_bytes = None
    464 
    465     while True:
    466         sample = sink.emit("pull-sample")
    467         if not sample:
    468             break
    469         buffer = sample.get_buffer()
    470         last_valid_raw_bytes = buffer.extract_dup(0, buffer.get_size())
    471 
    472     pipeline.set_state(Gst.State.NULL)
    473    
    474     processed_detections = []
    475 
    476     if last_valid_raw_bytes and len(last_valid_raw_bytes) >= 4:
    477         num_detections = int.from_bytes(last_valid_raw_bytes[:4], byteorder='little')
    478        
    479         if 0 < num_detections < 1000:
    480             print(f"DETECTIONS LOGGED: FOUND {num_detections} ACTIVE OBJECTS")
    481             print("-" * 70)
    482            
    483             offset = 4
    484             ds = ctypes.sizeof(AraDetection)
    485            
    486             for i in range(num_detections):
    487                 if offset + ds > len(last_valid_raw_bytes): break
    488                 det = AraDetection.from_buffer_copy(last_valid_raw_bytes[offset:offset+ds])
    489                 offset += ds
    490                
    491                     # Compute native image coordinate translation mapping
    492                 x1_mapped = det.xmin * (w_native / MODEL_W)
    493                 x2_mapped = det.xmax * (w_native / MODEL_W)
    494                 y1_mapped = det.ymin * (h_native / MODEL_H)
    495                 y2_mapped = det.ymax * (h_native / MODEL_H)
    496                
    497                 coco_name = COCO_CLASSES.get(det.class_id, "unknown")
    498                
    499                 print(f"Object {i+1}: ID={det.class_id} | Name={coco_name} | Confidence={det.confidence * 100:.1f}%")
    500                 print(f"          Bounding Box -> [{int(x1_mapped)}, {int(y1_mapped)}] to [{int(x2_mapped)}, {int(y2_mapped)}]")
    501                 print("-" * 70)
    502                
    503                 processed_detections.append((coco_name, det.confidence, x1_mapped, y1_mapped, x2_mapped, y2_mapped))
    504 
    505     # Render final multi-object annotated canvas
    506     if processed_detections:
    507         cmd_args = [f"convert {input_image}"]
    508         for coco_name, conf, x1, y1, x2, y2 in processed_detections:
    509             ix1, iy1, ix2, iy2 = int(x1), int(y1), int(x2), int(y2)
    510             label = f"{coco_name} {conf*100:.1f}%"
    511             cmd_args.append(f'-stroke green -strokewidth 2 -fill none -draw "rectangle {ix1},{iy1} {ix2},{iy2}"')
    512             cmd_args.append(f'-stroke none -fill white -pointsize 16 -annotate +{ix1}+{iy1 - 6} "{label}"')
    513            
    514         cmd_args.append(output_image)
    515         draw_cmd = " ".join(cmd_args)
    516        
    517         try:
    518             subprocess.run(draw_cmd, shell=True, check=True)
    519             print(f"SUCCESS: Mapped all boxes and text labels onto -> '{output_image}'\n")
    520         except subprocess.CalledProcessError:
    521             print("ERROR: ImageMagick rendering execution failed.\n")
    522     else:
    523         print("INFO: No operational object targets were captured by the NPU context.\n")
    524 
    525 if __name__ == '__main__':
    526     main()
    527 EOF
    528 }}}
    529   - The script using PyGObject which is a Python package that provides bindings for libraries based on GObject Introspection such as GTK, !WebKit, and GStreamer. It allows you to use C-based frameworks in python. We need to install the C libs for GSTreamer for this:
    530 {{{#!bash
    531 apt-get install -y \
    532   libcairo2-dev \
    533   libgirepository-2.0-dev \
    534   python3-dev \
    535   python3-gst-1.0 \
    536   cmake pkg-config
    537 # we are also going to need to install gstreamer and its dev packages
    538 apt-get install -y \
    539   libgstreamer1.0-dev \
    540   libgstreamer-plugins-base1.0-dev \
    541   libgstreamer-plugins-bad1.0-dev \
    542   gstreamer1.0-plugins-base \
    543   gstreamer1.0-plugins-good \
    544   gstreamer1.0-plugins-bad \
    545   gstreamer1.0-plugins-ugly \
    546   gstreamer1.0-libav \
    547   gstreamer1.0-tools
    548 }}}
    549   - create a python virtual env (always a good idea to keep python dependencies containerized) and install python libs we need:
    550 {{{#!bash
    551 # create a venv (.venv)
    552 uv venv
    553 # install our scripts dependencies
    554 uv pip install pygobject
    555 }}}
    556   - (optional) fetch some images for detection
    557 {{{#!bash
    558 # fetch a coco validation image; it contains a dog on a bench and the dog is at 208,147 to 293,289
    559 wget http://images.cocodataset.org/val2017/000000546829.jpg -O dog.jpg
    560 # use ffmpeg to grab a frame from within an MP4
    561 apt install -y ffmpeg
    562 ffmpeg -i /usr/share/ara2-vision-examples/sample_videos/video_0.mp4 -f null - # shows how lon git is (time=00:00:15.50)
    563 ffmpeg -i /usr/share/ara2-vision-examples/sample_videos/video_0.mp4 -ss 00:00:5 -frames:v 1 traffic.png
    564 }}}
    565   - run the script (image_detect.py <source-image> <destination-image> [model-path])
    566 {{{#!bash
    567 uv run image_detect.py dog.jpg coco_detections.jpg
    568 }}}
    569    - Note that without shm the pipeline needs to copy the raw image bytes over a local network-style socket connection. By mounting a dedicated memory path to /dev/shm you can eliminate that transfer (zero-copy): dvPre dumps the processed directly into a designated block of system RAM and dvInf uses a pointer to it
    570    - you would think that if your original image was 1080x1920 and you resized it to the model size of 640x640 that if you tell dvPost the orig-width=1080 orig-height=1920 that it would scale the bounding boxes properly however in practice it seems it does not unless your image has the same aspect ratio of the model. mapping it as above (telling dvPost that the image is 640x640 and scaling ourselves) resolves this
    571    - images:
    572 
    573 [[Image(dog.jpg,400px)]]
    574 [[Image(dog_detect.jpg,400px)]]
    575 
    576 [[Image(traffic.jpg,400px)]]
    577 [[Image(traffic_detect_yolo8n.jpg,400px)]]
    578 [[Image(traffic_detect_yolo8x.jpg,400px)]]
    579 
    580 
    581  * Video detection with boxing via Python in a headless webapp
    582   - Python is incredibly useful for accessing GStreamer and handling the ARA detection frame data and building webapps
    583   - The script using PyGObject which is a Python package that provides bindings for libraries based on GObject Introspection such as GTK, !WebKit, and GStreamer. It allows you to use C-based frameworks in python. We need to install the C libs for GSTreamer for this:
    584 {{{#!bash
    585 apt-get install -y \
    586   libcairo2-dev \
    587   libgirepository-2.0-dev \
    588   python3-dev \
    589   python3-gst-1.0 \
    590   cmake pkg-config
    591 # we are also going to need to install gstreamer and its dev packages
    592 apt-get install -y \
    593   libgstreamer1.0-dev \
    594   libgstreamer-plugins-base1.0-dev \
    595   libgstreamer-plugins-bad1.0-dev \
    596   gstreamer1.0-plugins-base \
    597   gstreamer1.0-plugins-good \
    598   gstreamer1.0-plugins-bad \
    599   gstreamer1.0-plugins-ugly \
    600   gstreamer1.0-libav \
    601   gstreamer1.0-tools
    602 }}}
    603   - create a python virtual env (always a good idea to keep python dependencies containerized) and install python libs we need:
    604 {{{#!bash
    605 # create a venv (.venv)
    606 uv venv
    607 # install our scripts dependencies
    608 uv pip install pygobject opencv-python-headless
    609 cat << EOF > vision-webapp.py
    610 #!/usr/bin/env python3
    611 """
    612 Ara NPU Basic Video Stream & Inference Hub
    613 ==========================================
    614 """
    615 
    616 import argparse
    617 import ctypes
    618 import glob
    619 import os
    620 import sys
    621 import threading
    622 import time
    623 import logging
    624 import cv2
    625 import numpy as np
    626 from flask import Flask, Response, jsonify, request, render_template_string
    627 import gi
    628 
    629 gi.require_version('Gst', '1.0')
    630 from gi.repository import Gst
    631 Gst.init(None)
    632 
    633 # Quiet down Werkzeug HTTP traffic logging to suppress 1Hz AJAX console pollution
    634 log = logging.getLogger('werkzeug')
    635 log.setLevel(logging.ERROR)
    636 
    637 app = Flask(__name__)
    638 lock = threading.Lock()
    639 
    640 class AraDetection(ctypes.Structure):
    641     _pack_ = 1
    642     _fields_ = [
    643         ("xmin", ctypes.c_float), ("ymin", ctypes.c_float),
    644         ("xmax", ctypes.c_float), ("ymax", ctypes.c_float),
    645         ("confidence", ctypes.c_float), ("class_id", ctypes.c_int32),
    646         ("class_name_ptr", ctypes.c_void_p)
    647     ]
    648 
    649 # --- STATE STORAGE ---
    650 STATE_REPO = {
    651     "frame": None,
    652     "detections": [],
    653     "active_source": None,
    654     "active_model_name": "yolov8n",
    655     "active_model_path": "/usr/share/cnn/detection/yolov8n/model.dvm",
    656     "restart_flag": False,
    657     "source_registry": [],
    658     "model_registry": ["yolov8n"],
    659    
    660     # Target Pipeline Resolutions
    661     "CANVAS_W": 640,
    662     "CANVAS_H": 360,
    663     "MODEL_W": 640,
    664     "MODEL_H": 640,
    665    
    666     # Live Telemetry Metrics
    667     "native_w": 0,
    668     "native_h": 0,
    669     "stream_w": 0,
    670     "stream_h": 0,
    671     "inference_fps": 0.0
    672 }
    673 
    674 # FPS Calculation variables bound directly to the Inference thread
    675 inference_timestamps = []
    676 
    677 COCO_LABELS = {
    678     0: 'person', 1: 'bicycle', 2: 'car', 3: 'motorcycle', 4: 'airplane', 5: 'bus',
    679     6: 'train', 7: 'truck', 8: 'boat', 9: 'traffic light', 10: 'fire hydrant',
    680     11: 'stop sign', 12: 'parking meter', 13: 'bench', 14: 'bird', 15: 'cat',
    681     16: 'dog', 17: 'horse', 18: 'sheep', 19: 'cow', 20: 'elephant', 21: 'bear',
    682     22: 'zebra', 23: 'giraffe', 24: 'backpack', 25: 'umbrella', 26: 'handbag',
    683     27: 'tie', 28: 'suitcase', 29: 'frisbee', 30: 'skis', 31: 'snowboard',
    684     32: 'sports ball', 33: 'kite', 34: 'baseball bat', 35: 'baseball glove',
    685     36: 'skateboard', 37: 'surfboard', 38: 'tennis racket', 39: 'bottle',
    686     40: 'wine glass', 41: 'cup', 42: 'fork', 43: 'knife', 44: 'spoon', 45: 'bowl',
    687     46: 'banana', 47: 'apple', 48: 'sandwich', 49: 'orange', 50: 'broccoli',
    688     51: 'carrot', 52: 'hot dog', 53: 'pizza', 54: 'donut', 55: 'cake',
    689     56: 'chair', 57: 'couch', 58: 'potted plant', 59: 'bed', 60: 'dining table',
    690     61: 'toilet', 62: 'tv', 63: 'laptop', 64: 'mouse', 65: 'remote', 66: 'keyboard',
    691     67: 'cell phone', 68: 'microwave', 69: 'oven', 70: 'toaster', 71: 'sink',
    692     72: 'refrigerator', 73: 'book', 74: 'clock', 75: 'vase', 76: 'scissors',
    693     77: 'teddy bear', 78: 'hair drier', 79: 'toothbrush'
    694 }
    695 
    696 def build_source_injection_string(source_path):
    697     if source_path.endswith(".mp4"):
    698         return f"filesrc location={source_path} ! decodebin ! videoconvert ! tee name=t "
    699     else:
    700         return f"v4l2src device={source_path} ! videoconvert ! tee name=t "
    701 
    702 def gstreamer_orchestration_loop():
    703     global inference_timestamps
    704     CANVAS_W = STATE_REPO["CANVAS_W"]
    705     CANVAS_H = STATE_REPO["CANVAS_H"]
    706     MODEL_W = STATE_REPO["MODEL_W"]
    707     MODEL_H = STATE_REPO["MODEL_H"]
    708 
    709     while True:
    710         while STATE_REPO["active_source"] is None:
    711             time.sleep(0.2)
    712             if STATE_REPO["restart_flag"]:
    713                 break
    714 
    715         current_target_source = STATE_REPO["active_source"]
    716         current_target_model = STATE_REPO["active_model_path"]
    717         STATE_REPO["restart_flag"] = False
    718        
    719         if current_target_source is None:
    720             continue
    721            
    722         source_segment = build_source_injection_string(current_target_source)
    723        
    724         pipe_str = (
    725             f"{source_segment} "
    726             f"t. ! queue max-size-buffers=2 leaky=downstream ! appsink name=nativesink sync=false async=false emit-signals=true "
    727             f"t. ! queue max-size-buffers=2 leaky=downstream ! videoscale ! video/x-raw,width={CANVAS_W},height={CANVAS_H} ! videoconvert ! video/x-raw,format=BGR ! appsink name=framesink sync=false async=false emit-signals=true "
    728             f"t. ! queue max-size-buffers=2 leaky=downstream ! "
    729             f"videoscale ! video/x-raw,width={MODEL_W},height={MODEL_H} ! videoconvert ! video/x-raw,format=BGRA ! "
    730             f"dvPre model={current_target_model} ! "
    731             f"dvInf model={current_target_model} sock=/var/run/proxy.sock use-shm=true shm-path=/dev/shm/ara_inf_ ! "
    732             f"dvPost model={current_target_model} orig-width={MODEL_W} orig-height={MODEL_H} ! "
    733             f"appsink name=postsink sync=false async=false emit-signals=true"
    734         )
    735 
    736         print(f"[LAUNCH PIPELINE]\n   {pipe_str}\n")
    737         pipeline = Gst.parse_launch(pipe_str)
    738        
    739         native_sink = pipeline.get_by_name("nativesink")
    740         frame_sink = pipeline.get_by_name("framesink")
    741         post_sink = pipeline.get_by_name("postsink")
    742 
    743         def on_native_caps(sink):
    744             sample = sink.emit("pull-sample")
    745             if sample:
    746                 caps = sample.get_caps()
    747                 struct = caps.get_structure(0)
    748                 STATE_REPO["native_w"] = struct.get_value("width")
    749                 STATE_REPO["native_h"] = struct.get_value("height")
    750             return Gst.FlowReturn.OK
    751 
    752         def on_new_detection(sink):
    753             global inference_timestamps
    754             sample = sink.emit("pull-sample")
    755             if sample:
    756                 # Calculate FPS derived purely from the inference hardware return loop
    757                 now = time.time()
    758                 inference_timestamps.append(now)
    759                 if len(inference_timestamps) > 30:
    760                     inference_timestamps.pop(0)
    761                 if len(inference_timestamps) > 1:
    762                     STATE_REPO["inference_fps"] = len(inference_timestamps) / (inference_timestamps[-1] - inference_timestamps[0])
    763 
    764                 buffer = sample.get_buffer()
    765                 raw_bytes = buffer.extract_dup(0, buffer.get_size())
    766                 if raw_bytes and len(raw_bytes) >= 4:
    767                     num_detections = np.frombuffer(raw_bytes[:4], dtype=np.uint32)[0]
    768                     local_dets = []
    769                     offset = 4
    770                     ds = ctypes.sizeof(AraDetection)
    771                     for _ in range(num_detections):
    772                         if offset + ds > len(raw_bytes): break
    773                         det = AraDetection.from_buffer_copy(raw_bytes[offset:offset+ds])
    774                         offset += ds
    775                         local_dets.append((det.class_id, det.confidence, det.xmin, det.ymin, det.xmax, det.ymax))
    776                     STATE_REPO["detections"] = local_dets
    777             return Gst.FlowReturn.OK
    778 
    779         def on_new_frame(sink):
    780             sample = sink.emit("pull-sample")
    781             if sample:
    782                 buffer = sample.get_buffer()
    783                 caps = sample.get_caps()
    784                 struct = caps.get_structure(0)
    785                 w = struct.get_value("width")
    786                 h = struct.get_value("height")
    787                
    788                 STATE_REPO["stream_w"] = w
    789                 STATE_REPO["stream_h"] = h
    790                
    791                 raw_bytes = buffer.extract_dup(0, buffer.get_size())
    792                 if raw_bytes:
    793                     try:
    794                         frame_flat = np.frombuffer(raw_bytes, dtype=np.uint8)
    795                         frame_arr = frame_flat.reshape((h, w, 3))
    796                         STATE_REPO["frame"] = frame_arr.copy()
    797                     except ValueError:
    798                         pass
    799             return Gst.FlowReturn.OK
    800 
    801         native_sink.connect("new-sample", on_native_caps)
    802         post_sink.connect("new-sample", on_new_detection)
    803         frame_sink.connect("new-sample", on_new_frame)
    804         pipeline.set_state(Gst.State.PLAYING)
    805 
    806         bus = pipeline.get_bus()
    807         while True:
    808             msg = bus.timed_pop_filtered(Gst.SECOND * 0.05, Gst.MessageType.ERROR | Gst.MessageType.EOS)
    809             if msg:
    810                 if msg.type == Gst.MessageType.EOS and current_target_source.endswith(".mp4"):
    811                     pipeline.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0)
    812                     continue
    813                 break
    814            
    815             if STATE_REPO["restart_flag"]:
    816                 break
    817        
    818         pipeline.set_state(Gst.State.NULL)
    819         STATE_REPO["frame"] = None
    820         STATE_REPO["detections"] = []
    821         STATE_REPO["native_w"] = 0
    822         STATE_REPO["native_h"] = 0
    823         STATE_REPO["stream_w"] = 0
    824         STATE_REPO["stream_h"] = 0
    825         STATE_REPO["inference_fps"] = 0.0
    826         inference_timestamps = []
    827         time.sleep(1.0)
    828 
    829 @app.route('/')
    830 def index():
    831     src_active = STATE_REPO["active_source"]
    832    
    833     if not STATE_REPO["source_registry"]:
    834         src_html = '<option value="" disabled selected>-- NO VALID INPUT SOURCES AVAILABLE --</option>'
    835     else:
    836         src_html = '<option value="" disabled selected>-- SELECT TARGET SOURCE CHANNEL --</option>' if src_active is None else ""
    837         src_html += "".join(f'<option value="{s}" {"selected" if s == src_active else ""}>{s}</option>' for s in STATE_REPO["source_registry"])
    838    
    839     mdl_active = STATE_REPO["active_model_name"]
    840     mdl_html = "".join(f'<option value="{m}" {"selected" if m == mdl_active else ""}>{m}</option>' for m in STATE_REPO["model_registry"])
    841 
    842     html_template = """<!DOCTYPE html>
    843     <html>
    844     <head>
    845         <title>Ara Stream Client</title>
    846         <style>
    847             body { font-family: sans-serif; background: #0c0c0e; color: #e1e1e6; margin: 0; padding: 20px; display: flex; flex-direction: column; align-items: center; }
    848             .dashboard-layout { display: flex; flex-direction: column; gap: 15px; width: 660px; }
    849             .panel { background: #121216; padding: 12px 15px; border-radius: 6px; border: 1px solid #1f1f24; display: flex; flex-direction: column; gap: 10px; }
    850             .control-row { display: flex; align-items: center; justify-content: space-between; }
    851             label { font-size: 12px; font-weight: bold; color: #8f8f9d; text-transform: uppercase; }
    852             select { background: #0c0c0e; color: #fff; border: 1px solid #04d361; padding: 6px 10px; border-radius: 4px; width: 420px; outline: none; }
    853             .stats-banner { display: flex; justify-content: space-between; background: #17171f; padding: 10px 15px; border: 1px solid #1f1f24; border-radius: 4px; font-family: monospace; font-size: 13px; color: #8f8f9d; }
    854             .stats-banner span strong { color: #04d361; }
    855             .media-container { background: #121216; padding: 8px; border-radius: 6px; border: 1px solid #1f1f24; position: relative; min-height: 480px; display: flex; align-items: center; justify-content: center; }
    856             img { display: block; border-radius: 4px; width: 100%; height: auto; }
    857             .overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(12,12,14,0.9); display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 6px; text-align: center; }
    858             .prompt-text { color: #04d361; font-weight: bold; font-size: 16px; margin-bottom: 10px; }
    859         </style>
    860         <script>
    861             let streamStarted = {% if active_src %}true{% else %}false{% endif %};
    862            
    863             async function switchConfig() {
    864                 const src = document.getElementById('source-picker').value;
    865                 const mdl = document.getElementById('model-picker').value;
    866                 if(!src) return;
    867                
    868                 await fetch('/api/swap_config', {
    869                     method: 'POST',
    870                     headers: { 'Content-Type': 'application/json' },
    871                     body: JSON.stringify({ "source": src, "model": mdl })
    872                 });
    873                
    874                 streamStarted = true;
    875                 document.getElementById('gatekeeper-overlay').style.display = 'none';
    876                 setTimeout(() => {
    877                     document.getElementById('stream-player').src = '/stream.mjpg';
    878                 }, 1000);
    879             }
    880 
    881             async function updateStreamMetrics() {
    882                 if (!streamStarted) return;
    883                 try {
    884                     const response = await fetch('/api/stream_info');
    885                     const data = await response.json();
    886                    
    887                     document.getElementById('metric-res').innerText = 'Source:' + data.native_w + 'x' + data.native_h + ' Canvas:' + data.width + 'x' + data.height;
    888                     document.getElementById('metric-fps').innerText = data.fps.toFixed(1);
    889                     document.getElementById('metric-dets').innerText = data.detections;
    890                 } catch (err) {}
    891             }
    892             setInterval(updateStreamMetrics, 1000);
    893         </script>
    894     </head>
    895     <body>
    896         <h2>Ara Vision Engine</h2>
    897         <div class="dashboard-layout">
    898             <div class="panel">
    899                 <div class="control-row">
    900                     <label for="source-picker">Media Stream Target:</label>
    901                     <select id="source-picker" onchange="switchConfig()">""" + src_html + """</select>
    902                 </div>
    903                 <div class="control-row">
    904                     <label for="model-picker">NPU Pipeline Model:</label>
    905                     <select id="model-picker" onchange="switchConfig()">""" + mdl_html + """</select>
    906                 </div>
    907             </div>
    908 
    909             <div class="stats-banner">
    910                 <span id="metric-res">Source:0x0 Canvas:0x0</span>
    911                 <span>NPU Inference: <span id="metric-fps">0.0</span> FPS</span>
    912                 <span>Active Detections: <span id="metric-dets">0</span></span>
    913             </div>
    914 
    915             <div class="media-container">
    916                 {% if not active_src %}
    917                 <div class="overlay" id="gatekeeper-overlay">
    918                     <div class="prompt-text">Awaiting Source Context</div>
    919                     <div style="color: #8f8f9d; font-size: 13px; max-width: 400px;">Please select a media path and model from the drop-downs above to mount your pipeline.</div>
    920                 </div>
    921                 {% endif %}
    922                 <img id="stream-player" {% if active_src %}src="/stream.mjpg"{% endif %} style="max-width: """ + str(STATE_REPO["CANVAS_W"]) + """px;" />
    923             </div>
    924         </div>
    925     </body>
    926     </html>"""
    927     return render_template_string(html_template, active_src=src_active)
    928 
    929 @app.route('/api/stream_info')
    930 def stream_info():
    931     with lock:
    932         return jsonify({
    933             "native_w": STATE_REPO["native_w"],
    934             "native_h": STATE_REPO["native_h"],
    935             "width": STATE_REPO["stream_w"],
    936             "height": STATE_REPO["stream_h"],
    937             "fps": STATE_REPO["inference_fps"],
    938             "detections": len(STATE_REPO["detections"])
    939         })
    940 
    941 @app.route('/api/swap_config', methods=['POST'])
    942 def swap_config():
    943     payload = request.get_json()
    944     src_selected = payload.get("source")
    945     mdl_selected = payload.get("model")
    946    
    947     with lock:
    948         trigger_restart = False
    949         if src_selected in STATE_REPO["source_registry"] and STATE_REPO["active_source"] != src_selected:
    950             STATE_REPO["active_source"] = src_selected
    951             trigger_restart = True
    952         if mdl_selected in STATE_REPO["model_registry"] and STATE_REPO["active_model_name"] != mdl_selected:
    953             base_dir = app.config["MODEL_DIR"]
    954             STATE_REPO["active_model_name"] = mdl_selected
    955             STATE_REPO["active_model_path"] = os.path.join(base_dir, mdl_selected, "model.dvm")
    956             trigger_restart = True
    957         if trigger_restart:
    958             STATE_REPO["restart_flag"] = True
    959     return jsonify({"status": "success"})
    960 
    961 def generate_mjpeg_stream_generator():
    962     MODEL_W = float(STATE_REPO["MODEL_W"])
    963     MODEL_H = float(STATE_REPO["MODEL_H"])
    964    
    965     while True:
    966         time.sleep(0.04)
    967         frame_copy = STATE_REPO["frame"]
    968         local_dets = list(STATE_REPO["detections"])
    969         if frame_copy is not None:
    970             frame = frame_copy.copy()
    971             h_native, w_native, _ = frame_copy.shape
    972             for class_id, confidence, rx1, ry1, rx2, ry2 in local_dets:
    973                 cx1 = int(rx1 * (float(w_native) / MODEL_W))
    974                 cx2 = int(rx2 * (float(w_native) / MODEL_W))
    975                 cy1 = int(ry1 * (float(h_native) / MODEL_H))
    976                 cy2 = int(ry2 * (float(h_native) / MODEL_H))
    977                 label = f"{COCO_LABELS.get(class_id, f'Class {class_id}')} ({confidence*100:.1f}%)"
    978                 cv2.rectangle(frame, (cx1, cy1), (cx2, cy2), (0, 255, 97), 2)
    979                 cv2.putText(frame, label, (cx1, max(15, cy1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 97), 2)
    980             _, encoded_img = cv2.imencode(".jpg", frame)
    981             yield (b'--frame\r\n'
    982                    b'Content-Type: image/jpeg\r\n\r\n' + encoded_img.tobytes() + b'\r\n')
    983         else:
    984             waiting_canvas = np.zeros((480, 640, 3), dtype=np.uint8)
    985             cv2.putText(waiting_canvas, "AWAITING MEDIA INPUT SELECTION...", (140, 240), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 97), 1)
    986             _, encoded_img = cv2.imencode(".jpg", waiting_canvas)
    987             yield (b'--frame\r\n'
    988                    b'Content-Type: image/jpeg\r\n\r\n' + encoded_img.tobytes() + b'\r\n')
    989 
    990 @app.route('/stream.mjpg')
    991 def video_feed_stream_route():
    992     return Response(generate_mjpeg_stream_generator(), mimetype='multipart/x-mixed-replace; boundary=frame')
    993 
    994 def main():
    995     parser = argparse.ArgumentParser(description="Wiki Template: Ara Flask Video Engine")
    996     parser.add_argument("--camera", default=None, help="Camera context device node path")
    997     parser.add_argument("--mp4", default=None, help="Directory containing target mp4 sample videos")
    998     parser.add_argument("--port", type=int, default=8080, help="Target port mapping")
    999     parser.add_argument("--model-dir", default="/usr/share/cnn/detection", help="Directory containing target models")
    1000     parser.add_argument("--model", default="yolov8n", help="Initial model selection")
    1001     args = parser.parse_args()
    1002 
    1003     app.config["MODEL_DIR"] = args.model_dir
    1004     STATE_REPO["source_registry"] = []
    1005 
    1006     if args.camera and os.path.exists(args.camera):
    1007         STATE_REPO["source_registry"].append(args.camera)
    1008 
    1009     if args.mp4 and os.path.exists(args.mp4):
    1010         local_videos = glob.glob(os.path.join(args.mp4, "*.mp4"))
    1011         for vid in sorted(local_videos):
    1012             STATE_REPO["source_registry"].append(vid)
    1013 
    1014     if os.path.exists(args.model_dir):
    1015         discovered_models = []
    1016         for entry in sorted(os.listdir(args.model_dir)):
    1017             full_subdir = os.path.join(args.model_dir, entry)
    1018             if os.path.isdir(full_subdir) and os.path.exists(os.path.join(full_subdir, "model.dvm")):
    1019                 discovered_models.append(entry)
    1020         if discovered_models:
    1021             STATE_REPO["model_registry"] = discovered_models
    1022             STATE_REPO["active_model_name"] = args.model if args.model in discovered_models else discovered_models[0]
    1023             STATE_REPO["active_model_path"] = os.path.join(args.model_dir, STATE_REPO["active_model_name"], "model.dvm")
    1024 
    1025     threading.Thread(target=gstreamer_orchestration_loop, daemon=True).start()
    1026 
    1027     print(f"Server serving on: http://localhost:{args.port}/")
    1028     app.run(host='0.0.0.0', port=args.port, threaded=True, use_reloader=False, debug=False)
    1029 
    1030 if __name__ == '__main__':
    1031     main()
    1032 EOF
    1033 }}}
    1034   - run the script (vison-webapp.py [--port <portno>] [--camera <camera-dev>] [--mp4 <mp4-dir>]
    1035 {{{#!bash
    1036 uv run vision-webapp.py --camera /dev/video_webcam --mp4 /usr/share/ara2-vision-examples/sample_videos/
    1037 }}}
    1038   - you can provide a webcam device path to enable streaming from a webcam and/or an mp4 directory to enable processing those. A dropdown will allow you to select the input stream and the model and the browser window will show you detections and statistics
    1039 
    1040 [[Image(vision-webapp.jpg,400px)]]
     276  filesrc location=traffic.png ! \
     277  pngdec ! imagefreeze num-buffers=10 ! \
     278  videoscale ! videoconvert ! video/x-raw,format=BGRx,width=640,height=480 ! \
     279  dvInf model=$MODEL orig-width=640 orig-height=480 stream=0 \
     280        sock=/var/run/proxy.sock use-shm=true shm-path=/dev/shm/ara_shm ! \
     281  fakesink sync=false | grep Detected
     282}}}
     283
     284For a more complete example see below
    1041285
    1042286
    1043287[=#eiq-aaf-connector]
    1044 === eIQ AAF Connector
     288== eIQ AAF Connector for LLM inference
    1045289The eIQ AAF Connector (edge Intelligence Ara Application Framework)
    1046290is a REST-based server that enables LLM inference on NXP i.MX processors with the ARA-240 DNPU. The API implemented is the de-facto API standard created by OpenAI for ChatGPT. It provides a simple Chat Completions-based HTTP interface for serving models to client applications.
     
    1053297 - Models
    1054298
    1055 Installation on a Gateworks board with Ubuntu based OS:
    1056  - extract the debian 'data' (do not install the package!)
    1057 {{{#!bash
     299Source:
     300 - https://github.com/nxp-imx-support/eiq-aaf-connector
     301
     302For ease of use Gateworks provides a pre-built deb package of eiq-aaf-connector v2.1 built from the NXP IMX Yocto BSP which you can install with:
     303{{{#!bash
     304# fetch
     305wget https://dev.gateworks.com/ara/eiq-aaf-connector_2.1-r0_arm64.deb
    1058306# extract data (but don't install)
    1059 dpkg-deb --vextract eiq-aaf-connector_2.0.deb /
    1060 }}}
    1061  - take care of postinst steps
    1062   1. Create the /usr/share/eiq/aaf-connector/venv (used by /usr/share/eiq/aaf-connector/venv/bin/connector)
    1063 {{{#!bash
    1064 # needs python 3.13 so we will install it in a virtual env for this user
    1065 uv python install 3.13
    1066 uv venv --python 3.13 "/usr/share/eiq/aaf-connector/venv"
    1067 # activate venv
    1068 source "/usr/share/eiq/aaf-connector/venv/bin/activate"
    1069 # install Python dependencies in venv from the Optimum Ara wheel
    1070 uv pip install --no-progress /usr/share/python-wheels/optimum_ara-2.0.0.2-py3-none-any.whl
    1071 # install Python dependencies in venv from the eIQ wheel in this package
    1072 uv pip install --no-progress /usr/share/python-wheels/eiq_aaf_connector-2.0.0-py3-none-any.whl
    1073 # ditch the default opencv-python which depends on libgl1-mesa and install the headless version instead
    1074 uv pip uninstall opencv-python
    1075 uv pip install opencv-python-headless
    1076 # deactivate venv
    1077 deactivate
    1078 }}}
    1079   1. Create systemd service file (not sure why this wasn't in the deb)
    1080 {{{#!bash
    1081 cat > /etc/systemd/system/eiq-aaf-connector.service << EOF
    1082 [Unit]
    1083 Description=eIQ AAF Connector Service
    1084 # No 'After' or 'Wants' for rt-sdk-ara2.service here
    1085 # This prevents the 'Ordering Cycle' entirely
    1086 After=network.target
    1087 StartLimitIntervalSec=0
    1088 
    1089 [Service]
    1090 Type=simple
    1091 User=root
    1092 WorkingDirectory=/usr/share/eiq/aaf-connector
    1093 
    1094 # This loop now handles the dependency logic internally.
    1095 # It will spin until the proxy is actually alive, regardless of
    1096 # which service started it or when.
    1097 ExecStartPre=/bin/bash -c 'until ss -Hltn | grep -E -q ":5000([[:space:]]|$)"; do echo "Waiting for ARA2 Proxy to initialize..." >&2; sleep 5; done'
    1098 ExecStartPre=/bin/sleep 2
    1099 
    1100 ExecStart=/usr/share/eiq/aaf-connector/venv/bin/connector --host 0.0.0.0 --port 8000
    1101 
    1102 Restart=on-failure
    1103 RestartSec=10s
    1104 StartLimitBurst=0
    1105 
    1106 StandardOutput=journal
    1107 StandardError=journal
    1108 
    1109 [Install]
    1110 WantedBy=multi-user.target
    1111 EOF
    1112 }}}
    1113   - this one differs from the one in the deb's postinst script as I found that one to not work (it would not wait for the proxy to be alive)
    1114   - If you wish this to be accessible from the Network set the host to '0.0.0.0' instead of '127.0.0.1':
    1115 {{{#!bash
    1116 sed -i 's|--host 127.0.0.1|--host 0.0.0.0|g' /etc/systemd/system/eiq-aaf-connector.service
    1117 }}}
    1118   1. add Ara2 optimized LLM models (these get installed to /usr/share/llm)
    1119 {{{#!bash
    1120 fetch_models --repo-id nxp/Qwen2.5-7B-Instruct-Ara240 # 7.7GiB
    1121 fetch_models --repo-id nxp/Qwen2.5-Coder-1.5B-Ara240 # 1.67GiB
    1122 }}}
    1123   1. edit the config file to enable the two models we just downloaded (using jq):
    1124 {{{#!bash
    1125 apt update && apt install -y jq
    1126 jq '(.available_models[] | select(.name == "Qwen2.5-Coder-1.5B") |  .enabled) = true' /usr/share/eiq/aaf-connector/server_config.json > /tmp/config.json && \
    1127   mv /tmp/config.json /usr/share/eiq/aaf-connector/server_config.json
    1128 jq '(.available_models[] | select(.name == "Qwen2.5-7B-Instruct") |  .enabled) = true' /usr/share/eiq/aaf-connector/server_config.json > /tmp/config.json && \
    1129   mv /tmp/config.json /usr/share/eiq/aaf-connector/server_config.json
    1130 }}}
    1131   - you can just as easily edit the file manually if you want
    1132   1. Enable and start service
     307dpkg-deb --vextract eiq-aaf-connector_2.1-r0_arm64.deb /
     308# run the install script
     309/usr/share/eiq/aaf-connector/install.sh
     310# fetch LLM models (installed to /usr/share/llm)
     311fetch_models --repo-id nxp/Qwen2.5-7B-Instruct-Ara240 # 7.7GiB LLM
     312fetch_models --repo-id nxp/Qwen2.5-Coder-1.5B-Ara240 # 1.67GiB LLM
     313fetch_models --repo-id nxp/Qwen2.5-VL-7B-Instruct-Ara240 # 12GB VLM
     314}}}
     315
     316files:
     317 - /usr/share/eiq/aaf-connector/server_config.json (config file)
     318 - /usr/share/eiq/aaf-connector/install.sh (install script)
     319 - /usr/share/python-wheels/eiq_aaf_connector-2.1-py3-none-any.whl (python wheel)
     320
     321The install script creates a systemd eiq-aaf-connector.service:
    1133322{{{#!bash
    1134323# Enable service on boot
     
    1138327}}}
    1139328
    1140 Note that it takes several minutes for the service to actually be ready for connections as it must process the models (monitor with 'journalctl -u eiq-aaf-connector.service --no-pager -f' and test that its ready for listening with 'ss -tulpn | grep :8000').
    1141 
    1142 By default, the connector configured above will start on 127.0.0.1:8000 which is the local loopback interface. To be able to run requests from another device, you can change the host to '0.0.0.0' in the service file.
    1143 
    1144 Notable Files:
    1145  - /usr/share/eiq/aaf-connector/server_config.json (server config file)
    1146  - /usr/share/python-wheels/eiq_aaf_connector-2.0.0-py3-none-any.whl - Python wheel
    1147  - /usr/bin/aaf-connector - shell script that activates the venv and executes the connector
    1148  - /usr/share/eiq/aaf-connector/venv - Python virtual env used by connector
    1149  - /etc/systemd/system/eiq-aaf-connector.service - systemd service
    1150 
    1151 
    1152 The connector self-hosts API documentation at http://<serverip>:8000/docs
     329Notes:
     330 - By default the connector will listen on 127.0.0.1:8000. If you wish the service to be accessible externally set the host to '0.0.0.0' instead:
     331{{{#!bash
     332sed -i 's|--host 127.0.0.1|--host 0.0.0.0|g' /etc/systemd/system/eiq-aaf-connector.service
     333}}}
     334 - the default config file has configuration for all of the above Ara models but they are not 'enabled' by default. You must only enable 1 model at a time and doing so loads the model onto the Ara when the servoce starts. To enable a model change the appropriate 'enabled' property to 'true' in /etc/systemd/system/eiq-aaf-connector.service and restart the service
     335 - it takes several minutes for the service to actually be ready for connections as it must process the models (monitor with 'journalctl -u eiq-aaf-connector.service --no-pager -f' and test that its ready for listening with 'ss -tulpn | grep :8000').
     336 - the connector self-hosts API documentation at http://<serverip>:8000/docs (available externally if configured for a host of 0.0.0.0)
    1153337
    1154338Example Usage:
     
    1196380
    1197381
    1198 == Ara2 SDK examples
    1199 
    1200 Here are some Ara2 SDK examples that were 'vibe coded' within minutes
     382[=examples]
     383== Examples
     384Here are some Ara example applications put together by Gateworks
    1201385
    1202386=== dvapi stats
     
    1290474}}}
    1291475
    1292 === command-line python eIQ chatbot
     476
     477=== Image Detection with boxying via Python
     478Python is incredibly useful for accessing GStreamer and handling the ARA detection frame data and imagemagick provides excellent tools for converting and drawing on images. We use PyGObject which is a Python package that provides bindings for libraries based on GObject Introspection such as GTK, !WebKit, and GStreamer. It allows you to use C-based frameworks in python.
     479
     480Steps:
     481 1. We need to install the C libs for GSTreamer and build utilities:
     482{{{#!bash
     483apt-get install -y \
     484  libcairo2-dev \
     485  libgirepository-2.0-dev \
     486  python3-dev \
     487  python3-gst-1.0 \
     488  cmake pkg-config
     489# we are also going to need to install gstreamer and its dev packages
     490apt-get install -y \
     491  libgstreamer1.0-dev \
     492  libgstreamer-plugins-base1.0-dev \
     493  libgstreamer-plugins-bad1.0-dev \
     494  gstreamer1.0-plugins-base \
     495  gstreamer1.0-plugins-good \
     496  gstreamer1.0-plugins-bad \
     497  gstreamer1.0-plugins-ugly \
     498  gstreamer1.0-libav \
     499  gstreamer1.0-tools
     500}}}
     501 1. create a python virtual env (always a good idea to keep python dependencies containerized) and install python libs we need:
     502{{{#!bash
     503# create a dir for the venv
     504mkdir image-detect
     505cd image-detect
     506# create a venv (.venv)
     507uv venv
     508# install our scripts dependencies
     509uv pip install pygobject
     510}}}
     511 1. (optional) fetch some images for detection
     512{{{#!bash
     513# fetch a coco validation image; it contains a dog on a bench and the dog is at 208,147 to 293,289
     514wget http://images.cocodataset.org/val2017/000000546829.jpg -O dog.jpg
     515# use ffmpeg to grab a frame from within an MP4
     516apt install -y ffmpeg
     517ffmpeg -i /usr/share/ara2-vision-examples/sample_videos/video_0.mp4 -f null - # shows how lon git is (time=00:00:15.50)
     518ffmpeg -i /usr/share/ara2-vision-examples/sample_videos/video_0.mp4 -ss 00:00:5 -frames:v 1 traffic.png
     519}}}
     520 1. fetch the script
     521{{{#!bash
     522wget https://dev.gateworks.com/ara/examples/image_detect.py
     523}}}
     524 1. run the script (image_detect.py <source-image> <destination-image> [model-path])
     525{{{#!bash
     526uv run image_detect.py dog.jpg coco_detections.jpg
     527}}}
     528   - Note that without shm the pipeline needs to copy the raw image bytes over a local network-style socket connection. By mounting a dedicated memory path to /dev/shm you can eliminate that transfer (zero-copy): dvPre dumps the processed directly into a designated block of system RAM and dvInf uses a pointer to it
     529   - you would think that if your original image was 1080x1920 and you resized it to the model size of 640x640 that if you tell dvPost the orig-width=1080 orig-height=1920 that it would scale the bounding boxes properly however in practice it seems it does not unless your image has the same aspect ratio of the model. mapping it as above (telling dvPost that the image is 640x640 and scaling ourselves) resolves this
     530   - images:
     531
     532[[Image(dog.jpg,400px)]]
     533[[Image(dog_detect.jpg,400px)]]
     534
     535[[Image(traffic.jpg,400px)]]
     536[[Image(traffic_detect_yolo8n.jpg,400px)]]
     537[[Image(traffic_detect_yolo8x.jpg,400px)]]
     538
     539
     540=== Video Detection Webapp via Python
     541Python is incredibly useful for accessing GStreamer and handling the ARA detection frame data and building webapps. The script using PyGObject which is a Python package that provides bindings for libraries based on GObject Introspection such as GTK, !WebKit, and GStreamer. It allows you to use C-based frameworks in python. We need to install the C libs for GStreamer for this
     542
     543Steps:
     544 1. We need to install the C libs for GStreamer and build utilities:
     545{{{#!bash
     546apt-get install -y \
     547  libcairo2-dev \
     548  libgirepository-2.0-dev \
     549  python3-dev \
     550  python3-gst-1.0 \
     551  cmake pkg-config
     552# we are also going to need to install GStreamer and its dev packages
     553apt-get install -y \
     554  libgstreamer1.0-dev \
     555  libgstreamer-plugins-base1.0-dev \
     556  libgstreamer-plugins-bad1.0-dev \
     557  gstreamer1.0-plugins-base \
     558  gstreamer1.0-plugins-good \
     559  gstreamer1.0-plugins-bad \
     560  gstreamer1.0-plugins-ugly \
     561  gstreamer1.0-libav \
     562  gstreamer1.0-tools
     563}}}
     564 1. create a python virtual env (always a good idea to keep python dependencies containerized) and install python libs we need:
     565{{{#!bash
     566# create a dir for the venv
     567mkdir vision-webapp
     568cd vision-webapp
     569# create a venv (.venv)
     570uv venv
     571# install our scripts dependencies
     572uv pip install pygobject opencv-python-headless flask
     573}}}
     574 1. fetch the script
     575{{{#!bash
     576wget https://dev.gateworks.com/ara/examples/vision-webapp.py
     577}}}
     578 1. run the script (vison-webapp.py [--port <portno>] [--camera <camera-dev>] [--mp4 <mp4-dir>]
     579{{{#!bash
     580uv run vision-webapp.py --camera /dev/video_webcam --mp4 /usr/share/media/sample_videos/
     581}}}
     582  - you can provide a webcam device path to enable streaming from a webcam and/or an mp4 directory to enable processing those. A dropdown will allow you to select the input stream and the model and the browser window will show you detections and statistics
     583
     584[[Image(vision-webapp.jpg,400px)]]
     585
     586
     587=== command-line python eIQ chatbot (chat.py)
    1293588This is a command-line chatbot written in python using the eIQ AAF Connector
    1294 
    1295 chat.py:
    1296 {{{#!python
    1297 import json
    1298 import requests
    1299 import time
    1300 import sys
    1301 
    1302 API_URL = "http://127.0.0.1:8000/v1/chat/completions"
    1303 MODEL_NAME = "Qwen2.5-7B-Instruct"
    1304 
    1305 def chat():
    1306     print(f"--- i.MX LLM Session (Model: {MODEL_NAME}) ---")
    1307     print("Type 'exit' to stop.\n")
    1308    
    1309     history = [{"role": "system", "content": "You are a helpful AI assistant."}]
    1310 
    1311     while True:
    1312         user_input = input("You: ")
    1313         if user_input.lower() in ['exit', 'quit']:
    1314             break
    1315 
    1316         history.append({"role": "user", "content": user_input})
    1317         payload = {
    1318             "model": MODEL_NAME,
    1319             "messages": history,
    1320             "temperature": 0.7,
    1321             "stream": True
    1322         }
    1323 
    1324         print("AI: ", end="", flush=True)
    1325        
    1326         # Start timing
    1327         start_time = time.time()
    1328         full_reply = ""
    1329         token_count = 0
    1330 
    1331         try:
    1332             response = requests.post(API_URL, json=payload, stream=True)
    1333             response.raise_for_status()
    1334 
    1335             for line in response.iter_lines():
    1336                 if line:
    1337                     decoded_line = line.decode('utf-8')
    1338                     if decoded_line.startswith("data: "):
    1339                         content = decoded_line[6:]
    1340                         if content.strip() == "[DONE]":
    1341                             break
    1342                        
    1343                         chunk = json.loads(content)
    1344                         if "choices" in chunk and chunk["choices"][0]["delta"].get("content"):
    1345                             text = chunk["choices"][0]["delta"]["content"]
    1346                             print(text, end="", flush=True)
    1347                             full_reply += text
    1348                             token_count += 1 # Rough estimate of tokens
    1349            
    1350             # End timing
    1351             end_time = time.time()
    1352             duration = end_time - start_time
    1353             tps = token_count / duration if duration > 0 else 0
    1354 
    1355             print(f"\n\n--- Stats ---")
    1356             print(f"Time taken: {duration:.2f} seconds")
    1357             print(f"Throughput: {tps:.2f} tokens/sec")
    1358             print(f"-------------\n")
    1359            
    1360             history.append({"role": "assistant", "content": full_reply})
    1361 
    1362         except Exception as e:
    1363             print(f"\nError: {e}")
    1364 
    1365 if __name__ == "__main__":
    1366    chat()
    1367 }}}
    1368 
    1369 Execution:
    1370 {{{#!bash
    1371 $ uv venv # create virtual python env in current dir
    1372 $ uv pip install requests # install python deps
    1373 $ uv run chat.py # run in venv
     589 1. create a python virtual env (always a good idea to keep python dependencies containerized) and install python libs we need:
     590{{{#!bash
     591# create a dir for the venv
     592mkdir chat
     593cd chat
     594# create a venv (.venv)
     595uv venv
     596# install our scripts dependencies
     597uv pip install -q requests
     598}}}
     599 1. fetch the script
     600{{{#!bash
     601wget https://dev.gateworks.com/ara/examples/chat.py
     602}}}
     603 1. run the script
     604{{{#!bash
     605uv run chat.py
     606}}}
     607
     608Example session:
     609{{{#!bash
    1374610--- i.MX LLM Session (Model: Qwen2.5-7B-Instruct) ---
    1375611Type 'exit' to stop.
     
    1390626}}}
    1391627
    1392 === Web based python eIQ chatbot
     628
     629=== Web based python eIQ chatbot (webchat.py)
    1393630This is a web based chatbot in python using eIQ AAF Connector
    1394631
    1395 webchat.py:
    1396 {{{#!python
    1397 import sys
    1398 import os
    1399 from datetime import datetime
    1400 
    1401 # --- KINARA SDK PATH INJECTION ---
    1402 DVAPI_DIR = "/usr/share/rt-sdk-ara240_2.0.4/include"
    1403 if os.path.exists(DVAPI_DIR):
    1404     sys.path.append(DVAPI_DIR)
    1405 
    1406 import streamlit as st
    1407 import requests
    1408 import json
    1409 import time
    1410 import psutil
    1411 import threading
    1412 import argparse
    1413 
    1414 # Attempt to import the Kinara Python APIs
    1415 try:
    1416     from dvapi import DVSession, dv_endpoint_get_statistics, dv_endpoint_free_statistics
    1417 except ImportError:
    1418     st.error(f"Critical: dvapi.py not found at {DVAPI_DIR}")
    1419     st.stop()
    1420 
    1421 # --- ARGUMENT PARSING ---
    1422 parser = argparse.ArgumentParser()
    1423 parser.add_argument("--host", type=str, default="127.0.0.1", help="AAF Connector Host")
    1424 parser.add_argument("--port", type=str, default="8000", help="AAF Connector Port")
    1425 parser.add_argument("--proxy-sock", type=str, default="/var/run/proxy.sock", help="Kinara Proxy socket")
    1426 args, _ = parser.parse_known_args()
    1427 
    1428 # --- CONFIGURATION ---
    1429 MODEL_NAME = "Qwen2.5-7B-Instruct"
    1430 API_URL = f"http://{args.host}:{args.port}/v1/chat/completions"
    1431 LOGO_URL = "/root/gateworks_logo.png"
    1432 
    1433 # --- HARDWARE TELEMETRY HELPERS ---
    1434 def get_dvapi_npu_stats():
    1435     try:
    1436         ret, session = DVSession.create_via_unix_socket(args.proxy_sock)
    1437         if ret != 0: return None
    1438         with session:
    1439             ret, ep_list = session.get_endpoint_list()
    1440             if ret != 0 or not ep_list: return None
    1441             ret, stats_ptr, count = dv_endpoint_get_statistics(session._session, ep_list[0]._endpoint)
    1442             if ret == 0 and count.value > 0:
    1443                 s = stats_ptr[0]
    1444                 TOTAL_CAPACITY_GB = 16.0
    1445                 free_gb = s.ep_dram_stats.ep_total_free_size / 1073741824
    1446                 used_gb = max(0, TOTAL_CAPACITY_GB - free_gb)
    1447                 dram_pct = (used_gb / TOTAL_CAPACITY_GB) * 100
    1448                 is_busy = st._npu_lock.locked()
    1449                 data = {"temp": s.ep_temp, "util": 100 if is_busy else 0, "ram_pct": dram_pct}
    1450                 dv_endpoint_free_statistics(stats_ptr, count)
    1451                 return data
    1452     except: return None
    1453 
    1454 def get_system_thermals():
    1455     zones = []
    1456     try:
    1457         for zone in sorted(os.listdir("/sys/class/thermal/")):
    1458             if zone.startswith("thermal_zone"):
    1459                 with open(f"/sys/class/thermal/{zone}/temp", "r") as f:
    1460                     z_temp = int(f.read().strip()) / 1000.0
    1461                 zones.append(z_temp)
    1462     except: pass
    1463     return zones
    1464 
    1465 def build_sidebar_html():
    1466     n_stats = get_dvapi_npu_stats()
    1467     cpu_usage = psutil.cpu_percent()
    1468     sys_ram = psutil.virtual_memory().percent
    1469     thermals = get_system_thermals()
    1470    
    1471     npu_html = f"<div style='border-top:1px solid #444; padding-top:5px; font-size:0.82rem;'><b>🔥 Ara2 NPU</b><br>"
    1472     if n_stats:
    1473         npu_html += f"NPU: {n_stats['util']}% {n_stats['temp']:.1f}C | RAM: {n_stats['ram_pct']:.1f}%"
    1474     else:
    1475         npu_html += "NPU Telemetry Unavailable"
    1476     npu_html += "</div>"
    1477 
    1478     sys_html = f"<div style='border-top:1px solid #444; margin-top:8px; padding-top:5px; font-size:0.82rem;'><b>💻 Syst
    1479 m</b><br>"
    1480     temp_str = "/".join([f"{t:.1f}C" for t in thermals])
    1481     sys_html += f"CPU: {cpu_usage:.1f}% {temp_str} | RAM: {sys_ram:.1f}%</div>"
    1482 
    1483     perf_val = st.session_state.get('last_perf', 'N/A')
    1484     perf_html = f"<div style='border-top:1px solid #444; margin-top:8px; padding-top:5px; font-size:0.82rem;'><b>⚡ Las
    1485  Result</b><br>{perf_val}</div>"
    1486     return npu_html + sys_html + perf_html
    1487 
    1488 # --- GLOBAL STATE ---
    1489 if not hasattr(st, '_npu_lock'): st._npu_lock = threading.Lock()
    1490 if not hasattr(st, '_active_user'): st._active_user = "None"
    1491 
    1492 st.set_page_config(page_title="Gateworks Venice AI", layout="wide")
    1493 
    1494 # --- SIDEBAR ---
    1495 with st.sidebar:
    1496     try: st.image(LOGO_URL, width=220)
    1497     except: st.write("### Gateworks Venice")
    1498    
    1499     status_slot = st.empty()
    1500     # Simplified to just show the IP address
    1501     user_id = st.context.ip_address or "127.0.0.1"
    1502 
    1503     if st._npu_lock.locked():
    1504         status_slot.warning(f"⚠️ BUSY: {st._active_user}")
    1505     else:
    1506         status_slot.success("🟢 READY")
    1507    
    1508     st.caption(f"User: {user_id}")
    1509    
    1510     stats_slot = st.empty()
    1511     stats_slot.markdown(build_sidebar_html(), unsafe_allow_html=True)
    1512 
    1513 # --- MAIN INTERFACE ---
    1514 st.title("🤖 i.MX Edge LLM")
    1515 
    1516 if "messages" not in st.session_state: st.session_state.messages = []
    1517 for msg in st.session_state.messages:
    1518     with st.chat_message(msg["role"]): st.markdown(msg["content"])
    1519 
    1520 if prompt := st.chat_input("Ask the NPU..."):
    1521     st.chat_message("user").markdown(prompt)
    1522     st.session_state.messages.append({"role": "user", "content": prompt})
    1523 
    1524     # Console: Log the Incoming Request / Queue status
    1525     ts_in = datetime.now().strftime("%H:%M:%S")
    1526     print(f"[{ts_in}] QUEUED: Request from {user_id} -> '{prompt[:40]}...'")
    1527 
    1528     with st.chat_message("assistant"):
    1529         response_placeholder = st.empty()
    1530        
    1531         # This lock handles the "Queued" logic—it will block here if someone else is talking
    1532         with st._npu_lock:
    1533             st._active_user = user_id
    1534             status_slot.warning(f"⚠️ BUSY: {user_id}")
    1535            
    1536             ts_start = datetime.now().strftime("%H:%M:%S")
    1537             print(f"[{ts_start}] PROCESSING: Active inference for {user_id}")
    1538            
    1539             full_response, token_count, start_time = "", 0, time.time()
    1540 
    1541             try:
    1542                 payload = {"model": MODEL_NAME, "messages": st.session_state.messages, "stream": True}
    1543                 r = requests.post(API_URL, json=payload, stream=True, timeout=120)
    1544                
    1545                 for line in r.iter_lines():
    1546                     if line:
    1547                         decoded = line.decode('utf-8').replace('data: ', '')
    1548                         if decoded.strip() == "[DONE]": break
    1549                         try:
    1550                             chunk = json.loads(decoded)
    1551                             content = chunk["choices"][0]["delta"].get("content", "")
    1552                             if content:
    1553                                 full_response += content
    1554                                 token_count += 1
    1555                                 response_placeholder.markdown(full_response + "▌")
    1556                                
    1557                                 if token_count % 12 == 0:
    1558                                     stats_slot.markdown(build_sidebar_html(), unsafe_allow_html=True)
    1559                         except: continue
    1560 
    1561                 duration = time.time() - start_time
    1562                 tps = token_count / duration if duration > 0 else 0
    1563                 st.session_state.last_perf = f"{token_count} tokens @ {tps:.1f} t/s"
    1564                
    1565                 response_placeholder.markdown(full_response)
    1566                 st.session_state.messages.append({"role": "assistant", "content": full_response})
    1567 
    1568                 # Console: Log Completion
    1569                 ts_out = datetime.now().strftime("%H:%M:%S")
    1570                 print(f"[{ts_out}] COMPLETE: {user_id} | {token_count} tokens | {tps:.1f} t/s")
    1571 
    1572             except Exception as e:
    1573                 st.error(f"Error: {e}")
    1574                 print(f"[{datetime.now().strftime('%H:%M:%S')}] ERROR: {e}")
    1575             finally:
    1576                 st._active_user = "None"
    1577                 stats_slot.markdown(build_sidebar_html(), unsafe_allow_html=True)
    1578                 status_slot.success("🟢 READY")
    1579                 st.rerun()
    1580 }}}
    1581 
    1582 Execution:
    1583 {{{#!bash
    1584 $ mkdir /root/webapp
    1585 $ cd /root/webapp
    1586 $ uv venv # create virtual python env in current dir
    1587 $ uv pip install streamlit requests psutil argparse # install python deps
    1588 $ uv run streamlit run webchat.py --server.address 0.0.0.0 --server.port 8501 -- --user-map users.json --host 127.0.0.1 --port 8000
    1589 }}}
    1590 
    1591 Service:
    1592  - if want this to run as a service:
    1593 {{{#!bash
    1594 cat << EOF > /etc/systemd/system/eiq-webapp.service:
    1595 [Unit]
    1596 Description=Streamlit Webapp for eIQ AAF
    1597 # Start after network is up
    1598 After=network.target
    1599 # We don't use 'After=eiq-aaf-connector.service' to avoid potential boot cycles
    1600 StartLimitIntervalSec=0
    1601 
    1602 [Service]
    1603 Type=simple
    1604 User=root
    1605 # Ensure we are in the directory where webapp.py lives
    1606 WorkingDirectory=/root/webapp
    1607 
    1608 # 1. Wait until the Connector is actually listening on Port 8000
    1609 ExecStartPre=/bin/bash -c 'until ss -Hltn | grep -E -q ":8000([[:space:]]|$)"; do echo "Waiting for eIQ Connector on Port 8000..." >&2; sleep 5; done'
    1610 
    1611 # 2. Launch the app using uv
    1612 # Note: Using absolute path for uv is safer in systemd
    1613 ExecStart=/usr/local/bin/uv run streamlit run webapp.py \
    1614     --server.address 0.0.0.0 \
    1615     --server.port 8501 \
    1616     -- \
    1617     --user-map users.json \
    1618     --host 127.0.0.1 \
    1619     --port 8000
    1620 
    1621 # Restart logic
    1622 Restart=on-failure
    1623 RestartSec=10s
    1624 StartLimitBurst=0
    1625 
    1626 # Standard Logging
    1627 StandardOutput=journal
    1628 StandardError=journal
    1629 
    1630 [Install]
    1631 WantedBy=multi-user.target
    1632 EOF
    1633 systemctl daemon-reload
    1634 systemctl enable eiq-webapp.service
    1635 systemctl start eiq-webapp.service
    1636 }}}
     632 1. create a python virtual env (always a good idea to keep python dependencies containerized) and install python libs we need:
     633{{{#!bash
     634# create a dir for the venv
     635mkdir webchat
     636cd webchat
     637# create a venv (.venv)
     638uv venv
     639# install our scripts dependencies
     640uv pip install -q fastapi psutil uvicorn
     641}}}
     642 1. fetch the script
     643{{{#!bash
     644wget https://dev.gateworks.com/ara/examples/webchat.py
     645}}}
     646 1. run the script
     647{{{#!bash
     648uv run webchat.py
     649}}}
     650 1. Open a web browser to your boards IP address port 8080: http://<ipaddr>:8080
     651
     652Notes:
     653 * By default this will listen for HTTP requests on port 8080
     654 * On startup it will enable the model (specified in the script) and restart the eIQ server if needed - waiting for the model to load may take several minutes
    1637655
    1638656
    1639657[=#vlm]
    1640 === Web based python VLM eIQ example
     658=== Web based python VLM eIQ example (webvlm.py)
    1641659The eIQ AAF Connector can be used to analyze video and images.
    1642660
     
    1645663 - eIQ AAF Connector
    1646664
    1647 Example:
    1648  - if you want some video examples you can download NXP's vlm-edge-studio_1.0.0.deb and extract its data:
    1649 {{{#!bash
    1650 # extract data (but don't install the deb)
    1651 dpkg-deb --vextract vlm-edge-studio_1.0.0.deb /
    1652 }}}
    1653   - this installs a number of videos to /usr/share/vlm-edge-studio/assets/videos
    1654  - The AAF connector requires a lot of DRAM when loading large models (ie the 12GB Qwen2.5-VL-7B-Instruct model) so we will create a swap file to avoid memory issues when loading the model:
    1655 {{{#!bash
    1656 swapon --show # shows nothing as not enabled
    1657 # pre-allocate space for swap file
    1658 fallocate -l 4G /swapfile
    1659 # make sure it is accessible by root only
    1660 chmod 600 /swapfile
    1661 # format the file as swap
    1662 mkswap /swapfile
    1663 # activate the swapfile
    1664 swapon /swapfile
    1665 # add it to /etc/fstab so that it mounts on boot
    1666 echo '/swapfile none swap sw 0 0' >> /etc/fstab
    1667 }}}
    1668  - install Qwen2.5-VL-7B-Instruct-Ara240 model
    1669 {{{#!bash
    1670 fetch_models --repo-id nxp/Qwen2.5-VL-7B-Instruct-Ara240 # 12GB
    1671 }}}
    1672  - To avoid loading models we are not using into the ARA and run into memory issues, disable all models except for Qwen2.5-7B-Instruct in the AAF connectors config file:
    1673 {{{#!python
    1674 python3 -c 'import json; p="/usr/share/eiq/aaf-connector/server_config.json"; f=open(p,"r+"); d=json.load(f); [m.update({"enabled": (m.get("name") == "Qwen2.5-VL-7B-Instruct")}) for m in d.get("available_models", [])]; f.seek(0); json.dump(d, f, indent=4); f.truncate()'
    1675 # restart AAF connector
    1676 systemctl restart eiq-aaf-connector.service
    1677 # wait for it to be up and running (as it will take several minutes to load the 12GB Qwen2.5-7B-Instruct to the ARA)
    1678 until (echo > /dev/tcp/127.0.0.1/8000) >/dev/null 2>&1; do echo -n .; sleep 1; done
    1679 }}}
    1680  - create a dir for us to work in and create the python script
    1681 {{{#!bash
    1682 mkdir vlm-webapp; cd vlm-webapp
    1683 cat << \EOF > vlm.py
    1684 import argparse
    1685 import os
    1686 import httpx
    1687 import uvicorn
    1688 import json
    1689 import urllib.request
    1690 import time
    1691 from datetime import datetime
    1692 from fastapi import FastAPI, HTTPException
    1693 from fastapi.responses import HTMLResponse, StreamingResponse
    1694 from fastapi.staticfiles import StaticFiles
    1695 from pydantic import BaseModel
    1696 from typing import List, Dict
    1697 
    1698 # ═══════════════════════════════════════════════════════════════
    1699 # Command Line Arguments & Global Constants Configuration
    1700 # ═══════════════════════════════════════════════════════════════
    1701 parser = argparse.ArgumentParser(description="VLM Edge Studio WebApp Bridge")
    1702 parser.add_argument("--video-dir", required=True, help="Directory path where video MP4 files are hosted")
    1703 parser.add_argument("--aaf-server", default="http://127.0.0.1:8000", help="AAF Server backend Base URL")
    1704 parser.add_argument("--host", default="0.0.0.0", help="Host binding address for this web application")
    1705 parser.add_argument("--port", type=int, default=8080, help="Port binding for this web application")
    1706 parser.add_argument("--verbose", action="store_true", default=False, help="Enable verbose raw JSON payload terminal dumping")
    1707 
    1708 args, _ = parser.parse_known_args()
    1709 
    1710 TARGET_MODEL = "Qwen2.5-VL-7B-Instruct"
    1711 
    1712 app = FastAPI(title="VLM Video Web Analyzer")
    1713 
    1714 if not os.path.isdir(args.video_dir):
    1715     raise RuntimeError(f"Provided video directory target does not exist: {args.video_dir}")
    1716 
    1717 # Mount local streaming static location directly from the primary video-dir configuration
    1718 app.mount("/stream/videos", StaticFiles(directory=args.video_dir), name="videos")
    1719 
    1720 class ChatMessage(BaseModel):
    1721     role: str
    1722     content: str
    1723 
    1724 class MultiTurnPayload(BaseModel):
    1725     video_name: str
    1726     history: List[ChatMessage]
    1727 
    1728 def get_timestamp():
    1729     return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
    1730 
    1731 # ═══════════════════════════════════════════════════════════════
    1732 # API Engine Intercept Proxy Routes
    1733 # ═══════════════════════════════════════════════════════════════
    1734 
    1735 @app.get("/api/videos", tags=["Media"])
    1736 async def get_available_videos():
    1737     try:
    1738         if not os.path.exists(args.video_dir):
    1739             return []
    1740         files = os.listdir(args.video_dir)
    1741         valid_extensions = (".mp4", ".mov", ".mkv", ".avi")
    1742         return [f for f in files if f.lower().endswith(valid_extensions)]
    1743     except Exception as e:
    1744         raise HTTPException(status_code=500, detail=str(e))
    1745 
    1746 @app.get("/api/metrics")
    1747 async def proxy_metrics():
    1748     headers = {"Accept": "application/json", "User-Agent": "AAFConnector/1.0"}
    1749     async with httpx.AsyncClient() as client:
    1750         try:
    1751             url = f"{args.aaf_server}/metrics/"
    1752             response = await client.get(url, params={"model_name": TARGET_MODEL}, headers=headers, timeout=3.0)
    1753             return response.json()
    1754         except Exception as e:
    1755             return {
    1756                 TARGET_MODEL: {
    1757                     "llm_average_token_per_second": 0.0,
    1758                     "llm_first_infer_duration": 0.0,
    1759                     "generated_token_num": 0
    1760                 }
    1761             }
    1762 
    1763 @app.post("/api/analyze")
    1764 async def proxy_analysis_stream(payload: MultiTurnPayload):
    1765     absolute_video_target_path = os.path.join(args.video_dir, payload.video_name)
    1766     start_time = time.time()
    1767    
    1768     if len(payload.history) > 1:
    1769         flattened_text = "Here is the conversation history context for this execution sequence:\n"
    1770         for msg in payload.history[:-1]:
    1771             label = "User Question" if msg.role == "user" else "Your Previous Response"
    1772             flattened_text += f"[{label}]: {msg.content}\n"
    1773         flattened_text += f"\n[New Follow-up Question to Answer]: {payload.history[-1].content}"
    1774     else:
    1775         flattened_text = payload.history[0].content
    1776 
    1777     aaf_payload = {
    1778         "model": TARGET_MODEL,
    1779         "stream": True,
    1780         "messages": [{
    1781             "role": "user",
    1782             "content": [
    1783                 {"type": "text", "text": flattened_text},
    1784                 {"type": "video_url", "video_url": {"url": absolute_video_target_path}}
    1785             ]
    1786         }]
    1787     }
    1788 
    1789     print("\n" + "═"*70)
    1790     print(f"[{get_timestamp()}] [CONVERSATIONAL INFERENCE DISPATCH]")
    1791     print(f"  Model ID      : {TARGET_MODEL}")
    1792     print(f"  Target Path   : {absolute_video_target_path}")
    1793     print(f"  Turn Count    : {len(payload.history)} turns processed in session state.")
    1794    
    1795     if args.verbose:
    1796         print("─"*70)
    1797         print("[RAW OUTGOING JSON PAYLOAD SENT TO AAF SERVER]:")
    1798         print(json.dumps(aaf_payload, indent=2))
    1799        
    1800     print("═"*70 + "\n")
    1801 
    1802     def raw_socket_generator():
    1803         target_endpoint = f"{args.aaf_server}/v1/chat/completions"
    1804         data_bytes = json.dumps(aaf_payload).encode('utf-8')
    1805        
    1806         req = urllib.request.Request(
    1807             target_endpoint,
    1808             data=data_bytes,
    1809             headers={
    1810                 "Content-Type": "application/json",
    1811                 "Accept": "application/json",
    1812                 "User-Agent": "AAFConnector/1.0"
    1813             },
    1814             method="POST"
    1815         )
    1816        
    1817         first_token_received = False
    1818        
    1819         try:
    1820             with urllib.request.urlopen(req, timeout=300.0) as response:
    1821                 while True:
    1822                     line_bytes = response.readline()
    1823                     if not line_bytes:
    1824                         break  
    1825                    
    1826                     line_str = line_bytes.decode('utf-8', errors='ignore')
    1827                     trimmed = line_str.strip()
    1828                    
    1829                     if trimmed:
    1830                         yield f"{trimmed}\n".encode('utf-8')
    1831                        
    1832                         if trimmed.startswith('data: '):
    1833                             data_content = trimmed[5:].strip()
    1834                             if data_content == "[DONE]":
    1835                                 continue
    1836                                
    1837                             try:
    1838                                 parsed = json.loads(data_content)
    1839                                 token = parsed["choices"][0]["delta"].get("content", "")
    1840                                 if token:
    1841                                     if not first_token_received:
    1842                                         ttft_duration = time.time() - start_time
    1843                                         print(f"[{get_timestamp()}] [TTFT / DECODE PHASE]: {ttft_duration:.2f}s.")
    1844                                         print(f"[{get_timestamp()}] [STREAMING TEXT TOKENS]: ", end="")
    1845                                         first_token_received = True
    1846                                    
    1847                                     print(token, end="", flush=True)
    1848                             except Exception:
    1849                                 pass
    1850                        
    1851         except urllib.error.HTTPError as http_err:
    1852             err_body = http_err.read().decode('utf-8', errors='ignore')
    1853             yield f"data: {{\"error\": \"AAF Server Engine error {http_err.code}: {err_body}\"}}\n\n".encode('utf-8')
    1854         except Exception as e:
    1855             yield f"data: {{\"error\": \"Direct socket pipeline fault: {str(e)}\"}}\n\n".encode('utf-8')
    1856         finally:
    1857             duration = time.time() - start_time
    1858             print("\n" + "═"*70)
    1859             print(f"[{get_timestamp()}] [INFERENCE COMPLETED] Turn Runtime: {duration:.2f}s")
    1860             print("═"*70 + "\n")
    1861 
    1862     return StreamingResponse(raw_socket_generator(), media_type="text/event-stream")
    1863 
    1864 # ═══════════════════════════════════════════════════════════════
    1865 # User Interface (HTML Layer)
    1866 # ═══════════════════════════════════════════════════════════════
    1867 @app.get("/", response_class=HTMLResponse)
    1868 async def serve_index():
    1869     # Enforcing a raw python string (r"") so Python never converts or drops backslashes
    1870     html_content = r"""
    1871     <!DOCTYPE html>
    1872     <html lang="en">
    1873     <head>
    1874         <meta charset="UTF-8">
    1875         <title>VLM Edge Studio Analyzer</title>
    1876         <script src="https://cdn.tailwindcss.com"></script>
    1877         <style>
    1878             .skeleton-pulse {
    1879                 background: linear-gradient(-90deg, #1e293b 0%, #334155 50%, #1e293b 100%);
    1880                 background-size: 400% 400%;
    1881                 animation: pulse 1.5s ease-in-out infinite;
    1882             }
    1883             @keyframes pulse {
    1884                 0% { background-position: 100% 50%; }
    1885                 100% { background-position: 0% 50%; }
    1886             }
    1887         </style>
    1888     </head>
    1889     <body class="bg-gray-900 text-gray-100 min-h-screen p-6">
    1890         <div class="max-w-6xl mx-auto space-y-6">
    1891             <header class="border-b border-gray-800 pb-4 flex justify-between items-center">
    1892                 <div>
    1893                     <h1 class="text-2xl font-bold tracking-wide text-indigo-400">VLM Edge Platform Interface</h1>
    1894                     <p id="metricsPanel" class="text-xs text-gray-400 mt-1 font-mono">Metrics: Waiting for pipeline activity...</p>
    1895                 </div>
    1896                 <div class="flex items-center space-x-3">
    1897                     <span class="text-xs font-mono bg-gray-800 border border-gray-700 rounded px-2.5 py-1 text-indigo-300">Target Profile: __MODEL_NAME_PLACEHOLDER__</span>
    1898                     <button id="clearChatBtn" class="bg-red-900/40 hover:bg-red-800 border border-red-700 text-red-200 text-xs py-1.5 px-3 rounded transition-colors">Clear Chat History</button>
    1899                 </div>
    1900             </header>
    1901 
    1902             <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
    1903                 <div class="lg:col-span-2 space-y-4">
    1904                     <div class="flex items-center space-x-4">
    1905                         <label class="font-medium text-sm">Select Stream Source:</label>
    1906                         <select id="videoSelect" class="flex-1 bg-gray-800 border border-gray-700 rounded p-2 focus:outline-none focus:border-indigo-500"></select>
    1907                     </div>
    1908                     <div class="bg-black rounded-lg overflow-hidden aspect-video relative flex items-center justify-center border border-gray-800">
    1909                         <video id="videoPlayer" controls class="w-full h-full hidden"></video>
    1910                         <div id="videoPlaceholder" class="text-gray-500 text-sm">No Active Video Stream Sample Loaded</div>
    1911                     </div>
    1912                 </div>
    1913 
    1914                 <div class="flex flex-col h-[480px]">
    1915                     <div class="bg-gray-800 border border-gray-700 rounded-lg p-4 flex-1 flex flex-col min-h-0 relative overflow-hidden">
    1916                         <div class="flex justify-between items-center mb-3 flex-none">
    1917                             <h2 class="text-sm font-semibold tracking-wider text-gray-400 uppercase">Conversational History Log</h2>
    1918                             <div id="busySpinner" class="hidden h-4 w-4 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"></div>
    1919                         </div>
    1920                         <div id="chatHistoryLog" class="flex-1 space-y-4 text-sm overflow-y-auto bg-gray-900 p-3 rounded border border-gray-750 font-mono min-h-0">
    1921                             <div class="text-gray-500 text-xs italic">System initialized. Awaiting prompt loop...</div>
    1922                         </div>
    1923                     </div>
    1924                    
    1925                     <div class="space-y-2 mt-4 flex-none">
    1926                         <textarea id="promptInput" rows="2" class="w-full bg-gray-800 border border-gray-700 rounded-lg p-3 text-sm focus:outline-none focus:border-indigo-500 resize-none placeholder-gray-500" placeholder="Ask a follow-up question..."></textarea>
    1927                         <button id="submitBtn" class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:bg-gray-700 disabled:cursor-not-allowed text-white font-medium py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center space-x-2">
    1928                             <span id="btnText">Execute Analysis Prompt</span>
    1929                         </button>
    1930                     </div>
    1931                 </div>
    1932             </div>
    1933         </div>
    1934 
    1935         <script>
    1936             const videoSelect = document.getElementById('videoSelect');
    1937             const videoPlayer = document.getElementById('videoPlayer');
    1938             const videoPlaceholder = document.getElementById('videoPlaceholder');
    1939             const promptInput = document.getElementById('promptInput');
    1940             const submitBtn = document.getElementById('submitBtn');
    1941             const btnText = document.getElementById('btnText');
    1942             const chatHistoryLog = document.getElementById('chatHistoryLog');
    1943             const metricsPanel = document.getElementById('metricsPanel');
    1944             const busySpinner = document.getElementById('busySpinner');
    1945             const clearChatBtn = document.getElementById('clearChatBtn');
    1946 
    1947             let chatHistoryBuffer = [];
    1948 
    1949             async function initializeApp() {
    1950                 try {
    1951                     const videoRes = await fetch('/api/videos');
    1952                     const videos = await videoRes.json();
    1953                     videos.forEach(v => videoSelect.add(new Option(v, v)));
    1954                    
    1955                     if(videos.length > 0) handleVideoChange(videos[0]);
    1956                 } catch (e) {
    1957                     chatHistoryLog.innerHTML = `<div class="text-red-400">Initialization Fault: ${e.message}</div>`;
    1958                 }
    1959             }
    1960 
    1961             function appendMessageBlock(role, text, isSkeleton = false) {
    1962                 const block = document.createElement('div');
    1963                 block.className = `p-2.5 rounded border ${role === 'user' ? 'bg-indigo-950/40 border-indigo-900/60 ml-6' : 'bg-gray-800/60 border-gray-700/50 mr-6'} ${isSkeleton ? 'skeleton-pulse min-h-[40px]' : ''}`;
    1964                
    1965                 const senderLabel = document.createElement('div');
    1966                 senderLabel.className = `text-[10px] font-bold uppercase tracking-wider mb-1 ${role === 'user' ? 'text-indigo-400' : 'text-gray-400'}`;
    1967                 senderLabel.textContent = role === 'user' ? '● User Prompt' : '● Model Response';
    1968                
    1969                 const contentText = document.createElement('div');
    1970                 contentText.className = "whitespace-pre-wrap leading-relaxed break-words text-sm font-mono text-gray-100";
    1971                 if (!isSkeleton) contentText.textContent = text;
    1972                
    1973                 block.appendChild(senderLabel);
    1974                 block.appendChild(contentText);
    1975                 chatHistoryLog.appendChild(block);
    1976                 chatHistoryLog.scrollTop = chatHistoryLog.scrollHeight;
    1977                 return contentText;
    1978             }
    1979 
    1980             async function updateMetrics(clientLatencySec) {
    1981                 try {
    1982                     const res = await fetch('/api/metrics');
    1983                     const root = await res.json();
    1984                     const metrics = Object.values(root)[0];
    1985                     if (metrics) {
    1986                         const tps = metrics.llm_average_token_per_second?.toFixed(1) || "0.0";
    1987                         const ttft = metrics.llm_first_infer_duration?.toFixed(2) || "0.00";
    1988                         const tokens = metrics.generated_token_num || 0;
    1989                         metricsPanel.textContent = `Metrics: ${tps} tok/s • TTFT: ${ttft}s • ${tokens} tokens • Latency: ${clientLatencySec.toFixed(2)}s`;
    1990                     }
    1991                 } catch (e) {
    1992                     console.error(e);
    1993                 }
    1994             }
    1995 
    1996             function handleVideoChange(filename) {
    1997                 resetChatHistory();
    1998                 if(!filename) {
    1999                     videoPlayer.classList.add('hidden');
    2000                     videoPlaceholder.classList.remove('hidden');
    2001                     return;
    2002                 }
    2003                 videoPlaceholder.classList.add('hidden');
    2004                 videoPlayer.classList.remove('hidden');
    2005                 videoPlayer.src = `/stream/videos/${encodeURIComponent(filename)}`;
    2006                 videoPlayer.load();
    2007             }
    2008 
    2009             function resetChatHistory() {
    2010                 chatHistoryBuffer = [];
    2011                 chatHistoryLog.innerHTML = `<div class="text-gray-500 text-xs italic">Conversation wiped. Ready for prompt input...</div>`;
    2012                 promptInput.value = "what is happening in this video?";
    2013             }
    2014 
    2015             videoSelect.addEventListener('change', (e) => handleVideoChange(e.target.value));
    2016             clearChatBtn.addEventListener('click', resetChatHistory);
    2017 
    2018             submitBtn.addEventListener('click', async () => {
    2019                 const prompt = promptInput.value.trim();
    2020                 const video_name = videoSelect.value;
    2021                
    2022                 if (!prompt || !video_name) return;
    2023 
    2024                 const clientStartTime = performance.now();
    2025 
    2026                 appendMessageBlock('user', prompt);
    2027                 chatHistoryBuffer.push({ role: 'user', content: prompt });
    2028                
    2029                 promptInput.value = "";
    2030                 submitBtn.disabled = true;
    2031                 btnText.textContent = "Processing Inference...";
    2032                 busySpinner.classList.remove('hidden');
    2033                
    2034                 const liveResponseNode = appendMessageBlock('assistant', "Connecting...", true);
    2035                
    2036                 try {
    2037                     const response = await fetch('/api/analyze', {
    2038                         method: 'POST',
    2039                         headers: { 'Content-Type': 'application/json' },
    2040                         body: JSON.stringify({ video_name, history: chatHistoryBuffer })
    2041                     });
    2042 
    2043                     if (!response.ok) throw new Error("Server engine pipeline connection fault.");
    2044 
    2045                     liveResponseNode.parentElement.classList.remove('skeleton-pulse');
    2046                     liveResponseNode.textContent = "";
    2047 
    2048                     const reader = response.body.getReader();
    2049                     const decoder = new TextDecoder();
    2050                     let buffer = "";
    2051                     let fullModelResponse = "";
    2052 
    2053                     while (true) {
    2054                         const { value, done } = await reader.read();
    2055                         if (done) break;
    2056                        
    2057                         buffer += decoder.decode(value, { stream: true });
    2058                         const lines = buffer.split('\n');
    2059                         buffer = lines.pop();
    2060                        
    2061                         for (const line of lines) {
    2062                             const trimmed = line.trim();
    2063                            
    2064                             if (!trimmed || !trimmed.startsWith('data: ')) continue;
    2065                            
    2066                             const dataStr = trimmed.slice(5).trim();
    2067                             if (dataStr === '[DONE]') continue;
    2068                            
    2069                             try {
    2070                                 const json = JSON.parse(dataStr);
    2071                                 if(json.error) {
    2072                                     liveResponseNode.textContent += `\n[AAF Error]: ${json.error}`;
    2073                                     continue;
    2074                                 }
    2075                                
    2076                                 const contentToken = json.choices?.[0]?.delta?.content || "";
    2077                                 if (contentToken) {
    2078                                     fullModelResponse += contentToken;
    2079                                     liveResponseNode.textContent = fullModelResponse;
    2080                                     chatHistoryLog.scrollTop = chatHistoryLog.scrollHeight;
    2081                                 }
    2082                             } catch(e) {}
    2083                         }
    2084                     }
    2085                    
    2086                     chatHistoryBuffer.push({ role: 'assistant', content: fullModelResponse });
    2087 
    2088                     const clientLatencySec = (performance.now() - clientStartTime) / 1000;
    2089                     setTimeout(() => updateMetrics(clientLatencySec), 500);
    2090 
    2091                 } catch (err) {
    2092                     liveResponseNode.parentElement.classList.remove('skeleton-pulse');
    2093                     liveResponseNode.textContent = `\n[Pipeline Runtime Exception]: ${err.message}`;
    2094                 } finally {
    2095                     submitBtn.disabled = false;
    2096                     btnText.textContent = "Execute Analysis Prompt";
    2097                     busySpinner.classList.add('hidden');
    2098                 }
    2099             });
    2100 
    2101             initializeApp();
    2102         </script>
    2103     </body>
    2104     </html>
    2105     """
    2106     return HTMLResponse(content=html_content.replace("__MODEL_NAME_PLACEHOLDER__", TARGET_MODEL))
    2107 
    2108 if __name__ == "__main__":
    2109     uvicorn.run(app, host=args.host, port=args.port)
    2110 EOF
    2111 }}}
    2112  - create a python virtual env (always a good idea to keep python dependencies containerized) and install python modules we need:
    2113 {{{#!bash
     665Requirements:
     666 - Ara runtime
     667 - eIQ AAF Connector
     668 - Qwen2.5-VL-7B-Instruct-Ara240 model
     669
     670Steps:
     671 1. create a python virtual env (always a good idea to keep python dependencies containerized) and install python libs we need:
     672{{{#!bash
     673# create a dir for the venv
     674mkdir webvlm
     675cd webvlm
    2114676# create a venv (.venv)
    2115677uv venv
    2116678# install our scripts dependencies
    2117 uv pip install httpx uvicorn fastapi argparse
    2118 }}}
    2119  - run the app giving it the host interface and port to listen on the URL of the AAF server and the directory of the videos:
    2120 {{{#!bash
    2121 # run the app
    2122 uv run vlm.py  --host 0.0.0.0 --port 8080 --video-dir /usr/share/vlm-edge-studio/assets/videos --aaf-server http://127.0.0.1:8000
    2123 }}}
    2124   - Note that the AAF server must have access to the video so if for some reason its running on a different server make sure to handle adjusting the URL that is submitted to analyze
    2125  - open a browser to the host port 8080, select a video and submit a query
     679uv pip install -q httpx uvicorn fastapi argparse
     680}}}
     681 1. fetch the script
     682{{{#!bash
     683wget https://dev.gateworks.com/ara/examples/webvlm.py
     684}}}
     685 1. run the script
     686{{{#!bash
     687uv run webvlm.py
     688}}}
     689 1. Open a web browser to your boards IP address port 8080: http://<ipaddr>:8080
     690
     691Notes:
     692 * By default this will listen for HTTP requests on port 8080
     693 * On startup it will enable the model (specified in the script) and restart the eIQ server if needed - waiting for the model to load may take several minutes
     694
    2126695
    2127696[[Image(vlm-webapp.jpg,400px)]]