DRIPPY4 / app /region_selector.py
hoangtaiii's picture
Upload 67 files
21aadae verified
Raw History Blame Contribute Delete
17.5 kB
"""
RegionSelectorDialog β€” Dialog chọn vΓΉng che sub vα»›i scrubber toΓ n video.
NΓ’ng cαΊ₯p so vα»›i bαΊ£n cΕ©:
- Thanh scrubber QSlider trượt toΓ n bα»™ video để kiểm tra vΓΉng che ở mọi frame
- NΓΊt Play/Pause để xem tα»± Δ‘α»™ng (25fps qua QTimer + seek, khΓ΄ng decode realtime)
- PhΓ­m tαΊ―t: Space=Play/Dα»«ng, ←=LΓΉi frame, β†’=TiαΊΏn frame, Enter=LΖ°u
- Overlay: vΓΉng Δ‘Γ£ lΖ°u fill đỏ mờ + label "βœ“ VΓΉng che"; Δ‘ang vαΊ½ fill vΓ ng nΓ©t Δ‘α»©t
- Giα»― VideoCapture mở xuyΓͺn suα»‘t dialog β†’ seek nhanh; release khi Δ‘Γ³ng
Backward compat: API giα»‘ng bαΊ£n cΕ© (constructor, self.region, accept/reject).
"""
import cv2
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QMessageBox
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QImage, QPixmap, QPainter, QPen, QColor
class RegionSelectorDialog(QDialog):
"""
Dialog chọn vùng che sub.
KαΊΏt quαΊ£:
self.region = (x, y, w, h, orig_video_w, orig_video_h) β€” pixel gα»‘c video
self.region = None nếu chưa vẽ hoặc bỏ qua
"""
def __init__(
self,
video_path,
parent=None,
is_batch: bool = False,
current_idx: int = 1,
total_videos: int = 1,
):
super().__init__(parent)
if is_batch:
self.setWindowTitle(
f"[{current_idx}/{total_videos}] Kéo thả chọn vùng che Sub "
"β€” Trượt / PhΓ‘t để kiểm tra toΓ n video"
)
else:
self.setWindowTitle(
"KΓ©o thαΊ£ chuα»™t để chọn vΓΉng che Sub "
"β€” Trượt / PhΓ‘t để kiểm tra toΓ n video (X để lΖ°u)"
)
self.setModal(True)
self.video_path = str(video_path)
self.region = None
# ── Mở VideoCapture (giα»― mở suα»‘t dialog) ────────────────────────
self._cap = cv2.VideoCapture(self.video_path)
if not self._cap.isOpened():
QMessageBox.critical(self, "Lα»—i", f"KhΓ΄ng mở được video:\n{self.video_path}")
super().reject()
return
self._total_frames = max(1, int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT)))
self._fps = self._cap.get(cv2.CAP_PROP_FPS) or 25.0
self.orig_w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
self.orig_h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# Load frame giα»―a video
mid = self._total_frames // 2
frame = self._read_frame(mid)
if frame is None:
QMessageBox.critical(self, "Lα»—i", "KhΓ΄ng thể đọc khung hΓ¬nh video!")
self._cap.release()
super().reject()
return
# ── Display state ────────────────────────────────────────────────
self.MAX_W = 1280
self.MAX_H = 720
self._current_frame_idx = mid
self.disp_w = 1
self.disp_h = 1
self.scale_x = 1.0
self.scale_y = 1.0
# ── Drawing state ────────────────────────────────────────────────
self.start_point = None
self.end_point = None
self.drawing = False
# ── Widgets ──────────────────────────────────────────────────────
self.label = QLabel()
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label.setCursor(Qt.CursorShape.CrossCursor)
self.label.mousePressEvent = self.mouse_press
self.label.mouseMoveEvent = self.mouse_move
self.label.mouseReleaseEvent = self.mouse_release
self.label.paintEvent = self.paint_label
hint = QLabel(
"πŸ“’ KΓ©o chuα»™t vαΊ½ vΓΉng che (hΓ¬nh chα»― nhαΊ­t đỏ). "
"β–Έ Trượt thanh để kiểm tra mọi frame. "
"β–Ά NΓΊt PhΓ‘t xem tα»± Δ‘α»™ng. "
"[Space]=Play/Dα»«ng [←→]=LΓΉi/TiαΊΏn frame [Enter]=LΖ°u"
)
hint.setWordWrap(True)
hint.setStyleSheet("font-size:12px; padding:4px; color:#ccc; background:#1a1a1e;")
# ── Scrubber row ─────────────────────────────────────────────────
self._playing = False
self.btn_play = QPushButton("β–Ά PhΓ‘t")
self.btn_play.setFixedWidth(100)
self._style_play_btn(playing=False)
self.btn_play.clicked.connect(self._toggle_play)
self.lbl_time = QLabel("00:00:00")
self.lbl_time.setFixedWidth(72)
self.lbl_time.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_time.setStyleSheet("font-family:monospace; font-size:12px; color:#aaa;")
self.slider = QSlider(Qt.Orientation.Horizontal)
self.slider.setMinimum(0)
self.slider.setMaximum(self._total_frames - 1)
self.slider.setValue(mid)
self.slider.setStyleSheet("""
QSlider::groove:horizontal {
height: 6px; background: #3a3a3a; border-radius: 3px;
}
QSlider::handle:horizontal {
background: #e74c3c; border: none;
width: 14px; height: 14px; margin: -4px 0; border-radius: 7px;
}
QSlider::sub-page:horizontal {
background: #c0392b; border-radius: 3px;
}
""")
self.slider.valueChanged.connect(self._on_slider_changed)
self.slider.sliderPressed.connect(self._pause)
total_sec = self._total_frames / self._fps
mm, ss = divmod(int(total_sec), 60)
hh, mm = divmod(mm, 60)
lbl_total = QLabel(f"{hh:02d}:{mm:02d}:{ss:02d}")
lbl_total.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl_total.setStyleSheet("font-family:monospace; font-size:12px; color:#555;")
lbl_total.setFixedWidth(72)
scrub_row = QHBoxLayout()
scrub_row.addWidget(self.btn_play)
scrub_row.addWidget(self.lbl_time)
scrub_row.addWidget(self.slider, stretch=1)
scrub_row.addWidget(lbl_total)
# ── Play timer ────────────────────────────────────────────────────
self._timer = QTimer(self)
self._timer.setInterval(int(1000 / min(self._fps, 25)))
self._timer.timeout.connect(self._advance_frame)
# ── Main layout ───────────────────────────────────────────────────
layout = QVBoxLayout(self)
layout.setSpacing(6)
layout.addWidget(hint)
layout.addWidget(self.label)
layout.addLayout(scrub_row)
# ── Batch mode buttons ────────────────────────────────────────────
self.is_batch = is_batch
if is_batch:
btn_layout = QHBoxLayout()
self.btn_skip = QPushButton("⏭️ Bỏ qua (Không che)")
self.btn_skip.setStyleSheet(
"background-color:#7F8C8D; color:white; padding:10px; "
"font-weight:bold; font-size:14px;"
)
self.btn_skip.clicked.connect(self._reject_and_stop)
label_next = "βœ… OK, Video cuα»‘i!" if current_idx == total_videos else "βœ… OK, Δ‘Γ£ che xong β†’ Next"
self.btn_next = QPushButton(label_next)
self.btn_next.setStyleSheet(
"background-color:#27AE60; color:white; padding:10px; "
"font-weight:bold; font-size:14px;"
)
self.btn_next.clicked.connect(self._accept_and_stop)
btn_layout.addWidget(self.btn_skip)
btn_layout.addWidget(self.btn_next)
layout.addLayout(btn_layout)
# Load frame Δ‘αΊ§u tiΓͺn
self._set_frame(mid)
# ── Video helpers ─────────────────────────────────────────────────────
def _read_frame(self, frame_idx: int):
"""Seek vΓ  đọc frame, trαΊ£ về numpy RGB hoαΊ·c None."""
self._cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, frame = self._cap.read()
if not ret or frame is None:
return None
return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
def _set_frame(self, frame_idx: int):
"""Seek Δ‘αΊΏn frame_idx, cαΊ­p nhαΊ­t label + slider + timestamp."""
frame_idx = max(0, min(frame_idx, self._total_frames - 1))
self._current_frame_idx = frame_idx
frame = self._read_frame(frame_idx)
if frame is None:
return
h, w, ch = frame.shape
scaled = QPixmap.fromImage(
QImage(frame.data, w, h, ch * w, QImage.Format.Format_RGB888)
).scaled(
self.MAX_W, self.MAX_H,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self.disp_w = scaled.width()
self.disp_h = scaled.height()
self.scale_x = self.orig_w / self.disp_w
self.scale_y = self.orig_h / self.disp_h
self.label.setPixmap(scaled)
self.label.setFixedSize(self.disp_w, self.disp_h)
self.label.update()
self.slider.blockSignals(True)
self.slider.setValue(frame_idx)
self.slider.blockSignals(False)
sec = frame_idx / self._fps
mm, ss = divmod(int(sec), 60)
hh, mm = divmod(mm, 60)
self.lbl_time.setText(f"{hh:02d}:{mm:02d}:{ss:02d}")
# ── Playback ──────────────────────────────────────────────────────────
def _style_play_btn(self, playing: bool):
if playing:
self.btn_play.setText("⏸ Tẑm dừng")
self.btn_play.setStyleSheet(
"background:#e67e22; color:#000; font-weight:bold; border-radius:4px; padding:4px;"
)
else:
self.btn_play.setText("β–Ά PhΓ‘t")
self.btn_play.setStyleSheet(
"background:#2ecc71; color:#000; font-weight:bold; border-radius:4px; padding:4px;"
)
def _on_slider_changed(self, value: int):
self._set_frame(value)
def _toggle_play(self):
if self._playing:
self._pause()
else:
self._play()
def _play(self):
self._playing = True
self._style_play_btn(playing=True)
self._timer.start()
def _pause(self):
self._playing = False
self._timer.stop()
self._style_play_btn(playing=False)
def _advance_frame(self):
"""Timer tick: tiαΊΏn 1 frame, loop khi hαΊΏt."""
nxt = self._current_frame_idx + 1
if nxt >= self._total_frames:
nxt = 0
self._set_frame(nxt)
# ── Coordinate normalization ──────────────────────────────────────────
def _display_geometry(self):
"""Return (off_x, off_y, disp_w, disp_h) β€” where the video pixmap actually
sits inside the QLabel, accounting for any centered letterbox/pillarbox
margin so mouse coordinates are never measured against the black bars."""
label_w = self.label.width()
label_h = self.label.height()
off_x = max(0, (label_w - self.disp_w) // 2)
off_y = max(0, (label_h - self.disp_h) // 2)
return off_x, off_y, self.disp_w, self.disp_h
def _ui_to_native(self, ux, uy):
"""Convert UI (label-local) coords -> normalized (0.0..1.0) coords ->
native video frame coords. Logs the full chain for debugging:
[UI Cords] -> [Normalized Cords] -> [Native Frame Cords]."""
off_x, off_y, dw, dh = self._display_geometry()
px = max(0.0, min(float(ux - off_x), float(dw)))
py = max(0.0, min(float(uy - off_y), float(dh)))
norm_x = px / dw if dw > 0 else 0.0
norm_y = py / dh if dh > 0 else 0.0
real_x = int(round(norm_x * self.orig_w))
real_y = int(round(norm_y * self.orig_h))
print(
f"[UI Cords] ({ux:.1f}, {uy:.1f}) -> "
f"[Normalized Cords] ({norm_x:.4f}, {norm_y:.4f}) -> "
f"[Native Frame Cords] ({real_x}, {real_y})"
)
return real_x, real_y
def _native_to_display(self, nx, ny):
"""Convert native frame coords back to label-local display coords."""
off_x, off_y, dw, dh = self._display_geometry()
dx = off_x + int(round(nx / self.orig_w * dw)) if self.orig_w else off_x
dy = off_y + int(round(ny / self.orig_h * dh)) if self.orig_h else off_y
return dx, dy
# ── Drawing ───────────────────────────────────────────────────────────
def mouse_press(self, event):
self._pause()
self.start_point = event.position().toPoint()
self.end_point = self.start_point
self.drawing = True
self.label.update()
def mouse_move(self, event):
if self.drawing:
self.end_point = event.position().toPoint()
self.label.update()
def mouse_release(self, event):
self.end_point = event.position().toPoint()
self.drawing = False
self.label.update()
off_x, off_y, dw, dh = self._display_geometry()
x1 = max(off_x, min(min(self.start_point.x(), self.end_point.x()), off_x + dw))
y1 = max(off_y, min(min(self.start_point.y(), self.end_point.y()), off_y + dh))
x2 = max(off_x, min(max(self.start_point.x(), self.end_point.x()), off_x + dw))
y2 = max(off_y, min(max(self.start_point.y(), self.end_point.y()), off_y + dh))
orig_x, orig_y = self._ui_to_native(x1, y1)
rx2, ry2 = self._ui_to_native(x2, y2)
orig_rw = max(1, rx2 - orig_x)
orig_rh = max(1, ry2 - orig_y)
if orig_rw > 0 and orig_rh > 0:
self.region = (orig_x, orig_y, orig_rw, orig_rh, self.orig_w, self.orig_h)
print(
f"[REGION] stored native region: x={orig_x}, y={orig_y}, "
f"w={orig_rw}, h={orig_rh} (frame {self.orig_w}x{self.orig_h})"
)
def keyPressEvent(self, event):
k = event.key()
if k in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self._accept_and_stop()
elif k == Qt.Key.Key_Space:
self._toggle_play()
elif k == Qt.Key.Key_Left:
self._pause()
self._set_frame(self._current_frame_idx - 1)
elif k == Qt.Key.Key_Right:
self._pause()
self._set_frame(self._current_frame_idx + 1)
else:
super().keyPressEvent(event)
def paint_label(self, event):
"""Overlay: vΓΉng Δ‘Γ£ lΖ°u (đỏ mờ) + Δ‘ang vαΊ½ (vΓ ng nΓ©t Δ‘α»©t)."""
QLabel.paintEvent(self.label, event)
painter = QPainter(self.label)
_off_x, _off_y, _dw, _dh = self._display_geometry()
# VΓΉng Δ‘Γ£ lΖ°u
if self.region:
rx, ry, rw, rh, _vw, _vh = self.region
dx, dy = self._native_to_display(rx, ry)
dx2, dy2 = self._native_to_display(rx + rw, ry + rh)
dw = dx2 - dx
dh = dy2 - dy
painter.fillRect(dx, dy, dw, dh, QColor(220, 0, 0, 70))
painter.setPen(QPen(QColor(255, 60, 60), 2, Qt.PenStyle.SolidLine))
painter.drawRect(dx, dy, dw, dh)
painter.setPen(QColor(255, 220, 0))
painter.drawText(dx + 4, dy + 15, "βœ“ VΓΉng che")
# Đang vẽ
if self.start_point and self.end_point and self.drawing:
x1 = min(self.start_point.x(), self.end_point.x())
y1 = min(self.start_point.y(), self.end_point.y())
w = abs(self.start_point.x() - self.end_point.x())
h = abs(self.start_point.y() - self.end_point.y())
painter.fillRect(x1, y1, w, h, QColor(255, 255, 0, 45))
painter.setPen(QPen(QColor(255, 230, 0), 1, Qt.PenStyle.DashLine))
painter.drawRect(x1, y1, w, h)
painter.end()
# ── Lifecycle ─────────────────────────────────────────────────────────
def _release(self):
self._pause()
if hasattr(self, "_cap") and self._cap.isOpened():
self._cap.release()
def _accept_and_stop(self):
self._release()
super().accept()
def _reject_and_stop(self):
self._release()
super().reject()
def closeEvent(self, event):
self._release()
super().closeEvent(event)
def accept(self):
self._release()
super().accept()
def reject(self):
self._release()
super().reject()