fix: smooth blur transitions, improve UI layout, report encoder
This commit is contained in:
parent
66e4472030
commit
09084b9b59
5 changed files with 243 additions and 110 deletions
|
|
@ -10,7 +10,6 @@ dependencies = [
|
|||
"opencv-python",
|
||||
"scikit-learn",
|
||||
"textual>=2.0.0",
|
||||
"textual-autocomplete>=4.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
15
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue