From 3e4460444a4ceaba2106325bb492e6c0ec95ddff Mon Sep 17 00:00:00 2001 From: meqdaddev Date: Mon, 31 Aug 2026 15:31:13 +0300 Subject: [PATCH 1/6] Fix TensorFlow/Keras 3 compatibility for exported .h5 models Teachable Machine's exported .h5 models embed a legacy DepthwiseConv2D layer config ('groups': 1) that current Keras (Keras 3, bundled by default since TensorFlow >= 2.16) rejects during deserialization: TypeError/ValueError: Unrecognized keyword arguments passed to DepthwiseConv2D: {'groups': 1} This has broken `pip install teachable-machine` on any current TensorFlow install since setup.py never pinned an upper/lower bound. Fixes #2. - Add a _CompatDepthwiseConv2D shim passed via custom_objects in load_model(), so exported models load on current TF/Keras without pinning an old TensorFlow version. - Add regression tests: a direct unit test for the shim, and an end-to-end test that builds a model, tampers its saved .h5 config to reproduce Teachable Machine's legacy shape, and verifies it still loads and classifies. Verified both that it fails without the fix and passes with it, against a real TensorFlow 2.21.0 install. - Bump install_requires to tensorflow>=2.16; raise python_requires to >=3.9 to match current TensorFlow's supported range; refresh the (previously stale, UTF-16 encoded) requirements.txt. - Bump version to 1.3.0; add CHANGELOG.md (mirrored into docs site). - Update README and docs site with a compatibility note and the new Python requirement. - Add GitHub Actions workflows: run tests on push/PR across Python 3.10-3.13, and publish to PyPI via Trusted Publishing when a GitHub Release is published. --- .github/workflows/publish.yml | 57 +++++++++++++++++++++++++ .github/workflows/tests.yml | 32 ++++++++++++++ CHANGELOG.md | 32 ++++++++++++++ README.md | 12 +++++- docs/changelog.md | 32 ++++++++++++++ docs/index.md | 10 +++++ docs/requirements.md | 14 ++++++- mkdocs.yml | 1 + requirements.txt | Bin 140 -> 108 bytes setup.py | 8 ++-- src/teachable_machine.py | 30 ++++++++++++- tests/test_teachable_machine.py | 72 +++++++++++++++++++++++++++++++- 12 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/tests.yml create mode 100644 CHANGELOG.md create mode 100644 docs/changelog.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9d1ca8e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,57 @@ +name: Publish to PyPI + +# Triggered when a GitHub Release is published (create a Release from a +# version tag, e.g. v1.3.0). Uses PyPI Trusted Publishing (OIDC) — no +# API token needs to be stored as a secret. +# +# One-time setup required on PyPI before this workflow can publish: +# https://pypi.org/manage/project/teachable_machine/settings/publishing/ +# Add a trusted publisher with: +# Owner: MeqdadDev +# Repository: teachable-machine +# Workflow: publish.yml +# Environment: pypi +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tooling + run: python -m pip install --upgrade build + + - name: Build sdist and wheel + run: python -m build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/teachable_machine/ + permissions: + id-token: write # required for PyPI Trusted Publishing + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ea424a4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: pytest -v diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c687269 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [1.3.0] + +### Fixed + +- **TensorFlow/Keras 3 compatibility**: Teachable Machine's exported `.h5` models embed a legacy `DepthwiseConv2D` layer config (`'groups': 1`) that current Keras (Keras 3, bundled by default since TensorFlow 2.16) rejects during deserialization, causing model loading to fail with `TypeError`/`ValueError: Unrecognized keyword arguments passed to DepthwiseConv2D: {'groups': 1}`. `TeachableMachine` now loads models with a compatibility shim that discards the unused key, so exported models work on up-to-date TensorFlow/Keras installs without pinning an old TensorFlow version. Fixes [#2](https://github.com/MeqdadDev/teachable-machine/issues/2). + +### Changed + +- `install_requires` now specifies `tensorflow>=2.16` (previously unpinned, which could silently resolve to a broken combination). +- `python_requires` raised to `>=3.9` to match current TensorFlow's supported Python range; removed unsupported 3.7/3.8 classifiers. +- `requirements.txt` (dev/test) updated to current dependency versions and now includes `pytest`, `pytest-mock`, and `h5py`. + +### Added + +- Regression tests covering the legacy `DepthwiseConv2D` config shape, including an end-to-end test that saves a model, tampers its config to reproduce Teachable Machine's legacy export, and verifies it still loads and classifies correctly. +- CI workflow to run the test suite on pushes/PRs, and a release workflow to publish to PyPI on tagged GitHub releases. + +## [1.2.1] - 2024-08-18 + +- Added `setup.py` for packaging. + +## [1.2.0] + +- Added package logo, expanded documentation (how-to guide, requirements, code examples), and unit tests. + +## [1.1] and [1.0] + +- Initial releases of the `TeachableMachine` class for image classification with exported Keras models. diff --git a/README.md b/README.md index 293b46b..ff0b36b 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,19 @@ Read more about the project (requirements, installation, examples and more) in t **Image Classification**: use exported keras model from Teachable Machine platform. +## Compatibility with recent TensorFlow/Keras releases + +Teachable Machine's exported `.h5` models embed a legacy `DepthwiseConv2D` layer config that current Keras (Keras 3, bundled by default since TensorFlow 2.16) rejects with an error such as: + +``` +TypeError: Unrecognized keyword arguments passed to DepthwiseConv2D: {'groups': 1} +``` + +Since `v1.3.0`, this package patches the model loader so exported models load correctly on up-to-date TensorFlow/Keras installs, with no need to pin an old TensorFlow version. See [issue #2](https://github.com/MeqdadDev/teachable-machine/issues/2) for background. + ## Requirements -``` Python >= 3.7 ``` +``` Python >= 3.9 ``` ## How to install package diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..c687269 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [1.3.0] + +### Fixed + +- **TensorFlow/Keras 3 compatibility**: Teachable Machine's exported `.h5` models embed a legacy `DepthwiseConv2D` layer config (`'groups': 1`) that current Keras (Keras 3, bundled by default since TensorFlow 2.16) rejects during deserialization, causing model loading to fail with `TypeError`/`ValueError: Unrecognized keyword arguments passed to DepthwiseConv2D: {'groups': 1}`. `TeachableMachine` now loads models with a compatibility shim that discards the unused key, so exported models work on up-to-date TensorFlow/Keras installs without pinning an old TensorFlow version. Fixes [#2](https://github.com/MeqdadDev/teachable-machine/issues/2). + +### Changed + +- `install_requires` now specifies `tensorflow>=2.16` (previously unpinned, which could silently resolve to a broken combination). +- `python_requires` raised to `>=3.9` to match current TensorFlow's supported Python range; removed unsupported 3.7/3.8 classifiers. +- `requirements.txt` (dev/test) updated to current dependency versions and now includes `pytest`, `pytest-mock`, and `h5py`. + +### Added + +- Regression tests covering the legacy `DepthwiseConv2D` config shape, including an end-to-end test that saves a model, tampers its config to reproduce Teachable Machine's legacy export, and verifies it still loads and classifies correctly. +- CI workflow to run the test suite on pushes/PRs, and a release workflow to publish to PyPI on tagged GitHub releases. + +## [1.2.1] - 2024-08-18 + +- Added `setup.py` for packaging. + +## [1.2.0] + +- Added package logo, expanded documentation (how-to guide, requirements, code examples), and unit tests. + +## [1.1] and [1.0] + +- Initial releases of the `TeachableMachine` class for image classification with exported Keras models. diff --git a/docs/index.md b/docs/index.md index a0cbe0d..7376c8a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,16 @@ Source Code is published on [GitHub](https://github.com/MeqdadDev/teachable-mach **Image Classification** +## Compatibility with recent TensorFlow/Keras releases + +Teachable Machine's exported `.h5` models embed a legacy `DepthwiseConv2D` layer config that current Keras (Keras 3, bundled by default since TensorFlow 2.16) rejects with an error such as: + +``` +TypeError: Unrecognized keyword arguments passed to DepthwiseConv2D: {'groups': 1} +``` + +Since `v1.3.0`, this package patches the model loader so exported models load correctly on up-to-date TensorFlow/Keras installs, with no need to pin an old TensorFlow version. See [issue #2](https://github.com/MeqdadDev/teachable-machine/issues/2) for background. + ## Links - [PyPI](https://pypi.org/project/teachable-machine/) diff --git a/docs/requirements.md b/docs/requirements.md index 15a58af..8ec0e90 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -1,12 +1,22 @@ ## Requirements ### Python Version -``` Python >= 3.7 ``` +``` Python >= 3.9 ``` ### Dependencies ```bash numpy Pillow -tensorflow +tensorflow>=2.16 ``` + +## Compatibility with recent TensorFlow/Keras releases + +Teachable Machine's exported `.h5` models embed a legacy `DepthwiseConv2D` layer config that current Keras (Keras 3, bundled by default since TensorFlow 2.16) rejects with an error such as: + +``` +TypeError: Unrecognized keyword arguments passed to DepthwiseConv2D: {'groups': 1} +``` + +Since `v1.3.0`, this package patches the model loader so exported models load correctly on up-to-date TensorFlow/Keras installs, with no need to pin an old TensorFlow version. See [issue #2](https://github.com/MeqdadDev/teachable-machine/issues/2) for background. diff --git a/mkdocs.yml b/mkdocs.yml index 16b9a09..fd63dfd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,4 +12,5 @@ nav: - Requirements: requirements.md - Code Examples: codeExamples.md - Explanation: explanation.md + - Changelog: changelog.md - Contributing: contribution.md diff --git a/requirements.txt b/requirements.txt index c28aedc97d7064848719eb278489a324c5ef9a48..95946277c5830d91c3b3068e4006c047195fd2ea 100644 GIT binary patch literal 108 zcmXwwOAbIl5Jca4?m|Msjx)G}5EGvf(;{xK{8n9E^`6nO5D9GbOu7d(6*(yxB~I(k e%*v_U;KjU@dpeUJF>N>Te~FAh%cy>!`0NWW+7mGV literal 140 zcmezWFPkBip@<=op_suI2#px@7!1Lr0Z=rV!GM96fr}xJp_Cz)p@5+hti}+i#)!cf qNScCW0~j(Ha)2=2.16", ], - python_requires=">=3.7", + python_requires=">=3.9", url="https://github.com/MeqdadDev/teachable-machine", author="Meqdad Dev", author_email="meqdad.darweesh@gmail.com", diff --git a/src/teachable_machine.py b/src/teachable_machine.py index efa43bf..5a47ec3 100644 --- a/src/teachable_machine.py +++ b/src/teachable_machine.py @@ -1,8 +1,32 @@ from tensorflow.keras.models import load_model +from tensorflow.keras.layers import DepthwiseConv2D from PIL import Image, ImageOps, ImageDraw, ImageFont import numpy as np +class _CompatDepthwiseConv2D(DepthwiseConv2D): + """ + Drop-in replacement for Keras' DepthwiseConv2D that tolerates the + stray 'groups' argument found in models exported by Google's + Teachable Machine platform. + + Teachable Machine's Keras/.h5 export embeds 'groups': 1 in every + saved DepthwiseConv2D layer config. That key was never meaningful + for a depthwise convolution, but older Keras ignored it. Keras 3 + (bundled by default since TensorFlow 2.16) validates layer configs + strictly and raises: + TypeError/ValueError: Unrecognized keyword arguments passed to + DepthwiseConv2D: {'groups': 1} + This subclass discards the unused key on construction so exported + models keep loading on current TensorFlow/Keras installs, without + requiring users to pin an old TensorFlow version. + """ + + def __init__(self, *args, **kwargs): + kwargs.pop("groups", None) + super().__init__(*args, **kwargs) + + class TeachableMachine(object): """ Create a TeachableMachine object to run pre-trained AI models. @@ -30,7 +54,11 @@ def __init__( def _load_model(self, model_path: str): try: - self._model = load_model(model_path, compile=False) + self._model = load_model( + model_path, + compile=False, + custom_objects={"DepthwiseConv2D": _CompatDepthwiseConv2D}, + ) except IOError as e: print("LoadingModelError: Error while loading Teachable Machine model") raise IOError("Error loading model") from e diff --git a/tests/test_teachable_machine.py b/tests/test_teachable_machine.py index 8bbd585..0bd740f 100644 --- a/tests/test_teachable_machine.py +++ b/tests/test_teachable_machine.py @@ -1,5 +1,7 @@ +import json + import pytest -from src.teachable_machine import TeachableMachine +from src.teachable_machine import TeachableMachine, _CompatDepthwiseConv2D from PIL import Image import numpy as np @@ -143,3 +145,71 @@ def test_preprocess_image_content(teachable_machine): # Check if the white square is still in the top-left corner (approximately) assert np.mean(processed[0, :50, :50]) > np.mean(processed[0, 50:, 50:]) + + +def test_compat_depthwise_conv2d_ignores_stray_groups_kwarg(): + """ + `_CompatDepthwiseConv2D` must silently drop an unrecognized 'groups' + kwarg, which Teachable Machine's exported .h5 configs always include. + Plain `tf.keras.layers.DepthwiseConv2D` raises on this under Keras 3 + (TensorFlow >= 2.16); this is the direct unit-level check of the fix. + """ + layer = _CompatDepthwiseConv2D(kernel_size=3, groups=1) + assert layer.kernel_size == (3, 3) + + +def test_load_model_with_legacy_groups_config(tmp_path): + """ + Regression test for https://github.com/MeqdadDev/teachable-machine/issues/2. + + Teachable Machine's Keras/.h5 export embeds a stray 'groups': 1 key + in every saved DepthwiseConv2D layer config. Current Keras (bundled + by default since TensorFlow 2.16) validates configs strictly and + raises on that unrecognized key. This test builds a tiny model, + tampers its saved .h5 config to reproduce that legacy shape, and + verifies TeachableMachine still loads and classifies with it. + """ + h5py = pytest.importorskip("h5py") + tf = pytest.importorskip("tensorflow") + + model = tf.keras.Sequential( + [ + tf.keras.layers.Input(shape=(224, 224, 3)), + tf.keras.layers.DepthwiseConv2D( + kernel_size=3, name="expanded_conv_depthwise" + ), + tf.keras.layers.Flatten(), + tf.keras.layers.Dense(2, activation="softmax"), + ] + ) + + model_path = tmp_path / "keras_model.h5" + model.save(model_path) + + # Reproduce Teachable Machine's legacy DepthwiseConv2D config by + # injecting the unused 'groups' key into the saved model config. + with h5py.File(model_path, "r+") as f: + model_config = json.loads(f.attrs["model_config"]) + layers = model_config["config"]["layers"] + patched_layers = [ + layer for layer in layers if layer["class_name"] == "DepthwiseConv2D" + ] + assert patched_layers, "test setup: no DepthwiseConv2D layer to tamper" + for layer in patched_layers: + layer["config"]["groups"] = 1 + f.attrs["model_config"] = json.dumps(model_config) + + labels_path = tmp_path / "labels.txt" + labels_path.write_text("0 Class A\n1 Class B\n") + + # Without the compatibility patch, this raises TypeError/ValueError + # on Keras 3: "Unrecognized keyword arguments... {'groups': 1}". + tm = TeachableMachine( + model_path=str(model_path), labels_file_path=str(labels_path) + ) + + sample_image = Image.new("RGB", (224, 224), color=(128, 128, 128)) + result = tm._get_image_classification(sample_image) + + assert result["class_name"] in {"0 Class A", "1 Class B"} + assert result["predictions"].shape == (2,) From f254a2be12b75e8c1189b2c92e9fe622ed42a8c4 Mon Sep 17 00:00:00 2001 From: meqdaddev Date: Mon, 31 Aug 2026 15:52:13 +0300 Subject: [PATCH 2/6] Fix requirements.txt encoding (was written as UTF-16, broke pip install in CI) --- requirements.txt | Bin 108 -> 54 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 95946277c5830d91c3b3068e4006c047195fd2ea..3ad4e1865506f74e74e9392342dfdaf4a4230a00 100644 GIT binary patch literal 54 zcmXR(%`46?O3TSFx3e|UGc@DME6pvaN>Te~FAh%cy>!`0NWW+7mGV From 55ede9ee515e7c5089e938b0025bce9f20695ddf Mon Sep 17 00:00:00 2001 From: meqdaddev Date: Mon, 31 Aug 2026 15:59:16 +0300 Subject: [PATCH 3/6] Add missing docstrings to test functions/fixture (DeepSource doc coverage) --- tests/test_teachable_machine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_teachable_machine.py b/tests/test_teachable_machine.py index 0bd740f..0b1258d 100644 --- a/tests/test_teachable_machine.py +++ b/tests/test_teachable_machine.py @@ -8,6 +8,7 @@ @pytest.fixture def teachable_machine(mocker): + """Return a TeachableMachine instance with load_model/open mocked out.""" # Create a TeachableMachine instance mocker.patch("src.teachable_machine.load_model", return_value="mock_model") @@ -101,6 +102,7 @@ def test_open_image(mocker): def test_preprocess_image_output_shape(teachable_machine): + """_preprocess_image should resize any input image to the model's fixed input shape.""" # Create a sample image sample_image = Image.new("RGB", (300, 200)) @@ -112,6 +114,7 @@ def test_preprocess_image_output_shape(teachable_machine): def test_preprocess_image_normalization(teachable_machine): + """_preprocess_image should scale pixel values into the [-1, 1] range.""" # Create a sample image with known values sample_array = np.full((300, 200, 3), 127, dtype=np.uint8) sample_image = Image.fromarray(sample_array) @@ -125,6 +128,7 @@ def test_preprocess_image_normalization(teachable_machine): def test_preprocess_image_different_sizes(teachable_machine): + """_preprocess_image should normalize inputs of any size to the same output shape.""" # Test with different image sizes sizes = [(100, 100), (500, 300), (224, 224)] @@ -135,6 +139,7 @@ def test_preprocess_image_different_sizes(teachable_machine): def test_preprocess_image_content(teachable_machine): + """_preprocess_image should preserve the relative spatial layout of image content.""" # Create a sample image with a specific pattern sample_array = np.zeros((300, 200, 3), dtype=np.uint8) sample_array[:100, :100] = 255 # White square in top-left corner From 8eae91ffa7fff7830bf7193bda3c9387c62fa695 Mon Sep 17 00:00:00 2001 From: meqdaddev Date: Mon, 31 Aug 2026 16:00:16 +0300 Subject: [PATCH 4/6] Add GitHub Actions workflow to build and deploy docs to Pages Docs deployment was entirely manual: someone runs mkdocs build locally and commits the compiled HTML into a separate `documentation` branch's /docs folder, which legacy GitHub Pages serves from. That branch hasn't been touched since Aug 2024, so the live docs site predates this PR's changes (and everything else merged to main since). This workflow builds the site with mkdocs on every push to main that touches docs/mkdocs.yml/src, and deploys it via actions/deploy-pages. Verified `python -m mkdocs build` locally: mkdocstrings correctly renders the TeachableMachine class API docs (confirms tensorflow/numpy/ PIL imports resolve during the build), no warnings. Requires a one-time manual step (documented in the workflow's comments): switch the repo's Pages source from "Deploy from a branch" to "GitHub Actions" in Settings -> Pages. --- .github/workflows/docs.yml | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..2e5050c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,68 @@ +name: Deploy Docs + +# Builds the mkdocs site and deploys it to GitHub Pages. +# +# One-time setup required before this workflow can deploy: in the repo +# Settings -> Pages -> "Build and deployment" -> Source, switch it from +# "Deploy from a branch" to "GitHub Actions". Until that switch is made, +# this workflow will build the site successfully but the deploy job will +# fail (Pages is still serving from the legacy `documentation` branch). +on: + push: + branches: [main] + paths: + - "docs/**" + - "mkdocs.yml" + - "src/**" + - "requirements.txt" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install mkdocs "mkdocstrings[python]" + + # mkdocstrings imports the package to pull in its docstrings, so + # `python -m mkdocs` (not the bare `mkdocs` entry point) is used to + # make sure the repo root (and therefore `src/`) is on sys.path. + - name: Build site + run: python -m mkdocs build --site-dir _site + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 From 33514861ad292a0577740ca4e8bc04d35720ae3a Mon Sep 17 00:00:00 2001 From: meqdaddev Date: Sat, 12 Sep 2026 20:33:54 +0300 Subject: [PATCH 5/6] Fix annotation crash: missing font on Windows + removed Pillow API show_prediction_on_image() had two latent bugs that surface together on Windows with recent Pillow: - ImageFont.truetype("DejaVuSans-Bold.ttf", ...) assumes that font is resolvable by bare name, which only holds on platforms where Pillow's bundled test font happens to be on the search path. On Windows it raises OSError: cannot open resource. Now falls back to ImageFont.load_default(size=...) when the named font can't be found. - ImageDraw.textsize() was removed in Pillow 10.0.0 (this repo pulls Pillow 12.x), so the very next line would raise AttributeError once the font issue above is fixed. Replaced with textbbox(), which returns the same width/height via the bounding box. --- src/teachable_machine.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/teachable_machine.py b/src/teachable_machine.py index 5a47ec3..a9841a7 100644 --- a/src/teachable_machine.py +++ b/src/teachable_machine.py @@ -187,9 +187,16 @@ def show_prediction_on_image( draw = ImageDraw.Draw(image) font_size = int(image.height * 0.04) # 4% of the image height - font = ImageFont.truetype("DejaVuSans-Bold.ttf", font_size) - - text_width, text_height = draw.textsize(text, font=font) + try: + # Not bundled with Pillow on every platform (notably Windows), + # where a bare name can't be resolved and raises OSError. + font = ImageFont.truetype("DejaVuSans-Bold.ttf", font_size) + except OSError: + font = ImageFont.load_default(size=font_size) + + # ImageDraw.textsize was removed in Pillow 10; use textbbox instead. + left, top, right, bottom = draw.textbbox((0, 0), text, font=font) + text_width, text_height = right - left, bottom - top position = (10, image.height - text_height - 10) draw.rectangle( From 5bc16b5cd90f8d7067f4b2991da5501c7e96bf89 Mon Sep 17 00:00:00 2001 From: meqdaddev Date: Sat, 12 Sep 2026 20:42:56 +0300 Subject: [PATCH 6/6] Fix highlight box clipping text descenders textbbox()'s (left, top, right, bottom) are offsets from the point passed in, and that offset -- particularly `top`, coming from font ascent -- is usually not zero. The rectangle was built by measuring the text's extent at the origin (0, 0) and then reusing that width/height as if the glyphs started exactly at `position`, which shifts the box up and clips the bottom of descenders (e.g. "g", "%"). Re-measure the bbox at the actual draw position instead, and pad it by a few pixels so the highlight comfortably covers the text. --- src/teachable_machine.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/teachable_machine.py b/src/teachable_machine.py index a9841a7..68734bf 100644 --- a/src/teachable_machine.py +++ b/src/teachable_machine.py @@ -195,17 +195,22 @@ def show_prediction_on_image( font = ImageFont.load_default(size=font_size) # ImageDraw.textsize was removed in Pillow 10; use textbbox instead. - left, top, right, bottom = draw.textbbox((0, 0), text, font=font) - text_width, text_height = right - left, bottom - top + # A first measurement at the origin gives the text's extent, used only + # to decide where to place it. + _, top, _, bottom = draw.textbbox((0, 0), text, font=font) + text_height = bottom - top position = (10, image.height - text_height - 10) + # textbbox's (left, top, right, bottom) are offsets from the point + # passed in, and that offset (particularly `top`, from font ascent) + # is usually not zero -- reusing the origin-measured box as if it + # started exactly at `position` shifts it up and clips the text's + # descenders. Re-measure at the actual draw position instead, and + # pad it a little so the box comfortably covers the glyphs. + padding = 4 + box = draw.textbbox(position, text, font=font) draw.rectangle( - [ - position[0], - position[1], - position[0] + text_width, - position[1] + text_height, - ], + [box[0] - padding, box[1] - padding, box[2] + padding, box[3] + padding], fill=(0, 0, 0, 128), )