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.
@@ -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())