package com.elphel.imagej.cuas.rt;
/**
 **
 ** CuasConditioning.java - lean per-sensor image conditioning for the CUAS RT / tile-processor path.
 **
 ** Copyright (C) 2026 Elphel, Inc.
 **
 ** -----------------------------------------------------------------------------**
 **
 **  CuasConditioning.java is free software: you can redistribute it and/or modify
 **  it under the terms of the GNU General Public License as published by
 **  the Free Software Foundation, either version 3 of the License, or
 **  (at your option) any later version.
 **
 **  This program is distributed in the hope that it will be useful,
 **  but WITHOUT ANY WARRANTY; without even the implied warranty of
 **  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 **  GNU General Public License for more details.
 **
 **  You should have received a copy of the GNU General Public License
 **  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 ** -----------------------------------------------------------------------------**
 **
 */

import com.elphel.imagej.common.DoubleGaussianBlur;
import com.elphel.imagej.cuas.CuasMotion;
import com.elphel.imagej.gpu.GpuQuad;
import com.elphel.imagej.tileprocessor.QuadCLT;

/**
 * Lean, self-contained per-sensor conditioning for the CUAS real-time / tile-processor path.
 * By Claude on 06/30/2026.
 *
 * Deliberately bypasses the heavy QuadCLT "conditioning over conditioning" instance-array path:
 * it operates directly on raw jp4-domain per-sensor pixel arrays and the (borrowed) calibration
 * values pulled from a QuadCLT (lwir_scales / lwir_offsets / lwir_scales2 and the per-pixel FPN).
 *
 * Linear conditioning (per sensor), applied in place:
 *         corrected = scales2*(raw-offset)^2 + scale*(raw-offset)    - FPN(x,y)
 *      matching the existing model (QuadCLT.calibratePhotometric2 forward form) so that with
 *      scales2==0 (the abandoned 2nd-order term) it is bit-identical to today's additive path.
 *      The native application already consumes full per-pixel scale and offset maps.
 *      Today's calibration fills the scale map uniformly per sensor; the future slow
 *      background calibration may replace those values with per-pixel dust/sun-burn gains.
 *
 * Row/Col has two explicitly distinct paths. The legacy direct reader estimates it on
 * conditioned pixels after photometric/FPN. The preloaded RT path reuses the borrowed
 * per-scene profiles (already calculated with the previous corr-xml calibration), map them
 * back to raw units, and apply them during setup before the timed linear stage.
 */
public class CuasConditioning {
	/** One resident calibration record per native GPU instance. The FPN object is
	 * shared by all spawned scenes; scales/offsets are cloned per scene, so their
	 * small value arrays are compared rather than their identities. */
	private static final java.util.WeakHashMap<GpuQuad, ConditioningCache> GPU_CONDITIONING =
			new java.util.WeakHashMap<GpuQuad, ConditioningCache>();
	private static class ConditioningCache {
		final Object fpn_key;
		final double [] scales;
		final double [] offsets;
		final long dc_bits;
		final int num_pixels;
		ConditioningCache(Object fpn_key, double [] scales, double [] offsets, double dc, int num_pixels) {
			this.fpn_key = fpn_key;
			this.scales = scales;
			this.offsets = offsets;
			this.dc_bits = Double.doubleToRawLongBits(dc);
			this.num_pixels = num_pixels;
		}
		boolean matches(Object fpn_key, double [] scales, double [] offsets, double dc, int num_pixels) {
			return (this.fpn_key == fpn_key) && (this.num_pixels == num_pixels) &&
					(this.dc_bits == Double.doubleToRawLongBits(dc)) &&
					java.util.Arrays.equals(this.scales, scales) &&
					java.util.Arrays.equals(this.offsets, offsets);
		}
	}

	public static class Config {
		public boolean gpu_enable           = true;   // use native two-map kernel when the timed stage is strictly linear; false = CPU linear oracle A/B // By Codex on 07/15/2026
		public boolean rowcol_enable        = true;   // legacy direct-reader estimator on conditioned pixels; the preloaded RT path consumes this choice during setup, applies existing profiles only, then disables it for the timed stage // By Claude on 07/07/2026, separation by Codex on 07/15/2026
		public boolean rowcol_hpf           = true;   // high-pass the 1-D avg profile before subtracting - applies ONLY when rowcol_um_sigma==0 (the UM measurement already band-limits; stacking both under-removed mid-band lines x2, see subtractRowColSensor) // By Claude on 07/07/2026
		public double  rowcol_hpf_sigma     = 8.0;    // px; ~ the LoG scale (KR3-ish) - noise band the LoG would pass
		public double  rowcol_um_sigma      = 10.0;   // UM sigma for the profile MEASUREMENT (content suppression borrowed from the oracle CorrectionFPN path: gate works on residual counts); 0 = measure on the data as-is (pre-07/07 behavior) // By Claude on 07/07/2026
		public double  rowcol_max_abs       = 50.0;   // only average pixels with |UM residual| within this (outlier reject, calibrated counts in the legacy estimator); +INF = no gate // By Claude on 07/07/2026
		public double  rowcol_weight_outlier= 0.01;   // small weight for out-of-range pixels
		public double  rowcol_min_inlier    = 0.33;   // minimal INLIER (|UM residual| <= max_abs) pixel fraction of a line to trust its estimate; below it (content-dominated line - e.g. terrain rows, where the outlier-weighted mean degenerates to the CONTENT mean and stamps bias bands, measured +10..22 counts in ROWCOL-04 terrain) the line gets ZERO correction. Also |estimate| > max_abs -> 0 (physical cap: line noise cannot exceed the gate scale). // By Claude on 07/07/2026
		public boolean photometric          = true;   // apply scale*(raw-offset) [+ scales2 quadratic]
		public boolean apply_fpn            = true;    // subtract per-pixel FPN (the offset map's per-pixel part)
		// Zero-DC (Andrey 2026-07-05, restoring a lost pre-CUAS invariant): the MCLT has no
		// clean DC/AC separation - a DC constant spreads over many TD points, costing float32
		// mantissa bits under the small AC signal (LWIR: ~100 counts/K, typical scene AC
		// ~300 counts = ~3K, current DC ~ -3500). Subtract ONE constant from ALL sensors
		// (pure shift - never rescale, counts stay physically meaningful; a common constant
		// preserves the photometric equalization). dc_level = the constant to subtract;
		// NaN = AUTO (the global mean over all sensors of this scene). A calibration-
		// maintained fixed level (no scene-to-scene DC flicker) is the production target.
		public boolean zero_dc              = true;   // By Claude on 07/05/2026
		public double  dc_level             = Double.NaN; // the PER-SEQUENCE constant (CuasRT.sequenceDcLevel - RT-STATE/appropriateCenter). NaN = per-scene sky median FALLBACK ONLY: it oscillates with rotation (terrain sweeps the sky half; 234-count pk-pk @ 1 rev period, POSE-10) and downstream temporal averages turn that into content ghosts. // By Claude on 07/05/2026
		public Config() {}
	}

