当前位置:主页 > 查看内容

Android 百度图像识别(详细步骤+源码)

发布时间:2021-05-08 00:00| 位朋友查看

简介:百度图像识别 运行效果图 一、创建平台应用 二、创建Android项目 三、网络访问框架 四、添加请求API接口 五、获取鉴权认证Token 六、网络图片Url识别 七、相册图片识别 八、拍照图片识别 九、源码 运行效果图 如果你对这个效果图不满意就不用往下看了那样只……

运行效果图

在这里插入图片描述

如果你对这个效果图不满意就不用往下看了,那样只会浪费你的时间。

一、创建平台应用

先登录百度开放平台
在这里插入图片描述

然后进入管理控制台找到图像识别
在这里插入图片描述
点击进入。
在这里插入图片描述
创建应用
在这里插入图片描述
然后点击下方的立即创建按钮。
在这里插入图片描述
注意看下图标注的信息

在这里插入图片描述
由于图像识别没有直接的Android SDK,因此本文将通过API访问进行图像的识别。

二、创建Android项目

通过API方式,则需要先完成鉴权认证,然后拿到Access Token,通过这个Access Token才能去请求这个图像识别的接口,这里要分为两步走。现在思路清楚了,下面先创建一个项目吧,命名为ImageDiscernDemo。
在这里插入图片描述
项目创建好之后,先配置项目。打开工程的build.gradle,添加如下代码:

	maven { url "https://jitpack.io" }

添加位置如下:
在这里插入图片描述

然后是修改app下的build.gradle,有两处

	compileOptions {
        sourceCompatibility = 1.8
        targetCompatibility = 1.8
    }
	//权限请求框架
    implementation 'com.tbruyelle.rxpermissions2:rxpermissions:0.9.4@aar'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'
    implementation "io.reactivex.rxjava2:rxjava:2.0.0"
    //retrofit2
    implementation 'com.squareup.retrofit2:retrofit:2.4.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.4.0'
    implementation 'com.squareup.okhttp3:logging-interceptor:3.4.1'
    //RecyclerView最好的适配器,让你的适配器一目了然,告别代码冗余
    implementation 'com.github.CymChad:BaseRecyclerViewAdapterHelper:2.9.30'
    //Glide框架
    implementation 'com.github.bumptech.glide:glide:4.12.0'
    annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
    //添加material库
    implementation 'com.google.android.material:material:1.2.1'

添加位置如下:
在这里插入图片描述

在这里插入图片描述

然后Sync Now,完成后来配置AndroidManifest.xml,添加如下权限:

	<!--网络权限-->
    <uses-permission android:name="android.permission.INTERNET"/>
    <!--文件读写-->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <!--相机-->
    <uses-permission android:name="android.permission.CAMERA"/>

在这里插入图片描述
再来配置一个FileProvider,在layout下新建一个xml文件,文件夹下新建一个file_paths.xml,里面的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path name="my_images" path="" />
</paths>

然后在AndroidManifest.xml中配置

		<provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.llw.imagediscerndemo.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths"/>
        </provider>

添加位置如下图:
在这里插入图片描述

下面我们首先简单来写一个网络访问的工具类。

三、网络访问框架

在com.llw.imagediscerndemo下新建一个network包,包下新建一个ServiceGenerator类,里面的代码如下:

package com.llw.imagediscerndemo.network;

import java.util.concurrent.TimeUnit;

import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;

/**
 * 接口地址管理
 *
 * @author llw
 */
public class ServiceGenerator {

    /**
     * 默认地址
     */
    public static String BASE_URL = "https://aip.baidubce.com";

    /**
     * 创建服务  参数就是API服务
     *
     * @param serviceClass 服务接口
     * @param <T>          泛型规范
     * @return api接口服务
     */
    public static <T> T createService(Class<T> serviceClass) {

        //创建OkHttpClient构建器对象
        OkHttpClient.Builder okHttpClientBuilder = new OkHttpClient.Builder();

        //设置请求超时的时间,这里是10秒
        okHttpClientBuilder.connectTimeout(20000, TimeUnit.MILLISECONDS);

        //消息拦截器  因为有时候接口不同在排错的时候 需要先从接口的响应中做分析。利用了消息拦截器可以清楚的看到接口返回的所有内容
        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();

        //setlevel用来设置日志打印的级别,共包括了四个级别:NONE,BASIC,HEADER,BODY
        //BASEIC:请求/响应行
        //HEADER:请求/响应行 + 头
        //BODY:请求/响应航 + 头 + 体
        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
        //为OkHttp添加消息拦截器
        okHttpClientBuilder.addInterceptor(httpLoggingInterceptor);

        //在Retrofit中设置httpclient
        //设置地址  就是上面的固定地址,如果你是本地访问的话,可以拼接上端口号  例如 +":8080"
        Retrofit retrofit = new Retrofit.Builder().baseUrl(BASE_URL)
                //用Gson把服务端返回的json数据解析成实体
                .addConverterFactory(GsonConverterFactory.create())
                //放入OKHttp,之前说过retrofit是对OkHttp的进一步封装
                .client(okHttpClientBuilder.build())
                .build();
        //返回这个创建好的API服务
        return retrofit.create(serviceClass);
    }

}

很简单的代码,也都是网络上常见的,OkHttp + Retrofit。

这里面的默认地址 https://aip.baidubce.com是图像识别API的固定地址,后面的有变化的,通过接口来配置。

然后再写一个NetCallBack类,用来处理Retrofit的返回,里面的代码如下:

package com.llw.imagediscerndemo.network;

import android.util.Log;

import com.google.gson.Gson;

import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;

/**
 * 网络请求回调
 *
 * @param <T>
 */
public abstract class NetCallBack<T> implements Callback<T> {//这里实现了retrofit2.Callback

    //访问成功回调
    @Override
    public void onResponse(Call<T> call, Response<T> response) {//数据返回
        if (response != null && response.body() != null && response.isSuccessful()) {
            onSuccess(call, response);
        } else {
            onFailed(response.raw().toString());
        }
    }

    //访问失败回调
    @Override
    public void onFailure(Call<T> call, Throwable t) {
        Log.d("data str", t.toString());
        onFailed(t.toString());
    }

    //数据返回
    public abstract void onSuccess(Call<T> call, Response<T> response);

    //失败异常
    public abstract void onFailed(String errorStr);


}

那么这样简单的网络框架写好了。

四、添加请求API接口

百度的图像识别,首先要完成鉴权认证,拿到一个Token,然后通过这个Token再去请求图像识别的API接口才行,所以要完成两步操作。先来看第一步
鉴权的地址如下:

https://aip.baidubce.com/oauth/2.0/token

这里面需要再带三个Url参数,使用Post请求方式。

grant_type: 必须参数,固定为client_credentials;
client_id: 必须参数,应用的API Key;
client_secret: 必须参数,应用的Secret Key;

返回的是一串JSON字符串,如下:

{
  "refresh_token": "25.b55fe1d287227ca97aab219bb249b8ab.315360000.1798284651.282335-8574074",
  "expires_in": 2592000,
  "scope": "public wise_adapt",
  "session_key": "9mzdDZXu3dENdFZQurfg0Vz8slgSgvvOAUebNFzyzcpQ5EnbxbF+hfG9DQkpUVQdh4p6HbQcAiz5RmuBAja1JJGgIdJI",
  "access_token": "24.6c5e1ff107f0e8bcef8c46d3424a0e78.2592000.1485516651.282335-8574074",
  "session_secret": "dfac94a3489fe9fca7c3221cbf7525ff"
}

当请求和返回都确定之后,我们就可以构建请求实体和返回实体了。
而请求实体都是放在Url中的,因此不需要通过实体来构建,直接传参数就好。

在com.llw.imagediscerndemo包下新建一个model包,包下新建一个GetTokenResponse类,里面的代码如下:

package com.llw.imagediscerndemo.model;

/**
 * 获取鉴权认证Token响应实体
 */
public class GetTokenResponse {

    /**
     * refresh_token : 25.b55fe1d287227ca97aab219bb249b8ab.315360000.1798284651.282335-8574074
     * expires_in : 2592000
     * scope : public wise_adapt
     * session_key : 9mzdDZXu3dENdFZQurfg0Vz8slgSgvvOAUebNFzyzcpQ5EnbxbF+hfG9DQkpUVQdh4p6HbQcAiz5RmuBAja1JJGgIdJI
     * access_token : 24.6c5e1ff107f0e8bcef8c46d3424a0e78.2592000.1485516651.282335-8574074
     * session_secret : dfac94a3489fe9fca7c3221cbf7525ff
     */

    private String refresh_token;
    private long expires_in;
    private String scope;
    private String session_key;
    private String access_token;
    private String session_secret;

    public String getRefresh_token() {
        return refresh_token;
    }

    public void setRefresh_token(String refresh_token) {
        this.refresh_token = refresh_token;
    }

    public long getExpires_in() {
        return expires_in;
    }

    public void setExpires_in(long expires_in) {
        this.expires_in = expires_in;
    }

    public String getScope() {
        return scope;
    }

    public void setScope(String scope) {
        this.scope = scope;
    }

    public String getSession_key() {
        return session_key;
    }

    public void setSession_key(String session_key) {
        this.session_key = session_key;
    }

    public String getAccess_token() {
        return access_token;
    }

    public void setAccess_token(String access_token) {
        this.access_token = access_token;
    }

    public String getSession_secret() {
        return session_secret;
    }

    public void setSession_secret(String session_secret) {
        this.session_secret = session_secret;
    }
}

这是通过刚才的返回数据生成的实体Bean,当网络请求返回数据后通过Retrofit会解析成这个返回实体。
下面添加接口,在network包下新建一个ApiService接口,里面的代码如下:

package com.llw.imagediscerndemo.network;

import com.llw.imagediscerndemo.model.GetTokenResponse;
import retrofit2.Call;
import retrofit2.http.Field;
import retrofit2.http.FormUrlEncoded;
import retrofit2.http.Headers;
import retrofit2.http.POST;

/**
 * API服务
 *
 * @author llw
 * @date 2021/4/1 17:48
 */
public interface ApiService {

    /**
     * 获取鉴权认证Token
     * @param grant_type 类型
     * @param client_id API Key
     * @param client_secret Secret Key
     * @return GetTokenResponse
     */
    @FormUrlEncoded
    @POST("/oauth/2.0/token")
    Call<GetTokenResponse> getToken(@Field("grant_type") String grant_type,
                                    @Field("client_id") String client_id,
                                    @Field("client_secret") String client_secret);

    
}

这里还有一个接口呢,那就是图像识别接口。地址为:

https://aip.baidubce.com/rest/2.0/image-classify/v2/advanced_general

找个接口比较特殊,官方文档的描述如下:
在这里插入图片描述
看你是否理解了。返回数据如下:

