diff --git a/android/robot/src/main/java/org/openbot/common/FeatureList.java b/android/robot/src/main/java/org/openbot/common/FeatureList.java index e023f70b7..a5fab63e3 100644 --- a/android/robot/src/main/java/org/openbot/common/FeatureList.java +++ b/android/robot/src/main/java/org/openbot/common/FeatureList.java @@ -37,6 +37,7 @@ public class FeatureList { public static final String PERSON_FOLLOWING = "Person Following"; public static final String OBJECT_NAV = "Object Tracking"; public static final String CART_SIMULATOR = "Cart Simulator"; + public static final String PERSON_CROP_COLLECTOR = "Person Crop Collector"; public static final String MODEL_MANAGEMENT = "Model Management"; public static final String POINT_GOAL_NAVIGATION = "Point Goal Navigation"; public static final String AUTONOMOUS_DRIVING = "Autonomous Driving"; @@ -84,6 +85,8 @@ public static ArrayList getCategories() { subCategories.add(new SubCategory(AUTOPILOT, R.drawable.ic_autopilot, "#44525F")); subCategories.add(new SubCategory(OBJECT_NAV, R.drawable.ic_person_search, "#E7CE88")); subCategories.add(new SubCategory(CART_SIMULATOR, R.drawable.ic_person_search, "#6BBF8A")); + subCategories.add( + new SubCategory(PERSON_CROP_COLLECTOR, R.drawable.ic_person_search, "#8BBF6B")); subCategories.add( new SubCategory(POINT_GOAL_NAVIGATION, R.drawable.ic_baseline_golf_course, "#1BBFBF")); subCategories.add(new SubCategory(MODEL_MANAGEMENT, R.drawable.ic_list_bulleted_48, "#BC7680")); diff --git a/android/robot/src/main/java/org/openbot/cropcollector/PersonCropCaptureConfig.java b/android/robot/src/main/java/org/openbot/cropcollector/PersonCropCaptureConfig.java new file mode 100644 index 000000000..5cdf4c883 --- /dev/null +++ b/android/robot/src/main/java/org/openbot/cropcollector/PersonCropCaptureConfig.java @@ -0,0 +1,11 @@ +package org.openbot.cropcollector; + +public class PersonCropCaptureConfig { + public String personId = ""; + public long intervalMs = 500; + public float minConfidence = 0.5f; + public boolean singlePersonOnly = true; + public float paddingRatio = 0.08f; + public int maxCrops = 120; + public int jpegQuality = 95; +} diff --git a/android/robot/src/main/java/org/openbot/cropcollector/PersonCropCollectorFragment.java b/android/robot/src/main/java/org/openbot/cropcollector/PersonCropCollectorFragment.java new file mode 100644 index 000000000..f711412f1 --- /dev/null +++ b/android/robot/src/main/java/org/openbot/cropcollector/PersonCropCollectorFragment.java @@ -0,0 +1,464 @@ +package org.openbot.cropcollector; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.SystemClock; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ImageProxy; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.jetbrains.annotations.NotNull; +import org.openbot.R; +import org.openbot.common.CameraFragment; +import org.openbot.databinding.FragmentPersonCropCollectorBinding; +import org.openbot.env.ImageUtils; +import org.openbot.tflite.Detector; +import org.openbot.tflite.Model; +import org.openbot.tflite.Network; +import org.openbot.utils.CameraUtils; +import org.openbot.utils.Enums; +import timber.log.Timber; + +public class PersonCropCollectorFragment extends CameraFragment { + + private FragmentPersonCropCollectorBinding binding; + private Handler handler; + private HandlerThread handlerThread; + + private boolean computingNetwork = false; + private float minConfidence = 0.5f; + + private Detector detector; + private Matrix frameToCropTransform; + private Bitmap croppedBitmap; + private int sensorOrientation; + private Matrix cropToFrameTransform; + + private Model model; + private Network.Device device = Network.Device.CPU; + private int numThreads = -1; + private final String classType = "person"; + + private long lastProcessingTimeMs = -1; + private long frameNum = 0; + + private final PersonCropCaptureConfig config = new PersonCropCaptureConfig(); + private PersonCropSession currentSession = null; + private PersonCropSaver saver = null; + private boolean captureEnabled = false; + private long lastSaveTimeMs = 0; + + private final List drawBoxes = new ArrayList<>(); + private int drawFrameWidth = 0; + private int drawFrameHeight = 0; + private int drawSensorOrientation = 0; + + private final Paint personBoxPaint = new Paint(); + private final Paint boxTextPaint = new Paint(); + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + personBoxPaint.setColor(Color.GREEN); + personBoxPaint.setStyle(Paint.Style.STROKE); + personBoxPaint.setStrokeWidth(6.0f); + boxTextPaint.setColor(Color.WHITE); + boxTextPaint.setTextSize(36.0f); + } + + @Override + public View onCreateView( + @NotNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + binding = FragmentPersonCropCollectorBinding.inflate(inflater, container, false); + return inflateFragment(binding, inflater, container); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + binding.confidenceValue.setText((int) (minConfidence * 100) + "%"); + binding.plusConfidence.setOnClickListener( + v -> { + int confValue = (int) (minConfidence * 100); + if (confValue >= 95) return; + confValue += 5; + minConfidence = confValue / 100f; + binding.confidenceValue.setText(confValue + "%"); + }); + binding.minusConfidence.setOnClickListener( + v -> { + int confValue = (int) (minConfidence * 100); + if (confValue <= 5) return; + confValue -= 5; + minConfidence = confValue / 100f; + binding.confidenceValue.setText(confValue + "%"); + }); + + List models = getModelNames(f -> f.type.equals(Model.TYPE.DETECTOR)); + initModelSpinner(binding.modelSpinner, models, preferencesManager.getObjectNavModel()); + + setAnalyserResolution(Enums.Preview.HD.getValue()); + + binding.trackingOverlay.addCallback(canvas -> drawOverlay(canvas)); + + binding.statusText.setText(getString(R.string.person_crop_idle)); + binding.btnStart.setEnabled(true); + binding.btnStop.setEnabled(false); + + binding.intervalValue.setText(config.intervalMs + "ms"); + binding.minusInterval.setOnClickListener( + v -> { + if (config.intervalMs <= 100) return; + config.intervalMs -= 100; + binding.intervalValue.setText(config.intervalMs + "ms"); + }); + binding.plusInterval.setOnClickListener( + v -> { + if (config.intervalMs >= 2000) return; + config.intervalMs += 100; + binding.intervalValue.setText(config.intervalMs + "ms"); + }); + binding.singlePersonSwitch.setOnCheckedChangeListener( + (buttonView, isChecked) -> config.singlePersonOnly = isChecked); + + binding.btnStart.setOnClickListener(v -> startCapture()); + binding.btnStop.setOnClickListener(v -> stopCapture()); + } + + private void startCapture() { + String personId = binding.personIdInput.getText().toString().trim(); + if (personId.isEmpty()) { + Toast.makeText(requireContext(), "请输入 Person ID", Toast.LENGTH_SHORT).show(); + return; + } + + config.personId = personId; + config.minConfidence = minConfidence; + + currentSession = new PersonCropSession(requireContext(), personId); + currentSession.initMetadataCsv(); + currentSession.writeSessionInfo(config); + + if (saver != null) { + saver.shutdown(); + } + saver = new PersonCropSaver(); + captureEnabled = true; + lastSaveTimeMs = 0; + + binding.btnStart.setEnabled(false); + binding.btnStop.setEnabled(true); + binding.personIdInput.setEnabled(false); + binding.modelSpinner.setEnabled(false); + binding.minusInterval.setEnabled(false); + binding.plusInterval.setEnabled(false); + binding.singlePersonSwitch.setEnabled(false); + binding.statusText.setText("采集中: " + currentSession.sessionId); + } + + private void stopCapture() { + captureEnabled = false; + if (saver != null) { + saver.shutdown(); + saver = null; + } + int saved = currentSession != null ? currentSession.savedCount : 0; + int skipped = currentSession != null ? currentSession.skippedCount : 0; + String path = currentSession != null ? currentSession.sessionDir.getAbsolutePath() : ""; + + binding.btnStart.setEnabled(true); + binding.btnStop.setEnabled(false); + binding.personIdInput.setEnabled(true); + binding.modelSpinner.setEnabled(true); + binding.minusInterval.setEnabled(true); + binding.plusInterval.setEnabled(true); + binding.singlePersonSwitch.setEnabled(true); + binding.statusText.setText( + String.format(Locale.US, "已停止: saved=%d skipped=%d\n%s", saved, skipped, path)); + } + + protected void onInferenceConfigurationChanged() { + computingNetwork = false; + if (croppedBitmap == null) return; + final Network.Device device = getDevice(); + final Model model = getModel(); + final int numThreads = getNumThreads(); + runInBackground(() -> recreateNetwork(model, device, numThreads)); + } + + private void recreateNetwork(Model model, Network.Device device, int numThreads) { + if (model == null) return; + Detector newDetector = null; + try { + newDetector = Detector.create(requireActivity(), model, device, numThreads); + } catch (IllegalArgumentException | IOException e) { + Timber.e(e, "Failed to create network."); + String msg = + model.pathType == Model.PATH_TYPE.URL + ? "该模型未下载,请先在主菜单 Model Management 中下载: " + model.name + : "模型加载失败: " + e.getMessage(); + requireActivity() + .runOnUiThread( + () -> + Toast.makeText(requireContext().getApplicationContext(), msg, Toast.LENGTH_LONG) + .show()); + return; + } + + if (detector != null) { + detector.close(); + } + detector = newDetector; + try { + croppedBitmap = + Bitmap.createBitmap( + detector.getImageSizeX(), detector.getImageSizeY(), Bitmap.Config.ARGB_8888); + frameToCropTransform = + ImageUtils.getTransformationMatrix( + getMaxAnalyseImageSize().getWidth(), + getMaxAnalyseImageSize().getHeight(), + croppedBitmap.getWidth(), + croppedBitmap.getHeight(), + sensorOrientation, + detector.getCropRect(), + detector.getMaintainAspect()); + cropToFrameTransform = new Matrix(); + frameToCropTransform.invert(cropToFrameTransform); + } catch (Exception e) { + Timber.e(e, "Failed to configure detector."); + requireActivity() + .runOnUiThread( + () -> + Toast.makeText( + requireContext().getApplicationContext(), + "模型配置失败: " + e.getMessage(), + Toast.LENGTH_LONG) + .show()); + } + } + + @Override + public synchronized void onResume() { + croppedBitmap = null; + handlerThread = new HandlerThread("inference"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + super.onResume(); + } + + @Override + public synchronized void onPause() { + if (captureEnabled) { + stopCapture(); + } + handlerThread.quitSafely(); + try { + handlerThread.join(); + handlerThread = null; + handler = null; + } catch (final InterruptedException e) { + e.printStackTrace(); + } + super.onPause(); + } + + protected synchronized void runInBackground(final Runnable r) { + if (handler != null) handler.post(r); + } + + @Override + protected void processUSBData(String data) {} + + @Override + protected void processControllerKeyData(String commandType) {} + + @Override + protected void processFrame(Bitmap bitmap, ImageProxy image) { + if (detector == null) { + updateCropImageInfo(); + if (detector == null) return; + } + + ++frameNum; + if (binding == null) return; + if (computingNetwork) return; + + computingNetwork = true; + runInBackground( + () -> { + final Canvas canvas = new Canvas(croppedBitmap); + Bitmap workingFrame = bitmap; + if (lensFacing == CameraSelector.LENS_FACING_FRONT) { + Bitmap flipped = CameraUtils.flipBitmapHorizontal(bitmap); + canvas.drawBitmap(flipped, frameToCropTransform, null); + workingFrame = flipped; + } else { + canvas.drawBitmap(bitmap, frameToCropTransform, null); + } + + if (detector != null) { + final long startTime = SystemClock.elapsedRealtime(); + final List results = + detector.recognizeImage(croppedBitmap, classType); + lastProcessingTimeMs = SystemClock.elapsedRealtime() - startTime; + + final List mappedRecognitions = new ArrayList<>(); + for (final Detector.Recognition result : results) { + final RectF location = result.getLocation(); + if (location != null && result.getConfidence() >= minConfidence) { + cropToFrameTransform.mapRect(location); + result.setLocation(location); + mappedRecognitions.add(result); + } + } + + int frameW = getMaxAnalyseImageSize().getWidth(); + int frameH = getMaxAnalyseImageSize().getHeight(); + updateDrawState(mappedRecognitions, frameW, frameH, sensorOrientation); + float fps = lastProcessingTimeMs > 0 ? 1000f / lastProcessingTimeMs : 0f; + + handleCropCapture(workingFrame, mappedRecognitions); + + updateDebugInfo(mappedRecognitions.size(), fps); + binding.trackingOverlay.postInvalidate(); + } + computingNetwork = false; + }); + } + + private void updateCropImageInfo() { + sensorOrientation = 90 - ImageUtils.getScreenOrientation(requireActivity()); + recreateNetwork(getModel(), getDevice(), getNumThreads()); + } + + private void handleCropCapture(Bitmap workingFrame, List persons) { + if (!captureEnabled || currentSession == null || saver == null) return; + + long now = System.currentTimeMillis(); + if (now - lastSaveTimeMs < config.intervalMs) return; + + if (currentSession.savedCount >= config.maxCrops) { + requireActivity().runOnUiThread(() -> stopCapture()); + return; + } + + if (persons.isEmpty()) { + currentSession.skippedCount++; + return; + } + + if (config.singlePersonOnly && persons.size() != 1) { + currentSession.skippedCount++; + return; + } + + int numPersons = persons.size(); + if (config.singlePersonOnly) { + int cropId = currentSession.savedCount + 1; + currentSession.savedCount = cropId; + saver.saveCropAsync( + workingFrame, persons.get(0), 0, numPersons, currentSession, config, frameNum, cropId); + } else { + for (int i = 0; i < numPersons; i++) { + int cropId = currentSession.savedCount + 1; + currentSession.savedCount = cropId; + saver.saveCropAsync( + workingFrame, persons.get(i), i, numPersons, currentSession, config, frameNum, cropId); + } + } + lastSaveTimeMs = now; + } + + private synchronized void updateDrawState( + List persons, int frameW, int frameH, int sensorOrientation) { + drawBoxes.clear(); + for (Detector.Recognition r : persons) { + if (r == null || r.getLocation() == null) continue; + drawBoxes.add(new RectF(r.getLocation())); + } + drawFrameWidth = frameW; + drawFrameHeight = frameH; + drawSensorOrientation = sensorOrientation; + } + + private void drawOverlay(Canvas canvas) { + if (drawFrameWidth <= 0 || drawFrameHeight <= 0) return; + final boolean rotated = drawSensorOrientation % 180 == 90; + final float multiplier = + Math.min( + canvas.getHeight() / (float) (rotated ? drawFrameWidth : drawFrameHeight), + canvas.getWidth() / (float) (rotated ? drawFrameHeight : drawFrameWidth)); + Matrix matrix = + ImageUtils.getTransformationMatrix( + drawFrameWidth, + drawFrameHeight, + (int) (multiplier * (rotated ? drawFrameHeight : drawFrameWidth)), + (int) (multiplier * (rotated ? drawFrameWidth : drawFrameHeight)), + drawSensorOrientation, + new RectF(0, 0, 0, 0), + false); + + List snapshot; + synchronized (this) { + snapshot = new ArrayList<>(drawBoxes); + } + for (RectF box : snapshot) { + RectF rect = new RectF(box); + matrix.mapRect(rect); + float cornerSize = Math.min(rect.width(), rect.height()) / 8.0f; + canvas.drawRoundRect(rect, cornerSize, cornerSize, personBoxPaint); + } + } + + private void updateDebugInfo(int persons, float fps) { + if (binding == null) return; + int saved = currentSession != null ? currentSession.savedCount : 0; + int skipped = currentSession != null ? currentSession.skippedCount : 0; + String info = + String.format( + Locale.US, + "persons=%d\nsaved=%d\nskipped=%d\nfps=%.1f", + persons, + saved, + skipped, + fps); + requireActivity().runOnUiThread(() -> binding.debugInfo.setText(info)); + } + + protected Model getModel() { + return model; + } + + @Override + protected void setModel(Model model) { + if (this.model != model) { + this.model = model; + preferencesManager.setObjectNavModel(model.name); + onInferenceConfigurationChanged(); + } + } + + protected Network.Device getDevice() { + return device; + } + + protected int getNumThreads() { + return numThreads; + } +} diff --git a/android/robot/src/main/java/org/openbot/cropcollector/PersonCropSaver.java b/android/robot/src/main/java/org/openbot/cropcollector/PersonCropSaver.java new file mode 100644 index 000000000..2db39ec8b --- /dev/null +++ b/android/robot/src/main/java/org/openbot/cropcollector/PersonCropSaver.java @@ -0,0 +1,142 @@ +package org.openbot.cropcollector; + +import android.graphics.Bitmap; +import android.graphics.RectF; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.openbot.tflite.Detector.Recognition; +import timber.log.Timber; + +public class PersonCropSaver { + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + public void saveCropAsync( + Bitmap frame, + Recognition person, + int personIndex, + int numPersons, + PersonCropSession session, + PersonCropCaptureConfig config, + long frameNum, + int cropId) { + RectF bbox = person.getLocation(); + if (bbox == null || frame == null) { + session.skippedCount++; + return; + } + + float padX = bbox.width() * config.paddingRatio; + float padY = bbox.height() * config.paddingRatio; + int left = clamp((int) (bbox.left - padX), 0, frame.getWidth() - 1); + int top = clamp((int) (bbox.top - padY), 0, frame.getHeight() - 1); + int right = clamp((int) (bbox.right + padX), left + 1, frame.getWidth()); + int bottom = clamp((int) (bbox.bottom + padY), top + 1, frame.getHeight()); + int w = right - left; + int h = bottom - top; + if (w <= 0 || h <= 0) { + session.skippedCount++; + return; + } + + final Bitmap crop; + try { + crop = Bitmap.createBitmap(frame, left, top, w, h); + } catch (Exception e) { + session.skippedCount++; + return; + } + + final int imgW = frame.getWidth(); + final int imgH = frame.getHeight(); + final boolean edgeTouch = left == 0 || top == 0 || right == imgW || bottom == imgH; + final RectF bboxCopy = new RectF(bbox); + final float confidence = person.getConfidence() == null ? 0f : person.getConfidence(); + + executor.execute( + () -> { + String filename = + String.format(Locale.US, "%06d_person%d_conf%.2f.jpg", cropId, personIndex, confidence); + File cropFile = new File(session.cropsDir, filename); + + try (FileOutputStream fos = new FileOutputStream(cropFile)) { + crop.compress(Bitmap.CompressFormat.JPEG, config.jpegQuality, fos); + } catch (IOException e) { + Timber.e(e, "Failed to save crop"); + } finally { + crop.recycle(); + } + + appendMetadataCsv( + session, + config, + personIndex, + numPersons, + cropId, + frameNum, + filename, + bboxCopy, + imgW, + imgH, + confidence, + edgeTouch); + }); + } + + private void appendMetadataCsv( + PersonCropSession session, + PersonCropCaptureConfig config, + int personIndex, + int numPersons, + int cropId, + long frameNum, + String filename, + RectF bbox, + int imgW, + int imgH, + float confidence, + boolean edgeTouch) { + String cropPath = "crops/" + filename; + String row = + String.format( + Locale.US, + "%s,%s,%d,%d,%d,%s,%d,%d,%.4f,%.1f,%.1f,%.1f,%.1f,%.1f,%.1f,%d,%d,%s,%s\n", + session.sessionId, + config.personId, + frameNum, + cropId, + System.currentTimeMillis(), + cropPath, + numPersons, + personIndex, + confidence, + bbox.left, + bbox.top, + bbox.right, + bbox.bottom, + bbox.width(), + bbox.height(), + imgW, + imgH, + edgeTouch ? "1" : "0", + "single_person_valid"); + try (FileWriter writer = new FileWriter(session.metadataCsv, true)) { + writer.append(row); + } catch (IOException e) { + Timber.e(e, "Failed to append metadata.csv"); + } + } + + public void shutdown() { + executor.shutdown(); + } + + private static int clamp(int v, int lo, int hi) { + return v < lo ? lo : (v > hi ? hi : v); + } +} diff --git a/android/robot/src/main/java/org/openbot/cropcollector/PersonCropSession.java b/android/robot/src/main/java/org/openbot/cropcollector/PersonCropSession.java new file mode 100644 index 000000000..f0c3061be --- /dev/null +++ b/android/robot/src/main/java/org/openbot/cropcollector/PersonCropSession.java @@ -0,0 +1,79 @@ +package org.openbot.cropcollector; + +import android.content.Context; +import android.os.Environment; +import android.util.Log; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import org.json.JSONException; +import org.json.JSONObject; + +public class PersonCropSession { + + private static final String TAG = "PersonCropSession"; + + private static final String CSV_HEADER = + "session_id,person_id,frame_id,crop_id,timestamp_ms,crop_path,num_persons,person_index," + + "confidence,bbox_left,bbox_top,bbox_right,bbox_bottom,bbox_width,bbox_height," + + "image_width,image_height,edge_touch,save_reason"; + + public final String sessionId; + public final String personId; + public final File sessionDir; + public final File cropsDir; + public final File metadataCsv; + public int savedCount = 0; + public int skippedCount = 0; + + public PersonCropSession(Context context, String personId) { + this.personId = personId; + String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + this.sessionId = personId + "_" + timestamp; + + File baseDir = + new File( + context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "cartfollow_crops"); + this.sessionDir = new File(baseDir, sessionId); + this.cropsDir = new File(sessionDir, "crops"); + this.cropsDir.mkdirs(); + this.metadataCsv = new File(sessionDir, "metadata.csv"); + } + + public void initMetadataCsv() { + try (FileWriter writer = new FileWriter(metadataCsv, false)) { + writer.append(CSV_HEADER).append('\n'); + } catch (IOException e) { + Log.e(TAG, "Failed to init metadata.csv", e); + } + } + + public void writeSessionInfo(PersonCropCaptureConfig config) { + JSONObject json = new JSONObject(); + try { + json.put("session_id", sessionId); + json.put("person_id", personId); + json.put("created_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(new Date())); + json.put("capture_interval_ms", config.intervalMs); + json.put("min_confidence", config.minConfidence); + json.put("single_person_only", config.singlePersonOnly); + json.put("bbox_padding_ratio", config.paddingRatio); + json.put("max_crops", config.maxCrops); + json.put("jpeg_quality", config.jpegQuality); + json.put("app_mode", "PersonCropCollector"); + } catch (JSONException e) { + Log.e(TAG, "Failed to build session_info.json", e); + return; + } + + File infoFile = new File(sessionDir, "session_info.json"); + try (FileWriter writer = new FileWriter(infoFile, false)) { + writer.write(json.toString(2)); + } catch (IOException | JSONException e) { + Log.e(TAG, "Failed to write session_info.json", e); + } + } +} diff --git a/android/robot/src/main/java/org/openbot/main/MainFragment.java b/android/robot/src/main/java/org/openbot/main/MainFragment.java index 8f350de77..792bb9709 100644 --- a/android/robot/src/main/java/org/openbot/main/MainFragment.java +++ b/android/robot/src/main/java/org/openbot/main/MainFragment.java @@ -92,6 +92,11 @@ public void onItemClick(SubCategory subCategory) { .navigate(R.id.action_mainFragment_to_cartSimFragment); break; + case FeatureList.PERSON_CROP_COLLECTOR: + Navigation.findNavController(requireView()) + .navigate(R.id.action_mainFragment_to_personCropCollectorFragment); + break; + case FeatureList.POINT_GOAL_NAVIGATION: Navigation.findNavController(requireView()) .navigate(R.id.action_mainFragment_to_pointGoalNavigationFragment); diff --git a/android/robot/src/main/res/layout/fragment_person_crop_collector.xml b/android/robot/src/main/res/layout/fragment_person_crop_collector.xml new file mode 100644 index 000000000..d427cee79 --- /dev/null +++ b/android/robot/src/main/res/layout/fragment_person_crop_collector.xml @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +