SXXXXXXX_ControlPanel/VideoReceiverSFP/gui/viewer_with_params.py
2026-01-16 15:42:22 +01:00

502 lines
20 KiB
Python

"""Tkinter viewer window for VideoReceiverSFP with MFD parameter controls."""
import threading
import time
import logging
import tkinter as tk
from tkinter import ttk, colorchooser
from typing import Any, Optional, Callable
import os
try:
from PIL import Image, ImageTk, ImageOps
except Exception:
Image = ImageTk = ImageOps = None
# Import MFD state if available
try:
from VideoReceiverSFP.core.mfd_state import MfdState
except ImportError:
import sys
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
try:
from core.mfd_state import MfdState
except ImportError:
MfdState = None
class SfpViewerWithParams:
"""Tkinter viewer with MFD parameter controls matching ControlPanel layout."""
def __init__(self,
window_title: str = "SFP Viewer with MFD Params",
on_mfd_param_changed: Optional[Callable] = None,
master: Optional[tk.Widget] = None,
compact: bool = False):
"""Initialize viewer with MFD parameter controls.
Args:
window_title: Window title
on_mfd_param_changed: Optional callback(param_type, name, value) when params change
"""
# If a master widget is provided, embed UI inside it; otherwise create a standalone Tk.
if master is None:
self._root = tk.Tk()
self._owns_root = True
self._root.title(window_title)
self._root.geometry("900x600")
else:
self._root = master
self._owns_root = False
# Callback for parameter changes
self._on_mfd_param_changed = on_mfd_param_changed
# Compact mode: hide controls by default and expose via right-click
self._compact = bool(compact)
# MFD state manager (local instance)
if MfdState:
self._mfd_state = MfdState()
else:
self._mfd_state = None
logging.warning("MfdState not available; params controls disabled")
# Build UI
self._build_ui()
# Image display
self._photo = None
self._pending_frame = None
self._lock = threading.Lock()
self._running = False
# FPS tracking
self._frame_times = []
self._first_frame_received = False
self._default_size = 484 # Default MFD size
# Schedule GUI polling
self._poll_interval_ms = 100
self._running = True
self._root.after(self._poll_interval_ms, self._poll)
try:
logging.getLogger().info("SfpViewerWithParams: GUI initialized")
# Raise window briefly only if we own the root
if self._owns_root:
try:
self._root.lift()
self._root.attributes("-topmost", True)
self._root.update()
def _unset_topmost():
try:
self._root.attributes("-topmost", False)
except Exception:
pass
self._root.after(200, _unset_topmost)
except Exception:
pass
except Exception:
pass
def _build_ui(self):
"""Build main UI: image on left, MFD parameters on right."""
# Main container
main_frame = ttk.Frame(self._root)
main_frame.pack(fill="both", expand=True, padx=5, pady=5)
# Left: Image display - fixed 484x484 initially
image_frame = ttk.Frame(main_frame)
image_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5))
self._image_frame = image_frame
# Use Canvas to ensure fixed size display
self._img_canvas = tk.Canvas(image_frame, width=484, height=484, bg="black", highlightthickness=0)
self._img_canvas.pack(fill="both", expand=True)
self._img_label = tk.Label(self._img_canvas, bg="black")
self._img_canvas.create_window(242, 242, window=self._img_label)
# Right: MFD Parameters (hidden in compact mode)
if not self._compact and self._mfd_state:
params_frame = self._build_mfd_params_frame(main_frame)
params_frame.grid(row=0, column=1, sticky="nsew")
else:
# Create a context menu on the image canvas to open controls when compact
try:
self._context_menu = tk.Menu(self._root, tearoff=0)
self._context_menu.add_command(label="Open Controls", command=self._open_controls_window)
self._img_canvas.bind("<Button-3>", self._on_canvas_right_click)
except Exception:
pass
# Bottom: Status bar
status_frame = ttk.Frame(main_frame, relief="sunken")
status_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0))
self._fps_label = ttk.Label(status_frame, text="FPS: 0.00")
self._fps_label.pack(side="left", padx=5)
# Grid weights
main_frame.rowconfigure(0, weight=1)
main_frame.rowconfigure(1, weight=0)
main_frame.columnconfigure(0, weight=3)
main_frame.columnconfigure(1, weight=1)
def _build_mfd_params_frame(self, parent) -> ttk.Frame:
"""Build MFD parameters control panel matching ControlPanel layout."""
params_frame = ttk.Labelframe(parent, text="MFD Parameters", padding=10)
# Category order matching ControlPanel
categories = ["Occlusion", "Cat A", "Cat B", "Cat C", "Cat C1", "Cat C2", "Cat C3"]
# Store widgets for updates
self._intensity_sliders = {}
self._color_buttons = {}
self._color_indicators = {}
row = 0
for cat_name in categories:
# Label
label = ttk.Label(params_frame, text=cat_name, width=10)
label.grid(row=row, column=0, sticky="w", pady=2)
# Intensity slider
intensity_var = tk.IntVar(value=255)
slider = ttk.Scale(
params_frame,
from_=0, to=255,
orient="horizontal",
variable=intensity_var,
command=lambda val, name=cat_name: self._on_intensity_changed(name, val)
)
slider.grid(row=row, column=1, sticky="ew", padx=5, pady=2)
self._intensity_sliders[cat_name] = (slider, intensity_var)
# Color button
color_btn = ttk.Button(
params_frame,
text="Color",
width=6,
command=lambda name=cat_name: self._on_color_button_clicked(name)
)
color_btn.grid(row=row, column=2, padx=2, pady=2)
self._color_buttons[cat_name] = color_btn
# Color indicator (shows current color)
color_indicator = tk.Label(params_frame, width=3, bg="#FFFFFF", relief="sunken")
color_indicator.grid(row=row, column=3, padx=2, pady=2)
self._color_indicators[cat_name] = color_indicator
row += 1
# Raw Map intensity
raw_label = ttk.Label(params_frame, text="Raw Map", width=10)
raw_label.grid(row=row, column=0, sticky="w", pady=2)
raw_var = tk.IntVar(value=255)
raw_slider = ttk.Scale(
params_frame,
from_=0, to=255,
orient="horizontal",
variable=raw_var,
command=lambda val: self._on_raw_map_changed(val)
)
raw_slider.grid(row=row, column=1, columnspan=3, sticky="ew", padx=5, pady=2)
self._raw_map_slider = (raw_slider, raw_var)
# Configure column weights
params_frame.columnconfigure(1, weight=1)
# Initialize color indicators from state
self._update_color_indicators()
# Save flags: placed inside MFD Parameters frame for convenience
# Provide a small English legend and stack the two checkboxes aligned left
legend_text = (
"Save options: Save received frames (.png) and raw payloads (.bin) "
"to the 'dumps' folder. Rotation keeps the last N files."
)
legend = ttk.Label(params_frame, text=legend_text, wraplength=220)
params_frame.rowconfigure(row+1, weight=0)
legend.grid(row=row+1, column=0, columnspan=4, sticky='w', pady=(8,2))
self._save_png_var = tk.BooleanVar(value=False)
self._save_bin_var = tk.BooleanVar(value=False)
save_png_cb = ttk.Checkbutton(params_frame, text="Save .png", variable=self._save_png_var, command=self._on_save_png_toggled)
save_bin_cb = ttk.Checkbutton(params_frame, text="Save .bin", variable=self._save_bin_var, command=self._on_save_bin_toggled)
# stack vertically, left-aligned
save_png_cb.grid(row=row+2, column=0, columnspan=4, sticky='w', pady=(2,2))
save_bin_cb.grid(row=row+3, column=0, columnspan=4, sticky='w', pady=(2,6))
return params_frame
def _update_color_indicators(self):
"""Update color indicator labels to reflect current MFD state colors."""
if not self._mfd_state:
return
for cat_name, indicator in self._color_indicators.items():
try:
cat_data = self._mfd_state.mfd_params["categories"].get(cat_name)
if cat_data:
bgr = cat_data["color"]
# Convert BGR to hex for Tkinter
hex_color = f"#{bgr[2]:02x}{bgr[1]:02x}{bgr[0]:02x}"
indicator.config(bg=hex_color)
except Exception as e:
logging.exception(f"Error updating color indicator for {cat_name}")
def _on_intensity_changed(self, category_name: str, value):
"""Handle intensity slider change."""
try:
int_val = int(float(value))
logging.info(f"MFD intensity changed: {category_name} = {int_val}")
if self._mfd_state:
self._mfd_state.update_category_intensity(category_name, int_val)
# Notify external callback
if self._on_mfd_param_changed:
self._on_mfd_param_changed("intensity", category_name, int_val)
except Exception as e:
logging.exception(f"Error updating intensity for {category_name}")
def _on_color_button_clicked(self, category_name: str):
"""Handle color button click - open color chooser."""
try:
# Get current color from state
current_bgr = (255, 255, 255)
if self._mfd_state:
cat_data = self._mfd_state.mfd_params["categories"].get(category_name)
if cat_data:
current_bgr = cat_data["color"]
# Convert BGR to RGB for colorchooser
current_rgb = (current_bgr[2], current_bgr[1], current_bgr[0])
# Open color picker
color = colorchooser.askcolor(
title=f"Choose color for {category_name}",
initialcolor=current_rgb
)
if color and color[0]: # color is ((r,g,b), "#rrggbb")
rgb = color[0]
# Convert RGB to BGR
new_bgr = (int(rgb[2]), int(rgb[1]), int(rgb[0]))
logging.info(f"MFD color changed: {category_name} = BGR{new_bgr}")
if self._mfd_state:
self._mfd_state.update_category_color(category_name, new_bgr)
self._update_color_indicators()
# Notify external callback
if self._on_mfd_param_changed:
self._on_mfd_param_changed("color", category_name, new_bgr)
except Exception as e:
logging.exception(f"Error updating color for {category_name}")
def _on_raw_map_changed(self, value):
"""Handle raw map intensity slider change."""
try:
int_val = int(float(value))
logging.info(f"MFD raw map intensity changed: {int_val}")
if self._mfd_state:
self._mfd_state.update_raw_map_intensity(int_val)
# Notify external callback
if self._on_mfd_param_changed:
self._on_mfd_param_changed("raw_map", None, int_val)
except Exception as e:
logging.exception("Error updating raw map intensity")
def get_mfd_lut(self):
"""Return current MFD LUT from state manager."""
if self._mfd_state:
return self._mfd_state.get_lut()
return None
def _on_save_png_toggled(self):
val = bool(self._save_png_var.get())
logging.info(f"UI: Save PNG toggled: {val}")
if self._on_mfd_param_changed:
self._on_mfd_param_changed("save_png", None, val)
def _on_save_bin_toggled(self):
val = bool(self._save_bin_var.get())
logging.info(f"UI: Save BIN toggled: {val}")
if self._on_mfd_param_changed:
self._on_mfd_param_changed("save_bin", None, val)
def _on_canvas_right_click(self, event):
try:
# Show context menu
if hasattr(self, '_context_menu') and self._context_menu:
self._context_menu.tk_popup(event.x_root, event.y_root)
except Exception:
pass
def _open_controls_window(self):
"""Open a Toplevel window containing the MFD parameter controls."""
try:
win = tk.Toplevel(self._root)
win.title("MFD Controls")
# Build controls inside this window using same builder
frame = self._build_mfd_params_frame(win)
frame.pack(fill='both', expand=True, padx=6, pady=6)
win.transient(self._root)
win.lift()
except Exception:
logging.exception("Failed to open MFD controls window")
def show_frame(self, frame: Any) -> None:
"""Queue a frame for display (thread-safe)."""
try:
# Debug: log what type of frame we receive
frame_type = type(frame).__name__
frame_info = f"type={frame_type}"
if hasattr(frame, 'size'):
frame_info += f" size={frame.size}"
elif hasattr(frame, '__len__'):
frame_info += f" len={len(frame)}"
logging.debug(f"show_frame received: {frame_info}")
with self._lock:
self._pending_frame = frame
self._frame_times.append(time.time())
# Trim old timestamps
cutoff = time.time() - 2.0
while self._frame_times and self._frame_times[0] < cutoff:
self._frame_times.pop(0)
except Exception as e:
logging.exception("Error in show_frame")
pass
def _build_tk_image(self, frame: Any):
"""Convert frame to PhotoImage for display."""
try:
if Image is None or ImageTk is None:
return None
# Log what we're trying to convert
frame_type = type(frame).__name__
logging.debug(f"_build_tk_image processing: type={frame_type}")
if hasattr(frame, "copy"):
img = frame.copy()
elif isinstance(frame, (bytes, bytearray)):
import io
logging.debug(f"_build_tk_image: converting bytes/bytearray (len={len(frame)}) to PIL Image")
img = Image.open(io.BytesIO(frame)).convert("RGB")
else:
logging.warning(f"_build_tk_image: unknown frame type {frame_type}")
return None
# Check first frame size and resize widget if needed
if not self._first_frame_received:
self._first_frame_received = True
if img.width != self._default_size or img.height != self._default_size:
try:
# Resize canvas to accommodate different image size
self._img_canvas.configure(width=img.width, height=img.height)
self._img_canvas.coords(self._img_label, img.width // 2, img.height // 2)
logging.info(f"MFD viewer: resized to {img.width}x{img.height} (first frame differs from default {self._default_size}x{self._default_size})")
except Exception:
pass
else:
logging.info(f"MFD viewer: first frame is {img.width}x{img.height}, keeping default size")
# Log stats and apply autocontrast if needed
try:
import numpy as np
if img.mode == 'RGB':
arr = np.asarray(img.convert('L'))
else:
arr = np.asarray(img)
vmin = int(arr.min())
vmax = int(arr.max())
logging.debug(f"Frame stats: min={vmin} max={vmax}")
# Apply autocontrast for low-contrast frames
if ImageOps and ((vmax - vmin) < 16 or vmax < 64):
logging.info("Low contrast detected; applying autocontrast")
img = ImageOps.autocontrast(img)
except Exception as e:
logging.exception("Error processing frame stats")
# Save debug copy
try:
dumps_dir = os.path.join(os.getcwd(), 'dumps')
os.makedirs(dumps_dir, exist_ok=True)
debug_path = os.path.join(dumps_dir, 'VideoReceiverSFP_viewer_debug.png')
img.save(debug_path)
except Exception:
pass
return ImageTk.PhotoImage(img)
except Exception as e:
logging.exception("Error building tk image")
return None
def _poll(self):
"""Periodic GUI update: process pending frame and update FPS."""
try:
frame_to_show = None
with self._lock:
if self._pending_frame is not None:
frame_to_show = self._pending_frame
self._pending_frame = None
if frame_to_show is not None:
photo = self._build_tk_image(frame_to_show)
if photo:
self._photo = photo
self._img_label.config(image=self._photo)
# Update FPS
now = time.time()
recent = [t for t in self._frame_times if now - t < 2.0]
if len(recent) >= 2:
elapsed = recent[-1] - recent[0]
if elapsed > 0:
fps = (len(recent) - 1) / elapsed
self._fps_label.config(text=f"FPS: {fps:.2f}")
except Exception as e:
logging.exception("Error in viewer poll")
finally:
try:
self._root.after(self._poll_interval_ms, self._poll)
except Exception:
pass
def run(self):
"""Start the viewer main loop."""
self._running = True
try:
# If this viewer created its own root, run the mainloop; otherwise block
if self._owns_root:
try:
self._root.mainloop()
except KeyboardInterrupt:
pass
else:
# Embedded mode: keep running until stop() is called
while self._running:
time.sleep(0.1)
finally:
self._running = False
def stop(self):
"""Stop the viewer."""
self._running = False
try:
if self._owns_root:
self._root.quit()
self._root.destroy()
except Exception:
pass