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
|
|
@ -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,28 +472,31 @@ class FaceSelectionScreen(Screen):
|
|||
"Uncheck faces you want to KEEP visible.",
|
||||
id="selection-help",
|
||||
)
|
||||
for cluster in self.real_clusters:
|
||||
sample_info = ""
|
||||
if cluster.id in self.face_samples:
|
||||
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",
|
||||
)
|
||||
|
||||
with Vertical(id="faces-list"):
|
||||
for cluster in self.real_clusters:
|
||||
sample_info = ""
|
||||
if cluster.id in self.face_samples:
|
||||
yield Button(
|
||||
"View",
|
||||
id=f"view-{cluster.id}",
|
||||
variant="default",
|
||||
classes="view-btn",
|
||||
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:
|
||||
yield Button(
|
||||
"View",
|
||||
id=f"view-{cluster.id}",
|
||||
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,35 +92,129 @@ 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:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"nullsrc=s=64x64:d=0.1",
|
||||
"-c:v",
|
||||
encoder,
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
return encoder
|
||||
# 1. NVIDIA NVENC
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"nullsrc=s=64x64:d=0.1",
|
||||
"-c:v",
|
||||
"h264_nvenc",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]
|
||||
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(
|
||||
|
|
@ -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,37 +306,38 @@ def encode_video(
|
|||
height, width = first_frame.shape[:2]
|
||||
|
||||
# Build FFmpeg encode command
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"bgr24",
|
||||
"-s",
|
||||
f"{width}x{height}",
|
||||
"-r",
|
||||
str(fps),
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-i",
|
||||
str(input_path),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
]
|
||||
ffmpeg_cmd = ["ffmpeg", "-y"]
|
||||
ffmpeg_cmd.extend(enc_in_args)
|
||||
ffmpeg_cmd.extend(
|
||||
[
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"bgr24",
|
||||
"-s",
|
||||
f"{width}x{height}",
|
||||
"-r",
|
||||
str(fps),
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-i",
|
||||
str(input_path),
|
||||
"-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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue