diff --git a/FishMeasure/fish_video_weight_evaluation.py b/FishMeasure/fish_video_weight_evaluation.py index ac30104..e83a6c2 100755 --- a/FishMeasure/fish_video_weight_evaluation.py +++ b/FishMeasure/fish_video_weight_evaluation.py @@ -383,17 +383,8 @@ def run_weight_estimation( print(f" WARNING: High CV detected ({cv_pct:.1f}% > {max_cv_length}%) - results may be unreliable") summary["cv_warning"] = True - # Print results + # Print summary (skip per-file details to reduce log noise) if verbose: - for it in per_file: - ply_name = Path(it["ply"]).name - g = float(it["predicted_weight_g"]) - length = float(it.get("length_input", float("nan"))) - is_outlier = it.get("is_outlier", False) - outlier_marker = " [OUTLIER]" if is_outlier else "" - length_str = f"{length:.1f}mm" if np.isfinite(length) else "nan" - print(f" {ply_name}: len={length_str} | {g:.2f}g{outlier_marker}") - print(f"\n Files processed: {summary.get('num_files_predicted', len(ply_list))}") if summary.get('num_outliers_removed', 0) > 0: print(f" Outliers removed: {summary['num_outliers_removed']}") @@ -628,17 +619,43 @@ def build_track_weights_minute_top5( return tid_max, tid_len_mm, minute_avg, top5 +def _build_per_frame_weight_lookup( + per_file: List[Dict[str, Any]], +) -> Dict[Tuple[int, int], Tuple[float, float]]: + """Build (frame_idx, track_id) -> (weight_g, length_mm) from DGCNN per_file results. + + frame_idx is 0-based (matches the idx used in process_single_svo2). + """ + lookup: Dict[Tuple[int, int], Tuple[float, float]] = {} + for it in per_file: + ply = str(it.get("ply", "")) + name = Path(ply).name + tid = _parse_tid_from_ply_name(name) + fn = _parse_frame_num_from_ply_name(name) + if tid is None or fn is None: + continue + frame_idx = fn - 1 + wg = float(it.get("predicted_weight_g", float("nan"))) + ln = float(it.get("length_input", float("nan"))) + if np.isfinite(wg): + lookup[(frame_idx, tid)] = (wg, ln if np.isfinite(ln) else float("nan")) + return lookup + + def finalize_preview_video_with_weights( video_buffer: List[Dict[str, Any]], *, fps_video: float, - weights_by_track: Dict[int, float], - lengths_by_track_mm: Dict[int, float], + per_frame_lookup: Dict[Tuple[int, int], Tuple[float, float]], class_names: Dict[int, str], svo_name: str, output_images_folder: Path, ) -> None: - """Redraw buffered frames with DGCNN weight/length labels; write side-by-side mp4.""" + """Redraw buffered frames with per-frame, per-fish DGCNN weight/length labels. + + Only (frame_idx, track_id) keys present in per_frame_lookup get numeric labels; + missing keys show weight as ``--`` (see draw_fish_boxes_from_arrays). + """ if not video_buffer: return out_frames: List[np.ndarray] = [] @@ -655,14 +672,26 @@ def finalize_preview_video_with_weights( tids = np.zeros(len(boxes), dtype=np.int64) else: tids = np.asarray(_tid, dtype=np.int64) + + frame_wdict: Dict[int, float] = {} + frame_ldict: Dict[int, float] = {} + for t in tids: + t = int(t) + key = (frame_idx, t) + if key in per_frame_lookup: + wg, ln = per_frame_lookup[key] + frame_wdict[t] = wg + if np.isfinite(ln): + frame_ldict[t] = ln + left_disp = draw_fish_boxes_from_arrays( left_raw.copy(), boxes, cls_ids, tids, class_names, - weights_by_track=weights_by_track, - lengths_by_track_mm=lengths_by_track_mm, + weights_by_track=frame_wdict, + lengths_by_track_mm=frame_ldict, ) info = f"[{frame_idx + 1}] {frame_name} | Detections: {num_dets}" cv2.putText(left_disp, info, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2, cv2.LINE_AA) @@ -678,7 +707,6 @@ def finalize_preview_video_with_weights( for fr in out_frames: vw.write(fr) vw.release() - print(f"✓ Saved video (weight overlay): {video_path.name} ({len(out_frames)} frames @ {fps_video} fps)") def segment_with_sam(sam_predictor, image_bgr, boxes_xyxy, device): @@ -1325,9 +1353,6 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de if save_raw_pointclouds and output_raw_pc_folder: output_raw_pc_folder.mkdir(parents=True, exist_ok=True) - print(f"Reading from SVO2 file: {svo_path.name}") - print(f"Output folder: {output_base.resolve()}") - # Initialize ZED reader zed_reader = ZEDReader(svo_path=str(svo_path), camera_mode=False, use_yolo_detector=False) if not zed_reader.open(): @@ -1355,19 +1380,6 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de next_track_id = 0 STATIONARY_THRESHOLD = 10 MOVEMENT_THRESHOLD = 5.0 - - try: - cam_cfg = zed_reader.zed.get_camera_information().camera_configuration - _fps_raw = getattr(cam_cfg, "fps", None) - try: - fps_timeline = float(_fps_raw) if _fps_raw is not None else 30.0 - except (TypeError, ValueError): - fps_timeline = 30.0 - if fps_timeline <= 0: - fps_timeline = 30.0 - except Exception: - fps_timeline = 30.0 - print(f" Timeline FPS (for per-minute buckets): {fps_timeline:.2f}") # 只要跑过 fish 内 DGCNN,就延迟写预览,以便叠加真实 weight/length(不再依赖 --weight-overlay-video) defer_video = bool(do_weight_estimation and not save_images) @@ -1412,8 +1424,6 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de defer_left_payload: Optional[Dict[str, Any]] = None frame_name = f"frame_{idx+1:06d}" - if idx % 30 == 0: - print(f"[{idx + 1}] {frame_name}") # Run YOLO tracking (fish) results = yolo_model.track(img, conf=conf, imgsz=imgsz, verbose=False, persist=True)[0] @@ -1677,8 +1687,7 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de print(f" Point cloud {fish_idx + 1}: PREDICTED={prediction.upper()} (class_id={class_id}, confidence={confidence:.3f}) - SKIPPED (BAD quality)") continue else: - # Point cloud is good - proceed to save - print(f" Point cloud {fish_idx + 1}: PREDICTED={prediction.upper()} (class_id={class_id}, confidence={confidence:.3f}) - SAVING (GOOD quality, confidence >= {pointcloud_classifier_threshold:.3f})") + pass else: # Classifier requested but not available print(f" Point cloud {fish_idx + 1}: WARNING - Classifier requested but not loaded, saving without classification") @@ -1688,7 +1697,7 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de if use_pointcloud_classifier: print(f" Point cloud {fish_idx + 1}: ERROR - Classifier flag was set but classifier is None. Check startup logs for loading errors.") else: - print(f" Point cloud {fish_idx + 1}: Classifier not enabled") + pass # Evaluate flatness if enabled if use_flatness_filter: @@ -1704,7 +1713,7 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de print(f" Point cloud {fish_idx + 1}: Flatness score {flatness_score:.2f}% < threshold {flatness_threshold:.2f}% - SKIPPED (not flat enough)") continue else: - print(f" Point cloud {fish_idx + 1}: Flatness score {flatness_score:.2f}% >= threshold {flatness_threshold:.2f}% - PASSED") + pass except Exception as e: print(f" Point cloud {fish_idx + 1}: WARNING - Flatness evaluation failed: {e}") # If flatness check is required and fails, skip saving @@ -1749,14 +1758,11 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de # Classifier not enabled, track all saved point clouds kept_pointclouds.append(str(ply_path)) - if filter_pointcloud: - print(f" Saved point cloud {fish_idx + 1}: {ply_path.name} ({original_count} -> {filtered_count} points)") - else: - print(f" Saved point cloud {fish_idx + 1}: {ply_path.name} ({filtered_count} points)") + pass idx += 1 - # DGCNN on saved clouds first (needed for deferred weight overlay on video) + # DGCNN on saved clouds first (needed for deferred weight overlay on video) if do_weight_estimation and kept_pointclouds: weight_output_dir = output_base / "weight_estimation" weight_output_dir.mkdir(parents=True, exist_ok=True) @@ -1767,47 +1773,28 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de remove_outliers=weight_remove_outliers, outlier_method=weight_outlier_method, max_cv_length=max_cv_length, - verbose=True, + verbose=False, top_k=weight_top_k, top_by_length=weight_top_by_length, length_switch_to_weight_mm=weight_length_switch_mm, ) - if wres is None: - print(f" WARNING: Weight estimation failed") - elif do_weight_estimation and not kept_pointclouds: - if use_pointcloud_classifier: - print(f" Warning: Weight estimation requested but no point clouds passed PointNet++ classifier") - else: - print(f" Warning: Weight estimation requested but no point clouds were saved") # Preview video after weights (so labels show mass in g, not depth mm) if not save_images: if defer_video and video_defer_buffer: - wdict: Dict[int, float] = {} - ldict: Dict[int, float] = {} + per_frame_lookup: Dict[Tuple[int, int], Tuple[float, float]] = {} wjson = output_base / "weight_estimation" / "weight_estimation_results.json" if wjson.is_file(): try: wr = json.loads(wjson.read_text(encoding="utf-8")) - summary = wr.get("summary") or {} - summary_wg = float(summary.get("avg_predicted_weight_g", float("nan"))) - summary_lmm = float(summary.get("avg_length_input_topk", float("nan"))) - all_tids: set = set() - for _e in video_defer_buffer: - _t = _e.get("track_ids") - if _t is not None: - all_tids.update(int(x) for x in np.asarray(_t).ravel()) - if np.isfinite(summary_wg): - wdict = {tid: summary_wg for tid in all_tids} - if np.isfinite(summary_lmm): - ldict = {tid: summary_lmm for tid in all_tids} - except Exception as e: - print(f" WARNING: Could not parse weight results for video overlay: {e}") + per_file_list = wr.get("per_file") or [] + per_frame_lookup = _build_per_frame_weight_lookup(per_file_list) + except Exception: + per_frame_lookup = {} finalize_preview_video_with_weights( video_defer_buffer, fps_video=10.0, - weights_by_track=wdict, - lengths_by_track_mm=ldict, + per_frame_lookup=per_frame_lookup, class_names=class_names, svo_name=svo_name, output_images_folder=output_images_folder, @@ -1821,9 +1808,6 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de for frame in video_frames: video_writer.write(frame) video_writer.release() - print(f"✓ Saved video: {video_path.name} ({len(video_frames)} frames)") - elif save_images: - print(f"✓ Saved {idx} frames as individual images") # Save tracking stats if fish_tracks: @@ -1848,7 +1832,6 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de } with open(stats_path, 'w', encoding='utf-8') as f: json.dump(tracking_data, f, indent=2) - print(f"✓ Saved tracking stats: {stats_path.name}") # Save list of point clouds kept for template matching if kept_pointclouds: @@ -1856,17 +1839,7 @@ def process_single_svo2(svo_path, output_base, yolo_model, sam_predictor, sam_de with open(pointcloud_list_path, 'w', encoding='utf-8') as f: for ply_path in kept_pointclouds: f.write(f"{ply_path}\n") - if use_pointcloud_classifier: - print(f"✓ Saved list of {len(kept_pointclouds)} point clouds that passed PointNet++ classifier: {pointcloud_list_path.name}") - else: - print(f"✓ Saved list of {len(kept_pointclouds)} point clouds for template matching: {pointcloud_list_path.name}") - else: - if use_pointcloud_classifier: - print(f" Note: PointNet++ classifier was enabled but no point clouds passed the filter") - else: - print(f" Note: No point clouds were saved") - print(f"✓ Processed {idx} frames from {svo_path.name}") return True except Exception as e: diff --git a/fish_api/app/services/action.py b/fish_api/app/services/action.py index 135d307..39ada07 100644 --- a/fish_api/app/services/action.py +++ b/fish_api/app/services/action.py @@ -74,6 +74,7 @@ def run_action_subprocess(mp4_path: Path, settings: Settings) -> str: cwd=str(settings.fish_action_root), env=os.environ.copy(), log_name="FishAction", + stream_to_logger=False, ) if proc.returncode != 0: err = proc.stdout or "" @@ -87,7 +88,7 @@ def run_action_subprocess(mp4_path: Path, settings: Settings) -> str: if not rows: raise RuntimeError("Empty prediction JSON") pred_en = str(rows[0].get("pred_3class", "")).strip().lower() - logger.info( + logger.debug( "[FishAction] prediction row:\n{}", format_json_pretty(rows[0]), ) diff --git a/fish_api/app/services/measure.py b/fish_api/app/services/measure.py index 7f21dda..c660d5e 100644 --- a/fish_api/app/services/measure.py +++ b/fish_api/app/services/measure.py @@ -102,6 +102,7 @@ def run_measure_subprocess(svo_path: Path, settings: Settings) -> None: cwd=str(settings.fish_measure_root), env=os.environ.copy(), log_name="FishMeasure", + stream_to_logger=False, ) if proc.returncode != 0: err = proc.stdout or "" @@ -581,28 +582,28 @@ def build_measure_snapshot(svo_path: Path, settings: Settings) -> MeasureSnapsho data = _load_weight_json(svo_path, settings) summary = data.get("dgcnn_summary") or data.get("weight_summary") or {} - weight_g = summary.get("avg_predicted_weight_g") - length_mm = summary.get("avg_length_input_topk") - if weight_g is None: - weight_g = data.get("avg_predicted_weight_g") - if length_mm is None: - length_mm = summary.get("avg_length_input") or data.get("avg_length_input") + result = _result_from_weight_prediction(data) - result: List[Dict[str, Any]] = [] - if weight_g is not None and length_mm is not None: - try: - w = float(weight_g) - l = float(length_mm) - if math.isfinite(w) and math.isfinite(l): - result = [{"id": 1, "weight": w, "length": l}] - except (TypeError, ValueError): - pass + if not result: + weight_g = summary.get("avg_predicted_weight_g") + length_mm = summary.get("avg_length_input_topk") + if weight_g is None: + weight_g = data.get("avg_predicted_weight_g") + if length_mm is None: + length_mm = summary.get("avg_length_input") or data.get("avg_length_input") + if weight_g is not None and length_mm is not None: + try: + w = float(weight_g) + l = float(length_mm) + if math.isfinite(w) and math.isfinite(l): + result = [{"id": 1, "weight": w, "length": l}] + except (TypeError, ValueError): + pass logger.info( - "[FishMeasure] parsed {}\navg_weight_g(top5)={} avg_length_mm(top5)={}\nresult:\n{}\ndgcnn_summary:\n{}", + "[FishMeasure] parsed {}\nresult ({} fish):\n{}\ndgcnn_summary:\n{}", svo_path.name, - weight_g, - length_mm, + len(result), format_json_pretty(result), format_json_pretty(summary if summary else {}), ) diff --git a/fish_api/app/subprocess_run.py b/fish_api/app/subprocess_run.py index c369aba..aa298a3 100644 --- a/fish_api/app/subprocess_run.py +++ b/fish_api/app/subprocess_run.py @@ -1,4 +1,4 @@ -"""子进程运行并把 stdout/stderr 流式写入 loguru,便于查看 FishMeasure / FishAction 中间输出。""" +"""子进程运行并把 stdout/stderr 合并;可选将逐行输出写入 loguru(测量/行为推理可关闭以减少日志)。""" from __future__ import annotations @@ -15,10 +15,12 @@ def run_subprocess_with_log( cwd: str, env: Optional[Dict[str, str]] = None, log_name: str, + stream_to_logger: bool = True, ) -> subprocess.CompletedProcess[str]: - """运行子进程,合并 stderr 到 stdout,按行输出到 loguru。 + """运行子进程,合并 stderr 到 stdout,可选按行输出到 loguru。 返回 CompletedProcess,stdout 为完整输出,便于失败时拼进异常信息。 + ``stream_to_logger=False`` 时不把子进程逐行写入日志(仍完整收集 stdout)。 """ proc = subprocess.Popen( cmd, @@ -33,9 +35,10 @@ def run_subprocess_with_log( if proc.stdout is not None: for line in proc.stdout: lines.append(line) - s = line.rstrip() - if s: - logger.info("[{}] {}", log_name, s) + if stream_to_logger: + s = line.rstrip() + if s: + logger.info("[{}] {}", log_name, s) rc = proc.wait() out = "".join(lines) return subprocess.CompletedProcess(cmd, rc, out, "")