293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""SAR-only Tkinter viewer for VideoReceiverSFP.
|
|
|
|
Displays high-resolution SAR images and exposes simple image controls
|
|
(brightness, contrast, autocontrast) and a Save PNG toggle. API mirrors
|
|
the other viewers: `show_frame(frame)`, `run()`, `run_async()`, `stop()`.
|
|
"""
|
|
import threading
|
|
import time
|
|
import logging
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
from typing import Any, Optional, Callable
|
|
import os
|
|
|
|
try:
|
|
from PIL import Image, ImageTk, ImageOps, ImageEnhance
|
|
except Exception:
|
|
Image = ImageTk = ImageOps = ImageEnhance = None
|
|
|
|
|
|
class SfpSarViewer:
|
|
def __init__(self, window_title: str = "SFP SAR Viewer", on_sar_param_changed: Optional[Callable] = None):
|
|
self._root = tk.Tk()
|
|
self._root.title(window_title)
|
|
self._root.geometry("1000x700")
|
|
|
|
self._on_sar_param_changed = on_sar_param_changed
|
|
|
|
# Build UI: image on left, controls on right
|
|
main = ttk.Frame(self._root)
|
|
main.pack(fill="both", expand=True, padx=6, pady=6)
|
|
|
|
# Left image area
|
|
img_frame = ttk.Frame(main)
|
|
img_frame.grid(row=0, column=0, sticky="nsew")
|
|
self._img_label = tk.Label(img_frame, bg="black")
|
|
self._img_label.pack(fill="both", expand=True)
|
|
|
|
# Right controls
|
|
ctrl_frame = ttk.Labelframe(main, text="SAR Controls", padding=8)
|
|
ctrl_frame.grid(row=0, column=1, sticky="nsew", padx=(8,0))
|
|
|
|
# Brightness slider (-100..100)
|
|
ttk.Label(ctrl_frame, text="Brightness").grid(row=0, column=0, sticky="w")
|
|
self._brightness_var = tk.IntVar(value=0)
|
|
b_slider = ttk.Scale(ctrl_frame, from_=-100, to=100, orient="horizontal",
|
|
variable=self._brightness_var, command=self._on_brightness_changed)
|
|
b_slider.grid(row=1, column=0, sticky="ew", pady=(0,6))
|
|
|
|
# Contrast slider (-100..100)
|
|
ttk.Label(ctrl_frame, text="Contrast").grid(row=2, column=0, sticky="w")
|
|
self._contrast_var = tk.IntVar(value=0)
|
|
c_slider = ttk.Scale(ctrl_frame, from_=-100, to=100, orient="horizontal",
|
|
variable=self._contrast_var, command=self._on_contrast_changed)
|
|
c_slider.grid(row=3, column=0, sticky="ew", pady=(0,6))
|
|
|
|
# Autocontrast button
|
|
auto_btn = ttk.Button(ctrl_frame, text="AutoContrast", command=self._on_autocontrast)
|
|
auto_btn.grid(row=4, column=0, sticky="ew", pady=(4,8))
|
|
|
|
# Show metadata button
|
|
meta_btn = ttk.Button(ctrl_frame, text="Show Metadata", command=self._on_show_metadata)
|
|
meta_btn.grid(row=5, column=0, sticky="ew", pady=(4,8))
|
|
|
|
# Save PNG toggle
|
|
self._save_png_var = tk.BooleanVar(value=False)
|
|
save_cb = ttk.Checkbutton(ctrl_frame, text="Save .png", variable=self._save_png_var, command=self._on_save_png_toggled)
|
|
save_cb.grid(row=6, column=0, sticky="w", pady=(2,2))
|
|
|
|
# Status / FPS
|
|
self._fps_label = ttk.Label(ctrl_frame, text="FPS: 0.00")
|
|
self._fps_label.grid(row=6, column=0, sticky="w", pady=(8,0))
|
|
|
|
ctrl_frame.columnconfigure(0, weight=1)
|
|
main.rowconfigure(0, weight=1)
|
|
main.columnconfigure(0, weight=3)
|
|
main.columnconfigure(1, weight=1)
|
|
|
|
# runtime state
|
|
self._photo = None
|
|
self._pending_frame = None
|
|
self._lock = threading.Lock()
|
|
self._running = False
|
|
self._frame_times = []
|
|
self._last_metadata = None
|
|
self._meta_window = None
|
|
self._meta_text = None
|
|
|
|
# polling
|
|
self._poll_interval_ms = 100
|
|
self._root.after(self._poll_interval_ms, self._poll)
|
|
|
|
try:
|
|
logging.getLogger().info("SfpSarViewer: GUI initialized")
|
|
except Exception:
|
|
pass
|
|
|
|
# --- control callbacks -------------------------------------------------
|
|
def _on_brightness_changed(self, value):
|
|
try:
|
|
v = int(float(value))
|
|
logging.debug("SAR brightness=%d", v)
|
|
if self._on_sar_param_changed:
|
|
self._on_sar_param_changed("brightness", None, v)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_contrast_changed(self, value):
|
|
try:
|
|
v = int(float(value))
|
|
logging.debug("SAR contrast=%d", v)
|
|
if self._on_sar_param_changed:
|
|
self._on_sar_param_changed("contrast", None, v)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_autocontrast(self):
|
|
logging.info("SAR autocontrast requested")
|
|
if self._on_sar_param_changed:
|
|
self._on_sar_param_changed("autocontrast", None, True)
|
|
|
|
def _on_save_png_toggled(self):
|
|
val = bool(self._save_png_var.get())
|
|
logging.info("SAR Save PNG toggled: %s", val)
|
|
if self._on_sar_param_changed:
|
|
self._on_sar_param_changed("save_png", None, val)
|
|
|
|
def _on_show_metadata(self):
|
|
try:
|
|
if self._meta_window is None or not getattr(self._meta_window, 'winfo_exists', lambda: False)():
|
|
self._meta_window = tk.Toplevel(self._root)
|
|
self._meta_window.title('SAR Metadata')
|
|
self._meta_text = tk.Text(self._meta_window, wrap='none', width=80, height=30, font=('Courier New', 9))
|
|
self._meta_text.pack(fill='both', expand=True)
|
|
# Insert current metadata
|
|
self._meta_text.config(state='normal')
|
|
self._meta_text.delete('1.0', tk.END)
|
|
if self._last_metadata:
|
|
self._meta_text.insert('1.0', self._last_metadata)
|
|
else:
|
|
self._meta_text.insert('1.0', '<No SAR metadata received yet>')
|
|
self._meta_text.config(state='disabled')
|
|
self._meta_window.lift()
|
|
except Exception:
|
|
pass
|
|
|
|
def show_metadata(self, metadata_str: str) -> None:
|
|
"""Called by the module to update stored metadata and (optionally) update the window."""
|
|
try:
|
|
self._last_metadata = metadata_str
|
|
if self._meta_text is not None and getattr(self._meta_text, 'config', None):
|
|
try:
|
|
self._meta_text.config(state='normal')
|
|
self._meta_text.delete('1.0', tk.END)
|
|
self._meta_text.insert('1.0', metadata_str or '<No metadata>')
|
|
self._meta_text.config(state='disabled')
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# --- frame handling ----------------------------------------------------
|
|
def show_frame(self, frame: Any) -> None:
|
|
try:
|
|
with self._lock:
|
|
self._pending_frame = frame
|
|
self._frame_times.append(time.time())
|
|
cutoff = time.time() - 2.0
|
|
while self._frame_times and self._frame_times[0] < cutoff:
|
|
self._frame_times.pop(0)
|
|
except Exception:
|
|
pass
|
|
|
|
def _apply_brightness_contrast(self, img: 'Image.Image') -> 'Image.Image':
|
|
try:
|
|
if Image is None:
|
|
return img
|
|
# Brightness: -100..100 -> factor 0.0..2.0 (0 black, 1 original, 2 double)
|
|
b = self._brightness_var.get()
|
|
bf = max(0.0, 1.0 + (b / 100.0))
|
|
# Contrast: -100..100 -> factor 0.0..2.0
|
|
c = self._contrast_var.get()
|
|
cf = max(0.0, 1.0 + (c / 100.0))
|
|
|
|
try:
|
|
if bf != 1.0:
|
|
img = ImageEnhance.Brightness(img).enhance(bf)
|
|
if cf != 1.0:
|
|
img = ImageEnhance.Contrast(img).enhance(cf)
|
|
except Exception:
|
|
logging.exception("Error applying brightness/contrast")
|
|
return img
|
|
except Exception:
|
|
return img
|
|
|
|
def _build_tk_image(self, frame: Any):
|
|
try:
|
|
if Image is None or ImageTk is None:
|
|
return None
|
|
|
|
if hasattr(frame, 'copy'):
|
|
img = frame.copy()
|
|
elif isinstance(frame, (bytes, bytearray)):
|
|
import io
|
|
img = Image.open(io.BytesIO(frame)).convert('L')
|
|
else:
|
|
return None
|
|
|
|
# Convert grayscale to RGB for display
|
|
if img.mode == 'L':
|
|
img = img.convert('RGB')
|
|
|
|
# Apply brightness/contrast
|
|
img = self._apply_brightness_contrast(img)
|
|
|
|
# Resize to fit the label widget while preserving aspect ratio.
|
|
try:
|
|
w = self._img_label.winfo_width()
|
|
h = self._img_label.winfo_height()
|
|
# If widget not yet laid out, winfo_ reports 1; use geometry fallback
|
|
if w > 2 and h > 2:
|
|
max_size = (max(1, w), max(1, h))
|
|
# Use a copy to avoid modifying original image object
|
|
img = img.copy()
|
|
img.thumbnail(max_size, Image.LANCZOS)
|
|
except Exception:
|
|
pass
|
|
|
|
# Save a 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_sar_last.png')
|
|
img.save(debug_path)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
return ImageTk.PhotoImage(img, master=self._root)
|
|
except Exception:
|
|
# Fallback to default behavior
|
|
return ImageTk.PhotoImage(img)
|
|
except Exception:
|
|
logging.exception("SfpSarViewer: failed to build tk image")
|
|
return None
|
|
|
|
def _poll(self):
|
|
try:
|
|
frame = None
|
|
with self._lock:
|
|
if self._pending_frame is not None:
|
|
frame = self._pending_frame
|
|
self._pending_frame = None
|
|
|
|
if frame is not None:
|
|
tkimg = self._build_tk_image(frame)
|
|
if tkimg is not None:
|
|
self._photo = tkimg
|
|
self._img_label.config(image=self._photo)
|
|
|
|
# update FPS
|
|
now = time.time()
|
|
cutoff = now - 1.0
|
|
fps = len([t for t in self._frame_times if t >= cutoff])
|
|
self._fps_label.config(text=f"FPS: {fps}")
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
self._root.after(self._poll_interval_ms, self._poll)
|
|
except Exception:
|
|
pass
|
|
|
|
def run(self):
|
|
self._running = True
|
|
try:
|
|
self._root.mainloop()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
self._running = False
|
|
|
|
def run_async(self):
|
|
threading.Thread(target=self.run, daemon=True).start()
|
|
|
|
def stop(self):
|
|
self._running = False
|
|
try:
|
|
self._root.quit()
|
|
self._root.destroy()
|
|
except Exception:
|
|
pass
|