image2mcq 1.0.0__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.
- image2mcq/__init__.py +13 -0
- image2mcq/cli.py +199 -0
- image2mcq/generator.py +685 -0
- image2mcq/image_ocr.py +389 -0
- image2mcq/models.py +100 -0
- image2mcq/prompts.py +120 -0
- image2mcq-1.0.0.dist-info/METADATA +194 -0
- image2mcq-1.0.0.dist-info/RECORD +12 -0
- image2mcq-1.0.0.dist-info/WHEEL +5 -0
- image2mcq-1.0.0.dist-info/entry_points.txt +2 -0
- image2mcq-1.0.0.dist-info/licenses/LICENSE +21 -0
- image2mcq-1.0.0.dist-info/top_level.txt +1 -0
image2mcq/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .generator import ImageMCQGenerator
|
|
2
|
+
from .image_ocr import ImageOCRExtractor
|
|
3
|
+
from .models import MCQQuestion, MCQSet, ContentBlock
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.0"
|
|
6
|
+
__author__ = "image2mcq"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ImageMCQGenerator",
|
|
9
|
+
"ImageOCRExtractor",
|
|
10
|
+
"MCQQuestion",
|
|
11
|
+
"MCQSet",
|
|
12
|
+
"ContentBlock",
|
|
13
|
+
]
|
image2mcq/cli.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _glob_files(folder: str, extensions: set) -> list:
|
|
12
|
+
folder = Path(folder)
|
|
13
|
+
if not folder.is_dir():
|
|
14
|
+
print(f"Error: folder not found: {folder}", file=sys.stderr)
|
|
15
|
+
sys.exit(1)
|
|
16
|
+
files = []
|
|
17
|
+
for ext in extensions:
|
|
18
|
+
files.extend(folder.glob(f"*{ext}"))
|
|
19
|
+
files.extend(folder.glob(f"*{ext.upper()}"))
|
|
20
|
+
seen = set()
|
|
21
|
+
unique = []
|
|
22
|
+
for f in sorted(files, key=lambda p: p.name.lower()):
|
|
23
|
+
if f.suffix.lower() in extensions and f.name not in seen:
|
|
24
|
+
seen.add(f.name)
|
|
25
|
+
unique.append(str(f))
|
|
26
|
+
return unique
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_api_key(args):
|
|
30
|
+
key = args.api_key or ""
|
|
31
|
+
if key:
|
|
32
|
+
return key
|
|
33
|
+
env_vars = {
|
|
34
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
35
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
36
|
+
"openai": "OPENAI_API_KEY",
|
|
37
|
+
"ollama": "",
|
|
38
|
+
}
|
|
39
|
+
env_key = env_vars.get(args.provider, "")
|
|
40
|
+
if env_key:
|
|
41
|
+
return os.environ.get(env_key, "")
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
parser = argparse.ArgumentParser(
|
|
47
|
+
prog="image2mcq",
|
|
48
|
+
description="Convert images (screenshots, scanned pages, diagrams) into MCQ questions using AI.",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
parser.add_argument("--version", action="store_true", help="Show version and exit")
|
|
52
|
+
|
|
53
|
+
input_group = parser.add_argument_group("Input sources (at least one required)")
|
|
54
|
+
input_group.add_argument("--image-url", metavar="URL", action="append", default=[],
|
|
55
|
+
help="Image URL (repeatable: --image-url url1 --image-url url2)")
|
|
56
|
+
input_group.add_argument("--image-path", metavar="FILE", action="append", default=[],
|
|
57
|
+
help="Local image file path (repeatable)")
|
|
58
|
+
input_group.add_argument("--image-folder", metavar="DIR", default="",
|
|
59
|
+
help="Scan folder for images (.png, .jpg, .jpeg, .gif, .bmp, .tiff, .webp)")
|
|
60
|
+
|
|
61
|
+
gen_group = parser.add_argument_group("Generation options")
|
|
62
|
+
gen_group.add_argument("-n", "--n", type=int, default=999,
|
|
63
|
+
help="Number of questions (default: 999 = as many as content supports)")
|
|
64
|
+
gen_group.add_argument("--difficulty", default=None,
|
|
65
|
+
help='E.g. "30%% easy, 40%% medium, 30%% hard"')
|
|
66
|
+
gen_group.add_argument("--topics", nargs="*", help="Focus topics")
|
|
67
|
+
gen_group.add_argument("--instructions", "-i", default="",
|
|
68
|
+
help='Custom instructions e.g. "Make answers very close and confusing"')
|
|
69
|
+
gen_group.add_argument("--batch-size", type=int, default=10,
|
|
70
|
+
help="Questions per API call (default: 10)")
|
|
71
|
+
|
|
72
|
+
ai_group = parser.add_argument_group("AI provider")
|
|
73
|
+
ai_group.add_argument("--provider", default="openrouter",
|
|
74
|
+
choices=["anthropic", "openai", "openrouter", "ollama"],
|
|
75
|
+
help="AI provider (default: openrouter). Use 'ollama' for local LLM.")
|
|
76
|
+
ai_group.add_argument("--mcq-model", default="",
|
|
77
|
+
help="MCQ generation model (or 'auto' to try --mcq-models)")
|
|
78
|
+
ai_group.add_argument("--mcq-models", default="",
|
|
79
|
+
help="Comma-separated priority model list for --mcq-model auto. "
|
|
80
|
+
"Runtime-reloadable via IMAGE2MCQ_MCQ_MODELS env var.")
|
|
81
|
+
ai_group.add_argument("--api-key", default="",
|
|
82
|
+
help="API key. Falls back to OPENROUTER_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY env var.")
|
|
83
|
+
ai_group.add_argument("--ollama-base-url", default="http://localhost:11434/v1",
|
|
84
|
+
help="Ollama API base URL (default: http://localhost:11434/v1). "
|
|
85
|
+
"Only used when --provider ollama.")
|
|
86
|
+
|
|
87
|
+
ocr_group = parser.add_argument_group("OCR & image processing")
|
|
88
|
+
ocr_group.add_argument("--ocr-model", default="pytesseract",
|
|
89
|
+
help="OCR backend: 'pytesseract', 'auto', or any OpenRouter model ID "
|
|
90
|
+
"(e.g. 'openai/gpt-4o'). (default: pytesseract)")
|
|
91
|
+
ocr_group.add_argument("--ocr-models", default="",
|
|
92
|
+
help="Comma-separated priority model list for --ocr-model auto. "
|
|
93
|
+
"E.g. 'gpt-4o,gemma-27b,gemma-12b,pytesseract'")
|
|
94
|
+
ocr_group.add_argument("--method", default="twostep", choices=["twostep", "images2mcq"],
|
|
95
|
+
help="Image processing: 'twostep' (OCR->MCQ) or 'images2mcq' (vision direct). "
|
|
96
|
+
"(default: twostep)")
|
|
97
|
+
ocr_group.add_argument("--save-ocr-path", default="",
|
|
98
|
+
help="File path to save OCR text when method=twostep")
|
|
99
|
+
ocr_group.add_argument("--prompt-log-path", default="",
|
|
100
|
+
help="Dump prompts to file, or 'stdout' / '-' for terminal")
|
|
101
|
+
|
|
102
|
+
out_group = parser.add_argument_group("Output")
|
|
103
|
+
out_group.add_argument("--output", "-o", default="",
|
|
104
|
+
help="Output file (.json or .txt). Default: stdout")
|
|
105
|
+
out_group.add_argument("--format", choices=["json", "pretty"], default="pretty",
|
|
106
|
+
help="Output format (default: pretty)")
|
|
107
|
+
|
|
108
|
+
args = parser.parse_args()
|
|
109
|
+
|
|
110
|
+
if args.version:
|
|
111
|
+
try:
|
|
112
|
+
from image2mcq import __version__
|
|
113
|
+
except ImportError:
|
|
114
|
+
__version__ = "unknown"
|
|
115
|
+
print(f"image2mcq v{__version__}")
|
|
116
|
+
sys.exit(0)
|
|
117
|
+
|
|
118
|
+
if args.image_folder:
|
|
119
|
+
args.image_path.extend(_glob_files(args.image_folder, _IMAGE_EXTENSIONS))
|
|
120
|
+
|
|
121
|
+
has_input = bool(args.image_url or args.image_path)
|
|
122
|
+
if not has_input:
|
|
123
|
+
parser.print_help()
|
|
124
|
+
print("\nError: at least one input source is required "
|
|
125
|
+
"(--image-url, --image-path, --image-folder)", file=sys.stderr)
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
from image2mcq import ImageMCQGenerator
|
|
130
|
+
except ImportError as e:
|
|
131
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
api_key = _get_api_key(args)
|
|
135
|
+
ocr_models = None
|
|
136
|
+
if args.ocr_models:
|
|
137
|
+
ocr_models = [m.strip() for m in args.ocr_models.split(",") if m.strip()]
|
|
138
|
+
mcq_model_list = None
|
|
139
|
+
if args.mcq_models:
|
|
140
|
+
mcq_model_list = [m.strip() for m in args.mcq_models.split(",") if m.strip()]
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
gen = ImageMCQGenerator(
|
|
144
|
+
api_key=api_key or None,
|
|
145
|
+
provider=args.provider,
|
|
146
|
+
mcq_model=args.mcq_model,
|
|
147
|
+
mcq_model_list=mcq_model_list,
|
|
148
|
+
batch_size=args.batch_size,
|
|
149
|
+
ocr_model=args.ocr_model,
|
|
150
|
+
ocr_models=ocr_models,
|
|
151
|
+
method=args.method,
|
|
152
|
+
save_ocr_path=args.save_ocr_path or None,
|
|
153
|
+
prompt_log_path=args.prompt_log_path or None,
|
|
154
|
+
ollama_base_url=args.ollama_base_url,
|
|
155
|
+
)
|
|
156
|
+
except ValueError as e:
|
|
157
|
+
print(f"Configuration error: {e}", file=sys.stderr)
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
n = args.n
|
|
161
|
+
difficulty = args.difficulty
|
|
162
|
+
topics = args.topics
|
|
163
|
+
instructions = args.instructions or None
|
|
164
|
+
|
|
165
|
+
mcq_set = None
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
if args.image_url and args.image_path:
|
|
169
|
+
print("Error: specify either --image-url or --image-path, not both.", file=sys.stderr)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
if args.image_url:
|
|
172
|
+
mcq_set = gen.from_image_urls(args.image_url, n=n, difficulty_mix=difficulty,
|
|
173
|
+
focus_topics=topics, custom_instructions=instructions)
|
|
174
|
+
if args.image_path:
|
|
175
|
+
mcq_set = gen.from_image_paths(args.image_path, n=n, difficulty_mix=difficulty,
|
|
176
|
+
focus_topics=topics, custom_instructions=instructions)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
print(f"Generation failed: {e}", file=sys.stderr)
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
|
|
181
|
+
if mcq_set is None or not mcq_set.questions:
|
|
182
|
+
print("No questions were generated.", file=sys.stderr)
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
if args.format == "json":
|
|
186
|
+
output = mcq_set.to_json()
|
|
187
|
+
else:
|
|
188
|
+
output = mcq_set.to_pretty_str()
|
|
189
|
+
|
|
190
|
+
if args.output:
|
|
191
|
+
with open(args.output, "w", encoding="utf-8") as f:
|
|
192
|
+
f.write(output)
|
|
193
|
+
print(f"Saved {mcq_set.total_questions} questions to {args.output}", file=sys.stderr)
|
|
194
|
+
else:
|
|
195
|
+
print(output)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|