File size: 8,841 Bytes
959d865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f2216b
959d865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import os
import tempfile
import shutil
import re
import zxing
import gradio as gr
import pandas as pd
from PIL import Image
from ultralytics import YOLO

MODEL = YOLO("qr_yolo26m_0.pt")
reader = zxing.BarCodeReader()

UPLOAD_DIR = tempfile.mkdtemp(prefix="grin_uploads_")

def handle_upload(files):
    """Copy uploaded files to temp dir; return dataframe + first image preview."""
    empty_df = pd.DataFrame(columns=["Original filename", "QRs\ndetected", "QRs\nparsed", "New filename"])
    if not files:
        return empty_df, None, None, "### 0 image(s) uploaded"

    rows = []
    for f in sorted(files, key=lambda x: os.path.basename(x)):
        basename = os.path.basename(f)
        dest = os.path.join(UPLOAD_DIR, basename)
        shutil.copy2(f, dest)
        rows.append({"Original filename": basename, "QRs\ndetected": "", "QRs\nparsed": "", "New filename": ""})

    df = pd.DataFrame(rows)
    first_image = Image.open(os.path.join(UPLOAD_DIR, df.iloc[0]["Original filename"]))
    count_text = f"### {len(df)} image(s) uploaded"
    return df, first_image, None, count_text


def preview_selected(evt: gr.SelectData, df):
    """Show the image for the clicked row."""
    if df is None or df.empty:
        return None
    row_idx = evt.index[0]
    filename = df.iloc[row_idx]["Original filename"]
    path = os.path.join(UPLOAD_DIR, filename)
    if os.path.exists(path):
        return Image.open(path)
    return None

def run_pipeline(df, progress=gr.Progress(track_tqdm=False)):
    """Run YOLO detection on each uploaded image and count aztec_code hits."""
    if df is None or df.empty:
        gr.Info("No images to process. Upload images first.")
        return df

    filenames = df["Original filename"].tolist()
    qr_counts = []
    parsed_qr_counts = []
    parsed_text = []
    for i, filename in progress.tqdm(enumerate(filenames), total=len(filenames), desc="Running detection"):
        path = os.path.join(UPLOAD_DIR, filename)
        img =  Image.open(path)
        r = MODEL(img, imgsz=640, conf=0.7, verbose=False)[0]
        count = 0
        parse_count = 0
        parser_results = []
        for box in r.boxes:
            if r.names[int(box.cls)] == "aztec_code":
                count += 1
                # try parsing the QR
                pad = 10
                x1,y1,x2,y2 = list(box.xyxy.cpu().numpy()[0])
                qr_crop = img.crop((x1-pad,y1-pad,x2+pad,y2+pad))
                zxing_results = reader.decode(qr_crop)
                if zxing_results and zxing_results.parsed:
                    parse_count += 1
                    parser_results.append(zxing_results.parsed)
        if len(parser_results) > 0:
            text = max(parser_results, key=len)
        else:
            text = ""
        qr_counts.append(count)
        parsed_qr_counts.append(parse_count)
        parsed_text.append(text)
    df["QRs\ndetected"] = qr_counts
    df["QRs\nparsed"] = parsed_qr_counts
    df["New filename"] = parsed_text
    gr.Info("Pipeline finished!")
    return df

def _format_name_from_qr(raw_text, separator):
    """Split QR text on spaces and join with user-selected separator."""
    if raw_text is None:
        return ""
    text = str(raw_text).strip()
    if not text:
        return ""
    parts = [p for p in text.split(" ") if p]
    joined = separator.join(parts).strip()
    # Keep names filesystem-safe.
    return re.sub(r"[\\/:*?\"<>|]+", "", joined).strip(" .")


def _dedupe_filename(filename, used_names):
    """Ensure duplicate names are made unique with a numeric suffix."""
    stem, ext = os.path.splitext(filename)
    candidate = filename
    counter = 2
    while candidate in used_names:
        candidate = f"{stem}_{counter}{ext}"
        counter += 1
    used_names.add(candidate)
    return candidate


def download_renamed(df, separator, include_mode):
    if df is None or df.empty:
        gr.Warning("No files are available to download.")
        return None

    sep = separator if separator not in (None, "") else "_"
    include_unparsed = include_mode == "Include images with no parsed QR"

    run_dir = tempfile.mkdtemp(prefix="grin_renamed_")
    renamed_dir = os.path.join(run_dir, "renamed_images")
    os.makedirs(renamed_dir, exist_ok=True)

    copied = 0
    skipped = 0
    used_names = set()

    for _, row in df.iterrows():
        original = str(row.get("Original filename", "")).strip()
        if not original:
            continue

        source = os.path.join(UPLOAD_DIR, original)
        if not os.path.exists(source):
            continue

        new_name_raw = row.get("New filename", "")
        if pd.isna(new_name_raw):
            new_name_raw = ""

        stem, ext = os.path.splitext(original)
        renamed_stem = _format_name_from_qr(new_name_raw, sep)

        if not renamed_stem:
            if not include_unparsed:
                skipped += 1
                continue
            renamed_stem = stem

        final_name = _dedupe_filename(f"{renamed_stem}{ext}", used_names)
        destination = os.path.join(renamed_dir, final_name)
        shutil.copy2(source, destination)
        copied += 1

    if copied == 0:
        gr.Warning("No files matched your download settings.")
        return None

    zip_path = shutil.make_archive(os.path.join(run_dir, "renamed_images"), "zip", renamed_dir)
    gr.Info(f"Prepared {copied} file(s) for download ({skipped} skipped).")
    return gr.update(value=zip_path, visible=True)


with gr.Blocks(title="GRIN Image Renamer") as demo:
    with gr.Row():
        with gr.Column(scale=3):
            gr.Markdown(
                """
                # GRIN Image Renamer

                This is a minimal app to rename images using information scraped from
                QR / Aztec codes detected in the image.
                
                Simply select images to upload, then click **Run Pipeline** to process them.
                After the pipeline has run, you can inspect the new image names and make any 
                necessary changes before downloading the renamed versions.

                *Note*: If you are running this app on your local computer via Docker Desktop,
                then the images are not uploaded to a web server of any kind. They are simply
                "uploaded" to a temporary storage location on your machine until the app is shut
                down.

                This app was written by Tyr Wiesner-Hanks of Breeding Insight, a USDA-funded
                initiative based at University of Florida. If you have any questions or feedback,
                please email me at [twiesnerhanks@ufl.edu](mailto:twiesnerhanks@ufl.edu).                
                """,
                container=True
            )
        with gr.Column(scale=2):
            upload = gr.File(
                label="Upload images",
                file_count="multiple",
                file_types=["image"],
                type="filepath",
            )
            upload_count = gr.Markdown("### 0 image(s) uploaded")
            run_btn = gr.Button("Run Pipeline", variant="primary")
        with gr.Column(scale=3):
            gr.Markdown("### Download settings")
            separator = gr.Textbox(label="Separator", value="_", max_lines=1)
            include_unparsed = gr.Radio(
                choices=[
                    "Include images with no parsed QR",
                    "Exclude images with no parsed QR",
                ],
                value="Include images with no parsed QR",
                label="Unparsed images",
            )
            prepare_btn = gr.Button("Prepare Download", variant="secondary")
            download_btn = gr.DownloadButton("Download renamed images", visible=False)

    with gr.Row(max_height=600):
        with gr.Column(scale=1):
            table = gr.Dataframe(
                headers=["Original filename", "QRs\ndetected", "QRs\nparsed", "New filename"],
                datatype=["str", "number", "number", "str"],
                interactive=True,
                max_height=550,
                column_widths=["40%","10%","10%","40%"]
            )
        with gr.Column(scale=1):
            preview = gr.Image(label="Image preview", type="pil")

    # --- wiring ---
    upload.upload(
        fn=handle_upload,
        inputs=[upload],
        outputs=[table, preview, upload, upload_count],
    )

    table.select(
        fn=preview_selected,
        inputs=[table],
        outputs=[preview],
    )

    run_btn.click(
        fn=run_pipeline,
        inputs=[table],
        outputs=[table],
    )

    prepare_btn.click(
        fn=download_renamed,
        inputs=[table, separator, include_unparsed],
        outputs=[download_btn],
    )


if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", server_port=7860)