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',
|