From 89b0f708d742c20233048cd37073b221a845aaf4 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 18 Oct 2026 11:04:53 -0400 Subject: [PATCH] Add geometric upper-bound gates: max face-area fraction, aspect window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tested against real failure samples from the v2 run: full-frame feather storm (area .64) and wide effect blob (area .70) now rejected; normal and close-up faces pass. Note: stylized OP/ED emblems are geometrically face-shaped and NOT catchable this way — documented, CCIP gate is the answer for those. --- README.md | 2 ++ anime_facesaver.py | 41 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bea1fcc..37e0cfc 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ context leaks onto the wrong card: | `--conf` | 0.3 | detector confidence (model's own recommendation; it is high-precision) | | `--min-width` / `--min-height` | 100 | minimum face box size in px | | `--min-face-fraction` | 0.005 | face box area as fraction of frame — kills distant background extras; `0` disables | +| `--max-face-fraction` | 0.30 | face box area upper cap — kills detector meltdowns on full-frame ED/OP effects (feather storms etc.); `0` disables | +| `--min-aspect` / `--max-aspect` | 0.45 / 1.6 | face box W/H ratio window — effect blobs rarely keep face proportions | | `--edge-margin` | 0 | require faces this far inside frame edges; `0` accepts edge-touching faces (original hard-rejected them) | | `--scene-threshold` | 30.0 | PySceneDetect ContentDetector threshold (same as original) | | `--imgsz` | 1280 | inference size; model was trained at 1280, `640` is faster at some recall cost | diff --git a/anime_facesaver.py b/anime_facesaver.py index 1bfb2e1..9d483e5 100644 --- a/anime_facesaver.py +++ b/anime_facesaver.py @@ -77,6 +77,27 @@ def parse_arguments(): help="Minimum face box area as a fraction of frame area (kills " "background-extra faces in wide shots; 0 disables).", ) + parser.add_argument( + "--max-face-fraction", + type=float, + default=0.30, + help="Maximum face box area as a fraction of frame area (kills " + "detector meltdowns on full-frame effects like ED feather " + "storms; 0 disables).", + ) + parser.add_argument( + "--min-aspect", + type=float, + default=0.45, + help="Minimum face box width/height ratio.", + ) + parser.add_argument( + "--max-aspect", + type=float, + default=1.6, + help="Maximum face box width/height ratio (a face wider than 1.6x " + "itself is almost always an effect blob).", + ) parser.add_argument( "--conf", type=float, @@ -168,10 +189,13 @@ def download_model(path): def face_qualifies(box, frame_shape, min_width, min_height, - min_face_fraction, edge_margin): + min_face_fraction, max_face_fraction, + min_aspect, max_aspect, edge_margin): """Original is_full_face(), corrected: the box must actually be a face (enforced by the detector class), size gates stay, hard - 'must-not-touch-edge' rule becomes an optional margin.""" + 'must-not-touch-edge' rule becomes an optional margin. + Upper-bound gates (max area, aspect) kill detector meltdowns on + full-frame stylized effects (ED feather storms etc.).""" x1, y1, x2, y2 = box frame_h, frame_w = frame_shape[:2] @@ -179,8 +203,15 @@ def face_qualifies(box, frame_shape, min_width, min_height, if w < min_width or h < min_height: return False - if min_face_fraction > 0: - if (w * h) < (frame_w * frame_h * min_face_fraction): + area_frac = (w * h) / float(frame_w * frame_h) + if min_face_fraction > 0 and area_frac < min_face_fraction: + return False + if max_face_fraction > 0 and area_frac > max_face_fraction: + return False + + if h > 0: + aspect = w / float(h) + if aspect < min_aspect or aspect > max_aspect: return False if edge_margin > 0: @@ -255,6 +286,8 @@ def process_video(video_path, output_dir, args, model, device, draw, if face_qualifies(box, frame.shape, args.min_width, args.min_height, args.min_face_fraction, + args.max_face_fraction, + args.min_aspect, args.max_aspect, args.edge_margin): out = os.path.join( output_dir,