From fd80c5d25e269bdbdc60e4d2f48411ded5f8ce49 Mon Sep 17 00:00:00 2001 From: Refinement Systems Date: Mon, 14 Sep 2026 23:00:38 +0200 Subject: [PATCH] stability sweep --- AGENTS.md | 6 +- README.md | 23 ++- pyproject.toml | 1 + src/dltb/distance_feedback.py | 116 ++++++++--- src/dltb/stability_sweep.py | 356 ++++++++++++++++++++++++++++++++++ 5 files changed, 466 insertions(+), 36 deletions(-) create mode 100644 src/dltb/stability_sweep.py diff --git a/AGENTS.md b/AGENTS.md index 255e050..3a0aef8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,9 @@ uv run dltb-distance # DreamSim perceptual drift/converg # (SMOKE_DISTANCE=1 adds it to smoke.sh) uv run dltb-distance-feedback --model sd-turbo --input input_example/test_512.png \ --anchor-blend 0.3 --frames 200 # static-source feedback loop + chart +uv run dltb-stability-sweep --model sd-turbo --input input_example/test_512.png \ + --min-blend 0.05 --max-blend 0.6 --step 0.05 --frames 200 \ + # sweep anchor-blend: stable frames + table uv run python src/dltb/analyze_drift.py # CPU-only drift metrics uv run dltb-stabilization # CPU-only verdict per metric column: # stabilized? where? at what value? @@ -80,7 +83,7 @@ stress battery for the stabilization detector. Verification ladder: ## Architecture -Shared library in `src/dltb/` + nine console scripts (see `[project.scripts]` +Shared library in `src/dltb/` + ten console scripts (see `[project.scripts]` in `pyproject.toml`): | File | Role | @@ -96,6 +99,7 @@ in `pyproject.toml`): | `distance_loop.py` | iterate + measure in one run (`dltb-distance-loop`): free-running loop with a strength-capable model, then DreamSim on the saved frames -> `distance_metrics.csv` + `distance_plot.png`; the pipeline is unloaded and released before DreamSim loads | | `distance_feedback.py` | continuous' stateful loop with a static source (`dltb-distance-feedback`): one image repeated for `--frames` frames, `--anchor-blend 0` = distance-loop, `1` = repeated fresh passes; saves every frame and every model input blend, then the same three-series DreamSim CSV + plot (input-vs-original is the third series) | | `detect_stabilization.py` | CPU-only verdict on a metrics CSV (`dltb-stabilization`): robust tail band (median ± k·MAD), backward scan for the last out-of-band smoothed sample, plateau diagnostics in band units (drift / level shift / wandering / oscillation / too short); reads `distance_metrics.csv`/`drift_metrics.csv`, writes `stabilization_report.csv` next to the input; `tests/test_detect_stabilization.py` is its stress battery | +| `stability_sweep.py` | anchor-blend stability sweep (`dltb-stability-sweep`): dltb-distance-feedback's loop per blend with ONE pipeline and ONE DreamSim load, `detect_stabilization` verdicts on to_prev/to_ref; stable blends reduced to two plateau frames + `run.json` under `stable/`, unstable moved whole to `unstable/`, summary `stability_sweep.csv` | | `klein.py` | klein-restricted wrapper; dual-ref conditioning hook; delegates to `continuous.run()` | Key invariants: diff --git a/README.md b/README.md index 9b352b3..8ab2155 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,15 @@ The console scripts share the library code in `src/dltb/` (`models`, `imaging`, `stabilization_report.csv` next to the input. No torch, no accelerator (`uv run python src/dltb/detect_stabilization.py` also works; needs only numpy — matplotlib only for `--plot`). +- `dltb-stability-sweep` — sweeps `dltb-distance-feedback`'s `--anchor-blend` + over `--min-blend/--max-blend/--step` with everything else fixed: ONE + pipeline load for the whole range, ONE DreamSim load for all verdicts, and + `detect_stabilization` judging the frame-vs-previous and frame-vs-reference + series per blend (the input-vs-ref series is ignored). Blends where **both** + series stabilize are reduced to their two plateau-opening frames plus + `run.json` under `stable//`; every other run moves wholesale to + `unstable//` for inspection; the stable ones are summarized in + `stability_sweep.csv` (`anchor-blend, v_prev, i_prev, v_ref, i_ref`). ## Documentation @@ -192,15 +201,23 @@ uv run dltb-distance-loop --model sd-turbo --input menu.png --strength 0.4 \ # blended in): 0.0 = dltb-distance-loop, 1.0 = repeated fresh passes uv run dltb-distance-feedback --model sd-turbo --input menu.png --strength 0.4 \ --anchor-blend 0.3 --frames 200 --prompt "a bronze lion sculpture" + +# Where does that loop stabilize? Sweep anchor-blend: stable blends keep their +# two plateau frames + a summary table, unstable ones move to unstable/ +uv run dltb-stability-sweep --model sd-turbo --input menu.png --strength 0.4 \ + --min-blend 0.05 --max-blend 0.6 --step 0.05 --frames 200 \ + --prompt "a bronze lion sculpture" ``` Runs land in `untracked/output_/_/` (e.g. `menu_free-running/`, `clip_stateful-a0.3_tailsfreeze-free-black60/`): the untouched input frame, the saved frames, and the output video(s). `dltb-distance-loop` and `dltb-distance-feedback` use fixed tags (no input stem) under the same model -tree — `distance_s/` and `feedback_s_a/`; since -prompt/steps/seed are not part of those tags, pass `--output-dir` subtrees when -varying them — their `run.json` records the settings of each run. +tree — `distance_s/`, `feedback_s_a/`, and the +sweep root `stability_s/` (with `stable/`, `unstable/` and +`stability_sweep.csv` inside); since prompt/steps/seed are not part of those +tags, pass `--output-dir` subtrees when varying them — their `run.json` +records the settings of each run. Run `uv run --help` for all options (strength, steps, seed handling, resolution, prompt, save frequency, video FPS, ...). diff --git a/pyproject.toml b/pyproject.toml index 343d415..69f6d35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dltb-distance = "dltb.analyze_distance:main" dltb-distance-loop = "dltb.distance_loop:main" dltb-distance-feedback = "dltb.distance_feedback:main" dltb-stabilization = "dltb.detect_stabilization:main" +dltb-stability-sweep = "dltb.stability_sweep:main" [build-system] requires = ["uv_build>=0.12.7,<0.13.0"] diff --git a/src/dltb/distance_feedback.py b/src/dltb/distance_feedback.py index 05f4e3d..b56f83e 100644 --- a/src/dltb/distance_feedback.py +++ b/src/dltb/distance_feedback.py @@ -81,6 +81,11 @@ DreamSim is loaded, so peak VRAM is the larger of the two models, not their sum. DreamSim runs on the same --device; its weights (~2.7 GB for the default ensemble) download into --cache-dir on first use. +The two phases are importable pieces (write_run_json, run_loop, load_dreamsim, +measure) with caller-owned accelerator lifecycles, so dltb-stability-sweep can +hold ONE pipeline across a whole anchor-blend sweep and ONE DreamSim load +across all of its verdicts. + Usage: uv run dltb-distance-feedback --model sd-turbo --input menu.png \\ --strength 0.4 --anchor-blend 0.3 --frames 200 \\ @@ -139,26 +144,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: return p.parse_args(argv) -def run(args: argparse.Namespace) -> None: +def write_run_json(args: argparse.Namespace, out_dir: Path, device: str) -> None: + """Settings sidecar for one feedback run (the tag encodes only strength + and blend, so the other knobs live here). Shared by run() and the + dltb-stability-sweep driver.""" spec = MODELS[args.model] width, height = resolve_geometry(spec, args.width, args.height) - if not 0.0 < args.strength <= 1.0: - raise SystemExit("--strength must be in (0, 1] (magnitude of noise per pass)") - if not 0.0 <= args.anchor_blend <= 1.0: - raise SystemExit("--anchor-blend must be in [0, 1]") - if args.frames < 1: - raise SystemExit("--frames must be >= 1") - check_requirements(spec, args.num_inference_steps, args.strength) - check_dreamsim_args(args) - - out_dir = run_dir(args.output_dir, args.model, - f"feedback_s{args.strength:g}_a{args.anchor_blend:g}") - frames_dir = out_dir / "frames" - inputs_dir = out_dir / "inputs" - frames_dir.mkdir(parents=True, exist_ok=True) - inputs_dir.mkdir(parents=True, exist_ok=True) - device = resolve_device(args.device) - (out_dir / "run.json").write_text(json.dumps({ "model": args.model, "model_id": spec.model_id, @@ -180,10 +171,24 @@ def run(args: argparse.Namespace) -> None: "cache_dir": str(args.cache_dir), }, indent=2) + "\n") - # --- phase 1: the diffusion model alone on the accelerator ------------- + +def run_loop(args: argparse.Namespace, out_dir: Path, pipe, + device: str) -> tuple[Path, list[Path], list[Path]]: + """Phase 1 of one feedback run: the diffusion loop, saved under out_dir. + + pipe must already be loaded; the CALLER owns its lifecycle, so a driver + can keep one pipeline across several anchor-blend values (run() loads and + releases it around this call). Saves frame_0000_original.png plus + frames/frame_NNNN.png and inputs/input_NNNN.png, and returns + (original_path, frame_paths, input_paths) for measure().""" from PIL import Image - pipe = load_pipeline(spec, args.offload, device) + spec = MODELS[args.model] + width, height = resolve_geometry(spec, args.width, args.height) + frames_dir = out_dir / "frames" + inputs_dir = out_dir / "inputs" + frames_dir.mkdir(parents=True, exist_ok=True) + inputs_dir.mkdir(parents=True, exist_ok=True) settings = PassSettings(prompt=args.prompt, num_inference_steps=args.num_inference_steps, strength=args.strength, @@ -215,16 +220,13 @@ def run(args: argparse.Namespace) -> None: frame_paths.append(path) if i % 10 == 0 or i == 1: print(f"frame {i}/{args.frames}", flush=True) + return original_path, frame_paths, input_paths - # The two models must never share the accelerator: drop the pipeline and - # return its cached blocks before DreamSim loads (peak VRAM = max, not sum). - del pipe - release_accelerator(device) - assemble_timelapse(frames_dir, out_dir / "timelapse.mp4", args.video_fps) - assemble_timelapse(inputs_dir, out_dir / "timelapse_inputs.mp4", args.video_fps, - pattern="input_*.png") - # --- phase 2: DreamSim over the original plus every saved frame -------- +def load_dreamsim(args: argparse.Namespace, device: str): + """Phase-2 setup shared by run() and dltb-stability-sweep: cache dir, + progress line, weights load. The caller owns the returned + (model, preprocess) lifecycle.""" cache_dir = Path(args.cache_dir) # dreamsim's own downloader mkdirs a single level (os.mkdir), so nested # --cache-dir needs this mkdir -p, exactly as in dltb-distance. @@ -232,8 +234,23 @@ def run(args: argparse.Namespace) -> None: label = args.dreamsim_type + ("_patch" if args.patch else "") print(f"loading dreamsim {label} on {device} (weights: {cache_dir}; the " "first run downloads them)", flush=True) - model, preprocess = load_model(args.dreamsim_type, args.patch, args.retries, - device, cache_dir) + return load_model(args.dreamsim_type, args.patch, args.retries, + device, cache_dir) + + +def measure(args: argparse.Namespace, out_dir: Path, original_path: Path, + frame_paths: list[Path], input_paths: list[Path], device: str, + model, preprocess, timelapses: bool = True) -> list[tuple]: + """Phase 2 of one feedback run over the saved frames: optional timelapse + assembly, DreamSim distances for the three series, distance_metrics.csv, + distance_plot.png, tail summary. model/preprocess must already be loaded + (caller-owned lifecycle, as in run_loop); returns the CSV rows.""" + if timelapses: + assemble_timelapse(out_dir / "frames", out_dir / "timelapse.mp4", + args.video_fps) + assemble_timelapse(out_dir / "inputs", out_dir / "timelapse_inputs.mp4", + args.video_fps, pattern="input_*.png") + # compute() compares each frame against its predecessor, so seeding the # list with the original gives frame 1 dreamsim_to_prev = vs original. The # extra list is index-aligned: row 0's "model input" is the original @@ -241,8 +258,6 @@ def run(args: argparse.Namespace) -> None: rows = compute([original_path, *frame_paths], original_path, model, preprocess, device, args.batch_size, extra=[original_path, *input_paths]) - del model, preprocess - release_accelerator(device) out_csv = out_dir / "distance_metrics.csv" out_plot = out_dir / "distance_plot.png" @@ -254,6 +269,7 @@ def run(args: argparse.Namespace) -> None: title += f" prompt={prompt!r}" plot_metrics(rows, out_plot, title, extra_label=INPUT_LABEL) + label = args.dreamsim_type + ("_patch" if args.patch else "") tail = rows[-10:] print(f"Analyzed {len(rows)} frames (original + {args.frames} passes); " f"dreamsim {label} on {device}") @@ -265,6 +281,42 @@ def run(args: argparse.Namespace) -> None: f"{statistics.mean(r[3] for r in tail):8.4f}") print(f" CSV : {out_csv}") print(f" plot: {out_plot}") + return rows + + +def run(args: argparse.Namespace) -> None: + spec = MODELS[args.model] + # early geometry validation (run_loop re-resolves; fail before the + # multi-GB pipeline load, not after) + resolve_geometry(spec, args.width, args.height) + if not 0.0 < args.strength <= 1.0: + raise SystemExit("--strength must be in (0, 1] (magnitude of noise per pass)") + if not 0.0 <= args.anchor_blend <= 1.0: + raise SystemExit("--anchor-blend must be in [0, 1]") + if args.frames < 1: + raise SystemExit("--frames must be >= 1") + check_requirements(spec, args.num_inference_steps, args.strength) + check_dreamsim_args(args) + + out_dir = run_dir(args.output_dir, args.model, + f"feedback_s{args.strength:g}_a{args.anchor_blend:g}") + device = resolve_device(args.device) + write_run_json(args, out_dir, device) + + # --- phase 1: the diffusion model alone on the accelerator ------------- + pipe = load_pipeline(spec, args.offload, device) + original_path, frame_paths, input_paths = run_loop(args, out_dir, pipe, device) + # The two models must never share the accelerator: drop the pipeline and + # return its cached blocks before DreamSim loads (peak VRAM = max, not sum). + del pipe + release_accelerator(device) + + # --- phase 2: DreamSim over the original plus every saved frame -------- + model, preprocess = load_dreamsim(args, device) + measure(args, out_dir, original_path, frame_paths, input_paths, + device, model, preprocess) + del model, preprocess + release_accelerator(device) def main(argv: list[str] | None = None) -> None: diff --git a/src/dltb/stability_sweep.py b/src/dltb/stability_sweep.py new file mode 100644 index 0000000..5f98928 --- /dev/null +++ b/src/dltb/stability_sweep.py @@ -0,0 +1,356 @@ +# Permission to use, copy, modify, and/or distribute this software for +# any purpose with or without fee is hereby granted. +# +# THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +# WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +# FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +# AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""dltb-stability-sweep: where does the static-source loop stop moving? + +Sweeps dltb-distance-feedback's --anchor-blend over a range with every other +knob fixed (one diffusion loop per blend value), then lets +detect_stabilization judge, per blend, the two series that matter here: + + dreamsim_to_prev frame_n vs frame_{n-1} (perceptual fixed point) + dreamsim_to_ref frame_n vs the original (drift) + +(the third feedback series, dreamsim_input_to_ref, is ignored). A blend value +counts as STABLE only when BOTH series come back 'stabilized' (the +informational constant_series / flat_from_start verdicts included). The +verdict sorts each run: + + stable /stable// keeps exactly three files -- the first + frame of each plateau plus the run's settings: + frame_XXXX_prev.png frame where to_prev stabilized + frame_YYYY_ref.png frame where to_ref stabilized + run.json + (XXXX/YYYY are frame numbers; they may coincide, and 0000 means + the prepared original itself) + unstable the whole staged run (frames, inputs, metrics CSV, plot, + run.json) moves to /unstable// unchanged, for + inspection with dltb-distance / dltb-stabilization + +and the stable runs are summarized in /stability_sweep.csv: + + anchor-blend, v_prev, i_prev, v_ref, i_ref + +with the blend value, the stabilized (plateau-median) to_prev distance and +its frame number, then the same for to_ref. + +Layout under the sweep root (default +untracked/output_/stability_s, or --output-dir/; runs +differing in other knobs still want --output-dir subtrees, same rule as every +tool -- the blend list itself lives in run.json and the table): + + stability_sweep.csv the summary table (stable blends only) + run.json the sweep's fixed settings + the blend list + stable// two frames + run.json per stable blend + unstable// full run directories, kept for inspection + _tmp// staging while the sweep is in flight (full feedback + run layout); every run is triaged out of it at the + end -- same filesystem, so moves are renames -- and + the directory is removed + +Timelapses are skipped in the sweep (dltb-assemble can encode one from any +kept frames directory later). + +TWO PHASES, ONE ACCELERATOR, ONCE: reusing dltb-distance-feedback's importable +phases (run_loop, measure, load_dreamsim, write_run_json), the pipeline is +loaded ONCE for the entire blend range and DreamSim ONCE for all measurements +-- peak VRAM is still max(two models), and an N-blend sweep pays one model +load instead of N. + +Stabilization needs history: the detector needs >= 20 samples and a plateau +worth the name, so --frames below ~40 cannot stabilize anything (its verdicts +then correctly say so and the runs land in unstable/). + +Usage: + uv run dltb-stability-sweep --model sd-turbo --input menu.png \\ + --min-blend 0.05 --max-blend 0.6 --step 0.05 --frames 200 \\ + --prompt "a bronze lion sculpture" + + # single value (min == max): one run, verdict + stabilized frames only + uv run dltb-stability-sweep --model sd-turbo --input menu.png \\ + --min-blend 0.3 --max-blend 0.3 --step 0.05 --frames 200 +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import shutil +from pathlib import Path + +from . import distance_feedback as fb +from .analyze_distance import add_dreamsim_args, check_dreamsim_args +from .args import (add_geometry_args, add_model_args, add_output_args, + add_pass_args, non_empty_path) +from .detect_stabilization import analyze_series, read_metrics_csv +from .models import (MODELS, STRENGTH_MODELS, check_requirements, + load_pipeline, release_accelerator, resolve_device, + resolve_geometry) +from .output import run_dir + +PREV_COL = "dreamsim_to_prev" +REF_COL = "dreamsim_to_ref" +SUMMARY_COLUMNS = ("anchor-blend", "v_prev", "i_prev", "v_ref", "i_ref") +SUMMARY_NAME = "stability_sweep.csv" + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + add_model_args(p, models=STRENGTH_MODELS) + add_pass_args(p) + add_geometry_args(p) + add_output_args(p) + p.add_argument("--input", required=True, type=non_empty_path, + help="Path to the input image (png/jpg/...); every simulated " + "source frame is this one image, prepared once") + p.add_argument("--frames", type=int, default=200, + help="Number of simulated video frames per blend value (one " + "model pass each); below ~40 the stabilization detector " + "cannot see a plateau") + p.add_argument("--min-blend", type=float, required=True, + help="First --anchor-blend value of the sweep (0.0 = " + "free-running, 1.0 = fully re-anchored)") + p.add_argument("--max-blend", type=float, required=True, + help="Last --anchor-blend value of the sweep (included when " + "a whole step lands on it)") + p.add_argument("--step", type=float, required=True, + help="Anchor-blend increment between sweep values") + add_dreamsim_args(p) + p.add_argument("--tail-frac", type=float, default=0.40, + help="detect_stabilization's tail fraction: trailing part of " + "each run assumed stationary (default 0.40)") + p.add_argument("--k-band", type=float, default=5.0, + help="detect_stabilization's band half-width in robust-sigma " + "units: the practical tolerance for 'settled' (default 5.0)") + p.add_argument("--min-plateau-frac", type=float, default=0.15, + help="detect_stabilization's minimum plateau fraction of the " + "run (default 0.15)") + return p.parse_args(argv) + + +def blend_values(min_blend: float, max_blend: float, step: float) -> list[float]: + """--anchor-blend values min + k*step up to and including max. + + The step COUNT is integral (not repeated addition), so values carry no + accumulation drift; max is included whenever a whole step lands on it. + Values must stay in --anchor-blend's [0, 1] domain, and distinct values + must also produce distinct run tags (:g = 6 significant digits). + """ + if not 0.0 <= min_blend <= max_blend <= 1.0: + raise SystemExit("--min-blend/--max-blend must satisfy " + "0 <= min <= max <= 1 (--anchor-blend's domain)") + if not step > 0.0: + raise SystemExit("--step must be > 0") + n = math.floor((max_blend - min_blend) / step + 1e-9) + values = [min_blend + k * step for k in range(n + 1)] + if len({f"{v:g}" for v in values}) != len(values): + raise SystemExit("--step too small: blend values collide in the run " + "tag at 6 significant digits") + return values + + +def blend_args(args: argparse.Namespace, blend: float) -> argparse.Namespace: + """A dltb-distance-feedback argument namespace for ONE blend value, + sharing the sweep's fixed knobs (the phase functions read attributes; + the sweep's own --min-blend/--max-blend/--step are not among them).""" + return argparse.Namespace( + model=args.model, width=args.width, height=args.height, + strength=args.strength, num_inference_steps=args.num_inference_steps, + guidance_scale=args.guidance_scale, prompt=args.prompt, + seed=args.seed, fixed_seed=args.fixed_seed, input=args.input, + frames=args.frames, anchor_blend=blend, + device=args.device, offload=args.offload, + batch_size=args.batch_size, dreamsim_type=args.dreamsim_type, + patch=args.patch, cache_dir=args.cache_dir, retries=args.retries, + video_fps=30, # unused: the sweep skips timelapse assembly + ) + + +def verdict(run_dir_: Path, args: argparse.Namespace) -> dict: + """detect_stabilization on the two series of one staged run's metrics + CSV; returns {column: report row} with status/flags/t_stab/value.""" + _, columns, data = read_metrics_csv(run_dir_ / "distance_metrics.csv") + missing = {PREV_COL, REF_COL} - set(columns) + if missing: + raise SystemExit(f"{run_dir_}: metrics CSV lacks {sorted(missing)}") + wanted = {c: data[c] for c in (PREV_COL, REF_COL)} + rows = analyze_series(list(wanted), wanted, tail_frac=args.tail_frac, + k_band=args.k_band, + min_plateau_frac=args.min_plateau_frac) + return {r["column"]: r for r in rows} + + +def keep_stabilized_frame(staged: Path, t_stab: int | None, dest: Path) -> int: + """Copy the frame that opened a plateau out of a staged run. + + t_stab is a row index of the metrics CSV, which is also the frame number + (row 0 is the prepared original). Returns that frame number. + """ + if t_stab is None: + raise SystemExit(f"{staged}: stabilized run without a plateau index") + name = ("frame_0000_original.png" if t_stab == 0 + else f"frames/frame_{t_stab:04d}.png") + src = staged / name + if not src.is_file(): + raise SystemExit(f"stabilized frame not found: {src}") + shutil.copy(src, dest) + return t_stab + + +def format_table(table: list[tuple]) -> str: + """Fixed-width rendering of the summary rows (same cells as the CSV).""" + + def cell(v): + if v is None: + return "-" + return f"{v:.6g}" if isinstance(v, float) else str(v) + + rows = [[f"{b:g}", cell(v_prev), cell(i_prev), cell(v_ref), cell(i_ref)] + for b, v_prev, i_prev, v_ref, i_ref in table] + widths = [max(len(c), *(len(r[i]) for r in rows)) + for i, c in enumerate(SUMMARY_COLUMNS)] + lines = [" ".join(c.ljust(widths[i]) for i, c in enumerate(SUMMARY_COLUMNS))] + lines.append(" ".join("-" * w for w in widths)) + for row in rows: + lines.append(" ".join(c.ljust(widths[i]) for i, c in enumerate(row))) + return "\n".join(lines) + + +def run(args: argparse.Namespace) -> None: + spec = MODELS[args.model] + blends = blend_values(args.min_blend, args.max_blend, args.step) + width, height = resolve_geometry(spec, args.width, args.height) + if args.frames < 1: + raise SystemExit("--frames must be >= 1") + if not 0.0 < args.strength <= 1.0: + raise SystemExit("--strength must be in (0, 1] (magnitude of noise per pass)") + check_requirements(spec, args.num_inference_steps, args.strength) + check_dreamsim_args(args) + if not 0.0 < args.tail_frac < 1.0: + raise SystemExit("--tail-frac must be in (0, 1)") + if args.k_band <= 0.0: + raise SystemExit("--k-band must be > 0") + if not 0.0 < args.min_plateau_frac <= 1.0: + raise SystemExit("--min-plateau-frac must be in (0, 1]") + + device = resolve_device(args.device) + root = run_dir(args.output_dir, args.model, f"stability_s{args.strength:g}") + tmp_root = root / "_tmp" + stable_root = root / "stable" + unstable_root = root / "unstable" + tmp_root.mkdir(parents=True, exist_ok=True) + + (root / "run.json").write_text(json.dumps({ + "tool": "dltb-stability-sweep", + "model": args.model, + "model_id": spec.model_id, + "input": str(args.input), + "width": width, + "height": height, + "frames": args.frames, + "prompt": args.prompt, + "strength": args.strength, + "num_inference_steps": args.num_inference_steps, + "guidance_scale": (spec.guidance_scale if args.guidance_scale is None + else args.guidance_scale), + "seed": args.seed, + "fixed_seed": args.fixed_seed, + "device": device, + "dreamsim_type": args.dreamsim_type + ("_patch" if args.patch else ""), + "batch_size": args.batch_size, + "cache_dir": str(args.cache_dir), + "min_blend": args.min_blend, + "max_blend": args.max_blend, + "step": args.step, + "blends": blends, + "tail_frac": args.tail_frac, + "k_band": args.k_band, + "min_plateau_frac": args.min_plateau_frac, + }, indent=2) + "\n") + + # --- phase 1: ONE pipeline, every blend's loop -------------------------- + pipe = load_pipeline(spec, args.offload, device) + runs: list[tuple] = [] # (blend, staged run dir, original, frames, inputs) + for blend in blends: + ns = blend_args(args, blend) + staged = tmp_root / f"feedback_s{args.strength:g}_a{blend:g}" + staged.mkdir(parents=True, exist_ok=True) + print(f"\n=== anchor-blend {blend:g}: feedback loop ===", flush=True) + fb.write_run_json(ns, staged, device) + runs.append((blend, staged, *fb.run_loop(ns, staged, pipe, device))) + # The two models must never share the accelerator: drop the pipeline and + # return its cached blocks before DreamSim loads (peak VRAM = max, not sum). + del pipe + release_accelerator(device) + + # --- phase 2: ONE DreamSim, every blend's measurement ------------------- + model, preprocess = fb.load_dreamsim(args, device) + for blend, staged, original_path, frame_paths, input_paths in runs: + print(f"\n=== anchor-blend {blend:g}: dreamsim ===", flush=True) + fb.measure(blend_args(args, blend), staged, original_path, frame_paths, + input_paths, device, model, preprocess, timelapses=False) + del model, preprocess + release_accelerator(device) + + # --- phase 3: verdicts and triage --------------------------------------- + table: list[tuple] = [] + for blend, staged, *_ in runs: + tag = staged.name + report = verdict(staged, args) + prev, ref = report[PREV_COL], report[REF_COL] + if not (prev["status"] == "stabilized" and ref["status"] == "stabilized"): + dest = unstable_root / tag + shutil.move(staged, dest) + print(f"anchor-blend {blend:g}: UNSTABLE " + f"({PREV_COL}: {prev['flags'] or '-'}; {REF_COL}: " + f"{ref['flags'] or '-'}) -> {dest}", flush=True) + continue + stable_dir = stable_root / tag + stable_dir.mkdir(parents=True, exist_ok=True) + i_prev = keep_stabilized_frame(staged, prev["t_stab"], + stable_dir / f"frame_{prev['t_stab']:04d}_prev.png") + i_ref = keep_stabilized_frame(staged, ref["t_stab"], + stable_dir / f"frame_{ref['t_stab']:04d}_ref.png") + shutil.move(staged / "run.json", stable_dir / "run.json") + shutil.rmtree(staged) + table.append((blend, prev["stabilized_value"], i_prev, + ref["stabilized_value"], i_ref)) + print(f"anchor-blend {blend:g}: stable (to_prev @ frame {i_prev} = " + f"{prev['stabilized_value']:.4g}; to_ref @ frame {i_ref} = " + f"{ref['stabilized_value']:.4g}) -> {stable_dir}", flush=True) + + tmp_root.rmdir() # every staged run was triaged out + + out_csv = root / SUMMARY_NAME + with out_csv.open("w", newline="") as fh: + w = csv.writer(fh) + w.writerow(SUMMARY_COLUMNS) + for blend, v_prev, i_prev, v_ref, i_ref in table: + w.writerow([f"{blend:g}", + "" if v_prev is None else v_prev, i_prev, + "" if v_ref is None else v_ref, i_ref]) + + print(f"\n{len(table)}/{len(blends)} blend values stable; " + f"{len(blends) - len(table)} moved to {unstable_root}") + print(f"table: {out_csv}") + if table: + print(format_table(table)) + + +def main(argv: list[str] | None = None) -> None: + run(parse_args(argv)) + + +if __name__ == "__main__": + main() -- 2.51.2