	/**
	 * Build a Config from the curt dialog parameters (roadmap RT step 2a: Row/Col optional/
	 * tunable - bypass when the FLIR internal correction is verified sufficient). The ONE
	 * place mapping curt.rowcol_* to Config so all RT ingest call sites stay consistent;
	 * defaults (rowcol_en=true, max_abs=0->INFINITY) reproduce the previous hardcoded
	 * Config bit-exactly. By Claude on 07/07/2026, from Andrey's plan.
	 */
	public static Config configFromCurt(com.elphel.imagej.tileprocessor.CuasRtParameters curt) { // By Claude on 07/07/2026
		final Config cfg = new Config();
		cfg.gpu_enable           = curt.cond_gpu; // By Codex on 07/15/2026
		cfg.rowcol_enable         = curt.rowcol_en;
		cfg.rowcol_hpf            = curt.rowcol_hpf;
		cfg.rowcol_hpf_sigma      = curt.rowcol_hpf_sigma;
		cfg.rowcol_um_sigma       = curt.rowcol_um_sigma; // By Claude on 07/07/2026
		cfg.rowcol_max_abs        = (curt.rowcol_max_abs > 0) ? curt.rowcol_max_abs : Double.POSITIVE_INFINITY;
		cfg.rowcol_weight_outlier = curt.rowcol_outlier;
		return cfg;
	}

	/**
	 * Prepare the native production two-map calibration once. The current direct-reader
	 * path is valid only with its legacy per-frame Row/Col estimator disabled; the
	 * preloaded path applies borrowed Row/Col profiles during setup and passes a
	 * linear-only Config here. Fixed (or disabled) DC and no quadratic term are also
	 * required. The CPU implementation remains the fallback/oracle for every other
	 * configuration and for pose_corr capture.
	 * By Codex on 07/15/2026.
	 */
	private static synchronized boolean prepareGpuConditioning(
			final GpuQuad      gpuQuad,
			final int          num_sens,
			final int          num_pixels,
			final double []     scales_in,
			final double []     offsets_in,
			final double []     scales2,
			final double [][][] fpn,
			final Config        cfg) {
		if (!cfg.gpu_enable || !gpuQuad.supportsConditioningMaps() || cfg.rowcol_enable ||
				com.elphel.imagej.gpu.testdata.PoseCorrExport.isArmed()) return false;
		if (cfg.zero_dc && Double.isNaN(cfg.dc_level)) return false; // AUTO median stays in the CPU oracle
		if (cfg.photometric && (scales2 != null)) for (double a : scales2) if (a != 0.0) return false;
		if ((num_sens <= 0) || (num_pixels <= 0)) return false;
		if (((scales_in != null) && (scales_in.length < num_sens)) ||
				((offsets_in != null) && (offsets_in.length < num_sens)) ||
				((scales2 != null) && (scales2.length < num_sens)) ||
				(cfg.apply_fpn && (fpn != null) && (fpn.length < num_sens))) return false;
		final double dc = cfg.zero_dc ? cfg.dc_level : 0.0;
		final double [] scales = new double [num_sens];
		final double [] offsets = new double [num_sens];
		for (int s = 0; s < num_sens; s++) {
			scales[s] = cfg.photometric && (scales_in != null) ? scales_in[s] : 1.0;
			offsets[s] = cfg.photometric && (offsets_in != null) ? offsets_in[s] : 0.0;
			if (cfg.apply_fpn && (fpn != null) && ((fpn[s] == null) || (fpn[s][0] == null) ||
					(fpn[s][0].length != num_pixels))) return false;
		}
		final Object fpn_key = cfg.apply_fpn ? fpn : null;
		final ConditioningCache cached = GPU_CONDITIONING.get(gpuQuad);
		if ((cached != null) && cached.matches(fpn_key, scales, offsets, dc, num_pixels)) return true;

		final float [] scale_maps = new float [num_sens * num_pixels];
		final float [] offset_maps = new float [scale_maps.length];
		for (int s = 0; s < num_sens; s++) {
			final double scale = scales[s];
			final double base = scale * offsets[s];
			final double [] fp = (cfg.apply_fpn && (fpn != null)) ? fpn[s][0] : null;
			final int base_index = s * num_pixels;
			for (int i = 0; i < num_pixels; i++) {
				scale_maps[base_index + i] = (float) scale;
				offset_maps[base_index + i] = (float) (base + ((fp != null) ? fp[i] : 0.0) + dc);
			}
		}
		if (!gpuQuad.setConditioningMaps(scale_maps, offset_maps)) return false;
		GPU_CONDITIONING.put(gpuQuad, new ConditioningCache(fpn_key, scales, offsets, dc, num_pixels));
		System.out.println("CuasConditioning: GPU two-map conditioning active (timed stage linear, resident calibration, dc="+
				dc+")");
		return true;
	}

