Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,16 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
Nesting Otomatis + Visualisasi Layout untuk Percetakan Banner
|
| 3 |
================================================================
|
| 4 |
-
|
| 5 |
-
pada satu roll/lembar bahan cetak (dengan rotasi otomatis jika hemat bahan),
|
| 6 |
-
dan Pillow untuk menghasilkan gambar pratinjau layout.
|
| 7 |
|
| 8 |
Fitur:
|
| 9 |
-
- Inner/Outer visualization
|
| 10 |
-
-
|
|
|
|
| 11 |
- 5 Mode Pencarian: Cepat / Standar / Dalaman / Pro / Menyeluruh
|
| 12 |
-
-
|
| 13 |
-
- Setara kemampuan operator profesional
|
| 14 |
- Deploy di Hugging Face Spaces (Gradio SDK)
|
| 15 |
"""
|
| 16 |
|
|
@@ -44,13 +42,11 @@ from rectpack.guillotine import (
|
|
| 44 |
# --------------------------------------------------------------------------
|
| 45 |
|
| 46 |
def generate_color(seed_text: str):
|
| 47 |
-
"""Menghasilkan warna acak tapi konsisten berdasarkan label item."""
|
| 48 |
random.seed(seed_text)
|
| 49 |
return tuple(random.randint(90, 220) for _ in range(3))
|
| 50 |
|
| 51 |
|
| 52 |
def load_font(size: int = 14):
|
| 53 |
-
"""Coba pakai font DejaVu (biasanya tersedia di Linux/HF Spaces)."""
|
| 54 |
candidates = [
|
| 55 |
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 56 |
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
@@ -64,21 +60,14 @@ def load_font(size: int = 14):
|
|
| 64 |
|
| 65 |
|
| 66 |
def lighten_color(c, factor=0.55):
|
| 67 |
-
"""Buat warna lebih terang untuk area margin (outer)."""
|
| 68 |
return tuple(min(255, int(v + (255 - v) * factor)) for v in c)
|
| 69 |
|
| 70 |
|
| 71 |
def get_sort_name(sort_algo):
|
| 72 |
-
"""Konversi constant sort ke string readable."""
|
| 73 |
mapping = {
|
| 74 |
-
SORT_AREA: "AREA",
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
SORT_SSIDE: "SSIDE",
|
| 78 |
-
SORT_RATIO: "RATIO",
|
| 79 |
-
SORT_DIFF: "DIFF",
|
| 80 |
-
SORT_NONE: "NONE",
|
| 81 |
-
None: "Default",
|
| 82 |
}
|
| 83 |
return mapping.get(sort_algo, str(sort_algo))
|
| 84 |
|
|
@@ -88,10 +77,6 @@ def get_sort_name(sort_algo):
|
|
| 88 |
# --------------------------------------------------------------------------
|
| 89 |
|
| 90 |
def prepare_rects(df: pd.DataFrame, margin: float):
|
| 91 |
-
"""
|
| 92 |
-
Konversi DataFrame ke daftar rectangle untuk rectpack.
|
| 93 |
-
Return: (rects, original_dims, total_rects, max_height)
|
| 94 |
-
"""
|
| 95 |
rects = []
|
| 96 |
original_dims = {}
|
| 97 |
|
|
@@ -120,15 +105,13 @@ def prepare_rects(df: pd.DataFrame, margin: float):
|
|
| 120 |
|
| 121 |
|
| 122 |
# --------------------------------------------------------------------------
|
| 123 |
-
# 3. CORE: PACKING ENGINE
|
| 124 |
# --------------------------------------------------------------------------
|
| 125 |
|
| 126 |
def run_packer(rects, roll_width, max_height, allow_rotation,
|
| 127 |
pack_algo=None, sort_algo=None, reverse=False):
|
| 128 |
"""
|
| 129 |
-
Jalankan rectpack
|
| 130 |
-
Jika reverse=True, urutan rects dibalik sebelum packing.
|
| 131 |
-
Return: (placed_rects, used_height, not_placed) atau None jika gagal.
|
| 132 |
"""
|
| 133 |
input_rects = list(reversed(rects)) if reverse else rects[:]
|
| 134 |
|
|
@@ -154,84 +137,166 @@ def run_packer(rects, roll_width, max_height, allow_rotation,
|
|
| 154 |
return placed, used_height, not_placed
|
| 155 |
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
# --------------------------------------------------------------------------
|
| 160 |
-
|
| 161 |
-
def render_layout(placed, used_height, original_dims, roll_width, margin,
|
| 162 |
-
px_per_unit=4.0):
|
| 163 |
"""
|
| 164 |
-
|
| 165 |
-
|
|
|
|
| 166 |
"""
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
draw = ImageDraw.Draw(img)
|
| 172 |
-
font = load_font(14)
|
| 173 |
-
font_small = load_font(11)
|
| 174 |
-
|
| 175 |
-
for r in placed:
|
| 176 |
-
base_label = r.rid.rsplit("#", 1)[0]
|
| 177 |
-
color = generate_color(base_label)
|
| 178 |
-
outer_color = lighten_color(color, 0.55)
|
| 179 |
-
inner_color = color
|
| 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 |
-
bbox_size = draw.textbbox((0, 0), size_text, font=font_small)
|
| 208 |
-
total_th = (bbox_label[3] - bbox_label[1]) + (bbox_size[3] - bbox_size[1]) + 4
|
| 209 |
|
| 210 |
-
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
| 211 |
-
ty = cy - total_th / 2
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
fill="black", font=font_small, anchor="ma")
|
| 217 |
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
-
# Statistik
|
| 222 |
-
used_area = sum(r.width * r.height for r in placed)
|
| 223 |
-
roll_area = roll_width * used_height
|
| 224 |
-
efficiency = (used_area / roll_area * 100) if roll_area > 0 else 0
|
| 225 |
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
)
|
| 233 |
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
# --------------------------------------------------------------------------
|
|
@@ -239,10 +304,6 @@ def render_layout(placed, used_height, original_dims, roll_width, margin,
|
|
| 239 |
# --------------------------------------------------------------------------
|
| 240 |
|
| 241 |
def get_pre_sort_strategies(rects):
|
| 242 |
-
"""
|
| 243 |
-
Berbagai strategi pengurutan input manual sebelum masuk rectpack.
|
| 244 |
-
Return: list of (name, sorted_rects)
|
| 245 |
-
"""
|
| 246 |
strategies = []
|
| 247 |
|
| 248 |
def area(r): return r[0] * r[1]
|
|
@@ -269,7 +330,6 @@ def get_pre_sort_strategies(rects):
|
|
| 269 |
]
|
| 270 |
strategies.extend(base)
|
| 271 |
|
| 272 |
-
# Group by label (keep same-label items together)
|
| 273 |
label_groups = {}
|
| 274 |
for r in rects:
|
| 275 |
label = r[2].rsplit("#", 1)[0]
|
|
@@ -279,7 +339,6 @@ def get_pre_sort_strategies(rects):
|
|
| 279 |
grouped.extend(label_groups[label])
|
| 280 |
strategies.append(("group_label", grouped))
|
| 281 |
|
| 282 |
-
# Interleave large-small (skyline strategy)
|
| 283 |
by_area = sorted(rects, key=area, reverse=True)
|
| 284 |
interleaved = []
|
| 285 |
i, j = 0, len(by_area) - 1
|
|
@@ -302,36 +361,38 @@ def search_single_width(rects, original_dims, total_rects, max_height,
|
|
| 302 |
roll_width, margin, allow_rotation,
|
| 303 |
search_mode, px_per_unit):
|
| 304 |
"""
|
| 305 |
-
|
| 306 |
-
Return: (img, stats_text,
|
| 307 |
"""
|
| 308 |
best_result = None
|
| 309 |
best_key = None
|
| 310 |
best_config = None
|
| 311 |
-
|
| 312 |
tried = 0
|
| 313 |
failed = 0
|
| 314 |
|
| 315 |
-
def evaluate(
|
| 316 |
-
nonlocal best_result, best_key, best_config,
|
| 317 |
-
if
|
| 318 |
return
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
if best_key is None or key < best_key:
|
| 327 |
best_key = key
|
| 328 |
-
best_result =
|
| 329 |
best_config = config_name
|
| 330 |
-
|
| 331 |
|
| 332 |
-
# ============================================================
|
| 333 |
-
# PHASE 1: GRID SEARCH
|
| 334 |
-
# ============================================================
|
| 335 |
ALL_PACK_ALGOS = [
|
| 336 |
None, MaxRectsBssf, MaxRectsBaf, MaxRectsBl, MaxRectsBlsf,
|
| 337 |
SkylineBl, SkylineBlWm, SkylineMwf, SkylineMwfl,
|
|
@@ -347,6 +408,7 @@ def search_single_width(rects, original_dims, total_rects, max_height,
|
|
| 347 |
ALL_SORT_ALGOS = [None, SORT_AREA, SORT_PERI, SORT_LSIDE, SORT_SSIDE,
|
| 348 |
SORT_RATIO, SORT_DIFF, SORT_NONE]
|
| 349 |
|
|
|
|
| 350 |
if search_mode in ("standard", "deep", "pro", "exhaustive"):
|
| 351 |
if search_mode == "standard":
|
| 352 |
pack_algos = [
|
|
@@ -365,22 +427,20 @@ def search_single_width(rects, original_dims, total_rects, max_height,
|
|
| 365 |
for sort_algo in sort_algos:
|
| 366 |
for reverse in [False, True]:
|
| 367 |
tried += 1
|
| 368 |
-
|
| 369 |
rects, roll_width, max_height, allow_rotation,
|
| 370 |
pack_algo, sort_algo, reverse
|
| 371 |
)
|
| 372 |
-
if
|
| 373 |
failed += 1
|
| 374 |
continue
|
| 375 |
algo_name = pack_algo.__name__ if pack_algo else "Default"
|
| 376 |
sort_name = get_sort_name(sort_algo)
|
| 377 |
rev = " (rev)" if reverse else ""
|
| 378 |
config = f"[Grid] {algo_name} + SORT_{sort_name}{rev}"
|
| 379 |
-
evaluate(
|
| 380 |
|
| 381 |
-
# ============================================================
|
| 382 |
# PHASE 2: PRE-SORT MANUAL
|
| 383 |
-
# ============================================================
|
| 384 |
if search_mode in ("pro", "exhaustive"):
|
| 385 |
pre_sorts = get_pre_sort_strategies(rects)
|
| 386 |
top_packs = [
|
|
@@ -392,53 +452,53 @@ def search_single_width(rects, original_dims, total_rects, max_height,
|
|
| 392 |
for pack_algo in top_packs:
|
| 393 |
for reverse in [False, True]:
|
| 394 |
tried += 1
|
| 395 |
-
|
| 396 |
sorted_rects, roll_width, max_height, allow_rotation,
|
| 397 |
pack_algo, SORT_NONE, reverse
|
| 398 |
)
|
| 399 |
-
if
|
| 400 |
failed += 1
|
| 401 |
continue
|
| 402 |
algo_name = pack_algo.__name__ if pack_algo else "Default"
|
| 403 |
rev = " (rev)" if reverse else ""
|
| 404 |
config = f"[PreSort:{sort_name}] {algo_name}{rev}"
|
| 405 |
-
evaluate(
|
| 406 |
|
| 407 |
-
#
|
| 408 |
-
# PHASE 3: MONTE CARLO RANDOM SHUFFLE
|
| 409 |
-
# ============================================================
|
| 410 |
if search_mode in ("pro", "exhaustive"):
|
| 411 |
mc_count = 500 if search_mode == "pro" else 2000
|
| 412 |
-
mc_configs = [
|
| 413 |
-
(None, None),
|
| 414 |
-
(MaxRectsBssf, SORT_AREA),
|
| 415 |
-
(GuillotineBssfSas, SORT_AREA),
|
| 416 |
-
]
|
| 417 |
|
| 418 |
for seed in range(mc_count):
|
| 419 |
random.seed(seed)
|
| 420 |
shuffled = random.sample(rects, len(rects))
|
| 421 |
for pack_algo, sort_algo in mc_configs:
|
| 422 |
tried += 1
|
| 423 |
-
|
| 424 |
shuffled, roll_width, max_height, allow_rotation,
|
| 425 |
pack_algo, sort_algo, False
|
| 426 |
)
|
| 427 |
-
if
|
| 428 |
failed += 1
|
| 429 |
continue
|
| 430 |
algo_name = pack_algo.__name__ if pack_algo else "Default"
|
| 431 |
config = f"[MC:{seed}] {algo_name}"
|
| 432 |
-
evaluate(
|
| 433 |
-
|
| 434 |
-
#
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
swap_iters = min(300, n * (n - 1) // 2)
|
| 441 |
-
|
| 442 |
seen_swaps = set()
|
| 443 |
attempts = 0
|
| 444 |
while len(seen_swaps) < swap_iters and attempts < swap_iters * 3:
|
|
@@ -451,45 +511,51 @@ def search_single_width(rects, original_dims, total_rects, max_height,
|
|
| 451 |
continue
|
| 452 |
seen_swaps.add(pair)
|
| 453 |
|
| 454 |
-
swapped =
|
| 455 |
swapped[i], swapped[j] = swapped[j], swapped[i]
|
| 456 |
-
|
| 457 |
tried += 1
|
| 458 |
-
|
| 459 |
swapped, roll_width, max_height, allow_rotation,
|
| 460 |
None, SORT_NONE, False
|
| 461 |
)
|
| 462 |
-
if
|
| 463 |
failed += 1
|
| 464 |
continue
|
| 465 |
config = f"[Swap:{i}-{j}] Default"
|
| 466 |
-
evaluate(
|
| 467 |
|
| 468 |
-
#
|
| 469 |
if best_result is None:
|
| 470 |
-
return None,
|
| 471 |
-
f"β οΈ Lebar {roll_width:g} cm: tidak ada kombinasi yang muat. "
|
| 472 |
-
"Banner terlalu besar untuk lebar ini."
|
| 473 |
-
), None, None
|
| 474 |
|
| 475 |
-
|
| 476 |
-
img,
|
| 477 |
-
|
| 478 |
)
|
| 479 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
stats_lines = [
|
| 481 |
f"π Algoritma terpilih : {best_config}",
|
| 482 |
f"π Mode pencarian : {search_mode.upper()}",
|
| 483 |
f"π Total iterasi dicoba : {tried} (gagal: {failed})",
|
| 484 |
-
f"
|
| 485 |
-
f"
|
| 486 |
-
f"π
|
| 487 |
-
f"
|
| 488 |
-
f"
|
| 489 |
-
f"
|
|
|
|
|
|
|
|
|
|
| 490 |
]
|
|
|
|
|
|
|
| 491 |
|
| 492 |
-
return img, "\n".join(stats_lines),
|
| 493 |
|
| 494 |
|
| 495 |
# --------------------------------------------------------------------------
|
|
@@ -503,12 +569,11 @@ def process(roll_widths_text, margin, allow_rotation, search_mode, table_data):
|
|
| 503 |
df = pd.DataFrame(table_data, columns=["Label", "Width", "Height", "Qty"])
|
| 504 |
df = df.dropna(subset=["Width", "Height", "Qty"], how="any")
|
| 505 |
|
| 506 |
-
# Parse daftar lebar roll
|
| 507 |
try:
|
| 508 |
widths = [float(w.strip()) for w in roll_widths_text.split(",") if w.strip()]
|
| 509 |
widths = [w for w in widths if w > 0]
|
| 510 |
except ValueError:
|
| 511 |
-
return None, "β οΈ Format daftar lebar roll tidak valid. Gunakan angka dipisah koma
|
| 512 |
|
| 513 |
if not widths:
|
| 514 |
return None, "β οΈ Masukkan minimal satu lebar roll yang valid."
|
|
@@ -518,12 +583,11 @@ def process(roll_widths_text, margin, allow_rotation, search_mode, table_data):
|
|
| 518 |
except (TypeError, ValueError):
|
| 519 |
margin = 0.0
|
| 520 |
|
| 521 |
-
# Persiapkan rects sekali untuk semua lebar
|
| 522 |
rects, original_dims, total_rects, max_height = prepare_rects(df, margin)
|
| 523 |
if rects is None:
|
| 524 |
-
return None, "β οΈ Tidak ada data banner yang valid
|
| 525 |
|
| 526 |
-
#
|
| 527 |
best_overall = None
|
| 528 |
best_key = None
|
| 529 |
best_width = None
|
|
@@ -531,73 +595,89 @@ def process(roll_widths_text, margin, allow_rotation, search_mode, table_data):
|
|
| 531 |
|
| 532 |
for width in widths:
|
| 533 |
if search_mode == "quick":
|
| 534 |
-
# Mode cepat: 1 kombinasi default
|
| 535 |
result = run_packer(rects, width, max_height, bool(allow_rotation))
|
| 536 |
if result is None:
|
| 537 |
-
width_results.append((width, None, "Gagal"))
|
| 538 |
continue
|
| 539 |
placed, used_height, not_placed = result
|
| 540 |
if not_placed > 0:
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
|
| 548 |
stats_lines = [
|
| 549 |
-
f"π Mode : CEPAT
|
| 550 |
f"π Lebar roll : {width:g} cm",
|
| 551 |
-
f"
|
| 552 |
-
f"
|
| 553 |
-
f"
|
| 554 |
-
f"
|
| 555 |
-
f"π Efisiensi bahan (asli) : {true_eff:.1f}%",
|
| 556 |
]
|
| 557 |
|
| 558 |
-
key = (
|
| 559 |
if best_key is None or key < best_key:
|
| 560 |
best_key = key
|
| 561 |
best_overall = (img, "\n".join(stats_lines))
|
| 562 |
best_width = width
|
| 563 |
|
| 564 |
-
width_results.append((width,
|
| 565 |
|
| 566 |
else:
|
| 567 |
-
|
| 568 |
-
img, stats, used_height, true_eff = search_single_width(
|
| 569 |
rects, original_dims, total_rects, max_height,
|
| 570 |
width, margin, bool(allow_rotation),
|
| 571 |
search_mode, px_per_unit=4.0
|
| 572 |
)
|
| 573 |
|
| 574 |
if img is None:
|
| 575 |
-
width_results.append((width, None, "Gagal"))
|
| 576 |
continue
|
| 577 |
|
| 578 |
-
key = (
|
|
|
|
|
|
|
| 579 |
if best_key is None or key < best_key:
|
| 580 |
best_key = key
|
| 581 |
best_overall = (img, stats)
|
| 582 |
best_width = width
|
| 583 |
|
| 584 |
-
width_results.append((width,
|
| 585 |
|
| 586 |
if best_overall is None:
|
| 587 |
return None, (
|
| 588 |
"β οΈ Tidak ada satupun lebar roll yang bisa menampung semua banner. "
|
| 589 |
-
"Coba tambahkan lebar roll yang lebih besar
|
| 590 |
)
|
| 591 |
|
| 592 |
-
# Tambahkan ringkasan perbandingan lebar roll ke stats
|
| 593 |
img, stats = best_overall
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
comparison.append(f" β {w:g} cm β {note}")
|
| 598 |
else:
|
| 599 |
-
mark = " β" if w == best_width else ""
|
| 600 |
-
comparison.append(f" {'β
' if w == best_width else ' '} {w:g} cm β
|
| 601 |
|
| 602 |
final_stats = stats + "\n" + "\n".join(comparison)
|
| 603 |
|
|
@@ -608,13 +688,14 @@ def process(roll_widths_text, margin, allow_rotation, search_mode, table_data):
|
|
| 608 |
# 8. UI GRADIO
|
| 609 |
# --------------------------------------------------------------------------
|
| 610 |
|
| 611 |
-
with gr.Blocks(title="Nesting Otomatis Banner -
|
| 612 |
-
gr.Markdown("# π¨οΈ Nesting Otomatis
|
| 613 |
gr.Markdown(
|
| 614 |
-
"Masukkan daftar banner dan **beberapa ukuran lebar roll** (pisah
|
| 615 |
-
"Aplikasi akan mencoba semua kombinasi
|
| 616 |
-
"lalu memilih yang
|
| 617 |
-
"
|
|
|
|
| 618 |
)
|
| 619 |
|
| 620 |
with gr.Row():
|
|
@@ -650,11 +731,11 @@ with gr.Blocks(title="Nesting Otomatis Banner - Percetakan") as demo:
|
|
| 650 |
["Banner C", 50, 50, 4],
|
| 651 |
],
|
| 652 |
)
|
| 653 |
-
btn = gr.Button("π Proses Nesting", variant="primary")
|
| 654 |
|
| 655 |
with gr.Column(scale=2):
|
| 656 |
output_img = gr.Image(label="Pratinjau Layout Cetakan", type="pil")
|
| 657 |
-
output_stats = gr.Textbox(label="Statistik Hasil Nesting", lines=
|
| 658 |
|
| 659 |
btn.click(
|
| 660 |
fn=process,
|
|
|
|
| 1 |
"""
|
| 2 |
Nesting Otomatis + Visualisasi Layout untuk Percetakan Banner
|
| 3 |
================================================================
|
| 4 |
+
Fokus: MEMINIMALISIR WASTE (space kosong) secara maksimal.
|
|
|
|
|
|
|
| 5 |
|
| 6 |
Fitur:
|
| 7 |
+
- Inner/Outer visualization
|
| 8 |
+
- Multi-roll otomatis (jika 1 roll tidak cukup, buat roll kedua, dst.)
|
| 9 |
+
- Iterasi multi-lebar roll: pilih kombinasi yang TOTAL WASTE paling sedikit
|
| 10 |
- 5 Mode Pencarian: Cepat / Standar / Dalaman / Pro / Menyeluruh
|
| 11 |
+
- Waste visualization: area kosong ditampilkan dengan grid abu-abu
|
|
|
|
| 12 |
- Deploy di Hugging Face Spaces (Gradio SDK)
|
| 13 |
"""
|
| 14 |
|
|
|
|
| 42 |
# --------------------------------------------------------------------------
|
| 43 |
|
| 44 |
def generate_color(seed_text: str):
|
|
|
|
| 45 |
random.seed(seed_text)
|
| 46 |
return tuple(random.randint(90, 220) for _ in range(3))
|
| 47 |
|
| 48 |
|
| 49 |
def load_font(size: int = 14):
|
|
|
|
| 50 |
candidates = [
|
| 51 |
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 52 |
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def lighten_color(c, factor=0.55):
|
|
|
|
| 63 |
return tuple(min(255, int(v + (255 - v) * factor)) for v in c)
|
| 64 |
|
| 65 |
|
| 66 |
def get_sort_name(sort_algo):
|
|
|
|
| 67 |
mapping = {
|
| 68 |
+
SORT_AREA: "AREA", SORT_PERI: "PERI", SORT_LSIDE: "LSIDE",
|
| 69 |
+
SORT_SSIDE: "SSIDE", SORT_RATIO: "RATIO", SORT_DIFF: "DIFF",
|
| 70 |
+
SORT_NONE: "NONE", None: "Default",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
return mapping.get(sort_algo, str(sort_algo))
|
| 73 |
|
|
|
|
| 77 |
# --------------------------------------------------------------------------
|
| 78 |
|
| 79 |
def prepare_rects(df: pd.DataFrame, margin: float):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
rects = []
|
| 81 |
original_dims = {}
|
| 82 |
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
# --------------------------------------------------------------------------
|
| 108 |
+
# 3. CORE: PACKING ENGINE (single & multi bin)
|
| 109 |
# --------------------------------------------------------------------------
|
| 110 |
|
| 111 |
def run_packer(rects, roll_width, max_height, allow_rotation,
|
| 112 |
pack_algo=None, sort_algo=None, reverse=False):
|
| 113 |
"""
|
| 114 |
+
Jalankan rectpack. Return: (placed, used_height, not_placed) atau None.
|
|
|
|
|
|
|
| 115 |
"""
|
| 116 |
input_rects = list(reversed(rects)) if reverse else rects[:]
|
| 117 |
|
|
|
|
| 137 |
return placed, used_height, not_placed
|
| 138 |
|
| 139 |
|
| 140 |
+
def pack_multi_roll(rects, roll_width, max_height, allow_rotation,
|
| 141 |
+
pack_algo=None, sort_algo=None, reverse=False):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
"""
|
| 143 |
+
Packing dengan multi-roll support.
|
| 144 |
+
Jika ada item yang tidak muat di roll 1, buat roll 2, dst.
|
| 145 |
+
Return: list of (placed, used_height) per roll, plus not_placed_final.
|
| 146 |
"""
|
| 147 |
+
remaining = list(reversed(rects)) if reverse else rects[:]
|
| 148 |
+
rolls = []
|
| 149 |
+
total_not_placed = 0
|
| 150 |
+
safety_limit = 50 # max rolls
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
+
for _ in range(safety_limit):
|
| 153 |
+
if not remaining:
|
| 154 |
+
break
|
| 155 |
|
| 156 |
+
kwargs = {"rotation": allow_rotation}
|
| 157 |
+
if pack_algo is not None:
|
| 158 |
+
kwargs["pack_algo"] = pack_algo
|
| 159 |
+
if sort_algo is not None:
|
| 160 |
+
kwargs["sort_algo"] = sort_algo
|
| 161 |
|
| 162 |
+
packer = newPacker(**kwargs)
|
| 163 |
+
for w, h, rid in remaining:
|
| 164 |
+
packer.add_rect(w, h, rid=rid)
|
| 165 |
+
packer.add_bin(roll_width, max_height)
|
| 166 |
+
packer.pack()
|
| 167 |
|
| 168 |
+
if len(packer) == 0 or len(packer[0]) == 0:
|
| 169 |
+
total_not_placed = len(remaining)
|
| 170 |
+
break
|
| 171 |
|
| 172 |
+
placed = packer[0]
|
| 173 |
+
used_height = max(r.y + r.height for r in placed)
|
| 174 |
+
rolls.append((placed, used_height))
|
| 175 |
|
| 176 |
+
# Item yang belum terpakai
|
| 177 |
+
placed_ids = {r.rid for r in placed}
|
| 178 |
+
remaining = [r for r in remaining if r[2] not in placed_ids]
|
| 179 |
+
total_not_placed = len(remaining)
|
| 180 |
|
| 181 |
+
return rolls, total_not_placed
|
|
|
|
|
|
|
| 182 |
|
|
|
|
|
|
|
| 183 |
|
| 184 |
+
# --------------------------------------------------------------------------
|
| 185 |
+
# 4. VISUALISASI β MULTI ROLL + WASTE GRID
|
| 186 |
+
# --------------------------------------------------------------------------
|
|
|
|
| 187 |
|
| 188 |
+
def draw_waste_grid(draw, x0, y0, x1, y1, step=20):
|
| 189 |
+
"""Gambar grid abu-abu di area kosong untuk menandai waste."""
|
| 190 |
+
for x in range(int(x0), int(x1), step):
|
| 191 |
+
draw.line([(x, y0), (x, y1)], fill="#e0e0e0", width=1)
|
| 192 |
+
for y in range(int(y0), int(y1), step):
|
| 193 |
+
draw.line([(x0, y), (x1, y)], fill="#e0e0e0", width=1)
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
+
def render_multi_roll(rolls, original_dims, roll_width, margin, px_per_unit=4.0):
|
| 197 |
+
"""
|
| 198 |
+
Render multiple rolls secara vertikal (stacked).
|
| 199 |
+
Return: (img, total_true_area, total_roll_area, total_rotated)
|
| 200 |
+
"""
|
| 201 |
+
if not rolls:
|
| 202 |
+
return None, 0, 0, 0
|
| 203 |
|
| 204 |
+
img_w = max(1, int(round(roll_width * px_per_unit)))
|
| 205 |
+
gap_between_rolls = 40 # pixel gap antar roll
|
| 206 |
+
total_img_h = sum(int(round(h * px_per_unit)) for _, h in rolls)
|
| 207 |
+
total_img_h += gap_between_rolls * (len(rolls) - 1) + 60 # extra for labels
|
| 208 |
|
| 209 |
+
img = Image.new("RGB", (img_w, total_img_h), "white")
|
| 210 |
+
draw = ImageDraw.Draw(img)
|
| 211 |
+
font = load_font(14)
|
| 212 |
+
font_small = load_font(11)
|
| 213 |
+
font_title = load_font(16)
|
| 214 |
+
|
| 215 |
+
y_offset = 0
|
| 216 |
+
total_true_area = 0
|
| 217 |
+
total_roll_area = 0
|
| 218 |
+
total_rotated = 0
|
| 219 |
+
|
| 220 |
+
for roll_idx, (placed, used_height) in enumerate(rolls):
|
| 221 |
+
roll_h_px = int(round(used_height * px_per_unit))
|
| 222 |
+
|
| 223 |
+
# Label roll
|
| 224 |
+
roll_label = f"ROLL {roll_idx + 1} | {roll_width:g} x {used_height:.1f} cm"
|
| 225 |
+
draw.text((img_w / 2, y_offset + 5), roll_label,
|
| 226 |
+
fill="#cc0000", font=font_title, anchor="ma")
|
| 227 |
+
|
| 228 |
+
roll_y0 = y_offset + 25
|
| 229 |
+
roll_y1 = roll_y0 + roll_h_px
|
| 230 |
+
|
| 231 |
+
# Bingkai roll
|
| 232 |
+
draw.rectangle([0, roll_y0, img_w - 1, roll_y1], outline="red", width=3)
|
| 233 |
+
|
| 234 |
+
# Gambar setiap item
|
| 235 |
+
for r in placed:
|
| 236 |
+
base_label = r.rid.rsplit("#", 1)[0]
|
| 237 |
+
color = generate_color(base_label)
|
| 238 |
+
outer_color = lighten_color(color, 0.55)
|
| 239 |
+
inner_color = color
|
| 240 |
+
|
| 241 |
+
orig_w, orig_h = original_dims[r.rid]
|
| 242 |
+
is_rotated = round(r.width - margin, 3) != round(orig_w, 3)
|
| 243 |
+
|
| 244 |
+
x0 = r.x * px_per_unit
|
| 245 |
+
y0 = roll_y0 + (used_height - r.y - r.height) * px_per_unit
|
| 246 |
+
x1 = (r.x + r.width) * px_per_unit
|
| 247 |
+
y1 = roll_y0 + (used_height - r.y) * px_per_unit
|
| 248 |
+
|
| 249 |
+
m = margin * px_per_unit
|
| 250 |
+
ix0, iy0 = x0 + m / 2, y0 + m / 2
|
| 251 |
+
ix1, iy1 = x1 - m / 2, y1 - m / 2
|
| 252 |
+
|
| 253 |
+
# Outer
|
| 254 |
+
draw.rectangle([x0, y0, x1, y1], fill=outer_color, outline="black", width=2)
|
| 255 |
+
# Inner
|
| 256 |
+
if ix1 > ix0 and iy1 > iy0:
|
| 257 |
+
draw.rectangle([ix0, iy0, ix1, iy1], fill=inner_color, outline="black", width=1)
|
| 258 |
+
|
| 259 |
+
# Label
|
| 260 |
+
label_text = base_label
|
| 261 |
+
size_text = f"{orig_w:g} x {orig_h:g}" + (" (diputar)" if is_rotated else "")
|
| 262 |
+
bbox_label = draw.textbbox((0, 0), label_text, font=font)
|
| 263 |
+
bbox_size = draw.textbbox((0, 0), size_text, font=font_small)
|
| 264 |
+
total_th = (bbox_label[3] - bbox_label[1]) + (bbox_size[3] - bbox_size[1]) + 4
|
| 265 |
+
|
| 266 |
+
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
| 267 |
+
ty = cy - total_th / 2
|
| 268 |
+
|
| 269 |
+
if (x1 - x0) > 20 and (y1 - y0) > 20:
|
| 270 |
+
draw.text((cx, ty), label_text, fill="black", font=font, anchor="ma")
|
| 271 |
+
draw.text((cx, ty + (bbox_label[3] - bbox_label[1]) + 4), size_text,
|
| 272 |
+
fill="black", font=font_small, anchor="ma")
|
| 273 |
+
|
| 274 |
+
total_true_area += orig_w * orig_h
|
| 275 |
+
if is_rotated:
|
| 276 |
+
total_rotated += 1
|
| 277 |
+
|
| 278 |
+
# Gambar waste grid di area kosong
|
| 279 |
+
placed_pixels = []
|
| 280 |
+
for r in placed:
|
| 281 |
+
x0 = r.x * px_per_unit
|
| 282 |
+
y0 = roll_y0 + (used_height - r.y - r.height) * px_per_unit
|
| 283 |
+
x1 = (r.x + r.width) * px_per_unit
|
| 284 |
+
y1 = roll_y0 + (used_height - r.y) * px_per_unit
|
| 285 |
+
placed_pixels.append((x0, y0, x1, y1))
|
| 286 |
+
|
| 287 |
+
# Scan area kosong secara kasar (sederhana: per baris)
|
| 288 |
+
# Untuk efisiensi, kita gambar grid di seluruh roll lalu overlay placed areas
|
| 289 |
+
# (Pillow tidak mendukung mask kompleks, jadi kita gambar grid di luar placed)
|
| 290 |
+
# Simplifikasi: gambar grid tipis di seluruh roll, item menutupinya
|
| 291 |
+
for x in range(0, img_w, 20):
|
| 292 |
+
draw.line([(x, roll_y0), (x, roll_y1)], fill="#f5f5f5", width=1)
|
| 293 |
+
for y in range(int(roll_y0), int(roll_y1), 20):
|
| 294 |
+
draw.line([(0, y), (img_w - 1, y)], fill="#f5f5f5", width=1)
|
| 295 |
+
|
| 296 |
+
total_roll_area += roll_width * used_height
|
| 297 |
+
y_offset = roll_y1 + gap_between_rolls
|
| 298 |
+
|
| 299 |
+
return img, total_true_area, total_roll_area, total_rotated
|
| 300 |
|
| 301 |
|
| 302 |
# --------------------------------------------------------------------------
|
|
|
|
| 304 |
# --------------------------------------------------------------------------
|
| 305 |
|
| 306 |
def get_pre_sort_strategies(rects):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
strategies = []
|
| 308 |
|
| 309 |
def area(r): return r[0] * r[1]
|
|
|
|
| 330 |
]
|
| 331 |
strategies.extend(base)
|
| 332 |
|
|
|
|
| 333 |
label_groups = {}
|
| 334 |
for r in rects:
|
| 335 |
label = r[2].rsplit("#", 1)[0]
|
|
|
|
| 339 |
grouped.extend(label_groups[label])
|
| 340 |
strategies.append(("group_label", grouped))
|
| 341 |
|
|
|
|
| 342 |
by_area = sorted(rects, key=area, reverse=True)
|
| 343 |
interleaved = []
|
| 344 |
i, j = 0, len(by_area) - 1
|
|
|
|
| 361 |
roll_width, margin, allow_rotation,
|
| 362 |
search_mode, px_per_unit):
|
| 363 |
"""
|
| 364 |
+
Cari layout paling hemat waste untuk SATU lebar roll.
|
| 365 |
+
Return: (img, stats_text, total_waste, num_rolls) atau (None, error, None, None)
|
| 366 |
"""
|
| 367 |
best_result = None
|
| 368 |
best_key = None
|
| 369 |
best_config = None
|
| 370 |
+
best_rolls = None
|
| 371 |
tried = 0
|
| 372 |
failed = 0
|
| 373 |
|
| 374 |
+
def evaluate(rolls, not_placed, config_name):
|
| 375 |
+
nonlocal best_result, best_key, best_config, best_rolls
|
| 376 |
+
if not rolls:
|
| 377 |
return
|
| 378 |
+
|
| 379 |
+
total_true = sum(
|
| 380 |
+
original_dims[r.rid][0] * original_dims[r.rid][1]
|
| 381 |
+
for placed, _ in rolls for r in placed
|
| 382 |
+
)
|
| 383 |
+
total_roll = sum(roll_width * h for _, h in rolls)
|
| 384 |
+
total_waste = total_roll - total_true
|
| 385 |
+
num_rolls = len(rolls)
|
| 386 |
+
max_h = max(h for _, h in rolls)
|
| 387 |
+
|
| 388 |
+
# Kriteria: waste paling sedikit, lalu roll paling sedikit, lalu tinggi paling rendah
|
| 389 |
+
key = (total_waste, num_rolls, max_h, not_placed)
|
| 390 |
if best_key is None or key < best_key:
|
| 391 |
best_key = key
|
| 392 |
+
best_result = (rolls, not_placed)
|
| 393 |
best_config = config_name
|
| 394 |
+
best_rolls = rolls
|
| 395 |
|
|
|
|
|
|
|
|
|
|
| 396 |
ALL_PACK_ALGOS = [
|
| 397 |
None, MaxRectsBssf, MaxRectsBaf, MaxRectsBl, MaxRectsBlsf,
|
| 398 |
SkylineBl, SkylineBlWm, SkylineMwf, SkylineMwfl,
|
|
|
|
| 408 |
ALL_SORT_ALGOS = [None, SORT_AREA, SORT_PERI, SORT_LSIDE, SORT_SSIDE,
|
| 409 |
SORT_RATIO, SORT_DIFF, SORT_NONE]
|
| 410 |
|
| 411 |
+
# PHASE 1: GRID SEARCH
|
| 412 |
if search_mode in ("standard", "deep", "pro", "exhaustive"):
|
| 413 |
if search_mode == "standard":
|
| 414 |
pack_algos = [
|
|
|
|
| 427 |
for sort_algo in sort_algos:
|
| 428 |
for reverse in [False, True]:
|
| 429 |
tried += 1
|
| 430 |
+
rolls, not_placed = pack_multi_roll(
|
| 431 |
rects, roll_width, max_height, allow_rotation,
|
| 432 |
pack_algo, sort_algo, reverse
|
| 433 |
)
|
| 434 |
+
if not rolls:
|
| 435 |
failed += 1
|
| 436 |
continue
|
| 437 |
algo_name = pack_algo.__name__ if pack_algo else "Default"
|
| 438 |
sort_name = get_sort_name(sort_algo)
|
| 439 |
rev = " (rev)" if reverse else ""
|
| 440 |
config = f"[Grid] {algo_name} + SORT_{sort_name}{rev}"
|
| 441 |
+
evaluate(rolls, not_placed, config)
|
| 442 |
|
|
|
|
| 443 |
# PHASE 2: PRE-SORT MANUAL
|
|
|
|
| 444 |
if search_mode in ("pro", "exhaustive"):
|
| 445 |
pre_sorts = get_pre_sort_strategies(rects)
|
| 446 |
top_packs = [
|
|
|
|
| 452 |
for pack_algo in top_packs:
|
| 453 |
for reverse in [False, True]:
|
| 454 |
tried += 1
|
| 455 |
+
rolls, not_placed = pack_multi_roll(
|
| 456 |
sorted_rects, roll_width, max_height, allow_rotation,
|
| 457 |
pack_algo, SORT_NONE, reverse
|
| 458 |
)
|
| 459 |
+
if not rolls:
|
| 460 |
failed += 1
|
| 461 |
continue
|
| 462 |
algo_name = pack_algo.__name__ if pack_algo else "Default"
|
| 463 |
rev = " (rev)" if reverse else ""
|
| 464 |
config = f"[PreSort:{sort_name}] {algo_name}{rev}"
|
| 465 |
+
evaluate(rolls, not_placed, config)
|
| 466 |
|
| 467 |
+
# PHASE 3: MONTE CARLO
|
|
|
|
|
|
|
| 468 |
if search_mode in ("pro", "exhaustive"):
|
| 469 |
mc_count = 500 if search_mode == "pro" else 2000
|
| 470 |
+
mc_configs = [(None, None), (MaxRectsBssf, SORT_AREA), (GuillotineBssfSas, SORT_AREA)]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
|
| 472 |
for seed in range(mc_count):
|
| 473 |
random.seed(seed)
|
| 474 |
shuffled = random.sample(rects, len(rects))
|
| 475 |
for pack_algo, sort_algo in mc_configs:
|
| 476 |
tried += 1
|
| 477 |
+
rolls, not_placed = pack_multi_roll(
|
| 478 |
shuffled, roll_width, max_height, allow_rotation,
|
| 479 |
pack_algo, sort_algo, False
|
| 480 |
)
|
| 481 |
+
if not rolls:
|
| 482 |
failed += 1
|
| 483 |
continue
|
| 484 |
algo_name = pack_algo.__name__ if pack_algo else "Default"
|
| 485 |
config = f"[MC:{seed}] {algo_name}"
|
| 486 |
+
evaluate(rolls, not_placed, config)
|
| 487 |
+
|
| 488 |
+
# PHASE 4: LOCAL SWAP (dari best order)
|
| 489 |
+
if search_mode == "exhaustive" and best_rolls is not None:
|
| 490 |
+
# Reconstruct best order
|
| 491 |
+
best_order = []
|
| 492 |
+
placed_ids = set()
|
| 493 |
+
for placed, _ in best_rolls:
|
| 494 |
+
for r in placed:
|
| 495 |
+
placed_ids.add(r.rid)
|
| 496 |
+
for r in rects:
|
| 497 |
+
if r[2] in placed_ids:
|
| 498 |
+
best_order.append(r)
|
| 499 |
+
|
| 500 |
+
n = len(best_order)
|
| 501 |
swap_iters = min(300, n * (n - 1) // 2)
|
|
|
|
| 502 |
seen_swaps = set()
|
| 503 |
attempts = 0
|
| 504 |
while len(seen_swaps) < swap_iters and attempts < swap_iters * 3:
|
|
|
|
| 511 |
continue
|
| 512 |
seen_swaps.add(pair)
|
| 513 |
|
| 514 |
+
swapped = best_order[:]
|
| 515 |
swapped[i], swapped[j] = swapped[j], swapped[i]
|
|
|
|
| 516 |
tried += 1
|
| 517 |
+
rolls, not_placed = pack_multi_roll(
|
| 518 |
swapped, roll_width, max_height, allow_rotation,
|
| 519 |
None, SORT_NONE, False
|
| 520 |
)
|
| 521 |
+
if not rolls:
|
| 522 |
failed += 1
|
| 523 |
continue
|
| 524 |
config = f"[Swap:{i}-{j}] Default"
|
| 525 |
+
evaluate(rolls, not_placed, config)
|
| 526 |
|
| 527 |
+
# Finalisasi
|
| 528 |
if best_result is None:
|
| 529 |
+
return None, f"β οΈ Lebar {roll_width:g} cm: tidak ada kombinasi yang muat.", None, None
|
|
|
|
|
|
|
|
|
|
| 530 |
|
| 531 |
+
rolls, not_placed = best_result
|
| 532 |
+
img, total_true_area, total_roll_area, total_rotated = render_multi_roll(
|
| 533 |
+
rolls, original_dims, roll_width, margin, px_per_unit
|
| 534 |
)
|
| 535 |
|
| 536 |
+
total_waste = total_roll_area - total_true_area
|
| 537 |
+
waste_pct = (total_waste / total_roll_area * 100) if total_roll_area > 0 else 0
|
| 538 |
+
num_rolls = len(rolls)
|
| 539 |
+
total_height = sum(h for _, h in rolls)
|
| 540 |
+
|
| 541 |
stats_lines = [
|
| 542 |
f"π Algoritma terpilih : {best_config}",
|
| 543 |
f"π Mode pencarian : {search_mode.upper()}",
|
| 544 |
f"π Total iterasi dicoba : {tried} (gagal: {failed})",
|
| 545 |
+
f"π Lebar roll : {roll_width:g} cm",
|
| 546 |
+
f"π¦ Jumlah roll yang dibutuhkan : {num_rolls}",
|
| 547 |
+
f"π Total panjang roll : {total_height:.1f} cm",
|
| 548 |
+
f"β
Item berhasil ditempatkan : {sum(len(p) for p, _ in rolls)} / {total_rects}",
|
| 549 |
+
f"π Item yang diputar 90Β° : {total_rotated}",
|
| 550 |
+
f"π Total luas item (asli) : {total_true_area:.1f} cmΒ²",
|
| 551 |
+
f"π Total luas roll terpakai : {total_roll_area:.1f} cmΒ²",
|
| 552 |
+
f"β»οΈ TOTAL WASTE : {total_waste:.1f} cmΒ²",
|
| 553 |
+
f"π Persentase waste : {waste_pct:.2f}%",
|
| 554 |
]
|
| 555 |
+
if not_placed > 0:
|
| 556 |
+
stats_lines.insert(6, f"β οΈ Item TIDAK muat : {not_placed}")
|
| 557 |
|
| 558 |
+
return img, "\n".join(stats_lines), total_waste, num_rolls
|
| 559 |
|
| 560 |
|
| 561 |
# --------------------------------------------------------------------------
|
|
|
|
| 569 |
df = pd.DataFrame(table_data, columns=["Label", "Width", "Height", "Qty"])
|
| 570 |
df = df.dropna(subset=["Width", "Height", "Qty"], how="any")
|
| 571 |
|
|
|
|
| 572 |
try:
|
| 573 |
widths = [float(w.strip()) for w in roll_widths_text.split(",") if w.strip()]
|
| 574 |
widths = [w for w in widths if w > 0]
|
| 575 |
except ValueError:
|
| 576 |
+
return None, "β οΈ Format daftar lebar roll tidak valid. Gunakan angka dipisah koma."
|
| 577 |
|
| 578 |
if not widths:
|
| 579 |
return None, "β οΈ Masukkan minimal satu lebar roll yang valid."
|
|
|
|
| 583 |
except (TypeError, ValueError):
|
| 584 |
margin = 0.0
|
| 585 |
|
|
|
|
| 586 |
rects, original_dims, total_rects, max_height = prepare_rects(df, margin)
|
| 587 |
if rects is None:
|
| 588 |
+
return None, "β οΈ Tidak ada data banner yang valid."
|
| 589 |
|
| 590 |
+
# Iterasi semua lebar roll, cari yang TOTAL WASTE paling sedikit
|
| 591 |
best_overall = None
|
| 592 |
best_key = None
|
| 593 |
best_width = None
|
|
|
|
| 595 |
|
| 596 |
for width in widths:
|
| 597 |
if search_mode == "quick":
|
| 598 |
+
# Mode cepat: 1 kombinasi default, single roll
|
| 599 |
result = run_packer(rects, width, max_height, bool(allow_rotation))
|
| 600 |
if result is None:
|
| 601 |
+
width_results.append((width, None, None, "Gagal"))
|
| 602 |
continue
|
| 603 |
placed, used_height, not_placed = result
|
| 604 |
if not_placed > 0:
|
| 605 |
+
# Coba multi-roll untuk quick mode juga
|
| 606 |
+
rolls, not_placed = pack_multi_roll(
|
| 607 |
+
rects, width, max_height, bool(allow_rotation)
|
| 608 |
+
)
|
| 609 |
+
if not rolls:
|
| 610 |
+
width_results.append((width, None, None, "Gagal"))
|
| 611 |
+
continue
|
| 612 |
+
img, total_true, total_roll, total_rot = render_multi_roll(
|
| 613 |
+
rolls, original_dims, width, margin
|
| 614 |
+
)
|
| 615 |
+
waste = total_roll - total_true
|
| 616 |
+
num_rolls = len(rolls)
|
| 617 |
+
else:
|
| 618 |
+
rolls = [(placed, used_height)]
|
| 619 |
+
img, total_true, total_roll, total_rot = render_multi_roll(
|
| 620 |
+
rolls, original_dims, width, margin
|
| 621 |
+
)
|
| 622 |
+
waste = total_roll - total_true
|
| 623 |
+
num_rolls = 1
|
| 624 |
|
| 625 |
stats_lines = [
|
| 626 |
+
f"π Mode : CEPAT",
|
| 627 |
f"π Lebar roll : {width:g} cm",
|
| 628 |
+
f"π¦ Jumlah roll : {num_rolls}",
|
| 629 |
+
f"π Total panjang : {sum(h for _, h in rolls):.1f} cm",
|
| 630 |
+
f"β»οΈ Total waste : {waste:.1f} cmΒ²",
|
| 631 |
+
f"π Persentase waste : {(waste/total_roll*100) if total_roll else 0:.2f}%",
|
|
|
|
| 632 |
]
|
| 633 |
|
| 634 |
+
key = (waste, num_rolls, sum(h for _, h in rolls))
|
| 635 |
if best_key is None or key < best_key:
|
| 636 |
best_key = key
|
| 637 |
best_overall = (img, "\n".join(stats_lines))
|
| 638 |
best_width = width
|
| 639 |
|
| 640 |
+
width_results.append((width, waste, num_rolls, f"{(waste/total_roll*100) if total_roll else 0:.1f}%"))
|
| 641 |
|
| 642 |
else:
|
| 643 |
+
img, stats, waste, num_rolls = search_single_width(
|
|
|
|
| 644 |
rects, original_dims, total_rects, max_height,
|
| 645 |
width, margin, bool(allow_rotation),
|
| 646 |
search_mode, px_per_unit=4.0
|
| 647 |
)
|
| 648 |
|
| 649 |
if img is None:
|
| 650 |
+
width_results.append((width, None, None, "Gagal"))
|
| 651 |
continue
|
| 652 |
|
| 653 |
+
key = (waste if waste is not None else float('inf'),
|
| 654 |
+
num_rolls if num_rolls is not None else 99,
|
| 655 |
+
0)
|
| 656 |
if best_key is None or key < best_key:
|
| 657 |
best_key = key
|
| 658 |
best_overall = (img, stats)
|
| 659 |
best_width = width
|
| 660 |
|
| 661 |
+
width_results.append((width, waste, num_rolls, f"{(waste/(waste+sum(original_dims[r][0]*original_dims[r][1] for r in original_dims))*100):.1f}%" if waste else "0%"))
|
| 662 |
|
| 663 |
if best_overall is None:
|
| 664 |
return None, (
|
| 665 |
"β οΈ Tidak ada satupun lebar roll yang bisa menampung semua banner. "
|
| 666 |
+
"Coba tambahkan lebar roll yang lebih besar."
|
| 667 |
)
|
| 668 |
|
|
|
|
| 669 |
img, stats = best_overall
|
| 670 |
+
|
| 671 |
+
# Perbandingan semua lebar
|
| 672 |
+
comparison = ["\n" + "β" * 50, "π PERBANDINGAN SEMUA LEBAR ROLL (diurut waste terendah):"]
|
| 673 |
+
sorted_results = sorted(width_results, key=lambda x: (x[1] if x[1] is not None else float('inf'), x[2] if x[2] is not None else 99))
|
| 674 |
+
|
| 675 |
+
for w, waste, rolls, note in sorted_results:
|
| 676 |
+
if waste is None:
|
| 677 |
comparison.append(f" β {w:g} cm β {note}")
|
| 678 |
else:
|
| 679 |
+
mark = " β PALING HEMAT" if w == best_width else ""
|
| 680 |
+
comparison.append(f" {'β
' if w == best_width else ' '} {w:g} cm β {rolls} roll, waste {waste:.1f} cmΒ² ({note}){mark}")
|
| 681 |
|
| 682 |
final_stats = stats + "\n" + "\n".join(comparison)
|
| 683 |
|
|
|
|
| 688 |
# 8. UI GRADIO
|
| 689 |
# --------------------------------------------------------------------------
|
| 690 |
|
| 691 |
+
with gr.Blocks(title="Nesting Otomatis Banner - Minim Waste") as demo:
|
| 692 |
+
gr.Markdown("# π¨οΈ Nesting Otomatis β Fokus Minimasi Waste")
|
| 693 |
gr.Markdown(
|
| 694 |
+
"Masukkan daftar banner dan **beberapa ukuran lebar roll** (pisah koma). "
|
| 695 |
+
"Aplikasi akan mencoba semua kombinasi pada **setiap lebar roll**, "
|
| 696 |
+
"lalu memilih yang **TOTAL WASTE PALING SEDIKIT**. "
|
| 697 |
+
"Jika 1 roll tidak cukup, otomatis dibuat **roll kedua, ketiga**, dst. "
|
| 698 |
+
"Area **pastel** = margin, **pekat** = banner, **grid abu** = waste/kosong."
|
| 699 |
)
|
| 700 |
|
| 701 |
with gr.Row():
|
|
|
|
| 731 |
["Banner C", 50, 50, 4],
|
| 732 |
],
|
| 733 |
)
|
| 734 |
+
btn = gr.Button("π Proses Nesting β Minim Waste", variant="primary")
|
| 735 |
|
| 736 |
with gr.Column(scale=2):
|
| 737 |
output_img = gr.Image(label="Pratinjau Layout Cetakan", type="pil")
|
| 738 |
+
output_stats = gr.Textbox(label="Statistik Hasil Nesting", lines=14)
|
| 739 |
|
| 740 |
btn.click(
|
| 741 |
fn=process,
|