{"id":12674,"date":"2026-10-04T22:43:31","date_gmt":"2026-10-05T03:43:31","guid":{"rendered":"https:\/\/www.rushworth.us\/lisa\/?p=12674"},"modified":"2026-11-04T22:50:46","modified_gmt":"2026-11-05T03:50:46","slug":"threads-changes","status":"publish","type":"post","link":"https:\/\/www.rushworth.us\/lisa\/?p=12674","title":{"rendered":"Threads &#8211; Changes"},"content":{"rendered":"<p>I had a few issues that prevented me from using Threads in its current form. I suspect my use case differs from the one they are coding to &#8211; I have resources and no need to limit myself to a small number of thread colors. Also, a hidden UI element seems to hide a few neighboring elements that I wanted to use.<\/p>\n<p>Updates:<\/p>\n<p>app.py<br \/>\n@@ -12,6 +12,7 @@ family of projects integrated:<br \/>\n&#8220;&#8221;&#8221;<br \/>\nimport base64<br \/>\nimport json<br \/>\n+import logging<br \/>\nimport os<br \/>\nimport sys<br \/>\nimport tempfile<br \/>\n@@ -36,8 +37,13 @@ from inkstitchlib import ai_naming<br \/>\nfrom inkstitchlib import density as density_map<br \/>\nfrom inkstitchlib import presets, sketch as sketch_mod<\/p>\n<p>-JOBS = os.path.join(tempfile.gettempdir(), &#8216;stitchforge_jobs&#8217;)<br \/>\n-os.makedirs(JOBS, exist_ok=True)<br \/>\n+#JOBS = os.path.join(tempfile.gettempdir(), &#8216;stitchforge_jobs&#8217;)<br \/>\n+#os.makedirs(JOBS, exist_ok=True)<br \/>\n+from pathlib import Path<br \/>\n+<br \/>\n+JOBS = Path(__file__).resolve().parent \/ &#8220;stitchforge_jobs&#8221;<br \/>\n+JOBS.mkdir(parents=True, exist_ok=True)<br \/>\n+<\/p>\n<p>app = FastAPI(title=&#8217;StitchForge&#8217;)<\/p>\n<p>@@ -276,6 +282,8 @@ def studio():<br \/>\n@app.post(&#8216;\/api\/analyze&#8217;)<br \/>\nasync def analyze(image: UploadFile = File(&#8230;), colors: int = Form(3)):<br \/>\n&#8220;&#8221;&#8221;Separate the image into colour layers and report stroke widths.&#8221;&#8221;&#8221;<br \/>\n+ if not 1 &lt;= colors &lt;= segment.MAX_COLORS:<br \/>\n+ raise HTTPException(400, &#8216;colors must be between 1 and %d&#8217; % segment.MAX_COLORS)<br \/>\njob = uuid.uuid4().hex[:12]<br \/>\nd = _job_dir(job, must_exist=False)<br \/>\nos.makedirs(d, exist_ok=True)<br \/>\n@@ -315,6 +323,8 @@ async def digitize(job: str = Form(&#8230;), colors: int = Form(3),<br \/>\ncap_mode: bool = Form(False),<br \/>\ntrim_dist: float = Form(2.0),<br \/>\nfabric: str = Form(&#8221;), machine: str = Form(&#8221;)):<br \/>\n+ if not 1 &lt;= colors &lt;= segment.MAX_COLORS:<br \/>\n+ raise HTTPException(400, &#8216;colors must be between 1 and %d&#8217; % segment.MAX_COLORS)<br \/>\nd = _job_dir(job)<br \/>\nsrc = None<br \/>\nfor fn in os.listdir(d):<br \/>\n@@ -716,6 +726,8 @@ async def vectorize(image: UploadFile = File(&#8230;), colors: int = Form(4),<br \/>\n&#8220;&#8221;&#8221;Look at the artwork and return editable vector layers \u2014 connected<br \/>\nregions as single objects, similar round shapes grouped, one layer per<br \/>\nlarge shape. No stitches are made until \/api\/stitch_layers.&#8221;&#8221;&#8221;<br \/>\n+ if not 1 &lt;= colors &lt;= segment.MAX_COLORS:<br \/>\n+ raise HTTPException(400, &#8216;colors must be between 1 and %d&#8217; % segment.MAX_COLORS)<br \/>\njob = uuid.uuid4().hex[:12]<br \/>\nd = _job_dir(job, must_exist=False)<br \/>\nos.makedirs(d, exist_ok=True)<br \/>\n@@ -723,7 +735,7 @@ async def vectorize(image: UploadFile = File(&#8230;), colors: int = Form(4),<br \/>\nwith open(src, &#8216;wb&#8217;) as f:<br \/>\nf.write(await image.read())<br \/>\ntry:<br \/>\n&#8211; lyrs, w, h = veclayers.vectorize(src, max(1, min(8, colors)),<br \/>\n+ lyrs, w, h = veclayers.vectorize(src, colors,<br \/>\nmax(10.0, min(400.0, width_mm)))<br \/>\nexcept veclayers.LayerError as e:<br \/>\nraise HTTPException(400, str(e))<br \/>\n@@ -766,12 +778,20 @@ async def stitch_layers(data: dict):<br \/>\n&#8220;&#8221;&#8221;Sew the arranged layers into a design (this is when stitches exist).&#8221;&#8221;&#8221;<br \/>\nlyrs = data.get(&#8216;layers&#8217;) or []<br \/>\nif not isinstance(lyrs, list) or not lyrs:<br \/>\n+ logging.getLogger(&#8216;uvicorn.error&#8217;).warning(<br \/>\n+ &#8216;AI layer stitching rejected: no layers to stitch&#8217;)<br \/>\nraise HTTPException(400, &#8216;no layers to stitch&#8217;)<br \/>\n&#8211; if len(lyrs) &gt; 80:<br \/>\n&#8211; raise HTTPException(400, &#8216;too many layers (max 80)&#8217;)<br \/>\n+ max_layers = 512<br \/>\n+ if len(lyrs) &gt; max_layers:<br \/>\n+ logging.getLogger(&#8216;uvicorn.error&#8217;).warning(<br \/>\n+ &#8216;AI layer stitching rejected: %d layers exceeds the limit of %d&#8217;,<br \/>\n+ len(lyrs), max_layers)<br \/>\n+ raise HTTPException(400, &#8216;too many layers (max %d)&#8217; % max_layers)<br \/>\ntry:<br \/>\npat, block_layers = veclayers.stitch(lyrs, settings=_stitch_settings(data))<br \/>\nexcept veclayers.LayerError as e:<br \/>\n+ logging.getLogger(&#8216;uvicorn.error&#8217;).warning(<br \/>\n+ &#8216;AI layer stitching rejected (%d layers): %s&#8217;, len(lyrs), e)<br \/>\nraise HTTPException(400, str(e))<br \/>\nexcept Exception as e:<br \/>\ntraceback.print_exc()<br \/>\n@@ -796,8 +816,9 @@ def fills_list():<br \/>\nreturn [{&#8216;id&#8217;: k, &#8216;name&#8217;: patterns.FILL_LABELS[k]} for k in veclayers.FILL_METHODS]<\/p>\n<p>-_FILL_PREVIEW_DIR = os.path.join(tempfile.gettempdir(), &#8216;stitchforge_fills&#8217;)<br \/>\n&#8211;<br \/>\n+#_FILL_PREVIEW_DIR = os.path.join(tempfile.gettempdir(), &#8216;stitchforge_fills&#8217;)<br \/>\n+_FILL_PREVIEW_DIR = Path(__file__).resolve().parent \/ &#8220;stitchforge_fills&#8221;<br \/>\n+_FILL_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)<\/p>\n<p>@app.get(&#8216;\/api\/fill_preview\/{name}.png&#8217;)<br \/>\ndef fill_preview(name: str):<\/p>\n<p>digitizer\/core.py<\/p>\n<p>@@ -610,9 +610,10 @@ def smooth(p, k=9):<br \/>\nif len(p) &lt; k or k &lt; 3:<br \/>\nreturn p<br \/>\nker = np.ones(k) \/ k<br \/>\n+ padded = np.pad(p, ((k \/\/ 2, (k &#8211; 1) \/\/ 2), (0, 0)), mode=&#8217;edge&#8217;)<br \/>\nout = p.copy()<br \/>\n&#8211; out[:, 0] = np.convolve(p[:, 0], ker, mode=&#8217;same&#8217;)<br \/>\n&#8211; out[:, 1] = np.convolve(p[:, 1], ker, mode=&#8217;same&#8217;)<br \/>\n+ out[:, 0] = np.convolve(padded[:, 0], ker, mode=&#8217;valid&#8217;)<br \/>\n+ out[:, 1] = np.convolve(padded[:, 1], ker, mode=&#8217;valid&#8217;)<br \/>\nout[:2], out[-2:] = p[:2], p[-2:] # don&#8217;t drag the tips inward<br \/>\nreturn out<\/p>\n<p>@@ -1041,7 +1042,8 @@ def sew_blob(sewer, poly, spacing, max_span, border_w, travel=None,<br \/>\nif not heavy_underlay and mode == &#8216;heavy&#8217;:<br \/>\nmode = &#8216;auto&#8217;<br \/>\nif _do(phase, UNDER) and mode != &#8216;none&#8217;:<br \/>\n&#8211; sew_edge_run(sewer, poly, inset=min(0.6, border_w), travel=travel)<br \/>\n+ inset = min(0.6, border_w) if border_w &gt; 0 else 0.6<br \/>\n+ sew_edge_run(sewer, poly, inset=inset, travel=travel)<br \/>\nax = principal_angle(poly)<br \/>\nif mode == &#8216;heavy&#8217;:<br \/>\nfor ang in (ax + 45, ax &#8211; 45):<br \/>\n@@ -1054,7 +1056,7 @@ def sew_blob(sewer, poly, spacing, max_span, border_w, travel=None,<br \/>\nif not _do(phase, TOP):<br \/>\nreturn<\/p>\n<p>&#8211; ring = satin_ring(poly, border_w, spacing, start=sewer.pos)<br \/>\n+ ring = satin_ring(poly, border_w, spacing, start=sewer.pos) if border_w &gt; 0 else None<br \/>\ncore = poly.buffer(-border_w * 0.72) if ring else poly<br \/>\nif core.geom_type == &#8216;MultiPolygon&#8217;:<br \/>\ncore = max(core.geoms, key=lambda g: g.area)<\/p>\n<p>digitizer\/engine.py<\/p>\n<p>@@ -24,7 +24,7 @@ class Params:<br \/>\nunderlay_spacing: float = 2.5<br \/>\nmin_fill_area: float = 0.20<br \/>\nmin_blob_area: float = 2.0 # below this, satin-ing a blob isn&#8217;t worth it<br \/>\n&#8211; border_width: float = 1.0 # satin outline band around blobs<br \/>\n+ border_width: float = 0.0 # optional satin outline band; zero disables it<br \/>\nfill_method: str = &#8216;tatami&#8217; # tatami | contour | circular (Ink\/Stitch fills)<br \/>\nfill_angle: float = 65.0<br \/>\nheavy_underlay: bool = True<br \/>\n@@ -155,10 +155,7 @@ def build_pattern(layers, canvas_mm, width_px, p: Params):<br \/>\nif phase != core.UNDER:<br \/>\nfill_methods.sew_area(s, g, p.fill_method, p.fill_angle,<br \/>\np.row_spacing, p.max_stitch, travel=travel)<br \/>\n&#8211; # too wide to satin across, but it still needs a hard edge:<br \/>\n&#8211; # a satin outline over the fill is what stops a torso or a<br \/>\n&#8211; # head reading as ragged<br \/>\n&#8211; if q.area &gt;= p.min_blob_area * 2:<br \/>\n+ if p.border_width &gt; 0 and q.area &gt;= p.min_blob_area * 2:<br \/>\nring = core.satin_ring(g, p.border_width, p.row_spacing)<br \/>\nif ring:<br \/>\ncore.sew_ring(s, ring, travel)<\/p>\n<p>digitizer\/segment.py<\/p>\n<p>@@ -4,6 +4,7 @@ import cv2<br \/>\nfrom PIL import Image<\/p>\n<p>MAX_WORK_PX = 1400 # working raster; bigger only costs time<br \/>\n+MAX_COLORS = 32<\/p>\n<p>def load(path):<br \/>\n@@ -43,7 +44,7 @@ def quantize(rgba, n_colors, fg=None):<br \/>\npix = rgb[fg]<br \/>\nif len(pix) &lt; 16:<br \/>\nraise ValueError(&#8216;the image looks empty once the background is removed&#8217;)<br \/>\n&#8211; n_colors = int(max(1, min(n_colors, 8, len(np.unique(pix, axis=0)))))<br \/>\n+ n_colors = int(max(1, min(n_colors, MAX_COLORS, len(np.unique(pix, axis=0)))))<br \/>\ncrit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.5)<br \/>\n_, lbl, centers = cv2.kmeans(pix, n_colors, None, crit, 4,<br \/>\ncv2.KMEANS_PP_CENTERS)<\/p>\n<p>inkstitchlib\/layers.py<\/p>\n<p>@@ -21,7 +21,7 @@ from shapely.geometry import Polygon, MultiPolygon, LineString<br \/>\nfrom digitizer import segment, core<br \/>\nfrom . import fills, patterns<\/p>\n<p>-DEFAULT_PARAMS = {&#8216;stitch&#8217;: &#8216;auto&#8217;, &#8216;fill_method&#8217;: &#8216;tatami&#8217;,<br \/>\n+DEFAULT_PARAMS = {&#8216;stitch&#8217;: &#8216;fill&#8217;, &#8216;fill_method&#8217;: &#8216;tatami&#8217;,<br \/>\n&#8216;angle&#8217;: &#8216;auto&#8217;, &#8216;density&#8217;: 0.4, &#8216;border_mm&#8217;: 1.0}<\/p>\n<p>inkstitchlib\/worksheet.py<\/p>\n<p>@@ -444,6 +444,7 @@ def _production_page(c, pattern, report, layers, thread_matches, preview_png,<br \/>\n_text(c, cols[&#8216;Name&#8217;], y, _fit(c, name, name_w, body, fs), fs)<br \/>\n_text(c, cols[&#8216;Chart&#8217;], y, chart, fs, align=&#8217;right&#8217;)<br \/>\ny -= lh<br \/>\n+ next_block = min(len(blocks), i + 1) if blocks else 0<br \/>\nc.setStrokeColor(black)<br \/>\nc.setLineWidth(0.8)<br \/>\nc.line(col_x, y + lh &#8211; 3.5, x1, y + lh &#8211; 3.5)<br \/>\n@@ -455,6 +456,75 @@ def _production_page(c, pattern, report, layers, thread_matches, preview_png,<br \/>\n_text(c, x0 + 150, fy, &#8216;Design last saved : %s&#8217; % _stamp(saved_at or now), 7.5)<br \/>\n_text(c, x0 + 345, fy, &#8216;Date printed: %s&#8217; % _stamp(now), 7.5)<br \/>\n_text(c, x1 &#8211; 3, fy, &#8216;Page %d of %d&#8217; % (c.getPageNumber(), pages), 7.5, align=&#8217;right&#8217;)<br \/>\n+ return next_block<br \/>\n+<br \/>\n+<br \/>\n+def _production_stop_page(c, blocks, layers, thread_matches, start, design_name,<br \/>\n+ client, saved_at, pages):<br \/>\n+ &#8220;&#8221;&#8221;Draw a full-width continuation page for the production stop sequence.&#8221;&#8221;&#8221;<br \/>\n+ W, H = letter<br \/>\n+ body = c._body<br \/>\n+ m = 0.22 * 72<br \/>\n+ x0, x1, y0, y1 = m, W &#8211; m, m, H &#8211; m<br \/>\n+ c.setStrokeColor(black)<br \/>\n+ c.setLineWidth(0.8)<br \/>\n+ c.rect(x0, y0, x1 &#8211; x0, y1 &#8211; y0)<br \/>\n+<br \/>\n+ _text(c, x0 + 8, y1 &#8211; 20, &#8216;Production Worksheet&#8217;, 12.5, bold=True, color=c._accent)<br \/>\n+ _text(c, x0 + 8, y1 &#8211; 35, design_name, 9)<br \/>\n+ _text(c, x0 + 8, y1 &#8211; 55, &#8216;Stop Sequence (continued)&#8217;, 10, bold=True)<br \/>\n+ c.line(x0, y1 &#8211; 64, x1, y1 &#8211; 64)<br \/>\n+<br \/>\n+ cols = {&#8216;#&#8217;: x0 + 8, &#8216;N#&#8217;: x0 + 35, &#8216;Color&#8217;: x0 + 66, &#8216;St.&#8217;: x0 + 151,<br \/>\n+ &#8216;Code&#8217;: x0 + 158, &#8216;Name&#8217;: x0 + 245, &#8216;Chart&#8217;: x1 &#8211; 8}<br \/>\n+ y = y1 &#8211; 82<br \/>\n+ for key, align in ((&#8216;#&#8217;, &#8216;l&#8217;), (&#8216;N#&#8217;, &#8216;l&#8217;), (&#8216;Color&#8217;, &#8216;l&#8217;), (&#8216;St.&#8217;, &#8216;r&#8217;),<br \/>\n+ (&#8216;Code&#8217;, &#8216;l&#8217;), (&#8216;Name&#8217;, &#8216;l&#8217;), (&#8216;Chart&#8217;, &#8216;r&#8217;)):<br \/>\n+ _text(c, cols[key], y, key, 8, bold=True,<br \/>\n+ align=&#8217;right&#8217; if align == &#8216;r&#8217; else &#8216;left&#8217;)<br \/>\n+ c.line(x0 + 5, y &#8211; 4, x1 &#8211; 5, y &#8211; 4)<br \/>\n+ y -= 16<br \/>\n+<br \/>\n+ unique = []<br \/>\n+ for layer in layers:<br \/>\n+ if layer[&#8216;hex&#8217;].upper() not in unique:<br \/>\n+ unique.append(layer[&#8216;hex&#8217;].upper())<br \/>\n+ index = start<br \/>\n+ while index &lt; len(blocks) and y &gt;= y0 + 30:<br \/>\n+ block = blocks[index]<br \/>\n+ layer = layers[index] if index &lt; len(layers) else {<br \/>\n+ &#8216;hex&#8217;: &#8216;#888888&#8217;, &#8216;name&#8217;: &#8216;Colour %d&#8217; % (index + 1)}<br \/>\n+ needle = unique.index(layer[&#8216;hex&#8217;].upper()) + 1 \\<br \/>\n+ if layer[&#8216;hex&#8217;].upper() in unique else index + 1<br \/>\n+ match = thread_matches[index] if thread_matches and index &lt; len(thread_matches) else None<br \/>\n+ code = (match.get(&#8216;thread_number&#8217;) or &#8221;) if match else &#8221;<br \/>\n+ name = (match.get(&#8216;thread_name&#8217;) if match else None) or layer.get(&#8216;name&#8217;, &#8221;)<br \/>\n+ chart = match.get(&#8216;palette&#8217;, &#8216;Default&#8217;) if match else &#8216;Default&#8217;<br \/>\n+ fs = 7.5<br \/>\n+ if c.stringWidth(chart, body, fs) &gt; 70:<br \/>\n+ chart = chart.split(&#8216; &#8216;)[0]<br \/>\n+ chart = _fit(c, chart, 70, body, fs)<br \/>\n+ name_w = cols[&#8216;Chart&#8217;] &#8211; c.stringWidth(chart, body, fs) &#8211; cols[&#8216;Name&#8217;] &#8211; 8<br \/>\n+<br \/>\n+ _text(c, cols[&#8216;#&#8217;], y, &#8216;%d.&#8217; % (index + 1), 8)<br \/>\n+ _text(c, cols[&#8216;N#&#8217;], y, str(needle), 8)<br \/>\n+ c.setFillColor(HexColor(layer[&#8216;hex&#8217;]))<br \/>\n+ c.setStrokeColor(black)<br \/>\n+ c.rect(cols[&#8216;Color&#8217;], y &#8211; 2, 72, 9, fill=1)<br \/>\n+ _text(c, cols[&#8216;St.&#8217;], y, &#8216;{:,}&#8217;.format(block[&#8216;stitches&#8217;]), fs, align=&#8217;right&#8217;)<br \/>\n+ _text(c, cols[&#8216;Code&#8217;], y, _fit(c, code, 80, body, fs), fs)<br \/>\n+ _text(c, cols[&#8216;Name&#8217;], y, _fit(c, name, name_w, body, fs), fs)<br \/>\n+ _text(c, cols[&#8216;Chart&#8217;], y, chart, fs, align=&#8217;right&#8217;)<br \/>\n+ y -= 14<br \/>\n+ index += 1<br \/>\n+<br \/>\n+ c.line(x0 + 5, y + 10, x1 &#8211; 5, y + 10)<br \/>\n+ now = datetime.datetime.now()<br \/>\n+ _text(c, x0 + 3, y0 + 4, &#8216;Authors:%s&#8217; % ((&#8216; &#8216; + client) if client else &#8221;), 7.5)<br \/>\n+ _text(c, x0 + 150, y0 + 4, &#8216;Design last saved : %s&#8217; % _stamp(saved_at or now), 7.5)<br \/>\n+ _text(c, x1 &#8211; 3, y0 + 4, &#8216;Page %d of %d&#8217; % (c.getPageNumber(), pages),<br \/>\n+ 7.5, align=&#8217;right&#8217;)<br \/>\n+ return index<\/p>\n<p>def _quote_page(c, stitches, quote_params, design_name, client=&#8221;, notes=&#8221;):<br \/>\n@@ -497,10 +567,17 @@ def build_production(path, pattern, report, layers, thread_matches=None,<br \/>\nc = Canvas(path, pagesize=letter)<br \/>\nc.setTitle(&#8216;%s \u2014 production worksheet&#8217; % design_name)<br \/>\n_apply_theme(c, theme, logo_path)<br \/>\n&#8211; _production_page(c, pattern, report, layers, thread_matches, preview_png,<br \/>\n&#8211; design_name, client, saved_at, title=title,<br \/>\n&#8211; pages=2 if quote_params else 1)<br \/>\n+ blocks = block_stats(pattern)<br \/>\n+ continuation_pages = int(math.ceil(max(0, len(blocks) &#8211; 32) \/ 48.0))<br \/>\n+ pages = 1 + continuation_pages + (1 if quote_params else 0)<br \/>\n+ next_block = _production_page(c, pattern, report, layers, thread_matches, preview_png,<br \/>\n+ design_name, client, saved_at, title=title, pages=pages)<br \/>\nc.showPage()<br \/>\n+ while next_block &lt; len(blocks):<br \/>\n+ _apply_theme(c, theme, logo_path)<br \/>\n+ next_block = _production_stop_page(c, blocks, layers, thread_matches, next_block,<br \/>\n+ design_name, client, saved_at, pages)<br \/>\n+ c.showPage()<br \/>\nif quote_params:<br \/>\n_apply_theme(c, theme, logo_path)<br \/>\n_quote_page(c, report.get(&#8216;stitches&#8217;, 0), quote_params, design_name)<br \/>\n@@ -582,6 +659,12 @@ def build(path, pattern, report, layers, thread_matches=None, preview_png=None,<br \/>\nc.line(MARGIN, y, PAGE_W &#8211; MARGIN, y)<br \/>\ny -= 16<br \/>\nfor i, L in enumerate(layers):<br \/>\n+ if y &lt; MARGIN + 24:<br \/>\n+ _footer(c)<br \/>\n+ c.showPage()<br \/>\n+ _apply_theme(c, theme, logo_path)<br \/>\n+ _header(c, design_name, &#8216;Thread sequence (continued)&#8217;)<br \/>\n+ y = PAGE_H &#8211; MARGIN &#8211; 48<br \/>\nc.setFillColor(HexColor(L[&#8216;hex&#8217;]))<br \/>\nc.setStrokeColor(LINE)<br \/>\nc.rect(MARGIN, y &#8211; 3, 8 * mm, 5 * mm, fill=1)<\/p>\n<p>static\/clients.html<\/p>\n<p>@@ -11,7 +11,7 @@<br \/>\n&#8211;bg:#f6f7f9; &#8211;surface:#ffffff; &#8211;ink:#1a1d23; &#8211;muted:#6b7280;<br \/>\n&#8211;line:#e9eaee; &#8211;line2:#e2e4e9;<br \/>\n&#8211;accent:#2563eb; &#8211;accent-press:#1d4ed8; &#8211;accent-soft:#eef4ff;<br \/>\n&#8211; &#8211;stage:#181c26; &#8211;ok:#188652; &#8211;warn:#a8620a; &#8211;warnbg:#fdf5e7; &#8211;danger:#c2410c;<br \/>\n+ &#8211;stage:#cccccc; &#8211;ok:#188652; &#8211;warn:#a8620a; &#8211;warnbg:#fdf5e7; &#8211;danger:#c2410c;<br \/>\n&#8211;r:16px; &#8211;shadow:0 1px 2px rgba(16,24,40,.05),0 8px 28px rgba(16,24,40,.07);<br \/>\n}<br \/>\n*{box-sizing:border-box}<\/p>\n<p>static\/index.html<\/p>\n<p>@@ -294,7 +294,7 @@<br \/>\ndetails.sec[open]&gt;summary::after{transform:rotate(-135deg)}<br \/>\ndetails.sec&gt;*:not(summary){margin-bottom:6px}<br \/>\ndetails.sec&gt;div:last-child,details.sec&gt;p:last-child{margin-bottom:14px}<br \/>\n&#8211; details.sec:has(&gt; .hide){display:none}<br \/>\n+ details.sec:has(&gt; .hide):not(:has(&gt; :not(summary):not(.hide))){display:none}<br \/>\ndetails.sec label{margin-top:8px}<br \/>\ndetails:not(.sec){background:var(&#8211;panel2);box-shadow:none;margin-top:10px}<br \/>\ndetails:not(.sec) summary{color:var(&#8211;ink)}<br \/>\n@@ -644,7 +644,7 @@<br \/>\n&lt;div class=&#8221;unit&#8221;&gt;&lt;input type=&#8221;number&#8221; id=&#8221;objGradTo&#8221; value=&#8221;1.5&#8243; step=&#8221;0.1&#8243; min=&#8221;0.3&#8243; max=&#8221;4&#8243;&gt;&lt;span&gt;mm&lt;\/span&gt;&lt;\/div&gt;<br \/>\n&lt;div class=&#8221;tglrow&#8221;&gt;&lt;span&gt;Reverse direction&lt;\/span&gt;&lt;label class=&#8221;tog&#8221;&gt;&lt;input type=&#8221;checkbox&#8221; id=&#8221;objGradFlip&#8221;&gt;&lt;i&gt;&lt;\/i&gt;&lt;\/label&gt;&lt;\/div&gt;<br \/>\n&lt;\/div&gt;<br \/>\n&#8211; &lt;div class=&#8221;tglrow&#8221;&gt;&lt;span&gt;Satin border&lt;\/span&gt;&lt;label class=&#8221;tog&#8221;&gt;&lt;input type=&#8221;checkbox&#8221; id=&#8221;objBorder&#8221; checked&gt;&lt;i&gt;&lt;\/i&gt;&lt;\/label&gt;&lt;\/div&gt;<br \/>\n+ &lt;div class=&#8221;tglrow&#8221;&gt;&lt;span&gt;Satin border&lt;\/span&gt;&lt;label class=&#8221;tog&#8221;&gt;&lt;input type=&#8221;checkbox&#8221; id=&#8221;objBorder&#8221;&gt;&lt;i&gt;&lt;\/i&gt;&lt;\/label&gt;&lt;\/div&gt;<br \/>\n&lt;\/details&gt;<br \/>\n&lt;\/div&gt;<br \/>\n&lt;details class=&#8221;sec&#8221; open&gt;&lt;summary&gt;Density Control&lt;i class=&#8221;qhelp&#8221; title=&#8221;On the inside of a bend satin needle points crowd together. Row shortening pulls every other one back once the inner step drops below the trigger \u00d7 the outer step.&#8221;&gt;?&lt;\/i&gt;&lt;\/summary&gt;<br \/>\n@@ -819,10 +819,7 @@<br \/>\n&lt;option value=&#8221;sketch&#8221;&gt;Sketch \u2014 one-colour outline&lt;\/option&gt;<br \/>\n&lt;\/select&gt;&lt;\/div&gt;<br \/>\n&lt;div id=&#8221;colorsWrap&#8221;&gt;&lt;label&gt;Thread colours&lt;\/label&gt;<br \/>\n&#8211; &lt;select id=&#8221;colors&#8221;&gt;<br \/>\n&#8211; &lt;option&gt;2&lt;\/option&gt;&lt;option selected&gt;3&lt;\/option&gt;&lt;option&gt;4&lt;\/option&gt;<br \/>\n&#8211; &lt;option&gt;5&lt;\/option&gt;&lt;option&gt;6&lt;\/option&gt;<br \/>\n&#8211; &lt;\/select&gt;&lt;\/div&gt;<br \/>\n+ &lt;input id=&#8221;colors&#8221; type=&#8221;number&#8221; min=&#8221;2&#8243; max=&#8221;32&#8243; value=&#8221;3&#8243;&gt;&lt;\/div&gt;<br \/>\n&lt;\/div&gt;<br \/>\n&lt;details class=&#8221;sec&#8221; open&gt;&lt;summary&gt;Fill Settings&lt;\/summary&gt;<br \/>\n&lt;div id=&#8221;setDigitize&#8221;&gt;<br \/>\n@@ -1009,8 +1006,8 @@<br \/>\n&lt;div class=&#8221;row&#8221;&gt;<br \/>\n&lt;div&gt;&lt;label&gt;Stitch&lt;\/label&gt;<br \/>\n&lt;select id=&#8221;vlStitch&#8221;&gt;<br \/>\n&#8211; &lt;option value=&#8221;auto&#8221; selected&gt;Auto (satin\/fill + border)&lt;\/option&gt;<br \/>\n&#8211; &lt;option value=&#8221;fill&#8221;&gt;Fill only&lt;\/option&gt;<br \/>\n+ &lt;option value=&#8221;auto&#8221;&gt;Auto (satin\/fill + border)&lt;\/option&gt;<br \/>\n+ &lt;option value=&#8221;fill&#8221; selected&gt;Fill only&lt;\/option&gt;<br \/>\n&lt;option value=&#8221;outline&#8221;&gt;Satin outline&lt;\/option&gt;<br \/>\n&lt;option value=&#8221;run&#8221;&gt;Running outline&lt;\/option&gt;<br \/>\n&lt;option value=&#8221;bean&#8221;&gt;Bean-stitch outline&lt;\/option&gt;<br \/>\n@@ -3747,7 +3744,7 @@ function mmPos(el, e) { const p = worldPos(el, e); return [p[0] \/ PXMM, p[1] \/ P<br \/>\nconst RUNS = [&#8216;run&#8217;, &#8216;bean&#8217;, &#8216;satin&#8217;, &#8216;estitch&#8217;, &#8216;triangle&#8217;, &#8216;cross&#8217;, &#8216;motif&#8217;];<br \/>\nconst DECOR = [&#8216;estitch&#8217;, &#8216;triangle&#8217;, &#8216;cross&#8217;, &#8216;motif&#8217;];<br \/>\nconst objDef = { color: &#8216;#1A3B69&#8217;, filled: true, run: &#8216;run&#8217;, pattern: &#8216;tatami&#8217;, angle: &#8216;auto&#8217;, density: 0.4, satinDensity: 0.32,<br \/>\n&#8211; border: true, width: 3, runLen: 2.5, stitchLen: 3.5, pull: 0.25, hand: 0, underpath: true, rowShort: true, trigger: 0.5,<br \/>\n+ border: false, width: 3, runLen: 2.5, stitchLen: 3.5, pull: 0.25, hand: 0, underpath: true, rowShort: true, trigger: 0.5,<br \/>\nunderlay: &#8216;auto&#8217;, split: true, splitMax: 7, stagger: true, cycles: 4, amount: 0.3, gradient: false, gradTo: 1.5, gradFlip: false,<br \/>\nshortFrac: 0.45, underlays: null };<br \/>\nconst UL_DEFAULT = { auto: [{ type: &#8216;contour&#8217;, len_mm: 1.5 }, { type: &#8216;center&#8217;, len_mm: 2.5 }], light: [{ type: &#8216;contour&#8217;, len_mm: 1.5 }], heavy: [{ type: &#8216;contour&#8217;, len_mm: 1.5 }, { type: &#8216;zigzag&#8217;, len_mm: 3 }], none: [] };<br \/>\n@@ -3835,7 +3832,7 @@ function syncObjBox() {<br \/>\nd.pattern = p.fill_method || &#8216;tatami&#8217;;<br \/>\nd.angle = p.angle === undefined ? &#8216;auto&#8217; : p.angle;<br \/>\nif (!d.filled || isLine) d.satinDensity = p.density || 0.32; else d.density = p.density || 0.4;<br \/>\n&#8211; d.border = p.stitch !== &#8216;fill&#8217;;<br \/>\n+ d.border = p.stitch === &#8216;auto&#8217; || p.stitch === &#8216;satin&#8217;;<br \/>\nd.width = p.width_mm || 3; d.runLen = p.run_len_mm || 2.5; d.stitchLen = p.stitch_len_mm || 3.5;<br \/>\nd.pull = p.pull_comp_mm === undefined ? +$(&#8216;pullComp&#8217;).value || 0.25 : p.pull_comp_mm;<br \/>\nd.hand = p.hand || 0;<\/p>\n<p>tests\/test_app.py<\/p>\n<p>@@ -1,9 +1,11 @@<br \/>\n&#8220;&#8221;&#8221;End-to-end tests over the HTTP API (run with: pytest).&#8221;&#8221;&#8221;<br \/>\nimport io<br \/>\nimport os<br \/>\n+import re<br \/>\nimport sys<br \/>\nimport tempfile<\/p>\n<p>+import numpy as np<br \/>\nimport pytest<br \/>\nfrom PIL import Image, ImageDraw<\/p>\n<p>@@ -12,6 +14,7 @@ os.environ.setdefault(&#8216;STITCHFORGE_DATA&#8217;,<br \/>\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))<\/p>\n<p>from fastapi.testclient import TestClient<br \/>\n+import pystitch<br \/>\nfrom app import app<\/p>\n<p>client = TestClient(app)<br \/>\n@@ -51,6 +54,38 @@ def test_digitize_report(image_job):<br \/>\nassert d[&#8216;threads&#8217;][0][&#8216;thread_name&#8217;]<\/p>\n<p>+def test_image_quantizer_supports_32_colors():<br \/>\n+ from digitizer import segment<br \/>\n+ colors = np.array([[i * 7 % 256, i * 13 % 256, i * 23 % 256, 255]<br \/>\n+ for i in range(32)], dtype=np.int16)<br \/>\n+ rgba = np.repeat(colors[:, None, :], 20, axis=1)<br \/>\n+ layers = segment.quantize(rgba, 32, np.ones(rgba.shape[:2], dtype=bool))<br \/>\n+ assert len(layers) == 32<br \/>\n+<br \/>\n+<br \/>\n+def test_image_color_limit_is_validated():<br \/>\n+ r = client.post(&#8216;\/api\/analyze&#8217;,<br \/>\n+ files={&#8216;image&#8217;: (&#8216;t.png&#8217;, _test_png(), &#8216;image\/png&#8217;)},<br \/>\n+ data={&#8216;colors&#8217;: 33})<br \/>\n+ assert r.status_code == 400<br \/>\n+ assert r.json()[&#8216;detail&#8217;] == &#8216;colors must be between 1 and 32&#8217;<br \/>\n+<br \/>\n+<br \/>\n+def test_vectorize_uses_requested_colors():<br \/>\n+ im = Image.new(&#8216;RGBA&#8217;, (240, 80), (0, 0, 0, 0))<br \/>\n+ d = ImageDraw.Draw(im)<br \/>\n+ for i in range(12):<br \/>\n+ d.rectangle([i * 20 + 2, 10, i * 20 + 17, 69],<br \/>\n+ fill=(i * 19, i * 37 % 256, i * 61 % 256, 255))<br \/>\n+ buf = io.BytesIO()<br \/>\n+ im.save(buf, &#8216;PNG&#8217;)<br \/>\n+ r = client.post(&#8216;\/api\/vectorize&#8217;,<br \/>\n+ files={&#8216;image&#8217;: (&#8216;colors.png&#8217;, buf.getvalue(), &#8216;image\/png&#8217;)},<br \/>\n+ data={&#8216;colors&#8217;: 12})<br \/>\n+ assert r.status_code == 200, r.text<br \/>\n+ assert len({layer[&#8216;color&#8217;] for layer in r.json()[&#8216;layers&#8217;]}) == 12<br \/>\n+<br \/>\n+<br \/>\ndef test_exports(image_job):<br \/>\njob, _ = image_job<br \/>\nfor fmt in (&#8216;dst&#8217;, &#8216;pes&#8217;, &#8216;jef&#8217;, &#8216;exp&#8217;, &#8216;vp3&#8217;, &#8216;csv&#8217;):<br \/>\n@@ -429,6 +464,44 @@ def test_production_worksheet():<br \/>\nassert ps[&#8216;max_stitch_mm&#8217;] &gt; 0 and ps[&#8216;thread_ft&#8217;] &gt; ps[&#8216;bobbin_ft&#8217;] &gt; 0<\/p>\n<p>+def test_worksheet_thread_lists_paginate(tmp_path, monkeypatch):<br \/>\n+ from inkstitchlib import worksheet<br \/>\n+ pattern = pystitch.EmbPattern()<br \/>\n+ layers = []<br \/>\n+ for index in range(40):<br \/>\n+ color = ((index * 47) % 256 &lt;&lt; 16) | ((index * 83) % 256 &lt;&lt; 8) | ((index * 131) % 256)<br \/>\n+ thread = pystitch.EmbThread()<br \/>\n+ thread.color = color<br \/>\n+ pattern.add_thread(thread)<br \/>\n+ if index:<br \/>\n+ pattern.color_change()<br \/>\n+ pattern.stitch_abs(index * 10, 0)<br \/>\n+ pattern.stitch_abs(index * 10 + 5, 5)<br \/>\n+ layers.append({&#8216;name&#8217;: &#8216;Colour %d&#8217; % (index + 1), &#8216;hex&#8217;: &#8216;#%06X&#8217; % color})<br \/>\n+<br \/>\n+ drawn = []<br \/>\n+ original_text = worksheet._text<br \/>\n+<br \/>\n+ def capture_text(canvas, x, y, text, *args, **kwargs):<br \/>\n+ drawn.append(str(text))<br \/>\n+ return original_text(canvas, x, y, text, *args, **kwargs)<br \/>\n+<br \/>\n+ monkeypatch.setattr(worksheet, &#8216;_text&#8217;, capture_text)<br \/>\n+ report = {&#8216;stitches&#8217;: 80, &#8216;width_mm&#8217;: 40, &#8216;height_mm&#8217;: 1,<br \/>\n+ &#8216;colour_changes&#8217;: 39, &#8216;travels&#8217;: 0, &#8216;trimmed&#8217;: 0}<br \/>\n+<br \/>\n+ production = tmp_path \/ &#8216;production.pdf&#8217;<br \/>\n+ worksheet.build(str(production), pattern, report, layers, layout=&#8217;production&#8217;)<br \/>\n+ assert &#8217;40.&#8217; in drawn<br \/>\n+ assert len(re.findall(rb&#8217;\/Type\\s*\/Page\\b&#8217;, production.read_bytes())) == 2<br \/>\n+<br \/>\n+ drawn.clear()<br \/>\n+ classic = tmp_path \/ &#8216;classic.pdf&#8217;<br \/>\n+ worksheet.build(str(classic), pattern, report, layers, layout=&#8217;classic&#8217;)<br \/>\n+ assert any(text.startswith(&#8216;#40 &#8216;) for text in drawn)<br \/>\n+ assert len(re.findall(rb&#8217;\/Type\\s*\/Page\\b&#8217;, classic.read_bytes())) &gt;= 3<br \/>\n+<br \/>\n+<br \/>\ndef test_client_design_library():<br \/>\n# main page + studio routes<br \/>\nassert b&#8217;clients&#8217; in client.get(&#8216;\/&#8217;).content.lower()<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I had a few issues that prevented me from using Threads in its current form. I suspect my use case differs from the one they are coding to &#8211; I have resources and no need to limit myself to a small number of thread colors. Also, a hidden UI element seems to hide a few &hellip;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[579],"tags":[664,2247],"class_list":["post-12674","post","type-post","status-publish","format-standard","hentry","category-embroidery","tag-python","tag-threads"],"_links":{"self":[{"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/posts\/12674","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=12674"}],"version-history":[{"count":1,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/posts\/12674\/revisions"}],"predecessor-version":[{"id":12675,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/posts\/12674\/revisions\/12675"}],"wp:attachment":[{"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=12674"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=12674"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=12674"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}