Skip to content

Commit e8083fd

Browse files
committed
Add request specific preset
1 parent f38a1d4 commit e8083fd

11 files changed

Lines changed: 669 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ VISION_NSFW_PRESET=strict
108108

109109
Individual tier settings (`VISION_NSFW_BLOCK`, `VISION_NSFW_REVIEW`, `VISION_NSFW_SENSITIVE`) always override the preset, so you can start from a preset and refine from there.
110110

111+
You can also **fine-tune each preset independently** at startup and let callers **select a preset per request**:
112+
113+
```dotenv
114+
# Raise the confidence bar for the strict preset's block tier
115+
VISION_NSFW_STRICT__BLOCK__CONFIDENCE=0.9
116+
117+
# Require detections to cover ≥ 5 % of the image for nude_female's review tier
118+
VISION_NSFW_NUDE_FEMALE__REVIEW__AREA_RATIO=0.05
119+
```
120+
121+
```json
122+
{ "photo_id": "42", "photo_path": "2024/01/photo.jpg", "preset": "strict" }
123+
```
124+
111125
See [Choose a preset](docs/2-how-to/choose-a-preset.md) and [Configure classification tiers](docs/2-how-to/tune-thresholds.md) for details.
112126

113127
## Environment variables
@@ -131,6 +145,7 @@ All variables are prefixed `VISION_NSFW_`. Copy `.env.example` to `.env` and fil
131145
| `VISION_NSFW_BLOCK` | _(see defaults)_ | JSON object configuring the block tier. See [Configuration Reference](docs/3-reference/configuration.md). |
132146
| `VISION_NSFW_REVIEW` | _(see defaults)_ | JSON object configuring the review tier. |
133147
| `VISION_NSFW_SENSITIVE` | _(see defaults)_ | JSON object configuring the sensitive tier. |
148+
| `VISION_NSFW_<PRESET>__<TIER>__<FIELD>` | _(none)_ | Per-preset threshold override. Tunes a specific preset in isolation so all presets are ready for per-request selection. Example: `VISION_NSFW_STRICT__BLOCK__CONFIDENCE=0.9`. |
134149

135150
Full reference: [docs/3-reference/configuration.md](docs/3-reference/configuration.md)
136151

@@ -154,11 +169,16 @@ Interactive docs: `http://localhost:8000/docs`
154169
```json
155170
{
156171
"photo_id": "42",
157-
"photo_path": "2024/01/photo.jpg"
172+
"photo_path": "2024/01/photo.jpg",
173+
"preset": "strict"
158174
}
159175
```
160176

161-
`photo_path` is relative to the shared volume root (`VISION_NSFW_PHOTOS_PATH`). The service validates it stays within that root.
177+
| Field | Required | Description |
178+
|---|---|---|
179+
| `photo_id` | Yes | Lychee photo identifier, echoed back in the callback. |
180+
| `photo_path` | Yes | Path relative to `VISION_NSFW_PHOTOS_PATH`. Validated to stay within that root. |
181+
| `preset` | No | Override the service-level preset for this job. See [Choose a preset](docs/2-how-to/choose-a-preset.md). |
162182

163183
The endpoint returns **`202 Accepted`** immediately. Results arrive via callback.
164184

‎app/api/routes.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,19 @@ async def detect(
9090
)
9191
raise HTTPException(status_code=400, detail="photo_path does not exist or is not a file")
9292

93+
if body.preset is not None:
94+
from app.config.presets import PRESETS
95+
96+
if body.preset not in PRESETS:
97+
raise HTTPException(status_code=400, detail=f"Unknown preset {body.preset!r}")
98+
9399
import json
94100

