Lab 4 An Image Class

Aims

The aim of this lab is to continue our exploration of TDD in python by developing an Image Class

  1. Configure GitHub for labs.
  2. Developing a Simple Image Class
  3. Understand and Use python packages and uv
  4. Develop some simple Image algorithms.

Getting Started

First we are going to set some global git configurations, in this case we can set the user name and email to any I will use my work email and username.

git config --global user.name=[your name]
git config --global user.email [your email]
git config --global init.defaultbranch main
git config --global pull.rebase false

We can see if this worked by typing

git config --global --list
user.name=jmacey
user.email=jmacey@bournemouth.ac.uk
init.defaultbranch=main
pull.rebase=false
(END)

Press q to exit.

We are now going to create a new labs folder based on our GitHub classroom (you would have been sent an invite). First we are going to enable GitHub over ssh via this method

We can now clone the repository via the GitHub link sent to you.

git clone git@gihub.com:/NCCA/[link sent]

We can change into the folder and create a new README.md file using the touch command

cd my_repo
touch README.md
zed .

Now add something like this using your name

# Jon Macey's Lab Repository

This repo will contain the labs we do in ASE

We then use the following git commands.

git add README.md
git commit -am "added readme file"
git push -u origin master

This should now upload the new README.md file to GitHub in your new repo. We will use this repo for all our lab session to make it easier for code review by staff. To make this easier we are now going to add a .gitignore file to ensure certain files and folders are not uploaded by mistake.

This link has some good starter .gitignore files for various languages we will modify the C++ and CMake ones.

Default .gitignore [click to expand]
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# UV
#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#uv.lock

# poetry
#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml

# pdm
#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#   pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
#   https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/

# pixi
#   Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
#   Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
#   in the .venv directory. It is recommended not to include this directory in version control.
.pixi

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

# PyCharm
#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
#  and can be added to the global gitignore or merged into this file.  For a more nuclear
#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/

# Visual Studio Code
#  Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
#  that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
#  and can be added to the global gitignore or merged into this file. However, if you prefer,
#  you could uncomment the following to ignore the entire vscode folder
# .vscode/

# Ruff stuff:
.ruff_cache/

# PyPI configuration file
.pypirc

# Cursor
#  Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
#  exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
#  refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore

# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/

First we will create a .gitignore at the root of our repository the copy the contents above in.

touch .gitignore

Getting Started

We are going to start a new project but this time it will be in the new repository we created so we can upload to GitHub later. We will use uv to create a new project for us. In this case we are going to create a python package for our image class.

uv init --package Image
cd Image
find .
.
./pyproject.toml
./README.md
./src
./src/image
./src/image/__init__.py

You can see uv has created a template project file which now has a src folder as well as the pyproject.toml file and README.md. We can run this package via uv using

uv run image
Hello from image!

To get started we are going to create a folder for tests and add our dependencies.

mkdir tests
uv add --dev pytest
uv add pillow

A RGBA class

In most image manipulation API’s image data is stored using a single unsigned integer with 32bit of precision, this allows for better cache and alignment with CPU word sizes (32 or 64 bits).

A 32-bit integer lets you pack RGBA—8 bits per channel—into one compact unit. Bitwise shifts and masks can quickly extract or modify channels this c++ code is quite common in API’s including things like Vulkan and OpenGL.

uint32_t pixel ; // packing is AARRGGBB
uint8_t r = (pixel >> 16) & 0xFF;
uint8_t g = (pixel >> 8)  & 0xFF;
uint8_t b = (pixel >> 0)  & 0xFF;
uint8_t a = (pixel >> 24) & 0xFF;

In python we don’t need to do this however we are going to use a python @dataclass to represent RGBA values.

An Image Class

We are going to develop an image class and the RGBA class using a TDD approach.

classDiagram
    class rgba {
        <<dataclass>>
        +int r
        +int g
        +int b
        +int a
        +__post_init__()
        +as_tuple() tuple[int, int, int, int]
        +__iter__()
    }

    class Image {
        -int _width
        -int _height
        -np.ndarray _rgba_data
        +width: int
        +height: int
        +pixels: np.ndarray
        +shape: tuple[int, ...]
        +__init__(width: int, height: int, fill_colour: Union[rgba, tuple, None])
        +set_pixel(x: int, y: int, colour: rgba)
        +get_pixel(x: int, y: int) Tuple[int, int, int, int]
        +clear(colour: rgba)
        +save(name: str)
        +line(sx: int, sy: int, ex: int, ey: int, colour: rgba)
        +rectangle(tx: int, ty: int, bx: int, by: int, colour: rgba)
        +__getitem__(key: tuple[int, int]) rgba
        +__setitem__(key: tuple[int, int], colour: rgba)
    }

    Image "1" -- "*" rgba : uses

We will use a TDD approach and write the following tests in sequence. This will be done interactively in the labs, however a full initial solution can be found here

# tests/test_Image.py
test_ctor_defaults()
test_ctor_values()

# tests/test_Image.py
def test_image_ctor_and_clear():
def test_set_get_pixel():
def test_line_horizontal():
def test_rectangle():
def test_save():

Building a package

In the pyproject.toml file you will notice it has a section as follows

[build-system]
[build-system]
requires = ["uv_build>=0.9.0,<0.10.0"]
build-backend = "uv_build"

It will exclude the tests from our package as they are not needed.

This is generated so we can package things using the uv_build build system. At it’s simplest level we can generate a python wheel and then install it into our own projects. To do this we can use the uv build command.

uv build
Building source distribution...
Building wheel from source distribution...
Successfully built dist/image-0.1.0.tar.gz
Successfully built dist/image-0.1.0-py3-none-any.whl

The file will contain everything in the project including the tests, if we wish to exclude these when we distribute we can add the following to the pyproject.toml

[tool.uv.build-backend]
module-root = "src"
exclude = ["tests"]

To test our project we can do the following in a new folder (I suggest in the folder below our current Image one).

uv init ImageTest
cd ImageTest
uv pip install ../Image/dist/image-0.1.0-py3-none-any.whl
uv run python
Python 3.13.3 (main, May 17 2025, 13:30:59) [Clang 20.1.4 ] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from image import Image
>>> i=Image(20,20)
>>> i.get_pixel(10,10)
RGBA(r=0, g=0, b=0, a=255)

This is a really useful way of building and testing our own packages before distributing.

Exercises

Using the Image class created above add a new method called

def line(self, sx: int, sy: int, ex: int, ey: int, color: RGBA):

Which uses the Bresenham line drawing algorithm outlined here to draw to the Image buffer. Note the following C++ functions may be of use std::swap and std::abs

A sample unit test for this method would be as follows, however this only tests a horizontal line. What other tests could be easily written?

def test_line_horizontal():
    img = Image(5, 5)
    blue = RGBA(0, 0, 255, 255)
    img.line(0, 2, 4, 2, blue)
    for x in range(5):
        assert img.get_pixel(x, 2) == blue

Also we can write a method called rectangle which takes in two _x,_y values for Top Left and Bottom Right and fills the area with a colour.

def rectangle(self, tx: int, ty: int, bx: int, by: int, color: RGBA):

With both of these methods we should think about what happens for values out of the image range.

Previous