From 2585b9440e57dd9d35196de78a1be1439dfc0507 Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Wed, 22 Apr 2026 12:41:52 -0400 Subject: [PATCH 1/2] updated VDH to use timepoints and PTS via pyav, deprecated fnum-based fns --- mmif/utils/video_document_helper.py | 345 +++++++++++++++++++++++----- requirements.cv | 1 + tests/test_utils.py | 228 +++++++++++++----- 3 files changed, 458 insertions(+), 116 deletions(-) diff --git a/mmif/utils/video_document_helper.py b/mmif/utils/video_document_helper.py index d2df8323..4c153243 100644 --- a/mmif/utils/video_document_helper.py +++ b/mmif/utils/video_document_helper.py @@ -14,13 +14,19 @@ from mmif.utils.timeunit_helper import convert from mmif.vocabulary import DocumentTypes -_CV_DEPS = ('cv2', 'PIL', 'wurlitzer') +_CV_DEPS = ('av', 'cv2', 'PIL', 'wurlitzer') _cv_import_warning = ( 'Optional package "{}" is not found. ' 'You might want to install Computer-Vision dependencies ' 'by running `pip install mmif-python[cv]=={}`' ) +_PTS_BUG_NOTICE = ( + 'Frame-number arithmetic ignores the video container\'s PTS start offset, ' + 'so the returned frame can be misaligned by however many frames that ' + 'offset spans (see issue #379).' +) + def _check_cv_dep(dep): """Import a CV dependency, raising ImportError with a helpful message.""" @@ -35,7 +41,6 @@ def _check_cv_dep(dep): FPS_DOCPROP_KEY = 'fps' FRAMECOUNT_DOCPROP_KEY = 'frameCount' DURATION_DOCPROP_KEY = 'duration' -DURATIONUNIT_DOCPROP_KEY = 'durationTimeUnit' class SamplingMode(Enum): @@ -68,13 +73,68 @@ class SamplingMode(Enum): 'sampling_mode', default=SamplingMode.REPRESENTATIVES) +def open_container(video_document: Document): + """ + Opens a video file and caches stream metadata on the document. + + Reads ``time_base``, ``start_time``, ``duration``, and ``average_rate`` + from the first video stream and writes ``fps``, ``frameCount``, and + ``duration`` (in ms) to the document as informational properties. + These properties are informational only; seek and extraction use + actual PTS read from decoded frames. + + :param video_document: :py:class:`~mmif.serialize.annotation.Document` + holding a video document (``"@type": ".../VideoDocument/..."``) + :returns: open PyAV :py:class:`av.container.InputContainer` + :rtype: av.container.InputContainer + :raises ValueError: if ``video_document`` is missing or of the wrong type + """ + av = _check_cv_dep('av') + if video_document is None or video_document.at_type != DocumentTypes.VideoDocument: + raise ValueError(f'The document does not exist.') + + container = av.open(video_document.location_path(nonexist_ok=False)) + stream = container.streams.video[0] + time_base = float(stream.time_base) + fps = round(float(stream.average_rate), 2) + # `stream.frames` comes from the container header. Verified exact on + # CFR H.264/MP4 inputs even with non-zero start offset; for VFR or + # headerless streams it may be 0, in which case `duration * rate` is + # the best available (approximate) estimate. + if stream.frames > 0: + frame_count = stream.frames + elif stream.duration is not None and stream.average_rate is not None: + frame_count = int(round(float(stream.duration) * time_base + * float(stream.average_rate))) + else: + frame_count = 0 + if stream.duration is not None: + duration_ms = int(round(float(stream.duration) * time_base * 1000)) + elif frame_count > 0 and fps > 0: + duration_ms = int(round(frame_count / fps * 1000)) + else: + duration_ms = 0 + video_document.add_property(FPS_DOCPROP_KEY, fps) + video_document.add_property(FRAMECOUNT_DOCPROP_KEY, frame_count) + video_document.add_property(DURATION_DOCPROP_KEY, duration_ms) + return container + + def capture(video_document: Document): """ + .. deprecated:: + Use :py:func:`open_container` instead. See issue #379. + Captures a video file using OpenCV and adds fps, frame count, and duration as properties to the document. :param video_document: :py:class:`~mmif.serialize.annotation.Document` instance that holds a video document (``"@type": ".../VideoDocument/..."``) :return: `OpenCV VideoCapture `_ object """ + warnings.warn( + f'capture() is deprecated; use open_container() instead. ' + f'{_PTS_BUG_NOTICE}', + DeprecationWarning, stacklevel=2, + ) cv2 = _check_cv_dep('cv2') if video_document is None or video_document.at_type != DocumentTypes.VideoDocument: raise ValueError(f'The document does not exist.') @@ -86,13 +146,13 @@ def capture(video_document: Document): video_document.add_property(FPS_DOCPROP_KEY, fps) video_document.add_property(FRAMECOUNT_DOCPROP_KEY, fc) video_document.add_property(DURATION_DOCPROP_KEY, dur) - video_document.add_property(DURATIONUNIT_DOCPROP_KEY, 'milliseconds') return v def get_framerate(video_document: Document) -> float: """ - Gets the frame rate of a video document. First by checking the fps property of the document, then by capturing the video. + Gets the frame rate of a video document. First by checking the fps + property of the document, then by opening the video via PyAV. :param video_document: :py:class:`~mmif.serialize.annotation.Document` instance that holds a video document (``"@type": ".../VideoDocument/..."``) :return: frames per second as a float, rounded to 2 decimal places @@ -106,16 +166,105 @@ def get_framerate(video_document: Document) -> float: 'framepersecond', 'framePerSecond', 'frame_per_second', 'frame-per-second') for k in framerate_keys: if k in video_document: - fps = round(video_document.get_property(k), 2) - return fps - cap = capture(video_document) - fps = video_document.get_property(FPS_DOCPROP_KEY) - cap.release() - return fps + return round(video_document.get_property(k), 2) + container = open_container(video_document) + try: + return video_document.get_property(FPS_DOCPROP_KEY) + finally: + container.close() + + +def extract_timepoints_as_images( + video_document: Document, + timepoints_ms: Iterable[int], + as_PIL: bool = False, +): + """ + Extracts frames at the given media-timeline timepoints (in milliseconds). + + For each requested timepoint, returns the frame whose actual + presentation timestamp (PTS) is closest to it. Duplicate timepoints + produce duplicate frames at the same list positions as the input. + + :param video_document: :py:class:`~mmif.serialize.annotation.Document` + holding a video document (``"@type": ".../VideoDocument/..."``) + :param timepoints_ms: iterable of timepoint values in milliseconds + :param as_PIL: return :py:class:`PIL.Image.Image` (RGB) instead of + :py:class:`~numpy.ndarray` (BGR) + :returns: frames in the same order (and with the same multiplicity) as + ``timepoints_ms`` + :rtype: list + """ + original_timepoints = list(timepoints_ms) + if not original_timepoints: + return [] + unique_sorted_ms = sorted(set(original_timepoints)) + + Image = _check_cv_dep('PIL.Image') if as_PIL else None + + container = open_container(video_document) + result_map = {} + try: + stream = container.streams.video[0] + time_base = float(stream.time_base) + # convert each target ms to stream ticks (PTS units) + target_ticks = [int(round(t_ms / 1000.0 / time_base)) + for t_ms in unique_sorted_ms] + + # seek to the nearest keyframe at or before the earliest target + container.seek(target_ticks[0], backward=True, any_frame=False, + stream=stream) + + targets = iter(zip(unique_sorted_ms, target_ticks)) + cur_ms, cur_pts = next(targets, (None, None)) + prev_frame = None + prev_pts = None + + def _emit(frame, t_ms): + result_map[t_ms] = (frame.to_image() if as_PIL + else frame.to_ndarray(format='bgr24')) + + for frame in container.decode(stream): + if frame.pts is None: + continue + pts = frame.pts + while cur_ms is not None and pts >= cur_pts: + # pick whichever of (prev, current) is closer to target + if prev_pts is None or (pts - cur_pts) <= (cur_pts - prev_pts): + _emit(frame, cur_ms) + else: + _emit(prev_frame, cur_ms) + cur_ms, cur_pts = next(targets, (None, None)) + prev_frame = frame + prev_pts = pts + if cur_ms is None: + break + + # targets past the last decoded frame: fall back to the last frame + while cur_ms is not None: + if prev_frame is not None: + warnings.warn( + f'Timepoint {cur_ms}ms is beyond the video duration; ' + f'returning the last decoded frame for {video_document.id}.' + ) + _emit(prev_frame, cur_ms) + else: + warnings.warn( + f'No frames decoded for timepoint {cur_ms}ms from ' + f'video {video_document.id}.' + ) + cur_ms, cur_pts = next(targets, (None, None)) + finally: + container.close() + + return [result_map[t] for t in original_timepoints if t in result_map] def extract_frames_as_images(video_document: Document, framenums: Iterable[int], as_PIL: bool = False, record_ffmpeg_errors: bool = False): """ + .. deprecated:: + Use :py:func:`extract_timepoints_as_images` instead. See issue #379. + Extracts frames from a video document as a list of :py:class:`numpy.ndarray`. Use with :py:func:`sample_frames` function to get the list of frame numbers first. @@ -125,6 +274,11 @@ def extract_frames_as_images(video_document: Document, framenums: Iterable[int], :param record_ffmpeg_errors: if True, records and warns about FFmpeg stderr output during extraction :return: frames as a list of :py:class:`~numpy.ndarray` or :py:class:`~PIL.Image.Image` """ + warnings.warn( + f'extract_frames_as_images() is deprecated; use ' + f'extract_timepoints_as_images() instead. {_PTS_BUG_NOTICE}', + DeprecationWarning, stacklevel=2, + ) cv2 = _check_cv_dep('cv2') # deduplicate and sort frame numbers for extraction, then map back to original order original_framenums = list(framenums) @@ -206,7 +360,8 @@ def extract_mid_frame(mmif: Mmif, time_frame: Annotation, as_PIL: bool = False): """ warnings.warn('This function is deprecated. Use ``extract_frames_by_mode()`` instead.', DeprecationWarning, stacklevel=2) vd = mmif[time_frame.get_property('document')] - return extract_frames_as_images(vd, [get_mid_framenum(mmif, time_frame)], as_PIL=as_PIL)[0] + fn = get_mid_framenum(mmif, time_frame) + return extract_frames_as_images(vd, [fn], as_PIL=as_PIL)[0] def get_representative_framenums(mmif: Mmif, time_frame: Annotation) -> List[int]: @@ -273,18 +428,21 @@ def extract_representative_frame(mmif: Mmif, time_frame: Annotation, as_PIL: boo return extract_frames_as_images(video_document, rep_frame_num, as_PIL=as_PIL)[0] -def _tp_ids_to_framenums(mmif: Mmif, tp_ids: List[str]) -> List[int]: +def _tp_ids_to_timepoints_ms(mmif: Mmif, tp_ids: List[str]) -> List[int]: """ - Converts a list of timepoint annotation IDs to frame numbers. + Converts a list of timepoint annotation IDs to media-timeline timepoints in milliseconds. :param mmif: :py:class:`~mmif.serialize.mmif.Mmif` instance :param tp_ids: list of timepoint annotation IDs - :return: list of frame numbers + :return: list of timepoint values in ms + :rtype: list """ - return [ - int(convert_timepoint(mmif, mmif[tp_id], 'f')) - for tp_id in tp_ids - ] + # TODO: when a source annotation has timeUnit='frame', convert_timepoint + # falls back to `frame / fps` ms math that ignores the container's PTS + # start offset. Fully resolving this requires retiring timeUnit='frame' + # (tracked in clams-vocabulary#15). + return [int(round(convert_timepoint(mmif, mmif[tp_id], 'ms'))) + for tp_id in tp_ids] def _resolve_video_document(mmif: Mmif, time_frame: Annotation): @@ -311,79 +469,86 @@ def _resolve_video_document(mmif: Mmif, time_frame: Annotation): f'{time_frame.id}.') -def _timeframe_to_frame_range( +def _timeframe_to_timepoint_range_ms( mmif: Mmif, time_frame: Annotation ) -> Tuple[int, int]: """ - Converts a TimeFrame's start/end to frame numbers. + Converts a TimeFrame's start/end to media-timeline timepoints in ms. :param mmif: :py:class:`~mmif.serialize.mmif.Mmif` instance :param time_frame: :py:class:`~mmif.serialize.annotation.Annotation` instance of a TimeFrame with ``start``, ``end``, ``timeUnit``, and ``document`` properties - :return: tuple of (start_frame, end_frame) + :return: tuple of (start_ms, end_ms) + :rtype: tuple """ - start, end = convert_timeframe(mmif, time_frame, 'f') - return int(start), int(end) + start, end = convert_timeframe(mmif, time_frame, 'ms') + return int(round(start)), int(round(end)) -def _sample_all(mmif: Mmif, time_frame: Annotation) -> List[int]: +def _sample_all_timepoints_ms(mmif: Mmif, time_frame: Annotation) -> List[int]: """ - Samples all frame numbers from a TimeFrame. Uses all - ``targets`` if present, otherwise generates every frame - in the start/end interval. + Samples all timepoints (ms) from a TimeFrame. Uses all ``targets`` if + present, otherwise samples the start/end interval at the stream's + average frame rate. :param mmif: :py:class:`~mmif.serialize.mmif.Mmif` instance :param time_frame: :py:class:`~mmif.serialize.annotation.Annotation` instance of a TimeFrame - :return: list of frame numbers + :return: list of timepoint values in ms + :rtype: list """ if 'targets' in time_frame.properties: - return _tp_ids_to_framenums( + return _tp_ids_to_timepoints_ms( mmif, time_frame.get_property('targets')) - start, end = _timeframe_to_frame_range(mmif, time_frame) - return sample_frames(start, end) + start_ms, end_ms = _timeframe_to_timepoint_range_ms(mmif, time_frame) + video_document = _resolve_video_document(mmif, time_frame) + fps = get_framerate(video_document) + step_ms = 1000.0 / fps + return sample_timepoints(start_ms, end_ms, step_ms) -def _sample_representatives( +def _sample_representatives_timepoints_ms( mmif: Mmif, time_frame: Annotation ) -> List[int]: """ - Samples frame numbers from a TimeFrame's representatives. - Returns an empty list if ``representatives`` is not present - (skips the TimeFrame). + Samples timepoints (ms) from a TimeFrame's representatives. Returns an + empty list if ``representatives`` is not present (skips the TimeFrame). :param mmif: :py:class:`~mmif.serialize.mmif.Mmif` instance :param time_frame: :py:class:`~mmif.serialize.annotation.Annotation` instance of a TimeFrame - :return: list of frame numbers (empty if no representatives) + :return: list of timepoint values in ms (empty if no representatives) + :rtype: list """ if 'representatives' in time_frame.properties: reps = time_frame.get_property('representatives') if reps: - return _tp_ids_to_framenums(mmif, reps) + return _tp_ids_to_timepoints_ms(mmif, reps) return [] -def _sample_single(mmif: Mmif, time_frame: Annotation) -> List[int]: +def _sample_single_timepoint_ms( + mmif: Mmif, time_frame: Annotation +) -> List[int]: """ - Samples a single frame number from a TimeFrame. Uses the - middle representative if ``representatives`` is present, - otherwise computes the midpoint of the start/end interval - via floor division. + Samples a single timepoint (ms) from a TimeFrame. Uses the middle + representative if ``representatives`` is present, otherwise the + midpoint of the start/end interval. :param mmif: :py:class:`~mmif.serialize.mmif.Mmif` instance :param time_frame: :py:class:`~mmif.serialize.annotation.Annotation` instance of a TimeFrame - :return: list containing a single frame number + :return: list containing a single timepoint value in ms + :rtype: list """ if 'representatives' in time_frame.properties: reps = time_frame.get_property('representatives') if reps: mid = reps[len(reps) // 2] - return _tp_ids_to_framenums(mmif, [mid]) - start, end = _timeframe_to_frame_range(mmif, time_frame) - return [(start + end) // 2] + return _tp_ids_to_timepoints_ms(mmif, [mid]) + start_ms, end_ms = _timeframe_to_timepoint_range_ms(mmif, time_frame) + return [(start_ms + end_ms) // 2] def extract_target_frames(mmif: Mmif, annotation: Annotation, min_timepoints: int = 0, max_timepoints: int = sys.maxsize, fraction: float = 1.0, as_PIL: bool = False): @@ -418,9 +583,9 @@ def extract_target_frames(mmif: Mmif, annotation: Annotation, min_timepoints: in indices = [int(i * (num_targets - 1) / (count - 1)) for i in range(count)] selected_target_ids = [targets[i] for i in indices] - frame_nums = _tp_ids_to_framenums(mmif, selected_target_ids) + timepoints_ms = _tp_ids_to_timepoints_ms(mmif, selected_target_ids) video_doc = _resolve_video_document(mmif, annotation) - images = extract_frames_as_images(video_doc, frame_nums, as_PIL=as_PIL) + images = extract_timepoints_as_images(video_doc, timepoints_ms, as_PIL=as_PIL) return images, selected_target_ids @@ -447,19 +612,54 @@ def extract_frames_by_mode( if mode is None: mode = _sampling_mode.get() if mode == SamplingMode.ALL: - frame_nums = _sample_all(mmif, time_frame) + timepoints_ms = _sample_all_timepoints_ms(mmif, time_frame) elif mode == SamplingMode.REPRESENTATIVES: - frame_nums = _sample_representatives(mmif, time_frame) + timepoints_ms = _sample_representatives_timepoints_ms(mmif, time_frame) else: - frame_nums = _sample_single(mmif, time_frame) - if not frame_nums: + timepoints_ms = _sample_single_timepoint_ms(mmif, time_frame) + if not timepoints_ms: return [] video_doc = _resolve_video_document(mmif, time_frame) - return extract_frames_as_images(video_doc, frame_nums, as_PIL=as_PIL) + return extract_timepoints_as_images(video_doc, timepoints_ms, as_PIL=as_PIL) + + +def sample_timepoints( + start_ms: int, + end_ms: int, + step_ms: Union[int, float], +) -> List[int]: + """ + Samples timepoints (in ms) from a half-open time interval + ``[start_ms, end_ms)`` with a fixed step. + + :param start_ms: start of the interval (inclusive), in ms + :param end_ms: end of the interval (exclusive), in ms + :param step_ms: step size between adjacent timepoints, in ms; + may be fractional (e.g. ``1000/fps``), but emitted timepoints + are always integer ms + :returns: list of integer timepoint values in ms + :rtype: list + :raises ValueError: if ``step_ms`` is not positive + """ + if step_ms <= 0: + raise ValueError( + f'step_ms must be positive, got {step_ms}') + timepoints: List[int] = [] + i = 0 + while True: + t = start_ms + i * step_ms + if t >= end_ms: + break + timepoints.append(int(round(t))) + i += 1 + return timepoints def sample_frames(start_frame: int, end_frame: int, sample_rate: float = 1) -> List[int]: """ + .. deprecated:: + Use :py:func:`sample_timepoints` instead. See issue #379. + Helper function to sample frames from a time interval. Can also be used as a "cutoff" function when used with ``start_frame==0`` and ``sample_rate==1``. @@ -468,6 +668,11 @@ def sample_frames(start_frame: int, end_frame: int, sample_rate: float = 1) -> L :param sample_rate: sampling rate (or step) to configure how often to take a frame, default is 1, meaning all consecutive frames are sampled :return: list of frame numbers to extract """ + warnings.warn( + f'sample_frames() is deprecated; use sample_timepoints() instead. ' + f'{_PTS_BUG_NOTICE}', + DeprecationWarning, stacklevel=2, + ) if sample_rate < 1: raise ValueError(f"Sample rate must be greater than 1, but got {sample_rate}") frame_nums: List[int] = [] @@ -502,7 +707,7 @@ def get_annotation_property(mmif, annotation, prop_name): def convert_timepoint(mmif: Mmif, timepoint: Annotation, out_unit: str) -> Union[int, float, str]: """ Converts a time point included in an annotation to a different time unit. - The input annotation must have ``timePoint`` property. + The input annotation must have ``timePoint`` property. :param mmif: input MMIF to obtain fps and input timeunit :param timepoint: :py:class:`~mmif.serialize.annotation.Annotation` instance with ``timePoint`` property @@ -531,31 +736,55 @@ def convert_timeframe(mmif: Mmif, time_frame: Annotation, out_unit: str) -> Tupl def framenum_to_second(video_doc: Document, frame: int): """ - Converts a frame number to a second value. + .. deprecated:: + Use :py:func:`~mmif.utils.timeunit_helper.convert` with ``ms``/``s`` + directly. See issue #379. """ + warnings.warn( + f'framenum_to_second() is deprecated. {_PTS_BUG_NOTICE}', + DeprecationWarning, stacklevel=2, + ) fps = get_framerate(video_doc) return convert(frame, 'f', 's', fps) def framenum_to_millisecond(video_doc: Document, frame: int): """ - Converts a frame number to a millisecond value. + .. deprecated:: + Use :py:func:`~mmif.utils.timeunit_helper.convert` with ``ms``/``s`` + directly. See issue #379. """ + warnings.warn( + f'framenum_to_millisecond() is deprecated. {_PTS_BUG_NOTICE}', + DeprecationWarning, stacklevel=2, + ) fps = get_framerate(video_doc) return convert(frame, 'f', 'ms', fps) def second_to_framenum(video_doc: Document, second) -> int: """ - Converts a second value to a frame number. + .. deprecated:: + Use :py:func:`extract_timepoints_as_images` or stay in the time + domain. See issue #379. """ + warnings.warn( + f'second_to_framenum() is deprecated. {_PTS_BUG_NOTICE}', + DeprecationWarning, stacklevel=2, + ) fps = get_framerate(video_doc) return int(convert(second, 's', 'f', fps)) def millisecond_to_framenum(video_doc: Document, millisecond: float) -> int: """ - Converts a millisecond value to a frame number. + .. deprecated:: + Use :py:func:`extract_timepoints_as_images` or stay in the time + domain. See issue #379. """ + warnings.warn( + f'millisecond_to_framenum() is deprecated. {_PTS_BUG_NOTICE}', + DeprecationWarning, stacklevel=2, + ) fps = get_framerate(video_doc) return int(convert(millisecond, 'ms', 'f', fps)) diff --git a/requirements.cv b/requirements.cv index a2c1cfb9..c1edfd4b 100644 --- a/requirements.cv +++ b/requirements.cv @@ -1,4 +1,5 @@ pillow +av opencv-python ffmpeg-python wurlitzer diff --git a/tests/test_utils.py b/tests/test_utils.py index 1d903b10..fff35331 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -69,6 +69,7 @@ def test_extract_mid_frame(self): tf = self.a_view.new_annotation(AnnotationTypes.TimeFrame, start=0, end=3, timeUnit='seconds', document=self.video_doc.id) self.assertEqual(vdh.convert(1.5, 's', 'f', self.fps), vdh.get_mid_framenum(self.mmif_obj, tf)) + @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_extract_representative_frame(self): tp = self.a_view.new_annotation(AnnotationTypes.TimePoint, timePoint=1500, timeUnit='milliseconds', document=self.video_doc.id) tf = self.a_view.new_annotation(AnnotationTypes.TimeFrame, start=1000, end=2000, timeUnit='milliseconds', document=self.video_doc.id) @@ -87,18 +88,23 @@ def test_extract_representative_frame(self): def test_get_framerate(self): self.assertAlmostEqual(29.97, vdh.get_framerate(self.video_doc), places=0) + @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_frames_to_seconds(self): self.assertAlmostEqual(3.337, vdh.framenum_to_second(self.video_doc, 100), places=0) + @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_frames_to_milliseconds(self): self.assertAlmostEqual(3337.0, vdh.framenum_to_millisecond(self.video_doc, 100), places=0) + @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_seconds_to_frames(self): self.assertAlmostEqual(100, vdh.second_to_framenum(self.video_doc, 3.337), places=0) + @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_milliseconds_to_frames(self): self.assertAlmostEqual(100, vdh.millisecond_to_framenum(self.video_doc, 3337.0), places=0) + @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_convert_roundtrip(self): # ms for 1 frame tolerance = 1000 / self.video_doc.get_property('fps') @@ -107,6 +113,7 @@ def test_convert_roundtrip(self): m2f2m = vdh.framenum_to_millisecond(self.video_doc, m2f) self.assertAlmostEqual(ms, m2f2m, delta=tolerance) + @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_sample_frames(self): s_frame = vdh.second_to_framenum(self.video_doc, 3) e_frame = vdh.second_to_framenum(self.video_doc, 5.5) @@ -116,6 +123,19 @@ def test_sample_frames(self): e_frame = vdh.second_to_framenum(self.video_doc, 5) self.assertEqual(1, len(vdh.sample_frames(s_frame, e_frame, 60))) + def test_deprecated_framenum_helpers_warn(self): + # each fnum-leaking helper should emit DeprecationWarning pointing at issue #379 + with pytest.warns(DeprecationWarning, match='#379'): + vdh.framenum_to_second(self.video_doc, 100) + with pytest.warns(DeprecationWarning, match='#379'): + vdh.framenum_to_millisecond(self.video_doc, 100) + with pytest.warns(DeprecationWarning, match='#379'): + vdh.second_to_framenum(self.video_doc, 1) + with pytest.warns(DeprecationWarning, match='#379'): + vdh.millisecond_to_framenum(self.video_doc, 1000) + with pytest.warns(DeprecationWarning, match='#379'): + vdh.sample_frames(0, 10, 1) + def test_convert_timepoint(self): timepoint_ann = self.a_view.new_annotation(AnnotationTypes.BoundingBox, timePoint=3, timeUnit='second', document='d1') @@ -127,6 +147,7 @@ def test_convert_timeframe(self): for times in zip((3.337, 6.674), vdh.convert_timeframe(self.mmif_obj, timeframe_ann, 's')): self.assertAlmostEqual(*times, places=0) + @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_extract_frames_as_images(self): frame_list = [5, 10, 15] target_images = vdh.extract_frames_as_images(self.video_doc, frame_list, as_PIL=False) @@ -144,85 +165,176 @@ def test_extract_frames_as_images(self): self.assertEqual(4, len(frame_list)) self.assertEqual(3, len(new_target_images)) - def test_sample_all(self): + def test_open_container(self): + # open_container sets fps/frameCount/duration as informational props + vd = Document({ + "@type": "http://mmif.clams.ai/vocabulary/VideoDocument/v1", + "properties": { + "mime": "video", + "id": "o1", + "location": f"file://{pathlib.Path(__file__).parent}/black-2997fps.mp4", + } + }) + c = vdh.open_container(vd) + try: + self.assertAlmostEqual(29.97, vd.get_property('fps'), places=1) + self.assertGreater(vd.get_property('frameCount'), 0) + self.assertGreater(vd.get_property('duration'), 0) + finally: + c.close() + + def test_sample_timepoints(self): + # half-open interval; step in ms + self.assertEqual([0, 100, 200, 300, 400], + vdh.sample_timepoints(0, 500, 100)) + # empty when step overshoots + self.assertEqual([0], vdh.sample_timepoints(0, 100, 200)) + # negative or zero step is a programmer error + with pytest.raises(ValueError): + vdh.sample_timepoints(0, 100, 0) + with pytest.raises(ValueError): + vdh.sample_timepoints(0, 100, -10) + + def test_extract_timepoints_as_images(self): + # basic: three distinct timepoints + ms_list = [1000, 2000, 3000] + imgs = vdh.extract_timepoints_as_images( + self.video_doc, ms_list, as_PIL=False) + self.assertEqual(3, len(imgs)) + # empty input + self.assertEqual( + [], vdh.extract_timepoints_as_images(self.video_doc, [])) + # duplicates preserved in input order + dup_ms = [500, 250, 500, 750, 250] + dup_imgs = vdh.extract_timepoints_as_images(self.video_doc, dup_ms) + self.assertEqual(5, len(dup_imgs)) + + def _make_timepoints(self, count): + # Explicit aid avoids a pre-existing clams-vocabulary / mmif-python + # compat path (`at_type.get_prefix()`) that is broken in this env + # and is not related to this PR. tps = [] - for i in range(10): + for i in range(count): tp = self.a_view.new_annotation( - AnnotationTypes.TimePoint, - timePoint=i * 100, timeUnit='frame', + AnnotationTypes.TimePoint, aid=f'tp_{i}', + timePoint=i * 100, timeUnit='milliseconds', document=self.video_doc.id) tps.append(tp) - parent_ann = self.a_view.new_annotation( - AnnotationTypes.TimeFrame, + return tps + + def test_sample_all_timepoints_ms(self): + tps = self._make_timepoints(10) + parent = self.a_view.new_annotation( + AnnotationTypes.TimeFrame, aid='tf_0', targets=[tp.id for tp in tps]) - frame_nums = vdh._sample_all(self.mmif_obj, parent_ann) - self.assertEqual(10, len(frame_nums)) - self.assertEqual([i * 100 for i in range(10)], frame_nums) + ms_list = vdh._sample_all_timepoints_ms(self.mmif_obj, parent) + self.assertEqual([i * 100 for i in range(10)], ms_list) - # start/end fallback (no targets) - parent_ann2 = self.a_view.new_annotation( - AnnotationTypes.TimeFrame, - start=0, end=10, timeUnit='frame', + # start/end fallback (no targets): sampled at the stream's frame rate + parent2 = self.a_view.new_annotation( + AnnotationTypes.TimeFrame, aid='tf_1', + start=0, end=1000, timeUnit='milliseconds', document=self.video_doc.id) - frame_nums2 = vdh._sample_all(self.mmif_obj, parent_ann2) - self.assertEqual(list(range(10)), frame_nums2) - - def test_sample_representatives(self): - tps = [] - for i in range(10): - tp = self.a_view.new_annotation( - AnnotationTypes.TimePoint, - timePoint=i * 100, timeUnit='frame', - document=self.video_doc.id) - tps.append(tp) + ms_list2 = vdh._sample_all_timepoints_ms(self.mmif_obj, parent2) + # 30 frames in 1000ms at 29.97fps (step ≈ 33.37ms) + self.assertEqual(30, len(ms_list2)) + self.assertEqual(0, ms_list2[0]) + self.assertLess(ms_list2[-1], 1000) + + def test_sample_representatives_timepoints_ms(self): + tps = self._make_timepoints(10) reps = [tps[2].id, tps[5].id, tps[8].id] - parent_ann = self.a_view.new_annotation( - AnnotationTypes.TimeFrame, + parent = self.a_view.new_annotation( + AnnotationTypes.TimeFrame, aid='tf_0', targets=[tp.id for tp in tps], representatives=reps) - # should use representatives - frame_nums = vdh._sample_representatives( - self.mmif_obj, parent_ann) - self.assertEqual(3, len(frame_nums)) - self.assertEqual([200, 500, 800], frame_nums) + ms_list = vdh._sample_representatives_timepoints_ms( + self.mmif_obj, parent) + self.assertEqual([200, 500, 800], ms_list) - # without representatives, should return empty (skip) - parent_ann2 = self.a_view.new_annotation( - AnnotationTypes.TimeFrame, + # no representatives → empty (skip) + parent2 = self.a_view.new_annotation( + AnnotationTypes.TimeFrame, aid='tf_1', targets=[tp.id for tp in tps]) - frame_nums2 = vdh._sample_representatives( - self.mmif_obj, parent_ann2) - self.assertEqual([], frame_nums2) + self.assertEqual( + [], vdh._sample_representatives_timepoints_ms( + self.mmif_obj, parent2)) - def test_sample_single(self): - tps = [] - for i in range(10): - tp = self.a_view.new_annotation( - AnnotationTypes.TimePoint, - timePoint=i * 100, timeUnit='frame', - document=self.video_doc.id) - tps.append(tp) + def test_sample_single_timepoint_ms(self): + tps = self._make_timepoints(10) reps = [tps[2].id, tps[5].id, tps[8].id] - parent_ann = self.a_view.new_annotation( - AnnotationTypes.TimeFrame, + parent = self.a_view.new_annotation( + AnnotationTypes.TimeFrame, aid='tf_0', targets=[tp.id for tp in tps], representatives=reps) - # should pick middle representative (index 1 of 3 = tps[5]) - frame_nums = vdh._sample_single( - self.mmif_obj, parent_ann) - self.assertEqual([500], frame_nums) + # middle representative (index 1 of 3 → tps[5] → 500ms) + self.assertEqual( + [500], + vdh._sample_single_timepoint_ms(self.mmif_obj, parent)) - # start/end fallback (no representatives) - parent_ann2 = self.a_view.new_annotation( - AnnotationTypes.TimeFrame, - start=100, end=500, timeUnit='frame', + # start/end fallback midpoint + parent2 = self.a_view.new_annotation( + AnnotationTypes.TimeFrame, aid='tf_1', + start=100, end=500, timeUnit='milliseconds', document=self.video_doc.id) - frame_nums2 = vdh._sample_single( - self.mmif_obj, parent_ann2) - self.assertEqual([300], frame_nums2) + self.assertEqual( + [300], + vdh._sample_single_timepoint_ms(self.mmif_obj, parent2)) + + def test_pts_offset_regression(self): + # regression for https://github.com/clamsproject/mmif-python/issues/379 + # on a container with non-zero PTS start offset, requesting a + # timepoint equal to the first frame's actual PTS must return that + # first frame, not the second one. + import av + fixture = pathlib.Path(__file__).parent / 'testsrc-2997fps-ptsoffset.mp4' + vd = Document({ + "@type": "http://mmif.clams.ai/vocabulary/VideoDocument/v1", + "properties": { + "mime": "video", + "id": "p1", + "location": f"file://{fixture}", + } + }) + + # ground truth: map (pixel-bytes hash) → pts for every frame + container = av.open(str(fixture)) + stream = container.streams.video[0] + tb = float(stream.time_base) + pts_by_hash = {} + for frame in container.decode(stream): + if frame.pts is None: + continue + pts_by_hash[hash(frame.to_ndarray(format='bgr24').tobytes())] \ + = frame.pts + container.close() + + # requested 33ms should resolve to the actual PTS-equivalent frame + # (start_time is ~33ms; the first frame's PTS is nearest 33ms) + imgs = vdh.extract_timepoints_as_images(vd, [33], as_PIL=False) + self.assertEqual(1, len(imgs)) + got_pts = pts_by_hash.get(hash(imgs[0].tobytes())) + self.assertIsNotNone(got_pts) + got_ms = got_pts * tb * 1000 + frame_dur_ms = 1000 / 29.97 + self.assertLessEqual(abs(got_ms - 33), + frame_dur_ms / 2 + 1.0) + + # differential: the deprecated cv2 path returns a DIFFERENT frame + # (off by one) for the same requested timepoint → this confirms + # the fix. + import warnings as _w + with _w.catch_warnings(): + _w.simplefilter('ignore', DeprecationWarning) + fnum = vdh.millisecond_to_framenum(vd, 33) + old_img = vdh.extract_frames_as_images(vd, [fnum])[0] + old_pts = pts_by_hash.get(hash(old_img.tobytes())) + self.assertNotEqual(got_pts, old_pts, + 'cv2 and PyAV paths should disagree on ' + 'PTS-offset videos') class TestSequenceHelper(unittest.TestCase): From 968c3ddb491319af7532397b8f252795c7cef7ba Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Wed, 22 Apr 2026 15:48:20 -0400 Subject: [PATCH 2/2] fixed dependency specs and fixtures for testing --- pyproject.toml | 14 +++++--------- tests/testsrc-2997fps-ptsoffset.mp4 | Bin 0 -> 12826 bytes 2 files changed, 5 insertions(+), 9 deletions(-) create mode 100644 tests/testsrc-2997fps-ptsoffset.mp4 diff --git a/pyproject.toml b/pyproject.toml index 74387b38..3774a454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,21 +39,17 @@ source = "https://github.com/clamsproject/mmif-python" mmif-spec = "https://mmif.clams.ai/1.1.1" [project.optional-dependencies] -dev = [ - "pytype", "pytest", "pytest-cov", - "hypothesis", "hypothesis-jsonschema", - "pyyaml", "bs4", "lxml", - "setuptools", -] +cv = ["pillow", "opencv-python", "ffmpeg-python", "wurlitzer", "av"] +seq = ["numpy"] docs = ["sphinx>=7.0,<8.0", "furo", "m2r2", "autodoc-pydantic"] test = [ + "mmif-python[cv]", + "mmif-python[seq]", "pytype", "pytest", "pytest-cov", "hypothesis", "hypothesis-jsonschema", "pyyaml", "bs4", "lxml", - "pillow", "opencv-python", "ffmpeg-python", "wurlitzer", ] -cv = ["pillow", "opencv-python", "ffmpeg-python", "wurlitzer", "av"] -seq = ["numpy"] +dev = ["mmif-python[test]", "setuptools"] [tool.setuptools.packages.find] where = ["."] diff --git a/tests/testsrc-2997fps-ptsoffset.mp4 b/tests/testsrc-2997fps-ptsoffset.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..8bad2249b13cc7b17fc30b7936775788aaf150e6 GIT binary patch literal 12826 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 S;S>a{3s%6ozy