小学期老师要求做一个能够进行目标检测的APP,前后鼓捣了好长时间,终于搞出来一个十分简单的APP界面。
APP界面概念图
APP总共有一个主界面,一个设置界面和一个检测界面。
APP界面设计
界面设计需要在layout中编写xml代码,或者也能直接按钮式设计。这一块感觉比较容易上手,而且结果反馈较快,就不再展示代码了。
APP功能
界面大概确定好怎么弄,接下来就是确定需要有什么功能了。
一、主界面
主界面很朴素,只有俩按钮,所以只需要按下按钮跳转到对应页面就行。
还需要设置页面跳转的动画。
二、设置界面
设置界面就是将设置好的配置发送给主界面或者目标检测界面。
三、目标检测界面
这个是APP的核心界面,功能也最多,实现也比较复杂。
- 图片选择展示框——点击可以从相册中选择图片,然后可以将获取的图片显示在框内,当检测出结果后还能将检测框加上一并显示出来。
- 按钮们——有拍照、选择照片、目标检测、保存图片四个按钮,分别点击图标可以触发相应功能。
- 图片展示栏——最下方的图片展示栏,可以展示检测后的图片,支持左右滑动。
- 目标检测动画——当点击目标检测按钮后,图片选择展示框会出现放大镜运动动画,同时运行目标检测算法。
功能实现方案
界面跳转
这个界面跳转算是最基础的功能了,实现也非常简单。首先,每个页面都对于android studio都是一个活动,活动与活动之间可以靠intent连接,页面的跳转就可以通过intent实现。
比如我设置好了两个页面mian和setting,现在要加上它们的跳转,可以新建一个intent,然后通过startActivity函数来实现跳转。
Intent intent =new Intent(Mian.this, Setting.class); //源Activity的对象,目标Activity的class
startActivity(intent);
那假如想要返回东西如何接收呢,就像设置界面想要把设置发送给主界面怎么办呢?可以用startActivityForResult (这个函数好像已经弃用了但还能用,我找半天也没找到能跑的解决方案,就暂时先用着它吧~),它可以在第二个页面关闭时返回数据给第一个页面,然后我们就可以接收了。
接收的时候,我们需要在主界面中重载一下onActivityResult,这个函数会接收返回的数据。
startActivityForResult(intent, 1) //第二个参数 1 是请求码,接收返回数据时会用到,可以用来判断是哪个活动返回的结果
……
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if(resultCode==1)
{
Bundle b=data.getExtras();//这就得到了传回来的数据,但是很明显不一定是我们能够用的
//那样就需要把传回来的数据包装成一个类(假如数据较大较复杂的话)实现Serializable或者Parcelable的接口。
cfg=(user_config) b.getSerializable("cfg"); //接着调用这个就可以得到我们最终想要的数据了。
}
}
保存设置
如何传输设置已经在上一部分说了,这里说一下如何保存设置——SharedPreference
Android提供了一个轻量级的储存类SharedPreference,它常常用于储存软件设置,会将设置保存在xml,下一次启动应用就无需重新输入设置了。
是照着这个博客的上手的
这个博客讲得挺全但是照着他的代码好像报错了,就没再看下去
这个博客讲得挺细
// 所的的值将会自动保存到SharePreferences
addPreferencesFromResource(R.xml.setting); //这个SharedPreference其他博客介绍的需要用preferencescreen来设计页面,因此不是在layout文件夹里设计页面,而是需要在xml文件夹里设计。
SharedPreferences sp= PreferenceManager.getDefaultSharedPreferences(this);
String obj_kind=sp.getString("OBJ_LIST","all"); //第一个参数是设置参数名称,第二个是默认值
获取图片
图片选择展示框和按钮们也并不难弄,主要是在获取图片时我卡了一阵。
这个博客代码很详细,但讲解得不多,主体就是按着他的来写的
拍照
拍照获取图片首先需要规定图片储存路径,然后封装成uri,最后新建intent启动相机。
File outputImage = new File(getExternalCacheDir(), "output_image.jpg"); //规定文件路径为SD卡关联缓存目录
try {
if (outputImage.exists()) {
outputImage.delete();
}
} catch (Exception e) {
e.printStackTrace();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //将文件路径封装成uri
CameraImgUri = FileProvider.getUriForFile(FindObject.this, "com.example.learning1.fileprovider", outputImage); //第二个参数里learning1是项目名
} else {
CameraImgUri = Uri.fromFile(outputImage);
}
//启动相机程序
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, CameraImgUri);
startActivityForResult(intent, TAKE_CAMERA);
相册选择
相册图片的选择流程大概是首先申请读图片权限,然后新建intent启动相册,选择图片后会返回一个带着图片文件uri数据的intent,然后再onActivityResult里面进行解析,把uri转化成真实地址,就可以读出来最终图片了。
这个博客代码讲解的多一些
// 申请读写权限
if (ContextCompat.checkSelfPermission(FindObject.this, Manifest.permission.WRITE_EXTERNAL_STORAGE)!= PackageManager.PERMISSION_GRANTED){
ActivityCompat.requestPermissions(FindObject.this,new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},1);
}
if (ContextCompat.checkSelfPermission(FindObject.this, Manifest.permission.READ_EXTERNAL_STORAGE)!= PackageManager.PERMISSION_GRANTED){
ActivityCompat.requestPermissions(FindObject.this,new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},2);
}
Intent intent = new Intent(Intent.ACTION_GET_CONTENT); //启动相册
startActivityForResult(intent, PICK_PHOTO); //这个函数弃用了,凑合着用吧,其他那些替代方案我也不会~
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode != Activity.RESULT_OK) return; //Handle error
if (requestCode == PICK_PHOTO) {
// 到这里就基本上获取到图片了,不过是以uri形式封装着的,还需要进一步解析
Uri uri=data.getData();
if(DocumentsContract.isDocumentUri(this,uri)) { // 如果是document类型的Uri,则通过document id处理
String document_id = DocumentsContract.getDocumentId(uri);
if ("com.android.providers.media.documents".equals(uri.getAuthority())){ //从自带文件管理器中获取
String id = document_id.split(":")[1]; // 解析出数字格式的id
String selection = MediaStore.Images.Media._ID + "=" + id;
ImagePath = getImagePath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, selection);
} else if ("com.android.providers.downloads.documents".equals(uri.getAuthority())) {
Uri contentUri = ContentUris.withAppendedId(Uri.parse("content: //downloads/public_downloads"), Long.valueOf(document_id));
ImagePath = getImagePath(contentUri, null);
}
}
Bitmap bitmap = BitmapFactory.decodeFile(imagePath);
}
图片展示栏
图片展示栏的功能是要存放检测后的图片,并能够左右滑动,因此,我们可以继承ViewGroup,每次检测完后,向子类中加一个ImageView展示检测后的图片,这个子类只需要添加ImageView为孩子节点即可。最后通过重载onLayout展示出图片来。要实现左右滑动的效果,可以重载onTouchEvent方法(但是我不理解为什么重载onTouchEvent方法后不需要改动onLayout函数,它不得重新展示图片吗?那样那些图片位置应该会变而不是固定的吧)。
就是照着这个博客写的
public void addImg(Bitmap bitmap){
ImageView view=new ImageView(getContext());
view.setImageBitmap(bitmap);
this.addView(view);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b){
int ChildNum=getChildCount();
int paddingPic=10,onPageNum=3;
for(int i=0;i<ChildNum;i+=onPageNum){
for(int j=0;j<onPageNum;j++){
if(i+j>=ChildNum) break;
View childView=getChildAt(i+j);
//layout 相对于父布局的位置
childView.layout(i/onPageNum*getWidth()+j*getWidth()/onPageNum+paddingPic ,0 ,i/onPageNum*getWidth()+(j+1)*getWidth()/onPageNum-paddingPic ,getHeight());
}
}
}
检测动画
要想检测时有动画,还不想干扰检测,就只能多线程操作,主线程里可以显示检测动画,子线程里进行检测。放大镜的随机运动,我是每隔2s随机运动一次,每次会给出两个坐标,从当前坐标走贝塞尔曲线到达最终点。还是很简单的。
首先是多线程,靠的是继承Thread类实现的,然后只需要执行run函数就可以开启子线程了。子线程要检测,就需要与主线程传值,是利用的handler实现的,在创建子线程时,作为参数给出。(由于子线程每次固定只检测一个图片,所以我图省事直接创建子线程对象时就把图片传进去而没有考虑主线程给子线程传数据。)继承Thread类,需要重载run方法,当检测完后,可以用handler发送一条消息给主线程。
这个博客讲了主线程和子线程的通信,但是看上面的介绍,我感觉有点疑惑,要是想要主线程和子线程相互通信,就需要在创建子线程时把主线程的Handler传给子线程,但是这个Handler创建时却需要子线程的Looper,这就尴尬了,先创建谁都缺一个~
这个博客介绍了子线程的创建和启动
Handler mHandler=new Handler(){
@Override
public void handleMessage(Message msg){ //重载接收消息的函数
}
}
Thread mThread=new Thread(Handler mHandler){
@Override
public void run(){
//写逻辑代码
Message msg=Message.obtain();
...
mHandler.sendMessage(msg);
}
}
mThread.start();
然后是放大镜的运动,可以用Timer类实现,再新建一个TimeTask对象,重载run方法,用来执行放大镜运动动画。最后用schedule函数开始计时器,就可以不断循环执行动画了。
final Timer[] timer = {new Timer()};
final TimerTask[] task = {new TimerTask(){
@Override
public void run(){
doAnimation(); //放大镜动画
}
}
timer[0].schedule(task[0], 0,2000); //第一个参数是timetask,第二个是延迟多少s执行,第三个是循环周期
最后是放大镜的运动动画实现。大致思路就是,我们先给规划出一条轨迹,然后动画播放出来,每祯都根据轨迹计算出当前走到的位置,然后再将放大镜移动到那里就行。这里我们需要干的只是规划轨迹,创建动画对象,移动放大镜,至于生成当前位置是有函数可以生成的。
这个博客介绍了安卓的动画控件
规划路径部分是照着这个博客做的
private void doAnimation(){
Path mPath=new Path(); //随机生成贝塞尔曲线
float x2,y2,x3,y3;
mPath.moveTo(anim_lastPosition[0],anim_lastPosition[1]);
x2=ShowImg_x+(float)(Math.random()*ShowImg_w);
y2=ShowImg_y+(float)(Math.random()*ShowImg_h);
x3=ShowImg_x+(float)(Math.random()*ShowImg_w);
y3=ShowImg_y+(float)(Math.random()*ShowImg_h);
mPath.cubicTo(anim_lastPosition[0], anim_lastPosition[1], x2, y2, x3, y3);
anim_lastPosition[0]=x3; //这个是为了每次放大镜运动时的连续
anim_lastPosition[1]=y3;
PathMeasure mPathMeasure = new PathMeasure(mPath,false); //这个可以就根据路径给出每个点的位置
float len=mPathMeasure.getLength();
float step=0.001f;
float[] mCurrentPoint=new float[2];
ValueAnimator animator=ValueAnimator.ofInt(0,1000); //设置一个值从0到1000的属性动画
animator.setDuration(1000); //动画时长为1s
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) { //更新动画
float dis=(int)animation.getAnimatedValue()*step*len; //计算属性动画值对应的路程
mPathMeasure.getPosTan(dis,mCurrentPoint,null); //根据路程求得所处的位置
FindingImg.layout((int)mCurrentPoint[0],(int)mCurrentPoint[1],
(int)mCurrentPoint[0]+FindingImg.getWidth(),(int)mCurrentPoint[1]+FindingImg.getHeight());
}
});
animator.start();
}
模型移植
模型移植这个APP有两种不同的移植方法,一种是借助ncnn,另一种是借助pytorch Java的库,前一种是另一个组员完成的,不太熟悉。我们调试的时候改了好多ndk版本才能用了。
我是采用的另一种方法,参照的这个博客,讲得很清楚了。
APP成品
时间原因,最终APP没有实现页面切换的效果,设置界面由于采用的是PreferenceScreen,不知道该怎么实现概念图中的效果。不过也基本能够完成目标检测的任务了。