SXXXXXXX_ControlPanel/VideoReceiverSFP/gui/viewer_sar.py

404 lines
15 KiB
Python

"""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("<Button-3>", 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 '<No SAR metadata>')
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 '<No metadata>')
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