package com.elphel.imagej.cuas.rt;

import java.awt.Rectangle;
import java.nio.file.Path;
import java.text.SimpleDateFormat; // By Claude on 06/11/2026
import java.util.Calendar;         // By Claude on 06/11/2026

import com.elphel.imagej.cameras.CLTParameters;
import com.elphel.imagej.cameras.EyesisCorrectionParameters.CorrectionParameters;
import com.elphel.imagej.common.ShowDoubleFloatArrays;
import com.elphel.imagej.cuas.CuasMotion;
import com.elphel.imagej.cuas.CuasRanging;
import com.elphel.imagej.ims.UasLogReader;
import com.elphel.imagej.tileprocessor.QuadCLT;
import com.elphel.imagej.tileprocessor.QuadCLTCPU;

import ij.ImagePlus;
import ij.ImageStack;

public class CuasDetectRT {
	public static final String SUFFIX_FPIXELS_TIFF = "-CUAS-MERGED-CUAS.tiff";
	public static final String SUFFIX_SYNTH_TIFF =   "-CUAS-SYNTHETIC-CUAS.tiff"; // synthetic input (curt_synth_src) // By Claude on 06/11/2026
	public static final String SUFFIX_LOG = "-LoG";
	public static final String SUFFIX_CONV2D = "-CONV2D";
	// SUFFIX_CONV3D3 removed 2026-06-20 (dead after 3d3/MF cleanup); see tag cuas-layer1. // By Claude
	public static final String SUFFIX_CONV5D =  "-CONV5D";
	public static final String SUFFIX_RECUR =   "-RECUR"; // By Claude on 06/09/2026
	// ROI override for synthetic input: covers the radial 11x11 target block of   // By Claude on 06/11/2026
	// gen_synth_cuas.py (center (320.5,256.5), step 30 -> +/-150 px) + kernel
	// margin, so the stored curt_save_select parameter keeps matching the REAL image.
	public static final Rectangle SYNTH_ROI = new Rectangle(160, 96, 320, 320); // By Claude on 06/11/2026
	// CONV3D3_MODES removed 2026-06-20 (dead after 3d3/MF cleanup); see tag cuas-layer1. // By Claude
	public static final int CONV3D3_MODE_PLAIN =  0;
	public static final int CONV3D3_MODE_WINNER = 1;
	public static final int CONV3D3_MODE_POWER =  2;
	public static final int MAXDECIMALS =         4;
	
	CuasRanging     cuasRanging;
	UasLogReader    uasLogReader;
	CLTParameters   clt_parameters;
	String          model_directory;
//	String []       model_names;
	QuadCLT         master_CLT;
	String          fpixels_file = null;
	String          cuas_synth_dir = null; // shared synth-grid dir (list SET "cuasSynth"); null/empty -> scan model_directory (old per-sequence behavior). By Claude on 06/24/2026
	String          cuas_noise = "";       // inline per-level L2 noise scales (list SET "cuasNoise"); empty -> theoretical sqrt default. By Claude on 06/24/2026
	public static final int NOISE_REF_LEVEL = 3;   // reference pyramid level (scale 1.0) the net is calibrated to. By Claude on 06/24/2026
	String          base_name = null; // add suffix and 
	float [][]      fpixels; 
	double [][]     dpixels; // same as fpixels for faster calculations on 64-bit processors 
	int             width;
	int             height;
	String []       time_stamps;
	float [][]      synth_pixels = null; // synthetic reference grid (scaled, NOT LoG'd), injected per pyramid level; null = real-only // By Claude on 06/14/2026
	double          infinity;
	
	// mode=3 (current, fast, bypasses OpticalFlow extras): read the merged-CUAS stack from a file in
	// model_directory. No GPU code here - the file was produced elsewhere. By Claude on 06/11/2026
	public CuasDetectRT(
			CLTParameters     clt_parameters,
			UasLogReader      uasLogReader,
			String            model_directory,
			String            cuas_synth_dir,  // shared synth-grid dir (list SET "cuasSynth"); "" -> per-sequence model_directory. By Claude on 06/24/2026
			String            cuas_noise,      // inline per-level noise scales (list SET "cuasNoise"); "" -> sqrt default. By Claude on 06/24/2026
			int               debugLevel) {
		this.uasLogReader =   uasLogReader;
		this.clt_parameters = clt_parameters;
		this.model_directory = model_directory;
		this.cuas_synth_dir = cuas_synth_dir;
		this.cuas_noise = (cuas_noise == null) ? "" : cuas_noise;
		this.infinity = clt_parameters.imp.cuas_infinity;
		// The REAL stack is always the pyramid base (gives the full-length levels); the synthetic // By Claude on 06/14/2026
		// reference grid (if curt_synth_src) is loaded separately (inside ingest()) and injected per-level.
		final String fpixels_suffix = SUFFIX_FPIXELS_TIFF; // By Claude on 06/14/2026
		String [] fpixels_paths = CorrectionParameters.getFilesByExtensionAsArray(model_directory, fpixels_suffix); // By Claude on 06/11/2026
		if (fpixels_paths.length > 0) {
			fpixels_file = newestFile(fpixels_paths); // pick up the newest matching file // By Claude on 06/14/2026
			if (fpixels_paths.length > 1) {
				System.out.println((fpixels_paths.length)+" files ending with \""+fpixels_suffix+"\" found in "+model_directory+", using the newest: "+fpixels_file); // By Claude on 06/14/2026
			}
			ImagePlus imp = new ImagePlus(fpixels_file);
			String fpixels_name = Path.of(fpixels_file).getFileName().toString();
			String core_base = fpixels_name.substring(0, fpixels_name.length() - fpixels_suffix.length()); // By Claude on 06/11/2026
			ingest(imp, core_base, debugLevel);
			if (fpixels != null) {
				System.out.println("Read image data from "+fpixels_file);
			} else {
				System.out.println("Failed to read image data from "+fpixels_file);
				fpixels_file = null;
			}
		} else {
			System.out.println("No files ending with \""+fpixels_suffix+"\" found in "+model_directory); // By Claude on 06/11/2026
			fpixels_file = null;
			fpixels = null;
			dpixels = null;
		}
		return;
	}

	// mode=0 (OpticalFlow curt_en path): consume a PRE-GENERATED merged-CUAS stack (ImagePlus). CUDA-free -
	// the GPU generator (CuasRanging.prepareFpixels(), which uses the CUDA tile-processor kernels) is called
	// explicitly by the caller, where the QuadCLT/GPU context lives, so this class stays independent of the
	// GPU code (which may be incompatible with a future CUDA). By Claude on 06/24/2026
	public CuasDetectRT(
			CLTParameters     clt_parameters,
			UasLogReader      uasLogReader,
			ImagePlus         imp_targets,     // pre-generated merged-CUAS stack (no UM); caller owns GPU generation
			String            model_directory, // where outputs are saved (e.g. center_CLT.getX3dDirectory())
			String            core_base_name,  // base for output filenames (e.g. center_CLT.getImageName())
			String            cuas_synth_dir,  // shared synth-grid dir (list SET "cuasSynth"); "" -> per-sequence model_directory. By Claude on 06/24/2026
			String            cuas_noise,      // inline per-level noise scales (list SET "cuasNoise"); "" -> sqrt default. By Claude on 06/24/2026
			int               debugLevel) {
		this.uasLogReader =   uasLogReader;
		this.clt_parameters = clt_parameters;
		this.model_directory = model_directory;
		this.cuas_synth_dir = cuas_synth_dir;
		this.cuas_noise = (cuas_noise == null) ? "" : cuas_noise;
		this.infinity = clt_parameters.imp.cuas_infinity;
		if ((imp_targets == null) || (imp_targets.getWidth() <= 0)) {
			System.out.println("CuasDetectRT(in-memory): null/empty imp_targets - nothing to do");
			fpixels = null;
			dpixels = null;
			return;
		}
		System.out.println("CuasDetectRT(in-memory): ingesting generated merged stack \""+imp_targets.getTitle()+
				"\" ("+imp_targets.getStackSize()+" slices) - outputs -> "+model_directory);
		ingest(imp_targets, core_base_name, debugLevel);
		return;
	}

	/** Populate fpixels/dpixels/width/height/time_stamps/base_name from a merged-CUAS ImagePlus
	 *  (file-loaded for mode=3 or generated in memory for mode=0). Shared by both constructors;
	 *  contains NO GPU code. By Claude on 06/24/2026 */
	private void ingest(ImagePlus imp, String core_base_name, int debugLevel) {
		String [] dbg_slices=null;
		float [][] dbg_pixels=null;
		boolean save_copy = false; // true;
		width =  (imp != null) ? imp.getWidth()  : 0;
		height = (imp != null) ? imp.getHeight() : 0;
		if (width > 0) {
				ImageStack stack = imp.getStack();
				// Skip non-ts ("average") slices;
				int first_slice = 1;
				int num_slices = stack.getSize();
				for (; !Character.isDigit(stack.getSliceLabel(first_slice).charAt(0)); first_slice++);
				time_stamps = new String [num_slices - first_slice + 1];
				if (save_copy) {
					dbg_slices = new String [num_slices];
					dbg_pixels = new float [num_slices][];
					for (int i = 0; i < dbg_slices.length; i++) {
						dbg_slices[i] = stack.getSliceLabel(1 + i);
						dbg_pixels[i] = (float[]) stack.getPixels (1 + i);
					}
				}
				fpixels = new float [time_stamps.length][width*height];
				for (int i = 0; i < time_stamps.length; i++) {
					time_stamps[i] = stack.getSliceLabel(first_slice + i);
					fpixels[i] = (float[]) stack.getPixels (first_slice + i);
				}
				base_name = core_base_name;
				if (clt_parameters.imp.curt_subtract_avg) base_name += "-SUBAVG"; // distinct output filenames for average-subtracted runs (don't overwrite) // By Claude on 06/14/2026
				if (clt_parameters.imp.curt_synth_src) { // load the synthetic reference grid separately (NOT mixed here) // By Claude on 06/14/2026
					// Real (loaded above) is the pyramid base. The synthetic file holds NORMALIZED
					// (peak 1) clean targets / a velocity-reference grid: load + scale it, keep it
					// aside (NO LoG - it has no slowly-varying background), and inject it TILED into
					// each pyramid level just before conv5d/DNN, so it is never averaged across levels
					// and looks the same at every level while the real noise is averaged (SNR up ~sqrt2/level).
					double synth_scale = clt_parameters.imp.curt_synth_scale;
					// Synthetic grid is sequence-INDEPENDENT: read it from the shared dir (list SET "cuasSynth")
					// when set; otherwise fall back to the per-sequence model_directory (old behavior / single-
					// sequence mode where the SET key is absent). By Claude on 06/24/2026
					String synth_dir = ((cuas_synth_dir != null) && !cuas_synth_dir.isEmpty()) ? cuas_synth_dir : model_directory;
					String [] synth_paths = CorrectionParameters.getFilesByExtensionAsArray(synth_dir, SUFFIX_SYNTH_TIFF);
					if (synth_paths.length > 0) {
						String synth_file = newestFile(synth_paths); // pick up the newest synthetic // By Claude on 06/14/2026
						ImagePlus imp_s = new ImagePlus(synth_file);
						ImageStack ss = imp_s.getStack();
						int s_first = 1;
						for (; !Character.isDigit(ss.getSliceLabel(s_first).charAt(0)); s_first++);
						int n_synth = ss.getSize() - s_first + 1;
						synth_pixels = new float [n_synth][];
						for (int i = 0; i < n_synth; i++) {
							float [] sp = (float[]) ss.getPixels(s_first + i);
							float [] sc = new float [sp.length];
							for (int k = 0; k < sp.length; k++) sc[k] = (float)(sp[k] * synth_scale);
							synth_pixels[i] = sc;
						}
						System.out.println("Synthetic grid: loaded "+n_synth+" frames (scale "+synth_scale+", no LoG) from "+synth_file+
								" - injected per pyramid level, tiled (synth[j % "+n_synth+"])");
					} else {
						System.out.println("curt_synth_src set but no \""+SUFFIX_SYNTH_TIFF+"\" found in "+model_directory+" - real only");
					}
					base_name += "-SYNTH"+d2s(synth_scale)+"B"; // real is always the averaged background base // By Claude on 06/14/2026
				}
		} else {
			System.out.println("CuasDetectRT.ingest(): empty/invalid merged stack - no pixel data");
			fpixels = null;
		}
		if (fpixels != null) {
			dpixels = new double [fpixels.length][width*height];
			for (int i = 0; i < fpixels.length; i++) for (int pix = 0; pix < fpixels[i].length; pix++) {
				dpixels[i][pix] = fpixels[i][pix]; 	
			}
		} else {
			dpixels = null;
		}
		// time_stamps
		if (dbg_pixels != null) {
			ImagePlus imp_copy = ShowDoubleFloatArrays.makeArrays(
					dbg_pixels, // float[][] pixels,
					getWidth(),  // int width,
					getHeight(), // int height,
					base_name+"-COPY",					
					dbg_slices); //String [] titles)
      		QuadCLTCPU.saveImagePlusInDirectory(imp_copy, model_directory);
 		}
		return;
	}
	
	public static String d2s(double d, int maxDecimalsPlaces) {
		String fmt = String.format("%%.%df", maxDecimalsPlaces);
		String s = String.format(fmt, d);
		s = s.trim();
		while (s.endsWith("0")) {
			s = s.substring(0,s.length()-1);
		}
		if (s.endsWith(".")) {
			s = s.substring(0,s.length()-1);
		}
		return s;
	}

	public static String d2s(double d) {
		return d2s(d, MAXDECIMALS);
	}

	/** Wall-clock stamp for tracing long-running stages (as in OpticalFlow.java). */
	public static String now() { // By Claude on 06/11/2026
		return new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(Calendar.getInstance().getTime());
	}

	/** Pick the most recently modified file among matches ("pick up the new one"). // By Claude on 06/14/2026 */
	private static String newestFile(String [] paths) {
		if ((paths == null) || (paths.length == 0)) return null;
		String best = paths[0];
		long bestT = new java.io.File(best).lastModified();
		for (int i = 1; i < paths.length; i++) {
			long t = new java.io.File(paths[i]).lastModified();
			if (t > bestT) { bestT = t; best = paths[i]; }
		}
		return best;
	}

	/** Parse a slice-label timestamp "sec_micro..." (ignoring any " f<idx>" / "-N" tail) to seconds. // By Claude on 06/14/2026 */
	private static double parseTs(String label) {
		if (label == null) return Double.NaN;
		try {
			int us = label.indexOf('_');
			if (us < 0) {
				int i = 0; while ((i < label.length()) && Character.isDigit(label.charAt(i))) i++;
				return (i > 0) ? Double.parseDouble(label.substring(0, i)) : Double.NaN;
			}
			long sec = Long.parseLong(label.substring(0, us));
			int j = us + 1; while ((j < label.length()) && Character.isDigit(label.charAt(j))) j++;
			double frac = (j > us + 1) ? Double.parseDouble("0." + label.substring(us + 1, j)) : 0.0;
			return sec + frac;
		} catch (Exception e) { return Double.NaN; }
	}

	/** Expand a timing-ROI spec: <=0 open; 0<v<10000 = last-4 of seconds (from the range, rollover-aware); >=10000 explicit. // By Claude on 06/14/2026 */
	private static double expandTimeSpec(double v, double minSec, boolean isEnd) {
		if (v <= 0) return isEnd ? Double.POSITIVE_INFINITY : Double.NEGATIVE_INFINITY;
		if (v >= 10000) return v; // explicit full timestamp (sub-second fraction preserved)
		long min  = (long) Math.floor(minSec);
		long high = min - (min % 10000);
		long tail = (long) Math.floor(v);   // integer seconds tail (last 4 digits)
		double frac = v - tail;             // sub-second fraction, e.g. 1/60 s steps
		long cand = high + tail;
		if (cand < min) cand += 10000;      // rolled over within the available range (...9999 -> ...+1 0000)
		if (frac > 0) return cand + frac;   // explicit sub-second given - use it for both ends
		return isEnd ? (cand + 0.999999) : (double) cand; // whole 'to' second when entered as an integer
	}

	/** [i0,i1) scene-index window for the timing ROI (curt_time_from/to), or full range if open/empty. // By Claude on 06/14/2026 */
	private int [] timeWindow(String [] ts) {
		int n = (ts == null) ? 0 : ts.length;
		double from = clt_parameters.imp.curt_time_from;
		double to   = clt_parameters.imp.curt_time_to;
		if ((n == 0) || ((from <= 0) && (to <= 0))) return new int[]{0, n};
		double minSec = Double.POSITIVE_INFINITY;
		double [] tv = new double [n];
		for (int i = 0; i < n; i++) { tv[i] = parseTs(ts[i]); if (tv[i] < minSec) minSec = tv[i]; }
		double f = expandTimeSpec(from, minSec, false);
		double t = expandTimeSpec(to,   minSec, true);
		int i0 = 0;  while ((i0 < n) && (tv[i0] <  f)) i0++;
		int i1 = i0; while ((i1 < n) && (tv[i1] <= t)) i1++;
		if (i0 >= n) { i0 = Math.max(0, n - 1); i1 = n; } // window past end - keep last scene
		if (i1 <= i0) i1 = Math.min(n, i0 + 1);           // keep at least one scene
		return new int[]{i0, i1};
	}

	/** Shallow sub-range of a rendered-ROI array / its labels for the timing ROI (no copy if full). // By Claude on 06/14/2026 */
	private static double [][][][] win4(double [][][][] a, int [] w) {
		return ((w[0] == 0) && (w[1] == a.length)) ? a : java.util.Arrays.copyOfRange(a, w[0], w[1]);
	}
	private static String [] winS(String [] a, int [] w) {
		return ((w[0] == 0) && (w[1] == a.length)) ? a : java.util.Arrays.copyOfRange(a, w[0], w[1]);
	}

	/** Re-bin each ROI pixel's velocity vector to its fractional offset position (px+dx, py+dy) via // By Claude on 06/14/2026
	 *  bilinear splat, so neighbours that both claim one sub-pixel target reinforce in the same bin.
	 *  field[roi_pix][nsub][nvel]; off[roi_pix][{dx,dy,s}] (may be null). Returns a new field. */
	private static double [][][] splatField(double [][][] field, double [][] off, Rectangle roi) {
		int rw = roi.width, rh = roi.height, npix = field.length;
		int nsub = field[0].length, nvel = field[0][0].length;
		double [][][] out = new double [npix][nsub][nvel];
		for (int p = 0; p < npix; p++) {
			if ((off == null) || (off[p] == null)) continue;
			double fx = (p % rw) + off[p][0], fy = (p / rw) + off[p][1];
			int x0 = (int) Math.floor(fx), y0 = (int) Math.floor(fy);
			double wx = fx - x0, wy = fy - y0;
			for (int gy = 0; gy < 2; gy++) for (int gx = 0; gx < 2; gx++) {
				int tx = x0 + gx, ty = y0 + gy;
				if ((tx < 0) || (tx >= rw) || (ty < 0) || (ty >= rh)) continue;
				double wgt = (gx == 0 ? 1.0 - wx : wx) * (gy == 0 ? 1.0 - wy : wy);
				if (wgt <= 0) continue;
				double [][] dst = out[ty * rw + tx], src = field[p];
				for (int su = 0; su < nsub; su++) for (int v = 0; v < nvel; v++) dst[su][v] += wgt * src[su][v];
			}
		}
		return out;
	}

	/** Level selector for the heavy per-level paths (3d3/conv5d, C5P-direct, DNN): // By Claude on 06/13/2026
	 *  empty/null curt_c5_levels = all built levels, otherwise only the listed ones. */
	/** v2 Stage-2 vote scatter: each ROI pixel splats an s^2-weighted bilinear vote at the tail
	 *  T = P - V*(N-1) (the invariant convergence point - all of a target's alias-ramp pixels land there).
	 *  S,Vx,Vy: [rw*rh] row-major. Returns [accS, accVx, accVy] (row-major). By Claude on 06/18/2026 */
	static double[][] voteScatter(double[] S, double[] Vx, double[] Vy, int rw, int rh, int Nm1) {
		int npix = rw * rh;
		double[] accS = new double[npix], accVx = new double[npix], accVy = new double[npix];
		for (int p = 0; p < npix; p++) {
			double s = S[p]; if (!(s > 0)) continue;
			double w = s;                                      // MF-S (option a): S is the matched-filter path-sum, already the right vote weight (full at head, ~0 on noise). By Claude on 06/18/2026
			double tx = (p % rw) - Vx[p] * Nm1, ty = (p / rw) - Vy[p] * Nm1;
			int x0 = (int) Math.floor(tx), y0 = (int) Math.floor(ty);
			double fx = tx - x0, fy = ty - y0;
			for (int dx = 0; dx < 2; dx++) for (int dy = 0; dy < 2; dy++) {
				int xi = x0 + dx, yi = y0 + dy;
				if (xi < 0 || xi >= rw || yi < 0 || yi >= rh) continue;
				double bw = w * (dx == 0 ? 1 - fx : fx) * (dy == 0 ? 1 - fy : fy);
				int idx = yi * rw + xi;
				accS[idx] += bw; accVx[idx] += bw * Vx[p]; accVy[idx] += bw * Vy[p];
			}
		}
		return new double[][] { accS, accVx, accVy };
	}

	// grid distribution-vote method (voteScatterGrid) removed 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026

	// bilinearFrame removed 2026-06-20 (dead after 3d3/MF cleanup); see tag cuas-layer1. // By Claude

	/** Head (T0) map straight from the CLEAN vote accumulator - the inverse of the vote scatter, but
	 *  driven by the accumulated (robust) consensus velocity, not the soft per-pixel refine. Each tail
	 *  bin with accS >= frac*max projects its vote strength to head = tail + (accVx/accS)*(N-1), so
	 *  detections land where -VXYS lights up (current target position). Bypasses the refine, whose peaks
	 *  are softer than its background; the summed vote is the better source. By Claude on 06/18/2026 */
	static double[] headFromVote(double[] accS, double[] accVx, double[] accVy, int rw, int rh, int Nm1, double frac) {
		int npix = rw * rh;
		double mx = 1e-9; for (double v : accS) if (v > mx) mx = v;
		double thr = frac * mx;
		double[] head = new double[npix];
		for (int p = 0; p < npix; p++) {
			double s = accS[p]; if (!(s >= thr)) continue;         // only the strong vote bins (real tails); ratios below are meaningless in the noise floor
			double vcx = accVx[p] / s, vcy = accVy[p] / s;          // accumulated consensus velocity at this tail
			double hx = (p % rw) + vcx * Nm1, hy = (p / rw) + vcy * Nm1;
			int x0 = (int) Math.floor(hx), y0 = (int) Math.floor(hy);
			double fx = hx - x0, fy = hy - y0;
			for (int dx = 0; dx < 2; dx++) for (int dy = 0; dy < 2; dy++) {
				int xi = x0 + dx, yi = y0 + dy;
				if (xi < 0 || xi >= rw || yi < 0 || yi >= rh) continue;
				head[yi * rw + xi] += s * (dx == 0 ? 1 - fx : fx) * (dy == 0 ? 1 - fy : fy);
			}
		}
		return head;
	}

	private static boolean c5LevelSelected(int [] c5_levels, int nlev) { // By Claude on 06/13/2026
		if ((c5_levels == null) || (c5_levels.length == 0)) return true;
		for (int l : c5_levels) if (l == nlev) return true;
		return false;
	}

	/** "Ghostbuster": the DNN velocity grid (radius vel_radius cells) extends past the trained # By Claude on 06/15/2026
	 * velocity disk, so untrained corner cells emit spurious sidelobes (ghosts) of non-trivial
	 * strength that would confuse the recurrent layer. For each ROI pixel: zero velocity cells with
	 * cell-radius > rmax_cells (= trained vmax_px * vel_decimate); if the pixel's PEAK lands in that
	 * untrained region the whole detection is a ghost -> zero its field AND set s=0 (off[pix][2]).
	 * field = [npix][1][nvel] (softmax*s), off = [npix][{dx,dy,s}] (may be null). rmax_cells<=0 disables. */
	private static void dnnGhostbust(double [][][] field, double [][] off, int vel_radius, double rmax_cells) {
		if ((field == null) || (rmax_cells <= 0)) return;
		final int vdim = 2 * vel_radius + 1;
		final double r2 = rmax_cells * rmax_cells;
		for (int pix = 0; pix < field.length; pix++) {
			double [] fv = (field[pix] != null) ? field[pix][0] : null;
			if (fv == null) continue;
			int amax = -1; double amx = Double.NEGATIVE_INFINITY;
			for (int v = 0; v < fv.length; v++) if (fv[v] > amx) { amx = fv[v]; amax = v; }
			if (amax < 0) continue;
			int avx = amax % vdim - vel_radius, avy = amax / vdim - vel_radius;
			if ((avx * avx + avy * avy) > r2) {                 // peak is in the untrained corner -> ghost
				java.util.Arrays.fill(fv, 0.0);
				if ((off != null) && (off[pix] != null)) off[pix][2] = 0.0; // s = 0 (don't feed the recurrent)
			} else {                                            // real peak: keep it, drop the corner sidelobes
				for (int v = 0; v < fv.length; v++) {
					int vx = v % vdim - vel_radius, vy = v / vdim - vel_radius;
					if ((vx * vx + vy * vy) > r2) fv[v] = 0.0;
				}
			}
		}
	}

	/** Tag a saved CUAS image with the full run config: copy ALL curt_* params (via the config
	 * serializer, so any new curt_ param is auto-included) into the ImagePlus properties and encode
	 * them to the ImageJ Info field (XML -> TIFF ImageDescription). The python reader (python3-imagej-tiff
	 * format) decodes <key>value</key> back to a dict - so every output is self-describing. By Claude on 06/16/2026 */
	private static ImagePlus tagCuasImp(ImagePlus imp, com.elphel.imagej.tileprocessor.IntersceneMatchParameters p) {
		if ((imp == null) || (p == null)) return imp;
		java.util.Properties props = new java.util.Properties();
		p.setProperties("", props);                       // serialize all params -> props (keys "curt_..." among others)
		for (String key : props.stringPropertyNames()) {
			if (key.startsWith("curt_")) imp.setProperty(key, props.getProperty(key));
		}
		com.elphel.imagej.readers.EyesisTiff.encodeProperiesToInfo(imp); // -> imp "Info" XML -> FileSaver writes ImageDescription
		return imp;
	}

	
	public int getWidth() {
		return width;
	}
	public int getHeight() {
		return height;
	}
	String [] getTimeStamps() {
		return time_stamps;
	}

	String [] getTimeStamps(int from_indx) {
		String [] time_stamps_from = new String[time_stamps.length - from_indx];
		System.arraycopy(time_stamps, from_indx, time_stamps_from, 0, time_stamps_from.length);
		return time_stamps_from;
	}
	String [] getTimeStamps(int from_indx, int tdecimate) {
		String [] time_stamps_decimated = new String[time_stamps.length / tdecimate];
		for (int i = 0; i < time_stamps_decimated.length; i++) {
			time_stamps_decimated[i] = time_stamps[(i+1)* tdecimate - 1];
		}
		return time_stamps_decimated;
	}

	
	
	public double [][] getDPpixels(){
		return dpixels;
	}
	
	public float [][] getFPixels() {
		return fpixels;
	}
	
	public String getBaseName() {
		return base_name;
	}
	
	public String getModelDirectory() {
		return model_directory;
	}

	/**
	 * Combine several same-size RECT renderings into one hyperstack: bottom slider // By Claude on 06/11/2026
	 * selects the view type (what was previously in separate files), inner slider -
	 * timestamp, so corresponding areas of all processing stages are immediately
	 * comparable at the same timestamp without aligning sliders across windows.
	 * @param type_imps   rendered RECT stacks: [0] - full-rate (every filter step),
	 *                    [1..] - recurrent-rate (every `period`-th step)
	 * @param type_titles one per type, e.g. {"CONV5D","NEW","PRESH","RECUR"}
	 * @param ts_full     all (filter-rate) timestamps
	 * @param ts_recur    recurrent-rate timestamps
	 * @param period      filter steps per recurrent step
	 * @param full        true - a slice per filter-rate timestamp, all-NaN filler
	 *                    where recurrent data is absent (3 of 4 now); false -
	 *                    decimated to recurrent-rate timestamps (filter subsampled)
	 * @param title       result title
	 * @return hyperstack ImagePlus (not shown), or null on pixel size mismatch
	 */
	private static ImagePlus combineRect5d( // By Claude on 06/11/2026
			ImagePlus [] type_imps,
			String []    type_titles,
			String []    ts_full,
			String []    ts_recur,
			int          period,
			boolean      full,
			String       title) {
		int width =  type_imps[0].getWidth();
		int height = type_imps[0].getHeight();
		for (ImagePlus imp : type_imps) {
			if ((imp == null) || (imp.getWidth() != width) || (imp.getHeight() != height)) {
				System.out.println("combineRect5d(): missing image or pixel size mismatch - skipping combined output");
				return null;
			}
		}
		int num_types =  type_imps.length;
		int num_slices = full ? ts_full.length : ts_recur.length;
		float [] nan_slice = new float [width * height]; // shared by all empty slices (display/save only)
		java.util.Arrays.fill(nan_slice, Float.NaN);
		float [][][] combo = new float [num_types][num_slices][];
		for (int n = 0; n < num_slices; n++) {
			int n_full =  full ? n : (n * period);  // index into the full-rate stack
			int n_recur = full ? (n / period) : n;  // index into the recurrent-rate stacks
			boolean has_recur = !full || (((n % period) == 0) && (n_recur < ts_recur.length));
			combo[0][n] = (n_full < type_imps[0].getStackSize()) ?
					(float []) type_imps[0].getStack().getPixels(n_full + 1) : nan_slice;
			for (int t = 1; t < num_types; t++) {
				combo[t][n] = (has_recur && (n_recur < type_imps[t].getStackSize())) ?
						(float []) type_imps[t].getStack().getPixels(n_recur + 1) : nan_slice;
			}
		}
		return ShowDoubleFloatArrays.showArraysHyperstack(
				combo,                          // float[][][] pixels [type][timestamp][pix]
				width,                          // int          width
				title,                          // String       title
				full ? ts_full : ts_recur,      // String []    titles (slice = timestamp)
				type_titles,                    // String []    frame_titles (view type)
				false);                         // boolean      show
	}

	/**
	 * Run the Layer 2 recurrent accumulation buffer over one pyramid level's filtered // By Claude on 06/11/2026
	 * fine-velocity ROI sequence and save the debug imagery. The pyramid is temporal
	 * (same pixel size at all levels), so the ROI and the buffer geometry apply
	 * unchanged - only the time step is 2^level base units, making deeper levels
	 * sensitive to proportionally slower (far/weak) targets.
	 * Always saves: -VMAX (per-pixel {Vx,Vy,W} fractional argmax hyperstack),
	 * -CMB-FULL-RECT and -CMB-DEC-RECT (combined {CONV5D,NEW,PRESH,RECUR} views).
	 * With save_individual (level 0 legacy set) also: -RECUR-RECT,
	 * -RECUR-HYPER-RECT, -NEW-RECT, -PRESH-RECT.
	 * All curt_recur_* / velocity-geometry parameters are read from clt_parameters.
	 * @param cuasRTUtils     renderers (showConvKernel5d etc.)
	 * @param conv5d_roi      [n5d][roi_npix][nsub][nvel] - filtered convolve3d ROI output of this level
	 * @param ts_5d           timestamps of this level (filter rate)
	 * @param roi             ROI rectangle (curt_save_select)
	 * @param title_base      title prefix for this level (level suffix already included)
	 * @param save_individual also save the separate per-stage files
	 */
	private void runRecurrentLevel( // By Claude on 06/11/2026
			CuasRTUtils     cuasRTUtils,
			double [][][][] conv5d_roi,
			String []       ts_5d,
			Rectangle       roi,
			String          title_base,
			boolean         save_individual) {
		int    period =       clt_parameters.imp.curt_recur_period;
		double w =            clt_parameters.imp.curt_recur_w;
		double leak =         clt_parameters.imp.curt_recur_leak;
		double wthresh =      clt_parameters.imp.curt_recur_wthresh;
		double spread =       clt_parameters.imp.curt_recur_spread;
		int    max_clust =    clt_parameters.imp.curt_recur_max_clust;
		double fit_rat =      clt_parameters.imp.curt_recur_fit_rat;
		double rs_frac =      clt_parameters.imp.curt_recur_rs_frac;
		double rs_min =       clt_parameters.imp.curt_recur_rs_min;
		int    pix_decimate = clt_parameters.imp.curt_pix_decimate;
		int    vel_radius =   clt_parameters.imp.curt_vel_radius;
		int    vel_decimate = clt_parameters.imp.curt_vel_decimate;
		double dbg_min =      clt_parameters.imp.curt_vel_dbgmin;
		int num_5d = conv5d_roi.length;
		CuasRecurrentBuffer recurrentBuffer = new CuasRecurrentBuffer(
				roi.width,                   // int roi_width,
				roi.height,                  // int roi_height,
				pix_decimate * pix_decimate, // int num_sub,
				vel_radius,                  // int velocity_radius,
				vel_decimate,                // int velocity_decimate,
				period,                      // int period,
				spread,                      // double spread,
				max_clust,                   // int max_clust,
				fit_rat,                     // double fit_rat,
				rs_frac,                     // double rs_frac,
				rs_min);                     // double rs_min)
		recurrentBuffer.setSaveStages(true);
		int max_recur = num_5d / period + 1;
		double [][][][] recur_states = new double[max_recur][][][];
		double [][][][] recur_cond =   new double[max_recur][][][]; // conditioned measurements (new data to combine)
		double [][][][] recur_pre =    new double[max_recur][][][]; // merged states before re-sharpening
		String []       recur_ts =     new String[max_recur];
		int num_recur = 0;
		for (int n5d = 0; n5d < num_5d; n5d++) {
			if (((n5d % period) != 0) || (conv5d_roi[n5d] == null)) continue;
			recurrentBuffer.update(
					conv5d_roi[n5d], // double [][][] measurement,
					w,               // double        w,
					leak,            // double        leak,
					wthresh);        // double        wthresh)
			recur_states[num_recur] = recurrentBuffer.getBufferCopy();
			recur_cond[num_recur] =   recurrentBuffer.getLastConditioned();
			recur_pre[num_recur] =    recurrentBuffer.getPreResharpen();
			recur_ts[num_recur++] =   ts_5d[n5d];
		}
		if (num_recur == 0) {
			return;
		}
		double [][][][] recur_show =     new double[num_recur][][][];
		double [][][][] recur_cond_show = new double[num_recur][][][];
		double [][][][] recur_pre_show =  new double[num_recur][][][];
		String []       recur_ts_show =  new String[num_recur];
		System.arraycopy(recur_states, 0, recur_show,      0, num_recur);
		System.arraycopy(recur_cond,   0, recur_cond_show, 0, num_recur);
		System.arraycopy(recur_pre,    0, recur_pre_show,  0, num_recur);
		System.arraycopy(recur_ts,     0, recur_ts_show,   0, num_recur);
		String title_recur = title_base+
				"-RP"+period+"-RW"+d2s(w)+"-RL"+d2s(leak)+
				"-RWT"+d2s(wthresh)+"-RS"+d2s(spread)+
				"-RC"+max_clust+"-RF"+d2s(fit_rat)+
				"-RR"+d2s(rs_frac)+"-RM"+d2s(rs_min)+SUFFIX_RECUR+"-D"+dbg_min;
		ImagePlus imp_recur_rect = cuasRTUtils.showConvKernel5d(
				recur_show,
				roi,
				recur_ts_show,
				title_recur+"-RECT");
		ImagePlus imp_recur_new = cuasRTUtils.showConvKernel5d(
				recur_cond_show,
				roi,
				recur_ts_show,
				title_recur+"-NEW-RECT");
		ImagePlus imp_recur_presh = cuasRTUtils.showConvKernel5d(
				recur_pre_show,
				roi,
				recur_ts_show,
				title_recur+"-PRESH-RECT");
		if (save_individual) {
			QuadCLTCPU.saveImagePlusInDirectory(imp_recur_rect,  getModelDirectory());
			QuadCLTCPU.saveImagePlusInDirectory(imp_recur_new,   getModelDirectory());
			QuadCLTCPU.saveImagePlusInDirectory(imp_recur_presh, getModelDirectory());
			ImagePlus imp_recur_hyper = cuasRTUtils.showConvKernel5dHyperRect(
					recur_show,
					roi,
					recur_ts_show,
					title_recur+"-HYPER-RECT");
			QuadCLTCPU.saveImagePlusInDirectory(imp_recur_hyper, getModelDirectory());
		}
		// low-resolution summary: per ROI pixel {Vx, Vy, W} - integer argmax over the
		// velocity plane (best sub), fractional-pixel argmax by cosine-windowed local
		// centroid (Correlation2d.getMaxXYCm), W = plane maximum
		final double vmax_cm_radius = 2.0; // cosine window radius, covers the +/-1.5 bump support
		final int    vmax_cm_passes = 1;   // window re-centering passes
		final double vmax_thresh =    1.0; // min plane max to report Vx,Vy (below - NaN); W is always kept,
		                                   // so ImageJ histogram/statistics (NaN-blind) of Vx,Vy show only
		                                   // the real cluster, and the W slice guides threshold choice
		final int vel_dim_recur = 2 * vel_radius + 1;
		final int roi_npix = roi.width * roi.height;
		double [][][] recur_vmax = new double [3][num_recur][roi_npix];
		for (int n = 0; n < num_recur; n++) {
			for (int p = 0; p < roi_npix; p++) {
				double [][] subs = recur_show[n][p];
				int best_s = -1;
				double mx = 0;
				for (int s = 0; s < subs.length; s++) {
					for (int v = 0; v < subs[s].length; v++) {
						if (subs[s][v] > mx) {
							mx = subs[s][v];
							best_s = s;
						}
					}
				}
				double [] xy = ((best_s >= 0) && (mx >= vmax_thresh)) ? com.elphel.imagej.tileprocessor.Correlation2d.getMaxXYCm(
						subs[best_s],    // double []  data (not modified: fpn_mask == null)
						vel_dim_recur,   // int        data_width
						vmax_cm_radius,  // double     radius
						vmax_cm_passes,  // int        refine (window re-centering)
						null,            // boolean [] fpn_mask
						false,           // boolean    ignore_border
						false) : null;   // boolean    debug
				if (xy != null) {
					recur_vmax[0][n][p] = xy[0];
					recur_vmax[1][n][p] = xy[1];
				} else {
					recur_vmax[0][n][p] = Double.NaN;
					recur_vmax[1][n][p] = Double.NaN;
				}
				recur_vmax[2][n][p] = mx; // W always kept (0 for empty) - guides vmax_thresh choice
			}
		}
		ImagePlus imp_recur_vmax = ShowDoubleFloatArrays.showArraysHyperstack(
				recur_vmax,                     // double[][][] pixels [{Vx,Vy,W}][scene][pix]
				roi.width,                      // int          width
				title_recur+"-VMAX",            // String       title
				recur_ts_show,                  // String []    titles (scene timestamps)
				new String[] {"Vx","Vy","W"},   // String []    frame_titles
				false);                         // boolean      show
		QuadCLTCPU.saveImagePlusInDirectory(imp_recur_vmax, getModelDirectory());
		// combined RECT hyperstacks: bottom slider - view type, inner slider - timestamp
		ImagePlus imp_filter_rect = cuasRTUtils.showConvKernel5d(
				conv5d_roi,
				roi,
				ts_5d,
				title_base+"-RECT");
		ImagePlus [] cmb_imps =   {imp_filter_rect, imp_recur_new, imp_recur_presh, imp_recur_rect};
		String []    cmb_titles = {"CONV5D", "NEW", "PRESH", "RECUR"};
		ImagePlus imp_cmb_full = combineRect5d(
				cmb_imps, cmb_titles, ts_5d, recur_ts_show,
				period, true, title_recur+"-CMB-FULL-RECT");
		if (imp_cmb_full != null) {
			QuadCLTCPU.saveImagePlusInDirectory(imp_cmb_full, getModelDirectory());
		}
		ImagePlus imp_cmb_dec = combineRect5d(
				cmb_imps, cmb_titles, ts_5d, recur_ts_show,
				period, false, title_recur+"-CMB-DEC-RECT");
		if (imp_cmb_dec != null) {
			QuadCLTCPU.saveImagePlusInDirectory(imp_cmb_dec, getModelDirectory());
		}
	}

	/** Remote DGX inference path (curt_dnn_remote): upload the SUBAVG+LoG-conditioned stack once,
	 *  the DGX builds the pyramid and runs full-res shift-and-stitch per (level, scene), returning the
	 *  full-frame {dx,dy,s,Vx,Vy} offset (-OFFSET) + the ROI 121-cell softmax*s (-RECT/-HYPER-RECT).
	 *  Mirrors the local DNN block's level/scene/timing-window indexing; no recurrent feed (the local
	 *  CPU path, curt_dnn_remote=false, still drives the Layer-2 experiments). Synthetic injection is
	 *  NOT applied here - it lives on dpixels_pyramid (local path); remote uploads dpixels_log.
	 *  Server: attic/imagej-elphel-internal/c5p_dnn/infer_server.py. By Claude on 06/20/2026 */
	private void runDnnRemote(
			CLTParameters clt_parameters,
			CuasRTUtils   cuasRTUtils,
			double [][]   dpixels_log,
			String [][]   ts_pyramid,
			int           pyramid_levels,
			int []        c5_levels,
			Rectangle     roi,
			String        title_conv5d) {
		final int dnn_stride = Math.max(1, clt_parameters.imp.curt_dnn_stride);
		final boolean use_l2 = clt_parameters.imp.curt_dnn_l2;                       // run Layer-2 on the DGX: -OFFSET carries L2 det+vel instead of L1. By Claude 06/22/2026
		final String  l2model = use_l2 ? clt_parameters.imp.curt_dnn_l2_model : "";  // L2 run dir on the DGX; empty = L1-only (old way)
		// L2 model id appended as the LAST filename suffix on -OFFSET so runs of different L2 models don't
		// collide (and Dolphin's begin...end.tiff truncation still shows the model). By Claude on 06/24/2026
		final String  l2tag = use_l2 ? ("-" + l2model.substring(l2model.lastIndexOf('/') + 1)) : "";
		final int W = getWidth(), H = getHeight();
		final boolean save_rect  = clt_parameters.imp.curt_save_c5rect;
		final boolean save_hyper = clt_parameters.imp.curt_save_c5hyper;
		final boolean synth = clt_parameters.imp.curt_synth_src && (synth_pixels != null) && (synth_pixels.length > 0);
		final boolean synth_bg = clt_parameters.imp.curt_synth_bg;
		final int n_synth = synth ? synth_pixels.length : 0;
		try { // auto-launch the DGX server if not already running (deploy bundled/override scripts, ssh-start, poll) // By Claude on 06/20/2026
			if (!clt_parameters.imp.curt_dnn_local) // local native backend has no server to launch. By Claude on 06/27/2026
					CuasDnnRemote.ensureServer(clt_parameters.imp.curt_dnn_remote_host, clt_parameters.imp.curt_dnn_remote_model, l2model, clt_parameters.imp.curt_dnn_remote_srcdir);
		} catch (Exception e) { System.out.println("runDnnRemote(): server auto-launch failed: "+e); }
		try (CuasDnnBackend remote = clt_parameters.imp.curt_dnn_local
					? new CuasDnnLocal(clt_parameters.imp.curt_dnn_local_dir, clt_parameters.imp.curt_dnn_remote_model, l2model)
					: new CuasDnnRemote(clt_parameters.imp.curt_dnn_remote_host)) {
			// Build the upload array: LoG-conditioned real (optionally synth_bg_avg-decimated upstream), optionally
			// with synthetic targets mixed in (tiled synth[t % n_synth]; clean = zero bg first) - matching the local
			// per-level injection (CuasDetectRT.java synth block) but PRE-pyramid; the DGX then averages it into levels.
			// So synth now works on the remote path. By Claude on 06/20/2026
			float [][] logf = new float [dpixels_log.length][];
			for (int t = 0; t < dpixels_log.length; t++) {
				double [] s = dpixels_log[t]; float [] f = new float [s.length];
				if (synth) {
					float [] sp = synth_pixels[t % n_synth]; int m = Math.min(s.length, sp.length);
					if (synth_bg) { for (int p = 0; p < s.length; p++) f[p] = (float) s[p]; for (int p = 0; p < m; p++) f[p] += sp[p]; }
					else          { for (int p = 0; p < m; p++) f[p] = sp[p]; }   // clean: drop real bg, synth only
				} else {
					for (int p = 0; p < s.length; p++) f[p] = (float) s[p];
				}
				logf[t] = f;
			}
			if (synth) System.out.println(now()+" runDnnRemote(): mixed synthetic grid (tiled "+n_synth+" frames"+(synth_bg?", over real bg":", CLEAN - bg zeroed")+") into the upload stack");
			long tup = System.currentTimeMillis();
			int [] levCounts = remote.upload(logf, H, W);
			int N_dnn = remote.getNFrames();
			System.out.println(now()+" runDnnRemote(): UPLOAD "+dpixels_log.length+" frames -> levels "
					+ java.util.Arrays.toString(levCounts) + " N="+N_dnn
					+ "  ("+(System.currentTimeMillis()-tup)+" ms, "+(dpixels_log.length*(long)H*W*4/1000000)+" MB)");
			int nlevels = Math.min(pyramid_levels, levCounts.length);
			// Per-level L1-input NOISE SCALES (single source of truth = here in Java; the server applies exactly what
			// we send). Parse the inline cuasNoise SET; validate (>= nlevels positive numbers) else WARN + fall back to
			// the theoretical sqrt(2)^(L-NOISE_REF_LEVEL). Recorded in every -OFFSET's metadata + printed near the end.
			// These are critical/sensitive params - never silently misapply. By Claude on 06/24/2026
			double [] noise_scales = new double [nlevels];
			boolean noise_default = true;
			if ((cuas_noise != null) && !cuas_noise.trim().isEmpty()) {
				String [] ntok = cuas_noise.trim().split("[\\s,;]+");
				boolean nok = (ntok.length >= nlevels);
				double [] np = new double [ntok.length];
				for (int i = 0; nok && (i < ntok.length); i++) {
					try { np[i] = Double.parseDouble(ntok[i]); if (np[i] <= 0) nok = false; }
					catch (NumberFormatException e) { nok = false; }
				}
				if (nok) { for (int L = 0; L < nlevels; L++) noise_scales[L] = np[L]; noise_default = false; }
				else System.out.println("runDnnRemote(): WARNING cuasNoise=\""+cuas_noise+"\" invalid (need >= "+nlevels+" positive numbers) - falling back to theoretical sqrt scales");
			}
			if (noise_default) for (int L = 0; L < nlevels; L++) noise_scales[L] = Math.pow(2.0, (L - NOISE_REF_LEVEL) / 2.0);
			final String noise_src = noise_default ? ("theoretical sqrt(2)^(L-"+NOISE_REF_LEVEL+")") : "cuasNoise SET";
			final String noise_meta = java.util.Arrays.toString(noise_scales)+" ["+noise_src+", ref LEV"+NOISE_REF_LEVEL+"]";
			for (int nlev = 0; nlev < nlevels; nlev++) {
				if (!c5LevelSelected(c5_levels, nlev) || (ts_pyramid[nlev] == null)) continue;
				int levLen = levCounts[nlev];
				int num_all = (levLen >= N_dnn) ? ((levLen - N_dnn)/dnn_stride + 1) : 0;
				if (num_all == 0) continue;
				// timing ROI (curt_time_from/to) on the level timestamps - infer only the in-window scenes
				String [] cand_ts = new String [num_all];
				for (int k = 0; k < num_all; k++) cand_ts[k] = ts_pyramid[nlev][k*dnn_stride + N_dnn - 1];
				int [] tw = timeWindow(cand_ts); int w0 = tw[0], num = tw[1]-tw[0];
				if (num <= 0) continue;
				int rnp = roi.width * roi.height;
				double [][][][] dnn_roi = new double [num][][][];     // [scene][roi_pix][1][nvel] -> -RECT/-HYPER-RECT
				final int n_off = use_l2 ? 6 : 5;                       // {dx,dy,s,Vx,Vy} (+L2 age when L2) full-frame -> -OFFSET. By Claude 06/24/2026
				double [][][]   off5    = new double [n_off][num][H*W];
				String [] ts_dnn = new String [num];
				System.out.println(now()+" runDnnRemote(): LEV"+nlev+" "+num+" of "+num_all+" scenes, stride "+dnn_stride+", ROI "+roi.width+"x"+roi.height);
				// batched: request the level's in-window scenes in chunks; the DGX runs them continuously
				// (production throughput) and ghostbusts on the GPU (so -OFFSET s + ROI 121 are both clean -
				// no Java-side ghostbust). rmax_cells = vmax*vel_decimate (<=0 disables). By Claude on 06/20/2026
				double rmax = (clt_parameters.imp.curt_dnn_vmax > 0) ?
						clt_parameters.imp.curt_dnn_vmax * clt_parameters.imp.curt_vel_decimate : 0.0;
				final int REQ = 64;   // scenes per round-trip (reply byte[] ~419MB at 640x512, < 2GB array cap)
				for (int j0 = 0; j0 < num; j0 += REQ) {
					int cnt = Math.min(REQ, num - j0);
					int startNewest = (w0 + j0)*dnn_stride + N_dnn - 1;   // absolute newest of the chunk's first scene
					long t0 = System.currentTimeMillis();
					CuasDnnRemote.BatchResult br = remote.inferBatch(nlev, startNewest, cnt, dnn_stride, roi, rmax, use_l2, use_l2 && (j0 == 0), noise_scales[nlev]);
					long t1 = System.currentTimeMillis();
					for (int jj = 0; jj < cnt; jj++) {
						int j = j0 + jj;
						double [][][] fld = new double [rnp][1][br.nvel];
						for (int p = 0; p < rnp; p++) { float [] rv = br.roiField[jj][p]; double [] dv = fld[p][0]; for (int v = 0; v < br.nvel; v++) dv[v] = rv[v]; }
						dnn_roi[j] = fld;
						for (int c = 0; c < Math.min(br.nch, off5.length); c++) { float [] sc = br.offset5[jj][c]; double [] dc = off5[c][j]; for (int p = 0; p < H*W; p++) dc[p] = sc[p]; }
						int newest = (w0 + j)*dnn_stride + N_dnn - 1;
						ts_dnn[j] = ts_pyramid[nlev][newest] + " f"+newest;
					}
					System.out.println(now()+"   DNN-remote LEV"+nlev+" scenes "+(j0+1)+".."+(j0+cnt)+"/"+num
							+": gpu="+d2s(br.gpuMs)+"ms ("+d2s(br.gpuMs/cnt)+"ms/scene) roundtrip="+(t1-t0)+"ms");
				}
				String roiTag = "-ROI"+roi.x+"_"+roi.y+"_"+roi.width+"_"+roi.height;
				String title = title_conv5d+"-DNN"+(use_l2?"-L2":"")+"-LEV"+nlev+roiTag; // -LEV0 too, for uniform level indexing (was: no tag for level 0). By Claude on 06/24/2026
				int [] win_dnn = timeWindow(ts_dnn);
				double [][][][] dnn_w = win4(dnn_roi, win_dnn); String [] ts_w = winS(ts_dnn, win_dnn);
				if (save_rect)  QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(cuasRTUtils.showConvKernel5d(         dnn_w, roi, ts_w, title+"-RECT"),       clt_parameters.imp), getModelDirectory());
				if (save_hyper) QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(cuasRTUtils.showConvKernel5dHyperRect(dnn_w, roi, ts_w, title+"-HYPER-RECT"), clt_parameters.imp), getModelDirectory());
				int nsc = win_dnn[1]-win_dnn[0];
				// -OFFSET reordered to {s,Vx,Vy,dx,dy}(+age when L2): s first so ImageJ auto-ranges on it. off5 is
				// [dx,dy,s,Vx,Vy(,age)]. By Claude on 06/20/2026, +L2 age 06/24/2026
				final int []    ord        = use_l2 ? new int[]{2, 3, 4, 0, 1, 5} : new int[]{2, 3, 4, 0, 1};
				final String [] off_labels = use_l2 ? new String[]{"s","Vx","Vy","dx","dy","age"} : new String[]{"s","Vx","Vy","dx","dy"};
				// NaN Vx,Vy,dx,dy where s < curt_dnn_thresh so velocity shows only at detections (ImageJ ignores NaN); keep s + age full. By Claude on 06/20/2026
				final double off_thr = clt_parameters.imp.curt_dnn_thresh;
				final double velScale = 1.0 / Math.max(1, clt_parameters.imp.curt_vel_decimate);   // Vx,Vy: cells -> px/level-frame (1/vel_decimate) // By Claude on 06/20/2026
				double [][][] off5_w = new double [ord.length][nsc][];   // window the full-frame offset for -OFFSET
				for (int k = 0; k < nsc; k++) {
					double [] sCh = off5[2][win_dnn[0]+k];             // s channel (off5 index 2)
					for (int c = 0; c < ord.length; c++) {
						double [] src = off5[ord[c]][win_dnn[0]+k]; double [] dst = new double [src.length];
						double scl = ((c == 1) || (c == 2)) ? velScale : 1.0;   // c1=Vx, c2=Vy cells->px; s/dx/dy/age unscaled
						boolean keepFull = (c == 0) || (use_l2 && (c == 5));    // s and age shown full (not NaN-gated by s) // By Claude 06/24/2026
						if (keepFull) System.arraycopy(src, 0, dst, 0, src.length);
						else for (int p = 0; p < src.length; p++) dst[p] = (sCh[p] >= off_thr) ? src[p] * scl : Double.NaN;
						off5_w[c][k] = dst;
					}
				}
				ImagePlus impOff = ShowDoubleFloatArrays.showArraysHyperstack(off5_w, W, title+"-OFFSET"+l2tag, ts_w, off_labels, false);
				tagCuasImp(impOff, clt_parameters.imp);
				impOff.setProperty("curt_save_select", new Rectangle(0,0,W,H).toString());   // -OFFSET is full-frame: record its real extent, not the 70x20 ROI // By Claude on 06/20/2026
				impOff.setProperty("curt_dnn_noise_scales", noise_meta);                     // ALL per-level scales (provenance: recover what each layer used). By Claude on 06/24/2026
				com.elphel.imagej.readers.EyesisTiff.encodeProperiesToInfo(impOff);           // re-encode Info with the override
				QuadCLTCPU.saveImagePlusInDirectory(impOff, getModelDirectory());
				System.out.println(now()+" runDnnRemote(): LEV"+nlev+" saved -RECT/-HYPER-RECT (ROI) + -OFFSET (full "+W+"x"+H+", {"+String.join(",", off_labels)+"})");
			}
			// Per-level noise scales used this run (printed near the END so it is easy to locate; also in each -OFFSET
			// metadata as curt_dnn_noise_scales). Critical/sensitive - keep visible. By Claude on 06/24/2026
			System.out.println(now()+" runDnnRemote(): PER-LEVEL NOISE SCALES = "+noise_meta);
		} catch (Exception e) {
			System.out.println("runDnnRemote() failed: "+e); e.printStackTrace();
		}
	}

	public CuasMotion detectTargets(
			CLTParameters     clt_parameters,
			boolean           batch_mode,
			int               debugLevel) {
		boolean  save_linear_cuas =  true;
		int      curt_pyramid =      clt_parameters.imp.curt_pyramid;
		double   curt_psf_radius =   clt_parameters.imp.curt_psf_radius;
		double   curt_n_sigma =      clt_parameters.imp.curt_n_sigma;
		// rleak0 local removed 2026-06-20 (LReLU conditioning now LINEAR); predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
		int      curt_pix_decimate = clt_parameters.imp.curt_pix_decimate;
		int      curt_vel_decimate = clt_parameters.imp.curt_vel_decimate;
		int      curt_vel_radius =   clt_parameters.imp.curt_vel_radius;
		boolean  curt_vel_thresh_en =clt_parameters.imp.curt_vel_thresh_en;
		int      curt_vel_suppr_rad =clt_parameters.imp.curt_vel_suppr_rad;
		double   curt_vel_athresh =  clt_parameters.imp.curt_vel_athresh;
		double   curt_vel_rthresh =  clt_parameters.imp.curt_vel_rthresh;
		double   curt_vel_dbg_min =  clt_parameters.imp.curt_vel_dbgmin; // temporary, testing with strong targets, make clean background
		boolean  curt_recur_en =     clt_parameters.imp.curt_recur_en;      // By Claude on 06/09/2026
		int      curt_recur_period = clt_parameters.imp.curt_recur_period;  // By Claude on 06/09/2026
		double   curt_recur_w =      clt_parameters.imp.curt_recur_w;       // By Claude on 06/09/2026
		double   curt_recur_leak =   clt_parameters.imp.curt_recur_leak;    // By Claude on 06/09/2026
		double   curt_recur_wthresh =clt_parameters.imp.curt_recur_wthresh; // By Claude on 06/09/2026
		double   curt_recur_spread = clt_parameters.imp.curt_recur_spread;  // By Claude on 06/09/2026
		int      curt_recur_max_clust = clt_parameters.imp.curt_recur_max_clust; // By Claude on 06/09/2026
		double   curt_recur_fit_rat = clt_parameters.imp.curt_recur_fit_rat; // By Claude on 06/10/2026
		double   curt_recur_rs_frac = clt_parameters.imp.curt_recur_rs_frac; // By Claude on 06/10/2026
		double   curt_recur_rs_min =  clt_parameters.imp.curt_recur_rs_min;  // By Claude on 06/10/2026

		double[] curt_temp_weights = clt_parameters.imp.curt_temp_weights.clone();
      	
		boolean  curt_save_c5full =  clt_parameters.imp.curt_save_c5full;
		boolean  curt_save_c5rect =  clt_parameters.imp.curt_save_c5rect;
		Rectangle curt_save_select =  clt_parameters.imp.curt_save_select;
		if (clt_parameters.imp.curt_synth_src) { // synthetic grid: HONOR the user ROI so it can be shrunk - the full-grid SYNTH_ROI (320x320) OOMs the per-velocity render (~12x expansion per axis); fall back to SYNTH_ROI only when no ROI is set // By Claude on 06/15/2026
			if (curt_save_select == null) {
				curt_save_select = new Rectangle(SYNTH_ROI);
				System.out.println("Synthetic input: no ROI set - using full-grid SYNTH_ROI = "+curt_save_select);
			} else {
				System.out.println("Synthetic input: using curt_save_select ROI = "+curt_save_select
						+ " (synthetic 11x11 grid spans ~"+SYNTH_ROI+"; aim the ROI inside it)"); // By Claude on 06/15/2026
			}
		}
        final double      min_frac_last = clt_parameters.imp.curt_min_frac; // By Claude on 06/12/2026
        if (clt_parameters.imp.curt_synth_src && (min_frac_last > 0)) { // By Claude on 06/12/2026
        	System.out.println("WARNING: synthetic (B-measurement) run with curt_min_frac = "+min_frac_last+
        			" - this NONLINEAR gate punches zeros into the strongest ridge cells; set 0 for a linear measurement");
        }
		
// Initialize LoG and other kernel parameters, calculate LoG first, show results
       	// kernel3d3_rad local + arg removed 2026-06-20 (dead after 3d3/MF cleanup); see tag cuas-layer1. // By Claude
       	CuasRTUtils cuasRTUtils = new CuasRTUtils (
    			curt_psf_radius,          // double   psf_radius,
				curt_n_sigma,             // double   n_sigma,
    			curt_temp_weights,        // double[] temporal_weights,
                curt_pix_decimate,        // int      pixel_decimate,
                curt_vel_decimate,        // int      velocity_decimate,
                curt_vel_radius,          // int      velocity_radius,
                curt_vel_suppr_rad,       // int      vel_suppr_rad,
    			getWidth (),              // int      width,
    			getHeight());             // int      height // c5_matched arg removed 2026-06-20 (C5P path gone); predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
       	// For testing, create whole arrays?
       	boolean save_LoG =        debugLevel> -4;
       	boolean save_5d =          debugLevel> -4;
       	boolean save_LoG_pixels = debugLevel> -4;
       	// save_3d3_pixels removed 2026-06-20 (3d3 path gone); predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
   		int num_hist_5d = cuasRTUtils.getNumHist();
       	
       	if (save_LoG) {
       		String title_log = getBaseName()+SUFFIX_LOG+"-PSF"+d2s(cuasRTUtils.getPsfRadius())+"-KR"+d2s(cuasRTUtils.getKernel2dRadius());
       		ImagePlus imp_log = cuasRTUtils.showKernel2d(title_log);
       		QuadCLTCPU.saveImagePlusInDirectory(imp_log,getModelDirectory());
       	}
       	if (save_5d) {
       	    String title_5d = getBaseName()+SUFFIX_CONV5D+
       	            "-NH"+num_hist_5d+
       	            "-VR"+curt_vel_radius+"-VD"+curt_vel_decimate;
       		ImagePlus imp_5d = cuasRTUtils.showKernel5d(title_5d);
       		QuadCLTCPU.saveImagePlusInDirectory(imp_5d,getModelDirectory());
       	}
       	double [][] dpixels = getDPpixels();
       	// Synthetic quick-run fast path (curt_synth_bg_avg > 1): decimate-average the REAL background by N // By Claude on 06/20/2026
       	// (the LEV-L noise floor, N=2^L) into a sequence (length = the "Time ROI" window; open/-1 = all frames) and force a single level - restoring the
       	// old synthetic+background mode speed. SUBAVG then runs over this short sequence; the SYNTHETIC targets are
       	// injected later (after SUBAVG), so they never enter the average. Velocity cells then encode LEV1/2/3.
       	final boolean synth_fast = clt_parameters.imp.curt_synth_src && (clt_parameters.imp.curt_synth_bg_avg > 1);
       	if (synth_fast && (dpixels != null) && (dpixels.length >= clt_parameters.imp.curt_synth_bg_avg)) {
       		final int navg = clt_parameters.imp.curt_synth_bg_avg;
       		int ngrp = dpixels.length / navg;                                  // each group = navg input frames -> one averaged (LEV) frame
       		String [] ts_grp = new String [ngrp];
       		for (int k = 0; k < ngrp; k++) ts_grp[k] = time_stamps[k*navg + navg - 1]; // last ts of each navg-group (the LEV timestamp)
       		int [] gwin = timeWindow(ts_grp);                                  // honor curt_time_from/to on the averaged (decimated) timestamps so the range can be extended // By Claude on 06/20/2026
       		int g0 = gwin[0], g1 = gwin[1];                                    // open Time ROI (-1) -> [0,ngrp) = ALL frames; "to" set -> up to that timestamp // By Claude on 06/20/2026
       		int kout = g1 - g0;
       		int npx_a = dpixels[0].length;
       		double [][] davg = new double [kout][npx_a];
       		String   [] tsavg = new String [kout];
       		for (int k = 0; k < kout; k++) {
       			int g = g0 + k;
       			for (int j = 0; j < navg; j++) { double [] fr = dpixels[g*navg + j]; for (int p = 0; p < npx_a; p++) davg[k][p] += fr[p]; }
       			for (int p = 0; p < npx_a; p++) davg[k][p] /= navg;
       			tsavg[k] = ts_grp[g];                                          // 8x-decimated timestamp (last of the navg-group)
       		}
       		this.dpixels = davg; this.time_stamps = tsavg; dpixels = davg;
       		System.out.println(now()+" detectTargets(): synth fast path - bg-averaged x"+navg+" -> "+kout+"/"+ngrp+" frames (Time ROI groups ["+g0+","+g1+")), single level"); // By Claude on 06/20/2026
       	}
       	if (clt_parameters.imp.curt_subtract_avg && (dpixels != null) && (dpixels.length > 0)) { // de-streak: subtract the static background before LoG so the sharp treeline edge (and its tile-grid ringing) is gone; the moving target is not in the average // By Claude on 06/14/2026
       		int npx = dpixels[0].length;
       		double [] mean = new double [npx];
       		int []    cnt  = new int    [npx];
       		// NaN-tolerant (union) average: as the camera rotates, side areas are seen only by some scenes.
       		// Average ONLY the non-NaN scenes per pixel so a border pixel survives wherever ANY scene saw it,
       		// instead of NaN-poisoning it in the mean (and hence in every frame after subtraction). Matches the
       		// oracle -CENTER-CUAS-MERGED-CUAS behavior; the plain sum here NaN-propagated (thick border after LoG).
       		// By Claude on 07/01/2026
       		for (double [] fr : dpixels) for (int p = 0; p < npx; p++) if (!Double.isNaN(fr[p])) { mean[p] += fr[p]; cnt[p]++; }
       		for (int p = 0; p < npx; p++) mean[p] = (cnt[p] > 0) ? (mean[p] / cnt[p]) : Double.NaN;
       		// frame - mean: a scene that saw the pixel stays valid; one that didn't stays NaN; NaN only where NO scene saw it.
       		for (double [] fr : dpixels) for (int p = 0; p < npx; p++) fr[p] -= mean[p];
       		System.out.println(now()+" detectTargets(): subtracted temporal average over "+dpixels.length+" input frames (curt_subtract_avg, NaN-tolerant union - uses whole sequence; realtime would use a prior-run average)"); // By Claude on 06/14/2026
       	}
       	if (save_LoG_pixels) { // pre-LoG stack (post-subtract-average): inspect edge structure BEFORE the LoG to
       		// separate union-average residual at the partial-coverage rotation edge from LoG-touching-NaN ringing. By Claude on 07/01/2026
       		String title_prelog = getBaseName()+"-PRELOG";
       		ImagePlus imp_prelog = ShowDoubleFloatArrays.makeArrays(dpixels, getWidth(), getHeight(), title_prelog, getTimeStamps());
       		QuadCLTCPU.saveImagePlusInDirectory(imp_prelog, getModelDirectory());
       	}
       	double [][] dpixels_log = new double [dpixels.length][dpixels[0].length];
    	double []   kernel2d = cuasRTUtils.getKernel2d();
    	double alpha0 =    1.0; // LoG conditioning is now LINEAR (identity, no LReLU); rleak0 removed 2026-06-20, predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
    	double alpha_pyr = 1.0; // pyramid averaging is now LINEAR (identity, no LReLU); rleak_pyr removed 2026-06-20, predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
    	// 3d3 coarse-velocity path removed 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026

    	System.out.println(now()+" detectTargets(): convolve2DLReLU (LoG)"); // By Claude on 06/11/2026
    	for (int nseq = 0; nseq < dpixels_log.length; nseq++) {
       		cuasRTUtils.convolve2DLReLU(
       				dpixels[nseq],     // final double [] data,
       				kernel2d,          // final double [] kernel, // square
       				dpixels_log[nseq], // double []       result_in,
       				alpha0);            // final double    alpha)
       	}
       	if (save_LoG_pixels) {
       		String title_conv2d = getBaseName()+SUFFIX_CONV2D+"-PSF"+d2s(cuasRTUtils.getPsfRadius())+
       				"-KR"+d2s(cuasRTUtils.getKernel2dRadius())+"-ALPHA0_"+d2s(alpha0);
       		
			ImagePlus imp_conv2d = ShowDoubleFloatArrays.makeArrays(
					dpixels_log, // float[][] pixels,
					getWidth(),  // int width,
					getHeight(), // int height,
					title_conv2d,					
					getTimeStamps()); //String [] titles)
			imp_conv2d.setDisplayRange(CuasRTUtils.IMP_MINMAX[0],CuasRTUtils.IMP_MINMAX[1]);

       		QuadCLTCPU.saveImagePlusInDirectory(imp_conv2d, getModelDirectory());
       	}

       	// 3d3 coarse-velocity path (and its conv5d-from-3d3 stage) removed 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
       	// The fine-velocity detector is now the DNN front-end fed from dpixels_pyramid; nothing consumes a 3d3 coarse-velocity history.

       	// building pyramid
   		int pyramid_levels = 	synth_fast ? 1 : clt_parameters.imp.curt_pyramid; // synth fast path forces a single level // By Claude on 06/20/2026
   		// Single level selector for ALL heavy per-level paths (3d3/conv5d, C5P-direct, DNN): // By Claude on 06/13/2026
   		// curt_c5_levels = {} -> all built levels; {k} -> run the heavy stages only at level k
   		// (the cheap temporalAverageLReLU/convolve3D3LReLU pyramid chain still builds up to k).
   		// Synthetic input now uses the same pyramid + level selection as real data (the old
   		// pyramid_levels=1 force and the curt_synth_bg_avg N= decimation were retired).
   		final int [] c5_levels = synth_fast ? new int[0] : clt_parameters.imp.curt_c5_levels; // synth fast path -> all built (= level 0 only) // By Claude on 06/20/2026
   		
   		double [][][]   dpixels_pyramid =     new double [pyramid_levels][][];
   		String [][]     ts_pyramid =          new String [pyramid_levels][];
   		// dpixels_3d3_pyramid / dpixels_5d_pyramid / dpixels_5d_roi_pyramid (the 3d3-fed conv5d stage) removed 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
   		ts_pyramid[0] =          getTimeStamps(1);
   		dpixels_pyramid[0] = new double[dpixels_log.length-1][width*height];
		for (int nseq = 1; nseq < dpixels_log.length; nseq++) {
			cuasRTUtils.temporalAverageLReLU(
					dpixels_log[nseq],          // final double [] data_now,
					dpixels_log[nseq-1],        // final double [] data_prev,
					dpixels_pyramid[0][nseq-1], // double []       result_in,
					alpha_pyr);                 // final double    alpha)
		}
//   		System.arraycopy(dpixels_log, 1, dpixels_pyramid[0], 0, dpixels_pyramid[0].length);
//   		int center9 = 4;
   		// save_5d_pixels removed 2026-06-20 (3d3-fed conv5d render gone); predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
   		String filt_suffix = curt_vel_thresh_en? ("-TE1" + "-TA"+d2s(curt_vel_athresh) + "-TR"+d2s(curt_vel_rthresh)+"-TD"+d2s(curt_vel_dbg_min)):
   			"-TE0";
   	    String title_conv5d = getBaseName()+SUFFIX_CONV5D+"-PSF"+d2s(cuasRTUtils.getPsfRadius())+
   	            "-KR"+d2s(cuasRTUtils.getKernel2dRadius())+"-A0_"+d2s(alpha0)+
   	            "-AP_"+d2s(alpha_pyr)+ // 3d3 params (-M/-F/-T/-P/-N/-A1) dropped from title 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
   	            "-NH"+num_hist_5d+
   	            "-VR"+curt_vel_radius+"-VD"+curt_vel_decimate+"-MF"+min_frac_last+
   	            filt_suffix +
   	            "-SR"+curt_vel_suppr_rad+"-D"+curt_vel_dbg_min;
   		// Layer 2 recurrent accumulation buffer (ROI only, every pyramid level).            // By Claude on 06/11/2026
   		// runRecurrentLevel() consumes the DNN field per level (see the DNN block below).    // By Claude on 06/11/2026
   		boolean run_recur = curt_recur_en && (curt_save_select != null);                      // By Claude on 06/09/2026
   		// LEV0 conv5d (3d3-fed convolve3d) + its saves/recurrent removed 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
   		for (int nlev = 0; nlev < pyramid_levels; nlev++) {
   	       	if (save_LoG_pixels) {
   	       		String title_conv2d_lev = getBaseName()+SUFFIX_CONV2D+"-PSF"+d2s(cuasRTUtils.getPsfRadius())+
   	       				"-KR"+d2s(cuasRTUtils.getKernel2dRadius())+"-ALPHA0_"+d2s(alpha0)+"-ALPHAPYR_"+d2s(alpha_pyr)+"-LEV"+nlev;
   				ImagePlus imp_conv2d = ShowDoubleFloatArrays.makeArrays(
   						dpixels_pyramid[nlev], // float[][] pixels,
   						getWidth(),  // int width,
   						getHeight(), // int height,
   						title_conv2d_lev,					
   						ts_pyramid[nlev]); //String [] titles)
   				imp_conv2d.setDisplayRange(CuasRTUtils.IMP_MINMAX[0],CuasRTUtils.IMP_MINMAX[1]);
   	       		QuadCLTCPU.saveImagePlusInDirectory(imp_conv2d, getModelDirectory());
   	       	}
   			
   			// run next pyramid level (use half of the temporal slices)
   			if (nlev < (pyramid_levels -1)) {
   				int num_lev_scenes = (dpixels_pyramid[nlev].length)/2 -1;
   				if (num_lev_scenes < 1) { // sequence too short to build deeper levels - stop here // By Claude on 06/14/2026
   					System.out.println(now()+" detectTargets(): pyramid level "+(nlev+1)+
   							" would have "+num_lev_scenes+" frames (LEV"+nlev+" has "+dpixels_pyramid[nlev].length+
   							") - stopping pyramid build at LEV"+nlev);
   					break;
   				}
   				System.out.println(now()+" detectTargets(): building pyramid level "+(nlev+1)); // By Claude on 06/11/2026
   				dpixels_pyramid[nlev+1] = new double[num_lev_scenes][width*height];
   				
   				ts_pyramid[nlev+1] = new String[num_lev_scenes];
   				// per-level 3d3 + conv5d (lev_sel_next) stage removed 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
   				// Only the cheap temporalAverageLReLU pyramid chain remains (feeds the C5P/DNN front-end below).
   				for (int nseq = 0; nseq < num_lev_scenes; nseq++) {
   					ts_pyramid[nlev+1][nseq] = ts_pyramid[nlev][2*(nseq + 1)];
   					cuasRTUtils.temporalAverageLReLU(
   							dpixels_pyramid[nlev][2*nseq+2],          // final double [] data_now,
   							dpixels_pyramid[nlev][2*nseq],        // final double [] data_prev,
   							dpixels_pyramid[nlev+1][nseq], // double []       result_in,
   							alpha_pyr);                 // final double    alpha)
   				}
   			}
   		}
   		// --- Synthetic reference grid injection + sqrt(2) noise check ---------------------- // By Claude on 06/14/2026
   		if (clt_parameters.imp.curt_synth_src && (synth_pixels != null) && (synth_pixels.length > 0)) {
   			// sqrt(2) check: full-frame std of the PURE real frame 0 should drop ~/sqrt(2) per level.
   			for (int nlev = 0; nlev < pyramid_levels; nlev++) {
   				if ((dpixels_pyramid[nlev] == null) || (dpixels_pyramid[nlev].length == 0)) continue;
   				double [] f0 = dpixels_pyramid[nlev][0];
   				double s = 0, s2 = 0; int np = f0.length;
   				for (double v : f0) { s += v; s2 += v*v; }
   				double mean = s/np, var = s2/np - mean*mean;
   				System.out.println(String.format("   LEV%d real background std = %.5f (frame0, %d px) - expect ~/sqrt(2) per level", nlev, Math.sqrt(Math.max(0.0,var)), np));
   			}
   			// Inject the synthetic grid TILED (synth[j %% n]) into each SELECTED built level, AFTER
   			// the pure-real pyramid is complete - so it is identical at every level (never averaged
   			// across levels) while the real noise is averaged. Moving cells wrap every n_synth frames
   			// (a deliberate recurrent-decay test case). C5P/DNN read this; legacy 3d3-conv5d does not.
   			int n_synth = synth_pixels.length;
   			boolean synth_bg = clt_parameters.imp.curt_synth_bg; // false = clean targets only: drop the real background (+its noise/NaNs) before injecting // By Claude on 06/15/2026
   			for (int nlev = 0; nlev < pyramid_levels; nlev++) {
   				if (!c5LevelSelected(c5_levels, nlev)) continue;
   				double [][] lev = dpixels_pyramid[nlev];
   				if (lev == null) continue;
   				for (int j = 0; j < lev.length; j++) {
   					float [] sp = synth_pixels[j % n_synth];
   					double [] d = lev[j];
   					if (!synth_bg) java.util.Arrays.fill(d, 0.0); // clean: replace the conditioned real background with zero, then add the synthetic target(s) // By Claude on 06/15/2026
   					int m = Math.min(d.length, sp.length);
   					for (int pix = 0; pix < m; pix++) d[pix] += sp[pix];
   				}
   			}
   			System.out.println(now()+" detectTargets(): injected synthetic grid (tiled "+n_synth+" frames) into selected pyramid levels"
   					+ (synth_bg ? " (over real background)" : " (CLEAN - background zeroed, curt_synth_bg=false)")); // By Claude on 06/15/2026
   			// Save the ACTUAL DNN input after mixing: LoG-conditioned real bg (avg-subtracted if curt_subtract_avg)
   			// + injected synth, full-frame per selected level, same timestamps - what the network really sees. By Claude on 06/16/2026
   			if (save_LoG_pixels) {
   				for (int nlev = 0; nlev < pyramid_levels; nlev++) {
   					if (!c5LevelSelected(c5_levels, nlev) || (dpixels_pyramid[nlev] == null)) continue;
   					String title_mix = getBaseName()+"-SYNTHMIX"+(synth_bg?"B":"")+(clt_parameters.imp.curt_subtract_avg?"-SUBAVG":"")+"-LEV"+nlev;
   					ImagePlus imp_mix = ShowDoubleFloatArrays.makeArrays(
   							dpixels_pyramid[nlev], getWidth(), getHeight(), title_mix, ts_pyramid[nlev]);
   					imp_mix.setDisplayRange(CuasRTUtils.IMP_MINMAX[0], CuasRTUtils.IMP_MINMAX[1]);
   					QuadCLTCPU.saveImagePlusInDirectory(imp_mix, getModelDirectory());
   				}
   			}
   		}
   		// C5P matched-filter / Bayes-posterior / whitening direct path (the c5-from-pixels block: convolve3dDirect, whitenC5PROI,
   		// posteriorGateC5PROI, the lambda posterior sweep, and its recurrent feed) removed 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
   		// The DNN front-end below is now the sole fine-velocity detector (fed from dpixels_pyramid).
   		// DNN front-end (-DNN): if curt_dnn_model is set, run the trained FCN per-pixel       // By Claude on 06/13/2026
   		// (stride-1, matching training) over the ROI -> (Vx,Vy,s) field = softmax(vel)*s,
   		// same shape as the C5P output, saved as -DNN-RECT / -DNN-HYPER-RECT. N (temporal
   		// depth, 8 or 9) is read from the loaded ONNX. Reuses curt_c5_levels for level gating. Recurrent feed is the next step
   		// (the field is [0,1]-scaled, unlike the C5P matched-filter response - needs rescale).
   		if (!clt_parameters.imp.curt_dnn_model.isEmpty() && (curt_save_select != null) && !clt_parameters.imp.curt_dnn_remote && !clt_parameters.imp.curt_dnn_local) { // local CPU path (remote=DGX / curt_dnn_local=native LibTorch blocks below) // By Claude on 06/13/2026, remote guard 06/20/2026, local guard 06/27/2026
   			final int vr_dnn = clt_parameters.imp.curt_vel_radius;                            // By Claude on 06/13/2026
   			final int dnn_stride = Math.max(1, clt_parameters.imp.curt_dnn_stride);           // 1 = every slice (testing), 4 = production 50% overlap // By Claude on 06/14/2026
   			try {                                                                             // By Claude on 06/13/2026
   				CuasDnnInfer dnn = new CuasDnnInfer(clt_parameters.imp.curt_dnn_model, 8);    // 8 = fallback hint only // By Claude on 06/13/2026
   				dnn.setPatch(clt_parameters.imp.curt_dnn_patch);                              // 24 or 32 (RF/training patch) - must match the loaded model // By Claude on 06/16/2026
   				final int N_dnn = dnn.getNFrames();                                           // 8 or 9, read from the loaded ONNX input shape -> 8/9 models swap at runtime // By Claude on 06/15/2026
   				for (int nlev = 0; nlev < pyramid_levels; nlev++) {                           // By Claude on 06/13/2026
   					if (!c5LevelSelected(c5_levels, nlev)) continue;                          // level gating (curt_c5_levels) // By Claude on 06/13/2026
   					double [][] framesD = dpixels_pyramid[nlev];                              // By Claude on 06/13/2026
   					if (framesD == null) continue;                                            // By Claude on 06/13/2026
   					// stride-4 (production) outputs every 4th level-slice (0..7, 4..11, ...); stride-1 (testing) every slice // By Claude on 06/14/2026
   					int num_all = (framesD.length >= N_dnn) ? ((framesD.length - N_dnn)/dnn_stride + 1) : 0; // By Claude on 06/14/2026
   					if (num_all == 0) continue;                                               // By Claude on 06/13/2026
   					// timing ROI (curt_time_from/to): infer ONLY the in-window scenes (compute-window, not just save) // By Claude on 06/14/2026
   					String [] cand_ts = new String [num_all];                                 // By Claude on 06/14/2026
   					for (int k = 0; k < num_all; k++) cand_ts[k] = ts_pyramid[nlev][k * dnn_stride + N_dnn - 1]; // By Claude on 06/14/2026
   					int [] tw = timeWindow(cand_ts); int w0 = tw[0], num = tw[1] - tw[0];     // By Claude on 06/14/2026
   					if (num <= 0) continue;                                                   // By Claude on 06/14/2026
   					double dnn_thresh = clt_parameters.imp.curt_dnn_thresh;                   // By Claude on 06/13/2026
   					if (dnn.isReg()) { // continuous-velocity (reg) head: save {S,Vx,Vy,sigma} + offset hyperstacks; no grid/ghostbuster/recurrent. By Claude on 06/17/2026
   						String title_reg = title_conv5d+"-DNN"+((nlev > 0)?("-LEV"+nlev):"")+"-ROI"+curt_save_select.x+"_"+curt_save_select.y+"_"+curt_save_select.width+"_"+curt_save_select.height;
   						int rnpr = curt_save_select.width * curt_save_select.height;
   						double [][][] svxys = new double [4][num][rnpr];   // [S,Vx,Vy,sigma][scene][pix] - S first (ImageJ auto-ranges from slice 1)
   						double [][][] soffr = new double [3][num][rnpr];   // [dx,dy,s]
   						String [] ts_reg = new String [num];
   						for (int j = 0; j < num; j++) {
   							int n5d = w0 + j, newest = n5d * dnn_stride + N_dnn - 1;
   							float [][] window = new float [N_dnn][];
   							for (int h = 0; h < N_dnn; h++) {
   								double [] src = framesD[newest - h]; float [] fw = new float [src.length];
   								for (int k = 0; k < src.length; k++) fw[k] = (float) src[k];
   								window[h] = fw;
   							}
   							double [][] o6 = dnn.inferROIReg(window, width, height, curt_save_select); // [pix]{S,Vx,Vy,sigma,dx,dy}
   							// dnn_t8frac T-8 ghost filter removed 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
   							for (int p = 0; p < rnpr; p++) {
   								double sv = o6[p][0];
   								svxys[0][j][p] = sv; svxys[1][j][p] = o6[p][1]; svxys[2][j][p] = o6[p][2]; svxys[3][j][p] = o6[p][3];
   								soffr[0][j][p] = (sv >= dnn_thresh) ? o6[p][4] : Double.NaN;
   								soffr[1][j][p] = (sv >= dnn_thresh) ? o6[p][5] : Double.NaN;
   								soffr[2][j][p] = sv;
   							}
   							ts_reg[j] = ts_pyramid[nlev][newest] + " f"+newest;
   							System.out.println(now()+"   DNN-reg scene "+(j+1)+"/"+num+" (frame "+newest+") done");
   						}
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(ShowDoubleFloatArrays.showArraysHyperstack(
   								svxys, curt_save_select.width, title_reg+"-VXYS", ts_reg, new String[]{"S","Vx","Vy","sigma"}, false), clt_parameters.imp), getModelDirectory());
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(ShowDoubleFloatArrays.showArraysHyperstack(
   								soffr, curt_save_select.width, title_reg+"-OFFSET", ts_reg, new String[]{"dx","dy","s"}, false), clt_parameters.imp), getModelDirectory());
   						if (!clt_parameters.imp.curt_stage2_model.isEmpty()) {  // v2 Stage-2: vote + learned refine views // By Claude on 06/18/2026
							int rw2 = curt_save_select.width, rh2 = curt_save_select.height, Nm1v = N_dnn - 1;
							double [][][] votem = new double [1][num][rnpr];   // vote heatmap (accS), tail-indexed
							double [][][] refd  = new double [3][num][rnpr];   // refined {S,Vx,Vy}, tail-indexed
							double [][][] heads = new double [1][num][rnpr];   // detection re-indexed to head (T0) // By Claude on 06/18/2026
							try {
								CuasStage2Infer s2 = new CuasStage2Infer(clt_parameters.imp.curt_stage2_model);
								for (int j = 0; j < num; j++) {
									double [][] acc = voteScatter(svxys[0][j], svxys[1][j], svxys[2][j], rw2, rh2, Nm1v);
									votem[0][j] = acc[0].clone();                                  // raw vote heatmap (display)
									heads[0][j] = headFromVote(acc[0], acc[1], acc[2], rw2, rh2, Nm1v, 0.25);  // head from clean vote consensus (frac=0.25*max) // By Claude on 06/18/2026
									double mx = 1e-9; for (double v : acc[0]) if (v > mx) mx = v;   // per-scene normalize to MATCH refine training (python: acc /= accS.max()) // By Claude on 06/18/2026
									for (int c = 0; c < 3; c++) for (int p = 0; p < acc[c].length; p++) acc[c][p] /= mx;
									double [][] r = s2.refine(acc, rw2, rh2);
									refd[0][j] = r[0]; refd[1][j] = r[1]; refd[2][j] = r[2];
								}
								s2.close();
								QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(ShowDoubleFloatArrays.showArraysHyperstack(
										votem, rw2, title_reg+"-VOTE", ts_reg, new String[]{"vote"}, false), clt_parameters.imp), getModelDirectory());
								QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(ShowDoubleFloatArrays.showArraysHyperstack(
										refd, rw2, title_reg+"-REFINED", ts_reg, new String[]{"S","Vx","Vy"}, false), clt_parameters.imp), getModelDirectory());
								QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(ShowDoubleFloatArrays.showArraysHyperstack(
										heads, rw2, title_reg+"-HEADS", ts_reg, new String[]{"head"}, false), clt_parameters.imp), getModelDirectory());
								System.out.println(now()+"   v2 Stage-2: saved -VOTE + -REFINED + -HEADS ("+num+" scenes)");
							} catch (Exception e) { System.out.println("v2 Stage-2 failed: "+e); }
						}
						continue;  // reg level done - skip the grid path below
   					}
   					double [][][][] dnn_roi = new double [num][][][];                         // By Claude on 06/13/2026
   					double [][][]   dnn_off = new double [num][][];                            // per scene: [roi_pix][{dx,dy,s}] - sub-pixel offset viz // By Claude on 06/14/2026
   					String [] ts_dnn = new String [num];                                      // By Claude on 06/13/2026
   					System.out.println(now()+" detectTargets(): DNN inferROI LEV"+nlev+" ("+num+" of "+num_all+" scenes, stride "+dnn_stride+", "+ // By Claude on 06/14/2026
   							(curt_save_select.width*curt_save_select.height)+" px/scene, thresh="+dnn_thresh+")"); // By Claude on 06/13/2026
   					for (int j = 0; j < num; j++) {                                           // By Claude on 06/14/2026
   						int n5d = w0 + j;                                                     // in-window scene index // By Claude on 06/14/2026
   						int newest = n5d * dnn_stride + N_dnn - 1;                            // absolute newest-frame index in framesD (strided) // By Claude on 06/14/2026
   						float [][] window = new float [N_dnn][];                              // By Claude on 06/13/2026
   						for (int h = 0; h < N_dnn; h++) {                                     // By Claude on 06/13/2026
   							double [] src = framesD[newest - h];                              // h=0 newest // By Claude on 06/13/2026
   							float [] fw = new float [src.length];                             // By Claude on 06/13/2026
   							for (int k = 0; k < src.length; k++) fw[k] = (float) src[k];      // By Claude on 06/13/2026
   							window[h] = fw;                                                   // By Claude on 06/13/2026
   						}                                                                     // By Claude on 06/13/2026
   						double [][] off = new double [curt_save_select.width * curt_save_select.height][]; // {dx,dy,s} per ROI pixel // By Claude on 06/14/2026
   						dnn_roi[j] = dnn.inferROI(window, width, height, curt_save_select, vr_dnn, 0.0, off); // full field, NO s-threshold: Layer 2 (recurrent) needs the weak sub-threshold signal to integrate; curt_dnn_thresh is viz-only. By Claude on 06/20/2026
   						dnn_off[j] = off;                                                     // By Claude on 06/14/2026
   						ts_dnn[j] = ts_pyramid[nlev][newest] + " f"+newest;                   // timestamp + abs frame index for matching // By Claude on 06/14/2026
   						System.out.println(now()+"   DNN scene "+(j+1)+"/"+num+" (frame "+newest+", "+ts_pyramid[nlev][newest]+") done"); // By Claude on 06/14/2026
   					}                                                                         // By Claude on 06/13/2026
   					// grid distribution-vote (-GVOTE / -GHEADS via voteScatterGrid) removed 2026-06-20; predecessor code at git tag cuas-layer1. // By Claude on 06/20/2026
   					// Ghostbuster: drop untrained corner velocities (cell-R > vmax*vel_decimate) before save // By Claude on 06/15/2026
   					// + recurrent feed, so the spurious corner sidelobes don't confuse the recurrent (s=0 for ghost-peak pixels)
   					if (clt_parameters.imp.curt_dnn_vmax > 0) {                                // By Claude on 06/15/2026
   						double rmax_cells = clt_parameters.imp.curt_dnn_vmax * clt_parameters.imp.curt_vel_decimate;
   						for (int j = 0; j < num; j++) dnnGhostbust(dnn_roi[j], dnn_off[j], vr_dnn, rmax_cells);
   					}
   					// ROI tag in the filename (x_y_w_h) so the python flight-log analysis auto-restores the ROI - no manual entry. By Claude on 06/16/2026
   					String roiTag = "-ROI"+curt_save_select.x+"_"+curt_save_select.y+"_"+curt_save_select.width+"_"+curt_save_select.height;
   					String title_dnn = title_conv5d+"-DNN"+((nlev > 0)?("-LEV"+nlev):"")+roiTag; // By Claude on 06/13/2026
   					int [] win_dnn = timeWindow(ts_dnn); // timing ROI // By Claude on 06/14/2026
   					double [][][][] dnn_w = win4(dnn_roi, win_dnn); String [] ts_dnn_w = winS(ts_dnn, win_dnn); // By Claude on 06/14/2026
   					if (curt_save_c5rect) {                                                   // By Claude on 06/13/2026
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(cuasRTUtils.showConvKernel5d( // By Claude on 06/13/2026
   								dnn_w, curt_save_select, ts_dnn_w, title_dnn+"-RECT"), clt_parameters.imp), getModelDirectory()); // metadata: all curt_* // By Claude on 06/16/2026
   					}                                                                         // By Claude on 06/13/2026
   					if (clt_parameters.imp.curt_save_c5hyper) {                               // By Claude on 06/13/2026
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(cuasRTUtils.showConvKernel5dHyperRect( // By Claude on 06/13/2026
   								dnn_w, curt_save_select, ts_dnn_w, title_dnn+"-HYPER-RECT"), clt_parameters.imp), getModelDirectory()); // metadata: all curt_* // By Claude on 06/16/2026
   					}                                                                         // By Claude on 06/13/2026
   					// -DNN-OFFSET: the per-pixel sub-pixel offset (dx,dy) + confidence s over the ROI,     // By Claude on 06/14/2026
   					// to see whether a cluster's offsets converge on one fractional position. Channels
   					// (bottom slider): 0=dx, 1=dy (NaN where s<0.05 so only detections show), 2=s.
   					{
   						int rwid = curt_save_select.width, rhgt = curt_save_select.height, rnp = rwid * rhgt;
   						int nsc = win_dnn[1] - win_dnn[0];
   						double [][][] off_hyper = new double [3][nsc][rnp];
   						double vthr = 0.05;
   						for (int k = 0; k < nsc; k++) {
   							double [][] off = dnn_off[win_dnn[0] + k];
   							for (int p = 0; p < rnp; p++) {
   								double dx = Double.NaN, dy = Double.NaN, sv = 0.0;
   								if ((off != null) && (off[p] != null)) { sv = off[p][2]; if (sv >= vthr) { dx = off[p][0]; dy = off[p][1]; } }
   								off_hyper[0][k][p] = dx; off_hyper[1][k][p] = dy; off_hyper[2][k][p] = sv;
   							}
   						}
   						QuadCLTCPU.saveImagePlusInDirectory(tagCuasImp(ShowDoubleFloatArrays.showArraysHyperstack(
   								off_hyper, rwid, title_dnn+"-OFFSET", ts_dnn_w, new String[]{"dx","dy","s"}, false), clt_parameters.imp), getModelDirectory()); // metadata: all curt_* // By Claude on 06/16/2026
   					}
   					// Feed the DNN field to the recurrent layer (Layer 2): as-is, or offset-splatted (px+dx,py+dy). // By Claude on 06/14/2026
   					// Tune the recurrent blend with curt_recur_w (0.25/0.75) etc.; the field is [0,1]-scaled so rs_min may need lowering.
   					if (run_recur && (num > 0)) {
   						boolean splat = clt_parameters.imp.curt_dnn_recur_splat;
   						double  rscale = clt_parameters.imp.curt_dnn_recur_scale;            // DNN field ~0.1 -> recurrent rs_min ~1.0 // By Claude on 06/14/2026
   						double [][][][] dnn_feed = new double [num][][][];
   						for (int j = 0; j < num; j++) {
   							double [][][] f = splat ? splatField(dnn_roi[j], dnn_off[j], curt_save_select) : dnn_roi[j];
   							if (rscale != 1.0) {
   								double [][][] g = new double [f.length][][];
   								for (int p = 0; p < f.length; p++) {
   									g[p] = new double [f[p].length][];
   									for (int su = 0; su < f[p].length; su++) { g[p][su] = f[p][su].clone(); for (int v = 0; v < g[p][su].length; v++) g[p][su][v] *= rscale; }
   								}
   								f = g;
   							}
   							dnn_feed[j] = f;
   						}
   						String rtitle = title_dnn + (splat ? "-SPLAT" : "");
   						System.out.println(now()+" detectTargets(): DNN recurrent feed LEV"+nlev+(splat?" (offset-splat)":" (as-is)")+", scale "+rscale); // By Claude on 06/14/2026
   						runRecurrentLevel(cuasRTUtils, dnn_feed, ts_dnn, curt_save_select, rtitle, false);
   					}
   				}                                                                             // By Claude on 06/13/2026
   				dnn.close();                                                                  // By Claude on 06/13/2026
   			} catch (Exception e) {                                                           // By Claude on 06/13/2026
   				System.out.println("DNN inference failed: "+e); e.printStackTrace();          // By Claude on 06/13/2026
   			}                                                                                 // By Claude on 06/13/2026
   		}                                                                                     // By Claude on 06/13/2026
   		// Remote DGX inference path (curt_dnn_remote): upload conditioned stack, DGX builds pyramid + infers. // By Claude on 06/20/2026
   		if ((curt_save_select != null) && (clt_parameters.imp.curt_dnn_remote || clt_parameters.imp.curt_dnn_local)) { // DGX server OR in-process native LibTorch (curt_dnn_local). By Claude on 06/27/2026
   			runDnnRemote(clt_parameters, cuasRTUtils, dpixels_log, ts_pyramid, pyramid_levels, c5_levels, curt_save_select, title_conv5d); // By Claude on 06/20/2026
   		}
   		System.out.println(now()+" detectTargets(): done"); // By Claude on 06/11/2026
   		return null;
   		/*
		CuasMotion cuasMotion = new CuasMotion (
				clt_parameters,              // CLTParameters     clt_parameters,
				getTimeStamps(),                // String []         scene_titles,
				cuasRanging.getCenter_CLT(), // QuadCLT           parentCLT,
				null, // fpixels_avg,                 // float []          fpixels_avg,
				uasLogReader,                // UasLogReader      uasLogReader,
				debugLevel);                 // int               debugLevel)
		return cuasMotion;
		*/
	}
}
/*
					uasLogReader,   // UasLogReader    uasLogReader,
batch_mode);    // boolean         batch_mode)

*/