Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions perception/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,45 @@
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,
'gateseg': GateSeg.GateCenterAlgo,
'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
19 changes: 19 additions & 0 deletions perception/misc/README.md
Original file line number Diff line number Diff line change
@@ -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/<task>/`.
- 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.

38 changes: 38 additions & 0 deletions perception/tasks/gate/tests/test_yolo_predict_smoke.py
Original file line number Diff line number Diff line change
@@ -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()
39 changes: 39 additions & 0 deletions perception/tasks/gate/tests/test_yolo_split.py
Original file line number Diff line number Diff line change
@@ -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() == ""
16 changes: 16 additions & 0 deletions perception/tasks/gate/yolo/README.md
Original file line number Diff line number Diff line change
@@ -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/`
8 changes: 8 additions & 0 deletions perception/tasks/gate/yolo/configs/gate_dataset.yaml
Original file line number Diff line number Diff line change
@@ -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']
8 changes: 8 additions & 0 deletions perception/tasks/gate/yolo/configs/legacy_dataset.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
path: data/gate_yolo_sample

train: train/images
val: val/images
test: test/images

nc: 2
names: ['reef_shark', 'sawfish']
58 changes: 58 additions & 0 deletions perception/tasks/gate/yolo/export_metrics.py
Original file line number Diff line number Diff line change
@@ -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}")
62 changes: 62 additions & 0 deletions perception/tasks/gate/yolo/predict_gate.py
Original file line number Diff line number Diff line change
@@ -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,
)
8 changes: 8 additions & 0 deletions perception/tasks/gate/yolo/split_data.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading