Add image editing feature with drawing tools

- Added pencil edit button to player controls
- Created EditPopup with drawing layer for image annotation
- Drawing tools: color selection (red/blue/green/black), thickness control
- Features: undo last stroke, clear all strokes, save edited image
- Playback automatically pauses during editing
- Only images (.jpg, .jpeg, .png, .bmp) can be edited
- Edited images saved with '_edited' suffix in same directory
- Drawing layer with touch support for annotations
- Full toolbar with color, thickness, and action controls
This commit is contained in:
Kiwy Signage Player
2025-12-04 22:40:05 +02:00
parent 07b7e96edd
commit 72d382b96b
3 changed files with 374 additions and 0 deletions

100
src/edit_drowing.py Normal file
View File

@@ -0,0 +1,100 @@
Kiwy drawing
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.image import Image
from kivy.uix.button import Button
from kivy.uix.widget import Widget
from kivy.graphics import Color, Line
from kivy.core.window import Window
class DrawLayer(Widget):
def init(self, **kwargs):
super().init(**kwargs)
self.strokes = [] # store all drawn lines
self.current_color = (1, 0, 0) # default red
self.current_width = 2 # default thickness
self.drawing_enabled = False # drawing toggle
def on_touch_down(self, touch):
if not self.drawing_enabled:
return False
with self.canvas:
Color(*self.current_color)
new_line = Line(points=[touch.x, touch.y], width=self.current_width)
self.strokes.append(new_line)
return True
def on_touch_move(self, touch):
if self.strokes and self.drawing_enabled:
self.strokes[-1].points += [touch.x, touch.y]
# ==========================
# UNDO LAST LINE
# ==========================
def undo(self):
if self.strokes:
last = self.strokes.pop()
self.canvas.remove(last)
# ==========================
# CHANGE COLOR
# ==========================
def set_color(self, color_tuple):
self.current_color = color_tuple
# ==========================
# CHANGE LINE WIDTH
# ==========================
def set_thickness(self, value):
self.current_width = value
class EditorUI(BoxLayout):
def init(self, **kwargs):
super().init(orientation="vertical", **kwargs)
# Background image
self.img = Image(source="graph.png", allow_stretch=True)
self.add_widget(self.img)
# Drawing layer above image
self.draw = DrawLayer()
self.add_widget(self.draw)
# Toolbar
toolbar = BoxLayout(size_hint_y=0.15)
toolbar.add_widget(Button(text="Draw On", on_press=self.toggle_draw))
toolbar.add_widget(Button(text="Red", on_press=lambda x: self.draw.set_color((1,0,0))))
toolbar.add_widget(Button(text="Blue", on_press=lambda x: self.draw.set_color((0,0,1))))
toolbar.add_widget(Button(text="Green", on_press=lambda x: self.draw.set_color((0,1,0))))
toolbar.add_widget(Button(text="Thin", on_press=lambda x: self.draw.set_thickness(2)))
toolbar.add_widget(Button(text="Thick", on_press=lambda x: self.draw.set_thickness(6)))
toolbar.add_widget(Button(text="Undo", on_press=lambda x: self.draw.undo()))
toolbar.add_widget(Button(text="Save", on_press=lambda x: self.save_image()))
self.add_widget(toolbar)
# ==========================
# TOGGLE DRAWING MODE
# ==========================
def toggle_draw(self, btn):
self.draw.drawing_enabled = not self.draw.drawing_enabled
btn.text = "Draw Off" if self.draw.drawing_enabled else "Draw On"
# ==========================
# SAVE MERGED IMAGE
# ==========================
def save_image(self):
self.export_to_png("edited_graph.png")
print("Saved as edited_graph.png")
class AnnotatorApp(App):
def build(self):
return EditorUI()
AnnotatorApp().run()

View File

