openmoderate 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openmoderate/__init__.py +43 -0
- openmoderate/categories.py +203 -0
- openmoderate/cli.py +390 -0
- openmoderate/config.py +100 -0
- openmoderate/engine/__init__.py +4 -0
- openmoderate/engine/base.py +19 -0
- openmoderate/engine/clip_engine.py +83 -0
- openmoderate/integrations/__init__.py +26 -0
- openmoderate/integrations/_common.py +176 -0
- openmoderate/integrations/django.py +189 -0
- openmoderate/integrations/fastapi.py +172 -0
- openmoderate/integrations/flask.py +149 -0
- openmoderate/io.py +115 -0
- openmoderate/moderator.py +274 -0
- openmoderate/py.typed +0 -0
- openmoderate/result.py +58 -0
- openmoderate/server/__init__.py +1 -0
- openmoderate/server/app.py +257 -0
- openmoderate/server/fetch.py +86 -0
- openmoderate/training/__init__.py +12 -0
- openmoderate/training/dataset.py +93 -0
- openmoderate/training/head.py +82 -0
- openmoderate/training/trainer.py +148 -0
- openmoderate/video.py +326 -0
- openmoderate-1.0.1.dist-info/METADATA +164 -0
- openmoderate-1.0.1.dist-info/RECORD +29 -0
- openmoderate-1.0.1.dist-info/WHEEL +4 -0
- openmoderate-1.0.1.dist-info/entry_points.txt +2 -0
- openmoderate-1.0.1.dist-info/licenses/LICENSE +202 -0
openmoderate/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""openmoderate — open-source image and video content moderation.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from openmoderate import Moderator
|
|
7
|
+
|
|
8
|
+
result = Moderator().analyze("upload.jpg")
|
|
9
|
+
if not result.is_safe:
|
|
10
|
+
print(result.violations)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Everything importable from this top-level package is public API and follows semantic
|
|
14
|
+
versioning. Submodules that are not re-exported here (and names starting with ``_``) may
|
|
15
|
+
change in minor releases.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from openmoderate.categories import DEFAULT_CATEGORIES, SAFE_PROMPTS, Category
|
|
19
|
+
from openmoderate.config import ModerationConfig
|
|
20
|
+
from openmoderate.io import ImageLoadError
|
|
21
|
+
from openmoderate.moderator import LabelSpec, Moderator
|
|
22
|
+
from openmoderate.result import Label, ModerationResult
|
|
23
|
+
from openmoderate.training.head import LinearHead
|
|
24
|
+
from openmoderate.video import VideoLoadError, VideoModerationResult, VideoSegment
|
|
25
|
+
|
|
26
|
+
__version__ = "1.0.1"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DEFAULT_CATEGORIES",
|
|
30
|
+
"SAFE_PROMPTS",
|
|
31
|
+
"Category",
|
|
32
|
+
"ImageLoadError",
|
|
33
|
+
"Label",
|
|
34
|
+
"LabelSpec",
|
|
35
|
+
"LinearHead",
|
|
36
|
+
"ModerationConfig",
|
|
37
|
+
"ModerationResult",
|
|
38
|
+
"Moderator",
|
|
39
|
+
"VideoLoadError",
|
|
40
|
+
"VideoModerationResult",
|
|
41
|
+
"VideoSegment",
|
|
42
|
+
"__version__",
|
|
43
|
+
]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Default moderation taxonomy.
|
|
2
|
+
|
|
3
|
+
Each category is described by natural-language prompts. The zero-shot engine compares an
|
|
4
|
+
image against every category's prompts *and* a pool of neutral "safe" prompts, so a
|
|
5
|
+
category only scores high when it explains the image better than ordinary content does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Category:
|
|
15
|
+
name: str
|
|
16
|
+
prompts: tuple[str, ...]
|
|
17
|
+
threshold: float = 0.5
|
|
18
|
+
parent: str | None = None
|
|
19
|
+
description: str = ""
|
|
20
|
+
# Category-specific contrast prompts: benign look-alikes that should *not* count
|
|
21
|
+
# for this category but may legitimately count for another (e.g. cigarettes are
|
|
22
|
+
# tobacco, not drugs).
|
|
23
|
+
negatives: tuple[str, ...] = ()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SAFE_PROMPTS: tuple[str, ...] = (
|
|
27
|
+
"a photo of a person wearing normal clothes",
|
|
28
|
+
"a photo of people at work or in a meeting",
|
|
29
|
+
"a portrait photo of a smiling person",
|
|
30
|
+
"a photo of a landscape or nature scene",
|
|
31
|
+
"a photo of a city street",
|
|
32
|
+
"a photo of food on a plate",
|
|
33
|
+
"a photo of an animal or pet",
|
|
34
|
+
"a photo of a building or room interior",
|
|
35
|
+
"a photo of everyday objects on a table",
|
|
36
|
+
"a screenshot of a document or website",
|
|
37
|
+
"a product photo on a white background",
|
|
38
|
+
"a photo of people playing sports",
|
|
39
|
+
"a family photo",
|
|
40
|
+
"a cartoon or illustration for children",
|
|
41
|
+
"a person at the beach wearing a swimsuit",
|
|
42
|
+
# Look-alikes: benign content that resembles a violating category. Without these the
|
|
43
|
+
# engine confuses national flags with hate symbols, kitchen knives with weapons, etc.
|
|
44
|
+
"the national flag of a country",
|
|
45
|
+
"a company logo or brand icon",
|
|
46
|
+
"abstract shapes and a simple geometric graphic",
|
|
47
|
+
"clipart, an icon or an emoji",
|
|
48
|
+
"kitchen knives and cooking utensils",
|
|
49
|
+
"a medical photo of prescription medicine",
|
|
50
|
+
"a doctor or nurse in a hospital",
|
|
51
|
+
"a soldier or police officer in uniform at a parade",
|
|
52
|
+
"people dancing at a party or concert",
|
|
53
|
+
"a red painting or red colored object",
|
|
54
|
+
"a glass of milk",
|
|
55
|
+
"a glass of orange juice or soft drink",
|
|
56
|
+
"a cup of coffee or tea",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
DEFAULT_CATEGORIES: tuple[Category, ...] = (
|
|
60
|
+
Category(
|
|
61
|
+
"nudity",
|
|
62
|
+
(
|
|
63
|
+
"a photo of a naked person",
|
|
64
|
+
"a nude photo showing genitals or bare breasts",
|
|
65
|
+
"explicit nudity",
|
|
66
|
+
),
|
|
67
|
+
threshold=0.5,
|
|
68
|
+
parent="explicit",
|
|
69
|
+
description="Exposed genitals, buttocks or female nipples.",
|
|
70
|
+
),
|
|
71
|
+
Category(
|
|
72
|
+
"sexual_activity",
|
|
73
|
+
(
|
|
74
|
+
"a pornographic image of people having sex",
|
|
75
|
+
"an explicit sexual act",
|
|
76
|
+
"hardcore pornography",
|
|
77
|
+
),
|
|
78
|
+
threshold=0.5,
|
|
79
|
+
parent="explicit",
|
|
80
|
+
description="Depictions of sexual acts.",
|
|
81
|
+
),
|
|
82
|
+
Category(
|
|
83
|
+
"suggestive",
|
|
84
|
+
(
|
|
85
|
+
"a sexually suggestive photo of a person in lingerie",
|
|
86
|
+
"an erotic provocative pose",
|
|
87
|
+
),
|
|
88
|
+
threshold=0.6,
|
|
89
|
+
parent="suggestive",
|
|
90
|
+
description="Revealing clothing or sexually suggestive poses without explicit nudity.",
|
|
91
|
+
),
|
|
92
|
+
Category(
|
|
93
|
+
"violence",
|
|
94
|
+
(
|
|
95
|
+
"a photo of people fighting violently",
|
|
96
|
+
"a person being beaten or attacked",
|
|
97
|
+
"a violent assault",
|
|
98
|
+
),
|
|
99
|
+
threshold=0.5,
|
|
100
|
+
parent="violence",
|
|
101
|
+
description="Physical violence between people.",
|
|
102
|
+
negatives=("a person holding a gun at a shooting range", "a sports match"),
|
|
103
|
+
),
|
|
104
|
+
Category(
|
|
105
|
+
"gore",
|
|
106
|
+
(
|
|
107
|
+
"a graphic photo of blood and gore",
|
|
108
|
+
"a mutilated dead body",
|
|
109
|
+
"a gruesome bloody wound",
|
|
110
|
+
),
|
|
111
|
+
threshold=0.45,
|
|
112
|
+
parent="violence",
|
|
113
|
+
description="Graphic blood, injuries, corpses.",
|
|
114
|
+
),
|
|
115
|
+
Category(
|
|
116
|
+
"weapons",
|
|
117
|
+
(
|
|
118
|
+
"a photo of a gun",
|
|
119
|
+
"a person holding a firearm or rifle",
|
|
120
|
+
"a photo of a knife used as a weapon",
|
|
121
|
+
"a revolver or pistol",
|
|
122
|
+
),
|
|
123
|
+
threshold=0.5,
|
|
124
|
+
parent="violence",
|
|
125
|
+
description="Firearms, knives and other weapons.",
|
|
126
|
+
negatives=("a kitchen knife for cooking food",),
|
|
127
|
+
),
|
|
128
|
+
Category(
|
|
129
|
+
"hate_symbols",
|
|
130
|
+
(
|
|
131
|
+
"a nazi swastika flag",
|
|
132
|
+
"a hate symbol or extremist flag",
|
|
133
|
+
"a ku klux klan hood and robe",
|
|
134
|
+
"a nazi swastika armband or banner",
|
|
135
|
+
"people wearing ku klux klan robes and pointed hoods",
|
|
136
|
+
"white supremacist propaganda",
|
|
137
|
+
),
|
|
138
|
+
threshold=0.5,
|
|
139
|
+
parent="hate",
|
|
140
|
+
description="Symbols and imagery associated with hate groups.",
|
|
141
|
+
),
|
|
142
|
+
Category(
|
|
143
|
+
"drugs",
|
|
144
|
+
(
|
|
145
|
+
"a photo of illegal drugs",
|
|
146
|
+
"cocaine lines on a table",
|
|
147
|
+
"a person injecting drugs with a needle",
|
|
148
|
+
"pills and drug paraphernalia",
|
|
149
|
+
"a photo of marijuana buds",
|
|
150
|
+
"a rolled cannabis joint",
|
|
151
|
+
"a glass bong for smoking marijuana",
|
|
152
|
+
"a bag of white powder drugs",
|
|
153
|
+
"a person smoking marijuana",
|
|
154
|
+
),
|
|
155
|
+
threshold=0.5,
|
|
156
|
+
parent="drugs",
|
|
157
|
+
description="Illegal drugs and drug use.",
|
|
158
|
+
negatives=("a person smoking a cigarette", "a vaccination or medical injection"),
|
|
159
|
+
),
|
|
160
|
+
Category(
|
|
161
|
+
"self_harm",
|
|
162
|
+
(
|
|
163
|
+
"a photo of self harm cuts on a wrist",
|
|
164
|
+
"a person attempting suicide",
|
|
165
|
+
),
|
|
166
|
+
threshold=0.45,
|
|
167
|
+
parent="self_harm",
|
|
168
|
+
description="Self-injury or suicide imagery.",
|
|
169
|
+
),
|
|
170
|
+
Category(
|
|
171
|
+
"alcohol",
|
|
172
|
+
(
|
|
173
|
+
"a photo of alcoholic drinks and beer bottles",
|
|
174
|
+
"a person drinking alcohol",
|
|
175
|
+
"a glass of beer",
|
|
176
|
+
"a glass of red wine",
|
|
177
|
+
"a cocktail drink in a glass",
|
|
178
|
+
"a bottle of whisky or liquor",
|
|
179
|
+
),
|
|
180
|
+
threshold=0.6,
|
|
181
|
+
parent="substances",
|
|
182
|
+
description="Alcoholic beverages (often allowed; off by default via high threshold).",
|
|
183
|
+
),
|
|
184
|
+
Category(
|
|
185
|
+
"tobacco",
|
|
186
|
+
(
|
|
187
|
+
"a person smoking a cigarette",
|
|
188
|
+
"a photo of cigarettes or vaping",
|
|
189
|
+
"a cigar",
|
|
190
|
+
"a pack of cigarettes",
|
|
191
|
+
),
|
|
192
|
+
threshold=0.6,
|
|
193
|
+
parent="substances",
|
|
194
|
+
description="Tobacco and vaping products.",
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_category(name: str, categories: tuple[Category, ...] = DEFAULT_CATEGORIES) -> Category:
|
|
200
|
+
for category in categories:
|
|
201
|
+
if category.name == name:
|
|
202
|
+
return category
|
|
203
|
+
raise KeyError(f"Unknown category: {name!r}")
|
openmoderate/cli.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""``openmoderate`` command-line interface.
|
|
2
|
+
|
|
3
|
+
openmoderate scan ./uploads [--json] [--fail-on-violation]
|
|
4
|
+
openmoderate train ./dataset -o my_head.omh
|
|
5
|
+
openmoderate evaluate ./dataset [--head my_head.omh]
|
|
6
|
+
openmoderate categories
|
|
7
|
+
openmoderate download
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from openmoderate import __version__
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_thresholds(items: list[str] | None) -> dict[str, float]:
|
|
22
|
+
out = {}
|
|
23
|
+
for item in items or []:
|
|
24
|
+
name, sep, value = item.partition("=")
|
|
25
|
+
if not sep:
|
|
26
|
+
raise SystemExit(f"--threshold expects NAME=VALUE, got {item!r}")
|
|
27
|
+
out[name.strip()] = float(value)
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _add_policy_args(p: argparse.ArgumentParser) -> None:
|
|
32
|
+
p.add_argument("--config", help="Policy YAML file")
|
|
33
|
+
p.add_argument("--head", help="Trained head (.omh) from `openmoderate train`")
|
|
34
|
+
p.add_argument(
|
|
35
|
+
"--head-weight", type=float, default=1.0, help="Blend for labels in both (default 1.0)"
|
|
36
|
+
)
|
|
37
|
+
p.add_argument(
|
|
38
|
+
"--threshold", "-t", action="append", metavar="NAME=VALUE", help="Override a threshold"
|
|
39
|
+
)
|
|
40
|
+
p.add_argument("--only", nargs="+", metavar="CATEGORY", help="Only check these categories")
|
|
41
|
+
p.add_argument("--exclude", nargs="+", metavar="CATEGORY", help="Skip these categories")
|
|
42
|
+
p.add_argument("--device", default="auto", help="auto, cpu, cuda or mps")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_moderator(args: argparse.Namespace):
|
|
46
|
+
from openmoderate import Moderator
|
|
47
|
+
|
|
48
|
+
return Moderator(
|
|
49
|
+
args.config,
|
|
50
|
+
thresholds=_parse_thresholds(args.threshold),
|
|
51
|
+
categories=args.only,
|
|
52
|
+
exclude=args.exclude,
|
|
53
|
+
head=args.head,
|
|
54
|
+
head_weight=args.head_weight,
|
|
55
|
+
device=args.device,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
60
|
+
from openmoderate.io import ImageLoadError, iter_image_files
|
|
61
|
+
from openmoderate.video import is_video_path
|
|
62
|
+
|
|
63
|
+
sources: list[str] = []
|
|
64
|
+
videos: list[str] = []
|
|
65
|
+
for target in args.paths:
|
|
66
|
+
if target.startswith(("http://", "https://")):
|
|
67
|
+
sources.append(target)
|
|
68
|
+
continue
|
|
69
|
+
path = Path(target)
|
|
70
|
+
if path.is_file():
|
|
71
|
+
(videos if is_video_path(path) else sources).append(str(path))
|
|
72
|
+
continue
|
|
73
|
+
found_images = [str(f) for f in iter_image_files(path)]
|
|
74
|
+
found_videos = sorted(str(f) for f in path.rglob("*") if is_video_path(f))
|
|
75
|
+
if not found_images and not found_videos:
|
|
76
|
+
print(f"warning: no images or videos found at {target}", file=sys.stderr)
|
|
77
|
+
sources.extend(found_images)
|
|
78
|
+
videos.extend(found_videos)
|
|
79
|
+
|
|
80
|
+
mod = _build_moderator(args)
|
|
81
|
+
flagged = errors = 0
|
|
82
|
+
for video in videos:
|
|
83
|
+
try:
|
|
84
|
+
res = mod.analyze_video(
|
|
85
|
+
video, fps=args.fps, max_duration=args.max_duration, min_frames=args.min_frames
|
|
86
|
+
)
|
|
87
|
+
except (ImportError, ValueError) as exc:
|
|
88
|
+
errors += 1
|
|
89
|
+
_emit_error(args, video, exc)
|
|
90
|
+
continue
|
|
91
|
+
flagged += not res.is_safe
|
|
92
|
+
_emit_video_result(args, res)
|
|
93
|
+
for i in range(0, len(sources), args.batch_size):
|
|
94
|
+
chunk = sources[i : i + args.batch_size]
|
|
95
|
+
try:
|
|
96
|
+
results = mod.analyze_batch(chunk)
|
|
97
|
+
except ImageLoadError:
|
|
98
|
+
# Fall back to one-by-one so a single bad file doesn't sink the batch.
|
|
99
|
+
results = []
|
|
100
|
+
for src in chunk:
|
|
101
|
+
try:
|
|
102
|
+
results.append(mod.analyze(src))
|
|
103
|
+
except ImageLoadError as exc:
|
|
104
|
+
errors += 1
|
|
105
|
+
_emit_error(args, src, exc)
|
|
106
|
+
for res in results:
|
|
107
|
+
flagged += not res.is_safe
|
|
108
|
+
_emit_result(args, res)
|
|
109
|
+
|
|
110
|
+
if not args.json:
|
|
111
|
+
total = len(sources) + len(videos)
|
|
112
|
+
print(f"\n{total} scanned, {flagged} flagged, {errors} unreadable", file=sys.stderr)
|
|
113
|
+
if args.fail_on_violation and flagged:
|
|
114
|
+
return 1
|
|
115
|
+
return 2 if errors else 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _emit_result(args: argparse.Namespace, res) -> None:
|
|
119
|
+
if args.json:
|
|
120
|
+
print(json.dumps(res.to_dict()))
|
|
121
|
+
return
|
|
122
|
+
verdict = "SAFE " if res.is_safe else "FLAGGED"
|
|
123
|
+
details = ", ".join(f"{v.name} {v.confidence:.0%}" for v in res.violations)
|
|
124
|
+
print(f"{verdict} {res.source} {details}".rstrip())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _emit_video_result(args: argparse.Namespace, res) -> None:
|
|
128
|
+
if args.json:
|
|
129
|
+
print(json.dumps(res.to_dict()))
|
|
130
|
+
return
|
|
131
|
+
verdict = "SAFE " if res.is_safe else "FLAGGED"
|
|
132
|
+
note = " (truncated)" if res.truncated else ""
|
|
133
|
+
print(
|
|
134
|
+
f"{verdict} {res.source} [video {res.duration:.1f}s, {res.frames_analyzed} frames]{note}"
|
|
135
|
+
)
|
|
136
|
+
for seg in res.segments:
|
|
137
|
+
print(
|
|
138
|
+
f" {_clock(seg.start)}-{_clock(seg.end)} {seg.name} {seg.max_confidence:.0%}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _clock(seconds: float) -> str:
|
|
143
|
+
minutes, secs = divmod(seconds, 60)
|
|
144
|
+
return f"{int(minutes)}:{secs:04.1f}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _emit_error(args: argparse.Namespace, src: str, exc: Exception) -> None:
|
|
148
|
+
if args.json:
|
|
149
|
+
print(json.dumps({"source": src, "error": str(exc)}))
|
|
150
|
+
else:
|
|
151
|
+
print(f"ERROR {src} {exc}", file=sys.stderr)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_train(args: argparse.Namespace) -> int:
|
|
155
|
+
from openmoderate.config import ModerationConfig
|
|
156
|
+
from openmoderate.engine.clip_engine import ClipEngine
|
|
157
|
+
from openmoderate.training import FolderDataset, TrainConfig, train_head
|
|
158
|
+
|
|
159
|
+
cfg = ModerationConfig.from_yaml(args.config) if args.config else ModerationConfig()
|
|
160
|
+
dataset = FolderDataset.from_folder(args.dataset, safe_label=args.safe_label)
|
|
161
|
+
print(f"Dataset: {len(dataset)} images", file=sys.stderr)
|
|
162
|
+
summary = dataset.summary()
|
|
163
|
+
for label, count in summary.items():
|
|
164
|
+
print(f" {label:20} {count}", file=sys.stderr)
|
|
165
|
+
n_safe = summary["(no label)"]
|
|
166
|
+
if n_safe < max(50, max(summary[label] for label in dataset.labels)):
|
|
167
|
+
print(
|
|
168
|
+
f"\nwarning: only {n_safe} images in '{args.safe_label}/'. The head learns what is "
|
|
169
|
+
"NOT a violation from this folder, so fill it with a broad sample of your normal "
|
|
170
|
+
"traffic (people, products, scenes...). A small or narrow safe set causes false "
|
|
171
|
+
"positives that validation metrics will not reveal.",
|
|
172
|
+
file=sys.stderr,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
engine = ClipEngine(cfg.model_name, cfg.pretrained, device=args.device, cache_dir=cfg.cache_dir)
|
|
176
|
+
embeddings = dataset.embed(engine, verbose=True, trim=cfg.trim_borders)
|
|
177
|
+
parents = {c.name: c.parent for c in cfg.categories if c.name in dataset.labels}
|
|
178
|
+
report = train_head(
|
|
179
|
+
embeddings,
|
|
180
|
+
dataset.targets,
|
|
181
|
+
dataset.labels,
|
|
182
|
+
TrainConfig(
|
|
183
|
+
epochs=args.epochs,
|
|
184
|
+
learning_rate=args.lr,
|
|
185
|
+
val_split=args.val_split,
|
|
186
|
+
threshold=args.threshold,
|
|
187
|
+
),
|
|
188
|
+
model_name=cfg.model_name,
|
|
189
|
+
pretrained=cfg.pretrained,
|
|
190
|
+
parents=parents,
|
|
191
|
+
)
|
|
192
|
+
if report.val_metrics:
|
|
193
|
+
print(f"\nValidation ({report.n_val} held-out images):", file=sys.stderr)
|
|
194
|
+
_print_metrics(report.val_metrics)
|
|
195
|
+
else:
|
|
196
|
+
print("\n(Not enough images for a validation split; no metrics.)", file=sys.stderr)
|
|
197
|
+
report.head.save(args.output)
|
|
198
|
+
print(f"\nSaved head to {args.output} (trained on all {len(dataset)} images)", file=sys.stderr)
|
|
199
|
+
print(f'Use it with: Moderator(head="{args.output}")', file=sys.stderr)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _print_metrics(metrics: dict[str, dict[str, float]]) -> None:
|
|
204
|
+
print(f" {'label':20} {'precision':>9} {'recall':>7} {'f1':>6} {'n':>5}", file=sys.stderr)
|
|
205
|
+
for label, m in metrics.items():
|
|
206
|
+
print(
|
|
207
|
+
f" {label:20} {m['precision']:9.2f} {m['recall']:7.2f} {m['f1']:6.2f} "
|
|
208
|
+
f"{m['support']:5d}",
|
|
209
|
+
file=sys.stderr,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_evaluate(args: argparse.Namespace) -> int:
|
|
214
|
+
import numpy as np
|
|
215
|
+
|
|
216
|
+
from openmoderate.training import FolderDataset, compute_metrics
|
|
217
|
+
|
|
218
|
+
dataset = FolderDataset.from_folder(args.dataset, safe_label=args.safe_label)
|
|
219
|
+
mod = _build_moderator(args)
|
|
220
|
+
names = mod.category_names
|
|
221
|
+
missing = [label for label in dataset.labels if label not in names]
|
|
222
|
+
if missing:
|
|
223
|
+
print(
|
|
224
|
+
f"warning: dataset labels not checked by this moderator, skipped: {missing}",
|
|
225
|
+
file=sys.stderr,
|
|
226
|
+
)
|
|
227
|
+
labels = [label for label in dataset.labels if label in names]
|
|
228
|
+
if not labels:
|
|
229
|
+
raise SystemExit("No dataset label matches a moderator category; nothing to evaluate.")
|
|
230
|
+
|
|
231
|
+
embeddings = dataset.embed(mod.engine, verbose=not args.json, trim=mod.config.trim_borders)
|
|
232
|
+
scores = mod.score_all(embeddings)
|
|
233
|
+
cols = [names.index(label) for label in labels]
|
|
234
|
+
targets = dataset.targets[:, [dataset.labels.index(label) for label in labels]]
|
|
235
|
+
all_thresholds = mod.thresholds
|
|
236
|
+
thresholds = [all_thresholds[label] for label in labels]
|
|
237
|
+
metrics = compute_metrics(targets, scores[:, cols], labels, thresholds)
|
|
238
|
+
|
|
239
|
+
# Overall: is an image flagged at all vs. does it carry any violation label.
|
|
240
|
+
flagged_any = np.any(scores >= np.array([all_thresholds[n] for n in names]), axis=1)
|
|
241
|
+
overall = compute_metrics(
|
|
242
|
+
(dataset.targets.sum(axis=1, keepdims=True) > 0).astype(float),
|
|
243
|
+
flagged_any[:, None].astype(float),
|
|
244
|
+
["any_violation"],
|
|
245
|
+
[0.5],
|
|
246
|
+
)
|
|
247
|
+
if args.json:
|
|
248
|
+
print(json.dumps({"labels": metrics, **overall}, indent=2))
|
|
249
|
+
else:
|
|
250
|
+
print(f"\nEvaluated {len(dataset)} images:", file=sys.stderr)
|
|
251
|
+
_print_metrics({**metrics, **overall})
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cmd_categories(args: argparse.Namespace) -> int:
|
|
256
|
+
from openmoderate import Moderator
|
|
257
|
+
|
|
258
|
+
class _NoEngine: # categories don't need the model
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
mod = Moderator(args.config, head=args.head, engine=_NoEngine()) # type: ignore[arg-type]
|
|
262
|
+
zero_shot = {c.name for c in mod.config.categories}
|
|
263
|
+
trained = set(mod.head.labels) if mod.head else set()
|
|
264
|
+
for spec in mod.label_specs:
|
|
265
|
+
kind = "+".join(
|
|
266
|
+
k for k, s in (("zero-shot", zero_shot), ("trained", trained)) if spec.name in s
|
|
267
|
+
)
|
|
268
|
+
print(f"{spec.name:20} {spec.parent or '-':12} {spec.threshold:5.2f} {kind}")
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_download(args: argparse.Namespace) -> int:
|
|
273
|
+
from openmoderate.config import ModerationConfig
|
|
274
|
+
from openmoderate.engine.clip_engine import ClipEngine
|
|
275
|
+
|
|
276
|
+
cfg = ModerationConfig.from_yaml(args.config) if args.config else ModerationConfig()
|
|
277
|
+
print(f"Downloading {cfg.model_name} ({cfg.pretrained})...", file=sys.stderr)
|
|
278
|
+
ClipEngine(cfg.model_name, cfg.pretrained, device="cpu", cache_dir=cfg.cache_dir).encode_texts(
|
|
279
|
+
["warm up"]
|
|
280
|
+
)
|
|
281
|
+
print("Done. The model is cached and openmoderate can now run offline.", file=sys.stderr)
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
286
|
+
import os
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
import uvicorn
|
|
290
|
+
except ImportError:
|
|
291
|
+
raise SystemExit(
|
|
292
|
+
"`serve` needs extra dependencies: pip install 'openmoderate[server]'"
|
|
293
|
+
) from None
|
|
294
|
+
|
|
295
|
+
# The app reads its settings from the environment so multi-worker mode works too.
|
|
296
|
+
for env, value in (
|
|
297
|
+
("OPENMODERATE_CONFIG", args.config),
|
|
298
|
+
("OPENMODERATE_HEAD", args.head),
|
|
299
|
+
("OPENMODERATE_DEVICE", args.device),
|
|
300
|
+
):
|
|
301
|
+
if value:
|
|
302
|
+
os.environ[env] = value
|
|
303
|
+
if args.allow_urls:
|
|
304
|
+
os.environ["OPENMODERATE_ALLOW_URLS"] = "true"
|
|
305
|
+
if args.host not in ("127.0.0.1", "localhost") and not os.getenv("OPENMODERATE_API_KEY"):
|
|
306
|
+
print(
|
|
307
|
+
f"warning: serving on {args.host} without OPENMODERATE_API_KEY; anyone who can "
|
|
308
|
+
"reach this port can use the API.",
|
|
309
|
+
file=sys.stderr,
|
|
310
|
+
)
|
|
311
|
+
uvicorn.run(
|
|
312
|
+
"openmoderate.server.app:app",
|
|
313
|
+
host=args.host,
|
|
314
|
+
port=args.port,
|
|
315
|
+
workers=args.workers,
|
|
316
|
+
log_level=args.log_level,
|
|
317
|
+
)
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
322
|
+
parser = argparse.ArgumentParser(
|
|
323
|
+
prog="openmoderate", description="Open-source image content moderation."
|
|
324
|
+
)
|
|
325
|
+
parser.add_argument("--version", action="version", version=f"openmoderate {__version__}")
|
|
326
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
327
|
+
|
|
328
|
+
p = sub.add_parser("scan", help="Scan images, folders or URLs")
|
|
329
|
+
p.add_argument("paths", nargs="+", help="Image files, folders (recursive) or URLs")
|
|
330
|
+
p.add_argument("--json", action="store_true", help="Output one JSON object per line")
|
|
331
|
+
p.add_argument(
|
|
332
|
+
"--fail-on-violation", action="store_true", help="Exit with code 1 if anything is flagged"
|
|
333
|
+
)
|
|
334
|
+
p.add_argument("--batch-size", type=int, default=16)
|
|
335
|
+
p.add_argument("--fps", type=float, default=1.0, help="Video frames sampled per second")
|
|
336
|
+
p.add_argument("--max-duration", type=float, help="Only analyse the first N seconds of videos")
|
|
337
|
+
p.add_argument(
|
|
338
|
+
"--min-frames", type=int, default=1, help="Ignore video segments shorter than N frames"
|
|
339
|
+
)
|
|
340
|
+
_add_policy_args(p)
|
|
341
|
+
p.set_defaults(func=cmd_scan)
|
|
342
|
+
|
|
343
|
+
p = sub.add_parser("train", help="Train a head on a folder dataset (dataset/<label>/*.jpg)")
|
|
344
|
+
p.add_argument("dataset", help="Folder with one sub-folder per label, plus safe/")
|
|
345
|
+
p.add_argument("-o", "--output", default="head.omh", help="Output file (default head.omh)")
|
|
346
|
+
p.add_argument("--safe-label", default="safe", help="Folder name for safe images")
|
|
347
|
+
p.add_argument("--epochs", type=int, default=300)
|
|
348
|
+
p.add_argument("--lr", type=float, default=0.05)
|
|
349
|
+
p.add_argument("--val-split", type=float, default=0.2)
|
|
350
|
+
p.add_argument("--threshold", type=float, default=0.5, help="Decision threshold to store")
|
|
351
|
+
p.add_argument("--config", help="Policy YAML (for model choice and category parents)")
|
|
352
|
+
p.add_argument("--device", default="auto")
|
|
353
|
+
p.set_defaults(func=cmd_train)
|
|
354
|
+
|
|
355
|
+
p = sub.add_parser("evaluate", help="Measure precision/recall on a labelled folder dataset")
|
|
356
|
+
p.add_argument("dataset")
|
|
357
|
+
p.add_argument("--safe-label", default="safe")
|
|
358
|
+
p.add_argument("--json", action="store_true")
|
|
359
|
+
_add_policy_args(p)
|
|
360
|
+
p.set_defaults(func=cmd_evaluate)
|
|
361
|
+
|
|
362
|
+
p = sub.add_parser("categories", help="List categories and thresholds")
|
|
363
|
+
p.add_argument("--config")
|
|
364
|
+
p.add_argument("--head")
|
|
365
|
+
p.set_defaults(func=cmd_categories)
|
|
366
|
+
|
|
367
|
+
p = sub.add_parser("download", help="Pre-download model weights (for offline/Docker use)")
|
|
368
|
+
p.add_argument("--config")
|
|
369
|
+
p.set_defaults(func=cmd_download)
|
|
370
|
+
|
|
371
|
+
p = sub.add_parser("serve", help="Run the REST API server")
|
|
372
|
+
p.add_argument("--host", default="127.0.0.1", help="Bind address (0.0.0.0 for all)")
|
|
373
|
+
p.add_argument("--port", type=int, default=8000)
|
|
374
|
+
p.add_argument("--workers", type=int, default=1, help="Processes (each loads the model)")
|
|
375
|
+
p.add_argument("--config", help="Policy YAML file")
|
|
376
|
+
p.add_argument("--head", help="Trained head (.omh)")
|
|
377
|
+
p.add_argument("--device", help="auto, cpu, cuda or mps")
|
|
378
|
+
p.add_argument("--allow-urls", action="store_true", help="Allow {'url': ...} requests")
|
|
379
|
+
p.add_argument("--log-level", default="info")
|
|
380
|
+
p.set_defaults(func=cmd_serve)
|
|
381
|
+
return parser
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
385
|
+
args = build_parser().parse_args(argv)
|
|
386
|
+
return args.func(args)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__":
|
|
390
|
+
raise SystemExit(main())
|