Files
NanoKVM-MIRROR/tools/logo_generator/logo_generator.py

606 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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': '💾 导出',
'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 文件',
'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',
'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 file',
'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)
def binary_to_image(binary_data, size=64):
"""
Convert 16x16 binary data to 64x64 RGBA image
Args:
binary_data: 16x16 numpy array of binary values
size: Size to scale the image to (default 64)
Returns:
PIL Image in RGBA mode
"""
# Create 16x16 RGBA image from binary data
img_16 = Image.new('RGBA', (16, 16), (0, 0, 0, 255)) # Black for 1
pixels = img_16.load()
for y in range(16):
for x in range(16):
if binary_data[y][x] == 1:
pixels[x, y] = (255, 255, 255, 255) # White
else:
pixels[x, y] = (0, 0, 0, 255) # Black
# Scale to 64x64 using NEAREST for pixel-perfect scaling
img_64 = img_16.resize((size, size), Image.Resampling.NEAREST)
return img_64
def image_to_ico(image_path=None, binary_data=None, output_path="logo.ico", size=64):
"""
Convert image or binary data to 64x64 32-bit ICO file
Args:
image_path: Path to the source image (optional if binary_data is provided)
binary_data: 16x16 numpy array of binary values (optional if image_path is provided)
output_path: Output ICO file path
size: Size to scale the icon to (default 64)
"""
try:
if image_path and os.path.exists(image_path):
img = Image.open(image_path).convert('RGBA')
# If not square, crop the center region
if img.width != img.height:
min_size = min(img.width, img.height)
left = (img.width - min_size) // 2
top = (img.height - min_size) // 2
img = img.crop((left, top, left + min_size, top + min_size))
# Resize to 64x64 using LANCZOS for high quality
img_64 = img.resize((size, size), Image.Resampling.LANCZOS)
elif binary_data is not None:
# Convert binary data to image and scale
img_64 = binary_to_image(binary_data, size)
else:
print("Error: No image source provided")
return False
except Exception as e:
print(f"Error: Failed to create ICO - {e}")
return False
# Save as ICO with 32-bit color depth
img_64.save(output_path, format='ICO', sizes=[(size, size)])
return True
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 and logo.ico"""
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)
# Export 64x64 32-bit ICO file
if self.has_image and self.image_path:
image_to_ico(self.image_path, "logo.ico", size=64)
else:
# Export from current canvas (16x16 -> 64x64)
image_to_ico(binary_data=self.current_binary, output_path="logo.ico", size=64)
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()