95101
queue: JobQueue = get_queue(request)
96102
accepted = await queue.enqueue(
97103
job_type="detect",
98104
photo_id=body.photo_id,
99-
payload=json.dumps({"photo_path": str(resolved)}),
105+
payload=json.dumps({"photo_path": str(resolved), "preset": body.preset}),
100106
)
101107
if not accepted:
102108
raise HTTPException(status_code=429, detail="Queue is full — try again later")
@@ -194,21 +200,37 @@ async def _run_detection_job(
194200
image_path: Path,
195201
executor: Executor,
196202
settings: AppSettings,
203+
preset_name: str | None = None,
197204
) -> None:
198205
"""Classify an image for NSFW content and notify Lychee via callback.
199206
200207
Runs entirely as an async background task after the ``/detect`` route has
201208
returned 202. CPU-bound NudeNet inference is offloaded to ``executor`` via
202209
``run_in_executor`` so the event loop remains responsive.
210+
211+
When *preset_name* is given it overrides the service-level label-set
212+
configuration for this job only; per-preset env overrides still apply.
203213
"""
204-
logger.info("Starting NSFW detection job for photo_id=%s, path=%s", photo_id, image_path)
214+
logger.info(
215+
"Starting NSFW detection job for photo_id=%s, path=%s, preset=%s",
216+
photo_id,
217+
image_path,
218+
preset_name,
219+
)
205220
try:
206221
from app.detection.detector import classify, detect_from_path
207222

208223
loop = asyncio.get_running_loop()
209224

210225
raw, w, h = await loop.run_in_executor(executor, detect_from_path, str(image_path))
211-
result = classify(raw, w, h, settings)
226+
227+
if preset_name is not None:
228+
block, review, sensitive = settings.resolve_preset(preset_name)
229+
effective_settings = settings.model_copy(update={"block": block, "review": review, "sensitive": sensitive})
230+
else:
231+
effective_settings = settings
232+
233+
result = classify(raw, w, h, effective_settings)
212234

