现在这个网站已经有了成品，请参考以下链接并试用 深度检测前言：一个生物多样性的梦作为一个 AI 架构师，我接到了一个很有意义的项目：开发一套生物多样性识别系统。目标很明确：识别各种鸟类（麻雀、喜鹊、白鹭、翠鸟...）识别各种鱼类（金鱼、鲤鱼、热带鱼...）识别各种小动物（松鼠、刺猬、兔子、蜥蜴...）应用场景包括：野外红外相机的自动物种识别水族馆/动物园的智能监控自然保护区的生态监测公民科学项目的物种记录技术选型自然是 YOLO——目标检测领域的当红炸子鸡，速度快、精度高、部署方便。然而，理想很丰满，现实很骨感。第一章：MacBook 的绝望——为什么我放弃了本地训练1.1 最初的尝试作为一个 MacBook Pro 用户（M4 Pro，18GB 内存），我天真地以为："Apple Silicon 不是很强吗？MPS 加速不是很快吗？训练个小模型应该没问题吧？"于是我开始了尝试：from ultralytics import YOLO # 加载预训练模型 model = YOLO('yolov8n.pt') # 开始训练 model.train( data='biodiversity.yaml', epochs=100, imgsz=640, batch=16, device='mps' # Apple Silicon GPU )然后...RuntimeError: MPS backend out of memory好吧，把 batch 改成 8：# 能跑了，但是... Epoch 1/100: 100%|██████████| 500/500 [15:32<00:00, 1.86s/it]一个 epoch 要 15 分钟，100 个 epoch 就是 25 小时！而且这还只是最小的 YOLOv8n，如果换成 YOLOv8m 或 YOLOv8l...1.2 MacBook 训练的残酷现实配置YOLOv8nYOLOv8sYOLOv8mMacBook M4 Pro 18GB勉强能跑OOM别想了每 epoch 时间~15min--100 epochs 总时间~25h--风扇噪音起飞--能否日常使用不能--问题总结：内存/显存不足：18GB 统一内存要同时跑系统和训练，捉襟见肘MPS 后端不成熟：经常遇到兼容性问题，某些操作不支持速度太慢：和 NVIDIA GPU 比，差距是数量级的影响日常使用：训练时电脑基本不能干别的事1.3 痛定思痛经过一番挣扎，我接受了现实：深度学习训练，还得是 NVIDIA GPU。MacBook 适合做推理、做轻量级实验，但大规模训练？还是交给专业的来吧。于是，我把目光投向了云 GPU 服务。第二章：AutoDL 初体验——云上炼丹的正确姿势2.1 为什么选择 AutoDL对比了几个云 GPU 服务：服务商优点缺点AutoDL便宜、简单、国内访问快高峰期可能没卡AWS/GCP稳定、配置灵活贵、需要信用卡、配置复杂阿里云/腾讯云大厂背书相对较贵Colab免费限时、断连、速度不稳定最终选择 AutoDL，原因：价格实惠：RTX 4090 大约 2-3 元/小时操作简单：网页端直接操作，支持 JupyterLab 和 SSH预装环境：有现成的 PyTorch 镜像，开箱即用数据传输快：国内服务器，上传下载速度有保障2.2 创建实例注册 AutoDL 账号（https://www.autodl.com）充值（支持支付宝，先充个 50-100 块试试）创建实例：GPU：RTX 4090（24GB 显存，性价比之王）镜像：PyTorch 2.0 + Python 3.10 + CUDA 11.8硬盘：系统盘默认，数据盘按需（我选了 50GB）2.3 环境配置SSH 连接到服务器后，配置训练环境：# 1. 确认 GPU 状态 nvidia-smi # 输出应该看到 RTX 4090，显存 24GB # +-----------------------------------------------------------------------------+ # | NVIDIA-SMI 525.xx.xx Driver Version: 525.xx.xx CUDA Version: 12.0 | # |-------------------------------+----------------------+----------------------+ # | GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC | # | Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. | # |===============================+======================+======================| # | 0 NVIDIA GeForce ... Off | 00000000:00:00.0 Off | N/A | # | 0% 30C P8 10W / 450W | 0MiB / 24576MiB | 0% Default | # +-------------------------------+----------------------+----------------------+ # 2. 安装 ultralytics（YOLOv8 官方库） pip install ultralytics -i https://pypi.tuna.tsinghua.edu.cn/simple # 3. 安装其他依赖 pip install albumentations opencv-python-headless -i https://pypi.tuna.tsinghua.edu.cn/simple # 4. 验证安装 python -c "from ultralytics import YOLO; print('YOLOv8 ready!')"第三章：数据集准备——生物多样性数据的艺术3.1 数据来源训练目标检测模型，最重要的是数据。我使用了多个数据源：数据源内容数量iNaturalist开源自然观察数据集~5000 张Open ImagesGoogle 开源数据集（筛选动物类别）~3000 张Kaggle 数据集鸟类、鱼类专项数据集~2000 张自行标注网络图片 + LabelImg 标注~1000 张最终数据集构成：biodiversity_dataset/ ├── images/ │ ├── train/ # 训练集 ~8800 张 │ ├── val/ # 验证集 ~1100 张 │ └── test/ # 测试集 ~1100 张 ├── labels/ │ ├── train/ # 对应标注文件 │ ├── val/ │ └── test/ └── biodiversity.yaml # 数据集配置文件3.2 类别定义根据项目需求，我定义了 20 个类别：# biodiversity.yaml path: /root/autodl-tmp/biodiversity_dataset train: images/train val: images/val test: images/test # 类别定义 names: # 鸟类 (0-7) 0: sparrow # 麻雀 1: magpie # 喜鹊 2: egret # 白鹭 3: kingfisher # 翠鸟 4: crow # 乌鸦 5: pigeon # 鸽子 6: swallow # 燕子 7: parrot # 鹦鹉 # 鱼类 (8-13) 8: goldfish # 金鱼 9: koi # 锦鲤 10: tropical_fish # 热带鱼 11: catfish # 鲶鱼 12: bass # 鲈鱼 13: salmon # 三文鱼 # 小动物 (14-19) 14: squirrel # 松鼠 15: rabbit # 兔子 16: hedgehog # 刺猬 17: lizard # 蜥蜴 18: frog # 青蛙 19: turtle # 乌龟 nc: 20 # 类别数量3.3 数据标注格式YOLO 使用的是归一化的 xywh 格式：# labels/train/img_001.txt # 格式：class_id center_x center_y width height # 所有值都是相对于图片尺寸的归一化值 (0-1) 0 0.456 0.523 0.124 0.089 14 0.721 0.634 0.098 0.1563.4 数据预处理脚本# scripts/prepare_dataset.py """ 数据集预处理脚本 1. 统一图片格式 2. 检查标注文件 3. 划分训练/验证/测试集 4. 数据增强（可选） """ import os import shutil import random from pathlib import Path from PIL import Image import yaml def check_image(img_path): """检查图片是否有效""" try: img = Image.open(img_path) img.verify() return True except: return False def check_label(label_path, num_classes=20): """检查标注文件格式是否正确""" if not os.path.exists(label_path): return False with open(label_path, 'r') as f: lines = f.readlines() for line in lines: parts = line.strip().split() if len(parts) != 5: return False class_id = int(parts[0]) if class_id < 0 or class_id >= num_classes: return False # 检查坐标是否在 0-1 范围内 for val in parts[1:]: v = float(val) if v < 0 or v > 1: return False return True def split_dataset(source_dir, output_dir, train_ratio=0.8, val_ratio=0.1): """划分数据集""" images_dir = Path(source_dir) / 'images' labels_dir = Path(source_dir) / 'labels' # 获取所有有效的图片-标注对 valid_pairs = [] for img_path in images_dir.glob('*.*'): if img_path.suffix.lower() in ['.jpg', '.jpeg', '.png', '.bmp']: label_path = labels_dir / (img_path.stem + '.txt') if check_image(img_path) and check_label(label_path): valid_pairs.append((img_path, label_path)) print(f"有效数据对: {len(valid_pairs)}") # 随机打乱 random.shuffle(valid_pairs) # 计算划分点 n = len(valid_pairs) train_end = int(n * train_ratio) val_end = int(n * (train_ratio + val_ratio)) splits = { 'train': valid_pairs[:train_end], 'val': valid_pairs[train_end:val_end], 'test': valid_pairs[val_end:] } # 创建目录并复制文件 output_path = Path(output_dir) for split_name, pairs in splits.items(): img_dir = output_path / 'images' / split_name lbl_dir = output_path / 'labels' / split_name img_dir.mkdir(parents=True, exist_ok=True) lbl_dir.mkdir(parents=True, exist_ok=True) for img_path, label_path in pairs: shutil.copy(img_path, img_dir / img_path.name) shutil.copy(label_path, lbl_dir / label_path.name) print(f"{split_name}: {len(pairs)} 张") return splits def analyze_dataset(dataset_dir, yaml_path): """分析数据集统计信息""" with open(yaml_path, 'r') as f: config = yaml.safe_load(f) class_names = config['names'] class_counts = {name: 0 for name in class_names.values()} labels_dir = Path(dataset_dir) / 'labels' / 'train' for label_file in labels_dir.glob('*.txt'): with open(label_file, 'r') as f: for line in f: class_id = int(line.strip().split()[0]) class_name = class_names[class_id] class_counts[class_name] += 1 print("\n=== 数据集类别分布 ===") for name, count in sorted(class_counts.items(), key=lambda x: -x[1]): bar = '█' * (count // 100) print(f"{name:15s}: {count:5d} {bar}") return class_counts if __name__ == '__main__': # 划分数据集 split_dataset( source_dir='/root/autodl-tmp/raw_data', output_dir='/root/autodl-tmp/biodiversity_dataset', train_ratio=0.8, val_ratio=0.1 ) # 分析数据集 analyze_dataset( dataset_dir='/root/autodl-tmp/biodiversity_dataset', yaml_path='/root/autodl-tmp/biodiversity_dataset/biodiversity.yaml' )3.5 数据增强策略YOLO 内置了丰富的数据增强，但我们也可以自定义：# 在训练配置中设置数据增强参数 augmentation_config = { 'hsv_h': 0.015, # 色调变化 'hsv_s': 0.7, # 饱和度变化 'hsv_v': 0.4, # 亮度变化 'degrees': 10, # 旋转角度 'translate': 0.1, # 平移 'scale': 0.5, # 缩放 'shear': 5, # 剪切 'perspective': 0.0, # 透视变换 'flipud': 0.5, # 上下翻转概率 'fliplr': 0.5, # 左右翻转概率 'mosaic': 1.0, # Mosaic 增强概率 'mixup': 0.1, # MixUp 增强概率 'copy_paste': 0.1, # Copy-Paste 增强概率 }第四章：模型训练——4090 的狂喜4.1 训练脚本# train.py """ 生物多样性目标检测模型训练脚本 使用 YOLOv8 在 RTX 4090 上训练 """ import os import torch from ultralytics import YOLO from datetime import datetime def train(): # ==================== 配置 ==================== # 数据集配置 DATA_YAML = '/root/autodl-tmp/biodiversity_dataset/biodiversity.yaml' # 模型选择（按需选择） # yolov8n.pt - Nano (最快，精度较低) # yolov8s.pt - Small (平衡) # yolov8m.pt - Medium (推荐) # yolov8l.pt - Large (精度高，较慢) # yolov8x.pt - XLarge (最高精度) MODEL = 'yolov8m.pt' # 训练超参数 EPOCHS = 150 BATCH_SIZE = 32 # 4090 24GB 显存可以用 32 IMG_SIZE = 640 WORKERS = 8 # 优化器配置 OPTIMIZER = 'AdamW' LR0 = 0.001 # 初始学习率 LRF = 0.01 # 最终学习率因子 MOMENTUM = 0.937 WEIGHT_DECAY = 0.0005 # 其他配置 PROJECT = 'runs/biodiversity' NAME = f'yolov8m_{datetime.now().strftime("%Y%m%d_%H%M%S")}' # ==================== 环境检查 ==================== print("=" * 50) print("环境检查") print("=" * 50) print(f"PyTorch 版本: {torch.__version__}") print(f"CUDA 可用: {torch.cuda.is_available()}") if torch.cuda.is_available(): print(f"CUDA 版本: {torch.version.cuda}") print(f"GPU 设备: {torch.cuda.get_device_name(0)}") print(f"GPU 显存: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB") print("=" * 50) # ==================== 加载模型 ==================== print(f"\n加载预训练模型: {MODEL}") model = YOLO(MODEL) # ==================== 开始训练 ==================== print(f"\n开始训练...") print(f"数据集: {DATA_YAML}") print(f"Epochs: {EPOCHS}") print(f"Batch Size: {BATCH_SIZE}") print(f"Image Size: {IMG_SIZE}") results = model.train( # 数据配置 data=DATA_YAML, epochs=EPOCHS, batch=BATCH_SIZE, imgsz=IMG_SIZE, # 设备配置 device=0, # 使用第一块 GPU workers=WORKERS, # 优化器配置 optimizer=OPTIMIZER, lr0=LR0, lrf=LRF, momentum=MOMENTUM, weight_decay=WEIGHT_DECAY, # 学习率调度 cos_lr=True, # 余弦退火学习率 warmup_epochs=3, # 预热 epoch 数 warmup_momentum=0.8, warmup_bias_lr=0.1, # 数据增强 hsv_h=0.015, hsv_s=0.7, hsv_v=0.4, degrees=10, translate=0.1, scale=0.5, shear=5, flipud=0.5, fliplr=0.5, mosaic=1.0, mixup=0.1, # 损失函数权重 box=7.5, # 边界框损失权重 cls=0.5, # 分类损失权重 dfl=1.5, # DFL 损失权重 # 正则化 dropout=0.0, # 保存配置 project=PROJECT, name=NAME, save=True, save_period=10, # 每 10 个 epoch 保存一次 # 日志配置 verbose=True, plots=True, # 生成训练曲线图 # 早停配置 patience=30, # 30 个 epoch 无改善则停止 # 其他 seed=42, deterministic=True, amp=True, # 混合精度训练，加速！ resume=False, # 是否从断点继续 ) print("\n" + "=" * 50) print("训练完成!") print("=" * 50) print(f"最佳模型保存在: {PROJECT}/{NAME}/weights/best.pt") return results if __name__ == '__main__': train()4.2 训练过程监控# monitor.py """ 训练过程监控脚本 实时查看训练状态 """ import os import time import subprocess def monitor_gpu(): """监控 GPU 使用情况""" while True: os.system('clear') print("=" * 60) print("GPU 监控 (按 Ctrl+C 退出)") print("=" * 60) # GPU 状态 result = subprocess.run(['nvidia-smi'], capture_output=True, text=True) print(result.stdout) # 最新日志 log_files = sorted( [f for f in os.listdir('runs/biodiversity') if os.path.isdir(f'runs/biodiversity/{f}')], reverse=True ) if log_files: latest_run = log_files[0] results_file = f'runs/biodiversity/{latest_run}/results.csv' if os.path.exists(results_file): print("\n最新训练结果:") os.system(f'tail -5 {results_file}') time.sleep(5) if __name__ == '__main__': try: monitor_gpu() except KeyboardInterrupt: print("\n监控已停止")4.3 4090 vs MacBook 训练对比实测数据：指标MacBook M4 ProRTX 4090模型YOLOv8n (被迫)YOLOv8mBatch Size8 (最大)32每 Epoch 时间~15 min~45 sec100 Epochs 总时间~25 h~1.25 h显存占用OOM~18 GB最终 mAP~0.45~0.72结论：4090 的训练速度是 MacBook 的 20 倍以上，而且可以训练更大的模型！第五章：模型评估与优化5.1 验证脚本# evaluate.py """ 模型评估脚本 """ from ultralytics import YOLO import matplotlib.pyplot as plt from pathlib import Path def evaluate_model(model_path, data_yaml, save_dir='evaluation_results'): """ 全面评估模型性能 """ print(f"加载模型: {model_path}") model = YOLO(model_path) # 创建保存目录 save_path = Path(save_dir) save_path.mkdir(exist_ok=True) # 在验证集上评估 print("\n在验证集上评估...") val_results = model.val( data=data_yaml, split='val', batch=32, imgsz=640, conf=0.25, iou=0.6, plots=True, save_json=True, ) # 打印关键指标 print("\n" + "=" * 50) print("评估结果") print("=" * 50) print(f"mAP50: {val_results.box.map50:.4f}") print(f"mAP50-95: {val_results.box.map:.4f}") print(f"Precision: {val_results.box.mp:.4f}") print(f"Recall: {val_results.box.mr:.4f}") # 各类别 AP print("\n各类别 AP:") class_names = model.names for i, ap in enumerate(val_results.box.ap50): print(f" {class_names[i]:15s}: {ap:.4f}") return val_results def analyze_errors(model_path, test_images_dir, save_dir='error_analysis'): """ 分析预测错误 """ model = YOLO(model_path) save_path = Path(save_dir) save_path.mkdir(exist_ok=True) # 预测测试图片 results = model.predict( source=test_images_dir, conf=0.25, save=True, save_txt=True, project=save_dir, name='predictions' ) print(f"预测结果已保存到: {save_dir}/predictions") return results def export_metrics_report(val_results, output_path='metrics_report.md'): """ 导出详细评估报告 """ report = f"""# 模型评估报告 ## 总体指标 | 指标 | 值 | |-----|-----| | mAP@0.5 | {val_results.box.map50:.4f} | | mAP@0.5:0.95 | {val_results.box.map:.4f} | | Precision | {val_results.box.mp:.4f} | | Recall | {val_results.box.mr:.4f} | ## 各类别性能 | 类别 | AP@0.5 | AP@0.5:0.95 | |-----|-------|------------| """ for i, (ap50, ap) in enumerate(zip(val_results.box.ap50, val_results.box.ap)): class_name = val_results.names[i] report += f"| {class_name} | {ap50:.4f} | {ap:.4f} |\n" with open(output_path, 'w') as f: f.write(report) print(f"报告已保存到: {output_path}") if __name__ == '__main__': MODEL_PATH = 'runs/biodiversity/yolov8m_best/weights/best.pt' DATA_YAML = '/root/autodl-tmp/biodiversity_dataset/biodiversity.yaml' # 评估模型 results = evaluate_model(MODEL_PATH, DATA_YAML) # 导出报告 export_metrics_report(results)5.2 我的训练结果经过 150 个 epoch 的训练，最终模型表现：=== 总体指标 === mAP@0.5: 0.724 mAP@0.5:0.95: 0.518 Precision: 0.731 Recall: 0.689 === 各类别 AP@0.5 === 鸟类: sparrow: 0.812 magpie: 0.798 egret: 0.856 kingfisher: 0.723 crow: 0.834 pigeon: 0.891 swallow: 0.654 parrot: 0.789 鱼类: goldfish: 0.756 koi: 0.812 tropical_fish: 0.634 catfish: 0.589 bass: 0.623 salmon: 0.712 小动物: squirrel: 0.834 rabbit: 0.867 hedgehog: 0.723 lizard: 0.598 frog: 0.645 turtle: 0.7895.3 性能优化技巧训练过程中我尝试了多种优化方法：1. 学习率调优# 使用学习率查找器 from ultralytics import YOLO model = YOLO('yolov8m.pt') # 学习率范围测试 # 可以通过观察 loss 曲线找到最佳学习率 # 一般选择 loss 下降最快的点的 1/102. 数据平衡针对类别不平衡问题：# 方法1：过采样少数类 # 方法2：欠采样多数类 # 方法3：使用 class weights # 在 YOLO 中可以通过调整 cls 损失权重来缓解 # 或者使用 Focal Loss3. 模型融合# 训练多个模型，推理时融合结果 from ultralytics import YOLO model1 = YOLO('runs/exp1/weights/best.pt') model2 = YOLO('runs/exp2/weights/best.pt') # 简单方法：取多个模型的平均置信度 # 高级方法：使用 WBF (Weighted Boxes Fusion)4. TTA（测试时增强）# 推理时使用数据增强 results = model.predict( source='test_image.jpg', augment=True # 开启 TTA )第六章：模型部署6.1 模型导出# export.py """ 模型导出脚本 支持多种格式 """ from ultralytics import YOLO def export_model(model_path): model = YOLO(model_path) # 导出 ONNX（通用格式，推荐） model.export( format='onnx', imgsz=640, simplify=True, opset=12, dynamic=False, # 固定输入尺寸 ) print("ONNX 模型已导出") # 导出 TensorRT（NVIDIA GPU 推理最快） model.export( format='engine', imgsz=640, device=0, half=True, # FP16 加速 ) print("TensorRT 模型已导出") # 导出 CoreML（iOS/macOS 部署） model.export( format='coreml', imgsz=640, nms=True, ) print("CoreML 模型已导出") # 导出 TFLite（移动端/边缘设备） model.export( format='tflite', imgsz=640, int8=True, # INT8 量化 ) print("TFLite 模型已导出") if __name__ == '__main__': export_model('runs/biodiversity/yolov8m_best/weights/best.pt')6.2 推理脚本# inference.py """ 推理脚本 支持图片、视频、摄像头 """ import cv2 from ultralytics import YOLO from pathlib import Path class BiodiversityDetector: def __init__(self, model_path, conf_threshold=0.25): self.model = YOLO(model_path) self.conf_threshold = conf_threshold # 类别颜色（BGR 格式） self.colors = { 'bird': (0, 255, 0), # 绿色 'fish': (255, 128, 0), # 蓝色 'animal': (0, 128, 255), # 橙色 } # 类别分组 self.bird_classes = ['sparrow', 'magpie', 'egret', 'kingfisher', 'crow', 'pigeon', 'swallow', 'parrot'] self.fish_classes = ['goldfish', 'koi', 'tropical_fish', 'catfish', 'bass', 'salmon'] self.animal_classes = ['squirrel', 'rabbit', 'hedgehog', 'lizard', 'frog', 'turtle'] def get_color(self, class_name): """根据类别返回颜色""" if class_name in self.bird_classes: return self.colors['bird'] elif class_name in self.fish_classes: return self.colors['fish'] else: return self.colors['animal'] def detect_image(self, image_path, save_path=None): """检测单张图片""" results = self.model.predict( source=image_path, conf=self.conf_threshold, verbose=False )[0] # 绘制结果 img = cv2.imread(image_path) annotated = self.draw_results(img, results) if save_path: cv2.imwrite(save_path, annotated) print(f"结果已保存到: {save_path}") return results, annotated def detect_video(self, video_path, output_path=None): """检测视频""" cap = cv2.VideoCapture(video_path) if output_path: fourcc = cv2.VideoWriter_fourcc(*'mp4v') fps = int(cap.get(cv2.CAP_PROP_FPS)) width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) out = cv2.VideoWriter(output_path, fourcc, fps, (width, height)) frame_count = 0 while cap.isOpened(): ret, frame = cap.read() if not ret: break # 检测 results = self.model.predict( source=frame, conf=self.conf_threshold, verbose=False )[0] # 绘制 annotated = self.draw_results(frame, results) if output_path: out.write(annotated) frame_count += 1 if frame_count % 30 == 0: print(f"已处理 {frame_count} 帧") cap.release() if output_path: out.release() print(f"视频已保存到: {output_path}") def detect_camera(self, camera_id=0): """实时摄像头检测""" cap = cv2.VideoCapture(camera_id) print("按 'q' 退出") while True: ret, frame = cap.read() if not ret: break results = self.model.predict( source=frame, conf=self.conf_threshold, verbose=False )[0] annotated = self.draw_results(frame, results) cv2.imshow('Biodiversity Detection', annotated) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows() def draw_results(self, img, results): """绘制检测结果""" annotated = img.copy() for box in results.boxes: # 获取边界框 x1, y1, x2, y2 = map(int, box.xyxy[0]) conf = float(box.conf[0]) cls_id = int(box.cls[0]) cls_name = results.names[cls_id] # 获取颜色 color = self.get_color(cls_name) # 绘制边界框 cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 2) # 绘制标签背景 label = f'{cls_name} {conf:.2f}' (label_w, label_h), _ = cv2.getTextSize( label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 1 ) cv2.rectangle( annotated, (x1, y1 - label_h - 10), (x1 + label_w, y1), color, -1 ) # 绘制标签文字 cv2.putText( annotated, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1 ) # 添加统计信息 stats = self.get_detection_stats(results) y_offset = 30 for category, count in stats.items(): text = f'{category}: {count}' cv2.putText( annotated, text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2 ) y_offset += 30 return annotated def get_detection_stats(self, results): """统计检测结果""" stats = {'Birds': 0, 'Fish': 0, 'Animals': 0} for box in results.boxes: cls_id = int(box.cls[0]) cls_name = results.names[cls_id] if cls_name in self.bird_classes: stats['Birds'] += 1 elif cls_name in self.fish_classes: stats['Fish'] += 1 else: stats['Animals'] += 1 return stats # 使用示例 if __name__ == '__main__': detector = BiodiversityDetector( model_path='runs/biodiversity/yolov8m_best/weights/best.pt', conf_threshold=0.3 ) # 检测图片 detector.detect_image('test_images/bird.jpg', 'output/bird_result.jpg') # 检测视频 # detector.detect_video('test_video.mp4', 'output/result.mp4') # 实时检测 # detector.detect_camera()6.3 API 服务# api_server.py """ FastAPI 推理服务 """ from fastapi import FastAPI, File, UploadFile from fastapi.responses import JSONResponse import cv2 import numpy as np from ultralytics import YOLO import io from PIL import Image app = FastAPI(title="Biodiversity Detection API") # 加载模型（全局单例） model = YOLO('runs/biodiversity/yolov8m_best/weights/best.pt') @app.post("/detect") async def detect(file: UploadFile = File(...), confidence: float = 0.25): """ 检测上传图片中的生物 """ # 读取图片 contents = await file.read() image = Image.open(io.BytesIO(contents)) # 推理 results = model.predict(source=image, conf=confidence, verbose=False)[0] # 构建响应 detections = [] for box in results.boxes: detections.append({ 'class': results.names[int(box.cls[0])], 'confidence': float(box.conf[0]), 'bbox': { 'x1': int(box.xyxy[0][0]), 'y1': int(box.xyxy[0][1]), 'x2': int(box.xyxy[0][2]), 'y2': int(box.xyxy[0][3]), } }) return JSONResponse({ 'success': True, 'count': len(detections), 'detections': detections }) @app.get("/health") async def health(): return {"status": "healthy"} # 启动命令: uvicorn api_server:app --host 0.0.0.0 --port 8000第七章：经验总结与踩坑记录7.1 踩过的坑坑 1：数据集路径问题# 错误：使用相对路径 data: dataset/biodiversity.yaml # 正确：使用绝对路径 data: /root/autodl-tmp/biodiversity_dataset/biodiversity.yaml坑 2：显存不足# 现象：CUDA out of memory # 解决方案： # 1. 减小 batch_size # 2. 减小图片尺寸 # 3. 使用混合精度训练 (amp=True) # 4. 使用梯度累积 # 5. 清理显存 import torch torch.cuda.empty_cache()坑 3：训练不收敛# 可能原因： # 1. 学习率太大或太小 # 2. 数据标注错误 # 3. 数据增强太激进 # 排查方法： # 1. 检查几张训练图片的标注是否正确 # 2. 先用小数据集快速迭代验证 # 3. 关闭数据增强看是否能收敛坑 4：AutoDL 连接断开# 使用 tmux 或 screen 保持会话 tmux new -s train python train.py # 断开后重新连接 tmux attach -t train # 或者使用 nohup nohup python train.py > train.log 2>&1 &7.2 性能优化清单✅ 使用预训练权重（迁移学习）✅ 开启混合精度训练（AMP）✅ 使用余弦退火学习率✅ 适当的数据增强✅ 使用 EMA（指数移动平均）✅ 早停防止过拟合✅ 多尺度训练✅ 标签平滑7.3 最终项目结构biodiversity_detection/ ├── data/ │ ├── raw/ # 原始数据 │ └── biodiversity_dataset/ # 处理后的数据集 │ ├── images/ │ ├── labels/ │ └── biodiversity.yaml ├── scripts/ │ ├── prepare_dataset.py # 数据准备 │ ├── train.py # 训练脚本 │ ├── evaluate.py # 评估脚本 │ ├── export.py # 模型导出 │ └── inference.py # 推理脚本 ├── runs/ # 训练输出 │ └── biodiversity/ │ └── yolov8m_best/ │ ├── weights/ │ │ ├── best.pt │ │ └── last.pt │ └── results.csv ├── deploy/ │ ├── api_server.py # API 服务 │ ├── requirements.txt │ └── Dockerfile └── README.md结语：从绝望到狂喜回顾这个项目，我的心路历程是这样的：天真期："MacBook 应该够用吧？"绝望期："为什么这么慢？为什么 OOM？"觉醒期："原来深度学习真的需要好显卡..."狂喜期："4090 真香！一个 epoch 不到一分钟！"满足期："模型终于能认出我家小区的麻雀了！"一些关键数字：项目数值总训练时间~4 小时AutoDL 费用~10 元最终 mAP@0.50.724支持类别数20模型大小~50 MB推理速度~15ms/张 (4090)如果你也有类似的项目需求，我的建议是：不要在消费级设备上死磕训练——租云 GPU，性价比更高数据质量比数量更重要——宁可少而精，不要多而乱从小模型开始——先跑通流程，再追求性能善用预训练权重——站在巨人的肩膀上多看官方文档——Ultralytics 的文档写得很好最后，希望这篇文章能帮助到同样想训练目标检测模型的你。让 AI 认识这个美丽星球上的每一个生命，这件事本身就很酷。项目代码已开源：GitHub - detech
前言：一个咸鱼玩家的觉醒事情是这样的。某天我下载了一款叫《咸鱼之王》的游戏。作为一个放置类游戏，它的核心玩法是——点点点。每日任务要点领奖励要点打副本要点抽卡要点合成要点……玩了一周后，我的手指已经开始抗议了。作为一个程序员，我陷入了深深的思考：我玩游戏，还是游戏玩我？于是，"猫吃鱼"项目诞生了——让脚本替我吃掉这条咸鱼。第一章：技术选型——为什么是 Auto.js1.1 安卓自动化方案对比方案优点缺点Auto.js无需 root，基于无障碍服务，JavaScript 语法官方版已下架，需要用开源版Tasker功能强大，生态丰富学习曲线陡峭，自动化能力有限ADB + Python灵活，可电脑控制需要连接电脑，不够便携按键精灵简单易用功能有限，很多要付费Root + 触摸模拟权限最高，啥都能干需要 root，有变砖风险最终选择 Auto.js（准确说是开源版 AutoX.js），原因：无需 root：基于安卓无障碍服务（Accessibility Service）JavaScript 语法：前端出身的我表示很亲切开源免费：AutoX.js 是社区维护的开源版本功能全面：控件操作、图色识别、悬浮窗、定时任务一应俱全1.2 环境搭建# 1. 下载 AutoX.js # GitHub: https://github.com/autox-community/AutoX # 下载 APK 安装到手机 # 2. 开启无障碍服务 # 设置 -> 无障碍 -> AutoX.js -> 开启 # 3. 开启悬浮窗权限 # 设置 -> 应用管理 -> AutoX.js -> 悬浮窗权限 # 4. 关闭电池优化（重要！） # 设置 -> 电池 -> AutoX.js -> 不优化 # 否则后台运行会被杀掉踩坑提醒：不同手机厂商的设置路径不一样，MIUI、ColorOS、EMUI 各有各的"特色"。有时候需要在"自启动管理"、"后台运行"等多个地方设置。第二章：无障碍服务——脚本的灵魂2.1 什么是无障碍服务无障碍服务（Accessibility Service）本来是安卓为视障人士设计的功能，可以：读取屏幕内容：获取当前界面所有控件的信息模拟用户操作：点击、滑动、输入文字监听系统事件：界面变化、通知到达等简单说，开启无障碍服务后，你的脚本就像一个"看得见屏幕的机器人"，可以知道屏幕上有什么，然后去操作它。2.2 控件 vs 坐标Auto.js 有两种主要的操作方式：方式一：基于控件（推荐） // 通过文字找到按钮并点击 click("领取奖励"); // 通过 id 找到控件 id("btn_claim").findOne().click(); // 通过描述找到控件 desc("关闭").findOne().click();方式二：基于坐标 // 直接点击屏幕坐标 click(540, 1200); // 滑动 swipe(500, 1500, 500, 500, 500);对比：特性控件操作坐标操作适配性好，不同分辨率通用差，换手机就废稳定性高，控件存在才操作低，可能点到别的地方速度稍慢，需要查找控件快，直接点适用场景原生应用、有控件信息的界面游戏画面、Canvas 渲染的界面《咸鱼之王》的尴尬在于——它是个混合应用，部分界面有控件信息，部分界面是纯游戏渲染，需要两种方式结合使用。2.3 布局分析——找到控件的"身份证"Auto.js 自带布局分析功能，可以查看当前界面的控件树： // 方法一：悬浮窗里点"布局分析" // 方法二：代码打印 auto.waitFor(); // 等待无障碍服务启动 let root = className("android.widget.FrameLayout").findOne(); log(root); // 打印控件树布局分析会显示每个控件的：text：显示的文字desc：描述（contentDescription）id：资源 IDclassName：控件类型bounds：位置和大小技巧：优先用 text 和 id，它们最稳定。第三章：项目架构——"猫吃鱼"的设计3.1 功能规划作为一个有追求的脚本，"猫吃鱼"要实现：猫吃鱼 ├── 日常任务 │ ├── 一键领取所有奖励 │ ├── 自动完成日常任务 │ └── 自动观看广告 ├── 战斗系统 │ ├── 自动挂机刷副本 │ ├── 自动打竞技场 │ └── 自动参与活动 ├── 资源管理 │ ├── 自动合成装备 │ ├── 自动强化英雄 │ └── 自动使用体力 └── 辅助功能 ├── 防检测（随机延迟） ├── 异常处理（弹窗、卡顿） └── 运行日志3.2 代码结构cat_eat_fish/ ├── main.js # 主入口 ├── config.js # 配置文件 ├── modules/ │ ├── daily.js # 日常任务 │ ├── battle.js # 战斗相关 │ ├── resource.js # 资源管理 │ └── utils.js # 工具函数 ├── lib/ │ ├── ui.js # 悬浮窗 UI │ ├── logger.js # 日志模块 │ └── ocr.js # 文字识别（可选） └── images/ # 图片资源（用于找图） ├── btn_claim.png ├── icon_close.png └── ...3.3 配置文件设计// config.js module.exports = { // 游戏包名 packageName: "com.xianyuwang.game", // 功能开关 features: { autoDailyTask: true, // 自动日常 autoWatchAd: false, // 自动看广告（费流量） autoBattle: true, // 自动战斗 autoMerge: true, // 自动合成 }, // 时间配置（毫秒） timing: { shortDelay: [300, 600], // 短延迟范围 mediumDelay: [800, 1500], // 中延迟范围 longDelay: [2000, 4000], // 长延迟范围 pageLoadWait: 2000, // 页面加载等待 }, // 战斗配置 battle: { maxRounds: 100, // 最大战斗轮数 autoRetry: true, // 失败自动重试 }, // 安全配置 safety: { randomDelay: true, // 随机延迟（防检测） maxRunTime: 3600000, // 最大运行时间（1小时） } };第四章：核心代码实现4.1 工具函数——脚本的基础设施 // modules/utils.js /** * 随机延迟，模拟人类操作 */ function randomSleep(min, max) { let delay = Math.floor(Math.random() * (max - min + 1)) + min; sleep(delay); return delay; } /** * 安全点击 - 基于文字 * @param {string} text - 要点击的文字 * @param {number} timeout - 超时时间（毫秒） * @returns {boolean} - 是否点击成功 */ function safeClickText(text, timeout = 5000) { let target = textContains(text).findOne(timeout); if (target) { let bounds = target.bounds(); // 点击控件中心，加一点随机偏移 let x = bounds.centerX() + random(-5, 5); let y = bounds.centerY() + random(-5, 5); click(x, y); log(`点击成功: ${text}`); randomSleep(300, 600); return true; } log(`未找到: ${text}`); return false; } /** * 安全点击 - 基于 ID */ function safeClickId(targetId, timeout = 5000) { let target = id(targetId).findOne(timeout); if (target) { target.click(); log(`点击成功: ${targetId}`); randomSleep(300, 600); return true; } log(`未找到: ${targetId}`); return false; } /** * 等待页面加载 */ function waitForPage(identifier, timeout = 10000) { let startTime = Date.now(); while (Date.now() - startTime < timeout) { if (text(identifier).exists() || id(identifier).exists()) { log(`页面已加载: ${identifier}`); randomSleep(500, 1000); return true; } sleep(500); } log(`页面加载超时: ${identifier}`); return false; } /** * 处理常见弹窗 */ function handlePopups() { // 关闭按钮 if (desc("关闭").exists()) { desc("关闭").findOne().click(); log("关闭弹窗"); return true; } // 确定按钮 if (text("确定").exists()) { click("确定"); log("点击确定"); return true; } // 广告关闭 if (id("ad_close").exists()) { id("ad_close").findOne().click(); log("关闭广告"); return true; } return false; } /** * 返回主界面 */ function goHome() { let maxTries = 5; while (maxTries > 0) { if (text("主城").exists() || id("main_page").exists()) { log("已在主界面"); return true; } back(); randomSleep(800, 1200); handlePopups(); maxTries--; } log("返回主界面失败"); return false; } module.exports = { randomSleep, safeClickText, safeClickId, waitForPage, handlePopups, goHome };4.2 日常任务模块 // modules/daily.js const utils = require('./utils.js'); const config = require('../config.js'); /** * 领取所有可领取的奖励 */ function claimAllRewards() { log("===== 开始领取奖励 ====="); // 领取邮件奖励 claimMailRewards(); // 领取任务奖励 claimTaskRewards(); // 领取活动奖励 claimEventRewards(); log("===== 奖励领取完成 ====="); } /** * 领取邮件奖励 */ function claimMailRewards() { log("检查邮件..."); // 点击邮件图标 if (!utils.safeClickText("邮件")) { log("未找到邮件入口"); return; } utils.waitForPage("邮件列表"); // 一键领取 if (text("一键领取").exists()) { utils.safeClickText("一键领取"); utils.randomSleep(1000, 1500); utils.handlePopups(); // 处理领取成功弹窗 } else { log("没有可领取的邮件"); } utils.goHome(); } /** * 领取任务奖励 */ function claimTaskRewards() { log("检查任务奖励..."); // 打开任务界面 if (!utils.safeClickText("任务")) { log("未找到任务入口"); return; } utils.waitForPage("每日任务"); // 循环领取所有可领取的奖励 let claimed = 0; while (true) { // 查找"领取"按钮 let claimBtn = text("领取").findOne(2000); if (claimBtn) { claimBtn.click(); claimed++; utils.randomSleep(500, 800); utils.handlePopups(); } else { break; } // 防止死循环 if (claimed > 20) break; } log(`共领取 ${claimed} 个任务奖励`); utils.goHome(); } /** * 自动完成简单任务 */ function autoCompleteTasks() { log("===== 开始自动任务 ====="); // 每日签到 dailyCheckIn(); // 免费抽卡 freeGacha(); // 领取体力 claimStamina(); // 快速战斗 quickBattle(); log("===== 自动任务完成 ====="); } /** * 每日签到 */ function dailyCheckIn() { log("执行每日签到..."); if (utils.safeClickText("签到")) { utils.waitForPage("签到"); // 点击签到按钮 if (text("签到").exists()) { utils.safeClickText("签到"); utils.randomSleep(1000, 1500); } utils.handlePopups(); utils.goHome(); } } /** * 免费抽卡 */ function freeGacha() { log("检查免费抽卡..."); if (utils.safeClickText("召唤")) { utils.waitForPage("召唤"); // 查找免费按钮 if (textContains("免费").exists()) { utils.safeClickText("免费"); utils.randomSleep(3000, 4000); // 等待抽卡动画 // 跳过动画或关闭结果 click(device.width / 2, device.height / 2); utils.randomSleep(500, 800); utils.handlePopups(); } else { log("今日免费次数已用完"); } utils.goHome(); } } module.exports = { claimAllRewards, autoCompleteTasks };4.3 战斗模块 // modules/battle.js const utils = require('./utils.js'); const config = require('../config.js'); /** * 自动刷副本 * @param {string} dungeonName - 副本名称 * @param {number} times - 刷取次数 */ function autoDungeon(dungeonName, times = 10) { log(`===== 开始刷副本: ${dungeonName} =====`); // 进入副本界面 if (!enterDungeon(dungeonName)) { log("进入副本失败"); return; } let successCount = 0; let failCount = 0; for (let i = 0; i < times; i++) { log(`第 ${i + 1}/${times} 次战斗`); // 检查体力 if (!checkStamina()) { log("体力不足，停止刷副本"); break; } // 开始战斗 if (startBattle()) { // 等待战斗结束 let result = waitBattleEnd(); if (result === "win") { successCount++; log(`战斗胜利！当前 ${successCount} 胜 ${failCount} 负`); } else if (result === "lose") { failCount++; log(`战斗失败... 当前 ${successCount} 胜 ${failCount} 负`); if (!config.battle.autoRetry) { log("配置为失败不重试，停止"); break; } } else { log("战斗状态异常"); utils.handlePopups(); } } else { log("开始战斗失败"); break; } // 战斗间隔 utils.randomSleep(1500, 2500); } log(`副本完成: ${successCount} 胜 ${failCount} 负`); utils.goHome(); } /** * 进入副本 */ function enterDungeon(dungeonName) { // 打开副本界面 if (!utils.safeClickText("副本") && !utils.safeClickText("冒险")) { return false; } utils.randomSleep(1000, 1500); // 选择具体副本 if (utils.safeClickText(dungeonName)) { utils.randomSleep(800, 1200); return true; } // 如果没找到，可能需要滑动查找 for (let i = 0; i < 3; i++) { swipe(540, 1500, 540, 800, 500); utils.randomSleep(500, 800); if (utils.safeClickText(dungeonName)) { utils.randomSleep(800, 1200); return true; } } return false; } /** * 开始战斗 */ function startBattle() { // 点击挑战按钮 if (utils.safeClickText("挑战") || utils.safeClickText("战斗") || utils.safeClickText("开始")) { utils.randomSleep(1500, 2000); return true; } return false; } /** * 等待战斗结束 */ function waitBattleEnd(timeout = 120000) { let startTime = Date.now(); while (Date.now() - startTime < timeout) { // 检查胜利 if (textContains("胜利").exists() || textContains("Victory").exists()) { utils.randomSleep(1000, 1500); // 点击继续或关闭 utils.safeClickText("继续") || utils.safeClickText("确定") || click(540, 1600); return "win"; } // 检查失败 if (textContains("失败").exists() || textContains("Defeat").exists()) { utils.randomSleep(1000, 1500); utils.safeClickText("确定") || click(540, 1600); return "lose"; } // 处理可能的弹窗 utils.handlePopups(); sleep(1000); } log("战斗超时"); return "timeout"; } /** * 检查体力是否充足 */ function checkStamina(minStamina = 10) { // 这里需要根据游戏实际 UI 来获取体力值 // 方法1：OCR 识别 // 方法2：找特定控件读取 // 方法3：检查"体力不足"提示 // 简化处理：如果没有"体力不足"提示就认为够用 return !textContains("体力不足").exists(); } /** * 自动竞技场 */ function autoArena(times = 5) { log(`===== 开始竞技场 ${times} 次 =====`); // 进入竞技场 if (!utils.safeClickText("竞技场") && !utils.safeClickText("PVP")) { log("未找到竞技场入口"); return; } utils.waitForPage("竞技场"); for (let i = 0; i < times; i++) { log(`竞技场第 ${i + 1}/${times} 场`); // 选择对手（选第一个，一般最弱） let opponent = className("android.widget.LinearLayout") .findOne(3000); if (opponent) { opponent.click(); utils.randomSleep(500, 800); // 点击挑战 if (startBattle()) { waitBattleEnd(60000); // 竞技场战斗一般较快 } } else { log("未找到对手"); break; } utils.randomSleep(2000, 3000); } utils.goHome(); } module.exports = { autoDungeon, autoArena };4.4 主程序入口 // main.js "ui"; // 启用 UI 模式 const config = require('./config.js'); const daily = require('./modules/daily.js'); const battle = require('./modules/battle.js'); const utils = require('./modules/utils.js'); // ==================== UI 界面 ==================== ui.layout( <vertical padding="16"> <text text="🐱 猫吃鱼 - 咸鱼之王助手" textSize="22sp" textColor="#333333" gravity="center" marginBottom="16"/> <horizontal> <checkbox id="cbDaily" text="日常任务" checked="true"/> <checkbox id="cbBattle" text="自动副本" checked="true" marginLeft="16"/> </horizontal> <horizontal marginTop="8"> <checkbox id="cbArena" text="竞技场" checked="false"/> <checkbox id="cbAd" text="看广告" checked="false" marginLeft="16"/> </horizontal> <horizontal marginTop="16"> <text text="副本次数: "/> </horizontal> <horizontal marginTop="24" gravity="center"> </horizontal> <text text="运行日志:" marginTop="16"/> <scroll h="200"> <text id="txtLog" textSize="12sp" textColor="#666666"/> </scroll> <text text="提示: 请先打开游戏，再点击开始运行" textSize="12sp" textColor="#999999" marginTop="8"/> </vertical> ); // ==================== 全局变量 ==================== let isRunning = false; let mainThread = null; // ==================== 日志函数 ==================== function appendLog(msg) { let time = new Date().toLocaleTimeString(); let logText = `[${time}] ${msg}\n`; ui.run(() => { ui.txtLog.setText(ui.txtLog.text() + logText); }); console.log(msg); } // 重写 log 函数 log = appendLog; // ==================== 事件处理 ==================== ui.btnStart.on("click", () => { if (isRunning) { toast("脚本正在运行中"); return; } // 检查无障碍服务 if (!auto.service) { toast("请先开启无障碍服务"); app.startActivity({ action: "android.settings.ACCESSIBILITY_SETTINGS" }); return; } isRunning = true; ui.btnStart.setText("运行中..."); mainThread = threads.start(function() { try { runScript(); } catch (e) { log("发生错误: " + e); } finally { isRunning = false; ui.run(() => { ui.btnStart.setText("开始运行"); }); } }); }); ui.btnStop.on("click", () => { if (mainThread) { mainThread.interrupt(); mainThread = null; } isRunning = false; ui.btnStart.setText("开始运行"); log("脚本已停止"); }); // ==================== 主逻辑 ==================== function runScript() { log("🐱 猫吃鱼启动！"); // 确保在游戏中 let packageName = config.packageName; if (currentPackage() !== packageName) { log("正在启动游戏..."); app.launchPackage(packageName); sleep(8000); // 等待游戏加载 } // 处理可能的开屏弹窗 utils.randomSleep(2000, 3000); utils.handlePopups(); // 执行日常任务 if (ui.cbDaily.checked) { daily.claimAllRewards(); daily.autoCompleteTasks(); } // 执行自动副本 if (ui.cbBattle.checked) { let times = parseInt(ui.inputTimes.text()) || 10; battle.autoDungeon("普通副本", times); } // 执行竞技场 if (ui.cbArena.checked) { battle.autoArena(5); } log("🎉 所有任务完成！"); } // ==================== 脚本退出处理 ==================== events.on("exit", function() { if (mainThread) { mainThread.interrupt(); } log("脚本退出"); });第五章：进阶技巧——让脚本更稳定5.1 图色识别——当控件失效时有些游戏界面是纯 Canvas 绘制的，没有控件信息。这时需要用图色识别： // 找图 let img = images.read("/sdcard/cat_eat_fish/images/btn_claim.png"); let point = findImage(captureScreen(), img, { threshold: 0.8, // 相似度阈值 region: [0, 0, device.width, device.height] // 搜索区域 }); if (point) { log(`找到图片: (${point.x}, ${point.y})`); click(point.x + 10, point.y + 10); // 点击图片中心偏移 } img.recycle(); // 释放图片资源，很重要！ // 找色 let color = "#FF5722"; // 目标颜色 let p = findColor(captureScreen(), color, { region: [100, 200, 300, 400], threshold: 10 }); if (p) { log(`找到颜色: (${p.x}, ${p.y})`); }注意：找图前需要获取截图权限：if (!requestScreenCapture()) { toast("请授予截图权限"); exit(); }5.2 多分辨率适配// 获取设备信息 let screenWidth = device.width; let screenHeight = device.height; // 设计基准（以 1080x1920 为基准） const BASE_WIDTH = 1080; const BASE_HEIGHT = 1920; // 坐标转换函数 function scaleX(x) { return Math.floor(x * screenWidth / BASE_WIDTH); } function scaleY(y) { return Math.floor(y * screenHeight / BASE_HEIGHT); } // 使用 click(scaleX(540), scaleY(1200));5.3 异常恢复机制 /** * 带重试的操作包装器 */ function withRetry(action, maxRetries = 3, delay = 1000) { for (let i = 0; i < maxRetries; i++) { try { if (action()) { return true; } } catch (e) { log(`操作失败 (${i + 1}/${maxRetries}): ${e}`); } sleep(delay); } return false; } /** * 全局异常处理 */ function globalErrorHandler() { // 检测是否还在游戏中 if (currentPackage() !== config.packageName) { log("检测到离开游戏，尝试返回"); app.launchPackage(config.packageName); sleep(5000); return; } // 处理常见弹窗 if (utils.handlePopups()) { return; } // 尝试返回主界面 utils.goHome(); } // 定时执行异常检测 setInterval(globalErrorHandler, 30000);5.4 防检测措施游戏可能会检测自动化脚本，一些防护措施：// 1. 随机延迟（最重要） function humanLikeDelay() { // 模拟人类操作的不规则延迟 let base = random(300, 800); // 偶尔来个长延迟，像是在思考 if (random(1, 10) === 1) { base += random(1000, 3000); } sleep(base); } // 2. 随机点击位置 function humanLikeClick(x, y, range = 10) { let actualX = x + random(-range, range); let actualY = y + random(-range, range); click(actualX, actualY); } // 3. 随机滑动速度 function humanLikeSwipe(x1, y1, x2, y2) { let duration = random(400, 800); swipe(x1, y1, x2, y2, duration); } // 4. 设置随机的运行时间，不要 24 小时挂机 function shouldTakeBreak() { // 每运行 1-2 小时休息一下 // ... }第六章：踩坑记录——血与泪的教训6.1 坑 1：无障碍服务被杀现象：脚本运行一段时间后自动停止原因：安卓系统的电池优化会杀后台应用解决：关闭 AutoX.js 的电池优化允许后台运行开启前台服务（悬浮窗）部分手机需要加入白名单6.2 坑 2：控件找不到现象：findOne() 返回 null可能原因：控件还没加载出来 → 加等待控件在 WebView 里 → 用 className("android.webkit.WebView") 先找到 WebView游戏用 Canvas 绘制 → 改用图色识别控件被遮挡 → 先关闭弹窗6.3 坑 3：click() 没反应现象：代码执行了但没效果可能原因：点击位置不对 → 用布局分析确认需要长按而不是点击 → 用 longClick()控件不可点击 → 点击其父控件有透明遮罩层 → 先处理遮罩 // 点击父控件 let target = text("目标").findOne(); if (target && !target.clickable()) { target.parent().click(); }6.4 坑 4：图片资源管理现象：运行一段时间后内存爆了原因：images.read() 和 captureScreen() 创建的图片没有释放解决：let img = captureScreen(); // ... 使用图片 img.recycle(); // 用完一定要 recycle！ // 或者用 images.copy() 复制一份 let copy = images.copy(img); img.recycle();第七章：最终效果与感悟7.1 成果展示经过两周的开发迭代，"猫吃鱼"实现了：✅ 自动领取所有奖励（邮件、任务、活动）✅ 自动完成日常任务（签到、免费抽卡）✅ 自动刷副本（支持配置次数）✅ 自动竞技场✅ 异常恢复（弹窗处理、游戏崩溃重启）✅ 运行日志和统计每天早上起床，打开脚本，等刷完牙洗完脸，日常任务已经做完了。时间节省：从每天手动操作 30-40 分钟，到现在只需要点一下"开始"。7.2 一些感悟自动化的本质是理解规律写脚本的过程，其实是在深入理解游戏机制。哪些操作是固定的？哪些是随机的？什么时候会弹窗？理解了这些，脚本才能稳定运行。程序员思维 vs 产品思维一开始我想把功能做得很全，后来发现 80% 的收益来自 20% 的功能。与其花大量时间做"完美"的脚本，不如先做好核心功能。边界情况永远比预想的多网络波动、游戏更新、意外弹窗... 真实环境比测试复杂得多。要有足够的异常处理和日志记录。关于游戏和脚本的思考用脚本"解放双手"后，我反而开始思考：如果一个游戏需要用脚本才能"好玩"，那它真的好玩吗？附录：常用 API 速查表控件操作// 查找控件 text("文字").findOne(timeout) textContains("部分文字").find() id("resource_id").findOne() desc("描述").findOne() className("android.widget.Button").find() // 组合查找 text("文字").className("Button").findOne() // 控件操作 widget.click() widget.longClick() widget.setText("文字") widget.bounds() // 获取位置 widget.parent() // 父控件 widget.children() // 子控件` ### 手势操作 javascript `click(x, y) longClick(x, y) swipe(x1, y1, x2, y2, duration) gesture(duration, [x1, y1], [x2, y2], ...) press(x, y, duration)图色操作requestScreenCapture() // 申请截图权限 captureScreen() // 截图 images.read(path) // 读取图片 findImage(img, template, options) // 找图 findColor(img, color, options) // 找色 images.pixel(img, x, y) // 获取像素颜色 img.recycle() // 释放资源应用操作app.launchPackage(packageName) // 启动应用 currentPackage() // 当前应用包名 currentActivity() // 当前 Activity back() // 返回键 home() // Home 键结语从一个想偷懒的念头，到一个能稳定运行的自动化脚本，"猫吃鱼"项目让我体会到了编程的乐趣——用代码解决实际问题。当然，这只是自动化入门。更复杂的场景可能需要：OCR 文字识别机器学习判断场景云控多设备管理...但对于一个咸鱼玩家来说，现在的"猫吃鱼"已经足够了。毕竟，真正的咸鱼，是让脚本替自己咸的。