{
	"log_id": 327863200205075661,
	"result_num": 5,
	"result": [{
		"score": 0.967622,
		"root": "公众人物",
		"baike_info": {
			"baike_url": "http://baike.baidu.com/item/%E6%96%B0%E5%9E%A3%E7%BB%93%E8%A1%A3/8035884",
			"image_url": "http://imgsrc.baidu.com/baike/pic/item/91ef76c6a7efce1b27893518a451f3deb58f6546.jpg",
			"description": "新垣结衣(Aragaki Yui),1988年6月11日出生于冲绳县那霸市。日本女演员、歌手、模特。毕业于日出高中。2001年,参加《nicola》模特比赛并获得最优秀奖。2005年,因出演现代剧《涩谷15》而作为演员出道。2006年,参演校园剧《我的老大,我的英雄》;同年,她还出版了个人首本写真集《水漾青春》。2007年,她从日出高校毕业后开始专注于演艺发展,并发表个人首张音乐专辑《天空》;同年,新垣结衣还主演了爱情片《恋空》,而她也凭借该片获得了多个电影新人奖项。2010年,主演爱情片《花水木》。2011年,主演都市剧《全开女孩》。2012年,相继参演现代剧《Legal High》、剧情片《剧场版新参者:麒麟之翼》。2013年,主演都市剧《飞翔情报室》。2014年,她主演了剧情片《黎明的沙耶》。2016年,主演爱情喜剧《逃避虽可耻但有用》,并凭借该剧获得了多个电视剧女主角奖项。2017年,主演爱情片《恋爱回旋》,凭借该片获得第60届蓝丝带奖最佳女主角;同年11月,她还凭借医疗剧《Code Blue 3》获得第94届日剧学院赏最佳女配角。"
		},
		"keyword": "新垣结衣"
	},
	{
		"score": 0.716067,
		"root": "人物-人物特写",
		"keyword": "头发"
	},
	{
		"score": 0.421281,
		"root": "商品-穿戴",
		"keyword": "围巾"
	},
	{
		"score": 0.22347,
		"root": "商品-五金",
		"keyword": "拉链"
	},
	{
		"score": 0.028031,
		"root": "商品-穿戴",
		"keyword": "脖套"
	}]
}

通过这个返回示例数据,可以生成一个实体Bean。在model包下新建一个GetDiscernResultResponse类,代码如下:

package com.llw.imagediscerndemo.model;

import java.util.List;

/**
 * 获取识别结果响应实体
 */
public class GetDiscernResultResponse {

    /**
     * log_id : 327863200205075661
     * result_num : 5
     * result : [{"score":0.967622,"root":"公众人物","baike_info":{"baike_url":"http://baike.baidu.com/item/%E6%96%B0%E5%9E%A3%E7%BB%93%E8%A1%A3/8035884","image_url":"http://imgsrc.baidu.com/baike/pic/item/91ef76c6a7efce1b27893518a451f3deb58f6546.jpg","description":"新垣结衣(Aragaki Yui),1988年6月11日出生于冲绳县那霸市。日本女演员、歌手、模特。毕业于日出高中。2001年,参加《nicola》模特比赛并获得最优秀奖。2005年,因出演现代剧《涩谷15》而作为演员出道。2006年,参演校园剧《我的老大,我的英雄》;同年,她还出版了个人首本写真集《水漾青春》。2007年,她从日出高校毕业后开始专注于演艺发展,并发表个人首张音乐专辑《天空》;同年,新垣结衣还主演了爱情片《恋空》,而她也凭借该片获得了多个电影新人奖项。2010年,主演爱情片《花水木》。2011年,主演都市剧《全开女孩》。2012年,相继参演现代剧《Legal High》、剧情片《剧场版新参者:麒麟之翼》。2013年,主演都市剧《飞翔情报室》。2014年,她主演了剧情片《黎明的沙耶》。2016年,主演爱情喜剧《逃避虽可耻但有用》,并凭借该剧获得了多个电视剧女主角奖项。2017年,主演爱情片《恋爱回旋》,凭借该片获得第60届蓝丝带奖最佳女主角;同年11月,她还凭借医疗剧《Code Blue 3》获得第94届日剧学院赏最佳女配角。"},"keyword":"新垣结衣"},{"score":0.716067,"root":"人物-人物特写","keyword":"头发"},{"score":0.421281,"root":"商品-穿戴","keyword":"围巾"},{"score":0.22347,"root":"商品-五金","keyword":"拉链"},{"score":0.028031,"root":"商品-穿戴","keyword":"脖套"}]
     */

    private long log_id;
    private int result_num;
    private List<ResultBean> result;

    public long getLog_id() {
        return log_id;
    }

    public void setLog_id(long log_id) {
        this.log_id = log_id;
    }

    public int getResult_num() {
        return result_num;
    }

    public void setResult_num(int result_num) {
        this.result_num = result_num;
    }

    public List<ResultBean> getResult() {
        return result;
    }

    public void setResult(List<ResultBean> result) {
        this.result = result;
    }

    public static class ResultBean {
        /**
         * score : 0.967622
         * root : 公众人物
         * baike_info : {"baike_url":"http://baike.baidu.com/item/%E6%96%B0%E5%9E%A3%E7%BB%93%E8%A1%A3/8035884","image_url":"http://imgsrc.baidu.com/baike/pic/item/91ef76c6a7efce1b27893518a451f3deb58f6546.jpg","description":"新垣结衣(Aragaki Yui),1988年6月11日出生于冲绳县那霸市。日本女演员、歌手、模特。毕业于日出高中。2001年,参加《nicola》模特比赛并获得最优秀奖。2005年,因出演现代剧《涩谷15》而作为演员出道。2006年,参演校园剧《我的老大,我的英雄》;同年,她还出版了个人首本写真集《水漾青春》。2007年,她从日出高校毕业后开始专注于演艺发展,并发表个人首张音乐专辑《天空》;同年,新垣结衣还主演了爱情片《恋空》,而她也凭借该片获得了多个电影新人奖项。2010年,主演爱情片《花水木》。2011年,主演都市剧《全开女孩》。2012年,相继参演现代剧《Legal High》、剧情片《剧场版新参者:麒麟之翼》。2013年,主演都市剧《飞翔情报室》。2014年,她主演了剧情片《黎明的沙耶》。2016年,主演爱情喜剧《逃避虽可耻但有用》,并凭借该剧获得了多个电视剧女主角奖项。2017年,主演爱情片《恋爱回旋》,凭借该片获得第60届蓝丝带奖最佳女主角;同年11月,她还凭借医疗剧《Code Blue 3》获得第94届日剧学院赏最佳女配角。"}
         * keyword : 新垣结衣
         */

        private double score;
        private String root;
        private BaikeInfoBean baike_info;
        private String keyword;

        public double getScore() {
            return score;
        }

        public void setScore(double score) {
            this.score = score;
        }

        public String getRoot() {
            return root;
        }

        public void setRoot(String root) {
            this.root = root;
        }

        public BaikeInfoBean getBaike_info() {
            return baike_info;
        }

        public void setBaike_info(BaikeInfoBean baike_info) {
            this.baike_info = baike_info;
        }

        public String getKeyword() {
            return keyword;
        }

        public void setKeyword(String keyword) {
            this.keyword = keyword;
        }

        public static class BaikeInfoBean {
            /**
             * baike_url : http://baike.baidu.com/item/%E6%96%B0%E5%9E%A3%E7%BB%93%E8%A1%A3/8035884
             * image_url : http://imgsrc.baidu.com/baike/pic/item/91ef76c6a7efce1b27893518a451f3deb58f6546.jpg
             * description : 新垣结衣(Aragaki Yui),1988年6月11日出生于冲绳县那霸市。日本女演员、歌手、模特。毕业于日出高中。2001年,参加《nicola》模特比赛并获得最优秀奖。2005年,因出演现代剧《涩谷15》而作为演员出道。2006年,参演校园剧《我的老大,我的英雄》;同年,她还出版了个人首本写真集《水漾青春》。2007年,她从日出高校毕业后开始专注于演艺发展,并发表个人首张音乐专辑《天空》;同年,新垣结衣还主演了爱情片《恋空》,而她也凭借该片获得了多个电影新人奖项。2010年,主演爱情片《花水木》。2011年,主演都市剧《全开女孩》。2012年,相继参演现代剧《Legal High》、剧情片《剧场版新参者:麒麟之翼》。2013年,主演都市剧《飞翔情报室》。2014年,她主演了剧情片《黎明的沙耶》。2016年,主演爱情喜剧《逃避虽可耻但有用》,并凭借该剧获得了多个电视剧女主角奖项。2017年,主演爱情片《恋爱回旋》,凭借该片获得第60届蓝丝带奖最佳女主角;同年11月,她还凭借医疗剧《Code Blue 3》获得第94届日剧学院赏最佳女配角。
             */

            private String baike_url;
            private String image_url;
            private String description;

            public String getBaike_url() {
                return baike_url;
            }

            public void setBaike_url(String baike_url) {
                this.baike_url = baike_url;
            }

            public String getImage_url() {
                return image_url;
            }

            public void setImage_url(String image_url) {
                this.image_url = image_url;
            }

            public String getDescription() {
                return description;
            }

            public void setDescription(String description) {
                this.description = description;
            }
        }
    }
}

下面在ApiService中添加接口。

	/**
     * 获取图像识别结果
     * @param accessToken 获取鉴权认证Token
     * @param url 网络图片Url
     * @return JsonObject
     */
    @FormUrlEncoded
    @POST("/rest/2.0/image-classify/v2/advanced_general")
    @Headers("Content-Type:application/x-www-form-urlencoded; charset=utf-8")
    Call<GetDiscernResultResponse> getDiscernResult(@Field("access_token") String accessToken,
                                                    @Field("url") String url);

我这个写法和官方说明好像有点不同,我没有用Body。选择直接把数据放在Url中请求。

现在万事具备了,下面先来获取鉴权认证Token。

五、获取鉴权认证Token

打开MainActivity,添加如下代码:

	private static final String TAG = "MainActivity";
    /**
     * Api服务
     */
    private ApiService service;
    /**
     * 鉴权Toeken
     */
    private String accessToken;

然后在onCreate方法中对ApiService进行实例化。

	service = ServiceGenerator.createService(ApiService.class);

新增如下方法获取Token,

	/**
     * 访问API获取接口
     */
    private void requestApiGetToken() {
        String grantType = "client_credentials";
        String apiKey = "TjPChftoEyBq7Nzm65KNerqr";
        String apiSecret = "eTph4jO95te6R3G2aecktGMbkieOv7rS";
        service.getToken(grantType, apiKey, apiSecret)
                .enqueue(new NetCallBack<GetTokenResponse>() {
                    @Override
                    public void onSuccess(Call<GetTokenResponse> call, Response<GetTokenResponse> response) {
                        if (response.body() != null) {
                            //鉴权Token
                            accessToken = response.body().getAccess_token();
                            Log.d(TAG,accessToken);
                        }
                    }

                    @Override
                    public void onFailed(String errorStr) {
                        Log.e(TAG, "获取Token失败,失败原因:" + errorStr);
                        accessToken = null;
                    }
                });
    }

然后在onCreate中调用它。
在这里插入图片描述
运行一下,看一下控制台是否打印了日志。
在这里插入图片描述
你可以看到这个Token还是挺长的。对于这个Token,是有有效期的,基本上是一个月,所以你可以不用每次使用时都重新请求这个接口去获取Token,这里可以用缓存来解决这个问题。

说一下逻辑,当通过接口拿到Token时保存Token、Token获取时间、Token有效时长三个数据到缓存中,每一次使用前进行一次判断,首先是判断有没有Token,其次是判断Token有没有过期。那么按照这个思路我们就可以这么写代码了。

这里为了方便我在com.llw.imagediscerndemo包下新建一个util包,包下新建一个Constant类,里面的代码如下:

package com.llw.imagediscerndemo.util;

/**
 * 全局常量
 */
public class Constant {
    /**
     * 鉴权Token
     */
    public static final String TOKEN = "accessToken";
    /**
     * 获取Token的时间
     */
    public static final String GET_TOKEN_TIME = "getTokenTime";
    /**
     * Token有效期
     */
    public static final String TOKEN_VALID_PERIOD = "tokenValidPeriod";
}

这三个值,我刚才也说明过了。下面写一个缓存的SPUtils工具类,里面的代码如下:

package com.llw.imagediscerndemo.util;

import android.content.Context;
import android.content.SharedPreferences;

/**
 * SharedPreferences工具类
 *
 * @author llw
 */
