diff --git a/tools/logo_generator/logo_generator.py b/tools/logo_generator/logo_generator.py new file mode 100644 index 0000000..c932422 --- /dev/null +++ b/tools/logo_generator/logo_generator.py @@ -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() diff --git a/tools/logo_generator/readme.md b/tools/logo_generator/readme.md new file mode 100644 index 0000000..bea0f53 --- /dev/null +++ b/tools/logo_generator/readme.md @@ -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! ๐ŸŽ‰ \ No newline at end of file