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", "opencv-python",
"scikit-learn", "scikit-learn",
"textual>=2.0.0", "textual>=2.0.0",
"textual-autocomplete>=4.0.0",
] ]
[project.scripts] [project.scripts]

View file

@ -23,7 +23,48 @@ from textual.widgets import (
Select, Select,
Static, 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""" LOGO = r"""
@ -47,7 +88,6 @@ class WelcomeScreen(Screen):
#app-container { #app-container {
width: 60; width: 60;
height: auto; height: auto;
border: heavy $accent;
padding: 1 2; padding: 1 2;
} }
@ -108,12 +148,10 @@ class WelcomeScreen(Screen):
with Horizontal(classes="form-row"): with Horizontal(classes="form-row"):
yield Label("Video file:", classes="form-label") yield Label("Video file:", classes="form-label")
video_input = Input( yield PathInput(
placeholder="Enter path to video...", placeholder="Enter path to video...",
id="video-input", id="video-input",
) )
yield video_input
yield PathAutoComplete(target=video_input, path=".")
with Horizontal(classes="form-row"): with Horizontal(classes="form-row"):
yield Label("Frame interval:", classes="form-label") yield Label("Frame interval:", classes="form-label")
@ -172,7 +210,6 @@ class ProcessingScreen(Screen):
#processing-container { #processing-container {
width: 60; width: 60;
height: auto; height: auto;
border: heavy $accent;
padding: 1 2; padding: 1 2;
} }
@ -339,11 +376,18 @@ class FaceSelectionScreen(Screen):
#selection-container { #selection-container {
width: 65; width: 65;
height: auto; height: auto;
max-height: 30;
border: heavy $accent;
padding: 1 2; padding: 1 2;
} }
#faces-list {
height: auto;
max-height: 20;
overflow-y: auto;
margin-bottom: 1;
border: solid $accent-muted;
padding: 1;
}
#selection-title { #selection-title {
text-align: center; text-align: center;
text-style: bold; text-style: bold;
@ -428,28 +472,31 @@ class FaceSelectionScreen(Screen):
"Uncheck faces you want to KEEP visible.", "Uncheck faces you want to KEEP visible.",
id="selection-help", id="selection-help",
) )
for cluster in self.real_clusters:
sample_info = "" with Vertical(id="faces-list"):
if cluster.id in self.face_samples: for cluster in self.real_clusters:
sample_info = ( sample_info = ""
f" [dim]{self.face_samples[cluster.id]}[/dim]"
)
with Horizontal(classes="face-row"):
yield Checkbox(
f"Person {cluster.id + 1} "
f"({len(cluster.faces)} detections)"
f"{sample_info}",
value=True,
id=f"cluster-{cluster.id}",
classes="face-checkbox",
)
if cluster.id in self.face_samples: if cluster.id in self.face_samples:
yield Button( sample_info = (
"View", f" [dim]{self.face_samples[cluster.id]}[/dim]"
id=f"view-{cluster.id}",
variant="default",
classes="view-btn",
) )
with Horizontal(classes="face-row"):
yield Checkbox(
f"Person {cluster.id + 1} "
f"({len(cluster.faces)} detections)"
f"{sample_info}",
value=True,
id=f"cluster-{cluster.id}",
classes="face-checkbox",
)
if cluster.id in self.face_samples:
yield Button(
"View",
id=f"view-{cluster.id}",
variant="default",
classes="view-btn",
)
with Horizontal(id="blur-method-row"): with Horizontal(id="blur-method-row"):
yield Label("Blur method:", id="blur-method-label") yield Label("Blur method:", id="blur-method-label")
yield Select( yield Select(
@ -542,7 +589,6 @@ class EncodingScreen(Screen):
#encoding-container { #encoding-container {
width: 60; width: 60;
height: auto; height: auto;
border: heavy $accent;
padding: 1 2; padding: 1 2;
} }
@ -614,14 +660,18 @@ class EncodingScreen(Screen):
def _encode(self) -> None: def _encode(self) -> None:
"""Run encoding in background thread.""" """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: def on_progress(current: int, total: int) -> None:
pct = (current / total) * 100 if total > 0 else 0 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: 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, input_path=self.video_path,
output_path=self.output_path, output_path=self.output_path,
clusters=self.clusters, clusters=self.clusters,
@ -629,8 +679,9 @@ class EncodingScreen(Screen):
frame_interval=self.interval, frame_interval=self.interval,
blur_method=self.blur_method, blur_method=self.blur_method,
progress_callback=on_progress, 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) self.app.call_from_thread(self._show_done)
except Exception as e: except Exception as e:
self._update_ui(f"Error: {e}", 0) self._update_ui(f"Error: {e}", 0)
@ -657,6 +708,8 @@ class PyFaceBlurApp(App):
CSS = """ CSS = """
Screen { Screen {
background: $surface; background: $surface;
border: heavy $accent;
padding: 1 2;
} }
""" """
BINDINGS = [ BINDINGS = [

View file

@ -149,20 +149,10 @@ def get_bboxes_for_frame(
bbox = interpolate_bboxes(prev_by_cluster[cid], next_by_cluster[cid], t) bbox = interpolate_bboxes(prev_by_cluster[cid], next_by_cluster[cid], t)
result.append((cid, bbox)) result.append((cid, bbox))
elif cid in prev_by_cluster: elif cid in prev_by_cluster:
# Face leaving: shrink to center of previous bbox # Face leaving: hold previous bbox static to ensure privacy until next keyframe
x1, y1, x2, y2 = prev_by_cluster[cid] result.append((cid, 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))
else: else:
# Face entering: grow from center of next bbox # Face entering: hold next bbox static to ensure privacy from previous keyframe
x1, y1, x2, y2 = next_by_cluster[cid] result.append((cid, 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))
return result return result

View file

@ -92,35 +92,129 @@ def probe_video(video_path: Path) -> dict:
return info 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. """Find the best available H.264 encoder by testing each in priority order.
Returns: Returns:
Name of the best available encoder (encoder_name, input_args, output_args)
""" """
for encoder in ENCODER_PRIORITY: # 1. NVIDIA NVENC
cmd = [ cmd = [
"ffmpeg", "ffmpeg",
"-v", "-v",
"quiet", "quiet",
"-f", "-f",
"lavfi", "lavfi",
"-i", "-i",
"nullsrc=s=64x64:d=0.1", "nullsrc=s=64x64:d=0.1",
"-c:v", "-c:v",
encoder, "h264_nvenc",
"-f", "-f",
"null", "null",
"-", "-",
] ]
result = subprocess.run(cmd, capture_output=True, timeout=10) if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0:
if result.returncode == 0: return "h264_nvenc", [], []
return encoder
raise RuntimeError( # 2. Linux VA-API (AMD/Intel)
"No H.264 encoder found. Available encoders checked: " cmd = [
+ ", ".join(ENCODER_PRIORITY) "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( def build_keyframe_bboxes(
@ -162,7 +256,8 @@ def encode_video(
frame_interval: int, frame_interval: int,
blur_method: BlurMethod = "gaussian", blur_method: BlurMethod = "gaussian",
progress_callback: Optional[Callable[[int, int], None]] = None, 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. """Re-encode video with face blur applied to selected clusters.
Args: Args:
@ -173,9 +268,17 @@ def encode_video(
frame_interval: Frame interval used during detection frame_interval: Frame interval used during detection
blur_method: Blur method to use blur_method: Blur method to use
progress_callback: Called with (current_frame, total_frames) 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) 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( keyframe_bboxes, keyframe_indices = build_keyframe_bboxes(
clusters, clusters,
@ -203,37 +306,38 @@ def encode_video(
height, width = first_frame.shape[:2] height, width = first_frame.shape[:2]
# Build FFmpeg encode command # Build FFmpeg encode command
ffmpeg_cmd = [ ffmpeg_cmd = ["ffmpeg", "-y"]
"ffmpeg", ffmpeg_cmd.extend(enc_in_args)
"-y", ffmpeg_cmd.extend(
"-f", [
"rawvideo", "-f",
"-pix_fmt", "rawvideo",
"bgr24", "-pix_fmt",
"-s", "bgr24",
f"{width}x{height}", "-s",
"-r", f"{width}x{height}",
str(fps), "-r",
"-i", str(fps),
"pipe:0", "-i",
"-i", "pipe:0",
str(input_path), "-i",
"-map", str(input_path),
"0:v:0", "-map",
] "0:v:0",
]
)
# Map audio from original if present # Map audio from original if present
if video_info["audio_codec"]: if video_info["audio_codec"]:
ffmpeg_cmd.extend(["-map", "1:a:0", "-c:a", "copy"]) ffmpeg_cmd.extend(["-map", "1:a:0", "-c:a", "copy"])
ffmpeg_cmd.extend(enc_out_args)
ffmpeg_cmd.extend( ffmpeg_cmd.extend(
[ [
"-c:v", "-c:v",
encoder, encoder_name,
"-b:v", "-b:v",
str(bitrate), str(bitrate),
"-pix_fmt",
"yuv420p",
str(output_path), str(output_path),
] ]
) )
@ -283,3 +387,5 @@ def encode_video(
if proc.returncode != 0: if proc.returncode != 0:
stderr = proc.stderr.read().decode() if proc.stderr else "" stderr = proc.stderr.read().decode() if proc.stderr else ""
raise RuntimeError(f"FFmpeg encoding failed: {stderr}") 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 = "opencv-python" },
{ name = "scikit-learn" }, { name = "scikit-learn" },
{ name = "textual" }, { name = "textual" },
{ name = "textual-autocomplete" },
{ name = "uniface" }, { name = "uniface" },
] ]
@ -112,7 +111,6 @@ requires-dist = [
{ name = "opencv-python" }, { name = "opencv-python" },
{ name = "scikit-learn" }, { name = "scikit-learn" },
{ name = "textual", specifier = ">=2.0.0" }, { name = "textual", specifier = ">=2.0.0" },
{ name = "textual-autocomplete", specifier = ">=4.0.0" },
{ name = "uniface" }, { 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" }, { 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]] [[package]]
name = "threadpoolctl" name = "threadpoolctl"
version = "3.6.0" version = "3.6.0"