Guest User

Untitled

a guest
Oct 1st, 2026
5
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 55.17 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """FlashPad Capture -- standalone GUI for the GE FlashPad detector.
  3.  
  4. One click: arm detector (two-phase EXECUTE) -> fire the X-ray source over serial
  5. -> receive the 2048 image datagrams (with live progress) -> reassemble to
  6. 2048x2048 16-bit -> subtract dark/offset -> repair dead rows/cols/pixels
  7. -> display with pan/zoom, CLAHE, palettes -> crop and save PNG/TIFF.
  8.  
  9. Dark frames live in <output>/darks/. Capturing a new dark auto-loads it as the active
  10. correction and deletes the older ones. Loading a different offset, or changing any
  11. display/defect setting, updates the image already on screen -- no re-exposure needed.
  12.  
  13. Requires: pyserial, Pillow, numpy, opencv (cv2), and flashpad_acquire.py alongside.
  14. Host: detector on its own NIC, host 192.168.1.1/24, link 100 Mbps full, MTU >= 5000.
  15. """
  16. import os
  17. import sys
  18. import glob
  19. import struct
  20. import threading
  21. import queue
  22. import time
  23. import traceback
  24. from datetime import datetime
  25.  
  26. import tkinter as tk
  27. from tkinter import ttk, filedialog, messagebox
  28.  
  29. import numpy as np
  30. import cv2
  31.  
  32. HERE = os.path.dirname(os.path.abspath(__file__))
  33. sys.path.insert(0, HERE)
  34.  
  35. try:
  36. import flashpad_acquire as fp
  37. except Exception as e: # pragma: no cover
  38. print("Cannot import flashpad_acquire.py from %s: %s" % (HERE, e))
  39. raise
  40.  
  41. try:
  42. import serial
  43. from serial.tools import list_ports
  44. except ImportError:
  45. serial = None
  46. list_ports = None
  47.  
  48. from PIL import Image, ImageTk
  49.  
  50. STRIDE = 4104 # datagram: [imageId:4LE][blockIndex:4LE][4096 B pixels]
  51. PAYLOAD = 4096
  52. NBLOCKS = 2048
  53. DIM = 2048
  54. TICK_HZ = 26.3e6 # measured: 250e6 ticks -> ~9.5 s window
  55.  
  56. PALETTES = {
  57. "Grayscale": None,
  58. "Bone": cv2.COLORMAP_BONE,
  59. "Hot": cv2.COLORMAP_HOT,
  60. "Inferno": cv2.COLORMAP_INFERNO,
  61. "Magma": cv2.COLORMAP_MAGMA,
  62. "Plasma": cv2.COLORMAP_PLASMA,
  63. "Viridis": cv2.COLORMAP_VIRIDIS,
  64. "Turbo": cv2.COLORMAP_TURBO,
  65. "Jet": cv2.COLORMAP_JET,
  66. }
  67.  
  68. HELP_TEXT = """\
  69. X-RAY SOURCE
  70. ------------
  71. Exposure (ms)
  72. How long the Arduino holds pin 13 HIGH = how long the tube actually fires.
  73. Sent over serial as C<ms> . Longer = more dose = brighter. Main brightness control.
  74.  
  75. Serial port / Terminator
  76. Arduino trigger link (9600 8N1). The sketch ends a command on '\\r' OR '\\n', so
  77. cr, lf and crlf are all valid.
  78.  
  79. DTR / RTS
  80. CH340 clones tie DTR (sometimes RTS) to RESET through a capacitor, so merely opening
  81. the port can reboot the board and the bootloader can swallow the first command.
  82. 'assert' = normal pyserial behaviour. 'deassert' = never touch the lines (no reset).
  83. 'pulse' = deliberately reset, then wait.
  84.  
  85. Boot wait (s)
  86. Pause after opening the port before sending anything, so the sketch is running.
  87.  
  88. Test link (CW0)
  89. Sends CW0: moves the stepper zero steps and replies "OK". Fires NOTHING. The only
  90. way to prove the board is receiving, because the C command sends no reply at all.
  91.  
  92.  
  93. DETECTOR
  94. --------
  95. Detector IP
  96. The FlashPad's address (default 192.168.1.30). The host must be 192.168.1.1/24.
  97.  
  98. Window (ticks) = the acquisition primitive's "MaxExpose Time"
  99. How long the panel keeps its integration window OPEN, in ~26 MHz ticks (measured),
  100. so 250,000,000 ~= 9.5 s and 4,000,000 ~= 150 ms.
  101. The X-ray must fire INSIDE this window -- the panel does NOT self-detect radiation
  102. (it is not AED); it is armed and then waits.
  103. GE's own Script0 uses 400,000 (~15 ms) because their system hardware-syncs the
  104. generator. We fire from software, so a longer window is more forgiving.
  105. TRADE-OFF: a longer window integrates more dark current, raising the baseline and
  106. noise. Shorten it once your timing is reliable, and re-shoot the dark when you do.
  107.  
  108. Scrubs = "No Of Scrubs"
  109. Readout/clear cycles run BEFORE the window opens, to flush residual charge off the
  110. panel. More scrubs = less ghosting/lag from the previous exposure, but each costs
  111. ~Scrub Duration (50,000 ticks ~= 2 ms) and delays the window. GE's Script0/1 use 0;
  112. their Script2 uses 15. If you see a faint copy of the previous shot, raise this.
  113.  
  114. TypeMode
  115. Which mode the detector's acquisition primitive runs.
  116. 0 = standard acquisition (GE Script0 -- expects an exposure)
  117. 1 = dark / offset acquisition (GE Script1 -- reads out with no X-ray)
  118. Leave at 0 for real shots. The DARK button works either way: it simply never fires.
  119.  
  120. (Fixed here: Scrub Duration 50,000 ; Tail Time 250,000 (~10 ms settle) ;
  121. Transfer Mode 0 (normal push-to-host) ; ImageId 1.)
  122.  
  123.  
  124. OFFSET / DARK CORRECTION
  125. ------------------------
  126. A dark frame is an exposure-free readout: the panel's fixed offset plus dark current for
  127. that window length. Subtracting it removes the pedestal and most fixed-pattern noise.
  128. IMPORTANT: a dark is only valid for the SAME Window (ticks) value, because dark current
  129. scales with integration time. Re-shoot the dark whenever you change the window.
  130. Capturing a dark auto-loads it and deletes older darks in <output>/darks/.
  131.  
  132.  
  133. DEFECT CORRECTION
  134. -----------------
  135. Repair dead rows / columns
  136. Fixes the dark lines -- defective detector rows/columns -- by interpolating across
  137. them from their good neighbours.
  138.  
  139. Sensitivity
  140. How far a row/column median must sit from its local neighbourhood before being
  141. called defective, in robust (MAD-based) sigmas. LOWER = more aggressive.
  142. 6 is a sane start; 4 catches faint lines; 10 is gentle if real structure is lost.
  143.  
  144. Also isolated pixels
  145. Repairs single hot/dead pixels too. The defect map comes from the DARK frame when one
  146. is loaded (correct -- defects are fixed-pattern); otherwise from the image itself,
  147. which is less reliable and can clip genuine detail.
  148.  
  149.  
  150. DISPLAY (affects the view and the saved PNG, never the 16-bit data)
  151. -------
  152. CLAHE + Clip / Tiles
  153. Contrast Limited Adaptive Histogram Equalisation: equalises contrast in local tiles
  154. instead of globally, so both dense and thin areas stay readable. Clip limits how much
  155. contrast any tile may gain (higher = stronger, but amplifies noise). Tiles sets the
  156. grid (8 = 8x8 tiles; fewer, larger tiles = more global, more = more local).
  157.  
  158. Palette / Invert
  159. False-colour map and polarity. Invert gives the familiar "film" look where dense
  160. material is bright.
  161.  
  162. Low % / High %
  163. Percentile window used to map 16-bit values into the 8-bit display range. Widen for
  164. a flatter image, narrow to stretch contrast around the mid-tones.
  165.  
  166.  
  167. VIEW AND CROP
  168. -------------
  169. Mouse wheel zoom about the cursor
  170. Left-drag pan (or move/resize the crop box when you grab it)
  171. Right-drag always pans
  172. Fit / 1:1 zoom presets
  173. Crop box drag inside to move, grab an edge or corner handle to resize.
  174. "Save PNG/TIFF (crop)" writes ONLY the boxed region --
  175. PNG uses what you see (palette/CLAHE/invert), TIFF keeps the
  176. original 16-bit values for quantitative work.
  177. """
  178.  
  179.  
  180. # ----------------------------------------------------------------- reassembly
  181. def reassemble(raw: bytes):
  182. blocks = {}
  183. ids = set()
  184. head = len(raw) % STRIDE # first record often arrives 16 B short, header-less
  185. off = 0
  186. if head:
  187. blocks[0] = raw[:head].ljust(PAYLOAD, b"\x00")
  188. off = head
  189. while off + 8 <= len(raw):
  190. img_id, blk = struct.unpack_from("<II", raw, off)
  191. ids.add(img_id)
  192. if blk not in blocks:
  193. blocks[blk] = raw[off + 8: off + STRIDE].ljust(PAYLOAD, b"\x00")
  194. off += STRIDE
  195. buf = bytearray()
  196. missing = []
  197. for b in range(NBLOCKS):
  198. if b in blocks:
  199. buf += blocks[b]
  200. else:
  201. missing.append(b)
  202. buf += b"\x00" * PAYLOAD
  203. a = np.frombuffer(bytes(buf), dtype="<u2").reshape(DIM, DIM)
  204. return a, {"blocks": len(blocks), "missing": missing,
  205. "ids": sorted(ids), "short_first": head}
  206.  
  207.  
  208. # ------------------------------------------------------------ defect handling
  209. BORDER = 12 # outer rows/cols always look odd; don't let them dominate detection
  210.  
  211.  
  212. def _bad_lines(a, axis, thresh, win=16, border=BORDER):
  213. med = np.median(a, axis=1 - axis).astype(np.float64)
  214. n = med.size
  215. base = np.empty(n)
  216. for i in range(n):
  217. lo, hi = max(0, i - win), min(n, i + win + 1)
  218. base[i] = np.median(med[lo:hi])
  219. dev = med - base
  220. mad = np.median(np.abs(dev - np.median(dev)))
  221. sigma = 1.4826 * mad if mad > 0 else 1.0
  222. bad = (np.abs(dev) / sigma) > thresh
  223. mx = a.max(axis=1 - axis)
  224. bad |= (mx == 0) # completely dead line
  225. if border:
  226. bad[:border] = False
  227. bad[-border:] = False
  228. return bad
  229.  
  230.  
  231. def _interp_lines(f, bad, axis):
  232. if not bad.any():
  233. return f
  234. work = f if axis == 0 else f.T
  235. good = np.where(~bad)[0]
  236. if good.size < 2:
  237. return f
  238. badi = np.where(bad)[0]
  239. idx = np.searchsorted(good, badi)
  240. lo = good[np.clip(idx - 1, 0, good.size - 1)]
  241. hi = good[np.clip(idx, 0, good.size - 1)]
  242. span = np.maximum(hi - lo, 1)
  243. w = np.where(hi != lo, (badi - lo) / span, 0.0).astype(np.float32)[:, None]
  244. work[badi, :] = work[lo, :] * (1.0 - w) + work[hi, :] * w
  245. return work if axis == 0 else work.T
  246.  
  247.  
  248. def build_defect_map(signal, dark, thresh):
  249. """Defective rows/columns from the SIGNAL frame unioned with the DARK frame.
  250.  
  251. Dead lines here are mostly GAIN defects: they only show up where there is signal,
  252. so a dark frame alone cannot find them (measured: col 783 scores 320 sigma in the
  253. corrected image but 4 in the dark). The dark still contributes offset-type defects.
  254.  
  255. Deliberately lines only. An isolated-pixel pass was tried and removed: its neighbour
  256. averages are taken from an image that still contains the dead lines, so pixels beside
  257. row 48 / 1093 / col 783 get flagged (10196 of them) and then "repaired" by averaging
  258. the dead line's zeros back in -- which smears the line WIDER instead of removing it.
  259. """
  260. a = signal.astype(np.float32)
  261. bad_rows = _bad_lines(a, 0, thresh)
  262. bad_cols = _bad_lines(a, 1, thresh)
  263. if dark is not None and dark.shape == signal.shape:
  264. d = dark.astype(np.float32)
  265. bad_rows |= _bad_lines(d, 0, thresh)
  266. bad_cols |= _bad_lines(d, 1, thresh)
  267. return {"rows": bad_rows, "cols": bad_cols}
  268.  
  269.  
  270. def apply_defect_map(img, dmap):
  271. f = img.astype(np.float32).copy()
  272. f = _interp_lines(f, dmap["rows"], 0)
  273. f = _interp_lines(f, dmap["cols"], 1)
  274. return np.clip(f, 0, 65535).astype(np.uint16)
  275.  
  276.  
  277. # ------------------------------------------------------------ display pipeline
  278. def to_u8(img16, lo_pct, hi_pct):
  279. s = np.sort(img16[4:DIM - 8:3, ::3].ravel())
  280. lo = float(s[int(s.size * lo_pct)])
  281. hi = float(s[min(s.size - 1, int(s.size * hi_pct))])
  282. rng = max(1.0, hi - lo)
  283. return np.clip((img16.astype(np.float32) - lo) * (255.0 / rng), 0, 255).astype(np.uint8)
  284.  
  285.  
  286. def render_rgb(img16, lo_pct, hi_pct, clahe_on, clip, tiles, palette, invert):
  287. u8 = to_u8(img16, lo_pct, hi_pct)
  288. if clahe_on:
  289. t = max(1, int(tiles))
  290. u8 = cv2.createCLAHE(clipLimit=float(clip), tileGridSize=(t, t)).apply(u8)
  291. if invert:
  292. u8 = 255 - u8
  293. cmap = PALETTES.get(palette)
  294. if cmap is None:
  295. rgb = np.dstack([u8, u8, u8])
  296. else:
  297. rgb = cv2.applyColorMap(u8, cmap)[:, :, ::-1] # BGR -> RGB
  298. return Image.fromarray(np.ascontiguousarray(rgb), mode="RGB")
  299.  
  300.  
  301. class Tip:
  302. def __init__(self, widget, text):
  303. self.w, self.text, self.tip = widget, text, None
  304. widget.bind("<Enter>", self.show)
  305. widget.bind("<Leave>", self.hide)
  306.  
  307. def show(self, _=None):
  308. if self.tip:
  309. return
  310. x = self.w.winfo_rootx() + 20
  311. y = self.w.winfo_rooty() + self.w.winfo_height() + 4
  312. self.tip = tk.Toplevel(self.w)
  313. self.tip.wm_overrideredirect(True)
  314. self.tip.wm_geometry("+%d+%d" % (x, y))
  315. tk.Label(self.tip, text=self.text, justify="left", background="#ffffe0",
  316. relief="solid", borderwidth=1, wraplength=390,
  317. font=("Segoe UI", 8)).pack()
  318.  
  319. def hide(self, _=None):
  320. if self.tip:
  321. self.tip.destroy()
  322. self.tip = None
  323.  
  324.  
  325. # -------------------------------------------------------------------- viewer
  326. class ImageView:
  327. """Canvas with wheel-zoom, drag-pan and a draggable/resizable red crop box."""
  328. HANDLE = 7
  329.  
  330. def __init__(self, parent, on_crop=None):
  331. self.canvas = tk.Canvas(parent, bg="#101010", highlightthickness=1,
  332. highlightbackground="#444", cursor="crosshair")
  333. self.img = None
  334. self.W = self.H = 0
  335. self.scale = 1.0
  336. self.ox = self.oy = 0.0
  337. self.crop = None
  338. self.on_crop = on_crop
  339. self._photo = None
  340. self._mode = None
  341. self._last = (0, 0)
  342. c = self.canvas
  343. c.bind("<Configure>", lambda e: self.render())
  344. c.bind("<MouseWheel>", self._wheel)
  345. c.bind("<Button-4>", lambda e: self._wheel(e, 1))
  346. c.bind("<Button-5>", lambda e: self._wheel(e, -1))
  347. c.bind("<ButtonPress-1>", self._press)
  348. c.bind("<B1-Motion>", self._drag)
  349. c.bind("<ButtonRelease-1>", self._release)
  350. c.bind("<ButtonPress-3>", self._press_pan)
  351. c.bind("<B3-Motion>", self._drag_pan)
  352. c.bind("<Motion>", self._hover)
  353.  
  354. # -- coordinate helpers
  355. def i2c(self, x, y):
  356. return (x - self.ox) * self.scale, (y - self.oy) * self.scale
  357.  
  358. def c2i(self, cx, cy):
  359. return self.ox + cx / self.scale, self.oy + cy / self.scale
  360.  
  361. def set_image(self, pil, keep_view=True):
  362. first = self.img is None or (self.W, self.H) != pil.size
  363. self.img = pil
  364. self.W, self.H = pil.size
  365. if self.crop is None:
  366. m = int(min(self.W, self.H) * 0.1)
  367. self.crop = [m, m, self.W - m, self.H - m]
  368. if first or not keep_view:
  369. self.fit()
  370. else:
  371. self.render()
  372.  
  373. def fit(self):
  374. cw = max(1, self.canvas.winfo_width())
  375. ch = max(1, self.canvas.winfo_height())
  376. if not self.W:
  377. return
  378. self.scale = min(cw / self.W, ch / self.H)
  379. self.ox = (self.W - cw / self.scale) / 2.0
  380. self.oy = (self.H - ch / self.scale) / 2.0
  381. self.render()
  382.  
  383. def one_to_one(self):
  384. cw = max(1, self.canvas.winfo_width())
  385. ch = max(1, self.canvas.winfo_height())
  386. cx, cy = self.c2i(cw / 2, ch / 2)
  387. self.scale = 1.0
  388. self.ox = cx - cw / 2.0
  389. self.oy = cy - ch / 2.0
  390. self.render()
  391.  
  392. def reset_crop(self):
  393. m = int(min(self.W, self.H) * 0.1)
  394. self.crop = [m, m, self.W - m, self.H - m]
  395. self.render()
  396. self._notify()
  397.  
  398. def crop_full(self):
  399. self.crop = [0, 0, self.W, self.H]
  400. self.render()
  401. self._notify()
  402.  
  403. def _notify(self):
  404. if self.on_crop:
  405. x0, y0, x1, y1 = self.get_crop()
  406. self.on_crop(x1 - x0, y1 - y0)
  407.  
  408. def get_crop(self):
  409. x0, y0, x1, y1 = self.crop
  410. x0, x1 = sorted((int(round(x0)), int(round(x1))))
  411. y0, y1 = sorted((int(round(y0)), int(round(y1))))
  412. x0 = max(0, min(self.W - 1, x0)); x1 = max(x0 + 1, min(self.W, x1))
  413. y0 = max(0, min(self.H - 1, y0)); y1 = max(y0 + 1, min(self.H, y1))
  414. return x0, y0, x1, y1
  415.  
  416. # -- rendering
  417. def render(self):
  418. c = self.canvas
  419. c.delete("all")
  420. if self.img is None:
  421. return
  422. cw = max(1, c.winfo_width())
  423. ch = max(1, c.winfo_height())
  424. ix0 = max(0, int(np.floor(self.ox)))
  425. iy0 = max(0, int(np.floor(self.oy)))
  426. ix1 = min(self.W, int(np.ceil(self.ox + cw / self.scale)) + 1)
  427. iy1 = min(self.H, int(np.ceil(self.oy + ch / self.scale)) + 1)
  428. if ix1 <= ix0 or iy1 <= iy0:
  429. return
  430. sub = self.img.crop((ix0, iy0, ix1, iy1))
  431. dw = max(1, int(round((ix1 - ix0) * self.scale)))
  432. dh = max(1, int(round((iy1 - iy0) * self.scale)))
  433. rs = Image.NEAREST if self.scale >= 1.5 else Image.BILINEAR
  434. self._photo = ImageTk.PhotoImage(sub.resize((dw, dh), rs))
  435. px, py = self.i2c(ix0, iy0)
  436. c.create_image(int(round(px)), int(round(py)), anchor="nw", image=self._photo)
  437. # crop rectangle + handles
  438. x0, y0, x1, y1 = self.get_crop()
  439. a = self.i2c(x0, y0)
  440. b = self.i2c(x1, y1)
  441. c.create_rectangle(a[0], a[1], b[0], b[1], outline="#ff2d2d", width=2)
  442. for hx, hy in self._handles(a, b):
  443. c.create_rectangle(hx - self.HANDLE, hy - self.HANDLE,
  444. hx + self.HANDLE, hy + self.HANDLE,
  445. outline="#ff2d2d", fill="#ff2d2d")
  446. c.create_text(a[0] + 4, a[1] - 10, anchor="w", fill="#ff6a6a",
  447. font=("Consolas", 8),
  448. text="crop %dx%d @%.0f%%" % (x1 - x0, y1 - y0, self.scale * 100))
  449.  
  450. def _handles(self, a, b):
  451. mx = (a[0] + b[0]) / 2.0
  452. my = (a[1] + b[1]) / 2.0
  453. return [(a[0], a[1]), (mx, a[1]), (b[0], a[1]),
  454. (a[0], my), (b[0], my),
  455. (a[0], b[1]), (mx, b[1]), (b[0], b[1])]
  456.  
  457. # -- interaction
  458. def _wheel(self, e, direction=None):
  459. if self.img is None:
  460. return
  461. d = direction if direction is not None else (1 if e.delta > 0 else -1)
  462. ix, iy = self.c2i(e.x, e.y)
  463. f = 1.25 if d > 0 else 1 / 1.25
  464. new = min(40.0, max(0.05, self.scale * f))
  465. self.scale = new
  466. self.ox = ix - e.x / self.scale
  467. self.oy = iy - e.y / self.scale
  468. self.render()
  469.  
  470. def _hit(self, e):
  471. if self.img is None or self.crop is None:
  472. return None
  473. x0, y0, x1, y1 = self.get_crop()
  474. a = self.i2c(x0, y0)
  475. b = self.i2c(x1, y1)
  476. names = ["nw", "n", "ne", "w", "e", "sw", "s", "se"]
  477. for (hx, hy), nm in zip(self._handles(a, b), names):
  478. if abs(e.x - hx) <= self.HANDLE + 2 and abs(e.y - hy) <= self.HANDLE + 2:
  479. return nm
  480. if min(a[0], b[0]) < e.x < max(a[0], b[0]) and min(a[1], b[1]) < e.y < max(a[1], b[1]):
  481. return "move"
  482. return None
  483.  
  484. def _hover(self, e):
  485. h = self._hit(e)
  486. cur = {"nw": "size_nw_se", "se": "size_nw_se", "ne": "size_ne_sw", "sw": "size_ne_sw",
  487. "n": "size_ns", "s": "size_ns", "w": "size_we", "e": "size_we",
  488. "move": "fleur"}.get(h, "crosshair")
  489. self.canvas.configure(cursor=cur)
  490.  
  491. def _press(self, e):
  492. self._mode = self._hit(e) or "pan"
  493. self._last = (e.x, e.y)
  494.  
  495. def _press_pan(self, e):
  496. self._mode = "pan"
  497. self._last = (e.x, e.y)
  498.  
  499. def _drag_pan(self, e):
  500. self._mode = "pan"
  501. self._drag(e)
  502.  
  503. def _drag(self, e):
  504. if self.img is None or self._mode is None:
  505. return
  506. dx = (e.x - self._last[0]) / self.scale
  507. dy = (e.y - self._last[1]) / self.scale
  508. self._last = (e.x, e.y)
  509. if self._mode == "pan":
  510. self.ox -= dx
  511. self.oy -= dy
  512. elif self._mode == "move":
  513. w = self.crop[2] - self.crop[0]
  514. h = self.crop[3] - self.crop[1]
  515. self.crop[0] = min(max(0, self.crop[0] + dx), self.W - w)
  516. self.crop[1] = min(max(0, self.crop[1] + dy), self.H - h)
  517. self.crop[2] = self.crop[0] + w
  518. self.crop[3] = self.crop[1] + h
  519. else:
  520. m = self._mode
  521. if "w" in m:
  522. self.crop[0] = min(max(0, self.crop[0] + dx), self.crop[2] - 4)
  523. if "e" in m:
  524. self.crop[2] = max(min(self.W, self.crop[2] + dx), self.crop[0] + 4)
  525. if "n" in m:
  526. self.crop[1] = min(max(0, self.crop[1] + dy), self.crop[3] - 4)
  527. if "s" in m:
  528. self.crop[3] = max(min(self.H, self.crop[3] + dy), self.crop[1] + 4)
  529. self.render()
  530.  
  531. def _release(self, e):
  532. if self._mode and self._mode != "pan":
  533. self._notify()
  534. self._mode = None
  535.  
  536.  
  537. # ----------------------------------------------------------------------- app
  538. class App:
  539. def __init__(self, root):
  540. self.root = root
  541. root.title("FlashPad Capture")
  542. self.q = queue.Queue()
  543. self.busy = False
  544. self.offset = None
  545. self.dmap = None
  546. self.last_img = None # raw uint16 of the last capture
  547. self.final = None # processed uint16 (offset + defects)
  548. self.last_base = None
  549. self.last_tag = None
  550. self.offset_name = tk.StringVar(value="(none - images saved uncorrected)")
  551. self._build()
  552. self._autoload_dark()
  553. self.root.after(80, self._drain)
  554.  
  555. # ---------------------------------------------------------------- layout
  556. def _build(self):
  557. main = ttk.Frame(self.root, padding=6)
  558. main.grid(sticky="nsew")
  559. self.root.columnconfigure(0, weight=1)
  560. self.root.rowconfigure(0, weight=1)
  561. main.columnconfigure(1, weight=1)
  562. main.rowconfigure(0, weight=1)
  563.  
  564. # left column: tabbed settings (keeps the panel short) + always-visible actions
  565. left = ttk.Frame(main)
  566. left.grid(row=0, column=0, sticky="ns", padx=(0, 8))
  567. left.columnconfigure(0, weight=1)
  568. nb = ttk.Notebook(left, width=252)
  569. nb.grid(row=0, column=0, sticky="new")
  570. tab_cap = ttk.Frame(nb, padding=4)
  571. tab_cor = ttk.Frame(nb, padding=4)
  572. tab_dsp = ttk.Frame(nb, padding=4)
  573. for t, n in ((tab_cap, "Capture"), (tab_cor, "Correct"), (tab_dsp, "Display")):
  574. t.columnconfigure(0, weight=1)
  575. nb.add(t, text=n)
  576. r = 1
  577.  
  578. box = ttk.LabelFrame(tab_cap, text="X-ray source", padding=5)
  579. box.grid(row=0, column=0, sticky="ew", pady=(0, 5))
  580. lab = ttk.Label(box, text="Exposure (ms)"); lab.grid(row=0, column=0, sticky="w")
  581. Tip(lab, "How long the tube fires (Arduino pin 13 HIGH), sent as C<ms>.\n"
  582. "Main brightness / dose control.")
  583. self.expose_ms = tk.StringVar(value="250")
  584. ttk.Entry(box, textvariable=self.expose_ms, width=10).grid(row=0, column=1, padx=3, sticky="w")
  585. ttk.Label(box, text="Serial port").grid(row=1, column=0, sticky="w")
  586. self.port = tk.StringVar(value="COM22")
  587. ports = ["COM22"]
  588. if list_ports:
  589. try:
  590. found = [p.device for p in list_ports.comports()]
  591. if found:
  592. ports = found
  593. self.port.set("COM22" if "COM22" in found else found[0])
  594. except Exception:
  595. pass
  596. ttk.Combobox(box, textvariable=self.port, values=ports, width=8).grid(
  597. row=1, column=1, padx=3, sticky="w")
  598. ttk.Label(box, text="Terminator").grid(row=2, column=0, sticky="w")
  599. self.term = tk.StringVar(value="crlf")
  600. ttk.Combobox(box, textvariable=self.term, values=["cr", "lf", "crlf"], width=8,
  601. state="readonly").grid(row=2, column=1, padx=3, sticky="w")
  602. l2 = ttk.Label(box, text="DTR"); l2.grid(row=3, column=0, sticky="w")
  603. Tip(l2, "CH340 clones tie DTR to RESET via a cap, so opening the port can reboot\n"
  604. "the board. assert = normal; deassert = never touch the lines;\n"
  605. "pulse = deliberate reset then wait.")
  606. self.dtr_mode = tk.StringVar(value="assert")
  607. ttk.Combobox(box, textvariable=self.dtr_mode, width=8, state="readonly",
  608. values=["assert", "deassert", "pulse"]).grid(row=3, column=1, padx=3, sticky="w")
  609. ttk.Label(box, text="RTS").grid(row=4, column=0, sticky="w")
  610. self.rts_mode = tk.StringVar(value="assert")
  611. ttk.Combobox(box, textvariable=self.rts_mode, width=8, state="readonly",
  612. values=["assert", "deassert"]).grid(row=4, column=1, padx=3, sticky="w")
  613. ttk.Label(box, text="Boot wait (s)").grid(row=5, column=0, sticky="w")
  614. self.open_delay = tk.StringVar(value="2.5")
  615. ttk.Entry(box, textvariable=self.open_delay, width=10).grid(row=5, column=1, padx=3, sticky="w")
  616. b = ttk.Button(box, text="Test link (CW0 - fires nothing)", command=self.test_link)
  617. b.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(4, 0))
  618. Tip(b, "CW0 moves the stepper zero steps and replies OK.\n"
  619. "The only way to prove the board is receiving.")
  620.  
  621. box2 = ttk.LabelFrame(tab_cap, text="Detector", padding=5)
  622. box2.grid(row=1, column=0, sticky="ew", pady=(0, 5))
  623. ttk.Label(box2, text="Detector IP").grid(row=0, column=0, sticky="w")
  624. self.det_ip = tk.StringVar(value=fp.DETECTOR_IP)
  625. ttk.Entry(box2, textvariable=self.det_ip, width=14).grid(row=0, column=1, padx=3, sticky="w")
  626. l3 = ttk.Label(box2, text="Window (ticks)"); l3.grid(row=1, column=0, sticky="w")
  627. Tip(l3, "MaxExpose Time: how long the panel keeps its window open.\n"
  628. "The X-ray must fire INSIDE it (the panel is not AED).\n"
  629. "Longer = easier timing, more dark current. GE uses 400,000 (~15 ms).")
  630. self.window = tk.StringVar(value="250000000")
  631. ttk.Entry(box2, textvariable=self.window, width=14).grid(row=1, column=1, padx=3, sticky="w")
  632. self.window_lbl = ttk.Label(box2, text="", foreground="#666")
  633. self.window_lbl.grid(row=2, column=0, columnspan=2, sticky="w")
  634. self.window.trace_add("write", lambda *a: self._upd_window())
  635. self._upd_window()
  636. l4 = ttk.Label(box2, text="Scrubs"); l4.grid(row=3, column=0, sticky="w")
  637. Tip(l4, "Clear cycles BEFORE the window opens, to flush residual charge.\n"
  638. "More = less ghosting from the previous shot, but delays the window.\n"
  639. "GE Script0/1 use 0; Script2 uses 15.")
  640. self.scrubs = tk.StringVar(value="0")
  641. ttk.Entry(box2, textvariable=self.scrubs, width=14).grid(row=3, column=1, padx=3, sticky="w")
  642. l5 = ttk.Label(box2, text="TypeMode"); l5.grid(row=4, column=0, sticky="w")
  643. Tip(l5, "0 = standard acquisition (GE Script0, expects an exposure)\n"
  644. "1 = dark/offset acquisition (GE Script1, no X-ray)\n"
  645. "Leave at 0 for real shots.")
  646. self.type_mode = tk.StringVar(value="0")
  647. ttk.Entry(box2, textvariable=self.type_mode, width=14).grid(row=4, column=1, padx=3, sticky="w")
  648.  
  649. box3 = ttk.LabelFrame(tab_cor, text="Offset / dark correction", padding=5)
  650. box3.grid(row=0, column=0, sticky="ew", pady=(0, 5))
  651. box3.columnconfigure(0, weight=1)
  652. ttk.Label(box3, textvariable=self.offset_name, wraplength=210,
  653. foreground="#444").grid(row=0, column=0, columnspan=2, sticky="w")
  654. ttk.Button(box3, text="Load offset...", command=self.load_offset).grid(
  655. row=1, column=0, sticky="ew", pady=(3, 0))
  656. ttk.Button(box3, text="Clear", command=self.clear_offset).grid(
  657. row=1, column=1, sticky="ew", pady=(3, 0))
  658.  
  659. box5 = ttk.LabelFrame(tab_cor, text="Defect correction", padding=5)
  660. box5.grid(row=1, column=0, sticky="ew", pady=(0, 5))
  661. self.fix_on = tk.BooleanVar(value=True)
  662. cb = ttk.Checkbutton(box5, text="repair dead rows / columns",
  663. variable=self.fix_on, command=self.reprocess)
  664. cb.grid(row=0, column=0, columnspan=2, sticky="w")
  665. Tip(cb, "Removes the dark lines by interpolating across defective rows and\n"
  666. "columns, detected on the offset-corrected image (these are gain\n"
  667. "defects, so a dark frame alone cannot see them) plus the dark.")
  668. l6 = ttk.Label(box5, text="Sensitivity"); l6.grid(row=1, column=0, sticky="w")
  669. Tip(l6, "Robust sigmas a row/col median must deviate from its neighbours.\n"
  670. "LOWER = more aggressive. Default 25: measured dead lines score\n"
  671. "150-320 here while normal structure stays under ~21.")
  672. self.fix_thresh = tk.StringVar(value="25.0")
  673. e6 = ttk.Entry(box5, textvariable=self.fix_thresh, width=8)
  674. e6.grid(row=1, column=1, padx=3, sticky="w")
  675. e6.bind("<Return>", lambda e: self.reprocess())
  676. ttk.Button(box5, text="Re-apply", command=self.reprocess).grid(
  677. row=2, column=0, columnspan=2, sticky="ew", pady=(3, 0))
  678. self.defect_lbl = ttk.Label(box5, text="", foreground="#666", wraplength=210)
  679. self.defect_lbl.grid(row=3, column=0, columnspan=2, sticky="w")
  680.  
  681. box6 = ttk.LabelFrame(tab_dsp, text="Display (view + saved PNG only)", padding=5)
  682. box6.grid(row=0, column=0, sticky="ew", pady=(0, 5))
  683. self.clahe_on = tk.BooleanVar(value=False)
  684. cbx = ttk.Checkbutton(box6, text="CLAHE contrast", variable=self.clahe_on,
  685. command=self.redisplay)
  686. cbx.grid(row=0, column=0, columnspan=2, sticky="w")
  687. Tip(cbx, "Contrast Limited Adaptive Histogram Equalisation: equalises contrast in\n"
  688. "local tiles, so dense and thin areas stay readable at once.")
  689. ttk.Label(box6, text="Clip").grid(row=1, column=0, sticky="w")
  690. self.clahe_clip = tk.DoubleVar(value=2.0)
  691. s1 = ttk.Scale(box6, from_=0.5, to=12.0, variable=self.clahe_clip,
  692. command=lambda v: self.redisplay())
  693. s1.grid(row=1, column=1, sticky="ew", padx=3)
  694. ttk.Label(box6, text="Tiles").grid(row=2, column=0, sticky="w")
  695. self.clahe_tiles = tk.IntVar(value=8)
  696. s2 = ttk.Scale(box6, from_=2, to=32, variable=self.clahe_tiles,
  697. command=lambda v: self.redisplay())
  698. s2.grid(row=2, column=1, sticky="ew", padx=3)
  699. ttk.Label(box6, text="Palette").grid(row=3, column=0, sticky="w")
  700. self.palette = tk.StringVar(value="Grayscale")
  701. ttk.Combobox(box6, textvariable=self.palette, values=list(PALETTES.keys()),
  702. width=11, state="readonly").grid(row=3, column=1, padx=3, sticky="w")
  703. self.palette.trace_add("write", lambda *a: self.redisplay())
  704. self.invert = tk.BooleanVar(value=False)
  705. ttk.Checkbutton(box6, text="Invert (film look)", variable=self.invert,
  706. command=self.redisplay).grid(row=4, column=0, columnspan=2, sticky="w")
  707. ttk.Label(box6, text="Low %").grid(row=5, column=0, sticky="w")
  708. self.lo_pct = tk.DoubleVar(value=1.0)
  709. ttk.Scale(box6, from_=0.0, to=20.0, variable=self.lo_pct,
  710. command=lambda v: self.redisplay()).grid(row=5, column=1, sticky="ew", padx=3)
  711. ttk.Label(box6, text="High %").grid(row=6, column=0, sticky="w")
  712. self.hi_pct = tk.DoubleVar(value=99.5)
  713. ttk.Scale(box6, from_=80.0, to=100.0, variable=self.hi_pct,
  714. command=lambda v: self.redisplay()).grid(row=6, column=1, sticky="ew", padx=3)
  715. box6.columnconfigure(1, weight=1)
  716.  
  717. box4 = ttk.LabelFrame(tab_dsp, text="Output", padding=5)
  718. box4.grid(row=1, column=0, sticky="ew", pady=(0, 5))
  719. box4.columnconfigure(0, weight=1)
  720. self.outdir = tk.StringVar(value=os.path.join(HERE, "captures"))
  721. ttk.Entry(box4, textvariable=self.outdir, width=22).grid(row=0, column=0, sticky="ew")
  722. ttk.Button(box4, text="...", width=3, command=self.pick_dir).grid(row=0, column=1)
  723. self.save_tif = tk.BooleanVar(value=True)
  724. ttk.Checkbutton(box4, text="auto-save 16-bit TIFF",
  725. variable=self.save_tif).grid(row=1, column=0, columnspan=2, sticky="w")
  726. ttk.Button(box4, text="Open captures folder", command=self.open_outdir).grid(
  727. row=2, column=0, columnspan=2, sticky="ew", pady=(3, 0))
  728.  
  729. self.btn_cap = ttk.Button(left, text="CAPTURE (fires X-ray)", command=self.do_capture)
  730. self.btn_cap.grid(row=r, column=0, sticky="ew", ipady=5); r += 1
  731. self.btn_dark = ttk.Button(left, text="Capture DARK (no X-ray)", command=self.do_dark)
  732. self.btn_dark.grid(row=r, column=0, sticky="ew", pady=(3, 0)); r += 1
  733. ttk.Button(left, text="? Explain every setting", command=self.show_help).grid(
  734. row=r, column=0, sticky="ew", pady=(3, 0)); r += 1
  735.  
  736. self.status = tk.StringVar(value="Ready.")
  737. ttk.Label(left, textvariable=self.status, wraplength=225,
  738. foreground="#063").grid(row=r, column=0, sticky="w", pady=(6, 0)); r += 1
  739. self.prog = ttk.Progressbar(left, mode="determinate", maximum=NBLOCKS)
  740. self.prog.grid(row=r, column=0, sticky="ew", pady=(3, 0)); r += 1
  741. self.prog_lbl = ttk.Label(left, text="", foreground="#666", font=("Consolas", 8))
  742. self.prog_lbl.grid(row=r, column=0, sticky="w")
  743.  
  744. # ---- right: toolbar + view + stats + log
  745. right = ttk.Frame(main)
  746. right.grid(row=0, column=1, sticky="nsew")
  747. right.columnconfigure(0, weight=1)
  748. right.rowconfigure(1, weight=3)
  749. right.rowconfigure(3, weight=1)
  750.  
  751. tb = ttk.Frame(right)
  752. tb.grid(row=0, column=0, sticky="ew", pady=(0, 3))
  753. ttk.Button(tb, text="Fit", width=5, command=lambda: self.view.fit()).pack(side="left")
  754. ttk.Button(tb, text="1:1", width=5, command=lambda: self.view.one_to_one()).pack(side="left", padx=2)
  755. ttk.Separator(tb, orient="vertical").pack(side="left", fill="y", padx=6)
  756. ttk.Button(tb, text="Crop: reset", command=lambda: self.view.reset_crop()).pack(side="left")
  757. ttk.Button(tb, text="Crop: full frame", command=lambda: self.view.crop_full()).pack(side="left", padx=2)
  758. ttk.Separator(tb, orient="vertical").pack(side="left", fill="y", padx=6)
  759. ttk.Button(tb, text="Save PNG (crop)", command=self.save_png_crop).pack(side="left")
  760. ttk.Button(tb, text="Save TIFF (crop)", command=self.save_tiff_crop).pack(side="left", padx=2)
  761. self.crop_lbl = ttk.Label(tb, text="", foreground="#a33", font=("Consolas", 8))
  762. self.crop_lbl.pack(side="left", padx=8)
  763.  
  764. self.view = ImageView(right, on_crop=self._crop_info)
  765. self.view.canvas.grid(row=1, column=0, sticky="nsew")
  766. self.stats_lbl = ttk.Label(right, text="no image yet", font=("Consolas", 9))
  767. self.stats_lbl.grid(row=2, column=0, sticky="w", pady=3)
  768. logf = ttk.LabelFrame(right, text="Log", padding=3)
  769. logf.grid(row=3, column=0, sticky="nsew")
  770. logf.columnconfigure(0, weight=1)
  771. logf.rowconfigure(0, weight=1)
  772. self.log = tk.Text(logf, height=8, wrap="none", font=("Consolas", 8))
  773. self.log.grid(row=0, column=0, sticky="nsew")
  774. sb = ttk.Scrollbar(logf, command=self.log.yview)
  775. sb.grid(row=0, column=1, sticky="ns")
  776. self.log.configure(yscrollcommand=sb.set)
  777.  
  778. def _crop_info(self, w, h):
  779. self.crop_lbl.configure(text="crop %d x %d px" % (w, h))
  780.  
  781. def _upd_window(self):
  782. try:
  783. t = int(self.window.get())
  784. self.window_lbl.configure(text="~ %.2f s open window" % (t / TICK_HZ))
  785. except ValueError:
  786. self.window_lbl.configure(text="(invalid)")
  787.  
  788. def show_help(self):
  789. w = tk.Toplevel(self.root)
  790. w.title("What every setting does")
  791. w.geometry("800x680")
  792. t = tk.Text(w, wrap="word", font=("Consolas", 9), padx=10, pady=10)
  793. t.pack(side="left", fill="both", expand=True)
  794. sb = ttk.Scrollbar(w, command=t.yview)
  795. sb.pack(side="right", fill="y")
  796. t.configure(yscrollcommand=sb.set)
  797. t.insert("1.0", HELP_TEXT)
  798. t.configure(state="disabled")
  799.  
  800. # ---------------------------------------------------------------- offsets
  801. def darks_dir(self):
  802. return os.path.join(self.outdir.get().strip() or HERE, "darks")
  803.  
  804. def _autoload_dark(self):
  805. try:
  806. hits = glob.glob(os.path.join(self.darks_dir(), "*_u16.raw"))
  807. if hits:
  808. self._set_offset(max(hits, key=os.path.getmtime), quiet=True)
  809. except Exception:
  810. pass
  811.  
  812. def _set_offset(self, path, quiet=False):
  813. d = open(path, "rb").read()
  814. if len(d) != DIM * DIM * 2:
  815. a, _ = reassemble(d)
  816. else:
  817. a = np.frombuffer(d, dtype="<u2").reshape(DIM, DIM)
  818. self.offset = a.copy()
  819. self.dmap = None
  820. self.q.put(("offsetname", "offset: %s (mean %.1f)"
  821. % (os.path.basename(path), float(a.mean()))))
  822. if not quiet:
  823. self._log("offset loaded: %s" % path)
  824.  
  825. def load_offset(self):
  826. p = filedialog.askopenfilename(
  827. title="Pick a dark/offset frame (_u16.raw or a raw capture)",
  828. initialdir=self.darks_dir() if os.path.isdir(self.darks_dir()) else HERE,
  829. filetypes=[("raw", "*.raw"), ("all", "*.*")])
  830. if not p:
  831. return
  832. try:
  833. self._set_offset(p)
  834. self.reprocess()
  835. except Exception as e:
  836. messagebox.showerror("Offset", "Could not load:\n%s" % e)
  837.  
  838. def clear_offset(self):
  839. self.offset = None
  840. self.dmap = None
  841. self.offset_name.set("(none - images saved uncorrected)")
  842. self.reprocess()
  843.  
  844. # ---------------------------------------------------------------- helpers
  845. def pick_dir(self):
  846. d = filedialog.askdirectory(initialdir=self.outdir.get() or HERE)
  847. if d:
  848. self.outdir.set(d)
  849.  
  850. def open_outdir(self):
  851. d = self.outdir.get().strip() or HERE
  852. try:
  853. os.makedirs(d, exist_ok=True)
  854. os.startfile(d) # Windows file explorer
  855. except AttributeError: # non-Windows fallback
  856. import subprocess
  857. subprocess.Popen(["xdg-open", d])
  858. except Exception as e:
  859. messagebox.showerror("Open folder", str(e))
  860.  
  861. def _log(self, msg):
  862. self.q.put(("log", msg))
  863.  
  864. def _drain(self):
  865. try:
  866. while True:
  867. kind, payload = self.q.get_nowait()
  868. if kind == "log":
  869. self.log.insert("end", payload.rstrip() + "\n")
  870. self.log.see("end")
  871. elif kind == "status":
  872. self.status.set(payload)
  873. elif kind == "stats":
  874. self.stats_lbl.configure(text=payload)
  875. elif kind == "defects":
  876. self.defect_lbl.configure(text=payload)
  877. elif kind == "offsetname":
  878. self.offset_name.set(payload)
  879. elif kind == "prog":
  880. n = payload
  881. self.prog.configure(value=min(n, NBLOCKS))
  882. self.prog_lbl.configure(text="%d / %d blocks (%.1f%%)"
  883. % (n, NBLOCKS, 100.0 * n / NBLOCKS))
  884. elif kind == "progmode":
  885. if payload == "busy":
  886. self.prog.configure(mode="indeterminate")
  887. self.prog.start(12)
  888. else:
  889. self.prog.stop()
  890. self.prog.configure(mode="determinate", value=0)
  891. self.prog_lbl.configure(text="")
  892. elif kind == "image":
  893. self.view.set_image(payload)
  894. elif kind == "done":
  895. self.busy = False
  896. self.prog.stop()
  897. self.btn_cap.configure(state="normal")
  898. self.btn_dark.configure(state="normal")
  899. except queue.Empty:
  900. pass
  901. self.root.after(80, self._drain)
  902.  
  903. # ------------------------------------------------------- serial plumbing
  904. def _open_serial(self, port, dtr_mode, rts_mode, delay):
  905. s = serial.Serial()
  906. s.port = port
  907. s.baudrate = 9600
  908. s.bytesize, s.parity, s.stopbits = 8, "N", 1
  909. s.timeout, s.write_timeout = 1, 2
  910. s.dsrdtr = s.rtscts = s.xonxoff = False
  911. s.dtr = (dtr_mode == "assert")
  912. s.rts = (rts_mode == "assert")
  913. s.open()
  914. if dtr_mode == "pulse":
  915. s.dtr = True
  916. time.sleep(0.15)
  917. s.dtr = False
  918. self._log("serial %s open: dtr=%s rts=%s, waiting %.1fs"
  919. % (s.name, s.dtr, s.rts, delay))
  920. time.sleep(delay)
  921. try:
  922. s.reset_input_buffer()
  923. s.reset_output_buffer()
  924. except Exception:
  925. pass
  926. return s
  927.  
  928. def _probe_link(self, ser, term):
  929. ser.write(("CW0%s" % term).encode())
  930. ser.flush()
  931. got = b""
  932. t0 = time.time()
  933. while time.time() - t0 < 2.5:
  934. d = ser.read(64)
  935. if d:
  936. got += d
  937. if b"OK" in got:
  938. return True, got
  939. return False, got
  940.  
  941. def test_link(self):
  942. if self.busy:
  943. return
  944. if serial is None:
  945. messagebox.showerror("Serial", "pyserial is not installed.")
  946. return
  947. self._lock()
  948. self.q.put(("progmode", "busy"))
  949. port, dtr, rts = self.port.get().strip(), self.dtr_mode.get(), self.rts_mode.get()
  950. try:
  951. delay = float(self.open_delay.get())
  952. except ValueError:
  953. delay = 2.5
  954. term = {"cr": "\r", "lf": "\n", "crlf": "\r\n"}[self.term.get()]
  955.  
  956. def run():
  957. ser = None
  958. try:
  959. self.q.put(("status", "testing %s ..." % port))
  960. ser = self._open_serial(port, dtr, rts, delay)
  961. ok, got = self._probe_link(ser, term)
  962. if ok:
  963. self._log("LINK OK -- board replied %r to CW0" % got)
  964. self.q.put(("status", "Link OK: Arduino alive and listening."))
  965. else:
  966. self._log("NO REPLY to CW0 (got %r). Try DTR=deassert, or "
  967. "DTR=pulse with boot wait 3-4 s." % got)
  968. self.q.put(("status", "No reply - not reaching the board."))
  969. except Exception as e:
  970. self._log("ERROR: %s" % e)
  971. self.q.put(("status", "FAILED: %s" % e))
  972. finally:
  973. if ser:
  974. ser.close()
  975. self.q.put(("progmode", "idle"))
  976. self.q.put(("done", None))
  977. threading.Thread(target=run, daemon=True).start()
  978.  
  979. def _lock(self):
  980. self.busy = True
  981. self.btn_cap.configure(state="disabled")
  982. self.btn_dark.configure(state="disabled")
  983.  
  984. # ---------------------------------------------------------------- actions
  985. def do_capture(self):
  986. self._start(fire=True)
  987.  
  988. def do_dark(self):
  989. self._start(fire=False)
  990.  
  991. def _start(self, fire):
  992. if self.busy:
  993. return
  994. try:
  995. p = {
  996. "expose_ms": int(self.expose_ms.get()),
  997. "window": int(self.window.get()),
  998. "scrubs": int(self.scrubs.get()),
  999. "type_mode": int(self.type_mode.get()),
  1000. "det_ip": self.det_ip.get().strip(),
  1001. "port": self.port.get().strip(),
  1002. "term": {"cr": "\r", "lf": "\n", "crlf": "\r\n"}[self.term.get()],
  1003. "dtr": self.dtr_mode.get(),
  1004. "rts": self.rts_mode.get(),
  1005. "delay": float(self.open_delay.get()),
  1006. "outdir": self.outdir.get().strip() or HERE,
  1007. "fire": fire,
  1008. "tif": bool(self.save_tif.get()),
  1009. }
  1010. except ValueError as e:
  1011. messagebox.showerror("Input", "Check the numeric fields:\n%s" % e)
  1012. return
  1013. if fire and serial is None:
  1014. messagebox.showerror("Serial", "pyserial is not installed.")
  1015. return
  1016. self._lock()
  1017. self.log.delete("1.0", "end")
  1018. threading.Thread(target=self._worker, args=(p,), daemon=True).start()
  1019.  
  1020. # ----------------------------------------------------------------- worker
  1021. def _worker(self, p):
  1022. try:
  1023. tag = "xray" if p["fire"] else "dark"
  1024. outdir = p["outdir"] if p["fire"] else os.path.join(p["outdir"], "darks")
  1025. os.makedirs(outdir, exist_ok=True)
  1026. ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  1027. base = os.path.join(outdir, "flashpad_%s_%s" % (tag, ts))
  1028.  
  1029. ser = None
  1030. if p["fire"]:
  1031. self.q.put(("progmode", "busy"))
  1032. self.q.put(("status", "opening %s ..." % p["port"]))
  1033. ser = self._open_serial(p["port"], p["dtr"], p["rts"], p["delay"])
  1034. ok, got = self._probe_link(ser, p["term"])
  1035. self._log("link check: %s%s" % ("OK" if ok else "NO REPLY",
  1036. " %r" % got if got else ""))
  1037.  
  1038. s = fp.FlashPadSession(detector_ip=p["det_ip"], host_ip=fp.HOST_IP,
  1039. timeout=5.0, verbose=True)
  1040. # hook the session logger for live receive progress
  1041. frames = [0]
  1042. orig_log = s._log
  1043.  
  1044. def hooked(msg):
  1045. m = str(msg)
  1046. if "ImgSock" in m:
  1047. frames[0] += 1
  1048. if frames[0] % 16 == 0 or frames[0] >= NBLOCKS:
  1049. self.q.put(("prog", frames[0]))
  1050. elif ("[OK]" in m or "WARN" in m or "ERROR" in m
  1051. or "EXECUTION_COMPLETE" in m or "missed" in m):
  1052. self._log(m.strip())
  1053. s._log = hooked
  1054.  
  1055. img_port = fp.HOST_IMAGE_PORT
  1056. try:
  1057. self.q.put(("progmode", "busy"))
  1058. self.q.put(("status", "discovering detector ..."))
  1059. s._open_sockets()
  1060. if not s.discover():
  1061. raise RuntimeError("discovery failed (check IP / 100 Mbps / MTU 5000)")
  1062. s.send_port_setup(host_cmd_port=s.reply_port, host_img_port=img_port)
  1063. s.request_signature()
  1064.  
  1065. self.q.put(("status", "arming: phase 1 (ROE init) ..."))
  1066. if not s.download_script(fp.build_script_7_roe_init(), "Script7"):
  1067. raise RuntimeError("Script7 download failed")
  1068. if not s.execute_script():
  1069. raise RuntimeError("Script7 execute failed")
  1070. if not s.wait_for_execution_complete(timeout_s=60.0):
  1071. raise RuntimeError("no EXECUTION_COMPLETE for Script7")
  1072.  
  1073. self.q.put(("status", "arming: phase 2 (acquisition) ..."))
  1074. cmds = [
  1075. fp.pack_acquisition(type_mode=p["type_mode"], image_id=1,
  1076. no_scrubs=p["scrubs"], scrub_duration=50000,
  1077. max_expose_time=p["window"],
  1078. tail_time=250000, transfer_mode=0),
  1079. fp.pack_delay(10000),
  1080. ]
  1081. if not s.download_script(fp.build_generic_script(0, 0, 0, cmds), "Script0"):
  1082. raise RuntimeError("Script0 download failed")
  1083. if not s._open_image_socket(img_port):
  1084. self._log("WARNING: image socket did not open")
  1085. if not s.execute_script():
  1086. raise RuntimeError("Script0 execute failed")
  1087.  
  1088. if p["fire"]:
  1089. msg = ("C%d%s" % (p["expose_ms"], p["term"])).encode()
  1090. ser.write(msg)
  1091. ser.flush()
  1092. self._log("sent %r (C commands are not acked by the sketch)" % msg)
  1093. self.q.put(("progmode", "idle"))
  1094. self.q.put(("status", "receiving image ..."))
  1095. raw = s.receive_image(output_path=base + ".raw", timeout_s=90.0,
  1096. script_id=0, image_port=img_port,
  1097. wait_exec_complete=True)
  1098. if not raw:
  1099. raise RuntimeError("no image data received")
  1100. self.q.put(("prog", NBLOCKS))
  1101. finally:
  1102. s._log = orig_log
  1103. try:
  1104. s._close_sockets()
  1105. except Exception:
  1106. pass
  1107. if ser:
  1108. ser.close()
  1109.  
  1110. self.q.put(("status", "reassembling ..."))
  1111. img, info = reassemble(raw)
  1112. self._log("blocks %d/%d missing %d ids %s"
  1113. % (info["blocks"], NBLOCKS, len(info["missing"]),
  1114. [hex(i) for i in info["ids"]]))
  1115. img.tofile(base + "_u16.raw")
  1116.  
  1117. if not p["fire"]:
  1118. self._prune_darks(outdir, keep_base=os.path.basename(base))
  1119. self._set_offset(base + "_u16.raw")
  1120. self._log("dark auto-loaded as the active offset correction")
  1121.  
  1122. self.last_img = img
  1123. self.last_base = base
  1124. self.last_tag = tag
  1125. self._process(img, base, tag, p["tif"])
  1126. self.q.put(("status", "Done: %s" % os.path.basename(base)))
  1127. except Exception as e:
  1128. self._log("ERROR: %s" % e)
  1129. self._log(traceback.format_exc())
  1130. self.q.put(("status", "FAILED: %s" % e))
  1131. finally:
  1132. self.q.put(("done", None))
  1133.  
  1134. def _prune_darks(self, d, keep_base):
  1135. removed = 0
  1136. for f in glob.glob(os.path.join(d, "flashpad_dark_*")):
  1137. if os.path.basename(f).startswith(keep_base):
  1138. continue
  1139. try:
  1140. os.remove(f)
  1141. removed += 1
  1142. except OSError:
  1143. pass
  1144. if removed:
  1145. self._log("removed %d old dark file(s)" % removed)
  1146.  
  1147. # ------------------------------------------------- processing / display
  1148. def _process(self, img, base, tag, want_tif):
  1149. """Offset subtraction + defect repair -> self.final, then display."""
  1150. line = "%s raw mean=%.1f min=%d max=%d" % (tag.upper(), img.mean(),
  1151. img.min(), img.max())
  1152. view = img
  1153. if self.offset is not None and tag != "dark" and self.offset.shape == img.shape:
  1154. view = np.clip(img.astype(np.int32) - self.offset.astype(np.int32),
  1155. 0, 65535).astype(np.uint16)
  1156. line += " | offset-corr mean=%.1f max=%d" % (view.mean(), view.max())
  1157.  
  1158. if self.fix_on.get():
  1159. try:
  1160. th = float(self.fix_thresh.get())
  1161. except ValueError:
  1162. th = 25.0
  1163. # detect on the offset-corrected signal (gain defects) unioned with the dark
  1164. self.dmap = build_defect_map(view, self.offset, th)
  1165. nr = int(self.dmap["rows"].sum())
  1166. nc = int(self.dmap["cols"].sum())
  1167. rows_list = list(np.where(self.dmap["rows"])[0][:6])
  1168. cols_list = list(np.where(self.dmap["cols"])[0][:6])
  1169. view = apply_defect_map(view, self.dmap)
  1170. msg = "repaired %d rows %s, %d cols %s" % (nr, rows_list, nc, cols_list)
  1171. self.q.put(("defects", msg))
  1172. line += " | defects fixed"
  1173. else:
  1174. self.q.put(("defects", "defect repair off"))
  1175.  
  1176. self.final = view
  1177. if want_tif:
  1178. Image.fromarray(view, mode="I;16").save(base + ".tif")
  1179. view.tofile(base + "_final_u16.raw")
  1180. self.q.put(("stats", line))
  1181. self._redisplay_and_autosave(base)
  1182.  
  1183. def _redisplay_and_autosave(self, base):
  1184. pil = self._make_rgb()
  1185. if pil is not None:
  1186. self.q.put(("image", pil))
  1187. if base:
  1188. pil.save(base + ".png")
  1189. self._log("saved %s.{raw,_u16.raw,_final_u16.raw,png,tif}"
  1190. % os.path.basename(base))
  1191.  
  1192. def _make_rgb(self):
  1193. if self.final is None:
  1194. return None
  1195. return render_rgb(self.final,
  1196. max(0.0, self.lo_pct.get()) / 100.0,
  1197. min(100.0, self.hi_pct.get()) / 100.0,
  1198. bool(self.clahe_on.get()),
  1199. float(self.clahe_clip.get()),
  1200. int(self.clahe_tiles.get()),
  1201. self.palette.get(),
  1202. bool(self.invert.get()))
  1203.  
  1204. def redisplay(self):
  1205. """Display-only update (CLAHE / palette / invert / stretch). Cheap."""
  1206. if self.final is None:
  1207. return
  1208. pil = self._make_rgb()
  1209. if pil is not None:
  1210. self.view.set_image(pil, keep_view=True)
  1211.  
  1212. def reprocess(self):
  1213. """Re-run offset + defect correction on the captured frame (no new exposure)."""
  1214. if self.last_img is None or self.busy:
  1215. return
  1216. def run():
  1217. try:
  1218. self.q.put(("status", "re-processing ..."))
  1219. self._process(self.last_img, self.last_base, self.last_tag,
  1220. bool(self.save_tif.get()))
  1221. self.q.put(("status", "Re-processed."))
  1222. except Exception as e:
  1223. self._log("ERROR during re-process: %s" % e)
  1224. threading.Thread(target=run, daemon=True).start()
  1225.  
  1226. # ------------------------------------------------------------- crop saves
  1227. def save_png_crop(self):
  1228. if self.final is None:
  1229. messagebox.showinfo("Save", "No image yet.")
  1230. return
  1231. x0, y0, x1, y1 = self.view.get_crop()
  1232. pil = self._make_rgb()
  1233. if pil is None:
  1234. return
  1235. p = filedialog.asksaveasfilename(
  1236. defaultextension=".png", filetypes=[("PNG", "*.png")],
  1237. initialdir=self.outdir.get(),
  1238. initialfile=os.path.basename(self.last_base or "flashpad") + "_crop.png")
  1239. if not p:
  1240. return
  1241. pil.crop((x0, y0, x1, y1)).save(p)
  1242. self._log("saved crop PNG %dx%d -> %s" % (x1 - x0, y1 - y0, p))
  1243. self.q.put(("status", "Saved crop PNG (%dx%d)" % (x1 - x0, y1 - y0)))
  1244.  
  1245. def save_tiff_crop(self):
  1246. if self.final is None:
  1247. messagebox.showinfo("Save", "No image yet.")
  1248. return
  1249. x0, y0, x1, y1 = self.view.get_crop()
  1250. p = filedialog.asksaveasfilename(
  1251. defaultextension=".tif", filetypes=[("TIFF", "*.tif")],
  1252. initialdir=self.outdir.get(),
  1253. initialfile=os.path.basename(self.last_base or "flashpad") + "_crop.tif")
  1254. if not p:
  1255. return
  1256. sub = self.final[y0:y1, x0:x1]
  1257. Image.fromarray(sub, mode="I;16").save(p)
  1258. sub.tofile(os.path.splitext(p)[0] + "_u16.raw")
  1259. self._log("saved crop TIFF %dx%d (16-bit values) -> %s" % (x1 - x0, y1 - y0, p))
  1260. self.q.put(("status", "Saved crop TIFF (%dx%d)" % (x1 - x0, y1 - y0)))
  1261.  
  1262.  
  1263. def main():
  1264. root = tk.Tk()
  1265. try:
  1266. root.call("tk", "scaling", 1.1)
  1267. except Exception:
  1268. pass
  1269. App(root)
  1270. # size to the screen so everything fits without the user resizing
  1271. sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
  1272. w, h = min(1180, sw - 120), min(860, sh - 140)
  1273. root.geometry("%dx%d+%d+%d" % (w, h, (sw - w) // 2, max(0, (sh - h) // 2 - 20)))
  1274. root.minsize(900, 600)
  1275. root.mainloop()
  1276.  
  1277.  
  1278. if __name__ == "__main__":
  1279. main()
  1280.  
Advertisement
Add Comment
Please, Sign In to add comment