import magic import exifread import xml.etree.ElementTree as ET from PIL import Image from PIL.ExifTags import TAGS, GPSTAGS from PIL import ExifTags import re from typing import Dict, Any, Optional, List, Union, Tuple from src.metadata_parser import MetadataParser from src.helpers.makernote_parser import MakerNoteHelper from src.helpers.image_utility_helper import load_image_resource from src.helpers.logger import logger # ------------------------- # Standalone functions # ------------------------- def load_image_data(image_path: str, input_type: str = None) -> tuple: """ Separate function to load image raw bytes and PIL object. Supports URL, Base64 and Local Path. """ return load_image_resource(image_path, input_type) class ImageMetadataDetector: def __init__(self): self.image_path = None self.image = None self.raw_bytes = None # ------------------------- # Public API # ------------------------- def predict(self, image_path: str, input_type: str = None) -> dict: """ Main entry point for the FastAPI app. """ result = self.detect(image_path, input_type) structured = MetadataParser.parse(result) return structured.model_dump(mode="json") def detect(self, image_path: str, input_type: str = None) -> dict: """ Main entry point. Detects file type, metadata types, and parses all metadata. """ logger.info(f"Detecting metadata for {image_path} (type: {input_type})") self.image_path = image_path self.load_image(image_path, input_type) result = { "file_path": self.image_path, "mime_type": self._detect_mime_type(), "image_format": self.image.format if self.image else None, "size": self.image.size if self.image else None, "size_bytes": len(self.raw_bytes), "color_space": self._detect_color_space(), "metadata_types": {}, "metadata": {} } # result["metadata"]["additional_metadata"] = {} meta_types = self._detect_metadata_types() result["metadata_types"] = meta_types if meta_types["exif"]: exif_res = self._parse_exif() result["metadata"]["exif"] = exif_res # Check for MakerNote specific parsing (Apple, etc) if "MakerNote" in exif_res or "MakerNote" in exif_res.get("ExifIFD", {}): result["metadata"]["makernote"] = self._parse_makernote(exif_res) if meta_types["iptc"]: result["metadata"]["iptc"] = self._parse_iptc() if meta_types["xmp"]: result["metadata"]["xmp"] = self._parse_xmp() # if meta_types["icc"]: # result["metadata"]["icc"] = self._parse_icc() if meta_types["png_text"]: raw, parsed_xmp = self._parse_png_text() result["metadata"]["png_text"] = raw if parsed_xmp: result["metadata"]["png_text_parsed"] = { "xmp": parsed_xmp } # return result return self._make_json_safe(result) # ------------------------- # Internal helpers # ------------------------- def load_image(self, image_path: str, input_type: str = None): """ Loads the image and stores it in the instance. """ self.image_path = image_path self.image, self.raw_bytes = load_image_data(image_path, input_type) def _convert_gps(self, gps): def to_deg(value): d, m, s = value return d[0]/d[1] + (m[0]/m[1])/60 + (s[0]/s[1])/3600 lat = to_deg(gps["GPSLatitude"]) if gps.get("GPSLatitudeRef") == "S": lat = -lat lon = to_deg(gps["GPSLongitude"]) if gps.get("GPSLongitudeRef") == "W": lon = -lon return {"lat": lat, "lon": lon} # ------------------------- # Detection # ------------------------- def _detect_mime_type(self) -> str: mime = magic.Magic(mime=True) return mime.from_buffer(self.raw_bytes) def _detect_color_space(self) -> Optional[str]: if not self.image: return None # 1. Check EXIF try: exif = self.image.getexif() if exif: # 0xA001 is ColorSpace tag cs = exif.get(0xA001) if cs == 1: return "sRGB" if cs == 2: return "Adobe RGB" if cs == 65535: return "Uncalibrated" except Exception: pass # 2. Check PIL info (PNG sRGB chunk or ICC Profile) if "srgb" in self.image.info: return "sRGB" if "icc_profile" in self.image.info: return "ICC Profile" return None def _detect_metadata_types(self) -> dict: meta = { "exif": False, "iptc": False, "xmp": False, "png_text": False } # EXIF if self.image and hasattr(self.image, "getexif"): try: if self.image.getexif(): meta["exif"] = True except Exception: pass # PNG text chunks if self.image and self.image.format == "PNG" and self.image.info: meta["png_text"] = True # XMP (embedded XML) if b" dict: """ Extract full EXIF including: - IFD0 - ExifIFD - GPSInfo - Interoperability IFD """ exif_data = {} exif = self.image.getexif() # ---- IFD0 + ExifIFD ---- for tag_id, value in exif.items(): tag_name = TAGS.get(tag_id, tag_id) # GPS handled separately if tag_name == "GPSInfo": continue exif_data[tag_name] = self._serialize(value) # add additional tags exif_data["additional"] = {} # ---- GPS IFD ---- gps_info = exif.get_ifd(ExifTags.IFD.GPSInfo) #0x8825 if gps_info: gps_data = {} for tag_id, value in gps_info.items(): tag_name = GPSTAGS.get(tag_id, tag_id) gps_data[tag_name] = self._serialize(value) exif_data["GPSInfo"] = gps_data # ---- Exif SubIFD ---- exif_ifd = exif.get_ifd(ExifTags.IFD.Exif) # ExifOffset 0x8769 if exif_ifd: exif_sub = {} for tag_id, value in exif_ifd.items(): tag_name = TAGS.get(tag_id, tag_id) exif_sub[tag_name] = self._serialize(value) exif_data["ExifIFD"] = exif_sub # ---- Interoperability IFD ---- interop_ifd = exif.get_ifd(ExifTags.IFD.IFD1) # InteropIFD 0xA005 if interop_ifd: interop = {} for tag_id, value in interop_ifd.items(): tag_name = TAGS.get(tag_id, tag_id) interop[tag_name] = self._serialize(value) exif_data["InteropIFD"] = interop # 3. Check for depth map information if "depth_images" in self.image.info and self.image.info["depth_images"]: logger.info("Depth map found.") exif_data["additional"]["contains_depth_map"] = True exif_data["additional"]["depth_map"] = self.image.info["depth_images"][0] # exif_data["InteropIFD"]["depth_map"] = "depth_map" # Usually, the first depth image is the one needed # depth_image = image.info["depth_images"][0] # Save or convert the depth map to a numpy array for processing # depth_image.save(output_depth_path) # print(f"Depth map saved to {output_depth_path}") # To convert to numpy for CV applications # depth_array = np.array(depth_image) # return depth_array # else: # print("No depth map found in this HEIC image.") # return None return exif_data def _parse_iptc(self) -> dict: iptc_data = {} with open(self.image_path, "rb") as f: tags = exifread.process_file(f, details=False) for tag, value in tags.items(): if tag.startswith("IPTC"): iptc_data[tag] = str(value) return iptc_data def _parse_xmp(self) -> dict: start = self.raw_bytes.find(b"") if start == -1 or end == -1: return {} end += len(b"") xmp_bytes = self.raw_bytes[start:end] try: root = ET.fromstring(xmp_bytes) return self._xml_to_dict(root) except Exception: return {"raw_xmp": xmp_bytes.decode(errors="ignore")} def _parse_makernote(self, exif_res: dict) -> dict: """ Extract and map MakerNote tags using MakerNoteHelper for deeper parsing. """ make = exif_res.get("Make", "Unknown") return MakerNoteHelper.parse_makernotes(self.image_path, make, self._serialize, exif_res) # def _parse_png_text(self) -> dict: def _parse_png_text(self): raw = dict(self.image.info) parsed_xmp = {} for key, value in raw.items(): if isinstance(value, str) and " dict: """ Parse XMP XML string into structured dict. """ try: # Remove xpacket headers xmp_str = re.sub(r"<\?xpacket.*?\?>", "", xmp_str, flags=re.DOTALL).strip() root = ET.fromstring(xmp_str) parsed = {} # Traverse RDF descriptions for elem in root.iter(): tag = elem.tag.split("}")[-1] # Attributes (THIS is where most XMP data lives) if elem.attrib: for k, v in elem.attrib.items(): key = k.split("}")[-1] parsed[key] = v # Text nodes (rare in XMP) if elem.text and elem.text.strip(): parsed[tag] = elem.text.strip() return parsed except Exception as e: return { "_error": "XMP parse failed", "_raw": xmp_str[:1000] } if __name__ == "__main__": import json import os import glob from pillow_heif import register_heif_opener register_heif_opener() # def natural_sort_key(path): # return [int(t) if t.isdigit() else t.lower() # for t in re.split(r'(\d+)', path)] # images = sorted(glob.glob("inputs/tests/*.*"),key=natural_sort_key) # images = ["inputs/tests/12_ip17pm.HEIC"] # images = ["inputs/tests/3_photo.jpg"] # images = ["inputs/tests/7_ai.png"] # images = ["inputs/tests/9_ai.jpeg"] images = ["/Users/rohan/Developer/2026/myaidetector/datasets/real/real (2).HEIC"] all_results = {} all_parsed_results = {} for image_path in images: if not os.path.isfile(image_path): logger.warning(f"Skipping {image_path} because it does not exist") continue detector = ImageMetadataDetector() result = detector.detect(image_path, "local_path") print(result) all_results[image_path] = result structured = MetadataParser.parse(result) all_parsed_results[image_path] = structured.model_dump(mode="json") # print(f"\n--- Metadata for {image_path} ---\n") # print(json.dumps(result, indent=2)) # print(f"\n--- Formatted ---\n") # print(structured.model_dump_json(indent=2)) # write once, after the loop with open("outputs/imageMetadataData.json", "w", encoding="utf-8") as f: json.dump(all_results, f, ensure_ascii=False, indent=4, sort_keys=True) logger.info("All metadata saved to outputs/imageMetadataData.json") with open("outputs/imageParsedData.json", "w", encoding="utf-8") as f: json.dump(all_parsed_results, f, ensure_ascii=False, indent=4) logger.info("All parsed metadata saved to outputs/imageParsedData.json") print(f"\n--- All parsed metadata saved to outputs/imageParsedData.json ---\n")