feat: 新增护眼距离和行走抖动检测提醒功能
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
package com.aoleyun.sn.activity;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.PointF;
|
||||
import android.media.Image;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.camera.core.CameraSelector;
|
||||
import androidx.camera.core.ExperimentalGetImage;
|
||||
import androidx.camera.core.ImageAnalysis;
|
||||
import androidx.camera.core.ImageProxy;
|
||||
import androidx.camera.core.Preview;
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider;
|
||||
import androidx.camera.view.PreviewView;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.aoleyun.sn.R;
|
||||
import com.aoleyun.sn.detection.DistanceReminderManager;
|
||||
import com.aoleyun.sn.detection.ShakeDetectionManager;
|
||||
import com.aoleyun.sn.view.FaceOverlayView;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.mlkit.vision.common.InputImage;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class CameraDetectionActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "CameraInputImage";
|
||||
private static final int PERMISSION_REQUEST_CAMERA = 1001;
|
||||
|
||||
private ListenableFuture<ProcessCameraProvider> cameraProviderFuture;
|
||||
private ExecutorService cameraExecutor;
|
||||
private PreviewView previewView;
|
||||
private FaceOverlayView faceOverlay;
|
||||
private android.widget.TextView tvStepInfo;
|
||||
private int currentImageWidth;
|
||||
private int currentImageHeight;
|
||||
|
||||
public static boolean isShowing = false;
|
||||
|
||||
private DistanceReminderManager reminderManager;
|
||||
private ShakeDetectionManager shakeDetectionManager;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
// 如果你需要预览界面,可以使用 xml 里的 PreviewView,这里仅演示后台获取数据流
|
||||
setContentView(R.layout.activity_camera_detection);
|
||||
previewView = findViewById(R.id.previewView);
|
||||
faceOverlay = findViewById(R.id.faceOverlay);
|
||||
tvStepInfo = findViewById(R.id.tvStepInfo);
|
||||
|
||||
// 初始化用于处理图像分析的单线程线程池,避免阻塞主线程
|
||||
cameraExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
// 检查并请求相机权限
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
startCamera();
|
||||
} else {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
new String[]{Manifest.permission.CAMERA}, PERMISSION_REQUEST_CAMERA);
|
||||
}
|
||||
|
||||
reminderManager = new DistanceReminderManager(new DistanceReminderManager.OnDistanceAlertListener() {
|
||||
@Override
|
||||
public void onTooClose() {
|
||||
// 触发提醒:弹出 Activity、播放提示音或让屏幕变模糊
|
||||
WarningDialogActivity.start(CameraDetectionActivity.this, WarningDialogActivity.TYPE_DISTANCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDistanceUpdate(double distance, PointF leftEye, PointF rightEye) {
|
||||
// 传给 Overlay 进行比例换算
|
||||
faceOverlay.updateData(distance, leftEye, rightEye, currentImageWidth, currentImageHeight);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化抖动(走路)检测
|
||||
shakeDetectionManager = new ShakeDetectionManager(this, new ShakeDetectionManager.OnShakeListener() {
|
||||
@Override
|
||||
public void onShakeDetected() {
|
||||
// 边走路边看平板提醒
|
||||
WarningDialogActivity.start(CameraDetectionActivity.this, WarningDialogActivity.TYPE_SHAKE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStatusUpdate(String info) {
|
||||
runOnUiThread(() -> {
|
||||
if (tvStepInfo != null) {
|
||||
tvStepInfo.setText(info);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化并启动 CameraX
|
||||
*/
|
||||
private void startCamera() {
|
||||
cameraProviderFuture = ProcessCameraProvider.getInstance(this);
|
||||
cameraProviderFuture.addListener(() -> {
|
||||
try {
|
||||
ProcessCameraProvider cameraProvider = cameraProviderFuture.get();
|
||||
bindCameraUseCases(cameraProvider);
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.e(TAG, "初始化 CameraProvider 失败", e);
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置并绑定相机用例
|
||||
*/
|
||||
private void bindCameraUseCases(@NonNull ProcessCameraProvider cameraProvider) {
|
||||
// 1. 选择【前置摄像头】
|
||||
CameraSelector cameraSelector = new CameraSelector.Builder()
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_FRONT)
|
||||
.build();
|
||||
|
||||
// 2. 创建预览用例
|
||||
Preview preview = new Preview.Builder().build();
|
||||
preview.setSurfaceProvider(previewView.getSurfaceProvider());
|
||||
|
||||
// 3. 创建图像分析用例(ImageAnalysis)
|
||||
ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
|
||||
// 设置背压策略:如果图像处理太慢,丢弃旧帧,保留最新的一帧(防积压爆内存)
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
// 建议设置输出的目标分辨率(例如 480x640 或 720x1280),对于人脸/距离检测绰绰有余,且能提升运行速度
|
||||
.setTargetResolution(new android.util.Size(640, 480))
|
||||
.build();
|
||||
|
||||
// 3. 设置分析器,在这里把每一帧转换为 InputImage
|
||||
imageAnalysis.setAnalyzer(cameraExecutor, new ImageAnalysis.Analyzer() {
|
||||
private long lastAnalyzedTimestamp = 0L;
|
||||
|
||||
@OptIn(markerClass = ExperimentalGetImage.class)
|
||||
@Override
|
||||
public void analyze(@NonNull ImageProxy imageProxy) {
|
||||
// 【性能调优】如果是防近视提醒,不需要一秒分析30次。
|
||||
// 这里限制为每 1000 毫秒(1秒)才执行一次算法
|
||||
long currentTimestamp = System.currentTimeMillis();
|
||||
if (currentTimestamp - lastAnalyzedTimestamp < 1000) {
|
||||
imageProxy.close(); // 必须关闭,否则不会收到下一帧
|
||||
return;
|
||||
}
|
||||
lastAnalyzedTimestamp = currentTimestamp;
|
||||
|
||||
// 从 imageProxy 中提取原生的 android.media.Image
|
||||
Image mediaImage = imageProxy.getImage();
|
||||
if (mediaImage != null) {
|
||||
// 获取图像的旋转角度(前置摄像头和设备方向有关,ML Kit 需要这个角度来正确旋转识别)
|
||||
int rotationDegrees = imageProxy.getImageInfo().getRotationDegrees();
|
||||
|
||||
// 【核心代码】将原生 Image 转换为 ML Kit 的 InputImage
|
||||
InputImage inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees);
|
||||
|
||||
// 此时已经成功拿到 InputImage,可以传给你的算法或 ML Kit 识别器
|
||||
// 【优化】传入 imageProxy 以便在异步任务完成后关闭它
|
||||
processInputImage(inputImage, imageProxy);
|
||||
} else {
|
||||
// 如果获取不到 Image,也需要关闭 imageProxy
|
||||
imageProxy.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// 在绑定前先解绑所有用例
|
||||
cameraProvider.unbindAll();
|
||||
|
||||
// 将前置摄像头、预览和图像分析用例绑定到当前 Activity 的生命周期
|
||||
cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "绑定相机用例失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务处理方法
|
||||
*/
|
||||
private void processInputImage(InputImage inputImage, ImageProxy imageProxy) {
|
||||
// 在这里调用你的人脸检测或者瞳距计算算法
|
||||
// Log.d(TAG, "成功获取到 InputImage,尺寸: " + inputImage.getWidth() + "x" + inputImage.getHeight());
|
||||
|
||||
// 记录当前图像尺寸,供 UI 绘制缩放使用
|
||||
// 重要:InputImage 的 getWidth/Height 返回的是原始 buffer 尺寸,
|
||||
// 而 ML Kit 的坐标是基于旋转后的。如果是 90/270 度旋转,宽高需要对调。
|
||||
int rotation = imageProxy.getImageInfo().getRotationDegrees();
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
currentImageWidth = inputImage.getHeight();
|
||||
currentImageHeight = inputImage.getWidth();
|
||||
} else {
|
||||
currentImageWidth = inputImage.getWidth();
|
||||
currentImageHeight = inputImage.getHeight();
|
||||
}
|
||||
|
||||
// 比如传入上一问提到的人脸识别:
|
||||
// 【关键修复】在 ML Kit 任务完成后再关闭 imageProxy,避免 Internal error (ModelResource.zza)
|
||||
reminderManager.checkDistance(inputImage)
|
||||
.addOnCompleteListener(task -> imageProxy.close());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
isShowing = true;
|
||||
if (shakeDetectionManager != null) {
|
||||
shakeDetectionManager.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
isShowing = false;
|
||||
if (shakeDetectionManager != null) {
|
||||
shakeDetectionManager.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
// 关闭线程池,防止内存泄漏
|
||||
if (cameraExecutor != null) {
|
||||
cameraExecutor.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// 处理权限回调
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == PERMISSION_REQUEST_CAMERA) {
|
||||
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
startCamera();
|
||||
} else {
|
||||
WarningDialogActivity.start(this, WarningDialogActivity.TYPE_DISTANCE, "需要相机权限来检测距离");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
package com.aoleyun.sn.activity;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.provider.Settings;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.databinding.DataBindingUtil;
|
||||
|
||||
import com.aoleyun.sn.BuildConfig;
|
||||
import com.aoleyun.sn.R;
|
||||
import com.aoleyun.sn.base.BaseDataBindingActivity;
|
||||
import com.aoleyun.sn.base.mvp.BaseMvpActivity;
|
||||
import com.aoleyun.sn.comm.CommonConfig;
|
||||
import com.aoleyun.sn.databinding.ActivityEyeProtectionBinding;
|
||||
import com.aoleyun.sn.detection.SensorCheckUtil;
|
||||
import com.aoleyun.sn.service.DetectionService;
|
||||
import com.aoleyun.sn.service.ShakeDetectionService;
|
||||
import com.aoleyun.sn.view.ToggleButton;
|
||||
import com.hjq.toast.Toaster;
|
||||
import com.tencent.mmkv.MMKV;
|
||||
|
||||
public class EyeProtectionActivity extends BaseDataBindingActivity {
|
||||
|
||||
private MMKV mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE);
|
||||
|
||||
|
||||
private ActivityEyeProtectionBinding mBinding;
|
||||
|
||||
|
||||
@@ -26,6 +34,7 @@ public class EyeProtectionActivity extends BaseDataBindingActivity {
|
||||
@Override
|
||||
protected void initDataBinding() {
|
||||
mBinding = DataBindingUtil.setContentView(this, R.layout.activity_eye_protection);
|
||||
mBinding.setClick(new BtnClick());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -65,6 +74,30 @@ public class EyeProtectionActivity extends BaseDataBindingActivity {
|
||||
}
|
||||
});
|
||||
|
||||
mBinding.toggleButton6.setOnToggleChanged(new ToggleButton.OnToggleChanged() {
|
||||
@Override
|
||||
public void onToggle(boolean on) {
|
||||
mMMKV.encode(CommonConfig.SHAKE_REMINDER, on ? 1 : 0);
|
||||
if (on) {
|
||||
startService(new Intent(EyeProtectionActivity.this, ShakeDetectionService.class));
|
||||
} else {
|
||||
stopService(new Intent(EyeProtectionActivity.this, ShakeDetectionService.class));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mBinding.toggleButton7.setOnToggleChanged(new ToggleButton.OnToggleChanged() {
|
||||
@Override
|
||||
public void onToggle(boolean on) {
|
||||
mMMKV.encode(CommonConfig.EYE_TO_SCREEN_DISTANCE_REMINDER, on ? 1 : 0);
|
||||
if (on) {
|
||||
startService(new Intent(EyeProtectionActivity.this, DetectionService.class));
|
||||
} else {
|
||||
stopService(new Intent(EyeProtectionActivity.this, DetectionService.class));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mBinding.clExit.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
@@ -94,5 +127,29 @@ public class EyeProtectionActivity extends BaseDataBindingActivity {
|
||||
} else {
|
||||
mBinding.toggleButton2.setToggleOff();
|
||||
}
|
||||
|
||||
int shakeReminder = mMMKV.decodeInt(CommonConfig.SHAKE_REMINDER, 0);
|
||||
if (shakeReminder == 1) {
|
||||
mBinding.toggleButton6.setToggleOn();
|
||||
} else {
|
||||
mBinding.toggleButton6.setToggleOff();
|
||||
}
|
||||
|
||||
int reminder = mMMKV.decodeInt(CommonConfig.EYE_TO_SCREEN_DISTANCE_REMINDER, 0);
|
||||
if (reminder == 1) {
|
||||
mBinding.toggleButton7.setToggleOn();
|
||||
} else {
|
||||
mBinding.toggleButton7.setToggleOff();
|
||||
}
|
||||
}
|
||||
|
||||
public class BtnClick {
|
||||
public void onDistanceClick(View view) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
if (!SensorCheckUtil.hasProximitySensor(EyeProtectionActivity.this)) {
|
||||
startActivity(new Intent(EyeProtectionActivity.this, CameraDetectionActivity.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.aoleyun.sn.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.aoleyun.sn.R;
|
||||
|
||||
public class WarningDialogActivity extends AppCompatActivity {
|
||||
|
||||
public static final String EXTRA_TYPE = "extra_type";
|
||||
public static final String EXTRA_MESSAGE = "extra_message";
|
||||
public static final int TYPE_DISTANCE = 0;
|
||||
public static final int TYPE_SHAKE = 1;
|
||||
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable finishRunnable = this::finish;
|
||||
|
||||
public static void start(Context context, int type) {
|
||||
start(context, type, null);
|
||||
}
|
||||
|
||||
public static void start(Context context, int type, String message) {
|
||||
Intent intent = new Intent(context, WarningDialogActivity.class);
|
||||
intent.putExtra(EXTRA_TYPE, type);
|
||||
if (message != null) {
|
||||
intent.putExtra(EXTRA_MESSAGE, message);
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_warning_dialog);
|
||||
|
||||
findViewById(R.id.iv_close).setOnClickListener(v -> finish());
|
||||
findViewById(R.id.btn_confirm).setOnClickListener(v -> finish());
|
||||
|
||||
updateUI(getIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
updateUI(intent);
|
||||
}
|
||||
|
||||
private void updateUI(Intent intent) {
|
||||
int type = intent.getIntExtra(EXTRA_TYPE, TYPE_DISTANCE);
|
||||
String customMessage = intent.getStringExtra(EXTRA_MESSAGE);
|
||||
|
||||
ImageView ivIcon = findViewById(R.id.iv_warning_icon);
|
||||
TextView tvTitle = findViewById(R.id.tv_title);
|
||||
TextView tvMessage = findViewById(R.id.tv_message);
|
||||
|
||||
if (type == TYPE_SHAKE) {
|
||||
ivIcon.setImageResource(R.drawable.ic_warning_shake);
|
||||
tvTitle.setText("检测到抖动");
|
||||
tvMessage.setText(customMessage != null ? customMessage : "设备正在抖动,请确认是否为误触");
|
||||
} else {
|
||||
ivIcon.setImageResource(R.drawable.ic_warning);
|
||||
tvTitle.setText("距离太近");
|
||||
tvMessage.setText(customMessage != null ? customMessage : "请保持安全距离,保护您的视力");
|
||||
}
|
||||
|
||||
handler.removeCallbacks(finishRunnable);
|
||||
handler.postDelayed(finishRunnable, 3000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finish() {
|
||||
super.finish();
|
||||
overridePendingTransition(0, android.R.anim.fade_out);
|
||||
}
|
||||
}
|
||||
@@ -198,17 +198,13 @@ public class MainViewModel extends BaseViewModel<ActivityMainBinding, ActivityEv
|
||||
}
|
||||
|
||||
public void checkTestUpdate() {
|
||||
if (!JgyUtils.getInstance().tagEmpty()) {
|
||||
NetInterfaceManager.getInstance()
|
||||
.checkTestUpdate(getLifecycle(), new NetInterfaceManager.onCompleteCallback() {
|
||||
@Override
|
||||
public void onComplete() {
|
||||
NetInterfaceManager.getInstance()
|
||||
.checkTestUpdate(getLifecycle(), new NetInterfaceManager.onCompleteCallback() {
|
||||
@Override
|
||||
public void onComplete() {
|
||||
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Log.e(TAG, "checkTestUpdate: tag is Empty");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void getDefaultDesktop() {
|
||||
|
||||
@@ -196,4 +196,9 @@ public class CommonConfig {
|
||||
/*系统设置菜单选项*/
|
||||
public final static String AOLE_SETTINGS_DISALLOW = "aole_settings_disallow";
|
||||
|
||||
|
||||
public final static String EYE_TO_SCREEN_DISTANCE_REMINDER = "aole_eye_to_screen_distance_reminder";
|
||||
|
||||
public final static String SHAKE_REMINDER = "aole_shake_reminder";
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.aoleyun.sn.detection;
|
||||
|
||||
import android.graphics.PointF;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.android.gms.tasks.Task;
|
||||
import com.google.mlkit.vision.common.InputImage;
|
||||
import com.google.mlkit.vision.face.Face;
|
||||
import com.google.mlkit.vision.face.FaceDetection;
|
||||
import com.google.mlkit.vision.face.FaceDetector;
|
||||
import com.google.mlkit.vision.face.FaceDetectorOptions;
|
||||
import com.google.mlkit.vision.face.FaceLandmark;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DistanceReminderManager {
|
||||
private static final String TAG = "DistanceReminder";
|
||||
|
||||
// 【核心阈值】两眼间距离占图片长边的比例。
|
||||
// 之前 640x480 分辨率下阈值为 220 像素,220/640 ≈ 0.34
|
||||
private static final double TOO_CLOSE_RATIO_THRESHOLD = 0.23;
|
||||
|
||||
private FaceDetector detector;
|
||||
private OnDistanceAlertListener listener;
|
||||
|
||||
public interface OnDistanceAlertListener {
|
||||
void onTooClose();
|
||||
|
||||
void onDistanceUpdate(double distance, PointF leftEye, PointF rightEye);
|
||||
}
|
||||
|
||||
public DistanceReminderManager(OnDistanceAlertListener listener) {
|
||||
this.listener = listener;
|
||||
|
||||
// 配置ML Kit:开启关键点检测(Landmark)
|
||||
FaceDetectorOptions options = new FaceDetectorOptions.Builder()
|
||||
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) // 追求速度
|
||||
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL) // 必须开启关键点
|
||||
.build();
|
||||
|
||||
this.detector = FaceDetection.getClient(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测人脸距离的方法(由相机帧回调定期调用,例如每秒检测2-3次)
|
||||
*
|
||||
* @return 返回 Task 供调用者管理生命周期(例如关闭 ImageProxy)
|
||||
*/
|
||||
public Task<List<Face>> checkDistance(InputImage image) {
|
||||
return detector.process(image)
|
||||
.addOnSuccessListener(faces -> {
|
||||
boolean faceFound = false;
|
||||
for (Face face : faces) {
|
||||
// 获取左眼和右眼的关键点
|
||||
FaceLandmark leftEye = face.getLandmark(FaceLandmark.LEFT_EYE);
|
||||
FaceLandmark rightEye = face.getLandmark(FaceLandmark.RIGHT_EYE);
|
||||
|
||||
if (leftEye != null && rightEye != null) {
|
||||
faceFound = true;
|
||||
PointF leftEyePos = leftEye.getPosition();
|
||||
PointF rightEyePos = rightEye.getPosition();
|
||||
|
||||
// 计算两眼之间的像素距离 (勾股定理)
|
||||
double eyeDistance = Math.sqrt(
|
||||
Math.pow(rightEyePos.x - leftEyePos.x, 2) +
|
||||
Math.pow(rightEyePos.y - leftEyePos.y, 2)
|
||||
);
|
||||
|
||||
Log.e(TAG, "checkDistance: 当前瞳距像素值: " + eyeDistance + ", 图片分辨率: " + image.getWidth() + "x" + image.getHeight());
|
||||
|
||||
if (listener != null) {
|
||||
listener.onDistanceUpdate(eyeDistance, leftEyePos, rightEyePos);
|
||||
}
|
||||
|
||||
// 计算归一化距离。
|
||||
// 【优化】为了解决横屏判断距离比竖屏短的问题,我们使用图像高度(image.getHeight())作为基准。
|
||||
// 在大多数传感器上,横屏时的高度 FOV 与竖屏时的宽度像素比例更接近,
|
||||
// 使用高度作为分母可以有效补偿横竖屏切换时的像素密度差异,使判断距离趋于一致。
|
||||
double normalizedDistance = eyeDistance / image.getHeight();
|
||||
|
||||
Log.e(TAG, "checkDistance: 归一化距离 (基于高度): " + normalizedDistance + ", 阈值: " + TOO_CLOSE_RATIO_THRESHOLD);
|
||||
|
||||
// 如果归一化距离大于阈值,说明离得太近了
|
||||
if (normalizedDistance > TOO_CLOSE_RATIO_THRESHOLD) {
|
||||
if (listener != null) {
|
||||
listener.onTooClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!faceFound && listener != null) {
|
||||
listener.onDistanceUpdate(0, null, null);
|
||||
}
|
||||
})
|
||||
.addOnFailureListener(e -> Log.e(TAG, "人脸检测失败", e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.aoleyun.sn.detection;
|
||||
|
||||
import android.content.Context;
|
||||
import android.hardware.Sensor;
|
||||
import android.hardware.SensorManager;
|
||||
import android.util.Log;
|
||||
|
||||
public class SensorCheckUtil {
|
||||
|
||||
/**
|
||||
* 检测设备是否有距离感应器
|
||||
*
|
||||
* @param context 上下文
|
||||
* @return true 有距离感应器;false 没有
|
||||
*/
|
||||
public static boolean hasProximitySensor(Context context) {
|
||||
// 1. 获取系统的传感器管理器
|
||||
SensorManager sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
|
||||
|
||||
if (sensorManager == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 获取默认的距离传感器
|
||||
Sensor proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
|
||||
|
||||
// 3. 判断是否为空
|
||||
if (proximitySensor != null) {
|
||||
Log.e("SensorCheck", "找到距离感应器: " + proximitySensor.getName());
|
||||
|
||||
String name = proximitySensor.getName(); // 传感器名称
|
||||
String vendor = proximitySensor.getVendor(); // 厂商
|
||||
float maxRange = proximitySensor.getMaximumRange(); // 最大测量距离(通常厘米为单位,很多只有 0.0 或 5.0)
|
||||
|
||||
Log.e("SensorDetails", "名称: " + name + ", 厂家: " + vendor + ", 最大范围: " + maxRange);
|
||||
return true;
|
||||
} else {
|
||||
Log.e("SensorCheck", "此设备没有距离感应器硬件");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.aoleyun.sn.detection;
|
||||
|
||||
import android.content.Context;
|
||||
import android.hardware.Sensor;
|
||||
import android.hardware.SensorEvent;
|
||||
import android.hardware.SensorEventListener;
|
||||
import android.hardware.SensorManager;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
|
||||
/**
|
||||
* 抖动检测管理类(用于实现边走路边看平板的护眼提醒)
|
||||
* 优先使用系统步数检测器(Step Detector),若硬件不支持则自动回退到线性加速度方差算法。
|
||||
*/
|
||||
public class ShakeDetectionManager implements SensorEventListener {
|
||||
private static final String TAG = "ShakeDetection";
|
||||
private SensorManager sensorManager;
|
||||
private Sensor stepSensor;
|
||||
private Sensor accelSensor;
|
||||
private OnShakeListener listener;
|
||||
|
||||
// --- 方案一:步数检测相关参数 ---
|
||||
private final List<Long> stepTimestamps = new ArrayList<>();
|
||||
// 判定为“正在走路”的阈值:5秒内走了 4 步以上
|
||||
private static final long STEP_TIME_WINDOW = 5000;
|
||||
private static final int STEP_THRESHOLD = 4;
|
||||
|
||||
// --- 方案二:加速度方差相关参数 ---
|
||||
private final Queue<Float> magnitudeWindow = new LinkedList<>();
|
||||
// 采样窗口大小:1.5 秒大约需要 25 个样本
|
||||
private static final int VARIANCE_WINDOW_SIZE = 25;
|
||||
// 提高方差阈值:正常的拿起放下抖动通常在 1.0~1.8 之间,我们将阈值提高到 2.0
|
||||
private static final float VARIANCE_THRESHOLD = 2.0f;
|
||||
// 增加评估频率控制:每 400ms 评估一次方差
|
||||
private long lastVarianceEvalTime = 0;
|
||||
private static final long VARIANCE_EVAL_INTERVAL = 400;
|
||||
// 持续检测到抖动的评估次数:连续 4 次评估达标才判定(400ms * 4 = 1.6秒的持续抖动)
|
||||
private int consecutiveShakeCount = 0;
|
||||
private static final int CONSECUTIVE_THRESHOLD = 4;
|
||||
|
||||
public interface OnShakeListener {
|
||||
/**
|
||||
* 当检测到持续抖动(疑似走路)时回调
|
||||
*/
|
||||
void onShakeDetected();
|
||||
|
||||
/**
|
||||
* 实时步数或抖动强度更新
|
||||
*
|
||||
* @param info 描述信息(如 "步数: 3" 或 "抖动方差: 1.5")
|
||||
*/
|
||||
default void onStatusUpdate(String info) {
|
||||
}
|
||||
}
|
||||
|
||||
public ShakeDetectionManager(Context context, OnShakeListener listener) {
|
||||
this.listener = listener;
|
||||
sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
|
||||
if (sensorManager != null) {
|
||||
// 1. 尝试获取步数检测传感器 (最精准、最省电)
|
||||
stepSensor = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR);
|
||||
if (stepSensor != null) {
|
||||
Log.d(TAG, "检测到硬件步数检测器: " + stepSensor.getName());
|
||||
} else {
|
||||
Log.d(TAG, "无硬件步数检测器: ");
|
||||
}
|
||||
|
||||
// 2. 获取线性加速度计作为备选 (排除重力干扰)
|
||||
accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION);
|
||||
if (accelSensor != null) {
|
||||
Log.d(TAG, "检测到线性加速度计: " + accelSensor.getName());
|
||||
} else {
|
||||
// 如果没有线性加速度计,降级使用普通加速度计
|
||||
accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
|
||||
if (accelSensor != null) {
|
||||
Log.d(TAG, "未检测到线性加速度计,降级使用普通加速度计: " + accelSensor.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stepSensor == null && accelSensor == null) {
|
||||
Log.e(TAG, "错误:该设备不支持任何步数或加速度传感器,抖动检测功能将失效。");
|
||||
}
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (sensorManager == null) return;
|
||||
|
||||
// 优先使用步数检测
|
||||
if (stepSensor != null) {
|
||||
Log.i(TAG, "启动检测:优先模式 [步数检测器]");
|
||||
sensorManager.registerListener(this, stepSensor, SensorManager.SENSOR_DELAY_NORMAL);
|
||||
} else if (accelSensor != null) {
|
||||
Log.i(TAG, "启动检测:回退模式 [加速度方差算法]");
|
||||
sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_UI);
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
Log.d(TAG, "停止检测,重置状态。");
|
||||
if (sensorManager != null) {
|
||||
sensorManager.unregisterListener(this);
|
||||
}
|
||||
stepTimestamps.clear();
|
||||
magnitudeWindow.clear();
|
||||
consecutiveShakeCount = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSensorChanged(SensorEvent event) {
|
||||
int sensorType = event.sensor.getType();
|
||||
|
||||
if (sensorType == Sensor.TYPE_STEP_DETECTOR) {
|
||||
handleStepDetection();
|
||||
} else if (sensorType == Sensor.TYPE_LINEAR_ACCELERATION || sensorType == Sensor.TYPE_ACCELEROMETER) {
|
||||
handleAccelDetection(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 方案一:基于步数检测器的算法
|
||||
*/
|
||||
private void handleStepDetection() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
stepTimestamps.add(currentTime);
|
||||
|
||||
// 清除超出时间窗口的历史数据
|
||||
long windowStartTime = currentTime - STEP_TIME_WINDOW;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stepTimestamps.removeIf(timestamp -> timestamp < windowStartTime);
|
||||
} else {
|
||||
// 兼容低版本
|
||||
while (!stepTimestamps.isEmpty() && stepTimestamps.get(0) < windowStartTime) {
|
||||
stepTimestamps.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
Log.v(TAG, "Step Detector: 检测到步伐,当前窗口步数=" + stepTimestamps.size());
|
||||
if (listener != null) {
|
||||
listener.onStatusUpdate("窗口步数: " + stepTimestamps.size());
|
||||
}
|
||||
|
||||
// 如果在窗口内连续检测到足够步数
|
||||
if (stepTimestamps.size() >= STEP_THRESHOLD) {
|
||||
Log.w(TAG, "检测到用户正在走路 (步数模式)");
|
||||
triggerCallback();
|
||||
stepTimestamps.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 方案二:基于加速度方差的算法
|
||||
*/
|
||||
private void handleAccelDetection(SensorEvent event) {
|
||||
float x = event.values[0];
|
||||
float y = event.values[1];
|
||||
float z = event.values[2];
|
||||
|
||||
// 计算合加速度模长
|
||||
float magnitude;
|
||||
if (event.sensor.getType() == Sensor.TYPE_LINEAR_ACCELERATION) {
|
||||
magnitude = (float) Math.sqrt(x * x + y * y + z * z);
|
||||
} else {
|
||||
// 普通加速度计减去重力近似值
|
||||
magnitude = (float) Math.abs(Math.sqrt(x * x + y * y + z * z) - 9.8);
|
||||
}
|
||||
|
||||
magnitudeWindow.add(magnitude);
|
||||
if (magnitudeWindow.size() > VARIANCE_WINDOW_SIZE) {
|
||||
magnitudeWindow.poll();
|
||||
}
|
||||
|
||||
// 窗口填满后按固定频率计算方差
|
||||
long currentTime = System.currentTimeMillis();
|
||||
if (magnitudeWindow.size() == VARIANCE_WINDOW_SIZE && (currentTime - lastVarianceEvalTime > VARIANCE_EVAL_INTERVAL)) {
|
||||
lastVarianceEvalTime = currentTime;
|
||||
double variance = calculateVariance(magnitudeWindow);
|
||||
|
||||
if (variance > VARIANCE_THRESHOLD) {
|
||||
consecutiveShakeCount++;
|
||||
String info = String.format("抖动方差: %.2f, 连续计数: %d", variance, consecutiveShakeCount);
|
||||
Log.d(TAG, info);
|
||||
if (listener != null) {
|
||||
listener.onStatusUpdate(info);
|
||||
}
|
||||
if (consecutiveShakeCount >= CONSECUTIVE_THRESHOLD) {
|
||||
Log.w(TAG, "检测到用户正在走路 (加速度方差模式)");
|
||||
triggerCallback();
|
||||
consecutiveShakeCount = 0;
|
||||
magnitudeWindow.clear();
|
||||
}
|
||||
} else {
|
||||
// 没达到阈值时,清空计数,要求必须是连续的抖动
|
||||
consecutiveShakeCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double calculateVariance(Queue<Float> queue) {
|
||||
double sum = 0;
|
||||
for (float val : queue) {
|
||||
sum += val;
|
||||
}
|
||||
double mean = sum / queue.size();
|
||||
|
||||
double temp = 0;
|
||||
for (float val : queue) {
|
||||
temp += (val - mean) * (val - mean);
|
||||
}
|
||||
return temp / queue.size();
|
||||
}
|
||||
|
||||
private void triggerCallback() {
|
||||
if (listener != null) {
|
||||
listener.onShakeDetected();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAccuracyChanged(Sensor sensor, int accuracy) {
|
||||
}
|
||||
}
|
||||
316
app/src/main/java/com/aoleyun/sn/service/DetectionService.java
Normal file
316
app/src/main/java/com/aoleyun/sn/service/DetectionService.java
Normal file
@@ -0,0 +1,316 @@
|
||||
package com.aoleyun.sn.service;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.PointF;
|
||||
import android.hardware.Sensor;
|
||||
import android.hardware.SensorEvent;
|
||||
import android.hardware.SensorEventListener;
|
||||
import android.hardware.SensorManager;
|
||||
import android.media.Image;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import android.view.OrientationEventListener;
|
||||
import android.view.Surface;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.camera.core.CameraSelector;
|
||||
import androidx.camera.core.ExperimentalGetImage;
|
||||
import androidx.camera.core.ImageAnalysis;
|
||||
import androidx.camera.core.ImageProxy;
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.LifecycleService;
|
||||
|
||||
import com.aoleyun.sn.activity.CameraDetectionActivity;
|
||||
import com.aoleyun.sn.activity.WarningDialogActivity;
|
||||
import com.aoleyun.sn.detection.DistanceReminderManager;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.mlkit.vision.common.InputImage;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 后台检测服务,用于持续监控人眼距离
|
||||
* 移植自 CameraDetectionActivity
|
||||
*/
|
||||
public class DetectionService extends LifecycleService {
|
||||
private static final String TAG = "DetectionService";
|
||||
|
||||
private ListenableFuture<ProcessCameraProvider> cameraProviderFuture;
|
||||
private ExecutorService cameraExecutor;
|
||||
private DistanceReminderManager reminderManager;
|
||||
private ImageAnalysis imageAnalysis;
|
||||
private OrientationEventListener orientationEventListener;
|
||||
|
||||
private SensorManager sensorManager;
|
||||
private Sensor proximitySensor;
|
||||
|
||||
private long lastAnalyzedTimestamp = 0L;
|
||||
private final Handler watchdogHandler = new Handler(Looper.getMainLooper());
|
||||
private static final long WATCHDOG_INTERVAL = 10000; // 10秒检测一次
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Log.d(TAG, "onCreate");
|
||||
|
||||
// 初始化用于处理图像分析的单线程线程池
|
||||
cameraExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
// 初始化距离检测管理器
|
||||
reminderManager = new DistanceReminderManager(new DistanceReminderManager.OnDistanceAlertListener() {
|
||||
@Override
|
||||
public void onTooClose() {
|
||||
// 触发提醒
|
||||
WarningDialogActivity.start(DetectionService.this, WarningDialogActivity.TYPE_DISTANCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDistanceUpdate(double distance, PointF leftEye, PointF rightEye) {
|
||||
// 后台服务通常不需要更新 UI Overlay,如果需要可以将数据通过 LiveEventBus 或广播发送出去
|
||||
}
|
||||
});
|
||||
|
||||
// 启动相机(系统应用假设已获得权限)
|
||||
startCamera();
|
||||
|
||||
// 监听屏幕旋转,优化检测准确性
|
||||
initOrientationListener();
|
||||
|
||||
// 启动看门狗,定时检查摄像头状态
|
||||
startWatchdog();
|
||||
|
||||
// 初始化传感器检测
|
||||
initProximitySensor();
|
||||
}
|
||||
|
||||
private void initProximitySensor() {
|
||||
sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
|
||||
if (sensorManager != null) {
|
||||
proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
|
||||
if (proximitySensor != null) {
|
||||
sensorManager.registerListener(proximityListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
|
||||
Log.d(TAG, "已注册距离传感器监听");
|
||||
} else {
|
||||
Log.d(TAG, "未找到距离传感器");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final SensorEventListener proximityListener = new SensorEventListener() {
|
||||
@Override
|
||||
public void onSensorChanged(SensorEvent event) {
|
||||
if (CameraDetectionActivity.isShowing) {
|
||||
return;
|
||||
}
|
||||
if (event.sensor.getType() == Sensor.TYPE_PROXIMITY) {
|
||||
float distance = event.values[0];
|
||||
// 大多数接近传感器是二值的(0或5),或者返回实际厘米数
|
||||
// 这里判断如果小于最大范围则认为过近
|
||||
if (distance < proximitySensor.getMaximumRange()) {
|
||||
Log.d(TAG, "距离传感器检测到距离过近: " + distance);
|
||||
// WarningDialogActivity.start(DetectionService.this, WarningDialogActivity.TYPE_DISTANCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAccuracyChanged(Sensor sensor, int accuracy) {
|
||||
}
|
||||
};
|
||||
|
||||
private void initOrientationListener() {
|
||||
orientationEventListener = new OrientationEventListener(this) {
|
||||
@Override
|
||||
public void onOrientationChanged(int orientation) {
|
||||
if (orientation == ORIENTATION_UNKNOWN) return;
|
||||
|
||||
// 将角度转换为 Surface.ROTATION
|
||||
int rotation;
|
||||
if (orientation >= 45 && orientation < 135) {
|
||||
rotation = Surface.ROTATION_270;
|
||||
} else if (orientation >= 135 && orientation < 225) {
|
||||
rotation = Surface.ROTATION_180;
|
||||
} else if (orientation >= 225 && orientation < 315) {
|
||||
rotation = Surface.ROTATION_90;
|
||||
} else {
|
||||
rotation = Surface.ROTATION_0;
|
||||
}
|
||||
|
||||
if (imageAnalysis != null) {
|
||||
imageAnalysis.setTargetRotation(rotation);
|
||||
}
|
||||
}
|
||||
};
|
||||
orientationEventListener.enable();
|
||||
}
|
||||
|
||||
private void startCamera() {
|
||||
cameraProviderFuture = ProcessCameraProvider.getInstance(this);
|
||||
cameraProviderFuture.addListener(() -> {
|
||||
try {
|
||||
ProcessCameraProvider cameraProvider = cameraProviderFuture.get();
|
||||
bindCameraUseCases(cameraProvider);
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.e(TAG, "初始化 CameraProvider 失败", e);
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this));
|
||||
}
|
||||
|
||||
private void bindCameraUseCases(@NonNull ProcessCameraProvider cameraProvider) {
|
||||
if (CameraDetectionActivity.isShowing) {
|
||||
Log.d(TAG, "CameraDetectionActivity 正在显示,跳过后台相机绑定");
|
||||
return;
|
||||
}
|
||||
// 绑定时更新时间戳,给予缓冲期
|
||||
lastAnalyzedTimestamp = System.currentTimeMillis();
|
||||
|
||||
// 1. 选择【前置摄像头】
|
||||
CameraSelector cameraSelector = new CameraSelector.Builder()
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_FRONT)
|
||||
.build();
|
||||
|
||||
// 2. 创建图像分析用例(无需 Preview 用例以节省资源)
|
||||
ImageAnalysis.Builder builder = new ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.setTargetResolution(new android.util.Size(640, 480));
|
||||
|
||||
// 设置初始旋转方向
|
||||
WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
|
||||
if (windowManager != null) {
|
||||
builder.setTargetRotation(windowManager.getDefaultDisplay().getRotation());
|
||||
}
|
||||
|
||||
imageAnalysis = builder.build();
|
||||
|
||||
// 3. 设置分析器
|
||||
imageAnalysis.setAnalyzer(cameraExecutor, new ImageAnalysis.Analyzer() {
|
||||
@OptIn(markerClass = ExperimentalGetImage.class)
|
||||
@Override
|
||||
public void analyze(@NonNull ImageProxy imageProxy) {
|
||||
// 限制频率,每秒执行一次算法
|
||||
long currentTimestamp = System.currentTimeMillis();
|
||||
if (currentTimestamp - DetectionService.this.lastAnalyzedTimestamp < 1000) {
|
||||
imageProxy.close();
|
||||
return;
|
||||
}
|
||||
DetectionService.this.lastAnalyzedTimestamp = currentTimestamp;
|
||||
|
||||
Image mediaImage = imageProxy.getImage();
|
||||
if (mediaImage != null) {
|
||||
int rotationDegrees = imageProxy.getImageInfo().getRotationDegrees();
|
||||
InputImage inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees);
|
||||
|
||||
// 调用检测逻辑
|
||||
reminderManager.checkDistance(inputImage)
|
||||
.addOnCompleteListener(task -> imageProxy.close());
|
||||
} else {
|
||||
imageProxy.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// 在绑定前先解绑所有用例
|
||||
cameraProvider.unbindAll();
|
||||
// 将图像分析用例绑定到 LifecycleService 的生命周期
|
||||
cameraProvider.bindToLifecycle(this, cameraSelector, imageAnalysis);
|
||||
Log.d(TAG, "相机用例绑定成功");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "绑定相机用例失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
super.onStartCommand(intent, flags, startId);
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
Log.d(TAG, "onDestroy");
|
||||
if (cameraExecutor != null) {
|
||||
cameraExecutor.shutdown();
|
||||
}
|
||||
if (orientationEventListener != null) {
|
||||
orientationEventListener.disable();
|
||||
}
|
||||
if (sensorManager != null) {
|
||||
sensorManager.unregisterListener(proximityListener);
|
||||
}
|
||||
stopWatchdog();
|
||||
}
|
||||
|
||||
private void startWatchdog() {
|
||||
watchdogHandler.postDelayed(watchdogRunnable, WATCHDOG_INTERVAL);
|
||||
}
|
||||
|
||||
private void stopWatchdog() {
|
||||
watchdogHandler.removeCallbacks(watchdogRunnable);
|
||||
}
|
||||
|
||||
private final Runnable watchdogRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
checkCameraStatus();
|
||||
watchdogHandler.postDelayed(this, WATCHDOG_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 定时检查摄像头状态,如果被抢占或未正常运行则尝试重新绑定
|
||||
*/
|
||||
private void checkCameraStatus() {
|
||||
if (CameraDetectionActivity.isShowing) {
|
||||
// 如果检测界面正在显示,后台服务主动释放摄像头,避免抢占
|
||||
releaseCameraIfBound();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cameraProviderFuture == null || !cameraProviderFuture.isDone()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ProcessCameraProvider cameraProvider = cameraProviderFuture.get();
|
||||
if (cameraProvider == null) return;
|
||||
|
||||
boolean isBound = false;
|
||||
if (imageAnalysis != null) {
|
||||
isBound = cameraProvider.isBound(imageAnalysis);
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
// 如果未绑定,或者已绑定但超过 15 秒没有收到图像帧(说明摄像头可能被抢占或异常)
|
||||
if (!isBound || (now - lastAnalyzedTimestamp > 15000)) {
|
||||
Log.w(TAG, "检测到摄像头未运行或可能被抢占,尝试重新绑定... isBound=" + isBound);
|
||||
bindCameraUseCases(cameraProvider);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "检查摄像头状态失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseCameraIfBound() {
|
||||
if (cameraProviderFuture == null || !cameraProviderFuture.isDone()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ProcessCameraProvider cameraProvider = cameraProviderFuture.get();
|
||||
if (imageAnalysis != null && cameraProvider.isBound(imageAnalysis)) {
|
||||
Log.d(TAG, "检测到 CameraDetectionActivity 正在运行,释放后台摄像头绑定");
|
||||
cameraProvider.unbind(imageAnalysis);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "释放摄像头失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.aoleyun.sn.service;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.aoleyun.sn.activity.WarningDialogActivity;
|
||||
import com.aoleyun.sn.detection.ShakeDetectionManager;
|
||||
|
||||
public class ShakeDetectionService extends Service {
|
||||
private static final String TAG = "ShakeDetectionService";
|
||||
private ShakeDetectionManager shakeDetectionManager;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Log.e(TAG, "onCreate: ");
|
||||
shakeDetectionManager = new ShakeDetectionManager(this, new ShakeDetectionManager.OnShakeListener() {
|
||||
@Override
|
||||
public void onShakeDetected() {
|
||||
// 后台提醒
|
||||
WarningDialogActivity.start(ShakeDetectionService.this, WarningDialogActivity.TYPE_SHAKE);
|
||||
}
|
||||
});
|
||||
shakeDetectionManager.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
Log.e(TAG, "onDestroy: ");
|
||||
if (shakeDetectionManager != null) {
|
||||
shakeDetectionManager.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,13 @@ import com.aoleyun.sn.bean.ScreenLockState;
|
||||
import com.aoleyun.sn.comm.CommonConfig;
|
||||
import com.aoleyun.sn.comm.JGYActions;
|
||||
import com.aoleyun.sn.comm.PackageNames;
|
||||
import com.aoleyun.sn.detection.SensorCheckUtil;
|
||||
import com.aoleyun.sn.gson.GsonUtils;
|
||||
import com.aoleyun.sn.hook.AoleyunActivityController;
|
||||
import com.aoleyun.sn.network.NetInterfaceManager;
|
||||
import com.aoleyun.sn.rlog.LogDBManager;
|
||||
import com.aoleyun.sn.service.DetectionService;
|
||||
import com.aoleyun.sn.service.ShakeDetectionService;
|
||||
import com.aoleyun.sn.utils.ApkUtils;
|
||||
import com.aoleyun.sn.utils.CacheUtils;
|
||||
import com.aoleyun.sn.utils.ForegroundAppUtil;
|
||||
@@ -481,6 +484,21 @@ public class MainService extends BaseService implements NetworkUtils.OnNetworkSt
|
||||
if (TextUtils.isEmpty(allowAppList)) {
|
||||
Settings.System.putString(getContentResolver(), CommonConfig.SEEWO_APP_SOURCE_INSTALL_WHITELIST, String.join(",", JgyUtils.DEFAULT_APP_SOURCE_WHITE_LIST));
|
||||
}
|
||||
|
||||
|
||||
int reminder = mMMKV.decodeInt(CommonConfig.EYE_TO_SCREEN_DISTANCE_REMINDER, 0);
|
||||
Log.e(TAG, "onCreate: reminder = " + reminder);
|
||||
if (reminder == 1) {
|
||||
if (!SensorCheckUtil.hasProximitySensor(this)) {
|
||||
startService(new Intent(this, DetectionService.class));
|
||||
}
|
||||
}
|
||||
|
||||
int shakeReminder = mMMKV.decodeInt(CommonConfig.SHAKE_REMINDER, 0);
|
||||
Log.e(TAG, "onCreate: shakeReminder = " + shakeReminder);
|
||||
if (shakeReminder == 1) {
|
||||
startService(new Intent(this, ShakeDetectionService.class));
|
||||
}
|
||||
}
|
||||
|
||||
private void initLiveData() {
|
||||
|
||||
@@ -238,15 +238,13 @@ public class MainServiceModel extends ViewModel {
|
||||
}
|
||||
|
||||
public void checkTestUpdate() {
|
||||
if (!JgyUtils.getInstance().tagEmpty()) {
|
||||
NetInterfaceManager.getInstance()
|
||||
.checkTestUpdate(getLifecycle(), new NetInterfaceManager.onCompleteCallback() {
|
||||
@Override
|
||||
public void onComplete() {
|
||||
NetInterfaceManager.getInstance()
|
||||
.checkTestUpdate(getLifecycle(), new NetInterfaceManager.onCompleteCallback() {
|
||||
@Override
|
||||
public void onComplete() {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void getDeveloper() {
|
||||
|
||||
95
app/src/main/java/com/aoleyun/sn/view/FaceOverlayView.java
Normal file
95
app/src/main/java/com/aoleyun/sn/view/FaceOverlayView.java
Normal file
@@ -0,0 +1,95 @@
|
||||
package com.aoleyun.sn.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PointF;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class FaceOverlayView extends View {
|
||||
private Paint pointPaint;
|
||||
private Paint textPaint;
|
||||
private PointF leftEye;
|
||||
private PointF rightEye;
|
||||
private double distance;
|
||||
private int imageWidth;
|
||||
private int imageHeight;
|
||||
|
||||
public FaceOverlayView(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public FaceOverlayView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
pointPaint = new Paint();
|
||||
pointPaint.setColor(Color.RED);
|
||||
pointPaint.setStyle(Paint.Style.FILL);
|
||||
pointPaint.setStrokeWidth(10f);
|
||||
|
||||
textPaint = new Paint();
|
||||
textPaint.setColor(Color.YELLOW);
|
||||
textPaint.setTextSize(60f);
|
||||
textPaint.setFakeBoldText(true);
|
||||
textPaint.setShadowLayer(5, 0, 0, Color.BLACK);
|
||||
}
|
||||
|
||||
public void updateData(double distance, PointF leftEye, PointF rightEye, int imageWidth, int imageHeight) {
|
||||
this.distance = distance;
|
||||
this.leftEye = leftEye;
|
||||
this.rightEye = rightEye;
|
||||
this.imageWidth = imageWidth;
|
||||
this.imageHeight = imageHeight;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
if (leftEye == null || rightEye == null || imageWidth <= 0 || imageHeight <= 0) {
|
||||
canvas.drawText("未检测到人脸", 50, 100, textPaint);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 获取 View 的尺寸
|
||||
float viewWidth = getWidth();
|
||||
float viewHeight = getHeight();
|
||||
|
||||
// 2. 计算缩放比例 (适配 PreviewView 的 FILL_CENTER 逻辑)
|
||||
// FILL_CENTER:取宽高缩放比例中较大的一个,使图像填满 View 且不拉伸,多出的部分被裁剪
|
||||
float scale = Math.max(viewWidth / imageWidth, viewHeight / imageHeight);
|
||||
|
||||
// 3. 计算偏移量(由居中裁剪产生)
|
||||
float postScaleWidth = imageWidth * scale;
|
||||
float postScaleHeight = imageHeight * scale;
|
||||
float offsetX = (viewWidth - postScaleWidth) / 2f;
|
||||
float offsetY = (viewHeight - postScaleHeight) / 2f;
|
||||
|
||||
// 4. 坐标映射并处理镜像
|
||||
// CameraX 的 PreviewView 显示前置摄像头时默认是镜像的
|
||||
// 映射公式: viewX = viewWidth - (imageX * scale + offsetX)
|
||||
float lx = viewWidth - (leftEye.x * scale + offsetX);
|
||||
float ly = leftEye.y * scale + offsetY;
|
||||
float rx = viewWidth - (rightEye.x * scale + offsetX);
|
||||
float ry = rightEye.y * scale + offsetY;
|
||||
|
||||
// 绘制眼睛点位
|
||||
canvas.drawCircle(lx, ly, 15, pointPaint);
|
||||
canvas.drawCircle(rx, ry, 15, pointPaint);
|
||||
|
||||
// 绘制连线,更直观
|
||||
canvas.drawLine(lx, ly, rx, ry, pointPaint);
|
||||
|
||||
canvas.drawText(String.format(Locale.getDefault(), "瞳距: %.2f", distance), 50, 100, textPaint);
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,11 @@ public class ToggleButton extends View {
|
||||
/**
|
||||
* 开启颜色
|
||||
*/
|
||||
private int onColor = Color.parseColor("#00d56b");
|
||||
private int onColor = Color.parseColor("#3a56e1");
|
||||
/**
|
||||
* 关闭颜色
|
||||
*/
|
||||
private int offBorderColor = Color.parseColor("#e7e4e4");
|
||||
private int offBorderColor = Color.parseColor("#727272");
|
||||
/**
|
||||
* 灰色带颜色
|
||||
*/
|
||||
@@ -56,7 +56,7 @@ public class ToggleButton extends View {
|
||||
/**
|
||||
* 边框大小
|
||||
*/
|
||||
private int borderWidth = 2;
|
||||
private int borderWidth = 3;
|
||||
/**
|
||||
* 垂直中心
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user