"""SAR-only Tkinter viewer for VideoReceiverSFP. Displays high-resolution SAR images and exposes image controls via context menu. The viewer dynamically adjusts its size to match the incoming frame resolution. """ 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: """Viewer for SAR images with dynamic resizing and zero-padding layout.""" def __init__(self, window_title: str = "SFP SAR Viewer", on_sar_param_changed: Optional[Callable] = None, master: Optional[tk.Widget] = None): """Initialize SAR viewer. If master is None, creates a standalone window.""" if master is None: self._root = tk.Tk() self._owns_root = True self._root.title(window_title) self._root.configure(bg="black") else: self._root = master self._owns_root = False self._on_sar_param_changed = on_sar_param_changed # Maximum display size (pixels per side) for rendering SAR preview. # The raw SAR image is kept at full resolution in `_current_pil_image_raw`. self._display_size = 484 # Build UI: Main frame with zero padding main = ttk.Frame(self._root, padding=0) main.pack(fill="both", expand=True) # Canvas for image display: highlightthickness=0 removes the internal gray border self._img_canvas = tk.Canvas(main, bg="black", highlightthickness=0, bd=0) self._img_canvas.pack(fill="both", expand=True, padx=0, pady=0) self._canvas_image_id = None # State for SAR controls self._brightness_var = tk.IntVar(value=0) self._contrast_var = tk.IntVar(value=0) self._save_png_var = tk.BooleanVar(value=False) self._autocontrast = False try: self._context_menu = tk.Menu(self._root, tearoff=0) self._context_menu.add_command(label="Open Controls", command=self._open_controls_window) self._context_menu.add_command(label="Show Metadata", command=self._on_show_metadata) self._img_canvas.bind("", self._on_canvas_right_click) except Exception: pass # Runtime state self._photo = None self._pending_frame = None self._lock = threading.Lock() self._current_pil_image = None self._current_pil_image_raw = None self._params_changed = False self._current_brightness = 0 self._current_contrast = 0 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._running = True self._root.after(self._poll_interval_ms, self._poll) logging.getLogger().info("SfpSarViewer: GUI initialized") # --- control callbacks ------------------------------------------------- def _on_brightness_changed(self, value): try: v = int(float(value)) with self._lock: self._current_brightness = v self._params_changed = True 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)) with self._lock: self._current_contrast = v self._params_changed = True if self._on_sar_param_changed: self._on_sar_param_changed("contrast", None, v) except Exception: pass def _on_autocontrast(self): # Toggle local autocontrast state and notify upstream try: with self._lock: self._autocontrast = not self._autocontrast self._params_changed = True if self._on_sar_param_changed: self._on_sar_param_changed("autocontrast", None, bool(self._autocontrast)) except Exception: pass def _on_save_current_image(self): """Save the current equalized SAR image to file.""" try: with self._lock: raw = self._current_pil_image_raw.copy() if self._current_pil_image_raw is not None else None if raw is None: return pil = self._apply_brightness_contrast(raw.copy()) ts = time.strftime('%Y%m%d_%H%M%S') dumps_dir = os.path.join(os.getcwd(), 'dumps') os.makedirs(dumps_dir, exist_ok=True) filepath = os.path.join(dumps_dir, f'SAR_equalized_{ts}.png') pil.save(filepath) logging.info("SAR: saved equalized image to %s", filepath) except Exception: logging.exception("SAR: failed to save current image") def _on_save_png_toggled(self): val = bool(self._save_png_var.get()) if self._on_sar_param_changed: self._on_sar_param_changed("save_png", None, val) def _on_canvas_right_click(self, event): try: if hasattr(self, '_context_menu') and self._context_menu: self._context_menu.tk_popup(event.x_root, event.y_root) except Exception: pass def set_display_size(self, size: int) -> None: """Set maximum side length (pixels) for displayed SAR preview images. The raw image remains stored at full resolution; only the displayed copy is scaled. """ try: s = int(size) if s < 16: s = 16 with self._lock: self._display_size = s self._params_changed = True except Exception: pass def _open_controls_window(self): """Open a Toplevel window with SAR controls.""" try: win = tk.Toplevel(self._root) win.title('SAR Controls') frm = ttk.Frame(win, padding=8) frm.pack(fill='both', expand=True) ttk.Label(frm, text="Brightness").grid(row=0, column=0, sticky="w") b_slider = ttk.Scale(frm, 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)) ttk.Label(frm, text="Contrast").grid(row=2, column=0, sticky="w") c_slider = ttk.Scale(frm, 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)) ttk.Button(frm, text="AutoContrast", command=self._on_autocontrast).grid(row=4, column=0, sticky="ew", pady=4) ttk.Button(frm, text="Show Metadata", command=self._on_show_metadata).grid(row=5, column=0, sticky="ew", pady=4) ttk.Button(frm, text="Save Image", command=self._on_save_current_image).grid(row=6, column=0, sticky="ew", pady=4) ttk.Checkbutton(frm, text="Save .png", variable=self._save_png_var, command=self._on_save_png_toggled).grid(row=7, column=0, sticky="w") frm.columnconfigure(0, weight=1) win.transient(self._root) win.lift() except Exception: pass def _on_show_metadata(self): try: if self._meta_window is None or not self._meta_window.winfo_exists(): 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) self._meta_text.config(state='normal') self._meta_text.delete('1.0', tk.END) self._meta_text.insert('1.0', self._last_metadata if self._last_metadata else '') self._meta_text.config(state='disabled') self._meta_window.lift() except Exception: pass def show_metadata(self, metadata_str: str) -> None: self._last_metadata = metadata_str if self._meta_text and self._meta_text.winfo_exists(): try: self._meta_text.config(state='normal') self._meta_text.delete('1.0', tk.END) self._meta_text.insert('1.0', metadata_str or '') self._meta_text.config(state='disabled') 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): if Image is None or ImageEnhance is None: return img try: # Apply autocontrast first if enabled try: if self._autocontrast and ImageOps is not None: img = ImageOps.autocontrast(img) except Exception: pass with self._lock: bf = max(0.0, 1.0 + (self._current_brightness / 100.0)) cf = max(0.0, 1.0 + (self._current_contrast / 100.0)) if bf != 1.0: img = ImageEnhance.Brightness(img).enhance(bf) if cf != 1.0: img = ImageEnhance.Contrast(img).enhance(cf) return img except Exception: return img def _build_tk_image(self, frame: Any): """Build PhotoImage and store raw copies for adjustments.""" 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('RGB') else: return None with self._lock: self._current_pil_image_raw = img.copy() # Apply processing disp_img = self._apply_brightness_contrast(img.copy()) with self._lock: self._current_pil_image = disp_img.copy() # Resize a copy for display if requested so the canvas/control does not grow display_copy = disp_img.copy() try: max_side = int(self._display_size) if self._display_size else None except Exception: max_side = None if max_side and (display_copy.width > max_side or display_copy.height > max_side): try: resample = getattr(Image, 'LANCZOS', 1) display_copy.thumbnail((max_side, max_side), resample) except Exception: try: display_copy.thumbnail((max_side, max_side)) except Exception: pass return ImageTk.PhotoImage(display_copy, master=self._root) except Exception: logging.exception("SfpSarViewer: failed to build tk image") return None def _draw_image_on_canvas(self, photo_image) -> None: """Draw image on canvas and adjust canvas size to match image resolution.""" try: self._last_photoimage = photo_image w, h = photo_image.width(), photo_image.height() # Sync canvas size with image size self._img_canvas.config(width=w, height=h) if self._canvas_image_id is None: self._canvas_image_id = self._img_canvas.create_image( 0, 0, image=self._last_photoimage, anchor='nw' ) else: self._img_canvas.itemconfigure(self._canvas_image_id, image=self._last_photoimage) self._img_canvas.coords(self._canvas_image_id, 0, 0) except Exception: pass def _poll(self): try: frame = None params_changed = False with self._lock: if self._pending_frame is not None: frame = self._pending_frame self._pending_frame = None params_changed = self._params_changed self._params_changed = False if frame is not None: tkimg = self._build_tk_image(frame) if tkimg is not None: self._photo = tkimg self._draw_image_on_canvas(self._photo) elif params_changed: self._refresh_current_image() except Exception: pass finally: try: self._root.after(self._poll_interval_ms, self._poll) except Exception: pass def run(self): self._running = True try: if self._owns_root: self._root.mainloop() else: while self._running: time.sleep(0.1) finally: self._running = False def _refresh_current_image(self) -> None: """Re-apply adjustments to the current raw image and update display.""" try: if Image is None or ImageTk is None: return with self._lock: raw = self._current_pil_image_raw.copy() if self._current_pil_image_raw is not None else None if raw is None: return pil = self._apply_brightness_contrast(raw.copy()) # Resize a display copy so the shown image respects _display_size display_copy = pil.copy() try: max_side = int(self._display_size) if self._display_size else None except Exception: max_side = None if max_side and (display_copy.width > max_side or display_copy.height > max_side): try: resample = getattr(Image, 'LANCZOS', 1) display_copy.thumbnail((max_side, max_side), resample) except Exception: try: display_copy.thumbnail((max_side, max_side)) except Exception: pass tkimg = ImageTk.PhotoImage(display_copy, master=self._root) with self._lock: self._photo = tkimg self._draw_image_on_canvas(self._photo) except Exception: logging.exception("SfpSarViewer: _refresh_current_image failed") def stop(self): self._running = False try: if self._owns_root: self._root.quit() self._root.destroy() except Exception: pass