Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -624,123 +624,207 @@ def extract_json_from_llm_response(raw_response: str) -> dict:
|
|
| 624 |
logger.error("Sanitized JSON still invalid:\n%s", json_string)
|
| 625 |
raise
|
| 626 |
|
| 627 |
-
# def
|
| 628 |
# """
|
| 629 |
-
#
|
| 630 |
-
|
| 631 |
-
# Accepts:
|
| 632 |
-
# - a list of base64 strings → picks the first element
|
| 633 |
-
# - a PIL Image instance → encodes to PNG/base64
|
| 634 |
-
# - a raw base64 string → strips whitespace and data URI prefix
|
| 635 |
# """
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
# if not raw_b64:
|
| 637 |
-
# return ""
|
| 638 |
|
| 639 |
-
# # 1. If it’s a list, take its first element
|
| 640 |
# if isinstance(raw_b64, list):
|
| 641 |
# raw_b64 = raw_b64[0] if raw_b64 else ""
|
| 642 |
# if not raw_b64:
|
| 643 |
-
# return ""
|
| 644 |
|
| 645 |
-
# # 2. If it’s a PIL Image, convert to base64
|
| 646 |
# if isinstance(raw_b64, Image.Image):
|
| 647 |
# buf = io.BytesIO()
|
| 648 |
# raw_b64.save(buf, format="PNG")
|
| 649 |
# raw_b64 = base64.b64encode(buf.getvalue()).decode()
|
| 650 |
|
| 651 |
-
# # 3. At this point it must be a string
|
| 652 |
# if not isinstance(raw_b64, str):
|
| 653 |
# raise TypeError(f"Expected base64 string or PIL Image, got {type(raw_b64)}")
|
| 654 |
|
| 655 |
-
# #
|
| 656 |
# clean_b64 = re.sub(r"^data:image\/[a-zA-Z]+;base64,", "", raw_b64)
|
| 657 |
# clean_b64 = clean_b64.replace("\n", "").replace("\r", "").strip()
|
| 658 |
|
| 659 |
-
# #
|
| 660 |
-
#
|
| 661 |
-
#
|
| 662 |
-
#
|
| 663 |
-
#
|
| 664 |
-
#
|
| 665 |
-
|
| 666 |
-
#
|
| 667 |
-
#
|
| 668 |
-
|
| 669 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
"""
|
| 671 |
-
|
| 672 |
-
|
|
|
|
|
|
|
| 673 |
"""
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
| 677 |
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
# Load into PIL
|
| 683 |
-
img = Image.open(BytesIO(image_data))
|
| 684 |
-
|
| 685 |
-
low, high = 20, 95 # reasonable JPEG quality range
|
| 686 |
-
best_b64 = None
|
| 687 |
-
best_size_kb = 0
|
| 688 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
while low <= high:
|
| 690 |
mid = (low + high) // 2
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
if size_kb <= max_kb:
|
| 696 |
-
|
| 697 |
-
best_b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
| 698 |
-
best_size_kb = size_kb
|
| 699 |
low = mid + 1
|
| 700 |
else:
|
| 701 |
-
# Too big, try lower
|
| 702 |
high = mid - 1
|
| 703 |
|
| 704 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
|
| 706 |
-
|
| 707 |
-
def clean_base64_for_model(raw_b64):
|
| 708 |
-
import io, base64, re
|
| 709 |
-
from PIL import Image
|
| 710 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
if not raw_b64:
|
| 712 |
-
return ""
|
| 713 |
|
| 714 |
if isinstance(raw_b64, list):
|
| 715 |
raw_b64 = raw_b64[0] if raw_b64 else ""
|
| 716 |
if not raw_b64:
|
| 717 |
-
return ""
|
| 718 |
|
| 719 |
if isinstance(raw_b64, Image.Image):
|
| 720 |
buf = io.BytesIO()
|
| 721 |
-
|
| 722 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
|
| 724 |
if not isinstance(raw_b64, str):
|
| 725 |
raise TypeError(f"Expected base64 string or PIL Image, got {type(raw_b64)}")
|
| 726 |
|
| 727 |
-
#
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 744 |
|
| 745 |
SCRATCH_OPCODES = [
|
| 746 |
'motion_movesteps', 'motion_turnright', 'motion_turnleft', 'motion_goto',
|
|
|
|
| 624 |
logger.error("Sanitized JSON still invalid:\n%s", json_string)
|
| 625 |
raise
|
| 626 |
|
| 627 |
+
# def reduce_image_size_to_limit(clean_b64_str, max_kb=4000):
|
| 628 |
# """
|
| 629 |
+
# Reduce an image's size to be as close as possible to max_kb without exceeding it.
|
| 630 |
+
# Returns the final base64 string and its size in KB.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
# """
|
| 632 |
+
# import re, base64
|
| 633 |
+
# from io import BytesIO
|
| 634 |
+
# from PIL import Image
|
| 635 |
+
|
| 636 |
+
# # Remove the data URI prefix
|
| 637 |
+
# base64_data = re.sub(r"^data:image\/[a-zA-Z]+;base64,", "", clean_b64_str)
|
| 638 |
+
# image_data = base64.b64decode(base64_data)
|
| 639 |
+
|
| 640 |
+
# # Load into PIL
|
| 641 |
+
# img = Image.open(BytesIO(image_data))
|
| 642 |
+
|
| 643 |
+
# low, high = 20, 95 # reasonable JPEG quality range
|
| 644 |
+
# best_b64 = None
|
| 645 |
+
# best_size_kb = 0
|
| 646 |
+
|
| 647 |
+
# while low <= high:
|
| 648 |
+
# mid = (low + high) // 2
|
| 649 |
+
# buffer = BytesIO()
|
| 650 |
+
# img.save(buffer, format="JPEG", quality=mid)
|
| 651 |
+
# size_kb = len(buffer.getvalue()) / 1024
|
| 652 |
+
|
| 653 |
+
# if size_kb <= max_kb:
|
| 654 |
+
# # This quality is valid, try higher
|
| 655 |
+
# best_b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
| 656 |
+
# best_size_kb = size_kb
|
| 657 |
+
# low = mid + 1
|
| 658 |
+
# else:
|
| 659 |
+
# # Too big, try lower
|
| 660 |
+
# high = mid - 1
|
| 661 |
+
|
| 662 |
+
# return f"data:image/jpeg;base64,{best_b64}"
|
| 663 |
+
|
| 664 |
+
# #clean the base64 model here
|
| 665 |
+
# def clean_base64_for_model(raw_b64):
|
| 666 |
+
# import io, base64, re
|
| 667 |
+
# from PIL import Image
|
| 668 |
+
|
| 669 |
# if not raw_b64:
|
| 670 |
+
# return "", ""
|
| 671 |
|
|
|
|
| 672 |
# if isinstance(raw_b64, list):
|
| 673 |
# raw_b64 = raw_b64[0] if raw_b64 else ""
|
| 674 |
# if not raw_b64:
|
| 675 |
+
# return "", ""
|
| 676 |
|
|
|
|
| 677 |
# if isinstance(raw_b64, Image.Image):
|
| 678 |
# buf = io.BytesIO()
|
| 679 |
# raw_b64.save(buf, format="PNG")
|
| 680 |
# raw_b64 = base64.b64encode(buf.getvalue()).decode()
|
| 681 |
|
|
|
|
| 682 |
# if not isinstance(raw_b64, str):
|
| 683 |
# raise TypeError(f"Expected base64 string or PIL Image, got {type(raw_b64)}")
|
| 684 |
|
| 685 |
+
# # Remove data URI prefix if present
|
| 686 |
# clean_b64 = re.sub(r"^data:image\/[a-zA-Z]+;base64,", "", raw_b64)
|
| 687 |
# clean_b64 = clean_b64.replace("\n", "").replace("\r", "").strip()
|
| 688 |
|
| 689 |
+
# # Log original size
|
| 690 |
+
# original_size = len(clean_b64.encode("utf-8"))
|
| 691 |
+
# print(f"Original Base64 size (bytes): {original_size}")
|
| 692 |
+
# if original_size > 4000000:
|
| 693 |
+
# # Reduce size to under 4 MB
|
| 694 |
+
# reduced_b64 = reduce_image_size_to_limit(clean_b64, max_kb=4000)
|
| 695 |
+
# clean_b64_2 = re.sub(r"^data:image\/[a-zA-Z]+;base64,", "", reduced_b64)
|
| 696 |
+
# clean_b64_2 = clean_b64_2.replace("\n", "").replace("\r", "").strip()
|
| 697 |
+
# reduced_size = len(clean_b64_2.encode("utf-8"))
|
| 698 |
+
# print(f"Reduced Base64 size (bytes): {reduced_size}")
|
| 699 |
+
# # Return both prefixed and clean reduced versions
|
| 700 |
+
# return f"data:image/jpeg;base64,{reduced_b64}"
|
| 701 |
+
# return f"data:image/jpeg;base64,{clean_b64}"
|
| 702 |
+
|
| 703 |
+
def reduce_image_size_to_limit(clean_b64_str: str, max_kb: int = 4000) -> str:
|
| 704 |
"""
|
| 705 |
+
Input: clean_b64_str = BASE64 STRING (no data: prefix)
|
| 706 |
+
Output: BASE64 STRING (no data: prefix), sized as close as possible to max_kb KB.
|
| 707 |
+
Guarantees: returns a valid base64 string (never None). May still be larger than max_kb
|
| 708 |
+
if saving at lowest quality cannot get under the limit.
|
| 709 |
"""
|
| 710 |
+
# sanitize
|
| 711 |
+
clean = re.sub(r"\s+", "", clean_b64_str).strip()
|
| 712 |
+
# fix padding
|
| 713 |
+
missing = len(clean) % 4
|
| 714 |
+
if missing:
|
| 715 |
+
clean += "=" * (4 - missing)
|
| 716 |
|
| 717 |
+
try:
|
| 718 |
+
image_data = base64.b64decode(clean)
|
| 719 |
+
except Exception as e:
|
| 720 |
+
raise ValueError("Invalid base64 input to reduce_image_size_to_limit") from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
|
| 722 |
+
try:
|
| 723 |
+
img = Image.open(io.BytesIO(image_data))
|
| 724 |
+
img.load()
|
| 725 |
+
except Exception as e:
|
| 726 |
+
raise ValueError("Could not open image from base64") from e
|
| 727 |
+
|
| 728 |
+
# convert alpha -> RGB because JPEG doesn't support alpha
|
| 729 |
+
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
|
| 730 |
+
background = Image.new("RGB", img.size, (255, 255, 255))
|
| 731 |
+
background.paste(img, mask=img.split()[-1] if img.mode != "RGB" else None)
|
| 732 |
+
img = background
|
| 733 |
+
elif img.mode != "RGB":
|
| 734 |
+
img = img.convert("RGB")
|
| 735 |
+
|
| 736 |
+
low, high = 20, 95
|
| 737 |
+
best_bytes = None
|
| 738 |
+
# binary search for best quality
|
| 739 |
while low <= high:
|
| 740 |
mid = (low + high) // 2
|
| 741 |
+
buf = io.BytesIO()
|
| 742 |
+
try:
|
| 743 |
+
img.save(buf, format="JPEG", quality=mid, optimize=True)
|
| 744 |
+
except OSError:
|
| 745 |
+
# some PIL builds/channels may throw on optimize=True; fallback without optimize
|
| 746 |
+
buf = io.BytesIO()
|
| 747 |
+
img.save(buf, format="JPEG", quality=mid)
|
| 748 |
+
size_kb = len(buf.getvalue()) / 1024.0
|
| 749 |
if size_kb <= max_kb:
|
| 750 |
+
best_bytes = buf.getvalue()
|
|
|
|
|
|
|
| 751 |
low = mid + 1
|
| 752 |
else:
|
|
|
|
| 753 |
high = mid - 1
|
| 754 |
|
| 755 |
+
# if never found a quality <= max_kb, use the smallest we created (quality = 20)
|
| 756 |
+
if best_bytes is None:
|
| 757 |
+
buf = io.BytesIO()
|
| 758 |
+
try:
|
| 759 |
+
img.save(buf, format="JPEG", quality=20, optimize=True)
|
| 760 |
+
except OSError:
|
| 761 |
+
buf = io.BytesIO()
|
| 762 |
+
img.save(buf, format="JPEG", quality=20)
|
| 763 |
+
best_bytes = buf.getvalue()
|
| 764 |
|
| 765 |
+
return base64.b64encode(best_bytes).decode("utf-8")
|
|
|
|
|
|
|
|
|
|
| 766 |
|
| 767 |
+
|
| 768 |
+
def clean_base64_for_model(raw_b64, max_bytes_threshold=4000000) -> str:
|
| 769 |
+
"""
|
| 770 |
+
Accepts: raw_b64 can be:
|
| 771 |
+
- a data URI 'data:image/png;base64,...'
|
| 772 |
+
- a plain base64 string
|
| 773 |
+
- a PIL Image
|
| 774 |
+
- a list containing the above (take first)
|
| 775 |
+
Returns: a data URI string 'data:<mime>;base64,<base64>' guaranteed to be syntactically valid.
|
| 776 |
+
"""
|
| 777 |
+
# normalize input
|
| 778 |
if not raw_b64:
|
| 779 |
+
return ""
|
| 780 |
|
| 781 |
if isinstance(raw_b64, list):
|
| 782 |
raw_b64 = raw_b64[0] if raw_b64 else ""
|
| 783 |
if not raw_b64:
|
| 784 |
+
return ""
|
| 785 |
|
| 786 |
if isinstance(raw_b64, Image.Image):
|
| 787 |
buf = io.BytesIO()
|
| 788 |
+
# convert to RGB and save as JPEG to keep consistent
|
| 789 |
+
img = raw_b64.convert("RGB")
|
| 790 |
+
img.save(buf, format="JPEG")
|
| 791 |
+
clean_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
| 792 |
+
mime = "image/jpeg"
|
| 793 |
+
return f"data:{mime};base64,{clean_b64}"
|
| 794 |
|
| 795 |
if not isinstance(raw_b64, str):
|
| 796 |
raise TypeError(f"Expected base64 string or PIL Image, got {type(raw_b64)}")
|
| 797 |
|
| 798 |
+
# detect mime if present; otherwise default to png
|
| 799 |
+
m = re.match(r"^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$", raw_b64, flags=re.DOTALL)
|
| 800 |
+
if m:
|
| 801 |
+
mime = m.group(1)
|
| 802 |
+
clean_b64 = m.group(2)
|
| 803 |
+
else:
|
| 804 |
+
# no prefix; assume png by default (you can change to jpeg if you prefer)
|
| 805 |
+
mime = "image/png"
|
| 806 |
+
clean_b64 = raw_b64
|
| 807 |
+
|
| 808 |
+
# sanitize base64 string
|
| 809 |
+
clean_b64 = re.sub(r"\s+", "", clean_b64).strip()
|
| 810 |
+
missing = len(clean_b64) % 4
|
| 811 |
+
if missing:
|
| 812 |
+
clean_b64 += "=" * (4 - missing)
|
| 813 |
+
|
| 814 |
+
original_size_bytes = len(clean_b64.encode("utf-8"))
|
| 815 |
+
# debug print
|
| 816 |
+
print(f"Original base64 size (bytes): {original_size_bytes}, mime: {mime}")
|
| 817 |
+
|
| 818 |
+
if original_size_bytes > max_bytes_threshold:
|
| 819 |
+
# reduce and return JPEG prefixed data URI (JPEG tends to compress better for photos)
|
| 820 |
+
reduced_clean = reduce_image_size_to_limit(clean_b64, max_kb=4000)
|
| 821 |
+
# reduced_clean is plain base64 (no prefix)
|
| 822 |
+
print(f"Reduced base64 size (bytes): {original_size_bytes}, mime: {mime}")
|
| 823 |
+
return f"data:image/jpeg;base64,{reduced_clean}"
|
| 824 |
+
|
| 825 |
+
# otherwise return original with its mime prefix (ensure prefix exists)
|
| 826 |
+
return f"data:{mime};base64,{clean_b64}"
|
| 827 |
+
|
| 828 |
|
| 829 |
SCRATCH_OPCODES = [
|
| 830 |
'motion_movesteps', 'motion_turnright', 'motion_turnleft', 'motion_goto',
|