File size: 4,827 Bytes
2857cf3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
from __future__ import annotations

from pathlib import Path
from types import SimpleNamespace

from PIL import Image
import pytest

import kneiff.utils.image.caption.blip as caption_blip
import kneiff.utils.image.caption.server as caption_server
from kneiff.progress import ProgressUpdate
from kneiff.utils.image.caption.batch import (
    CaptionBatchItemResult,
    plan_caption_batch,
)
from kneiff.utils.image.caption.io import CaptionSidecarIO


def test_caption_batch_plan_excludes_existing_sidecars(tmp_path: Path) -> None:
    first = tmp_path / "first.png"
    second = tmp_path / "second.png"
    Image.new("RGB", (2, 2), color="red").save(first)
    Image.new("RGB", (2, 2), color="blue").save(second)
    first.with_suffix(".cap.txt").write_text("existing", encoding="utf-8")

    jobs = plan_caption_batch(tmp_path, CaptionSidecarIO())

    assert [job.image_path for job in jobs] == [second]


def test_server_caption_batch_reports_written_and_caught_failures(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    first = tmp_path / "first.png"
    second = tmp_path / "second.png"
    Image.new("RGB", (2, 2), color="red").save(first)
    Image.new("RGB", (2, 2), color="blue").save(second)
    sidecar_io = CaptionSidecarIO()
    jobs = plan_caption_batch(tmp_path, sidecar_io)
    calls = 0

    monkeypatch.setattr(
        caption_server,
        "load_openai_backend_config",
        lambda **kwargs: SimpleNamespace(model="test-model"),
    )
    monkeypatch.setattr(caption_server, "build_openai_client", lambda cfg: object())
    monkeypatch.setattr(
        caption_server,
        "image_to_data_url",
        lambda *args, **kwargs: "data:image/jpeg;base64,test",
    )

    def fake_caption_one(*args: object, **kwargs: object) -> str:
        nonlocal calls
        calls += 1
        if calls == 2:
            raise RuntimeError("server unavailable")
        return "A concise caption."

    monkeypatch.setattr(caption_server, "caption_one", fake_caption_one)
    updates: list[ProgressUpdate] = []
    items: list[CaptionBatchItemResult] = []
    instructions: list[str] = []

    result = caption_server.run_caption_batch_with_server(
        jobs,
        sidecar_io=sidecar_io,
        model_name="test-model",
        progress_callback=updates.append,
        instruction_callback=instructions.append,
        item_callback=items.append,
    )

    assert result.count("written") == 1
    assert result.count("failed") == 1
    assert first.with_suffix(".cap.txt").is_file()
    assert not second.with_suffix(".cap.txt").exists()
    assert len(instructions) == 2
    assert [item.status for item in items] == ["written", "failed"]
    assert sum(update.advance for update in updates) == 2


class _FakeCaptioner:
    def caption(self, image: Image.Image, instruction: str, gen: object) -> str:
        del image, instruction, gen
        return "A local caption."


def test_blip_caption_batch_advances_for_image_open_skip(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    invalid = tmp_path / "invalid.png"
    valid = tmp_path / "valid.png"
    invalid.write_bytes(b"not an image")
    Image.new("RGB", (2, 2), color="green").save(valid)
    sidecar_io = CaptionSidecarIO()
    jobs = plan_caption_batch(tmp_path, sidecar_io)
    monkeypatch.setattr(
        caption_blip,
        "build_captioner",
        lambda model_name, backend: _FakeCaptioner(),
    )
    updates: list[ProgressUpdate] = []

    result = caption_blip.run_caption_batch_with_blip(
        jobs,
        sidecar_io=sidecar_io,
        progress_callback=updates.append,
    )

    assert result.count("skipped") == 1
    assert result.count("written") == 1
    assert valid.with_suffix(".cap.txt").is_file()
    assert sum(update.advance for update in updates) == 2


def test_blip_inference_failure_propagates_with_incomplete_progress(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    source = tmp_path / "source.png"
    Image.new("RGB", (2, 2), color="green").save(source)
    sidecar_io = CaptionSidecarIO()
    jobs = plan_caption_batch(tmp_path, sidecar_io)

    class FailingCaptioner:
        def caption(self, image: Image.Image, instruction: str, gen: object) -> str:
            del image, instruction, gen
            raise RuntimeError("inference failed")

    monkeypatch.setattr(
        caption_blip,
        "build_captioner",
        lambda model_name, backend: FailingCaptioner(),
    )
    updates: list[ProgressUpdate] = []

    with pytest.raises(RuntimeError, match="inference failed"):
        caption_blip.run_caption_batch_with_blip(
            jobs,
            sidecar_io=sidecar_io,
            progress_callback=updates.append,
        )

    assert updates == []
    assert not source.with_suffix(".cap.txt").exists()