Miscs improvements (Interro02)
This commit is contained in:
@@ -10,7 +10,7 @@ from pdf2image import convert_from_path
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
||||
|
||||
from copienator.commands import annotating
|
||||
from copienator import utils
|
||||
from copienator import configuration, utils
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
@@ -24,6 +24,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov
|
||||
from copienator.answer_info import build_answer_info
|
||||
from copienator.annotation_data import AnnotationData, load_annotation_data
|
||||
from copienator.filesystem import staged_files
|
||||
from copienator.return_answers import save_return_answer_options
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
@@ -69,10 +70,11 @@ def detect_checks_and_notes(
|
||||
print(f" Resizing annotated PDF from {user_image.size} to {reference.size}")
|
||||
user_image = user_image.resize(reference.size, Image.Resampling.LANCZOS)
|
||||
|
||||
difference = np.abs(
|
||||
np.array(reference).astype(int) - np.array(user_image).astype(int)
|
||||
).astype(np.uint8)
|
||||
difference_gray = np.mean(difference, axis=2)
|
||||
# Keep the full-size difference in uint8. Converting both tall group images
|
||||
# to the platform ``int`` dtype used several gigabytes per scan worker.
|
||||
difference = np.asarray(
|
||||
ImageChops.difference(reference, user_image), dtype=np.uint8
|
||||
)
|
||||
keep_mask = Image.new("L", reference.size, 255)
|
||||
mask_draw = ImageDraw.Draw(keep_mask)
|
||||
actions: list[dict[str, Any]] = []
|
||||
@@ -83,10 +85,14 @@ def detect_checks_and_notes(
|
||||
x1, y1, x2, y2 = map(int, raw_box["global_box"])
|
||||
x1, y1 = max(0, x1), max(0, y1)
|
||||
x2, y2 = min(reference.width, x2), min(reference.height, y2)
|
||||
region = difference_gray[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
|
||||
region = difference[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
|
||||
if region.size == 0:
|
||||
continue
|
||||
density = np.sum(region > 30) / region.size
|
||||
# Preserve the previous mean-across-RGB threshold, but allocate its
|
||||
# temporary float array only for the small checkbox region.
|
||||
density = np.count_nonzero(np.mean(region, axis=2) > 30) / (
|
||||
region.shape[0] * region.shape[1]
|
||||
)
|
||||
if density > 0.05:
|
||||
actions.append(raw_box)
|
||||
mask_draw.rectangle([x1 - 15, y1 - 15, x2 + 15, y2 + 15], fill=0)
|
||||
@@ -95,13 +101,17 @@ def detect_checks_and_notes(
|
||||
if raw_box.get("type") == "score" and raw_box.get("value") == 0.0:
|
||||
mask_draw.rectangle([0, y1 - 10, reference.width, y2 + 10], fill=0)
|
||||
|
||||
del difference
|
||||
reference_blur = reference.filter(ImageFilter.GaussianBlur(2))
|
||||
user_blur = user_image.filter(ImageFilter.GaussianBlur(2))
|
||||
diff_image = ImageChops.difference(reference_blur, user_blur).convert("L")
|
||||
alpha = np.where(np.array(diff_image) > 50, 255, 0).astype(np.uint8)
|
||||
final_alpha = np.minimum(alpha, np.array(keep_mask))
|
||||
del reference_blur, user_blur
|
||||
alpha = np.asarray(diff_image, dtype=np.uint8).copy()
|
||||
np.greater(alpha, 50, out=alpha)
|
||||
alpha *= np.uint8(255)
|
||||
np.minimum(alpha, np.asarray(keep_mask, dtype=np.uint8), out=alpha)
|
||||
notes = user_image.convert("RGBA")
|
||||
notes.putalpha(Image.fromarray(final_alpha))
|
||||
notes.putalpha(Image.fromarray(alpha))
|
||||
return actions, notes
|
||||
|
||||
|
||||
@@ -150,8 +160,10 @@ def apply_actions_and_regenerate(
|
||||
|
||||
labels_data = data[student_id]
|
||||
apply_checkbox_actions(labels_data, actions, print)
|
||||
score_path = output_dir / "score.json"
|
||||
preserve_score_file = update_score and score_path.is_file()
|
||||
if update_score:
|
||||
apply_score_overrides(labels_data, output_dir / "score.json", print)
|
||||
apply_score_overrides(labels_data, score_path, print)
|
||||
|
||||
scores = dict.fromkeys(all_labels, "")
|
||||
answer_labels: list[str] = []
|
||||
@@ -220,7 +232,8 @@ def apply_actions_and_regenerate(
|
||||
"touched.json", "answer_labels.json")) as staging:
|
||||
for label, image in dirty_images.items():
|
||||
image.save(staging / f"{label}.jpg")
|
||||
atomic_write_json(staging / "score.json", scores)
|
||||
if not preserve_score_file:
|
||||
atomic_write_json(staging / "score.json", scores)
|
||||
atomic_write_json(staging / "info.json", build_answer_info(
|
||||
scores, labels_data, answer_labels
|
||||
))
|
||||
@@ -229,13 +242,41 @@ def apply_actions_and_regenerate(
|
||||
if filtered_image is not None:
|
||||
filtered_image.save(staging / "Concat_F.jpg")
|
||||
|
||||
if preserve_score_file:
|
||||
print(f" Preserved existing score.json in {output_dir}")
|
||||
print(f" Saved regenerated files in {output_dir}")
|
||||
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, update_score: bool = False) -> ExitCode:
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
*,
|
||||
update_score: bool = False,
|
||||
return_answers_context: bool | None = None,
|
||||
return_answers_question: bool | None = None,
|
||||
return_answers_solution: bool | None = None,
|
||||
) -> ExitCode:
|
||||
workspace.require_files("labels", "correction.json")
|
||||
workspace.require_directories("Copies", "Par label", "Bnot")
|
||||
if configuration.RETURN_ANSWERS_ENABLED:
|
||||
save_return_answer_options(
|
||||
workspace.root,
|
||||
context=(
|
||||
configuration.RETURN_ANSWERS_CONTEXT
|
||||
if return_answers_context is None
|
||||
else return_answers_context
|
||||
),
|
||||
question=(
|
||||
configuration.RETURN_ANSWERS_QUESTION
|
||||
if return_answers_question is None
|
||||
else return_answers_question
|
||||
),
|
||||
solution=(
|
||||
configuration.RETURN_ANSWERS_SOLUTION
|
||||
if return_answers_solution is None
|
||||
else return_answers_solution
|
||||
),
|
||||
)
|
||||
all_labels = utils.read_all_labels(workspace.root)
|
||||
loaded = load_annotation_data(workspace)
|
||||
for warning in loaded.warnings:
|
||||
@@ -276,7 +317,28 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--update-score",
|
||||
action="store_true",
|
||||
help="Override generated scores with values from existing score.json files",
|
||||
help=(
|
||||
"Regenerate images with current statement/solution PDFs while "
|
||||
"preserving and applying existing score.json values"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--return-answers-context",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=configuration.RETURN_ANSWERS_CONTEXT,
|
||||
help="Include applicable context pages in individual answer exports",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--return-answers-question",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=configuration.RETURN_ANSWERS_QUESTION,
|
||||
help="Include the current question PDF in individual answer exports",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--return-answers-solution",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=configuration.RETURN_ANSWERS_SOLUTION,
|
||||
help="Include the current solution PDF in individual answer exports",
|
||||
)
|
||||
return parser
|
||||
|
||||
@@ -285,7 +347,13 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
return run(workspace_from_args(args), update_score=args.update_score)
|
||||
return run(
|
||||
workspace_from_args(args),
|
||||
update_score=args.update_score,
|
||||
return_answers_context=args.return_answers_context,
|
||||
return_answers_question=args.return_answers_question,
|
||||
return_answers_solution=args.return_answers_solution,
|
||||
)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user