"""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)) # 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=5, 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 = [] # 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) # --- 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) # 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