12 Commits
Author SHA1 Message Date
Salvador E. Tropea 0caf7f7177 [Fixed] To skip images in the Save .* To Path nodes
When they are blocked
2025-12-30 13:47:10 -03:00
Salvador E. Tropea 7e5f7c3c0a [Image/Mask loaders] Allow image recycle
So when we have multiple instances of the same image we load just once
2025-12-29 14:05:52 -03:00
Salvador E. Tropea 9ccef62bc6 [Added] Node to select the upscale method
So we can apply the same in various places
2025-11-30 15:46:43 -03:00
Salvador E. Tropea 0b8bf65b57 [Fixed] Flake8 issue 2025-11-30 15:46:11 -03:00
Salvador E. Tropea e72ea99a6e [Updated] Examples
DiS5K_1 is testing DIS-TE1/2 on BirefNet
2025-11-29 19:05:43 -03:00
Salvador E. Tropea 7cfe734e54 [Fixed] Debug infor for load_images_wrapper
Was always for masks, failed for images
2025-11-29 19:04:09 -03:00
Salvador E. Tropea fecff541e6 [Added] Automatic batch computation for E-measure
It went OoM for an 8800x7767 image
2025-11-29 19:02:46 -03:00
Salvador E. Tropea 4d23ae490a [Added] Mechanism to re-consolidate metrics
I.e. after an unexpected interrupt
2025-11-29 15:32:30 -03:00
Salvador E. Tropea 415513c1a7 [Added][SaliencyEvaluationMetrics] Progress bar 2025-11-28 13:42:20 -03:00
Salvador E. Tropea dbb20b3713 [Added][ImageMemoryEstimator] Node to estimate the size of a dataset 2025-11-28 13:41:46 -03:00
Salvador E. Tropea e105455bc7 [CartesianProduct][Added] Simple description 2025-11-28 09:40:12 -03:00
Salvador E. Tropea fb99aa0a65 [ConsolidateMetrics][Fixed] n_metric_ids must match n_destination
Also avoid trying to consolidate empty data
2025-11-28 09:38:24 -03:00
5 changed files with 299 additions and 55 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -7,7 +7,7 @@ def get_e_measure(
pred: torch.Tensor,
gt: torch.Tensor,
num_thresholds: int = F_POINTS,
chunk_size: int = 16
chunk_size: int = -1
) -> Tuple[float, float, float, torch.Tensor, torch.Tensor]:
"""
Calculates the E-measure scores using a memory-efficient chunking strategy.
@@ -38,6 +38,10 @@ def get_e_measure(
thlist = torch.linspace(0, 1 - 1e-10, num_thresholds, device=pred.device)
all_scores = []
if chunk_size == -1:
chunk_size = round(64 / (pred.shape[0] * pred.shape[1] / (1 << 20)))
chunk_size = min(max(1, chunk_size), num_thresholds)
# Process thresholds in memory-efficient chunks
for i in range(0, num_thresholds, chunk_size):
# Get the current chunk of thresholds
+24 -9
View File
@@ -147,13 +147,16 @@ def get_image_preview_info(file_name, where="input"):
return {"filename": fname, "subfolder": dname, "type": where}
def load_one_image(file_name, disp_name, embed_transparency):
def load_one_image(file_name, disp_name, embed_transparency, known=None):
if file_name is None:
return (empty_image(b=1, c=3), empty_image(b=1), file_name)
if not os.path.isabs(file_name):
file_name = os.path.join(get_input_directory(), file_name)
if not os.path.exists(file_name):
raise ValueError(f"File '{file_name}' not found")
if known is not None and file_name in known:
logger.debug('Recycling '+file_name)
return (known[file_name][0], known[file_name][1], file_name)
try:
if has_load_image:
@@ -182,6 +185,8 @@ def load_one_image(file_name, disp_name, embed_transparency):
# Concatenate image and mask into (b, h, w, 4)
image_with_alpha = torch.cat([image, 1.0 - mask], dim=-1)
result = (image_with_alpha, mask, file_name)
if known is not None:
known[file_name] = (result[0], result[1])
return result
except Exception as e:
@@ -191,13 +196,16 @@ def load_one_image(file_name, disp_name, embed_transparency):
"It may be corrupt or in an unsupported format.") from e
def load_one_mask(file_name, disp_name, channel='red'):
def load_one_mask(file_name, disp_name, channel='red', known=None):
if file_name is None:
return (empty_image(b=1), file_name)
if not os.path.isabs(file_name):
file_name = os.path.join(get_input_directory(), file_name)
if not os.path.exists(file_name):
raise ValueError(f"File '{file_name}' not found")
if known is not None and file_name in known:
logger.debug('Recycling '+file_name)
return (known[file_name], file_name)
try:
if has_load_image:
@@ -210,6 +218,8 @@ def load_one_mask(file_name, disp_name, channel='red'):
# Call the method and return its result directly
result = loader_instance.load_image(file_name, channel)
if known is not None:
known[file_name] = result[0]
return (result[0], file_name)
except Exception as e:
@@ -241,6 +251,8 @@ def load_images_wrapper(file_names, embed_transparency=False, disp_names=None, s
imgs = []
masks = []
known_imgs = {}
known_masks = {}
is_mask = channel is not None
used_file_names = []
all_preview_imgs = []
@@ -261,9 +273,9 @@ def load_images_wrapper(file_names, embed_transparency=False, disp_names=None, s
if is_mask:
# A mask
mask, file_name = load_one_mask(file_name, disp_name, channel)
mask, file_name = load_one_mask(file_name, disp_name, channel, known=known_masks)
else:
img, mask, file_name = load_one_image(file_name, disp_name, embed_transparency)
img, mask, file_name = load_one_image(file_name, disp_name, embed_transparency, known=known_imgs)
max_w = max(max_w, img.shape[2])
max_h = max(max_h, img.shape[1])
@@ -279,7 +291,7 @@ def load_images_wrapper(file_names, embed_transparency=False, disp_names=None, s
all_preview_imgs.append(get_image_preview_info(file_name))
for j in range(len(imgs_batch)):
if channel is None:
if not is_mask:
img = imgs_batch[j]
H, W = img.shape[1:3]
if H != max_h or W != max_w:
@@ -292,7 +304,7 @@ def load_images_wrapper(file_names, embed_transparency=False, disp_names=None, s
logger.debug(f"Upscaling mask to fit batch: {W}x{H} -> {max_mw}x{max_mh}")
masks_batch[j] = upscale_comfy(mask, max_mw, max_mh, "bicubic")
if channel is None:
if not is_mask:
imgs.append(torch.cat(imgs_batch))
masks.append(torch.cat(masks_batch))
else:
@@ -301,9 +313,9 @@ def load_images_wrapper(file_names, embed_transparency=False, disp_names=None, s
if is_mask:
# A mask
mask, file_name = load_one_mask(file_name, disp_names[i], channel)
mask, file_name = load_one_mask(file_name, disp_names[i], channel, known=known_masks)
else:
img, mask, file_name = load_one_image(file_name, disp_names[i], embed_transparency)
img, mask, file_name = load_one_image(file_name, disp_names[i], embed_transparency, known=known_imgs)
imgs.append(img)
masks.append(mask)
@@ -313,7 +325,8 @@ def load_images_wrapper(file_names, embed_transparency=False, disp_names=None, s
all_preview_imgs.append(get_image_preview_info(file_name))
logger.debug(f"Loaded {len(masks)} batches:")
for n, i in enumerate(masks):
items = masks if is_mask else imgs
for n, i in enumerate(items):
logger.debug(f"{n}) {i.shape}")
result = (imgs, masks, used_file_names) if channel is None else (masks, used_file_names)
@@ -339,6 +352,8 @@ def save_image(images, filenames, prompt=None, extra_pnginfo=None, compress_leve
all_preview_imgs = []
for index, (image, filename) in enumerate(zip(imgs, filenames)):
if filename is None:
continue
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
metadata = PngInfo()
+268 -44
View File
@@ -19,10 +19,11 @@ import re
from seconohe.apply_mask import apply_mask
from seconohe.color import color_to_rgb_float, color_to_rgb_uint8
from seconohe.comfy_misc import (get_input_directory, get_output_directory, MAX_RESOLUTION, PromptServer, IO, ComfyNodeABC,
ExecutionBlocker, upscale_methods)
ExecutionBlocker, upscale_methods, ProgressBar, get_annotated_filepath)
from seconohe.downloader import download_file
from seconohe.foreground_estimation.affce import affce
from seconohe.foreground_estimation.fmlfe import fmlfe, IMPL_PRIORITY
from seconohe.misc import format_bytes
from seconohe.tensor import batched_min_max_norm
from seconohe.torch import get_default_comfy_device, get_canonical_device
import torch
@@ -546,6 +547,12 @@ class ImageDataset(ComfyNodeABC):
"default": True,
"tooltip": "Block the execution when all images are processed"
}),
"invert": (IO.BOOLEAN, {
"default": False,
"tooltip": "Instead of listing the missing `destination` files\n"
"we'll list the existing `destination` files.\n"
"This can be used to collect generated information"
}),
}
}
@@ -565,7 +572,7 @@ class ImageDataset(ComfyNodeABC):
@classmethod
def IS_CHANGED(cls, source, pattern, destination, dest_ext, reference=None, sort_method="None",
image_load_cap=1, skip_first_images=0, select_every_nth=1, random_seed=1,
block_when_finished=False):
block_when_finished=False, invert=False):
# We always indicate an evaluation is needed.
# 1) We must check the directory to know it, so we don't have any advantage on doing it now.
# 2) ComfyUI makes any check impossible, if source is connected to a node it will be None
@@ -573,7 +580,7 @@ class ImageDataset(ComfyNodeABC):
def execute(self, source, pattern, destination, dest_ext, reference=None, sort_method="None",
image_load_cap=1, skip_first_images=0, select_every_nth=1, random_seed=1,
block_when_finished=False):
block_when_finished=False, invert=False):
logger.debug(f"ImageDataset.source = {source}")
logger.debug(f"ImageDataset.destination = {destination}")
# Here self isn't really needed, our state is the filesystem
@@ -643,7 +650,7 @@ class ImageDataset(ComfyNodeABC):
dest_path = dest_dir / dest_filename
# Skip if the result file already exists
if dest_path.exists():
if dest_path.exists() ^ invert:
continue
# Find the reference file (case-insensitive and extension-agnostic)
@@ -794,8 +801,60 @@ class SaliencyEvaluationMetrics(ComfyNodeABC):
UNIQUE_NAME = "SET_SaliencyEvaluationMetrics"
DISPLAY_NAME = "Saliency Evaluation Metrics"
def read_single(self, path):
if not path.is_file():
return None
with open(path, "rt") as f:
f.readline()
return float(f.readline())
def read_f(self, path):
if not path.is_file():
return None, None, None
f4 = torch.Tensor(size=(4, F_POINTS), device="cpu")
f1 = f4.movedim(1, 0) # Easier here, not for computation
with open(path, "rt") as f:
reader = csv.reader(f)
next(reader) # header
for c in range(F_POINTS):
f1[c] = torch.Tensor(tuple(float(v) for v in next(reader)))
next(reader)
row = next(reader)
assert row[0] == "Max", row
f_max = float(row[1])
row = next(reader)
assert row[0] == "Adaptive", row
f_adp = float(row[1])
return f_max, f4, f_adp
def read_e(self, path):
if not path.is_file():
return None, None, None, None, None
with open(path, "rt") as f:
reader = csv.reader(f)
next(reader) # header
all_e = []
thres = []
for c in range(F_POINTS):
row = next(reader)
thres.append(float(row[0]))
all_e.append(float(row[1]))
thres = torch.Tensor(thres)
all_e = torch.Tensor(all_e)
next(reader)
row = next(reader)
assert row[0] == "Mean"
e_mean = float(row[1])
row = next(reader)
assert row[0] == "Max"
e_max = float(row[1])
row = next(reader)
assert row[0] == "Adaptive"
e_adp = float(row[1])
return e_mean, e_max, e_adp, all_e, thres
def evaluate(self, prediction: torch.Tensor, ground_truth: torch.Tensor, unique_id,
img_name, normalize, result_save: bool = False, mae_enable: bool = True, mae_save: bool = False,
img_name, normalize: bool = False, result_save: bool = False, mae_enable: bool = True, mae_save: bool = False,
max_f_mes_enable: bool = True, max_f_mes_save: bool = False, s_mes_enable: bool = True,
s_mes_save: bool = False, e_mes_enable: bool = True, e_mes_save: bool = False,
wf_mes_enable: bool = True, wf_mes_save: bool = True):
@@ -835,6 +894,7 @@ class SaliencyEvaluationMetrics(ComfyNodeABC):
if names_len != imgs_len:
raise ValueError(f"Got {imgs_len} images and {names_len} names, they must match")
prog_bar = ProgressBar(pred_len)
for index_img in range(pred_len):
# Ensure tensors are on the same device
inputs_are_copies = get_canonical_device(prediction[index_img].device) != device
@@ -866,75 +926,105 @@ class SaliencyEvaluationMetrics(ComfyNodeABC):
# 1. Mean Absolute Error (MAE)
if mae_enable:
mae = torch.mean(torch.abs(pred_i - gt_i)).item()
logger.debug(f"MAE: {mae}")
mae_path = Path(imgp.parent, imgp.stem+"_mae.csv")
mae = self.read_single(mae_path)
if mae is None:
mae = torch.mean(torch.abs(pred_i - gt_i)).item()
logger.debug(f"MAE: {mae}")
if mae_save:
with open(mae_path, "wt") as f:
f.write(f"MAE\n{mae}")
else:
logger.debug(f"MAE already computed: {mae}")
mae_total += mae
res['mae'] = mae
if mae_save:
with open(Path(imgp.parent, imgp.stem+"_mae.csv"), "wt") as f:
f.write(f"MAE\n{mae}")
# --- Metrics requiring binary ground truth ---
if max_f_mes_enable or s_mes_enable or e_mes_enable or wf_mes_enable:
gt_binary = (gt_i >= 0.5).float()
gt_binary = None
# 2. Max F-measure
if max_f_mes_enable:
f_max, all_f, f_adp = get_f_measure(pred_i, gt_binary)
f_path = Path(imgp.parent, imgp.stem+"_F.csv")
f_max, all_f, f_adp = self.read_f(f_path)
if f_max is None:
gt_binary = (gt_i >= 0.5).float() if gt_binary is None else gt_binary
f_max, all_f, f_adp = get_f_measure(pred_i, gt_binary)
logger.debug(f"Fβmax: {f_max}")
if max_f_mes_save:
all_fp = all_f.movedim(1, 0)
with open(f_path, "wt") as f:
f.write("Threshold, F-measure, Precision, Recall\n")
for fn in all_fp:
f.write(f"{fn[0]}, {fn[1]}, {fn[2]}, {fn[3]}\n")
f.write(f"\nMax, {f_max}\n")
f.write(f"Adaptive, {f_adp}\n")
else:
logger.debug(f"Fβmax already computed: {f_max}")
f_measure_max_total += f_max
logger.debug(f"Fβmax: {f_max}")
res['max_f_mes'] = f_max
res['adp_f_mes'] = f_adp
res['f'] = all_f[1]
res['fp'] = all_f[2]
res['fr'] = all_f[3]
if max_f_mes_save:
with open(Path(imgp.parent, imgp.stem+"_F.csv"), "wt") as f:
f.write("Threshold, F-measure\n")
for fn in all_f:
f.write(f"{fn[0]}, {fn[1]}\n")
f.write(f"\nMax, {f_max}\n")
# 3. S-measure
if s_mes_enable:
s_measure = get_s_measure(pred_i, gt_binary)
s_path = Path(imgp.parent, imgp.stem+"_S.csv")
s_measure = self.read_single(s_path)
if s_measure is None:
gt_binary = (gt_i >= 0.5).float() if gt_binary is None else gt_binary
s_measure = get_s_measure(pred_i, gt_binary)
logger.debug(f"Sα: {s_measure}")
if s_mes_save:
with open(s_path, "wt") as f:
f.write(f"S-measure\n{s_measure}")
else:
logger.debug(f"Sα already computed: {s_measure}")
s_measure_total += s_measure
logger.debug(f"Sα: {s_measure}")
res['s_mes'] = s_measure
if s_mes_save:
with open(Path(imgp.parent, imgp.stem+"_S.csv"), "wt") as f:
f.write(f"S-measure\n{s_measure}")
# 4. E-measure
if e_mes_enable:
e_mean, e_max, e_adp, all_e, thres = get_e_measure(pred_i, gt_binary)
e_path = Path(imgp.parent, imgp.stem+"_E.csv")
e_mean, e_max, e_adp, all_e, thres = self.read_e(e_path)
if e_mean is None:
gt_binary = (gt_i >= 0.5).float() if gt_binary is None else gt_binary
e_mean, e_max, e_adp, all_e, thres = get_e_measure(pred_i, gt_binary)
logger.debug(f"Eϕ: {e_mean} {e_max} {e_adp}")
if e_mes_save:
with open(e_path, "wt") as f:
f.write("Threshold, E-measure\n")
for index, en in enumerate(all_e):
f.write(f"{thres[index]}, {en}\n")
f.write("\n")
f.write(f"Mean, {e_mean}\n")
f.write(f"Max, {e_max}\n")
f.write(f"Adaptive, {e_adp}\n")
else:
logger.debug(f"Eϕ already computed: {e_mean} {e_max} {e_adp}")
e_measure_total += e_mean
e_measure_max_total += e_max
e_measure_adp_total += e_adp
logger.debug(f"Eϕ: {e_mean} {e_max} {e_adp}")
res['e_mes'] = e_mean
res['max_e_mes'] = e_max
res['adp_e_mes'] = e_adp
res['e'] = all_e
if e_mes_save:
with open(Path(imgp.parent, imgp.stem+"_E.csv"), "wt") as f:
f.write("Threshold, E-measure\n")
for index, en in enumerate(all_e):
f.write(f"{thres[index]}, {en}\n")
f.write("\n")
f.write(f"Mean, {e_mean}\n")
f.write(f"Max, {e_max}\n")
f.write(f"Adaptive, {e_adp}\n")
# 5. Weighted F-measure
if wf_mes_enable:
wf = get_weighted_f_measure(pred_i, gt_binary)
wf_path = Path(imgp.parent, imgp.stem+"_wF.csv")
wf = self.read_single(wf_path)
if wf is None:
gt_binary = (gt_i >= 0.5).float() if gt_binary is None else gt_binary
wf = get_weighted_f_measure(pred_i, gt_binary)
logger.debug(f"Fβw: {wf}")
if wf_mes_save:
with open(wf_path, "wt") as f:
f.write(f"Weighted F-measure\n{wf}")
else:
logger.debug(f"Fβw already computed: {wf}")
weighted_f_total += wf
logger.debug(f"Fβw: {wf}")
res['wf_mes'] = wf
if wf_mes_save:
with open(Path(imgp.parent, imgp.stem+"_wF.csv"), "wt") as f:
f.write(f"Weighted F-measure\n{wf}")
if result_save and res:
with open(Path(imgp.parent, imgp.stem+".csv"), "wt") as f:
@@ -943,6 +1033,7 @@ class SaliencyEvaluationMetrics(ComfyNodeABC):
f.write(','.join(expand_data_row(res, keys))+"\n")
all.append(res)
prog_bar.update(1)
# Average metrics over the batch/es
mae_avg = mae_total / gt_len
@@ -1115,8 +1206,8 @@ class ConsolidateMetrics(ComfyNodeABC):
raise ValueError(f"Got {n_metrics} metrics and {n_destination} destinations. They must be multiples.")
if n_res_destination > 1:
raise ValueError(f"Only one resume destination currently supported, got {n_res_destination}")
if n_res_destination and n_metric_ids != n_metrics:
raise ValueError(f"Got {n_metrics} metrics and {n_metric_ids} metric IDs. They must match.")
if n_res_destination and n_metric_ids != n_destination:
raise ValueError(f"Got {n_destination} destinations and {n_metric_ids} metric IDs. They must match.")
slice_size = n_metrics // n_destination
res = []
@@ -1126,7 +1217,7 @@ class ConsolidateMetrics(ComfyNodeABC):
end = start + slice_size
data, total = self.consolidate(metrics[start:end], img_name[start:end], d)
res.append(data)
if n_res_destination:
if n_res_destination and total:
totals[metric_ids[c]] = total
if totals:
@@ -2521,6 +2612,29 @@ class ImagePad(ComfyNodeABC):
return (out_image, out_masks)
class ChooseUpscaleMethod(ComfyNodeABC):
"""
Used to send the same upscale option to other nodes.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"upscale_method": UPSCALE_OPT,
},
}
RETURN_TYPES = (IO.ANY,)
RETURN_NAMES = ("upscale_method",)
FUNCTION = "execute"
CATEGORY = BASE_CATEGORY + "/" + MANIPULATION_CATEGORY
UNIQUE_NAME = "SET_ChooseUpscaleMethod"
DISPLAY_NAME = "Choose Upscale Method"
def execute(self, upscale_method):
return (upscale_method, )
# Adapted from KJNodes, credits to Kijai (ImageResizeKJv2)
# Differences:
# - The color is an string that support various formats
@@ -3027,6 +3141,9 @@ class ImageWithTextLabel(ComfyNodeABC):
class CartesianProduct(ComfyNodeABC):
"""
Produces a list with all the combinations from the items in list_A and list_B.
"""
@classmethod
def INPUT_TYPES(s):
return {
@@ -3071,3 +3188,110 @@ class CartesianProduct(ComfyNodeABC):
res_B.append(b)
return (res_A, res_B)
class ImageMemoryEstimator:
"""
Estimates the ammount of RAM needed to load the indicated images.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"file_name": (IO.STRING, {
"forceInput": True,
"tooltip": "One or more image names. ComfyUI input dir is the base"}),
"is_image": (IO.BOOLEAN, {
"default": True,
"tooltip": "Compute three channels. Enable it and `is_mask` for RGBA"}),
"is_mask": (IO.BOOLEAN, {
"default": False,
"tooltip": "Compute one channel. Can be combined with `is_image`"}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
INPUT_IS_LIST = True
RETURN_TYPES = ("INT", "STRING", "STRING")
RETURN_NAMES = ("total_bytes", "human_readable", "file_name")
OUTPUT_IS_LIST = (False, False, True)
FUNCTION = "execute"
CATEGORY = BASE_CATEGORY + "/" + VALIDATION
UNIQUE_NAME = "SET_ImageMemoryEstimator"
DISPLAY_NAME = "Image Memory Estimator"
def execute(self, file_name, is_image, is_mask, unique_id):
total_pixels = 0
min_area = float('inf')
max_area = 0
min_dim = (0, 0)
max_dim = (0, 0)
valid_count = 0
# Bytes per pixel for ComfyUI Tensors (Float32 = 4 bytes, RGB = 3 channels)
channels = 3 if is_image[0] else 0
channels += 1 if is_mask[0] else 0
if channels == 0:
logger.warning("Enable `is_image` and/or `is_mask`")
BYTES_PER_PIXEL = 4 * channels
logger.debug(f"BYTES_PER_PIXEL {BYTES_PER_PIXEL}")
known = set()
for fname in file_name:
if fname is None:
continue
# Try to resolve path (handles both absolute paths and ComfyUI input folder files)
file_path = get_annotated_filepath(fname)
if not file_path or not os.path.exists(file_path):
logger.warning(f"Could not find file: {fname}")
continue
if file_path in known:
continue
try:
# PIL.Image.open is lazy; it reads metadata without loading pixel data
with Image.open(file_path) as img:
w, h = img.size
area = w * h
total_pixels += area
if area < min_area:
min_area = area
min_dim = (w, h)
if area > max_area:
max_area = area
max_dim = (w, h)
valid_count += 1
known.add(file_path)
except Exception as e:
logger.error(f"Failed to read metadata for {fname}: {e}")
total_bytes = total_pixels * BYTES_PER_PIXEL
human_readable = format_bytes(total_bytes)
# --- Information ---
logger.info("--- Image Memory Estimation ---")
msg = f"Number of images: {valid_count}"
logger.info(msg)
if valid_count > 0:
m = f"Minimum Dimensions: {min_dim[0]}x{min_dim[1]}"
logger.info(m)
msg += "<br>" + m
m = f"Maximum Dimensions: {max_dim[0]}x{max_dim[1]}"
logger.info(m)
msg += "<br>" + m
m = f"Total Raw Tensor Size: {human_readable} ({total_bytes} bytes)"
logger.info(m)
msg += "<br>" + m
else:
logger.info("No valid images processed.")
logger.info("-------------------------------")
send_progress_text(unique_id[0], msg)
return (total_bytes, human_readable, file_name)