diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..16fb93f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] - 2025-08-14 + +### Added +- **Lines function**: New functionality for drawing lines on basketball courts + - Support for basic lines, comet lines (increasing width), and transparent lines (increasing opacity) + - Colormap integration for lines + - Coordinate transformation support for all court orientations + - Basketball-specific applications (pass visualization, player movement tracking) + - Comprehensive error handling and validation + - Performance optimized using matplotlib's LineCollection +- **Documentation**: Complete usage guide and examples in README.md +- **Testing**: 12 comprehensive test cases covering all functionality +- **Examples**: New example scripts demonstrating Lines usage + +### Technical Details +- New module: `mplbasketball.lines` +- New function: `Lines()` with extensive parameter support +- Integration with existing coordinate transformation system +- Compatible with all court types (NBA, WNBA, NCAA, FIBA) + +## [1.0.0] - Initial Release - 2024-09-07 + +### Added +- Court class for 2D basketball court visualization +- Court3D class for 3D basketball court visualization +- Support for multiple court types (NBA, WNBA, NCAA, FIBA) +- Coordinate transformation utilities +- Integration with matplotlib functions +- Comprehensive documentation and examples diff --git a/README.md b/README.md index c79227f..ccc236e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ **A Python plotting library to visualize basketball data, created by the Sport Performance Lab (SPL) at Maple Leaf Sports & Entertainment (MLSE), Toronto, Canada.** +[![PyPI version](https://badge.fury.io/py/mplbasketball.svg)](https://badge.fury.io/py/mplbasketball) + [Sport Performance Lab (SPL)](https://www.mlsedigital.com/innovation-initiatives/sport-performance-lab) is a Research and Development group that works across all MLSE teams on strategic research initiatives in sports analytics and player performance. # Quick start @@ -54,6 +56,7 @@ Currently, you can use `mplbasketball` to 4. [FIBA](https://nz.basketball/wp-content/uploads/2020/02/FIBA-Basketball-Court-Dimensions.pdf) 2. View data in different orientations orientation (horizontal, vertical, and also normalized to left/right/up/down). The [`utils.transform`](./mplbasketball/utils.py) function makes going between orientations extremely easy and seamless. 3. Easily interface with existing `matplotlib` functions. +4. Draw lines with various effects (comet, transparent, colormap) using the `Lines` function # Before you begin @@ -253,6 +256,29 @@ ax.imshow(heatmap_2.T, extent=extent_2, origin='lower', cmap='cividis', zorder=-

+## The Lines function + +The `Lines` function provides a way to draw lines on basketball courts with various visual effects. It's particularly useful for visualizing passes, player movements, and other line-based data. + +### Basic usage + +```python +from mplbasketball import Court, Lines + +court = Court(court_type="nba", origin="center") +fig, ax = court.draw(orientation="h") + +# Draw a simple line +Lines(0, 0, 20, 20, color="red", linewidth=3, ax=ax) + +# Draw multiple lines +Lines([-20, 20], [10, -10], [20, -20], [-10, 10], + color=["red", "blue"], linewidth=3, ax=ax) +``` +

+ +

