"""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, master: Optional[tk.Widget] = None): # Support embedding inside an existing master (Labelframe/frame). If no master provided, # create a standalone Tk root as before. if master is None: self._root = tk.Tk() self._owns_root = True self._root.title(window_title) self._root.geometry("1000x700") else: self._root = master self._owns_root = False 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 - fixed 484x484, user can resize window img_frame = ttk.Frame(main) img_frame.grid(row=0, column=0, sticky="nsew") # Use Canvas to ensure fixed size display; we'll draw images into it self._img_canvas = tk.Canvas(img_frame, width=484, height=484, bg="black", highlightthickness=0) self._img_canvas.pack(fill="both", expand=True) self._canvas_image_id = None # 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 current equalized image button save_img_btn = ttk.Button(ctrl_frame, text="Save Image", command=self._on_save_current_image) save_img_btn.grid(row=6, 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=7, column=0, sticky="w", pady=(2,2)) ctrl_frame.columnconfigure(0, weight=1) main.rowconfigure(0, weight=1) main.rowconfigure(1, weight=0) main.columnconfigure(0, weight=3) main.columnconfigure(1, weight=1) # Bottom: Status bar (FPS) status_frame = ttk.Frame(main, 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) # 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) try: logging.getLogger().info("SfpSarViewer: GUI initialized") except Exception: pass # --- 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): if self._on_sar_param_changed: self._on_sar_param_changed("autocontrast", None, True) with self._lock: self._params_changed = True 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: logging.warning("SAR: no image to save") return # Apply current brightness/contrast to get the equalized image pil = self._apply_brightness_contrast(raw.copy()) # Save with timestamp import time ts = time.strftime('%Y%m%d_%H%M%S') dumps_dir = os.path.join(os.getcwd(), 'dumps') os.makedirs(dumps_dir, exist_ok=True) filename = f'SAR_equalized_{ts}.png' filepath = os.path.join(dumps_dir, filename) 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()) 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', '') 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 '') 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 or ImageEnhance is None: return img # Brightness: -100..100 -> factor 0.0..2.0 (0 black, 1 original, 2 double) with self._lock: b = self._current_brightness c = self._current_contrast bf = max(0.0, 1.0 + (b / 100.0)) # Contrast: -100..100 -> factor 0.0..2.0 cf = max(0.0, 1.0 + (c / 100.0)) result = img try: if bf != 1.0: result = ImageEnhance.Brightness(result).enhance(bf) if cf != 1.0: result = ImageEnhance.Contrast(result).enhance(cf) except Exception: logging.exception("Error applying brightness/contrast") return result 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') # Keep an unmodified raw copy so adjustments can be reapplied try: with self._lock: self._current_pil_image_raw = img.copy() except Exception: pass # Apply brightness/contrast to a copy for display try: disp_img = self._apply_brightness_contrast(img.copy()) except Exception: disp_img = img # Resize to fit the canvas while preserving aspect ratio. try: w = self._img_canvas.winfo_width() h = self._img_canvas.winfo_height() if w > 2 and h > 2: max_size = (max(1, w), max(1, h)) try: disp_img = disp_img.copy() disp_img.thumbnail(max_size, Image.LANCZOS) except Exception: pass except Exception: pass # Save a copy of current (adjusted) PIL image for quick display try: with self._lock: self._current_pil_image = disp_img.copy() except Exception: pass try: return ImageTk.PhotoImage(disp_img, master=self._root) except Exception: return ImageTk.PhotoImage(disp_img) except Exception: logging.exception("SfpSarViewer: failed to build tk image") return None def _draw_image_on_canvas(self, photo_image: ImageTk.PhotoImage) -> None: try: # Keep reference to avoid GC self._last_photoimage = photo_image # Draw into canvas, centered if self._canvas_image_id is None: self._canvas_image_id = self._img_canvas.create_image( self._img_canvas.winfo_width() // 2, self._img_canvas.winfo_height() // 2, image=self._last_photoimage, anchor='center' ) else: self._img_canvas.itemconfigure(self._canvas_image_id, image=self._last_photoimage) # Recenter in case canvas size changed self._img_canvas.coords(self._canvas_image_id, self._img_canvas.winfo_width() // 2, self._img_canvas.winfo_height() // 2) 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: # No new frame but params changed: refresh current image self._refresh_current_image() # 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: if self._owns_root: try: self._root.mainloop() except KeyboardInterrupt: pass else: # Embedded mode: block until stop() invoked while self._running: time.sleep(0.1) finally: self._running = False def _refresh_current_image(self) -> None: """Rebuild and display the current PIL image applying current SAR params.""" 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 try: pil = self._apply_brightness_contrast(raw.copy()) except Exception: pil = raw try: w = self._img_canvas.winfo_width() h = self._img_canvas.winfo_height() if w > 2 and h > 2: pil = pil.copy() pil.thumbnail((max(1, w), max(1, h)), Image.LANCZOS) except Exception: pass try: tkimg = ImageTk.PhotoImage(pil, master=self._root) except Exception: tkimg = ImageTk.PhotoImage(pil) with self._lock: self._photo = tkimg try: self._draw_image_on_canvas(self._photo) self._img_canvas.update_idletasks() except Exception: pass except Exception: logging.exception("SfpSarViewer: _refresh_current_image failed") 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