Added test script for sam cam auto exposure
This commit is contained in:
@@ -0,0 +1,929 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from time import sleep, perf_counter
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from aare.common.beamline import mx_beamline
|
||||
from aare.devices.area_detector import epicsAD
|
||||
from aare.daq.devices import BeamlineDevices
|
||||
# profile_X = PV('X10SA-SAMCAM:Stats5:ProfileAverageX_RBV')
|
||||
# profile_Y = PV('X10SA-SAMCAM:Stats5:ProfileAverageY_RBV')
|
||||
# hist = PV('X10SA-SAMCAM:Stats5:Histogram_RBV')
|
||||
#
|
||||
# print(sample_cam.expo_rbv.get())
|
||||
# print(np.array(profile_X.get()).mean())
|
||||
# print(np.array(profile_Y.get()).mean())
|
||||
# print(np.array((hist.get())).mean())
|
||||
# roi_mean = PV('X10SA-SAMCAM:Stats5:MeanValue_RBV')
|
||||
# cursorY = PV('X10SA-SAMCAM:Stats5:CursorY')
|
||||
# cursorY_RBV = PV('X10SA-SAMCAM:Stats5:CursorY_RBV')
|
||||
# cursorX = PV('X10SA-SAMCAM:Stats5:CursorX')
|
||||
# cursorX_RBV = PV('X10SA-SAMCAM:Stats5:CursorX_RBV')
|
||||
|
||||
# Python
|
||||
@dataclass(frozen=True)
|
||||
class MeteringConfig:
|
||||
"""
|
||||
Metering and robustness parameters for dark-background + bright sample scenes.
|
||||
"""
|
||||
# Use central ROI to reduce chance of metering random bright junk near edges.
|
||||
# 1.0 = full frame, 0.7 = central 70% in width/height.
|
||||
center_roi: float = 0.7
|
||||
|
||||
# Dynamic threshold: thr = percentile(gray, bg_percentile) + delta_dn
|
||||
bg_percentile: float = 10.0
|
||||
delta_dn: int = 15
|
||||
|
||||
# If mask is too small, fall back to a larger ROI or whole frame.
|
||||
min_mask_fraction: float = 0.002 # 0.2% of ROI pixels
|
||||
|
||||
# Winsorization for brightness metric (NOT for clipping metric):
|
||||
# clamp values above winsor_high before computing percentiles.
|
||||
winsor_high: int = 245
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TargetConfig:
|
||||
"""
|
||||
Control objectives.
|
||||
"""
|
||||
# Hard constraint: keep saturated fraction small.
|
||||
clip_limit: float = 0.003 # 0.3% of metered pixels >= clip_level
|
||||
|
||||
# Clip threshold (8-bit): treat >=254 as "near-saturated".
|
||||
clip_level: int = 254
|
||||
|
||||
# Percentile target of metered (masked) pixels. For metallic base variability,
|
||||
# p80 is usually more stable than p90.
|
||||
percentile: float = 80.0
|
||||
percentile_target: float = 160.0
|
||||
|
||||
# Optional: if the metered pixels are too sparse, you can skip updates.
|
||||
min_metered_pixels: int = 5000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LimitsConfig:
|
||||
"""
|
||||
Exposure/gain bounds and stepping.
|
||||
"""
|
||||
exposure_min_s: float = 0.0001
|
||||
exposure_max_s: float = 1.0
|
||||
|
||||
exposure_effective_min_s: float = 0.002
|
||||
exposure_quantum_s: float = 1e-6
|
||||
|
||||
gain_min: float = 0.0
|
||||
gain_max: float = 36.0
|
||||
|
||||
# Update aggressiveness
|
||||
exposure_k: float = 0.35 # proportional factor for percentile error
|
||||
exposure_clip_drop: float = 0.7 # multiply exposure by this when clipping too high
|
||||
|
||||
gain_step: float = 1.0 # gain adjustment step when exposure hits bounds
|
||||
|
||||
# Safety: limit how fast exposure can change to avoid oscillations
|
||||
max_exposure_scale_up: float = 1.25
|
||||
max_exposure_scale_down: float = 0.75
|
||||
|
||||
|
||||
@dataclass
|
||||
class Metrics:
|
||||
bg: float
|
||||
thr: int
|
||||
n_total: int
|
||||
n_metered: int
|
||||
mask_fraction: float
|
||||
clip_frac: float
|
||||
p50: float
|
||||
p70: float
|
||||
p80: float
|
||||
p90: float
|
||||
mean: float
|
||||
|
||||
|
||||
def _center_crop(gray: np.ndarray, frac: float) -> np.ndarray:
|
||||
if frac >= 1.0:
|
||||
return gray
|
||||
if frac <= 0.0:
|
||||
raise ValueError("center_roi must be in (0, 1].")
|
||||
h, w = gray.shape[:2]
|
||||
rh = max(1, int(h * frac))
|
||||
rw = max(1, int(w * frac))
|
||||
y0 = (h - rh) // 2
|
||||
x0 = (w - rw) // 2
|
||||
return gray[y0:y0 + rh, x0:x0 + rw]
|
||||
|
||||
def _percentile_from_hist(hist: np.ndarray, percentile: float) -> int:
|
||||
"""
|
||||
hist: counts per DN bin [0..255]
|
||||
percentile: 0..100
|
||||
returns: DN value (0..255)
|
||||
"""
|
||||
total = int(hist.sum())
|
||||
if total <= 0:
|
||||
return 0
|
||||
k = int(np.ceil((percentile / 100.0) * total))
|
||||
c = np.cumsum(hist)
|
||||
return int(np.searchsorted(c, k, side="left"))
|
||||
|
||||
def _hist_u8(a: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Fast histogram for uint8 array -> length 256.
|
||||
"""
|
||||
return np.bincount(a.ravel(), minlength=256)
|
||||
|
||||
# def compute_metrics(gray: np.ndarray, met: MeteringConfig, tgt: TargetConfig) -> Metrics:
|
||||
# """
|
||||
# Compute robust metering metrics for 8-bit dark-background scenes.
|
||||
# """
|
||||
# if gray.ndim != 2:
|
||||
# raise ValueError("compute_metrics expects a 2D grayscale image.")
|
||||
# g = gray
|
||||
# if g.dtype != np.uint8:
|
||||
# # If gray is float from RGB conversion, this handles it gracefully.
|
||||
# g = np.clip(g, 0, 255).astype(np.uint8)
|
||||
#
|
||||
# roi = _center_crop(g, met.center_roi)
|
||||
# n_total = int(roi.size)
|
||||
#
|
||||
# bg = float(np.percentile(roi, met.bg_percentile))
|
||||
# thr = int(min(255, max(0, round(bg + met.delta_dn))))
|
||||
#
|
||||
# mask = roi > thr
|
||||
# n_metered = int(mask.sum())
|
||||
# mask_fraction = float(n_metered / max(1, n_total))
|
||||
#
|
||||
# # If mask is too small, broaden the ROI (fallback to full frame).
|
||||
# if mask_fraction < met.min_mask_fraction:
|
||||
# roi = g
|
||||
# n_total = int(roi.size)
|
||||
# bg = float(np.percentile(roi, met.bg_percentile))
|
||||
# thr = int(min(255, max(0, round(bg + met.delta_dn))))
|
||||
# mask = roi > thr
|
||||
# n_metered = int(mask.sum())
|
||||
# mask_fraction = float(n_metered / max(1, n_total))
|
||||
#
|
||||
# if n_metered == 0:
|
||||
# return Metrics(
|
||||
# bg=bg, thr=thr,
|
||||
# n_total=n_total, n_metered=0, mask_fraction=0.0,
|
||||
# clip_frac=0.0, p50=0.0, p70=0.0, p80=0.0, p90=0.0, mean=float(np.mean(roi)),
|
||||
# )
|
||||
#
|
||||
# vals = roi[mask]
|
||||
#
|
||||
# # Clipping fraction on raw values
|
||||
# clip_frac = float(np.mean(roi >= tgt.clip_level))
|
||||
#
|
||||
# # Winsorize only for percentile/mean (prevents glints from dominating brightness target)
|
||||
# vals_w = np.minimum(vals, np.uint8(met.winsor_high))
|
||||
#
|
||||
# return Metrics(
|
||||
# bg=bg,
|
||||
# thr=thr,
|
||||
# n_total=n_total,
|
||||
# n_metered=n_metered,
|
||||
# mask_fraction=mask_fraction,
|
||||
# clip_frac=clip_frac,
|
||||
# p50=float(np.percentile(vals_w, 50)),
|
||||
# p70=float(np.percentile(vals_w, 70)),
|
||||
# p80=float(np.percentile(vals_w, 80)),
|
||||
# p90=float(np.percentile(vals_w, 90)),
|
||||
# mean=float(np.mean(vals_w)),
|
||||
# )
|
||||
|
||||
def compute_metrics(gray: np.ndarray, met: MeteringConfig, tgt: TargetConfig) -> Metrics:
|
||||
"""
|
||||
Compute robust metering metrics for 8-bit dark-background scenes.
|
||||
|
||||
Optimized:
|
||||
- avoids np.percentile on million-pixel arrays (uses 256-bin histograms)
|
||||
- optional subsampling
|
||||
"""
|
||||
if gray.ndim != 2:
|
||||
raise ValueError("compute_metrics expects a 2D grayscale image.")
|
||||
|
||||
g = gray
|
||||
if g.dtype != np.uint8:
|
||||
g = np.clip(g, 0, 255).astype(np.uint8)
|
||||
|
||||
roi_full = _center_crop(g, met.center_roi)
|
||||
|
||||
# Optional subsampling for speed
|
||||
s = int(getattr(met, "subsample", 1))
|
||||
if s > 1:
|
||||
roi = roi_full[::s, ::s]
|
||||
else:
|
||||
roi = roi_full
|
||||
|
||||
n_total = int(roi.size)
|
||||
|
||||
# Background percentile from histogram
|
||||
hist_roi = _hist_u8(roi)
|
||||
bg_dn = _percentile_from_hist(hist_roi, met.bg_percentile)
|
||||
thr = int(min(255, max(0, bg_dn + int(met.delta_dn))))
|
||||
|
||||
mask = roi > thr
|
||||
n_metered = int(mask.sum())
|
||||
mask_fraction = float(n_metered / max(1, n_total))
|
||||
|
||||
# If mask too small, fall back to full frame (still subsampled)
|
||||
if mask_fraction < met.min_mask_fraction:
|
||||
roi_full = g
|
||||
if s > 1:
|
||||
roi = roi_full[::s, ::s]
|
||||
else:
|
||||
roi = roi_full
|
||||
n_total = int(roi.size)
|
||||
|
||||
hist_roi = _hist_u8(roi)
|
||||
bg_dn = _percentile_from_hist(hist_roi, met.bg_percentile)
|
||||
thr = int(min(255, max(0, bg_dn + int(met.delta_dn))))
|
||||
|
||||
mask = roi > thr
|
||||
n_metered = int(mask.sum())
|
||||
mask_fraction = float(n_metered / max(1, n_total))
|
||||
|
||||
# Clip fraction on ROI (not just masked)
|
||||
# Uses histogram so it is cheap.
|
||||
hist_roi = _hist_u8(roi)
|
||||
clip_bins = hist_roi[int(tgt.clip_level):].sum()
|
||||
clip_frac = float(clip_bins / max(1, n_total))
|
||||
|
||||
if n_metered == 0:
|
||||
# Approx mean from hist (avoids np.mean)
|
||||
mean_roi = float(np.dot(np.arange(256, dtype=np.float64), hist_roi) / max(1, n_total))
|
||||
return Metrics(
|
||||
bg=float(bg_dn),
|
||||
thr=thr,
|
||||
n_total=n_total,
|
||||
n_metered=0,
|
||||
mask_fraction=0.0,
|
||||
clip_frac=clip_frac,
|
||||
p50=0.0,
|
||||
p70=0.0,
|
||||
p80=0.0,
|
||||
p90=0.0,
|
||||
mean=mean_roi,
|
||||
)
|
||||
|
||||
# Histogram of masked pixels with winsorization applied:
|
||||
# - compute histogram of masked values
|
||||
# - fold bins above winsor_high into winsor_high
|
||||
vals = roi[mask]
|
||||
hist_vals = _hist_u8(vals)
|
||||
|
||||
wh = int(met.winsor_high)
|
||||
if wh < 255:
|
||||
hist_vals[wh] += hist_vals[wh + 1:].sum()
|
||||
hist_vals[wh + 1:] = 0
|
||||
|
||||
p50 = float(_percentile_from_hist(hist_vals, 50.0))
|
||||
p70 = float(_percentile_from_hist(hist_vals, 70.0))
|
||||
p80 = float(_percentile_from_hist(hist_vals, 80.0))
|
||||
p90 = float(_percentile_from_hist(hist_vals, 90.0))
|
||||
|
||||
mean_vals = float(np.dot(np.arange(256, dtype=np.float64), hist_vals) / max(1, hist_vals.sum()))
|
||||
|
||||
return Metrics(
|
||||
bg=float(bg_dn),
|
||||
thr=thr,
|
||||
n_total=n_total,
|
||||
n_metered=n_metered,
|
||||
mask_fraction=mask_fraction,
|
||||
clip_frac=clip_frac,
|
||||
p50=p50,
|
||||
p70=p70,
|
||||
p80=p80,
|
||||
p90=p90,
|
||||
mean=mean_vals,
|
||||
)
|
||||
|
||||
def _get_percentile_value(m: Metrics, percentile: float) -> float:
|
||||
if abs(percentile - 50.0) < 1e-6:
|
||||
return m.p50
|
||||
if abs(percentile - 70.0) < 1e-6:
|
||||
return m.p70
|
||||
if abs(percentile - 80.0) < 1e-6:
|
||||
return m.p80
|
||||
if abs(percentile - 90.0) < 1e-6:
|
||||
return m.p90
|
||||
# If you want arbitrary percentiles, compute them directly in compute_metrics.
|
||||
raise ValueError("This implementation supports percentile ∈ {50, 80, 90} for speed/stability.")
|
||||
|
||||
|
||||
class AutoExposureController:
|
||||
"""
|
||||
Camera-agnostic controller: you inject how to read image and how to set/get exposure/gain.
|
||||
|
||||
This keeps the logic testable and usable both in DAQ and GUI contexts.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
get_gray_image: Callable[[], np.ndarray],
|
||||
get_exposure_s: Callable[[], float],
|
||||
set_exposure_s: Callable[[float], None],
|
||||
get_gain: Callable[[], float],
|
||||
set_gain: Callable[[float], None],
|
||||
metering: MeteringConfig | None = None,
|
||||
target: TargetConfig | None = None,
|
||||
limits: LimitsConfig | None = None,
|
||||
):
|
||||
self.get_gray_image = get_gray_image
|
||||
self.get_exposure_s = get_exposure_s
|
||||
self.set_exposure_s = set_exposure_s
|
||||
self.get_gain = get_gain
|
||||
self.set_gain = set_gain
|
||||
|
||||
self.metering = metering or MeteringConfig()
|
||||
self.target = target or TargetConfig()
|
||||
self.limits = limits or LimitsConfig()
|
||||
|
||||
# simple exponential smoothing for metrics
|
||||
self._ema_clip: Optional[float] = None
|
||||
self._ema_p: Optional[float] = None
|
||||
|
||||
def _clamp(self, x: float, lo: float, hi: float) -> float:
|
||||
# Enforce "effective" minimum to avoid PV rounding to 0.
|
||||
lo_eff = max(lo, self.limits.exposure_effective_min_s)
|
||||
|
||||
x = max(lo_eff, min(hi, x))
|
||||
|
||||
q = float(self.limits.exposure_quantum_s)
|
||||
if q > 0:
|
||||
x = round(x / q) * q
|
||||
|
||||
# Re-enforce bounds after rounding
|
||||
x = max(lo_eff, min(hi, x))
|
||||
return x
|
||||
|
||||
def _settle_after_change(self, new_exp_s: float, base_settle_s: float, fps:float = 25.0) -> None:
|
||||
"""
|
||||
Wait long enough that the next acquired frame reflects the new exposure.
|
||||
|
||||
For your typical 2–100 ms exposure range, this keeps the loop fast but stable.
|
||||
"""
|
||||
t = max(base_settle_s, 1/fps, new_exp_s)
|
||||
print(t)
|
||||
t = min(t, 0.35)
|
||||
sleep(t)
|
||||
|
||||
def _set_exposure_if_changed(self, new_exp: float, current_exp: float) -> bool:
|
||||
q = float(self.limits.exposure_quantum_s) if self.limits.exposure_quantum_s > 0 else 0.0
|
||||
eps = max(1e-9, 0.5 * q)
|
||||
if abs(new_exp - current_exp) <= eps:
|
||||
return False
|
||||
self.set_exposure_s(new_exp)
|
||||
return True
|
||||
|
||||
def _set_gain_if_changed(self, new_gain: float, current_gain: float) -> bool:
|
||||
if abs(new_gain - current_gain) < 1e-6:
|
||||
return False
|
||||
self.set_gain(new_gain)
|
||||
return True
|
||||
|
||||
def pre_bracket(self,
|
||||
base_settle_s: float = 0.01,
|
||||
max_steps: int = 8,
|
||||
max_up: float = 3.0,
|
||||
max_down: float = 0.33,
|
||||
deadband_dn: float = 25.0) -> None:
|
||||
"""
|
||||
Coarse stage that avoids oscillation:
|
||||
- uses exposure *= target/measured instead of fixed multipliers
|
||||
- clamps jump size (max_up/max_down)
|
||||
- reduces jump size if error sign flips (damping)
|
||||
"""
|
||||
last_err: float | None = None
|
||||
up_lim = float(max_up)
|
||||
dn_lim = float(max_down)
|
||||
|
||||
for _ in range(max_steps):
|
||||
gray = self.get_gray_image()
|
||||
m = compute_metrics(gray, self.metering, self.target)
|
||||
|
||||
exp = float(self.get_exposure_s())
|
||||
gain = float(self.get_gain())
|
||||
|
||||
# If we can't meter yet, increase exposure but not insanely.
|
||||
if m.n_metered < self.target.min_metered_pixels:
|
||||
new_exp = self._clamp(exp * min(up_lim, 2.0), self.limits.exposure_min_s, self.limits.exposure_max_s)
|
||||
if new_exp > exp + 1e-12:
|
||||
self.set_exposure_s(new_exp)
|
||||
self._settle_after_change(new_exp, base_settle_s)
|
||||
last_err = None
|
||||
continue
|
||||
|
||||
new_gain = self._clamp(gain + self.limits.gain_step, self.limits.gain_min, self.limits.gain_max)
|
||||
self.set_gain(new_gain)
|
||||
self._settle_after_change(exp, base_settle_s)
|
||||
last_err = None
|
||||
continue
|
||||
|
||||
pv = float(_get_percentile_value(m, self.target.percentile))
|
||||
err = float(self.target.percentile_target - pv)
|
||||
|
||||
# If we're already close in the coarse sense, stop bracketing.
|
||||
if abs(err) <= float(deadband_dn) and (m.clip_frac <= self.target.clip_limit):
|
||||
break
|
||||
|
||||
# If clipping is high, reduce exposure using a bounded drop (avoid slamming to ~0).
|
||||
if m.clip_frac > self.target.clip_limit:
|
||||
# Drop factor based on severity but bounded
|
||||
r = min(5.0, m.clip_frac / max(1e-12, self.target.clip_limit)) # 1..5
|
||||
drop = 0.85 - (r - 1.0) * (0.85 - 0.55) / (5.0 - 1.0) # 0.85..0.55
|
||||
drop = max(0.50, min(0.90, drop)) # hard bounds
|
||||
new_exp = self._clamp(exp * drop, self.limits.exposure_min_s, self.limits.exposure_max_s)
|
||||
if abs(new_exp - exp) > 1e-12:
|
||||
self.set_exposure_s(new_exp)
|
||||
self._settle_after_change(new_exp, base_settle_s)
|
||||
last_err = err
|
||||
continue
|
||||
|
||||
# Anti-oscillation: if error sign flips, reduce allowed jump size
|
||||
if last_err is not None and (err == 0.0 or (err > 0) != (last_err > 0)):
|
||||
up_lim = max(1.3, up_lim * 0.5)
|
||||
dn_lim = min(0.8, dn_lim + (1.0 - dn_lim) * 0.5) # move dn_lim toward 1.0 (less aggressive down)
|
||||
|
||||
# Main coarse jump: exp *= target / measured
|
||||
# If pv is tiny, protect division.
|
||||
denom = max(1.0, pv)
|
||||
ratio = float(self.target.percentile_target / denom)
|
||||
|
||||
# Clamp jump size so we don't overreact
|
||||
ratio = max(dn_lim, min(up_lim, ratio))
|
||||
|
||||
new_exp = self._clamp(exp * ratio, self.limits.exposure_min_s, self.limits.exposure_max_s)
|
||||
if abs(new_exp - exp) > 1e-12:
|
||||
self.set_exposure_s(new_exp)
|
||||
self._settle_after_change(new_exp, base_settle_s)
|
||||
|
||||
last_err = err
|
||||
|
||||
def time_test(self):
|
||||
t0 = perf_counter()
|
||||
gray = self.get_gray_image()
|
||||
t1 = perf_counter()
|
||||
m = compute_metrics(gray, self.metering, self.target)
|
||||
t2 = perf_counter()
|
||||
exp = float(self.get_exposure_s())
|
||||
gain = float(self.get_gain())
|
||||
t3 = perf_counter()
|
||||
print("gray:", t1 - t0, "metrics:", t2 - t1, "pvs:", t3 - t2)
|
||||
|
||||
def run_once(self, settle_s: float = 0.15, max_iters: int = 12, verbose: bool = True) -> tuple[
|
||||
float, float, Metrics]:
|
||||
"""
|
||||
For stationary / after centering: iterate until within constraints (or max_iters).
|
||||
|
||||
Returns: (final_exposure, final_gain, last_metrics)
|
||||
"""
|
||||
base_settle_s = float(settle_s) # keep caller's base settle; do NOT overwrite it in-loop
|
||||
last_m = None
|
||||
|
||||
for i in range(max_iters):
|
||||
t0 = perf_counter()
|
||||
gray = self.get_gray_image()
|
||||
t1 = perf_counter()
|
||||
m = compute_metrics(gray, self.metering, self.target)
|
||||
t2 = perf_counter()
|
||||
|
||||
exp = float(self.get_exposure_s())
|
||||
gain = float(self.get_gain())
|
||||
pv = _get_percentile_value(m, self.target.percentile)
|
||||
t3 = perf_counter()
|
||||
|
||||
if verbose:
|
||||
print(
|
||||
f"[AE once {i + 1:02d}/{max_iters}] exp={exp:.6f}s gain={gain:.2f} "
|
||||
f"clip={m.clip_frac:.4f} p{int(self.target.percentile)}={pv:.1f} "
|
||||
f"mask={m.mask_fraction * 100:.2f}% thr={m.thr} n={m.n_metered} "
|
||||
f"timing(gray={t1 - t0:.3f}s metrics={t2 - t1:.3f}s pvs={t3 - t2:.3f}s)"
|
||||
)
|
||||
|
||||
if (m.clip_frac <= self.target.clip_limit) and (abs(self.target.percentile_target - pv) <= 5.0):
|
||||
break
|
||||
|
||||
# Example: measure set+settle times for exposure changes
|
||||
if m.clip_frac > self.target.clip_limit:
|
||||
r = min(5.0, m.clip_frac / max(1e-12, self.target.clip_limit))
|
||||
adaptive_drop = 0.85 - (r - 1.0) * (0.85 - 0.40) / (5.0 - 1.0)
|
||||
adaptive_drop = self._clamp(adaptive_drop, 0.35, 0.90)
|
||||
|
||||
new_exp = self._clamp(exp * adaptive_drop, self.limits.exposure_min_s, self.limits.exposure_max_s)
|
||||
|
||||
ts = perf_counter()
|
||||
self.set_exposure_s(new_exp)
|
||||
te = perf_counter()
|
||||
|
||||
tw0 = perf_counter()
|
||||
self._settle_after_change(new_exp, base_settle_s)
|
||||
tw1 = perf_counter()
|
||||
|
||||
if verbose:
|
||||
print(f" set_exp={te - ts:.3f}s settle={tw1 - tw0:.3f}s")
|
||||
continue
|
||||
|
||||
# Protect highlights
|
||||
if m.clip_frac > self.target.clip_limit:
|
||||
tr = perf_counter()
|
||||
r = min(5.0, m.clip_frac / max(1e-12, self.target.clip_limit))
|
||||
adaptive_drop = 0.85 - (r - 1.0) * (0.85 - 0.40) / (5.0 - 1.0)
|
||||
adaptive_drop = self._clamp(adaptive_drop, 0.35, 0.90)
|
||||
ta = perf_counter()
|
||||
|
||||
tne = perf_counter()
|
||||
new_exp = self._clamp(exp * adaptive_drop, self.limits.exposure_min_s, self.limits.exposure_max_s)
|
||||
ts = perf_counter()
|
||||
self.set_exposure_s(new_exp)
|
||||
te = perf_counter()
|
||||
tw1 = perf_counter()
|
||||
self._settle_after_change(new_exp, base_settle_s)
|
||||
tw0 = perf_counter()
|
||||
if verbose:
|
||||
print(f"new_exp {tne-ts}s set_exp={te - ts:.3f}s settle={tw1 - tw0:.3f}s")
|
||||
continue
|
||||
|
||||
# Percentile control
|
||||
err = float(self.target.percentile_target - pv)
|
||||
|
||||
err_fullscale = 50.0
|
||||
err_norm = min(1.0, abs(err) / err_fullscale)
|
||||
scale_up_limit = 1.12 + err_norm * (2.20 - 1.12)
|
||||
scale_dn_limit = 0.88 - err_norm * (0.88 - 0.45)
|
||||
|
||||
scale = 1.0 + self.limits.exposure_k * (err / 255.0)
|
||||
scale = self._clamp(scale, scale_dn_limit, scale_up_limit)
|
||||
tne = perf_counter()
|
||||
new_exp = self._clamp(exp * scale, self.limits.exposure_min_s, self.limits.exposure_max_s)
|
||||
ts = perf_counter()
|
||||
if abs(new_exp - exp) < 1e-12:
|
||||
# only adjust gain at limits
|
||||
if err > 5 and exp >= self.limits.exposure_max_s - 1e-12:
|
||||
new_gain = self._clamp(gain + self.limits.gain_step, self.limits.gain_min, self.limits.gain_max)
|
||||
self.set_gain(new_gain)
|
||||
self._settle_after_change(exp, base_settle_s)
|
||||
elif err < -5 and exp <= self.limits.exposure_min_s + 1e-12:
|
||||
new_gain = self._clamp(gain - self.limits.gain_step, self.limits.gain_min, self.limits.gain_max)
|
||||
self.set_gain(new_gain)
|
||||
self._settle_after_change(exp, base_settle_s)
|
||||
else:
|
||||
break
|
||||
else:
|
||||
ts2 = perf_counter()
|
||||
self.set_exposure_s(new_exp)
|
||||
te = perf_counter()
|
||||
tw1 = perf_counter()
|
||||
self._settle_after_change(new_exp, base_settle_s)
|
||||
tw0 = perf_counter()
|
||||
if verbose:
|
||||
print(f"new_exp {ts-tne}s set_exp={te - ts2:.3f}s settle={tw0 - tw1:.3f}s")
|
||||
|
||||
exp = float(self.get_exposure_s())
|
||||
gain = float(self.get_gain())
|
||||
return exp, gain, last_m if last_m is not None else compute_metrics(self.get_gray_image(), self.metering,
|
||||
self.target)
|
||||
|
||||
def run_continuous(
|
||||
self,
|
||||
duration_s: float = 10.0,
|
||||
update_hz: float = 3.0,
|
||||
ema_alpha: float = 0.4,
|
||||
verbose: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
For moving scenes: low-rate updates + EMA smoothing to avoid oscillation.
|
||||
|
||||
It will update exposure/gain at `update_hz` for `duration_s`.
|
||||
"""
|
||||
if update_hz <= 0:
|
||||
raise ValueError("update_hz must be > 0")
|
||||
dt = 1.0 / update_hz
|
||||
|
||||
t0 = perf_counter()
|
||||
k = 0
|
||||
while (perf_counter() - t0) < duration_s:
|
||||
gray = self.get_gray_image()
|
||||
m = compute_metrics(gray, self.metering, self.target)
|
||||
|
||||
# Skip if metering unreliable
|
||||
if m.n_metered < self.target.min_metered_pixels:
|
||||
if verbose:
|
||||
print(f"[AE cont] skipped (n_metered={m.n_metered})")
|
||||
sleep(dt)
|
||||
continue
|
||||
|
||||
pv = _get_percentile_value(m, self.target.percentile)
|
||||
|
||||
# EMA smoothing
|
||||
if self._ema_clip is None:
|
||||
self._ema_clip = m.clip_frac
|
||||
self._ema_p = pv
|
||||
else:
|
||||
self._ema_clip = ema_alpha * m.clip_frac + (1 - ema_alpha) * self._ema_clip
|
||||
self._ema_p = ema_alpha * pv + (1 - ema_alpha) * self._ema_p
|
||||
|
||||
exp = float(self.get_exposure_s())
|
||||
gain = float(self.get_gain())
|
||||
|
||||
if verbose:
|
||||
print(
|
||||
f"[AE cont {k:04d}] exp={exp:.6f}s gain={gain:.2f} "
|
||||
f"clip={self._ema_clip:.4f} p{int(self.target.percentile)}={self._ema_p:.1f} "
|
||||
f"mask={m.mask_fraction*100:.2f}%"
|
||||
)
|
||||
|
||||
# Control using smoothed metrics
|
||||
if self._ema_clip > self.target.clip_limit:
|
||||
new_exp = exp * self.limits.exposure_clip_drop
|
||||
new_exp = max(new_exp, exp * self.limits.max_exposure_scale_down)
|
||||
new_exp = self._clamp(new_exp, self.limits.exposure_min_s, self.limits.exposure_max_s)
|
||||
self.set_exposure_s(new_exp)
|
||||
else:
|
||||
err = float(self.target.percentile_target - self._ema_p)
|
||||
scale = 1.0 + self.limits.exposure_k * (err / 255.0)
|
||||
scale = self._clamp(scale, self.limits.max_exposure_scale_down, self.limits.max_exposure_scale_up)
|
||||
new_exp = self._clamp(exp * scale, self.limits.exposure_min_s, self.limits.exposure_max_s)
|
||||
|
||||
if abs(new_exp - exp) > 1e-12:
|
||||
self.set_exposure_s(new_exp)
|
||||
else:
|
||||
# Use gain only if pinned at exposure limit
|
||||
if err > 8 and exp >= self.limits.exposure_max_s - 1e-12:
|
||||
self.set_gain(self._clamp(gain + self.limits.gain_step, self.limits.gain_min, self.limits.gain_max))
|
||||
elif err < -8 and exp <= self.limits.exposure_min_s + 1e-12:
|
||||
self.set_gain(self._clamp(gain - self.limits.gain_step, self.limits.gain_min, self.limits.gain_max))
|
||||
|
||||
k += 1
|
||||
sleep(dt)
|
||||
|
||||
# Python
|
||||
def build_controller(sample_cam, lim: LimitsConfig, tgt: TargetConfig, met:MeteringConfig) -> AutoExposureController:
|
||||
# sample_cam should be whatever your EPICS/AD wrapper object is.
|
||||
# Important: ensure camera is in manual exposure/gain mode before control.
|
||||
|
||||
def get_gray() -> np.ndarray:
|
||||
img = sample_cam.get_image(gray=True)
|
||||
return img # may be float; controller converts/clamps to uint8
|
||||
|
||||
def get_exp() -> float:
|
||||
return float(sample_cam.expo_rbv.get())
|
||||
|
||||
def set_exp(v: float) -> None:
|
||||
sample_cam.expo.put(float(v), wait=False)
|
||||
|
||||
def get_gain() -> float:
|
||||
return float(sample_cam.gain_rbv.get())
|
||||
|
||||
def set_gain(v: float) -> None:
|
||||
sample_cam.gain.put(float(v), wait=False)
|
||||
|
||||
|
||||
|
||||
return AutoExposureController(get_gray, get_exp, set_exp, get_gain, set_gain, metering=met, target=tgt, limits=lim)
|
||||
|
||||
def _now_s() -> float:
|
||||
return perf_counter()
|
||||
|
||||
def _fmt_ms(s: float) -> str:
|
||||
return f"{s * 1000.0:.1f} ms"
|
||||
|
||||
def _condition_name(reflector_up: bool, back_light: float) -> str:
|
||||
if reflector_up and back_light > 0.91:
|
||||
return "reflector_up + backlight_max"
|
||||
if reflector_up and back_light <= 0.91:
|
||||
return "reflector_up + backlight_off"
|
||||
return "reflector_down"
|
||||
|
||||
def _select_profiles(devs) -> tuple[MeteringConfig, TargetConfig, LimitsConfig]:
|
||||
"""
|
||||
Choose metering/targets/limits based on current lighting/reflector state.
|
||||
|
||||
IMPORTANT:
|
||||
- Use a fine exposure_quantum_s for backlight if you want sub-ms exposures.
|
||||
- exposure_effective_min_s can be as low as 50 us in backlight mode (per your tests).
|
||||
"""
|
||||
# You can keep your existing metering/targets here, or define distinct profiles.
|
||||
# These are conservative defaults; tweak as needed.
|
||||
metering_front = MeteringConfig(center_roi=0.7, bg_percentile=10.0, delta_dn=15, winsor_high=230)
|
||||
targets_front = TargetConfig(clip_limit=0.01, percentile=70.0, percentile_target=160.0, min_metered_pixels=5000)
|
||||
|
||||
metering_back = MeteringConfig(center_roi=0.6, bg_percentile=10.0, delta_dn=8, winsor_high=245)
|
||||
targets_back = TargetConfig(clip_limit=0.02, percentile=70.0, percentile_target=170.0, min_metered_pixels=2000)
|
||||
|
||||
if devs.reflector_up and devs.back_light > 0.91:
|
||||
limits = LimitsConfig(
|
||||
exposure_min_s=0.00005,
|
||||
exposure_max_s=0.1,
|
||||
exposure_effective_min_s=0.00005,
|
||||
exposure_quantum_s=1e-6, # NOT 0.001: you want sub-ms capability here
|
||||
gain_min=0.0,
|
||||
gain_max=36.0,
|
||||
exposure_k=0.70,
|
||||
max_exposure_scale_up=2.2,
|
||||
max_exposure_scale_down=0.45,
|
||||
)
|
||||
return metering_back, targets_back, limits
|
||||
|
||||
if devs.reflector_up:
|
||||
limits = LimitsConfig(
|
||||
exposure_min_s=0.0005,
|
||||
exposure_max_s=0.2,
|
||||
exposure_effective_min_s=0.001, # you said 1 ms is safe in backlight mode
|
||||
exposure_quantum_s=1e-4,
|
||||
gain_min=0.0,
|
||||
gain_max=36.0,
|
||||
exposure_k=0.80,
|
||||
max_exposure_scale_up=2.2,
|
||||
max_exposure_scale_down=0.45,
|
||||
)
|
||||
return metering_back, targets_back, limits
|
||||
|
||||
limits = LimitsConfig(
|
||||
exposure_min_s=0.002,
|
||||
exposure_max_s=0.5,
|
||||
exposure_effective_min_s=0.002,
|
||||
exposure_quantum_s=1e-4,
|
||||
gain_min=0.0,
|
||||
gain_max=36.0,
|
||||
exposure_k=0.80,
|
||||
max_exposure_scale_up=2.2,
|
||||
max_exposure_scale_down=0.45,
|
||||
)
|
||||
return metering_front, targets_front, limits
|
||||
|
||||
def _bench_run_one(ctrl: AutoExposureController,
|
||||
start_exp_s: float,
|
||||
start_gain: float,
|
||||
settle_s: float,
|
||||
max_iters: int,
|
||||
label: str,
|
||||
tol_dn: float) -> dict:
|
||||
"""
|
||||
Sets starting exposure/gain, then times run_once convergence.
|
||||
"""
|
||||
# Prime starting point
|
||||
ctrl.set_gain(float(start_gain))
|
||||
ctrl.set_exposure_s(float(start_exp_s))
|
||||
ctrl._settle_after_change(float(start_exp_s), settle_s)
|
||||
|
||||
t0 = _now_s()
|
||||
end_exp, end_gain, m = ctrl.run_once(settle_s=settle_s, max_iters=max_iters, verbose=False)
|
||||
dt = _now_s() - t0
|
||||
|
||||
pv = _get_percentile_value(m, ctrl.target.percentile)
|
||||
ok = (m.clip_frac <= ctrl.target.clip_limit) and (abs(ctrl.target.percentile_target - pv) <= float(tol_dn))
|
||||
|
||||
return {
|
||||
"label": label,
|
||||
"t_s": dt,
|
||||
"start_exp_s": start_exp_s,
|
||||
"start_gain": start_gain,
|
||||
"end_exp_s": float(end_exp),
|
||||
"end_gain": float(end_gain),
|
||||
"clip": float(m.clip_frac),
|
||||
"p": float(pv),
|
||||
"mask_frac": float(m.mask_fraction),
|
||||
"ok": bool(ok),
|
||||
"n_metered": int(m.n_metered),
|
||||
"tol_dn": float(tol_dn),
|
||||
}
|
||||
|
||||
def benchmark_controller(devs,
|
||||
sample_cam,
|
||||
expected_exp_s: dict[str, float],
|
||||
tolerance_dn: dict[str, float] | None = None,
|
||||
trials: int = 5,
|
||||
settle_s: float = 0.01,
|
||||
max_iters: int = 20,
|
||||
start_gain: float = 0.0) -> None:
|
||||
"""
|
||||
Benchmarks controller convergence speed for three lighting conditions.
|
||||
|
||||
tolerance_dn: optional per-condition tolerance on the chosen percentile metric.
|
||||
"""
|
||||
cond = _condition_name(devs.reflector_up, float(devs.back_light))
|
||||
met, tgt, lim = _select_profiles(devs)
|
||||
ctrl = build_controller(sample_cam, lim=lim, tgt=tgt, met=met)
|
||||
|
||||
exp_expected = float(expected_exp_s.get(cond, 0.01))
|
||||
exp_min = float(lim.exposure_min_s)
|
||||
exp_max = float(lim.exposure_max_s)
|
||||
|
||||
tol_dn = 5.0 if tolerance_dn is None else float(tolerance_dn.get(cond, 5.0))
|
||||
|
||||
starts = [
|
||||
("start=min", exp_min),
|
||||
("start=expected", exp_expected),
|
||||
("start=max", exp_max),
|
||||
]
|
||||
|
||||
print(f"\n=== Benchmark: {cond} ===")
|
||||
print(f"limits: exp[{lim.exposure_min_s} .. {lim.exposure_max_s}] effective_min={lim.exposure_effective_min_s} quantum={lim.exposure_quantum_s}")
|
||||
print(f"target: p{int(tgt.percentile)}={tgt.percentile_target} clip_limit={tgt.clip_limit} tol=±{tol_dn:.1f}DN settle_s(base)={settle_s} max_iters={max_iters} trials={trials}")
|
||||
|
||||
results: list[dict] = []
|
||||
for label, start_exp in starts:
|
||||
for k in range(trials):
|
||||
r = _bench_run_one(
|
||||
ctrl=ctrl,
|
||||
start_exp_s=start_exp,
|
||||
start_gain=start_gain,
|
||||
settle_s=settle_s,
|
||||
max_iters=max_iters,
|
||||
label=label,
|
||||
tol_dn=tol_dn,
|
||||
)
|
||||
r["trial"] = k + 1
|
||||
results.append(r)
|
||||
|
||||
# Print summary
|
||||
for label, _ in starts:
|
||||
rr = [r for r in results if r["label"] == label]
|
||||
times = np.array([r["t_s"] for r in rr], dtype=float)
|
||||
ok_rate = sum(1 for r in rr if r["ok"]) / max(1, len(rr))
|
||||
print(
|
||||
f"{label:14s} mean={_fmt_ms(times.mean())} p95={_fmt_ms(np.percentile(times, 95))} "
|
||||
f"min={_fmt_ms(times.min())} max={_fmt_ms(times.max())} ok={ok_rate*100:.0f}%"
|
||||
)
|
||||
|
||||
print("\nlabel,trial,t_ms,start_exp_ms,end_exp_ms,start_gain,end_gain,clip,p,mask_frac,tol_dn,ok,n_metered")
|
||||
for r in results:
|
||||
print(
|
||||
f"{r['label']},{r['trial']},{r['t_s']*1000.0:.3f},"
|
||||
f"{r['start_exp_s']*1000.0:.6f},{r['end_exp_s']*1000.0:.6f},"
|
||||
f"{r['start_gain']:.2f},{r['end_gain']:.2f},"
|
||||
f"{r['clip']:.6f},{r['p']:.3f},{r['mask_frac']:.6f},{r['tol_dn']:.1f},{int(r['ok'])},{r['n_metered']}"
|
||||
)
|
||||
|
||||
|
||||
sample_cam = epicsAD(f"{mx_beamline().name.upper()}-SAMCAM:")
|
||||
devs = BeamlineDevices(mx_beamline())
|
||||
metering = MeteringConfig(center_roi=0.7, bg_percentile=10.0, delta_dn=15, winsor_high=230)
|
||||
targets = TargetConfig(clip_limit=0.01, percentile=70.0, percentile_target=160.0)
|
||||
|
||||
|
||||
#
|
||||
if devs.reflector_up and devs.back_light > 0.91:
|
||||
limits = LimitsConfig(exposure_min_s=0.00005, exposure_max_s=0.1, exposure_effective_min_s=0.00005,
|
||||
exposure_quantum_s=1e-6,
|
||||
gain_min=0.0, gain_max=36.0, exposure_k=0.70, max_exposure_scale_up=1.6,
|
||||
max_exposure_scale_down=0.6)
|
||||
metering = MeteringConfig(
|
||||
center_roi=0.6,
|
||||
bg_percentile=10.0,
|
||||
delta_dn=8,
|
||||
winsor_high=245,
|
||||
)
|
||||
targets = TargetConfig(
|
||||
clip_limit=0.02, # allow more clipping (bright field can clip)
|
||||
percentile=70.0,
|
||||
percentile_target=170.0,
|
||||
min_metered_pixels=2000, # can be lower because backlight mask is usually strong
|
||||
)
|
||||
|
||||
elif devs.reflector_up:
|
||||
limits = LimitsConfig(exposure_min_s=0.0005, exposure_max_s=0.2, exposure_effective_min_s=0.002,
|
||||
exposure_quantum_s=0.001,
|
||||
gain_min=0.0, gain_max=36.0, exposure_k=0.80, max_exposure_scale_up=1.6,
|
||||
max_exposure_scale_down=0.6)
|
||||
else:
|
||||
limits = LimitsConfig(exposure_min_s=0.002, exposure_max_s=0.5, exposure_effective_min_s=0.002,
|
||||
exposure_quantum_s=0.001,
|
||||
gain_min=0.0, gain_max=36.0, exposure_k=0.80, max_exposure_scale_up=1.6,
|
||||
max_exposure_scale_down=0.6)
|
||||
targets = TargetConfig(
|
||||
clip_limit=0.01,
|
||||
percentile=70.0,
|
||||
percentile_target=160.0,
|
||||
min_metered_pixels=5000,
|
||||
)
|
||||
metering = MeteringConfig(
|
||||
center_roi=0.7,
|
||||
bg_percentile=10.0,
|
||||
delta_dn=15,
|
||||
winsor_high=230,
|
||||
)
|
||||
ctrl = build_controller(sample_cam, lim=limits, tgt=targets, met=metering)
|
||||
|
||||
# After centering and stationary:
|
||||
st = time.perf_counter()
|
||||
exp, gain, m = ctrl.run_once(settle_s=0.0, max_iters=20, verbose=True)
|
||||
print(f"run_once took {time.perf_counter() - st} s")
|
||||
|
||||
# expected = {
|
||||
# "reflector_up + backlight_max": 0.001, # ~1 ms typical
|
||||
# "reflector_up + backlight_off": 0.025, # adjust based on your observed behaviour
|
||||
# "reflector_down": 0.052, # 52 ms you reported
|
||||
# }
|
||||
# tolerance = {
|
||||
# "reflector_up + backlight_max": 5.0,
|
||||
# "reflector_up + backlight_off": 5.0,
|
||||
# "reflector_down": 5.0,
|
||||
# }
|
||||
# benchmark_controller(devs=devs, sample_cam=sample_cam, expected_exp_s=expected, trials=5, settle_s=0.01, max_iters=20, start_gain=0.0)
|
||||
Reference in New Issue
Block a user