Release 0.40.1 with Qwen symbol false-positive fix

This commit is contained in:
Victor Kuznetsov committed 2026-09-11 10:55:28 -07:00
1 parent b2e46ee8db
commit df94f2c908
6 files changed
+76 -7

No files matched your search

+7
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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",
+34 -4
View File
@@ -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
+32
View File
@@ -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
Generated
+1 -1
View File
@@ -3449,7 +3449,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.40.0"
version = "0.40.1"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },