# interactive_pipe > Turn plain python processing functions into interactive GUI apps with sliders — Qt, matplotlib, Jupyter or Gradio. interactive_pipe turns plain python processing functions into interactive GUI pipelines (Qt, matplotlib, Jupyter widgets, Gradio) using two decorators. Filters declare widget-bound keyword arguments; the pipeline function chains them and is analyzed statically, so its body must contain only filter calls. # Getting started # interactive_pipe **Turn plain python processing functions into an interactive GUI app — without writing a single line of GUI code.** ``` pip install interactive-pipe ``` - Develop an algorithm while debugging visually with plots, checking robustness and continuity to parameter changes. - Magically create a graphical interface to demonstrate a concept or tune your algorithm. ## How it works Decorate your processing functions with `@interactive()` to declare which keyword arguments become sliders, checkboxes or dropdowns. Chain them in a pipeline function decorated with `@interactive_pipeline(gui="qt")`. Calling the pipeline opens the GUI. ``` from interactive_pipe import interactive, interactive_pipeline import numpy as np @interactive(coeff=(1.0, [0.5, 2.0], "exposure")) def exposure(img, coeff=1.0): return img * coeff @interactive_pipeline(gui="qt") def my_pipeline(img): exposed = exposure(img) return exposed my_pipeline(np.array([0.0, 0.5, 0.8]) * np.ones((256, 512, 3))) ``` Head to the [Quickstart](https://balthazarneveu.github.io/interactive_pipe/getting-started/quickstart/index.md) for the full walkthrough, the [decorators guide](https://balthazarneveu.github.io/interactive_pipe/guide/decorators/index.md), or the [API reference](https://balthazarneveu.github.io/interactive_pipe/api/controls/index.md). ## Who is this for? **🎓 Scientific education** - Demonstrate concepts by interacting with curves and images. - Easy integration in Jupyter notebooks (works on Google Colab). **🎁 DIY hobbyists** - The declarative style makes a graphical interface in a few lines of code. - For instance: a [jukebox for a toddler](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/jukebox_demo.py) on a Raspberry Pi. **📷 Engineering (computer vision, image/signal processing)** - Make small experiments with visual checks while prototyping an algorithm or testing a neural network — and share a demo anyone on your team can play with. - Tune your algorithms with a GUI and save parameters for later batch processing. - The processing engine also runs without a GUI (headless), so the same code serves tuning *and* batch processing. - Keep your algorithm library untouched: interactivity is added by decoration, not by rewriting. ## Features - Modular multi-image processing filters with a declarative GUI: sliders, checkboxes, dropdowns, text prompts, image buttons, circular sliders. - Four backends: Qt, matplotlib, Jupyter widgets (`nb`), Gradio — plus headless mode ([backend matrix](https://balthazarneveu.github.io/interactive_pipe/getting-started/backends/index.md)). - Caching of intermediate results in RAM for much faster interaction. - [`KeyboardControl`](https://balthazarneveu.github.io/interactive_pipe/guide/keyboard/index.md): update values on key press instead of a slider. - [Curve plots (2D signals)](https://balthazarneveu.github.io/interactive_pipe/guide/curves/index.md), [table outputs](https://balthazarneveu.github.io/interactive_pipe/guide/tables/index.md) and [audio support](https://balthazarneveu.github.io/interactive_pipe/guide/audio/index.md). - [`TimeControl`](https://balthazarneveu.github.io/interactive_pipe/api/controls/index.md): play/pause an incrementing timer for animations. - [Context API](https://balthazarneveu.github.io/interactive_pipe/guide/context/index.md): share state across filters via the `context` and `events` proxies; arrange the display with [`layout`](https://balthazarneveu.github.io/interactive_pipe/guide/layout/index.md). - [Panel system](https://balthazarneveu.github.io/interactive_pipe/guide/panels/index.md): group controls into nested, collapsible, detachable panels. - MIT license. ## Agent-friendly docs Coding agents can fetch the whole documentation in one shot: - [`llms.txt`](https://balthazarneveu.github.io/interactive_pipe/llms.txt) — curated index - [`llms-full.txt`](https://balthazarneveu.github.io/interactive_pipe/llms-full.txt) — full documentation, including the API reference # Backends Select the backend with the `gui=` argument of `@interactive_pipeline` (or `@interact`): a string, a `Backend` enum value, or `None` for headless mode. ``` @interactive_pipeline(gui="qt") # PyQt / PySide window @interactive_pipeline(gui="mpl") # matplotlib figure @interactive_pipeline(gui="nb") # Jupyter notebook widgets (works on Colab) @interactive_pipeline(gui="gradio") # web app (add share_gradio_app=True to share it) @interactive_pipeline(gui="auto") # best available backend @interactive_pipeline(gui=None) # headless: returns a HeadlessPipeline ``` ## Feature matrix | ⭐ | *PyQt / PySide* | *Matplotlib* | *Jupyter notebooks incl. Google Colab* | *Gradio* | | ------------------------------- | --------------- | ------------ | -------------------------------------- | -------- | | Backend name | `qt` | `mpl` | `nb` | `gradio` | | Preview | | | | | | Plot curves | ✅ | ✅ | ✅ | ✅ | | Change layout | ✅ | ✅ | ✅ | ➖ | | Keyboard shortcuts / fullscreen | ✅ | ✅ | ➖ | ➖ | | Audio support | ✅ | ➖ | ➖ | ✅ | | Image buttons | ✅ | ➖ | ➖ | ➖ | | Circular slider | ✅ | ➖ | ➖ | ➖ | | Collapsible panels | ✅ | ➖ | ➖ | ✅ | | Animations | ✅ | ➖ | ➖ | ➖ | | Panels with sliders | ✅ | ➖ | ➖ | ✅ | | Tables | ✅ | ✅ | ✅ | ✅ | Controls degrade gracefully: for example a [`KeyboardControl`](https://balthazarneveu.github.io/interactive_pipe/guide/keyboard/index.md) maps back to a regular slider on the gradio and notebook backends, and a `CircularControl` falls back to a straight slider outside Qt. ## Headless `gui=None` (or `"headless"`) skips the GUI entirely and returns a `HeadlessPipeline`: the same pipeline code serves interactive tuning and batch processing, and it is what you want in unit tests. See the [Quickstart](https://balthazarneveu.github.io/interactive_pipe/getting-started/quickstart/#headless-mode-same-code-no-gui). # Installation ``` pip install interactive-pipe ``` The core package depends only on numpy, matplotlib, Pillow and PyYAML — the matplotlib backend works out of the box. ## Optional extras | Extra | Installs | When you need it | | ---------- | ---------------------------------------------------- | ---------------------------------------------------- | | `qt6` | PyQt6 | The Qt backend (`gui="qt"`), richest feature set | | `qt5` | PyQt5 | Qt backend on systems without Qt6 support | | `notebook` | ipywidgets | The Jupyter backend (`gui="nb"`), incl. Google Colab | | `full` | Qt6 + OpenCV + ipywidgets + pandas + gradio + pytest | Everything | ``` pip install "interactive-pipe[qt6]" # Qt backend pip install "interactive-pipe[notebook]" # Jupyter / Colab pip install "interactive-pipe[full]" # everything ``` Gradio (`gui="gradio"`) needs `pip install gradio` (included in `full`). ## From source ``` git clone git@github.com:balthazarneveu/interactive_pipe.git cd interactive_pipe pip install -e ".[full]" ``` ## Tested platforms - ✅ Linux (Ubuntu / KDE Neon) - ✅ Raspberry Pi - ✅ Google Colab (use `gui="nb"`) # Quickstart Let's define three very basic image processing filters — `exposure`, `black_and_white` and `blend` — and turn them into a GUI application. By design: - image buffer inputs are positional arguments, - keyword arguments are the parameters that can be turned into interactive widgets, - output buffers are simply returned, like in a regular function. The `@interactive()` decorator declares which parameters become widgets. Parameters declared as a **tuple/list** in the decorator become sliders, tick boxes or dropdown menus: `@interactive(param=(default, [min, max], name))` creates a float slider, for instance. Finally, the glue combining the filters is the pipeline function. Decorating it with `@interactive_pipeline(gui="qt")` means calling it magically opens a GUI-powered image processing pipeline. ``` from interactive_pipe import interactive, interactive_pipeline import numpy as np @interactive( coeff=(1., [0.5, 2.], "exposure"), bias=(0., [-0.2, 0.2]) ) def exposure(img, coeff=1., bias=0.): """Multiplies by coeff & adds a constant bias to the image""" # In the GUI, coeff is labelled "exposure". The bias tuple has no # trailing string, so its widget is named after the keyword arg. return img * coeff + bias @interactive(bnw=(True, "black and white")) def black_and_white(img, bnw=True): """Averages the 3 color channels (Black & White) if bnw=True""" # Booleans: a tuple like (True,) creates the tick box. return np.repeat(np.expand_dims(np.average(img, axis=-1), -1), img.shape[-1], axis=-1) if bnw else img @interactive(blend_coeff=(0.5, [0., 1.])) def blend(img0, img1, blend_coeff=0.5): """Blends between two images. - blend_coeff=0 -> image 0 [slider to the left ] - blend_coeff=1 -> image 1 [slider to the right] """ return (1 - blend_coeff) * img0 + blend_coeff * img1 # you can change the backend to mpl instead of qt here. @interactive_pipeline(gui="qt", size="fullscreen") def sample_pipeline(input_image): exposed = exposure(input_image) bnw_image = black_and_white(input_image) blended = blend(exposed, bnw_image) return exposed, blended, bnw_image if __name__ == '__main__': input_image = np.array([0., 0.5, 0.8]) * np.ones((256, 512, 3)) sample_pipeline(input_image) ``` ❤️ This code displays a GUI with three images; the middle one is the result of the blend. Pipeline functions contain only filter calls The pipeline function body is analyzed statically to build the execution graph, so it may contain **only filter calls, assignments and a return** — no if/for/while, no arithmetic. Put that logic inside the filters. Notes: - With a bare `@interactive()` and `def blend(img0, img1, blend_coeff=0.5):`, `blend_coeff` simply won't get a slider. - `@interactive(blend_coeff=[0., 1.])` creates a slider initialized to the middle of the range (0.5). - `@interactive(bnw=KeyboardControl(True, keydown="k"))` replaces the checkbox by a keypress event (press `K` to toggle) — see the [Keyboard guide](https://balthazarneveu.github.io/interactive_pipe/guide/keyboard/index.md). ## Keeping your library clean You may not want interactive_pipe imported in your algorithm library at all. Keep the filters as plain functions and add interactivity from a separate file: ``` # image_filters.py — your library, no interactive_pipe import import numpy as np def exposure(img, coeff=1., bias=0.): return coeff * img + bias def black_and_white(img, bnw=False): return np.repeat(np.expand_dims(np.average(img, axis=-1), -1), img.shape[-1], axis=-1) if bnw else img def blend(img0, img1, blend_coeff=0.): return (1 - blend_coeff) * img0 + blend_coeff * img1 ``` ``` # app.py — interactivity lives here import numpy as np from image_filters import exposure, black_and_white, blend from interactive_pipe import interactive, interactive_pipeline, Control interactive( coeff=Control(1., [0.5, 2.], name="exposure"), bias=Control(0., [-0.2, 0.2], name="offset expo"), )(exposure) interactive(bnw=Control(False, name="Black and White"))(black_and_white) interactive(blend_coeff=Control(0., [0., 1.], name="blend coefficient"))(blend) @interactive_pipeline(gui="qt") def sample_pipeline(input_image): exposed = exposure(input_image) bnw_image = black_and_white(input_image) blended = blend(exposed, bnw_image) return exposed, blended, bnw_image sample_pipeline(np.array([0., 0.5, 0.8]) * np.ones((512, 256, 3))) ``` ## Headless mode: same code, no GUI The engine underneath the GUI is a `HeadlessPipeline` — use it for batch processing or tests: ``` from interactive_pipe import pipeline # alias of interactive_pipeline @interactive_pipeline(gui=None) # or gui="headless" def sample_pipeline(input_image): exposed = exposure(input_image) bnw_image = black_and_white(input_image) blended = blend(exposed, bnw_image) return exposed, blended, bnw_image exposed, blended, bnw_image = sample_pipeline(input_image, bnw=True, blend_coeff=0.5) sample_pipeline.export_tuning("my_tuning.yaml") # save parameters... sample_pipeline.load_tuning() # ...and reload them later ``` The exported YAML keeps one section per filter, so the parameters you tuned in the GUI feed your batch runs. ## Next steps - [Backends](https://balthazarneveu.github.io/interactive_pipe/getting-started/backends/index.md) — pick between Qt, matplotlib, Jupyter and Gradio. - [Controls](https://balthazarneveu.github.io/interactive_pipe/guide/controls/index.md) — all the widget declaration patterns. - [Context & events](https://balthazarneveu.github.io/interactive_pipe/guide/context/index.md) and [Layout](https://balthazarneveu.github.io/interactive_pipe/guide/layout/index.md) — share state between filters and control the display. # Guide # Audio Audio is not returned from filters — it is driven by the `audio` proxy as a side effect, typically reacting to a control: ``` from interactive_pipe import audio, interactive @interactive(song=(["silence", "elephant", "snail"])) def choose_song(img, song="silence"): if song == "silence": audio.stop() else: audio.set(f"tracks/{song}.mp4") audio.play() return img ``` - `audio.set(path)` registers the file, `audio.play()` / `audio.pause()` / `audio.stop()` control playback. - Supported on the **Qt** backend (this is how the [Raspberry Pi jukebox](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/jukebox_demo.py) works) and on **Gradio**. - On Gradio you can additionally return 1D numpy arrays from filters to display audio players. - Outside a GUI (headless), the audio calls are silent no-ops, so the same filter stays batch-safe. ## API Details: [the `audio` proxy](https://balthazarneveu.github.io/interactive_pipe/api/context/index.md). # Context & events Filters share state with each other through the `context` proxy and react to GUI key presses through the read-only `events` proxy, both importable from `interactive_pipe`. `global_params` was removed in 0.9.0 The pre-0.9.0 pattern of declaring `global_params={}` (or `context`, `state`, ...) in a filter signature now raises `TypeError` at filter construction. The proxies replace it entirely — see the [changelog](https://balthazarneveu.github.io/interactive_pipe/changelog/index.md) for the migration guide. ## `context` — share data between filters ``` from interactive_pipe import interactive, context @interactive(color_choice=["red", "green", "blue", "gray"]) def generate_flat_colored_image(color_choice="red"): flat_array = COLOR_DICT[color_choice] * np.ones((64, 64, 3)) context["avg"] = np.average(flat_array) # dict-style context.avg = np.average(flat_array) # or attribute-style return flat_array def special_image_slice(img): out_img = img.copy() if context["avg"] > 0.4: # read what another filter stored out_img[out_img.shape[0] // 2:, ...] = 0. return out_img ``` `get_context()` returns the underlying shared dictionary directly. You can seed it at pipeline construction: `@interactive_pipeline(gui="qt", context={"avg": 0.0})`. ## `events` — key-bound one-shot flags Read-only flags raised by GUI key bindings for a single pipeline run — see [Keyboard](https://balthazarneveu.github.io/interactive_pipe/guide/keyboard/#key-bound-one-shot-events). ## API Full reference: [Context & layout](https://balthazarneveu.github.io/interactive_pipe/api/context/index.md). # Controls A control binds a GUI widget to a filter keyword argument. Declare controls in the `@interactive()` decorator; the widget type is inferred from the default value's type and the presence of a range. ## Two equivalent styles The same control can always be written as a **tuple shorthand** — great for prototyping — or as an **explicit object** — clearer in larger codebases and required for the extra arguments (`step`, `icons`, `tooltip`, ...): ``` from interactive_pipe import interactive @interactive(gain=(1.0, [0.5, 2.0], "exposure")) def exposure(img, gain=1.0): return img * gain ``` The tuple order is `(default, range, name, group)` — trailing elements are optional. ``` from interactive_pipe import interactive, Control @interactive(gain=Control(1.0, [0.5, 2.0], name="exposure")) def exposure(img, gain=1.0): return img * gain ``` `Control` unlocks every option: `Control(1.0, [0.5, 2.0], name="exposure", step=0.05, group="Panel A", tooltip="scene brightness")`. Even shorter A bare range works too: `@interactive(blend_coeff=[0., 1.])` creates a slider initialized to the middle of the range, and `@interactive(mode=["dark", "light"])` a dropdown defaulting to the first choice. Specialized widgets (keyboard, circular slider, timer) only exist as explicit objects. ## Declaration patterns | You want | Tuple shorthand | Explicit object | | -------------------------------------------------------------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------- | | Float slider | `gain=(1.0, [0.5, 2.0])` | `gain=Control(1.0, [0.5, 2.0])` | | ... with a custom label | `gain=(1.0, [0.5, 2.0], "exposure")` | `gain=Control(1.0, [0.5, 2.0], name="exposure")` | | Int slider (step 1) | `size=(5, [1, 15])` | `size=Control(5, [1, 15])` | | Checkbox | `flip=(True,)` | `flip=Control(True)` | | Dropdown | `mode=("light", ["dark", "light"])` | `mode=Control("light", ["dark", "light"])` | | Dropdown, first choice as default | `mode=["dark", "light"]` | — | | Free text box | `txt=("Hello world!", None)` | `txt=TextPrompt("Hello world!")` | | Grouped in a panel | `gain=(1.0, [0.5, 2.0], "gain", "Panel A")` | `gain=Control(1.0, [0.5, 2.0], group="Panel A")` | | Key-driven value ([Keyboard](https://balthazarneveu.github.io/interactive_pipe/guide/keyboard/index.md)) | — | `idx=KeyboardControl(0, [0, 2], keydown="pagedown", keyup="pageup", modulo=True)` | | Circular slider (Qt) | — | `angle=CircularControl(90, [0, 360], modulo=True)` | | Animated timer | — | `t=TimeControl(update_interval_ms=50)` | See the [Control API reference](https://balthazarneveu.github.io/interactive_pipe/api/controls/index.md) for every argument. ## Image buttons (Qt) A string dropdown can be rendered as image buttons by providing one icon per choice: ``` @interactive(song=Control("song_a", ["song_a", "song_b"], icons=["a.png", "b.png"])) def choose_song(song="song_a"): ... ``` This is how the [jukebox demo](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/jukebox_demo.py) works. ## Grouping controls Pass `group="Panel name"` (or a [`Panel`](https://balthazarneveu.github.io/interactive_pipe/guide/panels/index.md) instance) to gather related controls together in the GUI. ## Decorating without `@` Applying the decorator "from outside" keeps your library free of interactive_pipe imports: ``` from core_filters import processing_block from interactive_pipe import interactive interactive(angle=(0., [-360., 360.]))(processing_block) ``` # Curve plots Return a `Curve` from a filter to display a 2D plot instead of an image. Curves update live as sliders move, like any other output, and mix freely with images in the same pipeline. All backends display them ([backend matrix](https://balthazarneveu.github.io/interactive_pipe/getting-started/backends/index.md)). ## Returning a curve from a filter The most compact form is a list of `[x, y, style, label]` entries (matplotlib-style format strings): ``` from interactive_pipe import Curve, interactive import numpy as np @interactive( frequency=(2.0, [0.5, 10.0]), amplitude=(1.0, [0.1, 2.0]), ) def generate_curve(frequency=2.0, amplitude=1.0) -> Curve: x = np.linspace(0.0, 4.0 * np.pi, 200) y_main = amplitude * np.sin(frequency * x) y_ref = amplitude * np.cos(frequency * x) return Curve( [ [x, y_main, "b-", f"sin({frequency:.2f}x)"], [x, y_ref, "r--", f"cos({frequency:.2f}x)"], ], xlabel="x [rad]", ylabel="y", ylim=[-2.5, 2.5], grid=True, title=f"Oscillations (A={amplitude:.2f})", ) ``` - `Curve(...)` accepts axis labels, `title`, `grid`, and `xlim`/`ylim` — fixing the limits avoids the axes jumping around while you move sliders. - Each entry can also be a `SingleCurve(x, y, style=..., label=..., linewidth=..., alpha=...)` for finer control, a bare numpy array (plotted against its indices), or a dict of `SingleCurve` kwargs. - Arrange curves next to images with [`layout.grid`](https://balthazarneveu.github.io/interactive_pipe/guide/layout/index.md). Full example: [demo/independent_curve_image_demo.py](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/independent_curve_image_demo.py) (a curve and an image side by side, sharing sliders). ## Standalone mode: plot without any pipeline `Curve` is a plain data object — you can use it outside interactive_pipe entirely, as a thin matplotlib wrapper: ``` from interactive_pipe import Curve, SingleCurve import numpy as np x = np.linspace(0, 1, 100) curve = Curve([[x, x**2, "g-", "parabola"]], grid=True, xlabel="x") curve.show() # opens a matplotlib figure curve.show(figsize=(8, 4)) SingleCurve(x, np.sin(x)).show(title="standalone single curve") ``` `SingleCurve` also saves/loads to disk: `.save("signal.csv")` (x,y columns) or `.pkl`, and back with `SingleCurve.from_file("signal.csv")` — handy for comparing a live pipeline against recorded reference data. ## API Constructor details: [`Curve` / `SingleCurve`](https://balthazarneveu.github.io/interactive_pipe/api/data-objects/index.md). # Decorators The two entry points of the library: `@interactive` declares the controls on a filter, `@interactive_pipeline` turns the function chaining the filters into a GUI application. `block` and `pipeline` are top-level aliases of `interactive` and `interactive_pipeline` respectively, so you can write `@ip.block(...)` / `@ip.pipeline(...)` after `import interactive_pipe as ip`. There is also a one-shot `@interact` decorator for quickly opening a GUI on a single function — see [Tips & tricks](https://balthazarneveu.github.io/interactive_pipe/guide/tips/#one-shot-gui-with-interact). ## interactive ``` interactive(inplace: bool = False, **decorator_controls: Any) ``` Declare controls bound to a filter's keyword arguments. The decorated function keeps working as a plain function, but when used inside an `@interactive_pipeline` the declared controls appear as GUI widgets. Unlike `@interact`, no GUI is launched at decoration time, so the filter can be reused across pipelines. Parameters: | Name | Type | Description | Default | | ---------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | | `inplace` | `bool` | Declare that this filter mutates its input buffers. The engine then hands it private writable copies instead of read-only views. Prefer copying explicitly (img = img.copy()) in new code. | `False` | | `**decorator_controls` | `Any` | Mapping of keyword-argument name to a control declaration — a Control instance (or subclass such as KeyboardControl) or the (default, [min, max]) tuple shorthand. | `{}` | Example ``` @interactive(gain=(1.0, [0.0, 3.0])) def amplify(img, gain=1.0): return gain * img ``` Source code in `src/interactive_pipe/helper/filter_decorator.py` ```` def interactive(inplace: bool = False, **decorator_controls: Any): """Declare controls bound to a filter's keyword arguments. The decorated function keeps working as a plain function, but when used inside an ``@interactive_pipeline`` the declared controls appear as GUI widgets. Unlike ``@interact``, no GUI is launched at decoration time, so the filter can be reused across pipelines. Args: inplace: Declare that this filter mutates its input buffers. The engine then hands it private writable copies instead of read-only views. Prefer copying explicitly (``img = img.copy()``) in new code. **decorator_controls: Mapping of keyword-argument name to a control declaration — a ``Control`` instance (or subclass such as ``KeyboardControl``) or the ``(default, [min, max])`` tuple shorthand. Example: ```python @interactive(gain=(1.0, [0.0, 3.0])) def amplify(img, gain=1.0): return gain * img ``` """ def wrapper(func): controls = get_controls_from_decorated_function_declaration(func, decorator_controls) @functools.wraps(func) def inner(*args, **kwargs): # Explicit kwargs take precedence: the pipeline engine passes each # filter instance's own values (a repeated filter gets per-instance # control clones). Registered controls fill the rest so a direct # call of the decorated function uses the live control values. merged_kwargs = {**controls, **kwargs} bound_args = inspect.signature(func).bind(*args, **merged_kwargs) bound_args.apply_defaults() for k, v in bound_args.arguments.items(): if isinstance(v, Control): bound_args.arguments[k] = v.value # Call the original function with the processed arguments return func(*bound_args.args, **bound_args.kwargs) # picked up by HeadlessPipeline.from_function when building the FilterCore setattr(inner, "__interactive_pipe_inplace__", inplace) return inner return wrapper ```` ## interactive_pipeline ``` interactive_pipeline(gui: Union[str, Backend, None] = 'auto', safe_input_buffer_deepcopy: bool = True, cache: Union[bool, str] = False, readonly_inputs: bool = True, context: Optional[dict] = None, markdown_description: Optional[str] = None, name: Optional[str] = None, **kwargs_gui: Any) -> Callable[[Callable[..., Any]], Union[HeadlessPipeline, Callable[..., Any]]] ``` Decorator turning a pipeline function into an interactive GUI application. The decorated function chains filters (typically declared with `@interactive`) and is analyzed statically to build the execution graph. Its body must therefore contain only filter-function calls, assignments and a return statement — no control flow (if/for/while) or arithmetic; put such logic inside the filters themselves. Parameters: | Name | Type | Description | Default | | ---------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | | `gui` | `Union[str, Backend, None]` | Backend used to display the pipeline. One of "auto", "qt", "mpl", "nb" (Jupyter widgets), "gradio" or a Backend enum value. None or "headless" returns a HeadlessPipeline without launching any GUI. | `'auto'` | | `safe_input_buffer_deepcopy` | `bool` | Deep-copy the input buffers before each run so filters cannot mutate the original inputs. | `True` | | `readonly_inputs` | `bool` | Hand filters their numpy inputs as read-only views (default True), so in-place mutation (img += 1) raises immediately instead of silently corrupting sibling filters or cached buffers. Declare @interactive(inplace=True) on a filter that intentionally mutates its inputs (it then receives private writable copies), or pass False to restore the permissive behavior. | `True` | | `cache` | `Union[bool, str]` | Cache intermediate filter outputs between runs. - False (default): recompute every filter on every interaction. - True: sequential prefix cache - a change recomputes every filter after it in source order. - "graph": dependency-aware cache - only filters actually affected by a change are recomputed, following the data-flow graph and runtime-tracked context usage. - "graph-strict": same as "graph", but context reads return numpy arrays as read-only views so accidental in-place mutation raises at the offending line (debug helper). | `False` | | `context` | `Optional[dict]` | Initial content of the shared context dictionary, readable and writable from filters through the context proxy. | `None` | | `markdown_description` | `Optional[str]` | Description displayed by backends that support it (e.g. Gradio). | `None` | | `name` | `Optional[str]` | Window/application title. Defaults to the function name. | `None` | | `**kwargs_gui` | `Any` | Extra options forwarded to the GUI backend, e.g. size. | `{}` | Returns: | Type | Description | | ----------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `Callable[[Callable[..., Any]], Union[HeadlessPipeline, Callable[..., Any]]]` | A decorator. Applied to a pipeline function it returns either a | | `Callable[[Callable[..., Any]], Union[HeadlessPipeline, Callable[..., Any]]]` | HeadlessPipeline (when gui is None or "headless") or a | | `Callable[[Callable[..., Any]], Union[HeadlessPipeline, Callable[..., Any]]]` | callable that launches the GUI when invoked with the pipeline inputs. | Raises: | Type | Description | | ----------- | ------------------------------------------------------------------------------------------------------ | | `TypeError` | If a removed pre-0.9.0 context alias (global_params, state, ...) is passed; use context={...} instead. | Example ``` @interactive_pipeline(gui="qt", cache=True) def my_pipeline(img): flipped = flip(img) amplified = amplify(flipped) return flipped, amplified my_pipeline(input_image) # opens the interactive window ``` Source code in `src/interactive_pipe/helper/pipeline_decorator.py` ```` def interactive_pipeline( gui: Union[str, Backend, None] = "auto", safe_input_buffer_deepcopy: bool = True, cache: Union[bool, str] = False, readonly_inputs: bool = True, context: Optional[dict] = None, markdown_description: Optional[str] = None, name: Optional[str] = None, **kwargs_gui: Any, ) -> Callable[[Callable[..., Any]], Union[HeadlessPipeline, Callable[..., Any]]]: """Decorator turning a pipeline function into an interactive GUI application. The decorated function chains filters (typically declared with ``@interactive``) and is analyzed statically to build the execution graph. Its body must therefore contain only filter-function calls, assignments and a return statement — no control flow (if/for/while) or arithmetic; put such logic inside the filters themselves. Args: gui: Backend used to display the pipeline. One of ``"auto"``, ``"qt"``, ``"mpl"``, ``"nb"`` (Jupyter widgets), ``"gradio"`` or a ``Backend`` enum value. ``None`` or ``"headless"`` returns a ``HeadlessPipeline`` without launching any GUI. safe_input_buffer_deepcopy: Deep-copy the input buffers before each run so filters cannot mutate the original inputs. readonly_inputs: Hand filters their numpy inputs as read-only views (default True), so in-place mutation (``img += 1``) raises immediately instead of silently corrupting sibling filters or cached buffers. Declare ``@interactive(inplace=True)`` on a filter that intentionally mutates its inputs (it then receives private writable copies), or pass False to restore the permissive behavior. cache: Cache intermediate filter outputs between runs. - False (default): recompute every filter on every interaction. - True: sequential prefix cache - a change recomputes every filter after it in source order. - "graph": dependency-aware cache - only filters actually affected by a change are recomputed, following the data-flow graph and runtime-tracked ``context`` usage. - "graph-strict": same as "graph", but context reads return numpy arrays as read-only views so accidental in-place mutation raises at the offending line (debug helper). context: Initial content of the shared context dictionary, readable and writable from filters through the ``context`` proxy. markdown_description: Description displayed by backends that support it (e.g. Gradio). name: Window/application title. Defaults to the function name. **kwargs_gui: Extra options forwarded to the GUI backend, e.g. ``size``. Returns: A decorator. Applied to a pipeline function it returns either a ``HeadlessPipeline`` (when ``gui`` is None or ``"headless"``) or a callable that launches the GUI when invoked with the pipeline inputs. Raises: TypeError: If a removed pre-0.9.0 context alias (``global_params``, ``state``, ...) is passed; use ``context={...}`` instead. Example: ```python @interactive_pipeline(gui="qt", cache=True) def my_pipeline(img): flipped = flip(img) amplified = amplify(flipped) return flipped, amplified my_pipeline(input_image) # opens the interactive window ``` """ # Reject removed aliases of the 'context' parameter with a clear message for alias in REMOVED_CONTEXT_ALIASES: if alias in kwargs_gui: raise TypeError( f"@interactive_pipeline: '{alias}' argument was removed in interactive_pipe 0.9.0; " "pass context={...} instead." ) def wrapper(pipeline_function: Callable[..., Any]) -> Union[HeadlessPipeline, Callable[..., Any]]: headless_pipeline = HeadlessPipeline.from_function( pipeline_function, safe_input_buffer_deepcopy=safe_input_buffer_deepcopy, cache=cache, readonly_inputs=readonly_inputs, context=context, ) if gui is None or gui == "headless": return headless_pipeline else: InteractivePipeGui = get_interactive_pipeline_class(gui) gui_pipeline = InteractivePipeGui( pipeline=headless_pipeline, markdown_description=markdown_description, name=name if name is not None else pipeline_function.__name__, **kwargs_gui, ) @functools.wraps(pipeline_function) def inner(*args: Any, **kwargs: Any) -> Any: return gui_pipeline.__call__(*args, **kwargs) # Attach the GUI pipeline object to the function so users can access methods like graph_representation setattr(inner, "pipeline", gui_pipeline) setattr(inner, "graph_representation", gui_pipeline.graph_representation) return inner return wrapper ```` # Examples gallery ## Tutorials - **[Main tutorial on Hugging Face](https://huggingface.co/spaces/balthou/interactive-pipe-tutorial)** — interactive, runs in the browser - [Tutorial in a Colab notebook](https://colab.research.google.com/github/livalgo/interactive-pipe-examples/blob/main/interactive_pipe_tutorial.ipynb) - [Quickstart as a Colab notebook](https://colab.research.google.com/drive/1PZn8P_5TABVCugT3IcLespvZG-gxnFbO?usp=sharing) (ipywidgets backend) - [Speech exploration notebook](https://colab.research.google.com/drive/1mUX2FW0qflWn-v3nIx90P_KvRxnXlBpz#scrollTo=qDTaIwvaJQ6R) — signal processing in a notebook ## Showcase | Science notebook | Toddler DIY jukebox on a Raspberry Pi | | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | | | | Sliders appear automatically in a Jupyter notebook — works on Google Colab, ~40 lines of code. | Plays music when a toddler touches an icon; image buttons + audio on Qt. | | [Demo notebook on Colab](https://colab.research.google.com/drive/1AwHyjZH8MnzZqwsvbmxBoB15btuMIwtk?usp=sharing) | [jukebox_demo.py](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/jukebox_demo.py) | ### Multi-image pipeline The main demo: [demo/multi_image.py](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/multi_image.py) | GUI | Pipeline graph | | --- | -------------- | | | | ## Demo scripts All in [`demo/`](https://github.com/balthazarneveu/interactive_pipe/tree/master/demo): | Script | Shows | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `multi_image.py` (+ `.ipynb`) | The main tutorial pipeline: selection, exposure, blending | | `widgets_showcase.py` | Every widget type in one app | | `button_demo.py` | Image buttons | | `color_demo.py` | Dropdown-driven color generation | | `colored_checkerboard_rotation_demo.py` | `CircularControl` rotation | | `panel_demo.py` / `grouped_controls_demo.py` | [Panel system](https://balthazarneveu.github.io/interactive_pipe/guide/panels/index.md) | | `panel_position_demo.py` / `detached_panel_demo.py` | Panel placement and detached windows | | `key_event_demo.py` | Key-bound one-shot [events](https://balthazarneveu.github.io/interactive_pipe/guide/keyboard/#key-bound-one-shot-events) | | `time_wave_demo.py` / `time_physics_demo.py` | `TimeControl` animations | | `image_editing_demo.py` / `image_analysis_demo.py` | Realistic image workflows | | `independent_curve_image_demo.py` | Mixing `Curve` plots and images | | `table_demo.py` | `Table` outputs | | `jukebox_demo.py` | Raspberry Pi jukebox (audio + image buttons) | ## Curated samples The [`samples/`](https://github.com/balthazarneveu/interactive_pipe/tree/master/samples) folder walks through the declaration styles: - `decorated_pipeline.py` — decorator syntax with explicit `Control` objects - `decorated_pipeline_abbreviated.py` — the abbreviated tuple declarations - `object_oriented_pipeline_declarations.py` — pipeline graph built by hand, no decorators - `interact_sample/` — a small filter library + `@interact` one-shot GUI + its pytest suite # Keyboard ## GUI shortcuts Built-in shortcuts while using the GUI (Qt and matplotlib backends): - `F1` show the help shortcuts in the terminal - `F11` toggle fullscreen mode - `W` write full resolution image to disk - `R` reset parameters - `I` print parameters dictionary in the command line - `E` export parameters dictionary to a yaml file - `O` import parameters dictionary from a yaml file (sliders update) - `G` export a pipeline diagram (requires graphviz) ## KeyboardControl A `KeyboardControl` behaves exactly like a slider internally, but is driven by key presses instead of a widget: ``` from interactive_pipe import interactive, KeyboardControl @interactive( image_index=KeyboardControl(0, [0, 2], keydown="pagedown", keyup="pageup", modulo=True) ) def switch_image(img1, img2, img3, image_index=0): return [img1, img2, img3][image_index] ``` - `keydown` decreases the value (or toggles a bool), `keyup` increases it. - `modulo=True` wraps around at the range bounds — here `Page Up` past index 2 goes back to 0. - Special keys: arrows, `"pageup"`/`"pagedown"`, spacebar, `"f1"`–`"f12"`; anything else is a single character. - On backends without key events (gradio, notebook), a `KeyboardControl` maps back to a regular slider — no need to remove it. Keyboard bindings always use the explicit `KeyboardControl` object — there is no tuple shorthand for them. A bool `KeyboardControl(True, keydown="k")` turns a checkbox into a `K` toggle. ## Key-bound one-shot events For events that are not values — "trigger this on the next run" — bind a key to the context and read it through the read-only `events` proxy: ``` from interactive_pipe import events, interactive @interactive() def noise_burst(img): if events.get("noise_burst"): # True only on the run following the key press return add_noise(img) return img demo = interactive_pipeline(gui="qt")(my_pipeline) demo.pipeline.bind_key_to_context("n", "noise_burst", "one-shot noise burst") ``` See [demo/key_event_demo.py](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/key_event_demo.py) for the full example. # Layout ## The simplest way: return the grid The pipeline function's return statement *is* the layout. Return a flat tuple for a single row, or a nested list to arrange the outputs in a grid — no extra code: ``` @interactive_pipeline(gui="qt") def pipe(img): a = exposure(img) b = black_and_white(img) c = blend(a, b) d = compute_histo(c) return [[a, b], [c, d]] # 2x2 grid ``` - `return a, b, c` — one row of three. - `return [[a, b], [c, d]]` — 2×2 grid. - `return [[a], [b]]` — one column of two (stacked). - `return [[a, b], [c, None]]` — `None` leaves a cell empty. ## Dynamic layouts: the `layout` proxy For layouts that *change at runtime* — driven by a slider, a dropdown, or the data itself — use the `layout` proxy from inside any filter. Output names are the **variable names used in the pipeline function**: ``` from interactive_pipe import layout @interactive(layout_mode=["side_by_side", "grid2x2"]) def change_layout(layout_mode: str = "side_by_side"): if layout_mode == "side_by_side": layout.grid([["input", "result"]]) if layout_mode == "grid2x2": layout.grid([["input", "processed"], ["histogram_graph", "result"]]) layout.style("result", title="Final Result") def pipeline(input): processed = denoise(input) result = change_brightness(processed) histogram_graph = compute_histo(processed) change_layout() return result ``` Live layout switching (e.g. going from a side-by-side comparison to a 2x2 grid while the app runs) is supported on the Qt backend. ## `layout` methods Proxy for display and layout operations using contextvars. This class provides a clean API for controlling output display properties and grid arrangements without polluting function signatures. Available methods - style(): Set display properties (title, colormap, etc.) - grid(): Set the output grid layout - row(): Convenience for single-row layout ## style ``` style(name: str, *, title: Optional[str] = None, **style_kwargs: Any) -> None ``` Set display properties for an output. Parameters: | Name | Type | Description | Default | | ---------------- | --------------- | --------------------------------------------------------------------------- | ---------- | | `name` | `str` | Output variable name (must match the variable name in the return statement) | *required* | | `title` | `Optional[str]` | Display title for the output | `None` | | `**style_kwargs` | `Any` | Additional style properties (colormap, vmin, vmax, etc.) | `{}` | Example ``` layout.style("processed", title=f"Brightness: {brightness:.2f}") layout.style("heatmap", title="Heat Map", colormap="viridis", vmin=0, vmax=1) ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | ## grid ``` grid(arrangement: Union[List[str], List[List[str]]]) -> None ``` Set the output grid layout. Parameters: | Name | Type | Description | Default | | ------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------- | | `arrangement` | `Union[List[str], List[List[str]]]` | Either a flat list of output names (treated as a single row) or a 2D list of output names defining the grid arrangement | *required* | Example ``` # Single row (flat list) layout.grid(["img1", "img2", "img3"]) # Single row (nested list - equivalent to above) layout.grid([["img1", "img2", "img3"]]) # 2x2 grid layout.grid([ ["original", "processed"], ["histogram", "stats"] ]) ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | ## row ``` row(outputs: List[str]) -> None ``` Convenience method for single-row layout. This is equivalent to calling grid([outputs]). Parameters: | Name | Type | Description | Default | | --------- | ----------- | ----------------------------------------------- | ---------- | | `outputs` | `List[str]` | List of output names to display in a single row | *required* | Example ``` layout.row(["original", "filtered", "result"]) ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | # Panels Panels group controls into visual sections. The simplest form is the `group=` argument of a control: ``` from interactive_pipe import interactive, Control @interactive( brightness=Control(1.0, [0.0, 2.0], group="Color Adjustments"), contrast=Control(1.0, [0.5, 1.5], group="Color Adjustments"), ) def adjust(img, brightness=1.0, contrast=1.0): ... ``` Controls sharing the same group name land in the same panel — even across different filters (same-named string groups share one panel process-wide). ## Panel objects For fine control — nesting, grids, collapsing, positioning — build `Panel` instances: ``` from interactive_pipe import Panel text_panel = Panel("Text Settings", collapsible=True) color_panel = Panel("Color Adjustments", collapsible=True) effects_panel = Panel("Effects", collapsible=True, collapsed=False) # Nested structure with a grid layout: two panels side by side, one full width main_panel = Panel("Processing Controls").add_elements( [ [text_panel, color_panel], # row 1 [effects_panel], # row 2 ] ) @interactive(font_size=Control(12, [6, 48], group=text_panel)) def add_caption(img, font_size=12): ... ``` Features (Qt backend has the fullest support, gradio supports collapsible panels): - **Nesting**: panels contain sub-panels via `add_elements`, with row-based grid layouts. - **Collapsible**: `collapsible=True`, optionally starting `collapsed=True`. - **Positioning**: place the control panel left, right, top or bottom of the images. - **Detached panels**: pop the controls out into a separate window. ## Demos - [panel_demo.py](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/panel_demo.py) — nesting, grids, collapsing - [grouped_controls_demo.py](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/grouped_controls_demo.py) — string groups - [panel_position_demo.py](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/panel_position_demo.py) — positioning - [detached_panel_demo.py](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/detached_panel_demo.py) — separate control window Full reference: [`Panel`](https://balthazarneveu.github.io/interactive_pipe/api/controls/index.md). # Tables Return a `Table` to display tabular data — handy for live statistics next to the image being processed. Tables render on all four backends. ``` from interactive_pipe import Table, interactive @interactive() def compute_statistics(img) -> Table: channels = ["Red", "Green", "Blue"] stats = { "Channel": channels, "Mean": [img[:, :, i].mean() for i in range(3)], "Std": [img[:, :, i].std() for i in range(3)], } return Table(stats, title="Image statistics", precision=4) ``` ## Accepted input shapes - a dict of columns: `{"Channel": [...], "Mean": [...]}` (as above) - a 2D numpy array plus `columns=["X", "Y", ...]` - a numpy array with `columns=None` for a headerless raw matrix - a list of row dicts: `[{"x": 1, "y": 2}, ...]` - a pandas `DataFrame` (when pandas is installed; pandas is optional) `precision` controls float formatting. `table.as_dataframe()` converts back to pandas, and tables save to `.csv` / `.pkl` via `.save(path)`. Full example: [demo/table_demo.py](https://github.com/balthazarneveu/interactive_pipe/blob/master/demo/table_demo.py) (statistics, coordinate grids, DataFrames side by side). ## API Constructor details: [`Table`](https://balthazarneveu.github.io/interactive_pipe/api/data-objects/index.md). # Tips & tricks ## Generator filters (no inputs) Filters don't have to take image inputs — they can simply *generate* data: ``` from interactive_pipe import interactive, context import numpy as np COLOR_DICT = {"red": [1., 0., 0.], "green": [0., 1., 0.], "blue": [0., 0., 1.], "gray": [0.5, 0.5, 0.5]} @interactive(color_choice=["red", "green", "blue", "gray"]) def generate_flat_colored_image(color_choice="red"): flat_array = np.array(COLOR_DICT.get(color_choice)) * np.ones((64, 64, 3)) context["avg"] = np.average(flat_array) return flat_array ``` The `color_choice` list becomes a dropdown menu; the default is the first element. ## Switching between images ``` from interactive_pipe import KeyboardControl @interactive(image_index=KeyboardControl(0, [0, 2], keydown="pagedown", keyup="pageup", modulo=True)) def switch_image(img1, img2, img3, image_index=0): return [img1, img2, img3][image_index] ``` `modulo=True` makes the index wrap around (back to 0 past the maximum) — flip through images with `Page Up` / `Page Down`. ## Avoid in-place operations Filters route their buffers by reference, so mutating an input in place silently corrupts sibling filters and cached buffers. Since 0.9.1, inputs are handed to filters as **read-only views by default** (`readonly_inputs=True`), so the mistake raises immediately instead of producing wrong results: ``` # Raises since 0.9.1 — img is a read-only view def bad_processing_block(img): img += 1 return img ``` If a filter *intentionally* mutates its inputs, declare it with `@interactive(inplace=True)` — it then receives private writable copies: ``` @interactive(inplace=True) def add_one(img): img += 1 return img ``` Pass `readonly_inputs=False` to `@interactive_pipeline(...)` to restore the old permissive behavior. In-place mutation is also detected for torch tensors (which cannot be marked read-only). ## Cache intermediate results `@interactive_pipeline(gui="qt", cache=True)` keeps intermediate filter outputs in RAM, so moving a slider only re-runs the filters downstream of the change (in source order) — a big win for heavy pipelines. For pipelines with **independent branches**, use `cache="graph"` instead: this dependency-aware mode recomputes a filter only when it is actually affected — its own sliders moved, one of its producers was recomputed, or a `context` key it reads changed. Filters on branches unrelated to the change are left untouched. ``` @interactive_pipeline(gui="qt", cache="graph") def my_pipeline(img): exposed = exposure(img) # moving the exposure slider... smooth = denoise(img) # ...leaves denoise and annotate cached tagged = annotate(smooth) return [exposed, tagged] ``` Use `cache="graph-strict"` to additionally receive `context` numpy arrays as read-only views, catching accidental in-place mutation of shared context data. ## Export / import tuning Press `E` in the GUI to export the current parameters to YAML and `O` to load them back; headless pipelines expose the same via `export_tuning()` / `load_tuning()`. Press `G` to export a graphviz diagram of the pipeline. ## One-shot GUI with `@interact` For a quick experiment on a *single* function — no pipeline needed — `@interact` opens the GUI immediately at decoration time: ``` from interactive_pipe import interact import numpy as np image = np.random.rand(256, 512, 3) @interact(image, gain=(1.0, [0.0, 3.0])) def show(img, gain=1.0): return img * gain ``` Handy in notebooks and throwaway scripts; for anything reusable, prefer `@interactive` + `@interactive_pipeline` ([decorators guide](https://balthazarneveu.github.io/interactive_pipe/guide/decorators/index.md)). ## interact ``` interact(*decorator_args: Any, gui: str = 'auto', disable: bool = False, output_routing: Optional[List[str]] = None, size: Union[str, Tuple[int, int], None] = None, inplace: bool = False, **decorator_controls: Any) ``` Launch a GUI from a single decorated function (one-shot decorator). Unlike `@interactive`, decorating a function with `@interact` immediately runs it inside a GUI: widgets are created for the declared controls and the window opens right away. Inspired by the ipywidgets `interact` function, except that returning numpy arrays or `Curve` instances handles the display automatically — no matplotlib boilerplate. Parameters: | Name | Type | Description | Default | | ---------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------- | | `*decorator_args` | `Any` | Positional inputs passed to the function (e.g. the input image). | `()` | | `gui` | `str` | Backend, same values as interactive_pipeline (default "auto"). | `'auto'` | | `disable` | `bool` | When True, return the function untouched (no GUI). | `False` | | `output_routing` | `Optional[List[str]]` | Names of the outputs, when they cannot be deduced from a dry run. | `None` | | `size` | `Union[str, Tuple[int, int], None]` | Window/figure size hint forwarded to the backend. | `None` | | `**decorator_controls` | `Any` | Controls bound to keyword arguments by name (Control instances or the (default, [min, max]) tuple shorthand). | `{}` | Example ``` @interact(image, gain=(1.0, [0.0, 3.0])) def show(img, gain=1.0): return img * gain ``` Source code in `src/interactive_pipe/helper/filter_decorator.py` ```` def interact( *decorator_args: Any, gui: str = "auto", disable: bool = False, output_routing: Optional[List[str]] = None, size: Union[str, Tuple[int, int], None] = None, inplace: bool = False, **decorator_controls: Any, ): """Launch a GUI from a single decorated function (one-shot decorator). Unlike ``@interactive``, decorating a function with ``@interact`` immediately runs it inside a GUI: widgets are created for the declared controls and the window opens right away. Inspired by the ipywidgets ``interact`` function, except that returning numpy arrays or ``Curve`` instances handles the display automatically — no matplotlib boilerplate. Args: *decorator_args: Positional inputs passed to the function (e.g. the input image). gui: Backend, same values as ``interactive_pipeline`` (default ``"auto"``). disable: When True, return the function untouched (no GUI). output_routing: Names of the outputs, when they cannot be deduced from a dry run. size: Window/figure size hint forwarded to the backend. **decorator_controls: Controls bound to keyword arguments by name (``Control`` instances or the ``(default, [min, max])`` tuple shorthand). Example: ```python @interact(image, gain=(1.0, [0.0, 3.0])) def show(img, gain=1.0): return img * gain ``` """ omitted_parentheses_flag = False def wrapper(func): if disable: return func # this will run the GUI automatically filter_instance = filter_from_function(func, inplace=inplace, **decorator_controls) filter_instance.run_gui( *(decorator_args if not omitted_parentheses_flag else decorator_args[1:]), gui=gui, output_routing=output_routing, size=size, ) # return the original function if you want to keep using it afterwards return func if len(decorator_args) == 1 and callable(decorator_args[0]): omitted_parentheses_flag = True return wrapper(decorator_args[0]) # no parenthesis return wrapper ```` # API reference # Context & layout The proxies importable from `interactive_pipe` for sharing state and controlling the display — see the [Context & events](https://balthazarneveu.github.io/interactive_pipe/guide/context/index.md) and [Layout](https://balthazarneveu.github.io/interactive_pipe/guide/layout/index.md) guides for usage patterns. ## get_context ``` get_context() -> Dict[str, Any] ``` Get shared context for passing data between filters. This returns a simple dictionary for user-defined state only. Framework internals (keys starting with \_\_) are not accessible here. Returns: | Type | Description | | ---------------- | ------------------------------------------- | | `Dict[str, Any]` | A shared dictionary for user-defined state. | Raises: | Type | Description | | -------------- | ---------------------------------------- | | `RuntimeError` | If called outside of pipeline execution. | Example ``` # In filter A @interactive(threshold=(0.5, [0.0, 1.0])) def detect_objects(img, threshold=0.5): objects = find_objects(img, threshold) ctx = get_context() ctx["detected_objects"] = objects ctx["detection_count"] = len(objects) return img_with_boxes # In filter B (runs after A) @interactive() def analyze_objects(img): ctx = get_context() objects = ctx.get("detected_objects", []) count = ctx.get("detection_count", 0) layout.style("analysis", title=f"Found {count} objects") return analysis_result ``` Source code in `src/interactive_pipe/core/context.py` ```` def get_context() -> Dict[str, Any]: """Get shared context for passing data between filters. This returns a simple dictionary for user-defined state only. Framework internals (keys starting with __) are not accessible here. Returns: A shared dictionary for user-defined state. Raises: RuntimeError: If called outside of pipeline execution. Example: ```python # In filter A @interactive(threshold=(0.5, [0.0, 1.0])) def detect_objects(img, threshold=0.5): objects = find_objects(img, threshold) ctx = get_context() ctx["detected_objects"] = objects ctx["detection_count"] = len(objects) return img_with_boxes # In filter B (runs after A) @interactive() def analyze_objects(img): ctx = get_context() objects = ctx.get("detected_objects", []) count = ctx.get("detection_count", 0) layout.style("analysis", title=f"Found {count} objects") return analysis_result ``` """ ctx = _user_context.get() if ctx is None: raise RuntimeError( "get_context() called outside of pipeline execution. " "This function must be called from within a filter executed through an interactive pipeline." ) return ctx ```` ## \_ContextProxy Proxy object for direct dict-like access to user context. This allows using context["key"] or context.key directly instead of ctx = get_context(); ctx["key"]. Example ``` from interactive_pipe import context @interactive() def my_filter(img): # Direct dict-style access context["my_data"] = value other_data = context.get("other_key", default) # Or attribute-style access context.my_data = value other_data = context.other_key return img ``` Source code in `src/interactive_pipe/core/context.py` ```` class _ContextProxy: """Proxy object for direct dict-like access to user context. This allows using context["key"] or context.key directly instead of ctx = get_context(); ctx["key"]. Example: ```python from interactive_pipe import context @interactive() def my_filter(img): # Direct dict-style access context["my_data"] = value other_data = context.get("other_key", default) # Or attribute-style access context.my_data = value other_data = context.other_key return img ``` """ def __getitem__(self, key: str) -> Any: """Get item from context using dict-style access.""" return get_context()[key] def __setitem__(self, key: str, value: Any) -> None: """Set item in context using dict-style access.""" get_context()[key] = value def __getattr__(self, name: str) -> Any: """Get item from context using attribute-style access.""" try: return get_context()[name] except KeyError: raise AttributeError(f"Context has no attribute '{name}'. Available keys: {list(get_context().keys())}") def __setattr__(self, name: str, value: Any) -> None: """Set item in context using attribute-style access.""" get_context()[name] = value def __delitem__(self, key: str) -> None: """Delete item from context.""" del get_context()[key] def __contains__(self, key: str) -> bool: """Check if key exists in context.""" return key in get_context() def get(self, key: str, default: Any = None) -> Any: """Get item from context with default value.""" return get_context().get(key, default) def setdefault(self, key: str, default: Any = None) -> Any: """Set default value if key doesn't exist.""" return get_context().setdefault(key, default) def pop(self, key: str, *args) -> Any: """Pop item from context.""" return get_context().pop(key, *args) def keys(self): """Get context keys.""" return get_context().keys() def values(self): """Get context values.""" return get_context().values() def items(self): """Get context items.""" return get_context().items() def update(self, *args, **kwargs) -> None: """Update context with dict or kwargs.""" get_context().update(*args, **kwargs) def clear(self) -> None: """Clear context.""" get_context().clear() def __repr__(self) -> str: """String representation.""" try: return f"" except RuntimeError: return "" ```` ### __getitem__ ``` __getitem__(key: str) -> Any ``` Get item from context using dict-style access. Source code in `src/interactive_pipe/core/context.py` ``` def __getitem__(self, key: str) -> Any: """Get item from context using dict-style access.""" return get_context()[key] ``` ### __setitem__ ``` __setitem__(key: str, value: Any) -> None ``` Set item in context using dict-style access. Source code in `src/interactive_pipe/core/context.py` ``` def __setitem__(self, key: str, value: Any) -> None: """Set item in context using dict-style access.""" get_context()[key] = value ``` ### __getattr__ ``` __getattr__(name: str) -> Any ``` Get item from context using attribute-style access. Source code in `src/interactive_pipe/core/context.py` ``` def __getattr__(self, name: str) -> Any: """Get item from context using attribute-style access.""" try: return get_context()[name] except KeyError: raise AttributeError(f"Context has no attribute '{name}'. Available keys: {list(get_context().keys())}") ``` ### __setattr__ ``` __setattr__(name: str, value: Any) -> None ``` Set item in context using attribute-style access. Source code in `src/interactive_pipe/core/context.py` ``` def __setattr__(self, name: str, value: Any) -> None: """Set item in context using attribute-style access.""" get_context()[name] = value ``` ### __delitem__ ``` __delitem__(key: str) -> None ``` Delete item from context. Source code in `src/interactive_pipe/core/context.py` ``` def __delitem__(self, key: str) -> None: """Delete item from context.""" del get_context()[key] ``` ### __contains__ ``` __contains__(key: str) -> bool ``` Check if key exists in context. Source code in `src/interactive_pipe/core/context.py` ``` def __contains__(self, key: str) -> bool: """Check if key exists in context.""" return key in get_context() ``` ### get ``` get(key: str, default: Any = None) -> Any ``` Get item from context with default value. Source code in `src/interactive_pipe/core/context.py` ``` def get(self, key: str, default: Any = None) -> Any: """Get item from context with default value.""" return get_context().get(key, default) ``` ### setdefault ``` setdefault(key: str, default: Any = None) -> Any ``` Set default value if key doesn't exist. Source code in `src/interactive_pipe/core/context.py` ``` def setdefault(self, key: str, default: Any = None) -> Any: """Set default value if key doesn't exist.""" return get_context().setdefault(key, default) ``` ### pop ``` pop(key: str, *args) -> Any ``` Pop item from context. Source code in `src/interactive_pipe/core/context.py` ``` def pop(self, key: str, *args) -> Any: """Pop item from context.""" return get_context().pop(key, *args) ``` ### keys ``` keys() ``` Get context keys. Source code in `src/interactive_pipe/core/context.py` ``` def keys(self): """Get context keys.""" return get_context().keys() ``` ### values ``` values() ``` Get context values. Source code in `src/interactive_pipe/core/context.py` ``` def values(self): """Get context values.""" return get_context().values() ``` ### items ``` items() ``` Get context items. Source code in `src/interactive_pipe/core/context.py` ``` def items(self): """Get context items.""" return get_context().items() ``` ### update ``` update(*args, **kwargs) -> None ``` Update context with dict or kwargs. Source code in `src/interactive_pipe/core/context.py` ``` def update(self, *args, **kwargs) -> None: """Update context with dict or kwargs.""" get_context().update(*args, **kwargs) ``` ### clear ``` clear() -> None ``` Clear context. Source code in `src/interactive_pipe/core/context.py` ``` def clear(self) -> None: """Clear context.""" get_context().clear() ``` ### __repr__ ``` __repr__() -> str ``` String representation. Source code in `src/interactive_pipe/core/context.py` ``` def __repr__(self) -> str: """String representation.""" try: return f"" except RuntimeError: return "" ``` ## \_LayoutProxy Proxy for display and layout operations using contextvars. This class provides a clean API for controlling output display properties and grid arrangements without polluting function signatures. Available methods - style(): Set display properties (title, colormap, etc.) - grid(): Set the output grid layout - row(): Convenience for single-row layout Source code in `src/interactive_pipe/core/context.py` ```` class _LayoutProxy: """Proxy for display and layout operations using contextvars. This class provides a clean API for controlling output display properties and grid arrangements without polluting function signatures. Available methods: - style(): Set display properties (title, colormap, etc.) - grid(): Set the output grid layout - row(): Convenience for single-row layout """ def style(self, name: str, *, title: Optional[str] = None, **style_kwargs: Any) -> None: """Set display properties for an output. Args: name: Output variable name (must match the variable name in the return statement) title: Display title for the output **style_kwargs: Additional style properties (colormap, vmin, vmax, etc.) Example: ```python layout.style("processed", title=f"Brightness: {brightness:.2f}") layout.style("heatmap", title="Heat Map", colormap="viridis", vmin=0, vmax=1) ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() style = {} if title is not None: style["title"] = title style.update(style_kwargs) state.output_styles[name] = style def grid(self, arrangement: Union[List[str], List[List[str]]]) -> None: """Set the output grid layout. Args: arrangement: Either a flat list of output names (treated as a single row) or a 2D list of output names defining the grid arrangement Example: ```python # Single row (flat list) layout.grid(["img1", "img2", "img3"]) # Single row (nested list - equivalent to above) layout.grid([["img1", "img2", "img3"]]) # 2x2 grid layout.grid([ ["original", "processed"], ["histogram", "stats"] ]) ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() pipeline = state.pipeline if pipeline is not None: # Convert flat list to nested list (single row) if arrangement and isinstance(arrangement[0], str): arrangement = [arrangement] # type: ignore[list-item] pipeline.outputs = arrangement def row(self, outputs: List[str]) -> None: """Convenience method for single-row layout. This is equivalent to calling grid([outputs]). Args: outputs: List of output names to display in a single row Example: ```python layout.row(["original", "filtered", "result"]) ``` Raises: RuntimeError: If called outside of filter execution context. """ self.grid([outputs]) ```` ### style ``` style(name: str, *, title: Optional[str] = None, **style_kwargs: Any) -> None ``` Set display properties for an output. Parameters: | Name | Type | Description | Default | | ---------------- | --------------- | --------------------------------------------------------------------------- | ---------- | | `name` | `str` | Output variable name (must match the variable name in the return statement) | *required* | | `title` | `Optional[str]` | Display title for the output | `None` | | `**style_kwargs` | `Any` | Additional style properties (colormap, vmin, vmax, etc.) | `{}` | Example ``` layout.style("processed", title=f"Brightness: {brightness:.2f}") layout.style("heatmap", title="Heat Map", colormap="viridis", vmin=0, vmax=1) ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | Source code in `src/interactive_pipe/core/context.py` ```` def style(self, name: str, *, title: Optional[str] = None, **style_kwargs: Any) -> None: """Set display properties for an output. Args: name: Output variable name (must match the variable name in the return statement) title: Display title for the output **style_kwargs: Additional style properties (colormap, vmin, vmax, etc.) Example: ```python layout.style("processed", title=f"Brightness: {brightness:.2f}") layout.style("heatmap", title="Heat Map", colormap="viridis", vmin=0, vmax=1) ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() style = {} if title is not None: style["title"] = title style.update(style_kwargs) state.output_styles[name] = style ```` ### grid ``` grid(arrangement: Union[List[str], List[List[str]]]) -> None ``` Set the output grid layout. Parameters: | Name | Type | Description | Default | | ------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------- | | `arrangement` | `Union[List[str], List[List[str]]]` | Either a flat list of output names (treated as a single row) or a 2D list of output names defining the grid arrangement | *required* | Example ``` # Single row (flat list) layout.grid(["img1", "img2", "img3"]) # Single row (nested list - equivalent to above) layout.grid([["img1", "img2", "img3"]]) # 2x2 grid layout.grid([ ["original", "processed"], ["histogram", "stats"] ]) ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | Source code in `src/interactive_pipe/core/context.py` ```` def grid(self, arrangement: Union[List[str], List[List[str]]]) -> None: """Set the output grid layout. Args: arrangement: Either a flat list of output names (treated as a single row) or a 2D list of output names defining the grid arrangement Example: ```python # Single row (flat list) layout.grid(["img1", "img2", "img3"]) # Single row (nested list - equivalent to above) layout.grid([["img1", "img2", "img3"]]) # 2x2 grid layout.grid([ ["original", "processed"], ["histogram", "stats"] ]) ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() pipeline = state.pipeline if pipeline is not None: # Convert flat list to nested list (single row) if arrangement and isinstance(arrangement[0], str): arrangement = [arrangement] # type: ignore[list-item] pipeline.outputs = arrangement ```` ### row ``` row(outputs: List[str]) -> None ``` Convenience method for single-row layout. This is equivalent to calling grid([outputs]). Parameters: | Name | Type | Description | Default | | --------- | ----------- | ----------------------------------------------- | ---------- | | `outputs` | `List[str]` | List of output names to display in a single row | *required* | Example ``` layout.row(["original", "filtered", "result"]) ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | Source code in `src/interactive_pipe/core/context.py` ```` def row(self, outputs: List[str]) -> None: """Convenience method for single-row layout. This is equivalent to calling grid([outputs]). Args: outputs: List of output names to display in a single row Example: ```python layout.row(["original", "filtered", "result"]) ``` Raises: RuntimeError: If called outside of filter execution context. """ self.grid([outputs]) ```` ## \_AudioProxy Proxy for audio playback control using contextvars. This class provides a clean API for controlling audio playback without polluting function signatures with global_params. Source code in `src/interactive_pipe/core/context.py` ```` class _AudioProxy: """Proxy for audio playback control using contextvars. This class provides a clean API for controlling audio playback without polluting function signatures with global_params. """ def set(self, audio_path: str) -> None: """Set the audio file to play. Args: audio_path: Path to the audio file Example: ```python audio.set("path/to/track.mp3") ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() # AudioBindings defaults to no-ops, so this silently does nothing # outside a GUI (same behavior as before) state.audio.set_audio(audio_path) def play(self) -> None: """Start audio playback. Example: ```python audio.play() ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() state.audio.play() def pause(self) -> None: """Pause audio playback. Example: ```python audio.pause() ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() state.audio.pause() def stop(self) -> None: """Stop audio playback. Example: ```python audio.stop() ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() state.audio.stop() ```` ### set ``` set(audio_path: str) -> None ``` Set the audio file to play. Parameters: | Name | Type | Description | Default | | ------------ | ----- | ---------------------- | ---------- | | `audio_path` | `str` | Path to the audio file | *required* | Example ``` audio.set("path/to/track.mp3") ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | Source code in `src/interactive_pipe/core/context.py` ```` def set(self, audio_path: str) -> None: """Set the audio file to play. Args: audio_path: Path to the audio file Example: ```python audio.set("path/to/track.mp3") ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() # AudioBindings defaults to no-ops, so this silently does nothing # outside a GUI (same behavior as before) state.audio.set_audio(audio_path) ```` ### play ``` play() -> None ``` Start audio playback. Example ``` audio.play() ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | Source code in `src/interactive_pipe/core/context.py` ```` def play(self) -> None: """Start audio playback. Example: ```python audio.play() ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() state.audio.play() ```` ### pause ``` pause() -> None ``` Pause audio playback. Example ``` audio.pause() ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | Source code in `src/interactive_pipe/core/context.py` ```` def pause(self) -> None: """Pause audio playback. Example: ```python audio.pause() ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() state.audio.pause() ```` ### stop ``` stop() -> None ``` Stop audio playback. Example ``` audio.stop() ``` Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | Source code in `src/interactive_pipe/core/context.py` ```` def stop(self) -> None: """Stop audio playback. Example: ```python audio.stop() ``` Raises: RuntimeError: If called outside of filter execution context. """ state = _get_framework_state() state.audio.stop() ```` ## \_EventsProxy Read-only proxy for key-bound context events. GUI backends raise an event flag when a key registered through InteractivePipeGUI.bind_key_to_context is pressed; filters read the flags here. An event stays True only for the single pipeline run triggered by the key press - the GUI resets it right after. Example ``` from interactive_pipe import events, interactive @interactive() def denoise(img): strength = 0.5 if events.get("boost_denoise") else 0.1 return smooth(img, strength) ``` Prefer events.get(name): it returns False for events that were never bound, so the same filter also runs headless or in a GUI without the key binding. Source code in `src/interactive_pipe/core/context.py` ```` class _EventsProxy: """Read-only proxy for key-bound context events. GUI backends raise an event flag when a key registered through InteractivePipeGUI.bind_key_to_context is pressed; filters read the flags here. An event stays True only for the single pipeline run triggered by the key press - the GUI resets it right after. Example: ```python from interactive_pipe import events, interactive @interactive() def denoise(img): strength = 0.5 if events.get("boost_denoise") else 0.1 return smooth(img, strength) ``` Prefer events.get(name): it returns False for events that were never bound, so the same filter also runs headless or in a GUI without the key binding. """ def __getitem__(self, name: str) -> bool: """Strict access: raises KeyError if the event was never bound. Raises: RuntimeError: If called outside of filter execution context. """ return _get_framework_state().events[name] def get(self, name: str, default: bool = False) -> bool: """Return the event flag, or `default` when the event is not bound. Raises: RuntimeError: If called outside of filter execution context. """ return _get_framework_state().events.get(name, default) def __contains__(self, name: str) -> bool: """Check whether an event name is bound.""" return name in _get_framework_state().events def keys(self): """Names of all bound events.""" return _get_framework_state().events.keys() def __repr__(self) -> str: try: return f"" except RuntimeError: return "" ```` ### __getitem__ ``` __getitem__(name: str) -> bool ``` Strict access: raises KeyError if the event was never bound. Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | Source code in `src/interactive_pipe/core/context.py` ``` def __getitem__(self, name: str) -> bool: """Strict access: raises KeyError if the event was never bound. Raises: RuntimeError: If called outside of filter execution context. """ return _get_framework_state().events[name] ``` ### get ``` get(name: str, default: bool = False) -> bool ``` Return the event flag, or `default` when the event is not bound. Raises: | Type | Description | | -------------- | ---------------------------------------------- | | `RuntimeError` | If called outside of filter execution context. | Source code in `src/interactive_pipe/core/context.py` ``` def get(self, name: str, default: bool = False) -> bool: """Return the event flag, or `default` when the event is not bound. Raises: RuntimeError: If called outside of filter execution context. """ return _get_framework_state().events.get(name, default) ``` ### __contains__ ``` __contains__(name: str) -> bool ``` Check whether an event name is bound. Source code in `src/interactive_pipe/core/context.py` ``` def __contains__(self, name: str) -> bool: """Check whether an event name is bound.""" return name in _get_framework_state().events ``` ### keys ``` keys() ``` Names of all bound events. Source code in `src/interactive_pipe/core/context.py` ``` def keys(self): """Names of all bound events.""" return _get_framework_state().events.keys() ``` ## Backend Bases: `str`, `Enum` Supported GUI backends for interactive pipelines. Source code in `src/interactive_pipe/core/backend.py` ``` class Backend(str, Enum): """Supported GUI backends for interactive pipelines.""" QT = "qt" GRADIO = "gradio" MPL = "mpl" NB = "nb" AUTO = "auto" HEADLESS = "headless" ``` ## FilterError Bases: `Exception` Clean, user-friendly exception for filter errors. Displays only the relevant error information without the full framework traceback, making it easier to identify issues in user code. Source code in `src/interactive_pipe/core/engine.py` ``` class FilterError(Exception): """Clean, user-friendly exception for filter errors. Displays only the relevant error information without the full framework traceback, making it easier to identify issues in user code. """ def __init__(self, filter_name: str, original_error: Exception, tb=None): _install_excepthook() self.filter_name = filter_name self.original_error = original_error self.tb = tb self._user_frames = self._extract_user_frames() super().__init__(self._format_message()) def _extract_user_frames(self): """Extract relevant frames from the traceback (user code, not framework).""" if self.tb is None: return [] # Walk through traceback to find the user's code (not in interactive_pipe/src) tb_list = traceback.extract_tb(self.tb) user_frames = [] for frame in tb_list: # Skip frames from the interactive_pipe framework itself if "interactive_pipe" in frame.filename and "/src/" in frame.filename: continue user_frames.append(frame) # If no user frames found, return last frame from traceback if not user_frames and tb_list: return [tb_list[-1]] return user_frames def _format_message(self): """Format a clean, readable error message.""" error_str = str(self.original_error) lines = [ "", f" Filter '{self.filter_name}' raised {type(self.original_error).__name__}:", f" {error_str}", ] if self._user_frames: lines.append("") lines.append(" Traceback (user code only):") for frame in self._user_frames: lines.append(f" {frame.filename}:{frame.lineno} in {frame.name}()") if frame.line: lines.append(f" >>> {frame.line.strip()}") lines.append("") return "\n".join(lines) def print_compact(self, file=None): """Print a compact version of the error to stderr or specified file.""" if file is None: file = sys.stderr print(f"\n{'=' * 60}", file=file) print("PIPELINE ERROR", file=file) print(f"{'=' * 60}", file=file) print(str(self), file=file) print(f"{'=' * 60}\n", file=file) ``` ### print_compact ``` print_compact(file=None) ``` Print a compact version of the error to stderr or specified file. Source code in `src/interactive_pipe/core/engine.py` ``` def print_compact(self, file=None): """Print a compact version of the error to stderr or specified file.""" if file is None: file = sys.stderr print(f"\n{'=' * 60}", file=file) print("PIPELINE ERROR", file=file) print(f"{'=' * 60}", file=file) print(str(self), file=file) print(f"{'=' * 60}\n", file=file) ``` # Controls & panels ## Control Binds a GUI widget to a filter keyword argument. The widget type is inferred from the type of `value_default` and the presence of `value_range`: - `bool` -> checkbox (`value_range` must be None) - `int` / `float` with a 2-element `value_range` -> slider - `int` / `float` without `value_range` -> free numeric parameter - `str` with a list of choices as `value_range` -> dropdown - `str` without `value_range` -> free text prompt Parameters: | Name | Type | Description | Default | | --------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------- | | `value_default` | `Union[int, float, bool, str]` | Initial value; its type selects the widget (int, float, bool or str). | *required* | | `value_range` | `Optional[List[Union[int, float, str]]]` | [min, max] for numeric sliders, or the list of choices for a string dropdown. Must be None for bool. | `None` | | `name` | `Optional[str]` | Label displayed next to the widget. Auto-generated if omitted. | `None` | | `step` | `Optional[Union[int, float]]` | Slider increment. Defaults to 1 for int sliders and 1/100 of the range for float sliders. | `None` | | `filter_to_connect` | `Optional[FilterCore]` | Filter to connect immediately (requires parameter_name_to_connect). Usually left to the @interactive decorator. | `None` | | `parameter_name_to_connect` | `Optional[str]` | Keyword argument of the connected filter. | `None` | | `icons` | `Optional[List]` | Icon image paths (one per choice) for string dropdowns. | `None` | | `group` | `Optional[Union[str, 'Panel']]` | Panel instance or panel name used to group controls; same-named string groups share one panel across filters. | `None` | | `tooltip` | `Optional[str]` | Hover text displayed on the widget. | `None` | Example ``` @interactive( gain=Control(1.0, [0.0, 3.0]), # float slider flip=Control(False), # checkbox mode=Control("dark", ["dark", "light"]), # dropdown ) def my_filter(img, gain=1.0, flip=False, mode="dark"): ... ``` Source code in `src/interactive_pipe/headless/control.py` ```` class Control: """Binds a GUI widget to a filter keyword argument. The widget type is inferred from the type of ``value_default`` and the presence of ``value_range``: - ``bool`` -> checkbox (``value_range`` must be None) - ``int`` / ``float`` with a 2-element ``value_range`` -> slider - ``int`` / ``float`` without ``value_range`` -> free numeric parameter - ``str`` with a list of choices as ``value_range`` -> dropdown - ``str`` without ``value_range`` -> free text prompt Args: value_default: Initial value; its type selects the widget (int, float, bool or str). value_range: ``[min, max]`` for numeric sliders, or the list of choices for a string dropdown. Must be None for bool. name: Label displayed next to the widget. Auto-generated if omitted. step: Slider increment. Defaults to 1 for int sliders and 1/100 of the range for float sliders. filter_to_connect: Filter to connect immediately (requires ``parameter_name_to_connect``). Usually left to the ``@interactive`` decorator. parameter_name_to_connect: Keyword argument of the connected filter. icons: Icon image paths (one per choice) for string dropdowns. group: ``Panel`` instance or panel name used to group controls; same-named string groups share one panel across filters. tooltip: Hover text displayed on the widget. Example: ```python @interactive( gain=Control(1.0, [0.0, 3.0]), # float slider flip=Control(False), # checkbox mode=Control("dark", ["dark", "light"]), # dropdown ) def my_filter(img, gain=1.0, flip=False, mode="dark"): ... ``` """ counter = 0 _registry = {} # Global registry to store controls for each function # Process-wide by design: same-named string groups share one Panel across filters _panel_cache = {} @staticmethod def registry_key(func: Callable) -> str: # Keyed by module + qualname so same-named functions in different # modules do not collide. functools.wraps preserves both, so the key # is identical for a decorated function and its wrapper. return f"{func.__module__}.{func.__qualname__}" @classmethod def register(cls, func, param_name, control_instance): cls._registry.setdefault(cls.registry_key(func), {})[param_name] = control_instance @classmethod def get_controls(cls, func): if not isinstance(func, Callable): raise TypeError(f"get_controls expects the function object, got {type(func)}") return cls._registry.get(cls.registry_key(func), {}) def __init__( self, value_default: Union[int, float, bool, str], value_range: Optional[List[Union[int, float, str]]] = None, name: Optional[str] = None, step: Optional[Union[int, float]] = None, filter_to_connect: Optional[FilterCore] = None, parameter_name_to_connect: Optional[str] = None, icons: Optional[List] = None, group: Optional[Union[str, "Panel"]] = None, tooltip: Optional[str] = None, ) -> None: self.value_default = value_default self._type = None self._auto_named = False self.step = step self.tooltip = tooltip # Convert string to Panel for backward compatibility # Use cached Panel if same string name was used before if isinstance(group, str): from interactive_pipe.headless.panel import Panel if group not in Control._panel_cache: Control._panel_cache[group] = Panel(name=group) self.panel = Control._panel_cache[group] else: self.panel = group if isinstance(value_default, bool): self._type = bool self.step = 1 if value_range is not None: raise ValueError("value_range must be None for bool type") elif isinstance(value_default, (float, int)): if value_range is None: # free range parameter! self._type = int if isinstance(value_default, int) else float else: if not isinstance(value_range, (list, tuple)): raise TypeError(f"value_range must be a list or tuple, got {type(value_range)}") if len(value_range) != 2: raise ValueError(f"value_range must have exactly 2 elements, got {len(value_range)}") for choice in value_range: if not isinstance(choice, (float, int)): raise TypeError(f"value_range elements must be int or float, got {type(choice)}") if ( isinstance(value_default, int) and isinstance(value_range[0], int) and isinstance(value_range[1], int) ): if self.step is None: self.step = 1 self._type = int else: self._type = float if self.step is None: # Type narrowing: we know value_range contains numeric values here self.step = (value_range[1] - value_range[0]) / 100.0 # type: ignore if not (value_range[0] <= value_default <= value_range[1]): # type: ignore raise ValueError(f"value_default {value_default} must be within value_range {value_range}") elif isinstance(value_default, str): # similar to an enum if value_range is None: logging.debug("string prompt only - no range") self._type = str else: if not value_range: raise ValueError("value_range cannot be empty for string type") if not isinstance(value_range, (list, tuple)): raise TypeError(f"value_range must be a list or tuple, got {type(value_range)}") for choice in value_range: if not isinstance(choice, str): raise TypeError(f"value_range elements must be strings, got {type(choice)}") if value_default not in value_range: raise ValueError(f"{value_default} must be in {value_range}") self._type = str if self.step is None: self.step = 1 else: raise TypeError(f"Wrong value type: {type(value_default)}, expected int/float/bool/str") self.value_range = value_range # init current value self.value = value_default self.icons = icons if self.icons is not None: for icon in self.icons: if isinstance(icon, str): icon = Path(icon) if not icon.exists(): raise FileNotFoundError(f"Icon file not found: {icon}") if name is None: self._auto_named = True self.name = f"parameter {Control.counter}" else: self.name = name if not isinstance(self.name, str): raise TypeError(f"name must be a string, got {type(self.name)}") Control.counter += 1 if filter_to_connect is not None: if parameter_name_to_connect is None: raise ValueError("parameter_name_to_connect is required when filter_to_connect is provided") self.connect_filter(filter_to_connect, parameter_name_to_connect) else: self.update_param_func = None self.parameter_name_to_connect = parameter_name_to_connect self.filter_to_connect = filter_to_connect # Register with panel if provided if self.panel is not None: self.panel._register_control(self) def check_value(self, value): if isinstance(value, int) and self._type is float: value = float(value) elif isinstance(value, float) and self._type is int: # Convert float to int (e.g., from matplotlib sliders that return floats) value = int(round(value)) if self._type is not None and not isinstance(value, self._type): raise TypeError(f"Expected {self._type}, got {type(value)}") if isinstance(value, (float, int)) and self.value_range is not None: return max(self.value_range[0], min(value, self.value_range[1])) # type: ignore elif self._type is str and self.value_range is not None: if value not in self.value_range: raise ValueError(f"{value} must be in {self.value_range}") return value else: return value def __repr__(self) -> str: if self._type in [float, int]: if self.value_range: return ( f"{self.name} | {self.value} - range {self.value_range} " f"default = {self.value_default} type: {self._type} - step={self.step}" ) else: return ( f"{self.name} | {self.value} - RANGELESS - default = {self.value_default} " f"type: {self._type} - step={self.step}" ) elif self._type is bool: return f"{self.name} | Bool {self.value} - default {self.value_default}" elif self._type is str: return ( f"{self.name} | {self.value} - choices {self.value_range} default = {self.value_default} " f"type: {self._type} - step={self.step}" ) else: raise NotImplementedError @property def value(self): return self._value @value.setter def value(self, value=None): self._value = deepcopy(self.check_value(value) if value is not None else self.value_default) def reset(self): self.value = None def update(self, new_value): # Plug button self.value = new_value if self.update_param_func is not None: self.update_param_func(self.value) def _clone_unconnected(self, name: str) -> "Control": """Shallow clone for a repeated filter instance: same value spec, panel and widget type, but a fresh name, the default value and no filter connection (the pipeline connects it to the repeat).""" clone = copy(self) clone.name = name clone.filter_to_connect = None clone.parameter_name_to_connect = None clone.update_param_func = None clone.reset() if clone.panel is not None: clone.panel._register_control(clone) return clone def connect_parameter(self, update_param_func: Callable): self.update_param_func = update_param_func def connect_filter(self, filt: FilterCore, parameter_name): def update_param_func(val): logging.info(f"update filter {filt.name} - param {parameter_name} - value {val}") filt.values = {parameter_name: val} self.update_param_func = update_param_func self.parameter_name_to_connect = parameter_name self.filter_to_connect = filt ```` ## CircularControl Bases: `Control` Circular (dial) variant of a numeric slider. Behaves like a numeric `Control` but is rendered as a rotary dial. With `modulo=True` the value wraps around at the `value_range` bounds instead of saturating (useful for angles). Source code in `src/interactive_pipe/headless/control.py` ``` class CircularControl(Control): """Circular (dial) variant of a numeric slider. Behaves like a numeric ``Control`` but is rendered as a rotary dial. With ``modulo=True`` the value wraps around at the ``value_range`` bounds instead of saturating (useful for angles). """ def __init__( self, value_default: Union[int, float], value_range: Optional[List[Union[int, float]]] = None, modulo: bool = True, name: Optional[str] = None, step: Optional[Union[int, float]] = None, filter_to_connect: Optional[FilterCore] = None, parameter_name_to_connect: Optional[str] = None, group: Optional[Union[str, Panel]] = None, tooltip: Optional[str] = None, ) -> None: super().__init__( value_default=value_default, value_range=value_range, # type: ignore name=name, step=step, filter_to_connect=filter_to_connect, parameter_name_to_connect=parameter_name_to_connect, icons=None, group=group, tooltip=tooltip, ) self.modulo = modulo def __repr__(self) -> str: return super().__repr__() + f"| modulo:{self.modulo}" ``` ## TextPrompt Bases: `Control` Free-text entry box bound to a string keyword argument. Unlike a string `Control` with a `value_range` (a dropdown of fixed choices), a `TextPrompt` accepts arbitrary text typed by the user. Source code in `src/interactive_pipe/headless/control.py` ``` class TextPrompt(Control): """Free-text entry box bound to a string keyword argument. Unlike a string ``Control`` with a ``value_range`` (a dropdown of fixed choices), a ``TextPrompt`` accepts arbitrary text typed by the user. """ def __init__( self, value_default: str, name: Optional[str] = None, filter_to_connect: Optional[FilterCore] = None, parameter_name_to_connect: Optional[str] = None, group: Optional[Union[str, Panel]] = None, tooltip: Optional[str] = None, ) -> None: super().__init__( value_default=value_default, value_range=None, name=name, step=None, filter_to_connect=filter_to_connect, parameter_name_to_connect=parameter_name_to_connect, icons=None, group=group, tooltip=tooltip, ) def __repr__(self) -> str: return super().__repr__() ``` ## TimeControl Bases: `Control` Auto-incrementing time control for animations. Starts at 0.0 and increments automatically. Press 'p' to pause/resume. Note Only supported on Qt backend. Falls back to a regular slider on other backends (tooltip/group params are only relevant in that fallback case). Parameters: | Name | Type | Description | Default | | -------------------- | ----- | --------------------------------------------------------- | ------- | | `update_interval_ms` | `int` | Interval between updates in milliseconds (default: 1000). | `1000` | | `pause_resume_key` | `str` | Key to pause/resume (default: "p"). | `'p'` | Example ``` @interactive(time=TimeControl(update_interval_ms=50)) def animate(time=0.0): return time ``` Source code in `src/interactive_pipe/headless/control.py` ```` class TimeControl(Control): """ Auto-incrementing time control for animations. Starts at 0.0 and increments automatically. Press 'p' to pause/resume. Note: Only supported on Qt backend. Falls back to a regular slider on other backends (tooltip/group params are only relevant in that fallback case). Args: update_interval_ms: Interval between updates in milliseconds (default: 1000). pause_resume_key: Key to pause/resume (default: "p"). Example: ```python @interactive(time=TimeControl(update_interval_ms=50)) def animate(time=0.0): return time ``` """ def __init__( self, name: Optional[str] = None, update_interval_ms: int = 1000, pause_resume_key: str = "p", filter_to_connect: Optional[FilterCore] = None, parameter_name_to_connect: Optional[str] = None, group: Optional[Union[str, Panel]] = None, tooltip: Optional[str] = None, ) -> None: """Time control. Start at 0.0. Time can be paused/resumed""" super().__init__( value_default=0.0, value_range=[0.0, 3600.0], name=name, step=None, filter_to_connect=filter_to_connect, parameter_name_to_connect=parameter_name_to_connect, icons=None, group=group, tooltip=tooltip, ) self.update_interval_ms = update_interval_ms self.pause_resume_key = pause_resume_key def __repr__(self) -> str: return super().__repr__() ```` ### __init__ ``` __init__(name: Optional[str] = None, update_interval_ms: int = 1000, pause_resume_key: str = 'p', filter_to_connect: Optional[FilterCore] = None, parameter_name_to_connect: Optional[str] = None, group: Optional[Union[str, Panel]] = None, tooltip: Optional[str] = None) -> None ``` Time control. Start at 0.0. Time can be paused/resumed Source code in `src/interactive_pipe/headless/control.py` ``` def __init__( self, name: Optional[str] = None, update_interval_ms: int = 1000, pause_resume_key: str = "p", filter_to_connect: Optional[FilterCore] = None, parameter_name_to_connect: Optional[str] = None, group: Optional[Union[str, Panel]] = None, tooltip: Optional[str] = None, ) -> None: """Time control. Start at 0.0. Time can be paused/resumed""" super().__init__( value_default=0.0, value_range=[0.0, 3600.0], name=name, step=None, filter_to_connect=filter_to_connect, parameter_name_to_connect=parameter_name_to_connect, icons=None, group=group, tooltip=tooltip, ) self.update_interval_ms = update_interval_ms self.pause_resume_key = pause_resume_key ``` ## KeyboardControl Bases: `Control` Control driven by keyboard keys instead of a slider widget. Pressing `keydown` decreases the value by `step` (or moves to the previous choice for a string control); `keyup` increases it. A bool control toggles on each `keydown` press. Special keys such as arrows, page up/down, spacebar and F1-F12 are supported (see `SPECIAL_KEYS_LIST`); other keys are single characters. Parameters: | Name | Type | Description | Default | | --------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------- | ---------- | | `value_default` | `Union[int, float, bool, str]` | Initial value; its type selects the behavior (int, float, bool or str). | *required* | | `value_range` | `Optional[List[Union[int, float, str]]]` | [min, max] for numeric values, or the list of choices for a string control. | `None` | | `keydown` | `Optional[str]` | Key that decreases the value / toggles a bool. | `None` | | `keyup` | `Optional[str]` | Key that increases the value. Omit for a toggle-only binding. | `None` | | `modulo` | `bool` | When True, the value wraps around at the range bounds instead of saturating. | `False` | | `name` | `Optional[str]` | Label of the control. Auto-generated if omitted. | `None` | | `step` | `Optional[Union[int, float]]` | Increment applied on each key press (same defaults as Control). | `None` | | `filter_to_connect` | `Optional[FilterCore]` | Filter to connect immediately (requires parameter_name_to_connect). | `None` | | `parameter_name_to_connect` | `Optional[str]` | Keyword argument of the connected filter. | `None` | | `group` | `Optional[Union[str, Panel]]` | Panel instance or panel name used to group controls. | `None` | Example ``` @interactive( gain=KeyboardControl(1.0, [0.0, 3.0], keydown="g", keyup="h") ) def amplify(img, gain=1.0): return gain * img ``` Source code in `src/interactive_pipe/headless/keyboard.py` ```` class KeyboardControl(Control): """Control driven by keyboard keys instead of a slider widget. Pressing ``keydown`` decreases the value by ``step`` (or moves to the previous choice for a string control); ``keyup`` increases it. A bool control toggles on each ``keydown`` press. Special keys such as arrows, page up/down, spacebar and F1-F12 are supported (see ``SPECIAL_KEYS_LIST``); other keys are single characters. Args: value_default: Initial value; its type selects the behavior (int, float, bool or str). value_range: ``[min, max]`` for numeric values, or the list of choices for a string control. keydown: Key that decreases the value / toggles a bool. keyup: Key that increases the value. Omit for a toggle-only binding. modulo: When True, the value wraps around at the range bounds instead of saturating. name: Label of the control. Auto-generated if omitted. step: Increment applied on each key press (same defaults as ``Control``). filter_to_connect: Filter to connect immediately (requires ``parameter_name_to_connect``). parameter_name_to_connect: Keyword argument of the connected filter. group: ``Panel`` instance or panel name used to group controls. Example: ```python @interactive( gain=KeyboardControl(1.0, [0.0, 3.0], keydown="g", keyup="h") ) def amplify(img, gain=1.0): return gain * img ``` """ KEY_UP = "up" KEY_DOWN = "down" KEY_LEFT = "left" KEY_RIGHT = "right" KEY_PAGEUP = "pageup" KEY_PAGEDOWN = "pagedown" KEY_SPACEBAR = " " SPECIAL_KEYS_LIST = [ KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, KEY_PAGEUP, KEY_PAGEDOWN, KEY_SPACEBAR, ] + [f"f{i}" for i in range(1, 13)] def __init__( self, value_default: Union[int, float, bool, str], value_range: Optional[List[Union[int, float, str]]] = None, keydown: Optional[str] = None, keyup: Optional[str] = None, modulo: bool = False, name: Optional[str] = None, step: Optional[Union[int, float]] = None, filter_to_connect: Optional[FilterCore] = None, parameter_name_to_connect: Optional[str] = None, group: Optional[Union[str, Panel]] = None, ) -> None: super().__init__( value_default=value_default, value_range=value_range, name=name, step=step, filter_to_connect=filter_to_connect, parameter_name_to_connect=parameter_name_to_connect, icons=None, group=group, ) self.keyup = keyup self.keydown = keydown self.modulo = modulo def on_key(self, down=True): if self._type is bool: new_val = not self.value self.value = new_val return if self._type is int or self._type is float: if self.value_range is None: return current_val = self.value sign = -1 if down else +1 step = self.step mini, maxi = self.value_range[0], self.value_range[1] # type: ignore elif self._type is str: if self.value_range is None: return current_val = self.value_range.index(self.value) # type: ignore sign = -1 if down else +1 step = 1 mini, maxi = 0, len(self.value_range) - 1 else: return new_val = current_val + sign * step # type: ignore if new_val > maxi: # type: ignore new_val = mini if self.modulo else maxi if new_val < mini: # type: ignore new_val = maxi if self.modulo else mini if self._type is str and self.value_range is not None: new_val = self.value_range[int(new_val)] # type: ignore self.value = new_val def on_key_down(self): self.on_key(down=True) def on_key_up(self): if self.keyup is not None: self.on_key(down=False) def __repr__(self) -> str: return ( super().__repr__() + f" | down:{'' if self.keydown is None else self.keydown} | " + f"up:{'' if self.keyup is None else self.keyup} | modulo:{self.modulo}" ) @staticmethod def sanity_check_key(key): if not isinstance(key, str): raise TypeError(f"key must be a string, got {type(key)}") key = key.lower() if len(key) == 0: raise ValueError("key cannot be empty") if len(key) > 1: if key not in KeyboardControl.SPECIAL_KEYS_LIST: raise ValueError(f"key '{key}' is not supported. Use one of {KeyboardControl.SPECIAL_KEYS_LIST}") return key ```` ## Panel Panel for organizing controls into groups with optional nesting and grid layouts. Panels can contain: - Controls (assigned via Control(..., group=panel) or Control(..., group="name")) - Other Panels (nested hierarchy) - Grid layouts (via list of lists) Example ``` # Simple panel text_panel = Panel("Text Settings") # Collapsible panel color_panel = Panel("Colors", collapsible=True, collapsed=False) # Detached panel (Qt backend only - opens in separate window) tools_panel = Panel("Tools", detached=True, detached_size=(400, 600)) # Panel with position (left, right, top, or bottom) left_panel = Panel("Tools", position="left") right_panel = Panel("Settings", position="right") # Nested panels with grid layout main_panel = Panel("Main").add_elements([ [text_panel, color_panel], # Row 1: side by side [effects_panel], # Row 2: full width ]) ``` Source code in `src/interactive_pipe/headless/panel.py` ```` class Panel: """Panel for organizing controls into groups with optional nesting and grid layouts. Panels can contain: - Controls (assigned via Control(..., group=panel) or Control(..., group="name")) - Other Panels (nested hierarchy) - Grid layouts (via list of lists) Example: ```python # Simple panel text_panel = Panel("Text Settings") # Collapsible panel color_panel = Panel("Colors", collapsible=True, collapsed=False) # Detached panel (Qt backend only - opens in separate window) tools_panel = Panel("Tools", detached=True, detached_size=(400, 600)) # Panel with position (left, right, top, or bottom) left_panel = Panel("Tools", position="left") right_panel = Panel("Settings", position="right") # Nested panels with grid layout main_panel = Panel("Main").add_elements([ [text_panel, color_panel], # Row 1: side by side [effects_panel], # Row 2: full width ]) ``` """ def __init__( self, name: Optional[str] = None, collapsible: bool = False, collapsed: bool = False, detached: bool = False, detached_size: Optional[tuple] = None, position: Optional[str] = None, ) -> None: """Initialize a Panel. Args: name: Display name for the panel (shown in group box title) collapsible: Whether the panel can be collapsed/expanded collapsed: Initial collapsed state (only used if collapsible=True) detached: Whether to render panel in a separate window (Qt backend only) detached_size: Optional (width, height) tuple for detached window size position: Position relative to images - "left", "right", "top", "bottom", or None (defaults to "bottom") """ # Validate position valid_positions = {None, "left", "right", "top", "bottom"} if position not in valid_positions: raise ValueError(f"position must be one of {valid_positions}, got {position}") self.name = name self.collapsible = collapsible self.collapsed = collapsed self.detached = detached self.detached_size = detached_size self.position = position self.elements = [] # List of Panels or list of lists (grid) self.parent = None # Parent panel in hierarchy self._controls = [] # Controls assigned to this panel def add_elements(self, elements: Union[List["Panel"], List[List["Panel"]]]) -> "Panel": """Add child panels with optional grid layout. Args: elements: Can be: - List of Panels: [panel1, panel2] - vertical stack - List of lists: [[panel1, panel2], [panel3]] - grid layout Returns: self for method chaining Example: ```python main_panel.add_elements([ [text_panel, color_panel], # Row 1 [effects_panel], # Row 2 ]) ``` """ self.elements = elements self._set_parent_refs() return self def _set_parent_refs(self) -> None: """Set parent references for all child panels.""" if not self.elements: return # Check if grid layout (list of lists) if isinstance(self.elements[0], list): # Grid layout - cast to List[List[Panel]] for type checker grid_elements = cast(List[List["Panel"]], self.elements) for row in grid_elements: for panel in row: if isinstance(panel, Panel): panel.parent = self else: # Flat list - cast to List[Panel] for type checker flat_elements = cast(List["Panel"], self.elements) for panel in flat_elements: if isinstance(panel, Panel): panel.parent = self def _register_control(self, control) -> None: """Called when a control is assigned to this panel. Args: control: The Control instance being registered """ if control not in self._controls: self._controls.append(control) def __repr__(self) -> str: name_str = f'"{self.name}"' if self.name else "None" controls_count = len(self._controls) elements_count = len(self.elements) if self.elements else 0 detached_str = f", detached={self.detached}" if self.detached else "" position_str = f", position={self.position}" if self.position else "" return ( f"Panel(name={name_str}, collapsible={self.collapsible}, " f"collapsed={self.collapsed}{detached_str}{position_str}, " f"controls={controls_count}, elements={elements_count})" ) def __eq__(self, other) -> bool: """Panels are equal if they're the same object (for deduplication).""" return self is other def __hash__(self) -> int: """Use object id for hashing (for set/dict usage).""" return id(self) def get_root(self) -> "Panel": """Get the root panel in the hierarchy (walks up parent chain).""" current = self while current.parent is not None: current = current.parent return current ```` ### __init__ ``` __init__(name: Optional[str] = None, collapsible: bool = False, collapsed: bool = False, detached: bool = False, detached_size: Optional[tuple] = None, position: Optional[str] = None) -> None ``` Initialize a Panel. Parameters: | Name | Type | Description | Default | | --------------- | ----------------- | ---------------------------------------------------------------------------------------------- | ------- | | `name` | `Optional[str]` | Display name for the panel (shown in group box title) | `None` | | `collapsible` | `bool` | Whether the panel can be collapsed/expanded | `False` | | `collapsed` | `bool` | Initial collapsed state (only used if collapsible=True) | `False` | | `detached` | `bool` | Whether to render panel in a separate window (Qt backend only) | `False` | | `detached_size` | `Optional[tuple]` | Optional (width, height) tuple for detached window size | `None` | | `position` | `Optional[str]` | Position relative to images - "left", "right", "top", "bottom", or None (defaults to "bottom") | `None` | Source code in `src/interactive_pipe/headless/panel.py` ``` def __init__( self, name: Optional[str] = None, collapsible: bool = False, collapsed: bool = False, detached: bool = False, detached_size: Optional[tuple] = None, position: Optional[str] = None, ) -> None: """Initialize a Panel. Args: name: Display name for the panel (shown in group box title) collapsible: Whether the panel can be collapsed/expanded collapsed: Initial collapsed state (only used if collapsible=True) detached: Whether to render panel in a separate window (Qt backend only) detached_size: Optional (width, height) tuple for detached window size position: Position relative to images - "left", "right", "top", "bottom", or None (defaults to "bottom") """ # Validate position valid_positions = {None, "left", "right", "top", "bottom"} if position not in valid_positions: raise ValueError(f"position must be one of {valid_positions}, got {position}") self.name = name self.collapsible = collapsible self.collapsed = collapsed self.detached = detached self.detached_size = detached_size self.position = position self.elements = [] # List of Panels or list of lists (grid) self.parent = None # Parent panel in hierarchy self._controls = [] # Controls assigned to this panel ``` ### add_elements ``` add_elements(elements: Union[List[Panel], List[List[Panel]]]) -> Panel ``` Add child panels with optional grid layout. Parameters: | Name | Type | Description | Default | | ---------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------- | | `elements` | `Union[List[Panel], List[List[Panel]]]` | Can be: - List of Panels: [panel1, panel2] - vertical stack - List of lists: \[[panel1, panel2], [panel3]\] - grid layout | *required* | Returns: | Type | Description | | ------- | ------------------------ | | `Panel` | self for method chaining | Example ``` main_panel.add_elements([ [text_panel, color_panel], # Row 1 [effects_panel], # Row 2 ]) ``` Source code in `src/interactive_pipe/headless/panel.py` ```` def add_elements(self, elements: Union[List["Panel"], List[List["Panel"]]]) -> "Panel": """Add child panels with optional grid layout. Args: elements: Can be: - List of Panels: [panel1, panel2] - vertical stack - List of lists: [[panel1, panel2], [panel3]] - grid layout Returns: self for method chaining Example: ```python main_panel.add_elements([ [text_panel, color_panel], # Row 1 [effects_panel], # Row 2 ]) ``` """ self.elements = elements self._set_parent_refs() return self ```` ### __eq__ ``` __eq__(other) -> bool ``` Panels are equal if they're the same object (for deduplication). Source code in `src/interactive_pipe/headless/panel.py` ``` def __eq__(self, other) -> bool: """Panels are equal if they're the same object (for deduplication).""" return self is other ``` ### __hash__ ``` __hash__() -> int ``` Use object id for hashing (for set/dict usage). Source code in `src/interactive_pipe/headless/panel.py` ``` def __hash__(self) -> int: """Use object id for hashing (for set/dict usage).""" return id(self) ``` ### get_root ``` get_root() -> Panel ``` Get the root panel in the hierarchy (walks up parent chain). Source code in `src/interactive_pipe/headless/panel.py` ``` def get_root(self) -> "Panel": """Get the root panel in the hierarchy (walks up parent chain).""" current = self while current.parent is not None: current = current.parent return current ``` # Data objects Return these from filters to control how outputs are displayed. ## Image Bases: `Data` Image payload with save, load and display helpers. Wraps a float numpy array with values expected in `[0, 1]`, of shape `(H, W)` (grayscale) or `(H, W, 3)` (RGB). Saving and loading go through Pillow or OpenCV, whichever is installed; pass `backend="pillow"` or `backend="opencv"` to force one. Parameters: | Name | Type | Description | Default | | ------- | --------- | -------------------------------------------------------------------- | ---------- | | `data` | `ndarray` | Image array, float values in [0, 1]. | *required* | | `title` | `str` | Title used when displaying or appended to the file stem when saving. | `''` | Source code in `src/interactive_pipe/data_objects/image.py` ``` class Image(Data): """Image payload with save, load and display helpers. Wraps a float numpy array with values expected in ``[0, 1]``, of shape ``(H, W)`` (grayscale) or ``(H, W, 3)`` (RGB). Saving and loading go through Pillow or OpenCV, whichever is installed; pass ``backend="pillow"`` or ``backend="opencv"`` to force one. Args: data: Image array, float values in ``[0, 1]``. title: Title used when displaying or appended to the file stem when saving. """ def __init__(self, data: np.ndarray, title: str = "") -> None: super().__init__(data) self.title = title self.path = None def _set_file_extensions(self): self.file_extensions = [".png", ".jpg", ".tif"] def _save(self, path: Path, backend=None): if path is None: raise ValueError("Save requires a path") if self.title is not None: self.path = self.append_with_stem(path, self.title) else: self.path = path self.save_image(self.data, self.path, backend=backend) def _load(self, path: Path, backend=None, title=None) -> np.ndarray: if title is not None: self.title = title self.path = path return self.load_image(path, backend=backend) @staticmethod def save_image(data: np.ndarray, path: Path, precision: int = 8, backend: Optional[str] = None) -> None: """Save a ``[0, 1]`` float image array to disk. Args: data: Image array, float values in ``[0, 1]``. path: Destination file path (extension picks the format). precision: Bit depth of the written file (8 or 16; Pillow supports 8 only). backend: ``"pillow"`` or ``"opencv"``; auto-selected when None. """ backend = _resolve_image_backend(backend) if backend == IMAGE_BACKEND_OPENCV: Image.save_image_cv2(data, path, precision) if backend == IMAGE_BACKEND_PILLOW: Image.save_image_PIL(data, path, precision) @staticmethod def rescale_dynamic(data: np.ndarray, precision: int = 8) -> np.ndarray: """Scale a ``[0, 1]`` float image to integer dynamic (``[0, 2^precision - 1]``).""" if len(data.shape) == 2: # Black & white image data = np.expand_dims(data, axis=-1) # add channel dimension data = np.repeat(data, 3, axis=-1) # repeat for RGB amplitude = 2**precision - 1 return np.round(data * amplitude).clip(0, amplitude) @staticmethod def normalize_dynamic(img: np.ndarray, precision: int = 8) -> np.ndarray: """Scale an integer image (``[0, 2^precision - 1]``) to ``[0, 1]`` floats.""" return img / (2.0**precision - 1) # scale image data to [0, 1] @staticmethod def save_image_cv2(data, path: Path, precision=8): if not isinstance(path, Path): raise TypeError(f"path must be a Path object, got {type(path)}") out = Image.rescale_dynamic(data, precision=precision) out = out.astype(np.uint8 if precision == 8 else np.uint16) out = cv2.cvtColor(out, cv2.COLOR_BGR2RGB) cv2.imwrite(str(path), out) @staticmethod def save_image_PIL(data, path: Path, precision=8): if precision != 8: raise ValueError(f"PIL backend requires precision=8, got {precision}") if not isinstance(path, Path): raise TypeError(f"path must be a Path object, got {type(path)}") out = Image.rescale_dynamic(data, precision=precision) out = out.astype(np.uint8) # PIL requires image data in uint8 format out = PilImage.fromarray(out, "RGB") out.save(str(path)) @staticmethod def load_image_cv2(path: Path, precision=8) -> np.ndarray: img = cv2.imread(str(path)) if img is None: raise ValueError(f"Could not load image from {path}") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # type: ignore return Image.normalize_dynamic(img, precision=precision) @staticmethod def load_image_PIL(path: Path, precision=8) -> np.ndarray: img = PilImage.open(path) return Image.normalize_dynamic(np.array(img), precision=precision) @staticmethod def load_image(path: Path, precision: int = 8, backend: Optional[str] = None) -> np.ndarray: """Load an image file as a ``[0, 1]`` float RGB array. Args: path: Image file path. precision: Bit depth of the stored file used for normalization. backend: ``"pillow"`` or ``"opencv"``; auto-selected when None. """ backend = _resolve_image_backend(backend) if backend == IMAGE_BACKEND_OPENCV: return Image.load_image_cv2(path) return Image.load_image_PIL(path, precision) def show(self) -> None: """Display the image in a matplotlib figure (title includes the shape).""" plt.figure() plt.imshow(self.data) ttl = (f"{self.title} -" if self.title else "") + f"{self.data.shape}" plt.title(ttl) plt.show() ``` ### save_image ``` save_image(data: ndarray, path: Path, precision: int = 8, backend: Optional[str] = None) -> None ``` Save a `[0, 1]` float image array to disk. Parameters: | Name | Type | Description | Default | | ----------- | --------------- | ---------------------------------------------------------------- | ---------- | | `data` | `ndarray` | Image array, float values in [0, 1]. | *required* | | `path` | `Path` | Destination file path (extension picks the format). | *required* | | `precision` | `int` | Bit depth of the written file (8 or 16; Pillow supports 8 only). | `8` | | `backend` | `Optional[str]` | "pillow" or "opencv"; auto-selected when None. | `None` | Source code in `src/interactive_pipe/data_objects/image.py` ``` @staticmethod def save_image(data: np.ndarray, path: Path, precision: int = 8, backend: Optional[str] = None) -> None: """Save a ``[0, 1]`` float image array to disk. Args: data: Image array, float values in ``[0, 1]``. path: Destination file path (extension picks the format). precision: Bit depth of the written file (8 or 16; Pillow supports 8 only). backend: ``"pillow"`` or ``"opencv"``; auto-selected when None. """ backend = _resolve_image_backend(backend) if backend == IMAGE_BACKEND_OPENCV: Image.save_image_cv2(data, path, precision) if backend == IMAGE_BACKEND_PILLOW: Image.save_image_PIL(data, path, precision) ``` ### rescale_dynamic ``` rescale_dynamic(data: ndarray, precision: int = 8) -> np.ndarray ``` Scale a `[0, 1]` float image to integer dynamic (`[0, 2^precision - 1]`). Source code in `src/interactive_pipe/data_objects/image.py` ``` @staticmethod def rescale_dynamic(data: np.ndarray, precision: int = 8) -> np.ndarray: """Scale a ``[0, 1]`` float image to integer dynamic (``[0, 2^precision - 1]``).""" if len(data.shape) == 2: # Black & white image data = np.expand_dims(data, axis=-1) # add channel dimension data = np.repeat(data, 3, axis=-1) # repeat for RGB amplitude = 2**precision - 1 return np.round(data * amplitude).clip(0, amplitude) ``` ### normalize_dynamic ``` normalize_dynamic(img: ndarray, precision: int = 8) -> np.ndarray ``` Scale an integer image (`[0, 2^precision - 1]`) to `[0, 1]` floats. Source code in `src/interactive_pipe/data_objects/image.py` ``` @staticmethod def normalize_dynamic(img: np.ndarray, precision: int = 8) -> np.ndarray: """Scale an integer image (``[0, 2^precision - 1]``) to ``[0, 1]`` floats.""" return img / (2.0**precision - 1) # scale image data to [0, 1] ``` ### load_image ``` load_image(path: Path, precision: int = 8, backend: Optional[str] = None) -> np.ndarray ``` Load an image file as a `[0, 1]` float RGB array. Parameters: | Name | Type | Description | Default | | ----------- | --------------- | ---------------------------------------------------- | ---------- | | `path` | `Path` | Image file path. | *required* | | `precision` | `int` | Bit depth of the stored file used for normalization. | `8` | | `backend` | `Optional[str]` | "pillow" or "opencv"; auto-selected when None. | `None` | Source code in `src/interactive_pipe/data_objects/image.py` ``` @staticmethod def load_image(path: Path, precision: int = 8, backend: Optional[str] = None) -> np.ndarray: """Load an image file as a ``[0, 1]`` float RGB array. Args: path: Image file path. precision: Bit depth of the stored file used for normalization. backend: ``"pillow"`` or ``"opencv"``; auto-selected when None. """ backend = _resolve_image_backend(backend) if backend == IMAGE_BACKEND_OPENCV: return Image.load_image_cv2(path) return Image.load_image_PIL(path, precision) ``` ### show ``` show() -> None ``` Display the image in a matplotlib figure (title includes the shape). Source code in `src/interactive_pipe/data_objects/image.py` ``` def show(self) -> None: """Display the image in a matplotlib figure (title includes the shape).""" plt.figure() plt.imshow(self.data) ttl = (f"{self.title} -" if self.title else "") + f"{self.data.shape}" plt.title(ttl) plt.show() ``` ## Curve Bases: `Data` Attributes - `.grid` - `.xlabel` - `.ylabel` - `.xlim` - `.ylim` - `.title` - `.curves` ``` It is possible to access a curve by simply using the bracket operator `.curves[index]` is equivalent to `[index]` ``` Source code in `src/interactive_pipe/data_objects/curves.py` ``` class Curve(Data): """ Attributes - `.grid` - `.xlabel` - `.ylabel` - `.xlim` - `.ylim` - `.title` - `.curves` It is possible to access a curve by simply using the bracket operator `.curves[index]` is equivalent to `[index]` """ def __init__( self, curves: Union[ None, SingleCurve, np.ndarray, dict, List[Union[SingleCurve, list, tuple, dict, np.ndarray]], Tuple[Union[SingleCurve, list, tuple, dict, np.ndarray], ...], ], xlabel: Optional[str] = None, ylabel: Optional[str] = None, title: Optional[str] = None, grid: Optional[bool] = None, xlim: Optional[Tuple[Union[int, float], Union[int, float]]] = None, ylim: Optional[Tuple[Union[int, float], Union[int, float]]] = None, ) -> None: if curves is None: curve_list = [] elif isinstance(curves, SingleCurve): curve_list = [curves] logging.debug("Empty curve") elif isinstance(curves, np.ndarray): curve_list = [SingleCurve(y=curves)] logging.debug("Empty curve") elif isinstance(curves, dict): curve_list = [SingleCurve(**curves)] # type: ignore logging.debug("Empty curve") elif isinstance(curves, (list, tuple)): if all(isinstance(item, SingleCurve) for item in curves): curve_list = curves else: curve_list = [] for curve in curves: current_curve = self.__create_curve_from_abbreviation(curve) curve_list.append(current_curve) data = { "curves": curve_list, "xlabel": xlabel, "ylabel": ylabel, "title": title, "grid": grid, "xlim": xlim, "ylim": ylim, } super().__init__(data) def __create_curve_from_abbreviation(self, curve: Union[list, tuple, dict, SingleCurve, np.ndarray]): current_curve = None if isinstance(curve, (list, tuple)): if len(curve) < 2: raise ValueError(f"curve must have at least 2 elements (x, y), got {len(curve)} elements: {curve}") style = None if len(curve) >= 3: style = curve[2] if style is None or isinstance(style, str): label = None if len(curve) >= 4: label = curve[3] if label is not None: if not isinstance(label, str): raise TypeError(f"label must be a string, got {type(label)}") current_curve = SingleCurve(x=curve[0], y=curve[1], style=style, label=label) elif isinstance(style, dict): current_curve = SingleCurve(x=curve[0], y=curve[1], **style) else: current_curve = SingleCurve(x=curve[0], y=curve[1]) elif isinstance(curve, dict): current_curve = SingleCurve(**curve) elif isinstance(curve, SingleCurve): current_curve = curve elif isinstance(curve, np.ndarray): current_curve = SingleCurve(x=None, y=curve) if current_curve is None: raise ValueError(f"could not create a single curve from abbreviation: {curve}") return current_curve # .grid # --------------------------------------- @property def grid(self) -> bool: return self.data["grid"] @grid.setter def grid(self, grid: bool): self.data["grid"] = grid # .title # --------------------------------------- @property def title(self) -> str: return self.data["title"] @title.setter def title(self, title: str): self.data["title"] = title # .xlim / .ylim # --------------------------------------- @property def xlim(self) -> Optional[Tuple[Union[int, float], Union[int, float]]]: return self.data["xlim"] @xlim.setter def xlim(self, xlim: Optional[Tuple[Union[int, float], Union[int, float]]]): self.data["xlim"] = xlim @property def ylim(self) -> Optional[Tuple[Union[int, float], Union[int, float]]]: return self.data["ylim"] @ylim.setter def ylim(self, ylim: Optional[Tuple[Union[int, float], Union[int, float]]]): self.data["ylim"] = ylim # .xlabel / .ylabel # --------------------------------------- @property def xlabel(self) -> str: return self.data["xlabel"] @xlabel.setter def xlabel(self, xlabel: str): self.data["xlabel"] = xlabel @property def ylabel(self) -> str: return self.data["ylabel"] @ylabel.setter def ylabel(self, ylabel: str): self.data["ylabel"] = ylabel # .curves # --------------------------------------- @property def curves(self) -> List[SingleCurve]: return self.data["curves"] @curves.setter def curves(self, curves: List[SingleCurve]): self.data["curves"] = curves # brackets [] # --------------------------------------- def __getitem__(self, key: int) -> SingleCurve: if isinstance(key, slice): return [self.data["curves"][idx] for idx in range(*key.indices(len(self.data["curves"])))] if not isinstance(key, int): raise TypeError(f"key must be an integer, got {type(key)}") if key >= len(self.data["curves"]): raise IndexError(f"curve index {key} out of range (max: {len(self.data['curves']) - 1})") return self.data["curves"][key] def __setitem__(self, key: int, value): if isinstance(key, slice): for lin_index, idx in enumerate(range(*key.indices(len(value)))): if idx >= len(self.data["curves"]): raise IndexError(f"curve index {idx} out of range (max: {len(self.data['curves']) - 1})") self.data["curves"][idx] = value[lin_index] return if not isinstance(key, int): raise TypeError(f"key must be an integer, got {type(key)}") if key >= len(self.data["curves"]): raise IndexError(f"curve index {key} out of range (max: {len(self.data['curves']) - 1})") if not isinstance(value, SingleCurve): raise TypeError(f"value must be a SingleCurve instance, got {type(value)}") self.data["curves"][key] = value def append(self, new_curve: SingleCurve): self.data["curves"].append(new_curve) def prepend(self, new_curve: SingleCurve): self.data["curves"].insert(0, new_curve) def _set_file_extensions(self): self.file_extensions = [".png", ".jpg", ".pkl"] def _load(self, path: Path) -> dict: if path.suffix != ".pkl": raise ValueError(f"Unsupported file extension: {path.suffix}, expected .pkl") self.path = path if path.suffix == ".pkl": # App-controlled round-trip of files written by save_binary data = Data.load_binary(path, allow_pickle=True) return data def _save(self, path: Path, backend=None, figsize=None): if path is None: raise ValueError("Save requires a path") self.path = path if path.suffix == ".pkl": Data.save_binary(self.data, path) elif path.suffix in [".png", ".jpg"]: self.save_figure(self.data, self.path, backend=backend, figsize=figsize) @staticmethod def save_figure(data, path: Path, backend=None, figsize=None): if backend is None: backend = SIGNAL_BACKEND_MPL if backend not in [SIGNAL_BACKEND_MPL]: raise ValueError(f"backend must be {SIGNAL_BACKEND_MPL}, got {backend}") if backend == SIGNAL_BACKEND_MPL: Curve.save_figure_mpl(data, path, figsize=figsize) @staticmethod def save_figure_mpl(data, path: Path, figsize=None): fig, ax = plt.subplots(figsize=figsize) Curve._plot_curve(data, ax=ax) plt.savefig(path) plt.close(fig) def create_plot(self, ax=None): plt_obj = Curve._plot_curve(self.data, ax=ax) return plt_obj def update_plot(self, plt_obj, ax=None): Curve._update_plot(self.data, plt_obj, ax=ax) @staticmethod def _update_plot(data, plt_obj, ax=None): legend = ax.get_legend() if ax is not None else None texts = [] if legend is not None: texts = legend.get_texts() linear_index = 0 for curve_idx, curve in enumerate(data["curves"]): if curve.data.get("x", None) is not None: plt_obj[curve_idx][0].set_xdata(curve.data["x"]) if curve.data.get("y", None) is not None: plt_obj[curve_idx][0].set_ydata(curve.data["y"]) if curve.data["label"] is not None: texts[linear_index].set_text(curve.data["label"]) linear_index += 1 if ax is not None: if data.get("title", None) is not None: ax.set_title(data["title"]) if data.get("xlabel", None) is not None: ax.set_xlabel(data["xlabel"]) if data.get("ylabel", None) is not None: ax.set_ylabel(data["ylabel"]) if data.get("grid", None) is not None: ax.grid(data["grid"]) if data.get("xlim", None) is not None: ax.set_xlim(data["xlim"]) if data.get("ylim", None) is not None: ax.set_ylim(data["ylim"]) return plt_obj @staticmethod def _plot_curve(data, ax=None): legend_flag = False plt_obj = [] for curve in data["curves"]: if curve.data.get("x", None) is not None: inps = [ curve.data["x"], curve.data["y"], ] else: inps = [ curve.data["y"], ] has_style_fmt = curve.data.get("style", None) is not None if has_style_fmt: inps.append(curve.data["style"]) if curve.data.get("label", None) is not None: legend_flag = True # Build kwargs - avoid redundant linestyle when style fmt string is provided # The fmt string (e.g., "r-o") already contains linestyle info, so passing # linestyle keyword would be redundant and trigger a warning plot_kwargs = { "label": curve.data.get("label", None), "alpha": curve.data.get("alpha", None), } # Only add linestyle if style fmt string is not provided (to avoid redundancy warning) if not has_style_fmt: linestyle = curve.data.get("linestyle", None) if linestyle is not None: plot_kwargs["linestyle"] = linestyle linewidth = curve.data.get("linewidth", None) if linewidth is not None: plot_kwargs["linewidth"] = linewidth markersize = curve.data.get("markersize", None) if markersize is not None: plot_kwargs["markersize"] = markersize if ax is not None: plt_obj.append(ax.plot(*inps, **plot_kwargs)) if ax is not None: if legend_flag: ax.legend(loc="upper right") if data.get("title", None) is not None: ax.set_title(data["title"]) if data.get("xlabel", None) is not None: ax.set_xlabel(data["xlabel"]) if data.get("ylabel", None) is not None: ax.set_ylabel(data["ylabel"]) if data.get("grid", None) is not None: ax.grid(data["grid"]) if data.get("xlim", None) is not None: ax.set_xlim(data["xlim"]) if data.get("ylim", None) is not None: ax.set_ylim(data["ylim"]) return plt_obj def show(self, figsize=None): fig, ax = plt.subplots(figsize=figsize) Curve._plot_curve(self.data, ax=ax) plt.show() ``` ## SingleCurve Bases: `Data` SingleCurve are 2D signals you can plot, defined by x and y numpy arrays Attributes: .x .y .label .alpha .style You'd normally use matplotlib.plot(x, y, label) - .from_file("data.csv") containing x,y columns - save as: - .csv (not style, label or markers exported) - .pkl (dict) Source code in `src/interactive_pipe/data_objects/curves.py` ``` class SingleCurve(Data): """SingleCurve are 2D signals you can plot, defined by x and y numpy arrays Attributes: .x .y .label .alpha .style You'd normally use matplotlib.plot(x, y, label) - .from_file("data.csv") containing x,y columns - save as: - .csv (not style, label or markers exported) - .pkl (dict) """ def __init__( self, x: Optional[Union[List[np.ndarray], np.ndarray]] = None, y: Optional[Union[List[np.ndarray], np.ndarray]] = None, style: Optional[str] = None, label: Optional[str] = None, linestyle: Optional[str] = None, linewidth: Optional[int] = None, markersize: Optional[int] = None, alpha: Optional[float] = None, ): if x is not None and y is not None: if len(x) != len(y): raise ValueError(f"x and y lengths must match: x has {len(x)} elements, y has {len(y)} elements") data = { "x": x, "y": y, "label": label, "style": style, "linestyle": linestyle, "linewidth": linewidth, "markersize": markersize, "alpha": alpha, } super().__init__(data) @property def x(self) -> np.ndarray: return self.data["x"] @x.setter def x(self, x: np.ndarray): self.data["x"] = x @property def y(self) -> np.ndarray: return self.data["y"] @y.setter def y(self, y: np.ndarray): self.data["y"] = y @property def label(self) -> str: return self.data["label"] @label.setter def label(self, label): if label is not None and not isinstance(label, str): raise TypeError(f"label must be a string or None, got {type(label)}") self.data["label"] = label @property def style(self) -> str: return self.data["style"] @style.setter def style(self, style): if style is not None and not isinstance(style, str): raise TypeError(f"style must be a string or None, got {type(style)}") self.data["style"] = style @property def alpha(self) -> float: return self.data["alpha"] @alpha.setter def alpha(self, alpha): if alpha is None: self.data["alpha"] = None else: if not isinstance(alpha, (float, int)): raise TypeError(f"alpha must be a number, got {type(alpha)}") if not (0 <= alpha <= 1): raise ValueError(f"alpha must be between [0, 1], got {alpha}") self.data["alpha"] = alpha def _set_file_extensions(self): self.file_extensions = [".png", ".jpg", ".csv", ".pkl"] def _save( self, path: Path, backend=None, figsize=None, xlabel: Optional[str] = None, ylabel: Optional[str] = None, title: Optional[str] = None, grid: Optional[bool] = None, ): if path is None: raise ValueError("Save requires a path") self.path = path Curve([self], xlabel=xlabel, ylabel=ylabel, title=title, grid=grid) if path.suffix == ".csv": SingleCurve.save_tabular(self.data, path) elif path.suffix == ".pkl": Data.save_binary(self.data, path) elif path.suffix in [".png", "jpg"]: Curve.save_figure(data=self.data, path=path, backend=backend, figsize=figsize) def _load( self, path: Path, style: Optional[str] = None, label: Optional[str] = None, linestyle: Optional[str] = None, linewidth: Optional[int] = None, markersize: Optional[int] = None, alpha: Optional[float] = None, ) -> dict: if path.suffix not in [".csv", ".pkl"]: raise ValueError(f"Unsupported file extension: {path.suffix}, expected .csv or .pkl") self.path = path if path.suffix == ".csv": df = pd.read_csv(self.path) data = { "x": df["x"], "y": df["y"], "label": label, "style": style, "linestyle": linestyle, "linewidth": linewidth, "markersize": markersize, "alpha": alpha, } elif path.suffix == ".pkl": # App-controlled round-trip of files written by save_binary data = Data.load_binary(path, allow_pickle=True) if style is not None: data["style"] = style if label is not None: data["label"] = label if linestyle is not None: data["linestyle"] = linestyle if linewidth is not None: data["linewidth"] = linewidth if markersize is not None: data["markersize"] = markersize if alpha is not None: data["alpha"] = alpha return data def show( self, figsize=None, xlabel: Optional[str] = None, ylabel: Optional[str] = None, title: Optional[str] = None, grid: Optional[bool] = None, ): crv = Curve([self], xlabel=xlabel, ylabel=ylabel, title=title, grid=grid) crv.show(figsize=figsize) def as_dataframe(self): # -> pd.DataFrame df = SingleCurve.dataframe_from_data(self.data) self.df = df return df @staticmethod def dataframe_from_data(data): # -> pd.DataFrame if SIGNAL_BACKEND_PD not in signal_backends: raise RuntimeError("pandas backend is not available. Install pandas to use this feature.") df = pd.DataFrame.from_dict(dict(x=data["x"], y=data["y"])) return df @staticmethod def save_tabular(data, path: Path): if path.suffix != ".csv": raise ValueError(f"save_tabular requires .csv extension, got {path.suffix}") df = SingleCurve.dataframe_from_data(data) df.to_csv(path, index=False) ``` ## Table Bases: `Data` Tabular data for display in interactive_pipe. Works without pandas. Pandas features (DataFrame input, csv export) available when pandas is installed. Supports headerless tables for displaying raw matrices by setting columns=None when using numpy array input. Attributes: | Name | Type | Description | | ----------- | --------------- | ---------------------------------------------------- | | `columns` | `List[str]` | List of column names (empty strings for headerless). | | `values` | `List[List]` | 2D list of values (row-major). | | `title` | `Optional[str]` | Optional title. | | `precision` | `int` | Float formatting precision. | Source code in `src/interactive_pipe/data_objects/table.py` ``` class Table(Data): """Tabular data for display in interactive_pipe. Works without pandas. Pandas features (DataFrame input, csv export) available when pandas is installed. Supports headerless tables for displaying raw matrices by setting columns=None when using numpy array input. Attributes: columns: List of column names (empty strings for headerless). values: 2D list of values (row-major). title: Optional title. precision: Float formatting precision. """ def __init__( self, data: Union[Dict[str, List], List[Dict], np.ndarray, None] = None, columns: Optional[List[str]] = None, title: Optional[str] = None, precision: int = 2, ): if data is None: # Allow creation from Path (handled by parent class) data_dict = {"columns": [], "values": []} elif isinstance(data, Path): # Handled by parent class super().__init__(data, columns=columns, title=title, precision=precision) return elif PANDAS_AVAILABLE and isinstance(data, pd.DataFrame): # Handle pandas DataFrame input data_dict = self._normalize_dataframe(data) else: data_dict = self._normalize_data(data, columns) internal_data = { "columns": data_dict["columns"], "values": data_dict["values"], "title": title, "precision": precision, } super().__init__(internal_data) def _normalize_data( self, data: Union[Dict[str, List], List[Dict], np.ndarray], columns: Optional[List[str]] = None, ) -> Dict[str, Any]: """Convert various input formats to internal format.""" if isinstance(data, dict): # Dict of lists: {"col1": [1,2,3], "col2": [4,5,6]} if not all(isinstance(v, list) for v in data.values()): raise TypeError("Dict input must have list values") col_names = list(data.keys()) if not col_names: return {"columns": [], "values": []} # Check all lists have same length lengths = [len(v) for v in data.values()] if len(set(lengths)) > 1: raise ValueError(f"All columns must have the same length. Got lengths: {lengths}") # Convert to row-major format num_rows = lengths[0] values = [[data[col][row] for col in col_names] for row in range(num_rows)] return {"columns": col_names, "values": values} elif isinstance(data, list): if not data: return {"columns": [], "values": []} # List of dicts: [{"col1": 1, "col2": 4}, {"col1": 2, "col2": 5}] if isinstance(data[0], dict): col_names = list(data[0].keys()) # Check all dicts have same keys for i, row_dict in enumerate(data): if set(row_dict.keys()) != set(col_names): raise ValueError( f"Row {i} has different keys. Expected {col_names}, got {list(row_dict.keys())}" ) values = [[row_dict[col] for col in col_names] for row_dict in data] return {"columns": col_names, "values": values} else: raise TypeError( "List input must be a list of dictionaries. For 2D arrays, use numpy array with columns parameter." ) elif isinstance(data, np.ndarray): # 2D numpy array with optional columns parameter if len(data.shape) != 2: raise ValueError(f"Numpy array must be 2D, got shape {data.shape}") if columns is None: # Create empty column names for headerless table columns = [""] * data.shape[1] if columns is not None and len(columns) != data.shape[1]: raise ValueError(f"Number of columns ({len(columns)}) must match array width ({data.shape[1]})") values = data.tolist() return {"columns": columns, "values": values} else: raise TypeError( f"Unsupported data type: {type(data)}. " "Supported: dict of lists, list of dicts, 2D numpy array" + (", pandas DataFrame" if PANDAS_AVAILABLE else "") ) def _normalize_dataframe(self, df: "pd.DataFrame") -> Dict[str, Any]: """Convert pandas DataFrame to internal format.""" _require_pandas("DataFrame input") col_names = list(df.columns) values = df.values.tolist() return {"columns": col_names, "values": values} @property def columns(self) -> List[str]: return self.data["columns"] @columns.setter def columns(self, columns: List[str]): if not isinstance(columns, list): raise TypeError(f"columns must be a list, got {type(columns)}") if not all(isinstance(c, str) for c in columns): raise TypeError("All column names must be strings") # Validate consistency with existing row width if self.values and len(columns) != len(self.values[0]): raise ValueError(f"Number of columns ({len(columns)}) must match row width ({len(self.values[0])})") self.data["columns"] = columns @property def values(self) -> List[List]: return self.data["values"] @values.setter def values(self, values: List[List]): if not isinstance(values, list): raise TypeError(f"values must be a list, got {type(values)}") self.data["values"] = values @property def title(self) -> Optional[str]: return self.data["title"] @title.setter def title(self, title: Optional[str]): if title is not None and not isinstance(title, str): raise TypeError(f"title must be a string or None, got {type(title)}") self.data["title"] = title @property def precision(self) -> int: return self.data["precision"] @precision.setter def precision(self, precision: int): if not isinstance(precision, int): raise TypeError(f"precision must be an integer, got {type(precision)}") if precision < 0: raise ValueError(f"precision must be non-negative, got {precision}") self.data["precision"] = precision def __len__(self) -> int: """Return the number of rows in the table.""" return len(self.values) def __iter__(self): """Iterate over rows as dictionaries.""" for i in range(len(self.values)): yield dict(zip(self.columns, self.values[i])) def __getitem__(self, key: Union[int, str, slice]): """Access data by row index, column name, or slice.""" if isinstance(key, int): # Return row (support negative indexing) if key < -len(self.values) or key >= len(self.values): raise IndexError(f"Row index {key} out of range (length: {len(self.values)})") return dict(zip(self.columns, self.values[key])) elif isinstance(key, str): # Return column if key not in self.columns: raise KeyError(f"Column '{key}' not found. Available: {self.columns}") col_idx = self.columns.index(key) return [row[col_idx] for row in self.values] elif isinstance(key, slice): # Return slice of rows rows = self.values[key] return [dict(zip(self.columns, row)) for row in rows] else: raise TypeError(f"Key must be int, str, or slice, got {type(key)}") def _set_file_extensions(self): self.file_extensions = [".csv", ".pkl"] def as_dataframe(self) -> "pd.DataFrame": """Convert Table to pandas DataFrame. Returns: Table data as a pandas DataFrame Raises: RuntimeError: If pandas is not installed """ _require_pandas("as_dataframe()") return pd.DataFrame(self.values, columns=self.columns) # type: ignore def _save(self, path: Path, **kwargs): if path.suffix == ".pkl": Data.save_binary(self.data, path) elif path.suffix == ".csv": _require_pandas("CSV export") df = self.as_dataframe() df.to_csv(path, index=False) else: raise ValueError(f"Unsupported file extension: {path.suffix}. Supported: .pkl, .csv") def _load(self, path: Path, **kwargs) -> Dict[str, Any]: if path.suffix == ".pkl": # App-controlled round-trip of files written by save_binary data = Data.load_binary(path, allow_pickle=True) return data elif path.suffix == ".csv": _require_pandas("CSV import") df = pd.read_csv(path) data = { "columns": list(df.columns), "values": df.values.tolist(), "title": kwargs.get("title", None), "precision": kwargs.get("precision", 2), } return data else: raise ValueError(f"Unsupported file extension: {path.suffix}. Supported: .pkl, .csv") def _format_values(self) -> List[List[str]]: """Format values with precision for display.""" formatted = [] for row in self.values: formatted_row = [] for val in row: if isinstance(val, float): formatted_row.append(f"{val:.{self.precision}f}") else: formatted_row.append(str(val)) formatted.append(formatted_row) return formatted def create_table(self, ax: Optional[Any] = None) -> Any: """Create matplotlib table, returns table object. Args: ax: matplotlib Axes object (optional, will create if None) Returns: matplotlib Table object """ try: import matplotlib.pyplot as plt except ImportError: raise RuntimeError("Matplotlib is required for table rendering. Install with: pip install matplotlib") if ax is None: fig, ax = plt.subplots() ax.axis("off") # Check if this is a headerless table (all columns are empty strings) has_headers = any(col != "" for col in self.columns) if has_headers: table = ax.table( cellText=self._format_values(), colLabels=self.columns, loc="center", cellLoc="center", ) else: # Headerless table - don't show column labels table = ax.table( cellText=self._format_values(), loc="center", cellLoc="center", ) table.auto_set_font_size(False) table.set_fontsize(9) table.scale(1, 2) # Make cells taller for better readability if self.title: ax.set_title(self.title, pad=20) return table def update_table(self, _table_obj: Optional[Any] = None, ax: Optional[Any] = None) -> Any: """Update existing matplotlib table. Note: matplotlib tables don't update well, so we clear and recreate. Args: _table_obj: Previous table object (ignored, kept for API consistency with Curve.update_plot signature) ax: matplotlib Axes object Returns: matplotlib Table object """ if ax is None: raise ValueError("ax parameter is required for update_table()") ax.clear() return self.create_table(ax=ax) ``` ### __len__ ``` __len__() -> int ``` Return the number of rows in the table. Source code in `src/interactive_pipe/data_objects/table.py` ``` def __len__(self) -> int: """Return the number of rows in the table.""" return len(self.values) ``` ### __iter__ ``` __iter__() ``` Iterate over rows as dictionaries. Source code in `src/interactive_pipe/data_objects/table.py` ``` def __iter__(self): """Iterate over rows as dictionaries.""" for i in range(len(self.values)): yield dict(zip(self.columns, self.values[i])) ``` ### __getitem__ ``` __getitem__(key: Union[int, str, slice]) ``` Access data by row index, column name, or slice. Source code in `src/interactive_pipe/data_objects/table.py` ``` def __getitem__(self, key: Union[int, str, slice]): """Access data by row index, column name, or slice.""" if isinstance(key, int): # Return row (support negative indexing) if key < -len(self.values) or key >= len(self.values): raise IndexError(f"Row index {key} out of range (length: {len(self.values)})") return dict(zip(self.columns, self.values[key])) elif isinstance(key, str): # Return column if key not in self.columns: raise KeyError(f"Column '{key}' not found. Available: {self.columns}") col_idx = self.columns.index(key) return [row[col_idx] for row in self.values] elif isinstance(key, slice): # Return slice of rows rows = self.values[key] return [dict(zip(self.columns, row)) for row in rows] else: raise TypeError(f"Key must be int, str, or slice, got {type(key)}") ``` ### as_dataframe ``` as_dataframe() -> pd.DataFrame ``` Convert Table to pandas DataFrame. Returns: | Type | Description | | ----------- | -------------------------------- | | `DataFrame` | Table data as a pandas DataFrame | Raises: | Type | Description | | -------------- | -------------------------- | | `RuntimeError` | If pandas is not installed | Source code in `src/interactive_pipe/data_objects/table.py` ``` def as_dataframe(self) -> "pd.DataFrame": """Convert Table to pandas DataFrame. Returns: Table data as a pandas DataFrame Raises: RuntimeError: If pandas is not installed """ _require_pandas("as_dataframe()") return pd.DataFrame(self.values, columns=self.columns) # type: ignore ``` ### create_table ``` create_table(ax: Optional[Any] = None) -> Any ``` Create matplotlib table, returns table object. Parameters: | Name | Type | Description | Default | | ---- | --------------- | ------------------------------------------------------ | ------- | | `ax` | `Optional[Any]` | matplotlib Axes object (optional, will create if None) | `None` | Returns: | Type | Description | | ----- | ----------------------- | | `Any` | matplotlib Table object | Source code in `src/interactive_pipe/data_objects/table.py` ``` def create_table(self, ax: Optional[Any] = None) -> Any: """Create matplotlib table, returns table object. Args: ax: matplotlib Axes object (optional, will create if None) Returns: matplotlib Table object """ try: import matplotlib.pyplot as plt except ImportError: raise RuntimeError("Matplotlib is required for table rendering. Install with: pip install matplotlib") if ax is None: fig, ax = plt.subplots() ax.axis("off") # Check if this is a headerless table (all columns are empty strings) has_headers = any(col != "" for col in self.columns) if has_headers: table = ax.table( cellText=self._format_values(), colLabels=self.columns, loc="center", cellLoc="center", ) else: # Headerless table - don't show column labels table = ax.table( cellText=self._format_values(), loc="center", cellLoc="center", ) table.auto_set_font_size(False) table.set_fontsize(9) table.scale(1, 2) # Make cells taller for better readability if self.title: ax.set_title(self.title, pad=20) return table ``` ### update_table ``` update_table(_table_obj: Optional[Any] = None, ax: Optional[Any] = None) -> Any ``` Update existing matplotlib table. Note: matplotlib tables don't update well, so we clear and recreate. Parameters: | Name | Type | Description | Default | | ------------ | --------------- | ------------------------------------------------------------------------------------------ | ------- | | `_table_obj` | `Optional[Any]` | Previous table object (ignored, kept for API consistency with Curve.update_plot signature) | `None` | | `ax` | `Optional[Any]` | matplotlib Axes object | `None` | Returns: | Type | Description | | ----- | ----------------------- | | `Any` | matplotlib Table object | Source code in `src/interactive_pipe/data_objects/table.py` ``` def update_table(self, _table_obj: Optional[Any] = None, ax: Optional[Any] = None) -> Any: """Update existing matplotlib table. Note: matplotlib tables don't update well, so we clear and recreate. Args: _table_obj: Previous table object (ignored, kept for API consistency with Curve.update_plot signature) ax: matplotlib Axes object Returns: matplotlib Table object """ if ax is None: raise ValueError("ax parameter is required for update_table()") ax.clear() return self.create_table(ax=ax) ``` # Meta # FAQ ### What is the recommended way to access shared context? Use the clean context API for direct access: ``` from interactive_pipe import context, layout, audio, get_context @interactive() def my_filter(img): context["shared_key"] = "shared_value" # Direct dict-like access context.brightness = 0.5 layout.style("output_image", title="My Image") # Layout helpers return img ``` ### How do I change the layout? *Can I change the grid layout of images live? (e.g. comparing 2 images side by side, then switching to a 2x2 grid for debugging)* — Yes, on the Qt backend. Use the `layout` proxy to control image arrangement and styling; see the [Layout guide](https://balthazarneveu.github.io/interactive_pipe/guide/layout/index.md). ### Do I have to remove `KeyboardControl` when using gradio or notebook backends? No, don't worry — these map back to regular sliders. ### How do I play audio live? 🔊 Inside a processing block, write the audio file to disk and use the audio helper: ``` from interactive_pipe import audio audio.set(audio_file) ``` ### Do I have to decorate my processing block using the `@interactive` decorator? If you use the `@` decoration style, your function won't be usable in a regular manner (which may be problematic in a serious development environment): ``` @interactive(angle=(0., [-360., 360.])) def processing_block(angle=0.): ... ``` An alternative is to decorate the processing block from outside — in a file dedicated to interactivity, for instance: ``` # core_filter.py def processing_block(angle=0.): ... ``` ``` # graphical.py from core_filter import processing_block from interactive_pipe import interactive interactive(angle=(0., [-360., 360.]))(processing_block) ``` ### Can I call the pipeline in a command line/batch fashion? Yes, headless mode is supported: `@interactive_pipeline(gui=None)` returns a `HeadlessPipeline` you can call like a plain function. See the [Quickstart](https://balthazarneveu.github.io/interactive_pipe/getting-started/quickstart/#headless-mode-same-code-no-gui). ### Can I use in-place operations? Better avoid these in general. To avoid making extra copies, computing hashes everywhere and losing precious computation time, there are no checks that inputs are not modified in place. ``` # Don't do that! def bad_processing_block(inp): inp += 1 ``` ### What happened to `global_params`? The legacy names (`global_params`, `global_parameters`, `global_state`, `global_context`, `state`) were removed in 0.9.0. Declaring one of them as a filter keyword argument, or passing one as a pipeline-init argument, raises a `TypeError` with a migration hint. Use the clean context API instead: the `context` proxy inside filters, and `context={...}` at pipeline initialization. See the [migration guide](https://balthazarneveu.github.io/interactive_pipe/changelog/index.md). # Changelog ## 0.9.1 (July 2026) ### New features - **Dependency-aware cache (`cache="graph"`)**: a new opt-in cache mode that recomputes a filter only when it is actually affected by a change — its own sliders moved, one of its producers in the data-flow graph was recomputed, or a `context` key it reads was updated (tracked at runtime). Unlike `cache=True` (a sequential prefix cache that recomputes every filter after the change in source order), independent branches are left untouched. `cache=True` / `cache=False` behavior is unchanged. - `cache="graph-strict"` behaves like `"graph"` but hands out `context` numpy arrays as read-only views, so accidental in-place mutation of shared context data raises at the offending line (debugging helper). ### Improvements & bug fixes - **Read-only filter inputs by default (`readonly_inputs=True`)**: filters now receive their numpy inputs as read-only views, so an in-place mutation (`img += 1`) raises immediately instead of silently corrupting sibling filters or cached buffers. Declare `@interactive(inplace=True)` on a filter that intentionally mutates its inputs — it then receives private writable copies. Pass `readonly_inputs=False` to `@interactive_pipeline(...)` to restore the previous permissive behavior. - In-place mutation of torch tensor inputs is now detected as well (torch tensors do not support numpy's read-only flag), surfacing the same class of silent-corruption bug for PyTorch-based filters. ## 0.9.0 (July 2026) ### Breaking changes - Removed implicit dict injection: declaring `global_params` / `global_parameters` / `global_state` / `global_context` / `context` / `state` as a filter keyword argument now raises `TypeError` at filter construction. Use the `context`, `layout` and `audio` proxies instead (see migration guide below). - Removed pipeline-init aliases (`global_params=`, `state=`, ...): pass `context={...}`. - Removed `SharedContext` / `SharedContext.injected()`. - Removed layout aliases `layout.set_style` / `set_grid` / `canvas` / `set_canvas` (use `layout.style` / `layout.grid`) and the write-only `Control.group` attribute (use `Control.panel`). - Requires Python >= 3.9. ### Improvements & bug fixes - `import interactive_pipe` no longer fails when neither OpenCV nor Pillow is installed (the error is raised on first image save/load) and no longer overrides `sys.excepthook` as an import side effect. - Fixed the wavio availability flag, an unbound variable in audio saving, the dropped step default for string controls, and the filter-signature cache that never cached. - Runtime validation uses proper `TypeError`/`ValueError` exceptions instead of asserts; library diagnostics go through `logging` instead of `print`. ### Migration from old `context={}` or `global_params={}` patterns ``` # OLD (removed in 0.9.0 - now raises TypeError at filter construction) from interactive_pipe import interactive @interactive(brightness=(0.5, [0., 1.])) def apply_brightness(img: np.ndarray, brightness: float = 0.5, global_params={}): global_params["brightness"] = brightness # Storing shared data global_params["__output_styles"]["output"] = {"title": "Brightened"} # Setting layout return img * brightness # NEW (recommended) - using context and layout proxies from interactive_pipe import interactive, context, layout @interactive(brightness=(0.5, [0., 1.])) def apply_brightness(img: np.ndarray, brightness: float = 0.5): context["brightness"] = brightness # or: context.brightness = brightness layout.style("output", title="Brightened") return img * brightness ``` The new API provides: - `context` - For sharing data between filters (replaces `global_params["key"]`) - `layout` - For controlling output display (replaces `global_params["__output_styles"]`) - `audio` - For audio playback control - `get_context()` - Get the shared context dictionary directly ## 0.8.10 (March 2026) - Bugfix for jupyter notebooks backends. ## 0.8.9 (February 2026) ### New features - **Panel system**: Control panel layout and organization - Flexible panel positioning (left, right, top, bottom) - Detached control panels for separate windows - Nested panels and subpanels support - Grouped controls within panels - Improved spacing and borders for better visual organization - Full backend support (Qt, Gradio, matplotlib, notebook) - **Table data type**: Display tabular data natively - Core Table functionality without external dependencies - Optional pandas DataFrame support for advanced use cases - Rendering support across all backends (Qt, Gradio, matplotlib) - Headerless tables option - **TimeControl enhancements**: Better time-based parameter control - Improved slider help display - Additional demos showcasing time-based animations ### API improvements - Context support at pipeline initialization - Backend selection via enum (string format still supported) - Graph visualization for GUI pipelines (press `G`) ### Deprecations - Inline syntax deprecated (use decorator syntax instead) - `output_canvas` argument removed - Context aliases (`global_params`, `states` etc...) deprecated at initialization ## 0.8.8 (January 2026) ### New features - **Clean context API**: Access shared context directly without `global_params` pollution - `get_context()` - Get the shared context dictionary - `context` - Direct dict-like access to context - `layout` - Access layout configuration directly - `audio` - Access audio functionality directly ### Code quality improvements - Replaced all assertions with proper exceptions (`ValueError`, `TypeError`, `RuntimeError`) - Fixed all mutable default arguments across the codebase (prevents shared state bugs) - Improved type hints with proper `Optional` and `Any` types - Better error messages for debugging ### UX improvements - Dropdown menus are now hidden when only a single choice is available - Helpful message displayed when Graphviz is not available (when pressing `G`) - Fixed warning in linestyle for curves ### Bug fixes - Fixed audio initialization order in Qt backend - Fixed pytest failures for optional dependencies in CI - Fixed various edge cases in error handling ### License - Updated to MIT License ## History - Interactive pipe was initially developed by [Balthazar Neveu](https://github.com/balthazarneveu) as part of the [irdrone project](https://github.com/wisescootering/infrareddrone/tree/master/interactive) based on matplotlib. - Later, more contributions were also made by [Giuseppe Moschetti](https://github.com/g-moschetti) and Sylvain Leroy. - August 2023: rewriting the whole core and supporting several graphical backends! - September 2024: Gradio backend - January 2026: Clean context API and code quality improvements (v0.8.8) - February 2026: Panel system and Table support (v0.8.9) - July 2026: Removal of the legacy `global_params` injection in favor of the context API (v0.9.0)