Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,12 @@ This package is small, but a few things in it are load-bearing in ways that are
between the two copies always means the contract changed. It is excluded from `ruff format` for the same reason. If it
fails, fix the engine.

**The labels in `src/guard_local/models.py` are a public API.** The client derives each result's task id from
**The labels in `src/guard_local/targets.py` are a public API.** The client derives each result's task id from
`uuid5(namespace, label)`, so renaming a label silently changes the id that callers key on. They also mirror the tasks
the cloud API seeds, which is what lets someone test locally and then route to the cloud unchanged.

**The preprocessing must match the browser extension.** Both run the same ONNX file, and the scores only agree because
the transform agrees down to the rounding. Before touching `src/guard_local/media_utils.py`, read
the transform agrees down to the rounding. Before touching `src/guard_local/sources/model/transform.py`, read
`src/lib/localModel/runner.ts` in `guard-browser-extension`. If a change is intentional, re-record the expected values
in `test_golden_scores_for_a_deterministic_image` and say in the PR why the scores moved.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ for item in results:
print(f"{item['label']}: {item['score']:.2f}")

# AI-Generated: 0.90
# Violence: 0.02
# Violent: 0.02
# Explicit: 0.01
```

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "guard-local-detector"
version = "0.0.1"
version = "0.0.2"
description = "A local detection engine for the Guard Python client, integrating visual safety filters into your applications"
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -45,7 +45,7 @@ dependencies = [
"pillow>=10.4.0",
"pillow-heif>=1.0.0",
"av>=14.0.0",
"c2pa-python>=0.37.0",
"c2pa-python>=0.38.0",
]

[project.urls]
Expand Down
6 changes: 3 additions & 3 deletions src/guard_local/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
ModelLoadError,
UnsupportedMediaError,
)
from .tasks import TASKS, Task
from .targets import TARGETS, Target

try:
__version__ = version("guard-local-detector")
Expand All @@ -44,15 +44,15 @@

__all__ = [
"DETECTION_THRESHOLDS",
"TASKS",
"TARGETS",
"CategoryResult",
"GuardLocalError",
"LocalDetectorEngine",
"MediaDecodeError",
"ModelLoadError",
"Signal",
"SignalMatch",
"Task",
"Target",
"UnsupportedMediaError",
"__version__",
"passes_threshold",
Expand Down
2 changes: 1 addition & 1 deletion src/guard_local/detection/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class Signal:
Attributes:
id: A stable identifier that is unique across every layer. Callers
may branch on this, making it as much of a public API as the
task labels.
target labels.
category: The category this signal evaluates.
label: A short plain text name.
description: An explanation of what the signal means and why it
Expand Down
24 changes: 12 additions & 12 deletions src/guard_local/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from .exceptions import UnsupportedMediaError
from .sources import C2paSource, MetadataSource, ModelSource, Source
from .sources.model import AGGREGATORS
from .tasks import SUPPORTED_MEDIA_TYPES, TASKS, Task
from .targets import SUPPORTED_MEDIA_TYPES, TARGETS, Target

__all__ = ["LocalDetectorEngine"]

Expand Down Expand Up @@ -128,7 +128,7 @@ def __init__(

def analyze(self, data: bytes, media_type: str) -> List[Dict[str, Any]]:
"""
Score media against every detection task.
Score media against every detection target.

Args:
data: The raw media bytes to analyze. Still images are scored as a
Expand All @@ -137,9 +137,9 @@ def analyze(self, data: bytes, media_type: str) -> List[Dict[str, Any]]:
media_type: The MIME type of the media.

Returns:
A list of results with one entry per task, always returned in the
A list of results with one entry per target, always returned in the
same order. Each entry contains a `label`, a `score` representing a
probability from 0.0 to 1.0, a `description`, whether the task was
probability from 0.0 to 1.0, a `description`, whether the target was
`detected`, and the `matches` that argued for it.

Raises:
Expand All @@ -159,17 +159,17 @@ def analyze(self, data: bytes, media_type: str) -> List[Dict[str, Any]]:
source.analyze(data, media_type) for source in self.sources
)

return [self._report(task, found.get(task.category)) for task in TASKS]
return [self._report(target, found.get(target.category)) for target in TARGETS]

@staticmethod
def _report(task: Task, evidence: Optional[CategoryResult]) -> Dict[str, Any]:
def _report(target: Target, evidence: Optional[CategoryResult]) -> Dict[str, Any]:
"""
Phrase the merged evidence of one category as a task result.
Phrase the merged evidence of one category as a target result.

Args:
task: The task being reported.
target: The target being reported.
evidence: What the sources found, or `None` when no source spoke to this
task's category.
target's category.

Returns:
One result entry with the `score` constrained to the unit interval.
Expand All @@ -181,9 +181,9 @@ def _report(task: Task, evidence: Optional[CategoryResult]) -> Dict[str, Any]:
matches = evidence.matches if evidence else []

return {
"label": task.label,
"label": target.label,
"score": min(1.0, max(0.0, confidence / 100.0)),
"description": task.description,
"detected": passes_threshold(task.category, confidence),
"description": target.description,
"detected": passes_threshold(target.category, confidence),
"matches": [match.to_dict() for match in matches],
}
2 changes: 1 addition & 1 deletion src/guard_local/sources/metadata/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import pillow_heif
from PIL import ExifTags, Image, ImageFile, IptcImagePlugin

from guard_local.tasks import VIDEO_MEDIA_TYPES
from guard_local.targets import VIDEO_MEDIA_TYPES

__all__ = ["RawImageMetadata", "extract_image_metadata"]

Expand Down
2 changes: 1 addition & 1 deletion src/guard_local/sources/model/decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
MediaDecodeError,
UnsupportedMediaError,
)
from guard_local.tasks import (
from guard_local.targets import (
IMAGE_MEDIA_TYPES,
SUPPORTED_MEDIA_TYPES,
VIDEO_MEDIA_TYPES,
Expand Down
22 changes: 11 additions & 11 deletions src/guard_local/sources/model/source.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Scores pixels with the bundled multi-task vision model.

The model is a FastViT-T8 with one head per task. Because each head is trained with
The model is a FastViT-T8 with one head per target. Because each head is trained with
`BCEWithLogits`, every output is an unbounded logit that requires a sigmoid activation
to represent a probability. This is the only source that has an opinion about all three
categories and the only one that must decode the media first. It is also the only source
Expand All @@ -18,7 +18,7 @@
import numpy as np

from guard_local.detection import CategoryResult, SignalMatch, passes_threshold
from guard_local.tasks import TASKS, VIDEO_MEDIA_TYPES
from guard_local.targets import TARGETS, VIDEO_MEDIA_TYPES

from .decode import load_frames
from .session import ModelSession
Expand Down Expand Up @@ -78,15 +78,15 @@ def __init__(

def analyze(self, data: bytes, media_type: str) -> Dict[str, CategoryResult]:
"""
Decode the media and score every frame against every task.
Decode the media and score every frame against every target.

Args:
data: The raw media bytes.
media_type: The MIME type of the media.

Returns:
One result per category. This always returns all three categories because
the model has an opinion about every task regardless of how confident it is.
the model has an opinion on every target regardless of how confident it is.

Raises:
ModelLoadError: If the model could not be found or loaded.
Expand All @@ -98,37 +98,37 @@ def analyze(self, data: bytes, media_type: str) -> Dict[str, CategoryResult]:
frames = load_frames(data, media_type, max_frames=max_frames)

session, input_name = self.session.ensure()
outputs = [task.output for task in TASKS]
outputs = [target.output for target in TARGETS]
per_frame = [
session.run(outputs, {input_name: to_tensor(frame)}) for frame in frames
]

results: Dict[str, CategoryResult] = {}
for index, task in enumerate(TASKS):
for index, target in enumerate(TARGETS):
score = self._aggregate(
[
_sigmoid(float(np.asarray(result[index]).reshape(-1)[0]))
for result in per_frame
]
)
confidence = score * 100.0
results[task.category] = CategoryResult(
detected=passes_threshold(task.category, confidence),
results[target.category] = CategoryResult(
detected=passes_threshold(target.category, confidence),
# Kept as the probability the model produced rather than the whole
# percent the match displays. The browser extension rounds before
# comparing sources; comparing at full precision picks the same winner
# and spares the caller a rounding it never asked for.
confidence=confidence,
matches=[self._match(task.category, score)],
matches=[self._match(target.category, score)],
)
return results

def _aggregate(self, scores: Sequence[float]) -> float:
"""
Combine the per-frame scores of a single task into the final model score.
Combine the per-frame scores of a single target into the final model score.

Args:
scores: The probabilities calculated for every frame on a single task.
scores: The probabilities calculated for every frame on a single target.

Returns:
A probability between 0.0 and 1.0.
Expand Down
32 changes: 16 additions & 16 deletions src/guard_local/tasks.py → src/guard_local/targets.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""
The detection tasks and media types mirroring the vocabulary of the cloud API.
The detection targets and media types mirroring the vocabulary of the cloud API.

Labels in this module act as a public API. The Guard client assigns local results a task
ID of `uuid5(namespace, label)`. Because of this, renaming a label will silently change
the ID that callers rely on. These labels match the exact tasks seeded by the backend.
This consistency allows developers to test against the local engine and then transition
to the cloud without writing conditional logic for which engine ran.
Labels in this module act as a public API. The Guard client assigns local results a
target ID of `uuid5(namespace, label)`. Because of this, renaming a label will silently
change the ID that callers rely on. These labels match the exact tasks seeded by the
backend. This consistency allows developers to test against the local engine and then
transition to the cloud without writing conditional logic for which engine ran.
"""

from __future__ import annotations
Expand All @@ -18,22 +18,22 @@
__all__ = [
"IMAGE_MEDIA_TYPES",
"SUPPORTED_MEDIA_TYPES",
"TASKS",
"TARGETS",
"VIDEO_MEDIA_TYPES",
"Task",
"Target",
]


@dataclass(frozen=True)
class Task:
class Target:
"""
One detection head of the model sharing the same name as the cloud API.

Attributes:
label: The plain text detection name and the seed used for the task ID.
label: The plain text detection name and the seed used for the target ID.
description: The detailed explanation returned by the cloud API.
output: The name of the ONNX graph output carrying the logit for this head.
category: The detection category this task reports, matched to the naming
category: The detection category this target reports, matched to the naming
convention of the metadata and C2PA layers. This is the only place
where the vocabulary of the cloud API and the signal vocabulary are
tied together.
Expand All @@ -46,20 +46,20 @@ class Task:


#: The three heads in the exact order results are always reported.
TASKS: Tuple[Task, ...] = (
Task(
TARGETS: Tuple[Target, ...] = (
Target(
label="AI-Generated",
description="Detect AI-generated or manipulated media",
output="out_ai",
category=AI_GENERATED,
),
Task(
label="Violence",
Target(
label="Violent",
description="Detect violent media",
output="out_violence",
category=VIOLENT,
),
Task(
Target(
label="Explicit",
description="Detect sexually explicit media",
output="out_nsfw",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def test_labels_are_stable_across_calls() -> None:
"""
Ensure labels remain entirely stable across multiple calls.

Local task IDs are generated using a UUID5 hash of the label. Because
Local target IDs are generated using a UUID5 hash of the label. Because
of this, silently changing a label would inadvertently change the ID.
"""
def labels(raw: Union[Dict[str, Any], List[Dict[str, Any]]]) -> List[str]:
Expand Down
Loading
Loading