FajarHidaa commited on
Commit
eb2e0b1
Β·
verified Β·
1 Parent(s): 4dfe84e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +282 -201
app.py CHANGED
@@ -1,16 +1,14 @@
1
  """
2
  Nesting Otomatis + Visualisasi Layout untuk Percetakan Banner
3
  ================================================================
4
- Menggunakan rectpack untuk menghitung penempatan terbaik banner-banner
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 (banner asli vs area margin)
10
- - Iterasi multi-lebar roll: coba semua ukuran roll, pilih yang paling hemat
 
11
  - 5 Mode Pencarian: Cepat / Standar / Dalaman / Pro / Menyeluruh
12
- - Iterasi maksimal: grid search + pre-sort + Monte Carlo + Local Swap
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
- SORT_PERI: "PERI",
76
- SORT_LSIDE: "LSIDE",
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 dengan konfigurasi tertentu.
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
- # 4. VISUALISASI (Pillow) β€” INNER + OUTER
159
- # --------------------------------------------------------------------------
160
-
161
- def render_layout(placed, used_height, original_dims, roll_width, margin,
162
- px_per_unit=4.0):
163
  """
164
- Gambar hasil packing ke PIL Image.
165
- Return: (img, used_area, true_used_area, efficiency, true_efficiency, rotated_count)
 
166
  """
167
- img_w = max(1, int(round(roll_width * px_per_unit)))
168
- img_h = max(1, int(round(used_height * px_per_unit)))
169
-
170
- img = Image.new("RGB", (img_w, img_h), "white")
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
- orig_w, orig_h = original_dims[r.rid]
182
- is_rotated = round(r.width - margin, 3) != round(orig_w, 3)
 
183
 
184
- # Koordinat OUTER (termasuk margin)
185
- x0 = r.x * px_per_unit
186
- y0 = (used_height - r.y - r.height) * px_per_unit
187
- x1 = (r.x + r.width) * px_per_unit
188
- y1 = (used_height - r.y) * px_per_unit
189
 
190
- # Koordinat INNER (banner asli)
191
- m = margin * px_per_unit
192
- ix0, iy0 = x0 + m / 2, y0 + m / 2
193
- ix1, iy1 = x1 - m / 2, y1 - m / 2
 
194
 
195
- # Gambar OUTER (area margin) β€” warna pastel
196
- draw.rectangle([x0, y0, x1, y1], fill=outer_color, outline="black", width=2)
 
197
 
198
- # Gambar INNER (banner asli) β€” warna asli
199
- if ix1 > ix0 and iy1 > iy0:
200
- draw.rectangle([ix0, iy0, ix1, iy1], fill=inner_color, outline="black", width=1)
201
 
202
- # Label
203
- label_text = base_label
204
- size_text = f"{orig_w:g} x {orig_h:g}" + (" (diputar)" if is_rotated else "")
 
205
 
206
- bbox_label = draw.textbbox((0, 0), label_text, font=font)
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
- if (x1 - x0) > 20 and (y1 - y0) > 20:
214
- draw.text((cx, ty), label_text, fill="black", font=font, anchor="ma")
215
- draw.text((cx, ty + (bbox_label[3] - bbox_label[1]) + 4), size_text,
216
- fill="black", font=font_small, anchor="ma")
217
 
218
- # Bingkai roll
219
- draw.rectangle([0, 0, img_w - 1, img_h - 1], outline="red", width=3)
 
 
 
 
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
- true_used_area = sum(original_dims[r.rid][0] * original_dims[r.rid][1] for r in placed)
227
- true_efficiency = (true_used_area / roll_area * 100) if roll_area > 0 else 0
 
 
 
 
 
228
 
229
- rotated_count = sum(
230
- 1 for r in placed
231
- if round(r.width - margin, 3) != round(original_dims[r.rid][0], 3)
232
- )
233
 
234
- return img, used_area, true_used_area, efficiency, true_efficiency, rotated_count
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Iterasi berbagai kombinasi untuk SATU lebar roll tertentu.
306
- Return: (img, stats_text, used_height, true_eff) atau (None, error_msg, None, None)
307
  """
308
  best_result = None
309
  best_key = None
310
  best_config = None
311
- best_rects_order = None
312
  tried = 0
313
  failed = 0
314
 
315
- def evaluate(result, config_name, rects_order):
316
- nonlocal best_result, best_key, best_config, best_rects_order
317
- if result is None:
318
  return
319
- placed, used_height, not_placed = result
320
- if not_placed > 0:
321
- return
322
- true_area = sum(original_dims[r.rid][0] * original_dims[r.rid][1] for r in placed)
323
- roll_area = roll_width * used_height
324
- true_eff = (true_area / roll_area * 100) if roll_area > 0 else 0
325
- key = (used_height, -true_eff)
 
 
 
 
 
326
  if best_key is None or key < best_key:
327
  best_key = key
328
- best_result = result
329
  best_config = config_name
330
- best_rects_order = rects_order[:]
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
- result = run_packer(
369
  rects, roll_width, max_height, allow_rotation,
370
  pack_algo, sort_algo, reverse
371
  )
372
- if result is None:
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(result, config, rects)
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
- result = run_packer(
396
  sorted_rects, roll_width, max_height, allow_rotation,
397
  pack_algo, SORT_NONE, reverse
398
  )
399
- if result is None:
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(result, config, sorted_rects)
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
- result = run_packer(
424
  shuffled, roll_width, max_height, allow_rotation,
425
  pack_algo, sort_algo, False
426
  )
427
- if result is None:
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(result, config, shuffled)
433
-
434
- # ============================================================
435
- # PHASE 4: LOCAL SWAP OPTIMIZATION
436
- # ============================================================
437
- if search_mode == "exhaustive" and best_rects_order is not None:
438
- base_order = best_rects_order[:]
439
- n = len(base_order)
 
 
 
 
 
 
 
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 = base_order[:]
455
  swapped[i], swapped[j] = swapped[j], swapped[i]
456
-
457
  tried += 1
458
- result = run_packer(
459
  swapped, roll_width, max_height, allow_rotation,
460
  None, SORT_NONE, False
461
  )
462
- if result is None:
463
  failed += 1
464
  continue
465
  config = f"[Swap:{i}-{j}] Default"
466
- evaluate(result, config, swapped)
467
 
468
- # --- Finalisasi ---
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
- placed, used_height, not_placed = best_result
476
- img, used_area, true_used_area, eff, true_eff, rot_count = render_layout(
477
- placed, used_height, original_dims, roll_width, margin, px_per_unit
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"βœ… Item berhasil ditempatkan : {len(placed)} / {total_rects}",
485
- f"πŸ”„ Item yang diputar 90Β° : {rot_count}",
486
- f"πŸ“ Ukuran layout terpakai : {roll_width:g} x {used_height:.1f}",
487
- f"πŸ“ Total luas item (asli) : {true_used_area:.1f} unitΒ²",
488
- f"πŸ“Š Efisiensi bahan (asli) : {true_eff:.1f}%",
489
- f"πŸ“Š Efisiensi bahan (dgn margin): {eff:.1f}%",
 
 
 
490
  ]
 
 
491
 
492
- return img, "\n".join(stats_lines), used_height, true_eff
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, misal: 150, 200, 250"
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 untuk di-nesting."
525
 
526
- # --- Iterasi semua lebar roll, cari yang paling hemat ---
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 per lebar
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
- width_results.append((width, used_height, f"{not_placed} item tidak muat"))
542
- continue
543
-
544
- img, _, true_used_area, _, true_eff, rot_count = render_layout(
545
- placed, used_height, original_dims, width, margin
546
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
547
 
548
  stats_lines = [
549
- f"πŸ† Mode : CEPAT (1 kombinasi)",
550
  f"πŸ“ Lebar roll : {width:g} cm",
551
- f"βœ… Item berhasil ditempatkan : {len(placed)} / {total_rects}",
552
- f"πŸ”„ Item yang diputar 90Β° : {rot_count}",
553
- f"πŸ“ Ukuran layout terpakai : {width:g} x {used_height:.1f}",
554
- f"πŸ“ Total luas item (asli) : {true_used_area:.1f} unitΒ²",
555
- f"πŸ“Š Efisiensi bahan (asli) : {true_eff:.1f}%",
556
  ]
557
 
558
- key = (used_height, -true_eff)
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, used_height, f"{true_eff:.1f}%"))
565
 
566
  else:
567
- # Mode optimasi: jalankan pencarian mendalam per lebar
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 = (used_height, -true_eff)
 
 
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, used_height, f"{true_eff:.1f}%"))
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 atau kurangi ukuran banner."
590
  )
591
 
592
- # Tambahkan ringkasan perbandingan lebar roll ke stats
593
  img, stats = best_overall
594
- comparison = ["\n" + "─" * 40, "πŸ“‹ PERBANDINGAN SEMUA LEBAR ROLL:"]
595
- for w, h, note in width_results:
596
- if h is None:
 
 
 
 
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 β†’ tinggi {h:.1f}, efisiensi {note}{mark}")
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 - Percetakan") as demo:
612
- gr.Markdown("# πŸ–¨οΈ Nesting Otomatis & Visualisasi Layout Cetak Banner")
613
  gr.Markdown(
614
- "Masukkan daftar banner dan **beberapa ukuran lebar roll** (pisah dengan koma). "
615
- "Aplikasi akan mencoba semua kombinasi algoritma pada **setiap lebar roll**, "
616
- "lalu memilih yang paling hemat bahan. Area **pastel** = margin, "
617
- "area **pekat** = ukuran banner asli."
 
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=12)
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,