diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93d93ef..1ee5b7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,12 @@ name: CI -# Temporarily disabled: repo structure is mid-consolidation (task registration, -# vis.py rewrite, test-data relocation) and CI would fail on pre-existing -# issues unrelated to any given PR. Re-enable by restoring the push/pull_request -# triggers below. Still runnable manually via workflow_dispatch in the meantime. +# Lint (ruff/black) isn't gated here yet - the repo carries a lot of +# pre-existing style debt outside what this consolidation pass touched, and +# fixing it repo-wide is a separate task from getting tests running again. +# pytest-only for now. on: + push: + pull_request: workflow_dispatch: # CI installs only the classical dependency group. Torch/YOLO/XFeat testing @@ -12,27 +14,6 @@ on: # availability are settled. jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install dependencies - run: | - pip install -r requirements-classical.txt - pip install ruff black - - - name: Lint (ruff) - run: ruff check . - - - name: Format check (black) - run: black --check . - test-classical: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 55cbe5a..8450ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.MP4 *.mp4 *.avi +*.png +*.jpg # Byte-compiled / optimized / DLL files __pycache__/ @@ -23,3 +25,6 @@ build/ .vscode/ data/ + +# uv-managed virtual environment +.venv/ diff --git a/README.md b/README.md index a657514..dbe0bb7 100644 --- a/README.md +++ b/README.md @@ -4,38 +4,42 @@ Code Quality [![CodeFactor](https://www.codefactor.io/repository/github/berkeley ## Installation -We will use Conda for managing environments. We recommend installing Miniconda for Python 3.11 [here](https://docs.conda.io/en/latest/miniconda.html). -Python 3.11 is required to support YOLO models. -Then create an environment with +We use [uv](https://docs.astral.sh/uv/) for managing both the Python version and the virtual environment. Install it once per machine: - conda create -n urobotics python=3.11 + curl -LsSf https://astral.sh/uv/install.sh | sh -activate it with +(or `brew install uv` on macOS). Full install docs [here](https://docs.astral.sh/uv/getting-started/installation/). - conda activate urobotics - -Then clone the repo in a directory of your choice +Clone the repo in a directory of your choice git clone https://github.com/berkeleyauv/perception.git + cd perception + +Create the virtual environment — this downloads and pins Python 3.11 (required to support YOLO models) if you don't already have it, and creates `.venv/` in the repo: + + uv venv --python 3.11 + +Activate it -Change into the cloned repo directory and install it + source .venv/bin/activate - pip3 install -e ./ +Install the package in editable mode + uv pip install -e . Install dependencies with - pip3 install -r requirements-classical.txt + uv pip install -r requirements-classical.txt If you also need YOLO/torch-based models, install the torch group instead (it includes everything in `requirements-classical.txt`, plus `torch`, `torchvision`, and `ultralytics`) - pip3 install -r requirements-torch.txt + uv pip install -r requirements-torch.txt Also, our training data is stored here https://www.dropbox.com/sh/rrbfqfutrmifrxs/AAAfXxlcCtWZmUELp4wXyTIxa?dl=0 so download it and unzip it in the same folder as `perception`. ### Cython -To compile cythonized code, run the following commands after `cd`ing into the folder with Cython `setup.py` +To compile cythonized code, run the following commands (with the venv activated) after `cd`ing into the folder with Cython `setup.py` python setup.py build_ext --inplace cythonize file_to_cythonize.pyx @@ -45,46 +49,48 @@ To compile cythonized code, run the following commands after `cd`ing into the fo Misc code, camera calibration etc. ## tasks: -Code for specific tasks like +Code for specific competition tasks, one folder per task: -1. cross: cross detection -2. segmentation: -3. path_marker: path_marker detection -4. spinny_wheel_detection - -etc +1. `gate`: qualification gate detection (`classical/`) and orientation estimation (`orientation/`) +2. `slalom`: slalom pipe-set detection (`classical/`) and pipe-set sequence tracking (`slalom_sequence_tracker.py`) +3. `path_marker`: path marker detection +4. `buoy`, `torpedo`, `octagon`: scaffolded, no detection logic yet +5. `_archive`: retired tasks (cross, dice, roulette, slots) kept for reference, excluded from registry discovery In order to create your own algorithm to test: -1. Create .py and put it in one of the specific task folders in perception/tasks. +1. Create `.py` and put it in the relevant task folder under `perception/tasks/` (e.g. a new classical approach goes in that task's `classical/`). + +2. Create a class which extends `TaskPerceiver` (see `perception/tasks/TaskPerceiver.py` for the template with documentation) and decorate it with `@register_perceiver(task=..., algo=...)` from `perception/tasks/registry.py`. This is what makes it discoverable by `vis.py` — see the **vis** section below. -2. Create a class which extends the TaskPerceiver class. perception/tasks/TaskPerceiver.py includes a template with documentation for how to do this. +3. Pass `default=True` to `@register_perceiver` if this should be the algo `vis.py` runs for its task when `--algo` is omitted. A task with only one registered algo defaults to it automatically; a task with several needs one of them explicitly marked (currently `segmentation_a` for `gate`, and the sole algo for `slalom`/`path_marker`). Registering a second `default=True` algo for the same task raises an error. ## vis: -Visualization tools -Code for testing tasks (Ideally this should be placed a separate folder called `tests`). - -After writing the code for your specific task algorithm, you can do one of two things: - -1. Add this to the end of .py file: - - if __name__ == '__main__': - from perception.vis.vis import run - run(, , ) - and then run - - python .py -2. Add this to the perception/__init__.py file: - - import - - ALGOS = { - 'custom_name': . - } - and then run - - python perception/vis/vis.py --algorithm custom_name [--data ] [--profile ] [--save_video] - The **algorithm** parameter is required. If **data** isn't specified, it'll default to your webcam. If **profile** isn't specified, it will be off by default. Add the **save_video** tag if you want to save your vis test as an mp4 file. +Visualization tools for interactively running and debugging task algorithms. + +Every algorithm is a `TaskPerceiver` subclass (see `perception/tasks/TaskPerceiver.py`) decorated with `@register_perceiver(task=..., algo=...)`. Decorating a class is all that's needed to make it discoverable — there's no shared file to hand-edit: + + from perception.tasks.registry import register_perceiver + from perception.tasks.TaskPerceiver import TaskPerceiver + + @register_perceiver(task="gate", algo="my_algo") + class MyAlgo(TaskPerceiver): + ... + +Then run it with: + + python -m perception.vis.vis --task gate [--algo my_algo] [--data ] [--profile ] [--save_video] [--resize ] [--compare ] [--hide_labels] + +- `--task` is required and selects which task's perceivers to run. +- `--algo` is optional — omit it to use the task's default algo (see point 3 above); `vis.py` prints which algo it picked. If the task has no default set, it errors and asks you to pass `--algo` explicitly. +- `--data` defaults to your webcam; point it at an image, video, or a directory of either. +- `--profile` is off by default; pass a `cProfile` stats key (or omit for `'all'`) to profile the run. +- `--save_video` writes the debug-frame grid to `vis_rec.mp4`. +- `--resize` scales every frame before display (default `1.0`, no resize). +- `--compare ` runs a second algo for the same `--task` on the same frames and stacks it below the primary algo's grid in one "Debug Frames" window, each half labeled with its algo name in the top-left corner — useful for A/B'ing two algorithms (e.g. `center` vs. `segmentation_a` for `gate`) against the same footage. Stacking below (rather than beside) keeps each pane's width unchanged, so sub-frame resolution and label/slider text stay legible regardless of how many debug frames either algo returns. Sliders for both algos appear in the same window, prefixed with their algo name (e.g. `center: canny_low`) to keep them distinguishable. `--save_video` saves the combined, labeled view. +- `--hide_labels` drops the corner labels in `--compare` mode (shown by default). + +While a window is focused: `q`/`Esc` quits, `p` pauses, `i`/`o` slow down/speed up frame playback. ## wiki: Flowchart on TaskPerceiver, TaskReceiver, AlgorithmRunner. diff --git a/perception/tasks/_archive/slot_machine/slot_machine_test.py b/perception/tasks/_archive/slot_machine/slot_machine_demo.py similarity index 100% rename from perception/tasks/_archive/slot_machine/slot_machine_test.py rename to perception/tasks/_archive/slot_machine/slot_machine_demo.py diff --git a/perception/tasks/gate/classical/GateSegmentationAlgoA.py b/perception/tasks/gate/classical/GateSegmentationAlgoA.py index e59411b..5ae8510 100644 --- a/perception/tasks/gate/classical/GateSegmentationAlgoA.py +++ b/perception/tasks/gate/classical/GateSegmentationAlgoA.py @@ -8,15 +8,14 @@ from perception.tasks.TaskPerceiver import TaskPerceiver -@register_perceiver(task="gate", algo="segmentation_a") +@register_perceiver(task="gate", algo="segmentation_a", default=True) class GateSegmentationAlgoA(TaskPerceiver): center_x_locs, center_y_locs = [], [] def __init__(self): super().__init__() self.combined_filter = init_combined_filter() - - # TODO: fix return typing + def analyze(self, frame: np.ndarray, debug: bool, slider_vals=None) -> Tuple[float, float]: """Takes in the background removed image and returns the center between the two gate posts. diff --git a/perception/tasks/registry.py b/perception/tasks/registry.py index 1306e1c..793dd30 100644 --- a/perception/tasks/registry.py +++ b/perception/tasks/registry.py @@ -22,10 +22,15 @@ from perception.tasks.TaskPerceiver import TaskPerceiver _REGISTRY: dict[tuple[str, str], type[TaskPerceiver]] = {} +_DEFAULTS: dict[str, str] = {} -def register_perceiver(task: str, algo: str): - """Class decorator: register a TaskPerceiver subclass under (task, algo).""" +def register_perceiver(task: str, algo: str, default: bool = False): + """Class decorator: register a TaskPerceiver subclass under (task, algo). + + Pass default=True to make this the algo get_default_algo() returns for + `task` when a caller (e.g. the vis CLI) doesn't specify one explicitly. + """ def decorator(cls: type[TaskPerceiver]) -> type[TaskPerceiver]: key = (task, algo) @@ -37,6 +42,14 @@ def decorator(cls: type[TaskPerceiver]) -> type[TaskPerceiver]: f"register {cls.__module__}.{cls.__qualname__}" ) _REGISTRY[key] = cls + if default: + existing_default = _DEFAULTS.get(task) + if existing_default is not None and existing_default != algo: + raise ValueError( + f"task={task!r} already has default algo {existing_default!r}, " + f"cannot also mark {algo!r} as default" + ) + _DEFAULTS[task] = algo return cls return decorator @@ -62,6 +75,22 @@ def list_algos(task: str) -> list[str]: return sorted(algo for t, algo in _REGISTRY if t == task) +def get_default_algo(task: str) -> str: + """The default algo for a task: whichever was marked default=True, or the + sole registered algo if the task only has one. Raises KeyError if neither + applies, i.e. the caller must specify an algo explicitly. + """ + explicit = _DEFAULTS.get(task) + if explicit is not None: + return explicit + algos = list_algos(task) + if len(algos) == 1: + return algos[0] + raise KeyError( + f"no default algo for task={task!r} (algos: {', '.join(algos) or 'none registered'})" + ) + + _EXCLUDED_PACKAGES = ("perception.tasks._archive",) diff --git a/perception/vis/Visualizer.py b/perception/vis/Visualizer.py index 65c7bb2..2d6c947 100644 --- a/perception/vis/Visualizer.py +++ b/perception/vis/Visualizer.py @@ -8,14 +8,23 @@ def nothing(x): pass class Visualizer: - def __init__(self, kwargs: Dict[str, Tuple[Tuple[int, int], int]]): + def __init__(self, kwargs: Dict[str, Tuple[Tuple[int, int], int]], window_name: str = 'Debug Frames', label: str = None): + """ + label: when set, prefixes this instance's trackbar names with it (e.g. + "center: canny_low"). Needed when two Visualizers share a window_name + (compare mode) so trackbars for the same variable name in two + different algos don't collide. + """ self.variables = kwargs.keys() - cv.namedWindow('Debug Frames') + self.window_name = window_name + self._trackbar_names = {name: (f'{label}: {name}' if label else name) for name in self.variables} + cv.namedWindow(self.window_name) for name, info in kwargs.items(): slider_range, default_val = info low_range, high_range = slider_range - cv.createTrackbar(name, 'Debug Frames', low_range, high_range, nothing) - cv.setTrackbarPos(name, 'Debug Frames', default_val) + trackbar_name = self._trackbar_names[name] + cv.createTrackbar(trackbar_name, self.window_name, low_range, high_range, nothing) + cv.setTrackbarPos(trackbar_name, self.window_name, default_val) def three_stack(self, frames: List[np.ndarray]) -> List[np.ndarray]: newLst = [] @@ -75,5 +84,5 @@ def display(self, frames: List[np.ndarray]) -> np.ndarray: def update_vars(self) -> Dict[str, int]: variable_values = {} for var in self.variables: - variable_values[var] = cv.getTrackbarPos(var, 'Debug Frames') + variable_values[var] = cv.getTrackbarPos(self._trackbar_names[var], self.window_name) return variable_values \ No newline at end of file diff --git a/perception/vis/vis.py b/perception/vis/vis.py index 4647f8d..545da6f 100644 --- a/perception/vis/vis.py +++ b/perception/vis/vis.py @@ -1,18 +1,78 @@ import argparse import cProfile import os +import sys import cv2 as cv import imageio +import numpy as np from perception.tasks import registry from perception.vis.FrameWrapper import FrameWrapper from perception.vis.Visualizer import Visualizer -def run(data_sources, algorithm, save_video=False, resize=0.15): +def _colorize(text, code, stream): + # Skip escape codes when the stream isn't a terminal (piped/redirected + # output, log files) - raw codes there would just show up as garbage. + if not stream.isatty(): + return text + return f"\033[{code}m{text}\033[0m" + + +def _yellow(text): + return _colorize(text, "33", sys.stdout) + + +def _red(text): + return _colorize(text, "31", sys.stderr) + + +def _analyze(algorithm, window_builder, frame): + if algorithm.kwargs: + state, debug_frames = algorithm.analyze(frame, debug=True, slider_vals=window_builder.update_vars()) + else: + state, debug_frames = algorithm.analyze(frame, debug=True) + return state, window_builder.display(debug_frames) + + +def _label_frame(frame, text): + # Burns a readable label into the top-left corner of a copy of frame - + # used to tell the two halves of a --compare frame apart, since they + # share one window instead of getting one each. + labeled = frame.copy() + font, scale, thickness = cv.FONT_HERSHEY_SIMPLEX, 0.6, 2 + (text_w, text_h), baseline = cv.getTextSize(text, font, scale, thickness) + pad = 6 + cv.rectangle(labeled, (0, 0), (text_w + 2 * pad, text_h + baseline + 2 * pad), (0, 0, 0), -1) + cv.putText(labeled, text, (pad, text_h + pad), font, scale, (0, 255, 0), thickness, cv.LINE_AA) + return labeled + + +def _stack_compare(primary, compare): + # Stack the compare grid below the primary one rather than beside it. + # An algo's own debug grid is often already multi-column (2+ debug + # frames), so putting two of those side-by-side doubles the window's + # width - vstack instead keeps each pane's width (and thus its sub-frame + # resolution and label/slider text size) unchanged. + if primary.shape[1] != compare.shape[1]: + target_w = primary.shape[1] + scale = target_w / compare.shape[1] + compare = cv.resize(compare, (target_w, int(compare.shape[0] * scale))) + return np.vstack((primary, compare)) + + +def run(data_sources, algorithm, save_video=False, resize=0.15, compare_algorithm=None, + algo_label=None, compare_label=None, show_labels=True): out = None - window_builder = Visualizer(algorithm.kwargs) + window_name = 'Debug Frames' + compare_mode = compare_algorithm is not None + # In compare mode both algos share one window, so their trackbars need + # distinct names (label prefix) in case they use the same variable name. + window_builder = Visualizer(algorithm.kwargs, window_name=window_name, label=algo_label if compare_mode else None) + compare_window_builder = None + if compare_mode: + compare_window_builder = Visualizer(compare_algorithm.kwargs, window_name=window_name, label=compare_label) data = FrameWrapper(data_sources, resize) frame_count = 0 speed = 1 @@ -20,18 +80,19 @@ def run(data_sources, algorithm, save_video=False, resize=0.15): quit_requested = False for frame in data: if frame_count % speed == 0: - if algorithm.kwargs: - state, debug_frames = algorithm.analyze(frame, debug=True, slider_vals=window_builder.update_vars()) - else: - state, debug_frames = algorithm.analyze(frame, debug=True) + _, to_show = _analyze(algorithm, window_builder, frame) + if compare_mode: + _, compare_to_show = _analyze(compare_algorithm, compare_window_builder, frame) + if show_labels: + to_show = _label_frame(to_show, algo_label or 'primary') + compare_to_show = _label_frame(compare_to_show, compare_label or 'compare') + to_show = _stack_compare(to_show, compare_to_show) + cv.imshow(window_name, to_show) - to_show = window_builder.display(debug_frames) - cv.imshow('Debug Frames', to_show) if save_video: if out is None: out = imageio.get_writer('vis_rec.mp4') - out_img = cv.cvtColor(to_show, cv.COLOR_BGR2RGB) - out.append_data(out_img) + out.append_data(cv.cvtColor(to_show, cv.COLOR_BGR2RGB)) frame_count += 1 key = cv.waitKey(30) @@ -65,10 +126,10 @@ def run(data_sources, algorithm, save_video=False, resize=0.15): out.close() -def profile(*args, stats='all'): +def profile(*args, stats='all', **kwargs): pr = cProfile.Profile() pr.enable() - run(*args) + run(*args, **kwargs) pr.disable() if stats == 'all': pr.print_stats() @@ -83,9 +144,30 @@ def profile(*args, stats='all'): parser.add_argument( '--task', type=str, required=True, help='e.g. slalom, gate, path_marker' ) - parser.add_argument('--algo', type=str, required=True, help='e.g. classical') + parser.add_argument( + '--algo', + default=None, + type=str, + help='e.g. classical. If omitted, uses the task\'s default algo ' + '(its sole registered algo, or whichever was marked default=True).', + ) + parser.add_argument( + '--compare', + default=None, + type=str, + help='Second algo for the same --task, e.g. classical. Runs it on the ' + 'same frames and stacks it below the primary algo (each ' + 'corner-labeled with its algo name) in one window, for direct ' + 'comparison.', + ) parser.add_argument('--profile', default=None, type=str) parser.add_argument('--save_video', action='store_true') + parser.add_argument( + '--hide_labels', + action='store_true', + help='Hide the corner labels that identify each pane in --compare mode ' + '(shown by default).', + ) parser.add_argument( "--resize", default=1.0, @@ -97,15 +179,34 @@ def profile(*args, stats='all'): # Discover every @register_perceiver in perception.tasks, then look up the # requested one. No shared file needs hand-editing to add a new algorithm. registry.discover_all() + + algo_name = args.algo + if algo_name is None: + try: + algo_name = registry.get_default_algo(args.task) + except KeyError as exc: + raise SystemExit(_red(f"{exc}. Pass --algo explicitly.")) from None + print(_yellow(f"No --algo given, using default for task {args.task!r}: {algo_name}")) + try: - algorithm = registry.get_perceiver(args.task, args.algo)() + algorithm = registry.get_perceiver(args.task, algo_name)() except KeyError as exc: available = ", ".join( f"{task}/{algo}" for task in registry.list_tasks() for algo in registry.list_algos(task) ) - raise SystemExit(f"{exc}. Available: {available}") from None + raise SystemExit(_red(f"{exc}. Available: {available}")) from None + + compare_algorithm = None + if args.compare is not None: + try: + compare_algorithm = registry.get_perceiver(args.task, args.compare)() + except KeyError as exc: + available = ", ".join(registry.list_algos(args.task)) + raise SystemExit( + _red(f"{exc}. Available algos for task {args.task!r}: {available}") + ) from None # Initialize image source # detects args.data, get a list of all file directory when given a directory @@ -116,8 +217,13 @@ def profile(*args, stats='all'): data_sources = [args.data] if args.profile is None: - run(data_sources, algorithm, args.save_video, args.resize) + run( + data_sources, algorithm, args.save_video, args.resize, compare_algorithm, + algo_label=algo_name, compare_label=args.compare, show_labels=not args.hide_labels, + ) else: profile( - data_sources, algorithm, args.save_video, args.resize, stats=args.profile + data_sources, algorithm, args.save_video, args.resize, compare_algorithm, + algo_label=algo_name, compare_label=args.compare, show_labels=not args.hide_labels, + stats=args.profile, ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..91075c7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "perception" +version = "0.0.1" +description = "Perception algorithms for our autonomous submarine" +readme = "README.md" +requires-python = ">=3.11" +authors = [{ name = "Underwater Robotics at Berkeley" }] + +[tool.setuptools.packages.find] +include = ["perception*"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 8b214be..0000000 --- a/setup.py +++ /dev/null @@ -1,17 +0,0 @@ -import setuptools - -with open("README.md", "r") as fh: - long_description = fh.read() - -print(setuptools.find_packages()) - -setuptools.setup( - name="perception", - version="0.0.1", - author="Underwater Robotics at Berkeley", - description="Perception algorithms for our autonomous submarine", - long_description=long_description, - long_description_content_type="text/markdown", - packages=setuptools.find_packages(), - python_requires='>=3.11', -)