Analyze and Write tags

#10
by Tetsuoo - opened

Hi, I end up on this page because...
I'm looking for a way to analyze images in my folder, get Danbooru tags for each image then write them back into the images as metadatas. Is that possible ?

Owner

Hi @Tetsuoo I guess you can ask ChatGPT about it. Anyway, I got curious and asked it, so I'll share it:

Apparently, the way to add metadata is different for png and jpg.
For png, you can use this function:

import PIL.Image
from PIL import PngImagePlugin


def add_metadata_png(image_path: str, tags: str, out_path: str) -> None:
    metadata = PngImagePlugin.PngInfo()
    metadata.add_text("tags", tags)
    image = PIL.Image.open(image_path)
    image.save(out_path, pnginfo=metadata)

and for jpg, you can use this:

import piexif
import PIL.Image


def add_metadata_jpg(image_path: str, tags: str, out_path: str) -> None:
    image = PIL.Image.open(image_path)
    exif_dict = piexif.load(image.info["exif"]) if "exif" in image.info else {}
    exif_dict["Exif"] = exif_dict.get("Exif", {})
    exif_dict["Exif"][piexif.ExifIFD.UserComment] = tags.encode("utf-8")
    exif_bytes = piexif.dump(exif_dict)
    image.save(out_path, "JPEG", exif=exif_bytes)

wow I didn't expect an answer so fast ^^ Thank you
I tried the GPT from free Bing/Copilot (I don't think it's v4, probably 3.5?), but turns out Claude AI was better for me and more accurate. Actually I don't know Python coding !! (a bit of PHP-HTML-Javascript that's it) it's too hard for me but doable with the help of AI :) (and possibly real coders x) )
So after spending 2 days on it, finally I have something that works; not perfect but it does what I want, here if you're interested : https://www.mediafire.com/file/bfoqbw7nf9wi6mv/app_v4.py/file
I'm using XMP instead of EXIF, because if I'm not wrong AI generated images also use EXIF comment field so that way I avoid data collisions. Also XMP is compatible with both PNG and JPG. I also discovered that changing the image in Photoshop won't remove the metadatas, that's convenient.
I'd like to implement many changes like 1 tab for single image another for Batch folder processing (like Tagging extension for WebUI), but it's getting complex and even AI is starting to struggle more lol
I wish I could keep the original datetime properties of the input image files etc I'm sure there's still a lot of work to do on this...
Anyway hope you give it a look and tell me what you think ! :D

Edit : ah I forgot, you will need to install pyexiftool but it's also possible to use ExifTool.exe and call it directly. I put the resnet model and tags.txt in a '_resources' folder
Edit2 : I'll have to test with AI images generated with ComfyUI... not sure I can avoid meta collisions this time, we'll see

Owner

Nice! I didn't know about the XMP format, but it sounds useful. Thanks for sharing!
Also, thanks for sharing your code. Looks nice! I kind of feel it might make sense to make it a command line tool instead of a gradio app if you want to process all the images in a directory, but it just depends on your use case and preference, so I think gradio app is totally fine too.

Well, I love having an UI so I can choose a folder or drag and drop a single image to process, I'm not exactly a commandline guy unless I really, really have no other choice lol
BUT Gradio is not making the coding any easier for me as for every input images it creates a copy to work on; that means new images with a new datetime... not what I wanted. I'm pretty sure it worked once but I lost the code somewhere within all the failed attempts... this is all probably easy when you know what you're doing haha

okay... I need to accept that this is not possible x) Gradio cannot preserve original datetimes, I saw it working once but was probably hallucinating
So... what would be your approach for a command line tool ? Do I need to learn to compile and stuff

As for the timestamp of the original files, I think exiftool is appending _original to the original filename, so it will be updated anyway. So, maybe you might want to simply copy the original directory first and apply your code to the copied directory to get images with metadata. You can use shutil.copytree to copy directories.

Regarding the command line tool, I was thinking of something like this:

import argparse
import pathlib
import shutil

import deepdanbooru as dd
import exiftool
import numpy as np
import PIL.Image
import tensorflow as tf
import tqdm.auto


class Model:
    def __init__(self, model_path: str, labels_path: str):
        self.model = tf.keras.models.load_model(model_path)
        with open(labels_path) as f:
            self.labels = [line.strip() for line in f.readlines()]

    def predict(self, image: PIL.Image.Image) -> dict[str, float]:
        _, height, width, _ = self.model.input_shape
        image = np.asarray(image)
        image = tf.image.resize(
            image,
            size=(height, width),
            method=tf.image.ResizeMethod.AREA,
            preserve_aspect_ratio=True,
        )
        image = image.numpy()
        image = dd.image.transform_and_pad_image(image, width, height)
        image = image / 255.0
        probs = self.model.predict(image[None, ...])[0]
        probs = probs.astype(float)

        indices = np.argsort(probs)[::-1]
        result = dict()
        for index in indices:
            label = self.labels[index]
            prob = probs[index]
            result[label] = prob
        return result


def write_tags_to_metadata(image_path: str, tags: list[str]) -> None:
    with exiftool.ExifTool() as et:
        # Clear existing XMP subjects
        et.execute("-XMP-dc:Subject=", image_path)

        # Write each tag as a separate XMP subject
        for tag in tags:
            et.execute(f"-XMP-dc:Subject+={tag}", image_path)


def run(image_path: pathlib.Path, score_threshold: float, model: Model) -> None:
    image = PIL.Image.open(image_path)
    result = model.predict(image)
    tags = [tag for tag, prob in result.items() if prob >= score_threshold]
    write_tags_to_metadata(image_path.as_posix(), tags)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--input-dir", "-i", type=str, required=True)
    parser.add_argument("--score-threshold", type=float, default=0.5)
    parser.add_argument("--output-dir", "-o", type=str, required=True)
    parser.add_argument(
        "--model-path", type=str, default="_resources/model-resnet_custom_v3.h5"
    )
    parser.add_argument("--labels-path", type=str, default="_resources/tags.txt")
    args = parser.parse_args()

    model = Model(args.model_path, args.labels_path)

    output_dir = pathlib.Path(args.output_dir)
    shutil.copytree(args.input_dir, output_dir)

    paths = [
        path
        for path in sorted(output_dir.rglob("*"))
        if path.suffix.lower() in [".jpg", ".png", ".bmp"]
    ]

    for path in tqdm.auto.tqdm(paths):
        run(image_path=path, score_threshold=args.score_threshold, model=model)


if __name__ == "__main__":
    main()

You can use it like this:

python run.py -i input_images -o output_images

It copies input_dir to output_dir and add metadata to images in the output_dir. As I mentioned above, exiftool seems to copy and rename the original images, you might want to add code to remove files with the _original suffix.

Thank you for this :) I managed to run it without help so I guess I learned a few things on the way hehe
So far I could notice :

  • the program will break if output_dir already exist [WinError 183]. That reminds me when chatGPT was telling me to use makedir and then I had the same issue back then
  • as expected, because they are copies the timestamps are not preserved on the new tagged files. Rather, they get the present time. So maybe I should look for a way to transfer that from the _original files ?

The good thing with this commandline method is it's easier and faster to test, with every change I make I don't need to close/restart it like the Gradio method. Very convenient :)
Your code is completely different ! So more things for me to figure out (with the help of AI ;) ) but at least the paths seem more straightforward ? With Gradio and AI trying to patch my issues, it gave me headache with the intricate path variables. With nonsense like this :

original_folder_path = Path(image_path).parent.parent
original_file_path = original_folder_path / Path(image_path).name
copy2(image_path, original_file_path)

...I'm like, what ? x) I could read it 1,2 or 10 times and I still won't get it lol

Edit: I just realize why this datestamp transfer will never work... I was looking at the wrong place, datestamps are in file properties not metadatas (if that make sense)... This would probably work with images from a photocamera because the datestamps are stored in EXIF, that's not the case with any random image x)

YES !!! Ok no, not quite, but I'm almost there.
For some reason I failed every time I tried to use (py)exiftool to copy the timestamps. Took me HOURS to find out that it works using a subprocess instead :

(the file --> https://www.mediafire.com/file/c1cqc6nx83zs6qq/app_cmd_v1.py/file)

It kinda works, with a few quirks here and there :

  • the tagging works only with JPG and if filenames have a space in it. Very unusual. Really strange.

  • for the datestamps copy it's the opposite, it won't work if the filenames have a space in it x)
    I think it's a mess because I'm using pyExiftool to write the tags, but a subprocess of ExifTool.exe for the datestamps x) So maybe they both function a bit differently

  • Most PNG files won't work and will provoke weird "node convolution" errors, it's another mystery. Sometimes one of the PNG will success, leving the others in the dust. No idea what's going on.

  • of course WEBP fails.. ^^;

    import os
    import subprocess

    # Get a list of all files in the input folder
    input_files = os.listdir(input_dir)

    for filename in input_files:
        input_path = os.path.join(input_dir, filename)
        output_path = os.path.join(output_dir, filename)

        # Construct the exiftool command - copy all properties from the input images
        exiftool_command = f"exiftool -TagsFromFile {input_path} -All:All -FileModifyDate -FileCreateDate {output_path}"

        # Execute the command
        subprocess.run(exiftool_command, shell=True)

    print("Exiftool commands executed for all files.")

    folder_path = output_dir

    # Get a list of all files in the folder
    file_list = os.listdir(folder_path)

    # Iterate through the files and delete those with the "_original" suffix
    for filename in file_list:
        if filename.endswith("_original"):
            file_path = os.path.join(folder_path, filename)
            os.remove(file_path)
            print(f"Deleted: {file_path}")

    print("All files with '_original' suffix have been deleted.")

if __name__ == "__main__":
    main()

(hey how do you get colored code in there x) ) (Cool it works lol)

Ah, I misunderstood what you wanted to do about the timestamps. I thought you wanted to keep the original files intact, but you wanted to add the same timestamps to the images with metadata.

In that case, ChatGPT says that you can do something like this:

original_timestamp = os.path.getmtime(original_file)

os.utime(image_path, (original_timestamp, original_timestamp))

(hey how do you get colored code in there x) )

You can add three backticks and a language identifier around your code block. https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks#syntax-highlighting
The language identifier for Python is python or py.

Awesome thanks, this fixed it all !
WEBP didn't work simply because I forgot to add it in the list x)
I had issues with spaces but apparently it was a syntax problem. Instead of :

exiftool_command = f"exiftool -TagsFromFile {input_path} -All:All -FileModifyDate -FileCreateDate {output_path}"

It should be :

exiftool_command = f'exiftool -TagsFromFile "{input_path}" -All:All -FileModifyDate -FileCreateDate "{output_path}"'

So with all that, I think it's done !! <3
It's sad that the Gradio version didn't work but I'm feeling lazy now, how long I've been working on this ? About 6 days, I need a break lol

Here's last version : https://www.mediafire.com/file/kuksfll5lxy92ml/app_cmd_v2.py/file

Awesome!!

ok ok I know I said I would take a break but I'm addicted to this now xD I hope you don't mind.
Still trying to improve it and fix errors. I learned new things.
When processing PNGs it's more likely to fail with strange errors like :
image.png

...I was so close with this one hahaha I waited 33 min for nothing... the way it is coded, last operations happens only at the very end so if ONE error happens... it breaks the whole process xDDD And I think unfortunately it's faster that way, instead of doing a datestamp copy+delete _originals for every image one by one. But I will try that too if I can, for science lol
The error seemed related to depth so I found out that ONE file out of 220 broke everything and it's a PNG-32bit!? In theory with an alpha channel that I cannot see in Photoshop o_o
I couldn't reproduce such file, I need to investigate more, it's fascinating.

Another issue I found is that when tags already exist on the input images, the tagging doesn't work... Or so I thought. In fact, it works but at the end when the datestamps are copied, All:All copies everything, so it erases all the new tags with the old ones. So the correct code is :

exiftool_command = f'exiftool -TagsFromFile "{input_path}" -FileModifyDate -FileCreateDate "{output_path}"'

Old tags will be preserved anyway, because f"-XMP-dc:Subject+={tag}" This happens when you just copy/paste code but you don't know what you're doing. Now I know :D
Actually -FileCreateDate is probably useless but eh, it doesn't hurt lol

Ah, I think the error occurred because I forgot to add .convert("RGB") when loading images. (In the case of gradio app, it was fine because the default of gr.Image is RGB.)
I think you can fix it by changing

image = PIL.Image.open(image_path)

to

image = PIL.Image.open(image_path).convert("RGB")

Thank you... (it worked) AI didn't tell me this :'( I had no idea that you could virtually convert an image just for reading the content... And I couldn't find why other formats aren't compatible with the tagging system so I made it more complicate :/

  1. check PNG files with if img.mode != 'RGB'
  2. move all images that are not 24 bit to a new folder
  3. process the rest for tagging

Of course this opened a whole new world of small issues, like

  • the new folder is created whatever happens. Bad coding on my part, I'm literally starting by saying "if new folder doesn't exist, make one". So of course this gonna happen -_-;
  • if the new folder already exist -> error
  • if it finds a MP4 or a WBEM --> error

While in fact, the solution was much much simpler... damn.

Now I also start by an input instead of a parameter in the commandline (because it's nice :p )

output_dir = input("Output directory path for tagged images (default is \'tagged\'): ")
    if output_dir =="":
        output_dir="tagged"

Too simple I guess. I was happy with that until I realize that if copies of the images exist with the same name in different folders, ALL will be processed, I didn't expect that at all.
I'm thinking maybe it's because of : for path in sorted(output_dir.rglob("*")) ...but I'm not sure, I don't understand it very well

Alright I think I'm going to make another version that output text, it shouldn't be too hard. I don't know why but this webpage give me more accurate captions than Tagger extension in webUIs. Kohya_ss caption tool is also better than Tagger but it's very slow so I have to try and see if I can get something even better :) I'm working on a LoRA right now so this might be convenient

Sign up or log in to comment