213235
logger.info(
214236
"NSFW detection complete for photo_id=%s: should_block=%s, should_review=%s, is_sensitive=%s | "

‎app/api/schemas.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ class DetectRequest(BaseModel):
2727
before opening the file (path-traversal protection).
2828
"""
2929

30+
preset: str | None = None
31+
"""Named preset to apply for this specific request.
32+
33+
When set, overrides the service-level ``VISION_NSFW_PRESET`` / ``VISION_NSFW_BLOCK`` /
34+
``VISION_NSFW_REVIEW`` / ``VISION_NSFW_SENSITIVE`` configuration for this job only.
35+
Per-preset env overrides (e.g. ``VISION_NSFW_STRICT__BLOCK__CONFIDENCE``) still apply.
36+
37+
Valid values: ``strict``, ``moderation``, ``nude_female``, ``permissive``, ``social_media``.
38+
Omit (or ``null``) to use the service default.
39+
"""
40+
3041

3142
class BoundingBox(BaseModel):
3243
"""Pixel-space bounding box for a single detection."""

‎app/config/models.py‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Pydantic models for label-set configuration."""
22

3+
from __future__ import annotations
4+
35
from pydantic import BaseModel, Field, field_validator, model_validator
46

57
from app.config.labels import VALID_LABELS
@@ -56,8 +58,75 @@ def _validate_threshold_keys(cls, value: dict[str, LabelThreshold]) -> dict[str,
5658
return value
5759

5860
@model_validator(mode="after")
59-
def _validate_threshold_keys_subset_of_labels(self) -> "LabelSetConfig":
61+
def _validate_threshold_keys_subset_of_labels(self) -> LabelSetConfig:
6062
orphans = sorted(set(self.label_thresholds.keys()) - set(self.labels))
6163
if orphans:
6264
raise ValueError(f"label_thresholds contains labels not present in labels: {orphans}")
6365
return self
66+
67+
68+
class PresetTierOverride(BaseModel):
69+
"""Optional per-preset threshold/label overrides for one classification tier.
70+
71+
All fields default to ``None`` / empty, meaning "inherit from the preset definition".
72+
Only the fields you set are applied on top of the base preset tier.
73+
74+
Env var examples (with ``VISION_NSFW_STRICT__`` prefix for the *strict* preset):
75+
``VISION_NSFW_STRICT__BLOCK__CONFIDENCE=0.9``
76+
``VISION_NSFW_STRICT__REVIEW__AREA_RATIO=0.05``
77+
``VISION_NSFW_STRICT__BLOCK__LABELS='["ANUS_EXPOSED"]'`` (replaces preset labels)
78+
"""
79+
80+
labels: list[str] | None = None
81+
"""Replace the preset's label list for this tier. ``None`` keeps the preset labels."""
82+
83+
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
84+
"""Override the per-set confidence threshold. ``None`` keeps the preset default."""
85+
86+
area_ratio: float | None = Field(default=None, ge=0.0, le=1.0)
87+
"""Override the per-set area-ratio threshold. ``None`` keeps the preset default."""
88+
89+
label_thresholds: dict[str, LabelThreshold] = {}
90+
"""Per-label threshold overrides. Empty dict keeps the preset label_thresholds."""
91+
92+
@field_validator("labels", mode="after")
93+
@classmethod
94+
def _validate_labels(cls, value: list[str] | None) -> list[str] | None:
95+
if value is None:
96+
return value
97+
unknown = sorted(set(value) - VALID_LABELS)
98+
if unknown:
99+
raise ValueError(f"Unknown label(s): {unknown}. Valid labels are: {sorted(VALID_LABELS)}")
100+
return value
101+
102+
@field_validator("label_thresholds", mode="after")
103+
@classmethod
104+
def _validate_threshold_keys(cls, value: dict[str, LabelThreshold]) -> dict[str, LabelThreshold]:
105+
unknown = sorted(set(value.keys()) - VALID_LABELS)
106+
if unknown:
107+
raise ValueError(f"Unknown label(s) in label_thresholds: {unknown}")
108+
return value
109+
110+
def apply_to(self, base: LabelSetConfig) -> LabelSetConfig:
111+
"""Return a new ``LabelSetConfig`` with this override merged onto *base*."""
112+
return LabelSetConfig(
113+
labels=self.labels if self.labels is not None else base.labels,
114+
confidence=self.confidence if self.confidence is not None else base.confidence,
115+
area_ratio=self.area_ratio if self.area_ratio is not None else base.area_ratio,
116+
label_thresholds=self.label_thresholds if self.label_thresholds else base.label_thresholds,
117+
)
118+
119+
120+
class PresetOverride(BaseModel):
121+
"""Per-preset configuration overrides for all three classification tiers.
122+
123+
Loaded from env vars such as:
124+
``VISION_NSFW_STRICT__BLOCK__CONFIDENCE=0.9``
125+
``VISION_NSFW_NUDE_FEMALE__REVIEW__AREA_RATIO=0.05``
126+
127+
Unset tiers are inherited unchanged from the preset definition.
128+
"""
129+
130+
block: PresetTierOverride = Field(default_factory=PresetTierOverride)
131+
review: PresetTierOverride = Field(default_factory=PresetTierOverride)
132+
sensitive: PresetTierOverride = Field(default_factory=PresetTierOverride)

‎app/config/settings.py‎

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pydantic import Field, ValidationError, model_validator
88
from pydantic_settings import BaseSettings, SettingsConfigDict
99

10-
from app.config.models import LabelSetConfig
10+
from app.config.models import LabelSetConfig, PresetOverride
1111

1212
_RED = "\033[31m"
1313
_YELLOW = "\033[33m"
@@ -113,7 +113,19 @@ class AppSettings(BaseSettings):
113113
"""Load a named preset as the baseline for block / review / sensitive.
114114
115115
Explicit ``VISION_NSFW_BLOCK``, ``VISION_NSFW_REVIEW``, or
116-
``VISION_NSFW_SENSITIVE`` values still override the preset.
116+
``VISION_NSFW_SENSITIVE`` values still override the preset. Two override
117+
modes are supported:
118+
119+
* **Full override** — supply ``labels`` (and optionally thresholds). The
120+
preset's label list is ignored entirely for that tier.
121+
Example: ``VISION_NSFW_BLOCK='{"labels":["ANUS_EXPOSED"]}'``
122+
123+
* **Threshold-only override** — supply only threshold keys
124+
(``confidence``, ``area_ratio``, ``label_thresholds``) *without*
125+
``labels``. The preset's labels are kept and the explicit thresholds
126+
are merged on top.
127+
Example: ``VISION_NSFW_BLOCK__CONFIDENCE=0.9``
128+
Example: ``VISION_NSFW_REVIEW__AREA_RATIO=0.05``
117129
118130
Valid names: ``strict``, ``moderation``, ``nude_female``, ``permissive``,
119131
``social_media``."""
@@ -129,9 +141,35 @@ def _apply_preset(cls, data: dict) -> dict:
129141
preset = PRESETS.get(preset_name)
130142
if preset is None:
131143
raise ValueError(f"Unknown preset {preset_name!r}. Valid presets: {sorted(PRESETS)}")
144+
145+
# Per-preset env overrides, e.g. VISION_NSFW_STRICT__BLOCK__CONFIDENCE=0.9
146+
preset_override_raw = data.get(preset_name) or {}
147+
132148
for field in ("block", "review", "sensitive"):
149+
preset_config = getattr(preset, field).model_dump()
150+
151+
# Merge preset-specific tier override (labels replaced only when explicitly set)
152+
tier_raw = {}
153+
if isinstance(preset_override_raw, dict):
154+
raw = preset_override_raw.get(field) or {}
155+
if isinstance(raw, dict):
156+
if "labels" in raw and raw["labels"] is not None:
157+
tier_raw["labels"] = raw["labels"]
158+
for key in ("confidence", "area_ratio", "label_thresholds"):
159+
if raw.get(key) is not None:
160+
tier_raw[key] = raw[key]
161+
162+
merged_preset = {**preset_config, **tier_raw}
163+
133164
if field not in data:
134-
data[field] = getattr(preset, field).model_dump()
165+
data[field] = merged_preset
166+
elif isinstance(data[field], dict) and "labels" not in data[field]:
167+
# Global threshold-only override on top of the (already merged) preset config.
168+
# e.g. VISION_NSFW_BLOCK__CONFIDENCE=0.8 with VISION_NSFW_PRESET=strict
169+
# keeps strict's labels but uses confidence=0.8.
170+
data[field] = {**merged_preset, **data[field]}
171+
# else: global override includes explicit labels → use as-is (full override).
172+
135173
return data
136174

137175
# --- Label set configurations ---
@@ -169,6 +207,38 @@ def _apply_preset(cls, data: dict) -> dict:
169207
)
170208
"""Labels and thresholds for the *sensitive* tier (``is_sensitive`` in the callback)."""
171209

210+
# --- Per-preset overrides ---
211+
# Each field holds threshold/label overrides for the corresponding named preset.
212+
# These are loaded from env vars using the preset name as the nested key, e.g.:
213+
# VISION_NSFW_STRICT__BLOCK__CONFIDENCE=0.9
214+
# VISION_NSFW_NUDE_FEMALE__REVIEW__AREA_RATIO=0.05
215+
# All fields default to no-op (inherit from the preset definition).
216+
strict: PresetOverride = Field(default_factory=PresetOverride)
217+
moderation: PresetOverride = Field(default_factory=PresetOverride)
218+
nude_female: PresetOverride = Field(default_factory=PresetOverride)
219+
permissive: PresetOverride = Field(default_factory=PresetOverride)
220+
social_media: PresetOverride = Field(default_factory=PresetOverride)
221+
222+
def resolve_preset(self, name: str) -> tuple[LabelSetConfig, LabelSetConfig, LabelSetConfig]:
223+
"""Return ``(block, review, sensitive)`` for a preset with its env overrides applied.
224+
225+
Used for per-request preset selection: the caller swaps in these label sets
226+
instead of the global ``block`` / ``review`` / ``sensitive`` fields.
227+
228+
Raises ``ValueError`` for unknown preset names.
229+
"""
230+
from app.config.presets import PRESETS
231+
232+
if name not in PRESETS:
233+
raise ValueError(f"Unknown preset {name!r}. Valid presets: {sorted(PRESETS)}")
234+
preset = PRESETS[name]
235+
override: PresetOverride = getattr(self, name)
236+
return (
237+
override.block.apply_to(preset.block),
238+
override.review.apply_to(preset.review),
239+
override.sensitive.apply_to(preset.sensitive),
240+
)
241+
172242
# --- Concurrency ---
173243
thread_pool_size: int = 1
174244
"""Number of threads in the ``ThreadPoolExecutor`` used for CPU-bound inference."""

‎app/queue/worker.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,5 @@ async def _handle_detect(job: Any, state: Any, settings: AppSettings) -> None:
5353
image_path=Path(payload["photo_path"]),
5454
executor=state.executor,
5555
settings=settings,
56+
preset_name=payload.get("preset"),
5657
)

0 commit comments

Comments
 (0)