mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 08:00:32 +02:00
Add Hailuo, Kling, AVI, and FLV video coverage
This commit is contained in:
@@ -1063,8 +1063,8 @@ def cmd_metadata(
|
||||
Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
|
||||
China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
|
||||
strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
|
||||
from WebM/MP3/WAV/FLAC/OGG. The coded image, audio, and video data are left
|
||||
untouched.
|
||||
from WebM/MKV/AVI/FLV/MP3/WAV/FLAC/OGG. The coded image, audio, and video
|
||||
data are left untouched.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, strip_and_verify
|
||||
|
||||
@@ -1240,7 +1240,7 @@ def cmd_video_invisible(
|
||||
)
|
||||
@click.option(
|
||||
"--mark",
|
||||
type=click.Choice(["sora", "veo", "seedance", "dola"]),
|
||||
type=click.Choice(["sora", "veo", "seedance", "dola", "hailuo", "kling"]),
|
||||
default="sora",
|
||||
help="Visible AI mark to remove.",
|
||||
)
|
||||
|
||||
@@ -10,10 +10,11 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -125,7 +126,7 @@ _ISOBMFF_EXTS: frozenset[str] = frozenset({".avif", ".heif", ".heic", ".jxl", ".
|
||||
# RIFF / Vorbis). remove_ai_metadata strips their container metadata losslessly
|
||||
# via ffmpeg (`-c copy`), so it needs ffmpeg on PATH for these.
|
||||
_FFMPEG_STRIP_EXTS: frozenset[str] = frozenset(
|
||||
{".webm", ".mkv", ".mka", ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac"}
|
||||
{".webm", ".mkv", ".mka", ".avi", ".flv", ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac"}
|
||||
)
|
||||
|
||||
# China's mandatory AI-content labeling (TC260, the national cybersecurity
|
||||
@@ -161,6 +162,22 @@ TC260_AIGC_FIELDS: frozenset[str] = frozenset(
|
||||
"ServiceUser",
|
||||
}
|
||||
)
|
||||
MAX_TC260_VALUE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None:
|
||||
"""Parse a bounded JSON object carrying at least one normative TC260 field."""
|
||||
if len(value) > MAX_TC260_VALUE_BYTES:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value.rstrip(b"\x00 ").decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
fields = {str(key): str(item) for key, item in cast("dict[object, object]", parsed).items()}
|
||||
return fields if TC260_AIGC_FIELDS & fields.keys() else None
|
||||
|
||||
|
||||
# HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this
|
||||
# ``tEXt`` chunk key holding the job UUID. It marks the hosting job, not a
|
||||
@@ -365,6 +382,8 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
(as written by Doubao / ByteDance), read via PIL;
|
||||
- a native MP4/MOV ``AIGC`` key in ``moov.udta.meta.keys`` whose matching
|
||||
``ilst`` item carries the raw JSON object;
|
||||
- a native AVI ``LIST/INFO/AIGC`` chunk or FLV
|
||||
``script.onMetaData.AIGC`` string carrying the raw JSON object;
|
||||
- an XMP ``<TC260:AIGC>{...}</TC260:AIGC>`` block (HTML-entity encoded text),
|
||||
found by a container-agnostic raw-byte scan (PNG/JPEG/WebP alike); and
|
||||
- a raw-JSON ``{"AIGC":{...}}`` block with no namespace, as embedded in JPEG
|
||||
@@ -381,20 +400,17 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
namespaced XMP element is unambiguous, so any JSON object is accepted.
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
from typing import cast
|
||||
|
||||
def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None:
|
||||
if require_tc260_field:
|
||||
return parse_tc260_aigc_json(text.encode("utf-8"))
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
fields = {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
|
||||
if require_tc260_field and not (TC260_AIGC_FIELDS & fields.keys()):
|
||||
return None
|
||||
return fields
|
||||
return {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
|
||||
|
||||
# PNG tEXt chunk keyed "AIGC" with raw JSON (Doubao and other China gens).
|
||||
# The key is generic, so require a TC260 field to avoid a false positive.
|
||||
@@ -428,6 +444,22 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
|
||||
return result
|
||||
|
||||
# Native AVI and FLV TC260 metadata. Both readers walk their container
|
||||
# structures and skip media payloads instead of relying on a raw substring
|
||||
# that could collide inside compressed video.
|
||||
legacy_payloads: tuple[bytes, ...] = ()
|
||||
if image_path.suffix.lower() == ".avi":
|
||||
from remove_ai_watermarks.noai.riff import tc260_aigc_payloads as riff_tc260_aigc_payloads
|
||||
|
||||
legacy_payloads = riff_tc260_aigc_payloads(image_path)
|
||||
elif image_path.suffix.lower() == ".flv":
|
||||
from remove_ai_watermarks.noai.flv import tc260_aigc_payloads as flv_tc260_aigc_payloads
|
||||
|
||||
legacy_payloads = flv_tc260_aigc_payloads(image_path)
|
||||
for payload in legacy_payloads:
|
||||
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
|
||||
return result
|
||||
|
||||
# XMP TC260:AIGC, namespaced (unambiguous) in either serialization RDF allows:
|
||||
# an element <TC260:AIGC>{...}</TC260:AIGC> or an attribute TC260:AIGC="{...}"
|
||||
# (the attribute form is what PicWish writes). Both are HTML-entity encoded.
|
||||
@@ -1206,7 +1238,8 @@ def remove_ai_metadata(
|
||||
)
|
||||
return output_path
|
||||
|
||||
# Non-ISOBMFF audio/video (WebM/Matroska EBML, MP3 ID3, WAV/FLAC/OGG): the
|
||||
# Non-ISOBMFF audio/video (WebM/Matroska EBML, AVI/FLV, MP3 ID3,
|
||||
# WAV/FLAC/OGG): the
|
||||
# box walker can't reach these, so strip container metadata losslessly via
|
||||
# ffmpeg (-c copy -- codec data untouched, only tags/chapters dropped).
|
||||
if source_path.suffix.lower() in _FFMPEG_STRIP_EXTS:
|
||||
|
||||
@@ -8,14 +8,13 @@ their payloads.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, BinaryIO, cast
|
||||
from typing import TYPE_CHECKING, BinaryIO
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.metadata import TC260_AIGC_FIELDS
|
||||
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
|
||||
|
||||
_EBML_MAGIC = b"\x1aE\xdf\xa3"
|
||||
_SEGMENT_ID = 0x18538067
|
||||
@@ -25,7 +24,6 @@ _SIMPLE_TAG_ID = 0x67C8
|
||||
_TAG_NAME_ID = 0x45A3
|
||||
_TAG_STRING_ID = 0x4487
|
||||
_MAX_TAG_NAME_BYTES = 256
|
||||
_MAX_TC260_VALUE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def _vint_length(first: int, *, maximum: int) -> int | None:
|
||||
@@ -111,17 +109,6 @@ def _read_bounded(
|
||||
return value if len(value) == size else None
|
||||
|
||||
|
||||
def _is_tc260_aigc_json(value: bytes) -> bool:
|
||||
try:
|
||||
parsed = json.loads(value.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return False
|
||||
if not isinstance(parsed, dict):
|
||||
return False
|
||||
fields = cast("dict[object, object]", parsed)
|
||||
return bool(TC260_AIGC_FIELDS & {str(key) for key in fields})
|
||||
|
||||
|
||||
def _simple_tag_payloads(
|
||||
stream: BinaryIO,
|
||||
start: int,
|
||||
@@ -133,12 +120,12 @@ def _simple_tag_payloads(
|
||||
if element_id == _TAG_NAME_ID:
|
||||
name = _read_bounded(stream, payload_start, element_end, _MAX_TAG_NAME_BYTES)
|
||||
elif element_id == _TAG_STRING_ID:
|
||||
value = _read_bounded(stream, payload_start, element_end, _MAX_TC260_VALUE_BYTES)
|
||||
value = _read_bounded(stream, payload_start, element_end, MAX_TC260_VALUE_BYTES)
|
||||
if value is not None:
|
||||
values.append(value)
|
||||
if name != b"AIGC":
|
||||
return ()
|
||||
return tuple(value for value in values if _is_tc260_aigc_json(value))
|
||||
return tuple(value for value in values if parse_tc260_aigc_json(value) is not None)
|
||||
|
||||
|
||||
def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Bounded FLV metadata reader for native TC260 AIGC labels.
|
||||
|
||||
TC260-PG-20257A stores the label in the ``onMetaData`` script tag as an AMF0
|
||||
property named ``AIGC`` whose string value is the normative JSON object. Media
|
||||
tag payloads are skipped without being loaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
|
||||
|
||||
_SCRIPT_TAG = 18
|
||||
_MAX_SCRIPT_BYTES = 4 * 1024 * 1024
|
||||
|
||||
|
||||
def _u24(value: bytes) -> int:
|
||||
return int.from_bytes(value, "big")
|
||||
|
||||
|
||||
def _amf0_string(data: bytes, position: int, *, long: bool = False) -> tuple[bytes, int] | None:
|
||||
length_size = 4 if long else 2
|
||||
if position + length_size > len(data):
|
||||
return None
|
||||
length = int.from_bytes(data[position : position + length_size], "big")
|
||||
start = position + length_size
|
||||
end = start + length
|
||||
if end > len(data):
|
||||
return None
|
||||
return data[start:end], end
|
||||
|
||||
|
||||
def _skip_amf0(data: bytes, position: int, depth: int = 0) -> int | None:
|
||||
if position >= len(data) or depth > 8:
|
||||
return None
|
||||
value_type = data[position]
|
||||
position += 1
|
||||
if value_type == 0:
|
||||
return position + 8 if position + 8 <= len(data) else None
|
||||
if value_type == 1:
|
||||
return position + 1 if position + 1 <= len(data) else None
|
||||
if value_type == 2:
|
||||
parsed = _amf0_string(data, position)
|
||||
return parsed[1] if parsed is not None else None
|
||||
if value_type in {5, 6}:
|
||||
return position
|
||||
if value_type == 7:
|
||||
return position + 2 if position + 2 <= len(data) else None
|
||||
if value_type == 11:
|
||||
return position + 10 if position + 10 <= len(data) else None
|
||||
if value_type == 12:
|
||||
parsed = _amf0_string(data, position, long=True)
|
||||
return parsed[1] if parsed is not None else None
|
||||
if value_type == 10:
|
||||
if position + 4 > len(data):
|
||||
return None
|
||||
count = int.from_bytes(data[position : position + 4], "big")
|
||||
position += 4
|
||||
for _ in range(count):
|
||||
next_position = _skip_amf0(data, position, depth + 1)
|
||||
if next_position is None:
|
||||
return None
|
||||
position = next_position
|
||||
return position
|
||||
if value_type in {3, 8}:
|
||||
if value_type == 8:
|
||||
if position + 4 > len(data):
|
||||
return None
|
||||
position += 4
|
||||
while position + 3 <= len(data):
|
||||
name_length = int.from_bytes(data[position : position + 2], "big")
|
||||
position += 2
|
||||
if name_length == 0 and data[position] == 9:
|
||||
return position + 1
|
||||
position += name_length
|
||||
if position > len(data):
|
||||
return None
|
||||
next_position = _skip_amf0(data, position, depth + 1)
|
||||
if next_position is None:
|
||||
return None
|
||||
position = next_position
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _script_payloads(data: bytes) -> tuple[bytes, ...]:
|
||||
first = _amf0_string(data, 1) if data[:1] == b"\x02" else None
|
||||
if first is None or first[0] != b"onMetaData":
|
||||
return ()
|
||||
position = first[1]
|
||||
if position >= len(data) or data[position] not in {3, 8}:
|
||||
return ()
|
||||
if data[position] == 8:
|
||||
position += 5
|
||||
else:
|
||||
position += 1
|
||||
found: list[bytes] = []
|
||||
while position + 3 <= len(data):
|
||||
name_length = int.from_bytes(data[position : position + 2], "big")
|
||||
position += 2
|
||||
if name_length == 0 and data[position] == 9:
|
||||
break
|
||||
name_end = position + name_length
|
||||
if name_end > len(data):
|
||||
break
|
||||
name = data[position:name_end]
|
||||
position = name_end
|
||||
if name == b"AIGC" and position < len(data) and data[position] in {2, 12}:
|
||||
long = data[position] == 12
|
||||
parsed = _amf0_string(data, position + 1, long=long)
|
||||
if parsed is None:
|
||||
break
|
||||
value, position = parsed
|
||||
if len(value) <= MAX_TC260_VALUE_BYTES and parse_tc260_aigc_json(value) is not None:
|
||||
found.append(value)
|
||||
continue
|
||||
next_position = _skip_amf0(data, position)
|
||||
if next_position is None:
|
||||
break
|
||||
position = next_position
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
|
||||
"""Read validated TC260 values from FLV ``script.onMetaData.AIGC``."""
|
||||
found: list[bytes] = []
|
||||
try:
|
||||
with open(path, "rb") as stream:
|
||||
header = stream.read(9)
|
||||
if len(header) != 9 or header[:3] != b"FLV":
|
||||
return ()
|
||||
data_offset = int.from_bytes(header[5:9], "big")
|
||||
stream.seek(0, 2)
|
||||
file_size = stream.tell()
|
||||
position = data_offset + 4
|
||||
while position + 11 <= file_size:
|
||||
stream.seek(position)
|
||||
tag_header = stream.read(11)
|
||||
if len(tag_header) != 11:
|
||||
break
|
||||
tag_type = tag_header[0] & 0x1F
|
||||
data_size = _u24(tag_header[1:4])
|
||||
payload_start = position + 11
|
||||
payload_end = payload_start + data_size
|
||||
if payload_end + 4 > file_size:
|
||||
break
|
||||
if tag_type == _SCRIPT_TAG and data_size <= _MAX_SCRIPT_BYTES:
|
||||
payload = stream.read(data_size)
|
||||
if len(payload) == data_size:
|
||||
found.extend(_script_payloads(payload))
|
||||
if found:
|
||||
return tuple(found)
|
||||
position = payload_end + 4
|
||||
except OSError:
|
||||
return ()
|
||||
return tuple(found)
|
||||
@@ -23,11 +23,10 @@ Reference: ISO/IEC 14496-12 (ISOBMFF) and C2PA 2.1 spec §11.
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any, BinaryIO, cast
|
||||
from typing import TYPE_CHECKING, Any, BinaryIO
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@@ -38,7 +37,8 @@ from remove_ai_watermarks.metadata import (
|
||||
C2PA_UUID,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
TC260_AIGC_FIELDS,
|
||||
MAX_TC260_VALUE_BYTES,
|
||||
parse_tc260_aigc_json,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,14 +61,12 @@ _AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_
|
||||
# blanked in place (see ``blank_ai_xmp_packets``).
|
||||
_XMP_PACKET_RE = re.compile(rb"<\?xpacket begin=.*?<\?xpacket end=[^>]*?\?>", re.DOTALL)
|
||||
|
||||
|
||||
# TC260-PG-20257A stores an MP4/MOV label as an ``AIGC`` key in
|
||||
# ``moov.udta.meta.keys`` and its JSON value in the corresponding
|
||||
# ``moov.udta.meta.ilst`` item. The value is intentionally bounded before it is
|
||||
# read: the normative object is tiny, and a corrupt size must not allocate an
|
||||
# arbitrary amount of memory during an inspection.
|
||||
_MAX_TC260_VALUE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def _iter_top_level_boxes(data: bytes) -> Iterator[tuple[int, int, bytes, int]]:
|
||||
"""Yield ``(start, end, type, payload_offset)`` for each top-level box.
|
||||
|
||||
@@ -181,18 +179,6 @@ def _tc260_key_indices(
|
||||
return found
|
||||
|
||||
|
||||
def _is_tc260_aigc_json(value: bytes) -> bool:
|
||||
"""Require a JSON object carrying at least one normative TC260 field."""
|
||||
try:
|
||||
parsed = json.loads(value.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return False
|
||||
if not isinstance(parsed, dict):
|
||||
return False
|
||||
fields = cast("dict[object, object]", parsed)
|
||||
return bool(TC260_AIGC_FIELDS & {str(key) for key in fields})
|
||||
|
||||
|
||||
def _tc260_aigc_regions(
|
||||
stream: BinaryIO,
|
||||
file_size: int,
|
||||
@@ -249,11 +235,11 @@ def _tc260_aigc_regions(
|
||||
):
|
||||
value_start = data_payload + 8
|
||||
value_size = data_end - value_start
|
||||
if data_type != b"data" or value_size < 0 or value_size > _MAX_TC260_VALUE_BYTES:
|
||||
if data_type != b"data" or value_size < 0 or value_size > MAX_TC260_VALUE_BYTES:
|
||||
continue
|
||||
stream.seek(value_start)
|
||||
value = stream.read(value_size)
|
||||
if len(value) == value_size and _is_tc260_aigc_json(value):
|
||||
if len(value) == value_size and parse_tc260_aigc_json(value) is not None:
|
||||
regions.append((*key_span, value_start, data_end, value))
|
||||
return regions
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Bounded AVI metadata reader for native TC260 AIGC labels.
|
||||
|
||||
TC260-PG-20257A stores the label in an AVI ``LIST/INFO`` chunk whose child
|
||||
chunk ID is ``AIGC`` and whose value is the normative JSON object. The walker
|
||||
seeks over media chunks and reads only bounded metadata values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, BinaryIO
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
|
||||
|
||||
|
||||
def _info_payloads(
|
||||
stream: BinaryIO,
|
||||
start: int,
|
||||
end: int,
|
||||
) -> tuple[bytes, ...]:
|
||||
found: list[bytes] = []
|
||||
position = start
|
||||
while position + 8 <= end:
|
||||
stream.seek(position)
|
||||
chunk_id = stream.read(4)
|
||||
size_raw = stream.read(4)
|
||||
if len(chunk_id) != 4 or len(size_raw) != 4:
|
||||
break
|
||||
size = int.from_bytes(size_raw, "little")
|
||||
payload_start = position + 8
|
||||
payload_end = payload_start + size
|
||||
if payload_end > end:
|
||||
break
|
||||
if chunk_id == b"AIGC" and size <= MAX_TC260_VALUE_BYTES:
|
||||
value = stream.read(size)
|
||||
if len(value) == size and parse_tc260_aigc_json(value) is not None:
|
||||
found.append(value.rstrip(b"\x00 "))
|
||||
position = payload_end + (size & 1)
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
|
||||
"""Read validated TC260 values from an AVI ``LIST/INFO/AIGC`` chunk."""
|
||||
found: list[bytes] = []
|
||||
try:
|
||||
with open(path, "rb") as stream:
|
||||
header = stream.read(12)
|
||||
if len(header) != 12 or header[:4] != b"RIFF" or header[8:12] != b"AVI ":
|
||||
return ()
|
||||
stream.seek(0, 2)
|
||||
file_size = stream.tell()
|
||||
declared_end = min(8 + int.from_bytes(header[4:8], "little"), file_size)
|
||||
position = 12
|
||||
while position + 8 <= declared_end:
|
||||
stream.seek(position)
|
||||
chunk_id = stream.read(4)
|
||||
size_raw = stream.read(4)
|
||||
if len(chunk_id) != 4 or len(size_raw) != 4:
|
||||
break
|
||||
size = int.from_bytes(size_raw, "little")
|
||||
payload_start = position + 8
|
||||
payload_end = payload_start + size
|
||||
if payload_end > declared_end:
|
||||
break
|
||||
if chunk_id == b"LIST" and size >= 4:
|
||||
list_type = stream.read(4)
|
||||
if list_type == b"INFO":
|
||||
found.extend(_info_payloads(stream, payload_start + 4, payload_end))
|
||||
position = payload_end + (size & 1)
|
||||
except OSError:
|
||||
return ()
|
||||
return tuple(found)
|
||||
@@ -1,9 +1,10 @@
|
||||
"""High-level video processing API.
|
||||
|
||||
Supported experimental stages are container-level AI metadata inspection and
|
||||
removal, temporally stabilized visible Sora, Veo, Seedance, and Dola removal,
|
||||
and VAE regeneration that produces an externally verifiable SynthID candidate.
|
||||
The visible pixel path reuses the image package's shared fill backends.
|
||||
removal, temporally stabilized visible Sora, Veo, Seedance, Dola, Hailuo, and
|
||||
Kling removal, and VAE regeneration that produces an externally verifiable
|
||||
SynthID candidate. The visible pixel path reuses the image package's shared
|
||||
fill backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,9 +23,11 @@ from remove_ai_watermarks.video_synthid import (
|
||||
if TYPE_CHECKING:
|
||||
from remove_ai_watermarks.video_invisible import RegenerationMetrics
|
||||
|
||||
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv"})
|
||||
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".flv"})
|
||||
_ISOBMFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v"})
|
||||
_EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"})
|
||||
_RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"})
|
||||
_FLV_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".flv"})
|
||||
_REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS
|
||||
_EBML_MAGIC = b"\x1aE\xdf\xa3"
|
||||
|
||||
@@ -109,8 +112,11 @@ def _video_source(source: str | Path) -> Path:
|
||||
with path.open("rb") as stream:
|
||||
head = stream.read(12)
|
||||
suffix = path.suffix.lower()
|
||||
matches_container = (suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp") or (
|
||||
suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC)
|
||||
matches_container = (
|
||||
(suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp")
|
||||
or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC))
|
||||
or (suffix in _RIFF_VIDEO_EXTENSIONS and len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"AVI ")
|
||||
or (suffix in _FLV_VIDEO_EXTENSIONS and head.startswith(b"FLV"))
|
||||
)
|
||||
if not matches_container:
|
||||
raise ValueError(f"Video content does not match its {suffix} extension: {path}")
|
||||
@@ -182,11 +188,11 @@ def remove_video_visible(
|
||||
) -> VideoVisibleResult:
|
||||
"""Remove a supported visible AI wordmark from a video.
|
||||
|
||||
Supported marks are ``sora``, ``veo``, ``seedance``, and ``dola``. The full
|
||||
sequence is scanned before pixels change, and only recurring candidates are
|
||||
accepted. Audio is copied without re-encoding; video is transcoded because
|
||||
the pixels change. When no stable mark is found, no output is written and
|
||||
``output`` in the result is ``None``.
|
||||
Supported marks are ``sora``, ``veo``, ``seedance``, ``dola``, ``hailuo``,
|
||||
and ``kling``. The full sequence is scanned before pixels change, and only
|
||||
recurring candidates are accepted. Audio is copied without re-encoding;
|
||||
video is transcoded because the pixels change. When no stable mark is found,
|
||||
no output is written and ``output`` in the result is ``None``.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
from remove_ai_watermarks.video_visible import (
|
||||
@@ -195,18 +201,22 @@ def remove_video_visible(
|
||||
has_sora_provenance,
|
||||
has_veo_provenance,
|
||||
scan_dola_video,
|
||||
scan_hailuo_video,
|
||||
scan_kling_video,
|
||||
scan_seedance_video,
|
||||
scan_sora_video,
|
||||
scan_veo_video,
|
||||
stabilize_dola_localizations,
|
||||
stabilize_hailuo_localizations,
|
||||
stabilize_kling_localizations,
|
||||
stabilize_seedance_localizations,
|
||||
stabilize_sora_localizations,
|
||||
stabilize_veo_localizations,
|
||||
)
|
||||
from remove_ai_watermarks.watermark_registry import resolve_backend
|
||||
|
||||
if mark not in {"sora", "veo", "seedance", "dola"}:
|
||||
raise ValueError("Unsupported visible video mark; expected sora, veo, seedance, or dola")
|
||||
if mark not in {"sora", "veo", "seedance", "dola", "hailuo", "kling"}:
|
||||
raise ValueError("Unsupported visible video mark; expected sora, veo, seedance, dola, hailuo, or kling")
|
||||
if backend not in {"auto", "cv2", "migan", "lama"}:
|
||||
raise ValueError("Unsupported fill backend; expected auto, cv2, migan, or lama")
|
||||
|
||||
@@ -237,7 +247,7 @@ def remove_video_visible(
|
||||
)
|
||||
padding_fraction = 0.0
|
||||
mask_style = "box"
|
||||
else:
|
||||
elif mark == "dola":
|
||||
scan = scan_dola_video(source_path)
|
||||
regions = stabilize_dola_localizations(
|
||||
scan.detections,
|
||||
@@ -245,6 +255,16 @@ def remove_video_visible(
|
||||
)
|
||||
padding_fraction = 0.20
|
||||
mask_style = "box"
|
||||
elif mark == "hailuo":
|
||||
scan = scan_hailuo_video(source_path)
|
||||
regions = stabilize_hailuo_localizations(scan.detections)
|
||||
padding_fraction = 0.12
|
||||
mask_style = "box"
|
||||
else:
|
||||
scan = scan_kling_video(source_path)
|
||||
regions = stabilize_kling_localizations(scan.detections)
|
||||
padding_fraction = 0.12
|
||||
mask_style = "box"
|
||||
detected_frames = sum(region is not None for region in regions)
|
||||
if detected_frames == 0:
|
||||
return VideoVisibleResult(
|
||||
|
||||
@@ -4,10 +4,11 @@ Supported marks use fully synthetic silhouettes made from geometric primitives,
|
||||
OpenCV's built-in font, and Pillow's bundled font. Sora detection searches the
|
||||
full frame because the wordmark moves. Veo detection covers both the current
|
||||
four-point diamond and legacy ``Veo`` text. Seedance detects the boxed ``AI``
|
||||
label, while Dola detects its compact text label. A single frame is never enough
|
||||
to authorize removal: the temporal arbiter requires the candidate to recur at
|
||||
the same location across adjacent frames. This keeps isolated lookalikes in
|
||||
clean videos from becoming removal masks.
|
||||
label, Dola detects its compact text label, Hailuo detects the composite
|
||||
MINIMAX/Hailuo label, and Kling detects its version-independent wordmark core.
|
||||
A single frame is never enough to authorize removal: the temporal arbiter
|
||||
requires the candidate to recur at the same location across adjacent frames.
|
||||
This keeps isolated lookalikes in clean videos from becoming removal masks.
|
||||
|
||||
Video pixels are decoded with OpenCV and encoded with the system ``ffmpeg``.
|
||||
Audio is stream-copied from the source. The video stream must be transcoded
|
||||
@@ -64,6 +65,11 @@ _SEEDANCE_STRONG_CONFIDENCE = 0.43
|
||||
_DOLA_PROVENANCE_WEAK_CONFIDENCE = 0.48
|
||||
_DOLA_STRICT_WEAK_CONFIDENCE = 0.50
|
||||
_DOLA_STRONG_CONFIDENCE = 0.52
|
||||
_HAILUO_WEAK_CONFIDENCE = 0.30
|
||||
_HAILUO_STRONG_CONFIDENCE = 0.34
|
||||
_KLING_WEAK_CONFIDENCE = 0.20
|
||||
_KLING_STRONG_CONFIDENCE = 0.24
|
||||
_KLING_MIN_WHITE_FRACTION = 0.02
|
||||
_MIN_STABLE_FRAMES = 5
|
||||
_MIN_VEO_STABLE_FRAMES = 12
|
||||
_MIN_FIXED_MARK_STABLE_FRAMES = 12
|
||||
@@ -76,6 +82,8 @@ _VEO_DIAMOND_PROFILES = (
|
||||
(44, 29, 40),
|
||||
)
|
||||
_DOLA_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(22, 41))
|
||||
_HAILUO_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(28, 56, 3))
|
||||
_KLING_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(24, 49, 3))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -108,6 +116,14 @@ def _scalable_default_font(size: int) -> ImageFont.ImageFont | ImageFont.FreeTyp
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _crop_nonzero(image: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Crop a synthetic template to its nonzero footprint."""
|
||||
ys, xs = np.where(image > 0)
|
||||
if len(xs) == 0:
|
||||
return image
|
||||
return image[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _sora_templates() -> tuple[NDArray[Any], NDArray[Any]]:
|
||||
"""Return synthetic full-wordmark and mascot-only silhouettes.
|
||||
@@ -162,9 +178,7 @@ def _veo_templates() -> tuple[NDArray[Any], NDArray[Any]]:
|
||||
text_canvas = Image.new("L", (140, 60), 0)
|
||||
text_draw = ImageDraw.Draw(text_canvas)
|
||||
text_draw.text((2, 0), "Veo", font=_scalable_default_font(48), fill=255)
|
||||
text = np.asarray(text_canvas, dtype=np.uint8)
|
||||
ys, xs = np.where(text > 0)
|
||||
text = text[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
text = _crop_nonzero(np.asarray(text_canvas, dtype=np.uint8))
|
||||
return np.asarray(diamond_canvas, dtype=np.uint8), text
|
||||
|
||||
|
||||
@@ -201,8 +215,75 @@ def _dola_template() -> NDArray[Any]:
|
||||
3,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
ys, xs = np.where(canvas > 0)
|
||||
return canvas[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
return _crop_nonzero(canvas)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _hailuo_template() -> NDArray[Any]:
|
||||
"""Return a synthetic MINIMAX/Hailuo composite-label silhouette."""
|
||||
canvas = Image.new("L", (680, 112), 0)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# Five symmetric waveform strokes approximate the provider-independent
|
||||
# geometry at the left edge without copying pixels from an export.
|
||||
waveform_heights = (42, 70, 96, 70, 42)
|
||||
center_y = 56
|
||||
for index, height in enumerate(waveform_heights):
|
||||
x = 10 + index * 13
|
||||
draw.rounded_rectangle(
|
||||
(x, center_y - height // 2, x + 5, center_y + height // 2),
|
||||
radius=2,
|
||||
fill=255,
|
||||
)
|
||||
|
||||
font = _scalable_default_font(58)
|
||||
draw.text((84, 18), "MINIMAX", font=font, fill=255, stroke_width=1, stroke_fill=255)
|
||||
draw.rectangle((342, 18, 347, 92), fill=255)
|
||||
|
||||
# The Hailuo symbol is a ring with a small offset highlight.
|
||||
draw.ellipse((370, 20, 446, 96), outline=255, width=12)
|
||||
draw.ellipse((397, 38, 434, 76), fill=255)
|
||||
draw.ellipse((397, 29, 420, 52), fill=0)
|
||||
draw.text((456, 18), "hailuo AI", font=font, fill=255, stroke_width=1, stroke_fill=255)
|
||||
return np.asarray(canvas, dtype=np.uint8)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _kling_templates() -> tuple[NDArray[Any], ...]:
|
||||
"""Return synthetic font variants for the Kling wordmark core."""
|
||||
canvas = Image.new("L", (430, 104), 0)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
draw.text(
|
||||
(2, 12),
|
||||
"KLING AI",
|
||||
font=_scalable_default_font(64),
|
||||
fill=255,
|
||||
stroke_width=1,
|
||||
stroke_fill=255,
|
||||
)
|
||||
templates = [_crop_nonzero(np.asarray(canvas, dtype=np.uint8))]
|
||||
for font in (cv2.FONT_HERSHEY_SIMPLEX, cv2.FONT_HERSHEY_DUPLEX):
|
||||
cv_template = np.zeros((100, 500), dtype=np.uint8)
|
||||
cv2.putText(
|
||||
cv_template,
|
||||
"KLING AI",
|
||||
(2, 72),
|
||||
font,
|
||||
2.2,
|
||||
255,
|
||||
3,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
templates.append(_crop_nonzero(cv_template))
|
||||
return tuple(templates)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _kling_logo_template() -> NDArray[Any]:
|
||||
"""Return a synthetic ring approximation of the Kling swirl."""
|
||||
template = np.zeros((100, 100), dtype=np.uint8)
|
||||
cv2.circle(template, (50, 50), 34, 255, 14, cv2.LINE_AA)
|
||||
return _crop_nonzero(template)
|
||||
|
||||
|
||||
def _top_hat(gray: NDArray[Any]) -> NDArray[Any]:
|
||||
@@ -338,21 +419,43 @@ def _restore_region(
|
||||
return source_x, source_y, source_width, source_height
|
||||
|
||||
|
||||
def _detect_fixed_bottom_right_mark(
|
||||
def _bounded_region(
|
||||
x: int,
|
||||
y: int,
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
frame_width: int,
|
||||
frame_height: int,
|
||||
) -> Region:
|
||||
"""Clip an expanded region to the frame without changing its anchor."""
|
||||
bounded_x = max(0, x)
|
||||
bounded_y = max(0, y)
|
||||
return (
|
||||
bounded_x,
|
||||
bounded_y,
|
||||
min(frame_width - bounded_x, width + x - bounded_x),
|
||||
min(frame_height - bounded_y, height + y - bounded_y),
|
||||
)
|
||||
|
||||
|
||||
def _detect_fixed_mark(
|
||||
image_bgr: NDArray[Any],
|
||||
template: NDArray[Any],
|
||||
*,
|
||||
relative_heights: tuple[float, ...],
|
||||
search_origin: tuple[float, float],
|
||||
kernel_fraction: float,
|
||||
prefer_larger_within: float = 0.0,
|
||||
normalized: tuple[NDArray[Any], float] | None = None,
|
||||
frame_index: int,
|
||||
) -> FrameLocalization:
|
||||
"""Match one fixed bottom-right synthetic mark on a normalized frame."""
|
||||
"""Match one fixed synthetic mark inside a normalized-frame search region."""
|
||||
if image_bgr.size == 0:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
|
||||
frame_height, frame_width = image_bgr.shape[:2]
|
||||
gray, scale = _normalized_gray(image_bgr)
|
||||
gray, scale = normalized if normalized is not None else _normalized_gray(image_bgr)
|
||||
normalized_height, normalized_width = gray.shape[:2]
|
||||
short_side = min(normalized_height, normalized_width)
|
||||
search_x = round(normalized_width * search_origin[0])
|
||||
@@ -363,8 +466,7 @@ def _detect_fixed_bottom_right_mark(
|
||||
normalized_width - search_x,
|
||||
normalized_height - search_y,
|
||||
)
|
||||
best_confidence = 0.0
|
||||
best_region: Region | None = None
|
||||
matches: list[tuple[float, Region]] = []
|
||||
for relative_height in relative_heights:
|
||||
template_height = max(6, round(short_side * relative_height))
|
||||
template_width = max(1, round(template.shape[1] * template_height / template.shape[0]))
|
||||
@@ -379,9 +481,21 @@ def _detect_fixed_bottom_right_mark(
|
||||
region=search_region,
|
||||
kernel_size=max(3, round(template_height * kernel_fraction) | 1),
|
||||
)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_region = candidate
|
||||
if candidate is not None and confidence > 0:
|
||||
matches.append((confidence, candidate))
|
||||
if not matches:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
best_confidence, best_region = max(matches, key=lambda match: match[0])
|
||||
if prefer_larger_within > 0:
|
||||
eligible = [
|
||||
(confidence, candidate)
|
||||
for confidence, candidate in matches
|
||||
if confidence >= best_confidence - prefer_larger_within
|
||||
]
|
||||
best_confidence, best_region = max(
|
||||
eligible,
|
||||
key=lambda match: (match[1][2] * match[1][3], match[0]),
|
||||
)
|
||||
|
||||
return FrameLocalization(
|
||||
frame_index,
|
||||
@@ -397,7 +511,7 @@ def _detect_fixed_bottom_right_mark(
|
||||
|
||||
def detect_seedance_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the strongest fixed Seedance boxed-AI candidate."""
|
||||
return _detect_fixed_bottom_right_mark(
|
||||
return _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_seedance_template(),
|
||||
relative_heights=(0.065, 0.075, 0.085, 0.095, 0.105),
|
||||
@@ -409,7 +523,7 @@ def detect_seedance_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> F
|
||||
|
||||
def detect_dola_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the strongest fixed Dola AI text candidate."""
|
||||
return _detect_fixed_bottom_right_mark(
|
||||
return _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_dola_template(),
|
||||
relative_heights=_DOLA_RELATIVE_HEIGHTS,
|
||||
@@ -419,6 +533,137 @@ def detect_dola_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Frame
|
||||
)
|
||||
|
||||
|
||||
def detect_hailuo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the strongest fixed MINIMAX/Hailuo composite-label candidate."""
|
||||
detection = _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_hailuo_template(),
|
||||
relative_heights=_HAILUO_RELATIVE_HEIGHTS,
|
||||
search_origin=(0.28, 0.76),
|
||||
kernel_fraction=0.18,
|
||||
frame_index=frame_index,
|
||||
)
|
||||
if detection.region is None:
|
||||
return detection
|
||||
frame_width = image_bgr.shape[1]
|
||||
x, y, width, height = detection.region
|
||||
horizontal_padding = round(height * 1.25)
|
||||
region = _bounded_region(
|
||||
x - horizontal_padding,
|
||||
y,
|
||||
width + horizontal_padding * 2,
|
||||
height,
|
||||
frame_width=frame_width,
|
||||
frame_height=image_bgr.shape[0],
|
||||
)
|
||||
return FrameLocalization(
|
||||
frame_index,
|
||||
detection.confidence,
|
||||
region,
|
||||
)
|
||||
|
||||
|
||||
def detect_kling_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the fixed Kling wordmark core and include its version suffix."""
|
||||
if image_bgr.size == 0:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
frame_height, frame_width = image_bgr.shape[:2]
|
||||
normalized = _normalized_gray(image_bgr)
|
||||
expanded: list[FrameLocalization] = []
|
||||
for template in _kling_templates():
|
||||
detection = _detect_fixed_mark(
|
||||
image_bgr,
|
||||
template,
|
||||
relative_heights=_KLING_RELATIVE_HEIGHTS,
|
||||
search_origin=(0.64, 0.84),
|
||||
kernel_fraction=0.18,
|
||||
prefer_larger_within=0.06,
|
||||
normalized=normalized,
|
||||
frame_index=frame_index,
|
||||
)
|
||||
if detection.region is None:
|
||||
continue
|
||||
x, y, width, height = detection.region
|
||||
left_padding = round(height * 1.8)
|
||||
right_padding = round(height * 4.0)
|
||||
vertical_padding = round(height * 0.4)
|
||||
region = _bounded_region(
|
||||
x - left_padding,
|
||||
y - vertical_padding,
|
||||
width + left_padding + right_padding,
|
||||
height + vertical_padding * 2,
|
||||
frame_width=frame_width,
|
||||
frame_height=frame_height,
|
||||
)
|
||||
expanded.append(
|
||||
FrameLocalization(
|
||||
frame_index,
|
||||
detection.confidence,
|
||||
region,
|
||||
)
|
||||
)
|
||||
if not expanded:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
edge_candidates = [
|
||||
candidate
|
||||
for candidate in expanded
|
||||
if candidate.region is not None
|
||||
and candidate.region[0] + candidate.region[2] >= frame_width * 0.96
|
||||
and candidate.region[1] + candidate.region[3] >= frame_height * 0.94
|
||||
]
|
||||
font_candidate = None if not edge_candidates else max(edge_candidates, key=lambda candidate: candidate.confidence)
|
||||
|
||||
logo = _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_kling_logo_template(),
|
||||
relative_heights=tuple(value / 1000 for value in range(20, 61, 3)),
|
||||
search_origin=(0.62, 0.90),
|
||||
kernel_fraction=0.18,
|
||||
prefer_larger_within=0.03,
|
||||
normalized=normalized,
|
||||
frame_index=frame_index,
|
||||
)
|
||||
logo_candidate: FrameLocalization | None = None
|
||||
logo_x: int | None = None
|
||||
if logo.region is not None and logo.confidence >= 0.44:
|
||||
logo_x, logo_y, _, logo_height = logo.region
|
||||
logo_candidate = FrameLocalization(
|
||||
frame_index,
|
||||
logo.confidence,
|
||||
_bounded_region(
|
||||
logo_x - round(logo_height * 0.2),
|
||||
logo_y - round(logo_height * 0.25),
|
||||
round(logo_height * 7.8),
|
||||
round(logo_height * 1.5),
|
||||
frame_width=frame_width,
|
||||
frame_height=frame_height,
|
||||
),
|
||||
)
|
||||
|
||||
best = font_candidate or logo_candidate
|
||||
if (
|
||||
font_candidate is not None
|
||||
and font_candidate.region is not None
|
||||
and logo_candidate is not None
|
||||
and logo_x is not None
|
||||
):
|
||||
font_x, _, font_width, _ = font_candidate.region
|
||||
if logo_x <= font_x + round(font_width * 0.50):
|
||||
best = logo_candidate
|
||||
if best is None or best.region is None:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
x, y, width, height = best.region
|
||||
roi = image_bgr[y : y + height, x : x + width]
|
||||
if roi.ndim == 2:
|
||||
white_fraction = float(np.mean(roi >= 180))
|
||||
else:
|
||||
hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
|
||||
white_fraction = float(np.mean((hsv[:, :, 1] <= 55) & (hsv[:, :, 2] >= 180)))
|
||||
if white_fraction < _KLING_MIN_WHITE_FRACTION:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
return best
|
||||
|
||||
|
||||
def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the strongest current-diamond or legacy-text Veo candidate."""
|
||||
if image_bgr.size == 0:
|
||||
@@ -599,6 +844,38 @@ def stabilize_dola_localizations(
|
||||
)
|
||||
|
||||
|
||||
def stabilize_hailuo_localizations(
|
||||
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
|
||||
) -> list[Region | None]:
|
||||
"""Accept a recurring MINIMAX/Hailuo label at a fixed position."""
|
||||
return _stabilize_localizations(
|
||||
detections,
|
||||
provenance=False,
|
||||
weak_floor=_HAILUO_WEAK_CONFIDENCE,
|
||||
strong_floor=_HAILUO_STRONG_CONFIDENCE,
|
||||
transition_floor=0.28,
|
||||
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
|
||||
cover_after_confirmation=True,
|
||||
anchor_iou=0.80,
|
||||
)
|
||||
|
||||
|
||||
def stabilize_kling_localizations(
|
||||
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
|
||||
) -> list[Region | None]:
|
||||
"""Accept a recurring versioned Kling label at a fixed position."""
|
||||
return _stabilize_localizations(
|
||||
detections,
|
||||
provenance=False,
|
||||
weak_floor=_KLING_WEAK_CONFIDENCE,
|
||||
strong_floor=_KLING_STRONG_CONFIDENCE,
|
||||
transition_floor=0.30,
|
||||
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
|
||||
cover_after_confirmation=True,
|
||||
anchor_iou=0.80,
|
||||
)
|
||||
|
||||
|
||||
def _stabilize_localizations(
|
||||
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
|
||||
*,
|
||||
@@ -734,6 +1011,16 @@ def scan_dola_video(source: Path) -> VideoScan:
|
||||
return _scan_video(source, detect_dola_frame)
|
||||
|
||||
|
||||
def scan_hailuo_video(source: Path) -> VideoScan:
|
||||
"""Decode a video once and collect one untrusted Hailuo candidate per frame."""
|
||||
return _scan_video(source, detect_hailuo_frame)
|
||||
|
||||
|
||||
def scan_kling_video(source: Path) -> VideoScan:
|
||||
"""Decode a video once and collect one untrusted Kling candidate per frame."""
|
||||
return _scan_video(source, detect_kling_frame)
|
||||
|
||||
|
||||
def _mask_for_region(
|
||||
frame_bgr: NDArray[Any],
|
||||
region: Region,
|
||||
|
||||
Reference in New Issue
Block a user