make_background.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. #!/usr/bin/env python3
  2. """
  3. make_background.py — Convert a PNG to a raw RGB565 binary for the ST7789 display.
  4. The output is flashed to SPIFFS at /spiffs/bg/background.bin and loaded at
  5. runtime by the display driver. No firmware rebuild is required to change the
  6. background — after running this script, either reflash the SPIFFS image or
  7. upload the file via the HTTP API:
  8. curl -X POST "http://<device-ip>/api/fs/upload?path=/spiffs/bg/background.bin" \\
  9. --data-binary @data/bg/background.bin
  10. Usage:
  11. python3 components/display/make_background.py <source.png> [brightness]
  12. Arguments:
  13. source.png Path to the source PNG image (any size — will be resized to 320x170)
  14. brightness Optional brightness multiplier, 0.0–1.0 (default: 0.5)
  15. The ST7789 backlight renders images significantly brighter than
  16. a monitor — 0.5 is a good starting point, adjust to taste.
  17. Output:
  18. data/bg/background.bin (relative to project root)
  19. Example:
  20. python3 components/display/make_background.py ~/Desktop/my_background.png 0.5
  21. python3 components/display/make_background.py ~/Desktop/my_background.png 0.6
  22. Notes:
  23. - Output is always 320x170px, little-endian RGB565, 108,800 bytes
  24. - Byte order confirmed via colour bar test on ST7789 + esp_lvgl_port with swap_bytes=true
  25. - Floyd-Steinberg dithering is applied to reduce banding on dark gradients
  26. - The display is 16-bit colour (5R/6G/5B) — subtle gradients will show banding;
  27. design backgrounds with bold contrast for best results
  28. - After uploading, the display loads the new background on next boot
  29. """
  30. import sys
  31. import os
  32. try:
  33. from PIL import Image, ImageEnhance
  34. except ImportError:
  35. print("Error: Pillow is not installed.")
  36. print("Install it with: pip3 install Pillow")
  37. sys.exit(1)
  38. # ---- Constants ---------------------------------------------------------------
  39. DISPLAY_W = 320
  40. DISPLAY_H = 170
  41. DEFAULT_BRIGHTNESS = 0.5
  42. # Script lives in components/display/ — project root is two levels up
  43. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  44. PROJECT_DIR = os.path.dirname(os.path.dirname(SCRIPT_DIR))
  45. OUTPUT_DIR = os.path.join(PROJECT_DIR, 'data', 'bg')
  46. OUTPUT_PATH = os.path.join(OUTPUT_DIR, 'background.bin')
  47. # ---- Main --------------------------------------------------------------------
  48. def convert(source_path, brightness):
  49. print(f"Source: {source_path}")
  50. print(f"Brightness: {brightness}")
  51. print(f"Output: {OUTPUT_PATH}")
  52. print()
  53. # Load and resize
  54. img = Image.open(source_path).convert('RGB').resize(
  55. (DISPLAY_W, DISPLAY_H), Image.LANCZOS
  56. )
  57. # Brightness adjustment
  58. img = ImageEnhance.Brightness(img).enhance(brightness)
  59. # Floyd-Steinberg dithering — reduces banding on dark gradients at RGB565 depth
  60. img = img.convert(
  61. 'P', palette=Image.ADAPTIVE, dither=Image.FLOYDSTEINBERG, colors=256
  62. ).convert('RGB')
  63. # Convert to little-endian RGB565 raw binary
  64. pixels = img.load()
  65. bytes_out = bytearray()
  66. for y in range(DISPLAY_H):
  67. for x in range(DISPLAY_W):
  68. r, g, b = pixels[x, y]
  69. rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
  70. bytes_out.append(rgb565 & 0xFF) # low byte first (little-endian)
  71. bytes_out.append((rgb565 >> 8) & 0xFF)
  72. # Write raw binary
  73. os.makedirs(OUTPUT_DIR, exist_ok=True)
  74. with open(OUTPUT_PATH, 'wb') as f:
  75. f.write(bytes_out)
  76. expected = DISPLAY_W * DISPLAY_H * 2
  77. print(f"Written {len(bytes_out)} bytes ({DISPLAY_W}x{DISPLAY_H} px RGB565)")
  78. assert len(bytes_out) == expected, f"Size mismatch: {len(bytes_out)} != {expected}"
  79. print()
  80. print("Next steps:")
  81. print(" Flash SPIFFS: idf.py build && idf.py flash")
  82. print(" Or upload OTA: curl -X POST \"http://<device-ip>/api/fs/upload?path=/spiffs/bg/background.bin\" \\")
  83. print(f" --data-binary @{OUTPUT_PATH}")
  84. def main():
  85. if len(sys.argv) < 2:
  86. print(__doc__)
  87. sys.exit(1)
  88. source = os.path.expanduser(sys.argv[1])
  89. if not os.path.isfile(source):
  90. print(f"Error: file not found: {source}")
  91. sys.exit(1)
  92. brightness = DEFAULT_BRIGHTNESS
  93. if len(sys.argv) >= 3:
  94. try:
  95. brightness = float(sys.argv[2])
  96. if not 0.0 < brightness <= 1.0:
  97. raise ValueError
  98. except ValueError:
  99. print(f"Error: brightness must be a number between 0.0 and 1.0 (got '{sys.argv[2]}'")
  100. sys.exit(1)
  101. convert(source, brightness)
  102. if __name__ == '__main__':
  103. main()