From 29da3c52b6a85f30c7fd186e1246b47ab4af3285 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Sat, 30 May 2026 13:15:58 -0700 Subject: [PATCH] =?UTF-8?q?Raise=20default=20SynthID-removal=20strength=20?= =?UTF-8?q?0.05=20=E2=86=92=200.10=20(current=20Google=20SynthID)=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Raise default SynthID-removal strength 0.05 -> 0.10 (current Google SynthID) The old default (0.04/0.05) no longer removes the CURRENT Google SynthID (Nano Banana / Gemini 3): verified 2026-05-30 via the Gemini 'Verify with SynthID' oracle on a real image -- 0.05 still detected, 0.10 not detected (OpenAI's was already cleared at 0.05). Add DEFAULT_STRENGTH=0.10 in watermark_profiles, route the engine + CLI defaults to it. At 0.10 small text deforms more, which is why text protection (_run_region_hires) runs by default. CLAUDE.md SynthID note corrected. CAVEAT: n=1 Google + n=1 OpenAI; broad corpus oracle validation pending (task tracked). Co-Authored-By: Claude Opus 4.8 (1M context) * Drop unused LOW/MEDIUM/HIGH strength profiles; CLI --strength defaults to DEFAULT_STRENGTH The fixed strength presets (and get_recommended_strength) were dead -- nothing in the pipeline used them, only tests. One knob now: DEFAULT_STRENGTH (0.10), overridable per-call via the CLI --strength flag, which now defaults to that constant (single source of truth). Removed the WatermarkRemover.LOW/MEDIUM/HIGH class attrs and the get_recommended_strength tests. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- src/remove_ai_watermarks/cli.py | 7 +++- src/remove_ai_watermarks/invisible_engine.py | 6 ++- .../noai/watermark_profiles.py | 38 +++++++------------ .../noai/watermark_remover.py | 12 ++---- tests/test_platform.py | 28 -------------- 6 files changed, 27 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a0967af..01b1c73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,4 +80,4 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are - Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID` + `IPTC_AI_MARKERS`, plus EXIF `Software` / XMP `CreatorTool` generator tags via `metadata.exif_generator` (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via `noai/isobmff.py` (top-level ``uuid`` / ``jumb`` box stripper, no re-encoding), which now also drops a top-level XMP ``uuid`` box that carries an AI label (matched by AI-marker content, not by the XMP UUID, so byte-order-robust) and covers MP4/MOV/M4V/M4A by content sniff. **Non-ISOBMFF audio/video removal is via ffmpeg** (`_FFMPEG_STRIP_EXTS` -> `_strip_with_ffmpeg`): WebM/Matroska (EBML), MP3 (ID3), WAV/FLAC/OGG (RIFF/Vorbis) are stripped losslessly with `ffmpeg -map_metadata -1 -map_chapters -1 -c copy` (codec data untouched). Requires ffmpeg on PATH; raises `RuntimeError` if absent or if ffmpeg can't parse the file. Verified end-to-end (a real ffmpeg-made WAV/MP3 with a `title=Suno AI` tag -> tag gone, audio bytes preserved). **Meta-box XMP now handled (`isobmff.blank_ai_xmp_packets`, v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so `iloc` offsets and the coded image stay valid). **Still NOT built:** an `Exif` *item* inside the `meta` box (rare -- AI labels are XMP) needs full `iinf`/`iloc` surgery (offset rewrite) with corruption risk -- exiftool (R/W/C for HEIC/AVIF EXIF+XMP, verified on exiftool.org 2026-05-27) would do it but is a non-installed binary dep, so it stays a documented gap. **Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.) - **SynthID detection is metadata-only.** There is no reliable *local* detector of the SynthID *pixel* watermark — Google's decoder is proprietary, no public spec or API (only a waitlisted portal). Authoritative confirmation: Google DeepMind's own paper "SynthID-Image: Image watermarking at internet scale" (Gowal et al., arXiv:2510.09263) states the verification service is restricted to "trusted testers" and does not release detector weights or a reproducible algorithm — so a local pixel detector is infeasible by design, not just unbuilt. https://arxiv.org/abs/2510.09263 We detect SynthID by its C2PA companion (`synthid_source` / `SYNTHID_C2PA_ISSUERS`), which is reliable while the manifest is intact but says nothing once C2PA is stripped. **Surface-dependent blind spot (verified 2026-05-24):** the same Google model emits different metadata per surface -- the Gemini *app* wraps outputs in Google C2PA, but the *API/playground* (AI Studio, Nano Banana / gemini-2.5-flash-image) emits the SynthID *pixel* watermark (confirmed via the Gemini-app oracle) + the visible sparkle but **no C2PA/IPTC at all**, so `synthid_source` returns None despite SynthID being present. Only the pixel oracle or the visible-sparkle detector catches those. (Meta AI is another surface mismatch: it writes the IPTC `digitalSourceType=trainedAlgorithmicMedia` marker, not C2PA and not SynthID.) Google→SynthID is long-standing; OpenAI→SynthID is confirmed by OpenAI's Help Center (ChatGPT/Codex/API "include both C2PA metadata and SynthID watermarks", updated 2026-05-21) but time-gated (pre-rollout OpenAI images carry C2PA without SynthID), so the OpenAI verdict is hedged "likely". Oracles: Gemini app "Verify with SynthID" (Google), openai.com/verify (OpenAI). The spectral phase-coherence approach from `github.com/aloshdenny/reverse-SynthID` was evaluated (May 2026) and **does not work for real-content detection**: on its own shipped codebook + validation set, watermarked and cleaned images were indistinguishable (conf within noise, cleaned often higher); it only fires on pure-black 1024x1024 reference images at exact resolution (the controlled case it was calibrated on). The README's "90% / conf=0.91" reproduces only in that lab condition. Do not build a production detector on it; if revisited, it is experimental/diagnostic only and needs a per-resolution, per-model reference corpus. A from-scratch gpt-image pilot (2026-05-24) confirmed this independently: 5 independent solid-black gpt-image outputs share a near-identical fixed signature (pairwise residual correlation **0.92**, avg-template retains 97% energy), so the watermark/carrier IS strongly present and consistent on flat content — but the carrier frequencies extracted from it do NOT discriminate real content (carrier-to-random ratio: cleaned 1.86 > watermarked 1.53; a non-gpt-image image scored highest at 3.67). The signature drowns in content texture. Net: a perfectly consistent solid-color signature still yields no real-content pixel detector with magnitude/carrier methods. A corpus discrimination test (2026-05-24, `scripts/synthid_pixel_probe.py`, raw zero-mean residual NCC) independently re-confirms this: at matched resolution, SynthID positives do NOT cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg >= pos-vs-pos). The only high correlations were near-duplicate *content* (5 ChatGPT renders of one prompt at ~0.92, while a distinct ChatGPT image scored ~0 against them) — content, not a carrier. The probe is solid-fills-only and EXPERIMENTAL/DIAGNOSTIC; do not use it on real content. **Correction (deeper re-examination 2026-05-25):** the carrier IS real on solid fills — the earlier "no carrier" was a *method* artifact of using spatial / FFT-magnitude NCC, which can't see it. The carrier is a fixed *phase* at specific low frequencies, so the right metric is **per-bin phase coherence**. On 8 white `gemini-2.5-flash-image` fills (generated via the reverse-SynthID trick: identity-edit prompt "Recreate this image exactly as it is" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers `(0,±7..±12,±20..±23)` = **0.86** vs **0.31** random; single-image leave-one-out phase-match **+0.83** vs real photos **-0.24**. (Black `2.5-flash` fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.) **But it does not generalize:** (a) carriers are model-version + resolution + color specific — the repo's v4 codebook (built for `gemini-3.1-flash-image-preview` + `nano-banana-pro-preview`) scores ~0.527 on my 2.5-flash white fills, indistinguishable from negatives (~0.50), i.e. carriers shift across model versions and need a per-model codebook; (b) on real content (30 `2.5-flash` images) the carrier collapses — set phase coherence at carriers 0.37 ≈ random 0.42, and the repo's v4 detector gives content 0.518 ≈ negatives 0.504 (no separation; a faint +0.24 single-image lean is likely a brightness confound). Net: the spectral/phase approach is a real *controlled-fill* characterizer, NOT an arbitrary-real-content detector, and is brittle to model version. Metadata proxy + visible sparkle + online oracles remain the ceiling for real content. - **External AI-vs-real classifier models are out of scope (decided 2026-05-24).** Generic HuggingFace detectors (`Organika/sdxl-detector` Swin Transformer, `umm-maybe/AI-image-detector`, and fine-tunes) exist and report ~0.98 on their *own* SDXL-vs-real validation sets, but they are per-generator and the model cards themselves note degraded accuracy off-distribution; they are untested on gpt-image / Gemini Nano Banana (the metadata-stripped surfaces we care about), and our own light SDXL pass would likely defeat them the same way it defeats SynthID. Detection here stays local + signal-based (metadata + visible sparkle); do not add a bundled classifier dependency. -- **SynthID v2 vs default pipeline:** the SDXL-based default profile (since May 2026) defeats SynthID v2. **Verified end-to-end (May 2026):** local SDXL run on a Gemini 3 Pro output, checked via the Gemini app's "Verify with SynthID" feature, returned "no SynthID watermark detected". Also confirmed against **OpenAI's** SynthID (2026-05-23): a fresh ChatGPT/gpt-image output read "SynthID detected" on openai.com/verify before the local SDXL run and "SynthID not detected" after (corpus regression chain: pos `4ef377bd` -> cleaned `47188e88`). The same configuration is used in raiw-app production (`fal-ai/fast-sdxl/image-to-image`, strength 0.05, steps 50, guidance 7.5, no pre-downscale). fal's own `llms.txt` for `fast-sdxl` names the base checkpoint as `stabilityai/stable-diffusion-xl-base-1.0` (verified 2026-05-25) -- the exact checkpoint the local CLI defaults to (`DEFAULT_MODEL_ID`). So the local `invisible` default is weight-for-weight identical to prod; "fast-sdxl" is fal's optimized serving, not different weights. After the native-resolution fix the local pipeline matches prod on weights + strength + steps + guidance + resolution. SD-1.5 dreamshaper at 768 px was previously the default and does NOT defeat v2 — verified empirically against the same feature (strength 0.04, 0.10, and elastic warp α∈{5,8} all flagged positive). That SD-1.5 path was removed; only `default` (SDXL) and `ctrlregen` profiles remain. **Scope of the claim: defeating the SynthID verifier is NOT the same as forensic invisibility.** "Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark Removal" (arXiv:2605.09203, 2026-05) shows that six removal attacks across four families (UnMarker, CtrlRegen+, WatermarkAttacker, etc.) all leave forensic traces: independent detectors flag *removal-processed* images vs genuinely-clean ones at **>98% TPR at 1% FPR**. So our SDXL pass makes the oracle read "SynthID not detected," but the output can still be classifiable as "an image that went through a removal pipeline." Do not over-claim "indistinguishable from a real photo." https://arxiv.org/abs/2605.09203 +- **SynthID v2 vs default pipeline:** **CORRECTION (2026-05-30): strength 0.05 does NOT remove the CURRENT Google SynthID (Nano Banana / Gemini 3).** Re-verified via the Gemini "Verify with SynthID" oracle on a real image: at 0.05 SynthID is still detected; at **0.10 it is removed** (OpenAI's SynthID was already cleared at 0.05). So the default strength was raised 0.05 -> **0.10** (`DEFAULT_STRENGTH` in `watermark_profiles.py`; CLI `--strength` defaults to 0.10), and that higher strength is exactly why text protection (`_run_region_hires`) runs by default (text deforms more at 0.10). Caveat: n=1 Google + n=1 OpenAI image so far -- broad oracle validation across the corpus is pending (different images may need a different strength). The original claim below (0.05 defeats SynthID v2) held for the specific May-2026 Gemini output tested then but is stale for current Google SynthID. **Verified end-to-end (May 2026):** local SDXL run on a Gemini 3 Pro output, checked via the Gemini app's "Verify with SynthID" feature, returned "no SynthID watermark detected". Also confirmed against **OpenAI's** SynthID (2026-05-23): a fresh ChatGPT/gpt-image output read "SynthID detected" on openai.com/verify before the local SDXL run and "SynthID not detected" after (corpus regression chain: pos `4ef377bd` -> cleaned `47188e88`). The same configuration is used in raiw-app production (`fal-ai/fast-sdxl/image-to-image`, strength 0.05, steps 50, guidance 7.5, no pre-downscale). fal's own `llms.txt` for `fast-sdxl` names the base checkpoint as `stabilityai/stable-diffusion-xl-base-1.0` (verified 2026-05-25) -- the exact checkpoint the local CLI defaults to (`DEFAULT_MODEL_ID`). So the local `invisible` default is weight-for-weight identical to prod; "fast-sdxl" is fal's optimized serving, not different weights. After the native-resolution fix the local pipeline matches prod on weights + strength + steps + guidance + resolution. SD-1.5 dreamshaper at 768 px was previously the default and does NOT defeat v2 — verified empirically against the same feature (strength 0.04, 0.10, and elastic warp α∈{5,8} all flagged positive). That SD-1.5 path was removed; only `default` (SDXL) and `ctrlregen` profiles remain. **Scope of the claim: defeating the SynthID verifier is NOT the same as forensic invisibility.** "Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark Removal" (arXiv:2605.09203, 2026-05) shows that six removal attacks across four families (UnMarker, CtrlRegen+, WatermarkAttacker, etc.) all leave forensic traces: independent detectors flag *removal-processed* images vs genuinely-clean ones at **>98% TPR at 1% FPR**. So our SDXL pass makes the oracle read "SynthID not detected," but the output can still be classifiable as "an image that went through a removal pipeline." Do not over-claim "indistinguishable from a real photo." https://arxiv.org/abs/2605.09203 diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 598db45..6b7d606 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -21,6 +21,7 @@ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeEl from rich.table import Table from remove_ai_watermarks import __version__, watermark_registry +from remove_ai_watermarks.noai.watermark_profiles import DEFAULT_STRENGTH if TYPE_CHECKING: from numpy.typing import NDArray @@ -362,7 +363,7 @@ def cmd_erase( @click.option( "-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: _clean.)." ) -@click.option("--strength", type=float, default=0.05, help="Denoising strength (0.0-1.0). Default: 0.05.") +@click.option("--strength", type=float, default=DEFAULT_STRENGTH, help="Denoising strength (0.0-1.0). Default: 0.10.") @click.option("--steps", type=int, default=50, help="Number of denoising steps. Default: 50.") @click.option( "--pipeline", @@ -593,7 +594,9 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo @click.option( "--inpaint-method", type=click.Choice(["ns", "telea", "gaussian"]), default="ns", help="Inpainting method." ) -@click.option("--strength", type=float, default=0.05, help="Invisible watermark denoising strength (0.0-1.0).") +@click.option( + "--strength", type=float, default=DEFAULT_STRENGTH, help="Invisible watermark denoising strength. Default: 0.10." +) @click.option("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.") @click.option( "--pipeline", diff --git a/src/remove_ai_watermarks/invisible_engine.py b/src/remove_ai_watermarks/invisible_engine.py index e9051d8..146feac 100644 --- a/src/remove_ai_watermarks/invisible_engine.py +++ b/src/remove_ai_watermarks/invisible_engine.py @@ -19,6 +19,8 @@ import warnings from pathlib import Path from typing import TYPE_CHECKING +from remove_ai_watermarks.noai.watermark_profiles import DEFAULT_STRENGTH + if TYPE_CHECKING: from collections.abc import Callable @@ -133,7 +135,7 @@ class InvisibleEngine: Args: image_path: Path to the watermarked image. output_path: Output path (None = overwrite source). - strength: Denoising strength (0.0-1.0). Default 0.04. + strength: Denoising strength (0.0-1.0). None -> DEFAULT_STRENGTH (0.10). steps: Number of denoising steps. guidance_scale: Classifier-free guidance scale. seed: Random seed for reproducibility. @@ -277,7 +279,7 @@ class InvisibleEngine: self, input_dir: Path, output_dir: Path, - strength: float = 0.04, + strength: float = DEFAULT_STRENGTH, steps: int = 50, ) -> list[Path]: """Remove invisible watermarks from all images in a directory.""" diff --git a/src/remove_ai_watermarks/noai/watermark_profiles.py b/src/remove_ai_watermarks/noai/watermark_profiles.py index 847c91b..19c3f14 100644 --- a/src/remove_ai_watermarks/noai/watermark_profiles.py +++ b/src/remove_ai_watermarks/noai/watermark_profiles.py @@ -1,4 +1,4 @@ -"""Watermark removal model profiles, strength presets, and profile detection. +"""Watermark removal model profiles, the default strength, and profile detection. Pure configuration and lookup functions with no ML dependencies. """ @@ -8,12 +8,18 @@ from __future__ import annotations DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0" CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen" -LOW_STRENGTH = 0.04 -MEDIUM_STRENGTH = 0.35 -HIGH_STRENGTH = 0.7 - -_HIGH_PERTURBATION = ("stegasamp", "stegastamp", "treering", "ringid") -_LOW_PERTURBATION = ("stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden") +# Single default denoising strength for the SDXL img2img scrub, overridable from +# the CLI (`--strength`). Raised from the old 0.04/0.05 because that no longer +# removes the CURRENT Google SynthID (Nano Banana / Gemini 3): verified 2026-05-30 +# via the Gemini "Verify with SynthID" oracle on a real image -- 0.05 still +# detected, 0.10 not detected (OpenAI's SynthID was already cleared at 0.05). 0.10 +# keeps the visible change modest while removing both. CAVEAT: confirmed on n=1 +# Google + n=1 OpenAI image; broad oracle validation across the corpus is pending +# (different images may need a different strength). At this higher strength small +# text deforms more -- which is exactly why text protection (`_run_region_hires`) +# runs by default. (Fixed LOW/MEDIUM/HIGH presets were removed -- unused; the one +# knob is this default plus the per-call `--strength` override.) +DEFAULT_STRENGTH = 0.10 def get_model_id_for_profile(profile: str) -> str: @@ -31,21 +37,3 @@ def detect_model_profile(model_id: str) -> str: if "ctrlregen" in model_id.lower(): return "ctrlregen" return "default" - - -def get_recommended_strength(watermark_type: str) -> float: - """Get recommended strength for different watermark types. - - Args: - watermark_type: Type of watermark. One of: 'low', 'medium', 'high', - or specific names like 'stegastamp', 'treering', etc. - - Returns: - Recommended strength value. - """ - wt = watermark_type.lower() - if any(name in wt for name in _HIGH_PERTURBATION): - return HIGH_STRENGTH - if any(name in wt for name in _LOW_PERTURBATION): - return LOW_STRENGTH - return MEDIUM_STRENGTH diff --git a/src/remove_ai_watermarks/noai/watermark_remover.py b/src/remove_ai_watermarks/noai/watermark_remover.py index 770ac44..6a41362 100644 --- a/src/remove_ai_watermarks/noai/watermark_remover.py +++ b/src/remove_ai_watermarks/noai/watermark_remover.py @@ -31,9 +31,7 @@ from PIL import Image from remove_ai_watermarks.noai.watermark_profiles import ( CTRLREGEN_MODEL_ID, DEFAULT_MODEL_ID, - HIGH_STRENGTH, - LOW_STRENGTH, - MEDIUM_STRENGTH, + DEFAULT_STRENGTH, detect_model_profile, ) @@ -277,9 +275,7 @@ class WatermarkRemover: DEFAULT_MODEL_ID = DEFAULT_MODEL_ID CTRLREGEN_MODEL_ID = CTRLREGEN_MODEL_ID - LOW_STRENGTH = LOW_STRENGTH - MEDIUM_STRENGTH = MEDIUM_STRENGTH - HIGH_STRENGTH = HIGH_STRENGTH + DEFAULT_STRENGTH = DEFAULT_STRENGTH def __init__( self, @@ -446,7 +442,7 @@ class WatermarkRemover: if output_path is None: output_path = image_path - strength = strength or self.LOW_STRENGTH + strength = strength or self.DEFAULT_STRENGTH if not 0.0 <= strength <= 1.0: raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}") @@ -853,7 +849,7 @@ class WatermarkRemover: def remove_watermark( image_path: Path, output_path: Path | None = None, - strength: float = 0.04, + strength: float = DEFAULT_STRENGTH, model_id: str | None = None, device: str | None = None, hf_token: str | None = None, diff --git a/tests/test_platform.py b/tests/test_platform.py index e37a5bd..5d36314 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -15,7 +15,6 @@ from remove_ai_watermarks.noai.utils import get_image_format, is_supported_forma from remove_ai_watermarks.noai.watermark_profiles import ( detect_model_profile, get_model_id_for_profile, - get_recommended_strength, ) from remove_ai_watermarks.noai.watermark_remover import get_device, is_watermark_removal_available @@ -121,33 +120,6 @@ class TestModelProfiles: def test_detect_ctrlregen(self): assert detect_model_profile("yepengliu/ctrlregen") == "ctrlregen" - def test_recommended_strength_high(self): - assert get_recommended_strength("treering") == 0.7 - - def test_recommended_strength_low(self): - assert get_recommended_strength("stablesignature") == 0.04 - - def test_recommended_strength_medium(self): - assert get_recommended_strength("unknown_type") == 0.35 - - @pytest.mark.parametrize("wm_type", ["stegastamp", "stegasamp", "treering", "ringid"]) - def test_high_perturbation_watermark_types(self, wm_type): - """Robust spatial watermarks need aggressive (0.7) regeneration.""" - assert get_recommended_strength(wm_type) == 0.7 - - @pytest.mark.parametrize("wm_type", ["stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden"]) - def test_low_perturbation_watermark_types(self, wm_type): - """Fragile frequency/latent watermarks break at low (0.04) strength.""" - assert get_recommended_strength(wm_type) == 0.04 - - def test_strength_match_is_case_insensitive(self): - assert get_recommended_strength("TreeRing") == 0.7 - assert get_recommended_strength("StableSignature") == 0.04 - - def test_strength_matches_substring_in_descriptive_name(self): - # e.g. a CLI passing "treering_v2" or "synthid-stegastamp" still maps. - assert get_recommended_strength("treering_v2") == 0.7 - # ── Format utilities ────────────────────────────────────────────────