mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-22 00:31:15 +02:00
Release 0.40.1 with Qwen symbol false-positive fix
This commit is contained in:
6 files changed
+76
-7
No files matched your search
@@ -1391,6 +1391,13 @@ The shared text engine handles `千问AI生成`; `QwenEngine` also scans a squar
|
||||
bottom-right region for Qwen Create's three-lobe symbol. Its 0.65 NCC gate was
|
||||
measured against the cleared provider fixture (0.848) and 132 decoded tracked
|
||||
controls other than that positive (maximum 0.516 on 2026-09-10, zero fires).
|
||||
NCC is not portable across OpenCV builds: issue 108 reported 0.81 on
|
||||
`data/fixtures/provenance/chatgpt-1.png` (sand and denim, no glyph) while this
|
||||
tree's OpenCV 4.11 scores 0.437 on the same file. A fire also requires
|
||||
binarized IoU of the bright residual with the three-lobe silhouette at or
|
||||
above 0.70; the true fixture stays at or above 0.75 down to 512 px, and
|
||||
chatgpt-1 sits at 0.55. `provenance_ncc_factor` remains 1.0, so OpenAI C2PA
|
||||
cannot relax this mark.
|
||||
The removal mask reuses the aligned three-lobe alpha and expands it by 6% of
|
||||
the detected side; a solid square was rejected because classical inpainting
|
||||
left a visible rectangular blur.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.40.0"
|
||||
version = "0.40.1"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
|
||||
@@ -33,7 +33,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.40.0"
|
||||
__version__ = "0.40.1"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
|
||||
@@ -109,6 +109,14 @@ _SYMBOL_MARGIN_FRAC = 0.012
|
||||
_SYMBOL_SIZE_FRAC = 0.03125
|
||||
_SYMBOL_LADDER = (0.75, 0.875, 1.0, 1.125, 1.25)
|
||||
_SYMBOL_NCC_THRESHOLD = 0.65
|
||||
# NCC alone is not portable: issue 108 reported 0.81 on
|
||||
# data/fixtures/provenance/chatgpt-1.png (sand/denim, no glyph) while this
|
||||
# OpenCV build scores 0.437 on the same file. True Qwen Create matches keep
|
||||
# binarized IoU >= 0.75 down to 512 px; that fixture sits at 0.55. Require
|
||||
# both the NCC gate and this shape overlap so a high-NCC blob cannot fire.
|
||||
_SYMBOL_SHAPE_IOU_THRESHOLD = 0.70
|
||||
_SYMBOL_TEMPLATE_MASK_FRAC = 0.25
|
||||
_SYMBOL_RESPONSE_MASK_FRAC = 0.35
|
||||
_SYMBOL_MIN_SHORT_SIDE = 256
|
||||
_SYMBOL_MASK_ALPHA = 4 / 255
|
||||
_SYMBOL_MASK_DILATE_FRAC = 0.06
|
||||
@@ -165,6 +173,23 @@ class QwenEngine(TextMarkEngine):
|
||||
self._symbol_template = template
|
||||
self._symbol_template_scales: dict[int, NDArray[Any]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _symbol_shape_iou(response_crop: NDArray[Any], template: NDArray[Any]) -> float:
|
||||
"""Overlap of the bright residual with the three-lobe silhouette."""
|
||||
if response_crop.size == 0 or template.size == 0:
|
||||
return 0.0
|
||||
template_peak = float(template.max())
|
||||
response_peak = float(response_crop.max())
|
||||
if template_peak <= 0.0 or response_peak <= 0.0:
|
||||
return 0.0
|
||||
template_mask = template > (template_peak * _SYMBOL_TEMPLATE_MASK_FRAC)
|
||||
response_mask = response_crop > (response_peak * _SYMBOL_RESPONSE_MASK_FRAC)
|
||||
union = int(np.logical_or(template_mask, response_mask).sum())
|
||||
if union == 0:
|
||||
return 0.0
|
||||
intersection = int(np.logical_and(template_mask, response_mask).sum())
|
||||
return intersection / union
|
||||
|
||||
def _scaled_symbol_template(self, side: int) -> NDArray[Any]:
|
||||
"""Return a cached square symbol template for a measured ladder size."""
|
||||
template = self._symbol_template_scales.get(side)
|
||||
@@ -195,6 +220,7 @@ class QwenEngine(TextMarkEngine):
|
||||
|
||||
best_score = 0.0
|
||||
best_region = (0, 0, 0, 0)
|
||||
best_iou = 0.0
|
||||
for rung in _SYMBOL_LADDER:
|
||||
side = max(12, int(base * _SYMBOL_SIZE_FRAC * rung))
|
||||
if side >= search_side:
|
||||
@@ -202,11 +228,15 @@ class QwenEngine(TextMarkEngine):
|
||||
template = self._scaled_symbol_template(side)
|
||||
scores = cv2.matchTemplate(response, template, cv2.TM_CCOEFF_NORMED)
|
||||
_minimum, score, _min_location, location = cv2.minMaxLoc(scores)
|
||||
if score > best_score:
|
||||
best_score = float(score)
|
||||
best_region = (origin_x + location[0], origin_y + location[1], side, side)
|
||||
ncc = float(score)
|
||||
if ncc <= best_score:
|
||||
continue
|
||||
crop = response[location[1] : location[1] + side, location[0] : location[0] + side]
|
||||
best_score = ncc
|
||||
best_region = (origin_x + location[0], origin_y + location[1], side, side)
|
||||
best_iou = self._symbol_shape_iou(crop, template)
|
||||
det.confidence = best_score
|
||||
det.detected = best_score >= _SYMBOL_NCC_THRESHOLD
|
||||
det.detected = best_score >= _SYMBOL_NCC_THRESHOLD and best_iou >= _SYMBOL_SHAPE_IOU_THRESHOLD
|
||||
det.region = best_region
|
||||
return det
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from remove_ai_watermarks.qwen_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
_LADDER,
|
||||
_SYMBOL_SHAPE_IOU_THRESHOLD,
|
||||
QwenEngine,
|
||||
_alpha_template,
|
||||
_glyph_silhouette,
|
||||
@@ -32,6 +33,7 @@ _BIG_MODE, _SMALL_MODE = 0.203, 0.124
|
||||
_MARGIN = 0.025 # measured right/bottom margin of the real mark
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
_QWEN_CREATE = _ROOT / "data" / "fixtures" / "provenance" / "qwen-create-qwen-image-2.png"
|
||||
_CHATGPT_1 = _ROOT / "data" / "fixtures" / "provenance" / "chatgpt-1.png"
|
||||
|
||||
|
||||
def _compose(w: int, h: int, mode: float = _BIG_MODE, bg: float = 100.0):
|
||||
@@ -146,6 +148,26 @@ class TestDetect:
|
||||
assert y > image.shape[0] * 0.90
|
||||
assert width == height
|
||||
|
||||
def test_chatgpt_1_fixture_does_not_fire_qwen(self):
|
||||
# Issue 108: 0.40.0 reported visible_qwen 0.81 on this OpenAI C2PA
|
||||
# beach photo and auto-inpainted sand. NCC on the three-lobe template
|
||||
# is OpenCV-sensitive; the shape overlap must still refuse it.
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
image = imread(_CHATGPT_1)
|
||||
assert image is not None
|
||||
det = QwenEngine().detect(image)
|
||||
assert not det.detected
|
||||
record = identify(_CHATGPT_1, check_visible=True, check_invisible=False)
|
||||
assert all(signal.name != "visible_qwen" for signal in record.signals)
|
||||
|
||||
def test_symbol_shape_iou_rejects_a_solid_blob(self):
|
||||
template = QwenEngine()._scaled_symbol_template(32)
|
||||
blob = np.full_like(template, float(template.max()))
|
||||
assert QwenEngine._symbol_shape_iou(blob, template) < _SYMBOL_SHAPE_IOU_THRESHOLD
|
||||
assert QwenEngine._symbol_shape_iou(template, template) >= _SYMBOL_SHAPE_IOU_THRESHOLD
|
||||
|
||||
def test_unaccepted_symbol_candidate_cannot_hide_detected_text(self):
|
||||
text_detection = TextMarkDetection(detected=True, confidence=0.45)
|
||||
symbol_detection = TextMarkDetection(detected=False, confidence=0.46)
|
||||
@@ -176,6 +198,16 @@ class TestFootprintMaskAndRemoval:
|
||||
clean = cv2.GaussianBlur(np.full((640, 853, 3), 120, np.uint8), (5, 5), 0)
|
||||
assert QwenEngine().footprint_mask(clean, force=False) is None
|
||||
|
||||
def test_chatgpt_1_auto_remove_does_not_touch_pixels(self):
|
||||
from remove_ai_watermarks.api import remove_visible_detailed
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
image = imread(_CHATGPT_1)
|
||||
assert image is not None
|
||||
report = remove_visible_detailed(image, sensitivity="auto")
|
||||
assert report.status == "no_watermark"
|
||||
assert np.array_equal(report.image, image)
|
||||
|
||||
def test_removes_qwen_create_symbol(self):
|
||||
image = cv2.imread(str(_QWEN_CREATE), cv2.IMREAD_COLOR)
|
||||
assert image is not None
|
||||
|
||||
Reference in new issue
Block a user