public class SPUtils {
    private static final String NAME = "config";

    public static void putBoolean(String key, boolean value, Context context) {
        SharedPreferences sp = context.getSharedPreferences(NAME,
                Context.MODE_PRIVATE);
        sp.edit().putBoolean(key, value).commit();
    }

    public static boolean getBoolean(String key, boolean defValue, Context context) {
        SharedPreferences sp = context.getSharedPreferences(NAME,
                Context.MODE_PRIVATE);
        return sp.getBoolean(key, defValue);
    }

    public static void putString(String key, String value, Context context) {
        SharedPreferences sp = context.getSharedPreferences(NAME,
                Context.MODE_PRIVATE);
        sp.edit().putString(key, value).commit();
    }

    public static String getString(String key, String defValue, Context context) {
        if (context != null) {
            SharedPreferences sp = context.getSharedPreferences(NAME,
                    Context.MODE_PRIVATE);
            return sp.getString(key, defValue);
        }
        return "";

    }

    public static void putInt(String key, int value, Context context) {
        SharedPreferences sp = context.getSharedPreferences(NAME,
                Context.MODE_PRIVATE);
        sp.edit().putInt(key, value).commit();
    }


    public static int getInt(String key, int defValue, Context context) {
        SharedPreferences sp = context.getSharedPreferences(NAME,
                Context.MODE_PRIVATE);
        return sp.getInt(key, defValue);
    }

    public static void putLong(String key, long value, Context context) {
        SharedPreferences sp = context.getSharedPreferences(NAME,
                Context.MODE_PRIVATE);
        sp.edit().putLong(key, value).commit();
    }


    public static long getLong(String key, long defValue, Context context) {
        SharedPreferences sp = context.getSharedPreferences(NAME,
                Context.MODE_PRIVATE);
        return sp.getLong(key, defValue);
    }

    public static void remove(String key, Context context) {
        SharedPreferences sp = context.getSharedPreferences(NAME,
                Context.MODE_PRIVATE);
        sp.edit().remove(key).commit();
    }

}

也是很简单的代码,相信你一眼就看明白了,下面就该在MainActivity中去处理缓存数据的存取了。
首先是放缓存,这当然是在请求接口的成功数据返回中放,修改onSuccess中的代码,如下。

					@Override
                    public void onSuccess(Call<GetTokenResponse> call, Response<GetTokenResponse> response) {
                        if (response.body() != null) {
                            //鉴权Token
                            accessToken = response.body().getAccess_token();
                            //过期时间 秒
                            long expiresIn = response.body().getExpires_in();
                            //当前时间 秒
                            long currentTimeMillis = System.currentTimeMillis() / 1000;
                            //放入缓存
                            SPUtils.putString(Constant.TOKEN, accessToken, MainActivity.this);
                            SPUtils.putLong(Constant.GET_TOKEN_TIME, currentTimeMillis, MainActivity.this);
                            SPUtils.putLong(Constant.TOKEN_VALID_PERIOD, expiresIn, MainActivity.this);
                        }
                    }

然后写一个判断Token是否过期的方法,方法代码如下:

	/**
     * Token是否过期
     *
     * @return
     */
    private boolean isTokenExpired() {
        //获取Token的时间
        long getTokenTime = SPUtils.getLong(Constant.GET_TOKEN_TIME, 0, this);
        //获取Token的有效时间
        long effectiveTime = SPUtils.getLong(Constant.TOKEN_VALID_PERIOD, 0, this);
        //获取当前系统时间
        long currentTime = System.currentTimeMillis() / 1000;

        return (currentTime - getTokenTime) >= effectiveTime;
    }

这个方法也是很好理解的,首先取出缓存中的获取Token的时间,然后获取Token的有效时长,再获取当前系统时间,然后通过当前系统时间减去获得Token的时间,得到的值再与Token有效期做比较,如果大于等于有效期则说明Token过期,返回true,否则返回false。

下面再写一个方法,用来获取Token,同时将我们之前写的代码给串起来。

	/**
     * 获取鉴权Token
     */
    private String getAccessToken() {
        String token = SPUtils.getString(Constant.TOKEN, null, this);
        if (token == null) {
            //访问API获取接口
            requestApiGetToken();
        } else {
            //则判断Token是否过期
            if (isTokenExpired()) {
                //过期
                requestApiGetToken();
            } else {
                accessToken = token;
            }
        }
        return accessToken;
    }

首先获取缓存中的Token,应用第一次进入肯定是没有值的,没有值则返回默认值null,那么token变量此时为null,那么就会通过接口去获取Token,当获取之后存入缓存,再次进入时,就不是null了,那么就会通过isTokenExpired()方法来判断Token是否过期,过期了也是通过网络请求重新拿到Token放入缓存,如果没有过期则直接使用缓存中的Token,最后返回Token。

六、网络图片Url识别

Token拿到以后我们来进行网络图片Url识别。先说一下思路,首先是通过网络图片url和Token去请求接口,然后获得返回值,此时要显示一个加载条,然后通过返回数据渲染列表,当数据显示在列表之后就完成了。

首先找一个网络图片Url,如下:

https://bce-baiyu.cdn.bcebos.com/14ce36d3d539b6004ef2e45fe050352ac65cb71e.jpeg

这个网络图片是一个水杯的图片,如下所示:
在这里插入图片描述
首先修改布局activity_main.xml,里面的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <ImageView
        android:id="@+id/iv_picture"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="centerCrop" />

    <LinearLayout
        android:layout_marginBottom="16dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:gravity="center">

        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="IdentifyWebPictures"
            android:text="识别网络图片" />

    </LinearLayout>

    <ProgressBar
        android:visibility="gone"
        android:id="@+id/pb_loading"
        android:layout_centerInParent="true"
        android:layout_width="60dp"
        android:layout_height="60dp"/>


</RelativeLayout>

