[코드 리뷰] PySceneDetect Content-detect

마계닭·2026년 3월 27일

코드 리뷰

목록 보기
2/3

Git: PySceneDetect/scenedetect/detectors/content_detector.py
를 알아보며 cv2가 어떻게 사용되는지를 간단하게 알아보자

설명

PySceneDetect

  • 연속된 영상 파일에서 장면 전환 지점을 자동으로 탐지하는 파이썬 라이브러리
  • Cut이나 fade in/out, 디졸브 등 전환을 색상 변화나 밝기 변화를 기준으로 감지함
    • 감지된 장면을 기준으로 Video splitting도 가능

Content Detect

  • 인접한 두 프레임 간 content의 차이가 threshold를 넘길 경우 장면 전환으로 판단함
  • 다른 detector들에 비해서 빠르고 직관적인 편
  • 카메라가 빠르게 움직이는 등의 전체 프레임이 계속 크게 흔들리는 경우에는 오탐이 늘어날 수 있음
  • threshold, min_scene_len(최소 프레임) 등을 조절할 수 있음
    • 기본값 threshold == 27, min_scene_len == 15

코드

_mean_pixel_distance

def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float:
    """Return the mean average distance in pixel values between `left` and `right`.
    Both `left and `right` should be 2 dimensional 8-bit images of the same shape.
    """
    assert len(left.shape) == 2 and len(right.shape) == 2
    assert left.shape == right.shape
    num_pixels: float = float(left.shape[0] * left.shape[1])
    return numpy.sum(numpy.abs(left.astype(numpy.int32) - right.astype(numpy.int32))) / num_pixels

두 이미지 간(두 프레임 간) 픽셀 값 차이를 평균을 반환하는 함수

  • assert (조건): 뒤의 조건이 맞지 않을 경우 에러를 반환
    • 두 이미지 모두 2차원인지 확인(흑백)
    • 두 이미지의 크기가 같은지 확인
  • num_pixels: 전체 픽셀 수 (height * width)
  • return
    • astype: 이미지를 int32로 반환(음수값 대비)
    • 이후 차를 절댓값(abs)한 값들을 합산 후 나누기

_estimated_kernel_size

def _estimated_kernel_size(frame_width: int, frame_height: int) -> int:
    """Estimate kernel size based on video resolution."""
    # TODO: This equation is based on manual estimation from a few videos.
    # Create a more comprehensive test suite to optimize against.
    size: int = 4 + round(math.sqrt(frame_width * frame_height) / 192)
    if size % 2 == 0:
        size += 1
    return size

filter(kernel)의 크기를 해상도에 맞기 설정

  • size: 전체 픽셀 수(width * height) / scale 조정 값(hyperparameter)
    • 해당 값을 sqrt하여 대략적인 길이를 구함
    • +4는 최소값 보정
  • kernel은 중심이 필요하기에 홀수값으로 맞춰줌

class ContentDetector

class Components

class Components(ty.NamedTuple):
        """Components that make up a frame's score, and their default values."""

        delta_hue: float = 1.0
        """Difference between pixel hue values of adjacent frames."""
        delta_sat: float = 1.0
        """Difference between pixel saturation values of adjacent frames."""
        delta_lum: float = 1.0
        """Difference between pixel luma (brightness) values of adjacent frames."""
        delta_edges: float = 0.0
        """Difference between calculated edges of adjacent frames.

ContentDetector의 경우에는 BGR값을 HSV로 변환해서 작동함

  • Hue: 색의 종류(color wheel의 각도)
  • Saturation: 채도(색이 얼마나 선명한가)
  • Luma: 밝기(흑백 기준과 유사)
  • 각각 delta를 붙여서 두 프레임의 픽셀 간 차이를 나타냄
  • 만약 Luma-only를 킬 경우 delta_lum만 1.0, 나머지는 0.0으로 변환

_calculate_frame_score

def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> float:
        """Calculate score representing relative amount of motion in `frame_img` compared to
        the last time the function was called (returns 0.0 on the first call)."""
        # TODO: Add option to enable motion estimation before calculating score components.
        # TODO: Investigate methods of performing cheaper alternatives, e.g. shifting or resizing
        # the frame to simulate camera movement, using optical flow, etc...

        # Convert image into HSV colorspace.
        hue, sat, lum = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV))

        # Performance: Only calculate edges if we have to.
        calculate_edges: bool = (self._weights.delta_edges > 0.0) or self.stats_manager is not None
        edges = self._detect_edges(lum) if calculate_edges else None

        if self._last_frame is None:
            # Need another frame to compare with for score calculation.
            self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges)
            return 0.0

        score_components = ContentDetector.Components(
            delta_hue=_mean_pixel_distance(hue, self._last_frame.hue),
            delta_sat=_mean_pixel_distance(sat, self._last_frame.sat),
            delta_lum=_mean_pixel_distance(lum, self._last_frame.lum),
            delta_edges=(
                0.0 if edges is None else _mean_pixel_distance(edges, self._last_frame.edges)
            ),
        )

        frame_score: float = sum(
            component * weight
            for (component, weight) in zip(score_components, self._weights, strict=True)
        ) / sum(abs(weight) for weight in self._weights)

        # Record components and frame score if needed for analysis.
        if self.stats_manager is not None:
            metrics = {self.FRAME_SCORE_KEY: frame_score}
            metrics.update(score_components._asdict())
            self.stats_manager.set_metrics(timecode, metrics)

        # Store all data required to calculate the next frame's score.
        self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges)
        return frame_score

이전 프레임과 비교해서 얼마나 움직임이 있었는지를 계산하는 함수

  • cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV): BGR값으로 표현된 이미지 벡터를 HSV형태로 변환
    • cv2.split: 리스트 형태로 hue, sat, lum을 분리함
  • calculate_edges: edge계산이 필요한지 확인
    • 만약 필요하다면 _detect_edges로 넘어감
  • 만약 첫 프레임이라면 0.0을 반환
  • score_components: _mean_pixel_distance를 활용해서 hue, sat, lum, edges의 변화량 계산
  • frame_score: 각 component(hue, sat, lum, edges)에 weight를 곱한 뒤(zip 활용), 합을 나눠서 평균
    • 어떤 변화가 더 중요한지를 조절함
  • 필요할 경우 stats_manager로 통계 기록을 남김

process_frame

def process_frame(
        self, timecode: FrameTimecode, frame_img: numpy.ndarray
    ) -> ty.List[FrameTimecode]:
        """Process the next frame. `frame_num` is assumed to be sequential.

        Args:
            frame_num (int): Frame number of frame that is being passed. Can start from any value
                but must remain sequential.
            frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`.

        Returns:
           ty.List[int]: List of frames where scene cuts have been detected. There may be 0
            or more frames in the list, and not necessarily the same as frame_num.
        """
        self._frame_score = self._calculate_frame_score(timecode, frame_img)
        if self._frame_score is None:
            return []

        above_threshold: bool = self._frame_score >= self._threshold
        return self._flash_filter.filter(timecode=timecode, above_threshold=above_threshold)

계산한 score가 threshold를 넘기는지 확인 + filter 처리

  • _flash_filter: 노이즈나 순간적인 반짝임들을 제거
    • 연속된 변화만을 남김

_detect_edges

def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray:
        """Detect edges using the luma channel of a frame.

        Arguments:
            lum: 2D 8-bit image representing the luma channel of a frame.

        Returns:
            2D 8-bit image of the same size as the input, where pixels with values of 255
            represent edges, and all other pixels are 0.
        """
        # Initialize kernel.
        if self._kernel is None:
            kernel_size = _estimated_kernel_size(lum.shape[1], lum.shape[0])
            self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8)

        # Estimate levels for thresholding.
        # TODO: Add config file entries for sigma, aperture/kernel size, etc.
        sigma: float = 1.0 / 3.0
        median = numpy.median(lum)
        low = int(max(0, (1.0 - sigma) * median))
        high = int(min(255, (1.0 + sigma) * median))

        # Calculate edges using Canny algorithm, and reduce noise by dilating the edges.
        # This increases edge overlap leading to improved robustness against noise and slow
        # camera movement. Note that very large kernel sizes can negatively affect accuracy.
        edges = cv2.Canny(lum, low, high)
        return cv2.dilate(edges, self._kernel)

edge를 별도로 구분하는 이유

  • HSV만으로 판단하기에는 애매한 경우가 많음
    • 밝기만 많이 변하는 경우(조명 유무)
    • 색만 변하는 경우(색 필터 등)
  • edge
    • 형태를 남겨서 조명 등에 영향을 덜받게함

코드 설명

  • _estimated_kernel_size로 kernel 생성
    • 이때 lum만 사용(edge 검출은 밝기 변화가 제일 명확함)
  • sigma: canny edge 검출에 사용될 구간 값 결정
    • median을 기준으로 +sigma, -sigma까지 검출
  • cv2.Canny: canny edge detect algorithm 활용
  • cv2.dilate: edge를 더 두껍게 함
    • edge 비교가 더 안정적이도록 만듦(노이즈와 느린 움직임에 더 강해짐)

결론

Detection에 있어서 HSV > RGB

  • RGB의 경우에는 밝기와 색이 섞인 형태
    • 어느쪽의 변화인지 구분하기가 어려움

색 정보만이 아닌 edge 검출을 별도로 이용

  • HSV정보만을 사용하면 밝기나 필터 등 변수가 많음
    • edge를 별도로 검출하여 형태를 바탕으로도 구분해냄
profile
뉴비

0개의 댓글