	/**
	 * Apply borrowed per-scene Row/Col profiles to RAW sensor pixels in place.
	 * The profiles were calculated on images already equalized with the previous
	 * corr-xml calibration, so they are expressed in calibrated-count units. For
	 * the linear model, moving their subtraction to the camera/raw boundary is:
	 *
	 *   raw_corrected = raw - (row[x] + col[y]) / calibration_scale
	 *
	 * The calibration offset cancels exactly. A nonzero quadratic term is outside
	 * this contract. The RT preloader calls this while the previous scales are still
	 * attached to the scenes, before current-run photometric recalibration and before
	 * the timed CPU/GPU linear-conditioning loop.
	 * By Codex on 07/15/2026 from Andrey's calibration-provenance clarification.
	 */
	public static void applyCalibratedRowColToRaw(
			double [][][] image_data,
			int           width,
			double [][]   row,
			double [][]   col,
			double []     calibration_scales) {
		if ((image_data == null) || (row == null) || (col == null) || (calibration_scales == null)) {
			throw new IllegalArgumentException("Row/Col raw preprocessing requires image, row, col and calibration scales");
		}
		for (int s = 0; s < image_data.length; s++) {
			if ((image_data[s] == null) || (image_data[s].length == 0) || (image_data[s][0] == null)) continue;
			if ((s >= row.length) || (s >= col.length) || (s >= calibration_scales.length) ||
					(row[s] == null) || (col[s] == null)) {
				throw new IllegalArgumentException("Incomplete Row/Col calibration for sensor "+s);
			}
			final double [] px = image_data[s][0];
			if ((width <= 0) || ((px.length % width) != 0) || (row[s].length != width) ||
					(col[s].length != px.length / width)) {
				throw new IllegalArgumentException("Row/Col dimensions do not match raw sensor "+s);
			}
			final double scale = calibration_scales[s];
			if (!Double.isFinite(scale) || (scale == 0.0)) {
				throw new IllegalArgumentException("Invalid Row/Col calibration scale for sensor "+s+": "+scale);
			}
			final double inv_scale = 1.0 / scale;
			int i = 0;
			for (int y = 0; y < col[s].length; y++) {
				final double cy = col[s][y];
				for (int x = 0; x < width; x++) {
					px[i++] -= (row[s][x] + cy) * inv_scale;
				}
			}
		}
	}

	/**
	 * Complete legacy CPU conditioning oracle. Its per-frame Row/Col estimator
	 * remains after photometric/FPN to preserve validated direct-reader behavior.
	 * The preloaded RT path does not use that estimator: it applies the borrowed
	 * profiles at setup with {@link #applyCalibratedRowColToRaw}, disables
	 * rowcol_enable for the timed stage, then calls {@link #conditionLinear}.
	 */
	public static void condition(
			double [][][] image_data,
			int           width,
			double []     scales,
			double []     offsets,
			double []     scales2,
			double [][][] fpn,
			Config        cfg) {
		if (cfg == null) cfg = new Config();
		conditionPhotometricFpn(image_data, scales, offsets, scales2, fpn, cfg);
		if (cfg.rowcol_enable) {
			for (int s = 0; s < image_data.length; s++) {
				if ((image_data[s] != null) && (image_data[s].length > 0) && (image_data[s][0] != null)) {
					subtractRowColSensor(image_data[s][0], width, cfg);
				}
			}
		}
		subtractCommonDc(image_data, width, cfg);
	}

	/**
	 * Apply only scale/offset/FPN/common-DC conditioning to all sensors in place.
	 * Row/Col is not inspected here; callers may pre-apply it to raw input.
	 * @param image_data [sensor][0][pixel] mono LWIR, row-major (idx = y*width + x). Modified in place.
	 * @param width      image width in pixels (height = pixels/width).
	 * @param scales     per-sensor global scale  (lwir_scales;  null -> 1.0).
	 * @param offsets    per-sensor global offset (lwir_offsets; null -> 0.0), subtracted before scaling.
	 * @param scales2    per-sensor 2nd-order term (lwir_scales2; null -> 0.0; normally unused).
	 * @param fpn        [sensor][0][pixel] per-pixel additive FPN, subtracted after photometric (null -> none).
	 * @param cfg        configuration (null -> defaults).
	 */
	public static void conditionLinear(
			double [][][] image_data,
			int           width,
			double []     scales,
			double []     offsets,
			double []     scales2,
			double [][][] fpn,
			Config        cfg) {
		if (cfg == null) cfg = new Config();
		conditionPhotometricFpn(image_data, scales, offsets, scales2, fpn, cfg);
		subtractCommonDc(image_data, width, cfg);
	}

