Change Log
All notable changes to this package will be documented in this file. Two things about its shape are worth stating, because neither is the usual one.
There is no release yet. remotes::install_github("maju116/rplatypus") serves the default branch, so “the version I had working” is a commit rather than a tag. Entries are therefore dated rather than numbered: the heading they sit under is the version in DESCRIPTION, and the 0.1.1 below it is the 2020 package this one replaced.
Every entry names the engine it needs. The computation lives in pyplatypus and this package pins one exact version of it. A feature lands in the engine, the engine is published to PyPI, and only then can this package reach it - so the pin moving is part of a change here, not housekeeping alongside it. The pin is what stops a release of this package breaking because the engine drifted underneath it.
[0.2.1] - unreleased
2026-10-16 - classes_from: one binary mask file per class
Needs pyplatypus 0.8.0a28, and the pin moves with this. maju116/rplatypus[#180](https://github.com/maju116/rplatypus/issues/180), whose engine half is maju116/pyplatypus[#91](https://github.com/maju116/rplatypus/issues/91) - an issue open since 2022, and this is the half the author’s decision of 2026-10-12 deferred rather than dropped.
Added
segmentation_data(classes_from = ), the fourth way of saying what a mask’s classes are and the only one that can express an overlap that was drawn independently: a vessel crossing an organ, or two annotators’ masks of the same structure. One pattern per class, in class order, matched against a sample’s mask files.It is
regions’ sibling rather than its replacement, and the difference is the file on disk.regionsderives overlapping channels from the values of one label map, which is how BraTS stores its ground truth; this reads channels that were separate files to begin with, because no single voxel value can hold two things drawn independently. Everything the two share is everything after the target: no background entry, a sigmoid per channel rather than one softmax across them, a voxel that may be in several classes at once, and a predictionsave_masks()refuses -save_class_masks()andsave_class_volumes()have written per-class files since0.8.0a23and needed nothing for this.The exclusivity refusal is four-way, and that is the reason this is a code change rather than a pin bump.
segmentation_data()enumerates its parameters, so the field would otherwise be anunused argumenthere; and a baredatalist or a YAML file throughplatypus_spec()reaches the engine’s validator either way, so a constructor that refused what YAML accepts would leave the two R paths disagreeing about the same configuration. Measured onregionsinrplatypus[#152](https://github.com/maju116/rplatypus/issues/152)and not re-guessed here.The two pattern refusals say class where a class is what they are about - at least two patterns, and distinct ones.
channels_fromhas the same two for images, and the engine answers both through one matcher with a noun; a copied message would have been the likely slip, so a test holds each to its own wording.
Fixed
A model that leaves
separationto its weights is no longer read as semantic.0.8.0a28lets an instance model omit the field whenweightsnames a file whose description records what it was trained to predict; this package reads a model’s task from that field, so a silent model was read as semantic and refused besideinstance_data()as two tasks in one specification - which made the engine’s own headline example, written as an R snippet, impossible to write in R. A model now contributes the tasks it is admissible for andplatypus_spec()intersects them with the data’s one task.rplatypus[#181](https://github.com/maju116/rplatypus/issues/181), and it ismaju116/pyplatypus[#236](https://github.com/maju116/rplatypus/issues/236)’s R half, which that issue said did not exist.An intersection rather than a fallback, because a fallback swallows the case a wrong pairing is about:
u_net()besidedetection_data()says nothing about detection and is still refused. And a model that forgotseparationand names no weights is still semantic and still refused beside instance data - the condition for being admissible is the engine’s own,weightsbeing set, which is exactly where the engine permits the omission.Found by the field-prose ledger, which reported the engine’s
separationdescription rewritten. Re-reading R’s@paramagainst the new text is the only thing that step is for, and this time it turned up a mechanism rather than a sentence.
Documented
?segmentation_datagains the field, and the configuration vignette gains a subsection under Overlapping classes instead of one per voxel rather than a section of its own - the section list is pinned by a test in each package’s suite. That subsection replaces the paragraph which saidclasses_fromwas deliberately not here, true of every release until this one.
2026-10-16 - the pin moves again, and a sentence on ?metrics was true of no release
Needs pyplatypus 0.8.0a27, and the pin moves with this. Nothing in this package gains a function: the engine fixed a defect in the release it shipped metric_surface_dice() in, and what this package owes is one corrected sentence and the test that would have caught it.
Fixed
?metricssaid a callback watchingval_surface_diceon a tiled run “would wait forever”. It would not. Until pyplatypus0.8.0a27the engine accepted that pairing and the run stopped with an error at the end of the first epoch - the epoch paid for and nothing to show - because its specification layer decided which metrics a callback may watch from a list of names thatsurface_dicehad never been added to, while its trainer decided what an epoch reports by asking the metric itself. One question, two answers,cldicein both andsurface_dicein one. The engine now asks the metric in both places (pyplatypus[#239](https://github.com/maju116/rplatypus/issues/239)), so the pairing is refused when the configuration is read, which is what the page should have said and now does.A test holds it from this side, which is the part worth having: the claim is about a configuration this package sends, so it is this package’s to check rather than only the engine’s. It offers the engine both whole-mask metrics with a callback watching each, and carries two controls - untiled, the same callback is accepted, so the refusal is about the tiling; tiled with
metric_dice(), it is accepted, so it is about the metric. Without both, a test like this passes on an engine that refuses the configuration for a reason of its own.
2026-10-16 - how far wrong the boundary is, and a loss that weights the rare class
Needs pyplatypus 0.8.0a26, and the pin moves with this.
Added
metric_surface_dice(tau = ), normalised surface Dice (Nikolov et al., 2018). The three overlap metrics ask how much of the mask coincides andmetric_cldice()asks whether the structure does; none of them asks how far wrong the boundary is, which is the question a contour somebody has to approve is actually judged by.?loss_boundaryreports a model at Dice 0.9525 whose volumes run 5% small and 17% wrong per case, and no overlap column says that - a displaced boundary and a correct one of the same area score alike. It is also whatloss_boundary()optimises, so until now that loss was being judged by the metric it exists to improve on.tauis required, because no default would be honest: a tolerance belongs to the structure and the dataset, and conventionally it is the inter-observer variability for what is being drawn. It is in millimetres wheretarget_spacingputs the cases on a common grid and in voxels where it does not, which makes this the one metric here that reads the voxel spacing. Like clDice it is a property of a whole mask, so on a tiled run it is reported per case byevaluate_cases()and is absent from the epoch history.include_backgroundisFALSE, and for a measured reason rather than the usual one: a background boundary is the foreground’s plus the edge of the picture, and that edge agrees between any two masks whatever happened inside them. On four random pairs attau = 2the background scored 0.6110 to 0.7030 against the foreground’s 0.5583 to 0.6266 - higher every time, which is agreement on the field of view.loss_generalized_dice(), Dice with each class weighted by the inverse square of how much of the picture it occupies. The argument usually made for it is not the argument for it here, and the help page says so. It is introduced in the literature as a fix for Dice being dominated by the background;loss_dice()in this package is a mean over the class channels, so a 1% lesion already carries the same weight as the 99% around it. Measured on a 1% target, with one prediction finding the lesion and leaking an equal false positive and one missing it entirely:loss finds it misses it prefers finding by loss_dice()0.1658 0.4731 0.3073 loss_cce()0.1600 0.1600 nothing at all loss_cce_dice()0.1629 0.3166 0.1536 loss_generalized_dice()0.3278 0.9511 0.6233 So it adds weight beyond an equal share - 1 to 9801 on that target - and the objective genuinely dominated is the per-pixel half of
loss_cce_dice(). It is admissible as a model’s ownlossat both segmentation tasks and asloss_boundary(region = ), which is the combination worth knowing about since both features are motivated by the same picture;separation_flows(loss = )refuses it by name, because that target is a vector field.
Changed
?loss_boundarysaid “the nine region losses” and there are ten. The count is gone rather than corrected: a number in prose with nothing holding it is the failure §3C exists to prevent, so the page states the rule - every loss except the three that score numbers andboundaryitself - and a test holds that rule against the engine’s own union forBoundaryLoss.region. The same shape as the family page that twice failed to name a member of its family.?metricsdescribes theinclude_backgroundsplit rather than one exception to it. It said the default differs “for clDice alone”, and the test holding that said the same in its own name; both would have gone on passing with a fifth metric in the package. The partition is asserted now - every metric is in one of the two groups, and a new one has to be put in a group before the suite passes.The pin moves to 0.8.0a26, which also carries the measured answers to two things that were not built: cbDice, whose own gate now has a measurement pointing the other way, and the DIoU/CIoU/EIoU family. Both are answered on their issues rather than added as names.
2026-10-15 - the instance surface has a document that runs it
Needs pyplatypus 0.8.0a25, which is already the pin.
Added
vignette("instances"), the fifth worked document and the one the instance surface did not have.instance_data(),separation_rim(), the fourdecode_*,predict(decode = ),evaluate()with an average precision,overlay_instances(),overlay_instance_agreement()andinstance_sizes()all shipped with tests and a configuration section, and nothing joined them end to end. §4’s entry is the argument: a feature is not shipped until something has used it the way a user reaches it.It is not a second Data Science Bowl vignette. The subject is the difference between the two readings of one dataset, so it opens with the published weights decoded two ways - 245 objects by
nearest_seedagainst 180 byconnected_componentson the same picture, one forward pass, sixty-five touching nuclei run together - and putsdsbowl-unet’s mask beside the object map rather than describing it.Then it trains forty epochs beside the borrowed file in one specification, so
evaluate()returns six rows and the comparison is a table rather than a paragraph. It ends where the measurement does: a count and a size distribution, in pixels, with the reason there are no micrometres - this dataset records no scale, andinstance_sizes()takes aspacingprecisely so that a document with one can say millilitres, asvignette("volumes")does.Two things in it were not planned and are the best of it. The borrowed weights reproduce the Hub card’s numbers exactly - 0.5445, 0.5390, 0.4822 - so the document controls itself. And the forty-epoch run lands about one run-to-run spread behind on foreground Dice while being four to five of them behind on average precision: the extra eighty epochs bought a sharper rim rather than a better mask, which
foreground_dicecannot see because the rim is a third class whose quality only appears after decoding. That is a finding about time inside one recipe, and it sits beside rather than against the milestone’s finding about recipes. Both gaps are computed by the document, not typed into it, so re-knitting cannot leave them stale.
Fixed
- Two vignettes were drawing into the same figure file, and one of them was this one.
knitrnames a figure after its chunk and every vignette here shares onefig.path, so an unprefixedhistorychunk wrotefigures/history-1.png- which isvignette("data-science-bowl")’s training curve. Its picture became an instance model’s, silently: the file was still there and still a plot. Found by listing the built tarball, recovered withgit checkoutbecause the original was committed, and now held by a test that reads every shipped vignette and fails when two claim one path. The test demands no collision rather than a naming convention, because the first document to use a name breaks nothing.
2026-10-15 - a fourth set of published weights, and a score that needs its decoding
Needs pyplatypus 0.8.0a25, which is what the pin moves to here.
Added
weights = "dsbowl-instances"works, and it is the first published name whose score is not one number. A U-Net trained to tell touching nuclei apart on BBBC038v1 - interior, rim and background - measured on the same 134 held-out imagesdsbowl-unetreports:decoding instance AP objects found decode_nearest_seed()0.5445 5,378 of 5,999 decode_watershed()0.5390 5,378 of 5,999 decode_connected_components()0.4822 4,914 of 5,999 Foreground Dice 0.9118, the mask all three rows share.
This side needed the pin and nothing else, which is the cheapest kind of change here and worth naming as its own shape:
available_weights()reads the registry out of the engine, so the fourth name arrives in that data frame the moment the pin moves. No R code was written for it.What was written is the receipt. The description is what an R user reads before trusting a number, and 0.0623 separates the best row from the worst against a run-to-run spread of 0.009 - so a score quoted without its decoding is not a property of the weights. A test holds that description to its own arithmetic: every score in it has to name a decoding, the gap it claims for the separation signal has to be the gap between the rows it quotes, and the perfect-mask figure beside it has to be the larger of the two. Checked against the arithmetic rather than against the four decimals, because the measurements live in the Hub repository and nothing on this side can confirm 0.5445.
The entry’s own claim is the instance milestone’s finding rather than a recommendation: the separation signal is worth +0.0623 here, a perfect semantic mask is worth +0.2398, and a perfect mask through connected components caps any model at 0.7655. The mask is the limit on this data. The weights are published so that the measurement can be argued with by somebody who did not take it.
Changed
- The field-prose ledger records the new engine, and the diff is the reason this is a line rather than a silent regeneration:
tests/testthat/fixtures/field-prose.ymlmoved itsengine:line and not one of its 104 digests. No configuration field’s description changed in0.8.0a25, so no@paramon this side needed re-reading. Regenerating without looking at that diff is how the ledger would become a rubber stamp.
2026-10-15 - the two instance axes can be asked about
Needs pyplatypus 0.8.0a24, which is already the pin.
Added
available_separations()andavailable_decodings(), which completes the family. Eight of the ten listings the engine publishes were here and these two were not, so every axis of a specification could be asked about except the two that are only on the instance task - the newest ones, where a reader is least likely to know the names already.Found by comparing the two public surfaces name by name rather than by reading either, and it is the reference group’s own description that makes it a defect rather than a wish: the group these two sit in says it holds “the listings that ask the engine what those names are, rather than asking you to remember them”, with
separationsanddecodingslisted in it and no listing for either.Each carries the column it is opened for:
needson a decoding, the separation it requires - empty forconnected_components, which is why that one is always available - andhead_channelson a separation, how wide a head that target makes. Both come from the engine’s own rows rather than being named on this side, so a listing that grows a field arrives here with no R change; a test holds the other six listings to exactly three columns, because a bug in that would add a column everywhere rather than drop one here.
Changed
- A test called “every decoding the engine offers has a constructor” now asks the engine. It compared a list written beside it, so the engine could have grown a fifth decoding with nothing failing - the same shape as the warning this morning, a declaration beside a mechanism that does not do it. There is one for separations now too.
2026-10-15 - the wrap past the end of the palette is reported
Needs pyplatypus 0.8.0a24.
Changed
overlay_instances()warns when a picture needs more colours thancolourshas. It names how many it needs, how many there are, and how many objects lost the guarantee.Both packages were silent here until now and the silence was a gap rather than a decision: the engine’s own colouring helper said in its docstring that
overlay_instancesreported it, and nothing in either package emitted anything. Filed from this side while[#154](https://github.com/maju116/rplatypus/issues/154)was being written, fixed in the engine asmaju116/pyplatypus[#231](https://github.com/maju116/rplatypus/issues/231), and followed here the same day it was released - a warning on one side and silence on the other would be exactly the divergence[#154](https://github.com/maju116/rplatypus/issues/154)exists to prevent.The wrap is worth a word because it is the one case this figure answers its own question wrongly. Its subject is where does one object end; every other colour repeat is deliberate, and a wrap is the one that repeats between neighbours - provably, since a slot is only past the end of the palette once every colour before it was taken by a neighbour, so the fold lands on a colour a neighbour already holds.
The two messages are compared word for word, in the
enginejob. That is a stronger pair than the palette or the pixels: a reader who meets this warning in R and then in Python has to be told the same thing, and the pin is what makes comparing prose safe - one exact engine, so a rewording on either side fails here instead of drifting. Measured: of five mutations, changing one phrase of the message is caught by that test and by nothing else.
2026-10-15 - instances come back into R
Added
predict()returns the objects. It refused an instance fit until now, and the refusal was right for as long as the bridge had no call that returned a label map. A named list of integer maps, one per image, objects numbered from 1 and 0 for background.Named rather than ordered, and that is the part worth stating: the engine’s own list is unnamed, and anything pairing two sequences by position is one dropped batch away from naming the wrong picture - the mistake
predict_stream()exists on the semantic side to avoid. The names are read from the same two objects the engine pairs internally, and a test asserts they are the keysevaluate_cases()reports.A list rather than an array for the reason the detection one is a list: images differ in size and so does the number of objects.
decode =picks which decoding turns the channels into objects, by the name in the model’sdecodelist. Unset takes the model’s first, which is the engine’s default rather than one restated here - and an unknown name is refused by the engine, naming the ones the model does ask for. Refused outright on a fit that decodes nothing.instance_sizes()is one row per object:voxels, and withspacingeitherarea_mm2orvolume_ml. The question[#103](https://github.com/maju116/rplatypus/issues/103)opens with is how many nuclei, and how big is each one, which the published weights’ card cannot answer.The unit is in the column’s name rather than in the documentation, because a column whose unit lives elsewhere ends up in a paper as a bare number. Without
spacingthere is no such column at all, rather than a count pretending to be a measurement. Millilitres for a volume, as [mask_volume()] reports them, and a test holds the two to the same arithmetic.flow_to_colours()draws a two-channel vector field: hue is the direction, value is the length. A convention rather than a decision - which is why it is not indrawing_style()- and it is what makes aseparation_flows()model debuggable, because a field that is right everywhere except reversed looks obviously reversed here and identical in any loss.Pixel-identical to the engine’s on random fields, which is the only thing that can hold a convention.
Fixed
type = "probability"is refused on an instance fit, by name. An instance model’s channels are not all probabilities: the class channels are and a flow field’s components are raw, because a softmax across a flow component and a class returns a number that means nothing. The engine refuses to conflate the two and so does this - the value istype = "channels", which says what it gives.spaceis refused on an instance fit, with the reason rather than “unsupported”. Resampling a label map interpolates between object numbers, so object 3 beside object 5 comes back with a rim of object 4. A refusal saying only that it is not supported would read as a gap somebody might fill.
Measured
- A one-row flow field had no dimensions.
field[1, , ]on a2 x 1 x warray drops to a vector, so the picture came back with nodimat all - and a tiled model hands back exactly that shape at the edge of an image. Found because the comparison against the engine ran three shapes rather than one; with only the first it would have shipped.
2026-10-15 - drawing instances
Added
overlay_instances()draws an instance label map over its image, with one colour per object chosen so that no two touching objects share one. The rule is adjacency rather than identity: a colour repeats across the picture and never between neighbours, because nobody reads object 214 by its colour and everybody needs to see where one object ends.That is an exact graph colouring - Welsh-Powell, greedy in order of descending degree - and it is affordable rather than clever. Measured in the engine over all 670 Data Science Bowl cases, 524 of which hold touching objects: the hardest needs three of the eight slots in
class_colours, while the cheap alternative of colouring by object number modulo the palette paints 550 of 5436 adjacent pairs in one colour, 10.12% - one touching pair in ten drawn as a single object, in a figure whose whole subject is whether two objects are two.Eight-connected, so two objects meeting at a corner get different colours. A label-maker goes the other way and joins only along edges, because there the risk is merging two objects into one; here the risk is drawing two as one, which is the opposite direction.
overlay_instance_agreement()is found, missed and invented objects.overlay_agreement()answers the same three-way question about pixels, and on an instance result that is the wrong unit: a merged pair and a split object are both nearly perfect pixelwise and both wrong, which is what an average precision will not tell anybody. Matched whole object against whole object at one IoU threshold, strictly greater, and below 0.5 is refused for the reason the metric refuses it.
Measured
The matching needs no greedy pass, and that is provable rather than convenient. The engine matches greedily on descending IoU; this package matches every pair above the threshold, which is equivalent above 0.5: if two disjoint true objects both exceeded 0.5 against one prediction, each intersection would exceed half of it - the union is at least the prediction - and two disjoint intersections cannot together exceed it. So the matching is one to one whatever order it is taken in, which is also why the metric refuses a lower threshold.
Written as the equivalent rather than as a copy because
argsortis not stable: two pairs at the same IoU leave the engine in an order neither package controls, and a pixel-for-pixel test would then fail for something that is not a divergence.
Fixed
Nothing held the two packages to the same colouring, and the shared constant could not.
[#154](https://github.com/maju116/rplatypus/issues/154)’s subject: the palette is mirrored andtest-drawing-style.Rholds it, so the two would keep agreeing about which eight colours while disagreeing about which object gets which.tests/testthat/test-instance-drawing.Rdraws the same pictures both ways and compares them pixel for pixel - five label maps including three objects each touching the other two, and five ways an instance result goes wrong - which is the only thing that can hold a rule rather than a constant.Six mutations, each caught: four-connected instead of eight, colour by object number instead of by adjacency, ascending instead of descending degree,
>=instead of>at the threshold, and two ways of getting the outline wrong. The outline pair needed a new picture: against the first one both mutations passed, because every object in it was three pixels across and had no interior to lose. §4’s “a check calibrated loosely enough admits the failure it was written to prevent”, found by mutating rather than by reading.flow_to_colours()is still deferred, and the stated reason has expired.[#154](https://github.com/maju116/rplatypus/issues/154)defers it because “R cannot configureflowsuntilpyplatypus[#160](https://github.com/maju116/rplatypus/issues/160)” - which shipped in0.8.0a20, andseparation_flows()has been exported here since. The reason that holds now is a different one:predict()refuses an instance fit, so there is no way to get a flow field into R to draw. It belongs with[#103](https://github.com/maju116/rplatypus/issues/103), where the prediction comes back.
2026-10-14 - regions from R: overlapping classes
Added
segmentation_data(regions = ), the third way of saying what a mask’s classes are and the only one that lets a voxel belong to more than one. A list oflist(name = , of = ), whereofholds the label-map values making up that region:segmentation_data( "train/", "valid/", regions = list(list(name = "whole tumour", of = c(1, 2, 4)), list(name = "enhancing", of = 4)) )That is the BraTS shape, and the second entry shows that a single value is ordinary rather than an edge case. The engine has had the field since
0.8.0a14; R forwarding it was a code change rather than a pin bump, which is worth saying because the first guess was the opposite:segmentation_data()enumerates its parameters, soregionswas anunused argumentwhile a baredatalist and a YAML file both reached the engine’s own validator and were accepted. The constructor refusing what YAML took is the asymmetry this closes.pyplatypus[#91](https://github.com/maju116/rplatypus/issues/91),[#152](https://github.com/maju116/rplatypus/issues/152).Two things
?segmentation_datanow says that the field name does not. There is no background entry, son_classis not the number it would be for that manylabels. And each channel is an independent binary question - a sigmoid per channel rather than one softmax across them - so a voxel may be in several regions at once, which is the point.The configuration vignette has a section on overlapping classes, Overlapping classes instead of one per voxel, between the augmentation section and the instance one - because
regionsis stillsemantic_segmentationand what changes is what a channel means rather than what the task is. The section list is pinned by a test here and by the same list in the engine’s suite, so it landed in both repositories together:pyplatypus[#229](https://github.com/maju116/rplatypus/issues/229)first, because this side compares itself against that list.Two things in it are R’s own: a region is a
list(name = , of = )and the last one shows thatof = 4is ordinary rather than an edge case, andinspect_masks()names the region that matched nothing instead of giving its position. Both the YAML block and the R chunk are validated - checked by breaking each, which fails the test that reads it rather than passing quietly.inspect_masks()names regions rather than numbering them. The engine reports classes by position, and withregionsa position is the one thing a reader cannot resolve:missing_classes: 1means the second region, and counting entries in a configuration file to find out which is the step nobody takes. Withcolormaporlabelsthe number is the class, so it stays a number - naming it would invent something the specification did not say, and a test holds that.It matters most for the one failure
regionsinvites:of = 3against a label map holding4is a channel of zeros, so the model trains to predict nothing and scores a Dice of 0 for that region while every other number in the run looks ordinary.The positions are still attached as the
presentandmissingattributes, so anything reading them keeps working. The mapping is derived in R from the specification R owns rather than asked of the engine - which would mean a release and a pin move for a label - and what holds the two together is a test comparing R’s name against the engine’s own refusal sentence. An off-by-one in that mapping fails it, which is the one mistake whose output otherwise looks entirely ordinary.The exclusivity check is three-way and names what was given. One of
colormap,labelsandregions, in one message, matching the engine’s own sentence - and the message now says which arguments it found rather than only that one was expected.save_class_masks()writes an overlapping prediction, one file per class. Needs pyplatypus 0.8.0a23, which is what the pin moves to here, and where the writer lives; this side forwards to it rather than keeping a second copy of the rules, because the filenames are part of what somebody’s next script reads.<name><suffix>_<class>.png, black and white, and a class name becomes a filename by replacing anything a path cannot hold with_- sowhole tumouriswhole_tumour. Two class names that become the same filename are refused rather than written: one file would hold the second class, the first would be silently gone, and the number of returned paths would still be right.The argument is
classesand notnames, which is §3A applied before the surface existed:namesin this family is the files -save_masks()andsave_volumes()both use it that way - and a sibling writer where the same word means the classes instead would accept a call copied from one to the other whenever the counts happened to match. The engine’s own function calls themclassestoo, for the same reason.
Fixed
save_masks()andsave_volumes()refuse an overlapping prediction instead of silently reducing it. Both tookwhich.maxover the channel axis, which for sigmoid channels writes one of the classes a voxel is in and leaves nothing in the file to say so. They now count how many channels are hot at the most ambitious voxel and refuse with that number, each pointing at its own per-class writer.[#152](https://github.com/maju116/rplatypus/issues/152)namedsave_masks()and there turned out to be three reduction sites, not one:save_volumes()has two, a 4-dimensional array inside a list and a 5-dimensional stack. Found by asking what a BraTS user would actually run, which is §4’s a feature is not shipped until something has used it the way a user reaches it applied to a refusal.Detected from the array rather than from the specification, which is the same test the engine applies in
onehot_to_colours- so both halves refuse the same arrays, and a caller holding only a prediction can answer the question. A softmax prediction never trips it, since its channels sum to one and at most one can reach half; that control is a test rather than a sentence, because without it every ordinary run would start refusing.save_class_volumes()is the same thing for volumes, and it is the caseregionswas built for. BraTS names three overlapping regions and stores them as volumes, so a writer that only handled pictures would have left the motivating dataset with a refusal and no route to disk. One NIfTI per class, each carrying the geometry of the scan it was predicted from - which is the whole reason this is notsave_class_masks()with another extension.It needs no engine release: it goes through the same volume writer
save_volumes()uses, so the affine, the integer labels and the grid check are those rather than a second copy. What is a second copy is the rule turning a class name into a filename, because the volume path never reaches the engine’sclass_filename- so a test holds R’s rule to the engine’s over the names that are actually awkward, rather than a comment claiming they agree.predict(type = "class")refused rather than computed when the classes overlap, and"class"is the default, so this is the first call an R user makes on such a fit. There is no one class per pixel to return: the channels are independent binary questions, they do not compete, and anargmaxacross them names one of the classes a pixel is in. The refusal says to ask fortype = "probability"and names what writes it.Read off the specification the fit carries, which is a lever this package owns - the shape cannot answer it, because sigmoid channels and a softmax’s channels are the same array.
A single
ofvalue would have crossed over as a number rather than a list. reticulate turns a length-1 integer vector into a Python scalar, and the schema wants an array, soof = 4had to be built withas.list(). Measured both ways before the code was written, and the engine refuses the scalar - so this would have been a refusal nobody could read rather than a silent wrong answer, on the most ordinary region there is.Still in
[#152](https://github.com/maju116/rplatypus/issues/152), deliberately not here, and written as a paragraph rather than a heading becauseR CMD checkreads every###in this file as a version and says so:save_masks()refusing an overlapping prediction and the per-class writer that replaces it,inspect_masks()naming regions rather than numbering them, and the guide section. Each is independently worth having, which is why none waits for the others.
[0.2.0] - submitted to CRAN on 2026-10-14
A different package from 0.1.1 under the same name, and the section below says how different.
Migrating from 0.1.1
This was in the README until 2026-10-07 - seventy lines between ## Installing and the first example, about a version the reader does not have, before the one they do. It is here because this file is where R keeps what changed between versions, and because the renames table below was a second copy of one the README already carried; the fuller of the two survived.
Code written against the 2020 release will not run on this one. That version built Keras models through the R keras package and handed them back to you; this one describes a whole run as a specification and computes it in PyTorch. Of the 37 functions and objects the old version exported, 30 are gone — including all of yolo3(), darknet53(), load_darknet_weights(), get_boxes(), non_max_suppression(), plot_boxes(), segmentation_generator() and the annotation readers.
Seven names survive, and three of them are the ones to watch, because they are still exported, still callable, and mean something different:
| name | 0.1.1 | now |
|---|---|---|
u_net() |
u_net(net_h, net_w, grayscale, ...) → a Keras model |
u_net(name, input_shape, ...) → one model in a specification |
plot_masks() |
plot_masks(images_paths, masks, labels, colormap) |
plot_masks(images, prediction, truth, ...) |
loss_dice() |
no arguments, returns a Keras loss | loss_dice(smooth = 1), returns a loss to name in a specification |
The other four — binary_colormap, voc_colormap, binary_labels, voc_labels — are the same data as before.
Object detection is back, and the names are different. The old package handed you a Keras YOLOv3; this one describes a detector in a specification like everything else. So those five names are gone rather than moved, and what replaces them is:
| 0.1.1 | now |
|---|---|
yolo3(), darknet53() |
yolo3() as one entry in platypus_spec() - same name, a specification rather than a model |
load_darknet_weights() |
weights = "bccd-yolo3", or a path, or hf://owner/repo/file@commit |
get_boxes(), non_max_suppression() |
done inside predict(), which returns boxes already suppressed and in each image’s own pixels |
plot_boxes() |
plot_boxes() - same name, takes what predict() returns |
The annotation readers are gone as functions and are now what detection_data() reads for you: Pascal VOC XML or LabelMe JSON, named rather than guessed.
The old release is still there and still installable, pinned:
remotes::install_github("maju116/rplatypus@0.1.1")Its source is on the master branch, and the issues filed against it stay open. What has aged is its dependencies rather than its code: the pinned TensorFlow no longer installs on a current Python, which is the reason for a rewrite rather than a consequence of one.
2026-10-14 - the package is rplatypus
Changed
library(platypus)islibrary(rplatypus). The name collided withPlatypus, archived from CRAN on 2026-02-02 - unrelated work in immunology - and the two differed only in case, which the policy treats as a conflict and which archiving does not release.cran-comments.mdhad promised to resubmit under another name rather than ask for an exception, so this is that name rather than a new idea: one name per language, beside thepyplatypusengine on PyPI, and a name that says what the package is - the R surface over a Python engine.rplatypusis in neither CRAN’s current index nor its archive, checked against both with a positive control so that the absence means something.The functions did not change.
platypus_spec(),platypus_fit(),platypus_status(),platypus_device()andplatypus_use_torch()keep their names, and so do the S3 classesplatypus_fit,platypus_spec,platypus_splitand the rest, theplatypus_*condition classes, thePLATYPUS_*environment variables and the headersprint()writes. The rule is that the package’s identity - whatlibrary()and CRAN see - isrplatypus, and the brand is still platypus. §3A already said the S3 class keeps the prefix even where its constructor loses it; this is the same line drawn one level up, and it means no released surface moved.The repository was renamed too, to
maju116/rplatypus. Stars, forks, watchers and every issue came with it - read back from the API rather than assumed - andgithub.com/maju116/platypusredirects, so links in other people’s issue comments keep working. The site moved with the repository and is now at maju116.github.io/rplatypus; the former address has no redirect, because a project page is served under the repository’s own name and the only way to hold the old one would be to occupy the name the redirect needs.The dated entries below name
maju116/platypuswhere they were written that way. They are the record of what it was called at the time and they still resolve, so they are left alone rather than rewritten.
2026-10-14 - engine 0.8.0a22
This file has a gap and it is named rather than backfilled. The entry below is dated 2026-10-08 and the pin moved twice between then and here - to 0.8.0a12 for fives-unet ([#151](https://github.com/maju116/rplatypus/issues/151)) and to 0.8.0a20 for the instance task ([#155](https://github.com/maju116/rplatypus/issues/155)) - so the dates above this heading are not a complete record of what the pin did. Writing those entries now would be writing them from the commit log rather than from the work, which is a different thing from having written them; the heading says what this change is and the omission stays visible.
Changed
Engine pinned to
0.8.0a22, for two things R forwards rather than implements.A regression loss is refused as any segmentation model’s own
loss, not only an instance model’s.?lossesclaimed the narrower rule and was measured when it was written, so it was true rather than wrong - the engine’s refusal lived on the instance class alone, and a semantic model acceptedloss_mse()and trained with it, reporting a validation loss of 0.0014 for a mask that was half missing whereloss_cce()reaches a Dice of 1.0000. The defaultwhere = "foreground"is what made that look like a success: it averages the objective only inside the labelled objects and leaves the background unconstrained.pyplatypus[#223](https://github.com/maju116/rplatypus/issues/223).So
available_losses()is ten rows now rather than thirteen, andavailable_losses("instance_segmentation")is thirteen - the three regression losses are reachable where they have a target, which isseparation_flows(loss = ). Nothing R sends changes: a configuration refused after this pin move was refused by the engine before it.evaluate()on an instance fit carriesforeground_dice. It is the mask column to read across models - the binary overlap of anything the model called an object against anything that is one, the same arithmetic whatever the separation is. Thedicebeside it is a mean over the class channels the head has, three forseparation_rim()against two forseparation_flows(), so it is read down one model’s rows and never across two: on the Data Science Bowl’s validation cases it showed two masks 0.09 apart that are 0.0062 apart, and in the other direction.pyplatypus[#222](https://github.com/maju116/rplatypus/issues/222).
Fixed
?losses’taskparameter listed two tasks and there have been three since the engine’s0.8.0a20.
2026-10-08 - engine 0.8.0a2
Added
metric_cldice(), for structures where Dice asks the wrong question. It scores each mask against the other’s skeleton, so it measures whether a vessel is connected rather than whether its pixels coincide. Vessels, airways, neurons, cracks, catheters.The two disagree in a way worth knowing, measured on a nine-pixel vessel: one drawn two pixels thin scores Dice 0.875, and the same vessel severed in the middle scores 0.947 - Dice prefers the severed one. clDice gives them 1.000 and 0.943. Its limit is measured too: at one pixel wide, a one-pixel displacement leaves no overlap at all and every overlap-based metric reads 0, this one included.
include_backgrounddefaults toFALSEhere andTRUEfor the other three. The background’s skeleton lies inside the background by construction, so that class scores 1 whatever the model did.
Changed
- Engine pinned to
0.8.0a2.
2026-10-08 - engine 0.8.0a1
Added
drawing_style(), the five decisions every figure in this package makes when you do not give it one: which colours mean found, missed and invented, how much of the image an overlay lets through, how a box is labelled, and which boxes are worth drawing. Reach for it when you want to match a figure rather than restate it -colours = drawing_style()$box_colourskeeps one source where typing the hex codes starts a second.
Changed
The drawing decisions now come from the engine. They were written out in ten places across
R/plot.RandR/masks.R, andpyplatypus.styleheld the same five values - which is two copies that happened to agree, not one source. They agreed because five differences between the two packages’ figures had been found by laying them side by side and fixing them by hand.Nothing changes in any picture: the values are identical, and a test compares every one of them against the engine’s, so a change on that side now fails this package’s CI instead of being noticed by eye.
Drawing still needs no Python. That is why the values are mirrored in
R/style.Rrather than fetched:plot_masks(),plot_boxes()andoverlay_agreement()return without touching the bridge, and a default colour that called the engine would mean a five-gigabyte download to draw a mask.
2026-10-07 - engine 0.6.0a2, then 0.6.0a4, then 0.7.0a1
Changed, and it breaks code
Eight functions were renamed. The engine and this package had grown two words for the same thing in eight places, and a reader moving between the halves paid for every one. One name per concept now, and where the two disagreed the engine’s name won, because it is the layer that cannot be renamed later without changing the configuration format and the schema. The exception is
available_transforms(), where this package already had a family ofavailable_*names and the engine followed instead.was is why platypus_split()split_dataset()the engine’s name. The S3 class is still platypus_split- that is the object’s identity, and every class here is prefixedsplit_files()split_path()it reads one split’s path; it does not split files. split_samples()in the engine is a different function, and the two names invited pairing themmask_report()inspect_masks()the engine’s name, and the method it calls mask_classes()colours_to_classes()the engine’s name, and it says which way the conversion goes mask_colours()classes_to_colours()likewise. Note the engine’s onehot_to_colours()is not this function - it takes probabilities and argmaxes them, this takes class indicessave_weights()export_weights()the engine’s name; “export” says a sidecar is written beside the file available_augmentations()available_transforms()the engine’s name, and albumentations’ own word for them augment()augmentation_step()augmentis a generic in broom and means something else there.augmentationalone would have shadowed theaugmentation =argument it builds a list forNo aliases: two names for one thing is what this removes. The S3 class returned by
split_dataset()is stillplatypus_split- that is the object’s identity, and every class in the package is prefixed.evaluate(),evaluate_cases(),evaluate_classes()andevaluate_images()keep their names and so doesplatypus_fit(). The prefix on that one is not an inconsistency:fitis a generic in the tidymodels packages, so a barefitwould have been theirs to own, whileevaluateis a generic here and dispatches on the fit’s class whichever package was attached last.PYPLATYPUS_VERSIONispyplatypus_version. It was the only ALL-CAPS name in the package, so keeping it would have meant a lint exception for a style used exactly once.
Added
callback_swa(): average the weights over the last part of the run. What it is worth is not in the score column - Dice and per-case error do not move - it is in the spread. Measured over three seeds, the volume bias went from+4.5% / +12.2% / -11.6%to-0.1% / +4.2% / +2.1%, 5.7 times tighter. The help page says so in those words, because somebody reading “weight averaging” will look at Dice, find nothing, and conclude wrongly. Refused besidecallback_cosine_annealing(): a cosine has decayed to its floor by the time averaging begins, so the average is the last epoch.segmentation_data(validation = FALSE): say a run has no validation set. The third answer to the question a path and a split already answered, and it had to be a stated value rather than an absence, because “I have no validation set” and “I forgot” look identical and only one of them is a decision. It went in the same argument rather than a new one, so nothing can contradict it.
Fixed
- The Python shim shipped inside this package is now formatted and linted by ruff in CI, beside lintr. It had never been.
2026-10-07 - the documentation site
- The site is Quarto, not pkgdown, generated from
tools/reference-groups.ymlinto a sidebar nested in eight groups, and themed from a_brand.ymlthat is byte-identical to pyplatypus’s - so the two sites look alike by construction rather than by anybody remembering to copy a colour. - Every old address still works. 105 redirect stubs cover all 99 addresses the pkgdown site published, the eight renames among them.
2026-10-06 - engine 0.5.0a1 through 0.6.0a1
Added
segmentation_data(split = )anddetection_data(split = ): divide one training folder into splits without writing any files. The argument is a list offractions(two numbers, or three to cut a test set too) andgroup_by, which is a required key that may beNULL. The mistake it prevents leaves no trace: slices of one patient on both sides make validation measure memory rather than generalisation, and nothing in the output says so. An omitted key would be that choice made silently;NULLis the same choice made on purpose. For detection this is the only way to divide one folder, becausesplit_dataset()writes amaskscolumn.detection_crops(): cut the detected objects out of the images they were found in, at a fixed size, for handing to a classifier. Crops come from the source image through the same reader the dataset uses, are rounded outward so the whole box survives, and letterbox rather than stretch.yolo3(box_loss = "giou"). Measured on BCCD over three seeds, 150 epochs, it is worse on the metric it targets - matched overlap 0.7929 against 0.8038, established at 2.7 standard errors - and its spread is sixty times wider. It ships because its coordinate term reads zero when the boxes are right, where the default term cannot reach zero however right they are.?yolo3leads with that table, because the name invites choosing it and the measured answer is the other way.loss_boundary(): a region loss plus the signed distance to the truth’s boundary.regionandalphadefault toNULLhere and are left out of the request, so the engine’s own defaults apply -alpha = 0.9, which it pins with a test, because at 0.5 the surface term owns half the objective while the prediction is still random and the volume bias spread goes to +/-28%. What the loss trades is accuracy per case for being unbiased over a series: measured over three seeds, the systematic volume bias went from -5.18% to -0.06% and the per-case absolute error from 16.90% to 22.17%.
Fixed
- Every help page has an
\examplessection but one - 47 of 48 at the time,encodersbeing a concept page with no\usage- and a test parses every example in every.Rd, including inside\dontrun{}, and fails if it names a function that does not exist. What was at risk was never the functionality; it is the example text drifting from the API. - lintr runs over the package in CI and gates on its findings. Of 906 findings at lintr’s defaults, 890 were the 80-character line limit this package does not use; at 100 characters and
lint_package(), 25 - two of which were defects rather than style.
2026-10-05 - engine 0.3.0a15, then 0.4.0a1
evaluate_images(): one row per image for a detector -n_truth,n_predicted,matched,missed,spurious,mean_matched_iou. Order bymissedfor the frames it cannot see, bymean_matched_ioufor the ones where it finds everything and places it badly. No average precision per image: AP is the area under a ranking’s curve and on one picture it moves on a single box.platypus_spec(task = )can be stated, and is checked rather than trusted - it can confirm or refuse, never override. Unset, it is still derived from the constructors. Required in YAML and optional here is not an inconsistency:taskis needed exactly where the type is not already stated, andsegmentation_data()withu_net()can only mean one thing.
2026-10-04 - engine 0.3.0a12, then 0.3.0a13
- Object detection, from R.
detection_data(),yolo3(),detection_anchors(),plot_boxes(), andpredict()returning a list of boxes in each image’s own pixels - not an array, because images differ in size, so does the number of boxes, and a box in a letterboxed frame cannot be drawn on the photograph it came from. plot_anchors(): the fitted anchors against the boxes they were fitted to. It is in the vignette twice, on the split they were fitted on and the one they were not.- A README figure for detection, drawn by the published
bccd-yolo3with no training, andtools/readme_figures.Rthat reproduces it. - Corrected: the claim that COCO’s anchors cover BCCD’s boxes at 0.67 where fitted ones reach 0.92. Re-measured on the 2,805 real training boxes, the honest pair is 0.65 against 0.88. The direction survives and is large; both numbers were wrong in opposite directions and the gap was right by coincidence.
2026-10-03 - engine 0.3.0a10, then 0.3.0a11
u_net(encoder = "resnet34", pretrained = TRUE), withfreeze_encoderandencoder_learning_rate.pretrainedis a separate explicit flag so that naming a backbone never downloads anything nobody asked for. Measured on three datasets, pretraining never clearly beats the built-in encoder; what it buys is epochs, and what a swappable encoder buys is capacity where the target is thin. The help page carries the table.inspect_masks()andplatypus_fit(check_masks = FALSE): a declared class appearing in no mask has a target channel that is zero everywhere, so the model is never shown what it is being asked to find, andplatypus_fit()now refuses on it. A threshold on the unmatched fraction cannot do this job - on a 0.6% lesion a wholly wrong colormap is quieter than JPEG artefacts in healthy data.- Every help page renders its markdown.
DESCRIPTIONhad noRoxygen: list(markdown = TRUE)although every page had been written in markdown since the rewrite began, so not one cross-reference was a link: 0 to 42 links, 0 to 266 inline code spans, 36 of 40 pages changed. Nothing in the sources was wrong; nothing was converting them.
2026-09-27 - engine 0.3.0a6, then 0.3.0a8
predict(space = "source"): map a prediction back out of the model’s grid onto the grid of the scan it came from, undoing the resampling and the crop. Returns a list, and the type change is the API being honest - scans differ in size, and quietly resizing them to match is how a mask ends up describing anatomy it was not computed from.- Named weights.
weights = "dsbowl-unet"from the registry,hf://owner/repo/file@commit, or a local path, plusexport_weights()for your own. A registry name carries a commit and thehf://form requires one, because weights that change under a stable name are the worst kind of irreproducibility. - The Data Science Bowl vignette now opens with a mask in four lines - published weights,
fit = FALSE,plot_masks()- before any mention of training. First contact should not need a GPU and an afternoon. - Fixed, and found by writing that vignette:
weights = "dsbowl-unet"could not work from R at all. The bridge asked forpyplatypuswith no extras, sohuggingface_hubwas never in the environment reticulate builds. Nothing in either test suite could have caught it - the engine’s tests install the extra deliberately and the R tests ran where it was already present. - The README says what changed since 0.1.1, written by diffing the two
NAMESPACEfiles rather than from memory: of 0.1.1’s 37 exports, 30 are gone, and three of the seven survivors are worse than gone because they are still callable and mean something else.
2026-09-26 - engine 0.2.0a2 through 0.3.0a5
The day the package stopped being a bridge and became a surface. Five engine releases, each followed here.
- A worked vignette on the Data Science Bowl, precomputed so that reading it needs no GPU, and a README.
- Continuous integration: five
R CMD checkjobs - Ubuntu on R devel, release and oldrel-1, plus macOS and Windows on release - and anenginejob that installs the built package and runs the suite against the engine resolved from PyPI. That last one is the job that matters:pkgload::load_all()exposes a package’s internals,library()does not, and local work had been using the most permissive of the three ways a test can reach them. - Plotting and masks:
plot_masks(),plot_boxes(),overlay_mask(),overlay_agreement(),read_masks(),unite_masks(),mask_coverage(). - DICOM, properly: modality LUT, a fixed window, MONOCHROME1 inverted. Plus
series_report()for a folder of slices, whose decision is that a problem is a column, not an exception - a hundred cases contain a few bad ones, and stopping at the first turns an afternoon into a week. - Splitting by patient (
split_dataset(),split_path()) andevaluate_cases(). - Volumes.
input_shape = c(64, 64, 32)had been building a 3D U-Net since the first release; what was missing was reading NIfTI, where orientation is the whole problem, andlabelsas a second way to describe a mask, because volumes label with integers.save_volumes()writes NIfTI carrying the affine of the scan the mask was predicted from - without it a mask cannot be laid over its scan by any viewer, and the file looks perfectly normal while being wrong.mask_volume()reports millilitres, because a voxel count is meaningless outside one scanner. target_spacing: resample to a common voxel size. What makes it necessary is not slice thickness but field of view - forty slices of 1 mm is 40 mm of patient and forty of 2.5 mm is 100 mm, which is the ordinary state of clinical data.channels_from: one channel per file, stating the order and never inferring it. A model trained with FLAIR in channel one and used where channel one is T1 answers plausibly, with the right shape and range, and nothing downstream can tell.- Augmentation in 3D, and
available_transforms(rank = 3)to say what is usable - 97 of albumentations’ transforms accept a volume and the rest raise from inside the library. - A second vignette, on volumes, which generates its own data. It ends on a model at Dice 0.88 whose volumes are 12-23% too large, which is the argument for reporting volume beside overlap: Dice is dominated by an object’s interior and forgiving about its boundary.
segmentation_data(window = )replaceddicom_window, since NIfTI needs the same thing. The old name is still accepted; giving both is an error.
2026-09-25 - engine 0.2.0a1
The package exists.
- The bridge.
py_require()plus uv, resolving an isolated ephemeral environment atlibrary(rplatypus)- deliberately not the shared-Python arrangement that broke this package’s Keras stack in 2020.platypus_status()says what it found rather than leaving a failed import to be guessed at, andPLATYPUS_ENGINE_PATHpoints the bridge at a source tree during development. platypus_spec(), built either from arguments or from a YAML file and producing the same thing. One pipeline, two constructors.platypus_fit(),evaluate(),predict(),training_history(), and the engine’s complaints translated into R conditions rather than passed through as Python tracebacks.
[0.1.1] - 2020-10-17
The last release of the TensorFlow package, kept here for the record rather than as an ancestor of the entries above. It built Keras models through the R keras package: yolo3() and darknet53() with load_darknet_weights() for the original Darknet weights, a plain u_net(), segmentation_generator(), loss_dice(), the COCO and VOC colormaps, and readers for LabelMe JSON and Pascal VOC XML.
It is still installable and still pinned:
remotes::install_github("maju116/rplatypus@0.1.1")Its source is on the master branch and the issues filed against it stay open. What has aged is its dependencies rather than its code - the pinned TensorFlow no longer installs on a current Python, which is the reason for a rewrite rather than a consequence of one.