├── tests ├── __init__.py ├── baseline │ ├── test_court_3d.png │ ├── test_basic_court_generation.png │ ├── test_court_generation_options.png │ └── test_multiple_plot_generation.png ├── test_court3d.py ├── test_court.py └── test_utils.py ├── figs ├── court3d.png ├── court_h.png ├── heatmap.png ├── hexbin.png ├── center_h.png ├── mplbb_logo.png ├── multi_orientation.png └── center_h_populated.png ├── examples ├── sample_plot.png ├── plot_single.py └── plot_matrix.py ├── docs └── origin_vs_coordinates.pdf ├── mplbasketball ├── __init__.py ├── utils.py ├── court_params.py ├── court3d.py └── court.py ├── LICENSE.txt ├── pyproject.toml ├── .gitignore ├── CONTRIBUTING.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /figs/court3d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/figs/court3d.png -------------------------------------------------------------------------------- /figs/court_h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/figs/court_h.png -------------------------------------------------------------------------------- /figs/heatmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/figs/heatmap.png -------------------------------------------------------------------------------- /figs/hexbin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/figs/hexbin.png -------------------------------------------------------------------------------- /figs/center_h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/figs/center_h.png -------------------------------------------------------------------------------- /figs/mplbb_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/figs/mplbb_logo.png -------------------------------------------------------------------------------- /examples/sample_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/examples/sample_plot.png -------------------------------------------------------------------------------- /figs/multi_orientation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/figs/multi_orientation.png -------------------------------------------------------------------------------- /figs/center_h_populated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/figs/center_h_populated.png -------------------------------------------------------------------------------- /docs/origin_vs_coordinates.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/docs/origin_vs_coordinates.pdf -------------------------------------------------------------------------------- /mplbasketball/__init__.py: -------------------------------------------------------------------------------- 1 | from .court import Court 2 | from .court3d import Court3D 3 | 4 | __all__ = ["Court", "Court3D"] 5 | -------------------------------------------------------------------------------- /tests/baseline/test_court_3d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/tests/baseline/test_court_3d.png -------------------------------------------------------------------------------- /tests/baseline/test_basic_court_generation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/tests/baseline/test_basic_court_generation.png -------------------------------------------------------------------------------- /tests/baseline/test_court_generation_options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/tests/baseline/test_court_generation_options.png -------------------------------------------------------------------------------- /tests/baseline/test_multiple_plot_generation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlsedigital/mplbasketball/HEAD/tests/baseline/test_multiple_plot_generation.png -------------------------------------------------------------------------------- /examples/plot_single.py: -------------------------------------------------------------------------------- 1 | # Description: This script demonstrates how to plot a single shot chart using the mplbasketball package. 2 | 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | import sys 6 | 7 | sys.path.append("../") 8 | from mplbasketball.court import Court 9 | 10 | 11 | court_nba = Court( 12 | court_type="nba", 13 | ) 14 | ax = court_nba.draw() 15 | 16 | # Add things to plot here, using ax.scatter() 17 | 18 | plt.savefig("sample_plot.png") 19 | plt.show() 20 | -------------------------------------------------------------------------------- /tests/test_court3d.py: -------------------------------------------------------------------------------- 1 | # Testing basic 3D court plotting functionality 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | import pytest 6 | 7 | from mplbasketball.court3d import draw_court_3d 8 | 9 | 10 | @pytest.mark.mpl_image_compare(baseline_dir="baseline") 11 | def test_court_3d(): 12 | zlim = 20 13 | 14 | fig = plt.figure(figsize=(20, 20)) 15 | ax = fig.add_subplot(111, projection="3d") 16 | # Set up initial plot properties 17 | ax.set_zlim([0, zlim]) 18 | 19 | draw_court_3d(ax, origin=np.array([0.0, 0.0]), line_width=2) 20 | 21 | return fig 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2024 Maple Leaf Sports and Entertainment Ltd. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "mplbasketball" 3 | version = "1.0.0" 4 | description = "A Python plotting library for visualization of basketball data." 5 | authors = ["Sreekar Voleti "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/mlsedigital/mplbasketball" 9 | repository = "https://github.com/mlsedigital/mplbasketball" 10 | keywords = ["basketball", "visualization", "plotting", "matplotlib"] 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3.8", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Topic :: Scientific/Engineering :: Visualization" 21 | ] 22 | 23 | [tool.poetry.dependencies] 24 | python = "^3.9" 25 | numpy = "^1.26.0" 26 | matplotlib = "^3.9.0" 27 | 28 | [tool.poetry.urls] 29 | "Issues" = "https://github.com/mlsedigital/mplbasketball/issues" 30 | 31 | [tool.poetry.dev-dependencies] 32 | pytest = "^8.3.0" 33 | pytest-mpl = "0.17.0" 34 | 35 | [tool.poetry.group.dev.dependencies] 36 | pytest-cov = "^6.1.0" 37 | 38 | [build-system] 39 | requires = ["poetry-core>=1.9.0"] 40 | build-backend = "poetry.core.masonry.api" 41 | -------------------------------------------------------------------------------- /examples/plot_matrix.py: -------------------------------------------------------------------------------- 1 | # Description: This script demonstrates how to plot multiple basketball shots on a single plot using the mplbasketball package. 2 | 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | from mplbasketball.court import Court 6 | 7 | player_names = ["Fred Van Vleet", "Scottie Barnes", "OG Anunoby", "Pascal Siakam"] 8 | 9 | court_nba = Court( 10 | court_type="nba", 11 | ) 12 | x_pts = np.random.uniform(-47, 47, size=(20,)) 13 | y_pts = np.random.uniform(-25, 25, size=(20,)) 14 | axs = court_nba.draw(nrows=2, ncols=2) 15 | 16 | x_pts = np.random.uniform(-47, 47, size=(20,)) 17 | y_pts = np.random.uniform(-25, 25, size=(20,)) 18 | axs[0, 0].scatter(x=x_pts, y=y_pts, c="tab:blue") 19 | axs[0, 0].set_title(player_names[0]) 20 | 21 | x_pts = np.random.uniform(-47, 47, size=(20,)) 22 | y_pts = np.random.uniform(-25, 25, size=(20,)) 23 | axs[0, 1].scatter(x=x_pts, y=y_pts, c="tab:orange") 24 | axs[0, 1].set_title(player_names[1]) 25 | 26 | x_pts = np.random.uniform(-47, 47, size=(20,)) 27 | y_pts = np.random.uniform(-25, 25, size=(20,)) 28 | axs[1, 0].scatter(x=x_pts, y=y_pts, c="tab:red") 29 | axs[1, 0].set_title(player_names[2]) 30 | 31 | x_pts = np.random.uniform(-47, 47, size=(20,)) 32 | y_pts = np.random.uniform(-25, 25, size=(20,)) 33 | axs[1, 1].scatter(x=x_pts, y=y_pts, c="tab:green") 34 | axs[1, 1].set_title(player_names[3]) 35 | plt.tight_layout() 36 | plt.savefig("sample_plot.png", dpi=600) 37 | plt.show() 38 | -------------------------------------------------------------------------------- /tests/test_court.py: -------------------------------------------------------------------------------- 1 | # Testing basic 2D court plotting functionality 2 | 3 | import matplotlib 4 | import pytest 5 | 6 | from mplbasketball import Court 7 | 8 | matplotlib.use("Agg") 9 | 10 | 11 | @pytest.mark.mpl_image_compare(baseline_dir="baseline") 12 | def test_basic_court_generation(): 13 | """ 14 | Test whether basic court plotting works. 15 | """ 16 | court = Court(court_type="nba", origin="center", units="ft") 17 | # Create a new figure 18 | fig, ax = court.draw( 19 | orientation="h", 20 | nrows=1, 21 | ncols=1, 22 | dpi=200, 23 | showaxis=False, 24 | court_color="none", 25 | paint_color="none", 26 | line_color="black", 27 | line_alpha=1.0, 28 | line_width=None, 29 | hoop_alpha=1.0, 30 | pad=5.0, 31 | ) 32 | # Add any other basic elements (e.g., player positions) 33 | ax.scatter([1, 2, 3], [4, 5, 6], color="blue", label="Players") 34 | # Save the plot to the pytest-mpl baseline 35 | return fig 36 | 37 | 38 | @pytest.mark.mpl_image_compare(baseline_dir="baseline") 39 | def test_court_generation_options(): 40 | """ 41 | Test whether basic court plotting works with options. 42 | """ 43 | court = Court(court_type="nba", origin="center", units="m") 44 | # Create a new figure 45 | fig, ax = court.draw( 46 | orientation="v", 47 | nrows=1, 48 | ncols=1, 49 | dpi=200, 50 | showaxis=False, 51 | court_color="gold", 52 | paint_color="black", 53 | line_color="white", 54 | line_alpha=1.0, 55 | line_width=0.2, 56 | hoop_alpha=1.0, 57 | pad=5.0, 58 | ) 59 | # Add any other basic elements (e.g., player positions) 60 | # Save the plot to the pytest-mpl baseline 61 | return fig 62 | 63 | 64 | @pytest.mark.mpl_image_compare(baseline_dir="baseline") 65 | def test_multiple_plot_generation(): 66 | """ 67 | Test whether basic court plotting works with options. 68 | """ 69 | court = Court(court_type="nba", origin="center", units="m") 70 | # Create a new figure 71 | fig, ax = court.draw( 72 | orientation="v", 73 | nrows=2, 74 | ncols=3, 75 | dpi=200, 76 | showaxis=False, 77 | court_color="none", 78 | paint_color="none", 79 | line_color="green", 80 | line_alpha=1.0, 81 | line_width=None, 82 | hoop_alpha=1.0, 83 | pad=5.0, 84 | ) 85 | # Add any other basic elements (e.g., player positions) 86 | # Save the plot to the pytest-mpl baseline 87 | return fig 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Mac specific files 10 | *.DS_Store 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | *.ipynb 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | from mplbasketball.utils import transform 4 | 5 | def test_utils_functions(): 6 | """Test basic utility functions""" 7 | # Basic test to ensure the module is imported correctly 8 | assert True 9 | 10 | # We can add more specific tests as functions are implemented 11 | # For now, we'll just test what we know exists 12 | 13 | def test_transform_function(): 14 | """Test the transform function with various parameters""" 15 | # Create test data 16 | x = np.array([10.0, 20.0, 30.0]) 17 | y = np.array([5.0, 15.0, 25.0]) 18 | 19 | # Case 1: Same orientation (should not change) 20 | x_same, y_same = transform(x.copy(), y.copy(), 'h', 'h', 'center') 21 | np.testing.assert_array_equal(x_same, x) 22 | np.testing.assert_array_equal(y_same, y) 23 | 24 | # Case 2: Horizontal to vertical transformation 25 | x_hv, y_hv = transform(x.copy(), y.copy(), 'h', 'v', 'center') 26 | # Verify that the transformation occurred (x becomes -y, y becomes x) 27 | np.testing.assert_array_equal(x_hv, -y) 28 | np.testing.assert_array_equal(y_hv, x) 29 | 30 | # Case 3: Vertical to horizontal transformation 31 | x_vh, y_vh = transform(x.copy(), y.copy(), 'v', 'h', 'center') 32 | # Verify that the transformation occurred (x becomes y, y becomes -x) 33 | np.testing.assert_array_equal(x_vh, y) 34 | np.testing.assert_array_equal(y_vh, -x) 35 | 36 | # Case 4: Test different origins 37 | origins = ['center', 'top-left', 'bottom-left', 'top-right', 'bottom-right'] 38 | for origin in origins: 39 | x_o, y_o = transform(x.copy(), y.copy(), 'h', 'h', origin) 40 | assert isinstance(x_o, np.ndarray) 41 | assert isinstance(y_o, np.ndarray) 42 | assert len(x_o) == len(x) 43 | assert len(y_o) == len(y) 44 | 45 | # Case 5: Specific transformations 46 | # Horizontal to horizontal right 47 | x_hhr, y_hhr = transform(x.copy(), y.copy(), 'h', 'hr', 'center') 48 | assert isinstance(x_hhr, np.ndarray) 49 | assert isinstance(y_hhr, np.ndarray) 50 | 51 | # Horizontal to horizontal left 52 | x_hhl, y_hhl = transform(x.copy(), y.copy(), 'h', 'hl', 'center') 53 | assert isinstance(x_hhl, np.ndarray) 54 | assert isinstance(y_hhl, np.ndarray) 55 | 56 | # Vertical to vertical up 57 | x_vvu, y_vvu = transform(x.copy(), y.copy(), 'v', 'vu', 'center') 58 | assert isinstance(x_vvu, np.ndarray) 59 | assert isinstance(y_vvu, np.ndarray) 60 | 61 | # Vertical to vertical down 62 | x_vvd, y_vvd = transform(x.copy(), y.copy(), 'v', 'vd', 'center') 63 | assert isinstance(x_vvd, np.ndarray) 64 | assert isinstance(y_vvd, np.ndarray) 65 | 66 | # Case 6: Different court types 67 | court_types = ["nba", "wnba", "ncaa", "fiba"] 68 | for court_type in court_types: 69 | x_custom, y_custom = transform(x.copy(), y.copy(), 'h', 'v', 'center', court_type) 70 | assert isinstance(x_custom, np.ndarray) 71 | assert isinstance(y_custom, np.ndarray) 72 | 73 | # Case 7: Test invalid inputs 74 | with pytest.raises(ValueError): 75 | transform(x.copy(), y.copy(), 'invalid', 'h', 'center') 76 | 77 | with pytest.raises(ValueError): 78 | transform(x.copy(), y.copy(), 'h', 'invalid', 'center') 79 | 80 | with pytest.raises(ValueError): 81 | transform(x.copy(), y.copy(), 'h', 'v', 'invalid_origin') 82 | 83 | with pytest.raises(ValueError): 84 | transform(x.copy(), y.copy(), 'h', 'v', 'center', 'invalid_court') 85 | 86 | def test_color_functions(): 87 | """Test color utility functions if they exist""" 88 | try: 89 | # Test get_team_colors function if it exists 90 | colors = get_team_colors("LAL") 91 | assert isinstance(colors, dict) or isinstance(colors, tuple) or isinstance(colors, list) 92 | except NameError: 93 | # Function might not exist yet, skip this test 94 | pass 95 | 96 | try: 97 | # Test get_color_palette function if it exists 98 | palette = get_color_palette("default") 99 | assert isinstance(palette, dict) or isinstance(palette, list) 100 | except NameError: 101 | # Function might not exist yet, skip this test 102 | pass -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to mplbasketball 2 | 3 | Contributions are made to this repo via Issues and Pull Requests (PRs) 4 | 5 | ## Quicklinks 6 | 7 | - [Issues](#issues) 8 | - [Pull Requests](#pull-requests) 9 | - [Making Changes](#making-changes) 10 | - [Testing](#testing) 11 | - [Reviewing and Merging](#reviewing-and-merging) 12 | 13 | ## Issues 14 | 15 | Issues should be used to report problems with the library, request a new feature, or to discuss potential changes before a PR is created. 16 | 17 | If you find an Issue that addresses the problem you're having, please add your own reproduction information to the existing issue rather than creating a new one. Adding a [reaction](https://github.blog/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) can also help be indicating to our maintainers that a particular problem is affecting more than just the reporter. 18 | 19 | ## Pull Requests 20 | 21 | Pull requests should address a single concern with the least number of changed lines possible, be well-described, and ensure that the code is thoroughly tested before submission. We typically follow the standard Git workflow of cloning or forking the repository and then creating pull requests. Additionally, **poetry** is used to manage package dependencies in the project. To make a pull request: 22 | 23 | ### 1. Clone the Repository 24 | 25 | First, clone the repository from GitHub: 26 | 27 | ```bash 28 | git clone https://github.com/mlsedigital/mplbasketball.git 29 | ``` 30 | 31 | Then navigate into the project directory: 32 | 33 | ```bash 34 | cd mplbasketball 35 | ``` 36 | 37 | ### 2. Set up a Virtual Environment 38 | 39 | Create a Python virtual environment to isolate your dependencies: 40 | 41 | ```bash 42 | # macOS/Linux 43 | python3 -m venv .venv 44 | 45 | # Windows 46 | python -m venv .venv 47 | ``` 48 | 49 | Activate the virtual environment 50 | 51 | ```bash 52 | source .venv/bin/activate #on macOS/Linux 53 | 54 | source .venv\Scripts\activate #on WIndows 55 | ``` 56 | 57 | Verify the virtual environment is active (you should see `.venv` in your terminal prompt): 58 | 59 | ```bash 60 | which python 61 | ``` 62 | 63 | ### 3. Install Poetry 64 | 65 | Install Poetry within the virtual environment: 66 | 67 | ```bash 68 | pip install poetry 69 | ``` 70 | 71 | ### 4. Install Dependencies 72 | 73 | Inside the virtual environment, run the following to install all dependencies: 74 | 75 | ```bash 76 | poetry install 77 | ``` 78 | 79 | ## Making Changes 80 | 81 | 1. Make the necessary changes to the codebase. 82 | 2. Create a new branch for your changes: 83 | 84 | ```bash 85 | git checkout -b 86 | ``` 87 | 88 | 3. Commit your changes: 89 | 90 | ```bash 91 | git add . 92 | git commit -m "Describe your changes" 93 | ``` 94 | 95 | 4. Push your branch to Github and create a pull request 96 | 97 | ```bash 98 | git push origin 99 | ``` 100 | 101 | Open a pull request by visiting the [Pull Requests](https://github.com/mlsedigital/mplbasketball/pulls) page on GitHub. 102 | 103 | ## Testing 104 | 105 | Before submitting your pull request, make sure to run the tests to ensure your changes don't break existing functionality. 106 | 107 | ### Running Tests 108 | 109 | You can run the tests using pytest through Poetry: 110 | 111 | ```bash 112 | # Run all tests 113 | poetry run pytest 114 | 115 | # Run tests with verbose output 116 | poetry run pytest -v 117 | ``` 118 | 119 | ### Checking Code Coverage 120 | 121 | To check the code coverage of the tests, use the `pytest-cov` plugin: 122 | 123 | ```bash 124 | # Run tests with coverage report 125 | poetry run pytest --cov=mplbasketball 126 | 127 | # Generate a detailed HTML coverage report 128 | poetry run pytest --cov=mplbasketball --cov-report=html 129 | ``` 130 | 131 | The HTML coverage report will be generated in the `htmlcov` directory. Open `htmlcov/index.html` in your browser to view the detailed coverage report. 132 | 133 | ### Writing Tests 134 | 135 | When adding new features or fixing bugs, please include tests that cover your changes. Tests should be placed in the `tests/` directory and follow the existing naming conventions: 136 | 137 | - Test files should be named `test_*.py` 138 | - Test functions should be named `test_*` 139 | - Each test function should focus on testing a specific functionality 140 | 141 | ## Reviewing and Merging 142 | 143 | To facilitate a smooth merge: 144 | 145 | - **Be Responsive**: Address any feedback or requested changes promptly. 146 | - **Follow Workflow**: We use the standard Git workflow, typically involving cloning or forking the repository and creating pull requests. Ensure your branch is up-to-date with the main branch before submission. 147 | - **Dependency Management**: The project uses Poetry for package dependency management. Make sure to test your changes in a properly configured environment to avoid dependency conflicts. 148 | 149 | Once you submit a pull request, it will be reviewed by maintainers. Please be patient and responsive to feedback to ensure a smooth merge. 150 | 151 | --- 152 | 153 | Thank you for contributing! 154 | -------------------------------------------------------------------------------- /mplbasketball/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | import numpy as np 4 | 5 | from mplbasketball.court_params import _get_court_params_in_desired_units 6 | 7 | 8 | def transform( 9 | x: np.ndarray, 10 | y: np.ndarray, 11 | fr: Literal["h", "hl", "hr", "v", "vu", "vd"], 12 | to: Literal["h", "hl", "hr", "v", "vu", "vd"], 13 | origin: Literal["center", "top-left", "bottom-left", "top-right", "bottom-right"], 14 | court_type: Literal["nba", "wnba", "ncaa", "fiba"] = "nba", 15 | ) -> tuple[np.ndarray, np.ndarray]: 16 | """ 17 | Function to transform a set of x, y values to match orientations desired for plotting. 18 | 19 | Parameters: 20 | ----------- 21 | - x: np.array 22 | x-coordinates of the data points. 23 | - y: np.array 24 | y-coordinates of the data points. 25 | - fr: str 26 | Current orientation of the data points. Can be one of 'h', 'hl', 'hr', 'v', 'vu', 'vd'. 27 | - to: str 28 | Desired orientation of the data points. Can be one of 'h', 'hl', 'hr', 'v', 'vu', 'vd'. 29 | - origin: str 30 | Origin of the court. Can be one of 'center', 'top-left', 'bottom-left', 'top-right', 'bottom-right'. 31 | - court_type: str 32 | Court type. Can be one of 'nba', 'wnba', 'ncaa', 'fiba'. 33 | 34 | Returns: 35 | -------- 36 | - x: np.array 37 | Transformed x-coordinates. 38 | - y: np.array 39 | Transformed y-coordinates. 40 | """ 41 | # Validate inputs 42 | if fr not in ["h", "hl", "hr", "v", "vu", "vd"]: 43 | raise ValueError(f"Invalid value for 'fr': {fr}") 44 | if to not in ["h", "hl", "hr", "v", "vu", "vd"]: 45 | raise ValueError(f"Invalid value for 'to': {to}") 46 | if origin not in ["center", "top-left", "bottom-left", "top-right", "bottom-right"]: 47 | raise ValueError(f"Invalid value for 'origin': {origin}") 48 | if court_type not in ["nba", "wnba", "ncaa", "fiba"]: 49 | raise ValueError(f"Invalid value for 'court_type': {court_type}") 50 | 51 | # Retrieve court parameters 52 | court_params = _get_court_params_in_desired_units(court_type, "ft") 53 | court_dims = court_params["court_dims"] 54 | 55 | # Calculate center based on origin 56 | origins = { 57 | "center": [0.0, 0.0], 58 | "top-left": [court_dims[0] / 2, -court_dims[1] / 2], 59 | "bottom-left": [court_dims[0] / 2, court_dims[1] / 2], 60 | "top-right": [-court_dims[0] / 2, -court_dims[1] / 2], 61 | "bottom-right": [-court_dims[0] / 2, court_dims[1] / 2], 62 | } 63 | center_court = origins[origin] 64 | 65 | if fr == to: 66 | return x, y 67 | 68 | else: 69 | x_t, y_t = x.copy(), y.copy() 70 | 71 | if fr[0] == "h" and to[0] == "h": 72 | if (fr == "h" or fr == "hl") and to == "hr": 73 | x[x_t < center_court[0]] = 2 * center_court[0] - x[x_t < center_court[0]] 74 | y[x_t < center_court[0]] = 2 * center_court[1] - y[x_t < center_court[0]] 75 | elif (fr == "h" or fr == "hr") and to == "hl": 76 | x[x_t > center_court[0]] = 2 * center_court[0] - x[x_t > center_court[0]] 77 | y[x_t > center_court[0]] = 2 * center_court[1] - y[x_t > center_court[0]] 78 | 79 | elif fr[0] == "v" and to[0] == "v": 80 | if (fr == "v" or fr == "vl") and to == "vu": 81 | x[y_t < center_court[0]] = -2 * center_court[1] - x[y_t < center_court[0]] 82 | y[y_t < center_court[0]] = 2 * center_court[0] - y[y_t < center_court[0]] 83 | elif (fr == "v" or fr == "vu") and to == "vd": 84 | x[y > center_court[0]] = -2 * center_court[1] - x[y > center_court[0]] 85 | y[y > center_court[0]] = 2 * center_court[0] - y[y > center_court[0]] 86 | 87 | elif fr[0] == "h" and to[0] == "v": 88 | if to[0] == "v": # Works for any of 'h', 'hl', 'hr' 89 | x = -y_t 90 | y = x_t 91 | if to == "vu": 92 | x[y < center_court[0]] = -2 * center_court[1] - x[y < center_court[0]] 93 | y[y < center_court[0]] = 2 * center_court[0] - y[y < center_court[0]] 94 | elif to == "vd": 95 | x[y > center_court[0]] = -2 * center_court[1] - x[y > center_court[0]] 96 | y[y > center_court[0]] = 2 * center_court[0] - y[y > center_court[0]] 97 | 98 | elif fr[0] == "v" and to[0] == "h": 99 | if to[0] == "h": # Works for any of 'v', 'vu', 'vd' 100 | x = y_t 101 | y = -x_t 102 | if to == "hr": 103 | x[x < center_court[0]] = 2 * center_court[0] - x[x < center_court[0]] 104 | y[x < center_court[0]] = 2 * center_court[1] - y[x < center_court[0]] 105 | elif to == "hl": 106 | x[x > center_court[0]] = 2 * center_court[0] - x[x > center_court[0]] 107 | y[x > center_court[0]] = 2 * center_court[1] - y[x > center_court[0]] 108 | 109 | return x, y 110 | -------------------------------------------------------------------------------- /mplbasketball/court_params.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | nba_court_parameters = { 4 | "court_dims": [94.0, 50.0], 5 | # Hoop area 6 | "hoop_distance_from_edge": 5.25, 7 | "hoop_radius": 0.75, 8 | # backboard parameters 9 | "backboard_distance_from_edge": 4.0, 10 | "backboard_width": 6.0, 11 | "backboard_height": 3.5, 12 | "backboard_inner_rect_width": 2.0, 13 | "backboard_inner_rect_height": 1.5, 14 | "backboard_inner_rect_from_bottom": 1.0, 15 | "charge_circle_radius": 4.0, 16 | "charge_circle_side_length": 3.0, 17 | # Inbound lines 18 | "inbound_line_distance_from_edge": 28.0, 19 | "inbound_line_length": 3.0, 20 | "outbound_line_distance_from_center": 4.0 + 1 / 12.0, 21 | "outbound_line_length": 4.0, 22 | # Outer paint 23 | "outer_paint_dims": [18.0 + 5 / 6, 16 - 1 / 3], 24 | # Inner paint 25 | "inner_paint_dims": [18.0 + 5 / 6, 12 - 1 / 3], 26 | # Center circle 27 | "outer_circle_radius": 6.0, 28 | "inner_circle_radius": 2.0, 29 | # Three point area 30 | "three_point_arc_angle": 68.13, 31 | "three_point_arc_diameter": 47.5, 32 | "three_point_line_length": 14.0, 33 | "three_point_side_width": 3.0, 34 | # Hoop height 35 | "hoop_height": 10.0, 36 | } 37 | 38 | wnba_court_parameters = { 39 | "court_dims": [94.0, 50.0], 40 | # Hoop area 41 | "hoop_distance_from_edge": 5.25, 42 | "hoop_radius": 0.75, 43 | # backboard properties 44 | "backboard_distance_from_edge": 4.0, 45 | "backboard_width": 6.0, 46 | "backboard_height": 3.5, 47 | "backboard_inner_rect_width": 2.0, 48 | "backboard_inner_rect_height": 1.5, 49 | "backboard_inner_rect_from_bottom": 1.0, 50 | # court properties 51 | "charge_circle_radius": 4.0, 52 | "charge_circle_side_length": 3.0, 53 | # Inbound lines 54 | "inbound_line_distance_from_edge": 28.0, 55 | "inbound_line_length": 3.0, 56 | "outbound_line_distance_from_center": 4.0 + 1 / 12.0, 57 | "outbound_line_length": 4.0, 58 | # Outer paint 59 | "outer_paint_dims": [18.0 + 5 / 6, 16 - 1 / 3], 60 | # Inner paint 61 | "inner_paint_dims": [18.0 + 5 / 6, 12 - 1 / 3], 62 | # Center circle 63 | "outer_circle_radius": 6.0, 64 | "inner_circle_radius": 2.0, 65 | # Three point area 66 | "three_point_arc_angle": 83.51692630710276, 67 | "three_point_arc_diameter": 44.365, 68 | "three_point_line_length": 7.75, 69 | "three_point_side_width": 3.0, 70 | # Hoop height 71 | "hoop_height": 10.0, 72 | } 73 | 74 | ncaa_court_parameters = { 75 | "court_dims": [94.0, 50.0], 76 | # Hoop area 77 | "hoop_distance_from_edge": 5.25, 78 | "hoop_radius": 0.75, 79 | # backboard properties 80 | "backboard_distance_from_edge": 4.0, 81 | "backboard_width": 6.0, 82 | "backboard_height": 3.5, 83 | "backboard_inner_rect_width": 2.0, 84 | "backboard_inner_rect_height": 1.5, 85 | "backboard_inner_rect_from_bottom": 1.0, 86 | # court properties 87 | "charge_circle_radius": 4.0, 88 | "charge_circle_side_length": 3.0, 89 | # Inbound lines 90 | "inbound_line_distance_from_edge": 28.0, 91 | "inbound_line_length": 3.0, 92 | "outbound_line_distance_from_center": 4.0 + 1 / 12.0, 93 | "outbound_line_length": 4.0, 94 | # Outer paint 95 | "outer_paint_dims": [18.0 + 5 / 6, 12 - 1 / 3], 96 | # Inner paint 97 | "inner_paint_dims": [18.0 + 5 / 6, 12 - 1 / 3], 98 | # Center circle 99 | "outer_circle_radius": 6.0, 100 | "inner_circle_radius": 6.0, 101 | # Three point area 102 | "three_point_arc_angle": 78.95, 103 | "three_point_arc_diameter": 44.218, 104 | "three_point_line_length": 9.4, 105 | "three_point_side_width": 3.34375, 106 | # Hoop height 107 | "hoop_height": 10.0, 108 | } 109 | 110 | 111 | fiba_court_parameters = { 112 | "court_dims": [91.8635, 49.2126], 113 | # Hoop area 114 | "hoop_distance_from_edge": 5.167, 115 | "hoop_radius": 0.75, 116 | # backboard properties 117 | "backboard_distance_from_edge": 3.937, 118 | "backboard_width": 6.0, 119 | "backboard_height": 3.5, 120 | "backboard_inner_rect_width": 2.0, 121 | "backboard_inner_rect_height": 1.5, 122 | "backboard_inner_rect_from_bottom": 1.0, 123 | # court properties 124 | "charge_circle_radius": 3.94, 125 | "charge_circle_side_length": 3.0, 126 | # Inbound lines 127 | "inbound_line_distance_from_edge": 27.32, 128 | "inbound_line_length": 3.0, 129 | "outbound_line_distance_from_center": 3.9685 + 1 / 12.0, 130 | "outbound_line_length": 4.0, 131 | # Outer paint 132 | "outer_paint_dims": [18.0289 + 5 / 6, 16.08 - 1 / 3], 133 | # Inner paint 134 | "inner_paint_dims": [18.0289 + 5 / 6, 12 - 1 / 3], 135 | # Center circle 136 | "outer_circle_radius": 5.90551, 137 | "inner_circle_radius": 5.90551, 138 | # Three point area 139 | "three_point_arc_angle": 78.9, 140 | "three_point_arc_diameter": 44.218, 141 | "three_point_line_length": 9.4, 142 | "three_point_side_width": 2.953, 143 | # Hoop height 144 | "hoop_height": 10.0, 145 | } 146 | 147 | 148 | def _get_court_params_in_desired_units( 149 | court_type: Literal["nba", "wnba", "ncaa", "fiba"], desired_units: Literal["m", "ft"] 150 | ): 151 | """ 152 | Function to convert court parameters to units of choice. 153 | """ 154 | assert court_type in ["nba", "wnba", "ncaa", "fiba"], "Invalid court type" 155 | assert desired_units in ["m", "ft"], "Invalid units, Currently only 'm' and 'ft' are supported" 156 | 157 | if desired_units == "m": 158 | conversion_factor = 0.3048 159 | else: 160 | conversion_factor = 1.0 161 | 162 | if court_type == "nba": 163 | court_params = nba_court_parameters 164 | elif court_type == "wnba": 165 | court_params = wnba_court_parameters 166 | elif court_type == "ncaa": 167 | court_params = ncaa_court_parameters 168 | elif court_type == "fiba": 169 | court_params = fiba_court_parameters 170 | 171 | new_court_params = {} 172 | 173 | for key, value in court_params.items(): 174 | if "angle" not in key.split("_"): 175 | if isinstance(value, list): 176 | new_court_params[key] = [val * conversion_factor for val in value] 177 | else: 178 | new_court_params[key] = value * conversion_factor 179 | else: 180 | new_court_params[key] = value 181 | 182 | return new_court_params 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | **A Python plotting library to visualize basketball data, created by the Sport Performance Lab (SPL) at Maple Leaf Sports & Entertainment (MLSE), Toronto, Canada.** 6 | 7 | [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. 8 | 9 | # Quick start 10 | 11 | Install the package using `pip` (or `pip3`). 12 | 13 | ``` 14 | pip install mplbasketball 15 | ``` 16 | 17 | Plot an NBA basketball court, with the origin at center court, and measuring in feet (currently `"ft"` and `"m"` are supported): 18 | 19 | ```python 20 | from mplbasketball import Court 21 | 22 | court = Court(court_type="nba", origin="center", units="ft") 23 | fig, ax = court.draw(showaxis=True) 24 | ``` 25 | 26 |

27 | 28 |

29 | 30 | Plot some points on the court: 31 | 32 | ```python 33 | import numpy as np 34 | 35 | n_points = 100 36 | x = np.random.uniform(-47, 47, size=n_points) 37 | y = np.random.uniform(-25, 25, size=n_points) 38 | 39 | ax.scatter(x, y) 40 | ``` 41 | 42 |

43 | 44 |

45 | 46 | # List of capabilities 47 | 48 | Currently, you can use `mplbasketball` to 49 | 50 | 1. Plot 2D and 3D spatio-temporal basketball data from 4 major basketball competitions 51 | 1. [NBA](https://official.nba.com/rule-no-1-court-dimensions-equipment/) 52 | 2. [WNBA](https://www.wnba.com/archive/wnba/analysis/rule_one.html) 53 | 3. [NCAA](https://ncaaorg.s3.amazonaws.com/championships/sports/basketball/rules/common/PRXBB_CourtDiagram.pdf) 54 | 4. [FIBA](https://nz.basketball/wp-content/uploads/2020/02/FIBA-Basketball-Court-Dimensions.pdf) 55 | 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. 56 | 3. Easily interface with existing `matplotlib` functions. 57 | 58 | # Before you begin 59 | 60 | Some notes on plotting data using `mplbasketball`: 61 | 62 | 1. **Ensure your 2D data is in a right-handed coordinate system (RHCS).** Many data providers (including the [NBA](https://developer.geniussports.com/nbangss/rest/index_central.html#CourtDefinition)) provide their data using left handed coordinate systems (LHCS). If you obtain data in an LHCS, simply flip the sign of all the `y` components (assuming data is left-right), making it compatible with plotting using `mplbasketball`. 63 | 64 | # Usage 65 | 66 | ## The `Court` class 67 | 68 | The `Court` class comprises of the dimensions of the basketball court under consideration. When being defined, it takes in a `court_type`, which can currently be `"nba"` (default), `"wnba"`, or `"ncaa"`. Court dimension measurements are provided in `mplbasketball/court_params.py`. The `Court` class has a method `draw()`, which will draw the court in any desired `orientation`: 69 | 70 | 1. `"h"`: horizontal (default) 71 | 2. `"v"`: vertical 72 | 3. `"hl"`: horizontal, only left side 73 | 4. `"hr"`: horizontal, only right side 74 | 5. `"vu"`: vertical, only up side 75 | 6. `"vd"`: vertical, only down side 76 | 77 | The `draw()` method can either be called to define a matplotlib `fig` and `ax` object using: 78 | 79 | ```python 80 | from mplbasketball import Court 81 | 82 | court = Court(origin="top-left") 83 | fig, ax = court.draw(orientation="h") 84 | ``` 85 | 86 | After this, you can simply plot your data on the `ax` object. **Note that you may need to adjust the `zorder` to ensure all elements are properly visible.** 87 | 88 | ### Setting the origin 89 | 90 | The package allows for the origin of the data to be in 5 locations on the court (numbers quoted below are in feet, and in accordance with the NBA handbook's court dimensions, when using `"m"` as the unit, simply multiply the numbers below by the appropriate conversion factor): 91 | 92 | 1. `"center"`: Center court and the origin coincide. 93 | 2. `"top-left"`: Center court is at `[47, -25]`. 94 | 3. `"bottom-left"`: Center court is at `[47, 25]`. 95 | 4. `"top-right"`: Center court is at `[-47, -25]`. 96 | 5. `"bottom-right"`: Center court is at `[-47, 25]`. 97 | 98 | The origin should be specified in the initial specification of the `Court` object. To see what the x and y ranges are for each origin choice, see [this document](https://raw.githubusercontent.com/mlsedigital/mplbasketball/main/docs/origin_vs_coordinates.pdf). **These origin conventions assume the data is in the left-right direction.** 99 | 100 | ## Transforming Data to Different Orientations 101 | 102 | Often, it is more useful to view data from different perspectives. As mentioned in the preceding section, there are 6 orientations to view 2D spatiotemporal data in `mplbasketball`. 103 | 104 | Additionally, the `utils.transform()` function allows you to easily change perspectives while accounting for different court types. By selecting a `court_type` (e.g., `"nba"`, `"wnba"`, `"ncaa"`, or `"fiba"`), the function ensures that transformations are accurate for the specific dimensions and characteristics of the chosen court. 105 | 106 | We first load some data in its original form; say we are working with a dataset that uses the `"bottom-left"` part of the court as the origin. 107 | 108 | ```python 109 | import numpy as np 110 | import matplotlib.pyplot as plt 111 | from mplbasketball import Court 112 | from mplbasketball.utils import transform 113 | 114 | # Initialize Court object 115 | origin = "bottom-left" 116 | court_type = "nba" 117 | court = Court(origin=origin) 118 | fig, ax = plt.subplots(1, 4) 119 | 120 | # Simulate some data 121 | n_pts = 100 122 | x_1 = np.random.uniform(0, 94, size=n_pts) 123 | y_1 = np.random.uniform(0, 50, size=n_pts) 124 | 125 | x_2 = np.random.uniform(0, 94, size=n_pts) 126 | y_2 = np.random.uniform(0, 50, size=n_pts) 127 | 128 | # On the first subplot, plot the data as is 129 | court.draw(ax[0], ) 130 | ax[0].scatter(x_1, y_1, s=5, c="tab:blue") 131 | ax[0].scatter(x_2, y_2, s=5, c="tab:orange") 132 | ``` 133 | 134 | Now, say we want to visualize the first (blue) dataset normalized to the left side, and the second (orange) dataset normalized to the right. 135 | In the second subplot, we can transform the data such that all of the points are normalized to their respective side. 136 | 137 | ```python 138 | x_1_hl, y_1_hl = transform(x_1, y_1, fr="h", to="hl", origin=origin, court_type=court_type) 139 | x_2_hr, y_2_hr = transform(x_2, y_2, fr="h", to="hr", origin=origin, court_type=court_type) 140 | court.draw(ax[1], ) 141 | ax[1].scatter(x_1_hl, y_1_hl, s=5, c="tab:blue") 142 | ax[1].scatter(x_2_hr, y_2_hr, s=5, c="tab:orange") 143 | ``` 144 | 145 | Here, the `fr` and `to` arguments tell the function what orientation the data currently is in, and what the desired orientation is, respectively. Finally, we can visualize this left-normalized data on a vertical court. Say we want to look at the blue data with the hoop at the bottom (the `"vd"` orientation), and the orange data with the hoop at the top. This is again very easy: 146 | 147 | ```python 148 | x_1_vd, y_1_vd = transform(x_1_hl, y_1_hl, fr="hl", to="vd", origin=origin, court_type=court_type) 149 | court.draw(ax[2], orientation="vd") 150 | ax[2].scatter(x_1_vd, y_1_vd, s=5, c="tab:blue") 151 | 152 | x_2_vu, y_2_vu = transform(x_2_hr, y_2_hr, fr="hl", to="vu", origin=origin, court_type=court_type) 153 | court.draw(ax[3], orientation="vu") 154 | ax[3].scatter(x_2_vu, y_2_vu, s=5, c="tab:orange") 155 | ``` 156 | 157 | The final result looks like this: 158 | 159 |

160 | 161 |

162 | 163 | Some notes about the above: 164 | 165 | 1. To produce data for the final plot, we could have also used 166 | 167 | ```python 168 | x_1_vd, y_1_vd = transform(x_1, y_1, fr="h", to="vd", origin=origin, court_type=court_type) 169 | ``` 170 | 171 | 2. To show more/less of the court markings, we can make use of the `zorder` argument in the `ax.scatter` plots. 172 | 173 | ## 3D Court plotting 174 | 175 | The `court3d` module allows for the plotting of 3D basketball data, particularly useful when visualizing 3D ball motion, or in cases where body pose data is available. The `draw_court_3d()` function is the quickest way to obtain a drawing of a court in 3D space. 176 | 177 | ```python 178 | from mplbasketball.court3d import draw_court_3d 179 | import matplotlib.pyplot as plt 180 | 181 | zlim = 20 182 | 183 | fig = plt.figure(figsize=(20, 20)) 184 | ax = fig.add_subplot(111, projection="3d") 185 | # Set up initial plot properties 186 | ax.set_zlim([0, zlim]) 187 | 188 | draw_court_3d(ax, origin=np.array([0.0, 0.0]), line_width=2) 189 | ``` 190 | 191 |

192 | 193 |

194 | 195 | ## Interfacing with matplotlib functions 196 | 197 | ### Hex-binning 198 | 199 | ```python 200 | import numpy as np 201 | import matplotlib.pyplot as plt 202 | from mplbasketball import Court 203 | from mplbasketball.utils import transform 204 | 205 | # Initialize Court object 206 | origin = "bottom-left" 207 | court = Court(origin=origin) 208 | fig, ax = plt.subplots() 209 | 210 | # Simulate some data 211 | n_pts = 1000 212 | x_1 = np.random.uniform(0, 94, size=n_pts) 213 | y_1 = np.random.uniform(0, 50, size=n_pts) 214 | x_2 = np.random.uniform(0, 94, size=n_pts) 215 | y_2 = np.random.uniform(0, 50, size=n_pts) 216 | 217 | # Transform the data 218 | x_1_hl, y_1_hl = transform(x_1, y_1, fr="h", to="hl", origin=origin,court_type="nba") 219 | x_2_hr, y_2_hr = transform(x_2, y_2, fr="h", to="hr", origin=origin, court_type="nba") 220 | 221 | # Draw the court, slightly thicken the lines 222 | court.draw(ax, line_color="white", line_width=0.3) 223 | 224 | # Hex-bin the data, while ensuring that the court is plotted on top 225 | ax.hexbin(x_1_hl, y_1_hl, gridsize=(24, 18), extent=(0, 47, 0, 50), zorder=0) 226 | ax.hexbin(x_2_hr, y_2_hr, gridsize=(24, 18), extent=(47, 94, 0, 50), zorder=0 , cmap="hot") 227 | ``` 228 | 229 |

230 | 231 |

232 | 233 | ### Heatmaps 234 | 235 | ```python 236 | # Compute the heatmap 237 | heatmap_1, xedges_1, yedges_1 = np.histogram2d(x_1, y_1, bins=(94//4, 50//2), range=[[0, 94/2], [0, 50]]) 238 | heatmap_2, xedges_2, yedges_2 = np.histogram2d(x_2, y_2, bins=(94//4, 50//2), range=[[94/2, 94], [0, 50]]) 239 | 240 | # Draw the court, slightly thicken the lines 241 | fig, ax = court.draw(line_color="white", line_width=0.3) 242 | 243 | # Display the heatmaps 244 | extent_1 = [xedges_1[0], xedges_1[-1], yedges_1[0], yedges_1[-1]] 245 | extent_2 = [xedges_2[0], xedges_2[-1], yedges_2[0], yedges_2[-1]] 246 | 247 | ax.imshow(heatmap_1.T, extent=extent_1, origin='lower', cmap='cividis', zorder=-1) 248 | ax.imshow(heatmap_2.T, extent=extent_2, origin='lower', cmap='cividis', zorder=-1) 249 | 250 | ``` 251 | 252 |

253 | 254 |

255 | 256 | # Documentation 257 | 258 | 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)! 259 | 260 | # Contribute 261 | 262 | We welcome feedback and contributions to this package - browse the [open issues](https://github.com/mlsedigital/mplbasketball/issues), or open a [pull request](https://github.com/mlsedigital/mplbasketball/pulls)! 263 | 264 | Please follow the guidelines in our [CONTRIBUTING.md](./CONTRIBUTING.md) file to get started. 265 | 266 | # Inspirations 267 | 268 | This package takes inspiration from [mplsoccer](https://github.com/andrewRowlinson/mplsoccer), one of the first and best-written sports plotting libraries. Many of the structural decisions made here have been inspired by mplsoccer's `Pitch` class. 269 | 270 | # License 271 | 272 | [MIT](https://raw.githubusercontent.com/mlsedigital/mplbasketball/main/LICENSE.txt) 273 | -------------------------------------------------------------------------------- /mplbasketball/court3d.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | import matplotlib as mpl 4 | import matplotlib.lines as lines 5 | import matplotlib.patches as patches 6 | import matplotlib.pyplot as plt 7 | import numpy as np 8 | from matplotlib.axes import Axes 9 | from matplotlib.figure import Figure 10 | 11 | from mplbasketball.court_params import _get_court_params_in_desired_units 12 | 13 | Color = tuple[float, float, float] | str 14 | 15 | 16 | class Court3D: 17 | """ 18 | A class to represent a basketball court and facilitate its plotting. 19 | 20 | Attributes: 21 | - court_type (str): Type of the court, either 'nba', 'wnba', 'ncaa' or 'fiba'. 22 | - units (str): Units of the court dimensions, either 'ft' or 'm'. 23 | - court_parameters (dict): Parameters defining the dimensions and characteristics of the court. 24 | - origin (np.array): The origin point of the court. 25 | 26 | Methods: 27 | - draw(ax, orientation, half, nrows, ncols, dpi, showaxis, court_color, paint_color, line_color, line_alpha, line_width, hoop_alpha, pad): 28 | Draws the basketball court according to specified parameters. 29 | 30 | Args: 31 | - court_type (str): Specifies the type of basketball court ('nba', 'wnba', 'ncaa' or 'fiba'). Defaults to 'nba'. 32 | 33 | Raises: 34 | - AssertionError: If the provided court_type is not 'nba', 'wnba', 'ncaa' or 'fiba'. 35 | """ 36 | 37 | def __init__(self, court_type="nba", origin=np.array([0.0, 0.0]), units="ft"): 38 | assert court_type in [ 39 | "nba", 40 | "wnba", 41 | "ncaa", 42 | "fiba", 43 | ], "Invalid court_type. Please choose from [nba, wnba, ncaa, fiba]" 44 | assert units in ["ft", "m"], "Invalid units. Please choose from ['ft', 'm']" 45 | 46 | self.court_type = court_type 47 | self.units = units 48 | self.court_parameters = _get_court_params_in_desired_units(self.court_type, self.units) 49 | self.origin = origin 50 | 51 | def draw( 52 | self, 53 | ax: Axes, 54 | showaxis=False, 55 | court_color="none", 56 | paint_color="none", 57 | line_color="black", 58 | line_alpha=1.0, 59 | line_width: float = None, 60 | hoop_alpha=1.0, 61 | pad=5.0, 62 | ) -> tuple[Figure, Axes] | Figure: 63 | """ 64 | Draws the basketball court according to specified parameters. 65 | 66 | This method allows customization of the court's appearance and can plot either a full court or half-court in horizontal or vertical orientation. 67 | 68 | Args: 69 | - ax (matplotlib.axes.Axes, optional): The matplotlib axes to draw on. If None, a new figure and axes are created. 70 | - orientation (str): Orientation of the court. Defaults to 'h'. 71 | - nrows (int): Number of rows in the subplot grid. Defaults to 1. 72 | - ncols (int): Number of columns in the subplot grid. Defaults to 1. 73 | - dpi (int): Dots per inch for the plot. Defaults to 200. 74 | - showaxis (bool): Whether to show axis on the plot. Defaults to False. 75 | - court_color (str): Background color of the court. Defaults to 'none'. 76 | - paint_color (str): Color of the paint area. Defaults to 'none'. 77 | - line_color (str): Color of the lines on the court. Defaults to 'black'. 78 | - line_alpha (float): Transparency of court lines. Defaults to 1. 79 | - line_width (float): Width of the lines on the court. Defaults to 1/6. 80 | - hoop_alpha (float): Transparency of the hoop. Defaults to 1. 81 | - pad (float): Padding around the court. Defaults to 5. 82 | 83 | Returns: 84 | matplotlib.figure.Figure, matplotlib.axes.Axes: The figure and axes objects containing the court plot. 85 | 86 | Raises: 87 | AssertionError: If orientation is not 'horizontal' or 'vertical', or if dpi is less than 200. 88 | """ 89 | 90 | if line_width is None: 91 | if self.units == "ft": 92 | line_width = 1.0 / 6.0 93 | elif self.units == "m": 94 | line_width = 1.0 / 6.0 * 0.3048 95 | 96 | self._draw_horizontal_court( 97 | ax, 98 | court_color=court_color, 99 | paint_color=paint_color, 100 | line_color=line_color, 101 | line_alpha=line_alpha, 102 | line_width=line_width, 103 | hoop_alpha=hoop_alpha, 104 | pad=pad, 105 | ) 106 | 107 | if showaxis is False: 108 | ax.axis("off") 109 | ax.set_aspect("equal") 110 | return ax 111 | 112 | def _draw_horizontal_court( 113 | self, ax: Axes, court_color, paint_color, line_color, line_alpha, line_width, hoop_alpha, pad 114 | ): 115 | angle_a = 9.7800457882 # Angle 1 for lower FT line 116 | angle_b = 12.3415314172 # Angle 2 for lower FT line 117 | 118 | origin_shift_x, origin_shift_y = -self.origin 119 | court_x, court_y = self.court_parameters["court_dims"] 120 | cf = 0.0 # line_width/2 121 | 122 | # Draw the main court rectangle 123 | self._draw_rectangle( 124 | ax, 125 | origin_shift_x - court_x / 2 - cf, 126 | origin_shift_y - court_y / 2, 127 | court_x + 2 * cf, 128 | court_y + 2 * cf, 129 | line_width=line_width, 130 | line_color=line_color, 131 | line_style="-", 132 | face_color=court_color, 133 | alpha=line_alpha, 134 | ) 135 | 136 | # Draw the outer paint areas 137 | outer_paint_x, outer_paint_y = self.court_parameters["outer_paint_dims"] 138 | # Left side 139 | self._draw_rectangle( 140 | ax, 141 | origin_shift_x - court_x / 2 - cf, 142 | origin_shift_y - outer_paint_y / 2 - cf, 143 | outer_paint_x + 2 * cf, 144 | outer_paint_y + 2 * cf, 145 | line_width=line_width, 146 | line_color=line_color, 147 | line_style="-", 148 | face_color=paint_color, 149 | alpha=line_alpha, 150 | ) 151 | # Right side 152 | self._draw_rectangle( 153 | ax, 154 | origin_shift_x + court_x / 2 - outer_paint_x - cf, 155 | origin_shift_y - outer_paint_y / 2 - cf, 156 | outer_paint_x + 2 * cf, 157 | outer_paint_y + 2 * cf, 158 | line_width=line_width, 159 | line_color=line_color, 160 | line_style="-", 161 | face_color=paint_color, 162 | alpha=line_alpha, 163 | ) 164 | 165 | # Draw the inner paint areas 166 | inner_paint_x, inner_paint_y = self.court_parameters["inner_paint_dims"] 167 | 168 | # Draw charge circles 169 | charge_diameter = 2 * self.court_parameters["charge_circle_radius"] 170 | left_hoop_x = origin_shift_x - court_x / 2 + self.court_parameters["hoop_distance_from_edge"] 171 | right_hoop_x = origin_shift_x + court_x / 2 - self.court_parameters["hoop_distance_from_edge"] 172 | # Left side 173 | self._draw_circular_arc( 174 | ax, 175 | left_hoop_x, 176 | origin_shift_y, 177 | charge_diameter + cf, 178 | angle=0, 179 | theta1=-90, 180 | theta2=90, 181 | line_width=line_width, 182 | line_color=line_color, 183 | line_style="-", 184 | alpha=line_alpha, 185 | ) 186 | 187 | # Right side 188 | self._draw_circular_arc( 189 | ax, 190 | right_hoop_x, 191 | origin_shift_y, 192 | charge_diameter + cf, 193 | angle=180, 194 | theta1=-90, 195 | theta2=90, 196 | line_width=line_width, 197 | line_color=line_color, 198 | line_style="-", 199 | alpha=line_alpha, 200 | ) 201 | 202 | # Draw the free throw arcs 203 | # Left-upper 204 | self._draw_circular_arc( 205 | ax, 206 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 207 | origin_shift_y, 208 | inner_paint_y + 2 * cf, 209 | angle=0, 210 | theta1=-90, 211 | theta2=90, 212 | line_width=line_width, 213 | line_color=line_color, 214 | line_style="-", 215 | alpha=line_alpha, 216 | ) 217 | # Left-lower 218 | if self.court_type in ["nba", "wnba"]: 219 | # Draw the first arc of angle 'a' 220 | self._draw_circular_arc( 221 | ax, 222 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 223 | origin_shift_y, 224 | inner_paint_y + 2 * cf, 225 | angle=0, 226 | theta1=90, 227 | theta2=90 + angle_a, 228 | line_width=line_width, 229 | line_color=line_color, 230 | line_style="-", 231 | alpha=line_alpha, 232 | ) 233 | 234 | # Draw 13 arcs of angle 'b' 235 | for i in range(12): 236 | start_angle = 90 + angle_a + i * angle_b 237 | end_angle = start_angle + angle_b 238 | color = line_color if i % 2 == 1 else paint_color 239 | 240 | self._draw_circular_arc( 241 | ax, 242 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 243 | origin_shift_y, 244 | inner_paint_y + 2 * cf, 245 | angle=0, 246 | theta1=start_angle, 247 | theta2=end_angle, 248 | line_width=line_width, 249 | line_color=color, 250 | line_style="-", 251 | alpha=line_alpha, 252 | ) 253 | 254 | # Draw the final arc of angle 'a' 255 | self._draw_circular_arc( 256 | ax, 257 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 258 | origin_shift_y, 259 | inner_paint_y + 2 * cf, 260 | angle=0, 261 | theta1=90 + angle_a + 13 * angle_b, 262 | theta2=270, 263 | line_width=line_width, 264 | line_color=line_color, 265 | line_style="-", 266 | alpha=line_alpha, 267 | ) 268 | 269 | # Right side 270 | # Right-lower 271 | 272 | if self.court_type in ["nba", "wnba"]: 273 | # Draw the first arc of angle 'a' 274 | self._draw_circular_arc( 275 | ax, 276 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 277 | origin_shift_y, 278 | inner_paint_y + 2 * cf, 279 | angle=180, 280 | theta1=90, 281 | theta2=90 + angle_a, 282 | line_width=line_width, 283 | line_color=line_color, 284 | line_style="-", 285 | alpha=line_alpha, 286 | ) 287 | 288 | # Draw 13 arcs of angle 'b' 289 | for i in range(12): 290 | start_angle = 90 + angle_a + i * angle_b 291 | end_angle = start_angle + angle_b 292 | color = line_color if i % 2 == 1 else paint_color 293 | 294 | self._draw_circular_arc( 295 | ax, 296 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 297 | origin_shift_y, 298 | inner_paint_y + 2 * cf, 299 | angle=180, 300 | theta1=start_angle, 301 | theta2=end_angle, 302 | line_width=line_width, 303 | line_color=color, 304 | line_style="-", 305 | alpha=line_alpha, 306 | ) 307 | 308 | # Draw the final arc of angle 'a' 309 | self._draw_circular_arc( 310 | ax, 311 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 312 | origin_shift_y, 313 | inner_paint_y + 2 * cf, 314 | angle=180, 315 | theta1=90 + angle_a + 13 * angle_b, 316 | theta2=270, 317 | line_width=line_width, 318 | line_color=line_color, 319 | line_style="-", 320 | alpha=line_alpha, 321 | ) 322 | 323 | # Right-upper 324 | self._draw_circular_arc( 325 | ax, 326 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 327 | origin_shift_y, 328 | inner_paint_y + 2 * cf, 329 | angle=180, 330 | theta1=-90, 331 | theta2=90, 332 | line_width=line_width, 333 | line_color=line_color, 334 | line_style="-", 335 | alpha=line_alpha, 336 | ) 337 | 338 | # Draw inbound lines 339 | ib_line_distance = self.court_parameters["inbound_line_distance_from_edge"] 340 | ib_line_length = self.court_parameters["inbound_line_length"] 341 | ob_line_distance = self.court_parameters["outbound_line_distance_from_center"] 342 | ob_line_length = self.court_parameters["outbound_line_length"] 343 | # Left side 344 | self._draw_line( 345 | ax, 346 | origin_shift_x - court_x / 2 + ib_line_distance + cf, 347 | origin_shift_y + court_y / 2, 348 | 0.0, 349 | -ib_line_length + cf, 350 | line_width=line_width, 351 | line_color=line_color, 352 | line_style="-", 353 | alpha=line_alpha, 354 | ) 355 | self._draw_line( 356 | ax, 357 | origin_shift_x - court_x / 2 + ib_line_distance + cf, 358 | origin_shift_y - court_y / 2, 359 | 0.0, 360 | ib_line_length - cf, 361 | line_width=line_width, 362 | line_color=line_color, 363 | line_style="-", 364 | alpha=line_alpha, 365 | ) 366 | self._draw_line( 367 | ax, 368 | origin_shift_x - ob_line_distance, 369 | origin_shift_y + court_y / 2 + cf, 370 | 0.0, 371 | ob_line_length - cf, 372 | line_width=line_width, 373 | line_color=line_color, 374 | line_style="-", 375 | alpha=line_alpha, 376 | ) 377 | # Right side 378 | self._draw_line( 379 | ax, 380 | origin_shift_x + court_x / 2 - ib_line_distance + cf, 381 | origin_shift_y + court_y / 2, 382 | 0.0, 383 | -ib_line_length + cf, 384 | line_width=line_width, 385 | line_color=line_color, 386 | line_style="-", 387 | alpha=line_alpha, 388 | ) 389 | self._draw_line( 390 | ax, 391 | origin_shift_x + court_x / 2 - ib_line_distance + cf, 392 | origin_shift_y - court_y / 2, 393 | 0.0, 394 | ib_line_length - cf, 395 | line_width=line_width, 396 | line_color=line_color, 397 | line_style="-", 398 | alpha=line_alpha, 399 | ) 400 | self._draw_line( 401 | ax, 402 | origin_shift_x + ob_line_distance, 403 | origin_shift_y + court_y / 2 + cf, 404 | 0.0, 405 | ob_line_length - cf, 406 | line_width=line_width, 407 | line_color=line_color, 408 | line_style="-", 409 | alpha=line_alpha, 410 | ) 411 | 412 | # Draw three point areas 413 | # Draw the arcs arcs 414 | arc_diameter = self.court_parameters["three_point_arc_diameter"] - 0.15 415 | arc_angle = self.court_parameters["three_point_arc_angle"] 416 | # Left arc 417 | self._draw_circular_arc( 418 | ax, 419 | left_hoop_x, 420 | origin_shift_y, 421 | arc_diameter - 2 * cf, 422 | angle=0, 423 | theta1=-arc_angle, 424 | theta2=arc_angle, 425 | line_width=line_width, 426 | line_color=line_color, 427 | line_style="-", 428 | alpha=line_alpha, 429 | ) 430 | # Right arc 431 | self._draw_circular_arc( 432 | ax, 433 | right_hoop_x, 434 | origin_shift_y, 435 | arc_diameter - 2 * cf, 436 | angle=180.0, 437 | theta1=-arc_angle, 438 | theta2=arc_angle, 439 | line_width=line_width, 440 | line_color=line_color, 441 | line_style="-", 442 | alpha=line_alpha, 443 | ) 444 | # Draw the side lines 445 | line_length_3pt = self.court_parameters["three_point_line_length"] 446 | side_width_3pt = self.court_parameters["three_point_side_width"] 447 | # Left-upper side 448 | self._draw_line( 449 | ax, 450 | origin_shift_x - court_x / 2, 451 | origin_shift_y + court_y / 2 - side_width_3pt - cf, 452 | line_length_3pt, 453 | 0.0, 454 | line_width=line_width, 455 | line_color=line_color, 456 | line_style="-", 457 | alpha=line_alpha, 458 | ) 459 | # Left-lower side 460 | self._draw_line( 461 | ax, 462 | origin_shift_x - court_x / 2, 463 | origin_shift_y - court_y / 2 + side_width_3pt + cf, 464 | line_length_3pt, 465 | 0.0, 466 | line_width=line_width, 467 | line_color=line_color, 468 | line_style="-", 469 | alpha=line_alpha, 470 | ) 471 | # Right-upper side 472 | self._draw_line( 473 | ax, 474 | origin_shift_x + court_x / 2 - line_length_3pt, 475 | origin_shift_y + court_y / 2 - side_width_3pt - cf, 476 | line_length_3pt, 477 | 0.0, 478 | line_width=line_width, 479 | line_color=line_color, 480 | line_style="-", 481 | alpha=line_alpha, 482 | ) 483 | # Right-lower side 484 | self._draw_line( 485 | ax, 486 | origin_shift_x + court_x / 2 - line_length_3pt, 487 | origin_shift_y - court_y / 2 + side_width_3pt + cf, 488 | line_length_3pt, 489 | 0.0, 490 | line_width=line_width, 491 | line_color=line_color, 492 | line_style="-", 493 | alpha=line_alpha, 494 | ) 495 | 496 | # Draw center line 497 | self._draw_line( 498 | ax, 499 | origin_shift_x, 500 | origin_shift_y - court_y / 2, 501 | 0.0, 502 | court_y, 503 | line_width=line_width, 504 | line_color=line_color, 505 | line_style="-", 506 | alpha=line_alpha, 507 | ) 508 | 509 | # Draw the center circles 510 | # Outer circle 511 | self._draw_circle( 512 | ax, 513 | origin_shift_x, 514 | origin_shift_y, 515 | self.court_parameters["outer_circle_radius"], 516 | line_width=line_width, 517 | line_color=line_color, 518 | line_style="-", 519 | face_color=paint_color, 520 | alpha=line_alpha, 521 | ) 522 | # Inner circle 523 | self._draw_circle( 524 | ax, 525 | origin_shift_x, 526 | origin_shift_y, 527 | self.court_parameters["inner_circle_radius"], 528 | line_width=line_width, 529 | line_color=line_color, 530 | line_style="-", 531 | face_color=paint_color, 532 | alpha=line_alpha, 533 | ) 534 | 535 | def _draw_rectangle( 536 | self, 537 | ax: Axes, 538 | x0: float | int, 539 | y0: float | int, 540 | len_x: float | int, 541 | len_y: float | int, 542 | line_width, 543 | line_color, 544 | line_style, 545 | face_color, 546 | alpha, 547 | ): 548 | rectangle = patches.Rectangle( 549 | (x0, y0), 550 | len_x, 551 | len_y, 552 | linewidth=line_width, 553 | edgecolor=line_color, 554 | linestyle=line_style, 555 | facecolor=face_color, 556 | alpha=alpha, 557 | ) 558 | ax.add_patch(rectangle) 559 | 560 | def _draw_line( 561 | self, 562 | ax: Axes, 563 | x0: float | int, 564 | y0: float | int, 565 | dx: float | int, 566 | dy: float | int, 567 | line_width, 568 | line_color, 569 | line_style, 570 | alpha, 571 | ): 572 | line = lines.Line2D( 573 | [x0, x0 + dx], 574 | [y0, y0 + dy], 575 | linewidth=line_width, 576 | color=line_color, 577 | linestyle=line_style, 578 | alpha=alpha, 579 | ) 580 | ax.add_line(line) 581 | 582 | def _draw_circle( 583 | self, 584 | ax: Axes, 585 | x0: float | int, 586 | y0: float | int, 587 | diameter: float | int, 588 | line_width: float | int, 589 | line_color: Color, 590 | line_style, 591 | face_color, 592 | alpha, 593 | ): 594 | circle = patches.Circle( 595 | (x0, y0), 596 | diameter, 597 | linewidth=line_width, 598 | edgecolor=line_color, 599 | linestyle=line_style, 600 | facecolor=face_color, 601 | alpha=alpha, 602 | ) 603 | ax.add_patch(circle) 604 | 605 | def _draw_circular_arc( 606 | self, 607 | ax: Axes, 608 | x0: float | int, 609 | y0: float | int, 610 | diameter: float | int, 611 | angle: float | int, 612 | theta1: float | int, 613 | theta2: float | int, 614 | line_width: float | int, 615 | line_color: Color, 616 | line_style, 617 | alpha, 618 | ): 619 | circular_arc = patches.Arc( 620 | (x0, y0), 621 | diameter, 622 | diameter, 623 | angle=angle, 624 | theta1=theta1, 625 | theta2=theta2, 626 | linewidth=line_width, 627 | edgecolor=line_color, 628 | ls=line_style, 629 | alpha=alpha, 630 | ) 631 | # path = circular_arc.get_path().transformed(circular_arc.get_patch_transform()) 632 | # pathpatch = PatchDataUnits(path, facecolor='none', edgecolor=line_color, linewidth=line_width, linestyle=line_style) 633 | ax.add_patch(circular_arc) 634 | 635 | 636 | def draw_court_3d( 637 | ax3d, 638 | showaxis: bool = False, 639 | court_type: Literal["nba", "wnba", "ncaa", "fiba"] = "nba", 640 | units: Literal["ft", "m"] = "ft", 641 | court_color: str = "none", 642 | paint_color: str = "none", 643 | line_color: str = "black", 644 | line_alpha: float = 1.0, 645 | line_width: float = 2, 646 | hoop_color: str = "black", 647 | hoop_alpha: float = 1.0, 648 | pad: float = 5.0, 649 | origin: np.ndarray = np.array([0.0, 0.0]), 650 | ): 651 | fig2d, ax2d = plt.subplots() 652 | court = Court3D(court_type=court_type, origin=origin, units=units) 653 | court.draw( 654 | ax2d, 655 | showaxis=showaxis, 656 | court_color=court_color, 657 | paint_color=paint_color, 658 | line_color=line_color, 659 | line_alpha=line_alpha, 660 | line_width=line_width, 661 | hoop_alpha=hoop_alpha, 662 | pad=pad, 663 | ) 664 | 665 | # hoop_points: the number of points to sample around the circle 666 | n_hoop = 100 667 | 668 | # unit vectors for nudging points around 669 | ihat = np.array([[1.0, 0.0, 0.0]]).T 670 | jhat = np.array([[0.0, 1.0, 0.0]]).T 671 | khat = np.array([[0.0, 0.0, 1.0]]).T 672 | 673 | # Offsets of court-features from the origin 674 | # bb : backboard 675 | # ss : sweet-spot (the inner rectangle of the backboard) 676 | # hoop: the hoop 677 | # x-offsets are for mirroring about x 678 | # z-offsets for verticality 679 | bb_xoffset = court.court_parameters["backboard_distance_from_edge"] - court.court_parameters["court_dims"][0] / 2 680 | bb_zoffset = court.court_parameters["hoop_height"] - court.court_parameters["backboard_inner_rect_from_bottom"] 681 | ss_zoffset = court.court_parameters["hoop_height"] 682 | hoop_xoffset = court.court_parameters["hoop_distance_from_edge"] - court.court_parameters["court_dims"][0] / 2 683 | hoop_zoffset = court.court_parameters["hoop_height"] 684 | origin_shift_x, origin_shift_y = -court.origin 685 | court_x, court_y = court.court_parameters["court_dims"] 686 | 687 | # helper functions for building the hoop geometry 688 | # vrectangle_pts: creates a closed (meaning the last point is 689 | # a duplicate of the first point) rectangle with given height and width 690 | # where it is symmetric about the x-axis, and situated above the 691 | # xy plane 692 | def vrectangle_pts(w: float | int, h: float | int): 693 | return np.array( 694 | [ 695 | [origin_shift_x, origin_shift_y - w / 2, 0.0], 696 | [origin_shift_x, origin_shift_y - w / 2, h], 697 | [origin_shift_x, origin_shift_y + w / 2, h], 698 | [origin_shift_x, origin_shift_y + w / 2, 0.0], 699 | [origin_shift_x, origin_shift_y - w / 2, 0.0], 700 | ] 701 | ).T 702 | 703 | # hcircle_pts generates points to create a horizontal circle 704 | def hcircle_pts(radius: float | int, n: int): 705 | assert isinstance(n, int) and n >= 0, "n must be a non-negative integer" 706 | 707 | return np.array( 708 | [ 709 | origin_shift_x + radius * np.cos(np.linspace(0, 2 * np.pi, n)), 710 | origin_shift_y + radius * np.sin(np.linspace(0, 2 * np.pi, n)), 711 | np.zeros(n), 712 | ] 713 | ) 714 | 715 | # Generate the points for each of the backboards/hoops 716 | bb_pts = vrectangle_pts(court.court_parameters["backboard_width"], court.court_parameters["backboard_height"]) 717 | 718 | ss_pts = vrectangle_pts( 719 | court.court_parameters["backboard_inner_rect_width"], court.court_parameters["backboard_inner_rect_height"] 720 | ) 721 | 722 | # Generate raw points for the hoops 723 | hoop_pts = hcircle_pts(court.court_parameters["hoop_radius"], n_hoop) 724 | 725 | # do the offsets for both sides 726 | # backboards 727 | lbb = bb_pts + bb_xoffset * ihat + bb_zoffset * khat 728 | rbb = bb_pts - bb_xoffset * ihat + bb_zoffset * khat 729 | 730 | # sweet spots 731 | lss = ss_pts + bb_xoffset * ihat + ss_zoffset * khat 732 | rss = ss_pts - bb_xoffset * ihat + ss_zoffset * khat 733 | 734 | # hoops 735 | lhoop = hoop_pts + hoop_xoffset * ihat + hoop_zoffset * khat 736 | rhoop = hoop_pts - hoop_xoffset * ihat + hoop_zoffset * khat 737 | 738 | # draw the hoops and backboards 739 | ax3d.plot(*rbb, color=hoop_color, linewidth=line_width / 2) 740 | ax3d.plot(*lbb, color=hoop_color, linewidth=line_width / 2) 741 | ax3d.plot(*rss, color=hoop_color, linewidth=line_width / 4) 742 | ax3d.plot(*lss, color=hoop_color, linewidth=line_width / 4) 743 | ax3d.plot(*lhoop, color=hoop_color, linewidth=line_width) 744 | ax3d.plot(*rhoop, color=hoop_color, linewidth=line_width) 745 | 746 | """ 747 | # Draw the hoops 748 | left_hoop_x = origin_shift_x - court_x / 2 + court.court_parameters["hoop_distance_from_edge"] 749 | right_hoop_x = origin_shift_x + court_x / 2 - court.court_parameters["hoop_distance_from_edge"] 750 | # Left side 751 | left_hoop = patches.Circle( 752 | (left_hoop_x, origin_shift_y), 753 | court.court_parameters["hoop_diameter"], 754 | linewidth=line_width, 755 | edgecolor=hoop_color, 756 | linestyle="-", 757 | alpha=hoop_alpha, 758 | ) 759 | 760 | right_hoop = patches.Circle( 761 | (right_hoop_x, origin_shift_y), 762 | court.court_parameters["hoop_diameter"], 763 | linewidth=line_width, 764 | edgecolor=hoop_color, 765 | linestyle="-", 766 | alpha=hoop_alpha, 767 | ) 768 | 769 | # Draw the backboards 770 | bb_distance = court.court_parameters["backboard_distance_from_edge"] 771 | bb_width = court.court_parameters["backboard_width"] 772 | # Left side 773 | left_bb = lines.Line2D( 774 | [origin_shift_x - court_x / 2 + bb_distance, origin_shift_x - court_x / 2 + bb_distance], 775 | [origin_shift_y - bb_width / 2, origin_shift_y - bb_width / 2 + bb_width], 776 | linewidth=line_width, 777 | color=line_color, 778 | linestyle="-", 779 | alpha=hoop_alpha, 780 | ) 781 | 782 | right_bb = lines.Line2D( 783 | [origin_shift_x + court_x / 2 - bb_distance, origin_shift_x + court_x / 2 - bb_distance], 784 | [origin_shift_y - bb_width / 2, origin_shift_y - bb_width / 2 + bb_width], 785 | linewidth=line_width, 786 | color=line_color, 787 | linestyle="-", 788 | alpha=hoop_alpha, 789 | ) 790 | 791 | center = left_hoop.center 792 | radius = left_hoop.radius 793 | color = left_hoop.get_edgecolor() 794 | # Create a circle in 3D 795 | theta = np.linspace(0, 2 * np.pi, 100) 796 | x = center[0] + radius * np.cos(theta) 797 | y = center[1] + radius * np.sin(theta) 798 | ax3d.plot(x, y, zs=court.court_parameters["hoop_height"], zdir="z", color=hoop_color) 799 | 800 | center = right_hoop.center 801 | radius = right_hoop.radius 802 | color = right_hoop.get_edgecolor() 803 | # Create a circle in 3D 804 | theta = np.linspace(0, 2 * np.pi, 100) 805 | x = center[0] + radius * np.cos(theta) 806 | y = center[1] + radius * np.sin(theta) 807 | ax3d.plot(x, y, zs=court.court_parameters["hoop_height"], zdir="z", color=hoop_color) 808 | 809 | x, y = left_bb.get_data() 810 | ax3d.plot(x, y, zs=court.court_parameters["hoop_height"], zdir="z", color=hoop_color) 811 | 812 | x, y = right_bb.get_data() 813 | ax3d.plot(x, y, zs=court.court_parameters["hoop_height"], zdir="z", color=hoop_color) 814 | """ 815 | # For each line in the 2D plot, create a corresponding line in the 3D plot 816 | for line in ax2d.lines: 817 | x, y = line.get_data() 818 | ax3d.plot(x, y, zs=0, zdir="z", color=line.get_color(), lw=line_width) 819 | 820 | # For each patch (like circles), project it onto the 3D plot 821 | for patch in ax2d.patches: 822 | if isinstance(patch, mpl.patches.Circle): 823 | center = patch.center 824 | radius = patch.radius 825 | color = patch.get_edgecolor() 826 | # Create a circle in 3D 827 | theta = np.linspace(0, 2 * np.pi, 100) 828 | x = center[0] + radius * np.cos(theta) 829 | y = center[1] + radius * np.sin(theta) 830 | ax3d.plot(x, y, zs=0, zdir="z", color=color, lw=line_width) 831 | 832 | elif isinstance(patch, mpl.patches.Rectangle): 833 | # Project Rectangle 834 | xy = patch.get_xy() 835 | width, height = patch.get_width(), patch.get_height() 836 | rect_x = [xy[0], xy[0], xy[0] + width, xy[0] + width, xy[0]] 837 | rect_y = [xy[1], xy[1] + height, xy[1] + height, xy[1], xy[1]] 838 | ax3d.plot(rect_x, rect_y, zs=0, zdir="z", color=patch.get_edgecolor(), lw=line_width) 839 | 840 | elif isinstance(patch, mpl.patches.Arc): 841 | # Project Arc 842 | center, width, height = patch.center, patch.width, patch.height 843 | theta1, theta2, angle = ( 844 | np.radians(patch.theta1), 845 | np.radians(patch.theta2), 846 | np.radians(patch.angle), 847 | ) 848 | theta = np.linspace(angle + theta1, angle + theta2, 100) 849 | arc_x = center[0] + width / 2.0 * np.cos(theta) 850 | arc_y = center[1] + height / 2.0 * np.sin(theta) 851 | ax3d.plot( 852 | arc_x, 853 | arc_y, 854 | zs=0, 855 | zdir="z", 856 | color=patch.get_edgecolor(), 857 | ls=patch.get_linestyle(), 858 | lw=line_width, 859 | ) 860 | 861 | # Close the temporary figure 862 | plt.close(fig2d) 863 | -------------------------------------------------------------------------------- /mplbasketball/court.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | import matplotlib.lines as lines 4 | import matplotlib.patches as patches 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | from matplotlib.axes import Axes 8 | 9 | from mplbasketball.court_params import _get_court_params_in_desired_units 10 | 11 | 12 | class LineDataUnits(lines.Line2D): 13 | def __init__(self, *args, **kwargs): 14 | _lw_data = kwargs.pop("linewidth", 1) 15 | super().__init__(*args, **kwargs) 16 | self._lw_data = _lw_data 17 | 18 | def _get_lw(self): 19 | if self.axes is not None: 20 | ppd = 72.0 / self.axes.figure.dpi 21 | trans = self.axes.transData.transform 22 | return ((trans((1, self._lw_data)) - trans((0, 0))) * ppd)[1] 23 | else: 24 | return self._lw_data 25 | 26 | def _set_lw(self, lw): 27 | self._lw_data = lw 28 | 29 | _linewidth = property(_get_lw, _set_lw) 30 | 31 | 32 | class PatchDataUnits(patches.PathPatch): 33 | # https://stackoverflow.com/a/42972469/2912349 34 | def __init__(self, *args, **kwargs): 35 | _lw_data = kwargs.pop("linewidth", 1) 36 | super().__init__(*args, **kwargs) 37 | self._lw_data = _lw_data 38 | 39 | def _get_lw(self): 40 | if self.axes is not None: 41 | ppd = 72.0 / self.axes.figure.dpi 42 | trans = self.axes.transData.transform 43 | # the line mentioned below 44 | return ((trans((self._lw_data, self._lw_data)) - trans((0, 0))) * ppd)[1] 45 | else: 46 | return self._lw_data 47 | 48 | def _set_lw(self, lw): 49 | self._lw_data = lw 50 | 51 | _linewidth = property(_get_lw, _set_lw) 52 | 53 | 54 | class Court: 55 | """ 56 | A class to represent a basketball court and facilitate its plotting. 57 | 58 | Attributes: 59 | - court_type (str): Type of the court, either 'nba', 'wnba', 'ncaa' or 'fiba'. 60 | - units (str): Units of the court dimensions, either 'ft' or 'm'. 61 | - court_parameters (dict): Parameters defining the dimensions and characteristics of the court. 62 | - origin (np.array): The origin point of the court. 63 | 64 | Methods: 65 | - draw(ax, orientation, half, nrows, ncols, dpi, showaxis, court_color, paint_color, line_color, line_alpha, line_width, hoop_alpha, pad): 66 | Draws the basketball court according to specified parameters. 67 | 68 | Args: 69 | - court_type (str): Specifies the type of basketball court ('nba', 'wnba', 'ncaa' or 'fiba'). Defaults to 'nba'. 70 | 71 | Raises: 72 | - AssertionError: If the provided court_type is not 'nba', 'wnba', 'ncaa' or 'fiba'. 73 | """ 74 | 75 | def __init__( 76 | self, 77 | court_type: Literal["nba", "wnba", "ncaa", "fiba"] = "nba", 78 | origin: Literal["center", "top-left", "bottom-left", "top-right", "bottom-right"] = "top-left", 79 | units: Literal["ft", "m"] = "ft", 80 | ): 81 | assert court_type in [ 82 | "nba", 83 | "wnba", 84 | "ncaa", 85 | "fiba", 86 | ], "Invalid court_type. Please choose from ['nba', 'wnba', 'ncaa', 'fiba']" 87 | 88 | assert origin in [ 89 | "center", 90 | "top-left", 91 | "bottom-left", 92 | "top-right", 93 | "bottom-right", 94 | ], "Invalid origin. Choose from 'center', '(top/bottom)-(left/right)'" 95 | 96 | assert units in ["m", "ft"], "Invalid units. Currently only 'm' and 'ft' are supported" 97 | 98 | self.court_type = court_type 99 | self.units = units 100 | self.court_parameters = _get_court_params_in_desired_units(self.court_type, self.units) 101 | 102 | if origin == "center": 103 | self.origin = np.array([0.0, 0.0]) 104 | elif origin == "top-left": 105 | self.origin = np.array( 106 | [ 107 | -self.court_parameters["court_dims"][0] / 2, 108 | self.court_parameters["court_dims"][1] / 2, 109 | ] 110 | ) 111 | elif origin == "bottom-left": 112 | self.origin = np.array( 113 | [ 114 | -self.court_parameters["court_dims"][0] / 2, 115 | -self.court_parameters["court_dims"][1] / 2, 116 | ] 117 | ) 118 | elif origin == "top-right": 119 | self.origin = np.array( 120 | [ 121 | self.court_parameters["court_dims"][0] / 2, 122 | self.court_parameters["court_dims"][1] / 2, 123 | ] 124 | ) 125 | elif origin == "bottom-right": 126 | self.origin = np.array( 127 | [ 128 | self.court_parameters["court_dims"][0] / 2, 129 | -self.court_parameters["court_dims"][1] / 2, 130 | ] 131 | ) 132 | 133 | def draw( 134 | self, 135 | ax: Axes | None = None, 136 | orientation: Literal["v", "h", "hl", "hr", "vu", "vd"] = "h", 137 | nrows=1, 138 | ncols=1, 139 | dpi=200, 140 | showaxis=False, 141 | court_color="none", 142 | paint_color="none", 143 | line_color="black", 144 | line_alpha=1.0, 145 | line_width=None, 146 | hoop_alpha=1.0, 147 | pad=5.0, 148 | ): 149 | """ 150 | Draws the basketball court according to specified parameters. 151 | 152 | This method allows customization of the court's appearance and can plot either a full court or half-court in horizontal or vertical orientation. 153 | 154 | Args: 155 | - ax (matplotlib.axes.Axes, optional): The matplotlib axes to draw on. If None, a new figure and axes are created. 156 | - orientation (str): Orientation of the court. Defaults to 'h'. 157 | - nrows (int): Number of rows in the subplot grid. Defaults to 1. 158 | - ncols (int): Number of columns in the subplot grid. Defaults to 1. 159 | - dpi (int): Dots per inch for the plot. Defaults to 200. 160 | - showaxis (bool): Whether to show axis on the plot. Defaults to False. 161 | - court_color (str): Background color of the court. Defaults to 'none'. 162 | - paint_color (str): Color of the paint area. Defaults to 'none'. 163 | - line_color (str): Color of the lines on the court. Defaults to 'black'. 164 | - line_alpha (float): Transparency of court lines. Defaults to 1. 165 | - line_width (float): Width of the lines on the court in correct units. Defaults to None. 166 | - hoop_alpha (float): Transparency of the hoop. Defaults to 1. 167 | - pad (float): Padding around the court. Defaults to 5. 168 | 169 | Returns: 170 | - matplotlib.figure.Figure, matplotlib.axes.Axes: The figure and axes objects containing the court plot. 171 | 172 | Raises: 173 | - AssertionError: If orientation is not 'horizontal' or 'vertical', or if dpi is less than 200. 174 | """ 175 | 176 | assert orientation in [ 177 | "v", 178 | "h", 179 | "hl", 180 | "hr", 181 | "vu", 182 | "vd", 183 | ], "Invalid orientation. Choose 'horizontal' or 'vertical'" 184 | 185 | assert dpi >= 200, "DPI is too low" 186 | 187 | if len(orientation) > 1: 188 | half = orientation[1] 189 | else: 190 | half = None 191 | 192 | if line_width is None: 193 | if self.units == "ft": 194 | line_width = 1.0 / 6.0 195 | elif self.units == "m": 196 | line_width = 1.0 / 6.0 * 0.3045 197 | 198 | if ax is None: 199 | fig, axs = plt.subplots(nrows=nrows, ncols=ncols, dpi=dpi) 200 | if nrows == 1 and ncols == 1: 201 | if orientation[0] == "h": 202 | self._draw_horizontal_court( 203 | axs, 204 | half, 205 | court_color=court_color, 206 | paint_color=paint_color, 207 | line_color=line_color, 208 | line_alpha=line_alpha, 209 | line_width=line_width, 210 | hoop_alpha=hoop_alpha, 211 | pad=pad, 212 | ) 213 | elif orientation[0] == "v": 214 | self._draw_vertical_court( 215 | axs, 216 | half, 217 | court_color=court_color, 218 | paint_color=paint_color, 219 | line_color=line_color, 220 | line_alpha=line_alpha, 221 | line_width=line_width, 222 | hoop_alpha=hoop_alpha, 223 | pad=pad, 224 | ) 225 | if showaxis is False: 226 | axs.axis("off") 227 | axs.set_aspect("equal") 228 | return fig, axs 229 | else: 230 | for ax in axs.flatten(): 231 | if orientation[0] == "h": 232 | self._draw_horizontal_court( 233 | ax, 234 | half, 235 | court_color=court_color, 236 | paint_color=paint_color, 237 | line_color=line_color, 238 | line_alpha=line_alpha, 239 | line_width=line_width, 240 | hoop_alpha=hoop_alpha, 241 | pad=pad, 242 | ) 243 | elif orientation[0] == "v": 244 | self._draw_vertical_court( 245 | ax, 246 | half, 247 | court_color=court_color, 248 | paint_color=paint_color, 249 | line_color=line_color, 250 | line_alpha=line_alpha, 251 | line_width=line_width, 252 | hoop_alpha=hoop_alpha, 253 | pad=pad, 254 | ) 255 | if showaxis is False: 256 | ax.axis("off") 257 | ax.set_aspect("equal") 258 | return fig, axs 259 | else: 260 | if orientation[0] == "h": 261 | self._draw_horizontal_court( 262 | ax, 263 | half, 264 | court_color=court_color, 265 | paint_color=paint_color, 266 | line_color=line_color, 267 | line_alpha=line_alpha, 268 | line_width=line_width, 269 | hoop_alpha=hoop_alpha, 270 | pad=pad, 271 | ) 272 | elif orientation[0] == "v": 273 | self._draw_vertical_court( 274 | ax, 275 | half, 276 | court_color=court_color, 277 | paint_color=paint_color, 278 | line_color=line_color, 279 | line_alpha=line_alpha, 280 | line_width=line_width, 281 | hoop_alpha=hoop_alpha, 282 | pad=pad, 283 | ) 284 | if showaxis is False: 285 | ax.axis("off") 286 | ax.set_aspect("equal") 287 | return ax 288 | 289 | def _draw_horizontal_court( 290 | self, 291 | ax: Axes, 292 | half, 293 | court_color, 294 | paint_color, 295 | line_color, 296 | line_alpha, 297 | line_width, 298 | hoop_alpha, 299 | pad, 300 | ): 301 | origin_shift_x, origin_shift_y = -self.origin 302 | court_x, court_y = self.court_parameters["court_dims"] 303 | cf = line_width / 2 304 | 305 | angle_a = 9.7800457882 # Angle 1 for lower FT line 306 | angle_b = 12.3415314172 # Angle 2 for lower FT line 307 | 308 | if half is None: 309 | ax.set_xlim(origin_shift_x - court_x / 2 - pad, origin_shift_x + court_x / 2 + pad) 310 | ax.set_ylim(origin_shift_y - court_y / 2 - pad, origin_shift_y + court_y / 2 + pad) 311 | elif half == "l": 312 | ax.set_xlim(origin_shift_x - court_x / 2 - pad, origin_shift_x + cf) 313 | ax.set_ylim(origin_shift_y - court_y / 2 - pad, origin_shift_y + court_y / 2 + pad) 314 | elif half == "r": 315 | ax.set_xlim(origin_shift_x - cf, origin_shift_x + court_x / 2 + pad) 316 | ax.set_ylim(origin_shift_y - court_y / 2 - pad, origin_shift_y + court_y / 2 + pad) 317 | 318 | # Draw the main court rectangle 319 | self._draw_rectangle( 320 | ax, 321 | origin_shift_x - court_x / 2 - cf, 322 | origin_shift_y - court_y / 2 - cf, 323 | court_x + 2 * cf, 324 | court_y + 2 * cf, 325 | line_width=line_width, 326 | line_color=line_color, 327 | line_style="-", 328 | face_color=court_color, 329 | alpha=line_alpha, 330 | ) 331 | 332 | # Draw the outer paint areas 333 | outer_paint_x, outer_paint_y = self.court_parameters["outer_paint_dims"] 334 | # Left side 335 | if half is None or half == "l": 336 | self._draw_rectangle( 337 | ax, 338 | origin_shift_x - court_x / 2 - cf, 339 | origin_shift_y - outer_paint_y / 2 - cf, 340 | outer_paint_x + 2 * cf, 341 | outer_paint_y + 2 * cf, 342 | line_width=line_width, 343 | line_color=line_color, 344 | line_style="-", 345 | face_color=paint_color, 346 | alpha=line_alpha, 347 | ) 348 | # Right side 349 | if half is None or half == "r": 350 | self._draw_rectangle( 351 | ax, 352 | origin_shift_x + court_x / 2 - outer_paint_x - cf, 353 | origin_shift_y - outer_paint_y / 2 - cf, 354 | outer_paint_x + 2 * cf, 355 | outer_paint_y + 2 * cf, 356 | line_width=line_width, 357 | line_color=line_color, 358 | line_style="-", 359 | face_color=paint_color, 360 | alpha=line_alpha, 361 | ) 362 | 363 | inner_paint_x, inner_paint_y = self.court_parameters["inner_paint_dims"] 364 | 365 | # Draw the hoops 366 | left_hoop_x = origin_shift_x - court_x / 2 + self.court_parameters["hoop_distance_from_edge"] 367 | right_hoop_x = origin_shift_x + court_x / 2 - self.court_parameters["hoop_distance_from_edge"] 368 | # Left side 369 | if half is None or half == "l": 370 | self._draw_circle( 371 | ax, 372 | left_hoop_x, 373 | origin_shift_y, 374 | self.court_parameters["hoop_radius"], 375 | line_width=line_width, 376 | line_color=line_color, 377 | line_style="-", 378 | face_color="none", 379 | alpha=hoop_alpha, 380 | ) 381 | # Right side 382 | if half is None or half == "r": 383 | self._draw_circle( 384 | ax, 385 | right_hoop_x, 386 | origin_shift_y, 387 | self.court_parameters["hoop_radius"], 388 | line_width=line_width, 389 | line_color=line_color, 390 | line_style="-", 391 | face_color="none", 392 | alpha=hoop_alpha, 393 | ) 394 | # Draw the backboards 395 | bb_distance = self.court_parameters["backboard_distance_from_edge"] 396 | bb_width = self.court_parameters["backboard_width"] 397 | # Left side 398 | if half is None or half == "l": 399 | self._draw_line( 400 | ax, 401 | origin_shift_x - court_x / 2 + bb_distance, 402 | origin_shift_y - bb_width / 2, 403 | 0.0, 404 | bb_width, 405 | line_width=line_width, 406 | line_color=line_color, 407 | line_style="-", 408 | alpha=hoop_alpha, 409 | ) 410 | # Right side 411 | if half is None or half == "r": 412 | self._draw_line( 413 | ax, 414 | origin_shift_x + court_x / 2 - bb_distance, 415 | origin_shift_y - bb_width / 2, 416 | 0.0, 417 | bb_width, 418 | line_width=line_width, 419 | line_color=line_color, 420 | line_style="-", 421 | alpha=hoop_alpha, 422 | ) 423 | 424 | # Draw charge circles 425 | charge_diameter = 2 * self.court_parameters["charge_circle_radius"] 426 | # Left side 427 | if half is None or half == "l": 428 | self._draw_circular_arc( 429 | ax, 430 | left_hoop_x, 431 | origin_shift_y, 432 | charge_diameter + cf, 433 | angle=0, 434 | theta1=-90, 435 | theta2=90, 436 | line_width=line_width, 437 | line_color=line_color, 438 | line_style="-", 439 | alpha=line_alpha, 440 | ) 441 | # Right side 442 | if half is None or half == "r": 443 | self._draw_circular_arc( 444 | ax, 445 | right_hoop_x, 446 | origin_shift_y, 447 | charge_diameter + cf, 448 | angle=0, 449 | theta1=90, 450 | theta2=-90, 451 | line_width=line_width, 452 | line_color=line_color, 453 | line_style="-", 454 | alpha=line_alpha, 455 | ) 456 | 457 | # Draw the free throw arcs 458 | # Left-upper 459 | if half is None or half == "l": 460 | self._draw_circular_arc( 461 | ax, 462 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 463 | origin_shift_y, 464 | inner_paint_y + 2 * cf, 465 | angle=0, 466 | theta1=-90, 467 | theta2=90, 468 | line_width=line_width, 469 | line_color=line_color, 470 | line_style="-", 471 | alpha=line_alpha, 472 | ) 473 | # # Left-lower 474 | if self.court_type in ["nba", "wnba"]: 475 | # Draw the first arc of angle 'a' 476 | self._draw_circular_arc( 477 | ax, 478 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 479 | origin_shift_y, 480 | inner_paint_y + 2 * cf, 481 | angle=0, 482 | theta1=90, 483 | theta2=90 + angle_a, 484 | line_width=line_width, 485 | line_color=line_color, 486 | line_style="-", 487 | alpha=line_alpha, 488 | ) 489 | 490 | # Draw 13 arcs of angle 'b' 491 | for i in range(12): 492 | start_angle = 90 + angle_a + i * angle_b 493 | end_angle = start_angle + angle_b 494 | color = line_color if i % 2 == 1 else paint_color 495 | 496 | self._draw_circular_arc( 497 | ax, 498 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 499 | origin_shift_y, 500 | inner_paint_y + 2 * cf, 501 | angle=0, 502 | theta1=start_angle, 503 | theta2=end_angle, 504 | line_width=line_width, 505 | line_color=color, 506 | line_style="-", 507 | alpha=line_alpha, 508 | ) 509 | 510 | # Draw the final arc of angle 'a' 511 | self._draw_circular_arc( 512 | ax, 513 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 514 | origin_shift_y, 515 | inner_paint_y + 2 * cf, 516 | angle=0, 517 | theta1=90 + angle_a + 13 * angle_b, 518 | theta2=-90, 519 | line_width=line_width, 520 | line_color=line_color, 521 | line_style="-", 522 | alpha=line_alpha, 523 | ) 524 | 525 | # Right side 526 | if half is None or half == "r": 527 | # Right-lower 528 | if self.court_type in ["nba", "wnba"]: 529 | # Draw the first arc of angle 'a' 530 | self._draw_circular_arc( 531 | ax, 532 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 533 | origin_shift_y, 534 | inner_paint_y + 2 * cf, 535 | angle=180, 536 | theta1=90, 537 | theta2=90 + angle_a, 538 | line_width=line_width, 539 | line_color=line_color, 540 | line_style="-", 541 | alpha=line_alpha, 542 | ) 543 | 544 | # Draw 13 arcs of angle 'b' 545 | for i in range(12): 546 | start_angle = 90 + angle_a + i * angle_b 547 | end_angle = start_angle + angle_b 548 | color = line_color if i % 2 == 1 else paint_color 549 | 550 | self._draw_circular_arc( 551 | ax, 552 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 553 | origin_shift_y, 554 | inner_paint_y + 2 * cf, 555 | angle=180, 556 | theta1=start_angle, 557 | theta2=end_angle, 558 | line_width=line_width, 559 | line_color=color, 560 | line_style="-", 561 | alpha=line_alpha, 562 | ) 563 | 564 | # Draw the final arc of angle 'a' 565 | self._draw_circular_arc( 566 | ax, 567 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 568 | origin_shift_y, 569 | inner_paint_y + 2 * cf, 570 | angle=180, 571 | theta1=90 + angle_a + 13 * angle_b, 572 | theta2=-90, 573 | line_width=line_width, 574 | line_color=line_color, 575 | line_style="-", 576 | alpha=line_alpha, 577 | ) 578 | 579 | # Right-upper 580 | self._draw_circular_arc( 581 | ax, 582 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 583 | origin_shift_y, 584 | inner_paint_y + 2 * cf, 585 | angle=0, 586 | theta1=90, 587 | theta2=-90, 588 | line_width=line_width, 589 | line_color=line_color, 590 | line_style="-", 591 | alpha=line_alpha, 592 | ) 593 | 594 | # Draw inbound lines 595 | ib_line_distance = self.court_parameters["inbound_line_distance_from_edge"] 596 | ib_line_length = self.court_parameters["inbound_line_length"] 597 | ob_line_distance = self.court_parameters["outbound_line_distance_from_center"] 598 | ob_line_length = self.court_parameters["outbound_line_length"] 599 | # Left side 600 | if half is None or half == "l": 601 | self._draw_line( 602 | ax, 603 | origin_shift_x - court_x / 2 + ib_line_distance + cf, 604 | origin_shift_y + court_y / 2, 605 | 0.0, 606 | -ib_line_length + cf, 607 | line_width=line_width, 608 | line_color=line_color, 609 | line_style="-", 610 | alpha=line_alpha, 611 | ) 612 | self._draw_line( 613 | ax, 614 | origin_shift_x - court_x / 2 + ib_line_distance + cf, 615 | origin_shift_y - court_y / 2, 616 | 0.0, 617 | ib_line_length - cf, 618 | line_width=line_width, 619 | line_color=line_color, 620 | line_style="-", 621 | alpha=line_alpha, 622 | ) 623 | self._draw_line( 624 | ax, 625 | origin_shift_x - ob_line_distance, 626 | origin_shift_y + court_y / 2 + cf, 627 | 0.0, 628 | ob_line_length - cf, 629 | line_width=line_width, 630 | line_color=line_color, 631 | line_style="-", 632 | alpha=line_alpha, 633 | ) 634 | # Right side 635 | if half is None or half == "r": 636 | self._draw_line( 637 | ax, 638 | origin_shift_x + court_x / 2 - ib_line_distance + cf, 639 | origin_shift_y + court_y / 2, 640 | 0.0, 641 | -ib_line_length + cf, 642 | line_width=line_width, 643 | line_color=line_color, 644 | line_style="-", 645 | alpha=line_alpha, 646 | ) 647 | self._draw_line( 648 | ax, 649 | origin_shift_x + court_x / 2 - ib_line_distance + cf, 650 | origin_shift_y - court_y / 2, 651 | 0.0, 652 | ib_line_length - cf, 653 | line_width=line_width, 654 | line_color=line_color, 655 | line_style="-", 656 | alpha=line_alpha, 657 | ) 658 | self._draw_line( 659 | ax, 660 | origin_shift_x + ob_line_distance, 661 | origin_shift_y + court_y / 2 + cf, 662 | 0.0, 663 | ob_line_length - cf, 664 | line_width=line_width, 665 | line_color=line_color, 666 | line_style="-", 667 | alpha=line_alpha, 668 | ) 669 | 670 | # Draw three point areas 671 | # Draw the arcs arcs 672 | arc_diameter = self.court_parameters["three_point_arc_diameter"] - line_width / 2 673 | arc_angle = self.court_parameters["three_point_arc_angle"] 674 | # Left arc 675 | if half is None or half == "l": 676 | self._draw_circular_arc( 677 | ax, 678 | left_hoop_x, 679 | origin_shift_y, 680 | arc_diameter - 2 * cf, 681 | angle=0, 682 | theta1=-arc_angle, 683 | theta2=arc_angle, 684 | line_width=line_width, 685 | line_color=line_color, 686 | line_style="-", 687 | alpha=line_alpha, 688 | ) 689 | # Right arc 690 | if half is None or half == "r": 691 | self._draw_circular_arc( 692 | ax, 693 | right_hoop_x, 694 | origin_shift_y, 695 | arc_diameter - 2 * cf, 696 | angle=180.0, 697 | theta1=-arc_angle, 698 | theta2=arc_angle, 699 | line_width=line_width, 700 | line_color=line_color, 701 | line_style="-", 702 | alpha=line_alpha, 703 | ) 704 | # Draw the side lines 705 | line_length_3pt = self.court_parameters["three_point_line_length"] 706 | side_width_3pt = self.court_parameters["three_point_side_width"] 707 | # Left-upper side 708 | if half is None or half == "l": 709 | self._draw_line( 710 | ax, 711 | origin_shift_x - court_x / 2, 712 | origin_shift_y + court_y / 2 - side_width_3pt - cf, 713 | line_length_3pt, 714 | 0.0, 715 | line_width=line_width, 716 | line_color=line_color, 717 | line_style="-", 718 | alpha=line_alpha, 719 | ) 720 | # Left-lower side 721 | self._draw_line( 722 | ax, 723 | origin_shift_x - court_x / 2, 724 | origin_shift_y - court_y / 2 + side_width_3pt + cf, 725 | line_length_3pt, 726 | 0.0, 727 | line_width=line_width, 728 | line_color=line_color, 729 | line_style="-", 730 | alpha=line_alpha, 731 | ) 732 | if half is None or half == "r": 733 | # Right-upper side 734 | self._draw_line( 735 | ax, 736 | origin_shift_x + court_x / 2 - line_length_3pt, 737 | origin_shift_y + court_y / 2 - side_width_3pt - cf, 738 | line_length_3pt, 739 | 0.0, 740 | line_width=line_width, 741 | line_color=line_color, 742 | line_style="-", 743 | alpha=line_alpha, 744 | ) 745 | # Right-lower side 746 | self._draw_line( 747 | ax, 748 | origin_shift_x + court_x / 2 - line_length_3pt, 749 | origin_shift_y - court_y / 2 + side_width_3pt + cf, 750 | line_length_3pt, 751 | 0.0, 752 | line_width=line_width, 753 | line_color=line_color, 754 | line_style="-", 755 | alpha=line_alpha, 756 | ) 757 | 758 | # Draw center line 759 | self._draw_line( 760 | ax, 761 | origin_shift_x, 762 | origin_shift_y - court_y / 2, 763 | 0.0, 764 | court_y, 765 | line_width=line_width, 766 | line_color=line_color, 767 | line_style="-", 768 | alpha=line_alpha, 769 | ) 770 | 771 | # Draw the center circles 772 | # Outer circle 773 | self._draw_circle( 774 | ax, 775 | origin_shift_x, 776 | origin_shift_y, 777 | self.court_parameters["outer_circle_radius"], 778 | line_width=line_width, 779 | line_color=line_color, 780 | line_style="-", 781 | face_color=paint_color, 782 | alpha=line_alpha, 783 | ) 784 | # Inner circle 785 | self._draw_circle( 786 | ax, 787 | origin_shift_x, 788 | origin_shift_y, 789 | self.court_parameters["inner_circle_radius"], 790 | line_width=line_width, 791 | line_color=line_color, 792 | line_style="-", 793 | face_color=paint_color, 794 | alpha=line_alpha, 795 | ) 796 | 797 | def _draw_vertical_court( 798 | self, 799 | ax: Axes, 800 | half, 801 | court_color, 802 | paint_color, 803 | line_color, 804 | line_alpha, 805 | line_width, 806 | hoop_alpha, 807 | pad, 808 | ): 809 | court_x, court_y = self.court_parameters["court_dims"] 810 | origin_shift_x, origin_shift_y = -self.origin 811 | 812 | angle_a = 9.7800457882 # Angle 1 for lower FT line 813 | angle_b = 12.3415314172 # Angle 2 for lower FT line 814 | 815 | cf = line_width / 2 816 | 817 | if half is None: 818 | ax.set_ylim(origin_shift_x - court_x / 2 - pad, origin_shift_x + court_x / 2 + pad) 819 | ax.set_xlim(-origin_shift_y - court_y / 2 - pad, -origin_shift_y + court_y / 2 + pad) 820 | elif half == "d": 821 | ax.set_ylim(origin_shift_x - court_x / 2 - pad, origin_shift_x + cf) 822 | ax.set_xlim(-origin_shift_y - court_y / 2 - pad, -origin_shift_y + court_y / 2 + pad) 823 | elif half == "u": 824 | ax.set_ylim(origin_shift_x - cf, origin_shift_x + court_x / 2 + pad) 825 | ax.set_xlim(-origin_shift_y - court_y / 2 - pad, -origin_shift_y + court_y / 2 + pad) 826 | 827 | # Draw the main court rectangle 828 | self._draw_rectangle( 829 | ax, 830 | -origin_shift_y - court_y / 2 - cf, 831 | origin_shift_x - court_x / 2 - cf, 832 | court_y + 2 * cf, 833 | court_x + 2 * cf, 834 | line_width=line_width, 835 | line_color=line_color, 836 | line_style="-", 837 | face_color=court_color, 838 | alpha=line_alpha, 839 | ) 840 | 841 | # Draw the outer paint areas 842 | outer_paint_x, outer_paint_y = self.court_parameters["outer_paint_dims"] 843 | # Left side 844 | if half is None or half == "d": 845 | self._draw_rectangle( 846 | ax, 847 | -origin_shift_y - outer_paint_y / 2 - cf, 848 | origin_shift_x - court_x / 2 - cf, 849 | outer_paint_y + 2 * cf, 850 | outer_paint_x + 2 * cf, 851 | line_width=line_width, 852 | line_color=line_color, 853 | line_style="-", 854 | face_color=paint_color, 855 | alpha=line_alpha, 856 | ) 857 | # Right side 858 | if half is None or half == "u": 859 | self._draw_rectangle( 860 | ax, 861 | -origin_shift_y - outer_paint_y / 2 - cf, 862 | origin_shift_x + court_x / 2 - outer_paint_x - cf, 863 | outer_paint_y + 2 * cf, 864 | outer_paint_x + 2 * cf, 865 | line_width=line_width, 866 | line_color=line_color, 867 | line_style="-", 868 | face_color=paint_color, 869 | alpha=line_alpha, 870 | ) 871 | 872 | inner_paint_x, inner_paint_y = self.court_parameters["inner_paint_dims"] 873 | 874 | # Draw the hoops 875 | left_hoop_x = origin_shift_x - court_x / 2 + self.court_parameters["hoop_distance_from_edge"] 876 | right_hoop_x = origin_shift_x + court_x / 2 - self.court_parameters["hoop_distance_from_edge"] 877 | # Left side 878 | if half is None or half == "d": 879 | self._draw_circle( 880 | ax, 881 | -origin_shift_y, 882 | left_hoop_x, 883 | self.court_parameters["hoop_radius"], 884 | line_width=line_width, 885 | line_color=line_color, 886 | line_style="-", 887 | face_color="none", 888 | alpha=hoop_alpha, 889 | ) 890 | # Right side 891 | if half is None or half == "u": 892 | self._draw_circle( 893 | ax, 894 | -origin_shift_y, 895 | right_hoop_x, 896 | self.court_parameters["hoop_radius"], 897 | line_width=line_width, 898 | line_color=line_color, 899 | line_style="-", 900 | face_color="none", 901 | alpha=hoop_alpha, 902 | ) 903 | 904 | # Draw the backboards 905 | bb_distance = self.court_parameters["backboard_distance_from_edge"] 906 | bb_width = self.court_parameters["backboard_width"] 907 | # Left side 908 | if half is None or half == "d": 909 | self._draw_line( 910 | ax, 911 | -origin_shift_y - bb_width / 2, 912 | origin_shift_x - court_x / 2 + bb_distance, 913 | bb_width, 914 | 0.0, 915 | line_width=line_width, 916 | line_color=line_color, 917 | line_style="-", 918 | alpha=hoop_alpha, 919 | ) 920 | # Right side 921 | if half is None or half == "u": 922 | self._draw_line( 923 | ax, 924 | -origin_shift_y - bb_width / 2, 925 | origin_shift_x + court_x / 2 - bb_distance, 926 | bb_width, 927 | 0.0, 928 | line_width=line_width, 929 | line_color=line_color, 930 | line_style="-", 931 | alpha=hoop_alpha, 932 | ) 933 | 934 | # Draw charge circles 935 | charge_diameter = 2 * self.court_parameters["charge_circle_radius"] 936 | # Left side 937 | if half is None or half == "d": 938 | self._draw_circular_arc( 939 | ax, 940 | -origin_shift_y, 941 | left_hoop_x, 942 | charge_diameter + cf, 943 | angle=0, 944 | theta1=0, 945 | theta2=180, 946 | line_width=line_width, 947 | line_color=line_color, 948 | line_style="-", 949 | alpha=line_alpha, 950 | ) 951 | # Right side 952 | if half is None or half == "u": 953 | self._draw_circular_arc( 954 | ax, 955 | -origin_shift_y, 956 | right_hoop_x, 957 | charge_diameter + cf, 958 | angle=0, 959 | theta1=180, 960 | theta2=0, 961 | line_width=line_width, 962 | line_color=line_color, 963 | line_style="-", 964 | alpha=line_alpha, 965 | ) 966 | 967 | # Draw the free throw arcs 968 | # Left-upper 969 | if half is None or half == "d": 970 | self._draw_circular_arc( 971 | ax, 972 | -origin_shift_y, 973 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 974 | inner_paint_y + 2 * cf, 975 | angle=0, 976 | theta1=0, 977 | theta2=180, 978 | line_width=line_width, 979 | line_color=line_color, 980 | line_style="-", 981 | alpha=line_alpha, 982 | ) 983 | # # Left-lower 984 | if self.court_type in ["nba", "wnba"]: 985 | # Draw the first arc of angle 'a' 986 | self._draw_circular_arc( 987 | ax, 988 | -origin_shift_y, 989 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 990 | inner_paint_y + 2 * cf, 991 | angle=90, 992 | theta1=90, 993 | theta2=90 + angle_a, 994 | line_width=line_width, 995 | line_color=line_color, 996 | line_style="-", 997 | alpha=line_alpha, 998 | ) 999 | 1000 | # Draw 13 arcs of angle 'b' 1001 | for i in range(12): 1002 | start_angle = 90 + angle_a + i * angle_b 1003 | end_angle = start_angle + angle_b 1004 | color = line_color if i % 2 == 1 else paint_color 1005 | 1006 | self._draw_circular_arc( 1007 | ax, 1008 | -origin_shift_y, 1009 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 1010 | inner_paint_y + 2 * cf, 1011 | angle=90, 1012 | theta1=start_angle, 1013 | theta2=end_angle, 1014 | line_width=line_width, 1015 | line_color=color, 1016 | line_style="-", 1017 | alpha=line_alpha, 1018 | ) 1019 | 1020 | # Draw the final arc of angle 'a' 1021 | self._draw_circular_arc( 1022 | ax, 1023 | -origin_shift_y, 1024 | origin_shift_x - court_x / 2 + inner_paint_x + cf, 1025 | inner_paint_y + 2 * cf, 1026 | angle=90, 1027 | theta1=90 + angle_a + 13 * angle_b, 1028 | theta2=-90, 1029 | line_width=line_width, 1030 | line_color=line_color, 1031 | line_style="-", 1032 | alpha=line_alpha, 1033 | ) 1034 | 1035 | # Right side 1036 | if half is None or half == "u": 1037 | # Right-lower 1038 | if self.court_type in ["nba", "wnba"]: 1039 | # Draw the first arc of angle 'a' 1040 | self._draw_circular_arc( 1041 | ax, 1042 | -origin_shift_y, 1043 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 1044 | inner_paint_y + 2 * cf, 1045 | angle=270, 1046 | theta1=90, 1047 | theta2=90 + angle_a, 1048 | line_width=line_width, 1049 | line_color=line_color, 1050 | line_style="-", 1051 | alpha=line_alpha, 1052 | ) 1053 | 1054 | # Draw 13 arcs of angle 'b' 1055 | for i in range(12): 1056 | start_angle = 90 + angle_a + i * angle_b 1057 | end_angle = start_angle + angle_b 1058 | color = line_color if i % 2 == 1 else paint_color 1059 | 1060 | self._draw_circular_arc( 1061 | ax, 1062 | -origin_shift_y, 1063 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 1064 | inner_paint_y + 2 * cf, 1065 | angle=270, 1066 | theta1=start_angle, 1067 | theta2=end_angle, 1068 | line_width=line_width, 1069 | line_color=color, 1070 | line_style="-", 1071 | alpha=line_alpha, 1072 | ) 1073 | 1074 | # Draw the final arc of angle 'a' 1075 | self._draw_circular_arc( 1076 | ax, 1077 | -origin_shift_y, 1078 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 1079 | inner_paint_y + 2 * cf, 1080 | angle=270, 1081 | theta1=90 + angle_a + 13 * angle_b, 1082 | theta2=-90, 1083 | line_width=line_width, 1084 | line_color=line_color, 1085 | line_style="-", 1086 | alpha=line_alpha, 1087 | ) 1088 | 1089 | # Right-upper 1090 | self._draw_circular_arc( 1091 | ax, 1092 | -origin_shift_y, 1093 | origin_shift_x + court_x / 2 - inner_paint_x - cf, 1094 | inner_paint_y + 2 * cf, 1095 | angle=0, 1096 | theta1=180, 1097 | theta2=0, 1098 | line_width=line_width, 1099 | line_color=line_color, 1100 | line_style="-", 1101 | alpha=line_alpha, 1102 | ) 1103 | 1104 | # Draw inbound lines 1105 | ib_line_distance = self.court_parameters["inbound_line_distance_from_edge"] 1106 | ib_line_length = self.court_parameters["inbound_line_length"] 1107 | ob_line_distance = self.court_parameters["outbound_line_distance_from_center"] 1108 | ob_line_length = self.court_parameters["outbound_line_length"] 1109 | # Left side 1110 | if half is None or half == "d": 1111 | self._draw_line( 1112 | ax, 1113 | -origin_shift_y + court_y / 2, 1114 | origin_shift_x - court_x / 2 + ib_line_distance + cf, 1115 | -ib_line_length + cf, 1116 | 0.0, 1117 | line_width=line_width, 1118 | line_color=line_color, 1119 | line_style="-", 1120 | alpha=line_alpha, 1121 | ) 1122 | self._draw_line( 1123 | ax, 1124 | -origin_shift_y - court_y / 2, 1125 | origin_shift_x - court_x / 2 + ib_line_distance + cf, 1126 | ib_line_length - cf, 1127 | 0.0, 1128 | line_width=line_width, 1129 | line_color=line_color, 1130 | line_style="-", 1131 | alpha=line_alpha, 1132 | ) 1133 | self._draw_line( 1134 | ax, 1135 | -origin_shift_y - court_y / 2 - cf, 1136 | origin_shift_x - ob_line_distance, 1137 | -ob_line_length + cf, 1138 | 0.0, 1139 | line_width=line_width, 1140 | line_color=line_color, 1141 | line_style="-", 1142 | alpha=line_alpha, 1143 | ) 1144 | # Right side 1145 | if half is None or half == "u": 1146 | self._draw_line( 1147 | ax, 1148 | -origin_shift_y + court_y / 2, 1149 | origin_shift_x + court_x / 2 - ib_line_distance + cf, 1150 | -ib_line_length + cf, 1151 | 0.0, 1152 | line_width=line_width, 1153 | line_color=line_color, 1154 | line_style="-", 1155 | alpha=line_alpha, 1156 | ) 1157 | self._draw_line( 1158 | ax, 1159 | -origin_shift_y - court_y / 2, 1160 | origin_shift_x + court_x / 2 - ib_line_distance + cf, 1161 | ib_line_length - cf, 1162 | 0.0, 1163 | line_width=line_width, 1164 | line_color=line_color, 1165 | line_style="-", 1166 | alpha=line_alpha, 1167 | ) 1168 | self._draw_line( 1169 | ax, 1170 | -origin_shift_y - court_y / 2 - cf, 1171 | origin_shift_x + ob_line_distance, 1172 | -ob_line_length + cf, 1173 | 0.0, 1174 | line_width=line_width, 1175 | line_color=line_color, 1176 | line_style="-", 1177 | alpha=line_alpha, 1178 | ) 1179 | 1180 | # Draw three point areas 1181 | # Draw the arcs arcs 1182 | arc_diameter = self.court_parameters["three_point_arc_diameter"] - line_width / 2 1183 | arc_angle = self.court_parameters["three_point_arc_angle"] 1184 | # Left arc 1185 | if half is None or half == "d": 1186 | self._draw_circular_arc( 1187 | ax, 1188 | -origin_shift_y, 1189 | left_hoop_x, 1190 | arc_diameter - 2 * cf, 1191 | angle=0, 1192 | theta1=90 - arc_angle, 1193 | theta2=90 + arc_angle, 1194 | line_width=line_width, 1195 | line_color=line_color, 1196 | line_style="-", 1197 | alpha=line_alpha, 1198 | ) 1199 | # Right arc 1200 | if half is None or half == "u": 1201 | self._draw_circular_arc( 1202 | ax, 1203 | -origin_shift_y, 1204 | right_hoop_x, 1205 | arc_diameter - 2 * cf, 1206 | angle=180.0, 1207 | theta1=90 - arc_angle, 1208 | theta2=arc_angle + 90, 1209 | line_width=line_width, 1210 | line_color=line_color, 1211 | line_style="-", 1212 | alpha=line_alpha, 1213 | ) 1214 | # Draw the side lines 1215 | line_length_3pt = self.court_parameters["three_point_line_length"] 1216 | side_width_3pt = self.court_parameters["three_point_side_width"] 1217 | if half is None or half == "d": 1218 | # Left-upper side 1219 | self._draw_line( 1220 | ax, 1221 | -origin_shift_y + court_y / 2 - side_width_3pt - cf, 1222 | origin_shift_x - court_x / 2, 1223 | 0.0, 1224 | line_length_3pt, 1225 | line_width=line_width, 1226 | line_color=line_color, 1227 | line_style="-", 1228 | alpha=line_alpha, 1229 | ) 1230 | 1231 | # Left-lower side 1232 | self._draw_line( 1233 | ax, 1234 | -origin_shift_y - court_y / 2 + side_width_3pt + cf, 1235 | origin_shift_x - court_x / 2, 1236 | 0.0, 1237 | line_length_3pt, 1238 | line_width=line_width, 1239 | line_color=line_color, 1240 | line_style="-", 1241 | alpha=line_alpha, 1242 | ) 1243 | 1244 | if half is None or half == "u": 1245 | # Right-upper side 1246 | self._draw_line( 1247 | ax, 1248 | -origin_shift_y + court_y / 2 - side_width_3pt - cf, 1249 | origin_shift_x + court_x / 2 - line_length_3pt, 1250 | 0.0, 1251 | line_length_3pt, 1252 | line_width=line_width, 1253 | line_color=line_color, 1254 | line_style="-", 1255 | alpha=line_alpha, 1256 | ) 1257 | # Right-lower side 1258 | self._draw_line( 1259 | ax, 1260 | -origin_shift_y - court_y / 2 + side_width_3pt + cf, 1261 | origin_shift_x + court_x / 2 - line_length_3pt, 1262 | 0.0, 1263 | line_length_3pt, 1264 | line_width=line_width, 1265 | line_color=line_color, 1266 | line_style="-", 1267 | alpha=line_alpha, 1268 | ) 1269 | 1270 | # Draw center line 1271 | self._draw_line( 1272 | ax, 1273 | -origin_shift_y - court_y / 2, 1274 | origin_shift_x, 1275 | court_y, 1276 | 0.0, 1277 | line_width=line_width, 1278 | line_color=line_color, 1279 | line_style="-", 1280 | alpha=line_alpha, 1281 | ) 1282 | 1283 | # Draw the center circles 1284 | 1285 | # Outer circle 1286 | self._draw_circle( 1287 | ax, 1288 | -origin_shift_y, 1289 | origin_shift_x, 1290 | self.court_parameters["outer_circle_radius"], 1291 | line_width=line_width, 1292 | line_color=line_color, 1293 | line_style="-", 1294 | face_color=paint_color, 1295 | alpha=line_alpha, 1296 | ) 1297 | 1298 | # Inner circle 1299 | self._draw_circle( 1300 | ax, 1301 | -origin_shift_y, 1302 | origin_shift_x, 1303 | self.court_parameters["inner_circle_radius"], 1304 | line_width=line_width, 1305 | line_color=line_color, 1306 | line_style="-", 1307 | face_color=paint_color, 1308 | alpha=line_alpha, 1309 | ) 1310 | 1311 | def _draw_rectangle( 1312 | self, 1313 | ax: Axes, 1314 | x0: float | int, 1315 | y0: float | int, 1316 | len_x: float | int, 1317 | len_y: float | int, 1318 | line_width, 1319 | line_color, 1320 | line_style, 1321 | face_color, 1322 | alpha, 1323 | ): 1324 | rectangle = patches.Rectangle( 1325 | (x0, y0), 1326 | len_x, 1327 | len_y, 1328 | linewidth=line_width, 1329 | edgecolor=line_color, 1330 | linestyle=line_style, 1331 | facecolor=face_color, 1332 | alpha=alpha, 1333 | ) 1334 | path = rectangle.get_path().transformed(rectangle.get_patch_transform()) 1335 | pathpatch = PatchDataUnits( 1336 | path, 1337 | facecolor=face_color, 1338 | edgecolor=line_color, 1339 | linewidth=line_width, 1340 | linestyle=line_style, 1341 | ) 1342 | ax.add_patch(pathpatch) 1343 | 1344 | def _draw_line( 1345 | self, 1346 | ax: Axes, 1347 | x0: float | int, 1348 | y0: float | int, 1349 | dx: float | int, 1350 | dy: float | int, 1351 | line_width, 1352 | line_color, 1353 | line_style, 1354 | alpha, 1355 | ): 1356 | line = LineDataUnits( 1357 | [x0, x0 + dx], 1358 | [y0, y0 + dy], 1359 | linewidth=line_width, 1360 | color=line_color, 1361 | linestyle=line_style, 1362 | alpha=alpha, 1363 | ) 1364 | ax.add_line(line) 1365 | 1366 | def _draw_circle( 1367 | self, 1368 | ax: Axes, 1369 | x0: float | int, 1370 | y0: float | int, 1371 | diameter, 1372 | line_width, 1373 | line_color, 1374 | line_style, 1375 | face_color, 1376 | alpha, 1377 | ): 1378 | circle = patches.Circle( 1379 | (x0, y0), 1380 | diameter, 1381 | linewidth=line_width, 1382 | edgecolor=line_color, 1383 | linestyle=line_style, 1384 | facecolor=face_color, 1385 | alpha=alpha, 1386 | ) 1387 | path = circle.get_path().transformed(circle.get_patch_transform()) 1388 | pathpatch = PatchDataUnits( 1389 | path, 1390 | facecolor=face_color, 1391 | edgecolor=line_color, 1392 | linewidth=line_width, 1393 | linestyle=line_style, 1394 | ) 1395 | ax.add_patch(pathpatch) 1396 | 1397 | def _draw_circular_arc( 1398 | self, 1399 | ax: Axes, 1400 | x0: float | int, 1401 | y0: float | int, 1402 | diameter, 1403 | angle: float, 1404 | theta1: float, 1405 | theta2: float, 1406 | line_width, 1407 | line_color, 1408 | line_style, 1409 | alpha, 1410 | ): 1411 | circular_arc = patches.Arc( 1412 | (x0, y0), 1413 | diameter, 1414 | diameter, 1415 | angle=angle, 1416 | theta1=theta1, 1417 | theta2=theta2, 1418 | linewidth=line_width, 1419 | edgecolor=line_color, 1420 | ls=line_style, 1421 | alpha=alpha, 1422 | ) 1423 | path = circular_arc.get_path().transformed(circular_arc.get_patch_transform()) 1424 | pathpatch = PatchDataUnits( 1425 | path, facecolor="none", edgecolor=line_color, linewidth=line_width, linestyle=line_style 1426 | ) 1427 | ax.add_patch(pathpatch) 1428 | --------------------------------------------------------------------------------