@@ -46,12 +46,245 @@ from get_playlists import (
send_player_error_feedback
)
from keyboard_widget import KeyboardWidget
from kivy.graphics import Color, Line, Ellipse
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.slider import Slider
# Load the KV file
Builder.load_file('signage_player.kv')
# Removed VLCVideoWidget - using Kivy's built-in Video widget instead
class DrawingLayer(Widget):
"""Layer for drawing on top of images"""
def __init__(self, **kwargs):
super(DrawingLayer, self).__init__(**kwargs)
self.strokes = [] # Store all drawn lines
self.current_color = (1, 0, 0, 1) # Default red
self.current_width = 3 # Default thickness
self.drawing_enabled = True # Drawing always enabled in edit mode
def on_touch_down(self, touch):
if not self.drawing_enabled or not self.collide_point(*touch.pos):
return False
with self.canvas:
Color(*self.current_color)
new_line = Line(points=[touch.x, touch.y], width=self.current_width)
self.strokes.append({'line': new_line, 'color': self.current_color, 'width': self.current_width})
touch.ud['line'] = new_line
return True
def on_touch_move(self, touch):
if 'line' in touch.ud and self.drawing_enabled:
touch.ud['line'].points += [touch.x, touch.y]
return True
def undo(self):
"""Remove the last stroke"""
if self.strokes:
last_stroke = self.strokes.pop()
self.canvas.remove(last_stroke['line'])
Logger.info("DrawingLayer: Undid last stroke")
def clear_all(self):
"""Clear all strokes"""
for stroke in self.strokes:
self.canvas.remove(stroke['line'])
self.strokes = []
Logger.info("DrawingLayer: Cleared all strokes")
def set_color(self, color_tuple):
"""Set drawing color (RGBA)"""
self.current_color = color_tuple
Logger.debug(f"DrawingLayer: Color set to {color_tuple}")
def set_thickness(self, value):
"""Set line thickness"""
self.current_width = value
Logger.debug(f"DrawingLayer: Thickness set to {value}")
class EditPopup(Popup):
"""Popup for editing/annotating images"""
def __init__(self, player_instance, image_path, **kwargs):
super(EditPopup, self).__init__(**kwargs)
self.player = player_instance
self.image_path = image_path
self.drawing_layer = None
# Pause playback
self.was_paused = self.player.is_paused
if not self.was_paused:
self.player.is_paused = True
Clock.unschedule(self.player.next_media)
if self.player.current_widget and isinstance(self.player.current_widget, Video):
self.player.current_widget.state = 'pause'
# Show cursor
try:
Window.show_cursor = True
except:
pass
# Build UI
self.title = 'Edit Image'
self.size_hint = (0.95, 0.95)
self.auto_dismiss = False
# Main layout
main_layout = BoxLayout(orientation='vertical', padding=10, spacing=10)
# Image and drawing area
image_container = FloatLayout()
# Background image
self.image_widget = Image(
source=image_path,
allow_stretch=True,
keep_ratio=True,
size_hint=(1, 1)
)
image_container.add_widget(self.image_widget)
# Drawing layer on top
self.drawing_layer = DrawingLayer(size_hint=(1, 1))
image_container.add_widget(self.drawing_layer)
main_layout.add_widget(image_container)
# Toolbar
toolbar = BoxLayout(size_hint_y=None, height=80, spacing=10, padding=[10, 5])
# Color buttons
color_layout = BoxLayout(orientation='vertical', size_hint_x=0.15, spacing=5)
color_layout.add_widget(Label(text='Color:', size_hint_y=0.3, font_size='12sp'))
color_btns = BoxLayout(spacing=5)
color_btns.add_widget(Button(
text='Red',
background_color=(1, 0, 0, 1),
on_press=lambda x: self.drawing_layer.set_color((1, 0, 0, 1))
))
color_btns.add_widget(Button(
text='Blue',
background_color=(0, 0, 1, 1),
on_press=lambda x: self.drawing_layer.set_color((0, 0, 1, 1))
))
color_btns.add_widget(Button(
text='Green',
background_color=(0, 1, 0, 1),
on_press=lambda x: self.drawing_layer.set_color((0, 1, 0, 1))
))
color_btns.add_widget(Button(
text='Black',
background_color=(0, 0, 0, 1),
on_press=lambda x: self.drawing_layer.set_color((0, 0, 0, 1))
))
color_layout.add_widget(color_btns)
toolbar.add_widget(color_layout)
# Thickness controls
thickness_layout = BoxLayout(orientation='vertical', size_hint_x=0.15, spacing=5)
thickness_layout.add_widget(Label(text='Thickness:', size_hint_y=0.3, font_size='12sp'))
thickness_btns = BoxLayout(spacing=5)
thickness_btns.add_widget(Button(
text='Thin',
on_press=lambda x: self.drawing_layer.set_thickness(2)
))
thickness_btns.add_widget(Button(
text='Medium',
on_press=lambda x: self.drawing_layer.set_thickness(5)
))
thickness_btns.add_widget(Button(
text='Thick',
on_press=lambda x: self.drawing_layer.set_thickness(10)
))
thickness_layout.add_widget(thickness_btns)
toolbar.add_widget(thickness_layout)
# Action buttons
actions_layout = BoxLayout(orientation='vertical', size_hint_x=0.2, spacing=5)
actions_layout.add_widget(Label(text='Actions:', size_hint_y=0.3, font_size='12sp'))
action_btns = BoxLayout(spacing=5)
action_btns.add_widget(Button(
text='Undo',
background_color=(0.9, 0.6, 0.2, 1),
on_press=lambda x: self.drawing_layer.undo()
))
action_btns.add_widget(Button(
text='Clear',
background_color=(0.9, 0.3, 0.2, 1),
on_press=lambda x: self.drawing_layer.clear_all()
))
actions_layout.add_widget(action_btns)
toolbar.add_widget(actions_layout)
# Save and close buttons
final_btns = BoxLayout(orientation='vertical', size_hint_x=0.2, spacing=5)
final_btns.add_widget(Button(
text='Save',
background_color=(0.2, 0.8, 0.2, 1),
font_size='16sp',
on_press=self.save_image
))
final_btns.add_widget(Button(
text='Cancel',
background_color=(0.6, 0.2, 0.2, 1),
font_size='16sp',
on_press=self.close_without_saving
))
toolbar.add_widget(final_btns)
main_layout.add_widget(toolbar)
self.content = main_layout
# Bind to dismiss
self.bind(on_dismiss=self.on_popup_dismiss)
Logger.info(f"EditPopup: Opened for image {os.path.basename(image_path)}")
def save_image(self, instance):
"""Save the edited image"""
try:
# Generate output filename
base_name = os.path.basename(self.image_path)
name, ext = os.path.splitext(base_name)
output_path = os.path.join(
os.path.dirname(self.image_path),
f"{name}_edited{ext}"
)
# Export the entire widget (image + drawings) as PNG
self.content.export_to_png(output_path)
Logger.info(f"EditPopup: Saved edited image to {output_path}")
# Show confirmation
self.title = f'Saved as {os.path.basename(output_path)}'
Clock.schedule_once(lambda dt: self.dismiss(), 1)
except Exception as e:
Logger.error(f"EditPopup: Error saving image: {e}")
self.title = f'Error saving: {e}'
def close_without_saving(self, instance):
"""Close without saving"""
Logger.info("EditPopup: Closed without saving")
self.dismiss()
def on_popup_dismiss(self, *args):
"""Resume playback when popup closes"""
# Resume playback if it wasn't paused before
if not self.was_paused:
self.player.is_paused = False
self.player.play_current_media()
# Restart control hide timer
self.player.schedule_hide_controls()
Logger.info("EditPopup: Dismissed, playback resumed")
# Custom keyboard container with close button
class KeyboardContainer(BoxLayout):
def __init__(self, vkeyboard, **kwargs):
@@ -1070,6 +1303,39 @@ class SignagePlayer(Widget):
popup = SettingsPopup(player_instance=self, was_paused=was_paused)
popup.open()
def show_edit_interface(self, instance=None):
"""Show edit interface for current image"""
# Check if current media is an image
if not self.playlist or self.current_index >= len(self.playlist):
Logger.warning("SignagePlayer: No media to edit")
return
media_item = self.playlist[self.current_index]
file_name = media_item.get('file_name', '')
file_extension = os.path.splitext(file_name)[1].lower()
# Only allow editing images
if file_extension not in ['.jpg', '.jpeg', '.png', '.bmp']:
Logger.warning(f"SignagePlayer: Cannot edit {file_extension} files, only images")
# Show error message briefly
self.ids.status_label.text = 'Can only edit image files'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Get full path to current image
image_path = os.path.join(self.media_dir, file_name)
if not os.path.exists(image_path):
Logger.error(f"SignagePlayer: Image not found: {image_path}")
return
Logger.info(f"SignagePlayer: Opening edit interface for {file_name}")
# Open edit popup
popup = EditPopup(player_instance=self, image_path=image_path)
popup.open()
def show_exit_popup(self, instance=None):
"""Show exit password popup"""
# Pause playback when exit popup opens

View File

@@ -261,6 +261,14 @@
border: (0, 0, 0, 0)
on_press: root.toggle_pause()
Button:
id: edit_btn
size_hint: None, None
size: dp(50), dp(50)
background_normal: root.resources_path + '/pencil.png'
background_down: root.resources_path + '/pencil.png'
border: (0, 0, 0, 0)
on_press: root.show_edit_interface()
Button:
id: settings_btn
size_hint: None, None