fix: smooth blur transitions, improve UI layout, report encoder

This commit is contained in:
fiatcode 2026-02-28 09:02:05 +07:00
parent 66e4472030
commit 09084b9b59
5 changed files with 243 additions and 110 deletions

View file

@ -10,7 +10,6 @@ dependencies = [
"opencv-python",
"scikit-learn",
"textual>=2.0.0",
"textual-autocomplete>=4.0.0",
]
[project.scripts]

View file

@ -23,7 +23,48 @@ from textual.widgets import (
Select,
Static,
)
from textual_autocomplete import PathAutoComplete
from textual.events import Key
import glob
class PathInput(Input):
"""Custom input for bash-like path tab completion."""
def on_key(self, event: Key) -> None:
if event.key == "tab":
current = self.value
if not current:
return
event.prevent_default()
event.stop()
path = os.path.expanduser(current)
matches = glob.glob(path + "*")
if os.path.isdir(path) and not current.endswith("/"):
matches = glob.glob(path + "/*")
if not matches:
self.value = current + "/"
self.cursor_position = len(self.value)
return
if not matches:
return
if len(matches) == 1:
match = matches[0]
if os.path.isdir(match):
match += "/"
else:
match = os.path.commonprefix(matches)
if current.startswith("~"):
match = match.replace(os.path.expanduser("~"), "~", 1)
if match != current:
self.value = match
self.cursor_position = len(self.value)
LOGO = r"""
@ -47,7 +88,6 @@ class WelcomeScreen(Screen):
#app-container {
width: 60;
height: auto;
border: heavy $accent;
padding: 1 2;
}
@ -108,12 +148,10 @@ class WelcomeScreen(Screen):
with Horizontal(classes="form-row"):
yield Label("Video file:", classes="form-label")
video_input = Input(
yield PathInput(
placeholder="Enter path to video...",
id="video-input",
)
yield video_input
yield PathAutoComplete(target=video_input, path=".")
with Horizontal(classes="form-row"):
yield Label("Frame interval:", classes="form-label")
@ -172,7 +210,6 @@ class ProcessingScreen(Screen):
#processing-container {
width: 60;
height: auto;
border: heavy $accent;
padding: 1 2;
}
@ -339,11 +376,18 @@ class FaceSelectionScreen(Screen):
#selection-container {
width: 65;
height: auto;
max-height: 30;
border: heavy $accent;
padding: 1 2;
}
#faces-list {
height: auto;
max-height: 20;
overflow-y: auto;
margin-bottom: 1;
border: solid $accent-muted;
padding: 1;
}
#selection-title {
text-align: center;
text-style: bold;
@ -428,6 +472,8 @@ class FaceSelectionScreen(Screen):
"Uncheck faces you want to KEEP visible.",
id="selection-help",
)
with Vertical(id="faces-list"):
for cluster in self.real_clusters:
sample_info = ""
if cluster.id in self.face_samples:
@ -450,6 +496,7 @@ class FaceSelectionScreen(Screen):
variant="default",
classes="view-btn",
)
with Horizontal(id="blur-method-row"):
yield Label("Blur method:", id="blur-method-label")
yield Select(
@ -542,7 +589,6 @@ class EncodingScreen(Screen):
#encoding-container {
width: 60;
height: auto;
border: heavy $accent;
padding: 1 2;
}
@ -614,14 +660,18 @@ class EncodingScreen(Screen):
def _encode(self) -> None:
"""Run encoding in background thread."""
from .encode import encode_video
from .encode import encode_video, find_best_encoder
def on_progress(current: int, total: int) -> None:
pct = (current / total) * 100 if total > 0 else 0
self._update_ui(f"Frame {current}/{total}", pct)
self._update_ui(f"Frame {current}/{total} (Encoder: {encoder_name})", pct)
try:
encode_video(
best_enc = find_best_encoder()
encoder_name = best_enc[0]
self._update_ui(f"Starting encoding with {encoder_name}...", 0)
encoder_used = encode_video(
input_path=self.video_path,
output_path=self.output_path,
clusters=self.clusters,
@ -629,8 +679,9 @@ class EncodingScreen(Screen):
frame_interval=self.interval,
blur_method=self.blur_method,
progress_callback=on_progress,
encoder_override=best_enc,
)
self._update_ui("Encoding complete!", 100)
self._update_ui(f"Encoding complete! (Used {encoder_used})", 100)
self.app.call_from_thread(self._show_done)
except Exception as e:
self._update_ui(f"Error: {e}", 0)
@ -657,6 +708,8 @@ class PyFaceBlurApp(App):
CSS = """
Screen {
background: $surface;
border: heavy $accent;
padding: 1 2;
}
"""
BINDINGS = [

View file

@ -149,20 +149,10 @@ def get_bboxes_for_frame(
bbox = interpolate_bboxes(prev_by_cluster[cid], next_by_cluster[cid], t)
result.append((cid, bbox))
elif cid in prev_by_cluster:
# Face leaving: shrink to center of previous bbox
x1, y1, x2, y2 = prev_by_cluster[cid]
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
center_bbox = (cx, cy, cx, cy)
# Interpolate from full bbox (t=0) to center point (t=1)
bbox = interpolate_bboxes(prev_by_cluster[cid], center_bbox, t)
result.append((cid, bbox))
# Face leaving: hold previous bbox static to ensure privacy until next keyframe
result.append((cid, prev_by_cluster[cid]))
else:
# Face entering: grow from center of next bbox
x1, y1, x2, y2 = next_by_cluster[cid]
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
center_bbox = (cx, cy, cx, cy)
# Interpolate from center point (t=0) to full bbox (t=1)
bbox = interpolate_bboxes(center_bbox, next_by_cluster[cid], t)
result.append((cid, bbox))
# Face entering: hold next bbox static to ensure privacy from previous keyframe
result.append((cid, next_by_cluster[cid]))
return result

View file

@ -92,13 +92,13 @@ def probe_video(video_path: Path) -> dict:
return info
def find_best_encoder() -> str:
def find_best_encoder() -> Tuple[str, List[str], List[str]]:
"""Find the best available H.264 encoder by testing each in priority order.
Returns:
Name of the best available encoder
(encoder_name, input_args, output_args)
"""
for encoder in ENCODER_PRIORITY:
# 1. NVIDIA NVENC
cmd = [
"ffmpeg",
"-v",
@ -108,20 +108,114 @@ def find_best_encoder() -> str:
"-i",
"nullsrc=s=64x64:d=0.1",
"-c:v",
encoder,
"h264_nvenc",
"-f",
"null",
"-",
]
result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode == 0:
return encoder
if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0:
return "h264_nvenc", [], []
raise RuntimeError(
"No H.264 encoder found. Available encoders checked: "
+ ", ".join(ENCODER_PRIORITY)
# 2. Linux VA-API (AMD/Intel)
cmd = [
"ffmpeg",
"-v",
"quiet",
"-vaapi_device",
"/dev/dri/renderD128",
"-f",
"lavfi",
"-i",
"nullsrc=s=64x64:d=0.1",
"-vf",
"format=nv12,hwupload",
"-c:v",
"h264_vaapi",
"-f",
"null",
"-",
]
if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0:
return (
"h264_vaapi",
["-vaapi_device", "/dev/dri/renderD128"],
["-vf", "format=nv12,hwupload"],
)
# 3. AMD AMF (Windows/Proprietary Linux)
cmd = [
"ffmpeg",
"-v",
"quiet",
"-f",
"lavfi",
"-i",
"nullsrc=s=64x64:d=0.1",
"-c:v",
"h264_amf",
"-f",
"null",
"-",
]
if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0:
return "h264_amf", [], []
# 4. Intel QSV
cmd = [
"ffmpeg",
"-v",
"quiet",
"-f",
"lavfi",
"-i",
"nullsrc=s=64x64:d=0.1",
"-c:v",
"h264_qsv",
"-f",
"null",
"-",
]
if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0:
return "h264_qsv", [], []
# 5. Software fallback (libx264 if installed)
cmd = [
"ffmpeg",
"-v",
"quiet",
"-f",
"lavfi",
"-i",
"nullsrc=s=64x64:d=0.1",
"-c:v",
"libx264",
"-f",
"null",
"-",
]
if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0:
return "libx264", [], []
# 6. Software fallback (libopenh264)
cmd = [
"ffmpeg",
"-v",
"quiet",
"-f",
"lavfi",
"-i",
"nullsrc=s=64x64:d=0.1",
"-c:v",
"libopenh264",
"-f",
"null",
"-",
]
if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0:
return "libopenh264", [], []
raise RuntimeError("No suitable H.264 encoder found on this system.")
def build_keyframe_bboxes(
clusters: List[Cluster],
@ -162,7 +256,8 @@ def encode_video(
frame_interval: int,
blur_method: BlurMethod = "gaussian",
progress_callback: Optional[Callable[[int, int], None]] = None,
) -> None:
encoder_override: Optional[Tuple[str, List[str], List[str]]] = None,
) -> str:
"""Re-encode video with face blur applied to selected clusters.
Args:
@ -173,9 +268,17 @@ def encode_video(
frame_interval: Frame interval used during detection
blur_method: Blur method to use
progress_callback: Called with (current_frame, total_frames)
encoder_override: Optional tuple of (encoder_name, enc_in_args, enc_out_args)
Returns:
String description of the encoder used (for UI reporting)
"""
video_info = probe_video(input_path)
encoder = find_best_encoder()
if encoder_override:
encoder_name, enc_in_args, enc_out_args = encoder_override
else:
encoder_name, enc_in_args, enc_out_args = find_best_encoder()
keyframe_bboxes, keyframe_indices = build_keyframe_bboxes(
clusters,
@ -203,9 +306,10 @@ def encode_video(
height, width = first_frame.shape[:2]
# Build FFmpeg encode command
ffmpeg_cmd = [
"ffmpeg",
"-y",
ffmpeg_cmd = ["ffmpeg", "-y"]
ffmpeg_cmd.extend(enc_in_args)
ffmpeg_cmd.extend(
[
"-f",
"rawvideo",
"-pix_fmt",
@ -221,19 +325,19 @@ def encode_video(
"-map",
"0:v:0",
]
)
# Map audio from original if present
if video_info["audio_codec"]:
ffmpeg_cmd.extend(["-map", "1:a:0", "-c:a", "copy"])
ffmpeg_cmd.extend(enc_out_args)
ffmpeg_cmd.extend(
[
"-c:v",
encoder,
encoder_name,
"-b:v",
str(bitrate),
"-pix_fmt",
"yuv420p",
str(output_path),
]
)
@ -283,3 +387,5 @@ def encode_video(
if proc.returncode != 0:
stderr = proc.stderr.read().decode() if proc.stderr else ""
raise RuntimeError(f"FFmpeg encoding failed: {stderr}")
return encoder_name

15
uv.lock generated
View file

@ -102,7 +102,6 @@ dependencies = [
{ name = "opencv-python" },
{ name = "scikit-learn" },
{ name = "textual" },
{ name = "textual-autocomplete" },
{ name = "uniface" },
]
@ -112,7 +111,6 @@ requires-dist = [
{ name = "opencv-python" },
{ name = "scikit-learn" },
{ name = "textual", specifier = ">=2.0.0" },
{ name = "textual-autocomplete", specifier = ">=4.0.0" },
{ name = "uniface" },
]
@ -743,19 +741,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/be/e191c2a15da20530fde03564564e3e4b4220eb9d687d4014957e5c6a5e85/textual-8.0.0-py3-none-any.whl", hash = "sha256:8908f4ebe93a6b4f77ca7262197784a52162bc88b05f4ecf50ac93a92d49bb8f", size = 718904, upload-time = "2026-02-16T17:12:11.962Z" },
]
[[package]]
name = "textual-autocomplete"
version = "4.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "textual" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/3a/80411bc7b94969eb116ad1b18db90f8dce8a1de441278c4a81fee55a27ca/textual_autocomplete-4.0.6.tar.gz", hash = "sha256:2ba2f0d767be4480ecacb3e4b130cf07340e033c3500fc424fed9125d27a4586", size = 97967, upload-time = "2025-09-24T21:19:20.213Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/66/ebe744d79c87f25a42d2654dddbd09462edd595f2ded715245a51a546461/textual_autocomplete-4.0.6-py3-none-any.whl", hash = "sha256:bff69c19386e2cbb4a007503b058dc37671d480a4fa2ddb3959c15ceb4aff9b5", size = 16499, upload-time = "2025-09-24T21:19:18.489Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"