然后在MainActivity中写入IdentifyWebPictures方法,代码如下:

	/**
     * 识别网络图片
     *
     * @param view
     */
    public void IdentifyWebPictures(View view) {
    	
    }

首先创建对象

	/**
     * 显示图片
     */
    private ImageView ivPicture;
    /**
     * 进度条
     */
    private ProgressBar pbLoading;

然后在onCreate中绑定xml中的控件id。

		ivPicture = findViewById(R.id.iv_picture);
        pbLoading = findViewById(R.id.pb_loading);

下面来修改IdentifyWebPictures()方法的代码,如下:

	public void IdentifyWebPictures(View view) {
        pbLoading.setVisibility(View.VISIBLE);
        String token = getAccessToken();
        String imgUrl = "https://bce-baiyu.cdn.bcebos.com/14ce36d3d539b6004ef2e45fe050352ac65cb71e.jpeg";
        //显示图片
        Glide.with(this).load(imgUrl).into(ivPicture);
        showMsg("图像识别中");
        service.getDiscernResult(token, imgUrl).enqueue(new NetCallBack<GetDiscernResultResponse>() {
            @Override
            public void onSuccess(Call<GetDiscernResultResponse> call, Response<GetDiscernResultResponse> response) {
                List<GetDiscernResultResponse.ResultBean> result = response.body() != null ? response.body().getResult() : null;
                if (result != null && result.size() > 0) {
                    //显示识别结果
                    showDiscernResult(result);
                } else {
                    pbLoading.setVisibility(View.GONE);
                    showMsg("未获得相应的识别结果");
                }
            }

            @Override
            public void onFailed(String errorStr) {
                pbLoading.setVisibility(View.GONE);
                Log.e(TAG, "图像识别失败,失败原因:" + errorStr);
            }
        });
    }

当点击按钮时,显示进度条,然后通过getAccessToken()方法获取Token,之后显示网络图片在ImageView控件中,Toast提示一下,之后请求的成功和失败的回调了,在成功的回调中先判断数据是否为空,不为空再通过showDiscernResult()方法去显示数据,下面写这个方法。

	/**
     * 显示识别的结果列表
     *
     * @param result
     */
    private void showDiscernResult(List<GetDiscernResultResponse.ResultBean> result) {
        
    }

showMsg方法:

	/**
     * Toast提示
     * @param msg 内容
     */
    private void showMsg(String msg){
        Toast.makeText(this,msg,Toast.LENGTH_SHORT).show();
    }

为了不占用屏幕的控件,我这里打算用一个弹窗来显示数据,弹窗里面是一个列表,列表通过item布局构建,数据由刚才的方法传递进来,我们一步一步来写,首先构建item的布局。在layout下新建一个item_result_rv.xml,里面的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:padding="16dp"
    android:background="#FFF"
    android:layout_marginBottom="1dp"
    android:layout_height="wrap_content">
    <TextView
        android:id="@+id/tv_keyword"
        android:textSize="16sp"
        android:textColor="#000"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    <TextView
        android:layout_marginTop="@dimen/dp_4"
        android:layout_below="@+id/tv_keyword"
        android:id="@+id/tv_root"
        android:textSize="14sp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    <TextView
        android:id="@+id/tv_score"
        android:layout_alignParentEnd="true"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
</RelativeLayout>

item布局有了,下面构建弹窗的布局代码,在layout下新建一个 dialog_bottom.xml,里面的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#EEE"
    android:orientation="vertical">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#FFF"
        android:gravity="center"
        android:padding="16dp"
        android:text="识别结果" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_result"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="1dp" />

</LinearLayout>

下面布局都有了,先构建这个列表的适配器,在com.llw.imagediscerndemo下新建一个adapter包,包下新建一个DiscernResultAdapter类,里面的代码如下:

package com.llw.imagediscerndemo.adapter;

import androidx.annotation.Nullable;

import com.chad.library.adapter.base.BaseQuickAdapter;
import com.chad.library.adapter.base.BaseViewHolder;
import com.llw.imagediscerndemo.R;
import com.llw.imagediscerndemo.model.GetDiscernResultResponse;

import java.util.List;

/**
 * 识别结果列表适配器
 * @author llw
 */
public class DiscernResultAdapter extends BaseQuickAdapter<GetDiscernResultResponse.ResultBean, BaseViewHolder> {
    public DiscernResultAdapter(int layoutResId, @Nullable List<GetDiscernResultResponse.ResultBean> data) {
        super(layoutResId, data);
    }

    @Override
    protected void convert(BaseViewHolder helper, GetDiscernResultResponse.ResultBean item) {
        helper.setText(R.id.tv_keyword,item.getKeyword())
                .setText(R.id.tv_root,item.getRoot())
                .setText(R.id.tv_score,String.valueOf(item.getScore()));
    }
}

万事具备,只差显示数据了,下面进入MainActivity中,首先创建对象

	/**
     * 底部弹窗
     */
    private BottomSheetDialog bottomSheetDialog;
    /**
     * 弹窗视图
     */
    private View bottomView;

然后在onCreate中实例化,

	bottomSheetDialog = new BottomSheetDialog(this);
    bottomView = getLayoutInflater().inflate(R.layout.dialog_bottom, null);

然后修改showDiscernResult方法,代码如下:

	private void showDiscernResult(List<GetDiscernResultResponse.ResultBean> result) {
        bottomSheetDialog.setContentView(bottomView);
        bottomSheetDialog.getWindow().findViewById(R.id.design_bottom_sheet).setBackgroundColor(Color.TRANSPARENT);
        RecyclerView rvResult = bottomView.findViewById(R.id.rv_result);
        DiscernResultAdapter adapter = new DiscernResultAdapter(R.layout.item_result_rv, result);
        rvResult.setLayoutManager(new LinearLayoutManager(this));
        rvResult.setAdapter(adapter);
        //隐藏加载
        pbLoading.setVisibility(View.GONE);
        //显示弹窗
        bottomSheetDialog.show();
    }