	private static void conditionPhotometricFpn(
			double [][][] image_data,
			double []     scales,
			double []     offsets,
			double []     scales2,
			double [][][] fpn,
			Config        cfg) {
		final int num_sens = image_data.length;
		for (int s = 0; s < num_sens; s++) if (image_data[s] != null && image_data[s][0] != null) {
			final double [] px = image_data[s][0];
			if (cfg.photometric || cfg.apply_fpn) {
				final double B0 = (scales  != null) ? scales [s] : 1.0;
				final double C0 = (offsets != null) ? offsets[s] : 0.0;
				final double A0 = (scales2 != null) ? scales2[s] : 0.0;
				final double [] fp = (cfg.apply_fpn && fpn != null && fpn[s] != null) ? fpn[s][0] : null;
				for (int i = 0; i < px.length; i++) {
					double p = px[i];
					if (cfg.photometric) {
						final double d = p - C0;
						p = A0 * d * d + B0 * d;   // == B0*(raw-C0) when A0==0 (linear, matches current additive path)
					}
					if (fp != null) p -= fp[i];
					px[i] = p;
				}
			}
		}
	}

	private static void subtractCommonDc(
			double [][][] image_data,
			int           width,
			Config        cfg) {
		final int num_sens = image_data.length;
		// Zero-DC: one common constant subtracted from ALL sensors (see Config docs).
		// AUTO level = MEDIAN OF THE TOP HALF of the frame (y < height/2), pooled over all
		// sensors: for CUAS the sky background is what matters, and the median is robust to
		// both the (snowy March Latvia vs summer-calibration) ground and to targets. The
		// same level must be subtracted from the BORROWED averaged center image at borrow
		// time (firewall: borrowed data zeroed once at the boundary, never re-compensated).
		// By Claude on 07/05/2026, from Andrey's design.
		if (cfg.zero_dc) {
			double level = cfg.dc_level;
			if (Double.isNaN(level)) { // auto: median of the top (sky) half, all sensors pooled
				final int height = ((image_data[0] != null) && (image_data[0][0] != null)) ?
						image_data[0][0].length / width : 0;
				final int top_pix = (height / 2) * width;
				double [] samples = new double [num_sens * top_pix];
				int n = 0;
				for (int s = 0; s < num_sens; s++) if (image_data[s] != null && image_data[s][0] != null) {
					final double [] px = image_data[s][0];
					for (int i = 0; i < top_pix; i++) if (!Double.isNaN(px[i])) samples[n++] = px[i];
				}
				if (n == 0) return;
				java.util.Arrays.sort(samples, 0, n);
				level = samples[n/2];
			}
			for (int s = 0; s < num_sens; s++) if (image_data[s] != null && image_data[s][0] != null) {
				final double [] px = image_data[s][0];
				for (int i = 0; i < px.length; i++) px[i] -= level;
			}
		}
	}

	/**
	 * Pure data-domain appropriation kernel for the BORROWED center CLT: subtract
	 * level*pattern per TD point, NaN-aware (only where both terms are defined). Knows
	 * NOTHING of QuadCLT/GPU - the caller (CuasRT initialization, the borrow boundary)
	 * extracts the arrays and stores the result back. Returns a NEW array; the input is
	 * not modified. By Claude on 07/05/2026, per Andrey's clean-method ruling.
	 * @param fclt    borrowed center CLT (single merged-sensor slice, tile-major TD points)
	 * @param pattern CLT of a uniform 1.0 image over the same task tiles (NaN elsewhere)
	 * @param level   DC level to subtract (counts)
	 * @return zeroed copy of fclt (fclt - level*pattern where both defined)
	 */
	public static float [] zeroDcClt(
			final float [] fclt,
			final float [] pattern,
			final double   level) {
		final float [] zeroed = fclt.clone();
		final int len = Math.min(zeroed.length, pattern.length);
		int n_off = 0;
		for (int i = 0; i < len; i++) {
			final float p = pattern[i];
			if (!Float.isNaN(p) && !Float.isNaN(zeroed[i])) {
				zeroed[i] -= (float) (level * p);
				n_off++;
			}
		}
		System.out.println("CuasConditioning.zeroDcClt(): CLT offset by "+level+
				" over "+n_off+" TD points");
		return zeroed;
	}

	/**
	 * The A2/RT ingest: read a scene's RAW /jp4/ source, condition it with the CURRENT
	 * calibration (curt_calib-updated lwir scales/offsets/scales2 + per-pixel FPN from the
	 * scene QuadCLT), and feed the GPU DIRECTLY - completely bypassing the QuadCLT
	 * image_data mechanics (per Andrey: this is the permanent RT shape, especially as more
	 * data becomes GPU-memory-resident; QuadCLT stays for geometry/poses only).
	 *
	 * GPU handoff contract (validated by the bayer-guard fix, ebef0b23): bind the scene
	 * FIRST (saveQuadClt - conditional, resets the guard only on instance switch), clear
	 * hasNewImageData, then FORCE-upload - setBayerImages(data,true) sets bayer_set, so the
	 * pipeline's later pull-variant setBayerImages(false) skips its re-pull and the
	 * conditioned pixels survive through convert_direct. TELL if this ever breaks: the
	 * downstream results become identical to the prepared-data path (silent re-pull).
	 *
	 * By Claude on 07/05/2026, from Andrey's design.
	 * @param scene      physical scene: /jp4/ source, calibration globals, FPN, geometry
	 * @param cfg        conditioning configuration (null - defaults: rowcol+photometric+FPN)
	 * @param threadsMax max read threads
	 * @param debugLevel debug verbosity
	 * @return true on success; false if raw source missing (GPU state untouched)
	 */
	public static boolean conditionSceneToGpu(
			final QuadCLT scene,
			final Config  cfg,
			final int     threadsMax,
			final int     debugLevel) {
		return conditionSceneToGpuCuas(scene, cfg, null, null, threadsMax, debugLevel);
	}

	/**
	 * CUAS wrapper: the ONE place where per-scene MOTION enters the RT ingest (Andrey's
	 * design 2026-07-05). Sets the scene's ERS rates (when given) before the ingest so the
	 * downstream grid transform (transformToScenePxPyD -> ErsCorrection, the fully-preserved
	 * RGB-proven path) applies the rolling-shutter correction; omegas==null &&
	 * velocities==null leaves the scene's current ERS state untouched (legacy behavior).
	 * For CUAS: omegas from the wobble radius + rotation RPM (analytic, per scene phase);
	 * velocities - PROVISION only, pass {0,0,0} (or null) for the rotation-only camera.
	 * FUTURE (GPU migration): the same omegas/velocities feed the GPU corr_vector
	 * imu_rot/imu_move (get_tiles_offsets has per-sensor ERS support, currently dormant) -
	 * to be enabled after verifying no double-application against the CPU pXpYD ERS.
	 * By Claude on 07/05/2026, from Andrey's design.
	 * @param omegas     angular rates {az, tilt, roll} rad/s, scene-vs-virtual-center
	 *                   relative (wobble-scale; the constant-rotation part cancels against
	 *                   the co-rotating center) - or null (with velocities null: keep state)
	 * @param velocities linear rates {vx, vy, vz} m/s (provision; null/{0,0,0} for CUAS)
	 */
	public static boolean conditionSceneToGpuCuas(
			final QuadCLT   scene,
			final Config    cfg,
			final double [] omegas,
			final double [] velocities,
			final int       threadsMax,
			final int       debugLevel) {
		if ((omegas != null) || (velocities != null)) {
			scene.getErsCorrection().setErsDt(
					(velocities != null) ? velocities : new double [3],
					(omegas != null)     ? omegas     : new double [3]);
		}
		return conditionSceneToGpuInner(scene, cfg, threadsMax, debugLevel);
	}

	private static boolean conditionSceneToGpuInner(
			final QuadCLT scene,
			final Config  cfg,
			final int     threadsMax,
			final int     debugLevel) {
		// B0 conditioning attribution (one-shot, 3-B design 2026-07-16): direct-path
		// twin of the preloaded split below. By Claude on 07/16/2026.
		final boolean b0_report = !b0_condition_reported;
		final long b0_t0 = b0_report ? System.nanoTime() : 0L;
		final double [][][] data = CuasMotion.readRawImageData(scene, threadsMax, debugLevel);
		if (data == null) {
			return false;
		}
		final long b0_t1 = b0_report ? System.nanoTime() : 0L;
		final int width = scene.getGeometryCorrection().getSensorWH()[0];
		final Config use_cfg = (cfg != null) ? cfg : new Config();
		final GpuQuad gpuQuad = scene.getGPUQuad();
		boolean gpu_condition = prepareGpuConditioning(
				gpuQuad, data.length, data[0][0].length,
				scene.getLwirScales(), scene.getLwirOffsets(),
				scene.getLwirScales2(), scene.getFPN(), use_cfg);
		if (!gpu_condition) {
			condition(
					data,                    // double [][][] image_data (in place)
					width,                   // int           width
					scene.getLwirScales(),   // double []     scales
					scene.getLwirOffsets(),  // double []     offsets
					scene.getLwirScales2(),  // double []     scales2
					scene.getFPN(),          // double [][][] fpn (null - none)
					use_cfg);                 // Config
		}
		scene.saveQuadClt();                 // bind this scene (conditional; resets bayer guard on switch)
		scene.setHasNewImageData(false);     // the guard's other re-pull trigger - must be clear
		final long b0_t2 = b0_report ? System.nanoTime() : 0L;
		gpuQuad.setBayerImages(data, true);  // CPU path: conditioned; GPU path: raw (linear-only Config)
		if (b0_report) {
			b0_condition_reported = true;
			System.out.println(String.format(
					"CuasConditioning B0 direct split (one-shot): read+decode=%.3f ms,"+
					" prepare+cpu-condition=%.3f ms (gpu_condition=%b), setBayerImages H2D=%.3f ms",
					(b0_t1-b0_t0)*1e-6, (b0_t2-b0_t1)*1e-6, gpu_condition,
					(System.nanoTime()-b0_t2)*1e-6));
		}
		if (gpu_condition && !gpuQuad.execConditioning()) {
			// Fail safe: rebuild the known CPU result and replace the raw device image.
			condition(data, width, scene.getLwirScales(), scene.getLwirOffsets(),
					scene.getLwirScales2(), scene.getFPN(), use_cfg);
			gpuQuad.setBayerImages(data, true);
			gpu_condition = false;
		}
		// pose_corr kernel-test capture: the conditioned data is uploaded directly (never
		// stored in the QuadCLT), so the exporter takes its bit-exact copy here. No-op
		// unless the curt.kernel_test=pose_corr run armed it. By Claude on 07/13/2026.
		// An armed pose_corr capture deliberately forced the CPU path above, so data
		// is conditioned here. Normal GPU runs leave data raw and the no-op exporter
		// returns before looking at it.
		com.elphel.imagej.gpu.testdata.PoseCorrExport.offerImages(scene, data);
		return true;
	}

