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:
100
src/edit_drowing.py
Normal file
100
src/edit_drowing.py
Normal 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()
|
||||||
|
|
||||||
266
src/main.py
266
src/main.py
@@ -46,12 +46,245 @@ from get_playlists import (
|
|||||||
send_player_error_feedback
|
send_player_error_feedback
|
||||||
)
|
)
|
||||||
from keyboard_widget import KeyboardWidget
|
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
|
# Load the KV file
|
||||||
Builder.load_file('signage_player.kv')
|
Builder.load_file('signage_player.kv')
|
||||||
|
|
||||||
# Removed VLCVideoWidget - using Kivy's built-in Video widget instead
|
# 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
|
# Custom keyboard container with close button
|
||||||
class KeyboardContainer(BoxLayout):
|
class KeyboardContainer(BoxLayout):
|
||||||
def __init__(self, vkeyboard, **kwargs):
|
def __init__(self, vkeyboard, **kwargs):
|
||||||
@@ -1070,6 +1303,39 @@ class SignagePlayer(Widget):
|
|||||||
popup = SettingsPopup(player_instance=self, was_paused=was_paused)
|
popup = SettingsPopup(player_instance=self, was_paused=was_paused)
|
||||||
popup.open()
|
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):
|
def show_exit_popup(self, instance=None):
|
||||||
"""Show exit password popup"""
|
"""Show exit password popup"""
|
||||||
# Pause playback when exit popup opens
|
# Pause playback when exit popup opens
|
||||||
|
|||||||
@@ -261,6 +261,14 @@
|
|||||||
border: (0, 0, 0, 0)
|
border: (0, 0, 0, 0)
|
||||||
on_press: root.toggle_pause()
|
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:
|
Button:
|
||||||
id: settings_btn
|
id: settings_btn
|
||||||
size_hint: None, None
|
size_hint: None, None
|
||||||
|
|||||||
Reference in New Issue
Block a user