Skip to content

Commit ed75b5b

Browse files
committed
feat: ability to unload captio/review model after use
1 parent bc8f848 commit ed75b5b

10 files changed

Lines changed: 95 additions & 0 deletions

File tree

server/runners/generate.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ def run(progress: Progress) -> dict:
108108
findings = 0
109109
if params.review_after:
110110
findings = _review_all(progress, params, keys)
111+
if params.unload_after and loader.is_model_loaded():
112+
progress(sub="freeing the model…")
113+
for status, _done in loader.unload_model():
114+
progress(sub=status)
111115
return {"done": total, "grounded": grounded, "findings": findings}
112116

113117
return run

server/runners/review_run.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ def _judge_rule(rule, target, seed) -> dict | None:
129129
return caption_judge.judged_finding(target["caption"], verdict)
130130

131131

132+
def _unload_resident(progress: Progress) -> None:
133+
"""Free whatever model is still resident (the unload-after toggle)."""
134+
# pylint: disable=import-outside-toplevel
135+
from src import loader
136+
137+
if not loader.is_model_loaded():
138+
return
139+
progress(sub="freeing the model…")
140+
for status, _done in loader.unload_model():
141+
progress(sub=status)
142+
143+
132144
def review_run_body(params):
133145
"""Return a job body running a rule-based review over a dataset.
134146
@@ -193,6 +205,8 @@ def run(progress: Progress) -> dict:
193205
)
194206

195207
storage.close_review_run(run_id, state["found"])
208+
if params.unload_after:
209+
_unload_resident(progress)
196210
return {"reviewed": len(targets), "findings": state["found"]}
197211

198212
return run

server/schemas.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ class GenerateBody(BaseModel):
132132
# Off = caption only media whose caption is still empty; on (default)
133133
# regenerates every targeted media, already-captioned ones included.
134134
recaption: bool = True
135+
# Free the VRAM when the job is done (default: keep the model resident).
136+
unload_after: bool = False
135137

136138

137139
class DeployBody(BaseModel):
@@ -199,6 +201,8 @@ class ReviewRunBody(BaseModel):
199201
scope: str = "all" # all | selection | flagged | single
200202
rule_ids: list[int] | None = None # None = every enabled rule
201203
seed: int | None = None
204+
# Free the VRAM when the run is done (default: keep the model resident).
205+
unload_after: bool = False
202206

203207

204208
class ReviewDecideBody(BaseModel):

tests/test_api_caption_flow.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,41 @@ def test_generate_skips_filled_captions_when_recaption_off(
448448
assert result["done"] == 1
449449
assert storage.read_caption(dataset_id, filled, "txt") == "already there"
450450
assert storage.read_caption(dataset_id, empty, "txt") == "fresh"
451+
452+
453+
def test_generate_unloads_model_when_asked(
454+
store_db, thumb_cache_dir, tmp_path, monkeypatch
455+
):
456+
"""With ``unload_after``, the job frees the resident model at the end."""
457+
# pylint: disable=unused-argument
458+
from server.runners import generate as generate_runner
459+
from server.schemas import GenerateBody
460+
from src import loader
461+
462+
Image.new("RGB", (64, 64), (219, 68, 55)).save(tmp_path / "a.png")
463+
library_id = store.create_library("fixtures", str(tmp_path))
464+
store.scan_library(library_id)
465+
dataset_id = store.create_dataset("shapes")
466+
for row in store.list_library_media():
467+
store.add_media_to_dataset(dataset_id, row["id"])
468+
469+
unloaded = []
470+
monkeypatch.setattr(
471+
generate_runner,
472+
"_caption_one",
473+
lambda path, params, seed, profile: "text",
474+
)
475+
monkeypatch.setattr(loader, "is_model_loaded", lambda: True)
476+
monkeypatch.setattr(
477+
loader,
478+
"unload_model",
479+
lambda: unloaded.append(1) or iter([("freed", True)]),
480+
)
481+
params = GenerateBody(
482+
dataset_id=dataset_id,
483+
caption_type="txt",
484+
prompt="p",
485+
unload_after=True,
486+
)
487+
generate_runner.generate_body(params)(lambda **kwargs: None)
488+
assert unloaded == [1]

web/src/api/hooks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,8 @@ export interface GenerateVars {
764764
ground_after: boolean;
765765
/** Off = only caption media whose caption is still empty. */
766766
recaption: boolean;
767+
/** On = free the VRAM when the job ends (default: model stays loaded). */
768+
unload_after?: boolean;
767769
}
768770

769771
export function useGenerate() {
@@ -890,6 +892,7 @@ export function useRunReview() {
890892
scope: string;
891893
rule_ids?: number[] | null;
892894
seed?: number | null;
895+
unload_after?: boolean;
893896
}) => api.post<{ job_id: string }>("/review/run", vars),
894897
});
895898
}

web/src/components/organisms/BatchBar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function BatchBar() {
5151
: null,
5252
ground_after: gen.groundAfter,
5353
recaption: gen.recaption,
54+
unload_after: gen.unloadAfter,
5455
});
5556

5657
const addTagToAll = async () => {

web/src/components/organisms/CaptionDetailPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@ function RegenerateButton({
532532
ground_after: gen.groundAfter,
533533
// An explicit per-media regenerate always rewrites, filled or not.
534534
recaption: true,
535+
unload_after: gen.unloadAfter,
535536
},
536537
{ onSuccess: (data) => job.start(data.job_id) },
537538
);

web/src/components/organisms/CaptionLeftPanel.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ export function CaptionLeftPanel() {
140140
review_judge_profile_id: gen.reviewAfter ? data.judge_id : null,
141141
ground_after: gen.groundAfter,
142142
recaption: gen.recaption,
143+
unload_after: gen.unloadAfter,
143144
},
144145
{
145146
onSuccess: (result) => {
@@ -334,6 +335,16 @@ export function CaptionLeftPanel() {
334335
Only media whose caption is still empty will be captioned.
335336
</div>
336337
)}
338+
<label style={checkboxRow}>
339+
<input
340+
type="checkbox"
341+
checked={gen.unloadAfter}
342+
onChange={(event) =>
343+
gen.set({ unloadAfter: event.target.checked })
344+
}
345+
/>
346+
Unload the model after the job
347+
</label>
337348
<label style={checkboxRow}>
338349
<input
339350
type="checkbox"

web/src/components/organisms/ReviewView.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export function ReviewView() {
4949
const runReview = useRunReview();
5050
const profiles = useProfiles();
5151
const [scope, setScope] = useState("all");
52+
const [unloadAfter, setUnloadAfter] = useState(false);
5253
const [jobId, setJobId] = useState<string | null>(null);
5354
const job = useJobsStore((state) =>
5455
jobId ? state.jobs[jobId] : undefined,
@@ -105,6 +106,7 @@ export function ReviewView() {
105106
media_ids: mediaIds,
106107
judge_profile_id: profiles.data?.judge_id ?? null,
107108
scope,
109+
unload_after: unloadAfter,
108110
},
109111
{ onSuccess: (data) => setJobId(data.job_id) },
110112
);
@@ -125,6 +127,8 @@ export function ReviewView() {
125127
running={running}
126128
job={job}
127129
onRun={run}
130+
unloadAfter={unloadAfter}
131+
onUnloadAfter={setUnloadAfter}
128132
/>
129133
<Queue
130134
datasetId={datasetId}
@@ -156,6 +160,8 @@ function Rail({
156160
running,
157161
job,
158162
onRun,
163+
unloadAfter,
164+
onUnloadAfter,
159165
}: {
160166
datasetId: number;
161167
rules: ReviewRule[];
@@ -165,6 +171,8 @@ function Rail({
165171
running: boolean;
166172
job: JobLike | undefined;
167173
onRun: () => void;
174+
unloadAfter: boolean;
175+
onUnloadAfter: (value: boolean) => void;
168176
}) {
169177
const profiles = useProfiles();
170178
const createRule = useCreateReviewRule();
@@ -299,6 +307,14 @@ function Rail({
299307
▶ Run review
300308
</Button>
301309
)}
310+
<label style={checkRow}>
311+
<input
312+
type="checkbox"
313+
checked={unloadAfter}
314+
onChange={(event) => onUnloadAfter(event.target.checked)}
315+
/>
316+
Unload the model after the run
317+
</label>
302318
<p style={hintStyle}>
303319
Text-only rules run without loading images; vision rules load each
304320
one. Locked captions are skipped. Judge: {judgeLabel}.

web/src/store/captionStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ interface CaptionState {
1919
reviewAfter: boolean;
2020
/** Off = only caption media whose caption is still empty. */
2121
recaption: boolean;
22+
/** On = free the VRAM when the job ends (off keeps the model loaded). */
23+
unloadAfter: boolean;
2224
/** Chain a SigLIP grounding pass on every freshly written caption. */
2325
groundAfter: boolean;
2426
locked: Set<string>;
@@ -33,6 +35,7 @@ export const useCaptionStore = create<CaptionState>((set) => ({
3335
seed: "-1",
3436
reviewAfter: false,
3537
recaption: true,
38+
unloadAfter: false,
3639
groundAfter: false,
3740
locked: new Set(),
3841

0 commit comments

Comments
 (0)