diff --git a/detailing.py b/detailing.py index c175ac99..4d24ff4a 100644 --- a/detailing.py +++ b/detailing.py @@ -34,9 +34,10 @@ STAGE2_SIGMAS = (0.85, 0.725, 0.421875, 0.0) # CLIPSeg heat above this (post-sigmoid) counts as "the named thing is here". DEFAULT_THRESHOLD = 0.35 -# Tubes larger than this fraction of the frame are refused: at that size the pass -# stops being region detailing and becomes a second full render (cost ~= 4x area -# fraction x tail steps), which belongs to a different feature. +# Default ceiling on tube area, as a fraction of the frame - purely a COST guardrail +# (cost ~= 4x area fraction x tail steps, so a big tube starts to rival a second full +# render) exposed as detail_max_area so the user decides the cost/quality trade-off, +# not this constant. Never silently override what the user asked to detail. MAX_TUBE_AREA = 0.35 # Latent cells of context padding around the thresholded region, and the minimum @@ -357,7 +358,8 @@ def _downscale_to(video, h, w): def detail_refine_scene(chain, model, vae, sampler, positive, negative, latent, detail_targets, upsampler, seed, cfg, - threshold=DEFAULT_THRESHOLD, strength=1.0, debug=False): + threshold=DEFAULT_THRESHOLD, strength=1.0, area_cap=MAX_TUBE_AREA, + debug=False): """Run the segmented-detail pass on one finished scene latent. Returns (latent, note): the refined latent dict plus a run_mechanisms note. @@ -368,6 +370,10 @@ def detail_refine_scene(chain, model, vae, sampler, positive, negative, latent, nothing - always reported, with the max CLIPSeg score seen, because a miss is otherwise indistinguishable from "nothing to detail here" when it's actually just the threshold being wrong for this content. + `area_cap` is a COST guardrail, not a content judgment: the user named the + target, so a region crossing it is reported as a skip with the actual size + (never silently reinterpreted as "not found") and area_cap itself is a + user-facing knob (detail_max_area) - raise it, don't route around it here. `chain` is the FunPackLTXAVSceneChainSampler instance (reuses _latent_tensors and _sample_chunk so the refine denoise goes through the exact same path as the scene it is polishing). @@ -390,12 +396,13 @@ def detail_refine_scene(chain, model, vae, sampler, positive, negative, latent, if heat is None: return latent, f"segmented_detail(no match: '{target_str}' - no keyframe decoded)" diag = {} - tube = find_tube(heat, lat_h, lat_w, threshold=threshold, debug=debug, diag=diag) + tube = find_tube(heat, lat_h, lat_w, threshold=threshold, area_cap=area_cap, + debug=debug, diag=diag) if tube is None: if diag.get("reason") == "area_cap": note = (f"segmented_detail(no match: '{target_str}' region covers " - f"{diag['area']:.0%} of frame > {MAX_TUBE_AREA:.0%} cap - refused, " - "not a detail pass at that size)") + f"{diag['area']:.0%} of frame > {area_cap:.0%} cap - raise " + "detail_max_area if you want it detailed at this size anyway)") else: note = (f"segmented_detail(no match: '{target_str}', max CLIPSeg score " f"{diag.get('max_heat', 0.0):.2f} < threshold {threshold:.2f} - " diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index 286db88a..701a071f 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -204,6 +204,8 @@ hint: "Blend of the refined region at paste-back. 1.0 = full replacement inside the silhouette; 0 disables the pass." }, { name: "detail_threshold", label: "Detail match threshold", kind: "float", default: 0.35, min: 0.05, max: 0.9, step: 0.05, dependsOn: "segmented_detailing", hint: "CLIPSeg match confidence required before a region counts as found. Its raw score for a real region is often well under 0.5 — if the scene report shows 'no match: max CLIPSeg score X < threshold', lower this toward X rather than assuming nothing is there." }, + { name: "detail_max_area", label: "Detail max area", kind: "float", default: 0.35, min: 0.05, max: 1.0, step: 0.05, dependsOn: "segmented_detailing", + hint: "Ceiling on how much of the frame the region may cover before it's refused, as a fraction of the frame. Cost-only guardrail (a bigger region costs more, roughly 4× its area × 3 steps) — never a judgment call about whether it's worth detailing. If the scene report shows a region refused at some %, raise this above that % to detail it anyway. 1.0 = no cap." }, ]; const SAMPLER_KNOB_MAP = Object.fromEntries(SAMPLER_KNOBS.map((k) => [k.name, k])); @@ -316,7 +318,7 @@ chain_timing: ["frame_overlap", "transition_duration", "use_same_seed"], chain_guidance: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength", "score_slider", "score_slider_strength", "taste_nearest_prompt", "output_guidance", "output_guidance_strength", "dynashift", "dynashift_strength", "dynashift_threshold"], chain_decode: ["decode_noise_scale", "decode_timestep", "decode_tile_size"], - chain_experimental: ["plateau_cache", "plateau_cache_threshold", "segmented_detailing", "detail_targets", "detail_strength", "detail_threshold", "mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"], + chain_experimental: ["plateau_cache", "plateau_cache_threshold", "segmented_detailing", "detail_targets", "detail_strength", "detail_threshold", "detail_max_area", "mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"], }; function countChainView(p, id) { diff --git a/samplers.py b/samplers.py index 4b88987f..4bbf4e5f 100644 --- a/samplers.py +++ b/samplers.py @@ -2470,6 +2470,10 @@ class FunPackLTXAVSceneChainSampler: "default": 0.35, "min": 0.05, "max": 0.9, "step": 0.05, "tooltip": "CLIPSeg match confidence (post-sigmoid) required before a region counts as found. CLIPSeg's raw score for a real, correctly-named region is often well under 0.5 — if the scene report shows 'no match: max CLIPSeg score X < threshold', lower this toward X (or just below it) rather than assuming nothing is there. Lower = more permissive (more false positives on unrelated regions); higher = stricter.", }), + "detail_max_area": ("FLOAT", { + "default": 0.35, "min": 0.05, "max": 1.0, "step": 0.05, + "tooltip": "Ceiling on how much of the frame the detected region may cover before the pass refuses it, as a fraction of frame area. This is a COST guardrail only (cost ~= 4x area x 3 steps, so a large region starts to rival a second full render), never a judgment about whether the region is worth detailing — if the scene report shows a region refused at some %, raise this above that % to detail it anyway (up to 1.0 = no cap, full-frame allowed).", + }), }, "hidden": { "unique_id": "UNIQUE_ID", @@ -4670,6 +4674,7 @@ class FunPackLTXAVSceneChainSampler: taste_nearest_prompt=False, segmented_detailing=False, detail_targets="hands", detail_upsampler="None", detail_strength=1.0, detail_threshold=0.35, + detail_max_area=0.35, unique_id=None, prompt=None): if not isinstance(positive, list) or not positive: raise ValueError("positive conditioning must contain at least one scene entry.") @@ -5137,7 +5142,8 @@ class FunPackLTXAVSceneChainSampler: sampled, _detail_note = _detailing.detail_refine_scene( self, model, vae, sampler, scene_positive, scene_negative, sampled, detail_targets, _detail_upsampler_model, scene_seed, cfg, - threshold=detail_threshold, strength=detail_strength, debug=debug_log) + threshold=detail_threshold, strength=detail_strength, + area_cap=detail_max_area, debug=debug_log) # detail_refine_scene always returns a diagnostic note when it actually # ran detection (miss or hit) — None only means it never attempted # detection at all (which can't happen on this branch). diff --git a/tests/test_detailing.py b/tests/test_detailing.py index b541902a..5fdcc83c 100644 --- a/tests/test_detailing.py +++ b/tests/test_detailing.py @@ -299,6 +299,28 @@ def test_refine_reports_area_cap_reason(monkeypatch): assert "cap" in note.lower() assert chain.chunk_calls == [] +def test_refine_area_cap_is_a_cost_knob_not_a_judgment(monkeypatch): + """The cap must never override what the user explicitly asked to detail — it's a + cost guardrail the user can raise, not FunPack deciding a big region 'isn't worth + detailing'. Same whole-frame region: refused at the default cap, allowed once the + user raises detail_max_area (mirrors area_cap, threaded from the sampler node).""" + chain = _FakeChain() + latent, video = _scene_latent() + monkeypatch.setattr(detailing, "_decode_detection_frames", + lambda *a, **k: [(0, torch.zeros(160, 240, 3))]) + monkeypatch.setattr(detailing, "_clipseg_heat", lambda *a, **k: torch.full((160, 240), 0.9)) + monkeypatch.setattr( + detailing, "_run_upsampler", + lambda upsampler, crop, vae, debug=False: torch.nn.functional.interpolate( + crop, scale_factor=(1, 2, 2), mode="nearest")) + out_default, note_default = detailing.detail_refine_scene( + chain, "m", "v", "s", [], [], latent, "hands", object(), 0, 1.0) + assert out_default is latent and "cap" in note_default.lower() + out_raised, note_raised = detailing.detail_refine_scene( + chain, "m", "v", "s", [], [], latent, "hands", object(), 0, 1.0, area_cap=1.0) + assert note_raised is not None and "segmented_detail(" in note_raised and "cap" not in note_raised.lower() + assert out_raised is not latent + def _patch_detection_and_upsampler(monkeypatch): """Hot square in pixel space; upsampler = exact 2x nearest (model-free).""" heat = torch.zeros(160, 240)