Android开发笔记[5]-网页服务器

摘要

在Android上使用nanoHTTPD库创建网页服务器,实时在网页上查看Android的摄像头画面.

平台信息

  • Android Studio: Electric Eel | 2022.1.1 Patch 2
  • Gradle:distributionUrl=https://services.gradle.org/distributions/gradle-7.5-bin.zip
  • jvmTarget = '1.8'
  • minSdk 21
  • targetSdk 33
  • compileSdk 33
  • 开发语言:Kotlin,Java
  • ndkVersion = '25.2.9519653'

项目源码地址

[https://gitee.com/qsbye/AndTheStone]

nanoHTTPD简介

[https://gitcode.net/mirrors/nanohttpd/nanohttpd]
[https://github.com/nanohttpd/nanohttpd]
[https://developer.aliyun.com/article/818682]
NanoHTTPD is a light-weight HTTP server designed for embedding in other applications, released under a Modified BSD licence.
NanoHttpd仅有一个Java文件的微型Http服务器实现。其方便嵌入式设备(例如:Android设备)中启动一个本地服务器,接收客户端本地部分请求;应用场景也非常广泛,例如:本地代理方式播放m3u8视频、本地代理方式加载一些加密秘钥等。
核心功能描述:

  • Only one Java file, providing HTTP 1.1 support.
    仅一个Java文件,支持Http 1.1

  • No fixed config files, logging, authorization etc. (Implement by yourself if you need them. Errors are passed to java.util.logging, though.)
    没有固定的配置文件、日志系统、授权等等(如果你有需要需自己实现。工程中的日志输出,通过java.util.logging实现的)

  • Support for HTTPS (SSL).
    支持Https

  • Basic support for cookies.
    支持cookies

  • Supports parameter parsing of GET and POST methods.
    支持POST和GET 参数请求

  • Some built-in support for HEAD, POST and DELETE requests. You can easily implement/customize any HTTP method, though.
    内置支持HEAD、POST、DELETE请求,你可以方便的实现或自定义任何HTTP方法请求。

  • Supports file upload. Uses memory for small uploads, temp files for large ones.
    支持文件上传。小文件上传使用内存缓存,大文件使用临时文件缓存。

  • Never caches anything.
    不缓存任何内容

  • Does not limit bandwidth, request time or simultaneous connections by default.
    默认不限制带宽、请求时间 和 最大请求量

  • All header names are converted to lower case so they don't vary between browsers/clients.
    所有Header 名都被转换为小写,因此不会因客户端或浏览器的不同而有所差别

  • Persistent connections (Connection "keep-alive") support allowing multiple requests to be served over a single socket connection.
    支持一个socket连接服务多个长连接请求。

nanoHTTPD目录简介

NanoHTTPD project currently consist of four parts:

  • /core – Fully functional HTTP(s) server consisting of one (1) Java file, ready to be customized/inherited for your own project.

  • /samples – Simple examples on how to customize NanoHTTPD. See HelloServer.java for a killer app that greets you enthusiastically!

  • /websocket – Websocket implementation, also in a single Java file. Depends on core.

  • /webserver – Standalone file server. Run & enjoy. A popular use seems to be serving files out off an Android device.

  • /nanolets – Standalone nano app server, giving a servlet like system to the implementor.

  • /fileupload – integration of the apache common file upload library.

Core

  • Only one Java file, providing HTTP 1.1 support.
  • No fixed config files, logging, authorization etc. (Implement by yourself if you need them. Errors are passed to java.util.logging, though.)
  • Support for HTTPS (SSL).
  • Basic support for cookies.
  • Supports parameter parsing of GET and POST methods.
  • Some built-in support for HEAD, POST and DELETE requests. You can easily implement/customize any HTTP method, though.
  • Supports file upload. Uses memory for small uploads, temp files for large ones.
  • Never caches anything.
  • Does not limit bandwidth, request time or simultaneous connections by default.
  • All header names are converted to lower case so they don't vary between browsers/clients.
  • Persistent connections (Connection "keep-alive") support allowing multiple requests to be served over a single socket connection.

Websocket

  • Tested on Firefox, Chrome and IE.

Webserver

  • Default code serves files and shows (prints on console) all HTTP parameters and headers.
  • Supports both dynamic content and file serving.
  • File server supports directory listing, index.html and index.htm.
  • File server supports partial content (streaming & continue download).
  • File server supports ETags.
  • File server does the 301 redirection trick for directories without /.
  • File server serves also very long files without memory overhead.
  • Contains a built-in list of most common MIME types.
  • Runtime extension support (extensions that serve particular MIME types) - example extension that serves Markdown formatted files. Simply including an extension JAR in the webserver classpath is enough for the extension to be loaded.
  • Simple CORS support via --cors parameter
    • by default serves Access-Control-Allow-Headers: origin,accept,content-type
    • possibility to set Access-Control-Allow-Headers by setting System property: AccessControlAllowHeader
    • _example: _ -DAccessControlAllowHeader=origin,accept,content-type,Authorization
    • possible values:
      • --cors: activates CORS support, Access-Control-Allow-Origin will be set to *.
      • --cors=some_value: Access-Control-Allow-Origin will be set to some_value.

CORS argument examples

  • --cors=http://appOne.company.com
  • --cors="http://appOne.company.com, http://appTwo.company.com": note the double quotes so that the two URLs are considered part of a single argument.

在项目中使用nanoHTTPD

  1. 把整个nanohttpd文件夹复制到项目下即可使用
    nanohttpd-master\core\src\main\java\org\nanohttpd
  2. 或者添加依赖:
implementation'org.nanohttpd:nanohttpd:2.3.1'

图像流

jpeg图片格式简介

[https://www.cnblogs.com/P201821460033/p/13658489.html]
[https://www.britannica.com/technology/JPEG]
[https://jpeg.org]
[https://ds.jpeg.org/whitepapers/jpeg-ai-white-paper.pdf]
JPEG: 通过JPEG压缩算法去除冗余数据的过程属于有损压缩,压缩率越大,恢复图像后质量越低。还以2M Camera为例, RGB888 48Mbit大小的静图以10:1压缩率处理后大小仅为4.8Mbit,码率为144Mbps;一般压缩的目的是减小存储空间和便于传输.
JPEG(Joint Photographic Experts Group)是一种常用的图像存储格式,也是最常用的图像文件格式之一。它是由国际标准化组织(ISO)制定的一种面向连续色调静止图像的压缩标准。JPEG格式的文件通常以.jpg或.jpeg作为文件扩展名。

JPEG格式通过对图像的颜色变化进行平均处理,并丢弃人眼无法察觉的细节,从而实现了“有损”压缩。这种压缩方法使得JPEG格式在数字摄影和网络应用中得到广泛应用。JPEG格式可以在保持相对较高的图像质量的同时,减小图像文件的大小,从而节省存储空间和传输带宽。

JPEG文件由多个段组成,每个段代表不同的信息,例如图像数据、色彩空间信息、量化表等。每个段都有自己的唯一标识符,用于标识段的类型和目的。

总结一下,JPEG是一种常用的图像存储格式,通过有损压缩方法实现图像文件的大小减小,广泛应用于数字摄影和网络应用中。

JPEG格式可以分为:标准JPEG、渐进式JPEG 和 JPEG2000三种格式。

  • 标准JPEG:
    该类型的图片文件,在网络上应用较多,只有图片完全被加载和读取完毕之后,才能看到图片的全貌;它是一种很灵活的图片压缩方式,用户可以在压缩比和图片品质之间进行权衡。不过通常来讲,其压缩比在10:1到40:1之间,压缩比越大,品质就越差,压缩比越小,品质就越好。

  • 渐进式JPEG:
    该类型的图片是对标准JPEG格式的改进,当在网页上下载渐进式JPEG图片时,首先呈现图片的大概外貌,然后再逐渐呈现具体的细节部分,因而被称之为渐进式JPEG。

  • JPEG2002:
    一种全新的图片压缩发,压缩品质更好,并且改善了无线传输时,因信号不稳定而造成的马赛克及位置错乱等问题。另外,作为JPEG的升级版,JPEG2000的压缩率比标准JPEG高约30%,同时支持有损压缩和无损压缩。它还支持渐进式传输,即先传输图片的粗略轮廓,然后,逐步传输细节数据,使得图片由模糊到清晰逐步显示。

The scope of JPEG AI is the creation of a learning-based image coding standard offering a single-stream, compact compressed domain representation, targeting both human visualization, with significant compression efficiency improvement over image coding standards in common use at equivalent subjective quality, and effective performance for image processing and computer vision tasks, with the goal of supporting a royalty-free baseline.
JPEG AI targets a wide range of applications such as cloud storage, visual surveillance, autonomous vehicles and devices, image collection storage and management, live monitoring of visual data and media distribution. The objective is to design a coding solution that requires significant compression efficiency improvement over coding standards in common use at equivalent subjective quality as well as an effective compressed domain processing for machine learning-based image processing and computer vision tasks. Other key requirements include hardware/software implementation-friendly encoding and decoding, support for 8- and 10-bit depth, efficient coding of images with text and graphics, and progressive decoding.

jpeg图片偏绿的可能原因

[https://zhuanlan.zhihu.com/p/590753356?utm_id=0]
[https://fourcc.org/fccyvrgb.php]
我们在手机屏幕上看到的图片都存储着RGB信息(Red红、Green绿、Blue蓝),它能告诉屏幕上每个红绿蓝子像素应该以何等亮度发光,从而在屏幕上显示出图片的样貌。但在图像处理过程中,一般需要RGB信息转换成YUV信息(亮度、蓝色浓度偏移量、红色浓度偏移量)。因为人眼对Y代表的亮度信息更为敏感,算法可以着重压缩UV信息。这样就能在人眼感知差别不大的情况下,尽可能减小图片所占的存储空间。

一般而言,从RGB色彩模式转换到YUV色彩模式是轻微有损的,但损失较小,并不至于让图片朝着变绿的方向一路狂奔。但是开发者为了加速这个转换计算过程,不当地使用了位运算,导致数据在从RGB向YUV转换时会向下取。所以在重复压缩过程时, Y、U、V三个值就会不断减小,亮度Y值减小会让图片不断变暗,而UV不断减小,会让色彩不断向绿色的方向偏移。所以,经过多次压缩的图片会变绿、变暗。

YUV和RGB的转换:

\[Y = 0.299 R + 0.587 G + 0.114 B, \\ U = -0.1687 R - 0.3313 G + 0.5 B + 128, \\ V = 0.5 R - 0.4187 G - 0.0813 B + 128, \\ R = Y + 1.402 (V-128), \\ G= Y - 0.34414 (U-128) - 0.71414 (V-128), \\ B= Y + 1.772 (U-128), \\ \]

将YUV全0带入公式2,

得出

\[R = 1.402 * (-128) = -126.598, \\ G = -0.34414(-128) - 0.71414(-128) = 44.04992 + 91.40992 = 135.45984, \\ B = 1.772 * (-128) = -126.228, \\ \]

差不多就是\(R = -126, G = 135, B = -126\)

其中RGB有取值范围, 都是[0, 255]
所以最后就是\(R=0, G=135, B=0\)
所以图片偏绿,
因而图片偏绿有可能是因为YUV中有分量的值为0,而又强制转换为jpeg图片的结果.

图像中常见的颜色编码

[https://blog.csdn.net/nwpu053883/article/details/103733537]
[https://blog.csdn.net/weixin_42854120/article/details/122621489]

  1. YUV - 灰度(亮度) + 色差分量
    YUV格式: YUV与RGB编码方式(色域)不同。RGB使用红、绿、蓝三原色来表示颜色。而YUV使用亮度、色度来表示颜色。同样YUV也存在YUV422,YUV420格式。RGB与YUV可通过算法来进行转换。
  2. RGB - 红绿蓝 三色分量
    RGB格式: RGB格式是由RAW数据插值计算后获取的、每个像素均包含了RGB三种颜色的信息。比如RGB888格式,一个像素 由8bit Red、8bit Green、8bit Blue信息、共3bytes表示。RGB565 格式中一个像素则由5bit R、6bit G、5bit B、共16bit表示. 2M像素以RGB888格式表示时,数据量 2M24bit=48Mbits/帧, 48M30=1.44Gbps;
  3. RAW
    RAW格式: 从Sensor端最初获取的数字格式的数据, 又称为Bayer格式. 每个像素信息只有RGB中的某个颜色信息, 且每4个像素中有2个像素为G信息,1个R信息,1个B信息, 即GRBG格式; 2M像素Camera以RAW10格式(每个像素10bit)输出时,它的数据量为2M10bit=20Mbits/帧,20M30帧=600Mbps;

实现

项目主要目录

#网页目录
.
├── Dash
│   ├── Roboto-Black.woff2
│   ├── Roboto-Bold.woff2
│   ├── Roboto-Light.woff2
│   ├── Roboto-Medium.woff2
│   ├── Roboto-Regular.woff2
│   ├── Roboto-Thin.woff2
│   ├── bundle.js
│   ├── bundle.js.LICENSE.txt
│   ├── cam.jpg
│   ├── favicon.ico
│   ├── index.html
│   ├── jquery.min.js
│   ├── jquery.min.js.LICENSE.txt
│   ├── mdui.min.css
│   ├── mdui.min.js
│   └── mdui.min.js.LICENSE.txt
└── cat.png
#代码目录
.
├── CameraActivity.kt
├── ObjectAnalyzer.kt
├── SubMainActivity.kt
├── VisionActivity.kt
├── WebServerClass.kt
├── nanohttpd
│   ├── protocols
│   │   └── http
│   │       ├── ClientHandler.java
│   │       ├── HTTPSession.java
│   │       ├── IHTTPSession.java
│   │       ├── NanoHTTPD.java
│   │       ├── ServerRunnable.java
│   │       ├── content
│   │       │   ├── ContentType.java
│   │       │   ├── Cookie.java
│   │       │   └── CookieHandler.java
│   │       ├── request
│   │       │   └── Method.java
│   │       ├── response
│   │       │   ├── ChunkedOutputStream.java
│   │       │   ├── IStatus.java
│   │       │   ├── Response.java
│   │       │   └── Status.java
│   │       ├── sockets
│   │       │   ├── DefaultServerSocketFactory.java
│   │       │   └── SecureServerSocketFactory.java
│   │       ├── tempfiles
│   │       │   ├── DefaultTempFile.java
│   │       │   ├── DefaultTempFileManager.java
│   │       │   ├── DefaultTempFileManagerFactory.java
│   │       │   ├── ITempFile.java
│   │       │   └── ITempFileManager.java
│   │       └── threading
│   │           ├── DefaultAsyncRunner.java
│   │           └── IAsyncRunner.java
│   └── util
│       ├── IFactory.java
│       ├── IFactoryThrowing.java
│       ├── IHandler.java
│       └── ServerRunner.java
├── theme
│   ├── Color.kt
│   ├── Shape.kt
│   ├── Theme.kt
│   └── Type.kt
└── ui
    └── theme
        ├── Color.kt
        ├── Shape.kt
        ├── Theme.kt
        └── Type.kt

主要流程

  1. 启动网页服务器
  2. 获取摄像头画面
  3. 转换为jpeg图片

主要代码

build.gradle(app)

plugins {
    id "com.android.application"
    id "org.jetbrains.kotlin.android"
}

android {
    compileSdk 34

    defaultConfig {
        applicationId "com.qsbye.androidserial"
        minSdk 24
        targetSdk 34
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary true
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        viewBinding true
        compose true
    }
    composeOptions {
        //kotlinCompilerExtensionVersion '1.2.0'
        kotlinCompilerExtensionVersion = "1.4.3"
    }
    packagingOptions {
        resources {
            excludes += '/META-INF/{AL2.0,LGPL2.1}'
        }
    }
}

dependencies {
    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.5.1'
    implementation 'com.google.android.material:material:1.6.1'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    implementation 'androidx.core:core-ktx:+'
    implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
    implementation 'androidx.compose.material3:material3:1.0.0-alpha11'
    implementation 'androidx.core:core-ktx:+'
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
    implementation 'io.reactivex.rxjava2:rxjava:2.1.1'
    implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
    implementation 'io.github.xmaihh:serialport:2.1.1' //串口库
    implementation 'com.github.mik3y:usb-serial-for-android:3.6.0'//usb serial
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
    implementation "com.google.accompanist:accompanist-systemuicontroller:0.19.0"
    //摄像头相关
    def camerax_version = "1.3.0-rc01"
    // CameraX core library
    implementation "androidx.camera:camera-core:$camerax_version"
    // CameraX Camera2 extensions[可选]拓展库可实现人像、HDR、夜间和美颜、滤镜但依赖于OEM
    implementation "androidx.camera:camera-camera2:$camerax_version"
    // CameraX Lifecycle library[可选]避免手动在生命周期释放和销毁数据
    implementation "androidx.camera:camera-lifecycle:$camerax_version"
    // CameraX View class[可选]最佳实践,最好用里面的PreviewView,它会自行判断用SurfaceView还是TextureView来实现
    implementation "androidx.camera:camera-view:$camerax_version"

    //神经网络相关
    implementation 'com.google.android.material:material:1.9.0'
    implementation "androidx.compose.ui:ui:1.5.1"
    implementation "androidx.compose.material:material:1.5.1"
    implementation "androidx.compose.ui:ui-tooling:1.5.1"
    implementation "androidx.activity:activity-compose:1.8.0-rc01"
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
    implementation 'com.google.mlkit:image-labeling:17.0.7'
    implementation 'com.google.mlkit:object-detection:17.0.0'
    implementation "com.google.accompanist:accompanist-permissions:0.16.1"
    debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"

    //网页服务器相关
    implementation'org.nanohttpd:nanohttpd:2.3.1'
}

index.html

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>大石·网页前中后台</title>
    <link rel="stylesheet" href="./mdui.min.css">
    <link rel="icon" type="image/x-icon" href="./favicon.ico">
</head>

<header>
    <!-- start 顶部工具栏-->
    <div class="mdui-toolbar mdui-color-theme" id="my-toolbar">
        <a href="javascript:;" class="mdui-btn mdui-btn-icon" id="my-smile">
            <b class="mdui-icon mdui-fab-mini mdui-ripple">🙂</b>
        </a>
        <b>大石·网页前中后台</b>
    </div>
    <!-- end 顶部工具栏-->

</header>

<body>
    <!-- 引入jQuery -->
    <script src="./jquery.min.js"></script>

    <!-- 引入MDUI -->
    <script src="./mdui.min.js"></script>

    <!-- 在这里使用你的jQuery和Mdui代码 -->
    <script src="bundle.js"></script>

    <!-- start MDUI布局容器-->
    <div class="mdui-container-fluid">
        <!-- start 列布局-->
        <div class="mdui-row">
            
            <!-- start 内容区-->
            <div class="mdui-col-xs-12">

            <!-- start 操作区容器 -->
            <div class="mdui-cente mdui-col-xs-12">
            <!-- 开灯按钮 -->
            <button class="mdui-btn mdui-color-pink-500 mdui-ripple" id="openLightButton">开灯</button>
            
            <!-- 关灯按钮 -->
            <button class="mdui-btn mdui-color-blue-500 mdui-ripple" id="closeLightButton">关灯</button>

            <!-- end 操作区容器 -->
            </div>

            <!-- 创建图像容器 -->
            <div id="imageContainer" class="">
                <img id="videoImage" src="cam.jpg" alt="视频流加载中" class="">
            </div>

        <!-- end 内容区-->
        </div>

        <!-- end 列布局-->
        </div>

    <!-- end MDUI布局容器-->
    </div>
        
        <style>
            /* 设置页面方向为竖屏 */
            @media screen and (orientation:portrait) {
                /* 页面样式 */
                body {
                    transform: rotate(0deg); /* 将页面旋转回正常方向 */
                }
                
            /* 视口样式 */
                @viewport {
                    orientation: portrait;
                    width: 100vw; /* 使用设备的宽度 */
                }
            }

            /* 图片容器 */
            #imageContainer {
                width:180px; /* Maximum width in portrait mode */
                height: 320px;
                background-color: white;
                border-radius: 10px; /* Rounded corners */
                box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.5); /* Shadow */
                overflow: auto;
                position: relative;
            }

            /* 图片流 */
            #videoImage {
                object-fit: fill;
                transform-origin: center center; /* 设置变换的中心点为图片自身中心 */
                transform: rotate(90deg);
                max-width: none; /* 移除最大宽度限制 */
                max-height: 100%; /* 移除最大高度限制 */
                position: absolute;
                top: 0; /* 上边距为0,让图片顶部紧贴容器顶部 */
                left: -80px; /* 左边距为0,让图片左侧紧贴容器左侧 */
            }

          /* 顶部导航栏 */
            #my-toolbar{
                background-color: grey;
            }

          /* 笑脸图标 */
            #my-smile{
                transition: transform 0.3s ease;
            }

            #my-smile:hover{
                transform: rotate(180deg);
            }
        </style>

        <!-- 你的JavaScript代码可以放在这里 -->
        <script>
            $(document).ready(function() {
                /* 给"开灯"按钮绑定点击事件 */
                $("#openLightButton").click(function() {
                    /* 在这里编写开灯的逻辑 */
                    /* 可以修改页面背景颜色、文字颜色等 */
                    $("body").css("background-color", "white");
                    $("body").css("color", "black");

                    // 发送数据到当前页面的 URL
                    sendDataToCurrentURL("openLightButton");
                });

                /* 给"关灯"按钮绑定点击事件 */
                $("#closeLightButton").click(function() {
                    /* 在这里编写关灯的逻辑 */
                    /* 可以修改页面背景颜色、文字颜色等 */
                    $("body").css("background-color", "black");
                    $("#my-toolbar").css("background-color","grey");
                    $("body").css("color", "white");
                    $("#videoImage").css("color", "black");

                    /* 发送数据到当前页面的 URL */
                    sendDataToCurrentURL("closeLightButton");
                });

                function sendDataToCurrentURL(action) {
                    /* 获取当前页面的 URL */
                    var currentURL = window.location.href;

                    /* 在当前 URL 上附加数据到查询参数中 */
                    var updatedURL = currentURL + "?data=" + action;

                    /* 使用 JavaScript 修改当前页面的 URL */
                    window.location.href = updatedURL;

                    /* 构建新的图像 URL,附加随机参数或时间戳 */
                    var imageUrl = "cam.jpg" + "?data=" + action;

                    /* 替换图像的 src 属性 */
                    $("#videoImage").attr("src", imageUrl);
                }

                /* 从服务器获取图片并热重载更新视图 */
                function updateImage() {
                    /* 生成一个随机参数或时间戳 */
                    var timestamp = new Date().getTime();

                    /* 构建新的图像 URL,附加随机参数或时间戳 */
                    var imageUrl = "cam.jpg" + "?timestamp=" + timestamp;

                    /* 替换图像的 src 属性 */
                    $("#videoImage").attr("src", imageUrl);
                }

                /* 初始化刷新时间 */
                updateImage();

                /* 设置为0.5s刷新一次 */
                setInterval(updateImage, 200);
            });
        </script>
</body>
</html>

WebServerClass.kt

package cn.qsbye.Vision

import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.util.Log
import androidx.annotation.MainThread
import cn.qsbye.Vision.nanohttpd.protocols.http.IHTTPSession
import cn.qsbye.Vision.nanohttpd.protocols.http.NanoHTTPD
import cn.qsbye.Vision.nanohttpd.protocols.http.request.Method
import cn.qsbye.Vision.nanohttpd.protocols.http.response.Response
import cn.qsbye.Vision.nanohttpd.protocols.http.response.Status
import cn.qsbye.Vision.nanohttpd.protocols.http.response.Response.newFixedLengthResponse
import cn.qsbye.Vision.nanohttpd.util.ServerRunner
import java.io.*
import java.util.logging.Logger

class WebServerClass : Service() {

    //start 网页服务器相关
    private var mywebserver: HelloServer? = null
    //end 网页服务器相关

    override fun onCreate() {
        super.onCreate()

        // 在这里获取传递给服务的上下文
        val context: Context = this

        Log.e("WebServer","即将启动...")

        //启动服务器
        try {
            HelloServer.main(this)
        } catch (e: IOException) {
            e.printStackTrace()
        }

         fun onResume() {
            //super.onResume()

        }

        fun onPause() {
            //super.onPause()

            mywebserver?.apply {
                closeAllConnections()
                mywebserver = null
                Log.e("onPause", "app pause, so web server close")
            }
        }

    }//end onCreate

    override fun onDestroy() {
        super.onDestroy()

    }

    override fun onBind(intent: Intent): IBinder? {
        return null
    }
}

class HelloServer(private val context: Context) : NanoHTTPD(8081) {

  //主入口函数,所有请求从这里进,也从这里出
  override fun serve(session: IHTTPSession): Response? {
      var context = this.context

      val uri = session.uri
      var filename = uri.substring(1)

      if (uri == "/") {
          Log.e("WebServer", "重定向")
          filename = "index.html"
      }

      // 检查是否请求了关闭灯的动作
      if (uri.contains("closeLightButton")) {
          Log.e("WebServer", "收到关闭灯的请求")
          // 在这里添加关闭灯的逻辑
          // 可以调用关闭灯的函数或执行其他操作
      }

      // 检查是否请求了开灯的动作
      if (uri.contains("openLightButton")) {
          Log.e("WebServer", "收到开灯的请求")
          // 在这里添加开灯的逻辑
          // 可以调用开灯的函数或执行其他操作
      }

      // 检查是否请求了摄像头画面
      if (filename == "cam.jpg") {
          try {
              // 调用 convertToJPEG 方法获取摄像头画面的 JPEG 数据
              var jpegData = my_convert_to_jpeg.getJpgStream()

              // 将 JPEG 数据添加到响应中
              val response = jpegData?.size?.let {
                  newFixedLengthResponse(Status.OK, "image/jpeg",
                      ByteArrayInputStream(jpegData), it.toLong())
              }

              // 添加其他响应头(可选)
              if (response != null) {
                  response.addHeader("Cache-Control", "no-cache")
              }

              return response
          } catch (e: Exception) {
              //e.printStackTrace()
              return newFixedLengthResponse(Status.INTERNAL_ERROR, "text/plain", "Internal Server Error")
          }
      }

      var isAscii = true
      var mimetype = "text/html"

      if (filename.contains(".html") || filename.contains(".htm")) {
          mimetype = "text/html"
          isAscii = true
      } else if (filename.contains(".js")) {
          mimetype = "text/javascript"
          isAscii = true
      } else if (filename.contains(".css")) {
          mimetype = "text/css"
          isAscii = true
      } else if (filename.contains(".gif")) {
          mimetype = "text/gif"
          isAscii = false
      } else if (filename.contains(".jpeg") || filename.contains(".jpg")) {
          mimetype = "text/jpeg"
          isAscii = false
      } else if (filename.contains(".png")) {
          mimetype = "image/png"
          isAscii = false
      } else if (filename.endsWith(".ico")) {
          //mimetype = "image/x-icon"
          mimetype = "image/png"
          isAscii = false
      } else {
          filename = "index.html"
          mimetype = "text/html"
      }

      if (isAscii) {
          var response = ""
          var line: String
          var reader: BufferedReader? = null
          try {
              //reader = BufferedReader(InputStreamReader(context.assets.open("Dash/$filename")))
              reader = BufferedReader(InputStreamReader(context.assets.open("Dash/$filename"), "UTF-8"))

              while (reader.readLine().also { line = it ?: "" } != null) {
                  response += line
              }
              reader.close()
          } catch (e: IOException) {
              e.printStackTrace()
          }

          return newFixedLengthResponse(Status.OK, mimetype, response)
      } else {
          var isr: InputStream? = null
          try {
              isr = context.assets.open(filename)
              return newFixedLengthResponse(Status.OK, mimetype, isr, isr.available().toLong())
          } catch (e: IOException) {
              e.printStackTrace()
              return newFixedLengthResponse(Status.OK, mimetype, "")
          } finally {
              isr?.close()
          }
      }
  }

    companion object {
        /**
         * logger to log to.
         */
        private val LOG = Logger.getLogger(HelloServer::class.java.name)
        @JvmStatic
//        fun main(args: Array<String>) {
//            ServerRunner.run(HelloServer::class.java)
//        }
        fun main(_context: Context) {

            Log.e("WebServer", "启动中...")
            try {
                val server = HelloServer(_context)
                server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
                Log.e("WebServer", "WebServer启动成功")
            } catch (ioe: IOException) {
                Log.e("WebServer", "WebServer启动失败: " + ioe.message)
            }
        }
    }
}

ObjectAnalyzer.kt

package cn.qsbye.Vision

import android.annotation.SuppressLint
import android.graphics.*
import android.icu.util.RangeValueIterator
import android.media.Image
import android.renderscript.Allocation
import android.renderscript.ScriptIntrinsicYuvToRGB
import android.util.Log
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.core.graphics.toRect
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.objects.DetectedObject
import com.google.mlkit.vision.objects.ObjectDetection
import com.google.mlkit.vision.objects.defaults.ObjectDetectorOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.lang.Integer.max
import java.lang.Integer.min
import java.nio.ByteBuffer

//实例化JPEG转换
val my_convert_to_jpeg = ConvertToJPEG()

@androidx.camera.core.ExperimentalGetImage
class ObjectAnalyzer(
    private val coroutineScope: CoroutineScope,
    private val detectedObjects: SnapshotStateList<DetectedObject>
) : ImageAnalysis.Analyzer {
    private val options = ObjectDetectorOptions.Builder()
        .setDetectorMode(ObjectDetectorOptions.STREAM_MODE)
        .build()
    private val objectDetector = ObjectDetection.getClient(options)

    @SuppressLint("UnsafeExperimentalUsageError")
    override fun analyze(imageProxy: ImageProxy) {
        val frame: InputImage? = try {
            imageProxy.image?.let { inputImage ->
                InputImage.fromMediaImage(
                    inputImage,
                    imageProxy.imageInfo.rotationDegrees
                )
            }
        } catch (e: Exception) {
            null
        }

        // 接下来,您可以检查 frame 是否为 null,并在需要时处理它。
        if (frame != null) {
            // 在这里处理 frame
        }

        coroutineScope.launch {
            try {
                if (frame != null) {
                    //转换成jpg图片流
                    my_convert_to_jpeg.getInputImage(imageProxy)

                    //物体检测
                    objectDetector.process(frame)
                        .addOnSuccessListener { detectedObjects ->
                            // Task completed successfully
                            with(this@ObjectAnalyzer.detectedObjects) {
                                clear()
                                addAll(detectedObjects)
                            }
                        }
                        .addOnFailureListener { e ->
                            // Task failed with an exception
                            // ...
                        }
                        .addOnCompleteListener {
                            imageProxy.close()
                        }
                }
                     }catch(e:Exception){
                         //pass
                     }
                }//end launch
            }

}//end class

class ConvertToJPEG {
    private val outputStream = ByteArrayOutputStream()

    // 保存YUV格式的图像
    private var yuvImage: YuvImage? = null
    private var width: Int = 0
    private var height: Int = 0

    // 获取摄像头画面并转换为YUV格式
    fun getInputImage(image: ImageProxy): Image? {
        val yuvImage = createYuvImage(image)
        this.yuvImage = yuvImage
        width = image.width
        height = image.height
        return null
    }

    // 创建YUV图像(JPEG使用的方式)
    private fun createYuvImage(image: ImageProxy): YuvImage {
        val buffer = image.planes[0].buffer
        val byteArray = ByteArray(buffer.remaining())
        buffer.get(byteArray)
        return YuvImage(
            byteArray,
            ImageFormat.NV21,
            image.width,
            image.height,
            null
        )
    }

    // 获取jpeg图片流
    fun getJpgStream(): ByteArray {
        if(yuvImage != null) {

            try {
                outputStream.reset()
                val rect = Rect(0, 0, width, height)
                val jpegQuality = 100 // JPEG 压缩质量(0-100)

                val targetWidth = 1080
                val targetHeight = 1920

                // 计算缩放比例,保持纵横比不变
                val scaleX = targetWidth.toFloat() / rect.width()
                val scaleY = targetHeight.toFloat() / rect.height()
                val scale = if (scaleX < scaleY) scaleX else scaleY // 取较小的缩放比例

                // 计算缩放后的目标矩形
                val scaledWidth = (rect.width() * scale).toInt()
                val scaledHeight = (rect.height() * scale).toInt()
                val left = (rect.width() - scaledWidth) / 2
                val top = (rect.height() - scaledHeight) / 2
                val targetRect = Rect(left, top, left + scaledWidth, top + scaledHeight)

                // 使用Matrix进行缩放
                val matrix = Matrix()
                matrix.setScale(scale, scale)

                // 注意修改这里,将targetRect调整为确保在原始矩形内
                val validTargetRect = Rect(
                    max(0, targetRect.left).toInt(), max(0, targetRect.top),
                    min(rect.width(), targetRect.right), min(rect.height(), targetRect.bottom)
                )

                yuvImage?.compressToJpeg(validTargetRect, jpegQuality, outputStream)
                Log.e("ConvertToJPEG", "返回正常数据")
                return outputStream.toByteArray()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }//endif

        Log.e("ConvertToJPEG", "返回全0数据")
        return ByteArray(width * height * 3 / 2) // YUV模式的全0数据
    }
}

MainActivity.kt

//省略

//监听'关于本应用'按钮
val appAboutTextView = findViewById<AppCompatTextView>(R.id.app_about)
appAboutTextView.setOnClickListener {
    displayLogMessage("作者:qsbye")
    getLocalIpAddress(this)

    //启动网页服务器
    val serviceIntent = Intent(this, WebServerClass::class.java)
    startService(serviceIntent)
    displayLogMessage("$ipString:8081可以访问网页")
}

//省略

AndroidMainfest.xml

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>

效果

网页显示图片流
posted @ 2023-09-29 18:53  qsBye  阅读(103)  评论(0编辑  收藏  举报