	/**
	 * Upload resident linear calibration maps before the measured loop. Returns
	 * false when the selected configuration must use the CPU oracle.
	 */
	public static boolean preparePreloadedConditioning(
			final QuadCLT scene,
			final float [][] raw,
			final Config cfg) {
		if ((scene == null) || (raw == null) || (raw.length == 0) || (raw[0] == null) || (cfg == null)) return false;
		return prepareGpuConditioning(
				scene.getGPUQuad(), raw.length, raw[0].length,
				scene.getLwirScales(), scene.getLwirOffsets(), scene.getLwirScales2(), scene.getFPN(), cfg);
	}

	/**
	 * Upload one immutable preloaded raw scene and execute only the timed linear
	 * conditioning stage. Borrowed Row/Col profiles, when needed, were already
	 * applied before this call. The CPU backend writes into caller-owned scratch;
	 * the preloaded source is never mutated, so bootstrap/repeated passes are safe.
	 * Fixed sequence DC and scales2==0 are required by the preloaded RT contract.
	 * By Codex on 07/15/2026.
	 */
	public static boolean conditionPreloadedSceneToGpu(
			final QuadCLT   scene,
			final float [][] raw,
			final float [][] cpu_scratch,
			final Config    cfg) {
		if ((scene == null) || (raw == null) || (raw.length == 0) || (raw[0] == null)) return false;
		final Config use_cfg = (cfg != null) ? cfg : new Config();
		if (use_cfg.rowcol_enable) {
			throw new IllegalArgumentException("Preloaded Row/Col must be applied during setup, not in the timed stage");
		}
		if (use_cfg.zero_dc && Double.isNaN(use_cfg.dc_level)) {
			throw new IllegalArgumentException("Preloaded conditioning requires a fixed sequence DC level");
		}
		final int num_sens = raw.length;
		final int num_pixels = raw[0].length;
		for (int s = 0; s < num_sens; s++) {
			if ((raw[s] == null) || (raw[s].length != num_pixels)) return false;
		}
		final GpuQuad gpuQuad = scene.getGPUQuad();
		// B0 conditioning attribution (one-shot, 3-B design 2026-07-16): split the
		// per-scene "conditioning+H2D" stage into prepare / CPU-condition / H2D /
		// GPU-condition - sizes the B4 stream-overlap rung (H2D share = what
		// pinned+async can hide). By Claude on 07/16/2026.
		final boolean b0_report = !b0_condition_reported;
		final long b0_t0 = b0_report ? System.nanoTime() : 0L;
		boolean gpu_condition = prepareGpuConditioning(
				gpuQuad, num_sens, num_pixels,
				scene.getLwirScales(), scene.getLwirOffsets(),
				scene.getLwirScales2(), scene.getFPN(), use_cfg);
		final long b0_t1 = b0_report ? System.nanoTime() : 0L;
		final float [][] upload;
		if (gpu_condition) {
			upload = raw;
		} else {
			conditionLinearToFloat(raw, cpu_scratch, scene.getLwirScales(), scene.getLwirOffsets(),
					scene.getLwirScales2(), scene.getFPN(), use_cfg);
			upload = cpu_scratch;
		}
		scene.saveQuadClt();
		scene.setHasNewImageData(false);
		final long b0_t2 = b0_report ? System.nanoTime() : 0L;
		gpuQuad.setBayerImages(upload, true);
		final long b0_t3 = b0_report ? System.nanoTime() : 0L;
		if (gpu_condition && !gpuQuad.execConditioning()) {
			conditionLinearToFloat(raw, cpu_scratch, scene.getLwirScales(), scene.getLwirOffsets(),
					scene.getLwirScales2(), scene.getFPN(), use_cfg);
			gpuQuad.setBayerImages(cpu_scratch, true);
			gpu_condition = false;
		}
		if (b0_report) {
			b0_condition_reported = true;
			System.out.println(String.format(
					"CuasConditioning B0 preloaded split (one-shot): prepare=%.3f ms,"+
					" cpu-condition=%.3f ms (gpu_condition=%b), setBayerImages H2D=%.3f ms,"+
					" gpu-condition+guards=%.3f ms (%d sensors x %d px)",
					(b0_t1-b0_t0)*1e-6, (b0_t2-b0_t1)*1e-6, gpu_condition,
					(b0_t3-b0_t2)*1e-6, (System.nanoTime()-b0_t3)*1e-6, num_sens, num_pixels));
		}
		return true;
	}

	// 3-B rung B0: one-shot conditioning-stage split (see above). By Claude on 07/16/2026.
	private static boolean b0_condition_reported = false;