下面运行一下:
在这里插入图片描述
可以看到结果识别到了。

七、相册图片识别

在实际应用中,更多是采用本地的图片进行识别,通常是选择拍照的图片或者打开相册获取图片,先来看看通过相册获取图片进行图像识别。要实现这个功能首先要改一下接口,加一个image参数。

在这里插入图片描述
然后修改ImageDiscern方法。

	/**
     * 图像识别请求
     *
     * @param token       token
     * @param imageBase64 图片Base64
     * @param imgUrl      网络图片Url
     */
    private void ImageDiscern(String token, String imageBase64, String imgUrl) {
        service.getDiscernResult(token, imageBase64, imgUrl).enqueue(new NetCallBack<GetDiscernResultResponse>() {
            @Override
            public void onSuccess(Call<GetDiscernResultResponse> call, Response<GetDiscernResultResponse> response) {
                List<GetDiscernResultResponse.ResultBean> result = response.body() != null ? response.body().getResult() : null;
                if (result != null && result.size() > 0) {
                    //显示识别结果
                    showDiscernResult(result);
                } else {
                    pbLoading.setVisibility(View.GONE);
                    showMsg("未获得相应的识别结果");
                }
            }

            @Override
            public void onFailed(String errorStr) {
                pbLoading.setVisibility(View.GONE);
                Log.e(TAG, "图像识别失败,失败原因:" + errorStr);
            }
        });
    }

这个方法接收三个参数,Token、ImageBase64、图片Url。ImageBase64和图片Url只能二选一。选其中一个另一个则传null。比如之前的通过网络图片Url识别。
在这里插入图片描述
接口的相关方法都改好了,下面来写打开相册的方法。Android6.0以后读写文件都属于危险权限,因此需要动态请求。在MainActivity中声明:

	private RxPermissions rxPermissions;

然后在onCreate中实例化

	rxPermissions = new RxPermissions(this);

下面修改布局,在之前的按钮后面再加一个按钮

	<Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="IdentifyAlbumPictures"
            android:text="识别相册图片" />

然后在MainActivity中增加IdentifyAlbumPictures方法,代码如下:

	/**
     * 识别相册图片
     *
     * @param view
     */
    @SuppressLint("CheckResult")
    public void IdentifyAlbumPictures(View view) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            rxPermissions.request(
                    Manifest.permission.READ_EXTERNAL_STORAGE,
                    Manifest.permission.WRITE_EXTERNAL_STORAGE)
                    .subscribe(grant -> {
                        if (grant) {
                            //获得权限
                            openAlbum();
                        } else {
                            showMsg("未获取到权限");
                        }
                    });
        } else {
            openAlbum();
        }
    }

当获取到权限之后通过openAlbum()方法打开相册,openAlbum方法代码如下:

	/**
     * 打开相册
     */
    private void openAlbum() {
        Intent intent = new Intent();
        intent.setAction(Intent.ACTION_PICK);
        intent.setType("image/*");
        startActivityForResult(intent, OPEN_ALBUM_CODE);
    }

这里定义了一个请求码

	/**
     * 打开相册
     */
    private static final int OPEN_ALBUM_CODE = 100;

打开相册之后就要返回了,重写 onActivityResult方法

	@Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (resultCode == RESULT_OK) {
            pbLoading.setVisibility(View.VISIBLE);
            if (requestCode == OPEN_ALBUM_CODE) {
                //打开相册返回
                String[] filePathColumns = {MediaStore.Images.Media.DATA};
                final Uri imageUri = Objects.requireNonNull(data).getData();
                Cursor cursor = getContentResolver().query(imageUri, filePathColumns, null, null, null);
                cursor.moveToFirst();
                int columnIndex = cursor.getColumnIndex(filePathColumns[0]);
                //获取图片路径
                String imagePath = cursor.getString(columnIndex);
                cursor.close();
                //识别
                localImageDiscern(imagePath);
            } 
        } else {
            showMsg("什么都没有");
        }
    }