+ # Documentation Full documentation coming soon. In the meantime, check out the examples in this README, as well as some of our [examples](https://github.com/mlsedigital/mplbasketball/tree/main/examples)! diff --git a/examples/plot_lines.py b/examples/plot_lines.py new file mode 100644 index 0000000..c715401 --- /dev/null +++ b/examples/plot_lines.py @@ -0,0 +1,93 @@ +""" +Example demonstrating the lines functionality in mplbasketball. + +This example shows how to use the Lines function to draw various types of lines +on basketball courts, including basic lines, comet lines, transparent lines, +and lines with colormaps. This version simulates a high-intensity basketball game +with many passes and movements. +""" + +import matplotlib.pyplot as plt +import numpy as np + +from mplbasketball import Court, Lines + + +def generate_pass_data(n_passes=200): + """Generate realistic basketball pass data.""" + passes = [] + + # Define court boundaries (NBA court in feet) + court_width = 94 + court_height = 50 + + for _ in range(n_passes): + # Only assist passes + # Short to medium passes + x1 = np.random.uniform(-40, 40) + y1 = np.random.uniform(-20, 20) + x2 = x1 + np.random.uniform(-15, 15) + y2 = y1 + np.random.uniform(-15, 15) + + # Ensure coordinates are within court bounds + x1 = np.clip(x1, -47, 47) + y1 = np.clip(y1, -25, 25) + x2 = np.clip(x2, -47, 47) + y2 = np.clip(y2, -25, 25) + + passes.append((x1, y1, x2, y2, 'completed')) + + return passes + + +def main(): + # Create a court + court = Court(court_type="nba", origin="center") + fig, ax = court.draw(orientation="h", showaxis=False) + + # Generate pass data + print("Generating pass data...") + passes = generate_pass_data(n_passes=300) + + # Define styles for completed passes + completed_style = { + 'color': 'gold', + 'linewidth': 2, + 'comet': True, + 'transparent': False + } + + # Draw passes + print("Drawing passes...") + for x_start, y_start, x_end, y_end, pass_type in passes: + style = completed_style.copy() + + # Add some randomness to make it look more realistic + x_start += np.random.normal(0, 1) + y_start += np.random.normal(0, 1) + x_end += np.random.normal(0, 1) + y_end += np.random.normal(0, 1) + + # Draw the pass + Lines(x_start, y_start, x_end, y_end, ax=ax, **style) + + + + # Add title and adjust layout + plt.title("Basketball Game Simulation - Completed Passes\nUsing mplbasketball Lines Function", + fontsize=14, fontweight='bold', color='white') + + # Add legend + legend_elements = [ + plt.Line2D([0], [0], color='gold', linewidth=3, label='Completed Passes') + ] + + ax.legend(handles=legend_elements, loc='upper right', + bbox_to_anchor=(1.15, 1), fontsize=10) + + plt.tight_layout() + plt.show() + + +if __name__ == "__main__": + main() diff --git a/figs/completed_passes.png b/figs/completed_passes.png new file mode 100644 index 0000000..5285f95 Binary files /dev/null and b/figs/completed_passes.png differ diff --git a/mplbasketball/__init__.py b/mplbasketball/__init__.py index 0da6075..a89da8d 100644 --- a/mplbasketball/__init__.py +++ b/mplbasketball/__init__.py @@ -1,4 +1,5 @@ from .court import Court from .court3d import Court3D +from .lines import Lines -__all__ = ["Court", "Court3D"] +__all__ = ["Court", "Court3D", "Lines"] diff --git a/mplbasketball/lines.py b/mplbasketball/lines.py new file mode 100644 index 0000000..9145dd5 --- /dev/null +++ b/mplbasketball/lines.py @@ -0,0 +1,278 @@ +"""A module with functions for using LineCollection to create lines on basketball courts.""" + +import warnings +from typing import Literal + +import numpy as np +from matplotlib import colormaps, rcParams +from matplotlib.collections import LineCollection +from matplotlib.colors import to_rgba_array +from matplotlib.legend import Legend +from matplotlib.legend_handler import HandlerLineCollection + +from mplbasketball.utils import transform + +__all__ = ['Lines'] + + +def Lines(xstart, ystart, xend, yend, color=None, n_segments=100, comet=False, transparent=False, + alpha_start=0.01, alpha_end=1, cmap=None, ax=None, + fr: Literal["h", "hl", "hr", "v", "vu", "vd"] = "h", + to: Literal["h", "hl", "hr", "v", "vu", "vd"] = "h", + origin: Literal["center", "top-left", "bottom-left", "top-right", "bottom-right"] = "center", + court_type: Literal["nba", "wnba", "ncaa", "fiba"] = "nba", + reverse_cmap=False, **kwargs): + """Plots Lines using matplotlib.collections.LineCollection on basketball courts. + + This is a fast way to plot multiple lines without loops. + Also enables lines that increase in width or opacity by splitting + the line into n_segments of increasing width or opacity as the line progresses. + + Parameters + ---------- + xstart, ystart, xend, yend: array-like or scalar + Commonly, these parameters are 1D arrays. + These should be the start and end coordinates of the lines. + color : matplotlib color or sequence of colors, optional + Defaults to None. In that case the marker color is determined + by the value rcParams['lines.color'] + n_segments : int, default 100 + If comet=True or transparent=True this is used to split the line + into n_segments of increasing width/opacity. + comet : bool, default False + Whether to plot the lines increasing in width. + transparent : bool, default False + Whether to plot the lines increasing in opacity. + alpha_start: float, default 0.01 + The starting alpha value for transparent lines, between 0 (transparent) and 1 (opaque). + If transparent = True the line will be drawn to + linearly increase in opacity between alpha_start and alpha_end. + alpha_end : float, default 1 + The ending alpha value for transparent lines, between 0 (transparent) and 1 (opaque). + If transparent = True the line will be drawn to + linearly increase in opacity between alpha_start and alpha_end. + cmap : str, optional + A matplotlib cmap (colormap) name + ax : matplotlib.axes.Axes, optional + The axis to plot on. + fr : str, default 'h' + Current orientation of the data points. Can be one of 'h', 'hl', 'hr', 'v', 'vu', 'vd'. + to : str, default 'h' + Desired orientation of the data points. Can be one of 'h', 'hl', 'hr', 'v', 'vu', 'vd'. + origin : str, default 'center' + Origin of the court. Can be one of 'center', 'top-left', 'bottom-left', 'top-right', 'bottom-right'. + court_type : str, default 'nba' + Court type. Can be one of 'nba', 'wnba', 'ncaa', 'fiba'. + reverse_cmap : bool, default False + Whether to reverse the cmap colors. + If the court is horizontal and the y-axis is inverted then set this to True. + **kwargs : + All other keyword arguments are passed on to matplotlib.collections.LineCollection. + + Returns + ------- + LineCollection : matplotlib.collections.LineCollection + + Examples + -------- + >>> from mplbasketball import Court + >>> court = Court() + >>> fig, ax = court.draw() + >>> from mplbasketball.lines import Lines + >>> Lines(20, 20, 45, 80, comet=True, transparent=True, ax=ax) + + >>> import matplotlib.pyplot as plt + >>> fig, ax = plt.subplots() + >>> Lines([0.1, 0.4], [0.1, 0.5], [0.9, 0.4], [0.8, 0.8], ax=ax) + """ + if not isinstance(comet, bool): + raise TypeError("Invalid argument: comet should be bool (True or False).") + if not isinstance(transparent, bool): + raise TypeError("Invalid argument: transparent should be bool (True or False).") + + if alpha_start < 0 or alpha_start > 1: + raise TypeError("alpha_start values should be within 0-1 range") + if alpha_end < 0 or alpha_end > 1: + raise TypeError("alpha_end values should be within 0-1 range") + if alpha_start > alpha_end: + msg = "Alpha start > alpha end. The line will increase in transparency nearer to the end" + warnings.warn(msg) + + if 'colors' in kwargs: + warnings.warn("Lines method takes 'color' as an argument, 'colors' is ignored") + if color is not None and cmap is not None: + raise ValueError("Only use one of color or cmap arguments not both.") + if 'lw' in kwargs and 'linewidth' in kwargs: + raise TypeError("Lines got multiple values for 'linewidth' argument (linewidth and lw).") + + # Handle linewidth parameter + if 'lw' in kwargs: + lw = kwargs.pop('lw', 5) + elif 'linewidth' in kwargs: + lw = kwargs.pop('linewidth', 5) + else: + lw = 5 + + # Convert inputs to numpy arrays + xstart = np.ravel(xstart) + ystart = np.ravel(ystart) + xend = np.ravel(xend) + yend = np.ravel(yend) + lw = np.ravel(lw) + + # Validate array sizes + if (comet or transparent) and lw.size > 1: + msg = "Multiple linewidths with a comet or transparent Line is not implemented." + raise NotImplementedError(msg) + + if color is None and cmap is None: + color = rcParams['lines.color'] + + if (comet or transparent) and cmap is None and to_rgba_array(color).shape[0] > 1: + msg = "Multiple colors with a comet or transparent Line is not implemented." + raise NotImplementedError(msg) + + if xstart.size != ystart.size: + raise ValueError("xstart and ystart must be the same size") + if xstart.size != xend.size: + raise ValueError("xstart and xend must be the same size") + if ystart.size != yend.size: + raise ValueError("ystart and yend must be the same size") + if lw.size > 1 and lw.size != xstart.size: + raise ValueError("lw and xstart must be the same size") + + if lw.size == 1: + lw = lw[0] + + # Transform coordinates if needed + if fr != to: + xstart, ystart = transform(xstart, ystart, fr, to, origin, court_type) + xend, yend = transform(xend, yend, fr, to, origin, court_type) + + # Handle comet effect + if comet: + lw = np.linspace(1, lw, n_segments) + handler_first_lw = False + else: + handler_first_lw = True + + multi_segment = transparent is not False or comet is not False or cmap is not None + + # Handle transparent effect + if transparent: + cmap = _create_transparent_cmap(color, cmap, n_segments, alpha_start, alpha_end) + + if isinstance(cmap, str): + cmap = colormaps.get_cmap(cmap) + + if cmap is not None: + handler_cmap = True + line_collection = _lines_cmap(xstart, ystart, xend, yend, lw=lw, cmap=cmap, ax=ax, + n_segments=n_segments, multi_segment=multi_segment, + reverse_cmap=reverse_cmap, **kwargs) + else: + handler_cmap = False + line_collection = _lines_no_cmap(xstart, ystart, xend, yend, lw=lw, color=color, + ax=ax, n_segments=n_segments, + multi_segment=multi_segment, **kwargs) + + line_collection_handler = HandlerLines(numpoints=n_segments, invert_y=reverse_cmap, + first_lw=handler_first_lw, use_cmap=handler_cmap) + + Legend.update_default_handler_map({LineCollection: line_collection_handler}) + return line_collection + + +def _create_transparent_cmap(color, cmap, n_segments, alpha_start, alpha_end): + """Create a transparent colormap for lines.""" + if cmap is None: + color_rgba = to_rgba_array(color)[0] + colors = np.array([color_rgba] * n_segments) + colors[:, 3] = np.linspace(alpha_start, alpha_end, n_segments) + from matplotlib.colors import ListedColormap + return ListedColormap(colors) + else: + # If cmap is provided, create transparent version + if isinstance(cmap, str): + cmap = colormaps.get_cmap(cmap) + colors = cmap(np.linspace(0, 1, n_segments)) + colors[:, 3] = np.linspace(alpha_start, alpha_end, n_segments) + from matplotlib.colors import ListedColormap + return ListedColormap(colors) + + +def _create_segments(xstart, ystart, xend, yend, n_segments=100, multi_segment=False): + """Create line segments for LineCollection.""" + if multi_segment: + x = np.linspace(xstart, xend, n_segments + 1) + y = np.linspace(ystart, yend, n_segments + 1) + points = np.array([x, y]).T + points = np.concatenate([points, np.expand_dims(points[:, -1, :], 1)], axis=1) + points = np.expand_dims(points, 1) + segments = np.concatenate([points[:, :, :-2, :], + points[:, :, 1:-1, :], + points[:, :, 2:, :]], axis=1) + segments = np.transpose(segments, (0, 2, 1, 3)).reshape((-1, 3, 2)) + else: + segments = np.transpose(np.array([[xstart, ystart], [xend, yend]]), (2, 0, 1)) + return segments + + +def _lines_no_cmap(xstart, ystart, xend, yend, lw=None, color=None, ax=None, + n_segments=100, multi_segment=False, **kwargs): + """Create lines without colormap.""" + segments = _create_segments(xstart, ystart, xend, yend, + n_segments=n_segments, multi_segment=multi_segment) + color = to_rgba_array(color) + if (color.shape[0] > 1) and (color.shape[0] != xstart.size): + raise ValueError("xstart and color must be the same size") + line_collection = LineCollection(segments, color=color, linewidth=lw, snap=False, **kwargs) + line_collection = ax.add_collection(line_collection) + return line_collection + + +def _lines_cmap(xstart, ystart, xend, yend, lw=None, cmap=None, ax=None, + n_segments=100, multi_segment=False, reverse_cmap=False, **kwargs): + """Create lines with colormap.""" + segments = _create_segments(xstart, ystart, xend, yend, + n_segments=n_segments, multi_segment=multi_segment) + if reverse_cmap: + cmap = cmap.reversed() + line_collection = LineCollection(segments, cmap=cmap, linewidth=lw, snap=False, **kwargs) + line_collection = ax.add_collection(line_collection) + extent = ax.get_ylim() + court_array = np.linspace(extent[0], extent[1], n_segments) + line_collection.set_array(court_array) + return line_collection + + +class HandlerLines(HandlerLineCollection): + """Automatically generated by Lines() to allow use of linecollection in legend.""" + + def __init__(self, invert_y=False, first_lw=False, use_cmap=False, + marker_pad=0.3, numpoints=None, **kw): + HandlerLineCollection.__init__(self, marker_pad=marker_pad, numpoints=numpoints, **kw) + self.invert_y = invert_y + self.first_lw = first_lw + self.use_cmap = use_cmap + + def create_artists(self, legend, artist, xdescent, ydescent, + width, height, fontsize, trans): + x = np.linspace(0, width, self.get_numpoints(legend) + 1) + y = np.zeros(self.get_numpoints(legend) + 1) + height / 2. - ydescent + points = np.array([x, y]).T.reshape(-1, 1, 2) + segments = np.concatenate([points[:-1], points[1:]], axis=1) + lw = artist.get_linewidth() + if self.first_lw: + lw = lw[0] + if self.use_cmap: + cmap = artist.cmap + if self.invert_y: + cmap = cmap.reversed() + line_collection = LineCollection(segments, lw=lw, cmap=cmap, + snap=False, transform=trans) + line_collection.set_array(x) + else: + line_collection = LineCollection(segments, lw=lw, colors=artist.get_colors()[0], + snap=False, transform=trans) + return [line_collection] diff --git a/pyproject.toml b/pyproject.toml index 3e1a4d2..ff5313f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mplbasketball" -version = "1.0.0" +version = "1.1.0" description = "A Python plotting library for visualization of basketball data." authors = ["Sreekar Voleti "] license = "MIT" diff --git a/tests/test_lines.py b/tests/test_lines.py new file mode 100644 index 0000000..f86c036 --- /dev/null +++ b/tests/test_lines.py @@ -0,0 +1,123 @@ +"""Tests for the lines functionality in mplbasketball.""" + +import numpy as np +import pytest +from matplotlib.collections import LineCollection + +from mplbasketball import Court, Lines + + +class TestLines: + """Test class for lines functionality.""" + + def setup_method(self): + """Set up test fixtures.""" + self.court = Court(court_type="nba", origin="center") + self.fig, self.ax = self.court.draw(orientation="h") + + def test_basic_lines(self): + """Test basic line drawing.""" + xstart, ystart = [0, 10], [0, 10] + xend, yend = [20, 30], [20, 30] + + line_collection = Lines(xstart, ystart, xend, yend, + color="red", ax=self.ax) + + assert isinstance(line_collection, LineCollection) + assert len(line_collection.get_paths()) == 2 + + def test_comet_lines(self): + """Test comet line drawing.""" + line_collection = Lines(0, 0, 20, 20, comet=True, + color="blue", linewidth=5, ax=self.ax) + + assert isinstance(line_collection, LineCollection) + # Comet lines should have multiple segments + assert len(line_collection.get_paths()) > 1 + + def test_transparent_lines(self): + """Test transparent line drawing.""" + line_collection = Lines(0, 0, 20, 20, transparent=True, + color="green", alpha_start=0.1, alpha_end=1.0, + ax=self.ax) + + assert isinstance(line_collection, LineCollection) + # Transparent lines should have multiple segments + assert len(line_collection.get_paths()) > 1 + + def test_colormap_lines(self): + """Test lines with colormap.""" + line_collection = Lines([0, 10], [0, 10], [20, 30], [20, 30], + cmap="viridis", ax=self.ax) + + assert isinstance(line_collection, LineCollection) + assert line_collection.get_cmap() is not None + + def test_coordinate_transformation(self): + """Test coordinate transformation in lines.""" + # Test transformation from half-court left to full court + line_collection = Lines([-10, -15], [5, -5], [10, 15], [-5, 5], + fr="hl", to="h", color="purple", ax=self.ax) + + assert isinstance(line_collection, LineCollection) + + def test_invalid_parameters(self): + """Test error handling for invalid parameters.""" + # Test invalid comet parameter + with pytest.raises(TypeError): + Lines(0, 0, 20, 20, comet="invalid", ax=self.ax) + + # Test invalid transparent parameter + with pytest.raises(TypeError): + Lines(0, 0, 20, 20, transparent="invalid", ax=self.ax) + + # Test invalid alpha values + with pytest.raises(TypeError): + Lines(0, 0, 20, 20, transparent=True, alpha_start=-1, ax=self.ax) + + with pytest.raises(TypeError): + Lines(0, 0, 20, 20, transparent=True, alpha_end=2, ax=self.ax) + + def test_array_size_validation(self): + """Test validation of array sizes.""" + # Test mismatched array sizes + with pytest.raises(ValueError): + Lines([0, 10], [0], [20, 30], [20, 30], ax=self.ax) + + with pytest.raises(ValueError): + Lines([0, 10], [0, 10], [20], [20, 30], ax=self.ax) + + def test_multiple_colors_with_comet(self): + """Test that multiple colors with comet raises error.""" + with pytest.raises(NotImplementedError): + Lines([0, 10], [0, 10], [20, 30], [20, 30], + color=["red", "blue"], comet=True, ax=self.ax) + + def test_multiple_linewidths_with_comet(self): + """Test that multiple linewidths with comet raises error.""" + with pytest.raises(NotImplementedError): + Lines([0, 10], [0, 10], [20, 30], [20, 30], + linewidth=[2, 3], comet=True, ax=self.ax) + + def test_color_and_cmap_conflict(self): + """Test that color and cmap cannot be used together.""" + with pytest.raises(ValueError): + Lines(0, 0, 20, 20, color="red", cmap="viridis", ax=self.ax) + + def test_linewidth_aliases(self): + """Test that both 'linewidth' and 'lw' work.""" + line_collection1 = Lines(0, 0, 20, 20, linewidth=3, ax=self.ax) + line_collection2 = Lines(0, 0, 20, 20, lw=3, ax=self.ax) + + assert isinstance(line_collection1, LineCollection) + assert isinstance(line_collection2, LineCollection) + + def test_scalar_inputs(self): + """Test that scalar inputs work correctly.""" + line_collection = Lines(0, 0, 20, 20, color="red", ax=self.ax) + assert isinstance(line_collection, LineCollection) + assert len(line_collection.get_paths()) == 1 + + +if __name__ == "__main__": + pytest.main([__file__])