	/**
	 * D5 overlap variant of the preloaded upload (design ratified 07/17/2026):
	 * condition ONE scene into device image-set ring slot img_set while the
	 * PREVIOUS scene's DP chain runs. All GPU-side work rides the copy stream
	 * (staged H2D + the async conditioning kernel + the set's ready event); the
	 * only host cost is the pinned staged writes. Bytes are identical to
	 * conditionPreloadedSceneToGpu by construction - same staged upload, same
	 * conditioning kernel, only the target buffer and the stream differ (the
	 * D5a slot-equivalence case). On ANY failure the previous set is restored
	 * as active and false is returned - the caller reverts to the serial order
	 * (fail-safe; nothing was consumed from the target set).
	 * By Claude on 07/17/2026.
	 */
	public static boolean conditionPreloadedSceneToGpuOverlap(
			final QuadCLT   scene,
			final float [][] raw,
			final float [][] cpu_scratch,
			final Config    cfg,
			final int       img_set,       // target ring set (the NEXT scene's slot)
			final int       restore_set) { // active set to restore on failure
		if ((scene == null) || (raw == null) || (raw.length == 0) || (raw[0] == null)) return false;
		final Config use_cfg = (cfg != null) ? cfg : new Config();
		if (use_cfg.rowcol_enable || (use_cfg.zero_dc && Double.isNaN(use_cfg.dc_level))) return false;
		final int num_sens = raw.length;
		final int num_pixels = raw[0].length;
		for (int s = 0; s < num_sens; s++) {
			if ((raw[s] == null) || (raw[s].length != num_pixels)) return false;
		}
		final GpuQuad gpuQuad = scene.getGPUQuad();
		if (!gpuQuad.selectImageSet(img_set)) return false;
		boolean ok = false;
		try {
			final boolean gpu_condition = prepareGpuConditioning(
					gpuQuad, num_sens, num_pixels,
					scene.getLwirScales(), scene.getLwirOffsets(),
					scene.getLwirScales2(), scene.getFPN(), use_cfg);
			final float [][] upload;
			if (gpu_condition) {
				upload = raw;
			} else {
				conditionLinearToFloat(raw, cpu_scratch, scene.getLwirScales(), scene.getLwirOffsets(),
						scene.getLwirScales2(), scene.getFPN(), use_cfg);
				upload = cpu_scratch;
			}
			scene.saveQuadClt();
			scene.setHasNewImageData(false);
			gpuQuad.setBayerImages(upload, true); // staged -> the copy stream; a legacy fallback stays correct, just serial
			ok = gpu_condition ? gpuQuad.execConditioningAsync() : gpuQuad.imageSetReadyRecord();
		} finally {
			if (!ok) {
				gpuQuad.selectImageSet(restore_set); // never leave a half-filled set active
			}
		}
		return ok;
	}

	/** CPU float-output oracle for the immutable preloaded input. */
	private static void conditionLinearToFloat(
			final float [][] raw,
			final float [][] out,
			final double [] scales,
			final double [] offsets,
			final double [] scales2,
			final double [][][] fpn,
			final Config cfg) {
		if ((out == null) || (out.length < raw.length)) {
			throw new IllegalArgumentException("CPU conditioning scratch is missing");
		}
		final double dc = cfg.zero_dc ? cfg.dc_level : 0.0;
		for (int s = 0; s < raw.length; s++) {
			if ((out[s] == null) || (out[s].length != raw[s].length)) {
				throw new IllegalArgumentException("CPU conditioning scratch mismatch for sensor "+s);
			}
			final double b = (cfg.photometric && (scales != null)) ? scales[s] : 1.0;
			final double c = (cfg.photometric && (offsets != null)) ? offsets[s] : 0.0;
			final double a = (cfg.photometric && (scales2 != null)) ? scales2[s] : 0.0;
			final double [] fp = (cfg.apply_fpn && (fpn != null) && (fpn[s] != null)) ? fpn[s][0] : null;
			for (int i = 0; i < raw[s].length; i++) {
				double p = raw[s][i];
				if (cfg.photometric) {
					final double d = p - c;
					p = a*d*d + b*d;
				}
				if (fp != null) p -= fp[i];
				out[s][i] = (float) (p - dc);
			}
		}
	}

	/** Legacy conditioned-domain Row/Col estimator for one sensor's flat pixel array (in place): subtract per-row, then per-col.
	 *  Profiles are MEASURED on an unsharp-masked copy when rowcol_um_sigma > 0 (content
	 *  suppression borrowed from the oracle CorrectionFPN path - the max_abs gate then works
	 *  on residual counts instead of full-scale values), and SUBTRACTED from the original.
	 *  By Claude on 07/07/2026 (was: measured on the data as-is). */
	public static void subtractRowColSensor(final double [] px, final int width, final Config cfg) {
		final int height = px.length / width;
		double [] meas = px; // measurement copy (UM'd); profiles subtract from px
		if (cfg.rowcol_um_sigma > 0) {
			meas = px.clone();
			final double [] blurred = px.clone();
			new DoubleGaussianBlur().blurDouble(blurred, width, height,
					cfg.rowcol_um_sigma, cfg.rowcol_um_sigma, 0.01);
			for (int i = 0; i < meas.length; i++) meas[i] -= blurred[i];
		}
		// Profile HPF only WITHOUT the UM (measured, v013-ROWCOL-03 vs -02): the UM already
		// high-passes the measured profile by (1-G_um); stacking the profile HPF multiplies
		// in (1-G_hpf) - at period ~20 rows the removed fraction dropped 96% -> 91%, i.e.
		// residual mid-band line noise x2 (rows metric 10.6 -> 18.7). A single high-pass at
		// the UM sigma removes strictly more noise at every period AND protects the LoG's
		// low frequencies at least as well as the sigma-8 profile HPF did. By Claude on 07/07/2026.
		final boolean prof_hpf = cfg.rowcol_hpf && (cfg.rowcol_um_sigma <= 0);
		// --- per-row profile (average along x for each row y) ---
		double [] rowProf = lineAverage(meas, width, height, true, cfg.rowcol_max_abs, cfg.rowcol_weight_outlier,
				cfg.rowcol_min_inlier);
		if (prof_hpf) rowProf = highpass(rowProf, cfg.rowcol_hpf_sigma);
		for (int y = 0; y < height; y++) {
			final double sub = rowProf[y];
			if (sub != 0.0) {
				final int row0 = y * width;
				for (int x = 0; x < width; x++) px[row0 + x] -= sub;
				if (meas != px) for (int x = 0; x < width; x++) meas[row0 + x] -= sub; // keep the measurement copy row-corrected
			}
		}
		// --- per-col profile (average along y for each col x), recomputed on row-corrected data ---
		double [] colProf = lineAverage(meas, width, height, false, cfg.rowcol_max_abs, cfg.rowcol_weight_outlier,
				cfg.rowcol_min_inlier);
		if (prof_hpf) colProf = highpass(colProf, cfg.rowcol_hpf_sigma);
		for (int y = 0; y < height; y++) {
			final int row0 = y * width;
			for (int x = 0; x < width; x++) px[row0 + x] -= colProf[x];
		}
	}

