mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
606 lines
21 KiB
Python
606 lines
21 KiB
Python
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()
|