392 lines
15 KiB
Python
392 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)
|
|
|
|
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):
|
|
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:
|
|
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:
|
|
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 |