相册返回之后先拿到图片的Uri,然后通过Uri得到图片的路径,然后通过这个路径将图片转成字节,再转Base64,首先来看localImageDiscern方法。代码如下:

	/**
     * 本地图片识别
     */
    private void localImageDiscern(String imagePath) {
        try {
            String token = getAccessToken();
            //通过图片路径显示图片
            Glide.with(this).load(imagePath).into(ivPicture);
            //按字节读取文件
            byte[] imgData = FileUtil.readFileByBytes(imagePath);
            //字节转Base64
            String imageBase64 = Base64Util.encode(imgData);
            //图像识别
            ImageDiscern(token, imageBase64, null);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

这里面有两个工具类FileUtil和Base64Util,代码如下:

FileUtil.java

package com.llw.imagediscerndemo.util;

import java.io.*;

/**
 * 文件读取工具类
 */
public class FileUtil {

    /**
     * 读取文件内容,作为字符串返回
     */
    public static String readFileAsString(String filePath) throws IOException {
        File file = new File(filePath);
        if (!file.exists()) {
            throw new FileNotFoundException(filePath);
        } 

        if (file.length() > 1024 * 1024 * 1024) {
            throw new IOException("File is too large");
        } 

        StringBuilder sb = new StringBuilder((int) (file.length()));
        // 创建字节输入流  
        FileInputStream fis = new FileInputStream(filePath);  
        // 创建一个长度为10240的Buffer
        byte[] bbuf = new byte[10240];  
        // 用于保存实际读取的字节数  
        int hasRead = 0;  
        while ( (hasRead = fis.read(bbuf)) > 0 ) {  
            sb.append(new String(bbuf, 0, hasRead));  
        }  
        fis.close();  
        return sb.toString();
    }

    /**
     * 根据文件路径读取byte[] 数组
     */
    public static byte[] readFileByBytes(String filePath) throws IOException {
        File file = new File(filePath);
        if (!file.exists()) {
            throw new FileNotFoundException(filePath);
        } else {
            ByteArrayOutputStream bos = new ByteArrayOutputStream((int) file.length());
            BufferedInputStream in = null;

            try {
                in = new BufferedInputStream(new FileInputStream(file));
                short bufSize = 1024;
                byte[] buffer = new byte[bufSize];
                int len1;
                while (-1 != (len1 = in.read(buffer, 0, bufSize))) {
                    bos.write(buffer, 0, len1);
                }

                byte[] var7 = bos.toByteArray();
                return var7;
            } finally {
                try {
                    if (in != null) {
                        in.close();
                    }
                } catch (IOException var14) {
                    var14.printStackTrace();
                }

                bos.close();
            }
        }
    }
}

Base64Util.java

package com.llw.imagediscerndemo.util;

/**
 * Base64 工具类
 */
public class Base64Util {
    private static final char last2byte = (char) Integer.parseInt("00000011", 2);
    private static final char last4byte = (char) Integer.parseInt("00001111", 2);
    private static final char last6byte = (char) Integer.parseInt("00111111", 2);
    private static final char lead6byte = (char) Integer.parseInt("11111100", 2);
    private static final char lead4byte = (char) Integer.parseInt("11110000", 2);
    private static final char lead2byte = (char) Integer.parseInt("11000000", 2);
    private static final char[] encodeTable = new char[]{'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'};

    public Base64Util() {
    }

    public static String encode(byte[] from) {
        StringBuilder to = new StringBuilder((int) ((double) from.length * 1.34D) + 3);
        int num = 0;
        char currentByte = 0;

        int i;
        for (i = 0; i < from.length; ++i) {
            for (num %= 8; num < 8; num += 6) {
                switch (num) {
                    case 0:
                        currentByte = (char) (from[i] & lead6byte);
                        currentByte = (char) (currentByte >>> 2);
                    case 1:
                    case 3:
                    case 5:
                    default:
                        break;
                    case 2:
                        currentByte = (char) (from[i] & last6byte);
                        break;
                    case 4:
                        currentByte = (char) (from[i] & last4byte);
                        currentByte = (char) (currentByte << 2);
                        if (i + 1 < from.length) {
                            currentByte = (char) (currentByte | (from[i + 1] & lead2byte) >>> 6);
                        }
                        break;
                    case 6:
                        currentByte = (char) (from[i] & last2byte);
                        currentByte = (char) (currentByte << 4);
                        if (i + 1 < from.length) {
                            currentByte = (char) (currentByte | (from[i + 1] & lead4byte) >>> 4);
                        }
                }

                to.append(encodeTable[currentByte]);
            }
        }

        if (to.length() % 4 != 0) {
            for (i = 4 - to.length() % 4; i > 0; --i) {
                to.append("=");
            }
        }

        return to.toString();
    }
}

都放在util包下,那么现在就可以直接运行了。
在这里插入图片描述
通过这个图可以看到第一次识别失败了,第二次成功了,后续的都会成功,不知道是什么奇葩原因,有知道的记得告诉我啊。

八、拍照图片识别

首先还在在activity_main.xml中识别相册图片按钮的后面加一个识别拍照图片按钮,如下:

		<Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="IdentifyTakePhotoImage"
            android:text="识别拍照图片" />

在MainActivity中增加IdentifyTakePhotoImage方法,代码如下:

	/**
     * 识别拍照图片
     *
     * @param view
     */
    @SuppressLint("CheckResult")
    public void IdentifyTakePhotoImage(View view) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            rxPermissions.request(
                    Manifest.permission.CAMERA)
                    .subscribe(grant -> {
                        if (grant) {
                            //获得权限
                            turnOnCamera();
                        } else {
                            showMsg("未获取到权限");
                        }
                    });
        } else {
            turnOnCamera();
        }
    }

来看看turnOnCamera方法。在此之前创建变量,用来保存拍照后的图片

	private File outputImage;

turnOnCamera方法

	/**
     * 打开相机
     */
    private void turnOnCamera() {
        SimpleDateFormat timeStampFormat = new SimpleDateFormat("HH_mm_ss");
        String filename = timeStampFormat.format(new Date());
        //创建File对象
        outputImage = new File(getExternalCacheDir(), "takePhoto" + filename + ".jpg");
        Uri imageUri;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            imageUri = FileProvider.getUriForFile(this,
                    "com.llw.imagediscerndemo.fileprovider", outputImage);
        } else {
            imageUri = Uri.fromFile(outputImage);
        }
        //打开相机
        Intent intent = new Intent();
        intent.setAction(MediaStore.ACTION_IMAGE_CAPTURE);
        intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
        startActivityForResult(intent, TAKE_PHOTO_CODE);
    }

这里同样配置了一个打开相机的请求码

	/**
     * 打开相机
     */
    private static final int TAKE_PHOTO_CODE = 101;

下面进入到onActivityResult方法,加一个条件分支。
在这里插入图片描述
通过这个图片保存文件得到图片的路径,然后通过localImageDiscern()方法对这个路径下的文件进行处理,和打开相册之后拿到路径之后调用的是同一个方法。下面来运行一下:
在这里插入图片描述
嗯,那么到这里整个Demo就写完了,是不是还挺简单的,只要思路明确再加上细节处理的到位,任何的功能都不在话下,对吧。

九、源码

考虑到有时候GitHub会抽风,因此加上了CSDN的资源下载地址,我设置的0积分,随便下载。

如果你用的好,不妨给我的源码或者资源点个赞给个好评啥的。

GitHub源码地址:ImageDiscernDemo

CSDN资源地址:ImageDiscernDemo.rar

扫码下载APK使用:
在这里插入图片描述

我是初学者-Study,山高水长,后会有期~

;原文链接:https://blog.csdn.net/qq_38436214/article/details/115378957
本站部分内容转载于网络,版权归原作者所有,转载之目的在于传播更多优秀技术内容,如有侵权请联系QQ/微信:153890879删除,谢谢!

推荐图文


随机推荐