From 264846886bbc4661b7730bb887ba9b0273eb1e09 Mon Sep 17 00:00:00 2001 From: harshi-puli Date: Mon, 21 Sep 2026 03:31:37 -0700 Subject: [PATCH 1/2] inital yolo changes --- perception/__init__.py | 40 +++++-- .../gate/tests/test_yolo_predict_smoke.py | 38 +++++++ .../tasks/gate/tests/test_yolo_split.py | 39 +++++++ perception/tasks/gate/yolo/README.md | 16 +++ .../tasks/gate/yolo/configs/gate_dataset.yaml | 8 ++ .../gate/yolo/configs/legacy_dataset.yaml | 8 ++ perception/tasks/gate/yolo/export_metrics.py | 58 ++++++++++ perception/tasks/gate/yolo/predict_gate.py | 62 ++++++++++ perception/tasks/gate/yolo/split_data.py | 8 ++ perception/tasks/gate/yolo/split_gate_data.py | 106 ++++++++++++++++++ 10 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 perception/tasks/gate/tests/test_yolo_predict_smoke.py create mode 100644 perception/tasks/gate/tests/test_yolo_split.py create mode 100644 perception/tasks/gate/yolo/README.md create mode 100644 perception/tasks/gate/yolo/configs/gate_dataset.yaml create mode 100644 perception/tasks/gate/yolo/configs/legacy_dataset.yaml create mode 100644 perception/tasks/gate/yolo/export_metrics.py create mode 100644 perception/tasks/gate/yolo/predict_gate.py create mode 100644 perception/tasks/gate/yolo/split_data.py create mode 100644 perception/tasks/gate/yolo/split_gate_data.py diff --git a/perception/__init__.py b/perception/__init__.py index 3361afd..d193ed6 100644 --- a/perception/__init__.py +++ b/perception/__init__.py @@ -3,11 +3,27 @@ import perception.tasks.gate.classical.GateSegmentationAlgoA as GateSegA import perception.tasks.gate.classical.GateSegmentationAlgoB as GateSegB import perception.tasks.gate.classical.GateSegmentationAlgoC as GateSegC -import perception.tasks.segmentation.saliency_detection.MBD as MBD -from perception.tasks.segmentation.COMB_SAL_BG import COMB_SAL_BG import perception.vis.TestTasks.BackgroundRemoval as BackgroundRemoval -import perception.tasks._archive.roulette.color_detection as RouletteColorDetector -from perception.tasks._archive.dice.DiceDetector import DiceDetector + +try: + import perception.tasks.segmentation.saliency_detection.MBD as MBD +except ModuleNotFoundError: + MBD = None + +try: + from perception.tasks.segmentation.COMB_SAL_BG import COMB_SAL_BG +except ModuleNotFoundError: + COMB_SAL_BG = None + +try: + import perception.tasks._archive.roulette.color_detection as RouletteColorDetector +except ModuleNotFoundError: + RouletteColorDetector = None + +try: + from perception.tasks._archive.dice.DiceDetector import DiceDetector +except ModuleNotFoundError: + DiceDetector = None ALGOS = { 'test': TestAlgo.TestAlgo, @@ -15,9 +31,17 @@ 'gatesegA': GateSegA.GateSegmentationAlgoA, 'gatesegB': GateSegB.GateSegmentationAlgoB, 'gatesegC': GateSegC.GateSegmentationAlgoC, - 'MBD': MBD.MBD, 'bg-rm': BackgroundRemoval.BackgroundRemoval, - 'combined': COMB_SAL_BG, - 'roulette': RouletteColorDetector.RouletteColorDetector, - 'dice': DiceDetector } + +if MBD is not None: + ALGOS['MBD'] = MBD.MBD + +if COMB_SAL_BG is not None: + ALGOS['combined'] = COMB_SAL_BG + +if RouletteColorDetector is not None: + ALGOS['roulette'] = RouletteColorDetector.RouletteColorDetector + +if DiceDetector is not None: + ALGOS['dice'] = DiceDetector diff --git a/perception/tasks/gate/tests/test_yolo_predict_smoke.py b/perception/tasks/gate/tests/test_yolo_predict_smoke.py new file mode 100644 index 0000000..e9fba36 --- /dev/null +++ b/perception/tasks/gate/tests/test_yolo_predict_smoke.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[4] +SAMPLE_IMAGES = REPO_ROOT / "data/gate_yolo_sample/test/images" +DEFAULT_MODEL = REPO_ROOT / "data/gate_yolo_weights/yolo11n.pt" + + +def test_yolo_model_predicts_on_sample_gate_images(tmp_path: Path) -> None: + pytest.importorskip("ultralytics") + from ultralytics import YOLO + + if not DEFAULT_MODEL.exists(): + pytest.skip(f"Missing local YOLO weights: {DEFAULT_MODEL}") + if not SAMPLE_IMAGES.exists(): + pytest.skip(f"Missing local sample images: {SAMPLE_IMAGES}") + + image_paths = sorted(SAMPLE_IMAGES.glob("*.jpg"))[:2] + if len(image_paths) < 2: + pytest.skip("Need at least two gate sample images for YOLO smoke test") + + model = YOLO(str(DEFAULT_MODEL)) + results = model.predict( + source=[str(path) for path in image_paths], + conf=0.25, + imgsz=640, + project=str(tmp_path), + name="predict", + save=True, + save_txt=True, + save_conf=True, + verbose=False, + ) + + assert len(results) == 2 + assert (tmp_path / "predict").exists() diff --git a/perception/tasks/gate/tests/test_yolo_split.py b/perception/tasks/gate/tests/test_yolo_split.py new file mode 100644 index 0000000..d20510a --- /dev/null +++ b/perception/tasks/gate/tests/test_yolo_split.py @@ -0,0 +1,39 @@ +from pathlib import Path + +from perception.tasks.gate.yolo.split_gate_data import split_dataset + + +def write_sample(source: Path, name: str) -> None: + (source / "images" / f"{name}.jpg").write_bytes(b"fake image") + (source / "labels" / f"{name}.txt").write_text("0 0.5 0.5 0.1 0.1\n") + + +def test_split_dataset_creates_yolo_train_val_test_layout(tmp_path: Path) -> None: + source = tmp_path / "generated" + output = tmp_path / "split" + (source / "images").mkdir(parents=True) + (source / "labels").mkdir() + + for index in range(10): + write_sample(source, f"sample_{index:05d}") + + counts = split_dataset(source, output, train_ratio=0.6, val_ratio=0.2, seed=7) + + assert counts == {"train": 6, "val": 2, "test": 2} + for split_name, expected_count in counts.items(): + images = list((output / split_name / "images").glob("*.jpg")) + labels = list((output / split_name / "labels").glob("*.txt")) + assert len(images) == expected_count + assert len(labels) == expected_count + + +def test_split_dataset_writes_empty_label_when_missing(tmp_path: Path) -> None: + source = tmp_path / "generated" + output = tmp_path / "split" + (source / "images").mkdir(parents=True) + (source / "labels").mkdir() + (source / "images" / "sample_00000.jpg").write_bytes(b"fake image") + + split_dataset(source, output, train_ratio=1.0, val_ratio=0.0) + + assert (output / "train" / "labels" / "sample_00000.txt").read_text() == "" diff --git a/perception/tasks/gate/yolo/README.md b/perception/tasks/gate/yolo/README.md new file mode 100644 index 0000000..9a5b76e --- /dev/null +++ b/perception/tasks/gate/yolo/README.md @@ -0,0 +1,16 @@ +# Gate YOLO + +Utilities for training, evaluating, and running YOLO gate detection models. + +## Layout + +- `configs/gate_dataset.yaml`: YOLO dataset config for the gate classes. +- `configs/legacy_dataset.yaml`: Older dataset config kept for reference. +- `split_gate_data.py`: Splits a generated `images/` + `labels/` dataset into YOLO `train/`, `val/`, and `test/` folders. +- `predict_gate.py`: Runs inference with an exported or PyTorch YOLO model. +- `export_metrics.py`: Evaluates trained models and exports metrics/ONNX artifacts. + +Local sample data and model weights live under the repo-level ignored `data/` folder: + +- `data/gate_yolo_sample/` +- `data/gate_yolo_weights/` diff --git a/perception/tasks/gate/yolo/configs/gate_dataset.yaml b/perception/tasks/gate/yolo/configs/gate_dataset.yaml new file mode 100644 index 0000000..f254f46 --- /dev/null +++ b/perception/tasks/gate/yolo/configs/gate_dataset.yaml @@ -0,0 +1,8 @@ +path: data/gate_yolo_sample + +train: train/images +val: val/images +test: test/images + +nc: 3 +names: ['gate', 'search_rescue', 'survey_repair'] diff --git a/perception/tasks/gate/yolo/configs/legacy_dataset.yaml b/perception/tasks/gate/yolo/configs/legacy_dataset.yaml new file mode 100644 index 0000000..6132461 --- /dev/null +++ b/perception/tasks/gate/yolo/configs/legacy_dataset.yaml @@ -0,0 +1,8 @@ +path: data/gate_yolo_sample + +train: train/images +val: val/images +test: test/images + +nc: 2 +names: ['reef_shark', 'sawfish'] diff --git a/perception/tasks/gate/yolo/export_metrics.py b/perception/tasks/gate/yolo/export_metrics.py new file mode 100644 index 0000000..19b6629 --- /dev/null +++ b/perception/tasks/gate/yolo/export_metrics.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import csv +import json +from pathlib import Path +from ultralytics import YOLO + +REPO_ROOT = Path(__file__).resolve().parents[4] +RUNS = REPO_ROOT / "data/gate_yolo_runs/detect" +DATAFILE = str(REPO_ROOT / "perception/tasks/gate/yolo/configs/gate_dataset.yaml") +OUT_DIR = REPO_ROOT / "data/gate_yolo_metrics" +OUT_DIR.mkdir(exist_ok=True) + +MODELS = { + "gate_nano": RUNS / "gate_nano/weights/best.pt", + "gate_small": RUNS / "gate_small/weights/best.pt", + "gate_medium": RUNS / "gate_medium/weights/best.pt", +} + +summary = [] +for name, pt in MODELS.items(): + if not pt.exists(): + print(f"[SKIP] {name} - not found") + continue + print(f"\n{'='*40}\n{name}") + model = YOLO(str(pt)) + val = model.val(data=DATAFILE, split="test", verbose=False) + info = model.info(verbose=False) + + m = { + "model": name, + "mAP50": round(float(val.box.map50), 4), + "mAP50_95": round(float(val.box.map), 4), + "precision": round(float(val.box.mp), 4), + "recall": round(float(val.box.mr), 4), + "params_M": round(info[0] / 1e6, 3) if info else -1, + "flops_G": round(info[1] / 1e9, 3) if info else -1, + } + print(json.dumps(m, indent=2)) + (OUT_DIR / f"{name}_metrics.json").write_text(json.dumps(m, indent=2)) + + model.export(format="onnx", imgsz=640, opset=12, dynamic=False) + onnx_src = pt.parent / "best.onnx" + if onnx_src.exists(): + onnx_src.rename(OUT_DIR / f"{name}.onnx") + print(f"ONNX -> {OUT_DIR}/{name}.onnx") + summary.append(m) + +if summary: + csv_path = OUT_DIR / "gate_models_summary.csv" + with open(csv_path, "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=summary[0].keys()) + w.writeheader() + w.writerows(summary) + print(f"\nSummary -> {csv_path}") + print(f"\n{'Model':<20} {'mAP50':>7} {'mAP50-95':>9} {'P':>7} {'R':>7} {'Params(M)':>10}") + for m in sorted(summary, key=lambda x: x["mAP50_95"], reverse=True): + print(f"{m['model']:<20} {m['mAP50']:>7.4f} {m['mAP50_95']:>9.4f} " + f"{m['precision']:>7.4f} {m['recall']:>7.4f} {m['params_M']:>10}") diff --git a/perception/tasks/gate/yolo/predict_gate.py b/perception/tasks/gate/yolo/predict_gate.py new file mode 100644 index 0000000..708aefd --- /dev/null +++ b/perception/tasks/gate/yolo/predict_gate.py @@ -0,0 +1,62 @@ +import argparse +from pathlib import Path +from ultralytics import YOLO + +REPO_ROOT = Path(__file__).resolve().parents[4] +DEFAULT_MODEL = REPO_ROOT / "data/gate_yolo_weights/yolo11n.pt" +DEFAULT_OUTPUT = REPO_ROOT / "data/gate_yolo_predictions" + +CLASS_NAMES = { + 0: "gate", + 1: "search_rescue", + 2: "survey_repair" +} + +def run(source, model_path, conf, output_dir, show): + print(f"Model: {model_path}") + print(f"Source: {source}") + print(f"Conf threshold: {conf}") + + model = YOLO(str(model_path)) + + results = model.predict( + source=source, + conf=conf, + imgsz=640, + save=True, + save_txt=True, + save_conf=True, + project=str(output_dir), + name="run", + exist_ok=True, + show=show, + ) + + print(f"\nResults saved to {output_dir}/run/") + print(f"\nDetections summary:") + for r in results: + if len(r.boxes) == 0: + print(f" {Path(r.path).name}: no detections") + else: + for box in r.boxes: + cls = int(box.cls[0]) + conf_score = float(box.conf[0]) + name = CLASS_NAMES.get(cls, f"class_{cls}") + print(f" {Path(r.path).name}: {name} ({conf_score:.2%} confidence)") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Gate detection inference") + parser.add_argument("--source", required=True, help="Image, video, folder, or webcam index") + parser.add_argument("--model", default=str(DEFAULT_MODEL), help="Path to .pt or .onnx model") + parser.add_argument("--conf", type=float, default=0.25, help="Confidence threshold (0-1)") + parser.add_argument("--output", default=str(DEFAULT_OUTPUT), help="Output directory") + parser.add_argument("--show", action="store_true", help="Display results in window") + args = parser.parse_args() + + run( + source=args.source, + model_path=args.model, + conf=args.conf, + output_dir=args.output, + show=args.show, + ) diff --git a/perception/tasks/gate/yolo/split_data.py b/perception/tasks/gate/yolo/split_data.py new file mode 100644 index 0000000..a9e9d24 --- /dev/null +++ b/perception/tasks/gate/yolo/split_data.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the gate YOLO dataset splitter.""" + +from perception.tasks.gate.yolo.split_gate_data import main + + +if __name__ == "__main__": + main() diff --git a/perception/tasks/gate/yolo/split_gate_data.py b/perception/tasks/gate/yolo/split_gate_data.py new file mode 100644 index 0000000..991761d --- /dev/null +++ b/perception/tasks/gate/yolo/split_gate_data.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +import argparse +import random +import shutil +from pathlib import Path + + +IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png") +DEFAULT_GENERATED_ROOT = ( + Path.home() / "random-background-data-gen/datasets/generated/gate" +) +DEFAULT_OUTPUT = Path("data/gate_yolo_sample") + + +def newest_dataset_run(generated_root: Path) -> Path: + runs = sorted(path for path in generated_root.iterdir() if path.is_dir()) + if not runs: + raise RuntimeError(f"No dataset runs found in {generated_root}") + return runs[-1] + + +def split_dataset( + source: Path, + output: Path, + train_ratio: float = 0.75, + val_ratio: float = 0.15, + seed: int = 42, +) -> dict[str, int]: + image_dir = source / "images" + label_dir = source / "labels" + all_images = sorted( + path + for path in image_dir.iterdir() + if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS + ) + if not all_images: + raise RuntimeError(f"No images found in {image_dir}") + + rng = random.Random(seed) + rng.shuffle(all_images) + + n_images = len(all_images) + train_end = int(n_images * train_ratio) + val_end = train_end + int(n_images * val_ratio) + splits = { + "train": all_images[:train_end], + "val": all_images[train_end:val_end], + "test": all_images[val_end:], + } + + for split_name in splits: + for subdir in ("images", "labels"): + split_dir = output / split_name / subdir + if split_dir.exists(): + shutil.rmtree(split_dir) + split_dir.mkdir(parents=True) + + for split_name, images in splits.items(): + for image_path in images: + label_path = label_dir / f"{image_path.stem}.txt" + shutil.copy2(image_path, output / split_name / "images" / image_path.name) + destination_label = output / split_name / "labels" / f"{image_path.stem}.txt" + if label_path.exists(): + shutil.copy2(label_path, destination_label) + else: + destination_label.write_text("") + + return {split_name: len(images) for split_name, images in splits.items()} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Split generated gate data for YOLO.") + parser.add_argument( + "--source", + type=Path, + help="Dataset folder containing images/ and labels/. Defaults to newest generated gate run.", + ) + parser.add_argument( + "--generated-root", + type=Path, + default=DEFAULT_GENERATED_ROOT, + help="Root containing generated gate dataset runs.", + ) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + parser.add_argument("--train-ratio", type=float, default=0.75) + parser.add_argument("--val-ratio", type=float, default=0.15) + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + + source = args.source or newest_dataset_run(args.generated_root) + print(f"Using: {source}") + + counts = split_dataset( + source=source, + output=args.output, + train_ratio=args.train_ratio, + val_ratio=args.val_ratio, + seed=args.seed, + ) + for split_name, count in counts.items(): + print(f" {split_name}: {count}") + print(f"\nDone -> {args.output}") + + +if __name__ == "__main__": + main() From 6b2425c77d050dc82ad6a2f2c656a31173d08433 Mon Sep 17 00:00:00 2001 From: harshi-puli Date: Mon, 21 Sep 2026 19:01:31 -0700 Subject: [PATCH 2/2] Third party and misc folder read me/guideliens --- perception/misc/README.md | 19 +++++++++++++++++++ perception/third_party/README.md | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 perception/misc/README.md create mode 100644 perception/third_party/README.md diff --git a/perception/misc/README.md b/perception/misc/README.md new file mode 100644 index 0000000..2339f63 --- /dev/null +++ b/perception/misc/README.md @@ -0,0 +1,19 @@ +# Miscellaneous Perception Experiments + +This directory contains older exploratory scripts, notebooks, calibration utilities, and experimental image-processing code that has not been promoted into a task-specific package. + +## Current Contents + +- `dark_channel/`: dark-channel dehazing and depth-map experiments used by background-removal exploration. +- `combined_filter.py` and `combinedFilTest.py`: earlier combined-filter experiments. Active task code currently uses `perception.tasks.segmentation.combinedFilter`. +- `camera_chessboard_calibration.py`: camera calibration utility. +- `optical_flow.py`, `featureGray2_higher_order_fns.py`, and notebooks: exploratory perception prototypes. +- `DiceLabels.csv`: label data for older dice detection experiments. + +## Guidelines + +- New production task code should live under `perception/tasks//`. +- Shared reusable image-processing code should live under `perception/tasks/segmentation/` or another task-owned module with tests. +- Keep notebooks and one-off experiments here only when they are still useful reference material. +- If a misc script becomes part of a task workflow, move it into that task package and update imports/tests in the same change. + diff --git a/perception/third_party/README.md b/perception/third_party/README.md new file mode 100644 index 0000000..58fca16 --- /dev/null +++ b/perception/third_party/README.md @@ -0,0 +1,16 @@ +# Third-party Code + +This directory is for vendored external code that is used by perception tasks but is not maintained as first-party project code. + +## Current Contents + +- `sort/`: vendored SORT multi-object tracker implementation, including its upstream README, license, requirements, and sample MOT data. + +## Guidelines + +- Keep each dependency in its own subdirectory. +- Preserve upstream license files and README files. +- Avoid editing vendored code unless the local change is documented. +- Prefer adding a short note here when a new third-party dependency is added, including why it is vendored instead of installed from package metadata. +- Keep large generated outputs and local datasets out of this directory. +