mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
Add a logo file generator based on TUI
This commit is contained in:
532
tools/logo_generator/logo_generator.py
Normal file
532
tools/logo_generator/logo_generator.py
Normal file
@@ -0,0 +1,532 @@
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import sys
|
||||
import os
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Header, Footer, Button, Input, Label, Static
|
||||
from textual.containers import Container, Horizontal, Vertical, Center, Middle
|
||||
from textual.binding import Binding
|
||||
from textual import events
|
||||
from textual.message import Message
|
||||
|
||||
|
||||
# Language dictionary for i18n support
|
||||
LANGUAGES = {
|
||||
'zh': {
|
||||
'app_title': '🖼️ [ NanoKVM Logo 生成器 ]',
|
||||
'app_title_editor': '✏️ NanoKVM Logo 编辑器',
|
||||
'contrast_minus': '对比度-',
|
||||
'contrast_plus': '对比度+',
|
||||
'current_contrast': '当前对比度:{}',
|
||||
'click_hint': '💡 点击图片以修改像素',
|
||||
'btn_invert': '🔃 反转',
|
||||
'btn_reset': '🔄 重置',
|
||||
'btn_export': '💾 导出 logo.bin',
|
||||
'btn_quit': '❌ 退出',
|
||||
'help_text_with_image': '快捷键:↑↓ 调整±10 | ←→ 调整±1 | I 反转 | R 重置 | D 导出 | Q 退出',
|
||||
'help_text_editor': '快捷键:I 反转 | R 清空 | D 导出 | Q 退出',
|
||||
'status_blank_canvas': '空白画布 - 点击像素开始绘制',
|
||||
'status_loaded': '已加载:{}',
|
||||
'status_load_failed': '无法加载图片:{}',
|
||||
'status_contrast': '对比度:{}',
|
||||
'status_inverted': '已反转所有像素',
|
||||
'status_reset_image': '已重置为原始图片',
|
||||
'status_reset_canvas': '已清空画布',
|
||||
'status_export_error': '错误:没有可导出的数据',
|
||||
'status_exported': '✅ 已导出 logo.bin (32 字节)',
|
||||
'status_pixel_toggled': '切换像素 ({}, {})',
|
||||
'lang_select_title': '选择语言 / Select Language',
|
||||
'btn_chinese': '中文 (Chinese)',
|
||||
'btn_english': 'English',
|
||||
'binding_quit': '退出',
|
||||
'binding_export': '导出',
|
||||
'binding_reset': '重置',
|
||||
'binding_invert': '反转',
|
||||
'binding_threshold_up': '阈值 +10',
|
||||
'binding_threshold_down': '阈值 -10',
|
||||
'binding_threshold_up_small': '阈值 +1',
|
||||
'binding_threshold_down_small': '阈值 -1',
|
||||
},
|
||||
'en': {
|
||||
'app_title': '🖼️ [ NanoKVM Logo Generator ]',
|
||||
'app_title_editor': '✏️ NanoKVM Logo Editor',
|
||||
'contrast_minus': 'Contrast-',
|
||||
'contrast_plus': 'Contrast+',
|
||||
'current_contrast': 'Contrast: {}',
|
||||
'click_hint': '💡 Click image to toggle pixels',
|
||||
'btn_invert': '🔃 Invert',
|
||||
'btn_reset': '🔄 Reset',
|
||||
'btn_export': '💾 Export logo.bin',
|
||||
'btn_quit': '❌ Quit',
|
||||
'help_text_with_image': 'Shortcuts: ↑↓ ±10 | ←→ ±1 | I Invert | R Reset | D Export | Q Quit',
|
||||
'help_text_editor': 'Shortcuts: I Invert | R Clear | D Export | Q Quit',
|
||||
'status_blank_canvas': 'Blank canvas - Click pixels to draw',
|
||||
'status_loaded': 'Loaded: {}',
|
||||
'status_load_failed': 'Failed to load image: {}',
|
||||
'status_contrast': 'Contrast: {}',
|
||||
'status_inverted': 'All pixels inverted',
|
||||
'status_reset_image': 'Reset to original image',
|
||||
'status_reset_canvas': 'Canvas cleared',
|
||||
'status_export_error': 'Error: No data to export',
|
||||
'status_exported': '✅ Exported logo.bin (32 bytes)',
|
||||
'status_pixel_toggled': 'Toggled pixel ({}, {})',
|
||||
'lang_select_title': '选择语言 / Select Language',
|
||||
'btn_chinese': '中文 (Chinese)',
|
||||
'btn_english': 'English',
|
||||
'binding_quit': 'Quit',
|
||||
'binding_export': 'Export',
|
||||
'binding_reset': 'Reset',
|
||||
'binding_invert': 'Invert',
|
||||
'binding_threshold_up': 'Threshold +10',
|
||||
'binding_threshold_down': 'Threshold -10',
|
||||
'binding_threshold_up_small': 'Threshold +1',
|
||||
'binding_threshold_down_small': 'Threshold -1',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def reverse_byte(byte):
|
||||
"""Reverse the bits in a byte"""
|
||||
result = 0
|
||||
for i in range(8):
|
||||
if (byte >> i) & 1:
|
||||
result |= (1 << (7 - i))
|
||||
return result
|
||||
|
||||
|
||||
def process_image(image_path, threshold=128):
|
||||
"""
|
||||
Process image to 16x16 binary data
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file
|
||||
threshold: Binarization threshold (0-255)
|
||||
|
||||
Returns:
|
||||
16x16 numpy array of binary values
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
img = Image.open(image_path).convert('L')
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to open image - {e}")
|
||||
return None
|
||||
|
||||
# If not square, crop the center region
|
||||
if img.width != img.height:
|
||||
size = min(img.width, img.height)
|
||||
left = (img.width - size) // 2
|
||||
top = (img.height - size) // 2
|
||||
img = img.crop((left, top, left + size, top + size))
|
||||
|
||||
# Resize to 16x16 and binarize
|
||||
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||
binary = (np.array(img) < threshold).astype(np.uint8)
|
||||
|
||||
return binary
|
||||
|
||||
|
||||
def create_blank_image():
|
||||
"""Create a blank 16x16 image (all black)"""
|
||||
return np.zeros((16, 16), dtype=np.uint8)
|
||||
|
||||
|
||||
def binary_to_bytes(binary):
|
||||
"""Convert 16x16 binary data to 32 bytes"""
|
||||
data = []
|
||||
|
||||
for x in range(16):
|
||||
for page in range(2):
|
||||
byte = 0
|
||||
start_row = page * 8
|
||||
for bit in range(8):
|
||||
row = start_row + bit
|
||||
if row < 16 and binary[row][x] == 1:
|
||||
byte |= (1 << (7 - bit))
|
||||
data.append(reverse_byte(byte))
|
||||
|
||||
return bytes(data)
|
||||
|
||||
|
||||
class PixelGrid(Static):
|
||||
"""16x16 pixel grid component"""
|
||||
|
||||
class PixelClick(Message):
|
||||
def __init__(self, x: int, y: int):
|
||||
super().__init__()
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
DEFAULT_CSS = """
|
||||
PixelGrid {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, binary_data=None, id=None):
|
||||
super().__init__(id=id)
|
||||
self.binary_data = binary_data if binary_data is not None else np.zeros((16, 16), dtype=np.uint8)
|
||||
self.can_focus = True
|
||||
|
||||
def update_data(self, binary_data):
|
||||
self.binary_data = binary_data
|
||||
self.refresh()
|
||||
|
||||
def render(self):
|
||||
"""Render the pixel grid"""
|
||||
lines = []
|
||||
# Top border
|
||||
lines.append("┌" + "─" * 32 + "┐")
|
||||
|
||||
for y in range(16):
|
||||
line = "│"
|
||||
for x in range(16):
|
||||
if self.binary_data[y][x] == 1:
|
||||
line += "██"
|
||||
else:
|
||||
line += " "
|
||||
line += "│"
|
||||
lines.append(line)
|
||||
|
||||
# Bottom border
|
||||
lines.append("└" + "─" * 32 + "┘")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def on_click(self, event: events.Click):
|
||||
"""Handle click event"""
|
||||
# Get coordinates relative to the component
|
||||
# Use event.offset to get coordinates relative to the component
|
||||
try:
|
||||
local_x = event.offset_x
|
||||
local_y = event.offset_y
|
||||
except AttributeError:
|
||||
# If offset attribute doesn't exist, use event coordinates
|
||||
local_x = event.x if hasattr(event, 'x') else 0
|
||||
local_y = event.y if hasattr(event, 'y') else 0
|
||||
|
||||
# Render format:
|
||||
# Row 0: ┌────────────────────────────────┐ (top border)
|
||||
# Rows 1-16: │██ ██ ... │ (pixel rows, 16 pixels per row, 2 chars per pixel)
|
||||
# Row 17: └────────────────────────────────┘ (bottom border)
|
||||
# Column positions: 0 is left border │, 1-32 is pixel area (2 chars per pixel), 33 is right border │
|
||||
|
||||
# Check if within pixel grid area (y from 1 to 16, since row 0 is top border)
|
||||
if local_y >= 1 and local_y <= 16:
|
||||
grid_y = local_y - 1
|
||||
# Check if x is within valid range (1 to 32, since there are borders on both sides)
|
||||
if local_x >= 1 and local_x <= 32:
|
||||
# Each pixel takes 2 chars, (1,2) is column 0, (3,4) is column 1, etc.
|
||||
grid_x = (local_x - 1) // 2
|
||||
if 0 <= grid_x < 16 and 0 <= grid_y < 16:
|
||||
self.post_message(self.PixelClick(grid_x, grid_y))
|
||||
|
||||
|
||||
class LanguageSelectApp(App):
|
||||
"""Language selection screen"""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#lang-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#lang-title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
padding: 2 0;
|
||||
}
|
||||
|
||||
#lang-buttons {
|
||||
width: auto;
|
||||
height: auto;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
Button {
|
||||
margin: 1 2;
|
||||
min-width: 20;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.selected_language = 'zh' # Default to Chinese
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="lang-container"):
|
||||
yield Label("选择语言 / Select Language", id="lang-title")
|
||||
with Horizontal(id="lang-buttons"):
|
||||
yield Button("中文 (Chinese)", id="btn-chinese", variant="primary")
|
||||
yield Button("English", id="btn-english", variant="success")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
if event.button.id == "btn-chinese":
|
||||
self.selected_language = 'zh'
|
||||
self.exit('zh')
|
||||
elif event.button.id == "btn-english":
|
||||
self.selected_language = 'en'
|
||||
self.exit('en')
|
||||
|
||||
|
||||
class LogoApp(App):
|
||||
"""Logo conversion TUI application"""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
#threshold-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#threshold-value {
|
||||
text-align: center;
|
||||
padding: 0 1;
|
||||
width: 20;
|
||||
}
|
||||
|
||||
#button-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
content-align: center middle;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#status {
|
||||
text-align: center;
|
||||
padding: 1 0;
|
||||
text-style: italic;
|
||||
}
|
||||
|
||||
#help-text {
|
||||
text-align: center;
|
||||
padding: 1 0;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit"),
|
||||
Binding("d", "export", "Export"),
|
||||
Binding("r", "reset", "Reset"),
|
||||
Binding("i", "invert", "Invert"),
|
||||
Binding("up", "threshold_up", "Threshold +10"),
|
||||
Binding("down", "threshold_down", "Threshold -10"),
|
||||
Binding("left", "threshold_down_small", "Threshold -1"),
|
||||
Binding("right", "threshold_up_small", "Threshold +1"),
|
||||
]
|
||||
|
||||
def __init__(self, image_path=None, language='zh'):
|
||||
super().__init__()
|
||||
self.image_path = image_path
|
||||
self.language = language
|
||||
self.lang = LANGUAGES[language]
|
||||
self.original_binary = None
|
||||
self.current_binary = None
|
||||
self.threshold = 128
|
||||
self.has_image = image_path is not None
|
||||
|
||||
# Update bindings with selected language
|
||||
self.BINDINGS = [
|
||||
Binding("q", "quit", self.lang['binding_quit']),
|
||||
Binding("d", "export", self.lang['binding_export']),
|
||||
Binding("r", "reset", self.lang['binding_reset']),
|
||||
Binding("i", "invert", self.lang['binding_invert']),
|
||||
Binding("up", "threshold_up", self.lang['binding_threshold_up']),
|
||||
Binding("down", "threshold_down", self.lang['binding_threshold_down']),
|
||||
Binding("left", "threshold_down_small", self.lang['binding_threshold_down_small']),
|
||||
Binding("right", "threshold_up_small", self.lang['binding_threshold_up_small']),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Container(id="main-container"):
|
||||
if self.has_image:
|
||||
yield Label(self.lang['app_title'], id="title")
|
||||
# Contrast control area: [Contrast-] Current value [Contrast+]
|
||||
with Horizontal(id="threshold-container"):
|
||||
yield Button(self.lang['contrast_minus'], id="threshold-minus-btn", variant="primary")
|
||||
yield Label(self.lang['current_contrast'].format(self.threshold), id="threshold-value-label")
|
||||
yield Button(self.lang['contrast_plus'], id="threshold-plus-btn", variant="primary")
|
||||
else:
|
||||
yield Label(self.lang['app_title_editor'], id="title")
|
||||
yield PixelGrid(id="pixel-grid")
|
||||
yield Label(self.lang['click_hint'], id="click-hint")
|
||||
# Action button area: Invert | Reset | Export | Quit
|
||||
with Horizontal(id="button-container"):
|
||||
yield Button(self.lang['btn_invert'], id="invert-btn", variant="default")
|
||||
yield Button(self.lang['btn_reset'], id="reset-btn", variant="warning")
|
||||
yield Button(self.lang['btn_export'], id="export-btn", variant="success")
|
||||
yield Button(self.lang['btn_quit'], id="quit-btn", variant="error")
|
||||
yield Label("", id="status")
|
||||
if self.has_image:
|
||||
yield Label(self.lang['help_text_with_image'], id="help-text")
|
||||
else:
|
||||
yield Label(self.lang['help_text_editor'], id="help-text")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
"""When app starts"""
|
||||
self.pixel_grid = self.query_one("#pixel-grid", PixelGrid)
|
||||
self.threshold_value_label = self.query_one("#threshold-value-label", Label) if self.has_image else None
|
||||
self.status = self.query_one("#status", Label)
|
||||
|
||||
if self.has_image:
|
||||
self.load_image(self.image_path)
|
||||
else:
|
||||
self.current_binary = create_blank_image()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_blank_canvas'])
|
||||
|
||||
def load_image(self, path):
|
||||
"""Load image"""
|
||||
self.original_binary = process_image(path, self.threshold)
|
||||
if self.original_binary is not None:
|
||||
self.current_binary = self.original_binary.copy()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_loaded'].format(path))
|
||||
else:
|
||||
self.status.update(self.lang['status_load_failed'].format(path))
|
||||
|
||||
def update_threshold(self, new_value: int):
|
||||
"""Update threshold"""
|
||||
new_value = max(0, min(255, new_value))
|
||||
if new_value != self.threshold:
|
||||
self.threshold = new_value
|
||||
if self.threshold_value_label:
|
||||
self.threshold_value_label.update(self.lang['current_contrast'].format(self.threshold))
|
||||
if self.original_binary is not None:
|
||||
self.current_binary = process_image(self.image_path, self.threshold)
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_contrast'].format(self.threshold))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
"""Button press event"""
|
||||
button_id = event.button.id
|
||||
|
||||
if button_id == "invert-btn":
|
||||
self.action_invert()
|
||||
elif button_id == "reset-btn":
|
||||
self.action_reset()
|
||||
elif button_id == "export-btn":
|
||||
self.action_export()
|
||||
elif button_id == "quit-btn":
|
||||
self.action_quit()
|
||||
elif button_id == "threshold-plus-btn":
|
||||
self.action_threshold_up()
|
||||
elif button_id == "threshold-minus-btn":
|
||||
self.action_threshold_down()
|
||||
|
||||
def on_pixel_grid_pixel_click(self, event: PixelGrid.PixelClick):
|
||||
"""Pixel click event"""
|
||||
if self.current_binary is not None:
|
||||
# Toggle pixel value
|
||||
self.current_binary[event.y][event.x] = 1 - self.current_binary[event.y][event.x]
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_pixel_toggled'].format(event.x, event.y))
|
||||
|
||||
def action_invert(self):
|
||||
"""Invert all pixels"""
|
||||
if self.current_binary is not None:
|
||||
self.current_binary = 1 - self.current_binary
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_inverted'])
|
||||
|
||||
def action_reset(self):
|
||||
"""Reset to original image or clear"""
|
||||
if self.has_image and self.original_binary is not None:
|
||||
self.current_binary = self.original_binary.copy()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_reset_image'])
|
||||
else:
|
||||
self.current_binary = create_blank_image()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_reset_canvas'])
|
||||
|
||||
def action_export(self):
|
||||
"""Export logo.bin"""
|
||||
if self.current_binary is None:
|
||||
self.status.update(self.lang['status_export_error'])
|
||||
return
|
||||
|
||||
data = binary_to_bytes(self.current_binary)
|
||||
|
||||
with open("logo.bin", "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
self.status.update(self.lang['status_exported'])
|
||||
|
||||
def action_threshold_up(self):
|
||||
"""Increase threshold by 10"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold + 10)
|
||||
|
||||
def action_threshold_down(self):
|
||||
"""Decrease threshold by 10"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold - 10)
|
||||
|
||||
def action_threshold_up_small(self):
|
||||
"""Increase threshold by 1"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold + 1)
|
||||
|
||||
def action_threshold_down_small(self):
|
||||
"""Decrease threshold by 1"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold - 1)
|
||||
|
||||
def action_quit(self):
|
||||
self.exit()
|
||||
|
||||
|
||||
def main():
|
||||
image_path = None
|
||||
|
||||
# First, show language selection screen
|
||||
lang_app = LanguageSelectApp()
|
||||
selected_language = lang_app.run()
|
||||
|
||||
if len(sys.argv) >= 2:
|
||||
image_path = sys.argv[1]
|
||||
if not os.path.exists(image_path):
|
||||
print(f"Error: File not found - {image_path}")
|
||||
return
|
||||
|
||||
app = LogoApp(image_path, language=selected_language)
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
tools/logo_generator/readme.md
Normal file
39
tools/logo_generator/readme.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# NanoKVM Logo Generator
|
||||
|
||||
**Compatible Hardware:** NanoKVM-Cube, NanoKVM-PCIe
|
||||
|
||||
🛠️ **Tool Description:**
|
||||
This is a logo generator for the NanoKVM OLED display. You can use it to create a `logo.bin` file and place it in the `/boot` directory of your NanoKVM system.
|
||||
|
||||
📋 **Instructions:**
|
||||
|
||||
1. **Install required libraries:**
|
||||
```bash
|
||||
pip install Pillow numpy textual
|
||||
```
|
||||
|
||||
2. **Prepare your logo image:**
|
||||
Make sure it's as square as possible for best results 🖼️
|
||||
|
||||
3. **Run the script:**
|
||||
```bash
|
||||
python logo_generator.py path/to/your_logo.png
|
||||
```
|
||||
|
||||
4. **Choose your language:**
|
||||
Select either English or Chinese
|
||||
|
||||
5. **Adjust the contrast:**
|
||||
Fine-tune the contrast based on the preview below until you're happy with how it looks ✨
|
||||
|
||||
6. **Fine-tune pixel by pixel:**
|
||||
Click on individual pixels to adjust details. If you prefer an inverted display, just click the invert button 🔄
|
||||
|
||||
7. **Export and deploy:**
|
||||
Export the `logo.bin` file and copy it to the `/boot` directory of your NanoKVM system. You can do this by:
|
||||
* Enabling SSH and using SCP to copy the file 💻
|
||||
* Or directly placing it in the boot partition of your TF card 💾
|
||||
|
||||
8. **Reboot and enjoy:**
|
||||
Make sure your NanoKVM application version is greater than 2.3.6 ✅
|
||||
Reboot your NanoKVM, and you should see your custom logo on the OLED display! 🎉
|
||||
Reference in New Issue
Block a user