diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6b9a775 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,89 @@ +# Pybricks Robotics Development Guide + +## Project Overview +This is a **Pybricks MicroPython robotics project** for LEGO SPIKE Prime Hub. Code runs on the hub's MicroPython environment, not local Python - expect import errors in VS Code (this is normal). + +## Critical Architecture Knowledge + +### Dual-Environment Setup +- **Local Environment**: VS Code with `pybricksdev` tooling for development/debugging +- **Target Environment**: MicroPython on LEGO SPIKE Prime Hub where code actually executes +- **Key Insight**: Local Python is only for tooling; robot scripts use hub's MicroPython runtime + +### Hardware Configuration +See `architecture/robot_wiring.md` for current port mappings and sensor configurations. Always check this file before writing hardware-specific code. + +## Essential Development Workflows + +### Environment Setup (Required First Step) +```bash +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +## Agent Mode Automation Rules + +### Robot Name Discovery (REQUIRED PROCESS) +When running code via terminal commands, the agent MUST: +1. **ALWAYS check `.vscode/launch.json` FIRST** to extract the robot name +2. **Parse the `-n` argument** from the "args" array in the Pybricks configuration +3. **Use that exact name** in terminal commands automatically +4. **Never ask user for robot name** if it's already in launch.json +5. **If not found** try the same command again up to 2 more times + +### Example Workflow: +```bash +# Agent should automatically extract robot name from launch.json and use: +pybricksdev run ble script_name.py -n "ExtractedName" +# NOT: pybricksdev run ble script_name.py (missing name) +``` + +### Mandatory Pre-Run Checklist +Before running ANY code, agent must verify: +- [ ] Virtual environment activated (`source venv/bin/activate`) +- [ ] Hub powered and in pairing mode (blinking blue) +- [ ] Robot name extracted from `.vscode/launch.json` if using terminal +- [ ] Hardware ports match `architecture/robot_wiring.md` + +### Debug Configuration +Update `.vscode/launch.json` with your hub's actual name in the `-n` argument. The debugger uses the virtual environment's Python to run `pybricksdev` module. + +## Project-Specific Patterns + +### Script Structure Convention (REQUIRED PATTERN) +Every robot script MUST include: +```python +#!/usr/bin/env pybricks-micropython +from pybricks.hubs import PrimeHub +from pybricks.tools import wait, Matrix + +hub = PrimeHub() # ALWAYS initialize first + +try: + # Main logic here + # Define patterns as constants + # Main loop logic + pass +except KeyboardInterrupt: + print("Program interrupted") +finally: + hub.display.off() # ALWAYS cleanup +``` + +### MANDATORY ACTIONS (Never Skip) +1. **ALWAYS read `architecture/robot_wiring.md` FIRST** before writing hardware code +2. **ALWAYS suggest F5 debug BEFORE terminal commands** +3. **ALWAYS check launch.json for robot name** before BLE terminal commands +4. **ALWAYS mention retry attempts** (BLE discovery often fails first try) +5. **ALWAYS use absolute paths** for file operations + +## Dependencies & Integration +- **Core**: `pybricks` (hub runtime) + `pybricksdev` (development tools) +- **No external services**: Self-contained robotics project +- **Hardware dependency**: Requires physical LEGO SPIKE Prime Hub + +## Common Gotchas +- Import errors in VS Code are **expected** - code runs on hub's MicroPython, not locally +- Hub must be in pairing mode for Bluetooth connections +- NEVER use USB connection or try to connect to any hub while debugging via Bluetooth always suggest to retry a few times if the robot is not discovered the first time \ No newline at end of file diff --git a/README.md b/README.md index f920fed..d91dfbf 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,11 @@ pip install -r requirements.txt This repository includes a simple MicroPython script for Pybricks hubs: -### `robot_blink.py` - Robot Face Animation -A script that prints "Hello World" and displays a blinking robot face using Matrix patterns on the LED display. The robot has sensors, eyes, and features that create a friendly animated face with periodic blinking. +### `robot_blink.py` - Matrix Animation & GIF Playback +- Prints "Hello World" in the console when the hub program starts. +- Plays animated GIFs on the 5x5 matrix by scaling each frame down and mapping pixel intensity to LED brightness. +- **New:** Ships with an embedded animation so the hub can play a GIF even when no external files are transferred. +- Falls back to the original blinking robot face if no GIF is provided or an error occurs while loading the GIF. ### `.vscode/launch.json` - VS Code Debug Configuration A sample configuration for debugging Pybricks scripts directly from VS Code using the `pybricksdev` tool. @@ -26,9 +29,68 @@ A sample configuration for debugging Pybricks scripts directly from VS Code usin ### Expected Behavior - Console prints "Hello World!" and animation status messages -- LED matrix displays a robot face with sensors, eyes, and features -- Eyes blink periodically (closed eyes are dimmer) -- Pattern loops continuously with "Blink!" messages until stopped +- LED matrix plays the requested GIF at 5 FPS (one frame every 200 ms) +- LED brightness represents grayscale intensity from the GIF +- If no GIF is found, the LED matrix displays the classic blinking robot face with "Blink!" messages until stopped + +### Playing a GIF on the Matrix + +1. Place your animated GIF in the project directory (for example `robot_animation.gif`) or reference an existing GIF elsewhere. +2. Launch the script, passing the GIF path as a positional argument or with the `--gif` option: + + ```bash + # Default GIF in the project folder via Bluetooth ("John" is your hub name) + pybricksdev run ble robot_blink.py -n "John" + + # USB connection + pybricksdev run usb robot_blink.py + ``` + +### Choosing Which GIF Plays + +`pybricksdev run` does not pass additional command-line arguments to your script. The renderer will therefore look for a GIF in this order: + +1. The path specified by the `PYBRICKS_GIF` environment variable. +2. A file named `robot_animation.gif` in the project directory. +3. A file named `giphy-downsized.gif` in the project directory. +4. The first `*.gif` file found in the project directory. +5. **If none of the above exist but the script contains embedded frames, those frames will be used automatically.** + +To select a specific file without renaming it, set the environment variable before running the command (PowerShell example): + +```pwsh +$env:PYBRICKS_GIF = "C:\\path\\to\\my_animation.gif" +pybricksdev run ble robot_blink.py -n "John" +``` + +> ℹ️ The environment variable shortcut only applies when the host Python runtime provides the `os` module (e.g., running from your computer). On the hub itself the script simply continues to the default file search. + +When the variable is unset, the script will automatically pick the first available GIF or fall back to the blinking robot animation if none are found. + +### How GIF decoding works + +- When the script runs on your computer (where Pillow is available), it uses Pillow to scale the GIF frames down to the 5×5 matrix. +- When Pillow is **not** available (for example on the hub), the script falls back to a built-in pure MicroPython GIF decoder that supports global/local palettes, transparency, and interlaced frames. +- Debug printouts (prefixed with “Debug”) describe which path is taken and why; they show up in the VS Code debug console or the terminal running `pybricksdev`. + +3. To skip GIF playback and use the blink animation explicitly, add `--blink`. +4. To force the internally embedded animation regardless of available files, add `--embedded`. + +### Embedding a GIF directly into the script + +For Bluetooth transfers, only the Python source reaches the hub. To bake an animation into `robot_blink.py`: + +1. Place your source GIF (default: `giphy-downsized.gif`) in the project root. +2. Run `python scripts/generate_embedded_frames.py` from an activated virtual environment. + - The script uses the same pure-Python GIF decoder found in `robot_blink.py`, so it has **no Pillow dependency**. + - It emits two helper files: + - `embedded_frames.json` – raw brightness matrices for inspection. + - `embedded_frames_snippet.py` – a ready-to-paste `EMBEDDED_FRAMES_DATA = [...]` literal. +3. Copy the literal from `embedded_frames_snippet.py` into `robot_blink.py` (replacing the existing `EMBEDDED_FRAMES_DATA`). +4. Optionally adjust `EMBEDDED_SOURCE` and `EMBEDDED_FPS` to describe your animation. +5. Deploy the updated `robot_blink.py`—the hub can now render the baked animation with no external assets. + +The renderer converts each frame to grayscale, scales it down to 5×5 pixels, and maps brightness to the hub's 0-100 LED intensity range so you can perceive shades on the matrix. ### Compatible Hubs diff --git a/architecture/robot_wiring.md b/architecture/robot_wiring.md new file mode 100644 index 0000000..b7295e3 --- /dev/null +++ b/architecture/robot_wiring.md @@ -0,0 +1,13 @@ +Hub: Lego Spike Prime + +Port A: no connection + +Port B: Color Sensor + +Port C: no connection + +Port D: small motor controlling arms + +Port E: no connection + +Port F: small motor controlling hip movement diff --git a/embedded_frames.json b/embedded_frames.json new file mode 100644 index 0000000..a76d977 --- /dev/null +++ b/embedded_frames.json @@ -0,0 +1 @@ +[[[48, 48, 48, 48, 48], [48, 95, 28, 48, 48], [48, 40, 97, 48, 48], [48, 48, 48, 48, 48], [48, 49, 48, 72, 2]], [[48, 48, 48, 48, 48], [48, 48, 48, 44, 48], [48, 48, 98, 48, 48], [48, 49, 48, 48, 48], [49, 48, 91, 44, 44]], [[48, 48, 48, 48, 48], [48, 48, 48, 96, 48], [48, 48, 96, 48, 48], [48, 0, 48, 48, 48], [48, 48, 95, 41, 49]], [[48, 48, 48, 20, 48], [48, 48, 48, 97, 48], [48, 48, 96, 96, 48], [48, 4, 48, 48, 48], [48, 49, 98, 7, 55]], [[48, 48, 48, 0, 48], [48, 48, 96, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [48, 48, 95, 7, 54]], [[48, 48, 48, 0, 48], [48, 48, 98, 96, 48], [48, 48, 96, 0, 48], [48, 48, 48, 48, 48], [47, 49, 99, 2, 54]], [[48, 48, 24, 91, 48], [48, 48, 48, 96, 48], [48, 48, 96, 42, 48], [48, 48, 48, 49, 48], [49, 48, 99, 27, 15]], [[48, 91, 48, 48, 48], [48, 96, 48, 48, 48], [48, 79, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 95, 20, 7]], [[48, 48, 48, 48, 48], [48, 96, 48, 48, 48], [48, 98, 96, 48, 48], [48, 48, 48, 48, 48], [52, 48, 36, 55, 4]], [[48, 96, 48, 48, 48], [48, 69, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [96, 50, 12, 23, 7]], [[48, 7, 48, 48, 48], [48, 97, 96, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 47], [51, 51, 99, 16, 56]], [[48, 95, 48, 48, 48], [48, 95, 49, 48, 48], [48, 1, 94, 48, 48], [48, 48, 48, 48, 48], [48, 49, 99, 23, 49]], [[48, 48, 48, 48, 48], [48, 48, 48, 44, 48], [48, 48, 97, 48, 48], [48, 48, 48, 48, 48], [48, 49, 99, 36, 35]], [[48, 48, 48, 48, 48], [48, 48, 48, 94, 48], [48, 48, 95, 2, 48], [48, 26, 49, 48, 48], [48, 48, 94, 24, 11]], [[48, 48, 48, 96, 48], [48, 48, 48, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [49, 50, 99, 53, 11]], [[48, 48, 48, 4, 48], [48, 48, 96, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [55, 48, 99, 56, 4]], [[48, 48, 48, 99, 48], [48, 48, 48, 96, 48], [48, 48, 96, 16, 48], [48, 48, 48, 48, 48], [52, 48, 99, 62, 4]], [[48, 48, 3, 97, 48], [48, 48, 48, 96, 48], [48, 48, 96, 0, 48], [48, 41, 48, 48, 48], [48, 48, 97, 75, 71]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [50, 48, 97, 66, 43]], [[48, 48, 48, 48, 48], [48, 96, 48, 48, 48], [48, 96, 97, 48, 48], [48, 48, 48, 48, 48], [0, 0, 0, 0, 0]], [[48, 97, 48, 48, 48], [48, 96, 98, 48, 48], [48, 96, 95, 48, 48], [48, 48, 47, 49, 48], [47, 48, 98, 66, 20]], [[48, 0, 48, 48, 48], [48, 96, 11, 48, 48], [48, 95, 96, 48, 48], [48, 48, 48, 48, 48], [48, 48, 96, 41, 75]], [[48, 96, 48, 48, 48], [48, 96, 49, 48, 48], [48, 50, 96, 48, 48], [48, 50, 48, 48, 48], [48, 48, 97, 69, 59]], [[48, 48, 48, 0, 48], [48, 48, 48, 99, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [48, 48, 97, 16, 11]], [[48, 48, 48, 0, 48], [48, 48, 48, 95, 48], [48, 48, 97, 95, 48], [48, 0, 48, 48, 48], [49, 48, 99, 20, 22]], [[48, 48, 48, 4, 48], [48, 48, 7, 96, 48], [48, 48, 98, 0, 48], [48, 48, 48, 48, 48], [54, 48, 95, 39, 35]], [[48, 48, 48, 0, 48], [48, 48, 96, 96, 48], [48, 48, 96, 13, 48], [48, 48, 48, 48, 48], [52, 48, 97, 75, 32]], [[48, 48, 48, 97, 48], [48, 48, 47, 94, 48], [48, 48, 96, 16, 48], [48, 48, 48, 48, 48], [48, 48, 99, 22, 54]], [[48, 48, 48, 32, 48], [48, 48, 96, 96, 48], [48, 48, 70, 48, 48], [48, 48, 48, 48, 48], [48, 49, 96, 55, 27]], [[48, 48, 48, 4, 48], [48, 96, 96, 0, 48], [48, 48, 70, 48, 48], [48, 48, 48, 48, 48], [48, 48, 98, 55, 7]], [[48, 48, 1, 48, 48], [48, 48, 32, 49, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [48, 48, 88, 49, 2]], [[48, 0, 48, 48, 48], [48, 48, 96, 48, 48], [48, 48, 0, 48, 48], [48, 48, 48, 48, 48], [49, 48, 15, 49, 7]], [[48, 29, 0, 48, 48], [48, 48, 96, 96, 48], [48, 48, 73, 48, 48], [48, 48, 48, 48, 48], [49, 48, 91, 52, 49]], [[48, 48, 97, 48, 48], [48, 48, 97, 48, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [50, 48, 10, 56, 4]], [[48, 48, 99, 48, 48], [48, 48, 48, 48, 48], [48, 48, 99, 48, 48], [48, 48, 4, 48, 48], [48, 48, 88, 51, 49]], [[48, 48, 0, 48, 48], [48, 48, 48, 48, 48], [48, 48, 95, 48, 48], [48, 48, 48, 48, 48], [48, 48, 96, 32, 27]], [[48, 48, 48, 49, 48], [48, 48, 48, 96, 48], [48, 48, 95, 49, 48], [48, 27, 48, 48, 48], [48, 48, 96, 66, 49]], [[48, 48, 48, 7, 48], [48, 48, 0, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [51, 48, 71, 27, 26]], [[48, 48, 47, 99, 48], [48, 48, 48, 99, 48], [48, 48, 99, 95, 48], [48, 48, 48, 48, 48], [48, 49, 97, 17, 20]], [[48, 95, 48, 48, 48], [48, 96, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 99, 36, 4]], [[48, 96, 48, 48, 48], [48, 96, 48, 48, 48], [48, 95, 95, 48, 48], [48, 48, 48, 49, 48], [49, 48, 99, 59, 4]], [[48, 94, 48, 48, 48], [48, 75, 2, 48, 48], [48, 97, 96, 48, 48], [48, 48, 15, 48, 48], [51, 48, 96, 75, 73]], [[48, 7, 48, 48, 48], [48, 97, 96, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [31, 49, 97, 73, 11]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 1, 96, 48, 48], [48, 48, 48, 48, 48], [50, 49, 99, 43, 43]], [[0, 48, 48, 95, 0], [48, 48, 48, 96, 48], [48, 48, 94, 49, 48], [48, 48, 48, 0, 0], [0, 0, 0, 0, 0]], [[48, 48, 48, 97, 48], [48, 48, 48, 96, 48], [48, 48, 95, 48, 48], [48, 4, 48, 48, 48], [49, 48, 99, 62, 27]], [[48, 48, 48, 96, 48], [48, 48, 49, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [42, 48, 99, 66, 4]], [[48, 48, 48, 7, 48], [48, 48, 97, 99, 48], [48, 48, 95, 94, 48], [48, 48, 48, 48, 48], [17, 48, 95, 56, 2]], [[48, 48, 48, 96, 48], [48, 48, 49, 96, 48], [48, 48, 96, 11, 48], [48, 48, 48, 48, 48], [50, 51, 99, 64, 7]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 99, 97, 48, 48], [48, 48, 48, 48, 48], [49, 49, 97, 24, 20]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 97, 97, 48, 48], [48, 48, 48, 48, 48], [47, 48, 97, 36, 27]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 95, 97, 48, 48], [48, 48, 48, 48, 48], [49, 48, 97, 75, 44]], [[48, 96, 48, 48, 48], [48, 84, 95, 48, 48], [48, 96, 98, 48, 48], [48, 48, 48, 48, 48], [48, 48, 97, 21, 49]], [[48, 0, 48, 48, 48], [48, 96, 99, 48, 48], [48, 97, 95, 48, 48], [48, 48, 48, 48, 48], [49, 49, 97, 69, 2]], [[48, 48, 48, 50, 48], [48, 48, 48, 48, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [49, 47, 96, 43, 2]], [[48, 48, 48, 49, 48], [48, 48, 48, 95, 48], [48, 48, 95, 48, 48], [48, 32, 48, 48, 48], [48, 48, 94, 39, 4]], [[48, 48, 48, 7, 48], [48, 48, 48, 95, 48], [48, 48, 97, 98, 48], [48, 1, 48, 48, 48], [49, 48, 99, 22, 49]], [[48, 48, 48, 5, 48], [48, 48, 49, 94, 48], [48, 48, 94, 96, 48], [48, 48, 48, 48, 48], [49, 48, 55, 32, 48]], [[48, 48, 48, 0, 48], [48, 48, 96, 96, 48], [48, 48, 95, 27, 48], [48, 48, 48, 48, 48], [55, 48, 97, 43, 2]], [[48, 48, 0, 97, 48], [48, 48, 48, 96, 48], [48, 48, 95, 1, 48], [48, 48, 48, 48, 48], [47, 48, 95, 73, 4]], [[48, 94, 48, 48, 48], [48, 96, 48, 50, 48], [48, 97, 97, 48, 48], [48, 48, 48, 48, 48], [49, 48, 94, 49, 51]], [[48, 48, 48, 48, 48], [48, 97, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 48, 48, 48], [51, 48, 99, 17, 46]], [[48, 96, 48, 48, 48], [48, 96, 48, 48, 48], [48, 94, 96, 48, 48], [48, 48, 48, 48, 48], [51, 48, 96, 43, 51]], [[48, 4, 48, 48, 48], [48, 96, 96, 48, 48], [48, 95, 96, 48, 48], [48, 48, 48, 48, 48], [55, 48, 95, 72, 66]], [[48, 8, 48, 48, 48], [48, 96, 48, 48, 48], [48, 95, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 97, 75, 73]], [[48, 53, 48, 48, 48], [48, 97, 49, 48, 48], [48, 99, 96, 48, 48], [48, 48, 48, 48, 48], [47, 48, 97, 62, 7]], [[48, 97, 48, 48, 48], [48, 97, 48, 48, 48], [48, 27, 95, 48, 48], [48, 48, 48, 48, 48], [48, 48, 96, 49, 4]], [[48, 99, 48, 48, 48], [48, 96, 48, 48, 48], [48, 0, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 96, 73, 75]], [[48, 99, 48, 48, 48], [48, 96, 48, 48, 48], [48, 98, 96, 48, 48], [48, 48, 48, 48, 48], [48, 49, 95, 71, 62]], [[0, 96, 0, 0, 0], [48, 98, 48, 48, 48], [48, 95, 0, 48, 48], [48, 48, 48, 48, 48], [0, 0, 0, 0, 44]], [[48, 98, 48, 48, 48], [48, 95, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 50, 48, 48], [55, 48, 98, 73, 27]], [[48, 2, 48, 48, 51], [48, 96, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 48, 48, 48], [35, 48, 94, 66, 4]], [[48, 96, 48, 48, 48], [48, 97, 49, 48, 48], [48, 79, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 98, 62, 7]], [[48, 95, 49, 48, 48], [48, 97, 32, 48, 48], [48, 32, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 98, 59, 7]], [[48, 95, 48, 48, 48], [48, 97, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [51, 48, 95, 41, 27]], [[48, 0, 48, 48, 48], [48, 96, 48, 48, 48], [48, 99, 97, 48, 48], [48, 48, 48, 48, 48], [48, 48, 98, 32, 44]], [[48, 0, 48, 48, 48], [48, 95, 48, 48, 48], [48, 98, 97, 48, 48], [48, 48, 48, 48, 48], [48, 48, 99, 20, 49]], [[48, 96, 48, 48, 48], [48, 96, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [55, 49, 99, 2, 54]], [[48, 7, 48, 48, 48], [48, 96, 97, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [54, 49, 99, 2, 53]], [[48, 95, 48, 48, 48], [48, 96, 2, 48, 48], [48, 47, 96, 48, 48], [48, 48, 48, 48, 48], [49, 49, 98, 4, 51]]] \ No newline at end of file diff --git a/embedded_frames_snippet.py b/embedded_frames_snippet.py new file mode 100644 index 0000000..03a0835 --- /dev/null +++ b/embedded_frames_snippet.py @@ -0,0 +1 @@ +EMBEDDED_FRAMES_DATA = [[[48, 48, 48, 48, 48], [48, 95, 28, 48, 48], [48, 40, 97, 48, 48], [48, 48, 48, 48, 48], [48, 49, 48, 72, 2]], [[48, 48, 48, 48, 48], [48, 48, 48, 44, 48], [48, 48, 98, 48, 48], [48, 49, 48, 48, 48], [49, 48, 91, 44, 44]], [[48, 48, 48, 48, 48], [48, 48, 48, 96, 48], [48, 48, 96, 48, 48], [48, 0, 48, 48, 48], [48, 48, 95, 41, 49]], [[48, 48, 48, 20, 48], [48, 48, 48, 97, 48], [48, 48, 96, 96, 48], [48, 4, 48, 48, 48], [48, 49, 98, 7, 55]], [[48, 48, 48, 0, 48], [48, 48, 96, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [48, 48, 95, 7, 54]], [[48, 48, 48, 0, 48], [48, 48, 98, 96, 48], [48, 48, 96, 0, 48], [48, 48, 48, 48, 48], [47, 49, 99, 2, 54]], [[48, 48, 24, 91, 48], [48, 48, 48, 96, 48], [48, 48, 96, 42, 48], [48, 48, 48, 49, 48], [49, 48, 99, 27, 15]], [[48, 91, 48, 48, 48], [48, 96, 48, 48, 48], [48, 79, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 95, 20, 7]], [[48, 48, 48, 48, 48], [48, 96, 48, 48, 48], [48, 98, 96, 48, 48], [48, 48, 48, 48, 48], [52, 48, 36, 55, 4]], [[48, 96, 48, 48, 48], [48, 69, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [96, 50, 12, 23, 7]], [[48, 7, 48, 48, 48], [48, 97, 96, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 47], [51, 51, 99, 16, 56]], [[48, 95, 48, 48, 48], [48, 95, 49, 48, 48], [48, 1, 94, 48, 48], [48, 48, 48, 48, 48], [48, 49, 99, 23, 49]], [[48, 48, 48, 48, 48], [48, 48, 48, 44, 48], [48, 48, 97, 48, 48], [48, 48, 48, 48, 48], [48, 49, 99, 36, 35]], [[48, 48, 48, 48, 48], [48, 48, 48, 94, 48], [48, 48, 95, 2, 48], [48, 26, 49, 48, 48], [48, 48, 94, 24, 11]], [[48, 48, 48, 96, 48], [48, 48, 48, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [49, 50, 99, 53, 11]], [[48, 48, 48, 4, 48], [48, 48, 96, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [55, 48, 99, 56, 4]], [[48, 48, 48, 99, 48], [48, 48, 48, 96, 48], [48, 48, 96, 16, 48], [48, 48, 48, 48, 48], [52, 48, 99, 62, 4]], [[48, 48, 3, 97, 48], [48, 48, 48, 96, 48], [48, 48, 96, 0, 48], [48, 41, 48, 48, 48], [48, 48, 97, 75, 71]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [50, 48, 97, 66, 43]], [[48, 48, 48, 48, 48], [48, 96, 48, 48, 48], [48, 96, 97, 48, 48], [48, 48, 48, 48, 48], [0, 0, 0, 0, 0]], [[48, 97, 48, 48, 48], [48, 96, 98, 48, 48], [48, 96, 95, 48, 48], [48, 48, 47, 49, 48], [47, 48, 98, 66, 20]], [[48, 0, 48, 48, 48], [48, 96, 11, 48, 48], [48, 95, 96, 48, 48], [48, 48, 48, 48, 48], [48, 48, 96, 41, 75]], [[48, 96, 48, 48, 48], [48, 96, 49, 48, 48], [48, 50, 96, 48, 48], [48, 50, 48, 48, 48], [48, 48, 97, 69, 59]], [[48, 48, 48, 0, 48], [48, 48, 48, 99, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [48, 48, 97, 16, 11]], [[48, 48, 48, 0, 48], [48, 48, 48, 95, 48], [48, 48, 97, 95, 48], [48, 0, 48, 48, 48], [49, 48, 99, 20, 22]], [[48, 48, 48, 4, 48], [48, 48, 7, 96, 48], [48, 48, 98, 0, 48], [48, 48, 48, 48, 48], [54, 48, 95, 39, 35]], [[48, 48, 48, 0, 48], [48, 48, 96, 96, 48], [48, 48, 96, 13, 48], [48, 48, 48, 48, 48], [52, 48, 97, 75, 32]], [[48, 48, 48, 97, 48], [48, 48, 47, 94, 48], [48, 48, 96, 16, 48], [48, 48, 48, 48, 48], [48, 48, 99, 22, 54]], [[48, 48, 48, 32, 48], [48, 48, 96, 96, 48], [48, 48, 70, 48, 48], [48, 48, 48, 48, 48], [48, 49, 96, 55, 27]], [[48, 48, 48, 4, 48], [48, 96, 96, 0, 48], [48, 48, 70, 48, 48], [48, 48, 48, 48, 48], [48, 48, 98, 55, 7]], [[48, 48, 1, 48, 48], [48, 48, 32, 49, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [48, 48, 88, 49, 2]], [[48, 0, 48, 48, 48], [48, 48, 96, 48, 48], [48, 48, 0, 48, 48], [48, 48, 48, 48, 48], [49, 48, 15, 49, 7]], [[48, 29, 0, 48, 48], [48, 48, 96, 96, 48], [48, 48, 73, 48, 48], [48, 48, 48, 48, 48], [49, 48, 91, 52, 49]], [[48, 48, 97, 48, 48], [48, 48, 97, 48, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [50, 48, 10, 56, 4]], [[48, 48, 99, 48, 48], [48, 48, 48, 48, 48], [48, 48, 99, 48, 48], [48, 48, 4, 48, 48], [48, 48, 88, 51, 49]], [[48, 48, 0, 48, 48], [48, 48, 48, 48, 48], [48, 48, 95, 48, 48], [48, 48, 48, 48, 48], [48, 48, 96, 32, 27]], [[48, 48, 48, 49, 48], [48, 48, 48, 96, 48], [48, 48, 95, 49, 48], [48, 27, 48, 48, 48], [48, 48, 96, 66, 49]], [[48, 48, 48, 7, 48], [48, 48, 0, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [51, 48, 71, 27, 26]], [[48, 48, 47, 99, 48], [48, 48, 48, 99, 48], [48, 48, 99, 95, 48], [48, 48, 48, 48, 48], [48, 49, 97, 17, 20]], [[48, 95, 48, 48, 48], [48, 96, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 99, 36, 4]], [[48, 96, 48, 48, 48], [48, 96, 48, 48, 48], [48, 95, 95, 48, 48], [48, 48, 48, 49, 48], [49, 48, 99, 59, 4]], [[48, 94, 48, 48, 48], [48, 75, 2, 48, 48], [48, 97, 96, 48, 48], [48, 48, 15, 48, 48], [51, 48, 96, 75, 73]], [[48, 7, 48, 48, 48], [48, 97, 96, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [31, 49, 97, 73, 11]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 1, 96, 48, 48], [48, 48, 48, 48, 48], [50, 49, 99, 43, 43]], [[0, 48, 48, 95, 0], [48, 48, 48, 96, 48], [48, 48, 94, 49, 48], [48, 48, 48, 0, 0], [0, 0, 0, 0, 0]], [[48, 48, 48, 97, 48], [48, 48, 48, 96, 48], [48, 48, 95, 48, 48], [48, 4, 48, 48, 48], [49, 48, 99, 62, 27]], [[48, 48, 48, 96, 48], [48, 48, 49, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [42, 48, 99, 66, 4]], [[48, 48, 48, 7, 48], [48, 48, 97, 99, 48], [48, 48, 95, 94, 48], [48, 48, 48, 48, 48], [17, 48, 95, 56, 2]], [[48, 48, 48, 96, 48], [48, 48, 49, 96, 48], [48, 48, 96, 11, 48], [48, 48, 48, 48, 48], [50, 51, 99, 64, 7]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 99, 97, 48, 48], [48, 48, 48, 48, 48], [49, 49, 97, 24, 20]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 97, 97, 48, 48], [48, 48, 48, 48, 48], [47, 48, 97, 36, 27]], [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 95, 97, 48, 48], [48, 48, 48, 48, 48], [49, 48, 97, 75, 44]], [[48, 96, 48, 48, 48], [48, 84, 95, 48, 48], [48, 96, 98, 48, 48], [48, 48, 48, 48, 48], [48, 48, 97, 21, 49]], [[48, 0, 48, 48, 48], [48, 96, 99, 48, 48], [48, 97, 95, 48, 48], [48, 48, 48, 48, 48], [49, 49, 97, 69, 2]], [[48, 48, 48, 50, 48], [48, 48, 48, 48, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [49, 47, 96, 43, 2]], [[48, 48, 48, 49, 48], [48, 48, 48, 95, 48], [48, 48, 95, 48, 48], [48, 32, 48, 48, 48], [48, 48, 94, 39, 4]], [[48, 48, 48, 7, 48], [48, 48, 48, 95, 48], [48, 48, 97, 98, 48], [48, 1, 48, 48, 48], [49, 48, 99, 22, 49]], [[48, 48, 48, 5, 48], [48, 48, 49, 94, 48], [48, 48, 94, 96, 48], [48, 48, 48, 48, 48], [49, 48, 55, 32, 48]], [[48, 48, 48, 0, 48], [48, 48, 96, 96, 48], [48, 48, 95, 27, 48], [48, 48, 48, 48, 48], [55, 48, 97, 43, 2]], [[48, 48, 0, 97, 48], [48, 48, 48, 96, 48], [48, 48, 95, 1, 48], [48, 48, 48, 48, 48], [47, 48, 95, 73, 4]], [[48, 94, 48, 48, 48], [48, 96, 48, 50, 48], [48, 97, 97, 48, 48], [48, 48, 48, 48, 48], [49, 48, 94, 49, 51]], [[48, 48, 48, 48, 48], [48, 97, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 48, 48, 48], [51, 48, 99, 17, 46]], [[48, 96, 48, 48, 48], [48, 96, 48, 48, 48], [48, 94, 96, 48, 48], [48, 48, 48, 48, 48], [51, 48, 96, 43, 51]], [[48, 4, 48, 48, 48], [48, 96, 96, 48, 48], [48, 95, 96, 48, 48], [48, 48, 48, 48, 48], [55, 48, 95, 72, 66]], [[48, 8, 48, 48, 48], [48, 96, 48, 48, 48], [48, 95, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 97, 75, 73]], [[48, 53, 48, 48, 48], [48, 97, 49, 48, 48], [48, 99, 96, 48, 48], [48, 48, 48, 48, 48], [47, 48, 97, 62, 7]], [[48, 97, 48, 48, 48], [48, 97, 48, 48, 48], [48, 27, 95, 48, 48], [48, 48, 48, 48, 48], [48, 48, 96, 49, 4]], [[48, 99, 48, 48, 48], [48, 96, 48, 48, 48], [48, 0, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 96, 73, 75]], [[48, 99, 48, 48, 48], [48, 96, 48, 48, 48], [48, 98, 96, 48, 48], [48, 48, 48, 48, 48], [48, 49, 95, 71, 62]], [[0, 96, 0, 0, 0], [48, 98, 48, 48, 48], [48, 95, 0, 48, 48], [48, 48, 48, 48, 48], [0, 0, 0, 0, 44]], [[48, 98, 48, 48, 48], [48, 95, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 50, 48, 48], [55, 48, 98, 73, 27]], [[48, 2, 48, 48, 51], [48, 96, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 48, 48, 48], [35, 48, 94, 66, 4]], [[48, 96, 48, 48, 48], [48, 97, 49, 48, 48], [48, 79, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 98, 62, 7]], [[48, 95, 49, 48, 48], [48, 97, 32, 48, 48], [48, 32, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 98, 59, 7]], [[48, 95, 48, 48, 48], [48, 97, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [51, 48, 95, 41, 27]], [[48, 0, 48, 48, 48], [48, 96, 48, 48, 48], [48, 99, 97, 48, 48], [48, 48, 48, 48, 48], [48, 48, 98, 32, 44]], [[48, 0, 48, 48, 48], [48, 95, 48, 48, 48], [48, 98, 97, 48, 48], [48, 48, 48, 48, 48], [48, 48, 99, 20, 49]], [[48, 96, 48, 48, 48], [48, 96, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [55, 49, 99, 2, 54]], [[48, 7, 48, 48, 48], [48, 96, 97, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [54, 49, 99, 2, 53]], [[48, 95, 48, 48, 48], [48, 96, 2, 48, 48], [48, 47, 96, 48, 48], [48, 48, 48, 48, 48], [49, 49, 98, 4, 51]]] diff --git a/giphy-downsized.gif b/giphy-downsized.gif new file mode 100644 index 0000000..12cfd06 Binary files /dev/null and b/giphy-downsized.gif differ diff --git a/requirements.txt b/requirements.txt index ab9bb2b..4030ce3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pybricksdev -pybricks \ No newline at end of file +pybricks +Pillow \ No newline at end of file diff --git a/robot_blink.py b/robot_blink.py index 5d01327..8dd8a2a 100644 --- a/robot_blink.py +++ b/robot_blink.py @@ -1,69 +1,653 @@ #!/usr/bin/env pybricks-micropython +"""Pybricks GIF renderer for the 5x5 LED matrix. + +This script can play animated GIFs by scaling each frame down to the hub's +5x5 matrix and mapping pixel intensity to LED brightness. If no GIF is +available, it falls back to the classic blinking robot animation. """ -Simple MicroPython script for Pybricks hub -- Prints "Hello World" -- Shows a blinking robot face pattern on the LED matrix -""" + +try: + import os # type: ignore +except ImportError: # pragma: no cover - MicroPython hubs may not provide os + os = None # type: ignore[assignment] from pybricks.hubs import PrimeHub -from pybricks.tools import wait, Matrix - -# Initialize the hub -hub = PrimeHub() - -# Print Hello World to console -print("Hello World!") - -# Define a simple robot face pattern (5x5 matrix) -# This represents a simple robot face with sensors, eyes, and features -ROBOT_ON = Matrix([ - [100, 0, 100, 0, 100], # Sensors and top features - [ 0, 100, 100, 100, 0], # Head outline - [100, 100, 0, 100, 100], # Eyes and side features - [ 0, 100, 50, 100, 0], # Mouth and center area - [ 0, 0, 100, 0, 0] # Bottom features -]) - -# Define an "off" pattern (all LEDs off) -ROBOT_OFF = Matrix([ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0] -]) - -# Alternative: just show robot eyes blinking -ROBOT_EYES_CLOSED = Matrix([ - [100, 0, 100, 0, 100], # Sensors and top features - [ 0, 100, 100, 100, 0], # Head outline - [100, 50, 0, 50, 100], # Closed eyes (dimmer) and side features - [ 0, 100, 50, 100, 0], # Mouth and center area - [ 0, 0, 100, 0, 0] # Bottom features -]) - -print("Starting robot animation...") - -# Main animation loop - blink the robot +from pybricks.tools import Matrix, wait + try: + from PIL import Image, ImageSequence # type: ignore[import-not-found] +except ImportError: # pragma: no cover - handled gracefully at runtime + Image = None # type: ignore + ImageSequence = None # type: ignore + + +# Global configuration +FRAME_SIZE = (5, 5) +MAX_BRIGHTNESS = 100 +FPS = 5 +GIF_ENV_VAR = "PYBRICKS_GIF" +DEFAULT_GIF_CANDIDATES = ["robot_animation.gif", "giphy-downsized.gif"] +EMBEDDED_SENTINEL = "__embedded__" +EMBEDDED_SOURCE = "giphy-downsized.gif" +EMBEDDED_FPS = 5 +EMBEDDED_FRAMES_DATA = [ + [[48, 48, 48, 48, 48], [48, 95, 28, 48, 48], [48, 40, 97, 48, 48], [48, 48, 48, 48, 48], [48, 49, 48, 72, 2]], + [[48, 48, 48, 48, 48], [48, 48, 48, 44, 48], [48, 48, 98, 48, 48], [48, 49, 48, 48, 48], [49, 48, 91, 44, 44]], + [[48, 48, 48, 48, 48], [48, 48, 48, 96, 48], [48, 48, 96, 48, 48], [48, 0, 48, 48, 48], [48, 48, 95, 41, 49]], + [[48, 48, 48, 20, 48], [48, 48, 48, 97, 48], [48, 48, 96, 96, 48], [48, 4, 48, 48, 48], [48, 49, 98, 7, 55]], + [[48, 48, 48, 0, 48], [48, 48, 96, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [48, 48, 95, 7, 54]], + [[48, 48, 48, 0, 48], [48, 48, 98, 96, 48], [48, 48, 96, 0, 48], [48, 48, 48, 48, 48], [47, 49, 99, 2, 54]], + [[48, 48, 24, 91, 48], [48, 48, 48, 96, 48], [48, 48, 96, 42, 48], [48, 48, 48, 49, 48], [49, 48, 99, 27, 15]], + [[48, 91, 48, 48, 48], [48, 96, 48, 48, 48], [48, 79, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 95, 20, 7]], + [[48, 48, 48, 48, 48], [48, 96, 48, 48, 48], [48, 98, 96, 48, 48], [48, 48, 48, 48, 48], [52, 48, 36, 55, 4]], + [[48, 96, 48, 48, 48], [48, 69, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [96, 50, 12, 23, 7]], + [[48, 7, 48, 48, 48], [48, 97, 96, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 47], [51, 51, 99, 16, 56]], + [[48, 95, 48, 48, 48], [48, 95, 49, 48, 48], [48, 1, 94, 48, 48], [48, 48, 48, 48, 48], [48, 49, 99, 23, 49]], + [[48, 48, 48, 48, 48], [48, 48, 48, 44, 48], [48, 48, 97, 48, 48], [48, 48, 48, 48, 48], [48, 49, 99, 36, 35]], + [[48, 48, 48, 48, 48], [48, 48, 48, 94, 48], [48, 48, 95, 2, 48], [48, 26, 49, 48, 48], [48, 48, 94, 24, 11]], + [[48, 48, 48, 96, 48], [48, 48, 48, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [49, 50, 99, 53, 11]], + [[48, 48, 48, 4, 48], [48, 48, 96, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [55, 48, 99, 56, 4]], + [[48, 48, 48, 99, 48], [48, 48, 48, 96, 48], [48, 48, 96, 16, 48], [48, 48, 48, 48, 48], [52, 48, 99, 62, 4]], + [[48, 48, 3, 97, 48], [48, 48, 48, 96, 48], [48, 48, 96, 0, 48], [48, 41, 48, 48, 48], [48, 48, 97, 75, 71]], + [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [50, 48, 97, 66, 43]], + [[48, 48, 48, 48, 48], [48, 96, 48, 48, 48], [48, 96, 97, 48, 48], [48, 48, 48, 48, 48], [0, 0, 0, 0, 0]], + [[48, 97, 48, 48, 48], [48, 96, 98, 48, 48], [48, 96, 95, 48, 48], [48, 48, 47, 49, 48], [47, 48, 98, 66, 20]], + [[48, 0, 48, 48, 48], [48, 96, 11, 48, 48], [48, 95, 96, 48, 48], [48, 48, 48, 48, 48], [48, 48, 96, 41, 75]], + [[48, 96, 48, 48, 48], [48, 96, 49, 48, 48], [48, 50, 96, 48, 48], [48, 50, 48, 48, 48], [48, 48, 97, 69, 59]], + [[48, 48, 48, 0, 48], [48, 48, 48, 99, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [48, 48, 97, 16, 11]], + [[48, 48, 48, 0, 48], [48, 48, 48, 95, 48], [48, 48, 97, 95, 48], [48, 0, 48, 48, 48], [49, 48, 99, 20, 22]], + [[48, 48, 48, 4, 48], [48, 48, 7, 96, 48], [48, 48, 98, 0, 48], [48, 48, 48, 48, 48], [54, 48, 95, 39, 35]], + [[48, 48, 48, 0, 48], [48, 48, 96, 96, 48], [48, 48, 96, 13, 48], [48, 48, 48, 48, 48], [52, 48, 97, 75, 32]], + [[48, 48, 48, 97, 48], [48, 48, 47, 94, 48], [48, 48, 96, 16, 48], [48, 48, 48, 48, 48], [48, 48, 99, 22, 54]], + [[48, 48, 48, 32, 48], [48, 48, 96, 96, 48], [48, 48, 70, 48, 48], [48, 48, 48, 48, 48], [48, 49, 96, 55, 27]], + [[48, 48, 48, 4, 48], [48, 96, 96, 0, 48], [48, 48, 70, 48, 48], [48, 48, 48, 48, 48], [48, 48, 98, 55, 7]], + [[48, 48, 1, 48, 48], [48, 48, 32, 49, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [48, 48, 88, 49, 2]], + [[48, 0, 48, 48, 48], [48, 48, 96, 48, 48], [48, 48, 0, 48, 48], [48, 48, 48, 48, 48], [49, 48, 15, 49, 7]], + [[48, 29, 0, 48, 48], [48, 48, 96, 96, 48], [48, 48, 73, 48, 48], [48, 48, 48, 48, 48], [49, 48, 91, 52, 49]], + [[48, 48, 97, 48, 48], [48, 48, 97, 48, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [50, 48, 10, 56, 4]], + [[48, 48, 99, 48, 48], [48, 48, 48, 48, 48], [48, 48, 99, 48, 48], [48, 48, 4, 48, 48], [48, 48, 88, 51, 49]], + [[48, 48, 0, 48, 48], [48, 48, 48, 48, 48], [48, 48, 95, 48, 48], [48, 48, 48, 48, 48], [48, 48, 96, 32, 27]], + [[48, 48, 48, 49, 48], [48, 48, 48, 96, 48], [48, 48, 95, 49, 48], [48, 27, 48, 48, 48], [48, 48, 96, 66, 49]], + [[48, 48, 48, 7, 48], [48, 48, 0, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [51, 48, 71, 27, 26]], + [[48, 48, 47, 99, 48], [48, 48, 48, 99, 48], [48, 48, 99, 95, 48], [48, 48, 48, 48, 48], [48, 49, 97, 17, 20]], + [[48, 95, 48, 48, 48], [48, 96, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 99, 36, 4]], + [[48, 96, 48, 48, 48], [48, 96, 48, 48, 48], [48, 95, 95, 48, 48], [48, 48, 48, 49, 48], [49, 48, 99, 59, 4]], + [[48, 94, 48, 48, 48], [48, 75, 2, 48, 48], [48, 97, 96, 48, 48], [48, 48, 15, 48, 48], [51, 48, 96, 75, 73]], + [[48, 7, 48, 48, 48], [48, 97, 96, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [31, 49, 97, 73, 11]], + [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 1, 96, 48, 48], [48, 48, 48, 48, 48], [50, 49, 99, 43, 43]], + [[0, 48, 48, 95, 0], [48, 48, 48, 96, 48], [48, 48, 94, 49, 48], [48, 48, 48, 0, 0], [0, 0, 0, 0, 0]], + [[48, 48, 48, 97, 48], [48, 48, 48, 96, 48], [48, 48, 95, 48, 48], [48, 4, 48, 48, 48], [49, 48, 99, 62, 27]], + [[48, 48, 48, 96, 48], [48, 48, 49, 96, 48], [48, 48, 96, 96, 48], [48, 48, 48, 48, 48], [42, 48, 99, 66, 4]], + [[48, 48, 48, 7, 48], [48, 48, 97, 99, 48], [48, 48, 95, 94, 48], [48, 48, 48, 48, 48], [17, 48, 95, 56, 2]], + [[48, 48, 48, 96, 48], [48, 48, 49, 96, 48], [48, 48, 96, 11, 48], [48, 48, 48, 48, 48], [50, 51, 99, 64, 7]], + [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 99, 97, 48, 48], [48, 48, 48, 48, 48], [49, 49, 97, 24, 20]], + [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 97, 97, 48, 48], [48, 48, 48, 48, 48], [47, 48, 97, 36, 27]], + [[48, 97, 48, 48, 48], [48, 96, 48, 48, 48], [48, 95, 97, 48, 48], [48, 48, 48, 48, 48], [49, 48, 97, 75, 44]], + [[48, 96, 48, 48, 48], [48, 84, 95, 48, 48], [48, 96, 98, 48, 48], [48, 48, 48, 48, 48], [48, 48, 97, 21, 49]], + [[48, 0, 48, 48, 48], [48, 96, 99, 48, 48], [48, 97, 95, 48, 48], [48, 48, 48, 48, 48], [49, 49, 97, 69, 2]], + [[48, 48, 48, 50, 48], [48, 48, 48, 48, 48], [48, 48, 96, 48, 48], [48, 48, 48, 48, 48], [49, 47, 96, 43, 2]], + [[48, 48, 48, 49, 48], [48, 48, 48, 95, 48], [48, 48, 95, 48, 48], [48, 32, 48, 48, 48], [48, 48, 94, 39, 4]], + [[48, 48, 48, 7, 48], [48, 48, 48, 95, 48], [48, 48, 97, 98, 48], [48, 1, 48, 48, 48], [49, 48, 99, 22, 49]], + [[48, 48, 48, 5, 48], [48, 48, 49, 94, 48], [48, 48, 94, 96, 48], [48, 48, 48, 48, 48], [49, 48, 55, 32, 48]], + [[48, 48, 48, 0, 48], [48, 48, 96, 96, 48], [48, 48, 95, 27, 48], [48, 48, 48, 48, 48], [55, 48, 97, 43, 2]], + [[48, 48, 0, 97, 48], [48, 48, 48, 96, 48], [48, 48, 95, 1, 48], [48, 48, 48, 48, 48], [47, 48, 95, 73, 4]], + [[48, 94, 48, 48, 48], [48, 96, 48, 50, 48], [48, 97, 97, 48, 48], [48, 48, 48, 48, 48], [49, 48, 94, 49, 51]], + [[48, 48, 48, 48, 48], [48, 97, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 48, 48, 48], [51, 48, 99, 17, 46]], + [[48, 96, 48, 48, 48], [48, 96, 48, 48, 48], [48, 94, 96, 48, 48], [48, 48, 48, 48, 48], [51, 48, 96, 43, 51]], + [[48, 4, 48, 48, 48], [48, 96, 96, 48, 48], [48, 95, 96, 48, 48], [48, 48, 48, 48, 48], [55, 48, 95, 72, 66]], + [[48, 8, 48, 48, 48], [48, 96, 48, 48, 48], [48, 95, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 97, 75, 73]], + [[48, 53, 48, 48, 48], [48, 97, 49, 48, 48], [48, 99, 96, 48, 48], [48, 48, 48, 48, 48], [47, 48, 97, 62, 7]], + [[48, 97, 48, 48, 48], [48, 97, 48, 48, 48], [48, 27, 95, 48, 48], [48, 48, 48, 48, 48], [48, 48, 96, 49, 4]], + [[48, 99, 48, 48, 48], [48, 96, 48, 48, 48], [48, 0, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 96, 73, 75]], + [[48, 99, 48, 48, 48], [48, 96, 48, 48, 48], [48, 98, 96, 48, 48], [48, 48, 48, 48, 48], [48, 49, 95, 71, 62]], + [[0, 96, 0, 0, 0], [48, 98, 48, 48, 48], [48, 95, 0, 48, 48], [48, 48, 48, 48, 48], [0, 0, 0, 0, 44]], + [[48, 98, 48, 48, 48], [48, 95, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 50, 48, 48], [55, 48, 98, 73, 27]], + [[48, 2, 48, 48, 51], [48, 96, 48, 48, 48], [48, 97, 96, 48, 48], [48, 48, 48, 48, 48], [35, 48, 94, 66, 4]], + [[48, 96, 48, 48, 48], [48, 97, 49, 48, 48], [48, 79, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 98, 62, 7]], + [[48, 95, 49, 48, 48], [48, 97, 32, 48, 48], [48, 32, 96, 48, 48], [48, 48, 48, 48, 48], [49, 48, 98, 59, 7]], + [[48, 95, 48, 48, 48], [48, 97, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [51, 48, 95, 41, 27]], + [[48, 0, 48, 48, 48], [48, 96, 48, 48, 48], [48, 99, 97, 48, 48], [48, 48, 48, 48, 48], [48, 48, 98, 32, 44]], + [[48, 0, 48, 48, 48], [48, 95, 48, 48, 48], [48, 98, 97, 48, 48], [48, 48, 48, 48, 48], [48, 48, 99, 20, 49]], + [[48, 96, 48, 48, 48], [48, 96, 48, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [55, 49, 99, 2, 54]], + [[48, 7, 48, 48, 48], [48, 96, 97, 48, 48], [48, 96, 96, 48, 48], [48, 48, 48, 48, 48], [54, 49, 99, 2, 53]], + [[48, 95, 48, 48, 48], [48, 96, 2, 48, 48], [48, 47, 96, 48, 48], [48, 48, 48, 48, 48], [49, 49, 98, 4, 51]], +] + + +_EMBEDDED_MATRIX_CACHE = None + + +def embedded_frames_available(): + return bool(EMBEDDED_FRAMES_DATA) + + +def get_embedded_frames(): + global _EMBEDDED_MATRIX_CACHE + if not EMBEDDED_FRAMES_DATA: + return [] + if _EMBEDDED_MATRIX_CACHE is None: + frames = [] + for rows in EMBEDDED_FRAMES_DATA: + frames.append(Matrix(rows)) + _EMBEDDED_MATRIX_CACHE = frames + return list(_EMBEDDED_MATRIX_CACHE) + + +def brightness_from_pixel(value): + """Scale an 8-bit grayscale value (0-255) to the 0-100 LED brightness range.""" + + return max(0, min(MAX_BRIGHTNESS, round(value * MAX_BRIGHTNESS / 255))) + + +def image_to_matrix(image): + """Convert a Pillow image to a Pybricks Matrix at the configured frame size.""" + + grayscale = image.convert("L") + resampling = getattr(Image, "Resampling", None) + if resampling is not None: + resized = grayscale.resize(FRAME_SIZE, resampling.BILINEAR) # type: ignore[attr-defined] + else: + resized = grayscale.resize(FRAME_SIZE, Image.BILINEAR) # type: ignore[no-member] + width, height = FRAME_SIZE + pixels = list(resized.getdata()) + + rows = [] + for row in range(height): + start = row * width + end = start + width + row_values = [brightness_from_pixel(px) for px in pixels[start:end]] + rows.append(row_values) + + return Matrix(rows) + + +def load_gif_frames(path): + """Load a GIF file and return a list of matrices representing each frame.""" + + if Image is None or ImageSequence is None: + print("Debug: Pillow not available, using built-in GIF decoder") + return _load_gif_frames_pure(path) + + if not _file_exists(path): + raise FileNotFoundError(f"GIF file not found: {path}") + + frames = [] + with Image.open(path) as gif: # type: ignore[no-untyped-call] + for frame in ImageSequence.Iterator(gif): # type: ignore[attr-defined] + matrix = image_to_matrix(frame) + frames.append(matrix) + + if not frames: + raise ValueError(f"GIF file '{path}' does not contain any frames.") + + return frames + + +def _load_gif_frames_pure(path): + """Decode a GIF file without external dependencies.""" + + with open(path, "rb") as gif_file: + data = gif_file.read() + + if len(data) < 13: + raise ValueError("GIF file too small") + + header = data[0:6] + if header not in (b"GIF87a", b"GIF89a"): + raise ValueError("Unsupported GIF header: %s" % header) + + width = data[6] | (data[7] << 8) + height = data[8] | (data[9] << 8) + packed = data[10] + global_table_flag = packed & 0x80 + global_table_size = 2 ** ((packed & 0x07) + 1) + background_color_index = data[11] + # pixel aspect ratio ignored + + offset = 13 + global_color_table = None + if global_table_flag: + expected_len = offset + 3 * global_table_size + if len(data) < expected_len: + raise ValueError("Incomplete global color table") + global_color_table = [] + for i in range(global_table_size): + r = data[offset] + g = data[offset + 1] + b = data[offset + 2] + global_color_table.append((r, g, b)) + offset += 3 + + frames = [] + transparent_index = None + delay_time = 0 + + while offset < len(data): + block_id = data[offset] + offset += 1 + + if block_id == 0x3B: # Trailer + break + + if block_id == 0x21: # Extension + if offset >= len(data): + break + label = data[offset] + offset += 1 + + if label == 0xF9: # Graphics Control Extension + if offset >= len(data): + break + block_size = data[offset] + offset += 1 + if block_size != 4: + offset += block_size + else: + packed_fields = data[offset] + offset += 1 + delay_time = data[offset] | (data[offset + 1] << 8) + offset += 2 + transparent_index = data[offset] + offset += 1 + offset += 1 # block terminator + else: + # Skip other extensions + while True: + if offset >= len(data): + break + block_size = data[offset] + offset += 1 + if block_size == 0: + break + offset += block_size + continue + + if block_id == 0x2C: # Image Descriptor + if offset + 9 > len(data): + break + left = data[offset] | (data[offset + 1] << 8) + top = data[offset + 2] | (data[offset + 3] << 8) + frame_width = data[offset + 4] | (data[offset + 5] << 8) + frame_height = data[offset + 6] | (data[offset + 7] << 8) + packed_fields = data[offset + 8] + offset += 9 + + local_table_flag = packed_fields & 0x80 + interlace_flag = packed_fields & 0x40 + # sort_flag = packed_fields & 0x20 + local_table_size = 2 ** ((packed_fields & 0x07) + 1) + + color_table = global_color_table + if local_table_flag: + color_table = [] + for _ in range(local_table_size): + if offset + 3 > len(data): + raise ValueError("Incomplete local color table") + r = data[offset] + g = data[offset + 1] + b = data[offset + 2] + color_table.append((r, g, b)) + offset += 3 + + if color_table is None: + raise ValueError("GIF is missing a color table") + + if offset >= len(data): + break + + min_code_size = data[offset] + offset += 1 + + compressed_blocks = [] + while True: + if offset >= len(data): + break + block_size = data[offset] + offset += 1 + if block_size == 0: + break + compressed_blocks.append(data[offset:offset + block_size]) + offset += block_size + + compressed_data = b"".join(compressed_blocks) + indices = _gif_lzw_decompress(compressed_data, min_code_size, frame_width * frame_height) + + if interlace_flag: + indices = _deinterlace(indices, frame_width, frame_height) + + frame_brightness = _indices_to_brightness(indices, color_table, transparent_index, frame_width, frame_height) + downscaled = _downscale_frame(frame_brightness, frame_width, frame_height) + frames.append(Matrix(downscaled)) + + # Reset transparency for next frame unless explicitly set again + transparent_index = None + continue + + if not frames: + raise ValueError("No frames decoded from GIF") + + return frames + + +def _gif_lzw_decompress(data, min_code_size, expected_length): + if expected_length <= 0: + return [] + + clear_code = 1 << min_code_size + end_code = clear_code + 1 + code_size = min_code_size + 1 + max_code_size = 12 + + dictionary = {} + for i in range(clear_code): + dictionary[i] = [i] + + result = [] + data_len = len(data) + byte_pos = 0 + bit_pos = 0 + + def read_code(current_code_size): + nonlocal byte_pos, bit_pos + raw = 0 + for i in range(3): + idx = byte_pos + i + if idx < data_len: + raw |= data[idx] << (8 * i) + raw >>= bit_pos + mask = (1 << current_code_size) - 1 + code = raw & mask + bit_pos += current_code_size + while bit_pos >= 8: + bit_pos -= 8 + byte_pos += 1 + return code + + prev = [] + next_code = end_code + 1 + + while True: + if byte_pos >= data_len: + break + code = read_code(code_size) + + if code == clear_code: + dictionary = {} + for i in range(clear_code): + dictionary[i] = [i] + code_size = min_code_size + 1 + next_code = end_code + 1 + prev = [] + continue + + if code == end_code: + break + + if code in dictionary: + entry = dictionary[code][:] + elif code == next_code and prev: + entry = prev + [prev[0]] + else: + raise ValueError("Invalid LZW code: %s" % code) + + result.extend(entry) + + if prev: + dictionary[next_code] = prev + [entry[0]] + next_code += 1 + if next_code >= (1 << code_size) and code_size < max_code_size: + code_size += 1 + + prev = entry + + if expected_length and len(result) >= expected_length: + break + + return result[:expected_length] + + +def _deinterlace(indices, width, height): + result = [0] * (width * height) + passes = [ + (0, 8), + (4, 8), + (2, 4), + (1, 2), + ] + idx = 0 + for start, step in passes: + y = start + while y < height and idx < len(indices): + row_start = y * width + row_end = row_start + width + result[row_start:row_end] = indices[idx:idx + width] + idx += width + y += step + return result + + +def _indices_to_brightness(indices, color_table, transparent_index, width, height): + brightness = [0] * (width * height) + for i, index in enumerate(indices): + if index == transparent_index: + brightness[i] = 0 + continue + if index >= len(color_table): + brightness[i] = 0 + continue + r, g, b = color_table[index] + gray = (r * 299 + g * 587 + b * 114) // 1000 + brightness[i] = gray + return brightness + + +def _downscale_frame(brightness, width, height): + target_w, target_h = FRAME_SIZE + matrix = [] + for ty in range(target_h): + row = [] + source_y = int((ty + 0.5) * height / target_h) + if source_y >= height: + source_y = height - 1 + for tx in range(target_w): + source_x = int((tx + 0.5) * width / target_w) + if source_x >= width: + source_x = width - 1 + index = source_y * width + source_x + gray = brightness[index] + row.append(brightness_from_pixel(gray)) + matrix.append(row) + return matrix + + +def play_frames(hub, frames, fps=FPS): + """Continuously play the provided matrices on the hub at the requested FPS.""" + + frame_delay_ms = max(1, int(1000 / fps)) + + print(f"Playing GIF with {len(frames)} frames at {fps} FPS...") while True: - # Show the robot - hub.display.icon(ROBOT_ON) - wait(1000) # Show for 1 second - - # Quick blink - eyes closed - hub.display.icon(ROBOT_EYES_CLOSED) - wait(150) # Brief blink - - # Back to eyes open - hub.display.icon(ROBOT_ON) - wait(1000) # Show for 1 second - - - - print("Blink!") # Print to console each blink cycle - -except KeyboardInterrupt: - # Clean exit when stopped - print("Animation stopped") - hub.display.off() + for frame in frames: + hub.display.icon(frame) + wait(frame_delay_ms) + + +def run_robot_blink(hub): + """Fallback animation: simple blinking robot face.""" + + robot_on = Matrix([ + [100, 0, 100, 0, 100], + [0, 100, 100, 100, 0], + [100, 100, 0, 100, 100], + [0, 100, 50, 100, 0], + [0, 0, 100, 0, 0], + ]) + + robot_eyes_closed = Matrix([ + [100, 0, 100, 0, 100], + [0, 100, 100, 100, 0], + [100, 50, 0, 50, 100], + [0, 100, 50, 100, 0], + [0, 0, 100, 0, 0], + ]) + + print("Playing fallback robot blink animation...") + while True: + hub.display.icon(robot_on) + wait(1000) + hub.display.icon(robot_eyes_closed) + wait(150) + hub.display.icon(robot_on) + wait(1000) + print("Blink!") + + +def _file_exists(path): + try: + with open(path, "rb"): + return True + except OSError: + return False + + +def _resolve_env_gif(): + """Return a file path from the environment variable if available.""" + + if os is None or not hasattr(os, "getenv"): + print("Debug: OS module unavailable; skipping env lookup") + return None + + value = os.getenv(GIF_ENV_VAR) + if not value: + print("Debug: %s not set" % GIF_ENV_VAR) + return None + + path = value + if os and hasattr(os.path, "expanduser"): + path = os.path.expanduser(path) + + if _file_exists(path): + print("Debug: Using GIF from env %s" % path) + return path + + print("Warning: environment variable %s is set but file not found: %s" % (GIF_ENV_VAR, path)) + return None + + +def _find_default_gif(): + """Return the first available GIF using preferred filenames or any *.gif file.""" + + for name in DEFAULT_GIF_CANDIDATES: + if _file_exists(name): + print("Debug: Found default candidate %s" % name) + return name + + if os is not None and hasattr(os, "listdir"): + try: + candidates = sorted([f for f in os.listdir(".") if f.lower().endswith(".gif")]) + print("Debug: Directory GIF candidates %s" % candidates) + for candidate in candidates: + if _file_exists(candidate): + print("Debug: Using GIF from directory %s" % candidate) + return candidate + except OSError as exc: + print("Warning: Could not list directory for GIFs: %s" % exc) + + print("Debug: No GIF found; fallback will be used") + return None + + +def parse_gif_argument(argv): + """Parse CLI arguments for a GIF path. + + Accepts either `--gif path` or a positional path. Returns None if no GIF should + be played. + """ + + args = list(argv) + + if not args: + env_path = _resolve_env_gif() + if env_path is not None: + return env_path + return _find_default_gif() + + if "--embedded" in args: + print("Debug: --embedded flag detected") + return EMBEDDED_SENTINEL + + if "--gif" in args: + gif_index = args.index("--gif") + if gif_index + 1 >= len(args): + raise ValueError("Missing path after --gif option.") + selected = args[gif_index + 1] + print("Debug: --gif selected %s" % selected) + return selected + + if "--blink" in args: + return None + + # First positional argument treated as GIF path + if len(args) >= 1: + path = args[0] + if _file_exists(path): + print("Debug: Using positional path %s" % path) + return path + print("Debug: Positional path missing %s" % path) + + env_path = _resolve_env_gif() + if env_path is not None: + return env_path + + return _find_default_gif() + + +def main(argv=None): + """Application entry point.""" + + if argv is None: + argv = [] + + hub = PrimeHub() + print("Hello World!") + + try: + gif_path = parse_gif_argument(list(argv)) + if gif_path == EMBEDDED_SENTINEL: + frames = get_embedded_frames() + if frames: + print("Debug: Playing embedded GIF data from %s" % EMBEDDED_SOURCE) + play_frames(hub, frames, fps=EMBEDDED_FPS or FPS) + else: + print("Warning: Embedded frames requested but unavailable; falling back") + run_robot_blink(hub) + elif gif_path is not None: + print("Debug: Loading GIF %s" % gif_path) + frames = load_gif_frames(gif_path) + print("Debug: Loaded %d frames" % len(frames)) + play_frames(hub, frames, fps=FPS) + else: + if embedded_frames_available(): + frames = get_embedded_frames() + if frames: + print("Debug: No GIF path provided; using embedded frames from %s" % EMBEDDED_SOURCE) + play_frames(hub, frames, fps=EMBEDDED_FPS or FPS) + else: + print("Warning: Embedded frame cache empty; running fallback") + run_robot_blink(hub) + else: + print("Debug: No GIF selected, running fallback") + run_robot_blink(hub) + except Exception as exc: + print(f"Unable to play GIF: {exc}") + print("Falling back to robot blink animation.") + run_robot_blink(hub) + finally: + hub.display.off() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("Animation stopped") diff --git a/scripts/generate_embedded_frames.py b/scripts/generate_embedded_frames.py new file mode 100644 index 0000000..3b7ba0a --- /dev/null +++ b/scripts/generate_embedded_frames.py @@ -0,0 +1,282 @@ +from pathlib import Path + +TARGET_SIZE = (5, 5) +OUTPUT_PATH = Path("embedded_frames.json") +PY_SNIPPET_PATH = Path("embedded_frames_snippet.py") +INPUT_PATH = Path("giphy-downsized.gif") + + +def brightness_from_pixel(value: int) -> int: + return round(value * 100 / 255) + + +def _file_exists(path: Path) -> bool: + return path.exists() and path.is_file() + + +def _gif_lzw_decompress(data, min_code_size, expected_length): + if expected_length <= 0: + return [] + + clear_code = 1 << min_code_size + end_code = clear_code + 1 + code_size = min_code_size + 1 + max_code_size = 12 + + dictionary = {i: [i] for i in range(clear_code)} + + result = [] + data_len = len(data) + byte_pos = 0 + bit_pos = 0 + + def read_code(current_code_size): + nonlocal byte_pos, bit_pos + raw = 0 + for i in range(3): + idx = byte_pos + i + if idx < data_len: + raw |= data[idx] << (8 * i) + raw >>= bit_pos + mask = (1 << current_code_size) - 1 + code = raw & mask + bit_pos += current_code_size + while bit_pos >= 8: + bit_pos -= 8 + byte_pos += 1 + return code + + prev = [] + next_code = end_code + 1 + + while True: + if byte_pos >= data_len: + break + code = read_code(code_size) + + if code == clear_code: + dictionary = {i: [i] for i in range(clear_code)} + code_size = min_code_size + 1 + next_code = end_code + 1 + prev = [] + continue + + if code == end_code: + break + + if code in dictionary: + entry = dictionary[code][:] + elif code == next_code and prev: + entry = prev + [prev[0]] + else: + raise ValueError(f"Invalid LZW code: {code}") + + result.extend(entry) + + if prev: + dictionary[next_code] = prev + [entry[0]] + next_code += 1 + if next_code >= (1 << code_size) and code_size < max_code_size: + code_size += 1 + + prev = entry + + if expected_length and len(result) >= expected_length: + break + + return result[:expected_length] + + +def _deinterlace(indices, width, height): + result = [0] * (width * height) + passes = [(0, 8), (4, 8), (2, 4), (1, 2)] + idx = 0 + for start, step in passes: + y = start + while y < height and idx < len(indices): + row_start = y * width + row_end = row_start + width + result[row_start:row_end] = indices[idx:idx + width] + idx += width + y += step + return result + + +def _indices_to_brightness(indices, color_table, transparent_index): + brightness = [] + for index in indices: + if index == transparent_index or index >= len(color_table): + brightness.append(0) + continue + r, g, b = color_table[index] + gray = (r * 299 + g * 587 + b * 114) // 1000 + brightness.append(gray) + return brightness + + +def _downscale_frame(brightness, width, height): + target_w, target_h = TARGET_SIZE + matrix = [] + for ty in range(target_h): + row = [] + source_y = int((ty + 0.5) * height / target_h) + if source_y >= height: + source_y = height - 1 + for tx in range(target_w): + source_x = int((tx + 0.5) * width / target_w) + if source_x >= width: + source_x = width - 1 + index = source_y * width + source_x + gray = brightness[index] + row.append(brightness_from_pixel(gray)) + matrix.append(row) + return matrix + + +def decode_gif(path: Path): + data = path.read_bytes() + if len(data) < 13: + raise ValueError("GIF file too small") + + header = data[0:6] + if header not in (b"GIF87a", b"GIF89a"): + raise ValueError(f"Unsupported GIF header: {header}") + + width = data[6] | (data[7] << 8) + height = data[8] | (data[9] << 8) + packed = data[10] + global_table_flag = packed & 0x80 + global_table_size = 2 ** ((packed & 0x07) + 1) + + offset = 13 + global_color_table = None + if global_table_flag: + expected_len = offset + 3 * global_table_size + if len(data) < expected_len: + raise ValueError("Incomplete global color table") + global_color_table = [] + for _ in range(global_table_size): + r = data[offset] + g = data[offset + 1] + b = data[offset + 2] + global_color_table.append((r, g, b)) + offset += 3 + + frames = [] + transparent_index = None + + while offset < len(data): + block_id = data[offset] + offset += 1 + + if block_id == 0x3B: + break + + if block_id == 0x21: + if offset >= len(data): + break + label = data[offset] + offset += 1 + + if label == 0xF9: + if offset >= len(data): + break + block_size = data[offset] + offset += 1 + if block_size == 4: + packed_fields = data[offset] + offset += 1 + offset += 2 # delay time, ignored + transparent_index = data[offset] + offset += 1 + else: + offset += block_size + offset += 1 + else: + while True: + if offset >= len(data): + break + block_size = data[offset] + offset += 1 + if block_size == 0: + break + offset += block_size + continue + + if block_id == 0x2C: + if offset + 9 > len(data): + break + frame_width = data[offset + 4] | (data[offset + 5] << 8) + frame_height = data[offset + 6] | (data[offset + 7] << 8) + packed_fields = data[offset + 8] + offset += 9 + + local_table_flag = packed_fields & 0x80 + interlace_flag = packed_fields & 0x40 + local_table_size = 2 ** ((packed_fields & 0x07) + 1) + + color_table = global_color_table + if local_table_flag: + color_table = [] + for _ in range(local_table_size): + if offset + 3 > len(data): + raise ValueError("Incomplete local color table") + r = data[offset] + g = data[offset + 1] + b = data[offset + 2] + color_table.append((r, g, b)) + offset += 3 + + if color_table is None: + raise ValueError("GIF is missing a color table") + + if offset >= len(data): + break + + min_code_size = data[offset] + offset += 1 + + compressed_blocks = [] + while True: + if offset >= len(data): + break + block_size = data[offset] + offset += 1 + if block_size == 0: + break + compressed_blocks.append(data[offset:offset + block_size]) + offset += block_size + + compressed_data = b"".join(compressed_blocks) + indices = _gif_lzw_decompress(compressed_data, min_code_size, frame_width * frame_height) + + if interlace_flag: + indices = _deinterlace(indices, frame_width, frame_height) + + brightness = _indices_to_brightness(indices, color_table, transparent_index) + frame_matrix = _downscale_frame(brightness, frame_width, frame_height) + frames.append(frame_matrix) + + transparent_index = None + continue + + if not frames: + raise ValueError("No frames decoded from GIF") + + return frames + + +def main() -> None: + if not _file_exists(INPUT_PATH): + raise SystemExit(f"Input GIF not found: {INPUT_PATH}") + + frames = decode_gif(INPUT_PATH) + OUTPUT_PATH.write_text(str(frames)) + snippet = "EMBEDDED_FRAMES_DATA = " + repr(frames) + "\n" + PY_SNIPPET_PATH.write_text(snippet) + print(f"Wrote {len(frames)} frames to {OUTPUT_PATH}") + print(f"Wrote Python snippet to {PY_SNIPPET_PATH}") + + +if __name__ == "__main__": + main()