	/**
	 * Outlier-weighted average producing a 1-D profile, with the untrusted-line guard
	 * (By Claude on 07/07/2026, ROWCOL-04 finding): a CONTENT-DOMINATED line (inlier
	 * fraction below min_inlier - e.g. terrain rows where nearly every |UM residual|
	 * exceeds max_abs) makes the outlier-weighted mean degenerate to the content mean
	 * (the small weights cancel) and stamps bias bands into the image; such lines get 0
	 * (no correction). Also 0 when |estimate| > max_abs (line noise cannot physically
	 * exceed the gate scale). Guards inactive when max_abs is INFINITY (no gate - every
	 * pixel is an inlier).
	 * @param perRow true  -> length-height profile, averaging along x (per-row offset);
	 *               false -> length-width  profile, averaging along y (per-col offset).
	 */
	private static double [] lineAverage(
			final double [] px, final int width, final int height,
			final boolean perRow, final double max_abs, final double weight_outlier,
			final double min_inlier) {
		final int n = perRow ? height : width;
		final double [] sum = new double [n];
		final double [] wgt = new double [n];
		final int    [] num = new int    [n]; // finite samples
		final int    [] inl = new int    [n]; // inliers (|d| <= max_abs)
		int idx = 0;
		for (int y = 0; y < height; y++) {
			for (int x = 0; x < width; x++) {
				final double d = px[idx++];
				if (Double.isNaN(d)) continue;
				final int k = perRow ? y : x;
				final boolean inlier = (Math.abs(d) <= max_abs);
				final double w = inlier ? 1.0 : weight_outlier;
				sum[k] += w * d;
				wgt[k] += w;
				num[k]++;
				if (inlier) inl[k]++;
			}
		}
		for (int k = 0; k < n; k++) {
			double v = (wgt[k] > 0.0) ? (sum[k] / wgt[k]) : 0.0;
			if ((num[k] > 0) && (inl[k] < min_inlier * num[k])) v = 0.0; // content-dominated line
			else if (Math.abs(v) > max_abs)                     v = 0.0; // physical cap (no-op when max_abs is INF)
			sum[k] = v;
		}
		return sum;
	}

	/** 1-D Gaussian high-pass of a profile: x - smooth(x, sigma), reflect-padded. Returns a new array. */
	public static double [] highpass(final double [] x, final double sigma) {
		if (sigma <= 0.0) return x.clone();
		final int r = Math.max(1, (int) Math.ceil(3.0 * sigma));
		final double [] k = new double [2 * r + 1];
		double ksum = 0.0;
		for (int i = -r; i <= r; i++) { final double v = Math.exp(-0.5 * (i * i) / (sigma * sigma)); k[i + r] = v; ksum += v; }
		for (int i = 0; i < k.length; i++) k[i] /= ksum;
		final int n = x.length;
		final double [] hp = new double [n];
		for (int i = 0; i < n; i++) {
			double sm = 0.0;
			for (int j = -r; j <= r; j++) {
				int ii = i + j;
				if (ii < 0)      ii = -ii - 1;          // reflect
				else if (ii >= n) ii = 2 * n - ii - 1;  // reflect
				if (ii < 0) ii = 0; else if (ii >= n) ii = n - 1;
				sm += k[j + r] * x[ii];
			}
			hp[i] = x[i] - sm;
		}
		return hp;
	}

	/** Build the per-sensor per-pixel OFFSET map (post-scale, subtractive) = scale*offset + FPN,
	 *  so that (scale*raw - offset_map) == scale*(raw-offset) - FPN. For the future 2-map API /
	 *  visualization; not required by {@link #condition}. */
	public static double [][] buildOffsetMaps(
			final double [][][] fpn, final double [] scales, final double [] offsets, final int width) {
		final int num_sens = fpn.length;
		final double [][] out = new double [num_sens][];
		for (int s = 0; s < num_sens; s++) if (fpn[s] != null && fpn[s][0] != null) {
			final double B0 = (scales  != null) ? scales [s] : 1.0;
			final double C0 = (offsets != null) ? offsets[s] : 0.0;
			final double [] fp = fpn[s][0];
			final double [] m = new double [fp.length];
			final double base = B0 * C0;
			for (int i = 0; i < fp.length; i++) m[i] = base + fp[i];
			out[s] = m;
		}
		return out;
	}
}
