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 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 c28aedc..3ad4e18 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/setup.py b/setup.py index c1e8d40..509d80a 100644 --- a/setup.py +++ b/setup.py @@ -5,15 +5,13 @@ setup( name="teachable_machine", - version="1.2.1", + version="1.3.0", description="A Python package designed to simplify the integration of exported models from Google's Teachable Machine platform into various environments. \ This tool was specifically crafted to work seamlessly with Teachable Machine, making it easier to implement and use your trained models.", py_modules=["teachable_machine"], package_dir={"": "src"}, classifiers=[ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -30,9 +28,9 @@ install_requires=[ "numpy", "Pillow", - "tensorflow", + "tensorflow>=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..68734bf 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 @@ -159,18 +187,30 @@ 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. + # 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), ) diff --git a/tests/test_teachable_machine.py b/tests/test_teachable_machine.py index 8bbd585..0b1258d 100644 --- a/tests/test_teachable_machine.py +++ b/tests/test_teachable_machine.py @@ -1,11 +1,14 @@ +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 @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") @@ -99,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)) @@ -110,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) @@ -123,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)] @@ -133,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 @@ -143,3 +150,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,)